feat: add AI retouch and album preview actions
This commit is contained in:
@@ -43,6 +43,12 @@ protocol TravelAlbumServing {
|
||||
|
||||
/// 拉取相册小程序码。
|
||||
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse
|
||||
|
||||
/// 拉取当前景区可用的 AI 修图模板。
|
||||
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse
|
||||
|
||||
/// 提交相册素材 AI 修图任务。
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -155,6 +161,24 @@ final class TravelAlbumAPI: TravelAlbumServing {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取当前景区可用的 AI 修图模板。
|
||||
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "\(basePath)/ai-retouch-templates",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交相册素材 AI 修图任务;服务端 data 内容无需客户端消费。
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "\(basePath)/ai-retouch", body: request)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册 ID 请求体。
|
||||
|
||||
@@ -127,6 +127,10 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
||||
let fileSize: Int
|
||||
let coverUrl: String
|
||||
let isPurchased: Bool
|
||||
let aiRetouchStatus: Int
|
||||
let aiRetouchStatusName: String
|
||||
let aiRefinedURL: String
|
||||
let aiAtmosphereURL: String
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
|
||||
@@ -142,10 +146,40 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
||||
case fileSize = "file_size"
|
||||
case coverUrl = "cover_url"
|
||||
case isPurchased = "is_purchased"
|
||||
case aiRetouchStatus = "ai_retouch_status"
|
||||
case aiRetouchStatusName = "ai_retouch_status_name"
|
||||
case aiRefinedURL = "ai_refined_url"
|
||||
case aiAtmosphereURL = "ai_atmosphere_url"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
/// 从素材接口解码;AI 修图扩展字段缺失、为空或类型异常时使用安全默认值。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(Int.self, forKey: .id)
|
||||
userEquityTravelId = try container.decode(Int.self, forKey: .userEquityTravelId)
|
||||
status = try container.decode(Int.self, forKey: .status)
|
||||
orderNumber = try container.decode(String.self, forKey: .orderNumber)
|
||||
userId = try container.decode(Int.self, forKey: .userId)
|
||||
fileName = try container.decode(String.self, forKey: .fileName)
|
||||
fileType = try container.decode(Int.self, forKey: .fileType)
|
||||
fileUrl = try container.decode(String.self, forKey: .fileUrl)
|
||||
fileSize = try container.decode(Int.self, forKey: .fileSize)
|
||||
coverUrl = try container.decode(String.self, forKey: .coverUrl)
|
||||
isPurchased = try container.decode(Bool.self, forKey: .isPurchased)
|
||||
aiRetouchStatus = (try? container.decodeIfPresent(Int.self, forKey: .aiRetouchStatus)) ?? 0
|
||||
aiRetouchStatusName = (
|
||||
try? container.decodeIfPresent(String.self, forKey: .aiRetouchStatusName)
|
||||
) ?? ""
|
||||
aiRefinedURL = (try? container.decodeIfPresent(String.self, forKey: .aiRefinedURL)) ?? ""
|
||||
aiAtmosphereURL = (
|
||||
try? container.decodeIfPresent(String.self, forKey: .aiAtmosphereURL)
|
||||
) ?? ""
|
||||
createdAt = try container.decode(String.self, forKey: .createdAt)
|
||||
updatedAt = try container.decode(String.self, forKey: .updatedAt)
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
userEquityTravelId: Int = 0,
|
||||
@@ -158,6 +192,10 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
||||
fileSize: Int = 0,
|
||||
coverUrl: String = "",
|
||||
isPurchased: Bool = false,
|
||||
aiRetouchStatus: Int = 0,
|
||||
aiRetouchStatusName: String = "",
|
||||
aiRefinedURL: String = "",
|
||||
aiAtmosphereURL: String = "",
|
||||
createdAt: String = "",
|
||||
updatedAt: String = ""
|
||||
) {
|
||||
@@ -172,11 +210,68 @@ struct TravelAlbumMaterial: Decodable, Sendable, Equatable, Hashable, Identifiab
|
||||
self.fileSize = fileSize
|
||||
self.coverUrl = coverUrl
|
||||
self.isPurchased = isPurchased
|
||||
self.aiRetouchStatus = aiRetouchStatus
|
||||
self.aiRetouchStatusName = aiRetouchStatusName
|
||||
self.aiRefinedURL = aiRefinedURL
|
||||
self.aiAtmosphereURL = aiAtmosphereURL
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
/// 相册素材网格角标类别,用于稳定映射文案优先级和语义颜色。
|
||||
enum TravelAlbumMaterialBadgeKind: Sendable, Equatable {
|
||||
case purchased
|
||||
case pending
|
||||
case processing
|
||||
case retouched
|
||||
case cover
|
||||
case failed
|
||||
}
|
||||
|
||||
/// 相册素材网格角标展示内容。
|
||||
struct TravelAlbumMaterialBadgePresentation: Sendable, Equatable {
|
||||
let kind: TravelAlbumMaterialBadgeKind
|
||||
let text: String
|
||||
}
|
||||
|
||||
extension TravelAlbumMaterial {
|
||||
/// 按 AI 修图状态和购买状态生成网格角标;返回 nil 时隐藏角标。
|
||||
var badgePresentation: TravelAlbumMaterialBadgePresentation? {
|
||||
if aiRetouchStatus == 0 {
|
||||
return isPurchased
|
||||
? TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||
: nil
|
||||
}
|
||||
|
||||
let statusName = aiRetouchStatusName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
switch aiRetouchStatus {
|
||||
case 1:
|
||||
return TravelAlbumMaterialBadgePresentation(
|
||||
kind: .pending,
|
||||
text: statusName.isEmpty ? "待处理" : statusName
|
||||
)
|
||||
case 2:
|
||||
return TravelAlbumMaterialBadgePresentation(
|
||||
kind: .processing,
|
||||
text: statusName.isEmpty ? "修图中" : statusName
|
||||
)
|
||||
case 3:
|
||||
return TravelAlbumMaterialBadgePresentation(
|
||||
kind: statusName == "AI封面" ? .cover : .retouched,
|
||||
text: statusName.isEmpty ? "AI已修" : statusName
|
||||
)
|
||||
case 4:
|
||||
return TravelAlbumMaterialBadgePresentation(
|
||||
kind: .failed,
|
||||
text: statusName.isEmpty ? "失败" : statusName
|
||||
)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册创建请求体,对齐 Android `TravelAlbumCreateRequest`。
|
||||
struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable {
|
||||
let name: String
|
||||
@@ -247,6 +342,67 @@ struct TravelAlbumMpCodeResponse: Decodable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板,包含业务 ID、展示名称和预览图地址。
|
||||
struct TravelAlbumAIRetouchTemplate: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let previewURL: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case previewURL = "preview_url"
|
||||
}
|
||||
|
||||
/// 创建 AI 修图模板。
|
||||
init(id: Int, name: String, previewURL: String) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.previewURL = previewURL
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板接口响应,按原图、氛围感和封面风格分组。
|
||||
struct TravelAlbumAIRetouchTemplatesResponse: Decodable, Sendable, Equatable {
|
||||
let refinedTemplates: [TravelAlbumAIRetouchTemplate]
|
||||
let atmosphereTemplates: [TravelAlbumAIRetouchTemplate]
|
||||
let coverTemplates: [TravelAlbumAIRetouchTemplate]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case refinedTemplates = "refined_templates"
|
||||
case atmosphereTemplates = "atmosphere_templates"
|
||||
case coverTemplates = "cover_templates"
|
||||
}
|
||||
|
||||
/// 创建分组模板响应,默认各组为空。
|
||||
init(
|
||||
refinedTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
||||
atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = [],
|
||||
coverTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||
) {
|
||||
self.refinedTemplates = refinedTemplates
|
||||
self.atmosphereTemplates = atmosphereTemplates
|
||||
self.coverTemplates = coverTemplates
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交 AI 修图任务的请求参数。
|
||||
struct TravelAlbumAIRetouchRequest: Encodable, Sendable, Equatable {
|
||||
let userEquityTravelId: Int
|
||||
let materialIds: [Int]
|
||||
let refinedTemplateId: Int
|
||||
let atmosphereTemplateId: Int?
|
||||
let aiPreviewTabs: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userEquityTravelId = "user_equity_travel_id"
|
||||
case materialIds = "material_ids"
|
||||
case refinedTemplateId = "refined_template_id"
|
||||
case atmosphereTemplateId = "atmosphere_template_id"
|
||||
case aiPreviewTabs = "ai_preview_tabs"
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册展示格式化工具。
|
||||
enum TravelAlbumDisplayFormatter {
|
||||
/// 脱敏手机号。
|
||||
|
||||
@@ -84,10 +84,10 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||
assets.first { $0.kind == kind }
|
||||
}
|
||||
|
||||
/// 将当前素材映射为仅含原图的预览项目;新接口接入后替换此适配层即可。
|
||||
/// 将素材映射为原图及实际存在的 AI 精修、氛围感结果图。
|
||||
init(material: TravelAlbumMaterial) {
|
||||
originalMaterialId = material.id
|
||||
assets = [
|
||||
var mappedAssets = [
|
||||
TravelAlbumPreviewAsset(
|
||||
id: "original-\(material.id)",
|
||||
kind: .original,
|
||||
@@ -97,6 +97,33 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable {
|
||||
fileSize: material.fileSize
|
||||
),
|
||||
]
|
||||
let refinedURL = material.aiRefinedURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !refinedURL.isEmpty {
|
||||
mappedAssets.append(
|
||||
TravelAlbumPreviewAsset(
|
||||
id: "retouched-\(material.id)",
|
||||
kind: .retouched,
|
||||
fileURL: refinedURL,
|
||||
coverURL: refinedURL,
|
||||
fileName: material.fileName,
|
||||
fileSize: material.fileSize
|
||||
)
|
||||
)
|
||||
}
|
||||
let atmosphereURL = material.aiAtmosphereURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !atmosphereURL.isEmpty {
|
||||
mappedAssets.append(
|
||||
TravelAlbumPreviewAsset(
|
||||
id: "atmosphere-\(material.id)",
|
||||
kind: .atmosphere,
|
||||
fileURL: atmosphereURL,
|
||||
coverURL: atmosphereURL,
|
||||
fileName: material.fileName,
|
||||
fileSize: material.fileSize
|
||||
)
|
||||
)
|
||||
}
|
||||
assets = mappedAssets
|
||||
}
|
||||
|
||||
/// 创建包含关联图片的项目,主要供适配器和测试使用。
|
||||
@@ -142,6 +169,15 @@ enum TravelAlbumPreviewNavigator {
|
||||
$0.projectIndex == current.projectIndex - 1 && $0.kind == .original
|
||||
} ?? currentIndex - 1
|
||||
}
|
||||
|
||||
/// 返回删除当前项目后应展示的项目索引;优先保持原索引以显示下一张,末项则回退上一张。
|
||||
static func projectIndexAfterDeletion(
|
||||
deletedProjectIndex: Int,
|
||||
remainingProjectCount: Int
|
||||
) -> Int? {
|
||||
guard remainingProjectCount > 0 else { return nil }
|
||||
return min(max(0, deletedProjectIndex), remainingProjectCount - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览操作执行结果,便于后续替换真实业务接口。
|
||||
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
//
|
||||
// TravelAlbumAIRetouchTemplateViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// AI 修图模板类别,决定页面分组、选择规则和提交字段。
|
||||
enum TravelAlbumAIRetouchTemplateCategory: Int, Sendable, Hashable {
|
||||
case refined
|
||||
case atmosphere
|
||||
case cover
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .refined: "原图精修"
|
||||
case .atmosphere: "氛围感修图"
|
||||
case .cover: "封面风格模板"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板选择状态,负责实时加载、单选规则、校验和任务提交。
|
||||
final class TravelAlbumAIRetouchTemplateViewModel {
|
||||
private(set) var refinedTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||
private(set) var atmosphereTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||
private(set) var coverTemplates: [TravelAlbumAIRetouchTemplate] = []
|
||||
private(set) var selectedRefinedTemplateId: Int?
|
||||
private(set) var selectedAtmosphereTemplateId: Int?
|
||||
private(set) var selectedCoverTemplateId: Int?
|
||||
private(set) var isLoading = false
|
||||
private(set) var isSubmitting = false
|
||||
private(set) var loadErrorMessage: String?
|
||||
|
||||
let albumId: Int
|
||||
let scenicId: Int
|
||||
let materialIds: [Int]
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onSubmitted: (() -> Void)?
|
||||
|
||||
/// 创建模板选择状态;素材 ID 会排序并去重,确保提交稳定。
|
||||
init(albumId: Int, scenicId: Int, materialIds: [Int]) {
|
||||
self.albumId = albumId
|
||||
self.scenicId = scenicId
|
||||
self.materialIds = Array(Set(materialIds)).sorted()
|
||||
}
|
||||
|
||||
/// 选中不少于四张素材时才展示并要求选择封面模板。
|
||||
var showsCoverTemplates: Bool {
|
||||
materialIds.count >= 4
|
||||
}
|
||||
|
||||
/// 当前必选模板缺失时用于底部提示的文案。
|
||||
var validationMessage: String? {
|
||||
guard !isLoading, loadErrorMessage == nil else { return nil }
|
||||
if refinedTemplates.isEmpty || selectedRefinedTemplateId == nil {
|
||||
return "暂无可用的原图精修模板"
|
||||
}
|
||||
if showsCoverTemplates, coverTemplates.isEmpty || selectedCoverTemplateId == nil {
|
||||
return "暂无可用的封面风格模板"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 是否满足提交条件。
|
||||
var canSubmit: Bool {
|
||||
!isLoading
|
||||
&& !isSubmitting
|
||||
&& loadErrorMessage == nil
|
||||
&& !materialIds.isEmpty
|
||||
&& selectedRefinedTemplateId != nil
|
||||
&& (!showsCoverTemplates || selectedCoverTemplateId != nil)
|
||||
}
|
||||
|
||||
/// 实时拉取当前景区模板并按规则设置默认选择。
|
||||
func loadTemplates(api: any TravelAlbumServing) async {
|
||||
guard scenicId > 0 else {
|
||||
loadErrorMessage = "请先选择景区"
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
guard !isLoading else { return }
|
||||
isLoading = true
|
||||
loadErrorMessage = nil
|
||||
notifyStateChange()
|
||||
|
||||
do {
|
||||
let response = try await api.aiRetouchTemplates(scenicId: scenicId)
|
||||
refinedTemplates = response.refinedTemplates
|
||||
atmosphereTemplates = response.atmosphereTemplates
|
||||
coverTemplates = response.coverTemplates
|
||||
selectedRefinedTemplateId = refinedTemplates.first?.id
|
||||
selectedAtmosphereTemplateId = nil
|
||||
selectedCoverTemplateId = showsCoverTemplates ? coverTemplates.first?.id : nil
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
} catch {
|
||||
refinedTemplates = []
|
||||
atmosphereTemplates = []
|
||||
coverTemplates = []
|
||||
selectedRefinedTemplateId = nil
|
||||
selectedAtmosphereTemplateId = nil
|
||||
selectedCoverTemplateId = nil
|
||||
isLoading = false
|
||||
loadErrorMessage = error.localizedDescription.isEmpty ? "模板加载失败" : error.localizedDescription
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回指定分组的模板。
|
||||
func templates(for category: TravelAlbumAIRetouchTemplateCategory) -> [TravelAlbumAIRetouchTemplate] {
|
||||
switch category {
|
||||
case .refined: refinedTemplates
|
||||
case .atmosphere: atmosphereTemplates
|
||||
case .cover: coverTemplates
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回指定分组当前选中的模板 ID。
|
||||
func selectedTemplateId(for category: TravelAlbumAIRetouchTemplateCategory) -> Int? {
|
||||
switch category {
|
||||
case .refined: selectedRefinedTemplateId
|
||||
case .atmosphere: selectedAtmosphereTemplateId
|
||||
case .cover: selectedCoverTemplateId
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择模板;氛围感模板再次点击时取消,必选分组保持单选。
|
||||
func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) {
|
||||
guard templates(for: category).contains(where: { $0.id == id }) else { return }
|
||||
switch category {
|
||||
case .refined:
|
||||
selectedRefinedTemplateId = id
|
||||
case .atmosphere:
|
||||
selectedAtmosphereTemplateId = selectedAtmosphereTemplateId == id ? nil : id
|
||||
case .cover:
|
||||
guard showsCoverTemplates else { return }
|
||||
selectedCoverTemplateId = id
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 提交 AI 修图任务,成功后通知页面退出选择态。
|
||||
func submit(api: any TravelAlbumServing) async {
|
||||
guard !isSubmitting else { return }
|
||||
guard let refinedTemplateId = selectedRefinedTemplateId else {
|
||||
onShowMessage?("请选择原图精修模板")
|
||||
return
|
||||
}
|
||||
guard !showsCoverTemplates || selectedCoverTemplateId != nil else {
|
||||
onShowMessage?("请选择封面风格模板")
|
||||
return
|
||||
}
|
||||
guard !materialIds.isEmpty else {
|
||||
onShowMessage?("请选择要修图的照片")
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isSubmitting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
try await api.submitAIRetouch(
|
||||
TravelAlbumAIRetouchRequest(
|
||||
userEquityTravelId: albumId,
|
||||
materialIds: materialIds,
|
||||
refinedTemplateId: refinedTemplateId,
|
||||
atmosphereTemplateId: selectedAtmosphereTemplateId,
|
||||
aiPreviewTabs: showsCoverTemplates ? selectedCoverTemplateId : nil
|
||||
)
|
||||
)
|
||||
onSubmitted?()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "AI修图任务提交失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,25 @@ final class TravelAlbumDetailViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// AI 修图任务提交成功后退出选择模式并清空当前选择。
|
||||
func completeAIRetouchSubmission() {
|
||||
isSelectionMode = false
|
||||
selectedMaterialIds = []
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 预览页删除成功后移除对应素材,并同步全部/已购计数。
|
||||
func removeMaterialAfterPreviewDeletion(id: Int) {
|
||||
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
||||
let material = materials.remove(at: index)
|
||||
selectedMaterialIds.remove(id)
|
||||
allPhotoCount = max(0, allPhotoCount - 1)
|
||||
if material.isPurchased {
|
||||
purchasedPhotoCount = max(0, purchasedPhotoCount - 1)
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 删除已选素材。
|
||||
func deleteSelectedMaterials(api: any TravelAlbumServing) async {
|
||||
let ids = Array(selectedMaterialIds)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// TravelAlbumPreviewActionHandler.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 图片预览页业务操作实现,负责将底部操作转发到旅拍相册服务。
|
||||
final class TravelAlbumPreviewActionHandler: TravelAlbumPreviewActionHandling {
|
||||
private let api: any TravelAlbumServing
|
||||
|
||||
/// 使用旅拍相册服务创建操作处理器。
|
||||
init(api: any TravelAlbumServing) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
/// AI 修复仍由相册选择态的模板流程发起。
|
||||
func requestAIRetouch(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||
.unavailable("请在相册中选择照片后使用AI修图")
|
||||
}
|
||||
|
||||
/// 调用素材删除接口,仅在服务端确认成功后返回成功结果。
|
||||
func deleteProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||
do {
|
||||
try await api.deleteMaterial(id: originalMaterialId)
|
||||
return .success("删除成功")
|
||||
} catch is CancellationError {
|
||||
return .failure("删除已取消")
|
||||
} catch {
|
||||
let message = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return .failure(message.isEmpty ? "删除失败" : message)
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新关联结果图接口尚未提供。
|
||||
func refreshProject(originalMaterialId: Int) async -> TravelAlbumPreviewActionResult {
|
||||
.unavailable("关联图片刷新接口待接入")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
//
|
||||
// TravelAlbumAIRetouchTemplateViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// AI 修图模板选择 Sheet,展示三类横向模板列表、修图模式与固定底部操作。
|
||||
final class TravelAlbumAIRetouchTemplateViewController: BaseViewController {
|
||||
/// 页面使用的 collection section。
|
||||
private enum Section: Hashable {
|
||||
case templates(TravelAlbumAIRetouchTemplateCategory)
|
||||
case mode
|
||||
}
|
||||
|
||||
/// 页面使用的 diffable item。
|
||||
private enum Item: Hashable {
|
||||
case template(TravelAlbumAIRetouchTemplateCategory, TravelAlbumAIRetouchTemplate)
|
||||
case mode
|
||||
}
|
||||
|
||||
private let viewModel: TravelAlbumAIRetouchTemplateViewModel
|
||||
private let api: any TravelAlbumServing
|
||||
private let onSubmitted: () -> Void
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
private let statusContainer = UIView()
|
||||
private let statusIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private let statusLabel = UILabel()
|
||||
private let retryButton = UIButton(type: .system)
|
||||
private let bottomBar = UIView()
|
||||
private let bottomDivider = UIView()
|
||||
private let footerStack = UIStackView()
|
||||
private let validationLabel = UILabel()
|
||||
private let actionStack = UIStackView()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
|
||||
/// 创建 AI 修图模板选择 Sheet。
|
||||
init(
|
||||
viewModel: TravelAlbumAIRetouchTemplateViewModel,
|
||||
api: any TravelAlbumServing,
|
||||
onSubmitted: @escaping () -> Void
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.api = api
|
||||
self.onSubmitted = onSubmitted
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .pageSheet
|
||||
configureSheetPresentation()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AIRetouchTemplateStyle.pageBackground
|
||||
view.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateSheet"
|
||||
|
||||
titleLabel.text = "AI修图"
|
||||
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .semibold)
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.accessibilityTraits = .header
|
||||
|
||||
collectionView.backgroundColor = .clear
|
||||
collectionView.showsVerticalScrollIndicator = false
|
||||
collectionView.alwaysBounceVertical = true
|
||||
collectionView.delegate = self
|
||||
collectionView.accessibilityIdentifier = "travelAlbum.aiRetouchTemplateCollection"
|
||||
collectionView.register(
|
||||
TravelAlbumAIRetouchTemplateCell.self,
|
||||
forCellWithReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier
|
||||
)
|
||||
collectionView.register(
|
||||
TravelAlbumAIRetouchModeCell.self,
|
||||
forCellWithReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier
|
||||
)
|
||||
collectionView.register(
|
||||
TravelAlbumAIRetouchSectionHeader.self,
|
||||
forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader,
|
||||
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier
|
||||
)
|
||||
configureDataSource()
|
||||
|
||||
statusLabel.textColor = AIRetouchTemplateStyle.textSecondary
|
||||
statusLabel.font = .systemFont(ofSize: 14)
|
||||
statusLabel.textAlignment = .center
|
||||
statusLabel.numberOfLines = 0
|
||||
retryButton.setTitle("重试", for: .normal)
|
||||
retryButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
retryButton.accessibilityIdentifier = "travelAlbum.aiRetouchRetryButton"
|
||||
|
||||
bottomBar.backgroundColor = .white
|
||||
bottomBar.accessibilityIdentifier = "travelAlbum.aiRetouchBottomBar"
|
||||
bottomDivider.backgroundColor = AIRetouchTemplateStyle.border
|
||||
validationLabel.textColor = AIRetouchTemplateStyle.danger
|
||||
validationLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
validationLabel.textAlignment = .center
|
||||
validationLabel.numberOfLines = 0
|
||||
validationLabel.accessibilityIdentifier = "travelAlbum.aiRetouchValidationLabel"
|
||||
|
||||
footerStack.axis = .vertical
|
||||
footerStack.spacing = 8
|
||||
footerStack.alignment = .fill
|
||||
actionStack.axis = .horizontal
|
||||
actionStack.spacing = 12
|
||||
actionStack.distribution = .fillEqually
|
||||
actionStack.alignment = .fill
|
||||
configureCancelButton()
|
||||
configureConfirmButton()
|
||||
actionStack.addArrangedSubview(cancelButton)
|
||||
actionStack.addArrangedSubview(confirmButton)
|
||||
footerStack.addArrangedSubview(validationLabel)
|
||||
footerStack.addArrangedSubview(actionStack)
|
||||
|
||||
view.addSubview(titleLabel)
|
||||
view.addSubview(collectionView)
|
||||
view.addSubview(statusContainer)
|
||||
statusContainer.addSubview(statusIndicator)
|
||||
statusContainer.addSubview(statusLabel)
|
||||
statusContainer.addSubview(retryButton)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(bottomDivider)
|
||||
bottomBar.addSubview(footerStack)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(18)
|
||||
make.height.equalTo(30)
|
||||
}
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
bottomDivider.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
footerStack.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(10)
|
||||
make.leading.trailing.equalToSuperview().inset(18)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
actionStack.snp.makeConstraints { make in
|
||||
make.height.equalTo(48)
|
||||
}
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(bottomBar.snp.top)
|
||||
}
|
||||
statusContainer.snp.makeConstraints { make in
|
||||
make.edges.equalTo(collectionView)
|
||||
}
|
||||
statusIndicator.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalToSuperview().offset(-32)
|
||||
}
|
||||
statusLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(statusIndicator.snp.bottom).offset(14)
|
||||
make.leading.trailing.equalToSuperview().inset(40)
|
||||
}
|
||||
retryButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(statusLabel.snp.bottom).offset(12)
|
||||
make.centerX.equalToSuperview()
|
||||
make.height.equalTo(36)
|
||||
make.bottom.lessThanOrEqualToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
viewModel.onSubmitted = { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.dismiss(animated: true, completion: self.onSubmitted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
applyViewModel()
|
||||
Task { await viewModel.loadTemplates(api: api) }
|
||||
}
|
||||
|
||||
private func configureSheetPresentation() {
|
||||
guard let sheet = sheetPresentationController else { return }
|
||||
sheet.detents = [.large()]
|
||||
sheet.selectedDetentIdentifier = .large
|
||||
sheet.prefersGrabberVisible = true
|
||||
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
|
||||
}
|
||||
|
||||
private func configureCancelButton() {
|
||||
var configuration = UIButton.Configuration.filled()
|
||||
configuration.title = "取消"
|
||||
configuration.baseBackgroundColor = .white
|
||||
configuration.baseForegroundColor = AIRetouchTemplateStyle.primary
|
||||
configuration.background.cornerRadius = 14
|
||||
configuration.background.strokeColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.55)
|
||||
configuration.background.strokeWidth = 1
|
||||
configuration.titleTextAttributesTransformer = buttonTitleTransformer
|
||||
cancelButton.configuration = configuration
|
||||
cancelButton.accessibilityIdentifier = "travelAlbum.aiRetouchCancelButton"
|
||||
}
|
||||
|
||||
private func configureConfirmButton() {
|
||||
var configuration = UIButton.Configuration.filled()
|
||||
configuration.title = "确定"
|
||||
configuration.baseBackgroundColor = AIRetouchTemplateStyle.primary
|
||||
configuration.baseForegroundColor = .white
|
||||
configuration.background.cornerRadius = 14
|
||||
configuration.titleTextAttributesTransformer = buttonTitleTransformer
|
||||
confirmButton.configuration = configuration
|
||||
confirmButton.accessibilityIdentifier = "travelAlbum.aiRetouchConfirmButton"
|
||||
}
|
||||
|
||||
private var buttonTitleTransformer: UIConfigurationTextAttributesTransformer {
|
||||
UIConfigurationTextAttributesTransformer { attributes in
|
||||
var attributes = attributes
|
||||
attributes.font = .systemFont(ofSize: 16, weight: .semibold)
|
||||
return attributes
|
||||
}
|
||||
}
|
||||
|
||||
private func configureDataSource() {
|
||||
dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) {
|
||||
[weak self] collectionView, indexPath, item in
|
||||
guard let self else { return nil }
|
||||
switch item {
|
||||
case .template(let category, let template):
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: TravelAlbumAIRetouchTemplateCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TravelAlbumAIRetouchTemplateCell
|
||||
cell.apply(
|
||||
template: template,
|
||||
selected: self.viewModel.selectedTemplateId(for: category) == template.id
|
||||
)
|
||||
return cell
|
||||
case .mode:
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: TravelAlbumAIRetouchModeCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TravelAlbumAIRetouchModeCell
|
||||
cell.apply()
|
||||
return cell
|
||||
}
|
||||
}
|
||||
dataSource.supplementaryViewProvider = { [weak self] collectionView, kind, indexPath in
|
||||
guard kind == UICollectionView.elementKindSectionHeader,
|
||||
let self,
|
||||
self.dataSource.snapshot().sectionIdentifiers.indices.contains(indexPath.section),
|
||||
case .templates(let category) = self.dataSource.snapshot().sectionIdentifiers[indexPath.section] else {
|
||||
return nil
|
||||
}
|
||||
let header = collectionView.dequeueReusableSupplementaryView(
|
||||
ofKind: kind,
|
||||
withReuseIdentifier: TravelAlbumAIRetouchSectionHeader.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! TravelAlbumAIRetouchSectionHeader
|
||||
header.apply(title: category.title, optional: category == .atmosphere)
|
||||
return header
|
||||
}
|
||||
}
|
||||
|
||||
private func makeLayout() -> UICollectionViewCompositionalLayout {
|
||||
UICollectionViewCompositionalLayout { [weak self] sectionIndex, _ in
|
||||
guard let self,
|
||||
self.dataSource != nil,
|
||||
self.dataSource.snapshot().sectionIdentifiers.indices.contains(sectionIndex) else {
|
||||
return nil
|
||||
}
|
||||
switch self.dataSource.snapshot().sectionIdentifiers[sectionIndex] {
|
||||
case .templates:
|
||||
let itemSize = NSCollectionLayoutSize(
|
||||
widthDimension: .absolute(118),
|
||||
heightDimension: .absolute(154)
|
||||
)
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary
|
||||
section.interGroupSpacing = 12
|
||||
section.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 18, bottom: 12, trailing: 18)
|
||||
section.boundarySupplementaryItems = [
|
||||
NSCollectionLayoutBoundarySupplementaryItem(
|
||||
layoutSize: NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .absolute(40)
|
||||
),
|
||||
elementKind: UICollectionView.elementKindSectionHeader,
|
||||
alignment: .top
|
||||
),
|
||||
]
|
||||
return section
|
||||
case .mode:
|
||||
let itemSize = NSCollectionLayoutSize(
|
||||
widthDimension: .fractionalWidth(1),
|
||||
heightDimension: .absolute(72)
|
||||
)
|
||||
let item = NSCollectionLayoutItem(layoutSize: itemSize)
|
||||
let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item])
|
||||
let section = NSCollectionLayoutSection(group: group)
|
||||
section.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 18, bottom: 22, trailing: 18)
|
||||
return section
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
let isLoadFailure = viewModel.loadErrorMessage != nil
|
||||
statusContainer.isHidden = !viewModel.isLoading && !isLoadFailure
|
||||
collectionView.isHidden = viewModel.isLoading || isLoadFailure
|
||||
if viewModel.isLoading {
|
||||
statusIndicator.startAnimating()
|
||||
statusLabel.text = "正在加载修图模板…"
|
||||
retryButton.isHidden = true
|
||||
} else if let message = viewModel.loadErrorMessage {
|
||||
statusIndicator.stopAnimating()
|
||||
statusLabel.text = message
|
||||
retryButton.isHidden = false
|
||||
} else {
|
||||
statusIndicator.stopAnimating()
|
||||
statusLabel.text = nil
|
||||
retryButton.isHidden = true
|
||||
}
|
||||
|
||||
validationLabel.text = viewModel.validationMessage
|
||||
validationLabel.isHidden = viewModel.validationMessage == nil
|
||||
cancelButton.isEnabled = !viewModel.isSubmitting
|
||||
confirmButton.isEnabled = viewModel.canSubmit
|
||||
confirmButton.alpha = viewModel.canSubmit ? 1 : 0.45
|
||||
var confirmConfiguration = confirmButton.configuration
|
||||
confirmConfiguration?.title = viewModel.isSubmitting ? "提交中" : "确定"
|
||||
confirmConfiguration?.showsActivityIndicator = viewModel.isSubmitting
|
||||
confirmButton.configuration = confirmConfiguration
|
||||
isModalInPresentation = viewModel.isSubmitting
|
||||
applySnapshot()
|
||||
}
|
||||
|
||||
private func applySnapshot() {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
appendTemplates(.refined, to: &snapshot)
|
||||
appendTemplates(.atmosphere, to: &snapshot)
|
||||
if viewModel.showsCoverTemplates {
|
||||
appendTemplates(.cover, to: &snapshot)
|
||||
}
|
||||
snapshot.appendSections([.mode])
|
||||
snapshot.appendItems([.mode], toSection: .mode)
|
||||
snapshot.reconfigureItems(snapshot.itemIdentifiers)
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
}
|
||||
|
||||
private func appendTemplates(
|
||||
_ category: TravelAlbumAIRetouchTemplateCategory,
|
||||
to snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>
|
||||
) {
|
||||
let section = Section.templates(category)
|
||||
snapshot.appendSections([section])
|
||||
snapshot.appendItems(
|
||||
viewModel.templates(for: category).map { Item.template(category, $0) },
|
||||
toSection: section
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
guard !viewModel.isSubmitting else { return }
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
guard viewModel.canSubmit else {
|
||||
if let message = viewModel.validationMessage { showToast(message) }
|
||||
return
|
||||
}
|
||||
Task { await viewModel.submit(api: api) }
|
||||
}
|
||||
|
||||
@objc private func retryTapped() {
|
||||
Task { await viewModel.loadTemplates(api: api) }
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumAIRetouchTemplateViewController: UICollectionViewDelegate {
|
||||
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath),
|
||||
case .template(let category, let template) = item else { return }
|
||||
viewModel.toggleTemplate(id: template.id, category: category)
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板卡片,展示模板预览、名称和明确选中态。
|
||||
final class TravelAlbumAIRetouchTemplateCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "TravelAlbumAIRetouchTemplateCell"
|
||||
|
||||
private let previewImageView = UIImageView()
|
||||
private let nameLabel = UILabel()
|
||||
private let checkView = UIImageView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = .white
|
||||
contentView.layer.cornerRadius = 12
|
||||
contentView.layer.borderWidth = 1
|
||||
contentView.clipsToBounds = true
|
||||
|
||||
previewImageView.contentMode = .scaleAspectFill
|
||||
previewImageView.clipsToBounds = true
|
||||
previewImageView.backgroundColor = UIColor(hex: 0xEDF2F8)
|
||||
nameLabel.font = .systemFont(ofSize: 13, weight: .medium)
|
||||
nameLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||
nameLabel.textAlignment = .center
|
||||
nameLabel.lineBreakMode = .byTruncatingTail
|
||||
checkView.image = UIImage(systemName: "checkmark.circle.fill")
|
||||
checkView.tintColor = AIRetouchTemplateStyle.primary
|
||||
checkView.backgroundColor = .white
|
||||
checkView.layer.cornerRadius = 10
|
||||
|
||||
contentView.addSubview(previewImageView)
|
||||
contentView.addSubview(nameLabel)
|
||||
contentView.addSubview(checkView)
|
||||
previewImageView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(116)
|
||||
}
|
||||
nameLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(previewImageView.snp.bottom)
|
||||
make.leading.trailing.equalToSuperview().inset(6)
|
||||
make.bottom.equalToSuperview()
|
||||
}
|
||||
checkView.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(8)
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
}
|
||||
|
||||
@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(template: TravelAlbumAIRetouchTemplate, selected: Bool) {
|
||||
nameLabel.text = template.name
|
||||
if let url = URL(string: template.previewURL), !template.previewURL.isEmpty {
|
||||
previewImageView.contentMode = .scaleAspectFill
|
||||
previewImageView.kf.setImage(
|
||||
with: url,
|
||||
placeholder: UIImage(systemName: "photo")?.withTintColor(
|
||||
AIRetouchTemplateStyle.textSecondary,
|
||||
renderingMode: .alwaysOriginal
|
||||
)
|
||||
)
|
||||
} else {
|
||||
previewImageView.image = UIImage(systemName: "photo")
|
||||
previewImageView.tintColor = AIRetouchTemplateStyle.textSecondary
|
||||
previewImageView.contentMode = .scaleAspectFit
|
||||
}
|
||||
contentView.layer.borderColor = (
|
||||
selected ? AIRetouchTemplateStyle.primary : AIRetouchTemplateStyle.border
|
||||
).cgColor
|
||||
contentView.layer.borderWidth = selected ? 2 : 1
|
||||
checkView.isHidden = !selected
|
||||
isSelected = selected
|
||||
accessibilityLabel = template.name
|
||||
accessibilityValue = selected ? "已选择" : "未选择"
|
||||
accessibilityTraits = selected ? [.button, .selected] : [.button]
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板分组标题,可附带“选填”标签。
|
||||
final class TravelAlbumAIRetouchSectionHeader: UICollectionReusableView {
|
||||
static let reuseIdentifier = "TravelAlbumAIRetouchSectionHeader"
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let optionalLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
titleLabel.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||
optionalLabel.text = "选填"
|
||||
optionalLabel.font = .systemFont(ofSize: 11, weight: .medium)
|
||||
optionalLabel.textColor = AIRetouchTemplateStyle.primary
|
||||
optionalLabel.textAlignment = .center
|
||||
optionalLabel.backgroundColor = AIRetouchTemplateStyle.primary.withAlphaComponent(0.1)
|
||||
optionalLabel.layer.cornerRadius = 8
|
||||
optionalLabel.clipsToBounds = true
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(optionalLabel)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(18)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
optionalLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(titleLabel.snp.trailing).offset(8)
|
||||
make.centerY.equalTo(titleLabel)
|
||||
make.width.equalTo(38)
|
||||
make.height.equalTo(20)
|
||||
make.trailing.lessThanOrEqualToSuperview().offset(-18)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 更新分组标题与选填标签。
|
||||
func apply(title: String, optional: Bool) {
|
||||
titleLabel.text = title
|
||||
optionalLabel.isHidden = !optional
|
||||
accessibilityLabel = optional ? "\(title),选填" : title
|
||||
accessibilityTraits = .header
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模式卡片,展示计费方式与剩余张数占位。
|
||||
final class TravelAlbumAIRetouchModeCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "TravelAlbumAIRetouchModeCell"
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let remainingLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
contentView.backgroundColor = .white
|
||||
contentView.layer.cornerRadius = 14
|
||||
contentView.layer.borderWidth = 1
|
||||
contentView.layer.borderColor = AIRetouchTemplateStyle.border.cgColor
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
||||
titleLabel.textColor = AIRetouchTemplateStyle.textPrimary
|
||||
remainingLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium)
|
||||
remainingLabel.textColor = AIRetouchTemplateStyle.primary
|
||||
remainingLabel.textAlignment = .right
|
||||
|
||||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(remainingLabel)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
remainingLabel.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 应用当前固定修图模式与额度占位文案。
|
||||
func apply() {
|
||||
titleLabel.text = "AI精修 按张收费"
|
||||
remainingLabel.text = "剩余--张"
|
||||
accessibilityIdentifier = "travelAlbum.aiRetouchModeCell"
|
||||
accessibilityLabel = "AI精修,按张收费,剩余张数暂不可用"
|
||||
}
|
||||
}
|
||||
|
||||
/// AI 修图模板页视觉常量。
|
||||
private enum AIRetouchTemplateStyle {
|
||||
static let primary = UIColor(hex: 0x1677FF)
|
||||
static let pageBackground = UIColor(hex: 0xF7F9FC)
|
||||
static let textPrimary = UIColor(hex: 0x172033)
|
||||
static let textSecondary = UIColor(hex: 0x7F8A9E)
|
||||
static let border = UIColor(hex: 0xDCE4EF)
|
||||
static let danger = UIColor(hex: 0xE53935)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
private let viewModel: TravelAlbumDetailViewModel
|
||||
private let api: any TravelAlbumServing
|
||||
private let previewConfiguration: TravelAlbumPreviewConfiguration
|
||||
private let scenicIdProvider: () -> Int
|
||||
|
||||
private let contentView = UIView()
|
||||
private let infoCard = TravelAlbumInfoCard()
|
||||
@@ -25,17 +26,21 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumMaterial>!
|
||||
private let bottomBar = UIView()
|
||||
private let bottomDivider = UIView()
|
||||
private let selectionActionStack = UIStackView()
|
||||
private let aiRetouchButton = UIButton(type: .system)
|
||||
private let deleteSelectedButton = UIButton(type: .system)
|
||||
private let uploadButton = UIButton(type: .system)
|
||||
|
||||
init(
|
||||
albumId: Int,
|
||||
api: (any TravelAlbumServing)? = nil,
|
||||
previewConfiguration: TravelAlbumPreviewConfiguration = .init()
|
||||
previewConfiguration: TravelAlbumPreviewConfiguration = .init(),
|
||||
scenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }
|
||||
) {
|
||||
viewModel = TravelAlbumDetailViewModel(albumId: albumId)
|
||||
self.api = api ?? NetworkServices.shared.travelAlbumAPI
|
||||
self.previewConfiguration = previewConfiguration
|
||||
self.scenicIdProvider = scenicIdProvider
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@@ -118,8 +123,20 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
uploadConfiguration?.imagePadding = 8
|
||||
uploadButton.configuration = uploadConfiguration
|
||||
uploadButton.accessibilityLabel = "上传照片"
|
||||
configureBottomButton(deleteSelectedButton, title: "删除选中(0)", color: TravelAlbumDetailStyle.danger)
|
||||
deleteSelectedButton.isHidden = true
|
||||
selectionActionStack.axis = .horizontal
|
||||
selectionActionStack.alignment = .fill
|
||||
selectionActionStack.distribution = .fillEqually
|
||||
selectionActionStack.spacing = 12
|
||||
selectionActionStack.isHidden = true
|
||||
selectionActionStack.accessibilityIdentifier = "travelAlbum.selectionActionStack"
|
||||
configureBottomButton(aiRetouchButton, title: "AI修图", color: TravelAlbumDetailStyle.primary)
|
||||
aiRetouchButton.accessibilityLabel = "AI修图"
|
||||
aiRetouchButton.accessibilityIdentifier = "travelAlbum.aiRetouchButton"
|
||||
configureBottomButton(deleteSelectedButton, title: "删除", color: TravelAlbumDetailStyle.danger)
|
||||
deleteSelectedButton.accessibilityLabel = "删除"
|
||||
deleteSelectedButton.accessibilityIdentifier = "travelAlbum.deleteButton"
|
||||
selectionActionStack.addArrangedSubview(aiRetouchButton)
|
||||
selectionActionStack.addArrangedSubview(deleteSelectedButton)
|
||||
|
||||
view.addSubview(contentView)
|
||||
contentView.addSubview(infoCard)
|
||||
@@ -133,7 +150,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(bottomDivider)
|
||||
bottomBar.addSubview(uploadButton)
|
||||
bottomBar.addSubview(deleteSelectedButton)
|
||||
bottomBar.addSubview(selectionActionStack)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
@@ -150,7 +167,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
make.height.equalTo(48)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
}
|
||||
deleteSelectedButton.snp.makeConstraints { make in
|
||||
selectionActionStack.snp.makeConstraints { make in
|
||||
make.edges.equalTo(uploadButton)
|
||||
}
|
||||
|
||||
@@ -205,6 +222,7 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
allTabButton.addTarget(self, action: #selector(allTabTapped), for: .touchUpInside)
|
||||
purchasedTabButton.addTarget(self, action: #selector(purchasedTabTapped), for: .touchUpInside)
|
||||
selectButton.addTarget(self, action: #selector(selectTapped), for: .touchUpInside)
|
||||
aiRetouchButton.addTarget(self, action: #selector(aiRetouchTapped), for: .touchUpInside)
|
||||
deleteSelectedButton.addTarget(self, action: #selector(deleteSelectedTapped), for: .touchUpInside)
|
||||
uploadButton.addTarget(self, action: #selector(uploadTapped), for: .touchUpInside)
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
@@ -255,10 +273,14 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
: "已购照片不可删除"
|
||||
|
||||
let selectedCount = viewModel.selectedMaterialIds.count
|
||||
deleteSelectedButton.setTitle("删除选中(\(selectedCount))", for: .normal)
|
||||
let hasSelection = selectedCount > 0
|
||||
aiRetouchButton.isEnabled = hasSelection
|
||||
aiRetouchButton.alpha = hasSelection ? 1 : 0.45
|
||||
aiRetouchButton.accessibilityValue = "已选择 \(selectedCount) 张照片"
|
||||
deleteSelectedButton.isEnabled = selectedCount > 0
|
||||
deleteSelectedButton.alpha = selectedCount > 0 ? 1 : 0.45
|
||||
deleteSelectedButton.isHidden = !viewModel.isSelectionMode
|
||||
deleteSelectedButton.alpha = hasSelection ? 1 : 0.45
|
||||
deleteSelectedButton.accessibilityValue = "已选择 \(selectedCount) 张照片"
|
||||
selectionActionStack.isHidden = !viewModel.isSelectionMode
|
||||
uploadButton.isHidden = viewModel.isSelectionMode
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumMaterial>()
|
||||
@@ -396,6 +418,31 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
viewModel.toggleSelectionMode()
|
||||
}
|
||||
|
||||
@objc private func aiRetouchTapped() {
|
||||
let materialIds = Array(viewModel.selectedMaterialIds)
|
||||
guard !materialIds.isEmpty else { return }
|
||||
let scenicId = scenicIdProvider()
|
||||
guard scenicId > 0 else {
|
||||
showToast("请先选择景区")
|
||||
return
|
||||
}
|
||||
|
||||
let controller = TravelAlbumAIRetouchTemplateViewController(
|
||||
viewModel: TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: viewModel.albumId,
|
||||
scenicId: scenicId,
|
||||
materialIds: materialIds
|
||||
),
|
||||
api: api,
|
||||
onSubmitted: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.viewModel.completeAIRetouchSubmission()
|
||||
self.showToast("AI修图任务已提交")
|
||||
}
|
||||
)
|
||||
present(controller, animated: true)
|
||||
}
|
||||
|
||||
@objc private func deleteSelectedTapped() {
|
||||
let count = viewModel.selectedMaterialIds.count
|
||||
guard count > 0 else { return }
|
||||
@@ -429,12 +476,16 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
||||
totalCount: viewModel.currentPhotoCount,
|
||||
startProjectIndex: startIndex,
|
||||
configuration: previewConfiguration,
|
||||
actionHandler: TravelAlbumPreviewActionHandler(api: previewAPI),
|
||||
loadMore: {
|
||||
await previewViewModel.loadMaterials(reset: false, api: previewAPI)
|
||||
return (
|
||||
previewViewModel.materials.map(TravelAlbumPreviewProject.init(material:)),
|
||||
previewViewModel.currentPhotoCount
|
||||
)
|
||||
},
|
||||
onProjectDeleted: { materialId in
|
||||
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
|
||||
}
|
||||
)
|
||||
present(controller, animated: true)
|
||||
@@ -465,6 +516,17 @@ private enum TravelAlbumDetailStyle {
|
||||
static let textSecondary = UIColor(hex: 0x7F8A9E)
|
||||
static let border = UIColor(hex: 0xDCE4EF)
|
||||
static let danger = UIColor(hex: 0xE53935)
|
||||
|
||||
static func badgeColor(for kind: TravelAlbumMaterialBadgeKind) -> UIColor {
|
||||
switch kind {
|
||||
case .purchased: UIColor(hex: 0x475569)
|
||||
case .pending: UIColor(hex: 0xB45309)
|
||||
case .processing: UIColor(hex: 0x1D4ED8)
|
||||
case .retouched: UIColor(hex: 0x047857)
|
||||
case .cover: UIColor(hex: 0x6D28D9)
|
||||
case .failed: UIColor(hex: 0xB91C1C)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍相册摘要卡,展示封面、名称、用户手机号与创建时间。
|
||||
@@ -583,11 +645,13 @@ private final class TravelAlbumInfoCard: UIView {
|
||||
}
|
||||
|
||||
/// 旅拍相册素材网格单元,展示正方形缩略图、文件名、大小和选择状态。
|
||||
private final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
static let reuseIdentifier = "TravelAlbumMaterialCell"
|
||||
|
||||
private let imageView = UIImageView()
|
||||
private let checkImageView = UIImageView()
|
||||
private let badgeView = UIView()
|
||||
private let badgeLabel = UILabel()
|
||||
private let nameLabel = UILabel()
|
||||
private let sizeLabel = UILabel()
|
||||
|
||||
@@ -600,6 +664,19 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
checkImageView.tintColor = .white
|
||||
checkImageView.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||
checkImageView.layer.cornerRadius = 11
|
||||
checkImageView.accessibilityIdentifier = "travelAlbum.materialSelectionCheck"
|
||||
badgeView.layer.cornerRadius = 5
|
||||
badgeView.clipsToBounds = true
|
||||
badgeView.isHidden = true
|
||||
badgeView.isAccessibilityElement = false
|
||||
badgeView.accessibilityIdentifier = "travelAlbum.materialStatusBadge"
|
||||
badgeLabel.font = .systemFont(ofSize: 10, weight: .semibold)
|
||||
badgeLabel.textColor = .white
|
||||
badgeLabel.textAlignment = .center
|
||||
badgeLabel.isAccessibilityElement = false
|
||||
badgeLabel.accessibilityIdentifier = "travelAlbum.materialStatusBadgeLabel"
|
||||
badgeLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||
badgeLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
nameLabel.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
nameLabel.textColor = TravelAlbumDetailStyle.textPrimary
|
||||
nameLabel.lineBreakMode = .byTruncatingMiddle
|
||||
@@ -607,6 +684,8 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
sizeLabel.textColor = TravelAlbumDetailStyle.textSecondary
|
||||
|
||||
contentView.addSubview(imageView)
|
||||
imageView.addSubview(badgeView)
|
||||
badgeView.addSubview(badgeLabel)
|
||||
imageView.addSubview(checkImageView)
|
||||
contentView.addSubview(nameLabel)
|
||||
contentView.addSubview(sizeLabel)
|
||||
@@ -618,6 +697,15 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
make.top.trailing.equalToSuperview().inset(6)
|
||||
make.size.equalTo(22)
|
||||
}
|
||||
badgeView.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().inset(6)
|
||||
make.trailing.lessThanOrEqualTo(checkImageView.snp.leading).offset(-4)
|
||||
}
|
||||
badgeLabel.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(
|
||||
UIEdgeInsets(top: 3, left: 6, bottom: 3, right: 6)
|
||||
)
|
||||
}
|
||||
nameLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(imageView.snp.bottom).offset(6)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
@@ -646,9 +734,14 @@ private final class TravelAlbumMaterialCell: UICollectionViewCell {
|
||||
checkImageView.isHidden = !selectionMode
|
||||
checkImageView.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
checkImageView.tintColor = selected ? TravelAlbumDetailStyle.primary : .white
|
||||
let badge = material.badgePresentation
|
||||
badgeView.isHidden = badge == nil
|
||||
badgeLabel.text = badge?.text
|
||||
badgeView.backgroundColor = badge.map { TravelAlbumDetailStyle.badgeColor(for: $0.kind) }
|
||||
nameLabel.text = material.fileName.isEmpty ? "未命名照片" : material.fileName
|
||||
sizeLabel.text = TravelAlbumDisplayFormatter.fileSizeText(material.fileSize)
|
||||
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")"
|
||||
let badgeAccessibilityText = badge.map { ",状态:\($0.text)" } ?? ""
|
||||
accessibilityLabel = "\(nameLabel.text ?? "照片"),\(sizeLabel.text ?? "")\(badgeAccessibilityText)"
|
||||
accessibilityValue = selectionMode ? (selected ? "已选择" : "未选择") : nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,15 +17,16 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
private let configuration: TravelAlbumPreviewConfiguration
|
||||
private let actionHandler: any TravelAlbumPreviewActionHandling
|
||||
private let loadMore: TravelAlbumPreviewLoadMore?
|
||||
private let onProjectDeleted: ((Int) -> Void)?
|
||||
private var nodes: [TravelAlbumPreviewNode] = []
|
||||
private var currentNodeIndex = 0
|
||||
private var dragStartIndex = 0
|
||||
private var selectedKind: TravelAlbumPreviewAssetKind = .original
|
||||
private var chromeVisible = true
|
||||
private var isLoadingMore = false
|
||||
private var isDeletingProject = false
|
||||
private var didApplyInitialPosition = false
|
||||
private var lastCollectionSize: CGSize = .zero
|
||||
private var previewPrefetcher: ImagePrefetcher?
|
||||
|
||||
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout())
|
||||
private let topChrome = UIView()
|
||||
@@ -37,6 +38,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
private let divider = UIView()
|
||||
private let tabStack = UIStackView()
|
||||
private let actionStack = UIStackView()
|
||||
private let deleteButton = UIButton(type: .system)
|
||||
private var tabHeightConstraint: Constraint?
|
||||
|
||||
/// 创建全屏预览页。
|
||||
@@ -46,13 +48,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
startProjectIndex: Int,
|
||||
configuration: TravelAlbumPreviewConfiguration = .init(),
|
||||
actionHandler: any TravelAlbumPreviewActionHandling = PlaceholderTravelAlbumPreviewActionHandler(),
|
||||
loadMore: TravelAlbumPreviewLoadMore? = nil
|
||||
loadMore: TravelAlbumPreviewLoadMore? = nil,
|
||||
onProjectDeleted: ((Int) -> Void)? = nil
|
||||
) {
|
||||
self.projects = Self.deduplicated(projects)
|
||||
self.totalCount = max(totalCount, projects.count)
|
||||
self.configuration = configuration
|
||||
self.actionHandler = actionHandler
|
||||
self.loadMore = loadMore
|
||||
self.onProjectDeleted = onProjectDeleted
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .fullScreen
|
||||
rebuildNodes(keepingProjectIndex: max(0, min(startProjectIndex, projects.count - 1)), kind: .original)
|
||||
@@ -70,7 +74,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
updateForCurrentNode()
|
||||
prefetchAdjacentImages()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
@@ -81,13 +84,11 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
if didApplyInitialPosition {
|
||||
setPage(currentNodeIndex, animated: false)
|
||||
prefetchAdjacentImages()
|
||||
}
|
||||
}
|
||||
guard !didApplyInitialPosition, collectionView.bounds.width > 0 else { return }
|
||||
didApplyInitialPosition = true
|
||||
setPage(currentNodeIndex, animated: false)
|
||||
upgradeCurrentCellToDetailImage()
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
@@ -193,7 +194,15 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
|
||||
private func configureActions() {
|
||||
let aiButton = makeActionButton(title: "AI修复", systemName: "wand.and.stars", color: UIColor(hex: 0x60A5FA))
|
||||
let deleteButton = makeActionButton(title: "删除", systemName: "trash", color: UIColor(hex: 0xF87171))
|
||||
let deleteConfiguration = makeActionButton(
|
||||
title: "删除",
|
||||
systemName: "trash",
|
||||
color: UIColor(hex: 0xF87171)
|
||||
).configuration
|
||||
deleteButton.configuration = deleteConfiguration
|
||||
deleteButton.titleLabel?.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
deleteButton.accessibilityLabel = "删除"
|
||||
deleteButton.accessibilityIdentifier = "travelAlbum.previewDeleteButton"
|
||||
let refreshButton = makeActionButton(title: "刷新", systemName: "arrow.clockwise", color: .white)
|
||||
aiButton.addTarget(self, action: #selector(aiTapped), for: .touchUpInside)
|
||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||
@@ -305,8 +314,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
let oldProjectIndex = currentNode?.projectIndex
|
||||
guard oldNodeIndex != index else {
|
||||
updateForCurrentNode()
|
||||
upgradeCurrentCellToDetailImage()
|
||||
prefetchAdjacentImages()
|
||||
return
|
||||
}
|
||||
currentNodeIndex = index
|
||||
@@ -316,8 +323,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
}
|
||||
collectionView.visibleCells.compactMap { $0 as? TravelAlbumPreviewImageCell }.forEach { $0.resetZoom() }
|
||||
updateForCurrentNode()
|
||||
upgradeCurrentCellToDetailImage()
|
||||
prefetchAdjacentImages()
|
||||
}
|
||||
|
||||
private func resetProjectCellToOriginalIfNeeded(at nodeIndex: Int) {
|
||||
@@ -326,38 +331,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
let cell = collectionView.cellForItem(at: IndexPath(item: nodeIndex, section: 0)) as? TravelAlbumPreviewImageCell
|
||||
else { return }
|
||||
let project = projects[nodes[nodeIndex].projectIndex]
|
||||
cell.apply(asset: project.asset(for: .original), quality: .preview)
|
||||
}
|
||||
|
||||
private func upgradeCurrentCellToDetailImage() {
|
||||
guard let cell = collectionView.cellForItem(
|
||||
at: IndexPath(item: currentNodeIndex, section: 0)
|
||||
) as? TravelAlbumPreviewImageCell else { return }
|
||||
cell.apply(asset: currentAsset, quality: .detail)
|
||||
}
|
||||
|
||||
private func prefetchAdjacentImages() {
|
||||
previewPrefetcher?.stop()
|
||||
let indexes = [currentNodeIndex - 2, currentNodeIndex - 1, currentNodeIndex + 1, currentNodeIndex + 2]
|
||||
let urls = indexes.compactMap { index -> URL? in
|
||||
guard nodes.indices.contains(index) else { return nil }
|
||||
let node = nodes[index]
|
||||
let project = projects[node.projectIndex]
|
||||
let asset = project.asset(for: node.kind) ?? project.asset(for: .original)
|
||||
guard let text = asset?.previewURL, !text.isEmpty else { return nil }
|
||||
return URL(string: text)
|
||||
}
|
||||
guard !urls.isEmpty else {
|
||||
previewPrefetcher = nil
|
||||
return
|
||||
}
|
||||
let prefetcher = ImagePrefetcher(
|
||||
urls: urls,
|
||||
options: TravelAlbumPreviewImageRequest.previewOptions(viewSize: collectionView.bounds.size)
|
||||
)
|
||||
prefetcher.maxConcurrentDownloads = 2
|
||||
previewPrefetcher = prefetcher
|
||||
prefetcher.start()
|
||||
cell.apply(asset: project.asset(for: .original))
|
||||
}
|
||||
|
||||
private func setPage(_ index: Int, animated: Bool) {
|
||||
@@ -400,8 +374,6 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
self.setPage(self.currentNodeIndex, animated: false)
|
||||
self.isLoadingMore = false
|
||||
self.updateForCurrentNode()
|
||||
self.upgradeCurrentCellToDetailImage()
|
||||
self.prefetchAdjacentImages()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +449,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
}
|
||||
|
||||
@objc private func deleteTapped() {
|
||||
guard let id = currentProject?.originalMaterialId else { return }
|
||||
guard currentProject != nil, !isDeletingProject else { return }
|
||||
let alert = UIAlertController(
|
||||
title: "删除整个项目",
|
||||
message: "将同时删除原图及其全部关联图片,是否继续?",
|
||||
@@ -485,14 +457,87 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.performAction { [actionHandler = self.actionHandler] in
|
||||
await actionHandler.deleteProject(originalMaterialId: id)
|
||||
}
|
||||
self?.deleteCurrentProjectAfterConfirmation()
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 用户确认后删除当前项目;接口成功后才更新预览数据和位置。
|
||||
func deleteCurrentProjectAfterConfirmation() {
|
||||
guard !isDeletingProject,
|
||||
let project = currentProject,
|
||||
let deletedProjectIndex = projects.firstIndex(where: { $0.id == project.id })
|
||||
else { return }
|
||||
isDeletingProject = true
|
||||
updateDeleteButton()
|
||||
|
||||
Task { [weak self, actionHandler] in
|
||||
let result = await actionHandler.deleteProject(originalMaterialId: project.originalMaterialId)
|
||||
guard let self else { return }
|
||||
switch result {
|
||||
case .success(let message):
|
||||
self.applySuccessfulDeletion(
|
||||
project: project,
|
||||
deletedProjectIndex: deletedProjectIndex,
|
||||
message: message
|
||||
)
|
||||
case .failure(let message), .unavailable(let message):
|
||||
self.isDeletingProject = false
|
||||
self.updateDeleteButton()
|
||||
self.showPreviewToast(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applySuccessfulDeletion(
|
||||
project: TravelAlbumPreviewProject,
|
||||
deletedProjectIndex: Int,
|
||||
message: String?
|
||||
) {
|
||||
guard projects.indices.contains(deletedProjectIndex),
|
||||
projects[deletedProjectIndex].id == project.id
|
||||
else {
|
||||
isDeletingProject = false
|
||||
updateDeleteButton()
|
||||
return
|
||||
}
|
||||
|
||||
projects.remove(at: deletedProjectIndex)
|
||||
totalCount = max(0, totalCount - 1)
|
||||
onProjectDeleted?(project.originalMaterialId)
|
||||
|
||||
guard let targetProjectIndex = TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
|
||||
deletedProjectIndex: deletedProjectIndex,
|
||||
remainingProjectCount: projects.count
|
||||
) else {
|
||||
dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
selectedKind = .original
|
||||
rebuildNodes(keepingProjectIndex: targetProjectIndex, kind: .original)
|
||||
collectionView.reloadData()
|
||||
collectionView.layoutIfNeeded()
|
||||
setPage(currentNodeIndex, animated: false)
|
||||
isDeletingProject = false
|
||||
updateDeleteButton()
|
||||
updateForCurrentNode()
|
||||
if let message, !message.isEmpty {
|
||||
showPreviewToast(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDeleteButton() {
|
||||
deleteButton.isEnabled = !isDeletingProject
|
||||
deleteButton.alpha = isDeletingProject ? 0.55 : 1
|
||||
deleteButton.accessibilityValue = isDeletingProject ? "删除中" : nil
|
||||
var configuration = deleteButton.configuration
|
||||
configuration?.showsActivityIndicator = isDeletingProject
|
||||
configuration?.image = isDeletingProject ? nil : UIImage(systemName: "trash")
|
||||
configuration?.title = isDeletingProject ? "删除中" : "删除"
|
||||
deleteButton.configuration = configuration
|
||||
}
|
||||
|
||||
@objc private func refreshTapped() {
|
||||
guard let id = currentProject?.originalMaterialId else { return }
|
||||
performAction { [actionHandler] in await actionHandler.refreshProject(originalMaterialId: id) }
|
||||
@@ -517,8 +562,7 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC
|
||||
let kind = configuration.swipeMode == .projectsOnly && indexPath.item == currentNodeIndex
|
||||
? selectedKind
|
||||
: node.kind
|
||||
let quality: TravelAlbumPreviewImageQuality = indexPath.item == currentNodeIndex ? .detail : .preview
|
||||
cell.apply(asset: project.asset(for: kind) ?? project.asset(for: .original), quality: quality)
|
||||
cell.apply(asset: project.asset(for: kind) ?? project.asset(for: .original))
|
||||
cell.onSingleTap = { [weak self] in self?.toggleChrome() }
|
||||
cell.onZoomChanged = { [weak self] zoomed in
|
||||
self?.collectionView.isScrollEnabled = !zoomed
|
||||
@@ -565,38 +609,6 @@ extension TravelAlbumPhotoPreviewViewController: UICollectionViewDataSource, UIC
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览 Cell 的图片清晰度层级;滑动时使用轻量预览,停稳后升级为高清图。
|
||||
private enum TravelAlbumPreviewImageQuality: String {
|
||||
case preview
|
||||
case detail
|
||||
}
|
||||
|
||||
/// 统一生成预览页图片处理参数,确保展示请求与预取请求共用缓存键。
|
||||
private enum TravelAlbumPreviewImageRequest {
|
||||
static func previewOptions(viewSize: CGSize) -> KingfisherOptionsInfo {
|
||||
options(viewSize: viewSize, sizeMultiplier: 1)
|
||||
}
|
||||
|
||||
static func detailOptions(viewSize: CGSize) -> KingfisherOptionsInfo {
|
||||
options(viewSize: viewSize, sizeMultiplier: 2)
|
||||
}
|
||||
|
||||
private static func options(viewSize: CGSize, sizeMultiplier: CGFloat) -> KingfisherOptionsInfo {
|
||||
let fallbackSize = UIScreen.main.bounds.size
|
||||
let baseSize = viewSize.width > 0 && viewSize.height > 0 ? viewSize : fallbackSize
|
||||
let targetSize = CGSize(
|
||||
width: baseSize.width * sizeMultiplier,
|
||||
height: baseSize.height * sizeMultiplier
|
||||
)
|
||||
return [
|
||||
.processor(DownsamplingImageProcessor(size: targetSize)),
|
||||
.scaleFactor(UIScreen.main.scale),
|
||||
.backgroundDecode,
|
||||
.keepCurrentImageWhileLoading,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// 预览图片 Cell,使用 UIScrollView 提供远程加载、双击和双指缩放。
|
||||
private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
||||
static let reuseIdentifier = "TravelAlbumPreviewImageCell"
|
||||
@@ -607,7 +619,6 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
||||
private let imageView = UIImageView()
|
||||
private let retryButton = UIButton(type: .system)
|
||||
private var asset: TravelAlbumPreviewAsset?
|
||||
private var requestKey: String?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -624,7 +635,6 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
||||
imageView.kf.cancelDownloadTask()
|
||||
imageView.image = nil
|
||||
asset = nil
|
||||
requestKey = nil
|
||||
retryButton.isHidden = true
|
||||
resetZoom()
|
||||
}
|
||||
@@ -634,15 +644,10 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
||||
imageView.frame = scrollView.bounds
|
||||
}
|
||||
|
||||
func apply(asset newAsset: TravelAlbumPreviewAsset?, quality: TravelAlbumPreviewImageQuality) {
|
||||
let urlText = quality == .preview ? newAsset?.previewURL : newAsset?.displayURL
|
||||
let newRequestKey = "\(quality.rawValue):\(urlText ?? "")"
|
||||
let isSameRequest = requestKey == newRequestKey && imageView.image != nil
|
||||
func apply(asset newAsset: TravelAlbumPreviewAsset?) {
|
||||
asset = newAsset
|
||||
resetZoom()
|
||||
if !isSameRequest {
|
||||
loadImage(quality: quality, requestKey: newRequestKey)
|
||||
}
|
||||
loadImage()
|
||||
accessibilityLabel = newAsset.map {
|
||||
"\($0.kind.title),\($0.fileName.isEmpty ? "未命名照片" : $0.fileName)"
|
||||
}
|
||||
@@ -704,21 +709,16 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
||||
scrollView.addGestureRecognizer(doubleTap)
|
||||
}
|
||||
|
||||
private func loadImage(quality: TravelAlbumPreviewImageQuality, requestKey: String) {
|
||||
private func loadImage() {
|
||||
retryButton.isHidden = true
|
||||
let text = quality == .preview ? asset?.previewURL : asset?.displayURL
|
||||
let text = asset?.displayURL
|
||||
guard let text, let url = URL(string: text), !text.isEmpty else {
|
||||
imageView.image = nil
|
||||
self.requestKey = nil
|
||||
retryButton.isHidden = false
|
||||
return
|
||||
}
|
||||
self.requestKey = requestKey
|
||||
let options = quality == .preview
|
||||
? TravelAlbumPreviewImageRequest.previewOptions(viewSize: contentView.bounds.size)
|
||||
: TravelAlbumPreviewImageRequest.detailOptions(viewSize: contentView.bounds.size)
|
||||
imageView.kf.setImage(with: url, options: options) { [weak self] result in
|
||||
guard let self, self.requestKey == requestKey else { return }
|
||||
imageView.kf.setImage(with: url) { [weak self] result in
|
||||
guard let self, self.asset?.displayURL == text else { return }
|
||||
if case .failure = result, self.imageView.image == nil {
|
||||
self.retryButton.isHidden = false
|
||||
}
|
||||
@@ -747,9 +747,7 @@ private final class TravelAlbumPreviewImageCell: UICollectionViewCell, UIScrollV
|
||||
}
|
||||
|
||||
@objc private func retryTapped() {
|
||||
guard let requestKey else { return }
|
||||
let quality: TravelAlbumPreviewImageQuality = requestKey.hasPrefix("preview:") ? .preview : .detail
|
||||
loadImage(quality: quality, requestKey: requestKey)
|
||||
loadImage()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// TravelAlbumAIRetouchTemplateViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// AI 修图模板选择状态测试。
|
||||
@MainActor
|
||||
final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase {
|
||||
func testLoadDefaultsRequiredSelectionsAndLeavesAtmosphereEmpty() async {
|
||||
let api = makeAPI()
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 8,
|
||||
scenicId: 18,
|
||||
materialIds: [4, 3, 2, 1]
|
||||
)
|
||||
|
||||
await viewModel.loadTemplates(api: api)
|
||||
|
||||
XCTAssertEqual(api.aiRetouchTemplateScenicIds, [18])
|
||||
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
|
||||
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
||||
XCTAssertEqual(viewModel.selectedCoverTemplateId, 31)
|
||||
XCTAssertTrue(viewModel.showsCoverTemplates)
|
||||
XCTAssertTrue(viewModel.canSubmit)
|
||||
}
|
||||
|
||||
func testAtmosphereSelectionTogglesOffWhenTappedAgain() async {
|
||||
let api = makeAPI()
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 8,
|
||||
scenicId: 18,
|
||||
materialIds: [1]
|
||||
)
|
||||
await viewModel.loadTemplates(api: api)
|
||||
|
||||
viewModel.toggleTemplate(id: 21, category: .atmosphere)
|
||||
XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 21)
|
||||
|
||||
viewModel.toggleTemplate(id: 21, category: .atmosphere)
|
||||
XCTAssertNil(viewModel.selectedAtmosphereTemplateId)
|
||||
}
|
||||
|
||||
func testThreeMaterialsHideCoverAndOmitCoverFromSubmission() async {
|
||||
let api = makeAPI()
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 8,
|
||||
scenicId: 18,
|
||||
materialIds: [3, 1, 2]
|
||||
)
|
||||
var submitted = false
|
||||
viewModel.onSubmitted = { submitted = true }
|
||||
await viewModel.loadTemplates(api: api)
|
||||
|
||||
XCTAssertFalse(viewModel.showsCoverTemplates)
|
||||
XCTAssertNil(viewModel.selectedCoverTemplateId)
|
||||
|
||||
await viewModel.submit(api: api)
|
||||
|
||||
XCTAssertTrue(submitted)
|
||||
XCTAssertEqual(api.aiRetouchRequests.count, 1)
|
||||
XCTAssertEqual(api.aiRetouchRequests.first?.materialIds, [1, 2, 3])
|
||||
XCTAssertNil(api.aiRetouchRequests.first?.aiPreviewTabs)
|
||||
}
|
||||
|
||||
func testFourMaterialsSubmitAllSelectedTemplateIds() async {
|
||||
let api = makeAPI()
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 8,
|
||||
scenicId: 18,
|
||||
materialIds: [1, 2, 3, 4]
|
||||
)
|
||||
await viewModel.loadTemplates(api: api)
|
||||
viewModel.toggleTemplate(id: 12, category: .refined)
|
||||
viewModel.toggleTemplate(id: 21, category: .atmosphere)
|
||||
|
||||
await viewModel.submit(api: api)
|
||||
|
||||
XCTAssertEqual(
|
||||
api.aiRetouchRequests.first,
|
||||
TravelAlbumAIRetouchRequest(
|
||||
userEquityTravelId: 8,
|
||||
materialIds: [1, 2, 3, 4],
|
||||
refinedTemplateId: 12,
|
||||
atmosphereTemplateId: 21,
|
||||
aiPreviewTabs: 31
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testMissingRequiredCoverTemplateDisablesSubmission() async {
|
||||
let api = makeAPI()
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
refinedTemplates: [template(11, "清透")],
|
||||
atmosphereTemplates: [template(21, "暖阳")],
|
||||
coverTemplates: []
|
||||
)
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 8,
|
||||
scenicId: 18,
|
||||
materialIds: [1, 2, 3, 4]
|
||||
)
|
||||
|
||||
await viewModel.loadTemplates(api: api)
|
||||
|
||||
XCTAssertFalse(viewModel.canSubmit)
|
||||
XCTAssertEqual(viewModel.validationMessage, "暂无可用的封面风格模板")
|
||||
}
|
||||
|
||||
func testLoadFailureExposesRetryMessageAndKeepsSubmissionDisabled() async {
|
||||
let api = makeAPI()
|
||||
api.aiRetouchTemplatesError = APIError.serverCode(500, "模板服务繁忙")
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 8,
|
||||
scenicId: 18,
|
||||
materialIds: [1]
|
||||
)
|
||||
|
||||
await viewModel.loadTemplates(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.loadErrorMessage, "模板服务繁忙")
|
||||
XCTAssertFalse(viewModel.canSubmit)
|
||||
XCTAssertTrue(viewModel.refinedTemplates.isEmpty)
|
||||
}
|
||||
|
||||
func testSubmitFailureKeepsSelectionAndAllowsRetry() async {
|
||||
let api = makeAPI()
|
||||
api.submitAIRetouchError = APIError.serverCode(500, "提交服务繁忙")
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 8,
|
||||
scenicId: 18,
|
||||
materialIds: [1]
|
||||
)
|
||||
var message: String?
|
||||
var submitted = false
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
viewModel.onSubmitted = { submitted = true }
|
||||
await viewModel.loadTemplates(api: api)
|
||||
|
||||
await viewModel.submit(api: api)
|
||||
|
||||
XCTAssertEqual(api.aiRetouchRequests.count, 1)
|
||||
XCTAssertEqual(message, "提交服务繁忙")
|
||||
XCTAssertFalse(submitted)
|
||||
XCTAssertFalse(viewModel.isSubmitting)
|
||||
XCTAssertTrue(viewModel.canSubmit)
|
||||
XCTAssertEqual(viewModel.selectedRefinedTemplateId, 11)
|
||||
}
|
||||
|
||||
func testSubmittingPreventsDuplicateRequest() async {
|
||||
let api = makeAPI()
|
||||
api.submitAIRetouchDelayNanoseconds = 50_000_000
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 8,
|
||||
scenicId: 18,
|
||||
materialIds: [1]
|
||||
)
|
||||
await viewModel.loadTemplates(api: api)
|
||||
|
||||
let firstSubmission = Task { await viewModel.submit(api: api) }
|
||||
await waitUntil { viewModel.isSubmitting }
|
||||
await viewModel.submit(api: api)
|
||||
await firstSubmission.value
|
||||
|
||||
XCTAssertEqual(api.aiRetouchRequests.count, 1)
|
||||
XCTAssertFalse(viewModel.isSubmitting)
|
||||
}
|
||||
|
||||
private func makeAPI() -> TravelAlbumMockAPI {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
refinedTemplates: [template(11, "清透"), template(12, "自然")],
|
||||
atmosphereTemplates: [template(21, "暖阳")],
|
||||
coverTemplates: [template(31, "杂志")]
|
||||
)
|
||||
return api
|
||||
}
|
||||
|
||||
private func template(_ id: Int, _ name: String) -> TravelAlbumAIRetouchTemplate {
|
||||
TravelAlbumAIRetouchTemplate(id: id, name: name, previewURL: "https://cdn.example.com/\(id).jpg")
|
||||
}
|
||||
|
||||
private func waitUntil(_ condition: @escaping () -> Bool) async {
|
||||
for _ in 0 ..< 100 {
|
||||
if condition() { return }
|
||||
await Task.yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,16 +51,27 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
}
|
||||
|
||||
func testMaterialListAndDeleteAndMpCode() async throws {
|
||||
let materialList = envelopeJSON(#"{"total":0,"list":[]}"#)
|
||||
let materialList = envelopeJSON(
|
||||
#"{"total":1,"list":[{"id":6,"user_equity_travel_id":3,"status":1,"order_number":"","user_id":9,"file_name":"A.JPG","file_type":2,"file_url":"https://cdn/a.jpg","file_size":1024,"cover_url":"","is_purchased":false,"ai_retouch_status":3,"ai_retouch_status_name":"AI已修","ai_refined_url":"https://cdn/refined.jpg","ai_atmosphere_url":"https://cdn/atmosphere.jpg","created_at":"","updated_at":""}]}"#
|
||||
)
|
||||
let empty = envelopeJSON("{}")
|
||||
let code = envelopeJSON(#"{"mp_code_oss_url":"https://cdn/qr.png"}"#)
|
||||
let session = MockURLSession(responses: [materialList, empty, code])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
_ = try await api.materialList(userEquityTravelId: 3, page: 1, pageSize: 30, orderBy: 4, isPurchased: 1)
|
||||
let materials = try await api.materialList(
|
||||
userEquityTravelId: 3,
|
||||
page: 1,
|
||||
pageSize: 30,
|
||||
orderBy: 4,
|
||||
isPurchased: 1
|
||||
)
|
||||
try await api.deleteAlbum(id: 3)
|
||||
let response = try await api.mpCode(id: 3)
|
||||
|
||||
XCTAssertEqual(materials.list.first?.aiRetouchStatus, 3)
|
||||
XCTAssertEqual(materials.list.first?.aiRefinedURL, "https://cdn/refined.jpg")
|
||||
XCTAssertEqual(materials.list.first?.aiAtmosphereURL, "https://cdn/atmosphere.jpg")
|
||||
XCTAssertEqual(response.mpCodeOssUrl, "https://cdn/qr.png")
|
||||
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/travel-album/material-list")
|
||||
let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems
|
||||
@@ -113,6 +124,69 @@ final class TravelAlbumAPITests: XCTestCase {
|
||||
XCTAssertEqual(query?.first { $0.name == "user_equity_travel_id" }?.value, "3")
|
||||
}
|
||||
|
||||
func testAIRetouchTemplatesBuildsQueryAndDecodesGroups() async throws {
|
||||
let data = envelopeJSON(
|
||||
#"{"refined_templates":[{"id":1,"name":"清透","preview_url":"https://cdn/refined.jpg"}],"atmosphere_templates":[{"id":2,"name":"暖阳","preview_url":"https://cdn/atmosphere.jpg"}],"cover_templates":[{"id":3,"name":"杂志","preview_url":"https://cdn/cover.jpg"}]}"#
|
||||
)
|
||||
let session = MockURLSession(responses: [data])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
let response = try await api.aiRetouchTemplates(scenicId: 18)
|
||||
|
||||
XCTAssertEqual(response.refinedTemplates.first?.name, "清透")
|
||||
XCTAssertEqual(response.atmosphereTemplates.first?.id, 2)
|
||||
XCTAssertEqual(response.coverTemplates.first?.previewURL, "https://cdn/cover.jpg")
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch-templates")
|
||||
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
|
||||
XCTAssertEqual(query?.first { $0.name == "scenic_id" }?.value, "18")
|
||||
}
|
||||
|
||||
func testSubmitAIRetouchEncodesRequiredAndSelectedOptionalTemplates() async throws {
|
||||
let session = MockURLSession(responses: [envelopeJSON(#"{"task_id":9}"#)])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
try await api.submitAIRetouch(
|
||||
TravelAlbumAIRetouchRequest(
|
||||
userEquityTravelId: 6,
|
||||
materialIds: [11, 12, 13, 14],
|
||||
refinedTemplateId: 21,
|
||||
atmosphereTemplateId: 22,
|
||||
aiPreviewTabs: 31
|
||||
)
|
||||
)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/ai-retouch")
|
||||
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any]
|
||||
XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 6)
|
||||
XCTAssertEqual(body?["material_ids"] as? [Int], [11, 12, 13, 14])
|
||||
XCTAssertEqual(body?["refined_template_id"] as? Int, 21)
|
||||
XCTAssertEqual(body?["atmosphere_template_id"] as? Int, 22)
|
||||
XCTAssertEqual(body?["ai_preview_tabs"] as? Int, 31)
|
||||
}
|
||||
|
||||
func testSubmitAIRetouchOmitsAllOptionalTemplatesWhenAbsent() async throws {
|
||||
let session = MockURLSession(responses: [envelopeJSON(#"{"accepted":true}"#)])
|
||||
let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
try await api.submitAIRetouch(
|
||||
TravelAlbumAIRetouchRequest(
|
||||
userEquityTravelId: 6,
|
||||
materialIds: [11],
|
||||
refinedTemplateId: 21,
|
||||
atmosphereTemplateId: nil,
|
||||
aiPreviewTabs: nil
|
||||
)
|
||||
)
|
||||
|
||||
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any]
|
||||
XCTAssertNil(body?["atmosphere_template_id"])
|
||||
XCTAssertNil(body?["ai_preview_tabs"])
|
||||
}
|
||||
|
||||
private func envelopeJSON(_ dataJSON: String) -> Data {
|
||||
"""
|
||||
{"code":100000,"msg":"success","data":\(dataJSON)}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//
|
||||
// TravelAlbumDetailViewControllerTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 相册管理页选择态底部操作区测试。
|
||||
@MainActor
|
||||
final class TravelAlbumDetailViewControllerTests: XCTestCase {
|
||||
func testPreviewDeleteHandlerCallsMaterialAPIAndPreservesServerFailure() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let handler = TravelAlbumPreviewActionHandler(api: api)
|
||||
|
||||
let success = await handler.deleteProject(originalMaterialId: 8)
|
||||
|
||||
XCTAssertEqual(api.deletedMaterialIds, [8])
|
||||
XCTAssertEqual(success, .success("删除成功"))
|
||||
|
||||
api.deleteMaterialError = APIError.serverCode(500, "服务暂不可用")
|
||||
let failure = await handler.deleteProject(originalMaterialId: 9)
|
||||
|
||||
XCTAssertEqual(api.deletedMaterialIds, [8, 9])
|
||||
XCTAssertEqual(failure, .failure("服务暂不可用"))
|
||||
}
|
||||
|
||||
func testPreviewSuccessfulDeletionShowsNextProjectAndPreventsDuplicateRequests() async throws {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.deleteMaterialDelayNanoseconds = 1_000_000
|
||||
var deletedIds: [Int] = []
|
||||
let controller = TravelAlbumPhotoPreviewViewController(
|
||||
projects: [
|
||||
makePreviewProject(id: 1, fileName: "第一张.jpg"),
|
||||
makePreviewProject(id: 2, fileName: "第二张.jpg"),
|
||||
makePreviewProject(id: 3, fileName: "第三张.jpg"),
|
||||
],
|
||||
totalCount: 3,
|
||||
startProjectIndex: 0,
|
||||
actionHandler: TravelAlbumPreviewActionHandler(api: api),
|
||||
onProjectDeleted: { deletedIds.append($0) }
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
controller.deleteCurrentProjectAfterConfirmation()
|
||||
controller.deleteCurrentProjectAfterConfirmation()
|
||||
await waitUntil {
|
||||
controller.view.allAccessibilityLabels().contains("第 1 张,共 2 张")
|
||||
}
|
||||
|
||||
XCTAssertEqual(api.deletedMaterialIds, [1])
|
||||
XCTAssertEqual(deletedIds, [1])
|
||||
XCTAssertTrue(
|
||||
controller.view.allLabels().contains { $0.text == "第二张.jpg" }
|
||||
)
|
||||
}
|
||||
|
||||
func testPreviewDeletingLastProjectShowsPreviousProject() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let controller = TravelAlbumPhotoPreviewViewController(
|
||||
projects: [
|
||||
makePreviewProject(id: 1, fileName: "第一张.jpg"),
|
||||
makePreviewProject(id: 2, fileName: "第二张.jpg"),
|
||||
makePreviewProject(id: 3, fileName: "第三张.jpg"),
|
||||
],
|
||||
totalCount: 3,
|
||||
startProjectIndex: 2,
|
||||
actionHandler: TravelAlbumPreviewActionHandler(api: api)
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
controller.deleteCurrentProjectAfterConfirmation()
|
||||
await waitUntil {
|
||||
controller.view.allAccessibilityLabels().contains("第 2 张,共 2 张")
|
||||
}
|
||||
|
||||
XCTAssertEqual(api.deletedMaterialIds, [3])
|
||||
XCTAssertTrue(
|
||||
controller.view.allLabels().contains { $0.text == "第二张.jpg" }
|
||||
)
|
||||
}
|
||||
|
||||
func testPreviewDeletingOnlyProjectDismissesPage() async {
|
||||
UIView.setAnimationsEnabled(false)
|
||||
defer { UIView.setAnimationsEnabled(true) }
|
||||
let api = TravelAlbumMockAPI()
|
||||
let host = UIViewController()
|
||||
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
|
||||
window.rootViewController = host
|
||||
window.makeKeyAndVisible()
|
||||
let controller = TravelAlbumPhotoPreviewViewController(
|
||||
projects: [makePreviewProject(id: 1, fileName: "仅有一张.jpg")],
|
||||
totalCount: 1,
|
||||
startProjectIndex: 0,
|
||||
actionHandler: TravelAlbumPreviewActionHandler(api: api)
|
||||
)
|
||||
host.present(controller, animated: false)
|
||||
await waitUntil { controller.presentingViewController === host }
|
||||
|
||||
controller.deleteCurrentProjectAfterConfirmation()
|
||||
await waitUntil { host.presentedViewController == nil }
|
||||
|
||||
XCTAssertEqual(api.deletedMaterialIds, [1])
|
||||
XCTAssertNil(host.presentedViewController)
|
||||
window.isHidden = true
|
||||
}
|
||||
|
||||
func testSelectionModeShowsAIRetouchOnLeftAndDeleteOnRight() async throws {
|
||||
let controller = TravelAlbumDetailViewController(albumId: 0)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
|
||||
let selectButton = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
($0 as? UIButton)?.accessibilityLabel == "选择照片"
|
||||
} as? UIButton
|
||||
)
|
||||
selectButton.sendActions(for: .touchUpInside)
|
||||
await Task.yield()
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
let actionStack = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.selectionActionStack"
|
||||
} as? UIStackView
|
||||
)
|
||||
let aiRetouchButton = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchButton"
|
||||
} as? UIButton
|
||||
)
|
||||
let deleteButton = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.deleteButton"
|
||||
} as? UIButton
|
||||
)
|
||||
|
||||
XCTAssertFalse(actionStack.isHidden)
|
||||
XCTAssertEqual(actionStack.arrangedSubviews, [aiRetouchButton, deleteButton])
|
||||
XCTAssertEqual(actionStack.distribution, .fillEqually)
|
||||
XCTAssertEqual(actionStack.spacing, 12)
|
||||
XCTAssertEqual(aiRetouchButton.configuration?.title, "AI修图")
|
||||
XCTAssertEqual(deleteButton.configuration?.title, "删除")
|
||||
XCTAssertEqual(aiRetouchButton.bounds.width, deleteButton.bounds.width, accuracy: 0.5)
|
||||
XCTAssertLessThan(aiRetouchButton.frame.minX, deleteButton.frame.minX)
|
||||
}
|
||||
|
||||
func testAIRetouchTemplateSheetUsesLargeDetentAndFixedBottomActions() async throws {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
|
||||
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
|
||||
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")]
|
||||
)
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 9,
|
||||
scenicId: 18,
|
||||
materialIds: [1, 2, 3, 4]
|
||||
)
|
||||
let controller = TravelAlbumAIRetouchTemplateViewController(
|
||||
viewModel: viewModel,
|
||||
api: api,
|
||||
onSubmitted: {}
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 }
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
let sheet = try XCTUnwrap(controller.sheetPresentationController)
|
||||
XCTAssertEqual(sheet.detents.count, 1)
|
||||
XCTAssertEqual(sheet.selectedDetentIdentifier, .large)
|
||||
XCTAssertTrue(sheet.prefersGrabberVisible)
|
||||
|
||||
let collectionView = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection"
|
||||
} as? UICollectionView
|
||||
)
|
||||
let bottomBar = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchBottomBar"
|
||||
}
|
||||
)
|
||||
let cancelButton = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchCancelButton"
|
||||
} as? UIButton
|
||||
)
|
||||
let confirmButton = try XCTUnwrap(
|
||||
controller.view.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton"
|
||||
} as? UIButton
|
||||
)
|
||||
await waitUntil { confirmButton.isEnabled }
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
XCTAssertTrue(collectionView.collectionViewLayout is UICollectionViewCompositionalLayout)
|
||||
XCTAssertFalse(bottomBar.isDescendant(of: collectionView))
|
||||
XCTAssertEqual(cancelButton.configuration?.title, "取消")
|
||||
XCTAssertEqual(confirmButton.configuration?.title, "确定")
|
||||
XCTAssertEqual(cancelButton.bounds.width, confirmButton.bounds.width, accuracy: 0.5)
|
||||
XCTAssertTrue(confirmButton.isEnabled)
|
||||
}
|
||||
|
||||
func testAIRetouchSheetShowsOptionalAtmosphereCoverAndModeCopy() async throws {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse(
|
||||
refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")],
|
||||
atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")],
|
||||
coverTemplates: [TravelAlbumAIRetouchTemplate(id: 3, name: "杂志", previewURL: "")]
|
||||
)
|
||||
let viewModel = TravelAlbumAIRetouchTemplateViewModel(
|
||||
albumId: 9,
|
||||
scenicId: 18,
|
||||
materialIds: [1, 2, 3, 4]
|
||||
)
|
||||
let controller = TravelAlbumAIRetouchTemplateViewController(
|
||||
viewModel: viewModel,
|
||||
api: api,
|
||||
onSubmitted: {}
|
||||
)
|
||||
controller.loadViewIfNeeded()
|
||||
controller.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
|
||||
await waitUntil { !viewModel.isLoading && api.aiRetouchTemplateScenicIds.count == 1 }
|
||||
controller.view.layoutIfNeeded()
|
||||
|
||||
let accessibleLabels = controller.view.allAccessibilityLabels()
|
||||
XCTAssertTrue(accessibleLabels.contains("原图精修"))
|
||||
XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填"))
|
||||
XCTAssertTrue(accessibleLabels.contains("封面风格模板"))
|
||||
|
||||
let modeCell = TravelAlbumAIRetouchModeCell(frame: .zero)
|
||||
modeCell.apply()
|
||||
XCTAssertEqual(modeCell.accessibilityLabel, "AI精修,按张收费,剩余张数暂不可用")
|
||||
}
|
||||
|
||||
func testAIRetouchTemplateCellExposesSelectedState() {
|
||||
let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero)
|
||||
let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")
|
||||
|
||||
cell.apply(template: template, selected: true)
|
||||
|
||||
XCTAssertEqual(cell.contentView.layer.borderWidth, 2)
|
||||
XCTAssertTrue(cell.accessibilityTraits.contains(.selected))
|
||||
XCTAssertEqual(cell.accessibilityValue, "已选择")
|
||||
|
||||
cell.apply(template: template, selected: false)
|
||||
|
||||
XCTAssertEqual(cell.contentView.layer.borderWidth, 1)
|
||||
XCTAssertFalse(cell.accessibilityTraits.contains(.selected))
|
||||
XCTAssertEqual(cell.accessibilityValue, "未选择")
|
||||
}
|
||||
|
||||
func testMaterialCellShowsSemanticStatusBadgeWithoutOverlappingSelectionCheck() throws {
|
||||
let cases: [(TravelAlbumMaterial, String, UInt)] = [
|
||||
(TravelAlbumMaterial(isPurchased: true), "已购", 0x475569),
|
||||
(TravelAlbumMaterial(aiRetouchStatus: 1), "待处理", 0xB45309),
|
||||
(TravelAlbumMaterial(aiRetouchStatus: 2), "修图中", 0x1D4ED8),
|
||||
(TravelAlbumMaterial(aiRetouchStatus: 3), "AI已修", 0x047857),
|
||||
(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面"),
|
||||
"AI封面",
|
||||
0x6D28D9
|
||||
),
|
||||
(TravelAlbumMaterial(aiRetouchStatus: 4), "失败", 0xB91C1C),
|
||||
]
|
||||
|
||||
for (material, expectedText, expectedColor) in cases {
|
||||
let cell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
||||
cell.apply(material: material, selectionMode: true, selected: false)
|
||||
cell.layoutIfNeeded()
|
||||
|
||||
let badgeView = try XCTUnwrap(
|
||||
cell.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
|
||||
}
|
||||
)
|
||||
let badgeLabel = try XCTUnwrap(
|
||||
cell.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadgeLabel"
|
||||
} as? UILabel
|
||||
)
|
||||
let selectionCheck = try XCTUnwrap(
|
||||
cell.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialSelectionCheck"
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertFalse(badgeView.isHidden)
|
||||
XCTAssertEqual(badgeLabel.text, expectedText)
|
||||
XCTAssertEqual(badgeView.backgroundColor?.travelAlbumTestHexRGB, expectedColor)
|
||||
XCTAssertLessThan(badgeView.frame.maxX, selectionCheck.frame.minX)
|
||||
XCTAssertTrue(cell.accessibilityLabel?.contains("状态:\(expectedText)") == true)
|
||||
}
|
||||
|
||||
let hiddenCell = TravelAlbumMaterialCell(frame: CGRect(x: 0, y: 0, width: 176, height: 220))
|
||||
hiddenCell.apply(
|
||||
material: TravelAlbumMaterial(aiRetouchStatus: 0, aiRetouchStatusName: "已上传"),
|
||||
selectionMode: false,
|
||||
selected: false
|
||||
)
|
||||
let hiddenBadge = try XCTUnwrap(
|
||||
hiddenCell.findSubview {
|
||||
$0.accessibilityIdentifier == "travelAlbum.materialStatusBadge"
|
||||
}
|
||||
)
|
||||
XCTAssertTrue(hiddenBadge.isHidden)
|
||||
XCTAssertFalse(hiddenCell.accessibilityLabel?.contains("状态:") == true)
|
||||
}
|
||||
|
||||
private func waitUntil(_ condition: @escaping () -> Bool) async {
|
||||
for _ in 0 ..< 200 {
|
||||
if condition() { return }
|
||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func makePreviewProject(id: Int, fileName: String) -> TravelAlbumPreviewProject {
|
||||
TravelAlbumPreviewProject(
|
||||
originalMaterialId: id,
|
||||
assets: [
|
||||
TravelAlbumPreviewAsset(
|
||||
id: "original-\(id)",
|
||||
kind: .original,
|
||||
fileURL: "",
|
||||
coverURL: "",
|
||||
fileName: fileName,
|
||||
fileSize: 0
|
||||
),
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
var travelAlbumTestHexRGB: UInt {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
return UInt(round(red * 255)) << 16
|
||||
| UInt(round(green * 255)) << 8
|
||||
| UInt(round(blue * 255))
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIView {
|
||||
func findSubview(where predicate: (UIView) -> Bool) -> UIView? {
|
||||
if predicate(self) { return self }
|
||||
for subview in subviews {
|
||||
if let match = subview.findSubview(where: predicate) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func allAccessibilityLabels() -> [String] {
|
||||
let current = accessibilityLabel.map { [$0] } ?? []
|
||||
return current + subviews.flatMap { $0.allAccessibilityLabels() }
|
||||
}
|
||||
|
||||
func allLabels() -> [UILabel] {
|
||||
let current = (self as? UILabel).map { [$0] } ?? []
|
||||
return current + subviews.flatMap { $0.allLabels() }
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,46 @@ final class TravelAlbumModelsTests: XCTestCase {
|
||||
XCTAssertFalse(project.hasVariants)
|
||||
}
|
||||
|
||||
func testPreviewProjectMapsNonemptyAIResultURLsInCanonicalOrder() {
|
||||
let material = TravelAlbumMaterial(
|
||||
id: 8,
|
||||
fileName: "IMG_0008.JPG",
|
||||
fileUrl: "https://cdn.example.com/original.jpg",
|
||||
fileSize: 4096,
|
||||
coverUrl: "https://cdn.example.com/cover.jpg",
|
||||
aiRefinedURL: " https://cdn.example.com/refined.jpg ",
|
||||
aiAtmosphereURL: "https://cdn.example.com/atmosphere.jpg"
|
||||
)
|
||||
|
||||
let project = TravelAlbumPreviewProject(material: material)
|
||||
|
||||
XCTAssertEqual(project.orderedAssets.map(\.kind), [.original, .retouched, .atmosphere])
|
||||
XCTAssertEqual(
|
||||
project.asset(for: .retouched)?.displayURL,
|
||||
"https://cdn.example.com/refined.jpg"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
project.asset(for: .atmosphere)?.previewURL,
|
||||
"https://cdn.example.com/atmosphere.jpg"
|
||||
)
|
||||
XCTAssertNil(project.asset(for: .cover))
|
||||
XCTAssertTrue(project.hasVariants)
|
||||
}
|
||||
|
||||
func testPreviewProjectIgnoresBlankAIResultURLs() {
|
||||
let material = TravelAlbumMaterial(
|
||||
id: 8,
|
||||
fileUrl: "https://cdn.example.com/original.jpg",
|
||||
aiRefinedURL: " \n ",
|
||||
aiAtmosphereURL: "\t"
|
||||
)
|
||||
|
||||
XCTAssertEqual(
|
||||
TravelAlbumPreviewProject(material: material).orderedAssets.map(\.kind),
|
||||
[.original]
|
||||
)
|
||||
}
|
||||
|
||||
func testPreviewAssetFallsBackToAvailableURLForBothQualityLevels() {
|
||||
let missingOriginal = TravelAlbumPreviewAsset(
|
||||
id: "cover-only",
|
||||
@@ -91,6 +131,36 @@ final class TravelAlbumModelsTests: XCTestCase {
|
||||
XCTAssertEqual(nodes[target], TravelAlbumPreviewNode(projectIndex: 0, kind: .original))
|
||||
}
|
||||
|
||||
func testPreviewDeletionPrefersNextThenFallsBackToPreviousAndClosesWhenEmpty() {
|
||||
XCTAssertEqual(
|
||||
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
|
||||
deletedProjectIndex: 0,
|
||||
remainingProjectCount: 2
|
||||
),
|
||||
0
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
|
||||
deletedProjectIndex: 1,
|
||||
remainingProjectCount: 2
|
||||
),
|
||||
1
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
|
||||
deletedProjectIndex: 2,
|
||||
remainingProjectCount: 2
|
||||
),
|
||||
1
|
||||
)
|
||||
XCTAssertNil(
|
||||
TravelAlbumPreviewNavigator.projectIndexAfterDeletion(
|
||||
deletedProjectIndex: 0,
|
||||
remainingProjectCount: 0
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPlaceholderPreviewActionsReturnUnavailableWithoutMutation() async {
|
||||
let handler = PlaceholderTravelAlbumPreviewActionHandler()
|
||||
let aiResult = await handler.requestAIRetouch(originalMaterialId: 9)
|
||||
@@ -153,6 +223,10 @@ final class TravelAlbumModelsTests: XCTestCase {
|
||||
"file_size": 2048,
|
||||
"cover_url": "",
|
||||
"is_purchased": false,
|
||||
"ai_retouch_status": 3,
|
||||
"ai_retouch_status_name": "AI已修",
|
||||
"ai_refined_url": "https://cdn/refined.jpg",
|
||||
"ai_atmosphere_url": "https://cdn/atmosphere.jpg",
|
||||
"created_at": "",
|
||||
"updated_at": ""
|
||||
}
|
||||
@@ -162,6 +236,70 @@ final class TravelAlbumModelsTests: XCTestCase {
|
||||
XCTAssertEqual(material.userEquityTravelId, 7)
|
||||
XCTAssertEqual(material.fileName, "IMG_0001.JPG")
|
||||
XCTAssertFalse(material.isPurchased)
|
||||
XCTAssertEqual(material.aiRetouchStatus, 3)
|
||||
XCTAssertEqual(material.aiRetouchStatusName, "AI已修")
|
||||
XCTAssertEqual(material.aiRefinedURL, "https://cdn/refined.jpg")
|
||||
XCTAssertEqual(material.aiAtmosphereURL, "https://cdn/atmosphere.jpg")
|
||||
}
|
||||
|
||||
func testTravelAlbumMaterialDefaultsInvalidAIFields() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 11,
|
||||
"user_equity_travel_id": 7,
|
||||
"status": 1,
|
||||
"order_number": "",
|
||||
"user_id": 9,
|
||||
"file_name": "IMG_0001.JPG",
|
||||
"file_type": 2,
|
||||
"file_url": "https://cdn/a.jpg",
|
||||
"file_size": 2048,
|
||||
"cover_url": "",
|
||||
"is_purchased": false,
|
||||
"ai_retouch_status": "invalid",
|
||||
"ai_retouch_status_name": null,
|
||||
"ai_refined_url": 123,
|
||||
"created_at": "",
|
||||
"updated_at": ""
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let material = try JSONDecoder().decode(TravelAlbumMaterial.self, from: json)
|
||||
|
||||
XCTAssertEqual(material.aiRetouchStatus, 0)
|
||||
XCTAssertEqual(material.aiRetouchStatusName, "")
|
||||
XCTAssertEqual(material.aiRefinedURL, "")
|
||||
XCTAssertEqual(material.aiAtmosphereURL, "")
|
||||
}
|
||||
|
||||
func testTravelAlbumMaterialBadgePriorityAndFallbacks() {
|
||||
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 0).badgePresentation)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(isPurchased: true, aiRetouchStatus: 0, aiRetouchStatusName: "已上传")
|
||||
.badgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .purchased, text: "已购")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 1, aiRetouchStatusName: " 排队中 ").badgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .pending, text: "排队中")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 2, aiRetouchStatusName: " ").badgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .processing, text: "修图中")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 3).badgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .retouched, text: "AI已修")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 3, aiRetouchStatusName: "AI封面").badgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .cover, text: "AI封面")
|
||||
)
|
||||
XCTAssertEqual(
|
||||
TravelAlbumMaterial(aiRetouchStatus: 4).badgePresentation,
|
||||
TravelAlbumMaterialBadgePresentation(kind: .failed, text: "失败")
|
||||
)
|
||||
XCTAssertNil(TravelAlbumMaterial(aiRetouchStatus: 99, aiRetouchStatusName: "未知").badgePresentation)
|
||||
}
|
||||
|
||||
func testDisplayFormatters() {
|
||||
|
||||
@@ -173,6 +173,39 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.selectedMaterialIds, [1])
|
||||
}
|
||||
|
||||
func testCompletingAIRetouchClearsSelectionModeAndSelectedMaterials() {
|
||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||
viewModel.toggleSelectionMode()
|
||||
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 1, status: 1))
|
||||
|
||||
viewModel.completeAIRetouchSubmission()
|
||||
|
||||
XCTAssertFalse(viewModel.isSelectionMode)
|
||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||
}
|
||||
|
||||
func testPreviewDeletionRemovesMaterialAndSynchronizesCounts() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
api.materialListResponses = [
|
||||
TravelAlbumListResponse(
|
||||
total: 3,
|
||||
list: [
|
||||
TravelAlbumMaterial(id: 1),
|
||||
TravelAlbumMaterial(id: 2, isPurchased: true),
|
||||
TravelAlbumMaterial(id: 3),
|
||||
]
|
||||
),
|
||||
]
|
||||
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||
await viewModel.loadMaterials(reset: true, api: api)
|
||||
|
||||
viewModel.removeMaterialAfterPreviewDeletion(id: 2)
|
||||
|
||||
XCTAssertEqual(viewModel.materials.map(\.id), [1, 3])
|
||||
XCTAssertEqual(viewModel.allPhotoCount, 2)
|
||||
XCTAssertEqual(viewModel.purchasedPhotoCount, 0)
|
||||
}
|
||||
|
||||
func testDeleteAlbumCallsCallback() async {
|
||||
let api = TravelAlbumMockAPI()
|
||||
let viewModel = TravelAlbumDetailViewModel(albumId: 5)
|
||||
@@ -1015,7 +1048,13 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
var uploadMaterialResponse = TravelAlbumMaterial()
|
||||
var materialClientPhotoIdsResponse = TravelAlbumMaterialClientPhotoIDsResponse(clientPhotoIds: [])
|
||||
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
|
||||
var aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse()
|
||||
var createError: Error?
|
||||
var aiRetouchTemplatesError: Error?
|
||||
var submitAIRetouchError: Error?
|
||||
var submitAIRetouchDelayNanoseconds: UInt64 = 0
|
||||
var deleteMaterialError: Error?
|
||||
var deleteMaterialDelayNanoseconds: UInt64 = 0
|
||||
|
||||
private(set) var availableOrdersCallCount = 0
|
||||
private(set) var createRequests: [TravelAlbumCreateRequest] = []
|
||||
@@ -1024,6 +1063,8 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
private(set) var materialClientPhotoIdsCallCount = 0
|
||||
private(set) var deletedAlbumIds: [Int] = []
|
||||
private(set) var deletedMaterialIds: [Int] = []
|
||||
private(set) var aiRetouchTemplateScenicIds: [Int] = []
|
||||
private(set) var aiRetouchRequests: [TravelAlbumAIRetouchRequest] = []
|
||||
|
||||
func availableOrders() async throws -> [TravelAlbumAvailableOrder] {
|
||||
availableOrdersCallCount += 1
|
||||
@@ -1084,9 +1125,27 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
|
||||
|
||||
func deleteMaterial(id: Int) async throws {
|
||||
deletedMaterialIds.append(id)
|
||||
if deleteMaterialDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: deleteMaterialDelayNanoseconds)
|
||||
}
|
||||
if let deleteMaterialError { throw deleteMaterialError }
|
||||
}
|
||||
|
||||
func mpCode(id: Int) async throws -> TravelAlbumMpCodeResponse {
|
||||
mpCodeResponse
|
||||
}
|
||||
|
||||
func aiRetouchTemplates(scenicId: Int) async throws -> TravelAlbumAIRetouchTemplatesResponse {
|
||||
aiRetouchTemplateScenicIds.append(scenicId)
|
||||
if let aiRetouchTemplatesError { throw aiRetouchTemplatesError }
|
||||
return aiRetouchTemplatesResponse
|
||||
}
|
||||
|
||||
func submitAIRetouch(_ request: TravelAlbumAIRetouchRequest) async throws {
|
||||
aiRetouchRequests.append(request)
|
||||
if submitAIRetouchDelayNanoseconds > 0 {
|
||||
try await Task.sleep(nanoseconds: submitAIRetouchDelayNanoseconds)
|
||||
}
|
||||
if let submitAIRetouchError { throw submitAIRetouchError }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user