diff --git a/suixinkan/Features/TravelAlbum/AI修图接口文档.md b/suixinkan/Features/TravelAlbum/AI修图接口文档.md index 4b19ffb..b94d53a 100644 --- a/suixinkan/Features/TravelAlbum/AI修图接口文档.md +++ b/suixinkan/Features/TravelAlbum/AI修图接口文档.md @@ -86,10 +86,10 @@ GET /api/yf-handset-app/photog/travel-album/ai-retouch-options?user_equity_trave | `scope` | 使用场景 | |---|---| -| `batch` | 网格多选 AI 修图 | -| `all_variants` | 预览页无 Tab,或当前是原图 Tab | -| `refined_only` | 当前是精修后 Tab | -| `atmosphere_only` | 当前是氛围感 Tab | +| `batch` | 网格多选 AI 修图,或预览页尚无 AI 结果 Tab 时首次修图 | +| `all_variants` | 预览页已有 AI 结果 Tab;当前为原图、精修后或氛围感均使用此范围 | +| `refined_only` | 仅重修精修结果的接口能力,当前客户端无独立入口 | +| `atmosphere_only` | 仅重修氛围感结果的接口能力,当前客户端无独立入口 | `user_equity_travel_id`、`scope` 和 `source_count` 均必填。`source_count` 用于计算封面模板是否显示和必选。 @@ -226,7 +226,7 @@ Content-Type: application/json } ``` -#### 5.4.2 预览页原图 Tab +#### 5.4.2 预览页已有 AI 结果 Tab ```json { @@ -234,10 +234,6 @@ Content-Type: application/json "scope": "all_variants", "source_material_ids": ["2031"], "outputs": [ - { - "type": "refined", - "template_id": "tpl_refined_15" - }, { "type": "atmosphere", "template_id": "tpl_atmosphere_09" @@ -246,7 +242,7 @@ Content-Type: application/json } ``` -如用户未选氛围感模板,`outputs` 中不传 `atmosphere`。缺失表示“本次不处理”,不表示删除现有氛围感图。 +已有精修或氛围感结果时,无论当前位于原图、精修后还是氛围感 Tab,精修和氛围感模板均为选填,但至少选择一种。`outputs` 只传本次选中的结果类型;缺失表示“本次不处理”,不表示删除或覆盖对应的已有结果。 #### 5.4.3 只重新精修 @@ -285,7 +281,7 @@ Content-Type: application/json | `scope` | 原图数 | 精修 | 氛围感 | 封面 | |---|---:|---|---|---| | `batch` | 1 至 50 | 必选 | 可选 | 少于 4 张禁止;4 张及以上必选 | -| `all_variants` | 必须为 1 | 必选 | 可选 | 禁止 | +| `all_variants` | 必须为 1 | 可选(与氛围感至少一项) | 可选(与精修至少一项) | 禁止 | | `refined_only` | 必须为 1 | 必选 | 禁止 | 禁止 | | `atmosphere_only` | 必须为 1 | 禁止 | 必选 | 禁止 | diff --git a/suixinkan/Features/TravelAlbum/AI修图需求.md b/suixinkan/Features/TravelAlbum/AI修图需求.md index a5ef74f..54d9d2d 100644 --- a/suixinkan/Features/TravelAlbum/AI修图需求.md +++ b/suixinkan/Features/TravelAlbum/AI修图需求.md @@ -114,9 +114,9 @@ Cell 左上角展示稳定的修图状态: | 当前情况 | 模板要求 | 生成与覆盖规则 | |---|---|---| | 不显示 Tab | 精修必选,氛围感可选 | 首次生成关联的精修图,可选生成氛围感图 | -| 已显示 Tab,当前为“原图” | 精修必选,氛围感可选 | 重新生成精修图;如选氛围感则也重新生成。新结果成功后原子替换对应旧结果 | -| 已显示 Tab,当前为“精修后” | 精修必选 | 只重新生成精修图,成功后覆盖旧精修图,不影响氛围感图 | -| 已显示 Tab,当前为“氛围感” | 氛围感必选 | 只重新生成氛围感图,成功后覆盖旧氛围感图,不影响精修图 | +| 已显示 Tab,当前为“原图” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 | +| 已显示 Tab,当前为“精修后” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 | +| 已显示 Tab,当前为“氛围感” | 精修、氛围感均可选,至少选择一种 | 只重新生成本次选中的结果类型;新结果成功后替换对应旧结果,未选类型保持不变 | 重新修图期间应保留上一版成功图片可见;只有新结果成功时才原子替换当前版本。失败时继续保留旧版本,并返回可展示的错误信息。 diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift index a9b0e6e..eb8db99 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -387,14 +387,13 @@ enum TravelAlbumAIRetouchWorkflow: Sendable, Equatable { } } - /// 当前分组是否允许不选择;首次修图和原图 Tab 覆盖重修的氛围感模板选填。 + /// 当前分组是否允许不选择;首次修图仅氛围感选填,已有 AI 结果的预览重修两类模板均选填。 func isOptional(_ category: TravelAlbumAIRetouchTemplateCategory) -> Bool { - guard category == .atmosphere else { return false } switch self { case .initial: - return true + return category == .atmosphere case .reretouch(_, _, .all): - return true + return category == .refined || category == .atmosphere case .reretouch: return false } diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift index 32d5024..bc77069 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumPreviewModels.swift @@ -175,7 +175,7 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable { self.assets = assets.filter { seenKinds.insert($0.kind).inserted } } - /// 根据当前 Tab 生成首次修图或覆盖重修工作流。 + /// 根据是否已有 AI 结果生成首次修图或双模板覆盖重修工作流。 func aiRetouchWorkflow( albumId: Int, selectedKind: TravelAlbumPreviewAssetKind @@ -183,16 +183,8 @@ struct TravelAlbumPreviewProject: Identifiable, Sendable, Hashable { guard hasVariants else { return .initial(albumId: albumId, materialIds: [originalMaterialId]) } - switch selectedKind { - case .original: - return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all) - case .retouched: - return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .refined) - case .atmosphere: - return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .atmosphere) - case .cover: - return nil - } + guard selectedKind != .cover else { return nil } + return .reretouch(materialId: originalMaterialId, batchId: aiRetouchBatchId, type: .all) } } diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift index 7bb4bba..727bedb 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumAIRetouchTemplateViewModel.swift @@ -90,7 +90,8 @@ final class TravelAlbumAIRetouchTemplateViewModel { case .refined, .atmosphere: return 1 case .all: - return 1 + (selectedAtmosphereTemplateId == nil ? 0 : 1) + return (selectedRefinedTemplateId == nil ? 0 : 1) + + (selectedAtmosphereTemplateId == nil ? 0 : 1) } } } @@ -109,6 +110,11 @@ final class TravelAlbumAIRetouchTemplateViewModel { return unavailableMessage(for: category) } } + if case .reretouch(_, _, .all) = workflow, + selectedRefinedTemplateId == nil, + selectedAtmosphereTemplateId == nil { + return "请至少选择一个修图模板" + } guard let remainingQuota else { return "剩余修图次数获取失败,请刷新后重试" } @@ -146,7 +152,9 @@ final class TravelAlbumAIRetouchTemplateViewModel { atmosphereTemplates = response.atmosphereTemplates coverTemplates = response.coverTemplates remainingQuota = response.remainingQuota - selectedRefinedTemplateId = visibleCategories.contains(.refined) ? refinedTemplates.first?.id : nil + selectedRefinedTemplateId = visibleCategories.contains(.refined) && !isOptional(.refined) + ? refinedTemplates.first?.id + : nil selectedAtmosphereTemplateId = visibleCategories.contains(.atmosphere) && !isOptional(.atmosphere) ? atmosphereTemplates.first?.id : nil @@ -188,14 +196,14 @@ final class TravelAlbumAIRetouchTemplateViewModel { } } - /// 选择模板;仅选填分组允许再次点击取消,必选分组保持单选。 + /// 选择模板;选填分组允许再次点击取消,必选分组保持单选。 func toggleTemplate(id: Int, category: TravelAlbumAIRetouchTemplateCategory) { guard visibleCategories.contains(category), templates(for: category).contains(where: { $0.id == id }) else { return } switch category { case .refined: - selectedRefinedTemplateId = id + selectedRefinedTemplateId = isOptional(category) && selectedRefinedTemplateId == id ? nil : id case .atmosphere: selectedAtmosphereTemplateId = isOptional(category) && selectedAtmosphereTemplateId == id ? nil : id case .cover: diff --git a/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift index c384c04..f19adb5 100644 --- a/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift +++ b/suixinkanTests/TravelAlbumAIRetouchTemplateViewModelTests.swift @@ -102,7 +102,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { ) } - func testReretouchWorkflowShowsAndSubmitsOnlyRequiredCategories() async { + func testReretouchWorkflowAllowsAtmosphereOnlyFromOriginalTab() async { let api = makeAPI() let refined = TravelAlbumAIRetouchTemplateViewModel( scenicId: 18, @@ -133,9 +133,19 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { ) await all.loadTemplates(api: api) XCTAssertEqual(all.visibleCategories, [.refined, .atmosphere]) - XCTAssertEqual(all.selectedRefinedTemplateId, 11) + XCTAssertNil(all.selectedRefinedTemplateId) XCTAssertNil(all.selectedAtmosphereTemplateId) + XCTAssertTrue(all.isOptional(.refined)) XCTAssertTrue(all.isOptional(.atmosphere)) + XCTAssertEqual(all.requiredQuota, 0) + XCTAssertFalse(all.canSubmit) + XCTAssertEqual(all.validationMessage, "请至少选择一个修图模板") + + all.toggleTemplate(id: 11, category: .refined) + XCTAssertEqual(all.selectedRefinedTemplateId, 11) + all.toggleTemplate(id: 11, category: .refined) + XCTAssertNil(all.selectedRefinedTemplateId) + all.toggleTemplate(id: 21, category: .atmosphere) XCTAssertEqual(all.requiredQuota, 1) XCTAssertTrue(all.canSubmit) await all.submit(api: api) @@ -159,8 +169,8 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { id: 9, aiRetouchBatchId: 90, type: .all, - refinedTemplateId: 11, - atmosphereTemplateId: nil + refinedTemplateId: nil, + atmosphereTemplateId: 21 ), ]) } @@ -183,7 +193,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { XCTAssertTrue(api.aiReretouchRequests.isEmpty) } - func testMissingOptionalAtmosphereDoesNotDisableAllReretouchWorkflow() async { + func testMissingOptionalAtmosphereStillAllowsSelectingRefinedInAllReretouchWorkflow() async { let api = makeAPI() api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( refinedTemplates: [template(11, "清透")], @@ -198,6 +208,8 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { await viewModel.loadTemplates(api: api) + XCTAssertFalse(viewModel.canSubmit) + viewModel.toggleTemplate(id: 11, category: .refined) XCTAssertTrue(viewModel.canSubmit) XCTAssertNil(viewModel.validationMessage) XCTAssertEqual(viewModel.requiredQuota, 1) @@ -213,7 +225,7 @@ final class TravelAlbumAIRetouchTemplateViewModelTests: XCTestCase { ) let reretouch = TravelAlbumAIRetouchTemplateViewModel( scenicId: 18, - workflow: .reretouch(materialId: 7, batchId: 70, type: .all) + workflow: .reretouch(materialId: 7, batchId: 70, type: .refined) ) await reretouch.loadTemplates(api: refinedAPI) XCTAssertFalse(reretouch.canSubmit) diff --git a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift index ed1515b..b63939f 100644 --- a/suixinkanTests/TravelAlbumDetailViewControllerTests.swift +++ b/suixinkanTests/TravelAlbumDetailViewControllerTests.swift @@ -918,6 +918,43 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertEqual(quotaLabel.accessibilityLabel, "剩余8张修图额度") } + func testOriginalTabReretouchSheetShowsBothTemplateGroupsAsOptional() async throws { + let api = TravelAlbumMockAPI() + api.aiRetouchTemplatesResponse = TravelAlbumAIRetouchTemplatesResponse( + refinedTemplates: [TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "")], + atmosphereTemplates: [TravelAlbumAIRetouchTemplate(id: 2, name: "暖阳", previewURL: "")], + remainingQuota: 8 + ) + let viewModel = TravelAlbumAIRetouchTemplateViewModel( + scenicId: 18, + workflow: .reretouch(materialId: 7, batchId: 70, type: .all) + ) + let controller = TravelAlbumAIRetouchTemplateViewController( + viewModel: viewModel, + api: api, + onSubmitted: { _ in } + ) + 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 confirmButton = try XCTUnwrap( + controller.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton" + } as? UIButton + ) + let accessibleLabels = controller.view.allAccessibilityLabels() + XCTAssertTrue(accessibleLabels.contains("原图精修,选填")) + XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填")) + XCTAssertFalse(confirmButton.isEnabled) + + viewModel.toggleTemplate(id: 2, category: .atmosphere) + await waitUntil { confirmButton.isEnabled } + XCTAssertNil(viewModel.selectedRefinedTemplateId) + XCTAssertEqual(viewModel.selectedAtmosphereTemplateId, 2) + } + func testAIRetouchTemplateCellExposesSelectedState() { let cell = TravelAlbumAIRetouchTemplateCell(frame: .zero) let template = TravelAlbumAIRetouchTemplate(id: 1, name: "清透", previewURL: "") @@ -1039,7 +1076,7 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertEqual(comparisonButton.bounds.size, CGSize(width: 48, height: 48)) } - func testPreviewAIRetouchUsesSelectedRetouchedTabWorkflowAndNotifiesSubmission() async throws { + func testPreviewAIRetouchFromRetouchedTabAllowsBothOptionalTemplates() async throws { UIView.setAnimationsEnabled(false) defer { UIView.setAnimationsEnabled(true) } let api = TravelAlbumMockAPI() @@ -1106,7 +1143,16 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { $0.accessibilityIdentifier == "travelAlbum.aiRetouchConfirmButton" } as? UIButton ) - await waitUntil { api.aiRetouchTemplateScenicIds.count == 1 && confirmButton.isEnabled } + let collectionView = try XCTUnwrap( + sheet.view.findSubview { + $0.accessibilityIdentifier == "travelAlbum.aiRetouchTemplateCollection" + } as? UICollectionView + ) + await waitUntil { + api.aiRetouchTemplateScenicIds.count == 1 && + collectionView.numberOfSections > 2 && + collectionView.numberOfItems(inSection: 2) == 1 + } sheet.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844) sheet.view.layoutIfNeeded() @@ -1114,7 +1160,11 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { label.isHidden ? nil : label.text } XCTAssertTrue(visibleLabelTexts.contains("原图精修")) - XCTAssertFalse(visibleLabelTexts.contains { $0.contains("氛围感修图") }) + XCTAssertTrue(visibleLabelTexts.contains("氛围感修图")) + let accessibleLabels = sheet.view.allAccessibilityLabels() + XCTAssertTrue(accessibleLabels.contains("原图精修,选填")) + XCTAssertTrue(accessibleLabels.contains("氛围感修图,选填")) + XCTAssertFalse(confirmButton.isEnabled) let selectionCountLabel = try XCTUnwrap( sheet.view.findSubview { $0.accessibilityIdentifier == "travelAlbum.aiRetouchSelectionCountLabel" @@ -1133,11 +1183,25 @@ final class TravelAlbumDetailViewControllerTests: XCTestCase { XCTAssertEqual(selectionCountLabel.text, "已选择 1 张照片") XCTAssertTrue(tipsContainer.isHidden) XCTAssertTrue(tipsLabel.isHidden) + collectionView.delegate?.collectionView?( + collectionView, + didSelectItemAt: IndexPath(item: 0, section: 2) + ) + await waitUntil { confirmButton.isEnabled } confirmButton.sendActions(for: .touchUpInside) await waitUntil { api.aiReretouchRequests.count == 1 } await waitUntil { submissionNotified } - XCTAssertEqual(api.aiReretouchRequests.first?.type, .refined) + XCTAssertEqual( + api.aiReretouchRequests.first, + TravelAlbumAIReretouchRequest( + id: 7, + aiRetouchBatchId: 70, + type: .all, + refinedTemplateId: nil, + atmosphereTemplateId: 21 + ) + ) XCTAssertTrue(submissionNotified) XCTAssertTrue(window.rootViewController === controller) } diff --git a/suixinkanTests/TravelAlbumModelsTests.swift b/suixinkanTests/TravelAlbumModelsTests.swift index befbcbe..7672bff 100644 --- a/suixinkanTests/TravelAlbumModelsTests.swift +++ b/suixinkanTests/TravelAlbumModelsTests.swift @@ -268,7 +268,7 @@ final class TravelAlbumModelsTests: XCTestCase { ) } - func testPreviewProjectMapsCurrentTabToMinimalAIRetouchWorkflow() { + func testPreviewProjectUsesAllReretouchWorkflowForEveryVisibleVariantTab() { let originalOnly = makePreviewProject(id: 9, kinds: [.original]) let variants = TravelAlbumPreviewProject( originalMaterialId: 10, @@ -286,11 +286,11 @@ final class TravelAlbumModelsTests: XCTestCase { ) XCTAssertEqual( variants.aiRetouchWorkflow(albumId: 7, selectedKind: .retouched), - .reretouch(materialId: 10, batchId: 88, type: .refined) + .reretouch(materialId: 10, batchId: 88, type: .all) ) XCTAssertEqual( variants.aiRetouchWorkflow(albumId: 7, selectedKind: .atmosphere), - .reretouch(materialId: 10, batchId: 88, type: .atmosphere) + .reretouch(materialId: 10, batchId: 88, type: .all) ) XCTAssertNil(variants.aiRetouchWorkflow(albumId: 7, selectedKind: .cover)) }