import Foundation /// AI 修图任务列表状态,负责筛选、游标分页、去重和静默刷新。 final class TravelAlbumAIJobListViewModel { private(set) var items: [TravelAlbumAIJobSummary] = [] private(set) var selectedFilter: TravelAlbumAIJobFilter = .all private(set) var isLoading = false private(set) var isRefreshing = false private(set) var isLoadingMore = false private(set) var errorMessage: String? private var nextCursor: String? private var hasMore = false var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var containsInProgressJobs: Bool { items.contains { $0.status.isInProgress } } var canLoadMore: Bool { hasMore && !isLoadingMore } /// 选择筛选项并重新加载第一页。 func selectFilter(_ filter: TravelAlbumAIJobFilter, api: any TravelAlbumServing) async { guard selectedFilter != filter else { return } selectedFilter = filter items = [] notify() await loadFirstPage(api: api) } /// 首次加载或下拉刷新第一页。 func loadFirstPage(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async { guard !isLoading && !isRefreshing else { return } if refreshing { isRefreshing = true } else if !silent { isLoading = true } errorMessage = nil notify() defer { isLoading = false isRefreshing = false notify() } await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent) } /// 加载下一页。 func loadMore(api: any TravelAlbumServing) async { guard canLoadMore, let cursor = nextCursor else { return } isLoadingMore = true notify() defer { isLoadingMore = false; notify() } await request(cursor: cursor, reset: false, api: api, allowCursorRecovery: true, silent: false) } private func request( cursor: String?, reset: Bool, api: any TravelAlbumServing, allowCursorRecovery: Bool, silent: Bool ) async { do { let response = try await api.aiRetouchJobList( statusGroup: selectedFilter, limit: 20, cursor: cursor ) if reset { items = response.items } else { var known = Set(items.map(\.id)) items += response.items.filter { known.insert($0.id).inserted } } nextCursor = response.nextCursor hasMore = response.hasMore && response.nextCursor?.isEmpty == false errorMessage = nil } catch is CancellationError { return } catch { let message = error.localizedDescription if allowCursorRecovery && message.localizedCaseInsensitiveContains("cursor") { nextCursor = nil hasMore = false await request(cursor: nil, reset: true, api: api, allowCursorRecovery: false, silent: silent) return } if items.isEmpty { errorMessage = message.isEmpty ? "任务加载失败" : message } if !silent { onShowMessage?(message.isEmpty ? "任务加载失败" : message) } } } private func notify() { onStateChange?() } } /// AI 修图任务详情状态,负责刷新、404 识别和终态判断。 final class TravelAlbumAIJobDetailViewModel { private(set) var detail: TravelAlbumAIJobDetail? private(set) var isLoading = false private(set) var isRefreshing = false private(set) var errorMessage: String? private(set) var isNotFound = false let batchId: Int var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onNotFound: (() -> Void)? init(batchId: Int) { self.batchId = batchId } var shouldPoll: Bool { detail?.status.isInProgress == true } /// 拉取任务详情;静默刷新失败时保留现有内容。 func load(api: any TravelAlbumServing, refreshing: Bool = false, silent: Bool = false) async { guard batchId > 0, !isLoading && !isRefreshing else { if batchId <= 0 { markNotFound() } return } if refreshing { isRefreshing = true } else if detail == nil && !silent { isLoading = true } errorMessage = nil notify() defer { isLoading = false isRefreshing = false notify() } do { detail = try await api.aiRetouchJobInfo(batchId: batchId) isNotFound = false } catch is CancellationError { return } catch APIError.httpStatus(let status, _) where status == 404 { markNotFound() } catch { let message = error.localizedDescription.isEmpty ? "任务详情加载失败" : error.localizedDescription if detail == nil { errorMessage = message } if !silent { onShowMessage?(message) } } } private func markNotFound() { guard !isNotFound else { return } isNotFound = true onNotFound?() } private func notify() { onStateChange?() } }