import SwiftUI @MainActor final class SourceSwitchViewModel: ObservableObject { enum State: Equatable { case idle, searching, ready, empty } @Published private(set) var state: State = .idle @Published private(set) var candidates: [BookSourceCandidate] = [] @Published private(set) var switchingID: String? @Published private(set) var errorMessage: String? private let book: Book var bookName: String { book.name } private let service: BookSourceSwitchService private var selectionGeneration = 0 init(book: Book, service: BookSourceSwitchService = .init(), candidates: [BookSourceCandidate] = []) { self.book = book self.service = service self.candidates = candidates state = candidates.isEmpty ? .idle : .ready } func load(sources: [BookSource]) async { guard state == .idle else { return } state = .searching for await update in await service.candidateUpdates(for: book, sources: sources) { guard !Task.isCancelled else { state = candidates.isEmpty ? .idle : .ready; return } candidates = update } guard !Task.isCancelled else { state = candidates.isEmpty ? .idle : .ready; return } state = candidates.isEmpty ? .empty : .ready } func select(_ candidate: BookSourceCandidate, sources: [BookSource]) async -> (BookSourceSwitchReport, BookSource)? { guard switchingID == nil, let source = sources.first(where: { $0.bookSourceUrl == candidate.book.origin }) else { return nil } switchingID = candidate.id let generation = selectionGeneration errorMessage = nil defer { switchingID = nil } let report = await service.prepareSwitch(current: book, candidate: candidate.book, source: source) guard generation == selectionGeneration, !Task.isCancelled else { return nil } guard let report else { errorMessage = "候选书源无法读取目录或当前章节正文,请选择其他书源。已保留当前书源和阅读位置。" return nil } return (report, source) } func clearError() { errorMessage = nil } func cancelSelection() { selectionGeneration &+= 1 } } struct SourceSwitchView: View { @Environment(\.dismiss) private var dismiss @StateObject private var model: SourceSwitchViewModel let sources: [BookSource] let onSwitched: @MainActor (BookSourceSwitchReport, BookSource) -> Void init( book: Book, sources: [BookSource], demoCandidates: [BookSourceCandidate] = [], onSwitched: @escaping @MainActor (BookSourceSwitchReport, BookSource) -> Void ) { self.sources = sources self.onSwitched = onSwitched _model = StateObject(wrappedValue: SourceSwitchViewModel(book: book, candidates: demoCandidates)) } var body: some View { Group { if model.candidates.isEmpty && (model.state == .searching || model.state == .idle) { VStack(spacing: 14) { ProgressView() Text("正在从其他书源查找同名作品…").foregroundStyle(.secondary) } } else if model.state == .empty { ReadingUnavailableView( "没有可用换源", systemImage: "arrow.triangle.2.circlepath", description: Text("仅显示书名一致的候选,并优先匹配作者。请确认其他书源已启用且支持搜索。") ) } else { List { if model.state == .searching { HStack(spacing: 10) { ProgressView() Text("仍在查找其他书源,可直接选择已找到的结果") .font(.caption).foregroundStyle(.secondary) } } ForEach(model.candidates) { candidate in candidateRow(candidate) } } .listStyle(.insetGrouped) .readingScrollContentBackgroundHidden() .background(ReadingTheme.canvas) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(ReadingTheme.canvas) .navigationTitle("为《\(model.bookName)》换源") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } } .task { await model.load(sources: sources) } .onDisappear { model.cancelSelection() } .alert("换源失败", isPresented: Binding( get: { model.errorMessage != nil }, set: { if !$0 { model.clearError() } } )) { Button("好") { model.clearError() } } message: { Text(model.errorMessage ?? "") } } private func candidateRow(_ candidate: BookSourceCandidate) -> some View { Button { Task { if let (report, source) = await model.select(candidate, sources: sources) { onSwitched(report, source) dismiss() } } } label: { HStack(spacing: 13) { Image(systemName: "network") .foregroundStyle(ReadingTheme.accent) .frame(width: 42, height: 42) .background(ReadingTheme.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 13)) VStack(alignment: .leading, spacing: 5) { Text(candidate.book.originName).font(.headline).foregroundStyle(.primary) Text(candidate.book.author.isEmpty ? "作者信息缺失" : candidate.book.author) .font(.subheadline).foregroundStyle(.secondary) if model.switchingID == candidate.id { Text("正在验证目录和正文…").font(.caption).foregroundStyle(.secondary) } else if let latest = candidate.book.latestChapterTitle, !latest.isEmpty { Text(latest).font(.caption).foregroundStyle(.secondary).lineLimit(1) } } Spacer() if model.switchingID == candidate.id { ProgressView() } else { Image(systemName: "arrow.right.circle.fill").foregroundStyle(ReadingTheme.coral) } } .padding(.vertical, 5) } .disabled(model.switchingID != nil) .accessibilityIdentifier("sourceSwitch.candidate.\(candidate.book.origin)") } }