fix: align AI retouch template rules

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