From d04641b623856a7019c54aa5d35b0b28adf4299d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Thu, 27 Aug 2026 16:03:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=9B=B8=E5=86=8C?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E4=BF=AE=E5=9B=BE=E4=B8=8EOTG=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持相册修图配置、模板选择和传输模式选择;上传后自动提交修图并展示状态角标及精修预览。补充接口文档与相关测试。 --- .../AI自动修图后端接口改造精简版.md | 92 +++ .../TravelAlbum/AI自动修图接口补充.md | 52 ++ .../TravelAlbum/API/TravelAlbumAPI.swift | 14 + .../Models/TravelAlbumModels.swift | 163 +++- .../Models/TravelAlbumOTGTransferModels.swift | 34 +- .../Storage/TravelAlbumOTGPhotoStore.swift | 50 +- ...avelAlbumAutoRetouchSettingViewModel.swift | 105 +++ .../TravelAlbumEntryViewModel.swift | 11 +- .../WiredCameraTransferViewModel.swift | 335 +++++++- ...CreateTravelAlbumSheetViewController.swift | 90 ++- ...utoRetouchSettingSheetViewController.swift | 357 +++++++++ .../TravelAlbumDetailViewController.swift | 14 +- ...AlbumTransferModeSheetViewController.swift | 138 ++++ .../WiredCameraTransferViewController.swift | 200 ++++- suixinkanTests/TravelAlbumAPITests.swift | 65 +- ...TravelAlbumDetailViewControllerTests.swift | 107 ++- .../TravelAlbumViewModelTests.swift | 722 +++++++++++++++++- 17 files changed, 2523 insertions(+), 26 deletions(-) create mode 100644 suixinkan/Features/TravelAlbum/AI自动修图后端接口改造精简版.md create mode 100644 suixinkan/Features/TravelAlbum/AI自动修图接口补充.md create mode 100644 suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAutoRetouchSettingViewModel.swift create mode 100644 suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift create mode 100644 suixinkan/UI/TravelAlbum/TravelAlbumTransferModeSheetViewController.swift diff --git a/suixinkan/Features/TravelAlbum/AI自动修图后端接口改造精简版.md b/suixinkan/Features/TravelAlbum/AI自动修图后端接口改造精简版.md new file mode 100644 index 0000000..4610c42 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/AI自动修图后端接口改造精简版.md @@ -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 任务失败或额度不足时,原图上传结果保持成功状态。 diff --git a/suixinkan/Features/TravelAlbum/AI自动修图接口补充.md b/suixinkan/Features/TravelAlbum/AI自动修图接口补充.md new file mode 100644 index 0000000..9ba0a17 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/AI自动修图接口补充.md @@ -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`,不展示模板描述,也不自动生成氛围感或封面。 diff --git a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift index 0564e04..70e47df 100644 --- a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift +++ b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift @@ -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)) diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift index eb8db99..cbb46d6 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -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: 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 } } diff --git a/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift index de1aa1d..67f4f4c 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift @@ -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 ) } } diff --git a/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift index 1d9845b..22b2713 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift @@ -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 - copy.status = .pending - copy.progress = 0 - copy.errorMessage = nil + 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 } diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAutoRetouchSettingViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAutoRetouchSettingViewModel.swift new file mode 100644 index 0000000..94d6787 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAutoRetouchSettingViewModel.swift @@ -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?() } +} diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift index f77858e..7d7c833 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumEntryViewModel.swift @@ -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 ) } diff --git a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift index 79f1154..b8f1e6a 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift @@ -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 = [] private var suppressSelectedTimeSlotNotification = false private var serverStatusSyncTask: Task? + private var configurationSyncTask: Task? + private var autoRetouchPollingTask: Task? @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?() diff --git a/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift b/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift index 6136835..e3a18fe 100644 --- a/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift +++ b/suixinkan/UI/TravelAlbum/CreateTravelAlbumSheetViewController.swift @@ -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 { diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift new file mode 100644 index 0000000..c401680 --- /dev/null +++ b/suixinkan/UI/TravelAlbum/TravelAlbumAutoRetouchSettingSheetViewController.swift @@ -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! + 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(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() + 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 + } +} diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift index dea84ef..c0d9039 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift @@ -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) diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumTransferModeSheetViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumTransferModeSheetViewController.swift new file mode 100644 index 0000000..f6a704c --- /dev/null +++ b/suixinkan/UI/TravelAlbum/TravelAlbumTransferModeSheetViewController.swift @@ -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) } +} diff --git a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift index e6c2f68..ef95711 100644 --- a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift +++ b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift @@ -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? 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) { diff --git a/suixinkanTests/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbumAPITests.swift index c6dc4c1..4f00b28 100644 --- a/suixinkanTests/TravelAlbumAPITests.swift +++ b/suixinkanTests/TravelAlbumAPITests.swift @@ -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", ]) } diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift index b63939f..057652e 100644 --- a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift +++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift @@ -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 } diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index 79a16aa..8b6fce8 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -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 = [] 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 {