760 lines
34 KiB
Python
760 lines
34 KiB
Python
import sys
|
||
import os
|
||
import re
|
||
import datetime
|
||
from PyQt6.QtWidgets import (
|
||
QApplication, QWidget, QLabel, QMessageBox, QLineEdit, QFormLayout, QDialog, QDialogButtonBox, QSizePolicy
|
||
)
|
||
from PyQt6.QtGui import QIcon
|
||
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"<p><span style='color:{magenta};font-weight:bold;'>{title}</span> {value}</p>"
|
||
# 书评分段处理
|
||
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"<p>{seg}</p>" 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"<span style='color:{magenta};font-weight:bold;'>书籍简评:</span>"
|
||
]
|
||
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())
|