From d80289fe73bb970169568cbff64cc7b9dc51ae57 Mon Sep 17 00:00:00 2001 From: Spasol Date: Sun, 17 Aug 2025 22:08:25 +0800 Subject: [PATCH] =?UTF-8?q?=E9=80=89=E6=8B=A9=E5=9B=BE=E7=89=87=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jetson Media.xcodeproj/project.pbxproj | 33 ++ .../xcshareddata/WorkspaceSettings.xcsettings | 5 + .../xcshareddata/swiftpm/Package.resolved | 15 + .../WorkspaceSettings.xcsettings | 14 + Jetson Media/Jetson_MediaApp.swift | 5 +- Jetson Media/been/Album.swift | 15 + Jetson Media/been/AlbumItem.swift | 8 + Jetson Media/been/Config.swift | 27 ++ Jetson Media/cache/ImageCacheManager.swift | 39 +++ .../cache/ReadingProgressManager.swift | 62 ++++ Jetson Media/ui/AsyncImageView.swift | 216 +++++++++++-- Jetson Media/ui/MainView.swift | 124 ++++++++ Jetson Media/ui/PhotoView.swift | 290 +++++++++++++---- Jetson Media/ui/ProfileView.swift | 291 ++++++++++++++++++ Jetson Media/ui/RankingsView.swift | 116 +++++++ Jetson Media/ui/SearchView.swift | 152 +++++++-- Jetson Media/ui/UserDefaults.swift | 29 ++ Jetson Media/utill/ThickSlider.swift | 56 ++++ Jetson-Media-Info.plist | 15 + 19 files changed, 1402 insertions(+), 110 deletions(-) create mode 100644 Jetson Media.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 Jetson Media.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Jetson Media.xcodeproj/project.xcworkspace/xcuserdata/spasolreisa.xcuserdatad/WorkspaceSettings.xcsettings create mode 100644 Jetson-Media-Info.plist diff --git a/Jetson Media.xcodeproj/project.pbxproj b/Jetson Media.xcodeproj/project.pbxproj index 3c4ad4c..3daadc7 100644 --- a/Jetson Media.xcodeproj/project.pbxproj +++ b/Jetson Media.xcodeproj/project.pbxproj @@ -29,9 +29,22 @@ 22F829AF2E50B29C00911ECA /* Jetson MediaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Jetson MediaUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 228E1BB92E50BD5C001E78DA /* Exceptions for "Jetson Media" folder in "Jetson Media" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + ui/Untitled.swift, + ); + target = 22F829942E50B29A00911ECA /* Jetson Media */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 22F829972E50B29B00911ECA /* Jetson Media */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 228E1BB92E50BD5C001E78DA /* Exceptions for "Jetson Media" folder in "Jetson Media" target */, + ); path = "Jetson Media"; sourceTree = ""; }; @@ -195,6 +208,9 @@ ); mainGroup = 22F8298C2E50B29A00911ECA; minimizedProjectReferenceProxies = 1; + packageReferences = ( + 22F16B172E518445003AB150 /* XCRemoteSwiftPackageReference "Kingfisher" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 22F829962E50B29B00911ECA /* Products */; projectDirPath = ""; @@ -399,6 +415,9 @@ DEVELOPMENT_TEAM = FXLG7YJAG6; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Jetson-Media-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_LSApplicationCategoryType = ""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -428,6 +447,9 @@ DEVELOPMENT_TEAM = FXLG7YJAG6; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Jetson-Media-Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = ""; + INFOPLIST_KEY_LSApplicationCategoryType = ""; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -558,6 +580,17 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 22F16B172E518445003AB150 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://gh.llkk.cc/https://github.com/onevcat/Kingfisher"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.5.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ }; rootObject = 22F8298D2E50B29A00911ECA /* Project object */; } diff --git a/Jetson Media.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Jetson Media.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Jetson Media.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/Jetson Media.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Jetson Media.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..ef34bee --- /dev/null +++ b/Jetson Media.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "72ff32661102b7b480d55d87de58435343e87079b41fe3bf1dca3b79e172b54f", + "pins" : [ + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://gh.llkk.cc/https://github.com/onevcat/Kingfisher", + "state" : { + "revision" : "2015fda791daa72c8058619545a593bf8c1dd59f", + "version" : "8.5.0" + } + } + ], + "version" : 3 +} diff --git a/Jetson Media.xcodeproj/project.xcworkspace/xcuserdata/spasolreisa.xcuserdatad/WorkspaceSettings.xcsettings b/Jetson Media.xcodeproj/project.xcworkspace/xcuserdata/spasolreisa.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..bbfef02 --- /dev/null +++ b/Jetson Media.xcodeproj/project.xcworkspace/xcuserdata/spasolreisa.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,14 @@ + + + + + BuildLocationStyle + UseAppPreferences + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/Jetson Media/Jetson_MediaApp.swift b/Jetson Media/Jetson_MediaApp.swift index ba75a7f..4b967a4 100644 --- a/Jetson Media/Jetson_MediaApp.swift +++ b/Jetson Media/Jetson_MediaApp.swift @@ -9,9 +9,12 @@ import SwiftUI @main struct Jetson_MediaApp: App { + let tabState = TabBarState() // 创建底部导航栏状态实例 + var body: some Scene { WindowGroup { - ContentView() + MainView() + .environmentObject(tabState) // 注入环境,供所有子视图使用 } } } diff --git a/Jetson Media/been/Album.swift b/Jetson Media/been/Album.swift index e69de29..ab250dd 100644 --- a/Jetson Media/been/Album.swift +++ b/Jetson Media/been/Album.swift @@ -0,0 +1,15 @@ +struct Album: Codable { + let album_id: String + let name: String + let authors: [String] + let actors: [String] + let tags: [String] + let image_urls: [String] + let nums: [Int] + + enum CodingKeys: String, CodingKey { + case album_id, name, authors, actors, tags + case image_urls = "image_urls" + case nums + } +} diff --git a/Jetson Media/been/AlbumItem.swift b/Jetson Media/been/AlbumItem.swift index e69de29..fb77d4a 100644 --- a/Jetson Media/been/AlbumItem.swift +++ b/Jetson Media/been/AlbumItem.swift @@ -0,0 +1,8 @@ +// 共用的数据模型 +struct AlbumItem: Codable, Identifiable { + let album_id: String + let title: String + + var id: String { album_id } +} + diff --git a/Jetson Media/been/Config.swift b/Jetson Media/been/Config.swift index e69de29..1cfa721 100644 --- a/Jetson Media/been/Config.swift +++ b/Jetson Media/been/Config.swift @@ -0,0 +1,27 @@ +import Foundation + +// 配置类:统一管理API地址、常量等 +final class Config { + // 单例实例,确保全局唯一 + static let shared = Config() + + // 服务器主机地址(HOST) + private let host = "http://jm.rbq.college" + + // 私有初始化方法,防止外部创建实例 + private init() {} + + // 拼接完整的API地址 + func apiURL(path: String) -> String { + // 处理path开头的斜杠,确保拼接正确 + let formattedPath = path.starts(with: "/") ? path : "/\(path)" + return "\(host)\(formattedPath)" + } + + // 常用API路径常量(可选,进一步简化调用) + struct Path { + static let search = "search/" // 搜索接口 + static let albumDetail = "album/" // 漫画详情接口 + static let rankings = "rankings/" // 排行榜接口(如果有) + } +} diff --git a/Jetson Media/cache/ImageCacheManager.swift b/Jetson Media/cache/ImageCacheManager.swift index e69de29..c230bfb 100644 --- a/Jetson Media/cache/ImageCacheManager.swift +++ b/Jetson Media/cache/ImageCacheManager.swift @@ -0,0 +1,39 @@ +import UIKit + +/// 图片缓存管理单例,负责图片的缓存、读取和清除 +class ImageCacheManager { + static let shared = ImageCacheManager() + private let cache = NSCache() + + // 私有初始化,确保单例 + private init() { + // 可设置缓存上限(可选) + cache.totalCostLimit = 1024 * 1024 * 100 // 100MB + } + + /// 从缓存获取图片 + /// - Parameter url: 图片URL字符串 + /// - Returns: 缓存的图片(如果存在) + func getImage(for url: String) -> UIImage? { + return cache.object(forKey: url as NSString) + } + + /// 保存图片到缓存 + /// - Parameters: + /// - image: 要缓存的图片 + /// - url: 图片URL字符串(作为缓存键) + func saveImage(_ image: UIImage, for url: String) { + cache.setObject(image, forKey: url as NSString) + } + + /// 清除指定URL的图片缓存 + /// - Parameter url: 图片URL字符串 + func removeImage(for url: String) { + cache.removeObject(forKey: url as NSString) + } + + /// 清除所有图片缓存 + func clearAllCache() { + cache.removeAllObjects() + } +} diff --git a/Jetson Media/cache/ReadingProgressManager.swift b/Jetson Media/cache/ReadingProgressManager.swift index e69de29..dcd4d0a 100644 --- a/Jetson Media/cache/ReadingProgressManager.swift +++ b/Jetson Media/cache/ReadingProgressManager.swift @@ -0,0 +1,62 @@ +import Foundation + +class ReadingProgressManager { + static let shared = ReadingProgressManager() + private let userDefaults = UserDefaults.standard + + private init() {} + + /// 保存阅读进度(直接存储图片索引,确保准确性) + func saveProgress(albumId: String, imageIndex: Int) { + userDefaults.set(imageIndex, forKey: progressKey(albumId: albumId)) + print("已保存进度 - 漫画ID: \(albumId), 图片索引: \(imageIndex)") + } + + /// 获取保存的阅读进度(返回图片索引) + func getProgress(albumId: String) -> Int { + return userDefaults.integer(forKey: progressKey(albumId: albumId)) + } + + /// 清除指定漫画的阅读进度 + func clearProgress(albumId: String) { + userDefaults.removeObject(forKey: progressKey(albumId: albumId)) + } + + /// 生成唯一存储键 + private func progressKey(albumId: String) -> String { + return "readingProgress_\(albumId)" + } +} + +/// 收藏管理工具 +class FavoriteManager { + static let shared = FavoriteManager() + private let userDefaults = UserDefaults.standard + private let favoriteKey = "favoriteAlbumIds" + + private init() {} + + /// 获取所有收藏的漫画ID + var favoriteAlbumIds: [String] { + userDefaults.array(forKey: favoriteKey) as? [String] ?? [] + } + + /// 切换漫画的收藏状态 + /// - Parameter albumId: 漫画ID + func toggleFavorite(albumId: String) { + var favorites = favoriteAlbumIds + if favorites.contains(albumId) { + favorites.removeAll { $0 == albumId } + } else { + favorites.append(albumId) + } + userDefaults.set(favorites, forKey: favoriteKey) + } + + /// 检查漫画是否已收藏 + /// - Parameter albumId: 漫画ID + /// - Returns: 是否收藏 + func isFavorite(albumId: String) -> Bool { + return favoriteAlbumIds.contains(albumId) + } +} diff --git a/Jetson Media/ui/AsyncImageView.swift b/Jetson Media/ui/AsyncImageView.swift index 7096844..45dfc1f 100644 --- a/Jetson Media/ui/AsyncImageView.swift +++ b/Jetson Media/ui/AsyncImageView.swift @@ -1,5 +1,7 @@ import SwiftUI +import UIKit +/// 带缓存、并发控制、图片解密、双指选择效果和分享功能的异步图片视图 struct AsyncImageView: View { let url: String let nums: [Int] @@ -8,97 +10,251 @@ struct AsyncImageView: View { @State private var isLoading = false @State private var error: Error? - // 静态属性,用于维护当前加载的索引范围 + @State private var isSelecting = false // 是否处于选择状态 + @State private var pressStartTime: Date? + @State private var longPressActive = false + @State private var showShareSheet = false // 控制分享面板显示 + private static var currentlyLoadingIndices: Set = [] private static let maxConcurrentLoads = 4 + private static let longPressThreshold: TimeInterval = 0.1 // 长按阈值(秒) var body: some View { Group { if let image = image { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) + ZStack(alignment: .center) { + // 原始图片 + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + + // 选择状态的视觉效果 + if isSelecting { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .colorMultiply(.white) + .opacity(0.7) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(Color.blue, lineWidth: 3) // 改为蓝色边框更醒目 + ) + .blendMode(.lighten) + + // 分享按钮(仅在选择状态显示) + VStack { + Spacer() + Button(action: { + showShareSheet = true + }) { + Image(systemName: "square.and.arrow.up") + .foregroundColor(.white) + .padding() + .background(Color.blue) + .clipShape(Circle()) + .shadow(radius: 4) + } + .padding() + } + } + } + // 双指手势识别 + .gesture( + MagnificationGesture(minimumScaleDelta: 0) + .onChanged { _ in + handleTwoFingerGesture() + } + .onEnded { _ in + // 只有长按超过阈值才保持选择状态 + let shouldKeepSelection = pressStartTime.map { + Date().timeIntervalSince($0) >= Self.longPressThreshold + } ?? false + + withAnimation(.easeOut) { + isSelecting = shouldKeepSelection + } + + if !shouldKeepSelection { + pressStartTime = nil + longPressActive = false + } + } + ) + // 分享视图 + .sheet(isPresented: $showShareSheet) { + ShareSheet(activityItems: [image]) { completed in + if completed { + withAnimation(.easeOut) { + isSelecting = false + } + pressStartTime = nil + longPressActive = false + } + } + } } else if isLoading { ProgressView() + .frame(height: 200) } else if error != nil { - VStack { + VStack(spacing: 8) { Image(systemName: "exclamationmark.triangle") .foregroundColor(.red) Text(error?.localizedDescription ?? "未知错误") .font(.caption) + .foregroundColor(.gray) } + .frame(height: 200) } else { Color.gray.opacity(0.3) + .frame(height: 200) } } .onAppear { loadImage() } + .onDisappear { + Self.currentlyLoadingIndices.remove(index) + } } + // 处理双指手势 + private func handleTwoFingerGesture() { + if pressStartTime == nil { + pressStartTime = Date() + } else { + // 检查是否超过长按阈值 + let pressDuration = Date().timeIntervalSince(pressStartTime!) + if pressDuration >= Self.longPressThreshold && !longPressActive { + longPressActive = true + // 显示选择效果 + withAnimation(.easeIn) { + isSelecting = true + } + } + } + } + + // 图片加载核心逻辑 private func loadImage() { - // 如果已经在加载中,或者超出最大加载限制,直接返回 + let cacheKey = extractCacheKey(from: url) + + // 1. 先检查缓存,如果有直接使用 + if let cachedImage = ImageCacheManager.shared.getImage(for: cacheKey) { + image = cachedImage + print("从缓存加载 - 键: \(cacheKey)") + return + } + + // 2. 检查并发加载限制 guard !Self.currentlyLoadingIndices.contains(index), Self.currentlyLoadingIndices.count < Self.maxConcurrentLoads else { return } - // 添加当前索引到加载集合 + // 3. 开始加载流程 Self.currentlyLoadingIndices.insert(index) isLoading = true error = nil - // 确保 nums 和 index 对齐,避免越界 + // 4. 验证解密参数 guard let decodeNum = nums[safe: index] else { - print("错误:nums 中未找到索引 \(index) 对应的值,可能 API 数据不一致") - Self.currentlyLoadingIndices.remove(index) - return - } - - print("调试信息:图片 URL: \(url), 解密参数 num: \(decodeNum), 索引: \(index)") - - guard let imageUrl = URL(string: url) else { - self.error = NSError(domain: "无效的URL", code: 1, userInfo: nil) + error = NSError(domain: "数据错误", code: 1, userInfo: [NSLocalizedDescriptionKey: "图片参数不匹配"]) isLoading = false Self.currentlyLoadingIndices.remove(index) return } - // 加载图片数据 + print("调试信息:加载图片 - URL: \(url), 缓存键: \(cacheKey), 解密参数: \(decodeNum), 索引: \(index)") + + // 5. 验证URL有效性 + guard let imageUrl = URL(string: url) else { + error = NSError(domain: "URL错误", code: 2, userInfo: [NSLocalizedDescriptionKey: "无效的图片URL"]) + isLoading = false + Self.currentlyLoadingIndices.remove(index) + return + } + + // 6. 网络请求加载图片 URLSession.shared.dataTask(with: imageUrl) { data, response, error in DispatchQueue.main.async { - // 移除当前索引 + // 清理加载状态 Self.currentlyLoadingIndices.remove(index) self.isLoading = false + // 处理网络错误 if let error = error { self.error = error - print("图片加载失败,错误: \(error.localizedDescription)") + print("图片加载失败(网络错误): \(error.localizedDescription)") return } + // 验证响应和数据 guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode), let data = data, !data.isEmpty else { - self.error = NSError(domain: "无效响应", code: 2, userInfo: nil) - print("图片加载失败,无效响应或数据为空") + self.error = NSError(domain: "响应错误", code: 3, userInfo: [NSLocalizedDescriptionKey: "无效的服务器响应"]) + print("图片加载失败(无效响应)") return } - if let loadedImage = UIImage(data: data) { - print("图片加载成功,开始解密...") - let decodedImage = loadedImage.decodeImage(num: decodeNum) - self.image = decodedImage - print("解密完成,图片已更新") - } else { - self.error = NSError(domain: "图片数据解码失败", code: 4, userInfo: nil) - print("图片数据解码失败") + // 解码图片并应用解密 + guard let loadedImage = UIImage(data: data) else { + self.error = NSError(domain: "解码错误", code: 4, userInfo: [NSLocalizedDescriptionKey: "无法解析图片数据"]) + print("图片加载失败(数据无效)") + return } + + // 解密并缓存图片(使用简化键) + let decodedImage = loadedImage.decodeImage(num: decodeNum) + self.image = decodedImage + ImageCacheManager.shared.saveImage(decodedImage, for: cacheKey) + print("图片加载成功(已缓存) - 键: \(cacheKey)") } }.resume() } + + /// 从URL中提取简化的缓存键 + private func extractCacheKey(from urlString: String) -> String { + guard let url = URL(string: urlString) else { + return urlString.hashValue.description + } + + let pathComponents = url.pathComponents + + if let photosIndex = pathComponents.firstIndex(of: "photos"), + photosIndex + 1 < pathComponents.count, + photosIndex + 2 < pathComponents.count { + let idComponent = pathComponents[photosIndex + 1] + var fileComponent = pathComponents[photosIndex + 2] + + if let dotIndex = fileComponent.lastIndex(of: ".") { + fileComponent = String(fileComponent[.. Void + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + controller.completionWithItemsHandler = { (activityType, completed, returnedItems, error) in + completion(completed) + } + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +// 数组安全访问扩展 extension Array { subscript(safe index: Int) -> Element? { return indices.contains(index) ? self[index] : nil diff --git a/Jetson Media/ui/MainView.swift b/Jetson Media/ui/MainView.swift index e69de29..7d68eb7 100644 --- a/Jetson Media/ui/MainView.swift +++ b/Jetson Media/ui/MainView.swift @@ -0,0 +1,124 @@ +import SwiftUI +import UIKit + +// 1. 状态管理类 - 控制底部导航栏显示状态 +class TabBarState: ObservableObject { + @Published var isTabBarHidden = false // 控制底部导航栏是否隐藏 +} + +// 扩展状态管理类,提供便捷方法 +extension TabBarState { + func hideTabBar() { isTabBarHidden = true } + func showTabBar() { isTabBarHidden = false } + func toggleTabBar() { isTabBarHidden.toggle() } +} + +// 2. 自定义TabBar控制器(支持全屏显示) +class CustomTabBarController: UITabBarController { + var shouldHideTabBar: Bool = false { + didSet { + updateTabBarVisibility() + } + } + + override func viewDidLoad() { + super.viewDidLoad() + // 配置控制器支持全屏 + edgesForExtendedLayout = .all // 允许内容延伸到导航栏/状态栏下方 + extendedLayoutIncludesOpaqueBars = true // 包含不透明的导航栏 + automaticallyAdjustsScrollViewInsets = false // 禁止自动调整滚动视图Insets + } + + private func updateTabBarVisibility() { + UIView.animate(withDuration: 0.3) { + self.tabBar.isHidden = self.shouldHideTabBar + // 调整安全区域:全屏时移除底部额外内边距 + self.viewControllers?.forEach { vc in + vc.additionalSafeAreaInsets.bottom = self.shouldHideTabBar ? 0 : 0 + // 强制子视图刷新布局 + vc.view.setNeedsLayout() + } + } + } +} + +// 3. SwiftUI与UIKit的桥接视图(支持全屏) +struct TabBarContainerView: UIViewControllerRepresentable { + @Binding var isTabBarHidden: Bool + + init(isTabBarHidden: Binding) { + self._isTabBarHidden = isTabBarHidden + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIViewController(context: Context) -> CustomTabBarController { + let tabBarController = CustomTabBarController() + + // 配置搜索标签页(添加全屏修饰符) + let searchView = SearchView() + .edgesIgnoringSafeArea(.all) // 忽略安全区域 + + let searchVC = UIHostingController(rootView: searchView) + searchVC.tabBarItem = UITabBarItem( + title: "搜索", + image: UIImage(systemName: "magnifyingglass"), + tag: 0 + ) + searchVC.edgesForExtendedLayout = .all + + // 配置排行榜标签页(添加全屏修饰符) + let rankingsView = RankingsView() + .edgesIgnoringSafeArea(.all) + + let rankingsVC = UIHostingController(rootView: rankingsView) + rankingsVC.tabBarItem = UITabBarItem( + title: "排行榜", + image: UIImage(systemName: "chart.bar"), + tag: 1 + ) + rankingsVC.edgesForExtendedLayout = .all + + // 配置我的标签页(添加全屏修饰符) + let profileView = ProfileView() + .edgesIgnoringSafeArea(.all) + + let profileVC = UIHostingController(rootView: profileView) + profileVC.tabBarItem = UITabBarItem( + title: "我的", + image: UIImage(systemName: "person"), + tag: 2 + ) + profileVC.edgesForExtendedLayout = .all + + tabBarController.viewControllers = [searchVC, rankingsVC, profileVC] + return tabBarController + } + + func updateUIViewController(_ uiViewController: CustomTabBarController, context: Context) { + uiViewController.shouldHideTabBar = isTabBarHidden + } + + class Coordinator: NSObject { + var parent: TabBarContainerView + + init(_ parent: TabBarContainerView) { + self.parent = parent + } + } +} + +// 4. 主视图(支持全屏) +struct MainView: View { + @EnvironmentObject var tabState: TabBarState + + var body: some View { + TabBarContainerView(isTabBarHidden: $tabState.isTabBarHidden) + .navigationTitle("漫画列表") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.hidden, for: .navigationBar) + .edgesIgnoringSafeArea(.all) // 主视图忽略安全区域 + } +} diff --git a/Jetson Media/ui/PhotoView.swift b/Jetson Media/ui/PhotoView.swift index 6469d45..2318ca0 100644 --- a/Jetson Media/ui/PhotoView.swift +++ b/Jetson Media/ui/PhotoView.swift @@ -1,52 +1,190 @@ import SwiftUI import Combine +import Foundation +import UIKit struct PhotoView: View { let albumId: String @StateObject private var viewModel: PhotoViewModel + @State private var currentImageIndex: Int = 0 // 当前可见的图片索引 + @State private var progress: Double = 0 // 进度条比例(0-1) + @State private var totalImages: Int = 0 // 总图片数量 + @State private var isFullscreen = false // 全屏状态标记 + @Environment(\.presentationMode) var presentationMode // 返回上级页面 + @EnvironmentObject var tabState: TabBarState // 获取状态对象 + + // 定时器:降低保存频率,减少性能消耗 + private let saveTimer = Timer.publish(every: 2, on: .main, in: .common) + private var timerCancellable: AnyCancellable? // 用于存储定时器订阅 + init(albumId: String) { self.albumId = albumId self._viewModel = StateObject(wrappedValue: PhotoViewModel(albumId: albumId)) + self.timerCancellable = saveTimer.autoconnect().sink(receiveValue: { _ in }) } var body: some View { - ZStack { + ZStack(alignment: .bottom) { + // 背景色(根据全屏状态切换) + Color(isFullscreen ? .black : .systemBackground) + .edgesIgnoringSafeArea(.all) + if viewModel.isLoading { - ProgressView() + ProgressView("加载中...") + .foregroundColor(isFullscreen ? .white : .black) + } else if let error = viewModel.error { + VStack(spacing: 16) { + Text("加载失败") + .font(.headline) + .foregroundColor(isFullscreen ? .white : .black) + Text(error.localizedDescription) + .font(.subheadline) + .foregroundColor(isFullscreen ? .gray : .secondary) + Button("重试") { viewModel.loadAlbumData() } + .foregroundColor(.blue) + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(8) + } } else if let album = viewModel.album { ScrollView { - VStack(alignment: .leading, spacing: 8) { - Text(album.name) - .font(.headline) - - Text("作者: \(album.authors.joined(separator: ", "))") - .font(.subheadline) - - Text("标签: \(album.tags.joined(separator: ", "))") - .font(.caption) - .foregroundColor(.gray) - } - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(.systemBackground)) - - LazyVStack(spacing: 0) { // 去掉图片间距 - ForEach(Array(album.image_urls.enumerated()), id: \.offset) { index, url in - AsyncImageView( - url: url, - nums: album.nums, - index: index - ) - .frame(maxWidth: .infinity) // 强制图片填充父视图宽度 - .aspectRatio(contentMode: .fill) // 确保图片填充整个区域 - .clipped() // 防止图片超出边界 + ScrollViewReader { proxy in + // 漫画信息头部(全屏时隐藏) + if !isFullscreen { + VStack(alignment: .leading, spacing: 8) { + Text(album.name) + .font(.headline) + Text("作者: \(album.authors.joined(separator: ", "))") + .font(.subheadline) + .foregroundColor(isFullscreen ? .gray : .secondary) + Text("标签: \(album.tags.joined(separator: ", "))") + .font(.caption) + .foregroundColor(isFullscreen ? .gray : .secondary) + + // 收藏按钮 + Button(action: toggleFavorite) { + Image(systemName: viewModel.isFavorite ? "heart.fill" : "heart") + .foregroundColor(viewModel.isFavorite ? .red : .gray) + Text(viewModel.isFavorite ? "取消收藏" : "收藏") + .foregroundColor(isFullscreen ? .white : .black) + } + .padding(.vertical, 8) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(isFullscreen ? Color.black : Color(.systemBackground)) + .id("header") + } + + // 图片列表 + LazyVStack(spacing: 0) { + ForEach(Array(album.image_urls.enumerated()), id: \.offset) { index, url in + AsyncImageView( + url: url, + nums: album.nums, + index: index + ) + .frame(maxWidth: .infinity) + .id(index) + // 当图片进入屏幕时更新当前索引 + .onAppear { + // 只有当图片索引大于当前索引时才更新(避免回滚时误判) + if index > currentImageIndex || abs(index - currentImageIndex) > 3 { + currentImageIndex = index + updateProgressRatio() + } + } + } + } + .onAppear { + totalImages = album.image_urls.count + // 初始化:加载上次保存的图片索引 + let savedIndex = ReadingProgressManager.shared.getProgress(albumId: albumId) + currentImageIndex = clamp(value: savedIndex, min: 0, max: totalImages - 1) + updateProgressRatio() + // 在PhotoView的onAppear中添加 + UserDefaults.standard.addHistory(albumId: albumId) + // 自动跳转到上次位置 + print("上次阅读位置: \(savedIndex)") + if totalImages > 0, currentImageIndex > 0 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation { + proxy.scrollTo(currentImageIndex, anchor: .top) + } + } + } + } + // 进度条拖动时同步到对应图片 + .onChange(of: progress) { newValue in + let targetIndex = Int(newValue * Double(max(0, totalImages - 1))) + let clampedIndex = clamp(value: targetIndex, min: 0, max: totalImages - 1) + if clampedIndex != currentImageIndex { + currentImageIndex = clampedIndex + withAnimation { + proxy.scrollTo(currentImageIndex, anchor: .top) + } + } } } } - .edgesIgnoringSafeArea(.all) // 让内容填充整个屏幕 - } else if let error = viewModel.error { - Text("加载失败: \(error.localizedDescription)") + .padding(.top, isFullscreen ? 0 : 60) + .edgesIgnoringSafeArea(isFullscreen ? .all : .vertical) + .onTapGesture { + // 点击切换全屏状态 + withAnimation { + tabState.toggleTabBar() + isFullscreen.toggle() + adjustSystemUI() + } + } + // 定期保存进度 + .onReceive(saveTimer) { _ in + guard totalImages > 0 else { return } + ReadingProgressManager.shared.saveProgress(albumId: albumId, imageIndex: currentImageIndex) + } + .onDisappear { + ReadingProgressManager.shared.saveProgress(albumId: albumId, imageIndex: currentImageIndex) + timerCancellable?.cancel() // 取消定时器订阅 + // 恢复系统UI + UIApplication.shared.isStatusBarHidden = false + } + + // 控制栏(全屏时显示) + if isFullscreen { + // 顶部控制栏 + VStack { + HStack { + // 返回按钮 + Button(action: { presentationMode.wrappedValue.dismiss() }) { + Image(systemName: "chevron.left") + .foregroundColor(.white) + .padding() + .background(Color.black.opacity(0.5)) + .cornerRadius(8) + } + + Spacer() + } + .padding() + Spacer() + } + } + + if totalImages > 0 && !isFullscreen { + CustomSlider( + value: $progress, + trackHeight: 20, // 这里可以设置足够粗的高度(如20px) + tintColor: .blue + ) + .background(Color(.systemBackground).opacity(0.9)) + .cornerRadius(8) // 圆角配合高度,更美观 + .shadow(radius: 5) + .padding(.horizontal, 30) // 左右留空 + .padding(.bottom, tabState.isTabBarHidden ? 30 : 60) // 底部距离 + .padding(.top, 10) // 向上偏移 + } + } } .alert(isPresented: $viewModel.showAlert) { @@ -56,16 +194,62 @@ struct PhotoView: View { dismissButton: .default(Text("确定")) ) } + .navigationBarHidden(isFullscreen) + .statusBarHidden(isFullscreen) + .onAppear { + // 初始化系统UI状态 + adjustSystemUI() + } + .onDisappear { + // 离开阅读器时显示底部导航栏 + tabState.showTabBar() + } + } + + // 调整系统UI(状态栏和导航栏) + private func adjustSystemUI() { + UIApplication.shared.isStatusBarHidden = isFullscreen + // 强制刷新状态栏 + UIApplication.shared.windows.first?.rootViewController?.setNeedsStatusBarAppearanceUpdate() + } + + // 更新进度条比例 + private func updateProgressRatio() { + guard totalImages > 0 else { + progress = 0 + return + } + let safeTotal = max(1, totalImages - 1) // 避免除以0 + progress = Double(currentImageIndex) / Double(safeTotal) + } + + // 切换收藏状态 + private func toggleFavorite() { + FavoriteManager.shared.toggleFavorite(albumId: albumId) + viewModel.isFavorite = FavoriteManager.shared.isFavorite(albumId: albumId) + } + + // 限制数值范围 + private func clamp(value: Int, min: Int, max: Int) -> Int { + return Swift.max(min, Swift.min(value, max)) } } -// 其余代码保持不变(PhotoView, PhotoViewModel, Album结构体等) + +// 滚动偏移量偏好键 +struct ScrollOffsetKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() + } +} + class PhotoViewModel: ObservableObject { let albumId: String @Published var album: Album? @Published var isLoading = false @Published var error: Error? - @Published var scrollPosition: Double = 0 + @Published var isFavorite: Bool = false // 收藏状态 @Published var showAlert = false @Published var alertTitle = "" @Published var alertMessage = "" @@ -74,17 +258,24 @@ class PhotoViewModel: ObservableObject { init(albumId: String) { self.albumId = albumId + checkIfFavorite() loadAlbumData() } + // 检查是否已收藏 + private func checkIfFavorite() { + isFavorite = FavoriteManager.shared.isFavorite(albumId: albumId) + } + func loadAlbumData() { isLoading = true error = nil + album = nil // 重置之前的数据 - let urlString = "https://jms.godserver.cn/album/\(albumId)/" + let urlString = Config.shared.apiURL(path: "album/\(albumId)/") guard let url = URL(string: urlString) else { - error = NSError(domain: "无效的URL", code: 0, userInfo: nil) - isLoading = false + let error = NSError(domain: "PhotoViewModel", code: 0, userInfo: [NSLocalizedDescriptionKey: "无效的URL"]) + handleError(error: error) return } @@ -97,34 +288,29 @@ class PhotoViewModel: ObservableObject { .sink(receiveCompletion: { [weak self] completion in self?.isLoading = false if case .failure(let error) = completion { - self?.error = error - print("相册加载失败: \(error.localizedDescription)") + self?.handleError(error: error) } }, receiveValue: { [weak self] album in + self?.isLoading = false self?.album = album print("成功加载相册: \(album.name), 包含 \(album.image_urls.count) 张图片") }) .store(in: &cancellables) } + // 错误处理封装 + private func handleError(error: Error) { + self.error = error + self.alertTitle = "加载失败" + self.alertMessage = error.localizedDescription + self.showAlert = true + self.isLoading = false + print("相册加载失败: \(error.localizedDescription)") + } + func showAlert(title: String, message: String) { alertTitle = title alertMessage = message showAlert = true } } -struct Album: Codable { - let album_id: String - let name: String - let authors: [String] - let actors: [String] - let tags: [String] - let image_urls: [String] - let nums: [Int] - - enum CodingKeys: String, CodingKey { - case album_id, name, authors, actors, tags - case image_urls = "image_urls" - case nums - } -} diff --git a/Jetson Media/ui/ProfileView.swift b/Jetson Media/ui/ProfileView.swift index e69de29..c2fe91a 100644 --- a/Jetson Media/ui/ProfileView.swift +++ b/Jetson Media/ui/ProfileView.swift @@ -0,0 +1,291 @@ +import SwiftUI +import Foundation + +// 扩展UserDefaults,用于存储历史记录 +extension UserDefaults { + // 历史记录存储格式:[album_id: 最近访问时间戳] + private var historyRecordsKey: String { "history_records" } + + var historyRecords: [String: TimeInterval] { + get { + (object(forKey: historyRecordsKey) as? [String: TimeInterval]) ?? [:] + } + set { + set(newValue, forKey: historyRecordsKey) + } + } + + // 添加历史记录(自动去重并更新时间戳) + func addHistory(albumId: String) { + var records = historyRecords + records[albumId] = Date().timeIntervalSince1970 // 更新为当前时间戳 + historyRecords = records + } + + // 删除单条历史记录 + func removeHistory(albumId: String) { + var records = historyRecords + records.removeValue(forKey: albumId) + historyRecords = records + } + + // 清除所有历史记录 + func clearAllHistory() { + historyRecords = [:] + } +} + +struct ProfileView: View { + // 示例用户数据 + let username = "Jetson User" + + // 收藏列表数据 + @State private var favoriteAlbums: [AlbumItem] = [] + @State private var isLoadingFavorites = false + + // 历史记录数据 + @State private var historyAlbums: [AlbumItem] = [] + @State private var isLoadingHistory = false + @State private var showHistoryEmpty = false + + // 错误提示 + @State private var showError = false + @State private var errorMessage = "" + + var body: some View { + NavigationStack { + List { + // 用户信息头部 + Section { + HStack(alignment: .center, spacing: 12) { + // 头像 + Image(systemName: "person.circle.fill") + .resizable() + .frame(width: 80, height: 80) + .foregroundColor(.blue) + + // 用户名 + VStack(spacing: 4) { + Text(username) + .font(.headline) + } + } + .padding(.vertical, 20) + } + + // 收藏区域 + Section(header: Text("我的收藏") + .font(.headline)) { + if isLoadingFavorites { + HStack { + Spacer() + ProgressView("加载收藏中...") + Spacer() + } + .padding(.vertical, 20) + } else if favoriteAlbums.isEmpty { + Text("暂无收藏,快去收藏喜欢的漫画吧~") + .foregroundColor(.gray) + .padding(.vertical, 20) + } else { + ForEach(favoriteAlbums) { item in + NavigationLink(destination: PhotoView(albumId: item.album_id)) { + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.subheadline) + .lineLimit(2) + Text("ID: \(item.album_id)") + .font(.caption) + .foregroundColor(.gray) + } + } + } + .onDelete(perform: removeFromFavorites) + } + } + + // 历史记录区域 + Section(header: HStack { + Text("浏览历史") + .font(.headline) + Spacer() + Button("清除全部") { + clearAllHistory() + } + .foregroundColor(.red) + .font(.subheadline) + }) { + if isLoadingHistory { + HStack { + Spacer() + ProgressView("加载历史中...") + Spacer() + } + .padding(.vertical, 20) + } else if historyAlbums.isEmpty { + Text("暂无浏览记录") + .foregroundColor(.gray) + .padding(.vertical, 20) + } else { + ForEach(historyAlbums) { item in + NavigationLink(destination: PhotoView(albumId: item.album_id)) { + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.subheadline) + .lineLimit(2) + Text("ID: \(item.album_id)") + .font(.caption) + .foregroundColor(.gray) + } + } + } + .onDelete(perform: removeFromHistory) + } + } + + // 设置区域 + Section(header: Text("设置")) { + Button(action: {}) { + HStack { + Image(systemName: "gear") + .foregroundColor(.gray) + Text("设置") + } + } + } + } + .navigationTitle("我的") + .toolbar { + EditButton() + } + .alert(isPresented: $showError) { + Alert(title: Text("加载失败"), message: Text(errorMessage), dismissButton: .default(Text("确定"))) + } + .onAppear { + loadFavoriteAlbums() + loadHistoryAlbums() // 加载历史记录 + } + } + } + + // MARK: - 收藏相关方法 + private func loadFavoriteAlbums() { + let favoriteIds = UserDefaults.standard.favoriteAlbumIds + guard !favoriteIds.isEmpty else { + favoriteAlbums = [] + return + } + + isLoadingFavorites = true + favoriteAlbums = [] + let group = DispatchGroup() + + for id in favoriteIds { + group.enter() + fetchAlbumInfo(albumId: id) { albumItem in + if let item = albumItem { + favoriteAlbums.append(item) + } + group.leave() + } + } + + group.notify(queue: .main) { + favoriteAlbums.sort { $0.album_id > $1.album_id } + isLoadingFavorites = false + } + } + + private func removeFromFavorites(at offsets: IndexSet) { + guard let index = offsets.first else { return } + let removedItem = favoriteAlbums[index] + favoriteAlbums.remove(at: index) + + var favorites = UserDefaults.standard.favoriteAlbumIds + favorites.removeAll { $0 == removedItem.album_id } + UserDefaults.standard.favoriteAlbumIds = favorites + } + + // MARK: - 历史记录相关方法 + private func loadHistoryAlbums() { + let historyRecords = UserDefaults.standard.historyRecords + // 按时间戳排序(最新的在前) + let sortedIds = historyRecords.keys.sorted { + historyRecords[$0] ?? 0 > historyRecords[$1] ?? 0 + } + + guard !sortedIds.isEmpty else { + historyAlbums = [] + return + } + + isLoadingHistory = true + historyAlbums = [] + let group = DispatchGroup() + + for id in sortedIds { + group.enter() + fetchAlbumInfo(albumId: id) { albumItem in + if let item = albumItem { + historyAlbums.append(item) + } + group.leave() + } + } + + group.notify(queue: .main) { + isLoadingHistory = false + } + } + + private func removeFromHistory(at offsets: IndexSet) { + guard let index = offsets.first else { return } + let removedItem = historyAlbums[index] + historyAlbums.remove(at: index) + UserDefaults.standard.removeHistory(albumId: removedItem.album_id) + } + + private func clearAllHistory() { + UserDefaults.standard.clearAllHistory() + historyAlbums = [] + } + + // MARK: - 通用方法:获取漫画信息 + private func fetchAlbumInfo(albumId: String, completion: @escaping (AlbumItem?) -> Void) { + let urlString = "http://jm.rbq.college/album/\(albumId)/" + guard let url = URL(string: urlString) else { + completion(nil) + return + } + + URLSession.shared.dataTask(with: url) { data, response, error in + if let error = error { + DispatchQueue.main.async { + showError(message: "加载失败: \(error.localizedDescription)") + } + completion(nil) + return + } + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, + let data = data else { + completion(nil) + return + } + + do { + let album = try JSONDecoder().decode(Album.self, from: data) + let albumItem = AlbumItem(album_id: album.album_id, title: album.name) + completion(albumItem) + } catch { + print("解析数据失败: \(error)") + completion(nil) + } + }.resume() + } + + private func showError(message: String) { + errorMessage = message + showError = true + } +} diff --git a/Jetson Media/ui/RankingsView.swift b/Jetson Media/ui/RankingsView.swift index e69de29..7509ea1 100644 --- a/Jetson Media/ui/RankingsView.swift +++ b/Jetson Media/ui/RankingsView.swift @@ -0,0 +1,116 @@ +import SwiftUI + +struct RankingsView: View { + @StateObject private var viewModel = RankingsViewModel() + + var body: some View { + NavigationStack { + Group { + if viewModel.isLoading { + ProgressView("加载排行榜...") + } else if let error = viewModel.error { + VStack { + Text("加载失败: \(error.localizedDescription)") + Button("重试") { + viewModel.loadRankings() + } + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } + } else { + List { + ForEach(Array(viewModel.rankings.enumerated()), id: \.element.album_id) { index, item in + HStack(alignment: .center, spacing: 12) { + // 排名标签 + Text("\(index + 1)") + .font(.headline) + .frame(width: 24, height: 24) + .background(index < 3 ? Color.yellow : Color.gray) + .foregroundColor(index < 3 ? Color.black : Color.white) + .cornerRadius(4) + .padding(.leading, 4) + + // 内容标题 + NavigationLink(destination: PhotoView(albumId: item.album_id)) { + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.subheadline) + .lineLimit(1) + Text("ID: \(item.album_id)") + .font(.caption) + .foregroundColor(.gray) + } + } + + Spacer() + + // 热度图标 + Image(systemName: "flame.fill") + .foregroundColor(.orange) + } + } + } + .refreshable { + viewModel.loadRankings() + } + } + } + .navigationTitle("周热度排行") + } + } +} + +// 排行榜视图模型 +class RankingsViewModel: ObservableObject { + @Published var rankings: [AlbumItem] = [] + @Published var isLoading = false + @Published var error: Error? + + init() { + loadRankings() + } + + func loadRankings() { + isLoading = true + error = nil + + let urlString = Config.shared.apiURL(path: "rankings/week?page=1") + + guard let url = URL(string: urlString) else { + error = NSError(domain: "RankingsError", code: 0, userInfo: [NSLocalizedDescriptionKey: "无效的URL"]) + isLoading = false + return + } + + URLSession.shared.dataTask(with: url) { [weak self] data, response, error in + DispatchQueue.main.async { + self?.isLoading = false + + if let error = error { + self?.error = error + return + } + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + self?.error = NSError(domain: "RankingsError", code: 1, userInfo: [NSLocalizedDescriptionKey: "服务器返回错误"]) + return + } + + guard let data = data else { + self?.error = NSError(domain: "RankingsError", code: 2, userInfo: [NSLocalizedDescriptionKey: "没有接收到数据"]) + return + } + + do { + let decoder = JSONDecoder() + self?.rankings = try decoder.decode([AlbumItem].self, from: data) + } catch { + self?.error = NSError(domain: "RankingsError", code: 3, userInfo: [NSLocalizedDescriptionKey: "解析数据失败: \(error.localizedDescription)"]) + print("排行榜解码错误: \(error)") + } + } + }.resume() + } +} diff --git a/Jetson Media/ui/SearchView.swift b/Jetson Media/ui/SearchView.swift index 9a6164f..731fa1e 100644 --- a/Jetson Media/ui/SearchView.swift +++ b/Jetson Media/ui/SearchView.swift @@ -1,44 +1,133 @@ import SwiftUI +import Foundation +// 搜索历史记录管理器(保持不变) +class SearchHistoryManager { + static let shared = SearchHistoryManager() + private let userDefaultsKey = "search_history" + private let maxHistoryCount = 50 + + var history: [String] { + UserDefaults.standard.array(forKey: userDefaultsKey) as? [String] ?? [] + } + + func addHistory(_ query: String) { + var newHistory = history + if let index = newHistory.firstIndex(of: query) { + newHistory.remove(at: index) + } + newHistory.insert(query, at: 0) + if newHistory.count > maxHistoryCount { + newHistory = Array(newHistory.prefix(maxHistoryCount)) + } + UserDefaults.standard.set(newHistory, forKey: userDefaultsKey) + } + + func removeHistory(_ query: String) { + var newHistory = history + if let index = newHistory.firstIndex(of: query) { + newHistory.remove(at: index) + UserDefaults.standard.set(newHistory, forKey: userDefaultsKey) + } + } + + func clearAllHistory() { + UserDefaults.standard.removeObject(forKey: userDefaultsKey) + } +} + +// 修改后的搜索视图(重点调整历史记录条目的交互) struct SearchView: View { @State private var searchQuery: String = "" @State private var searchResults: [AlbumItem] = [] @State private var isSearching: Bool = false @State private var showAlert: Bool = false @State private var alertMessage: String = "" - + @State private var searchHistory: [String] = [] + var body: some View { - Form { - Section(header: Text("搜索")) { - HStack { - TextField("输入搜索内容", text: $searchQuery) - .textFieldStyle(RoundedBorderTextFieldStyle()) - - Button(action: performSearch) { - if isSearching { - ProgressView() - } else { - Image(systemName: "magnifyingglass") + NavigationStack { + Form { + Section(header: Text("Jetson Media")) { + HStack { + TextField("输入搜索内容", text: $searchQuery) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .onChange(of: searchQuery) { + if $0.isEmpty { + loadHistory() + } + } + + Button(action: performSearch) { + if isSearching { + ProgressView() + } else { + Image(systemName: "magnifyingglass") + } } + .disabled(searchQuery.isEmpty || isSearching) } - .disabled(searchQuery.isEmpty || isSearching) } - } - - if !searchResults.isEmpty { - Section(header: Text("搜索结果")) { - ForEach(searchResults, id: \.album_id) { item in - NavigationLink(destination: PhotoView(albumId: item.album_id)) { - VStack(alignment: .leading) { - Text(item.title) - .font(.headline) - Text("ID: \(item.album_id)") - .font(.subheadline) - .foregroundColor(.gray) + + // 搜索历史区域(调整条目交互) + if searchQuery.isEmpty && !searchHistory.isEmpty { + Section(header: HStack { + Text("搜索历史") + Spacer() + Button("清除全部") { + SearchHistoryManager.shared.clearAllHistory() + loadHistory() + } + .foregroundColor(.red) + .font(.subheadline) + }) { + ForEach(searchHistory, id: \.self) { query in + // 用HStack包裹,区分文本点击和删除按钮点击 + HStack { + // 文本区域:仅触发搜索 + Text(query) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + .onTapGesture { + // 点击文本直接搜索 + searchQuery = query + performSearch() + } + + // 删除按钮:仅触发删除 + Button(action: { + SearchHistoryManager.shared.removeHistory(query) + loadHistory() + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.gray) + } + .buttonStyle(PlainButtonStyle()) // 避免按钮点击影响整个行 } } } } + + // 搜索结果区域(保持不变) + if !searchResults.isEmpty { + Section(header: Text("搜索结果")) { + ForEach(searchResults, id: \.album_id) { item in + NavigationLink(destination: PhotoView(albumId: item.album_id)) { + VStack(alignment: .leading) { + Text(item.title) + .font(.headline) + Text("ID: \(item.album_id)") + .font(.subheadline) + .foregroundColor(.gray) + } + } + } + } + } + } + .navigationTitle("搜索") + .onAppear { + loadHistory() } } .alert(isPresented: $showAlert) { @@ -46,15 +135,23 @@ struct SearchView: View { } } + private func loadHistory() { + searchHistory = SearchHistoryManager.shared.history + } + private func performSearch() { guard !searchQuery.isEmpty else { return } + SearchHistoryManager.shared.addHistory(searchQuery) + loadHistory() + isSearching = true searchResults = [] let encodedQuery = searchQuery.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? searchQuery - let urlString = "https://jms.godserver.cn/search/?search_query=\(encodedQuery)&page=1" - + let urlString = Config.shared.apiURL( + path: "\(Config.Path.search)?search_query=\(encodedQuery)&page=1" + ) guard let url = URL(string: urlString) else { showAlert(message: "无效的URL") isSearching = false @@ -91,6 +188,7 @@ struct SearchView: View { } task.resume() } + private func showAlert(message: String) { alertMessage = message showAlert = true diff --git a/Jetson Media/ui/UserDefaults.swift b/Jetson Media/ui/UserDefaults.swift index e69de29..85aa08d 100644 --- a/Jetson Media/ui/UserDefaults.swift +++ b/Jetson Media/ui/UserDefaults.swift @@ -0,0 +1,29 @@ +import SwiftUI +import Combine +import Foundation + +// 扩展UserDefaults以存储收藏和阅读进度数据 +extension UserDefaults { + // 收藏的相册ID + var favoriteAlbumIds: [String] { + get { + return array(forKey: "favoriteAlbumIds") as? [String] ?? [] + } + set { + set(newValue, forKey: "favoriteAlbumIds") + } + } + + // 保存阅读进度 + func saveReadingProgress(albumId: String, position: Double) { + set(position, forKey: "readingProgress_\(albumId)") + } + + // 获取阅读进度 + func getReadingProgress(albumId: String) -> Double { + return double(forKey: "readingProgress_\(albumId)") + } + + private var favoriteAlbumIdsKey: String { "favorite_album_ids" } + +} diff --git a/Jetson Media/utill/ThickSlider.swift b/Jetson Media/utill/ThickSlider.swift index e69de29..1741316 100644 --- a/Jetson Media/utill/ThickSlider.swift +++ b/Jetson Media/utill/ThickSlider.swift @@ -0,0 +1,56 @@ +import SwiftUI +import UIKit + +// 1. 自定义UIKit滑块(支持调整高度) +class ThickSlider: UISlider { + var trackHeight: CGFloat = 40 // 滑块轨道高度(可调整) + + override func trackRect(forBounds bounds: CGRect) -> CGRect { + // 调整轨道框架,设置自定义高度 + let original = super.trackRect(forBounds: bounds) + return CGRect(x: original.origin.x, + y: (bounds.height - trackHeight) / 2, + width: original.width, + height: trackHeight) + } +} + +// 2. SwiftUI包装器 +struct CustomSlider: UIViewRepresentable { + @Binding var value: Double + var trackHeight: CGFloat + var tintColor: Color + + func makeUIView(context: Context) -> ThickSlider { + let slider = ThickSlider() + slider.trackHeight = trackHeight + slider.minimumValue = 0 + slider.maximumValue = 1 + slider.value = Float(value) + slider.addTarget(context.coordinator, action: #selector(Coordinator.valueChanged(_:)), for: .valueChanged) + slider.tintColor = UIColor(tintColor) + return slider + } + + func updateUIView(_ uiView: ThickSlider, context: Context) { + uiView.value = Float(value) + uiView.trackHeight = trackHeight + uiView.tintColor = UIColor(tintColor) + } + + func makeCoordinator() -> Coordinator { + Coordinator(value: $value) + } + + class Coordinator: NSObject { + @Binding var value: Double + + init(value: Binding) { + _value = value + } + + @objc func valueChanged(_ sender: UISlider) { + value = Double(sender.value) + } + } +} diff --git a/Jetson-Media-Info.plist b/Jetson-Media-Info.plist new file mode 100644 index 0000000..bbcb48c --- /dev/null +++ b/Jetson-Media-Info.plist @@ -0,0 +1,15 @@ + + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + Privacy - Photo Library Add Usage Description + + UIViewControllerBasedStatusBarAppearance + + +