选择图片分享功能

This commit is contained in:
2025-08-17 22:08:25 +08:00
parent e6afca5ba6
commit d80289fe73
19 changed files with 1402 additions and 110 deletions

View File

@@ -9,9 +9,12 @@ import SwiftUI
@main
struct Jetson_MediaApp: App {
let tabState = TabBarState() //
var body: some Scene {
WindowGroup {
ContentView()
MainView()
.environmentObject(tabState) // 使
}
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,8 @@
//
struct AlbumItem: Codable, Identifiable {
let album_id: String
let title: String
var id: String { album_id }
}

View File

@@ -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/" //
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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 {
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[..<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

View File

@@ -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. SwiftUIUIKit
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) //
}
}

View File

@@ -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()
// PhotoViewonAppear
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
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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

View File

@@ -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" }
}

View File

@@ -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)
}
}
}