import sys import os import re from urllib.parse import unquote import datetime from PyQt6.QtWidgets import ( QApplication, QWidget, QPushButton, QLabel, QListWidget, QFileDialog, QMessageBox, QLineEdit, QFormLayout, QDialog, QDialogButtonBox, QTextEdit, QHBoxLayout, QSizePolicy ) from PyQt6.QtGui import QIcon, QPixmap from PyQt6.QtCore import QSettings, QSize, QByteArray from PyQt6 import uic import config from exportbooknotes import BookNotesExporter from booklist_parse import BookListManager from review_worker import BookReviewWorker from cover_mixin import CoverMixin from finished_books_mixin import FinishedBooksMixin class ConfigDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("配置参数") layout = QFormLayout(self) self.inputs = {} for attr in dir(config): if attr.isupper(): val = getattr(config, attr) inp = QLineEdit(str(val)) layout.addRow(attr, inp) self.inputs[attr] = inp buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) def get_config(self): return {k: v.text() for k, v in self.inputs.items()} class IBookExportApp(CoverMixin, FinishedBooksMixin, QWidget): def __init__(self): super().__init__() # ====== UI 加载 ====== ui_file = os.path.join(os.path.dirname(__file__), 'ibook_export_app.ui') uic.loadUi(ui_file, self) self.setWindowTitle("iBook笔记专家") if os.path.exists(config.APP_ICON): self.setWindowIcon(QIcon(config.APP_ICON)) # ====== 数据准备 ====== try: from exportbooknotes import sync_source_files sync_source_files(config) except Exception as e: print(f"警告: 初始同步源数据失败: {e}") self.exporter = BookNotesExporter(config) self.manager = BookListManager(plist_path=config.LOCAL_BOOKS_PLIST, db_path=config.LOCAL_LIBRARY_DB) self.booksinfo = self.manager.get_books_info() self.last_open_times = self.manager.get_books_last_open() self.assetid2name, self.assetid2lastopen = {}, {} for assetid, info in self.booksinfo.items(): name = info.get('displayname') or info.get('itemname') or assetid if '-' in name: name = name.split('-', 1)[0].strip() self.assetid2name[assetid] = name self.assetid2lastopen[assetid] = self.last_open_times.get(assetid, {}).get('last_open', 0) self.sorted_assetids = sorted(self.assetid2name.keys(), key=lambda aid: self.assetid2lastopen[aid], reverse=True) for aid in self.sorted_assetids: self.listwidget.addItem(f"{self.assetid2name[aid]} [{self.assetid2lastopen[aid]}]") # 更新左侧提示标签文本与样式(白色黑体,底色 #4c221b) try: total_books = len(self.sorted_assetids) if hasattr(self, 'label') and isinstance(self.label, QLabel): self.label.setText(f"请选择要导出的书籍【{total_books}本】:") self.label.setStyleSheet("QLabel { background-color: #4c221b; color: #ffffff; font-family: 'STHeiti, Heiti SC, SimHei'; font-weight: bold; padding:4px 6px; border-radius:4px; }") except Exception as _e_lbl: print('信息: 设置导出提示标签样式失败:', _e_lbl) # 调整导出 & 配置按钮为同一行 + 圆角胶囊风格 try: from PyQt6.QtWidgets import QHBoxLayout, QWidget if hasattr(self, 'export_btn') and hasattr(self, 'config_btn'): # 如果还在原父布局中(垂直),则新建一行容器 parent_layout = self.export_btn.parentWidget().layout() if self.export_btn.parentWidget() else None if parent_layout and self.export_btn in [parent_layout.itemAt(i).widget() for i in range(parent_layout.count()) if parent_layout.itemAt(i).widget()]: # 创建水平布局并放置两个按钮 row_container = QWidget(self) h = QHBoxLayout(row_container); h.setContentsMargins(0,0,0,0); h.setSpacing(12) # 取出旧按钮(避免重复显示) self.export_btn.setParent(row_container) self.config_btn.setParent(row_container) h.addWidget(self.export_btn) h.addWidget(self.config_btn) parent_layout.addWidget(row_container) # 设置样式 pill_css = ( "QPushButton { border:none; color:#ffffff; padding:6px 22px; font-size:14px; font-weight:600; border-radius:22px; }" "QPushButton#export_btn { background: qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:0, stop:0 #63a9ff, stop:1 #388bff); }" "QPushButton#config_btn { background: qlineargradient(spread:pad, x1:0, y1:0, x2:1, y2:0, stop:0 #7ed957, stop:1 #4caf50); }" "QPushButton:hover { filter: brightness(1.08); }" "QPushButton:pressed { filter: brightness(0.92); }" "QPushButton:disabled { background:#888888; color:#dddddd; }" ) # 仅作用于这两个按钮:分别附加 objectName 选择器 self.export_btn.setObjectName('export_btn') self.config_btn.setObjectName('config_btn') self.export_btn.setStyleSheet(pill_css) self.config_btn.setStyleSheet(pill_css) except Exception as _e_pill: print('信息: 设置按钮行/样式失败:', _e_pill) # ====== 信号 ====== self.export_btn.clicked.connect(self.export_notes) self.config_btn.clicked.connect(self.show_config) # iPad 数据包导出按钮 if hasattr(self, 'ipad_export_btn'): try: self.ipad_export_btn.clicked.connect(self.export_ipad_bundle) except Exception: pass self.listwidget.currentRowChanged.connect(self.update_book_info) self.listwidget.installEventFilter(self) # ====== 封面标签 ====== if all(hasattr(self, n) for n in ('cover_label_1','cover_label_2','cover_label_3')): self._cover_labels = [self.cover_label_1, self.cover_label_2, self.cover_label_3] else: self._cover_labels = [getattr(self, 'book_cover_label', QLabel('封面', self))] for lab in self._cover_labels: lab.setMinimumWidth(180) lab.setMaximumWidth(180) lab.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) try: from PyQt6.QtCore import Qt as _QtFP lab.setFocusPolicy(_QtFP.FocusPolicy.NoFocus) except Exception: pass # 封面高度是否弹性跟随文本区(默认 False: 固定算法,不扩张) self.cover_elastic = False # 调整文本区域弹性:宽度随可用空间扩展,高度优先扩展 try: from PyQt6.QtWidgets import QSizePolicy as _QSP sp = self.book_toc_textedit.sizePolicy() sp.setHorizontalPolicy(_QSP.Policy.Expanding) sp.setVerticalPolicy(_QSP.Policy.Expanding) self.book_toc_textedit.setSizePolicy(sp) # 给封面横排区域一个较小的最小高度,避免撑开 if hasattr(self, 'covers_layout') and self._cover_labels: for lab in self._cover_labels: lab.setMinimumHeight(10) except Exception: pass self.cover_ratio = 1.2 self._export_tab_index = None self.book_toc_textedit.setPlainText("书籍信息 / 简评") # 状态 & 缓存 self._review_worker = None self._current_bookname = None self._active_workers = [] self._cover_pixmaps_original = [] # 恢复窗口尺寸 self._restore_window_geometry() # 设置封面标签对齐 try: from PyQt6.QtCore import Qt as _QtAlign for lab in self._cover_labels: lab.setAlignment(_QtAlign.AlignmentFlag.AlignHCenter | _QtAlign.AlignmentFlag.AlignTop) except Exception: pass # 应用全局字体(含苹方支持) try: self._apply_global_font() except Exception: pass # 初始封面 + 首本书信息 (及 AI 简评触发) self._load_initial() # 已读书籍网格 try: self._populate_finished_books_grid() except Exception as e: print('警告: 已读书籍网格填充失败', e) # 滚动区域策略 & 事件过滤 if hasattr(self, 'finished_scroll_area'): from PyQt6.QtCore import Qt as _Qt self.finished_scroll_area.setHorizontalScrollBarPolicy(_Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.finished_scroll_area.setVerticalScrollBarPolicy(_Qt.ScrollBarPolicy.ScrollBarAsNeeded) try: self.finished_scroll_area.viewport().installEventFilter(self) except Exception as _e_vp: print('信息: 安装 viewport 事件过滤器失败', _e_vp) # Tab 切换监听 (C + A 方案) from PyQt6.QtWidgets import QTabWidget try: tabs = self.findChildren(QTabWidget) if tabs: self._main_tab_widget = tabs[0] self._main_tab_widget.currentChanged.connect(self._on_main_tab_changed) self._detect_export_tab_index() except Exception as _e_tab: print('信息: 连接 tab 切换失败', _e_tab) # 首次显示后再排一次 from PyQt6.QtCore import QTimer QTimer.singleShot(80, self._relayout_finished_grid) def eventFilter(self, obj, event): from PyQt6.QtCore import QEvent # 方案 C: 监听 finished_scroll_area 的 viewport Resize try: if hasattr(self, 'finished_scroll_area') and obj == self.finished_scroll_area.viewport(): if event.type() == QEvent.Type.Resize: # 可选节流:仅当宽度真实变化较大时 new_w = obj.width() last_w = getattr(self, '_finished_viewport_last_width', None) if last_w is None or abs(new_w - last_w) > 8: self._finished_viewport_last_width = new_w self._relayout_finished_grid() except Exception: pass # 原有 listwidget 回车导出逻辑 if obj == self.listwidget and event.type() == QEvent.Type.KeyPress: if event.key() in (0x01000004, 0x01000005): # Return / Enter self.export_notes() return True return super().eventFilter(obj, event) def _load_initial(self): """启动时: 1. 显示前三本封面 2. 初始化文本区域为第一本书的基础信息(无简评内容,只留段落标题) """ try: if not hasattr(self, '_cover_labels') or not self.sorted_assetids: return from PyQt6.QtGui import QPixmap self._cover_pixmaps_original = [] first_indices = list(range(min(3, len(self.sorted_assetids)))) for pos in range(3): if pos < len(first_indices): aid = self.sorted_assetids[first_indices[pos]] info = self.booksinfo.get(aid, {}) cpath = self.find_book_cover(aid, info) if cpath: pm = QPixmap(cpath) if not pm.isNull(): self._cover_pixmaps_original.append(pm) self._cover_labels[pos].setPixmap(pm) continue self._cover_pixmaps_original.append(None) self._cover_labels[pos].setText('无封面') else: self._cover_pixmaps_original.append(None) self._cover_labels[pos].setText('') self._apply_cover_scale() # 首本书信息 first_aid = self.sorted_assetids[0] first_info = self.booksinfo.get(first_aid, {}) bookname_display = first_info.get('displayname') or first_info.get('itemname') or first_aid author = first_info.get('author', '') btype = first_info.get('type', '') get_time = first_info.get('date', '') self._base_info_cache = { 'bookname': bookname_display, 'author': author, 'type': btype, 'date': get_time } self._current_bookname = bookname_display # 读取/触发首本书书评 import json json_path = os.path.join(os.path.dirname(__file__), 'bookintro.json') try: with open(json_path, 'r', encoding='utf-8') as f: intro_dict = json.load(f) except Exception: intro_dict = {} review = intro_dict.get(bookname_display) if review: html = self._build_book_html(review) self.book_toc_textedit.setHtml(html) else: loading_html = self._build_book_html("简评获取中...") self.book_toc_textedit.setHtml(loading_html) prompt = f"{bookname_display} 400字书评 三段 简洁精炼" worker = BookReviewWorker(bookname_display, prompt, json_path, parent=self) worker.finished.connect(lambda bname, rev: self._on_review_finished(bname, rev)) worker.finished.connect(lambda _b, _r, w=worker: self._remove_worker(w)) self._review_worker = worker self._active_workers.append(worker) worker.start() except Exception as e: print('初始加载失败:', e) def _on_main_tab_changed(self, index): """方案 A: 当切换到 '已读书籍' 标签时强制重排一次,并做一次短延迟的二次重排,避免初次显示宽度未稳定。""" try: if not hasattr(self, '_main_tab_widget'): return w = self._main_tab_widget.widget(index) # 通过对象名或包含的 finished_scroll_area 判断(加括号避免优先级问题) hit = False if hasattr(self, 'finished_scroll_area'): try: if self.finished_scroll_area.isAncestorOf(w) or w.isAncestorOf(self.finished_scroll_area): hit = True except Exception: pass # 退化:检查对象名包含关键字 if not hit: name = getattr(w, 'objectName', lambda: '')() if 'finished' in name.lower(): hit = True if hit: # 立即重排 self._relayout_finished_grid() from PyQt6.QtCore import QTimer # 120ms 后再重排一次(宽度稳定后) QTimer.singleShot(120, self._relayout_finished_grid) except Exception as e: print('tab 切换重排失败:', e) def showEvent(self, event): """首次显示窗口后,再安排一次延迟重排,提升初始网格正确率。""" try: super().showEvent(event) from PyQt6.QtCore import QTimer QTimer.singleShot(80, self._relayout_finished_grid) except Exception: pass def _detect_export_tab_index(self): """探测导出标签索引:优先使用 ui 中命名的 tab_export;否则通过包含 listwidget 的页面推断。""" if getattr(self, '_export_tab_index', None) is not None: return try: if not hasattr(self, '_main_tab_widget'): return idx = -1 if hasattr(self, 'tab_export'): idx = self._main_tab_widget.indexOf(self.tab_export) if idx < 0: from PyQt6.QtWidgets import QListWidget for i in range(self._main_tab_widget.count()): page = self._main_tab_widget.widget(i) if page.findChild(QListWidget, 'listwidget') is not None: idx = i break if idx < 0: idx = 0 # 兜底 self._export_tab_index = idx except Exception as e: print('探测导出标签索引失败:', e) def _switch_to_export_tab(self): try: if not hasattr(self, '_main_tab_widget'): from PyQt6.QtWidgets import QTabWidget tabs = self.findChildren(QTabWidget) if tabs: self._main_tab_widget = tabs[0] if not hasattr(self, '_main_tab_widget'): return self._detect_export_tab_index() if self._export_tab_index is None: return if self._main_tab_widget.currentIndex() != self._export_tab_index: self._main_tab_widget.setCurrentIndex(self._export_tab_index) except Exception as e: print('切换导出标签失败:', e) def _on_finished_cover_clicked(self, asset_id): # 在主列表中选中对应书籍(若存在) try: if not hasattr(self, 'sorted_assetids'): return if asset_id not in self.sorted_assetids: return row = self.sorted_assetids.index(asset_id) # 切换到“导出”标签(自动探测索引) self._switch_to_export_tab() self.listwidget.setCurrentRow(row) # 确保可见并聚焦 try: item = self.listwidget.item(row) if item: self.listwidget.scrollToItem(item) self.listwidget.setFocus() except Exception: pass # 触发 update_book_info 逻辑自动刷新右侧 except Exception as e: print('点击已读书籍封面失败:', e) def export_notes(self): idx = self.listwidget.currentRow() if idx < 0: QMessageBox.warning(self, "提示", "请先选择一本书") return assetid = self.sorted_assetids[idx] selected_booksnote = self.exporter.build_booksnote(bookid=assetid) selected_booksinfo = {assetid: self.booksinfo.get(assetid, {})} bookname = selected_booksinfo[assetid].get("displayname") or selected_booksinfo[assetid].get("itemname") or assetid ts = datetime.datetime.now().strftime('%m%d%H%M') shortname = re.split(r'[.::_\【\[\((]', bookname)[0].strip() export_dir = getattr(config, "EXPORT_NOTES_DIR", os.getcwd()) if not os.path.exists(export_dir): os.makedirs(export_dir) out_path = os.path.join(export_dir, f"notes_{shortname}-{ts}.md") self.exporter.export_booksnote_to_md(selected_booksnote, selected_booksinfo, out_path) QMessageBox.information(self, "导出成功", f"已导出到:{out_path}") def export_ipad_bundle(self): """导出 iPad 数据包 ZIP。""" try: from ipad_bundle_export import export_ipad_bundle ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S') out_dir = getattr(config, 'EXPORT_NOTES_DIR', os.getcwd()) os.makedirs(out_dir, exist_ok=True) out_path = os.path.join(out_dir, f'ipad_bundle_{ts}.zip') export_ipad_bundle(out_path, self.manager, self.exporter) QMessageBox.information(self, '完成', f'iPad 数据包已生成:\n{out_path}') except Exception as e: QMessageBox.critical(self, '错误', f'导出 iPad 数据包失败: {e}') def show_config(self): dlg = ConfigDialog(self) if dlg.exec(): dlg.get_config() QMessageBox.information(self, "提示", "配置已更新(仅本次运行有效)") try: self._apply_global_font() except Exception as e: print('字体刷新失败:', e) def update_book_info(self, row): if row < 0: # 不再清空:保持初始加载的封面;仅清空文本 self.book_toc_textedit.clear() return assetid = self.sorted_assetids[row] book_info = self.booksinfo.get(assetid, {}) # 计算当前与后续两本 total = len(self.sorted_assetids) indices = [(row + i) % total for i in range(min(3,total))] self._cover_pixmaps_original = [] from PyQt6.QtGui import QPixmap # 先清空所有标签 for lab in self._cover_labels: lab.clear() lab.setText("加载中") for pos, aid_idx in enumerate(indices): aid_show = self.sorted_assetids[aid_idx] binfo = self.booksinfo.get(aid_show, {}) cpath = self.find_book_cover(aid_show, binfo) label = self._cover_labels[pos] if cpath: pm = QPixmap(cpath) if not pm.isNull(): self._cover_pixmaps_original.append(pm) label.setText("") continue self._cover_pixmaps_original.append(None) label.setText("无封面") # 填充不足三本情况 for pos in range(len(indices), 3): lab = self._cover_labels[pos] lab.setText("无") self._cover_pixmaps_original.append(None) self._apply_cover_scale() # 生成 HTML 信息基础部分 bookname_display = book_info.get('displayname', '') or book_info.get('itemname', '') or assetid author = book_info.get('author', '') btype = book_info.get('type', '') get_time = book_info.get('date', '') self._base_info_cache = { 'bookname': bookname_display, 'author': author, 'type': btype, 'date': get_time } import json bookname = bookname_display self._current_bookname = bookname json_path = os.path.join(os.path.dirname(__file__), 'bookintro.json') try: with open(json_path, 'r', encoding='utf-8') as f: intro_dict = json.load(f) except Exception: intro_dict = {} review = intro_dict.get(bookname) if review: html = self._build_book_html(review) self.book_toc_textedit.setHtml(html) else: prompt = f"{bookname} 400字书评 三段 简洁精炼" loading_html = self._build_book_html("简评获取中...") self.book_toc_textedit.setHtml(loading_html) worker = BookReviewWorker(bookname, prompt, json_path, parent=self) # UI 更新 worker.finished.connect(lambda bname, review: self._on_review_finished(bname, review)) # 完成后从活动列表移除 worker.finished.connect(lambda _b, _r, w=worker: self._remove_worker(w)) self._review_worker = worker self._active_workers.append(worker) worker.start() def _on_review_finished(self, bookname, review, base_text=None): # base_text 兼容旧调用,可忽略 if bookname != self._current_bookname: return html = self._build_book_html(review) self.book_toc_textedit.setHtml(html) def _load_initial_covers(self): """在应用启动时加载列表中前三本书的封面到三个标签。""" try: if not hasattr(self, '_cover_labels') or not self.sorted_assetids: return from PyQt6.QtGui import QPixmap self._cover_pixmaps_original = [] # 取前三本 first_indices = list(range(min(3, len(self.sorted_assetids)))) for pos in range(3): if pos < len(first_indices): aid = self.sorted_assetids[first_indices[pos]] info = self.booksinfo.get(aid, {}) cpath = self.find_book_cover(aid, info) if cpath: pm = QPixmap(cpath) if not pm.isNull(): self._cover_pixmaps_original.append(pm) self._cover_labels[pos].setPixmap(pm) continue self._cover_pixmaps_original.append(None) self._cover_labels[pos].setText('无封面') else: self._cover_pixmaps_original.append(None) self._cover_labels[pos].setText('') self._apply_cover_scale() except Exception as e: print('初始封面加载失败:', e) def resizeEvent(self, event): # 窗口尺寸变化时重新计算封面大小 try: self._apply_cover_scale() self._relayout_finished_grid() except Exception: pass # ---------------- 全局字体应用(含苹方别名) ---------------- def _apply_global_font(self): try: from PyQt6.QtGui import QFontDatabase, QFont from PyQt6.QtWidgets import QApplication fam_cfg = getattr(config, 'FONT_FAMILY', None) size = int(getattr(config, 'FONT_SIZE', 14)) candidates_cfg = list(getattr(config, 'FONT_CANDIDATES', [])) # 如果用户直接输入 'PingFang' 统一展开具体系列供匹配 extra_pingfang = ['PingFang SC','PingFang TC','PingFang HK'] expanded = [] if fam_cfg and fam_cfg.lower().startswith('pingfang'): expanded.extend(extra_pingfang) # 去重保持顺序 seen = set() def add_seq(seq): for f in seq: if f and f not in seen: seen.add(f); expanded.append(f) add_seq([fam_cfg]) add_seq(expanded) add_seq(candidates_cfg) available = set(QFontDatabase.families()) chosen = None for f in expanded: if f in available: chosen = f; break if not chosen: print('[字体] 无可用字体,放弃') return font = QFont(chosen, size) QApplication.instance().setFont(font) self.setFont(font) print(f'[字体] 应用 {chosen} {size}px') except Exception as e: print('全局字体应用失败:', e) def _build_book_html(self, review_text: str) -> str: """构建包含加粗紫红色标题的 HTML 内容,AI书评分段显示。""" info = getattr(self, '_base_info_cache', {}) magenta = "#C71585" # 紫红色 def line(title, value): return f"

