feat: 新增相册自动修图与OTG状态预览

支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。
This commit is contained in:
2026-08-27 16:03:40 +08:00
parent 9fce6ef713
commit d04641b623
17 changed files with 2523 additions and 26 deletions
@@ -0,0 +1,92 @@
# AI 自动修图后端接口改造(精简版)
## 一、需要修改的接口
### 1. 创建相册
`POST /api/yf-handset-app/photog/travel-album/create`
请求新增:
```json
{
"auto_retouch_config": {
"enabled": true,
"refined_template_id": 12
}
}
```
规则:
- `enabled = true` 时,`refined_template_id` 必填且模板必须有效。
- `enabled = false` 时,服务端将 `refined_template_id` 规范化为 `null`。
- 响应返回完整 `auto_retouch_config`。
### 2. 相册详情和列表
- `GET /api/yf-handset-app/photog/travel-album/info`
- `GET /api/yf-handset-app/photog/travel-album/list`
每个相册新增响应字段:
```json
{
"auto_retouch_config": {
"enabled": true,
"refined_template_id": 12
}
}
```
规则:
- 历史相册没有配置时按关闭状态返回。
## 二、需要新增的接口
### 更新相册自动修图配置
`POST /api/yf-handset-app/photog/travel-album/auto-retouch-config`
请求:
```json
{
"user_equity_travel_id": 88,
"enabled": true,
"refined_template_id": 12
}
```
成功响应 `data`:
```json
{
"enabled": true,
"refined_template_id": 12
}
```
规则:
- 返回服务端规范化后的完整配置。
- 多设备同时修改时采用最后一次写入生效。
## 三、无需新增但需要确保可用的接口
- `POST .../upload-material`:成功后必须返回稳定的服务端素材 `id`。
- `GET .../ai-retouch-templates`:直接使用现有 `refined_templates`,无需新增模板描述字段。
- `POST .../ai-retouch`:请求和响应保持现状,不增加 `client_request_id`。
- `GET .../ai-retouch-job-info`:支持按任务批次查询 `queued / processing / succeeded / partially_succeeded / failed / canceled` 状态。
客户端会在 `upload-material` 成功后提交 AI 任务,并在前台每 8 秒查询任务状态。AI 额度不足、模板失效或任务失败都不能回滚原图上传结果。
由于 `ai-retouch` 不提供幂等能力,客户端在请求超时、断网等结果不确定的情况下不得自动重复提交;只能先同步素材或任务状态。若无法确认是否已创建任务,需提示用户“提交状态未知”,避免直接重试导致重复任务或重复扣额。
## 四、后端验收重点
1. 创建、详情和列表中的配置字段保持一致。
2. 配置关闭时服务端将 `refined_template_id` 规范化为 `null`。
3. 原图登记成功后可以使用现有 `ai-retouch` 接口创建单素材精修任务。
4. AI 任务失败或额度不足时,原图上传结果保持成功状态。
@@ -0,0 +1,52 @@
# AI 自动修图接口补充
本文仅描述相册级自动修图新增契约;现有手动批量 AI 修图接口保持不变。
## 相册配置
`create` 请求新增:
```json
{
"auto_retouch_config": {
"enabled": true,
"refined_template_id": 12
}
}
```
`create`、`info`、`list` 响应返回服务端规范化后的完整配置:
```json
{
"auto_retouch_config": {
"enabled": true,
"refined_template_id": 12
}
}
```
旧相册缺少 `auto_retouch_config` 时,客户端按关闭处理。
## 更新配置
```http
POST /api/yf-handset-app/photog/travel-album/auto-retouch-config
Content-Type: application/json
```
```json
{
"user_equity_travel_id": 88,
"enabled": true,
"refined_template_id": 12
}
```
响应 `data` 为服务端规范化后的完整配置。多设备同时修改时采用最后一次写入生效。
## AI 任务提交
`POST ai-retouch` 请求和响应保持现状,不增加 `client_request_id`。请求结果不确定时客户端不得自动重复提交,应先同步素材或任务状态;无法确认时提示用户“提交状态未知”。
自动修图只使用现有模板接口的 `refined_templates`,不展示模板描述,也不自动生成氛围感或封面。
@@ -50,6 +50,11 @@ protocol TravelAlbumServing {
/// 拉取当前景区可用的 AI 修图模板。 /// 拉取当前景区可用的 AI 修图模板。
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
/// 更新相册级自动 AI 修图配置。
func updateAutoRetouchConfiguration(
_ request: TravelAlbumAutoRetouchConfigurationRequest
) async throws -> TravelAlbumAutoRetouchConfiguration
/// 提交相册素材 AI 修图任务。 /// 提交相册素材 AI 修图任务。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission
@@ -207,6 +212,15 @@ final class TravelAlbumAPI: TravelAlbumServing {
) )
} }
/// 更新自动修图配置并返回服务端最新版本。
func updateAutoRetouchConfiguration(
_ request: TravelAlbumAutoRetouchConfigurationRequest
) async throws -> TravelAlbumAutoRetouchConfiguration {
try await client.send(
APIRequest(method: .post, path: "\(basePath)/auto-retouch-config", body: request)
)
}
/// 提交相册素材 AI 修图任务并返回任务摘要。 /// 提交相册素材 AI 修图任务并返回任务摘要。
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission { func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request)) try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request))
@@ -5,6 +5,36 @@
import Foundation import Foundation
/// 相册级自动 AI 修图配置,由服务端同步并在每次上传开始时固定快照。
struct TravelAlbumAutoRetouchConfiguration: Codable, Sendable, Equatable, Hashable {
let enabled: Bool
let refinedTemplateId: Int?
enum CodingKeys: String, CodingKey {
case enabled
case refinedTemplateId = "refined_template_id"
}
/// 默认关闭自动修图,用于兼容尚未返回配置字段的旧接口。
static let disabled = TravelAlbumAutoRetouchConfiguration(
enabled: false,
refinedTemplateId: nil
)
/// 创建经过规范化的自动修图配置。
init(enabled: Bool, refinedTemplateId: Int?) {
let validTemplateId = refinedTemplateId.flatMap { $0 > 0 ? $0 : nil }
self.enabled = enabled
self.refinedTemplateId = enabled ? validTemplateId : nil
}
/// 上传页紧凑设置项的展示文案。
var displayTitle: String { enabled ? "AI修图" : "不修图" }
/// 当前配置是否可以用于提交自动修图任务。
var isValid: Bool { !enabled || refinedTemplateId != nil }
}
/// 旅拍相册用户信息,对齐 Android `TravelAlbumUserEntity`。 /// 旅拍相册用户信息,对齐 Android `TravelAlbumUserEntity`。
struct TravelAlbumUser: Decodable, Sendable, Equatable, Hashable { struct TravelAlbumUser: Decodable, Sendable, Equatable, Hashable {
let id: Int let id: Int
@@ -33,6 +63,7 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
let createdAt: String let createdAt: String
let updatedAt: String let updatedAt: String
let user: TravelAlbumUser? let user: TravelAlbumUser?
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id case id
@@ -50,6 +81,31 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
case createdAt = "created_at" case createdAt = "created_at"
case updatedAt = "updated_at" case updatedAt = "updated_at"
case user case user
case autoRetouchConfiguration = "auto_retouch_config"
}
/// 解码相册详情;旧响应缺少自动修图配置时按关闭状态兼容。
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
storeUserId = try container.decode(Int.self, forKey: .storeUserId)
name = try container.decode(String.self, forKey: .name)
type = try container.decode(Int.self, forKey: .type)
orderNumber = try container.decode(String.self, forKey: .orderNumber)
materialNum = try container.decode(Int.self, forKey: .materialNum)
materialPrice = try container.decode(Int.self, forKey: .materialPrice)
materialPackagePrice = try container.decode(Int.self, forKey: .materialPackagePrice)
photoPrice = try container.decode(Int.self, forKey: .photoPrice)
coverUrl = try container.decode(String.self, forKey: .coverUrl)
userId = try container.decode(Int.self, forKey: .userId)
status = try container.decode(Int.self, forKey: .status)
createdAt = try container.decode(String.self, forKey: .createdAt)
updatedAt = try container.decode(String.self, forKey: .updatedAt)
user = try container.decodeIfPresent(TravelAlbumUser.self, forKey: .user)
autoRetouchConfiguration = try container.decodeIfPresent(
TravelAlbumAutoRetouchConfiguration.self,
forKey: .autoRetouchConfiguration
) ?? .disabled
} }
init( init(
@@ -67,7 +123,8 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
status: Int = 0, status: Int = 0,
createdAt: String = "", createdAt: String = "",
updatedAt: String = "", updatedAt: String = "",
user: TravelAlbumUser? = nil user: TravelAlbumUser? = nil,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
) { ) {
self.id = id self.id = id
self.storeUserId = storeUserId self.storeUserId = storeUserId
@@ -84,6 +141,7 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = updatedAt self.updatedAt = updatedAt
self.user = user self.user = user
self.autoRetouchConfiguration = autoRetouchConfiguration
} }
/// 展示用手机号。 /// 展示用手机号。
@@ -287,6 +345,7 @@ struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
let materialPrice: Double? let materialPrice: Double?
let materialPackagePrice: Double? let materialPackagePrice: Double?
let photoPrice: Double? let photoPrice: Double?
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case name case name
@@ -296,6 +355,67 @@ struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
case materialPrice = "material_price" case materialPrice = "material_price"
case materialPackagePrice = "material_package_price" case materialPackagePrice = "material_package_price"
case photoPrice = "photo_price" case photoPrice = "photo_price"
case autoRetouchConfiguration = "auto_retouch_config"
}
private enum AutoRetouchConfigurationCodingKeys: String, CodingKey {
case enabled
case refinedTemplateId = "refined_template_id"
}
/// 创建相册请求;自动修图配置默认关闭以兼容现有调用方。
init(
name: String,
type: Int,
orderNumber: String?,
materialNum: Int?,
materialPrice: Double?,
materialPackagePrice: Double?,
photoPrice: Double?,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
) {
self.name = name
self.type = type
self.orderNumber = orderNumber
self.materialNum = materialNum
self.materialPrice = materialPrice
self.materialPackagePrice = materialPackagePrice
self.photoPrice = photoPrice
self.autoRetouchConfiguration = autoRetouchConfiguration
}
/// 编码创建参数;相册配置版本由服务端生成,不随创建请求上送。
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(type, forKey: .type)
try container.encodeIfPresent(orderNumber, forKey: .orderNumber)
try container.encodeIfPresent(materialNum, forKey: .materialNum)
try container.encodeIfPresent(materialPrice, forKey: .materialPrice)
try container.encodeIfPresent(materialPackagePrice, forKey: .materialPackagePrice)
try container.encodeIfPresent(photoPrice, forKey: .photoPrice)
var configuration = container.nestedContainer(
keyedBy: AutoRetouchConfigurationCodingKeys.self,
forKey: .autoRetouchConfiguration
)
try configuration.encode(autoRetouchConfiguration.enabled, forKey: .enabled)
try configuration.encodeIfPresent(
autoRetouchConfiguration.refinedTemplateId,
forKey: .refinedTemplateId
)
}
}
/// 更新相册自动修图配置的请求参数。
struct TravelAlbumAutoRetouchConfigurationRequest: Encodable, Sendable, Equatable {
let userEquityTravelId: Int
let enabled: Bool
let refinedTemplateId: Int?
enum CodingKeys: String, CodingKey {
case userEquityTravelId = "user_equity_travel_id"
case enabled
case refinedTemplateId = "refined_template_id"
} }
} }
@@ -337,6 +457,28 @@ struct TravelAlbumListResponse<Item: Decodable & Sendable & Equatable>: Decodabl
/// 旅拍相册创建响应。 /// 旅拍相册创建响应。
struct TravelAlbumCreateResponse: Decodable, Sendable, Equatable { struct TravelAlbumCreateResponse: Decodable, Sendable, Equatable {
let id: Int let id: Int
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
enum CodingKeys: String, CodingKey {
case id
case autoRetouchConfiguration = "auto_retouch_config"
}
/// 创建相册响应;后端暂未回传配置时按关闭状态兼容。
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
autoRetouchConfiguration = try container.decodeIfPresent(
TravelAlbumAutoRetouchConfiguration.self,
forKey: .autoRetouchConfiguration
) ?? .disabled
}
/// 创建相册响应测试数据。
init(id: Int, autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled) {
self.id = id
self.autoRetouchConfiguration = autoRetouchConfiguration
}
} }
/// 旅拍相册小程序码响应。 /// 旅拍相册小程序码响应。
@@ -496,6 +638,7 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
let refinedTemplateId: Int let refinedTemplateId: Int
let atmosphereTemplateId: Int? let atmosphereTemplateId: Int?
let coverTemplateId: Int? let coverTemplateId: Int?
let clientRequestId: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case userEquityTravelId = "user_equity_travel_id" case userEquityTravelId = "user_equity_travel_id"
@@ -503,6 +646,24 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
case refinedTemplateId = "refined_template_id" case refinedTemplateId = "refined_template_id"
case atmosphereTemplateId = "atmosphere_template_id" case atmosphereTemplateId = "atmosphere_template_id"
case coverTemplateId = "cover_template_id" case coverTemplateId = "cover_template_id"
case clientRequestId = "client_request_id"
}
/// 创建首次 AI 修图请求;手动修图可不传幂等标识,自动修图必须传稳定标识。
init(
userEquityTravelId: Int,
materialIds: [Int],
refinedTemplateId: Int,
atmosphereTemplateId: Int?,
coverTemplateId: Int?,
clientRequestId: String? = nil
) {
self.userEquityTravelId = userEquityTravelId
self.materialIds = materialIds
self.refinedTemplateId = refinedTemplateId
self.atmosphereTemplateId = atmosphereTemplateId
self.coverTemplateId = coverTemplateId
self.clientRequestId = clientRequestId
} }
} }
@@ -19,6 +19,13 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
var errorMessage: String? var errorMessage: String?
let localPath: String let localPath: String
var remoteUrl: String var remoteUrl: String
var serverMaterialId: Int = 0
var autoRetouchState: TravelAlbumAutoRetouchState = .none
var autoRetouchTemplateId: Int? = nil
var autoRetouchClientRequestId: String = ""
var autoRetouchBatchId: Int = 0
var autoRetouchAttempt: Int = 0
var autoRetouchErrorMessage: String? = nil
/// 是否未上传完成。 /// 是否未上传完成。
var isNotUploaded: Bool { var isNotUploaded: Bool {
@@ -31,6 +38,16 @@ struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
} }
} }
/// OTG 照片自动修图阶段,与原图上传状态独立保存。
enum TravelAlbumAutoRetouchState: String, Codable, Sendable, Equatable, Hashable {
case none = "NONE"
case pendingSubmission = "PENDING_SUBMISSION"
case submitting = "SUBMITTING"
case processing = "PROCESSING"
case completed = "COMPLETED"
case failed = "FAILED"
}
/// OTG 传输页半小时维度时间槽。 /// OTG 传输页半小时维度时间槽。
struct TravelAlbumOTGTimeSlot: Hashable, Sendable { struct TravelAlbumOTGTimeSlot: Hashable, Sendable {
let id: String let id: String
@@ -85,6 +102,14 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
self == .liveUpload self == .liveUpload
} }
/// 模式选择 Sheet 的辅助说明。
var detailText: String {
switch self {
case .liveUpload: return "相机拍摄后,照片自动传输并上传到当前相册"
case .postTransfer: return "拍摄完成后,再选择照片批量传输"
}
}
/// 根据展示标题解析传输模式。 /// 根据展示标题解析传输模式。
static func option(title: String) -> TravelAlbumOTGTransferMode? { static func option(title: String) -> TravelAlbumOTGTransferMode? {
allCases.first { $0.title == title } allCases.first { $0.title == title }
@@ -343,7 +368,14 @@ extension TravelAlbumOTGPhotoRecord {
progress: progress, progress: progress,
errorMessage: errorMessage, errorMessage: errorMessage,
localPath: localPath, localPath: localPath,
remoteUrl: remoteUrl remoteUrl: remoteUrl,
serverMaterialId: serverMaterialId,
autoRetouchState: autoRetouchState,
autoRetouchTemplateId: autoRetouchTemplateId,
autoRetouchClientRequestId: autoRetouchClientRequestId,
autoRetouchBatchId: autoRetouchBatchId,
autoRetouchAttempt: autoRetouchAttempt,
autoRetouchErrorMessage: autoRetouchErrorMessage
) )
} }
} }
@@ -56,6 +56,13 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
let albumId: Int let albumId: Int
let userId: String let userId: String
var remoteUrl: String var remoteUrl: String
var serverMaterialId: Int
var autoRetouchState: TravelAlbumAutoRetouchState
var autoRetouchTemplateId: Int?
var autoRetouchClientRequestId: String
var autoRetouchBatchId: Int
var autoRetouchAttempt: Int
var autoRetouchErrorMessage: String?
var updatedAt: Int64 var updatedAt: Int64
/// 创建 OTG 本地照片记录。 /// 创建 OTG 本地照片记录。
@@ -74,6 +81,13 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId: Int, albumId: Int,
userId: String, userId: String,
remoteUrl: String = "", remoteUrl: String = "",
serverMaterialId: Int = 0,
autoRetouchState: TravelAlbumAutoRetouchState = .none,
autoRetouchTemplateId: Int? = nil,
autoRetouchClientRequestId: String = "",
autoRetouchBatchId: Int = 0,
autoRetouchAttempt: Int = 0,
autoRetouchErrorMessage: String? = nil,
updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000) updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000)
) { ) {
self.id = id self.id = id
@@ -90,12 +104,22 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
self.albumId = albumId self.albumId = albumId
self.userId = userId self.userId = userId
self.remoteUrl = remoteUrl self.remoteUrl = remoteUrl
self.serverMaterialId = max(0, serverMaterialId)
self.autoRetouchState = autoRetouchState
self.autoRetouchTemplateId = autoRetouchTemplateId
self.autoRetouchClientRequestId = autoRetouchClientRequestId
self.autoRetouchBatchId = max(0, autoRetouchBatchId)
self.autoRetouchAttempt = max(0, autoRetouchAttempt)
self.autoRetouchErrorMessage = autoRetouchErrorMessage
self.updatedAt = updatedAt self.updatedAt = updatedAt
} }
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt
case serverMaterialId, autoRetouchState, autoRetouchTemplateId
case autoRetouchClientRequestId, autoRetouchBatchId, autoRetouchAttempt
case autoRetouchErrorMessage
} }
/// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。 /// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
@@ -115,16 +139,34 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
albumId = try container.decode(Int.self, forKey: .albumId) albumId = try container.decode(Int.self, forKey: .albumId)
userId = try container.decode(String.self, forKey: .userId) userId = try container.decode(String.self, forKey: .userId)
remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? "" remoteUrl = try container.decodeIfPresent(String.self, forKey: .remoteUrl) ?? ""
serverMaterialId = try container.decodeIfPresent(Int.self, forKey: .serverMaterialId) ?? 0
autoRetouchState = try container.decodeIfPresent(
TravelAlbumAutoRetouchState.self,
forKey: .autoRetouchState
) ?? .none
autoRetouchTemplateId = try container.decodeIfPresent(Int.self, forKey: .autoRetouchTemplateId)
autoRetouchClientRequestId = try container.decodeIfPresent(
String.self,
forKey: .autoRetouchClientRequestId
) ?? ""
autoRetouchBatchId = try container.decodeIfPresent(Int.self, forKey: .autoRetouchBatchId) ?? 0
autoRetouchAttempt = try container.decodeIfPresent(Int.self, forKey: .autoRetouchAttempt) ?? 0
autoRetouchErrorMessage = try container.decodeIfPresent(String.self, forKey: .autoRetouchErrorMessage)
updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0 updatedAt = try container.decodeIfPresent(Int64.self, forKey: .updatedAt) ?? 0
} }
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。 /// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord { func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
guard status == .transferring || status == .uploading else { return self }
var copy = self var copy = self
copy.status = .pending if status == .transferring || status == .uploading {
copy.progress = 0 copy.status = .pending
copy.errorMessage = nil copy.progress = 0
copy.errorMessage = nil
}
if autoRetouchState == .submitting {
copy.autoRetouchState = .pendingSubmission
}
guard copy != self else { return self }
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
return copy return copy
} }
@@ -0,0 +1,105 @@
import Foundation
/// 自动修图设置状态,负责加载真实精修模板、单选和配置校验,不直接创建修图任务。
final class TravelAlbumAutoRetouchSettingViewModel {
/// 设置页当前层级。
enum Stage: Sendable, Equatable {
case mode
case templates
}
let scenicId: Int
let startsWithModeSelection: Bool
private(set) var stage: Stage
private(set) var templates: [TravelAlbumAIRetouchTemplate] = []
private(set) var selectedTemplateId: Int?
private(set) var isEnabled: Bool
private(set) var isLoading = false
private(set) var errorMessage: String?
var onStateChange: (() -> Void)?
/// 创建自动修图配置状态。
init(
scenicId: Int,
configuration: TravelAlbumAutoRetouchConfiguration,
startsWithModeSelection: Bool
) {
self.scenicId = scenicId
self.startsWithModeSelection = startsWithModeSelection
self.stage = startsWithModeSelection ? .mode : .templates
self.isEnabled = configuration.enabled
self.selectedTemplateId = configuration.refinedTemplateId
}
/// 当前可提交配置;启用状态必须已经选择服务端模板。
var pendingConfiguration: TravelAlbumAutoRetouchConfiguration? {
if !isEnabled { return .disabled }
guard let selectedTemplateId,
templates.contains(where: { $0.id == selectedTemplateId }) else {
return nil
}
return TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: selectedTemplateId
)
}
/// 拉取当前景区的真实精修模板。
func loadTemplates(api: any TravelAlbumServing) async {
guard scenicId > 0 else {
errorMessage = "请先选择景区"
notify()
return
}
guard !isLoading else { return }
isLoading = true
errorMessage = nil
notify()
defer {
isLoading = false
notify()
}
do {
let response = try await api.aiRetouchTemplates(scenicId: scenicId)
templates = response.refinedTemplates
if !templates.contains(where: { $0.id == selectedTemplateId }) {
selectedTemplateId = nil
}
if templates.isEmpty { errorMessage = "暂无可用的原图精修模板" }
} catch is CancellationError {
return
} catch {
templates = []
errorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription
}
}
/// 选择一级修图方式。
func selectMode(enabled: Bool) {
isEnabled = enabled
if enabled {
stage = .templates
} else {
selectedTemplateId = nil
}
notify()
}
/// 选择一个真实精修模板。
func selectTemplate(id: Int) {
guard templates.contains(where: { $0.id == id }) else { return }
isEnabled = true
selectedTemplateId = id
notify()
}
/// 从模板列表返回修图方式层级。
func returnToModeSelection() {
guard startsWithModeSelection else { return }
stage = .mode
notify()
}
private func notify() { onStateChange?() }
}
@@ -121,6 +121,7 @@ final class TravelAlbumEntryViewModel {
freeCount: String, freeCount: String,
singlePrice: String, singlePrice: String,
packagePrice: String, packagePrice: String,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
order: TravelAlbumAvailableOrder?, order: TravelAlbumAvailableOrder?,
api: any TravelAlbumServing api: any TravelAlbumServing
) async { ) async {
@@ -143,6 +144,10 @@ final class TravelAlbumEntryViewModel {
onShowMessage?("请输入有效的单张照片价格") onShowMessage?("请输入有效的单张照片价格")
return return
} }
if autoRetouchConfiguration.enabled && !autoRetouchConfiguration.isValid {
onShowMessage?("请选择有效的 AI 修图模板")
return
}
let albumName: String let albumName: String
switch mode { switch mode {
@@ -162,7 +167,8 @@ final class TravelAlbumEntryViewModel {
materialNum: Int(freeCount) ?? 0, materialNum: Int(freeCount) ?? 0,
materialPrice: materialPrice, materialPrice: materialPrice,
materialPackagePrice: Double(packagePrice) ?? 0, materialPackagePrice: Double(packagePrice) ?? 0,
photoPrice: 0 photoPrice: 0,
autoRetouchConfiguration: autoRetouchConfiguration
) )
case .preOrder: case .preOrder:
request = TravelAlbumCreateRequest( request = TravelAlbumCreateRequest(
@@ -172,7 +178,8 @@ final class TravelAlbumEntryViewModel {
materialNum: nil, materialNum: nil,
materialPrice: nil, materialPrice: nil,
materialPackagePrice: nil, materialPackagePrice: nil,
photoPrice: nil photoPrice: nil,
autoRetouchConfiguration: autoRetouchConfiguration
) )
} }
@@ -20,6 +20,22 @@ struct TravelAlbumPhoneAlbumImportItem: Sendable, Equatable {
} }
} }
/// OTG 自动修图预览的数据校验错误,避免进入缺少素材或结果的空白预览页。
enum TravelAlbumOTGPreviewError: LocalizedError, Equatable {
case materialUnavailable
case resultNotReady
/// 预览入口可直接展示给用户的错误说明。
var errorDescription: String? {
switch self {
case .materialUnavailable:
return "素材信息暂不可用,请返回相册刷新后重试"
case .resultNotReady:
return "修图结果暂未同步,请稍后重试"
}
}
}
/// 有线相机传输页 ViewModel,编排 ImageCaptureCore 连接、照片导入、本地缓存与上传登记。 /// 有线相机传输页 ViewModel,编排 ImageCaptureCore 连接、照片导入、本地缓存与上传登记。
@MainActor @MainActor
final class WiredCameraTransferViewModel { final class WiredCameraTransferViewModel {
@@ -63,7 +79,12 @@ final class WiredCameraTransferViewModel {
private(set) var sonyMTPHint: String? private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false private(set) var isContentCatalogReady = false
let retouchOption = "不修图" private(set) var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration {
didSet { notifyStateChanged() }
}
private(set) var isUpdatingAutoRetouchConfiguration = false {
didSet { notifyStateChanged() }
}
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload { private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload {
didSet { notifyStateChanged() } didSet { notifyStateChanged() }
@@ -87,6 +108,8 @@ final class WiredCameraTransferViewModel {
private var queuedAutoUploadPhotoIds: Set<String> = [] private var queuedAutoUploadPhotoIds: Set<String> = []
private var suppressSelectedTimeSlotNotification = false private var suppressSelectedTimeSlotNotification = false
private var serverStatusSyncTask: Task<Void, Never>? private var serverStatusSyncTask: Task<Void, Never>?
private var configurationSyncTask: Task<Void, Never>?
private var autoRetouchPollingTask: Task<Void, Never>?
@MainActor @MainActor
init( init(
@@ -94,6 +117,8 @@ final class WiredCameraTransferViewModel {
albumTitle: String, albumTitle: String,
headerPhone: String, headerPhone: String,
scenicSpotLabel: String? = nil, scenicSpotLabel: String? = nil,
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
connectionManager: (any WiredCameraConnectionManaging)? = nil, connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil, uploader: (any TravelAlbumOTGUploading)? = nil,
@@ -112,7 +137,11 @@ final class WiredCameraTransferViewModel {
self.api = api ?? NetworkServices.shared.travelAlbumAPI self.api = api ?? NetworkServices.shared.travelAlbumAPI
self.appStore = appStore self.appStore = appStore
self.userDefaults = userDefaults self.userDefaults = userDefaults
self.transferMode = Self.persistedTransferMode(in: userDefaults) self.autoRetouchConfiguration = initialAutoRetouchConfiguration
self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults)
if let initialTransferMode {
userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey)
}
} }
/// 相机状态文案。 /// 相机状态文案。
@@ -213,6 +242,40 @@ final class WiredCameraTransferViewModel {
transferMode.title transferMode.title
} }
/// 自动修图设置 Chip 的展示文案。
var retouchOption: String { autoRetouchConfiguration.displayTitle }
/// 自动修图设置页复用当前注入的相册服务。
var autoRetouchAPI: any TravelAlbumServing { api }
/// 获取已完成自动修图照片的最新原图与精修图,仅供只读预览,不改变上传或修图状态。
func loadAutoRetouchPreviewProject(photoId: String) async throws -> TravelAlbumPreviewProject {
try Task.checkCancellation()
guard let record = persistedRecordsById[photoId],
record.autoRetouchState == .completed,
record.serverMaterialId > 0 else {
throw TravelAlbumOTGPreviewError.materialUnavailable
}
let material = try await api.materialInfo(
userEquityTravelId: albumId,
materialId: record.serverMaterialId
)
try Task.checkCancellation()
let project = TravelAlbumPreviewProject(material: material)
guard material.id == record.serverMaterialId,
let original = project.asset(for: .original), !original.displayURL.isEmpty else {
throw TravelAlbumOTGPreviewError.materialUnavailable
}
guard let retouched = project.asset(for: .retouched), !retouched.displayURL.isEmpty else {
throw TravelAlbumOTGPreviewError.resultNotReady
}
return TravelAlbumPreviewProject(
originalMaterialId: project.originalMaterialId,
aiRetouchBatchId: project.aiRetouchBatchId,
assets: [original, retouched]
)
}
/// 指定上传弹窗选项。 /// 指定上传弹窗选项。
var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] { var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] {
TravelAlbumOTGSpecifyUploadOption.allCases TravelAlbumOTGSpecifyUploadOption.allCases
@@ -230,6 +293,9 @@ final class WiredCameraTransferViewModel {
syncFromConnectionManager() syncFromConnectionManager()
loadPersistedPhotos() loadPersistedPhotos()
syncServerUploadStatuses() syncServerUploadStatuses()
syncAutoRetouchConfiguration()
resumePendingAutoRetouches()
updateAutoRetouchPolling()
connectionManager.start() connectionManager.start()
} }
@@ -237,10 +303,29 @@ final class WiredCameraTransferViewModel {
func stop() { func stop() {
serverStatusSyncTask?.cancel() serverStatusSyncTask?.cancel()
serverStatusSyncTask = nil serverStatusSyncTask = nil
configurationSyncTask?.cancel()
configurationSyncTask = nil
autoRetouchPollingTask?.cancel()
autoRetouchPollingTask = nil
connectionManager.unbindDelegate() connectionManager.unbindDelegate()
connectionManager.suspendLiveTransfer() connectionManager.suspendLiveTransfer()
} }
/// App 进入后台时停止配置请求和任务轮询,保留待恢复状态。
func applicationDidEnterBackground() {
configurationSyncTask?.cancel()
configurationSyncTask = nil
autoRetouchPollingTask?.cancel()
autoRetouchPollingTask = nil
}
/// App 回到前台时同步跨设备配置并恢复自动修图任务。
func applicationDidBecomeActive() {
syncAutoRetouchConfiguration()
resumePendingAutoRetouches()
updateAutoRetouchPolling()
}
/// 主动断开相机连接。 /// 主动断开相机连接。
func disconnect() { func disconnect() {
connectionManager.disconnect() connectionManager.disconnect()
@@ -301,6 +386,50 @@ final class WiredCameraTransferViewModel {
userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey) userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey)
} }
/// 保存相册级自动修图配置;服务端成功后才更新页面状态。
func updateAutoRetouchConfiguration(_ configuration: TravelAlbumAutoRetouchConfiguration) async {
guard !isUpdatingAutoRetouchConfiguration else { return }
guard configuration.isValid else {
showMessage("请选择有效的 AI 修图模板")
return
}
isUpdatingAutoRetouchConfiguration = true
defer { isUpdatingAutoRetouchConfiguration = false }
do {
autoRetouchConfiguration = try await api.updateAutoRetouchConfiguration(
TravelAlbumAutoRetouchConfigurationRequest(
userEquityTravelId: albumId,
enabled: configuration.enabled,
refinedTemplateId: configuration.refinedTemplateId
)
)
showMessage(autoRetouchConfiguration.enabled ? "已开启 AI 自动修图" : "已关闭 AI 自动修图")
} catch {
showMessage(error.localizedDescription.isEmpty ? "自动修图设置保存失败" : error.localizedDescription)
}
}
/// 重试单张已上传照片的自动修图,不重复上传原图。
func retryAutoRetouch(photoId: String) {
guard var record = persistedRecordsById[photoId],
record.status == .uploaded,
record.serverMaterialId > 0,
record.autoRetouchState == .failed,
record.autoRetouchTemplateId != nil else {
showMessage("当前照片无法重新修图")
return
}
if record.autoRetouchBatchId > 0 {
record.autoRetouchAttempt += 1
record.autoRetouchBatchId = 0
record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record)
}
record.autoRetouchState = .pendingSubmission
record.autoRetouchErrorMessage = nil
persist(record)
Task { await submitAutoRetouch(photoId: photoId) }
}
/// 切换上传格式。 /// 切换上传格式。
func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) { func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
guard photoFormatOption != option else { return } guard photoFormatOption != option else { return }
@@ -523,6 +652,166 @@ final class WiredCameraTransferViewModel {
} }
} }
private func syncAutoRetouchConfiguration() {
configurationSyncTask?.cancel()
configurationSyncTask = Task { [weak self] in
await self?.refreshAutoRetouchConfiguration()
}
}
private func refreshAutoRetouchConfiguration() async {
do {
let album = try await api.info(id: albumId)
try Task.checkCancellation()
autoRetouchConfiguration = album.autoRetouchConfiguration
} catch is CancellationError {
return
} catch {
OTGLog.error(.connection, "sync auto retouch configuration failed: \(error.localizedDescription)")
}
}
private func resumePendingAutoRetouches() {
let photoIds = persistedRecordsById.values.compactMap { record in
record.status == .uploaded && record.autoRetouchState == .pendingSubmission
? record.id
: nil
}
guard !photoIds.isEmpty else { return }
Task { [weak self] in
guard let self else { return }
for photoId in photoIds {
await submitAutoRetouch(photoId: photoId)
}
}
}
private func submitAutoRetouch(photoId: String) async {
guard var record = persistedRecordsById[photoId],
record.status == .uploaded,
record.serverMaterialId > 0,
let templateId = record.autoRetouchTemplateId,
record.autoRetouchState == .pendingSubmission || record.autoRetouchState == .failed else {
return
}
if record.autoRetouchClientRequestId.isEmpty {
record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record)
}
record.autoRetouchState = .submitting
record.autoRetouchErrorMessage = nil
persist(record)
do {
let submission = try await api.submitAIRetouch(
TravelAlbumAIRetouchRequest(
userEquityTravelId: albumId,
materialIds: [record.serverMaterialId],
refinedTemplateId: templateId,
atmosphereTemplateId: nil,
coverTemplateId: nil,
clientRequestId: record.autoRetouchClientRequestId
)
)
guard var latest = persistedRecordsById[photoId] else { return }
latest.autoRetouchBatchId = submission.aiRetouchBatchId
latest.autoRetouchState = autoRetouchState(for: submission.status)
latest.autoRetouchErrorMessage = latest.autoRetouchState == .failed ? submission.status.title : nil
persist(latest)
updateAutoRetouchPolling()
} catch is CancellationError {
guard var latest = persistedRecordsById[photoId], latest.autoRetouchState == .submitting else { return }
latest.autoRetouchState = .pendingSubmission
persist(latest)
} catch {
guard var latest = persistedRecordsById[photoId] else { return }
if isAmbiguousAutoRetouchSubmissionFailure(error) {
latest.autoRetouchState = .pendingSubmission
latest.autoRetouchErrorMessage = "网络中断,恢复后将自动继续修图"
persist(latest)
showMessage(latest.autoRetouchErrorMessage ?? "网络恢复后将自动继续修图")
return
}
latest.autoRetouchState = .failed
latest.autoRetouchErrorMessage = error.localizedDescription.isEmpty
? "AI 修图任务提交失败"
: error.localizedDescription
persist(latest)
showMessage(latest.autoRetouchErrorMessage ?? "AI 修图任务提交失败")
}
}
private func updateAutoRetouchPolling() {
guard autoRetouchPollingTask == nil,
persistedRecordsById.values.contains(where: {
$0.autoRetouchState == .processing && $0.autoRetouchBatchId > 0
}) else { return }
autoRetouchPollingTask = Task { [weak self] in
guard let self else { return }
while !Task.isCancelled {
await refreshAutoRetouchJobs()
guard persistedRecordsById.values.contains(where: {
$0.autoRetouchState == .processing && $0.autoRetouchBatchId > 0
}) else { break }
try? await Task.sleep(for: .seconds(8))
}
autoRetouchPollingTask = nil
}
}
private func refreshAutoRetouchJobs() async {
let batchIds = Set(persistedRecordsById.values.compactMap { record in
record.autoRetouchState == .processing && record.autoRetouchBatchId > 0
? record.autoRetouchBatchId
: nil
})
for batchId in batchIds {
do {
let detail = try await api.aiRetouchJobInfo(batchId: batchId)
try Task.checkCancellation()
let state = autoRetouchState(for: detail.status)
guard state != .processing else { continue }
let matchingRecords = persistedRecordsById.values.filter { $0.autoRetouchBatchId == batchId }
for var record in matchingRecords {
record.autoRetouchState = state
record.autoRetouchErrorMessage = state == .failed ? detail.status.title : nil
persist(record)
}
} catch is CancellationError {
return
} catch {
OTGLog.error(.connection, "poll auto retouch job failed: \(batchId) \(error.localizedDescription)")
}
}
}
private func autoRetouchState(for status: TravelAlbumAIJobStatus) -> TravelAlbumAutoRetouchState {
switch status {
case .queued, .processing, .unknown:
return .processing
case .succeeded, .partiallySucceeded:
return .completed
case .failed, .canceled:
return .failed
}
}
private func makeAutoRetouchClientRequestId(record: TravelAlbumOTGPhotoRecord) -> String {
let templateId = record.autoRetouchTemplateId ?? 0
return "auto-\(albumId)-\(record.serverMaterialId)-tpl\(templateId)-a\(record.autoRetouchAttempt)"
}
private func isAmbiguousAutoRetouchSubmissionFailure(_ error: Error) -> Bool {
guard let apiError = error as? APIError else { return false }
switch apiError {
case .networkFailed, .invalidResponse, .emptyData, .decodeFailed:
return true
case .httpStatus(let status, _):
return status == 408 || status >= 500
case .invalidURL, .serverCode:
return false
}
}
private func applyMergedPhotos() { private func applyMergedPhotos() {
let persistedItems = persistedRecordsById.values let persistedItems = persistedRecordsById.values
.map { $0.toPhotoItem(storage: storage, albumId: albumId) } .map { $0.toPhotoItem(storage: storage, albumId: albumId) }
@@ -588,6 +877,7 @@ final class WiredCameraTransferViewModel {
} }
private func uploadPhoto(id: String) async { private func uploadPhoto(id: String) async {
let retouchSnapshot = autoRetouchConfiguration
do { do {
let record = try await localRecordForUpload(id: id) let record = try await localRecordForUpload(id: id)
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil) updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
@@ -597,13 +887,44 @@ final class WiredCameraTransferViewModel {
) { [weak self] progress in ) { [weak self] progress in
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil) self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil)
} }
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl) completeOriginalUpload(id: id, material: material, retouchSnapshot: retouchSnapshot)
if retouchSnapshot.enabled {
await submitAutoRetouch(photoId: id)
}
} catch { } catch {
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription) updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
showMessage(error.localizedDescription) showMessage(error.localizedDescription)
} }
} }
private func completeOriginalUpload(
id: String,
material: TravelAlbumMaterial,
retouchSnapshot: TravelAlbumAutoRetouchConfiguration
) {
guard var record = persistedRecordsById[id] else {
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl)
return
}
record.status = .uploaded
record.progress = 100
record.errorMessage = nil
record.remoteUrl = material.fileUrl
record.serverMaterialId = material.id
record.autoRetouchTemplateId = retouchSnapshot.enabled ? retouchSnapshot.refinedTemplateId : nil
record.autoRetouchBatchId = 0
record.autoRetouchAttempt = 0
record.autoRetouchErrorMessage = nil
if retouchSnapshot.enabled, retouchSnapshot.refinedTemplateId != nil {
record.autoRetouchState = .pendingSubmission
record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record)
} else {
record.autoRetouchState = .none
record.autoRetouchClientRequestId = ""
}
persist(record)
}
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord { private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
if let record = persistedRecordsById[id], if let record = persistedRecordsById[id],
!record.localPath.isEmpty, !record.localPath.isEmpty,
@@ -653,6 +974,14 @@ final class WiredCameraTransferViewModel {
applyMergedPhotos() applyMergedPhotos()
} }
private func persist(_ record: TravelAlbumOTGPhotoRecord) {
var updated = record
updated.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
persistedRecordsById[updated.id] = updated
storage.upsert(updated, albumId: albumId)
applyMergedPhotos()
}
private func notifyStateChanged() { private func notifyStateChanged() {
if Thread.isMainThread { if Thread.isMainThread {
onStateChange?() onStateChange?()
@@ -22,8 +22,14 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
private let freeCountField = UITextField() private let freeCountField = UITextField()
private let singlePriceField = UITextField() private let singlePriceField = UITextField()
private let packagePriceField = UITextField() private let packagePriceField = UITextField()
private let autoRetouchSectionView = UIView()
private let autoRetouchTitleLabel = UILabel()
private let autoRetouchDetailLabel = UILabel()
private let noRetouchOption = TravelAlbumModeOptionView()
private let aiRetouchOption = TravelAlbumModeOptionView()
private let cancelButton = UIButton(type: .system) private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system)
private var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) { init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) {
self.viewModel = viewModel self.viewModel = viewModel
@@ -31,8 +37,14 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet modalPresentationStyle = .pageSheet
if let sheetPresentationController { if let sheetPresentationController {
sheetPresentationController.detents = [.medium(), .large()] let formDetent = UISheetPresentationController.Detent.Identifier("createTravelAlbumForm")
sheetPresentationController.detents = [
.custom(identifier: formDetent) { min(650, $0.maximumDetentValue) },
.large(),
]
sheetPresentationController.selectedDetentIdentifier = formDetent
sheetPresentationController.prefersGrabberVisible = false sheetPresentationController.prefersGrabberVisible = false
sheetPresentationController.preferredCornerRadius = 22
} }
} }
@@ -62,6 +74,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad) configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad)
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad) configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad) configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad)
configureAutoRetouchSection()
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary) configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white) configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
@@ -112,6 +125,8 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
[freeCountField, singlePriceField, packagePriceField].forEach { [freeCountField, singlePriceField, packagePriceField].forEach {
$0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged) $0.addTarget(self, action: #selector(textFieldEditingChanged(_:)), for: .editingChanged)
} }
noRetouchOption.addTarget(self, action: #selector(noRetouchTapped), for: .touchUpInside)
aiRetouchOption.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside)
} }
override func viewDidDisappear(_ animated: Bool) { override func viewDidDisappear(_ animated: Bool) {
@@ -148,6 +163,42 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField)) fieldsStack.addArrangedSubview(makeFieldGroup(title: "打包价格(元)", required: false, field: packagePriceField))
fieldsStack.addArrangedSubview(autoRetouchSectionView)
}
private func configureAutoRetouchSection() {
autoRetouchSectionView.backgroundColor = .white
autoRetouchSectionView.layer.cornerRadius = 10
autoRetouchSectionView.layer.borderWidth = 1
autoRetouchSectionView.layer.borderColor = AppColor.border.cgColor
autoRetouchTitleLabel.text = "修图方式"
autoRetouchTitleLabel.font = .systemFont(ofSize: 14, weight: .medium)
autoRetouchTitleLabel.textColor = AppColor.textPrimary
autoRetouchDetailLabel.text = "选择 AI 修图后,需要再选一个效果模板"
autoRetouchDetailLabel.font = .systemFont(ofSize: 12)
autoRetouchDetailLabel.textColor = AppColor.textSecondary
let optionsStack = UIStackView(arrangedSubviews: [noRetouchOption, aiRetouchOption])
optionsStack.axis = .horizontal
optionsStack.spacing = 10
optionsStack.distribution = .fillEqually
autoRetouchSectionView.addSubview(autoRetouchTitleLabel)
autoRetouchSectionView.addSubview(autoRetouchDetailLabel)
autoRetouchSectionView.addSubview(optionsStack)
autoRetouchTitleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(12)
make.leading.trailing.equalToSuperview().inset(14)
}
autoRetouchDetailLabel.snp.makeConstraints { make in
make.top.equalTo(autoRetouchTitleLabel.snp.bottom).offset(4)
make.leading.trailing.equalTo(autoRetouchTitleLabel)
}
optionsStack.snp.makeConstraints { make in
make.top.equalTo(autoRetouchDetailLabel.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(12)
make.height.equalTo(76)
}
updateAutoRetouchSection()
} }
private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView { private func makeFieldGroup(title: String, required: Bool, field: UITextField) -> UIView {
@@ -188,6 +239,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
freeCount: freeCountField.text ?? "", freeCount: freeCountField.text ?? "",
singlePrice: singlePriceField.text ?? "", singlePrice: singlePriceField.text ?? "",
packagePrice: packagePriceField.text ?? "", packagePrice: packagePriceField.text ?? "",
autoRetouchConfiguration: autoRetouchConfiguration,
order: nil, order: nil,
api: api api: api
) )
@@ -199,6 +251,42 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
} }
} }
@objc private func noRetouchTapped() {
autoRetouchConfiguration = .disabled
updateAutoRetouchSection()
}
@objc private func aiRetouchTapped() {
guard presentedViewController == nil else { return }
let settingViewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: AppStore.shared.session.currentScenicId,
configuration: autoRetouchConfiguration,
startsWithModeSelection: false
)
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
viewModel: settingViewModel,
api: api
)
controller.onConfirm = { [weak self] configuration in
self?.autoRetouchConfiguration = configuration
self?.updateAutoRetouchSection()
}
present(controller, animated: true)
}
private func updateAutoRetouchSection() {
noRetouchOption.apply(title: "不修图", desc: "保留原图", selected: !autoRetouchConfiguration.enabled)
aiRetouchOption.apply(
title: "AI 修图",
desc: autoRetouchConfiguration.enabled ? "已选择真实 AI 模板" : "点击选模板",
selected: autoRetouchConfiguration.enabled
)
noRetouchOption.accessibilityLabel = "不修图,保留原图"
aiRetouchOption.accessibilityLabel = "AI 修图,点击选择模板"
noRetouchOption.accessibilityValue = autoRetouchConfiguration.enabled ? "未选择" : "已选择"
aiRetouchOption.accessibilityValue = autoRetouchConfiguration.enabled ? "已选择" : "未选择"
}
@objc private func textFieldEditingChanged(_ field: UITextField) { @objc private func textFieldEditingChanged(_ field: UITextField) {
let text = field.text ?? "" let text = field.text ?? ""
if field === freeCountField { if field === freeCountField {
@@ -0,0 +1,357 @@
import Kingfisher
import SnapKit
import UIKit
/// 相册自动 AI 修图设置 Sheet,使用服务端精修模板并保持单选。
final class TravelAlbumAutoRetouchSettingSheetViewController: BaseViewController {
private enum Item: Hashable {
case mode(Bool)
case template(TravelAlbumAIRetouchTemplate)
}
var onConfirm: ((TravelAlbumAutoRetouchConfiguration) -> Void)?
private let viewModel: TravelAlbumAutoRetouchSettingViewModel
private let api: any TravelAlbumServing
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let backButton = UIButton(type: .system)
private let tableView = UITableView(frame: .zero, style: .plain)
private var dataSource: UITableViewDiffableDataSource<Int, Item>!
private let statusContainer = UIView()
private let activityIndicator = UIActivityIndicatorView(style: .medium)
private let statusLabel = UILabel()
private let retryButton = UIButton(type: .system)
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
/// 创建设置 Sheet。
init(
viewModel: TravelAlbumAutoRetouchSettingViewModel,
api: any TravelAlbumServing
) {
self.viewModel = viewModel
self.api = api
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .pageSheet
if let sheetPresentationController {
sheetPresentationController.detents = [.large()]
sheetPresentationController.selectedDetentIdentifier = .large
sheetPresentationController.prefersGrabberVisible = true
sheetPresentationController.preferredCornerRadius = 22
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func setupUI() {
view.backgroundColor = .white
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
subtitleLabel.font = .systemFont(ofSize: 13)
subtitleLabel.textColor = AppColor.textSecondary
subtitleLabel.textAlignment = .center
subtitleLabel.numberOfLines = 0
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.tintColor = AppColor.textPrimary
backButton.accessibilityLabel = "返回修图方式"
tableView.backgroundColor = .white
tableView.separatorStyle = .none
tableView.rowHeight = 116
tableView.delegate = self
tableView.register(AutoRetouchOptionCell.self, forCellReuseIdentifier: AutoRetouchOptionCell.reuseIdentifier)
configureDataSource()
statusLabel.font = .systemFont(ofSize: 14)
statusLabel.textColor = AppColor.textSecondary
statusLabel.textAlignment = .center
statusLabel.numberOfLines = 0
retryButton.setTitle("重试", for: .normal)
retryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
activityIndicator.color = AppColor.primary
configureAction(cancelButton, title: "取消", filled: false)
configureAction(confirmButton, title: "确定", filled: true)
confirmButton.accessibilityIdentifier = "travelAlbum.autoRetouchConfirmButton"
view.addSubview(titleLabel)
view.addSubview(subtitleLabel)
view.addSubview(backButton)
view.addSubview(tableView)
view.addSubview(statusContainer)
statusContainer.addSubview(activityIndicator)
statusContainer.addSubview(statusLabel)
statusContainer.addSubview(retryButton)
view.addSubview(cancelButton)
view.addSubview(confirmButton)
}
override func setupConstraints() {
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(56)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(28)
}
backButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(18)
make.centerY.equalTo(titleLabel)
make.size.equalTo(36)
}
cancelButton.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
make.height.equalTo(52)
make.width.equalTo(confirmButton)
}
confirmButton.snp.makeConstraints { make in
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().offset(-16)
make.top.bottom.width.equalTo(cancelButton)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(14)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(cancelButton.snp.top).offset(-12)
}
statusContainer.snp.makeConstraints { $0.edges.equalTo(tableView) }
activityIndicator.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-30)
}
statusLabel.snp.makeConstraints { make in
make.top.equalTo(activityIndicator.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(36)
}
retryButton.snp.makeConstraints { make in
make.top.equalTo(statusLabel.snp.bottom).offset(10)
make.centerX.equalToSuperview()
make.height.equalTo(36)
}
}
override func bindActions() {
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
Task { await viewModel.loadTemplates(api: api) }
}
private func configureDataSource() {
dataSource = UITableViewDiffableDataSource<Int, Item>(tableView: tableView) { [weak self] tableView, indexPath, item in
guard let self else { return nil }
let cell = tableView.dequeueReusableCell(
withIdentifier: AutoRetouchOptionCell.reuseIdentifier,
for: indexPath
) as! AutoRetouchOptionCell
switch item {
case .mode(let enabled):
cell.apply(
title: enabled ? "AI 修图" : "不修图",
detail: enabled ? "选择后需再选一个修图模板" : "保留原图",
imageURL: nil,
systemImage: enabled ? "wand.and.stars" : "photo",
selected: self.viewModel.isEnabled == enabled
)
case .template(let template):
cell.apply(
title: template.name,
detail: "",
imageURL: template.previewURL,
systemImage: nil,
selected: self.viewModel.selectedTemplateId == template.id
)
}
return cell
}
}
@MainActor
private func applyViewModel() {
let isMode = viewModel.stage == .mode
titleLabel.text = isMode ? "选择修图方式" : "选择修图模板"
subtitleLabel.text = isMode
? "照片上传前可选择保留原图,或使用 AI 自动修图"
: "缩略图为模板实际效果,后续上传的照片将自动套用"
backButton.isHidden = isMode || !viewModel.startsWithModeSelection
confirmButton.isEnabled = viewModel.pendingConfiguration != nil && !viewModel.isLoading
confirmButton.alpha = confirmButton.isEnabled ? 1 : 0.45
statusContainer.isHidden = !viewModel.isLoading && viewModel.errorMessage == nil
if viewModel.isLoading {
activityIndicator.startAnimating()
statusLabel.text = "正在加载修图模板…"
retryButton.isHidden = true
} else {
activityIndicator.stopAnimating()
statusLabel.text = viewModel.errorMessage
retryButton.isHidden = viewModel.errorMessage == nil
}
let previousItems = Set(dataSource.snapshot().itemIdentifiers)
var snapshot = NSDiffableDataSourceSnapshot<Int, Item>()
snapshot.appendSections([0])
let items: [Item]
if isMode {
items = [.mode(false), .mode(true)]
} else {
items = viewModel.templates.map(Item.template)
}
snapshot.appendItems(items)
snapshot.reconfigureItems(items.filter(previousItems.contains))
dataSource.apply(snapshot, animatingDifferences: true)
}
private func configureAction(_ button: UIButton, title: String, filled: Bool) {
button.setTitle(title, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
button.layer.cornerRadius = 12
if filled {
button.backgroundColor = AppColor.primary
button.setTitleColor(.white, for: .normal)
} else {
button.backgroundColor = UIColor(hex: 0xF4F5F7)
button.setTitleColor(AppColor.textSecondary, for: .normal)
}
}
@objc private func cancelTapped() { dismiss(animated: true) }
@objc private func backTapped() { viewModel.returnToModeSelection() }
@objc private func retryTapped() { Task { await viewModel.loadTemplates(api: api) } }
@objc private func confirmTapped() {
guard let configuration = viewModel.pendingConfiguration else { return }
let completion = onConfirm
dismiss(animated: true) { completion?(configuration) }
}
}
extension TravelAlbumAutoRetouchSettingSheetViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
switch item {
case .mode(let enabled): viewModel.selectMode(enabled: enabled)
case .template(let template): viewModel.selectTemplate(id: template.id)
}
}
}
/// 自动修图方式或模板列表单元,提供效果图、说明和明确单选状态。
private final class AutoRetouchOptionCell: UITableViewCell {
static let reuseIdentifier = "AutoRetouchOptionCell"
private let card = UIView()
private let previewImageView = UIImageView()
private let titleLabel = UILabel()
private let detailLabel = UILabel()
private let selectionImageView = UIImageView()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
card.layer.cornerRadius = 12
card.layer.borderWidth = 1
previewImageView.contentMode = .scaleAspectFill
previewImageView.clipsToBounds = true
previewImageView.layer.cornerRadius = 8
titleLabel.font = .systemFont(ofSize: 16, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
detailLabel.font = .systemFont(ofSize: 13)
detailLabel.textColor = AppColor.textSecondary
detailLabel.numberOfLines = 2
selectionImageView.contentMode = .scaleAspectFit
contentView.addSubview(card)
card.addSubview(previewImageView)
card.addSubview(titleLabel)
card.addSubview(detailLabel)
card.addSubview(selectionImageView)
card.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(6)
make.leading.trailing.equalToSuperview().inset(16)
}
previewImageView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.top.bottom.equalToSuperview().inset(10)
make.width.equalTo(previewImageView.snp.height)
}
selectionImageView.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
make.size.equalTo(24)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(previewImageView.snp.trailing).offset(14)
make.trailing.lessThanOrEqualTo(selectionImageView.snp.leading).offset(-12)
make.bottom.equalTo(card.snp.centerY).offset(-2)
}
detailLabel.snp.makeConstraints { make in
make.leading.trailing.equalTo(titleLabel)
make.top.equalTo(card.snp.centerY).offset(4)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func prepareForReuse() {
super.prepareForReuse()
previewImageView.kf.cancelDownloadTask()
previewImageView.image = nil
}
func apply(
title: String,
detail: String,
imageURL: String?,
systemImage: String?,
selected: Bool
) {
titleLabel.text = title
let normalizedDetail = detail.trimmingCharacters(in: .whitespacesAndNewlines)
detailLabel.text = normalizedDetail
detailLabel.isHidden = normalizedDetail.isEmpty
titleLabel.snp.remakeConstraints { make in
make.leading.equalTo(previewImageView.snp.trailing).offset(14)
make.trailing.lessThanOrEqualTo(selectionImageView.snp.leading).offset(-12)
if normalizedDetail.isEmpty {
make.centerY.equalToSuperview()
} else {
make.bottom.equalTo(card.snp.centerY).offset(-2)
}
}
if let imageURL, let url = URL(string: imageURL), !imageURL.isEmpty {
previewImageView.contentMode = .scaleAspectFill
previewImageView.backgroundColor = .clear
previewImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
} else {
previewImageView.image = systemImage.flatMap(UIImage.init(systemName:))
previewImageView.contentMode = .center
previewImageView.tintColor = AppColor.primary
previewImageView.backgroundColor = AppColor.primary.withAlphaComponent(0.08)
}
card.backgroundColor = selected ? AppColor.primary.withAlphaComponent(0.07) : .white
card.layer.borderColor = (selected ? AppColor.primary : AppColor.border).cgColor
selectionImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
selectionImageView.tintColor = selected ? AppColor.primary : AppColor.textTertiary
accessibilityLabel = normalizedDetail.isEmpty ? title : "\(title),\(normalizedDetail)"
accessibilityValue = selected ? "已选择" : "未选择"
isAccessibilityElement = true
}
}
@@ -498,13 +498,25 @@ final class TravelAlbumDetailViewController: BaseViewController {
} }
@objc private func uploadTapped() { @objc private func uploadTapped() {
guard presentedViewController == nil else { return }
let selector = TravelAlbumTransferModeSheetViewController()
selector.onModeSelected = { [weak self] mode in
self?.openWiredTransfer(mode: mode)
}
present(selector, animated: true)
}
private func openWiredTransfer(mode: TravelAlbumOTGTransferMode) {
let album = viewModel.album let album = viewModel.album
refreshState.markRefreshNeeded() refreshState.markRefreshNeeded()
let controller = WiredCameraTransferViewController( let controller = WiredCameraTransferViewController(
viewModel: WiredCameraTransferViewModel( viewModel: WiredCameraTransferViewModel(
albumId: album?.id ?? viewModel.albumId, albumId: album?.id ?? viewModel.albumId,
albumTitle: album?.name ?? "", albumTitle: album?.name ?? "",
headerPhone: album?.displayPhone ?? "" headerPhone: album?.displayPhone ?? "",
initialTransferMode: mode,
initialAutoRetouchConfiguration: album?.autoRetouchConfiguration ?? .disabled,
api: api
) )
) )
navigationController?.pushViewController(controller, animated: true) navigationController?.pushViewController(controller, animated: true)
@@ -0,0 +1,138 @@
import SnapKit
import UIKit
/// 相册管理上传入口的传输模式选择 Sheet。
final class TravelAlbumTransferModeSheetViewController: UIViewController {
var onModeSelected: ((TravelAlbumOTGTransferMode) -> Void)?
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let optionsStack = UIStackView()
private let cancelButton = UIButton(type: .system)
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
modalPresentationStyle = .pageSheet
if let sheetPresentationController {
let identifier = UISheetPresentationController.Detent.Identifier("travelAlbumTransferMode")
sheetPresentationController.detents = [
.custom(identifier: identifier) { min(356, $0.maximumDetentValue) },
]
sheetPresentationController.selectedDetentIdentifier = identifier
sheetPresentationController.prefersGrabberVisible = true
sheetPresentationController.preferredCornerRadius = 22
}
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupConstraints()
}
private func setupUI() {
view.backgroundColor = .white
titleLabel.text = "选择传输模式"
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
subtitleLabel.text = "选择后进入对应的照片传输页面"
subtitleLabel.font = .systemFont(ofSize: 13)
subtitleLabel.textColor = AppColor.textSecondary
subtitleLabel.textAlignment = .center
optionsStack.axis = .vertical
optionsStack.spacing = 12
TravelAlbumOTGTransferMode.allCases.enumerated().forEach { index, mode in
optionsStack.addArrangedSubview(makeOption(mode: mode, index: index))
}
cancelButton.setTitle("取消", for: .normal)
cancelButton.setTitleColor(AppColor.textSecondary, for: .normal)
cancelButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .semibold)
cancelButton.backgroundColor = UIColor(hex: 0xF4F5F7)
cancelButton.layer.cornerRadius = 12
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
view.addSubview(titleLabel)
view.addSubview(subtitleLabel)
view.addSubview(optionsStack)
view.addSubview(cancelButton)
}
private func setupConstraints() {
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.leading.trailing.equalToSuperview().inset(20)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(7)
make.leading.trailing.equalToSuperview().inset(20)
}
optionsStack.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(18)
make.leading.trailing.equalToSuperview().inset(16)
}
cancelButton.snp.makeConstraints { make in
make.top.equalTo(optionsStack.snp.bottom).offset(14)
make.leading.trailing.equalTo(optionsStack)
make.height.equalTo(48)
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-10)
}
}
private func makeOption(mode: TravelAlbumOTGTransferMode, index: Int) -> UIView {
let container = UIView()
container.backgroundColor = UIColor(hex: 0xF7F9FC)
container.layer.cornerRadius = 12
container.layer.borderWidth = 1
container.layer.borderColor = UIColor(hex: 0xE6ECF5).cgColor
let title = UILabel()
title.text = mode.title
title.font = .systemFont(ofSize: 16, weight: .semibold)
title.textColor = AppColor.textPrimary
let detail = UILabel()
detail.text = mode.detailText
detail.font = .systemFont(ofSize: 12)
detail.textColor = AppColor.textSecondary
detail.numberOfLines = 2
let enter = UILabel()
enter.text = "进入"
enter.font = .systemFont(ofSize: 13, weight: .semibold)
enter.textColor = AppColor.primary
let button = UIButton(type: .custom)
button.tag = index
button.accessibilityLabel = "\(mode.title),\(mode.detailText)"
button.addTarget(self, action: #selector(modeTapped(_:)), for: .touchUpInside)
container.addSubview(title)
container.addSubview(detail)
container.addSubview(enter)
container.addSubview(button)
title.snp.makeConstraints { make in
make.top.equalToSuperview().offset(13)
make.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualTo(enter.snp.leading).offset(-12)
}
detail.snp.makeConstraints { make in
make.top.equalTo(title.snp.bottom).offset(5)
make.leading.equalTo(title)
make.trailing.lessThanOrEqualTo(enter.snp.leading).offset(-12)
}
enter.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
}
button.snp.makeConstraints { $0.edges.equalToSuperview() }
container.snp.makeConstraints { $0.height.equalTo(78) }
return container
}
@objc private func modeTapped(_ sender: UIButton) {
guard TravelAlbumOTGTransferMode.allCases.indices.contains(sender.tag) else { return }
let mode = TravelAlbumOTGTransferMode.allCases[sender.tag]
let completion = onModeSelected
dismiss(animated: true) { completion?(mode) }
}
@objc private func cancelTapped() { dismiss(animated: true) }
}
@@ -40,7 +40,7 @@ final class WiredCameraTransferViewController: BaseViewController {
private let refreshButton = UIButton(type: .system) private let refreshButton = UIButton(type: .system)
private let helpLabel = UILabel() private let helpLabel = UILabel()
private let chipsStack = UIStackView() private let chipsStack = UIStackView()
private let retouchButton = WiredTransferSettingChipButton() private let retouchButton = WiredTransferSettingChipButton(showsChevron: true)
private let formatButton = WiredTransferSettingChipButton() private let formatButton = WiredTransferSettingChipButton()
private let modeButton = WiredTransferSettingChipButton(showsChevron: true) private let modeButton = WiredTransferSettingChipButton(showsChevron: true)
private let settingsStatsDivider = UIView() private let settingsStatsDivider = UIView()
@@ -71,6 +71,7 @@ final class WiredCameraTransferViewController: BaseViewController {
private let specifyButton = UIButton(type: .system) private let specifyButton = UIButton(type: .system)
private var isHistoryImportButtonVisible = false private var isHistoryImportButtonVisible = false
private var previousNavigationBarStyle: (tintColor: UIColor?, barStyle: UIBarStyle, isTranslucent: Bool)? private var previousNavigationBarStyle: (tintColor: UIColor?, barStyle: UIBarStyle, isTranslucent: Bool)?
private var previewLoadingTask: Task<Void, Never>?
init(viewModel: WiredCameraTransferViewModel) { init(viewModel: WiredCameraTransferViewModel) {
self.viewModel = viewModel self.viewModel = viewModel
@@ -91,13 +92,64 @@ final class WiredCameraTransferViewController: BaseViewController {
titleStack.alignment = .center titleStack.alignment = .center
titleStack.spacing = 1 titleStack.spacing = 1
navigationItem.titleView = titleStack navigationItem.titleView = titleStack
var taskConfiguration = UIButton.Configuration.plain()
taskConfiguration.title = "修图任务"
taskConfiguration.image = UIImage(systemName: "list.bullet")?.withTintColor(.white, renderingMode: .alwaysOriginal)
taskConfiguration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)
taskConfiguration.imagePadding = 4
taskConfiguration.baseForegroundColor = .white
taskConfiguration.imageColorTransformer = UIConfigurationColorTransformer { _ in .white }
taskConfiguration.background.backgroundColor = .clear
taskConfiguration.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 4, bottom: 0, trailing: 4)
taskConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var outgoing = incoming
outgoing.font = .systemFont(ofSize: 13, weight: .medium)
outgoing.foregroundColor = .white
return outgoing
}
let taskButton = UIButton(type: .custom)
taskButton.configuration = taskConfiguration
taskButton.tintColor = .white
taskButton.configurationUpdateHandler = { button in
button.alpha = button.isHighlighted ? 0.6 : 1
}
taskButton.accessibilityLabel = "查看AI修图任务"
taskButton.accessibilityIdentifier = "wiredTransfer.aiRetouchTasksButton"
taskButton.addTarget(self, action: #selector(openAIJobList), for: .touchUpInside)
taskButton.snp.makeConstraints { make in
make.height.equalTo(44)
make.width.greaterThanOrEqualTo(44)
}
let taskItem = UIBarButtonItem(customView: taskButton)
taskItem.tintColor = .white
taskItem.accessibilityLabel = "查看AI修图任务"
if #available(iOS 26.0, *) {
// 蓝色导航栏使用轻量入口,避免系统共享玻璃背景形成深色胶囊。
taskItem.hidesSharedBackground = true
}
navigationItem.rightBarButtonItem = taskItem
} }
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
applyViewModel() applyViewModel()
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationEnteredBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationBecameActive),
name: UIApplication.didBecomeActiveNotification,
object: nil
)
} }
deinit { NotificationCenter.default.removeObserver(self) }
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
applyNavigationBarStyle() applyNavigationBarStyle()
@@ -107,6 +159,7 @@ final class WiredCameraTransferViewController: BaseViewController {
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
previewLoadingTask?.cancel()
viewModel.stop() viewModel.stop()
restoreNavigationBarStyle() restoreNavigationBarStyle()
} }
@@ -161,7 +214,6 @@ final class WiredCameraTransferViewController: BaseViewController {
[retouchButton, formatButton, modeButton].forEach { [retouchButton, formatButton, modeButton].forEach {
chipsStack.addArrangedSubview($0) chipsStack.addArrangedSubview($0)
} }
retouchButton.isUserInteractionEnabled = false
formatButton.isUserInteractionEnabled = false formatButton.isUserInteractionEnabled = false
settingsStatsDivider.backgroundColor = AppColor.border settingsStatsDivider.backgroundColor = AppColor.border
@@ -189,6 +241,7 @@ final class WiredCameraTransferViewController: BaseViewController {
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
collectionView.backgroundColor = .white collectionView.backgroundColor = .white
collectionView.accessibilityIdentifier = "wiredTransfer.photoCollectionView"
collectionView.delegate = self collectionView.delegate = self
collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 8, right: 0) collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 8, right: 0)
collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier) collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier)
@@ -387,6 +440,7 @@ final class WiredCameraTransferViewController: BaseViewController {
Task { @MainActor in self?.showToast(message) } Task { @MainActor in self?.showToast(message) }
} }
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
retouchButton.addTarget(self, action: #selector(retouchTapped), for: .touchUpInside)
batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside) batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside)
historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside) historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside)
albumImportButton.addTarget(self, action: #selector(albumImportTapped), for: .touchUpInside) albumImportButton.addTarget(self, action: #selector(albumImportTapped), for: .touchUpInside)
@@ -415,7 +469,8 @@ final class WiredCameraTransferViewController: BaseViewController {
statusLabel.backgroundColor = (isFailed ? AppColor.danger : AppColor.primary).withAlphaComponent(0.10) statusLabel.backgroundColor = (isFailed ? AppColor.danger : AppColor.primary).withAlphaComponent(0.10)
refreshButton.setTitle(viewModel.actionButtonText, for: .normal) refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
retouchButton.apply(title: viewModel.retouchOption) retouchButton.apply(title: viewModel.isUpdatingAutoRetouchConfiguration ? "保存中" : viewModel.retouchOption)
retouchButton.isEnabled = !viewModel.isUpdatingAutoRetouchConfiguration
formatButton.apply(title: "JPG") formatButton.apply(title: "JPG")
modeButton.apply(title: viewModel.transferModeOption) modeButton.apply(title: viewModel.transferModeOption)
helpLabel.attributedText = helpText(viewModel.sonyMTPHint) helpLabel.attributedText = helpText(viewModel.sonyMTPHint)
@@ -567,6 +622,7 @@ final class WiredCameraTransferViewController: BaseViewController {
let selected = viewModel.selectedPhotoIds.contains(item.id) let selected = viewModel.selectedPhotoIds.contains(item.id)
cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected) cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected)
cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) } cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) }
cell.onRetryRetouch = { [weak self] in self?.viewModel.retryAutoRetouch(photoId: item.id) }
cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) } cell.onDelete = { [weak self] in self?.viewModel.deletePhoto(photoId: item.id) }
} }
@@ -711,6 +767,39 @@ final class WiredCameraTransferViewController: BaseViewController {
viewModel.refreshCameraFiles() viewModel.refreshCameraFiles()
} }
@objc private func openAIJobList() {
guard let navigationController,
navigationController.topViewController === self,
presentedViewController == nil,
previewLoadingTask == nil else { return }
navigationController.pushViewController(
TravelAlbumAIJobListViewController(api: viewModel.autoRetouchAPI),
animated: true
)
}
@objc private func applicationEnteredBackground() { viewModel.applicationDidEnterBackground() }
@objc private func applicationBecameActive() { viewModel.applicationDidBecomeActive() }
@objc private func retouchTapped() {
guard presentedViewController == nil else { return }
let settingViewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: AppStore.shared.session.currentScenicId,
configuration: viewModel.autoRetouchConfiguration,
startsWithModeSelection: true
)
let controller = TravelAlbumAutoRetouchSettingSheetViewController(
viewModel: settingViewModel,
api: viewModel.autoRetouchAPI
)
controller.onConfirm = { [weak self] configuration in
guard let self else { return }
Task { await self.viewModel.updateAutoRetouchConfiguration(configuration) }
}
present(controller, animated: true)
}
@objc private func batchTapped() { @objc private func batchTapped() {
viewModel.onBatchUploadButtonClick() viewModel.onBatchUploadButtonClick()
} }
@@ -788,6 +877,11 @@ final class WiredCameraTransferViewController: BaseViewController {
} }
private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) { private func presentPhotoPreview(_ item: TravelAlbumOTGPhotoItem) {
guard previewLoadingTask == nil, presentedViewController == nil else { return }
if item.autoRetouchState == .completed {
presentAutoRetouchPreview(photoId: item.id)
return
}
guard let url = item.thumbnailURL else { guard let url = item.thumbnailURL else {
showToast("暂无可预览图片") showToast("暂无可预览图片")
return return
@@ -797,6 +891,39 @@ final class WiredCameraTransferViewController: BaseViewController {
: MediaPreviewItem(source: .remoteImage(url)) : MediaPreviewItem(source: .remoteImage(url))
MediaPreviewViewController.present(from: self, items: [previewItem], startIndex: 0) MediaPreviewViewController.present(from: self, items: [previewItem], startIndex: 0)
} }
private func presentAutoRetouchPreview(photoId: String) {
previewLoadingTask = Task { @MainActor [weak self] in
guard let self else { return }
showLoading()
defer {
hideLoading()
previewLoadingTask = nil
}
do {
let project = try await viewModel.loadAutoRetouchPreviewProject(photoId: photoId)
try Task.checkCancellation()
guard viewIfLoaded?.window != nil,
presentedViewController == nil,
!viewModel.selectUploadMode else { return }
present(
TravelAlbumPhotoPreviewViewController(
projects: [project],
totalCount: 1,
startProjectIndex: 0,
startKind: .retouched,
allowsActions: false
),
animated: true
)
} catch is CancellationError {
// 离开传输页后不再弹出预览或错误提示。
} catch {
guard !Task.isCancelled else { return }
showToast(error.localizedDescription.isEmpty ? "修图结果加载失败,请重试" : error.localizedDescription)
}
}
}
} }
extension WiredCameraTransferViewController: PHPickerViewControllerDelegate { extension WiredCameraTransferViewController: PHPickerViewControllerDelegate {
@@ -1111,12 +1238,13 @@ private final class WiredTransferSectionHeaderView: UICollectionReusableView {
} }
/// 有线传输照片列表 Cell。 /// 有线传输照片列表 Cell。
private final class WiredTransferPhotoCell: UICollectionViewCell { final class WiredTransferPhotoCell: UICollectionViewCell {
static let reuseIdentifier = "WiredTransferPhotoCell" static let reuseIdentifier = "WiredTransferPhotoCell"
private static let previewImageSize = CGSize(width: 96, height: 96) private static let previewImageSize = CGSize(width: 96, height: 96)
private let selectionIconView = UIImageView() private let selectionIconView = UIImageView()
private let imageView = UIImageView() private let imageView = UIImageView()
private let retouchBadgeLabel = UILabel()
private let statusLabel = UILabel() private let statusLabel = UILabel()
private let titleLabel = UILabel() private let titleLabel = UILabel()
private let sizeLabel = UILabel() private let sizeLabel = UILabel()
@@ -1125,6 +1253,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
private let separatorView = UIView() private let separatorView = UIView()
var onRetry: (() -> Void)? var onRetry: (() -> Void)?
var onRetryRetouch: (() -> Void)?
var onDelete: (() -> Void)? var onDelete: (() -> Void)?
override init(frame: CGRect) { override init(frame: CGRect) {
@@ -1142,10 +1271,12 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
imageView.kf.cancelDownloadTask() imageView.kf.cancelDownloadTask()
imageView.image = nil imageView.image = nil
onRetry = nil onRetry = nil
onRetryRetouch = nil
onDelete = nil onDelete = nil
menuButton.menu = nil menuButton.menu = nil
} }
/// 分别渲染原图上传进度与自动修图角标,修图状态不参与进度计算。
func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) { func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) {
let canSelect = item.canSelectForUpload let canSelect = item.canSelectForUpload
let rowAlpha: CGFloat = selectionMode && !canSelect ? 0.45 : 1 let rowAlpha: CGFloat = selectionMode && !canSelect ? 0.45 : 1
@@ -1172,22 +1303,64 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
statusLabel.text = statusText(item.status) statusLabel.text = statusText(item.status)
statusLabel.textColor = statusTextColor(item.status) statusLabel.textColor = statusTextColor(item.status)
statusLabel.backgroundColor = statusBackgroundColor(item.status) statusLabel.backgroundColor = statusBackgroundColor(item.status)
applyRetouchBadge(state: item.autoRetouchState)
progressView.isHidden = item.status != .uploading && item.status != .transferring progressView.isHidden = item.status != .uploading && item.status != .transferring
progressView.progress = Float(item.progress) / 100.0 progressView.progress = Float(item.progress) / 100.0
selectionIconView.isHidden = !selectionMode selectionIconView.isHidden = !selectionMode
selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5) selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5)
menuButton.isHidden = selectionMode menuButton.isHidden = selectionMode
menuButton.menu = makeMenu(canRetry: item.status == .failed || item.status == .pending) menuButton.menu = makeMenu(
canRetryUpload: item.status == .failed || item.status == .pending,
canRetryRetouch: item.autoRetouchState == .failed
)
accessibilityLabel = "\(item.fileName),\(statusText(item.status))"
if !retouchBadgeLabel.isHidden, let retouchStatus = retouchBadgeLabel.accessibilityLabel {
accessibilityLabel?.append(",修图状态:\(retouchStatus)")
}
if let error = item.autoRetouchErrorMessage, !error.isEmpty {
accessibilityHint = "失败原因:\(error)"
} else {
accessibilityHint = nil
}
updateImageConstraints(selectionMode: selectionMode) updateImageConstraints(selectionMode: selectionMode)
} }
private func applyRetouchBadge(state: TravelAlbumAutoRetouchState) {
retouchBadgeLabel.isHidden = state == .none
switch state {
case .none:
retouchBadgeLabel.backgroundColor = .clear
retouchBadgeLabel.accessibilityLabel = nil
case .pendingSubmission, .submitting, .processing:
retouchBadgeLabel.backgroundColor = UIColor(hex: 0x7C3AED)
retouchBadgeLabel.accessibilityLabel = "修图中"
case .completed:
retouchBadgeLabel.backgroundColor = UIColor(hex: 0x047857)
retouchBadgeLabel.accessibilityLabel = "修图成功"
case .failed:
retouchBadgeLabel.backgroundColor = AppColor.danger
retouchBadgeLabel.accessibilityLabel = "修图失败"
}
}
private func setupUI() { private func setupUI() {
contentView.backgroundColor = .white contentView.backgroundColor = .white
selectionIconView.contentMode = .scaleAspectFit selectionIconView.contentMode = .scaleAspectFit
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true imageView.clipsToBounds = true
imageView.layer.cornerRadius = 6 imageView.layer.cornerRadius = 6
imageView.accessibilityIdentifier = "wiredTransfer.thumbnail"
retouchBadgeLabel.text = "修"
retouchBadgeLabel.font = .systemFont(ofSize: 9, weight: .bold)
retouchBadgeLabel.textColor = .white
retouchBadgeLabel.textAlignment = .center
retouchBadgeLabel.backgroundColor = .clear
retouchBadgeLabel.layer.cornerRadius = 8
retouchBadgeLabel.clipsToBounds = true
retouchBadgeLabel.isHidden = true
retouchBadgeLabel.accessibilityIdentifier = "wiredTransfer.retouchBadge"
statusLabel.accessibilityIdentifier = "wiredTransfer.uploadStatus"
statusLabel.font = .systemFont(ofSize: 9) statusLabel.font = .systemFont(ofSize: 9)
statusLabel.textAlignment = .center statusLabel.textAlignment = .center
statusLabel.layer.cornerRadius = 3 statusLabel.layer.cornerRadius = 3
@@ -1203,6 +1376,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
sizeLabel.textColor = AppColor.textTertiary sizeLabel.textColor = AppColor.textTertiary
progressView.progressTintColor = AppColor.primary progressView.progressTintColor = AppColor.primary
progressView.trackTintColor = AppColor.border progressView.trackTintColor = AppColor.border
progressView.accessibilityIdentifier = "wiredTransfer.uploadProgress"
menuButton.setImage(UIImage(systemName: "ellipsis"), for: .normal) menuButton.setImage(UIImage(systemName: "ellipsis"), for: .normal)
menuButton.tintColor = AppColor.textSecondary menuButton.tintColor = AppColor.textSecondary
menuButton.showsMenuAsPrimaryAction = true menuButton.showsMenuAsPrimaryAction = true
@@ -1210,6 +1384,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
contentView.addSubview(selectionIconView) contentView.addSubview(selectionIconView)
contentView.addSubview(imageView) contentView.addSubview(imageView)
contentView.addSubview(retouchBadgeLabel)
contentView.addSubview(titleLabel) contentView.addSubview(titleLabel)
contentView.addSubview(statusLabel) contentView.addSubview(statusLabel)
contentView.addSubview(sizeLabel) contentView.addSubview(sizeLabel)
@@ -1223,6 +1398,11 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
make.size.equalTo(20) make.size.equalTo(20)
} }
updateImageConstraints(selectionMode: true) updateImageConstraints(selectionMode: true)
retouchBadgeLabel.snp.makeConstraints { make in
make.top.equalTo(imageView).offset(2)
make.trailing.equalTo(imageView).offset(-2)
make.size.equalTo(16)
}
titleLabel.snp.makeConstraints { make in titleLabel.snp.makeConstraints { make in
make.top.equalTo(imageView).offset(1) make.top.equalTo(imageView).offset(1)
make.leading.equalTo(imageView.snp.trailing).offset(8) make.leading.equalTo(imageView.snp.trailing).offset(8)
@@ -1257,15 +1437,19 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
} }
} }
private func makeMenu(canRetry: Bool) -> UIMenu { private func makeMenu(canRetryUpload: Bool, canRetryRetouch: Bool) -> UIMenu {
let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in let retry = UIAction(title: "重传", image: UIImage(systemName: "arrow.clockwise")) { [weak self] _ in
self?.onRetry?() self?.onRetry?()
} }
retry.attributes = canRetry ? [] : [.disabled] retry.attributes = canRetryUpload ? [] : [.disabled]
let retryRetouch = UIAction(title: "重新修图", image: UIImage(systemName: "wand.and.stars")) { [weak self] _ in
self?.onRetryRetouch?()
}
retryRetouch.attributes = canRetryRetouch ? [] : [.disabled]
let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in let delete = UIAction(title: "删除", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in
self?.onDelete?() self?.onDelete?()
} }
return UIMenu(children: [retry, delete]) return UIMenu(children: [retry, retryRetouch, delete])
} }
private func updateImageConstraints(selectionMode: Bool) { private func updateImageConstraints(selectionMode: Bool) {
+63 -2
View File
@@ -36,7 +36,11 @@ final class TravelAlbumAPITests: XCTestCase {
materialNum: 2, materialNum: 2,
materialPrice: 10.5, materialPrice: 10.5,
materialPackagePrice: 88, materialPackagePrice: 88,
photoPrice: 0 photoPrice: 0,
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 12
)
) )
) )
@@ -48,6 +52,60 @@ final class TravelAlbumAPITests: XCTestCase {
XCTAssertEqual(body?["type"] as? Int, 1) XCTAssertEqual(body?["type"] as? Int, 1)
XCTAssertEqual(body?["material_num"] as? Int, 2) XCTAssertEqual(body?["material_num"] as? Int, 2)
XCTAssertEqual(body?["material_price"] as? Double, 10.5) XCTAssertEqual(body?["material_price"] as? Double, 10.5)
let retouchConfiguration = body?["auto_retouch_config"] as? [String: Any]
XCTAssertEqual(retouchConfiguration?["enabled"] as? Bool, true)
XCTAssertEqual(retouchConfiguration?["refined_template_id"] as? Int, 12)
XCTAssertNil(retouchConfiguration?["version"])
XCTAssertNil(retouchConfiguration?["updated_at"])
}
func testInfoDecodesNewConfigurationAndDefaultsOldAlbumToDisabled() async throws {
let configuredAlbum = envelopeJSON(
#"{"id":8,"store_user_id":1,"name":"配置相册","type":1,"order_number":"","material_num":1,"material_price":10,"material_package_price":0,"photo_price":0,"cover_url":"","user_id":2,"status":1,"created_at":"","updated_at":"","auto_retouch_config":{"enabled":true,"refined_template_id":12}}"#
)
let legacyAlbum = envelopeJSON(
#"{"id":9,"store_user_id":1,"name":"旧相册","type":1,"order_number":"","material_num":1,"material_price":10,"material_package_price":0,"photo_price":0,"cover_url":"","user_id":2,"status":1,"created_at":"","updated_at":""}"#
)
let session = MockURLSession(responses: [configuredAlbum, legacyAlbum])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
let configured = try await api.info(id: 8)
let legacy = try await api.info(id: 9)
XCTAssertEqual(
configured.autoRetouchConfiguration,
TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 12
)
)
XCTAssertEqual(legacy.autoRetouchConfiguration, .disabled)
}
func testUpdateAutoRetouchConfigurationEncodesBodyAndDecodesNormalizedResponse() async throws {
let data = envelopeJSON(
#"{"enabled":true,"refined_template_id":18}"#
)
let session = MockURLSession(responses: [data])
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.updateAutoRetouchConfiguration(
TravelAlbumAutoRetouchConfigurationRequest(
userEquityTravelId: 6,
enabled: true,
refinedTemplateId: 18
)
)
XCTAssertEqual(response.refinedTemplateId, 18)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/auto-retouch-config")
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 6)
XCTAssertEqual(body?["enabled"] as? Bool, true)
XCTAssertEqual(body?["refined_template_id"] as? Int, 18)
XCTAssertNil(body?["expected_version"])
} }
func testMaterialListAndDeleteAndMpCode() async throws { func testMaterialListAndDeleteAndMpCode() async throws {
@@ -192,7 +250,8 @@ final class TravelAlbumAPITests: XCTestCase {
materialIds: [11, 12, 13, 14], materialIds: [11, 12, 13, 14],
refinedTemplateId: 21, refinedTemplateId: 21,
atmosphereTemplateId: 22, atmosphereTemplateId: 22,
coverTemplateId: 31 coverTemplateId: 31,
clientRequestId: "auto-6-11-tpl21-a0"
) )
) )
@@ -205,12 +264,14 @@ final class TravelAlbumAPITests: XCTestCase {
XCTAssertEqual(body?["refined_template_id"] as? Int, 21) XCTAssertEqual(body?["refined_template_id"] as? Int, 21)
XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22) XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22)
XCTAssertEqual(body?["cover_template_id"] as? Int, 31) XCTAssertEqual(body?["cover_template_id"] as? Int, 31)
XCTAssertEqual(body?["client_request_id"] as? String, "auto-6-11-tpl21-a0")
XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [ XCTAssertEqual(Set(body?.keys.map { $0 } ?? []), [
"user_equity_travel_id", "user_equity_travel_id",
"material_ids", "material_ids",
"refined_template_id", "refined_template_id",
"atmosphere_template_id", "atmosphere_template_id",
"cover_template_id", "cover_template_id",
"client_request_id",
]) ])
} }
@@ -260,7 +260,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertFalse(GlobalLoadingManager.shared.isShowing) XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
} }
func testAIJobEntryOnlyAppearsOnAlbumManagementNavigationBar() { func testAlbumManagementShowsAIJobEntryButAlbumEntryDoesNot() {
let detail = TravelAlbumDetailViewController(albumId: 2, api: TravelAlbumMockAPI()) let detail = TravelAlbumDetailViewController(albumId: 2, api: TravelAlbumMockAPI())
detail.setupNavigationBar() detail.setupNavigationBar()
let entry = TravelAlbumEntryViewController(api: TravelAlbumMockAPI()) let entry = TravelAlbumEntryViewController(api: TravelAlbumMockAPI())
@@ -1394,6 +1394,111 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("修图状态:") == true) XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("修图状态:") == true)
} }
func testWiredTransferProgressUsesOnlyUploadStatusAndRealProgress() throws {
let cell = WiredTransferPhotoCell(frame: CGRect(x: 0, y: 0, width: 300, height: 66))
let progressView = try XCTUnwrap(cell.findSubview {
$0.accessibilityIdentifier == "wiredTransfer.uploadProgress"
} as? UIProgressView)
let uploadStates: [TravelAlbumOTGUploadStatus] = [.pending, .transferring, .uploading, .uploaded, .failed]
let retouchStates: [TravelAlbumAutoRetouchState] = [.none, .pendingSubmission, .submitting, .processing, .completed, .failed]
for uploadState in uploadStates {
for retouchState in retouchStates {
var item = makeWiredTransferPhotoItem(state: retouchState)
item.status = uploadState
item.progress = uploadState == .uploaded ? 100 : 37
cell.apply(item: item, selectionMode: false, selected: false)
XCTAssertEqual(progressView.isHidden, uploadState != .transferring && uploadState != .uploading)
XCTAssertEqual(progressView.progress, Float(item.progress) / 100, accuracy: 0.001)
}
}
}
func testWiredTransferRetouchBadgeShowsStateColorsAndKeepsCircularThumbnailLayout() throws {
let cases: [(TravelAlbumAutoRetouchState, String, UIColor)] = [
(.pendingSubmission, "修图中", UIColor(hex: 0x7C3AED)),
(.submitting, "修图中", UIColor(hex: 0x7C3AED)),
(.processing, "修图中", UIColor(hex: 0x7C3AED)),
(.completed, "修图成功", UIColor(hex: 0x047857)),
(.failed, "修图失败", AppColor.danger),
]
let cell = WiredTransferPhotoCell(frame: CGRect(x: 0, y: 0, width: 300, height: 66))
let badge = try XCTUnwrap(cell.findSubview {
$0.accessibilityIdentifier == "wiredTransfer.retouchBadge"
} as? UILabel)
let thumbnail = try XCTUnwrap(cell.findSubview {
$0.accessibilityIdentifier == "wiredTransfer.thumbnail"
})
for (state, expectedStatus, expectedColor) in cases {
cell.apply(item: makeWiredTransferPhotoItem(state: state), selectionMode: false, selected: false)
cell.layoutIfNeeded()
XCTAssertFalse(badge.isHidden)
XCTAssertEqual(badge.text, "修")
XCTAssertEqual(badge.backgroundColor?.travelAlbumTestHexRGB, expectedColor.travelAlbumTestHexRGB)
XCTAssertEqual(badge.textColor, .white)
XCTAssertEqual(badge.frame.size, CGSize(width: 16, height: 16))
// Auto Layout 对齐 alignmentRect,系统符号占位图的 frame 可能带有留白。
let badgeAlignmentRect = badge.alignmentRect(forFrame: badge.frame)
let thumbnailAlignmentRect = thumbnail.alignmentRect(forFrame: thumbnail.frame)
XCTAssertEqual(badgeAlignmentRect.minY, thumbnailAlignmentRect.minY + 2, accuracy: 0.1)
XCTAssertEqual(badgeAlignmentRect.maxX, thumbnailAlignmentRect.maxX - 2, accuracy: 0.1)
XCTAssertEqual(badge.layer.cornerRadius, 8)
XCTAssertEqual(badge.accessibilityLabel, expectedStatus)
XCTAssertTrue(cell.accessibilityLabel?.contains("修图状态:\(expectedStatus)") == true)
}
cell.prepareForReuse()
cell.apply(item: makeWiredTransferPhotoItem(state: .none), selectionMode: false, selected: false)
XCTAssertTrue(badge.isHidden)
XCTAssertNil(badge.accessibilityLabel)
XCTAssertFalse(cell.accessibilityLabel?.contains("修图状态:") == true)
}
func testWiredTransferUploadLabelIgnoresAllRetouchStates() throws {
let cell = WiredTransferPhotoCell(frame: CGRect(x: 0, y: 0, width: 300, height: 66))
let label = try XCTUnwrap(cell.findSubview {
$0.accessibilityIdentifier == "wiredTransfer.uploadStatus"
} as? UILabel)
let successColor = UIColor(hex: 0x16A34A)
let cases: [(TravelAlbumOTGUploadStatus, String, UIColor, UIColor)] = [
(.pending, "待上传", AppColor.textSecondary, AppColor.pageBackground),
(.transferring, "传输中", AppColor.primary, AppColor.primary.withAlphaComponent(0.12)),
(.uploading, "上传中", AppColor.primary, AppColor.primary.withAlphaComponent(0.12)),
(.uploaded, "已上传", successColor, successColor.withAlphaComponent(0.12)),
(.failed, "上传失败", AppColor.danger, AppColor.danger.withAlphaComponent(0.12)),
]
let retouchStates: [TravelAlbumAutoRetouchState] = [
.none, .pendingSubmission, .submitting, .processing, .completed, .failed,
]
for (uploadStatus, text, foreground, background) in cases {
for retouchState in retouchStates {
var item = makeWiredTransferPhotoItem(state: retouchState)
item.status = uploadStatus
cell.apply(item: item, selectionMode: false, selected: false)
XCTAssertFalse(label.isHidden)
XCTAssertEqual(label.text, text)
XCTAssertEqual(label.textColor, foreground)
XCTAssertEqual(label.backgroundColor, background)
XCTAssertTrue(cell.accessibilityLabel?.hasPrefix("photo.JPG,\(text)") == true)
}
}
}
private func makeWiredTransferPhotoItem(state: TravelAlbumAutoRetouchState) -> TravelAlbumOTGPhotoItem {
TravelAlbumOTGPhotoItem(
id: "photo", sourceId: "photo", fileName: "photo.JPG", thumbnailURL: nil,
capturedAt: "2026-08-27 12:00:00", fileSizeText: "1 KB", fileSizeBytes: 1024,
status: .uploaded, progress: 100, errorMessage: nil, localPath: "", remoteUrl: "",
autoRetouchState: state
)
}
private func waitUntil(_ condition: @escaping () -> Bool) async { private func waitUntil(_ condition: @escaping () -> Bool) async {
for _ in 0 ..< 200 { for _ in 0 ..< 200 {
if condition() { return } if condition() { return }
+720 -2
View File
@@ -3,6 +3,7 @@
// suixinkanTests // suixinkanTests
// //
import UIKit
import XCTest import XCTest
@testable import suixinkan @testable import suixinkan
@@ -451,9 +452,72 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
} }
} }
/// 自动修图配置 ViewModel 测试。
@MainActor
final class TravelAlbumAutoRetouchSettingViewModelTests: XCTestCase {
func testLoadsOnlyRefinedTemplatesAndKeepsSingleSelection() async {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
refinedTemplates: [
TravelAlbumAIRetouchTemplate(id: 11, name: "清透", previewURL: "https://cdn/11.jpg"),
TravelAlbumAIRetouchTemplate(id: 12, name: "质感", previewURL: "https://cdn/12.jpg"),
],
atmosphereTemplates: [
TravelAlbumAIRetouchTemplate(id: 21, name: "暖阳", previewURL: "https://cdn/21.jpg"),
],
coverTemplates: []
)
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: 18,
configuration: .disabled,
startsWithModeSelection: true
)
viewModel.selectMode(enabled: true)
await viewModel.loadTemplates(api: api)
viewModel.selectTemplate(id: 11)
viewModel.selectTemplate(id: 12)
XCTAssertEqual(viewModel.templates.map(\.id), [11, 12])
XCTAssertEqual(viewModel.selectedTemplateId, 12)
XCTAssertEqual(viewModel.pendingConfiguration?.refinedTemplateId, 12)
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
}
func testTemplateLoadFailureKeepsConfigurationInvalidForRetry() async {
let api = TravelAlbumMockAPI()
api.aiRetouchTemplatesError = APIError.httpStatus(500, "模板服务不可用")
let viewModel = TravelAlbumAutoRetouchSettingViewModel(
scenicId: 18,
configuration: TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 12),
startsWithModeSelection: false
)
await viewModel.loadTemplates(api: api)
XCTAssertTrue(viewModel.templates.isEmpty)
XCTAssertNil(viewModel.pendingConfiguration)
XCTAssertFalse(viewModel.errorMessage?.isEmpty ?? true)
}
}
/// 有线传输 ViewModel 测试。 /// 有线传输 ViewModel 测试。
@MainActor @MainActor
final class WiredCameraTransferViewModelTests: XCTestCase { final class WiredCameraTransferViewModelTests: XCTestCase {
func testLegacyOTGRecordDefaultsAutomaticRetouchMetadata() throws {
let data = Data(
#"{"id":"legacy","sourceId":"legacy","clientPhotoId":"client-id","fileName":"A.JPG","localPath":"originals/A.JPG","thumbnailPath":"","capturedAt":"2026-08-26 12:00:00","fileSizeBytes":1024,"status":"UPLOADED","progress":100,"albumId":9,"userId":"u1","remoteUrl":"https://cdn/A.JPG","updatedAt":1}"#.utf8
)
let record = try JSONDecoder().decode(TravelAlbumOTGPhotoRecord.self, from: data)
XCTAssertEqual(record.serverMaterialId, 0)
XCTAssertEqual(record.autoRetouchState, .none)
XCTAssertNil(record.autoRetouchTemplateId)
XCTAssertTrue(record.autoRetouchClientRequestId.isEmpty)
XCTAssertEqual(record.autoRetouchBatchId, 0)
}
func testDefaultDisconnectedStateAndOptions() { func testDefaultDisconnectedStateAndOptions() {
let viewModel = WiredCameraTransferViewModel( let viewModel = WiredCameraTransferViewModel(
albumId: 1, albumId: 1,
@@ -837,6 +901,635 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(api.materialClientPhotoIdsCallCount, 1) XCTAssertEqual(api.materialClientPhotoIdsCallCount, 1)
} }
func testOriginalUploadDoesNotSubmitAIRetouchWhenConfigurationIsDisabled() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
let api = TravelAlbumMockAPI()
let record = try makePersistedOTGRecord(
context: context,
id: "without-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: .disabled
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.status == .uploaded }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertTrue(api.aiRetouchRequests.isEmpty)
XCTAssertEqual(context.store.load(albumId: 9).first?.autoRetouchState, TravelAlbumAutoRetouchState.none)
}
func testEnabledConfigurationSubmitsOneIdempotentAIRetouchAfterOriginalUpload() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [77]
let api = TravelAlbumMockAPI()
api.aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 901,
userEquityTravelId: 9,
status: .queued,
progress: TravelAlbumAIJobProgress(
total: 1,
queued: 1,
processing: 0,
succeeded: 0,
failed: 0,
canceled: 0
),
createdAt: "2026-08-26T12:00:00Z"
)
let record = try makePersistedOTGRecord(
context: context,
id: "automatic-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let configuration = TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 12
)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: configuration
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { api.aiRetouchRequests.count == 1 }
let request = try XCTUnwrap(api.aiRetouchRequests.first)
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertEqual(request.materialIds, [77])
XCTAssertEqual(request.refinedTemplateId, 12)
XCTAssertEqual(request.clientRequestId, "auto-9-77-tpl12-a0")
let persisted = try XCTUnwrap(context.store.load(albumId: 9).first)
XCTAssertEqual(persisted.status, .uploaded)
XCTAssertEqual(persisted.progress, 100)
XCTAssertEqual(persisted.serverMaterialId, 77)
XCTAssertEqual(persisted.autoRetouchBatchId, 901)
XCTAssertEqual(persisted.autoRetouchState, .processing)
}
func testAIRetouchSubmissionFailureKeepsUploadAndRetryDoesNotUploadOriginalAgain() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [88]
let api = TravelAlbumMockAPI()
api.submitAIRetouchError = APIError.serverCode(190001, "AI 修图额度不足")
let record = try makePersistedOTGRecord(
context: context,
id: "retry-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 19
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.autoRetouchState == .failed }
let firstRequestId = api.aiRetouchRequests.first?.clientRequestId
XCTAssertEqual(context.store.load(albumId: 9).first?.status, .uploaded)
XCTAssertEqual(context.store.load(albumId: 9).first?.progress, 100)
api.submitAIRetouchError = nil
viewModel.retryAutoRetouch(photoId: record.id)
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertEqual(api.aiRetouchRequests.last?.clientRequestId, firstRequestId)
XCTAssertEqual(context.store.load(albumId: 9).first?.status, .uploaded)
XCTAssertEqual(context.store.load(albumId: 9).first?.progress, 100)
}
func testAmbiguousNetworkFailureStaysPendingAndForegroundResumeReusesRequestId() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [89]
let api = TravelAlbumMockAPI()
api.submitAIRetouchError = APIError.networkFailed("连接中断")
let record = try makePersistedOTGRecord(
context: context,
id: "ambiguous-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 20
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.autoRetouchState == .pendingSubmission }
let firstRequestId = try XCTUnwrap(api.aiRetouchRequests.first?.clientRequestId)
api.submitAIRetouchError = nil
viewModel.applicationDidBecomeActive()
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertEqual(api.aiRetouchRequests.last?.clientRequestId, firstRequestId)
XCTAssertEqual(context.store.load(albumId: 9).first?.autoRetouchState, .processing)
}
func testTerminalTaskFailureRetryCreatesNewAttemptIdentifierWithoutReupload() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [90]
let api = TravelAlbumMockAPI()
api.aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 902,
userEquityTravelId: 9,
status: .failed,
progress: TravelAlbumAIJobProgress(
total: 1,
queued: 0,
processing: 0,
succeeded: 0,
failed: 1,
canceled: 0
),
createdAt: "2026-08-26T12:00:00Z"
)
let record = try makePersistedOTGRecord(
context: context,
id: "terminal-retouch",
capturedAt: "2026-08-26 12:00:00",
status: .pending
)
context.store.save([record], albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 22
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: record.id)
await waitUntil { context.store.load(albumId: 9).first?.autoRetouchState == .failed }
let firstRequestId = api.aiRetouchRequests.first?.clientRequestId
api.aiJobSubmission = TravelAlbumAIJobSubmission(
aiRetouchBatchId: 903,
userEquityTravelId: 9,
status: .queued,
progress: TravelAlbumAIJobProgress(
total: 1,
queued: 1,
processing: 0,
succeeded: 0,
failed: 0,
canceled: 0
),
createdAt: "2026-08-26T12:01:00Z"
)
viewModel.retryAutoRetouch(photoId: record.id)
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(uploader.uploadCallCount, 1)
XCTAssertNotEqual(api.aiRetouchRequests.last?.clientRequestId, firstRequestId)
XCTAssertEqual(api.aiRetouchRequests.last?.clientRequestId, "auto-9-90-tpl22-a1")
}
func testConfigurationChangeOnlyAffectsPhotosWhoseUploadHasNotStarted() async throws {
let context = makeOTGTestContext()
let uploader = MockTravelAlbumOTGUploader()
uploader.materialIds = [101, 102]
let api = TravelAlbumMockAPI()
let records = [
try makePersistedOTGRecord(
context: context,
id: "first-snapshot",
capturedAt: "2026-08-26 12:00:00",
status: .pending
),
try makePersistedOTGRecord(
context: context,
id: "second-snapshot",
capturedAt: "2026-08-26 12:01:00",
status: .pending
),
]
context.store.save(records, albumId: 9)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api,
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 11
)
)
viewModel.reloadLocalPhotos()
viewModel.retryPhoto(photoId: records[0].id)
await waitUntil { api.aiRetouchRequests.count == 1 }
api.autoRetouchConfigurationResponse = TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 12
)
await viewModel.updateAutoRetouchConfiguration(
TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 12)
)
viewModel.retryPhoto(photoId: records[1].id)
await waitUntil { api.aiRetouchRequests.count == 2 }
XCTAssertEqual(api.aiRetouchRequests.map(\.refinedTemplateId), [11, 12])
XCTAssertEqual(api.aiRetouchRequests.map(\.clientRequestId), [
"auto-9-101-tpl11-a0",
"auto-9-102-tpl12-a0",
])
}
func testPendingAIRetouchResumesWithPersistedIdempotencyIdentifier() async {
let context = makeOTGTestContext()
let api = TravelAlbumMockAPI()
let persisted = TravelAlbumOTGPhotoRecord(
id: "resume-retouch",
fileName: "resume.JPG",
localPath: "originals/resume.JPG",
capturedAt: "2026-08-26 12:00:00",
fileSizeBytes: 1024,
status: .uploaded,
progress: 100,
albumId: 9,
userId: "u1",
remoteUrl: "https://cdn.example.com/resume.JPG",
serverMaterialId: 99,
autoRetouchState: .pendingSubmission,
autoRetouchTemplateId: 21,
autoRetouchClientRequestId: "persisted-request-id"
)
context.store.save([persisted], albumId: 9)
let uploader = MockTravelAlbumOTGUploader()
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
uploader: uploader,
api: api
)
viewModel.reloadLocalPhotos()
viewModel.applicationDidBecomeActive()
await waitUntil { api.aiRetouchRequests.count == 1 }
XCTAssertEqual(uploader.uploadCallCount, 0)
XCTAssertEqual(api.aiRetouchRequests.first?.clientRequestId, "persisted-request-id")
XCTAssertEqual(api.aiRetouchRequests.first?.materialIds, [99])
}
func testConfigurationUpdateUsesServerNormalizedResponse() async {
let context = makeOTGTestContext()
let api = TravelAlbumMockAPI()
let serverConfiguration = TravelAlbumAutoRetouchConfiguration(
enabled: true,
refinedTemplateId: 31
)
api.autoRetouchConfigurationResponse = serverConfiguration
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
api: api,
initialAutoRetouchConfiguration: .disabled
)
var message = ""
viewModel.onShowMessage = { message = $0 }
await viewModel.updateAutoRetouchConfiguration(
TravelAlbumAutoRetouchConfiguration(enabled: true, refinedTemplateId: 22)
)
XCTAssertEqual(api.autoRetouchConfigurationRequests.first?.refinedTemplateId, 22)
XCTAssertEqual(viewModel.autoRetouchConfiguration, serverConfiguration)
XCTAssertEqual(message, "已开启 AI 自动修图")
}
}
/// OTG 修图任务入口、只读预览路由和失败恢复测试。
@MainActor
final class WiredCameraTransferPreviewTests: XCTestCase {
func testTaskButtonUsesCompactWhiteIconAndTitleWithoutSharedBackground() throws {
let fixture = try makeFixture()
let controller = WiredCameraTransferViewController(viewModel: fixture.viewModel)
controller.setupNavigationBar()
let album = TravelAlbumDetailViewController(albumId: 9, api: fixture.api)
album.setupNavigationBar()
let expected = try XCTUnwrap(album.navigationItem.rightBarButtonItems?.first { $0.title == "修图任务" })
let item = try XCTUnwrap(controller.navigationItem.rightBarButtonItem)
let button = try XCTUnwrap(item.customView as? UIButton)
let fittingSize = button.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
XCTAssertEqual(button.configuration?.title, expected.title)
XCTAssertNotNil(button.configuration?.image)
XCTAssertEqual(button.configuration?.image?.renderingMode, .alwaysOriginal)
XCTAssertEqual(button.buttonType, .custom)
XCTAssertEqual(button.accessibilityLabel, expected.accessibilityLabel)
XCTAssertEqual(button.configuration?.baseForegroundColor, .white)
XCTAssertEqual(button.tintColor, .white)
XCTAssertEqual(button.configuration?.background.backgroundColor, .clear)
XCTAssertEqual(button.accessibilityIdentifier, "wiredTransfer.aiRetouchTasksButton")
XCTAssertEqual(fittingSize.height, 44, accuracy: 0.1)
XCTAssertGreaterThanOrEqual(fittingSize.width, 44)
XCTAssertLessThanOrEqual(fittingSize.width, 90)
if #available(iOS 26.0, *) {
XCTAssertTrue(item.hidesSharedBackground)
}
XCTAssertTrue(button.isEnabled)
}
func testTaskButtonOpensExistingJobListWithInjectedAPIAndReturnsToTransferState() async throws {
let fixture = try makeFixture()
fixture.viewModel.selectTransferMode("拍后传输")
fixture.viewModel.selectTab(.uploaded)
let controller = WiredCameraTransferViewController(viewModel: fixture.viewModel)
let navigation = UINavigationController(rootViewController: controller)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = navigation
window.makeKeyAndVisible()
defer {
window.isHidden = true
window.rootViewController = nil
fixture.viewModel.stop()
}
controller.loadViewIfNeeded()
let button = try XCTUnwrap(controller.navigationItem.rightBarButtonItem?.customView as? UIButton)
button.sendActions(for: .touchUpInside)
let jobList = try XCTUnwrap(navigation.topViewController as? TravelAlbumAIJobListViewController)
jobList.loadViewIfNeeded()
await waitUntil { !fixture.api.aiJobListRequests.isEmpty && navigation.transitionCoordinator == nil }
XCTAssertEqual(fixture.api.aiJobListRequests.first?.statusGroup, .all)
button.sendActions(for: .touchUpInside)
XCTAssertEqual(navigation.viewControllers.count, 2)
XCTAssertTrue(navigation.topViewController === jobList)
navigation.popViewController(animated: false)
XCTAssertTrue(navigation.topViewController === controller)
XCTAssertEqual(fixture.viewModel.transferMode, .postTransfer)
XCTAssertEqual(fixture.viewModel.selectedTab, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.status, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.progress, 100)
XCTAssertEqual((controller.navigationItem.rightBarButtonItem?.customView as? UIButton)?.configuration?.title, "修图任务")
XCTAssertEqual(navigation.navigationBar.tintColor, .white)
}
func testPreviewLoadsLatestOriginalAndRefinedResultWithoutChangingUploadState() async throws {
let fixture = try makeFixture()
let project = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTAssertEqual(fixture.api.materialInfoRequests, [.init(userEquityTravelId: 9, materialId: 77)])
XCTAssertEqual(project.originalMaterialId, 77)
XCTAssertEqual(project.aiRetouchBatchId, 901)
XCTAssertEqual(project.orderedAssets.map(\.kind), [.original, .retouched])
XCTAssertEqual(project.asset(for: .retouched)?.displayURL, fixture.api.materialInfoResponse.aiRefinedURL)
XCTAssertNotNil(project.comparisonContent(for: .retouched))
XCTAssertEqual(fixture.viewModel.photos.first?.status, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.progress, 100)
XCTAssertEqual(fixture.viewModel.photos.first?.autoRetouchState, .completed)
XCTAssertTrue(fixture.api.aiRetouchRequests.isEmpty)
XCTAssertTrue(fixture.api.uploadMaterialRequests.isEmpty)
}
func testPreviewRejectsMissingMaterialIdAndUnfinishedRetouchWithoutRequest() async throws {
for (state, materialId) in [(TravelAlbumAutoRetouchState.completed, 0), (.processing, 77), (.none, 77)] {
let fixture = try makeFixture(state: state, materialId: materialId)
do {
_ = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTFail("Invalid preview should fail")
} catch {
XCTAssertEqual(error as? TravelAlbumOTGPreviewError, .materialUnavailable)
}
XCTAssertTrue(fixture.api.materialInfoRequests.isEmpty)
}
}
func testPreviewRejectsMissingRefinedResultAndPropagatesRequestFailure() async throws {
let fixture = try makeFixture()
fixture.api.materialInfoResponse = TravelAlbumMaterial(id: 77, fileUrl: "file:///original.jpg", aiRefinedURL: " ")
do {
_ = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTFail("Missing refined result should fail")
} catch {
XCTAssertEqual(error as? TravelAlbumOTGPreviewError, .resultNotReady)
}
let failure = APIError.networkFailed("网络不可用")
fixture.api.materialInfoError = failure
do {
_ = try await fixture.viewModel.loadAutoRetouchPreviewProject(photoId: "preview")
XCTFail("Request failure should propagate")
} catch {
XCTAssertEqual(error.localizedDescription, failure.localizedDescription)
}
XCTAssertEqual(fixture.viewModel.photos.first?.status, .uploaded)
XCTAssertEqual(fixture.viewModel.photos.first?.progress, 100)
XCTAssertEqual(fixture.viewModel.photos.first?.autoRetouchState, .completed)
}
func testCompletedPhotoOpensReadOnlyRefinedPreviewAndIgnoresDuplicateTaps() async throws {
let fixture = try makeFixture()
fixture.api.materialInfoDelayNanoseconds = 100_000_000
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
tapPhoto(in: page.collection)
await waitUntil { fixture.api.materialInfoRequests.count == 1 }
XCTAssertTrue(GlobalLoadingManager.shared.isShowing)
await waitUntil { page.controller.presentedViewController is TravelAlbumPhotoPreviewViewController }
let preview = try XCTUnwrap(page.controller.presentedViewController as? TravelAlbumPhotoPreviewViewController)
preview.loadViewIfNeeded()
let variants = try XCTUnwrap(findView(in: preview.view, identifier: "travelAlbum.previewVariantSegmentedControl") as? UISegmentedControl)
let actions = try XCTUnwrap(findView(in: preview.view, identifier: "travelAlbum.previewActionStack"))
let comparison = try XCTUnwrap(findView(in: preview.view, identifier: "travelAlbum.previewComparisonButton"))
XCTAssertEqual(variants.numberOfSegments, 2)
XCTAssertEqual(variants.titleForSegment(at: variants.selectedSegmentIndex), "精修后")
XCTAssertTrue(actions.isHidden)
XCTAssertFalse(comparison.isHidden)
XCTAssertEqual(fixture.api.materialInfoRequests.count, 1)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
func testUnfinishedFailedAndUnretouchedPhotosKeepGenericPreview() async throws {
for state in [TravelAlbumAutoRetouchState.none, .processing, .failed] {
let fixture = try makeFixture(state: state)
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
await waitUntil { page.controller.presentedViewController != nil }
XCTAssertTrue(page.controller.presentedViewController is MediaPreviewViewController)
XCTAssertTrue(fixture.api.materialInfoRequests.isEmpty)
}
}
func testPreviewFailuresStayOnTransferPageAndStopLoading() async throws {
for scenario in 0..<3 {
let fixture = try makeFixture(materialId: scenario == 0 ? 0 : 77)
let expectedMessage: String
switch scenario {
case 0:
expectedMessage = TravelAlbumOTGPreviewError.materialUnavailable.localizedDescription
case 1:
let error = APIError.networkFailed("预览网络不可用")
fixture.api.materialInfoError = error
expectedMessage = error.localizedDescription
default:
fixture.api.materialInfoResponse = TravelAlbumMaterial(id: 77, fileUrl: "file:///original.jpg")
expectedMessage = TravelAlbumOTGPreviewError.resultNotReady.localizedDescription
}
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
await waitUntil { self.containsLabel(in: page.controller.view, text: expectedMessage) }
XCTAssertNil(page.controller.presentedViewController)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
XCTAssertTrue(containsLabel(in: page.controller.view, text: expectedMessage))
}
}
func testSelectionModeDoesNotOpenPreview() async throws {
let fixture = try makeFixture()
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
fixture.viewModel.onBatchUploadButtonClick()
tapPhoto(in: page.collection)
XCTAssertNil(page.controller.presentedViewController)
XCTAssertTrue(fixture.api.materialInfoRequests.isEmpty)
}
func testLeavingTransferPageCancelsPreviewLoading() async throws {
let fixture = try makeFixture()
fixture.api.materialInfoDelayNanoseconds = 500_000_000
let page = try await showPage(viewModel: fixture.viewModel)
defer { closePage(page, viewModel: fixture.viewModel) }
tapPhoto(in: page.collection)
await waitUntil { fixture.api.materialInfoRequests.count == 1 }
page.controller.viewWillDisappear(false)
await waitUntil { !GlobalLoadingManager.shared.isShowing }
XCTAssertNil(page.controller.presentedViewController)
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
}
private func makeFixture(
state: TravelAlbumAutoRetouchState = .completed,
materialId: Int = 77
) throws -> (viewModel: WiredCameraTransferViewModel, api: TravelAlbumMockAPI) {
let context = makeOTGTestContext()
var record = try makePersistedOTGRecord(context: context, id: "preview", capturedAt: "2026-08-27 12:00:00", status: .uploaded)
record.clientPhotoId = "preview-client"
record.progress = 100
record.serverMaterialId = materialId
record.autoRetouchState = state
record.autoRetouchBatchId = 901
context.store.save([record], albumId: 9)
let api = TravelAlbumMockAPI()
api.materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [record.clientPhotoId])
let imageURL = try XCTUnwrap(context.store.absoluteURL(for: record.localPath, albumId: 9)).absoluteString
api.materialInfoResponse = TravelAlbumMaterial(
id: 77, userEquityTravelId: 9, fileName: "preview.JPG", fileUrl: imageURL,
aiRetouchBatchId: 901, aiRefinedURL: imageURL, aiAtmosphereURL: imageURL
)
let viewModel = makeWiredViewModel(
context: context,
manager: MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])),
api: api
)
viewModel.reloadLocalPhotos()
return (viewModel, api)
}
/// 测试页面与其窗口,保持预览展示期间的 UIKit 生命周期。
private struct Page {
let window: UIWindow
let controller: WiredCameraTransferViewController
let collection: UICollectionView
}
private func showPage(viewModel: WiredCameraTransferViewModel) async throws -> Page {
let controller = WiredCameraTransferViewController(viewModel: viewModel)
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
window.rootViewController = controller
window.makeKeyAndVisible()
controller.loadViewIfNeeded()
let collection = try XCTUnwrap(findView(in: controller.view, identifier: "wiredTransfer.photoCollectionView") as? UICollectionView)
await waitUntil { collection.numberOfSections > 0 && collection.numberOfItems(inSection: 0) > 0 }
controller.view.layoutIfNeeded()
return Page(window: window, controller: controller, collection: collection)
}
private func closePage(_ page: Page, viewModel: WiredCameraTransferViewModel) {
page.controller.dismiss(animated: false)
page.window.isHidden = true
page.window.rootViewController = nil
viewModel.stop()
}
private func tapPhoto(in collection: UICollectionView) {
collection.delegate?.collectionView?(collection, didSelectItemAt: IndexPath(item: 0, section: 0))
}
private func findView(in view: UIView, identifier: String) -> UIView? {
if view.accessibilityIdentifier == identifier { return view }
for subview in view.subviews {
if let match = findView(in: subview, identifier: identifier) { return match }
}
return nil
}
private func containsLabel(in view: UIView, text: String) -> Bool {
if let label = view as? UILabel, label.text == text { return true }
return view.subviews.contains { containsLabel(in: $0, text: text) }
}
} }
/// 旅拍相册相机历史导入 ViewModel 测试。 /// 旅拍相册相机历史导入 ViewModel 测试。
@@ -1037,13 +1730,15 @@ private func makeWiredViewModel(
manager: MockWiredCameraConnectionManager, manager: MockWiredCameraConnectionManager,
uploader: MockTravelAlbumOTGUploader? = nil, uploader: MockTravelAlbumOTGUploader? = nil,
api: TravelAlbumMockAPI? = nil, api: TravelAlbumMockAPI? = nil,
userDefaults: UserDefaults = makeOTGTestDefaults() userDefaults: UserDefaults = makeOTGTestDefaults(),
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
) -> WiredCameraTransferViewModel { ) -> WiredCameraTransferViewModel {
prepareAppStoreForOTGTests() prepareAppStoreForOTGTests()
return WiredCameraTransferViewModel( return WiredCameraTransferViewModel(
albumId: albumId, albumId: albumId,
albumTitle: "相册", albumTitle: "相册",
headerPhone: "13800138000", headerPhone: "13800138000",
initialAutoRetouchConfiguration: initialAutoRetouchConfiguration,
connectionManager: manager, connectionManager: manager,
storage: context.store, storage: context.store,
uploader: uploader ?? MockTravelAlbumOTGUploader(), uploader: uploader ?? MockTravelAlbumOTGUploader(),
@@ -1248,6 +1943,8 @@ private final class MockCameraDriver: CameraDriver {
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading { private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
private(set) var uploadCallCount = 0 private(set) var uploadCallCount = 0
private(set) var uploadedRecordIds: [String] = [] private(set) var uploadedRecordIds: [String] = []
var materialIds: [Int] = []
var error: Error?
func upload( func upload(
record: TravelAlbumOTGPhotoRecord, record: TravelAlbumOTGPhotoRecord,
@@ -1256,7 +1953,13 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
) async throws -> TravelAlbumMaterial { ) async throws -> TravelAlbumMaterial {
uploadCallCount += 1 uploadCallCount += 1
uploadedRecordIds.append(record.id) uploadedRecordIds.append(record.id)
return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)") if let error { throw error }
let materialId = materialIds.isEmpty ? uploadCallCount : materialIds.removeFirst()
return TravelAlbumMaterial(
id: materialId,
fileName: record.fileName,
fileUrl: "https://cdn.example.com/\(record.fileName)"
)
} }
} }
@@ -1290,10 +1993,13 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
var materialListFailingCallIndexes: Set<Int> = [] var materialListFailingCallIndexes: Set<Int> = []
var materialInfoResponse = TravelAlbumMaterial() var materialInfoResponse = TravelAlbumMaterial()
var materialInfoError: Error? var materialInfoError: Error?
var materialInfoDelayNanoseconds: UInt64 = 0
var uploadMaterialResponse = TravelAlbumMaterial() var uploadMaterialResponse = TravelAlbumMaterial()
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: []) var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "") var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse() var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse()
var autoRetouchConfigurationResponse: TravelAlbumAutoRetouchConfiguration = .disabled
var updateAutoRetouchConfigurationError: Error?
var createError: Error? var createError: Error?
var aiRetouchTemplatesError: Error? var aiRetouchTemplatesError: Error?
var submitAIRetouchError: Error? var submitAIRetouchError: Error?
@@ -1324,6 +2030,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
private(set) var deletedMaterialIDBatches: [[Int]] = [] private(set) var deletedMaterialIDBatches: [[Int]] = []
private(set) var aiRetouchTemplateScenicIds: [Int] = [] private(set) var aiRetouchTemplateScenicIds: [Int] = []
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = [] private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
private(set) var autoRetouchConfigurationRequests: [TravelAlbumAutoRetouchConfigurationRequest] = []
private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = [] private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = []
private(set) var aiJobListRequests: [AIJobListRequest] = [] private(set) var aiJobListRequests: [AIJobListRequest] = []
@@ -1375,6 +2082,9 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
materialInfoRequests.append( materialInfoRequests.append(
MaterialInfoRequest(userEquityTravelId: userEquityTravelId, materialId: materialId) MaterialInfoRequest(userEquityTravelId: userEquityTravelId, materialId: materialId)
) )
if materialInfoDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: materialInfoDelayNanoseconds)
}
if let materialInfoError { throw materialInfoError } if let materialInfoError { throw materialInfoError }
return materialInfoResponse return materialInfoResponse
} }
@@ -1411,6 +2121,14 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
return aiRetouchTemplatesResponse return aiRetouchTemplatesResponse
} }
func updateAutoRetouchConfiguration(
_ request: TravelAlbumAutoRetouchConfigurationRequest
) async throws -> TravelAlbumAutoRetouchConfiguration {
autoRetouchConfigurationRequests.append(request)
if let updateAutoRetouchConfigurationError { throw updateAutoRetouchConfigurationError }
return autoRetouchConfigurationResponse
}
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission { func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
aiRetouchRequests.append(request) aiRetouchRequests.append(request)
if submitAIRetouchDelayNanoseconds > 0 { if submitAIRetouchDelayNanoseconds > 0 {