'update'
This commit is contained in:
22
ipad_app/Sources/CLIMain.swift
Normal file
22
ipad_app/Sources/CLIMain.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import Foundation
|
||||
|
||||
@available(macOS 10.15, iOS 13.0, *)
|
||||
@main
|
||||
struct CLIMain {
|
||||
static func main() async {
|
||||
guard CommandLine.arguments.count > 1 else {
|
||||
print("用法: IpadReaderCLI <bundle.zip>")
|
||||
return
|
||||
}
|
||||
let zipPath = CommandLine.arguments[1]
|
||||
let url = URL(fileURLWithPath: zipPath)
|
||||
let importer = BundleImporter()
|
||||
do {
|
||||
let idx = try await importer.importZipFile(at: url)
|
||||
print("导入成功,书籍数: \(idx.books.count)")
|
||||
print("统计: 年总时长 \(idx.stats.global.year_total_minutes/60) 小时")
|
||||
} catch {
|
||||
print("导入失败: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
51
ipad_app/Sources/ContentView.swift
Normal file
51
ipad_app/Sources/ContentView.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var importer: BundleImporter
|
||||
@EnvironmentObject var libraryVM: LibraryViewModel
|
||||
|
||||
@State private var tab = 0
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $tab) {
|
||||
NavigationStack {
|
||||
BookListView(importer: importer, vm: libraryVM)
|
||||
}
|
||||
.tabItem { Label("书库", systemImage: "books.vertical") }
|
||||
.tag(0)
|
||||
|
||||
NavigationStack {
|
||||
if let index = importer.index {
|
||||
StatsView(index: index)
|
||||
} else {
|
||||
VStack(spacing: 16) {
|
||||
Text("尚未导入数据包")
|
||||
Button("导入 iPad 导出包") { importer.presentPicker() }
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
.tabItem { Label("统计", systemImage: "chart.bar") }
|
||||
.tag(1)
|
||||
|
||||
NavigationStack {
|
||||
ScrollView { VStack(alignment:.leading, spacing: 12) {
|
||||
Text("关于").font(.title2).bold()
|
||||
Text("离线阅读与统计查看器。加载 Python 导出的 iPad Bundle。")
|
||||
Button("重新导入") { importer.presentPicker() }
|
||||
if let p = importer.lastImportedURL {
|
||||
Text("最近: \(p.lastPathComponent)").font(.footnote).foregroundColor(.secondary)
|
||||
}
|
||||
}.padding() }
|
||||
}
|
||||
.tabItem { Label("关于", systemImage: "info.circle") }
|
||||
.tag(2)
|
||||
}
|
||||
.onReceive(importer.$bundleIndex) { newIndex in
|
||||
if let newIndex {
|
||||
libraryVM.attach(index: newIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
17
ipad_app/Sources/IpadReaderApp.swift
Normal file
17
ipad_app/Sources/IpadReaderApp.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct IpadReaderApp: App {
|
||||
@StateObject private var importer = BundleImporter()
|
||||
@StateObject private var libraryVM = LibraryViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(importer)
|
||||
.environmentObject(libraryVM)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
50
ipad_app/Sources/Models/BundleModels.swift
Normal file
50
ipad_app/Sources/Models/BundleModels.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
struct BookMeta: Identifiable, Codable {
|
||||
let id: String
|
||||
let title: String
|
||||
let author: String
|
||||
let type: String
|
||||
let last_open: Double? // 支持浮点 Unix 时间戳
|
||||
let readtime30d: [Int]?
|
||||
let readtime12m: [Int]?
|
||||
let readtime_year: Int?
|
||||
let is_finished_this_year: Bool?
|
||||
}
|
||||
|
||||
struct AnnotationRow: Identifiable, Codable {
|
||||
let id: String // uuid
|
||||
let creationdate: String?
|
||||
let idref: String?
|
||||
let filepos: String?
|
||||
let selected: String?
|
||||
let note: String?
|
||||
init(uuid: String, creationdate: String?, idref: String?, filepos: String?, selected: String?, note: String?) {
|
||||
self.id = uuid
|
||||
self.creationdate = creationdate
|
||||
self.idref = idref
|
||||
self.filepos = filepos
|
||||
self.selected = selected
|
||||
self.note = note
|
||||
}
|
||||
}
|
||||
|
||||
struct StatsGlobal: Codable {
|
||||
let year_total_minutes: Int
|
||||
let month_avg_minutes: Int
|
||||
let week_total_minutes: Int
|
||||
let day_avg_minutes: Int
|
||||
let finished_books_count: Int
|
||||
}
|
||||
|
||||
struct StatsPayload: Codable {
|
||||
let generated_at: String?
|
||||
let global: StatsGlobal
|
||||
}
|
||||
|
||||
struct BundleIndex {
|
||||
let books: [BookMeta]
|
||||
let stats: StatsPayload
|
||||
let bookIntro: [String:String]
|
||||
let annotationsMap: [String:[AnnotationRow]]
|
||||
}
|
||||
130
ipad_app/Sources/Services/BundleImporter.swift
Normal file
130
ipad_app/Sources/Services/BundleImporter.swift
Normal file
@@ -0,0 +1,130 @@
|
||||
// BundleImporter.swift
|
||||
// 导入 ZIP 包并解析为 BundleIndex
|
||||
|
||||
import Foundation
|
||||
import ZIPFoundation
|
||||
import UniformTypeIdentifiers
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@available(macOS 10.15, iOS 13.0, *)
|
||||
final class BundleImporter: NSObject, ObservableObject {
|
||||
@Published var bundleIndex: BundleIndex?
|
||||
@Published var progressMessage: String? = nil
|
||||
@Published var errorMessage: String? = nil
|
||||
@Published var lastImportedURL: URL? = nil
|
||||
|
||||
var index: BundleIndex? { bundleIndex }
|
||||
private let fm = FileManager.default
|
||||
|
||||
// 公开导入方法供CLI使用
|
||||
func importZipFile(at url: URL) async throws -> BundleIndex {
|
||||
let ts = Int(Date().timeIntervalSince1970)
|
||||
let baseDir = try bundleBaseDirectory()
|
||||
let target = baseDir.appendingPathComponent("bundle_\(ts)")
|
||||
try fm.createDirectory(at: target, withIntermediateDirectories: true)
|
||||
try fm.unzipItem(at: url, to: target)
|
||||
let idx = try parseBundle(at: target)
|
||||
await MainActor.run {
|
||||
self.bundleIndex = idx
|
||||
self.lastImportedURL = url
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// iOS 选择器
|
||||
func presentPicker() {
|
||||
#if canImport(UIKit)
|
||||
let types: [UTType] = [UTType.filenameExtension("zip")!]
|
||||
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
|
||||
picker.delegate = self
|
||||
picker.allowsMultipleSelection = false
|
||||
UIApplication.shared.firstKeyWindow?.rootViewController?.present(picker, animated: true)
|
||||
#else
|
||||
print("[BundleImporter] presentPicker() unsupported on this platform")
|
||||
#endif
|
||||
}
|
||||
|
||||
// 存储目录
|
||||
private func bundleBaseDirectory() throws -> URL {
|
||||
let appSup = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
let dir = appSup.appendingPathComponent("ImportedBundle")
|
||||
if !fm.fileExists(atPath: dir.path) { try fm.createDirectory(at: dir, withIntermediateDirectories: true) }
|
||||
return dir
|
||||
}
|
||||
|
||||
@MainActor private func update(message: String?) { self.progressMessage = message }
|
||||
|
||||
// 解析内容
|
||||
private func parseBundle(at dir: URL) throws -> BundleIndex {
|
||||
func load<T:Decodable>(_ name: String, _ type: T.Type) throws -> T {
|
||||
let data = try Data(contentsOf: dir.appendingPathComponent(name))
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
let books: [BookMeta] = try load("books_meta.json", [BookMeta].self)
|
||||
let stats: StatsPayload = try load("stats.json", StatsPayload.self)
|
||||
let introData = try? Data(contentsOf: dir.appendingPathComponent("bookintro.json"))
|
||||
let bookIntro = (introData.flatMap { try? JSONDecoder().decode([String:String].self, from: $0) }) ?? [:]
|
||||
|
||||
let annotationsRaw = try Data(contentsOf: dir.appendingPathComponent("annotations.json"))
|
||||
let anyMap = (try? JSONSerialization.jsonObject(with: annotationsRaw)) as? [String:Any] ?? [:]
|
||||
var annMap: [String:[AnnotationRow]] = [:]
|
||||
for (k,v) in anyMap {
|
||||
guard let arr = v as? [[String:Any]] else { continue }
|
||||
let rows: [AnnotationRow] = arr.compactMap { d in
|
||||
guard let uuid = d["uuid"] as? String else { return nil }
|
||||
return AnnotationRow(
|
||||
uuid: uuid,
|
||||
creationdate: d["creationdate"] as? String,
|
||||
idref: d["idref"] as? String,
|
||||
filepos: d["filepos"] as? String,
|
||||
selected: d["selected"] as? String,
|
||||
note: d["note"] as? String
|
||||
)
|
||||
}
|
||||
annMap[k] = rows
|
||||
}
|
||||
return BundleIndex(books: books, stats: stats, bookIntro: bookIntro, annotationsMap: annMap)
|
||||
}
|
||||
|
||||
// 导入执行
|
||||
private func importZip(at url: URL) async {
|
||||
await update(message: "解压中...")
|
||||
do {
|
||||
let ts = Int(Date().timeIntervalSince1970)
|
||||
let baseDir = try bundleBaseDirectory()
|
||||
let target = baseDir.appendingPathComponent("bundle_\(ts)")
|
||||
try fm.createDirectory(at: target, withIntermediateDirectories: true)
|
||||
try fm.unzipItem(at: url, to: target)
|
||||
await update(message: "解析 JSON...")
|
||||
let idx = try parseBundle(at: target)
|
||||
await MainActor.run {
|
||||
self.bundleIndex = idx
|
||||
self.lastImportedURL = url
|
||||
}
|
||||
await update(message: nil)
|
||||
} catch {
|
||||
await MainActor.run { self.errorMessage = error.localizedDescription }
|
||||
await update(message: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
extension BundleImporter: UIDocumentPickerDelegate {
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
guard let url = urls.first else { return }
|
||||
lastImportedURL = url
|
||||
Task { await importZip(at: url) }
|
||||
}
|
||||
}
|
||||
|
||||
extension UIApplication {
|
||||
var firstKeyWindow: UIWindow? {
|
||||
self.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
.flatMap { $0.windows }
|
||||
.first { $0.isKeyWindow }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
65
ipad_app/Sources/Services/ImageCache.swift
Normal file
65
ipad_app/Sources/Services/ImageCache.swift
Normal file
@@ -0,0 +1,65 @@
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
typealias PlatformImage = UIImage
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
typealias PlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
actor ImageCache {
|
||||
static let shared = ImageCache()
|
||||
private var cache: [String:PlatformImage] = [:]
|
||||
|
||||
func image(for id: String, in baseDir: URL) -> PlatformImage? {
|
||||
if let c = cache[id] { return c }
|
||||
let url = baseDir.appendingPathComponent("covers/\(id).jpg")
|
||||
#if canImport(UIKit)
|
||||
guard let img = PlatformImage(contentsOfFile: url.path) else { return nil }
|
||||
#elseif canImport(AppKit)
|
||||
guard let img = PlatformImage(contentsOf: url) else { return nil }
|
||||
#endif
|
||||
cache[id] = img
|
||||
return img
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
struct CoverImageView: View {
|
||||
let book: BookMeta
|
||||
let bundleDir: URL
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
@State private var uiImage: PlatformImage? = nil
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
if let img = uiImage {
|
||||
#if canImport(UIKit)
|
||||
Image(uiImage: img).resizable().scaledToFit().cornerRadius(6)
|
||||
#elseif canImport(AppKit)
|
||||
Image(nsImage: img).resizable().scaledToFit().cornerRadius(6)
|
||||
#endif
|
||||
} else {
|
||||
Rectangle().fill(Color.gray.opacity(0.2)).overlay(Text("No Cover").font(.caption))
|
||||
}
|
||||
#else
|
||||
Rectangle().fill(Color.gray.opacity(0.2)).overlay(Text("No Cover").font(.caption))
|
||||
#endif
|
||||
}
|
||||
#if canImport(UIKit) || canImport(AppKit)
|
||||
.task {
|
||||
if uiImage == nil, let img = await ImageCache.shared.image(for: book.id, in: bundleDir) {
|
||||
uiImage = img
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
19
ipad_app/Sources/ViewModels/LibraryViewModel.swift
Normal file
19
ipad_app/Sources/ViewModels/LibraryViewModel.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@available(macOS 10.15, iOS 13.0, *)
|
||||
@MainActor
|
||||
final class LibraryViewModel: ObservableObject {
|
||||
@Published var bundleIndex: BundleIndex?
|
||||
@Published var searchText: String = ""
|
||||
|
||||
var filteredBooks: [BookMeta] {
|
||||
guard let books = bundleIndex?.books else { return [] }
|
||||
let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if q.isEmpty { return books.sorted { ($0.last_open ?? 0) > ($1.last_open ?? 0) } }
|
||||
return books.filter { $0.title.localizedCaseInsensitiveContains(q) || $0.author.localizedCaseInsensitiveContains(q) }
|
||||
.sorted { ($0.last_open ?? 0) > ($1.last_open ?? 0) }
|
||||
}
|
||||
|
||||
func attach(index: BundleIndex) { self.bundleIndex = index }
|
||||
}
|
||||
55
ipad_app/Sources/Views/BookDetailView.swift
Normal file
55
ipad_app/Sources/Views/BookDetailView.swift
Normal file
@@ -0,0 +1,55 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
@available(macOS 10.15, iOS 13.0, *)
|
||||
struct BookDetailView: View {
|
||||
let book: BookMeta
|
||||
let index: BundleIndex
|
||||
let bundleDir: URL
|
||||
|
||||
var annotations: [AnnotationRow] { index.annotationsMap[book.id] ?? [] }
|
||||
var review: String? { index.bookIntro[book.title] }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment:.top, spacing:16) {
|
||||
CoverImageView(book: book, bundleDir: bundleDir).frame(width:120,height:170)
|
||||
VStack(alignment:.leading, spacing:8) {
|
||||
Text(book.title).font(.title2).bold()
|
||||
Text(book.author).foregroundColor(.secondary)
|
||||
if let y = book.readtime_year { Text("全年阅读 \(y/60) 小时").font(.callout) }
|
||||
if book.is_finished_this_year == true { Text("今年已读完 ✅").font(.caption).foregroundColor(.green) }
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
if let review = review, !review.isEmpty {
|
||||
VStack(alignment:.leading, spacing:8) {
|
||||
Text("书籍简评").font(.headline)
|
||||
Text(review).font(.body)
|
||||
}
|
||||
}
|
||||
if !annotations.isEmpty {
|
||||
VStack(alignment:.leading, spacing:8) {
|
||||
Text("批注 (\(annotations.count))").font(.headline)
|
||||
ForEach(annotations) { ann in
|
||||
VStack(alignment:.leading, spacing:4) {
|
||||
if let sel = ann.selected { Text(sel).font(.body) }
|
||||
if let note = ann.note, !note.isEmpty { Text("✎ " + note).font(.footnote).foregroundColor(.secondary) }
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("无批注").foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("详情")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
57
ipad_app/Sources/Views/BookListView.swift
Normal file
57
ipad_app/Sources/Views/BookListView.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
struct BookListView: View {
|
||||
@ObservedObject var importer: BundleImporter
|
||||
@ObservedObject var vm: LibraryViewModel
|
||||
@State private var selected: BookMeta? = nil
|
||||
|
||||
private func bundleDir(of id: String) -> URL? { // 取最新导入目录
|
||||
guard let root = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("ImportedBundle") else { return nil }
|
||||
let subs = (try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
|
||||
return subs.sorted { $0.lastPathComponent > $1.lastPathComponent }.first
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
TextField("搜索书名/作者", text: $vm.searchText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("导入") { importer.presentPicker() }
|
||||
if let msg = importer.progressMessage { Text(msg).font(.footnote).foregroundColor(.secondary) }
|
||||
}.padding(.horizontal).padding(.top,8)
|
||||
Divider()
|
||||
if vm.filteredBooks.isEmpty {
|
||||
EmptyStateView(message: "无数据,点击导入按钮加载 ZIP", actionTitle: "导入") { importer.presentPicker() }
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
List(vm.filteredBooks) { book in
|
||||
Button {
|
||||
selected = book
|
||||
} label: {
|
||||
HStack(alignment:.top, spacing:12) {
|
||||
if let dir = bundleDir(of: book.id) {
|
||||
CoverImageView(book: book, bundleDir: dir).frame(width:70,height:100)
|
||||
}
|
||||
VStack(alignment:.leading, spacing:4) {
|
||||
Text(book.title).font(.headline)
|
||||
Text(book.author).font(.subheadline).foregroundColor(.secondary)
|
||||
if let rt = book.readtime_year { Text("全年阅读: \(rt/60) 小时").font(.caption).foregroundColor(.secondary) }
|
||||
if book.is_finished_this_year == true { Text("今年已读完 ✅").font(.caption2).foregroundColor(.green) }
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $selected) { b in
|
||||
if let idx = importer.bundleIndex, let dir = bundleDir(of: b.id) {
|
||||
BookDetailView(book: b, index: idx, bundleDir: dir)
|
||||
} else {
|
||||
Text("数据缺失")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
52
ipad_app/Sources/Views/Components/BubbleMetricsView.swift
Normal file
52
ipad_app/Sources/Views/Components/BubbleMetricsView.swift
Normal file
@@ -0,0 +1,52 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
@available(macOS 10.15, iOS 13.0, *)
|
||||
struct BubbleMetric: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
let display: String
|
||||
let valueMinutes: Double // 用于相对半径
|
||||
let color: Color
|
||||
}
|
||||
|
||||
@available(macOS 10.15, iOS 13.0, *)
|
||||
struct BubbleMetricsView: View {
|
||||
let metrics: [BubbleMetric]
|
||||
|
||||
private func radius(for v: Double, maxV: Double) -> CGFloat {
|
||||
guard maxV > 0 else { return 20 }
|
||||
let norm = sqrt(v / maxV)
|
||||
return 30 + norm * 70
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let maxV = metrics.map { $0.valueMinutes }.max() ?? 1
|
||||
ZStack {
|
||||
ForEach(Array(metrics.enumerated()), id: \.[1].id) { idx, m in
|
||||
// 简单环形布局
|
||||
let angle = Double(idx) / Double(metrics.count) * Double.pi * 2
|
||||
let R = min(geo.size.width, geo.size.height) / 2 - 80
|
||||
let x = geo.size.width/2 + CGFloat(cos(angle)) * R
|
||||
let y = geo.size.height/2 + CGFloat(sin(angle)) * R
|
||||
let r = radius(for: m.valueMinutes, maxV: maxV)
|
||||
Circle()
|
||||
.fill(m.color.opacity(0.75))
|
||||
.frame(width: r, height: r)
|
||||
.overlay(
|
||||
VStack(spacing:4){
|
||||
Text(m.display).font(.system(size: min(18, r*0.28))).bold().foregroundColor(.white)
|
||||
Text(m.label).font(.system(size: min(14, r*0.22))).foregroundColor(.white)
|
||||
}.padding(4)
|
||||
)
|
||||
.position(x: x, y: y)
|
||||
.shadow(radius: 4, y: 2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.frame(height: 340)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
19
ipad_app/Sources/Views/Components/EmptyStateView.swift
Normal file
19
ipad_app/Sources/Views/Components/EmptyStateView.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let message: String
|
||||
let actionTitle: String
|
||||
let action: () -> Void
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(.secondary)
|
||||
Text(message).foregroundColor(.secondary)
|
||||
Button(actionTitle, action: action)
|
||||
}
|
||||
.padding(40)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
50
ipad_app/Sources/Views/StatsView.swift
Normal file
50
ipad_app/Sources/Views/StatsView.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct StatsView: View {
|
||||
let index: BundleIndex
|
||||
|
||||
struct DayVal: Identifiable { let id = UUID(); let label: String; let value: Int }
|
||||
struct MonthVal: Identifiable { let id = UUID(); let label: String; let value: Int }
|
||||
struct YearVal: Identifiable { let id = UUID(); let label: String; let hours: Double }
|
||||
|
||||
var weekSeries: [DayVal] {
|
||||
let arr = (index.stats.global.week_total_minutes, index.stats.global) // placeholder; real daily array不可用
|
||||
// 因数据包 week 只给总和,无法还原每日;占位 7 段按衰减示例
|
||||
let total = Double(index.stats.global.week_total_minutes)
|
||||
return (0..<7).map { i in
|
||||
let frac = (7.0-Double(i))*1.0/28.0 + 0.05
|
||||
return DayVal(label: ["今","昨","2","3","4","5","6"][i], value: Int(total * frac / 2.0))
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment:.leading, spacing: 24) {
|
||||
Text("阅读统计").font(.title2).bold().padding(.top)
|
||||
// 气泡指标
|
||||
let g = index.stats.global
|
||||
let metrics: [BubbleMetric] = [
|
||||
BubbleMetric(label: "全年", display: String(format: "%d小时", g.year_total_minutes/60), valueMinutes: Double(g.year_total_minutes), color: .blue),
|
||||
BubbleMetric(label: "月均", display: String(format: "%d分钟", g.month_avg_minutes), valueMinutes: Double(g.month_avg_minutes), color: .purple),
|
||||
BubbleMetric(label: "近7天", display: String(format: "%d分钟", g.week_total_minutes), valueMinutes: Double(g.week_total_minutes), color: .pink),
|
||||
BubbleMetric(label: "日均", display: String(format: "%d分钟", g.day_avg_minutes), valueMinutes: Double(g.day_avg_minutes), color: .yellow),
|
||||
BubbleMetric(label: "已读", display: String(format: "%d本", g.finished_books_count), valueMinutes: Double(g.finished_books_count*60), color: .green)
|
||||
]
|
||||
BubbleMetricsView(metrics: metrics)
|
||||
// 占位周数据图
|
||||
Chart(weekSeries) { item in
|
||||
BarMark(x: .value("Day", item.label), y: .value("Min", item.value))
|
||||
}.frame(height: 180)
|
||||
Text("说明: 因数据包未携带逐日数组,这里周图为占位模拟。可扩展导出添加 daily 数组。")
|
||||
.font(.footnote).foregroundColor(.secondary)
|
||||
// 年度平均小时散点(使用 readtime_year)
|
||||
let yearHours = Double(g.year_total_minutes)/60.0
|
||||
HStack { Text("全年总时长约 \(String(format: "%.1f", yearHours)) 小时").font(.headline); Spacer() }
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user