{title} {value}

" # 书评分段处理 def review_lines(text): # 按换行或两个以上空格分段 if not text: return [""] # 兼容多种分段格式 segments = [seg.strip() for seg in re.split(r'\n{2,}|\r{2,}|\n|\r|\s{2,}', text) if seg.strip()] return [f"

{seg}

" for seg in segments] parts = [ line("书名:", f"{info.get('author','')} - {info.get('bookname','')}") , line("作者:", info.get('author','')), line("类型:", info.get('type','')), line("获取时间:", info.get('date','')), f"书籍简评:" ] parts += review_lines(review_text) return "".join(parts) def _init_charts(self): """使用原生 Qt 组件渲染统计标签页四个图表(取代 matplotlib)。""" try: from charts import BarChartWidget, BubbleMetricsWidget, ScatterChartWidget except Exception as e: print('警告: 无法导入原生图表组件 charts.py:', e) return required = [ ('frame_week', 'weekLayout'), ('frame_month', 'monthLayout'), ('frame_year', 'yearLayout'), ('frame_bubble', 'bubbleLayout'), ] for attr, layout_name in required: if not hasattr(self, attr) or not hasattr(self, layout_name): print('信息: 缺少统计容器', attr) return try: week_data = self.manager.get_total_readtime(days=7) month_data = self.manager.get_total_readtime(days=30) year_data = self.manager.get_total_readtime12m() year_total_minutes = self.manager.get_total_readtime_year() except Exception as e: print('警告: 统计数据获取失败:', e) return if all(v == 0 for v in week_data + month_data + year_data): for _, layout_name in required: getattr(self, layout_name).addWidget(QLabel('暂无阅读数据')) return # 最近7天:weekday 英文缩写(索引0=今天) today = datetime.date.today() recent_days = [today - datetime.timedelta(days=i) for i in range(len(week_data))] WEEK_ABBR = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'] week_labels = [WEEK_ABBR[d.weekday()] for d in recent_days] # 最近30天:日期标签,旋转45度防重叠 month_recent_days = [today - datetime.timedelta(days=i) for i in range(len(month_data))] month_labels = [f"{d.month}月{d.day}日" for d in month_recent_days] year_labels = [f'{i+1}月' for i in range(12)] year_hours = [round(m/60.0, 1) for m in year_data] week_chart = BarChartWidget(week_data, title='', unit='分钟', labels=week_labels, value_format=lambda v: f'{int(v)}') # 让横坐标先显示第30天数据(即最近的日期在最左侧) month_chart = BarChartWidget( month_data[::-1], # 反转数据 title='', unit='分钟', labels=month_labels[::-1], # 反转标签 value_format=lambda v: f'{int(v)}', label_rotation=45 ) year_chart = ScatterChartWidget(year_hours, title='', unit='小时', labels=year_labels) # 确保图表在网格中可弹性扩展 from PyQt6.QtWidgets import QSizePolicy for wdg in (week_chart, month_chart, year_chart): wdg.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.weekLayout.addWidget(week_chart) self.monthLayout.addWidget(month_chart) self.yearLayout.addWidget(year_chart) year_hours_total = year_total_minutes / 60.0 month_avg_hours = (sum(year_data)/12.0)/60.0 if year_data else 0 week_hours = sum(week_data)/60.0 day_avg_minutes = (sum(month_data)/30.0) if month_data else 0 bubble_metrics = [ ('全年', year_hours_total, 'h', '#5b6ee1'), ('月均', month_avg_hours, 'h', '#c9b2d9'), ('近7天', week_hours, 'h', '#f4b2c2'), ('日均', day_avg_minutes, 'm', '#b9b542'), ] # 新增:今年已读完书籍数量气泡(单位:book) try: finished_books = self.manager.get_finished_books_this_year() finished_count = len(finished_books) bubble_metrics.append(('已读', finished_count, 'book', '#6aa84f')) except Exception as e: print('信息: 获取已读书籍数量失败:', e) bubble_widget = BubbleMetricsWidget(bubble_metrics) bubble_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.bubbleLayout.addWidget(bubble_widget) # ---------------- 窗口尺寸持久化 ---------------- def _restore_window_geometry(self): settings = QSettings('iBookTools', 'iBook笔记专家') geo = settings.value('mainWindowGeometry') if isinstance(geo, QByteArray) and not geo.isEmpty(): try: self.restoreGeometry(geo) return except Exception: pass # 兼容旧版本仅存宽高 w = settings.value('windowWidth', type=int) h = settings.value('windowHeight', type=int) if w and h and w > 100 and h > 100: self.resize(QSize(w, h)) else: # 默认初始尺寸 self.resize(1500, 900) def closeEvent(self, event): try: settings = QSettings('iBookTools', 'iBook笔记专家') # 新格式:直接保存几何 settings.setValue('mainWindowGeometry', self.saveGeometry()) # 兼容旧字段 settings.setValue('windowWidth', self.width()) settings.setValue('windowHeight', self.height()) # 优雅等待所有后台线程结束(给最多 8 秒) deadline = datetime.datetime.now() + datetime.timedelta(seconds=8) for w in list(self._active_workers): if w.isRunning(): remaining = (deadline - datetime.datetime.now()).total_seconds() if remaining <= 0: break # wait 参数是毫秒 w.wait(int(min(remaining, 2) * 1000)) # 分批等待,避免一次性卡太久 except Exception: pass super().closeEvent(event) def _remove_worker(self, worker): try: if worker in self._active_workers: self._active_workers.remove(worker) except Exception: pass # ------------------------------------------------ # ------------------------------------------------ if __name__ == "__main__": app = QApplication(sys.argv) # 设置应用程序名称和组织信息 app.setApplicationName("iBook笔记专家") app.setApplicationDisplayName("iBook笔记专家") app.setOrganizationName("iBook Tools") # 设置应用程序图标 if os.path.exists(config.APP_ICON): app.setWindowIcon(QIcon(config.APP_ICON)) win = IBookExportApp() try: win._init_charts() except Exception: pass win.show() sys.exit(app.exec())