选择图片分享功能
This commit is contained in:
@@ -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 = "<group>";
|
||||
};
|
||||
@@ -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 */;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuildLocationStyle</key>
|
||||
<string>UseAppPreferences</string>
|
||||
<key>CustomBuildLocationType</key>
|
||||
<string>RelativeToDerivedData</string>
|
||||
<key>DerivedDataLocationStyle</key>
|
||||
<string>Default</string>
|
||||
<key>ShowSharedSchemesAutomaticallyEnabled</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -9,9 +9,12 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct Jetson_MediaApp: App {
|
||||
let tabState = TabBarState() // 创建底部导航栏状态实例
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
MainView()
|
||||
.environmentObject(tabState) // 注入环境,供所有子视图使用
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// 共用的数据模型
|
||||
struct AlbumItem: Codable, Identifiable {
|
||||
let album_id: String
|
||||
let title: String
|
||||
|
||||
var id: String { album_id }
|
||||
}
|
||||
|
||||
|
||||
@@ -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/" // 排行榜接口(如果有)
|
||||
}
|
||||
}
|
||||
|
||||
39
Jetson Media/cache/ImageCacheManager.swift
vendored
39
Jetson Media/cache/ImageCacheManager.swift
vendored
@@ -0,0 +1,39 @@
|
||||
import UIKit
|
||||
|
||||
/// 图片缓存管理单例,负责图片的缓存、读取和清除
|
||||
class ImageCacheManager {
|
||||
static let shared = ImageCacheManager()
|
||||
private let cache = NSCache<NSString, UIImage>()
|
||||
|
||||
// 私有初始化,确保单例
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
62
Jetson Media/cache/ReadingProgressManager.swift
vendored
62
Jetson Media/cache/ReadingProgressManager.swift
vendored
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Int> = []
|
||||
private static let maxConcurrentLoads = 4
|
||||
private static let longPressThreshold: TimeInterval = 0.1 // 长按阈值(秒)
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = image {
|
||||
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("图片加载成功,开始解密...")
|
||||
// 解码图片并应用解密
|
||||
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
|
||||
print("解密完成,图片已更新")
|
||||
} else {
|
||||
self.error = NSError(domain: "图片数据解码失败", code: 4, userInfo: nil)
|
||||
print("图片数据解码失败")
|
||||
}
|
||||
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[..<dotIndex])
|
||||
}
|
||||
|
||||
return "\(idComponent)/\(fileComponent)"
|
||||
}
|
||||
|
||||
return url.path.hashValue.description
|
||||
}
|
||||
}
|
||||
|
||||
// 分享视图包装器
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
let completion: (Bool) -> 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
|
||||
|
||||
@@ -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<Bool>) {
|
||||
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) // 主视图忽略安全区域
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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(.gray)
|
||||
.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(Color(.systemBackground))
|
||||
.background(isFullscreen ? Color.black : Color(.systemBackground))
|
||||
.id("header")
|
||||
}
|
||||
|
||||
LazyVStack(spacing: 0) { // 去掉图片间距
|
||||
// 图片列表
|
||||
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() // 防止图片超出边界
|
||||
.frame(maxWidth: .infinity)
|
||||
.id(index)
|
||||
// 当图片进入屏幕时更新当前索引
|
||||
.onAppear {
|
||||
// 只有当图片索引大于当前索引时才更新(避免回滚时误判)
|
||||
if index > currentImageIndex || abs(index - currentImageIndex) > 3 {
|
||||
currentImageIndex = index
|
||||
updateProgressRatio()
|
||||
}
|
||||
}
|
||||
}
|
||||
.edgesIgnoringSafeArea(.all) // 让内容填充整个屏幕
|
||||
} else if let error = viewModel.error {
|
||||
Text("加载失败: \(error.localizedDescription)")
|
||||
}
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,62 @@
|
||||
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 {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(header: Text("搜索")) {
|
||||
Section(header: Text("Jetson Media")) {
|
||||
HStack {
|
||||
TextField("输入搜索内容", text: $searchQuery)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.onChange(of: searchQuery) {
|
||||
if $0.isEmpty {
|
||||
loadHistory()
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: performSearch) {
|
||||
if isSearching {
|
||||
@@ -25,6 +69,46 @@ struct SearchView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索历史区域(调整条目交互)
|
||||
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
|
||||
@@ -41,20 +125,33 @@ struct SearchView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("搜索")
|
||||
.onAppear {
|
||||
loadHistory()
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $showAlert) {
|
||||
Alert(title: Text("提示"), message: Text(alertMessage), dismissButton: .default(Text("确定")))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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" }
|
||||
|
||||
}
|
||||
|
||||
@@ -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<Double>) {
|
||||
_value = value
|
||||
}
|
||||
|
||||
@objc func valueChanged(_ sender: UISlider) {
|
||||
value = Double(sender.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
Jetson-Media-Info.plist
Normal file
15
Jetson-Media-Info.plist
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>Privacy - Photo Library Add Usage Description</key>
|
||||
<string></string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user