feat: 新增相册自动修图与OTG状态预览
支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。
This commit is contained in:
@@ -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 修图模板。
|
||||
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
|
||||
|
||||
/// 更新相册级自动 AI 修图配置。
|
||||
func updateAutoRetouchConfiguration(
|
||||
_ request: TravelAlbumAutoRetouchConfigurationRequest
|
||||
) async throws -> TravelAlbumAutoRetouchConfiguration
|
||||
|
||||
/// 提交相册素材 AI 修图任务。
|
||||
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 修图任务并返回任务摘要。
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws -> TravelAlbumAIJobSubmission {
|
||||
try await client.send(APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request))
|
||||
|
||||
@@ -5,6 +5,36 @@
|
||||
|
||||
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`。
|
||||
struct TravelAlbumUser: Decodable, Sendable, Equatable, Hashable {
|
||||
let id: Int
|
||||
@@ -33,6 +63,7 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
let user: TravelAlbumUser?
|
||||
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
@@ -50,6 +81,31 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
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(
|
||||
@@ -67,7 +123,8 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
status: Int = 0,
|
||||
createdAt: String = "",
|
||||
updatedAt: String = "",
|
||||
user: TravelAlbumUser? = nil
|
||||
user: TravelAlbumUser? = nil,
|
||||
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
|
||||
) {
|
||||
self.id = id
|
||||
self.storeUserId = storeUserId
|
||||
@@ -84,6 +141,7 @@ struct TravelAlbum: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.user = user
|
||||
self.autoRetouchConfiguration = autoRetouchConfiguration
|
||||
}
|
||||
|
||||
/// 展示用手机号。
|
||||
@@ -287,6 +345,7 @@ struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
|
||||
let materialPrice: Double?
|
||||
let materialPackagePrice: Double?
|
||||
let photoPrice: Double?
|
||||
let autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
@@ -296,6 +355,67 @@ struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
|
||||
case materialPrice = "material_price"
|
||||
case materialPackagePrice = "material_package_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 {
|
||||
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 atmosphereTemplateId: Int?
|
||||
let coverTemplateId: Int?
|
||||
let clientRequestId: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userEquityTravelId = "user_equity_travel_id"
|
||||
@@ -503,6 +646,24 @@ struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
|
||||
case refinedTemplateId = "refined_template_id"
|
||||
case atmosphereTemplateId = "atmosphere_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?
|
||||
let localPath: 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 {
|
||||
@@ -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 传输页半小时维度时间槽。
|
||||
struct TravelAlbumOTGTimeSlot: Hashable, Sendable {
|
||||
let id: String
|
||||
@@ -85,6 +102,14 @@ enum TravelAlbumOTGTransferMode: String, CaseIterable, Sendable {
|
||||
self == .liveUpload
|
||||
}
|
||||
|
||||
/// 模式选择 Sheet 的辅助说明。
|
||||
var detailText: String {
|
||||
switch self {
|
||||
case .liveUpload: return "相机拍摄后,照片自动传输并上传到当前相册"
|
||||
case .postTransfer: return "拍摄完成后,再选择照片批量传输"
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据展示标题解析传输模式。
|
||||
static func option(title: String) -> TravelAlbumOTGTransferMode? {
|
||||
allCases.first { $0.title == title }
|
||||
@@ -343,7 +368,14 @@ extension TravelAlbumOTGPhotoRecord {
|
||||
progress: progress,
|
||||
errorMessage: errorMessage,
|
||||
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 userId: 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
|
||||
|
||||
/// 创建 OTG 本地照片记录。
|
||||
@@ -74,6 +81,13 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||||
albumId: Int,
|
||||
userId: 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)
|
||||
) {
|
||||
self.id = id
|
||||
@@ -90,12 +104,22 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||||
self.albumId = albumId
|
||||
self.userId = userId
|
||||
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
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id, sourceId, clientPhotoId, fileName, localPath, thumbnailPath, capturedAt
|
||||
case fileSizeBytes, status, progress, errorMessage, albumId, userId, remoteUrl, updatedAt
|
||||
case serverMaterialId, autoRetouchState, autoRetouchTemplateId
|
||||
case autoRetouchClientRequestId, autoRetouchBatchId, autoRetouchAttempt
|
||||
case autoRetouchErrorMessage
|
||||
}
|
||||
|
||||
/// 解码本地索引;旧版本缺少 `clientPhotoId` 时先保留为空,由 Store 一次性迁移并回写。
|
||||
@@ -115,16 +139,34 @@ struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable {
|
||||
albumId = try container.decode(Int.self, forKey: .albumId)
|
||||
userId = try container.decode(String.self, forKey: .userId)
|
||||
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
|
||||
}
|
||||
|
||||
/// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。
|
||||
func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord {
|
||||
guard status == .transferring || status == .uploading else { return self }
|
||||
var copy = self
|
||||
if status == .transferring || status == .uploading {
|
||||
copy.status = .pending
|
||||
copy.progress = 0
|
||||
copy.errorMessage = nil
|
||||
}
|
||||
if autoRetouchState == .submitting {
|
||||
copy.autoRetouchState = .pendingSubmission
|
||||
}
|
||||
guard copy != self else { return self }
|
||||
copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
return copy
|
||||
}
|
||||
|
||||
+105
@@ -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,
|
||||
singlePrice: String,
|
||||
packagePrice: String,
|
||||
autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
|
||||
order: TravelAlbumAvailableOrder?,
|
||||
api: any TravelAlbumServing
|
||||
) async {
|
||||
@@ -143,6 +144,10 @@ final class TravelAlbumEntryViewModel {
|
||||
onShowMessage?("请输入有效的单张照片价格")
|
||||
return
|
||||
}
|
||||
if autoRetouchConfiguration.enabled && !autoRetouchConfiguration.isValid {
|
||||
onShowMessage?("请选择有效的 AI 修图模板")
|
||||
return
|
||||
}
|
||||
|
||||
let albumName: String
|
||||
switch mode {
|
||||
@@ -162,7 +167,8 @@ final class TravelAlbumEntryViewModel {
|
||||
materialNum: Int(freeCount) ?? 0,
|
||||
materialPrice: materialPrice,
|
||||
materialPackagePrice: Double(packagePrice) ?? 0,
|
||||
photoPrice: 0
|
||||
photoPrice: 0,
|
||||
autoRetouchConfiguration: autoRetouchConfiguration
|
||||
)
|
||||
case .preOrder:
|
||||
request = TravelAlbumCreateRequest(
|
||||
@@ -172,7 +178,8 @@ final class TravelAlbumEntryViewModel {
|
||||
materialNum: nil,
|
||||
materialPrice: 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 连接、照片导入、本地缓存与上传登记。
|
||||
@MainActor
|
||||
final class WiredCameraTransferViewModel {
|
||||
@@ -63,7 +79,12 @@ final class WiredCameraTransferViewModel {
|
||||
private(set) var sonyMTPHint: String?
|
||||
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 transferMode: TravelAlbumOTGTransferMode = .liveUpload {
|
||||
didSet { notifyStateChanged() }
|
||||
@@ -87,6 +108,8 @@ final class WiredCameraTransferViewModel {
|
||||
private var queuedAutoUploadPhotoIds: Set<String> = []
|
||||
private var suppressSelectedTimeSlotNotification = false
|
||||
private var serverStatusSyncTask: Task<Void, Never>?
|
||||
private var configurationSyncTask: Task<Void, Never>?
|
||||
private var autoRetouchPollingTask: Task<Void, Never>?
|
||||
|
||||
@MainActor
|
||||
init(
|
||||
@@ -94,6 +117,8 @@ final class WiredCameraTransferViewModel {
|
||||
albumTitle: String,
|
||||
headerPhone: String,
|
||||
scenicSpotLabel: String? = nil,
|
||||
initialTransferMode: TravelAlbumOTGTransferMode? = nil,
|
||||
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled,
|
||||
connectionManager: (any WiredCameraConnectionManaging)? = nil,
|
||||
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
|
||||
uploader: (any TravelAlbumOTGUploading)? = nil,
|
||||
@@ -112,7 +137,11 @@ final class WiredCameraTransferViewModel {
|
||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||
self.appStore = appStore
|
||||
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
|
||||
}
|
||||
|
||||
/// 自动修图设置 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] {
|
||||
TravelAlbumOTGSpecifyUploadOption.allCases
|
||||
@@ -230,6 +293,9 @@ final class WiredCameraTransferViewModel {
|
||||
syncFromConnectionManager()
|
||||
loadPersistedPhotos()
|
||||
syncServerUploadStatuses()
|
||||
syncAutoRetouchConfiguration()
|
||||
resumePendingAutoRetouches()
|
||||
updateAutoRetouchPolling()
|
||||
connectionManager.start()
|
||||
}
|
||||
|
||||
@@ -237,10 +303,29 @@ final class WiredCameraTransferViewModel {
|
||||
func stop() {
|
||||
serverStatusSyncTask?.cancel()
|
||||
serverStatusSyncTask = nil
|
||||
configurationSyncTask?.cancel()
|
||||
configurationSyncTask = nil
|
||||
autoRetouchPollingTask?.cancel()
|
||||
autoRetouchPollingTask = nil
|
||||
connectionManager.unbindDelegate()
|
||||
connectionManager.suspendLiveTransfer()
|
||||
}
|
||||
|
||||
/// App 进入后台时停止配置请求和任务轮询,保留待恢复状态。
|
||||
func applicationDidEnterBackground() {
|
||||
configurationSyncTask?.cancel()
|
||||
configurationSyncTask = nil
|
||||
autoRetouchPollingTask?.cancel()
|
||||
autoRetouchPollingTask = nil
|
||||
}
|
||||
|
||||
/// App 回到前台时同步跨设备配置并恢复自动修图任务。
|
||||
func applicationDidBecomeActive() {
|
||||
syncAutoRetouchConfiguration()
|
||||
resumePendingAutoRetouches()
|
||||
updateAutoRetouchPolling()
|
||||
}
|
||||
|
||||
/// 主动断开相机连接。
|
||||
func disconnect() {
|
||||
connectionManager.disconnect()
|
||||
@@ -301,6 +386,50 @@ final class WiredCameraTransferViewModel {
|
||||
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) {
|
||||
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() {
|
||||
let persistedItems = persistedRecordsById.values
|
||||
.map { $0.toPhotoItem(storage: storage, albumId: albumId) }
|
||||
@@ -588,6 +877,7 @@ final class WiredCameraTransferViewModel {
|
||||
}
|
||||
|
||||
private func uploadPhoto(id: String) async {
|
||||
let retouchSnapshot = autoRetouchConfiguration
|
||||
do {
|
||||
let record = try await localRecordForUpload(id: id)
|
||||
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
|
||||
@@ -597,13 +887,44 @@ final class WiredCameraTransferViewModel {
|
||||
) { [weak self] progress in
|
||||
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 {
|
||||
updateRecord(id: id, status: .failed, progress: 0, error: 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 {
|
||||
if let record = persistedRecordsById[id],
|
||||
!record.localPath.isEmpty,
|
||||
@@ -653,6 +974,14 @@ final class WiredCameraTransferViewModel {
|
||||
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() {
|
||||
if Thread.isMainThread {
|
||||
onStateChange?()
|
||||
|
||||
@@ -22,8 +22,14 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
private let freeCountField = UITextField()
|
||||
private let singlePriceField = 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 confirmButton = UIButton(type: .system)
|
||||
private var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
|
||||
|
||||
init(viewModel: TravelAlbumEntryViewModel, api: any TravelAlbumServing) {
|
||||
self.viewModel = viewModel
|
||||
@@ -31,8 +37,14 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .pageSheet
|
||||
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.preferredCornerRadius = 22
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +74,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
configureTextField(freeCountField, placeholder: "请输入免费张数", keyboardType: .numberPad)
|
||||
configureTextField(singlePriceField, placeholder: "请输入单张照片价格", keyboardType: .decimalPad)
|
||||
configureTextField(packagePriceField, placeholder: "请输入打包价格", keyboardType: .decimalPad)
|
||||
configureAutoRetouchSection()
|
||||
|
||||
configureActionButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xF4F4F4), titleColor: AppColor.textSecondary)
|
||||
configureActionButton(confirmButton, title: "确定", backgroundColor: AppColor.primary, titleColor: .white)
|
||||
@@ -112,6 +125,8 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
[freeCountField, singlePriceField, packagePriceField].forEach {
|
||||
$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) {
|
||||
@@ -148,6 +163,42 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "免费张数", required: false, field: freeCountField))
|
||||
fieldsStack.addArrangedSubview(makeFieldGroup(title: "单张照片价格(元)", required: true, field: singlePriceField))
|
||||
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 {
|
||||
@@ -188,6 +239,7 @@ final class CreateTravelAlbumSheetViewController: BaseViewController {
|
||||
freeCount: freeCountField.text ?? "",
|
||||
singlePrice: singlePriceField.text ?? "",
|
||||
packagePrice: packagePriceField.text ?? "",
|
||||
autoRetouchConfiguration: autoRetouchConfiguration,
|
||||
order: nil,
|
||||
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) {
|
||||
let text = field.text ?? ""
|
||||
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() {
|
||||
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
|
||||
refreshState.markRefreshNeeded()
|
||||
let controller = WiredCameraTransferViewController(
|
||||
viewModel: WiredCameraTransferViewModel(
|
||||
albumId: album?.id ?? viewModel.albumId,
|
||||
albumTitle: album?.name ?? "",
|
||||
headerPhone: album?.displayPhone ?? ""
|
||||
headerPhone: album?.displayPhone ?? "",
|
||||
initialTransferMode: mode,
|
||||
initialAutoRetouchConfiguration: album?.autoRetouchConfiguration ?? .disabled,
|
||||
api: api
|
||||
)
|
||||
)
|
||||
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 helpLabel = UILabel()
|
||||
private let chipsStack = UIStackView()
|
||||
private let retouchButton = WiredTransferSettingChipButton()
|
||||
private let retouchButton = WiredTransferSettingChipButton(showsChevron: true)
|
||||
private let formatButton = WiredTransferSettingChipButton()
|
||||
private let modeButton = WiredTransferSettingChipButton(showsChevron: true)
|
||||
private let settingsStatsDivider = UIView()
|
||||
@@ -71,6 +71,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
private let specifyButton = UIButton(type: .system)
|
||||
private var isHistoryImportButtonVisible = false
|
||||
private var previousNavigationBarStyle: (tintColor: UIColor?, barStyle: UIBarStyle, isTranslucent: Bool)?
|
||||
private var previewLoadingTask: Task<Void, Never>?
|
||||
|
||||
init(viewModel: WiredCameraTransferViewModel) {
|
||||
self.viewModel = viewModel
|
||||
@@ -91,13 +92,64 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
titleStack.alignment = .center
|
||||
titleStack.spacing = 1
|
||||
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() {
|
||||
super.viewDidLoad()
|
||||
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) {
|
||||
super.viewWillAppear(animated)
|
||||
applyNavigationBarStyle()
|
||||
@@ -107,6 +159,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
previewLoadingTask?.cancel()
|
||||
viewModel.stop()
|
||||
restoreNavigationBarStyle()
|
||||
}
|
||||
@@ -161,7 +214,6 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
[retouchButton, formatButton, modeButton].forEach {
|
||||
chipsStack.addArrangedSubview($0)
|
||||
}
|
||||
retouchButton.isUserInteractionEnabled = false
|
||||
formatButton.isUserInteractionEnabled = false
|
||||
settingsStatsDivider.backgroundColor = AppColor.border
|
||||
|
||||
@@ -189,6 +241,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
|
||||
collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||
collectionView.backgroundColor = .white
|
||||
collectionView.accessibilityIdentifier = "wiredTransfer.photoCollectionView"
|
||||
collectionView.delegate = self
|
||||
collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 8, right: 0)
|
||||
collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier)
|
||||
@@ -387,6 +440,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
|
||||
retouchButton.addTarget(self, action: #selector(retouchTapped), for: .touchUpInside)
|
||||
batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside)
|
||||
historyImportButton.addTarget(self, action: #selector(historyImportTapped), 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)
|
||||
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")
|
||||
modeButton.apply(title: viewModel.transferModeOption)
|
||||
helpLabel.attributedText = helpText(viewModel.sonyMTPHint)
|
||||
@@ -567,6 +622,7 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
let selected = viewModel.selectedPhotoIds.contains(item.id)
|
||||
cell.apply(item: item, selectionMode: viewModel.selectUploadMode, selected: selected)
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -711,6 +767,39 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
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() {
|
||||
viewModel.onBatchUploadButtonClick()
|
||||
}
|
||||
@@ -788,6 +877,11 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
}
|
||||
|
||||
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 {
|
||||
showToast("暂无可预览图片")
|
||||
return
|
||||
@@ -797,6 +891,39 @@ final class WiredCameraTransferViewController: BaseViewController {
|
||||
: MediaPreviewItem(source: .remoteImage(url))
|
||||
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 {
|
||||
@@ -1111,12 +1238,13 @@ private final class WiredTransferSectionHeaderView: UICollectionReusableView {
|
||||
}
|
||||
|
||||
/// 有线传输照片列表 Cell。
|
||||
private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "WiredTransferPhotoCell"
|
||||
private static let previewImageSize = CGSize(width: 96, height: 96)
|
||||
|
||||
private let selectionIconView = UIImageView()
|
||||
private let imageView = UIImageView()
|
||||
private let retouchBadgeLabel = UILabel()
|
||||
private let statusLabel = UILabel()
|
||||
private let titleLabel = UILabel()
|
||||
private let sizeLabel = UILabel()
|
||||
@@ -1125,6 +1253,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
private let separatorView = UIView()
|
||||
|
||||
var onRetry: (() -> Void)?
|
||||
var onRetryRetouch: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@@ -1142,10 +1271,12 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
imageView.kf.cancelDownloadTask()
|
||||
imageView.image = nil
|
||||
onRetry = nil
|
||||
onRetryRetouch = nil
|
||||
onDelete = nil
|
||||
menuButton.menu = nil
|
||||
}
|
||||
|
||||
/// 分别渲染原图上传进度与自动修图角标,修图状态不参与进度计算。
|
||||
func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) {
|
||||
let canSelect = item.canSelectForUpload
|
||||
let rowAlpha: CGFloat = selectionMode && !canSelect ? 0.45 : 1
|
||||
@@ -1172,22 +1303,64 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
statusLabel.text = statusText(item.status)
|
||||
statusLabel.textColor = statusTextColor(item.status)
|
||||
statusLabel.backgroundColor = statusBackgroundColor(item.status)
|
||||
applyRetouchBadge(state: item.autoRetouchState)
|
||||
progressView.isHidden = item.status != .uploading && item.status != .transferring
|
||||
progressView.progress = Float(item.progress) / 100.0
|
||||
selectionIconView.isHidden = !selectionMode
|
||||
selectionIconView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
selectionIconView.tintColor = canSelect ? (selected ? AppColor.primary : AppColor.textTertiary) : AppColor.textTertiary.withAlphaComponent(0.5)
|
||||
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)
|
||||
}
|
||||
|
||||
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() {
|
||||
contentView.backgroundColor = .white
|
||||
selectionIconView.contentMode = .scaleAspectFit
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
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.textAlignment = .center
|
||||
statusLabel.layer.cornerRadius = 3
|
||||
@@ -1203,6 +1376,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
sizeLabel.textColor = AppColor.textTertiary
|
||||
progressView.progressTintColor = AppColor.primary
|
||||
progressView.trackTintColor = AppColor.border
|
||||
progressView.accessibilityIdentifier = "wiredTransfer.uploadProgress"
|
||||
menuButton.setImage(UIImage(systemName: "ellipsis"), for: .normal)
|
||||
menuButton.tintColor = AppColor.textSecondary
|
||||
menuButton.showsMenuAsPrimaryAction = true
|
||||
@@ -1210,6 +1384,7 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
|
||||
contentView.addSubview(selectionIconView)
|
||||
contentView.addSubview(imageView)
|
||||
contentView.addSubview(retouchBadgeLabel)
|
||||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(statusLabel)
|
||||
contentView.addSubview(sizeLabel)
|
||||
@@ -1223,6 +1398,11 @@ private final class WiredTransferPhotoCell: UICollectionViewCell {
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
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
|
||||
make.top.equalTo(imageView).offset(1)
|
||||
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
|
||||
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
|
||||
self?.onDelete?()
|
||||
}
|
||||
return UIMenu(children: [retry, delete])
|
||||
return UIMenu(children: [retry, retryRetouch, delete])
|
||||
}
|
||||
|
||||
private func updateImageConstraints(selectionMode: Bool) {
|
||||
|
||||
@@ -36,7 +36,11 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
materialNum: 2,
|
||||
materialPrice: 10.5,
|
||||
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?["material_num"] as? Int, 2)
|
||||
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 {
|
||||
@@ -192,7 +250,8 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
materialIds: [11, 12, 13, 14],
|
||||
refinedTemplateId: 21,
|
||||
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?["atmosphere_template_id"] as? Int, 22)
|
||||
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 } ?? []), [
|
||||
"user_equity_travel_id",
|
||||
"material_ids",
|
||||
"refined_template_id",
|
||||
"atmosphere_template_id",
|
||||
"cover_template_id",
|
||||
"client_request_id",
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -260,7 +260,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
XCTAssertFalse(GlobalLoadingManager.shared.isShowing)
|
||||
}
|
||||
|
||||
func testAIJobEntryOnlyAppearsOnAlbumManagementNavigationBar() {
|
||||
func testAlbumManagementShowsAIJobEntryButAlbumEntryDoesNot() {
|
||||
let detail = TravelAlbumDetailViewController(albumId: 2, api: TravelAlbumMockAPI())
|
||||
detail.setupNavigationBar()
|
||||
let entry = TravelAlbumEntryViewController(api: TravelAlbumMockAPI())
|
||||
@@ -1394,6 +1394,111 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
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 {
|
||||
for _ in 0 ..< 200 {
|
||||
if condition() { return }
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import XCTest
|
||||
@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 测试。
|
||||
@MainActor
|
||||
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() {
|
||||
let viewModel = WiredCameraTransferViewModel(
|
||||
albumId: 1,
|
||||
@@ -837,6 +901,635 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||
|
||||
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 测试。
|
||||
@@ -1037,13 +1730,15 @@ private func makeWiredViewModel(
|
||||
manager: MockWiredCameraConnectionManager,
|
||||
uploader: MockTravelAlbumOTGUploader? = nil,
|
||||
api: TravelAlbumMockAPI? = nil,
|
||||
userDefaults: UserDefaults = makeOTGTestDefaults()
|
||||
userDefaults: UserDefaults = makeOTGTestDefaults(),
|
||||
initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled
|
||||
) -> WiredCameraTransferViewModel {
|
||||
prepareAppStoreForOTGTests()
|
||||
return WiredCameraTransferViewModel(
|
||||
albumId: albumId,
|
||||
albumTitle: "相册",
|
||||
headerPhone: "13800138000",
|
||||
initialAutoRetouchConfiguration: initialAutoRetouchConfiguration,
|
||||
connectionManager: manager,
|
||||
storage: context.store,
|
||||
uploader: uploader ?? MockTravelAlbumOTGUploader(),
|
||||
@@ -1248,6 +1943,8 @@ private final class MockCameraDriver: CameraDriver {
|
||||
private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
||||
private(set) var uploadCallCount = 0
|
||||
private(set) var uploadedRecordIds: [String] = []
|
||||
var materialIds: [Int] = []
|
||||
var error: Error?
|
||||
|
||||
func upload(
|
||||
record: TravelAlbumOTGPhotoRecord,
|
||||
@@ -1256,7 +1953,13 @@ private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading {
|
||||
) async throws -> TravelAlbumMaterial {
|
||||
uploadCallCount += 1
|
||||
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 materialInfoResponse = TravelAlbumMaterial()
|
||||
var materialInfoError: Error?
|
||||
var materialInfoDelayNanoseconds: UInt64 = 0
|
||||
var uploadMaterialResponse = TravelAlbumMaterial()
|
||||
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
|
||||
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
|
||||
var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse()
|
||||
var autoRetouchConfigurationResponse: TravelAlbumAutoRetouchConfiguration = .disabled
|
||||
var updateAutoRetouchConfigurationError: Error?
|
||||
var createError: Error?
|
||||
var aiRetouchTemplatesError: Error?
|
||||
var submitAIRetouchError: Error?
|
||||
@@ -1324,6 +2030,7 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
private(set) var deletedMaterialIDBatches: [[Int]] = []
|
||||
private(set) var aiRetouchTemplateScenicIds: [Int] = []
|
||||
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
|
||||
private(set) var autoRetouchConfigurationRequests: [TravelAlbumAutoRetouchConfigurationRequest] = []
|
||||
private(set) var aiReretouchRequests: [TravelAlbumAIReretouchRequest] = []
|
||||
private(set) var aiJobListRequests: [AIJobListRequest] = []
|
||||
|
||||
@@ -1375,6 +2082,9 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
materialInfoRequests.append(
|
||||
MaterialInfoRequest(userEquityTravelId: userEquityTravelId, materialId: materialId)
|
||||
)
|
||||
if materialInfoDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: materialInfoDelayNanoseconds)
|
||||
}
|
||||
if let materialInfoError { throw materialInfoError }
|
||||
return materialInfoResponse
|
||||
}
|
||||
@@ -1411,6 +2121,14 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
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 {
|
||||
aiRetouchRequests.append(request)
|
||||
if submitAIRetouchDelayNanoseconds > 0 {
|
||||
|
||||
Reference in New Issue
Block a user