fix: streamline AI retouch submission feedback
This commit is contained in:
@@ -13,7 +13,7 @@ AI 修图属于异步长耗时任务。当前用户提交后只能等待或主
|
|||||||
1. 当前账号全部相册的 AI 修图任务列表。
|
1. 当前账号全部相册的 AI 修图任务列表。
|
||||||
2. 单次 AI 修图任务详情。
|
2. 单次 AI 修图任务详情。
|
||||||
3. AI 修图终态消息推送。
|
3. AI 修图终态消息推送。
|
||||||
4. 从推送、任务列表和提交成功提示进入对应任务的完整导航链路。
|
4. 从推送和任务列表进入对应任务的完整导航链路;提交成功后仅 Toast 提示并返回相册管理。
|
||||||
|
|
||||||
本期解决“看得到进度、完成会通知、结果可直达”的问题,不增加任务取消、批量重试、历史版本管理或后台供应商诊断能力。
|
本期解决“看得到进度、完成会通知、结果可直达”的问题,不增加任务取消、批量重试、历史版本管理或后台供应商诊断能力。
|
||||||
|
|
||||||
@@ -65,7 +65,8 @@ AI 修图推送优先跳转到对应任务详情页。
|
|||||||
相册管理选择照片
|
相册管理选择照片
|
||||||
→ 提交 AI 修图
|
→ 提交 AI 修图
|
||||||
→ 后端返回 ai_retouch_batch_id
|
→ 后端返回 ai_retouch_batch_id
|
||||||
→ 客户端提示“AI修图任务已提交”并提供“查看任务”
|
→ 客户端 Toast 提示“AI修图任务已提交,完成后将通过消息通知”
|
||||||
|
→ 关闭模板页及可能存在的照片预览页,回到相册管理并刷新列表
|
||||||
→ 用户可离开页面
|
→ 用户可离开页面
|
||||||
→ 任务进入终态后收到 type = 14 推送
|
→ 任务进入终态后收到 type = 14 推送
|
||||||
→ 点击推送进入任务详情
|
→ 点击推送进入任务详情
|
||||||
@@ -75,7 +76,8 @@ AI 修图推送优先跳转到对应任务详情页。
|
|||||||
### 3.2 页面入口
|
### 3.2 页面入口
|
||||||
|
|
||||||
- 相册管理页导航栏右侧增加“修图任务”,进入当前账号的全局任务列表;新增相册页不展示该入口。
|
- 相册管理页导航栏右侧增加“修图任务”,进入当前账号的全局任务列表;新增相册页不展示该入口。
|
||||||
- AI 修图提交成功提示提供“查看任务”,直接进入本次任务详情。
|
- AI 修图提交成功仅展示自动消失的 Toast,不弹出查看任务确认框,也不提供立即跳转操作。
|
||||||
|
- 提交成功后回到相册管理页,并刷新相册摘要、数量和当前素材列表。
|
||||||
- 任务列表点击卡片进入对应任务详情。
|
- 任务列表点击卡片进入对应任务详情。
|
||||||
- `type = 14` 推送携带有效任务 ID 时直达详情,否则进入任务列表。
|
- `type = 14` 推送携带有效任务 ID 时直达详情,否则进入任务列表。
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,12 @@ final class TravelAlbumDetailViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// AI 修图提交成功后重置选择状态,并刷新相册摘要、数量与当前素材列表。
|
||||||
|
func refreshAfterAIRetouchSubmission(api: any TravelAlbumServing) async {
|
||||||
|
completeAIRetouchSubmission()
|
||||||
|
await refreshAll(api: api)
|
||||||
|
}
|
||||||
|
|
||||||
/// 预览页删除成功后移除对应素材,并同步全部/已购计数。
|
/// 预览页删除成功后移除对应素材,并同步全部/已购计数。
|
||||||
func removeMaterialAfterPreviewDeletion(id: Int) {
|
func removeMaterialAfterPreviewDeletion(id: Int) {
|
||||||
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
guard let index = materials.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
|||||||
@@ -731,7 +731,7 @@ private final class AIJobDetailTargetRow: UIView {
|
|||||||
make.height.equalTo(28)
|
make.height.equalTo(28)
|
||||||
}
|
}
|
||||||
errorContainer.snp.makeConstraints { make in
|
errorContainer.snp.makeConstraints { make in
|
||||||
make.top.equalTo(thumbnailView.snp.bottom).offset(10)
|
make.top.equalTo(templateLabel.snp.bottom).offset(12)
|
||||||
make.leading.equalTo(titleLabel)
|
make.leading.equalTo(titleLabel)
|
||||||
make.trailing.equalToSuperview().inset(4)
|
make.trailing.equalToSuperview().inset(4)
|
||||||
make.bottom.equalToSuperview().inset(12)
|
make.bottom.equalToSuperview().inset(12)
|
||||||
@@ -781,7 +781,11 @@ private final class AIJobDetailTargetRow: UIView {
|
|||||||
let showsError = target.displayFailureMessage != nil
|
let showsError = target.displayFailureMessage != nil
|
||||||
errorContainer.isHidden = !showsError
|
errorContainer.isHidden = !showsError
|
||||||
errorContainer.snp.remakeConstraints { make in
|
errorContainer.snp.remakeConstraints { make in
|
||||||
make.top.equalTo(thumbnailView.snp.bottom).offset(showsError ? 10 : 0)
|
if showsError {
|
||||||
|
make.top.equalTo(templateLabel.snp.bottom).offset(12)
|
||||||
|
} else {
|
||||||
|
make.top.equalTo(thumbnailView.snp.bottom)
|
||||||
|
}
|
||||||
make.leading.equalTo(titleLabel)
|
make.leading.equalTo(titleLabel)
|
||||||
make.trailing.equalToSuperview().inset(4)
|
make.trailing.equalToSuperview().inset(4)
|
||||||
make.bottom.equalToSuperview().inset(12)
|
make.bottom.equalToSuperview().inset(12)
|
||||||
|
|||||||
@@ -458,29 +458,14 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
materialIds: materialIds
|
materialIds: materialIds
|
||||||
),
|
),
|
||||||
api: api,
|
api: api,
|
||||||
onSubmitted: { [weak self] submission in
|
onSubmitted: { [weak self] _ in self?.handleAIRetouchSubmitted() }
|
||||||
guard let self else { return }
|
|
||||||
self.viewModel.completeAIRetouchSubmission()
|
|
||||||
self.presentAIRetouchSubmitted(submission)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
present(controller, animated: true)
|
present(controller, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentAIRetouchSubmitted(_ submission: TravelAlbumAIJobSubmission) {
|
private func handleAIRetouchSubmitted() {
|
||||||
let alert = UIAlertController(
|
showToast("AI修图任务已提交,完成后将通过消息通知")
|
||||||
title: "AI修图任务已提交",
|
Task { await viewModel.refreshAfterAIRetouchSubmission(api: api) }
|
||||||
message: "完成后将通过消息通知,你也可以随时查看处理进度。",
|
|
||||||
preferredStyle: .alert
|
|
||||||
)
|
|
||||||
alert.addAction(UIAlertAction(title: "稍后查看", style: .cancel))
|
|
||||||
alert.addAction(UIAlertAction(title: "查看任务", style: .default) { [weak self] _ in
|
|
||||||
self?.navigationController?.pushViewController(
|
|
||||||
TravelAlbumAIJobDetailViewController(batchId: submission.aiRetouchBatchId),
|
|
||||||
animated: true
|
|
||||||
)
|
|
||||||
})
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func deleteSelectedTapped() {
|
@objc private func deleteSelectedTapped() {
|
||||||
@@ -533,7 +518,8 @@ final class TravelAlbumDetailViewController: BaseViewController {
|
|||||||
},
|
},
|
||||||
onProjectDeleted: { materialId in
|
onProjectDeleted: { materialId in
|
||||||
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
|
previewViewModel.removeMaterialAfterPreviewDeletion(id: materialId)
|
||||||
}
|
},
|
||||||
|
onAIRetouchSubmitted: { [weak self] in self?.handleAIRetouchSubmitted() }
|
||||||
)
|
)
|
||||||
present(controller, animated: true)
|
present(controller, animated: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
private let loadMore: TravelAlbumPreviewLoadMore?
|
private let loadMore: TravelAlbumPreviewLoadMore?
|
||||||
private let reload: TravelAlbumPreviewReload?
|
private let reload: TravelAlbumPreviewReload?
|
||||||
private let onProjectDeleted: ((Int) -> Void)?
|
private let onProjectDeleted: ((Int) -> Void)?
|
||||||
|
private let onAIRetouchSubmitted: (() -> Void)?
|
||||||
private let allowsActions: Bool
|
private let allowsActions: Bool
|
||||||
private var nodes: [TravelAlbumPreviewNode] = []
|
private var nodes: [TravelAlbumPreviewNode] = []
|
||||||
private var currentNodeIndex = 0
|
private var currentNodeIndex = 0
|
||||||
@@ -73,7 +74,8 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
aiRetouchAPI: (any TravelAlbumServing)? = nil,
|
aiRetouchAPI: (any TravelAlbumServing)? = nil,
|
||||||
loadMore: TravelAlbumPreviewLoadMore? = nil,
|
loadMore: TravelAlbumPreviewLoadMore? = nil,
|
||||||
reload: TravelAlbumPreviewReload? = nil,
|
reload: TravelAlbumPreviewReload? = nil,
|
||||||
onProjectDeleted: ((Int) -> Void)? = nil
|
onProjectDeleted: ((Int) -> Void)? = nil,
|
||||||
|
onAIRetouchSubmitted: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
self.projects = Self.deduplicated(projects)
|
self.projects = Self.deduplicated(projects)
|
||||||
self.totalCount = max(totalCount, projects.count)
|
self.totalCount = max(totalCount, projects.count)
|
||||||
@@ -85,6 +87,7 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
self.loadMore = loadMore
|
self.loadMore = loadMore
|
||||||
self.reload = reload
|
self.reload = reload
|
||||||
self.onProjectDeleted = onProjectDeleted
|
self.onProjectDeleted = onProjectDeleted
|
||||||
|
self.onAIRetouchSubmitted = onAIRetouchSubmitted
|
||||||
self.allowsActions = allowsActions
|
self.allowsActions = allowsActions
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
modalPresentationStyle = .fullScreen
|
modalPresentationStyle = .fullScreen
|
||||||
@@ -726,34 +729,14 @@ final class TravelAlbumPhotoPreviewViewController: UIViewController {
|
|||||||
workflow: workflow
|
workflow: workflow
|
||||||
),
|
),
|
||||||
api: aiRetouchAPI,
|
api: aiRetouchAPI,
|
||||||
onSubmitted: { [weak self] submission in
|
onSubmitted: { [weak self] _ in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.reloadProjects(showGlobalLoading: false, forceRefreshImage: false)
|
self.dismiss(animated: true) { self.onAIRetouchSubmitted?() }
|
||||||
self.presentAIRetouchSubmitted(submission)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
present(controller, animated: true)
|
present(controller, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentAIRetouchSubmitted(_ submission: TravelAlbumAIJobSubmission) {
|
|
||||||
let alert = UIAlertController(
|
|
||||||
title: "AI修图任务已提交",
|
|
||||||
message: "完成后将通过消息通知。",
|
|
||||||
preferredStyle: .alert
|
|
||||||
)
|
|
||||||
alert.addAction(UIAlertAction(title: "知道了", style: .cancel))
|
|
||||||
alert.addAction(UIAlertAction(title: "查看任务", style: .default) { [weak self] _ in
|
|
||||||
guard let self, let navigationController = presentingViewController?.navigationController else { return }
|
|
||||||
dismiss(animated: true) {
|
|
||||||
navigationController.pushViewController(
|
|
||||||
TravelAlbumAIJobDetailViewController(batchId: submission.aiRetouchBatchId),
|
|
||||||
animated: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
present(alert, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func deleteTapped() {
|
@objc private func deleteTapped() {
|
||||||
guard currentProject != nil, !isDeletingProject else { return }
|
guard currentProject != nil, !isDeletingProject else { return }
|
||||||
let alert = UIAlertController(
|
let alert = UIAlertController(
|
||||||
|
|||||||
@@ -274,6 +274,33 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testAIRetouchSubmissionRefreshesAlbumCountsAndCurrentMaterialList() async {
|
||||||
|
let api = TravelAlbumMockAPI()
|
||||||
|
api.infoResponse = TravelAlbum(id: 2, name: "刷新后的相册")
|
||||||
|
api.materialListResponses = [
|
||||||
|
TravelAlbumListResponse(total: 3, list: []),
|
||||||
|
TravelAlbumListResponse(total: 1, list: []),
|
||||||
|
TravelAlbumListResponse(total: 3, list: [
|
||||||
|
TravelAlbumMaterial(id: 7),
|
||||||
|
TravelAlbumMaterial(id: 8),
|
||||||
|
TravelAlbumMaterial(id: 9),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
let viewModel = TravelAlbumDetailViewModel(albumId: 2)
|
||||||
|
viewModel.toggleSelectionMode()
|
||||||
|
viewModel.toggleMaterialSelection(TravelAlbumMaterial(id: 7, status: 1))
|
||||||
|
|
||||||
|
await viewModel.refreshAfterAIRetouchSubmission(api: api)
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.isSelectionMode)
|
||||||
|
XCTAssertTrue(viewModel.selectedMaterialIds.isEmpty)
|
||||||
|
XCTAssertEqual(viewModel.album?.name, "刷新后的相册")
|
||||||
|
XCTAssertEqual(viewModel.allPhotoCount, 3)
|
||||||
|
XCTAssertEqual(viewModel.purchasedPhotoCount, 1)
|
||||||
|
XCTAssertEqual(viewModel.materials.map(\.id), [7, 8, 9])
|
||||||
|
XCTAssertEqual(api.materialRequests.count, 3)
|
||||||
|
}
|
||||||
|
|
||||||
func testPreviewDeletionRemovesMaterialAndSynchronizesCounts() async {
|
func testPreviewDeletionRemovesMaterialAndSynchronizesCounts() async {
|
||||||
let api = TravelAlbumMockAPI()
|
let api = TravelAlbumMockAPI()
|
||||||
api.materialListResponses = [
|
api.materialListResponses = [
|
||||||
|
|||||||
Reference in New Issue
Block a user