import SwiftUI @MainActor final class ExploreSourceViewModel: ObservableObject { enum State: Equatable { case idle, loading, ready, empty, failed } let source: BookSource @Published private(set) var kinds: [ExploreKind] = [] @Published private(set) var selectedKind: ExploreKind? @Published private(set) var books: [SearchBook] = [] @Published private(set) var diagnostics: [RuleDiagnostic] = [] @Published private(set) var state: State = .idle @Published private(set) var isLoadingMore = false private let service: BookExploreService private var page = 1 private var canLoadMore = false private var paginationTask: Task? init(source: BookSource, service: BookExploreService = .init()) { self.source = source self.service = service } deinit { paginationTask?.cancel() } func load() async { guard state == .idle else { return } await reload() } func reload() async { paginationTask?.cancel() selectedKind = nil kinds = [] books = [] diagnostics = [] page = 1 canLoadMore = false state = .loading let report = await service.kinds(for: source) guard !Task.isCancelled else { return } kinds = report.kinds.filter { $0.navigableURL != nil } diagnostics = report.diagnostics guard let first = kinds.first else { state = diagnostics.contains(where: { $0.severity == .error }) ? .failed : .empty return } await select(kind: first) } func select(kind: ExploreKind) async { guard kind.navigableURL != nil else { return } paginationTask?.cancel() selectedKind = kind books = [] diagnostics = [] page = 1 canLoadMore = false state = .loading if ProcessInfo.processInfo.arguments.contains("-uiDemo") { books = DemoContent.exploreBooks state = .ready canLoadMore = true return } let outcome = await service.explore(source: source, kind: kind, page: page) guard !Task.isCancelled, selectedKind?.id == kind.id else { return } diagnostics = outcome.diagnostics books = outcome.books canLoadMore = !outcome.books.isEmpty state = books.isEmpty ? (outcome.diagnostics.isEmpty ? .empty : .failed) : .ready } func loadNextPageIfNeeded(current book: SearchBook) { guard book.id == books.last?.id, canLoadMore, !isLoadingMore, let kind = selectedKind else { return } isLoadingMore = true let nextPage = page + 1 paginationTask = Task { [service, source] in if ProcessInfo.processInfo.arguments.contains("-uiDemo") { try? await Task.sleep(nanoseconds: 300_000_000) guard !Task.isCancelled else { return } canLoadMore = false isLoadingMore = false return } let outcome = await service.explore(source: source, kind: kind, page: nextPage) guard !Task.isCancelled, selectedKind?.id == kind.id else { isLoadingMore = false return } let knownURLs = Set(books.map(\.bookURL)) books.append(contentsOf: outcome.books.filter { !knownURLs.contains($0.bookURL) }) diagnostics += outcome.diagnostics page = nextPage canLoadMore = !outcome.books.isEmpty isLoadingMore = false } } } struct ExploreView: View { @EnvironmentObject private var sourceLibrary: SourceLibraryModel private var sources: [BookSource] { sourceLibrary.sources .filter { $0.enabled && $0.enabledExplore && !($0.exploreUrl?.isEmpty ?? true) } .sorted { if $0.customOrder != $1.customOrder { return $0.customOrder < $1.customOrder } return $0.bookSourceName.localizedStandardCompare($1.bookSourceName) == .orderedAscending } } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { ReadingPageHeader(title: "发现", subtitle: "下一本好书,在这里遇见") { NavigationLink { SearchView() } label: { ReadingToolbarIcon(systemName: "magnifyingglass") } .accessibilityLabel("搜索") } VStack(alignment: .leading, spacing: 24) { HStack(spacing: 16) { Image(systemName: "safari") .font(.system(size: 30, weight: .ultraLight)) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 6) { Text("从喜欢的书源开始").font(.headline) Text("浏览分类与榜单,发现更多故事") .font(.caption).foregroundStyle(.secondary) } Spacer(minLength: 0) } .padding(22) .frame(maxWidth: .infinity, alignment: .leading) .background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 20)) HStack(alignment: .firstTextBaseline) { Text("分类与榜单").font(.title3.bold()) .accessibilityAddTraits(.isHeader) Spacer() Text("\(sources.count)个书源").font(.caption).foregroundStyle(.secondary) } if sources.isEmpty { ReadingUnavailableView { Label("暂无发现书源", systemImage: "books.vertical") } description: { Text("添加带有发现分类的书源,即可在这里浏览书籍。") } actions: { NavigationLink("管理书源") { SourceManagementView() } .buttonStyle(.borderedProminent) } } else { LazyVStack(spacing: 0) { ForEach(sources) { source in NavigationLink { ExploreSourceView(source: source) } label: { HStack(spacing: 12) { ExploreSourceRow(source: source) Spacer(minLength: 0) Image(systemName: "chevron.right") .font(.caption).foregroundStyle(.tertiary) } .padding(.vertical, 8) .contentShape(Rectangle()) } .buttonStyle(.plain) if source.id != sources.last?.id { Rectangle().fill(ReadingTheme.separator).frame(height: 0.5) .padding(.leading, 58) } } } } NavigationLink { SourceManagementView() } label: { HStack(spacing: 14) { Image(systemName: "slider.horizontal.3") .font(.system(size: 20)) .frame(width: 40, height: 44) VStack(alignment: .leading, spacing: 5) { Text("管理书源").font(.subheadline.weight(.semibold)) Text("添加与整理阅读来源").font(.caption).foregroundStyle(.secondary) } Spacer(minLength: 0) Image(systemName: "arrow.up.right").font(.subheadline) } .foregroundStyle(ReadingTheme.ink) .padding(18) .background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 18)) } .buttonStyle(.plain) } .padding(.horizontal, 24) .padding(.bottom, 32) } } .background(ReadingTheme.canvas) .navigationBarHidden(true) } } private struct ExploreSourceRow: View { let source: BookSource var body: some View { HStack(spacing: 12) { Image(systemName: source.bookSourceType == 1 ? "headphones" : "books.vertical") .font(.system(size: 20)) .foregroundStyle(ReadingTheme.accent) .frame(width: 44, height: 48) .background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 12)) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 4) { Text(source.bookSourceName) .font(.body.weight(.medium)) .foregroundStyle(.primary) .lineLimit(2) Text(source.groups.isEmpty ? (URL(string: source.bookSourceUrl)?.host ?? source.bookSourceUrl) : source.groups.joined(separator: " · ")) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } .padding(.vertical, 7) .contentShape(Rectangle()) } } private struct ExploreSourceView: View { @StateObject private var model: ExploreSourceViewModel init(source: BookSource) { _model = StateObject(wrappedValue: ExploreSourceViewModel(source: source)) } var body: some View { ScrollView { LazyVStack(spacing: 0) { if model.kinds.count > 1 { kindPicker } content if model.isLoadingMore { ProgressView("继续加载…").padding(.vertical, 12) } } .padding(.horizontal, 16) .padding(.vertical, 12) } .background(ReadingTheme.canvas) .navigationTitle(model.source.bookSourceName) .navigationBarTitleDisplayMode(.inline) .task { await model.load() } .refreshable { await model.reload() } } private var kindPicker: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(model.kinds) { kind in Button(kind.title) { Task { await model.select(kind: kind) } } .font(.subheadline.weight(.medium)) .padding(.horizontal, 12) .frame(minHeight: 44) .foregroundStyle(model.selectedKind?.id == kind.id ? Color.primary : .secondary) .overlay(alignment: .bottom) { if model.selectedKind?.id == kind.id { Rectangle().fill(Color.primary).frame(height: 2) } } .accessibilityAddTraits(model.selectedKind?.id == kind.id ? .isSelected : []) } } } } @ViewBuilder private var content: some View { switch model.state { case .idle, .loading: ProgressView("正在加载小说…") .frame(maxWidth: .infinity) .padding(.vertical, 80) case .empty: ReadingUnavailableView( "暂无小说", systemImage: "books.vertical", description: Text("当前书源或分类暂时没有内容,可以下拉刷新。") ) .padding(.vertical, 40) case .failed where model.books.isEmpty: ReadingUnavailableView( "加载失败", systemImage: "exclamationmark.triangle", description: Text(model.diagnostics.first?.message ?? "书源发现规则解析失败。") ) .padding(.vertical, 40) default: ForEach(model.books) { book in NavigationLink { BookDetailView(searchBook: book, source: model.source) } label: { ExploreBookRow(book: book) } .buttonStyle(.plain) .onAppear { model.loadNextPageIfNeeded(current: book) } } } } } private struct ExploreBookRow: View { let book: SearchBook var body: some View { HStack(alignment: .top, spacing: 13) { BookCoverArtwork(title: book.name, author: book.author, remoteURL: book.coverURL, width: 52, height: 74) VStack(alignment: .leading, spacing: 6) { Text(book.name) .font(.body.weight(.medium)) .foregroundStyle(ReadingTheme.ink) .lineLimit(2) Text(book.author.isEmpty ? "未知作者" : book.author) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) if let kind = book.kind, !kind.isEmpty { Text(kind) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } if let intro = book.intro, !intro.isEmpty { Text(intro) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } } .frame(maxWidth: .infinity, alignment: .leading) Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(.tertiary) .padding(.top, 4) } .padding(.vertical, 12) .overlay(alignment: .bottom) { Rectangle().fill(ReadingTheme.separator).frame(height: 0.5) .padding(.leading, 65) } } }