import SwiftUI import UniformTypeIdentifiers struct RootTabView: View { @EnvironmentObject private var sourceLibrary: SourceLibraryModel private enum Tab: Hashable { case bookshelf, explore, statistics, my } @State private var selection: Tab @State private var showsDemoReader = false @State private var didOpenDemoReader = false @State private var sourceImportIsPresented = false @State private var displayedSourceNotice: SourceLibraryModel.Notice? @AppStorage("themeMode") private var themeMode = "0" init() { let arguments = ProcessInfo.processInfo.arguments let requested = arguments.firstIndex(of: "-uiDemoTab") .flatMap { arguments.indices.contains($0 + 1) ? arguments[$0 + 1] : nil } let initial: Tab = switch requested { case "explore": .explore case "statistics": .statistics case "settings": .my default: switch UserDefaults.standard.string(forKey: "defaultHomePage") { case "explore": .explore case "statistics": .statistics case "settings": .my default: .bookshelf } } _selection = State(initialValue: initial) } var body: some View { TabView(selection: $selection) { ReadingNavigationStack { Group { if ProcessInfo.processInfo.arguments.contains("-uiDemoSourceSwitch") { SourceSwitchView( book: DemoContent.books[0], sources: DemoContent.sources, demoCandidates: DemoContent.sourceSwitchCandidates ) { _, _ in } } else if ProcessInfo.processInfo.arguments.contains("-uiDemoBookDetail") { BookDetailView( demoBook: DemoContent.books[0], source: DemoContent.sources[0], chapters: DemoContent.readerChapters ) } else if requestedDemoPage == "search" { SearchView(initialKeyword: automationSearchKeyword) } else if requestedDemoPage == "sources" { SourceManagementView() } else { BookshelfView() } } .readingNavigationDestination(isPresented: $showsDemoReader) { ReaderView( demoBook: DemoContent.books[0], chapters: DemoContent.readerChapters, initialIndex: 0, content: DemoContent.readerText ) } .task { guard !didOpenDemoReader else { return } didOpenDemoReader = true showsDemoReader = ProcessInfo.processInfo.arguments.contains("-uiDemoReader") } } .tabItem { Label("书架", systemImage: "book.fill") } .tag(Tab.bookshelf) ReadingNavigationStack { ExploreView() } .tabItem { Label("发现", systemImage: "safari") } .tag(Tab.explore) ReadingNavigationStack { ReadingStatisticsView() } .tabItem { Label("统计", systemImage: "chart.bar") } .tag(Tab.statistics) ReadingNavigationStack { if ProcessInfo.processInfo.arguments.contains("-uiDemoRules") { ReplaceRuleManagementView() } else if ProcessInfo.processInfo.arguments.contains("-uiDemoHistory") { ReadingHistoryView() } else if ProcessInfo.processInfo.arguments.contains("-uiDemoFiles") { FileManagementView() } else if ProcessInfo.processInfo.arguments.contains("-uiDemoOtherSettings") { OtherSettingsView() } else if ProcessInfo.processInfo.arguments.contains("-uiDemoAbout") { AboutView() } else if ProcessInfo.processInfo.arguments.contains("-uiDemoLogs") { DiagnosticLogView() } else { SettingsView() } } .tabItem { Label("我的", systemImage: "person.crop.circle") } .tag(Tab.my) } .tint(ReadingTheme.accent) .readingTabBarBackground() .preferredColorScheme(themeMode == "2" ? .dark : (themeMode == "1" || themeMode == "3" ? .light : nil)) .transaction { transaction in if themeMode == "3" { transaction.animation = nil } } .sheet(item: $sourceLibrary.pendingImport, onDismiss: { sourceImportIsPresented = false displayedSourceNotice = sourceLibrary.notice }) { pending in SourceImportReviewView(pending: pending) .environmentObject(sourceLibrary) .onAppear { sourceImportIsPresented = true } } .onChange(of: sourceLibrary.notice?.id) { _ in if !sourceImportIsPresented { displayedSourceNotice = sourceLibrary.notice } } .alert(item: $displayedSourceNotice) { notice in Alert( title: Text(notice.title), message: Text(notice.detail), dismissButton: .default(Text("好")) { sourceLibrary.notice = nil } ) } } private var requestedDemoPage: String? { let arguments = ProcessInfo.processInfo.arguments guard let index = arguments.firstIndex(of: "-uiDemoTab"), arguments.indices.contains(index + 1) else { return nil } return arguments[index + 1] } private var automationSearchKeyword: String { let arguments = ProcessInfo.processInfo.arguments guard let index = arguments.firstIndex(of: "-uiAutomationSearch"), arguments.indices.contains(index + 1) else { return "" } return arguments[index + 1] } } private struct BookshelfView: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize @EnvironmentObject private var library: BookLibraryModel @EnvironmentObject private var sources: SourceLibraryModel @State private var showsLocalImporter = false @State private var pendingLocalURLs: [URL] = [] @State private var selectedGroupID = BookGroup.idAll @State private var showsGroupManager = false @AppStorage("bookshelfLayout") private var bookshelfLayout = "grid3" private var selectedGroup: BookGroup { library.visibleGroups.first(where: { $0.id == selectedGroupID }) ?? library.visibleGroups.first ?? BookGroup(groupId: BookGroup.idAll, groupName: "全部") } private var displayedBooks: [Book] { library.books(in: selectedGroup) } private var recentBook: Book? { displayedBooks.filter(BookReadingPresentation.hasStarted) .sorted { $0.durChapterTime > $1.durChapterTime } .first } var body: some View { ScrollView { VStack(spacing: 0) { ReadingPageHeader(title: "书架", subtitle: "阅读,让时间有了形状") { NavigationLink { SearchView() } label: { ReadingToolbarIcon(systemName: "magnifyingglass") } .accessibilityLabel("搜索") bookshelfMenu } if library.visibleGroups.count > 1 { bookshelfGroups.padding(.bottom, 24) } LazyVStack(alignment: .leading, spacing: 0) { if displayedBooks.isEmpty { EmptyBookshelfHero { showsLocalImporter = true } } else { if let book = recentBook { VStack(alignment: .leading, spacing: 12) { Text("继续阅读") .font(.subheadline.weight(.medium)) .foregroundStyle(.secondary) NavigationLink { BookshelfBookDestination(book: book, source: sources.sources.first { $0.bookSourceUrl == book.origin }) } label: { ContinueReadingCard(book: book) } .buttonStyle(.plain) .accessibilityLabel("继续阅读,\(book.name),\(BookReadingPresentation.position(book))") .accessibilityIdentifier("bookshelf.continue") } .padding(.bottom, 30) } bookshelfSummary if bookshelfLayout.hasPrefix("grid") { LazyVGrid(columns: gridColumns, alignment: .leading, spacing: 24) { ForEach(displayedBooks) { book in BookshelfBookLink(book: book, compact: false, grid: true) } } } else { ForEach(displayedBooks) { book in BookshelfBookLink(book: book, compact: bookshelfLayout == "compact", grid: false) if book.id != displayedBooks.last?.id { Rectangle().fill(ReadingTheme.separator).frame(height: 0.5) .padding(.leading, bookshelfLayout == "compact" ? 54 : 66) } } } } } .padding(.horizontal, 24) .padding(.bottom, 32) } } .background(ReadingTheme.canvas) .navigationBarHidden(true) .fileImporter( isPresented: $showsLocalImporter, allowedContentTypes: [.text, .plainText, .data], allowsMultipleSelection: true ) { result in switch result { case .success(let urls): pendingLocalURLs = urls.filter { $0.pathExtension.lowercased() == "txt" } if pendingLocalURLs.isEmpty, !urls.isEmpty { library.reportError(LocalBookError.unsupportedFormat) } case .failure(let error): library.reportError(error) } } .sheet(isPresented: Binding( get: { !pendingLocalURLs.isEmpty }, set: { if !$0 { pendingLocalURLs = [] } } )) { LocalBookImportReviewView(urls: pendingLocalURLs) { pendingLocalURLs = [] } .environmentObject(library) } .sheet(isPresented: $showsGroupManager) { BookGroupManagerView() .environmentObject(library) } .alert("本地书导入失败", isPresented: Binding( get: { library.errorMessage != nil }, set: { if !$0 { library.clearError() } } )) { Button("好") { library.clearError() } } message: { Text(library.errorMessage ?? "未知错误") } .alert("书架更新", isPresented: Binding( get: { library.updateMessage != nil }, set: { if !$0 { library.clearUpdateMessage() } } )) { Button("好") { library.clearUpdateMessage() } } message: { Text(library.updateMessage ?? "") } } private var bookshelfSummary: some View { HStack(alignment: .center) { VStack(alignment: .leading, spacing: 6) { Text(selectedGroupID == BookGroup.idAll ? "全部书籍" : selectedGroup.groupName) .font(.title3.bold()) .accessibilityAddTraits(.isHeader) Text("\(displayedBooks.count)本") .font(.caption).foregroundStyle(.secondary) } Spacer() HStack(spacing: 0) { Button { bookshelfLayout = "grid3" } label: { Image(systemName: "square.grid.2x2.fill") .foregroundStyle(bookshelfLayout.hasPrefix("grid") ? ReadingTheme.accent : .secondary) .frame(width: 44, height: 44) } .accessibilityLabel("网格布局") .accessibilityAddTraits(bookshelfLayout.hasPrefix("grid") ? .isSelected : []) Button { bookshelfLayout = "list" } label: { Image(systemName: "list.bullet") .foregroundStyle(bookshelfLayout.hasPrefix("grid") ? .secondary : ReadingTheme.accent) .frame(width: 44, height: 44) } .accessibilityLabel("列表布局") .accessibilityAddTraits(bookshelfLayout.hasPrefix("grid") ? [] : .isSelected) } .font(.system(size: 18, weight: .medium)) .buttonStyle(.plain) } .padding(.bottom, 20) } @ViewBuilder private var bookshelfMenu: some View { if library.isCheckingUpdates { ProgressView().controlSize(.small).frame(width: 44, height: 44) } else { Menu { Button("更新目录", systemImage: "arrow.clockwise") { Task { await library.checkForUpdates(sources: sources.sources) } } Button("本地书", systemImage: "plus") { showsLocalImporter = true } Button("书架分组", systemImage: "rectangle.3.group") { showsGroupManager = true } Menu("书架布局", systemImage: "square.grid.2x2") { layoutButton("列表", value: "list") layoutButton("紧凑列表", value: "compact") ForEach(2...6, id: \.self) { count in layoutButton("\(count) 列宫格", value: "grid\(count)") } } NavigationLink { FileManagementView() } label: { Label("文件管理", systemImage: "folder") } } label: { ReadingToolbarIcon(systemName: "ellipsis") } .accessibilityLabel("更多") } } private var bookshelfGroups: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 24) { ForEach(library.visibleGroups) { group in Button(group.groupName) { selectedGroupID = group.id } .font(.subheadline.weight(selectedGroupID == group.id ? .semibold : .regular)) .foregroundStyle(selectedGroupID == group.id ? Color.primary : .secondary) .frame(minHeight: 44) .overlay(alignment: .bottom) { if selectedGroupID == group.id { Rectangle().fill(ReadingTheme.accent).frame(height: 2) } } .accessibilityAddTraits(selectedGroupID == group.id ? .isSelected : []) } } .padding(.horizontal, 24) } .overlay(alignment: .bottom) { Rectangle().fill(ReadingTheme.separator).frame(height: 0.5) } } private var gridColumns: [GridItem] { let count = Int(bookshelfLayout.dropFirst(4)) ?? 3 let maximum = dynamicTypeSize.isAccessibilitySize ? 2 : 6 return Array(repeating: GridItem(.flexible(), spacing: 16, alignment: .top), count: max(2, min(count, maximum))) } @ViewBuilder private func layoutButton(_ title: String, value: String) -> some View { Button { bookshelfLayout = value } label: { if bookshelfLayout == value { Label(title, systemImage: "checkmark") } else { Text(title) } } } } private struct BookshelfBookLink: View { @EnvironmentObject private var library: BookLibraryModel @EnvironmentObject private var sources: SourceLibraryModel let book: Book let compact: Bool let grid: Bool @State private var showsGroupSelector = false private var source: BookSource? { sources.sources.first { $0.bookSourceUrl == book.origin } } var body: some View { NavigationLink { BookshelfBookDestination(book: book, source: source) } label: { if grid { BookshelfGridItem(book: book) } else { BookshelfRow(book: book, compact: compact) } } .buttonStyle(.plain) .contextMenu { NavigationLink { if let source { BookDetailView(book: book, source: source) } else { LocalBookDetailView(book: book) } } label: { Label("书籍信息", systemImage: "info.circle") } Button("移至分组", systemImage: "rectangle.3.group") { showsGroupSelector = true } Button("移出书架", systemImage: "trash", role: .destructive) { Task { await library.remove(bookURL: book.bookUrl) } } } .sheet(isPresented: $showsGroupSelector) { BookGroupSelectView(book: book).environmentObject(library) } } } private struct BookshelfGridItem: View { let book: Book var body: some View { VStack(alignment: .leading, spacing: 0) { GeometryReader { geometry in BookCoverArtwork( title: book.name, author: book.author, remoteURL: book.coverUrl, width: geometry.size.width, height: geometry.size.height ) } .aspectRatio(0.70, contentMode: .fit) .frame(maxWidth: 180) .frame(maxWidth: .infinity) .overlay(alignment: .topTrailing) { if book.lastCheckCount > 0 { Circle() .fill(ReadingTheme.coral) .frame(width: 7, height: 7) .overlay { Circle().stroke(ReadingTheme.canvas, lineWidth: 2) } .offset(x: 3, y: -3) .accessibilityLabel("更新 \(book.lastCheckCount) 章") } } Text(book.name) .font(.caption.weight(.semibold)) .lineLimit(2) .padding(.top, 10) Text(book.author.isEmpty ? "未知作者" : book.author) .font(.caption2).foregroundStyle(.secondary).lineLimit(1) .padding(.top, 3) ReadingProgressLine(value: BookReadingPresentation.progress(book)) .padding(.top, 12) Text(BookReadingPresentation.progressLabel(book)) .font(.caption2).foregroundStyle(.secondary).lineLimit(1) .padding(.top, 7) } } } private struct BookshelfBookDestination: View { let book: Book let source: BookSource? var body: some View { if ProcessInfo.processInfo.arguments.contains("-uiDemo") { ReaderView(demoBook: book, chapters: DemoContent.readerChapters, initialIndex: 0, content: DemoContent.readerText) } else { BookshelfReadView(book: book, source: source) } } } private struct ContinueReadingCard: View { @Environment(\.dynamicTypeSize) private var dynamicTypeSize let book: Book var body: some View { HStack(alignment: .center, spacing: 18) { VStack(alignment: .leading, spacing: 0) { Text(book.name) .font(.title3.bold()) .foregroundStyle(ReadingTheme.ink) .lineLimit(2) Text(book.author.isEmpty ? "未知作者" : book.author) .font(.subheadline).foregroundStyle(.secondary) .padding(.top, 7) Text(BookReadingPresentation.position(book)) .font(.caption).foregroundStyle(.secondary) .lineLimit(2) .padding(.top, 20) if book.totalChapterNum > 0 { HStack(spacing: 10) { ReadingProgressLine(value: BookReadingPresentation.progress(book)) Text("\(Int((BookReadingPresentation.progress(book) * 100).rounded()))%") .font(.caption2.monospacedDigit()) .foregroundStyle(.secondary) } .padding(.top, 12) } HStack(spacing: 12) { Text("继续阅读") Image(systemName: "arrow.right") } .font(.caption.weight(.semibold)) .foregroundStyle(ReadingTheme.onAccent) .padding(.horizontal, 18) .frame(minHeight: 40) .background(ReadingTheme.accent, in: Capsule()) .padding(.top, 20) } .frame(maxWidth: .infinity, alignment: .leading) if !dynamicTypeSize.isAccessibilitySize { BookCoverArtwork(title: book.name, author: book.author, remoteURL: book.coverUrl, width: 94, height: 140) } } .padding(20) .background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 20)) } } private struct BookGroupManagerView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var library: BookLibraryModel @State private var newName = "" @State private var showsCreate = false @State private var editingGroup: BookGroup? @State private var editingName = "" private var customGroups: [BookGroup] { library.groups.filter { $0.groupId > 0 }.sorted { $0.order < $1.order } } var body: some View { ReadingNavigationStack { List { Section { HStack { Text("全部") Spacer() Text("系统分组").font(.caption).foregroundStyle(.secondary) } } Section("自定义分组") { ForEach(customGroups) { group in HStack { Toggle(isOn: Binding( get: { group.show }, set: { visible in Task { await library.setGroupVisible(group, visible: visible) } } )) { Text(group.groupName) } Button { editingGroup = group editingName = group.groupName } label: { Image(systemName: "pencil") } .buttonStyle(.borderless) } .swipeActions { Button("删除", role: .destructive) { Task { await library.deleteGroup(group) } } } } .onMove { offsets, destination in Task { await library.moveGroups(from: offsets, to: destination) } } } } .overlay { if customGroups.isEmpty { ReadingUnavailableView("没有自定义分组", systemImage: "rectangle.3.group", description: Text("点右上角加号新建分组。")) } } .navigationTitle("书架分组") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } } ToolbarItemGroup(placement: .topBarTrailing) { EditButton() Button("新建", systemImage: "plus") { newName = ""; showsCreate = true } } } .alert("新建分组", isPresented: $showsCreate) { TextField("分组名称", text: $newName) Button("取消", role: .cancel) {} Button("确定") { Task { await library.createGroup(name: newName) } } } .alert("编辑分组", isPresented: Binding( get: { editingGroup != nil }, set: { if !$0 { editingGroup = nil } } )) { TextField("分组名称", text: $editingName) Button("取消", role: .cancel) { editingGroup = nil } Button("确定") { guard let group = editingGroup else { return } Task { await library.renameGroup(group, name: editingName) } editingGroup = nil } } } } } private struct BookGroupSelectView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var library: BookLibraryModel let book: Book @State private var selected: Set init(book: Book) { self.book = book _selected = State(initialValue: []) } private var customGroups: [BookGroup] { library.groups.filter { $0.groupId > 0 }.sorted { $0.order < $1.order } } var body: some View { ReadingNavigationStack { List(customGroups) { group in Button { if !selected.insert(group.id).inserted { selected.remove(group.id) } } label: { HStack { Text(group.groupName).foregroundStyle(.primary) Spacer() if selected.contains(group.id) { Image(systemName: "checkmark").foregroundStyle(ReadingTheme.accent) } } } } .overlay { if customGroups.isEmpty { ReadingUnavailableView("没有自定义分组", systemImage: "rectangle.3.group") } } .navigationTitle("移至分组") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("确定") { Task { await library.setBook(book, groupIDs: selected); dismiss() } } } } .onAppear { selected = Set(customGroups.filter { (book.group & $0.groupId) != 0 }.map(\.groupId)) } } .interactiveDismissDisabled() } } private struct LocalBookImportReviewView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var library: BookLibraryModel let urls: [URL] let onFinish: () -> Void @State private var selectedPaths: Set @State private var isImporting = false init(urls: [URL], onFinish: @escaping () -> Void) { self.urls = urls self.onFinish = onFinish _selectedPaths = State(initialValue: Set(urls.map(\.path))) } var body: some View { ReadingNavigationStack { List(urls, id: \.path) { url in Button { if !selectedPaths.insert(url.path).inserted { selectedPaths.remove(url.path) } } label: { HStack { Image(systemName: selectedPaths.contains(url.path) ? "checkmark.square.fill" : "square") .foregroundStyle(selectedPaths.contains(url.path) ? ReadingTheme.accent : .secondary) VStack(alignment: .leading) { Text(url.deletingPathExtension().lastPathComponent).foregroundStyle(.primary) Text(url.lastPathComponent).font(.caption).foregroundStyle(.secondary) } } } } .overlay { if isImporting { ProgressView("正在导入…") } } .navigationTitle("导入本地书") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { finish() }.disabled(isImporting) } ToolbarItem(placement: .topBarTrailing) { Button(selectedPaths.count == urls.count ? "取消全选" : "全选") { if selectedPaths.count == urls.count { selectedPaths.removeAll() } else { selectedPaths = Set(urls.map(\.path)) } } .disabled(isImporting) } ToolbarItem(placement: .confirmationAction) { Button("导入") { isImporting = true let selected = urls.filter { selectedPaths.contains($0.path) } Task { await library.importLocalFiles(selected) finish() } } .disabled(selectedPaths.isEmpty || isImporting) } } } .interactiveDismissDisabled(isImporting) } private func finish() { onFinish() dismiss() } } private struct EmptyBookshelfHero: View { let onImport: () -> Void var body: some View { VStack(spacing: 24) { Image(systemName: "books.vertical") .font(.system(size: 40, weight: .ultraLight)) .foregroundStyle(ReadingTheme.accent) .accessibilityHidden(true) VStack(spacing: 10) { Text("书架还是空的").font(.title3.weight(.semibold)) Text("导入本地书,或添加书源后搜索书籍。") .font(.subheadline).foregroundStyle(.secondary) .multilineTextAlignment(.center) } VStack(spacing: 12) { Button("导入本地书", action: onImport) .buttonStyle(.borderedProminent) .controlSize(.large) NavigationLink("添加书源") { SourceManagementView() } .font(.subheadline) .frame(minHeight: 44) } } .frame(maxWidth: .infinity) .padding(.vertical, 80) } } struct BookshelfReadView: View { let book: Book let source: BookSource? @StateObject private var model = BookshelfReadViewModel() var body: some View { Group { if !model.chapters.isEmpty, let resolvedBook = model.resolvedBook { if let source { ReaderView( book: resolvedBook, chapters: model.chapters, initialIndex: resolvedBook.durChapterIndex, source: source ) } else { ReaderView(book: resolvedBook, chapters: model.chapters, initialIndex: resolvedBook.durChapterIndex) } } else if model.isLoading { ProgressView("正在打开…") } else if let errorMessage = model.errorMessage { ReadingUnavailableView("无法打开书籍", systemImage: "book.closed", description: Text(errorMessage)) } else { ProgressView("正在打开…") } } .task { await model.load(book: book, source: source) } } } private struct BookshelfRow: View { let book: Book var compact = false @ScaledMetric(relativeTo: .body) private var coverWidth = 52 private var readingPosition: String { if let title = book.durChapterTitle, !title.isEmpty { return title } if book.durChapterIndex > 0 || book.durChapterPos > 0 || book.durChapterTime > 0 { return book.totalChapterNum > 0 ? "第 \(book.durChapterIndex + 1) 章 / 共 \(book.totalChapterNum) 章" : "已读至第 \(book.durChapterIndex + 1) 章" } return "尚未阅读" } var body: some View { HStack(alignment: .center, spacing: 14) { BookCoverArtwork( title: book.name, author: book.author, remoteURL: book.coverUrl, width: compact ? 40 : coverWidth, height: compact ? 56 : coverWidth * 1.42 ) VStack(alignment: .leading, spacing: 5) { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(book.name) .font(.body.weight(.medium)) .foregroundStyle(.primary) .lineLimit(2) Spacer(minLength: 0) if book.lastCheckCount > 0 { Text("+\(book.lastCheckCount)") .font(.caption.monospacedDigit()) .foregroundStyle(ReadingTheme.accent) .fixedSize() .accessibilityLabel("更新 \(book.lastCheckCount) 章") } } Text(book.author.isEmpty ? "未知作者" : book.author) .font(.caption).foregroundStyle(.secondary).lineLimit(1) if !compact { Text(readingPosition) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) } } .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, compact ? 10 : 12) .contentShape(Rectangle()) .accessibilityElement(children: .combine) .accessibilityValue(book.latestChapterTitle.map { "最新章节:\($0)" } ?? "") } } struct SearchView: View { @EnvironmentObject private var library: SourceLibraryModel let fixedSource: BookSource? private let initialKeyword: String @State private var keyword: String @StateObject private var model = SearchViewModel() @State private var showsScope = false @State private var selectedGroups = Set() @State private var selectedSourceID: String? @State private var didStartInitialSearch = false @State private var searchIsPresented = false @AppStorage("precisionSearch") private var precisionSearch = false init(fixedSource: BookSource? = nil, initialKeyword: String = "") { self.fixedSource = fixedSource self.initialKeyword = initialKeyword _keyword = State(initialValue: initialKeyword) } var body: some View { ScrollView { LazyVStack(spacing: 12) { switch model.state { case .idle: idleContent case .searching where model.books.isEmpty: initialLoadingView case .unavailable: unavailableView case .empty: emptyResultsView case .searching, .finished: resultsContent } if model.state != .searching, !model.diagnostics.isEmpty { diagnosticsCard } } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 28) } .background(ReadingTheme.canvas) .navigationTitle("搜索") .readingSearchable(text: $keyword, isPresented: $searchIsPresented, prompt: "书名或作者") .readingScrollDismissesKeyboard() .onSubmit(of: [.text, .search]) { startSearch() } .onDisappear { if model.state == .searching || model.isLoadingMore { model.cancel() } } .onChange(of: keyword) { value in if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { model.cancel(reset: true) } } .onChange(of: library.sources.count) { _ in guard !didStartInitialSearch, !initialKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !library.sources.isEmpty else { return } didStartInitialSearch = true startSearch() } .task { await model.loadSearchKeywords() if initialKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { searchIsPresented = true } else if !library.sources.isEmpty, !didStartInitialSearch { didStartInitialSearch = true startSearch() } } .toolbar { ToolbarItem(placement: .topBarTrailing) { Menu { Toggle("精准搜索", isOn: $precisionSearch) Button("分组或书源", systemImage: "scope") { showsScope = true } NavigationLink { SourceManagementView() } label: { Label("书源管理", systemImage: "network") } if model.state == .searching { Button("停止搜索", systemImage: "stop.fill", role: .destructive) { model.cancel() } } else if model.isPaused { Button("继续搜索", systemImage: "play.fill") { model.resume() } } } label: { Label("搜索选项", systemImage: "slider.horizontal.3") } } } .sheet(isPresented: $showsScope) { SearchScopeSheet( sources: library.sources.filter(\.enabled), groups: library.groups, selectedGroups: $selectedGroups, selectedSourceID: $selectedSourceID ) } } @ViewBuilder private var idleContent: some View { discoveryPrompt if !model.searchKeywords.isEmpty { searchHistoryView } } private var discoveryPrompt: some View { Button { searchIsPresented = true } label: { HStack(alignment: .top, spacing: 12) { Image(systemName: "magnifyingglass") .font(.body) .foregroundStyle(ReadingTheme.accent) VStack(alignment: .leading, spacing: 6) { Text("搜索书名或作者") .font(.headline.weight(.medium)) .foregroundStyle(.primary) Text("\(searchableSourceCount) 个书源 · \(scopeDisplay)") .font(.caption) .foregroundStyle(.secondary) } Spacer(minLength: 0) } .padding(.vertical, 20) } .buttonStyle(.plain) } private var initialLoadingView: some View { VStack(spacing: 12) { ReadingCard { VStack(alignment: .leading, spacing: 14) { HStack(spacing: 12) { ProgressView().controlSize(.regular) VStack(alignment: .leading, spacing: 3) { Text("正在搜索“\(model.submittedKeyword)”").font(.headline) Text(searchProgressText).font(.caption).foregroundStyle(.secondary) } Spacer() Button("停止", role: .destructive) { model.cancel() } .font(.subheadline.weight(.medium)) } ProgressView(value: searchProgress) .tint(ReadingTheme.accent) Text("结果会在书源返回后立即显示,无需等待全部完成。") .font(.caption).foregroundStyle(.secondary) } } ForEach(0..<2, id: \.self) { _ in SearchLoadingRow() } } } private var resultsContent: some View { Group { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline) { VStack(alignment: .leading, spacing: 2) { Text("“\(model.submittedKeyword)”").font(.headline).lineLimit(1) Text(model.isShowingCachedResults ? (model.state == .searching ? "已显示缓存结果,正在更新书源" : "已显示缓存结果") : "找到 \(model.books.count) 本,已自动合并重复来源") .font(.caption).foregroundStyle(.secondary) } Spacer() if model.isPaused { Button("继续", systemImage: "play.fill") { model.resume() } .buttonStyle(.bordered) .controlSize(.small) } else if model.state == .searching { StatusPill(text: "\(model.completedSourceCount)/\(model.totalSourceCount)", systemImage: "magnifyingglass") } else { StatusPill(text: scopeDisplay, systemImage: "scope") } } if model.state == .searching { ProgressView(value: searchProgress).tint(ReadingTheme.accent) } } .padding(.vertical, 4) ForEach(model.books) { book in if let source = library.sources.first(where: { $0.bookSourceUrl == book.origin }) { NavigationLink { BookDetailView(searchBook: book, source: source) } label: { SearchBookRow(book: book) } .buttonStyle(.plain) .onAppear { loadMoreIfNeeded(book) } } else { SearchBookRow(book: book) .onAppear { loadMoreIfNeeded(book) } } } if model.isLoadingMore { HStack(spacing: 10) { ProgressView().controlSize(.small) Text("正在查找更多结果…").font(.subheadline).foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .padding(.vertical, 12) } else if !model.hasMore, model.state == .finished { Text("已经到底了") .font(.caption).foregroundStyle(.tertiary) .frame(maxWidth: .infinity).padding(.vertical, 8) } } } private var emptyResultsView: some View { SearchStateCard( title: model.isPaused ? "搜索已停止" : "没有找到“\(model.submittedKeyword)”", detail: emptyResultsDetail, systemImage: model.isPaused ? "pause.circle" : "book.closed" ) { if model.isPaused { Button("继续搜索", systemImage: "play.fill") { model.resume() } .buttonStyle(.borderedProminent).tint(ReadingTheme.accent) } Button("修改关键词", systemImage: "text.cursor") { searchIsPresented = true } .buttonStyle(.bordered) } } private var unavailableView: some View { SearchStateCard( title: "当前范围没有可搜索书源", detail: "请启用带搜索规则的书源,或调整当前搜索范围。", systemImage: "network.slash" ) { Button("调整范围", systemImage: "scope") { showsScope = true } .buttonStyle(.borderedProminent).tint(ReadingTheme.accent) NavigationLink { SourceManagementView() } label: { Label("管理书源", systemImage: "network") } .buttonStyle(.bordered) } } private var searchProgress: Double { guard model.totalSourceCount > 0 else { return 0 } return Double(model.completedSourceCount) / Double(model.totalSourceCount) } private var emptyResultsDetail: String { if model.isPaused { return "你可以继续搜索剩余书源,或修改关键词重新搜索。" } if precisionSearch { return "试试更短的书名、作者名,或关闭精准搜索。" } return "试试更短的书名、作者名,或调整搜索范围。" } private var searchProgressText: String { model.completedSourceCount == 0 ? "正在连接 \(model.totalSourceCount) 个书源" : "已完成 \(model.completedSourceCount) / \(model.totalSourceCount) 个书源" } private var searchableSourceCount: Int { scopedSources.filter { $0.enabled && $0.searchUrl?.isEmpty == false }.count } private func loadMoreIfNeeded(_ book: SearchBook) { if model.state != .searching, book.id == model.books.last?.id { model.loadMore() } } private var diagnosticsCard: some View { DisclosureGroup("部分书源未返回结果(\(model.diagnostics.count))") { VStack(alignment: .leading, spacing: 10) { ForEach(model.diagnostics.prefix(20)) { diagnostic in HStack(alignment: .top) { Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) VStack(alignment: .leading, spacing: 2) { Text(diagnostic.message).font(.subheadline) Text(diagnostic.code).font(.caption.monospaced()).foregroundStyle(.secondary) } } } } .padding(.top, 10) } .padding(16) .background(.background, in: RoundedRectangle(cornerRadius: 18)) } private var searchHistoryView: some View { ReadingCard { VStack(alignment: .leading, spacing: 0) { HStack { Text("搜索历史").font(.headline) Spacer() Button("清空") { Task { await model.clearSearchKeywords() } }.font(.subheadline) } .padding(.bottom, 8) ForEach(model.searchKeywords) { item in Divider().padding(.leading, 28) HStack(spacing: 10) { Button { keyword = item.word; startSearch() } label: { HStack(spacing: 10) { Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary) Text(item.word).foregroundStyle(.primary).lineLimit(1) Spacer() } .contentShape(Rectangle()) } .buttonStyle(.plain) Button { Task { await model.deleteSearchKeyword(item.word) } } label: { Image(systemName: "xmark").font(.caption.bold()).foregroundStyle(.tertiary) .frame(width: 28, height: 28) } .buttonStyle(.plain) .accessibilityLabel("删除 \(item.word)") } .padding(.vertical, 7) } } } } private var scopedSources: [BookSource] { if var fixedSource { fixedSource.enabled = true; return [fixedSource] } let enabled = library.sources.filter(\.enabled) if let selectedSourceID { return enabled.filter { $0.id == selectedSourceID } } guard !selectedGroups.isEmpty else { return enabled } return enabled.filter { source in selectedGroups.contains(where: source.hasGroup) } } private var scopeDisplay: String { if let fixedSource { return fixedSource.bookSourceName } if let selectedSourceID, let source = library.sources.first(where: { $0.id == selectedSourceID }) { return source.bookSourceName } return selectedGroups.isEmpty ? "全部书源" : selectedGroups.sorted().joined(separator: ",") } private func startSearch() { let value = keyword.trimmingCharacters(in: .whitespacesAndNewlines) guard !value.isEmpty else { return } searchIsPresented = false model.search(keyword: value, sources: scopedSources, precision: precisionSearch) } } private struct SearchLoadingRow: View { var body: some View { ReadingCard { HStack(spacing: 15) { RoundedRectangle(cornerRadius: 8).fill(.secondary.opacity(0.12)).frame(width: 58, height: 82) VStack(alignment: .leading, spacing: 10) { RoundedRectangle(cornerRadius: 4).fill(.secondary.opacity(0.14)).frame(width: 150, height: 14) RoundedRectangle(cornerRadius: 4).fill(.secondary.opacity(0.1)).frame(width: 90, height: 11) RoundedRectangle(cornerRadius: 4).fill(.secondary.opacity(0.08)).frame(maxWidth: .infinity).frame(height: 11) } } } .accessibilityHidden(true) } } private struct SearchStateCard: View { let title: String let detail: String let systemImage: String @ViewBuilder let actions: Actions var body: some View { ReadingCard { VStack(spacing: 14) { Image(systemName: systemImage) .font(.system(size: 32)) .foregroundStyle(ReadingTheme.accent) .frame(width: 64, height: 64) .background(ReadingTheme.accentSoft.opacity(0.5), in: Circle()) VStack(spacing: 5) { Text(title).font(.headline).multilineTextAlignment(.center) Text(detail).font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center) } HStack(spacing: 10) { actions } } .frame(maxWidth: .infinity) .padding(.vertical, 16) } } } private struct SearchBookRow: View { let book: SearchBook var body: some View { HStack(alignment: .top, spacing: 14) { 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(.primary).lineLimit(2) Text(book.author.isEmpty ? "未知作者" : book.author) .font(.caption).foregroundStyle(.secondary) if let latest = book.latestChapterTitle, !latest.isEmpty { Text(latest) .font(.caption).foregroundStyle(.secondary).lineLimit(1) } HStack(spacing: 6) { Text(book.origins.count > 1 ? "\(book.origins.count) 个来源" : book.originName) .lineLimit(1) if let wordCount = book.wordCount, !wordCount.isEmpty { Text("·") Text(wordCount).lineLimit(1) } } .font(.caption) .foregroundStyle(.secondary) } Spacer(minLength: 0) Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary) } .padding(.vertical, 12) .overlay(alignment: .bottom) { Rectangle().fill(ReadingTheme.separator).frame(height: 0.5) .padding(.leading, 66) } .contentShape(Rectangle()) .accessibilityElement(children: .combine) } } private struct SettingsView: View { @Environment(\.colorScheme) private var colorScheme @AppStorage("themeMode") private var themeMode = "0" @ScaledMetric(relativeTo: .body) private var rowHeight = 52 var body: some View { GeometryReader { geometry in ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 0) { profileHeader(topInset: geometry.safeAreaInsets.top) VStack(alignment: .leading, spacing: 24) { appCard readingSection rulesSection settingSection("文件与存储") { settingLink("文件管理", icon: "folder", showsSeparator: false) { FileManagementView() } } supportSection } .padding(.horizontal, 28) .padding(.bottom, 32) } .background(alignment: .top) { Image("ProfileHeader") .resizable() .scaledToFill() .frame(width: geometry.size.width, height: geometry.safeAreaInsets.top + 156) .clipped() .overlay { if colorScheme == .dark { Color.black.opacity(0.62) } } .overlay(alignment: .bottom) { LinearGradient( colors: [ProfileTheme.canvas.opacity(0), ProfileTheme.canvas], startPoint: .top, endPoint: .bottom ) .frame(height: 44) } .accessibilityHidden(true) } } .background(ProfileTheme.canvas) .ignoresSafeArea(.container, edges: .top) .accessibilityIdentifier("profile.scroll") } .background(ProfileTheme.canvas) .navigationBarHidden(true) } private func profileHeader(topInset: CGFloat) -> some View { VStack(alignment: .leading, spacing: 8) { Text("我的") .font(.largeTitle.bold()) .foregroundStyle(ProfileTheme.ink) .accessibilityAddTraits(.isHeader) Text("按照你的方式阅读") .font(.subheadline) .foregroundStyle(ProfileTheme.secondary) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 28) .padding(.top, topInset + 42) .padding(.bottom, 34) } private var appCard: some View { NavigationLink { AboutView() } label: { HStack(spacing: 16) { Image("BrandIcon") .resizable() .scaledToFit() .frame(width: 58, height: 58) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 6) { Text(AppIdentity.name) .font(.headline) .foregroundStyle(ProfileTheme.ink) .fixedSize(horizontal: false, vertical: true) Text("快速搜书,自由阅读") .font(.footnote) .foregroundStyle(ProfileTheme.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) Image(systemName: "chevron.right") .font(.system(size: 13, weight: .medium)) .foregroundStyle(ProfileTheme.ink.opacity(0.75)) .frame(width: 26, height: 26) .background(ProfileTheme.detail.opacity(0.10), in: Circle()) .accessibilityHidden(true) } .padding(14) .frame(maxWidth: .infinity, minHeight: 86, alignment: .leading) .background { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(LinearGradient( colors: [ProfileTheme.surface, ProfileTheme.appCardTint], startPoint: .topLeading, endPoint: .bottomTrailing )) } .overlay { RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(ProfileTheme.surface.opacity(0.9), lineWidth: 0.75) } .shadow(color: .black.opacity(0.035), radius: 18, x: 0, y: 8) } .buttonStyle(.plain) .accessibilityElement(children: .combine) .accessibilityHint("查看关于与版本信息") .accessibilityIdentifier("profile.about") } private var readingSection: some View { settingSection("阅读") { settingLink("阅读设置", icon: "textformat.size") { ReadingPreferencesView() } Menu { Picker("外观", selection: $themeMode) { Text("跟随系统").tag("0") Text("浅色").tag("1") Text("深色").tag("2") Text("E-Ink 模式").tag("3") } } label: { settingRow("外观", icon: "circle.lefthalf.filled", value: appearanceTitle, showsValuePill: true) } .buttonStyle(.plain) .accessibilityLabel("外观") .accessibilityValue(appearanceTitle) .accessibilityIdentifier("profile.appearance") settingLink("在线朗读", icon: "waveform") { HttpTTSManagementView() } settingLink("书签", icon: "bookmark") { AllBookmarksView() } settingLink("阅读记录", icon: "clock", showsSeparator: false) { ReadingHistoryView() } } } private var rulesSection: some View { settingSection("书源与规则") { settingLink("书源管理", icon: "globe") { SourceManagementView() } settingLink("规则订阅", icon: "link") { RuleSubscriptionView() } settingLink("替换净化", icon: "text.badge.checkmark") { ReplaceRuleManagementView() } settingLink("TXT 目录规则", icon: "list.bullet.rectangle", showsSeparator: false) { TxtTocRuleManagementView() } } } private var supportSection: some View { settingSection("项目与支持") { Link(destination: AppIdentity.authorURL) { settingRow("开发者 Aminark", icon: "person.crop.circle", isExternal: true) } .buttonStyle(.plain) Link(destination: AppIdentity.projectURL) { settingRow("GitHub 项目", icon: "chevron.left.forwardslash.chevron.right", isExternal: true) } .buttonStyle(.plain) settingLink("其他设置", icon: "gearshape") { OtherSettingsView() } settingLink("关于", icon: "info.circle", showsSeparator: false) { AboutView() } } } private var appearanceTitle: String { switch themeMode { case "1": "浅色" case "2": "深色" case "3": "E-Ink 模式" default: "跟随系统" } } private func settingSection(_ title: String, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 12) { Text(title) .font(.body) .foregroundStyle(ProfileTheme.secondary) .accessibilityAddTraits(.isHeader) VStack(spacing: 0, content: content) .background(ProfileTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .shadow(color: .black.opacity(0.025), radius: 20, x: 0, y: 8) } } private func settingLink( _ title: String, icon: String, showsSeparator: Bool = true, @ViewBuilder destination: () -> Destination ) -> some View { NavigationLink(destination: destination) { settingRow(title, icon: icon, showsSeparator: showsSeparator) } .buttonStyle(.plain) .accessibilityIdentifier("profile.\(icon)") } private func settingRow( _ title: String, icon: String, value: String? = nil, showsValuePill: Bool = false, isExternal: Bool = false, showsSeparator: Bool = true ) -> some View { HStack(spacing: 18) { ProfileSettingIcon(systemName: icon) .frame(width: 26, height: 24) .accessibilityHidden(true) Text(title) .font(.body) .foregroundStyle(ProfileTheme.ink) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) if let value { Text(value) .font(.footnote) .foregroundStyle(ProfileTheme.secondary) .multilineTextAlignment(.trailing) .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, showsValuePill ? 10 : 0) .padding(.vertical, showsValuePill ? 4 : 0) .overlay { if showsValuePill { Capsule().strokeBorder(ProfileTheme.separator, lineWidth: 0.5) } } } Image(systemName: isExternal ? "arrow.up.right" : "chevron.right") .font(.caption.weight(.medium)) .foregroundStyle(ProfileTheme.secondary.opacity(0.65)) .accessibilityHidden(true) } .padding(.horizontal, 18) .padding(.vertical, 12) .frame(minHeight: rowHeight) .contentShape(Rectangle()) .overlay(alignment: .bottom) { if showsSeparator { Rectangle() .fill(ProfileTheme.separator) .frame(height: 0.5) .padding(.leading, 62) .padding(.trailing, 18) .accessibilityHidden(true) } } .accessibilityElement(children: .combine) } } private enum ProfileTheme { static let canvas = adaptive(0xF8F8F7, dark: 0x141416) static let surface = adaptive(0xFFFFFF, dark: 0x232326) static let appCardTint = adaptive(0xFBF3EC, dark: 0x302923) static let ink = adaptive(0x171817, dark: 0xF3F0EC) static let secondary = adaptive(0x898A8C, dark: 0xA5A2A0) static let detail = adaptive(0xBA916B, dark: 0xD1AB84) static let separator = adaptive(0xEAEAEA, dark: 0x373638) private static func adaptive(_ light: UInt32, dark: UInt32) -> Color { Color(uiColor: UIColor { traits in let hex = traits.userInterfaceStyle == .dark ? dark : light return UIColor( red: CGFloat((hex >> 16) & 0xff) / 255, green: CGFloat((hex >> 8) & 0xff) / 255, blue: CGFloat(hex & 0xff) / 255, alpha: 1 ) }) } } private struct ProfileSettingIcon: View { let systemName: String var body: some View { Group { if systemName == "textformat.size" { HStack(alignment: .firstTextBaseline, spacing: 0) { Text("A").font(.system(size: 24, weight: .light)).foregroundStyle(ProfileTheme.ink) Text("A").font(.system(size: 13, weight: .medium)).foregroundStyle(ProfileTheme.detail) } } else if ["circle.lefthalf.filled", "waveform", "bookmark", "clock", "globe"].contains(systemName) { Canvas { context, size in context.scaleBy(x: size.width / 24, y: size.height / 24) draw(in: &context) } .frame(width: 22, height: 22) } else { Image(systemName: systemName) .font(.system(size: 20, weight: .regular)) .symbolRenderingMode(.palette) .foregroundStyle(ProfileTheme.ink, ProfileTheme.detail) } } } private func draw(in context: inout GraphicsContext) { let stroke = StrokeStyle(lineWidth: 1.65, lineCap: .round, lineJoin: .round) let circle = Path(ellipseIn: CGRect(x: 2.5, y: 2.5, width: 19, height: 19)) switch systemName { case "circle.lefthalf.filled": var half = Path() half.addArc(center: CGPoint(x: 12, y: 12), radius: 9.5, startAngle: .degrees(90), endAngle: .degrees(270), clockwise: false) half.closeSubpath() context.fill(half, with: .color(ProfileTheme.ink)) context.stroke(circle, with: .color(ProfileTheme.ink), style: stroke) var edge = Path() edge.addArc(center: CGPoint(x: 12, y: 12), radius: 10.2, startAngle: .degrees(105), endAngle: .degrees(255), clockwise: false) context.stroke(edge, with: .color(ProfileTheme.detail), style: StrokeStyle(lineWidth: 1, lineCap: .round)) case "waveform": for (index, height) in [4.0, 12.0, 21.0, 14.0, 5.0].enumerated() { let bar = CGRect(x: 2 + Double(index) * 4.4, y: (24 - height) / 2, width: 2.4, height: height) context.fill(Path(roundedRect: bar, cornerRadius: 1.2), with: .color(index > 2 ? ProfileTheme.detail : ProfileTheme.ink)) } case "bookmark": var bookmark = Path() bookmark.move(to: CGPoint(x: 5, y: 21)) bookmark.addLine(to: CGPoint(x: 5, y: 4)) bookmark.addQuadCurve(to: CGPoint(x: 7, y: 2), control: CGPoint(x: 5, y: 2)) bookmark.addLine(to: CGPoint(x: 17, y: 2)) bookmark.addQuadCurve(to: CGPoint(x: 19, y: 4), control: CGPoint(x: 19, y: 2)) bookmark.addLine(to: CGPoint(x: 19, y: 21)) bookmark.addLine(to: CGPoint(x: 12, y: 16)) bookmark.closeSubpath() context.stroke(bookmark, with: .color(ProfileTheme.detail), style: stroke) case "clock": context.stroke(circle, with: .color(ProfileTheme.ink), style: stroke) var hands = Path() hands.move(to: CGPoint(x: 12, y: 6)) hands.addLine(to: CGPoint(x: 12, y: 12)) hands.addLine(to: CGPoint(x: 16, y: 15)) context.stroke(hands, with: .color(ProfileTheme.detail), style: stroke) case "globe": let meridian = Path(ellipseIn: CGRect(x: 7.5, y: 2.5, width: 9, height: 19)) context.stroke(meridian, with: .color(ProfileTheme.detail), style: stroke) var parallels = Path() for y in [7.0, 17.0] { parallels.move(to: CGPoint(x: 4, y: y)) parallels.addQuadCurve(to: CGPoint(x: 20, y: y), control: CGPoint(x: 12, y: 12)) } context.stroke(parallels, with: .color(ProfileTheme.detail), style: stroke) context.stroke(circle, with: .color(ProfileTheme.ink), style: stroke) default: break } } } private struct AllBookmarksView: View { @EnvironmentObject private var activity: ReadingActivityModel @EnvironmentObject private var library: BookLibraryModel @EnvironmentObject private var sources: SourceLibraryModel @State private var editingBookmark: Bookmark? @State private var exportsJSON = false @State private var exportsMarkdown = false private var sections: [BookmarkSection] { var output: [BookmarkSection] = [] for bookmark in activity.bookmarks { let id = bookmark.bookName + "\u{0}" + bookmark.bookAuthor if output.last?.id == id { output[output.count - 1].bookmarks.append(bookmark) } else { output.append(.init(id: id, bookName: bookmark.bookName, author: bookmark.bookAuthor, bookmarks: [bookmark])) } } return output } var body: some View { List { if activity.bookmarks.isEmpty { ReadingUnavailableView( "暂无书签", systemImage: "bookmark.slash", description: Text("阅读时可从菜单或点击区域添加书签。") ) } else { ForEach(sections) { section in Section("\(section.bookName)(\(section.author))") { ForEach(section.bookmarks) { bookmark in bookmarkLink(bookmark) .contextMenu { Button("编辑书签", systemImage: "square.and.pencil") { editingBookmark = bookmark } } } } } } } .navigationTitle("书签") .toolbar { ToolbarItem(placement: .topBarTrailing) { if !activity.bookmarks.isEmpty { Menu("导出", systemImage: "square.and.arrow.up") { Button("导出 JSON") { exportsJSON = true } Button("导出 Markdown") { exportsMarkdown = true } } } } } .sheet(item: $editingBookmark) { bookmark in BookmarkEditorView(bookmark: bookmark) } .fileExporter( isPresented: $exportsJSON, document: BookmarkExportDocument(data: jsonExportData), contentType: .json, defaultFilename: exportFilename(extension: "json") ) { _ in } .fileExporter( isPresented: $exportsMarkdown, document: BookmarkExportDocument(data: Data(markdownExport.utf8)), contentType: .plainText, defaultFilename: exportFilename(extension: "md") ) { _ in } } private func matchingBook(_ bookmark: Bookmark) -> Book? { library.books.first { $0.name == bookmark.bookName && $0.author == bookmark.bookAuthor } } @ViewBuilder private func bookmarkLink(_ bookmark: Bookmark) -> some View { if let book = matchingBook(bookmark) { NavigationLink { BookmarkReaderDestination( book: book, bookmark: bookmark, source: sources.sources.first { $0.bookSourceUrl == book.origin } ) } label: { bookmarkRow(bookmark) } } else { Button { editingBookmark = bookmark } label: { bookmarkRow(bookmark) } } } private func bookmarkRow(_ bookmark: Bookmark) -> some View { VStack(alignment: .leading, spacing: 6) { Text(bookmark.chapterName.isEmpty ? "第 \(bookmark.chapterIndex + 1) 章" : bookmark.chapterName) .font(.headline).foregroundStyle(ReadingTheme.coral) if !bookmark.bookText.isEmpty { Text(bookmark.bookText).font(.footnote).foregroundStyle(.secondary).lineLimit(3) } if !bookmark.content.isEmpty { Label(bookmark.content, systemImage: "note.text").font(.caption).lineLimit(2) } } .padding(.vertical, 5) } private var jsonExportData: Data { (try? JSONEncoder().encode(activity.bookmarks)) ?? Data("[]".utf8) } private var markdownExport: String { var output = "" var name = "" var author = "" for bookmark in activity.bookmarks { if bookmark.bookName != name && bookmark.bookAuthor != author { name = bookmark.bookName author = bookmark.bookAuthor output += "## \(bookmark.bookName) \(bookmark.bookAuthor)\n\n" } output += "#### \(bookmark.chapterName)\n\n" output += "###### 原文\n \(bookmark.bookText)\n\n" output += "###### 摘要\n \(bookmark.content)\n\n" } return output } private func exportFilename(extension value: String) -> String { let formatter = DateFormatter() formatter.dateFormat = "yyMMddHHmmss" return "bookmark-\(formatter.string(from: Date())).\(value)" } } private struct BookmarkSection: Identifiable { let id: String let bookName: String let author: String var bookmarks: [Bookmark] } private struct BookmarkExportDocument: FileDocument { static var readableContentTypes: [UTType] { [.json, .plainText] } let data: Data init(data: Data) { self.data = data } init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: data) } } private struct BookmarkEditorView: View { @EnvironmentObject private var activity: ReadingActivityModel @Environment(\.dismiss) private var dismiss let bookmark: Bookmark @State private var bookText: String @State private var content: String init(bookmark: Bookmark) { self.bookmark = bookmark _bookText = State(initialValue: bookmark.bookText) _content = State(initialValue: bookmark.content) } var body: some View { ReadingNavigationStack { Form { Section("原文") { TextEditor(text: $bookText).frame(minHeight: 150) } Section("摘要") { TextEditor(text: $content).frame(minHeight: 120) } Section { Button("删除书签", role: .destructive) { Task { await activity.removeBookmark(bookmark); dismiss() } } } } .navigationTitle(bookmark.chapterName.isEmpty ? "编辑书签" : bookmark.chapterName) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("保存") { var updated = bookmark updated.bookText = bookText updated.content = content Task { await activity.updateBookmark(updated); dismiss() } } } } } } } private struct BookmarkReaderDestination: View { let book: Book let bookmark: Bookmark let source: BookSource? @State private var chapters: [BookChapter] = [] @State private var preparedBook: Book? @State private var isLoading = true @State private var errorMessage: String? var body: some View { Group { if let preparedBook, !chapters.isEmpty { if book.origin == "local" { ReaderView(book: preparedBook, chapters: chapters, initialIndex: preparedBook.durChapterIndex) } else if let source { ReaderView(book: preparedBook, chapters: chapters, initialIndex: preparedBook.durChapterIndex, source: source) } else { ReadingUnavailableView("书源不可用", systemImage: "network.slash", description: Text("请先恢复或启用这本书对应的书源。")) } } else if isLoading { ProgressView("正在恢复书签位置…") } else { ReadingUnavailableView( "无法打开书签", systemImage: "bookmark.slash", description: Text(errorMessage ?? "当前目录中找不到书签对应章节。") ) } } .navigationTitle(bookmark.chapterName.isEmpty ? book.name : bookmark.chapterName) .navigationBarTitleDisplayMode(.inline) .task { await load() } } private func load() async { guard isLoading, chapters.isEmpty else { return } let loaded: [BookChapter] if book.origin == "local" { let targetBook = book do { loaded = try await Task.detached { try LocalBookService.live().loadChapters(book: targetBook).chapters }.value } catch { errorMessage = error.localizedDescription isLoading = false return } } else { guard let source else { errorMessage = "书源已删除或未启用。" isLoading = false return } let coordinator = Self.chapterCoordinator() let cached = await coordinator.storedChapters(bookURL: book.bookUrl) if cached.isEmpty { let report = await coordinator.refresh(book: book, source: source) loaded = report.chapters if loaded.isEmpty { errorMessage = report.diagnostics.first?.message ?? "目录加载失败。" } } else { loaded = cached } } guard let target = targetIndex(in: loaded) else { isLoading = false return } var restored = book restored.durChapterIndex = target restored.durChapterPos = max(bookmark.chapterPosition, 0) restored.durChapterTitle = loaded[target].title chapters = loaded preparedBook = restored isLoading = false } private func targetIndex(in chapters: [BookChapter]) -> Int? { bookmark.resolvedChapterIndex(in: chapters) ?? chapters.firstIndex(where: { !$0.isVolume }) } private static func chapterCoordinator() -> BookUpdateCoordinator { BookUpdateCoordinator(repository: AppBookChapterRepository.live()) } } private struct SearchScopeSheet: View { @Environment(\.dismiss) private var dismiss let sources: [BookSource] let groups: [String] @Binding var selectedGroups: Set @Binding var selectedSourceID: String? @State private var mode = 0 @State private var sourceFilter = "" @State private var filterIsPresented = false @State private var workingGroups: Set @State private var workingSourceID: String? init( sources: [BookSource], groups: [String], selectedGroups: Binding>, selectedSourceID: Binding ) { self.sources = sources self.groups = groups _selectedGroups = selectedGroups _selectedSourceID = selectedSourceID _workingGroups = State(initialValue: selectedGroups.wrappedValue) _workingSourceID = State(initialValue: selectedSourceID.wrappedValue) _mode = State(initialValue: selectedSourceID.wrappedValue == nil ? 0 : 1) } var body: some View { ReadingNavigationStack { List { Picker("范围类型", selection: $mode) { Text("分组").tag(0) Text("书源").tag(1) } .pickerStyle(.segmented) Button("全部书源") { selectedGroups.removeAll() selectedSourceID = nil dismiss() } if mode == 0 { ForEach(groups, id: \.self) { group in Button { workingSourceID = nil if !workingGroups.insert(group).inserted { workingGroups.remove(group) } } label: { HStack { Text(group).foregroundStyle(.primary) Spacer() if workingGroups.contains(group) { Image(systemName: "checkmark") } } } } } else { ForEach(filteredSources) { source in Button { workingGroups.removeAll() workingSourceID = source.id filterIsPresented = false } label: { HStack { VStack(alignment: .leading) { Text(source.bookSourceName).foregroundStyle(.primary) Text(source.bookSourceUrl).font(.caption).foregroundStyle(.secondary) } Spacer() if workingSourceID == source.id { Image(systemName: "checkmark") } } } } } } .navigationTitle("分组或书源") .navigationBarTitleDisplayMode(.inline) .readingSearchable(text: $sourceFilter, isPresented: $filterIsPresented, prompt: "筛选书源") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("确定") { selectedGroups = workingGroups selectedSourceID = workingSourceID dismiss() } } } } } private var filteredSources: [BookSource] { guard !sourceFilter.isEmpty else { return sources } return sources.filter { $0.bookSourceName.localizedCaseInsensitiveContains(sourceFilter) || $0.bookSourceUrl.localizedCaseInsensitiveContains(sourceFilter) } } } private struct ReadingPreferencesView: View { @AppStorage("textSize") private var fontSize = 20.0 @AppStorage("letterSpacing") private var letterSpacing = 0.1 @AppStorage("lineSpacingExtra") private var lineSpacing = 12.0 @AppStorage("paragraphSpacing") private var paragraphSpacing = 2.0 @AppStorage("paragraphIndent") private var paragraphIndent = "  " @AppStorage("paddingLeft") private var paddingLeft = 16.0 @AppStorage("paddingRight") private var paddingRight = 16.0 @AppStorage("paddingTop") private var paddingTop = 6.0 @AppStorage("paddingBottom") private var paddingBottom = 6.0 @AppStorage("textBold") private var fontWeight = 0 @AppStorage("titleMode") private var titleMode = 0 @AppStorage("titleSize") private var titleSize = 0.0 @AppStorage("titleTopSpacing") private var titleTopSpacing = 0.0 @AppStorage("titleBottomSpacing") private var titleBottomSpacing = 0.0 @AppStorage("underlineMode") private var underlineMode = 0 @AppStorage("readerTheme") private var theme = "wechat" @AppStorage("pageAnim") private var pageAnimation = 0 @AppStorage("readerKeepScreenOn") private var keepScreenOn = false @AppStorage("readerHideHomeIndicator") private var hideHomeIndicator = false var body: some View { Form { Section("排版") { ReadingLabeledContent("字号", value: String(format: "%.0f", fontSize)) Slider(value: $fontSize, in: 5...50, step: 1) ReadingLabeledContent("字间距", value: String(format: "%.2f", letterSpacing)) Slider(value: $letterSpacing, in: -0.5...0.5, step: 0.01) ReadingLabeledContent("行间距", value: String(format: "%.0f", lineSpacing)) Slider(value: $lineSpacing, in: 0...50, step: 1) ReadingLabeledContent("段距", value: String(format: "%.1f", paragraphSpacing)) Slider(value: $paragraphSpacing, in: 0...50, step: 1) Picker("字重", selection: $fontWeight) { Text("正常").tag(0) Text("粗体").tag(1) Text("细体").tag(2) } TextField("段落缩进字符", text: $paragraphIndent) Picker("下划线", selection: $underlineMode) { Text("关闭").tag(0) Text("实线").tag(1) Text("虚线").tag(2) } } Section("正文边距") { paddingSlider("左边距", value: $paddingLeft) paddingSlider("右边距", value: $paddingRight) paddingSlider("上边距", value: $paddingTop) paddingSlider("下边距", value: $paddingBottom) } Section("章节标题") { Picker("标题模式", selection: $titleMode) { Text("左对齐").tag(0) Text("居中").tag(1) Text("隐藏").tag(2) } ReadingLabeledContent("字号增量", value: String(format: "%+.0f", titleSize)) Slider(value: $titleSize, in: -10...30, step: 1) ReadingLabeledContent("上间距", value: String(Int(titleTopSpacing))) Slider(value: $titleTopSpacing, in: 0...100, step: 1) ReadingLabeledContent("下间距", value: String(Int(titleBottomSpacing))) Slider(value: $titleBottomSpacing, in: 0...100, step: 1) } Section("翻页") { Picker("翻页模式", selection: $pageAnimation) { Text("覆盖").tag(0) Text("滑动").tag(1) Text("仿真").tag(2) Text("滚动").tag(3) Text("无动画").tag(4) } } Section("屏幕") { Toggle("阅读时不自动锁屏", isOn: $keepScreenOn) Toggle("阅读时隐藏小横条", isOn: $hideHomeIndicator) } Section("阅读背景") { ForEach(ReaderStylePreset.legadoDefaults) { preset in Button { theme = preset.id } label: { HStack { RoundedRectangle(cornerRadius: 5) .fill(Color(uiColor: preset.background)) .frame(width: 38, height: 28) .overlay { RoundedRectangle(cornerRadius: 5).stroke(Color.secondary.opacity(0.5)) } Text(preset.name).foregroundStyle(.primary) Spacer() if ReaderStylePreset.resolve(theme).id == preset.id { Image(systemName: "checkmark").foregroundStyle(ReadingTheme.accent) } } } } } Section("预览") { Text("雨落在旧城的瓦檐上,故事从一束灯光开始。") .tracking(letterSpacing * fontSize) .font(.system(size: fontSize, design: .serif)) .lineSpacing(lineSpacing) .foregroundStyle(Color(uiColor: ReaderStylePreset.resolve(theme).foreground)) .padding(.vertical, 12) .listRowBackground(Color(uiColor: ReaderStylePreset.resolve(theme).background)) } } .navigationTitle("阅读设置") } private func paddingSlider(_ title: String, value: Binding) -> some View { VStack { ReadingLabeledContent(title, value: String(Int(value.wrappedValue))) Slider(value: value, in: 0...100, step: 1) } } }