Files

1 line
1.1 MiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "aarongig/pdf2zh-desktop", "files": {"ui/main_window.py": {"ead24a4b52b86c4f7b59958f51c5d72d2641064a": {"t": 1785674187.8725162, "n": 266583, "h": "6380f23012856569", "c": "\"\"\"\npdf2zh for Mac — 完整版主窗口\n全功能适配: 三种预览模式 / 分块翻译 / 页码范围 / 历史记录 / 20+翻译服务\n\"\"\"\n\nimport sys\nimport os\nimport json\nimport webbrowser\nimport time\nfrom pathlib import Path\nimport fitz\n\n\ndef _install_pdf2zh_color_fix():\n \"\"\"v2.3.3 运行时热补丁 (import hook, 不改 pdf2zh 模块本身):\n 修复某些期刊 PDF(ICC 颜色空间) 白色矩形 '1 1 1 scn' 被截断成 '1 scn' → 渲染成红色,\n 以及译文文字继承颜色导致变红。在 pdf2zh.pdfinterp / pdf2zh.converter 被导入时自动打补丁。\n \"\"\"\n import sys as _sys\n import importlib.util as _ilu\n from importlib.abc import MetaPathFinder\n\n _patched = set()\n\n def _patch_pdfinterp(mod):\n for cn in (\"PDFPageInterpreterEx\", \"PDFPageInterpreter\"):\n cls = getattr(mod, cn, None)\n if not cls or getattr(cls, \"_p2z_scn_fixed\", False):\n continue\n\n def _mk(is_stroke):\n attr = \"scs\" if is_stroke else \"ncs\"\n cattr = \"scolor\" if is_stroke else \"ncolor\"\n\n def _f(self):\n cs = getattr(self.graphicstate, attr, None) or getattr(self, attr, None)\n n = cs.ncomponents if cs else 1\n _num = 0\n for _v in reversed(self.argstack):\n if isinstance(_v, (int, float)):\n _num += 1\n else:\n break\n if _num > n:\n n = _num\n a = self.pop(n)\n setattr(self.graphicstate, cattr, a)\n return a\n\n return _f\n\n cls.do_scn = _mk(False)\n cls.do_sc = cls.do_scn\n cls.do_SCN = _mk(True)\n cls.do_SC = cls.do_SCN\n cls._p2z_scn_fixed = True\n\n def _patch_converter(mod):\n tc = getattr(mod, \"TranslateConverter\", None)\n if not tc or getattr(tc, \"_p2z_color_fixed\", False):\n return\n _orig = tc.receive_layout\n\n def _wrapped(self, ltpage):\n ops = _orig(self, ltpage)\n if isinstance(ops, str) and ops:\n return \"0 g 0 G \" + ops\n return ops\n\n tc.receive_layout = _wrapped\n tc._p2z_color_fixed = True\n\n def _patch_translator(mod):\n import re as _re\n dt = getattr(mod, \"DeepseekTranslator\", None)\n ot = getattr(mod, \"OpenAITranslator\", None)\n\n # v2.3.5: DeepSeek V4(v4-flash/v4-pro, 及已映射到 v4 的 deepseek-chat 别名)是混合推理模型,\n # 裸调默认走\"思考模式\" → 逐段先推理再输出 → 翻译极慢/偶发漏译/(经代理时)推理泄漏进译文。\n # 对这些实例注入 thinking:disabled 退回直出; deepseek-reasoner 纯推理不动。\n if dt is not None and not getattr(dt, \"_p2z_thinking_fixed\", False):\n _orig_init = dt.__init__\n\n def _new_init(self, *a, **kw):\n _orig_init(self, *a, **kw)\n try:\n _m = (getattr(self, \"model\", \"\") or \"\").lower()\n if (\"v4\" in _m) or (\"chat\" in _m):\n if not getattr(self, \"options\", None):\n self.options = {}\n self.options[\"extra_body\"] = {\"thinking\": {\"type\": \"disabled\"}}\n except Exception:\n pass\n\n dt.__init__ = _new_init\n dt._p2z_thinking_fixed = True\n\n # v2.3.6: 弱模型/推理模型往译文里加译者注(注:...)/前言/思考块(见 issue #27)。\n # A 强约束 system prompt(所有 OpenAI 兼容服务) + B 输出兜底清洗。\n if ot is not None and not getattr(ot, \"_p2z_mt_clean\", False):\n _NOTE = _re.compile(\n r\"\\s*[((]\\s*(注|註|译者注|譯者註|译注|譯注|Note|N\\.?B\\.?)\\s*[::].*$\",\n _re.DOTALL | _re.IGNORECASE,\n )\n _PRE = _re.compile(\n r\"^\\s*(译文|译|翻译|Translation|Translated text)\\s*[::]\\s*\", _re.IGNORECASE\n )\n _THK = _re.compile(r\"<think>.*?</think>\", _re.DOTALL | _re.IGNORECASE)\n _SYS = (\n \"You are a professional translation engine. Output ONLY the translated text — nothing else.\\n\"\n \"STRICT RULES:\\n\"\n \"1. NEVER add explanations, comments, annotations, or translator notes.\\n\"\n \"2. NEVER add parenthetical remarks such as (注:…), (注:…), (Note:…), or [译者注:…].\\n\"\n \"3. NEVER add a prefix like '译文:', '翻译:', or 'Translation:'.\\n\"\n \"4. NEVER comment on terminology or justify your word choices.\\n\"\n \"5. Keep formula placeholders {v*} / {{v*}} exactly unchanged.\\n\"\n \"6. Translate terms and short fragments directly and concisely — still translation only.\"\n )\n\n def _clean(t):\n if not t or not isinstance(t, str):\n return t\n o = t\n t = _THK.sub(\"\", t)\n t = _PRE.sub(\"\", t)\n t = _NOTE.sub(\"\", t)\n t = t.strip()\n return t if t else o.strip()\n\n # A: OpenAITranslator 加 _SYSTEM_PROMPT + prompt override(无 system 则加)\n if not hasattr(ot, \"_SYSTEM_PROMPT\"):\n ot._SYSTEM_PROMPT = _SYS\n _orig_prompt = ot.prompt\n\n def _new_prompt(self, text, prompt_template=None):\n msgs = _orig_prompt(self, text, prompt_template)\n sysp = getattr(self, \"_SYSTEM_PROMPT\", None)\n if sysp and not (\n msgs and isinstance(msgs[0], dict) and msgs[0].get(\"role\") == \"system\"\n ):\n return [{\"role\": \"system\", \"content\": sysp}] + list(msgs)\n return msgs\n\n ot.prompt = _new_prompt\n # DeepSeek 去掉自己的 prompt override → 继承(用 DeepSeek 自己的 _SYSTEM_PROMPT), 避免双 system\n if dt is not None and \"prompt\" in dt.__dict__:\n try:\n del dt.prompt\n except Exception:\n pass\n\n # B: do_translate 后清洗(并防 frozen 版 content=None 崩)\n _orig_do = ot.do_translate\n\n def _new_do(self, text):\n try:\n out = _orig_do(self, text)\n except AttributeError as _e:\n if \"NoneType\" in str(_e):\n return \"\"\n raise\n return _clean(out)\n\n ot.do_translate = _new_do\n ot._p2z_mt_clean = True\n\n _patchers = {\n \"pdf2zh.pdfinterp\": _patch_pdfinterp,\n \"pdf2zh.converter\": _patch_converter,\n \"pdf2zh.translator\": _patch_translator,\n }\n\n class _Finder(MetaPathFinder):\n def find_spec(self, name, path, target=None):\n if name not in _patchers or name in _patched:\n return None\n _patched.add(name) # 防递归\n try:\n spec = _ilu.find_spec(name)\n except Exception:\n return None\n if spec is None or spec.loader is None:\n return None\n _orig_exec = spec.loader.exec_module\n\n def _exec(module):\n _orig_exec(module)\n try:\n _patchers[name](module)\n except Exception:\n pass\n\n spec.loader.exec_module = _exec\n return spec\n\n try:\n _sys.meta_path.insert(0, _Finder())\n except Exception:\n pass\n\n # 兜底: 若目标模块在 Finder 安装前已被导入(import 顺序不定), 直接补一次\n for _n, _p in _patchers.items():\n if _n in _sys.modules and _n not in _patched:\n _patched.add(_n)\n try:\n _p(_sys.modules[_n])\n except Exception:\n pass\n\n\n_install_pdf2zh_color_fix()\n\n\ndef _res(*parts):\n \"\"\"获取资源文件路径,兼容 PyInstaller 打包和开发环境\"\"\"\n if getattr(sys, '_MEIPASS', None):\n return os.path.join(sys._MEIPASS, *parts)\n return os.path.join(os.path.dirname(os.path.dirname(__file__)), *parts)\n\nfrom PyQt5.QtWidgets import (\n QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLayout,\n QPushButton, QLabel, QComboBox, QProgressBar, QFileDialog, QDialog,\n QStackedWidget, QStackedLayout, QFrame, QScrollArea, QGraphicsDropShadowEffect,\n QCheckBox, QListWidget, QListWidgetItem, QLineEdit, QSpinBox, QPlainTextEdit,\n QSizePolicy, QSlider, QSplitter, QTabBar, QMessageBox, QMenu, QAction,\n)\nfrom PyQt5.QtCore import Qt, pyqtSignal, QTimer, QSize, QEvent, QRect\nfrom PyQt5.QtGui import (QColor, QDragEnterEvent, QDropEvent, QImage, QPixmap,\n QScreen, QIcon, QPainter, QPainterPath, QPen, QRegion)\n\nfrom ui.config_manager import UserConfigManager, HistoryManager\nfrom ui.translate_worker import (\n TranslateWorker, LANG_MAP, SERVICE_MAP, PAGE_PRESETS,\n OUTPUT_MODES, parse_page_range, detect_zotero_source,\n get_zotero_item_key, zotero_auto_link, zotero_plugin_installed,\n build_service_envs, SummaryWorker, QAWorker,\n)\n\n# ─── 苹果风配色 ─────────────────────────────────────────────\n\nL = {\n \"bg\":\"#FFF\",\"bg2\":\"#F9F9FB\",\"bg3\":\"#F2F2F7\",\"elev\":\"#FFF\",\n \"sb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #FBFBFD,stop:1 #F2F2F7)\",\n \"sb_act\":\"rgba(0,113,227,0.12)\",\"sb_hov\":\"rgba(0,0,0,0.04)\",\n \"t1\":\"#1D1D1F\",\"t2\":\"#6E6E73\",\"t3\":\"#AEAEB2\",\"t4\":\"#C7C7CC\",\n \"acc\":\"#0071E3\",\"acc_l\":\"rgba(0,113,227,0.10)\",\"acc_h\":\"#0077ED\",\"acc_p\":\"#005BBB\",\n \"acc_g\":\"qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 #0071E3,stop:1 #00A1FF)\",\n \"ok\":\"#34C759\",\"err\":\"#FF3B30\",\n \"brd\":\"rgba(0,0,0,0.06)\",\"brd_s\":\"rgba(0,0,0,0.10)\",\n \"card\":\"#FFF\",\"card_b\":\"rgba(0,0,0,0.04)\",\n \"inp\":\"#F2F2F7\",\"inp_b\":\"rgba(0,0,0,0.08)\",\n \"dz_bg\":\"#FAFBFF\",\"dz_b\":\"rgba(0,113,227,0.20)\",\n \"tag_bg\":\"rgba(0,113,227,0.08)\",\"tag_fg\":\"#0071E3\",\n \"scr\":\"rgba(0,0,0,0.12)\",\"scr_h\":\"rgba(0,0,0,0.24)\",\n \"pv_bg\":\"#FFFFFF\",\"pv_tb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #FBFBFD,stop:1 #F2F2F7)\",\n \"pv_l_bg\":\"rgba(0,113,227,0.07)\",\"pv_l_fg\":\"#0071E3\",\n \"link\":\"#6E6E73\",\"link_h\":\"#0071E3\",\n}\n\nD = {\n \"bg\":\"#1C1C1E\",\"bg2\":\"#2C2C2E\",\"bg3\":\"#3A3A3C\",\"elev\":\"#2C2C2E\",\n \"sb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #242426,stop:1 #1C1C1E)\",\n \"sb_act\":\"rgba(10,132,255,0.20)\",\"sb_hov\":\"rgba(255,255,255,0.06)\",\n \"t1\":\"#F5F5F7\",\"t2\":\"#98989D\",\"t3\":\"#636366\",\"t4\":\"#48484A\",\n \"acc\":\"#0A84FF\",\"acc_l\":\"rgba(10,132,255,0.15)\",\"acc_h\":\"#409CFF\",\"acc_p\":\"#0071E3\",\n \"acc_g\":\"qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 #0A84FF,stop:1 #5AC8FA)\",\n \"ok\":\"#30D158\",\"err\":\"#FF453A\",\n \"brd\":\"rgba(255,255,255,0.06)\",\"brd_s\":\"rgba(255,255,255,0.10)\",\n \"card\":\"#2C2C2E\",\"card_b\":\"rgba(255,255,255,0.12)\",\n \"inp\":\"#3A3A3C\",\"inp_b\":\"rgba(255,255,255,0.08)\",\n \"dz_bg\":\"#2A2A2E\",\"dz_b\":\"rgba(10,132,255,0.35)\",\n \"tag_bg\":\"rgba(10,132,255,0.15)\",\"tag_fg\":\"#0A84FF\",\n \"scr\":\"rgba(255,255,255,0.12)\",\"scr_h\":\"rgba(255,255,255,0.24)\",\n \"pv_bg\":\"#1C1C1E\",\"pv_tb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #2C2C2E,stop:1 #242426)\",\n \"pv_l_bg\":\"rgba(10,132,255,0.12)\",\"pv_l_fg\":\"#0A84FF\",\n \"link\":\"#98989D\",\"link_h\":\"#0A84FF\",\n}\n\n_C = L # 当前活跃配色(深色/浅色切换时更新)\n\n\n# ─── 磨砂提示气泡 ──────────────────────────────────────────\nclass _Tip(QWidget):\n \"\"\"截取屏幕 → 缩放模糊 → 叠加半透明蒙层 → 圆角裁剪 = 磨砂玻璃\"\"\"\n _inst = None\n _PAD = 10\n\n def __init__(self):\n super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint)\n self.setAttribute(Qt.WA_TranslucentBackground)\n self.setAttribute(Qt.WA_ShowWithoutActivating)\n self._text = \"\"\n self._blur_pix = None\n\n def _grab_and_blur(self, x, y, w, h):\n \"\"\"安全截取屏幕区域并模糊\"\"\"\n try:\n screen = QApplication.primaryScreen()\n if not screen:\n return\n pix = screen.grabWindow(0, x, y, w, h)\n if pix.isNull():\n return\n img = pix.toImage()\n # 缩小到 1/8 再放大 = 快速高斯模糊\n from PyQt5.QtCore import QSize\n tiny = img.scaled(QSize(max(1, w // 8), max(1, h // 8)),\n Qt.IgnoreAspectRatio, Qt.SmoothTransformation)\n blurred = tiny.scaled(QSize(w, h),\n Qt.IgnoreAspectRatio, Qt.SmoothTransformation)\n self._blur_pix = QPixmap.fromImage(blurred)\n except Exception:\n self._blur_pix = None\n\n def paintEvent(self, e):\n from PyQt5.QtGui import QPainter, QPainterPath, QFont, QPen\n p = QPainter(self)\n p.setRenderHint(QPainter.Antialiasing)\n pad = self._PAD\n rect = self.rect().adjusted(pad, pad, -pad, -pad)\n radius = 8.0\n is_dark = (_C is D)\n path = QPainterPath()\n path.addRoundedRect(float(rect.x()), float(rect.y()),\n float(rect.width()), float(rect.height()), radius, radius)\n # ── 柔和阴影 ──\n for i in range(5):\n a = max(1, (35 if is_dark else 20) // (i + 1))\n d = (i + 1) * 2.0\n sp = QPainterPath()\n sr = rect.adjusted(-d, -d + 1, d, d + 2)\n sp.addRoundedRect(float(sr.x()), float(sr.y()),\n float(sr.width()), float(sr.height()), radius + d, radius + d)\n p.fillPath(sp, QColor(0, 0, 0, a))\n # ── 裁剪到圆角 ──\n p.setClipPath(path)\n # ── 模糊背景 ──\n if self._blur_pix and not self._blur_pix.isNull():\n p.drawPixmap(rect, self._blur_pix)\n else:\n fb = QColor(45, 45, 48) if is_dark else QColor(245, 245, 247)\n p.fillPath(path, fb)\n # ── 半透明蒙层(磨砂感的关键) ──\n overlay = QColor(30, 30, 32, 140) if is_dark else QColor(255, 255, 255, 178)\n p.fillRect(rect, overlay)\n # ── 顶部高光 ──\n hi = QPainterPath()\n hr = rect.adjusted(1, 1, -1, -rect.height() + 16)\n hi.addRoundedRect(float(hr.x()), float(hr.y()),\n float(hr.width()), float(hr.height()), radius - 1, radius - 1)\n p.fillPath(hi, QColor(255, 255, 255, 25 if is_dark else 120))\n # ── 边框 ──\n p.setClipping(False)\n brd = QColor(255, 255, 255, 20) if is_dark else QColor(0, 0, 0, 15)\n p.setPen(QPen(brd, 0.5))\n p.drawPath(path)\n # ── 文字 ──\n p.setPen(QColor(_C[\"t1\"]))\n font = QFont(\"Helvetica Neue\", 11)\n font.setFamilies([\"Helvetica Neue\", \"PingFang SC\"])\n p.setFont(font)\n tr = rect.adjusted(10, 7, -10, -7)\n p.drawText(tr, Qt.AlignLeft | Qt.AlignVCenter | Qt.TextWordWrap, self._text)\n p.end()\n\n def _calc_size(self):\n from PyQt5.QtGui import QFont, QFontMetrics\n font = QFont(\"Helvetica Neue\", 11)\n font.setFamilies([\"Helvetica Neue\", \"PingFang SC\"])\n fm = QFontMetrics(font)\n br = fm.boundingRect(0, 0, 300, 9999,\n Qt.AlignLeft | Qt.TextWordWrap, self._text)\n pad = self._PAD\n self.setFixedSize(br.width() + 20 + pad * 2, br.height() + 14 + pad * 2)\n\n @classmethod\n def show_tip(cls, pos, text, widget=None):\n if not text:\n cls.hide_tip(); return\n if cls._inst is None:\n cls._inst = cls()\n tip = cls._inst\n tip._text = text\n tip._calc_size()\n # 先确保 tooltip 自身不可见,再截取目标位置的屏幕\n tip.hide()\n pad = tip._PAD\n tx, ty = pos.x() + 8, pos.y() + 14\n # 边界检测:确保不超出屏幕\n screen = QApplication.primaryScreen()\n if screen:\n sg = screen.availableGeometry()\n if tx + tip.width() > sg.right():\n tx = sg.right() - tip.width()\n if ty + tip.height() > sg.bottom():\n ty = pos.y() - tip.height() - 4 # 移到光标上方\n tx = max(sg.left(), tx)\n ty = max(sg.top(), ty)\n bx, by = tx + pad, ty + pad\n tw, th = tip.width() - pad * 2, tip.height() - pad * 2\n tip._grab_and_blur(bx, by, tw, th)\n tip.move(tx, ty)\n tip.show()\n tip.update()\n\n @classmethod\n def hide_tip(cls):\n if cls._inst: cls._inst.hide()\n\n\ndef _fix_combo_popup(combo):\n \"\"\"去掉 QComboBox 的 macOS 焦点虚线框\"\"\"\n combo.setAttribute(Qt.WA_MacShowFocusRect, False)\n\n\ndef _install_tip_filter(app):\n \"\"\"安装全局 tooltip 拦截 + combo 修复\"\"\"\n from PyQt5.QtCore import QObject\n\n class F(QObject):\n def eventFilter(self, obj, event):\n if event.type() == QEvent.ToolTip:\n text = \"\"\n if hasattr(obj, 'toolTip'):\n text = obj.toolTip()\n # QListWidget viewport → item tooltip\n if not text and hasattr(obj, 'parent'):\n p = obj.parent()\n if hasattr(p, 'itemAt'):\n item = p.itemAt(event.pos())\n if item: text = item.toolTip()\n if text:\n from PyQt5.QtGui import QCursor\n _Tip.show_tip(QCursor.pos(), text, obj)\n event.accept()\n return True\n return False\n if event.type() == QEvent.Leave:\n _Tip.hide_tip()\n return False\n\n f = F(app)\n app._tip_filter = f\n app.installEventFilter(f)\n\n\ndef S(c):\n return f\"\"\"\n QMainWindow{{background:{c[\"bg\"]};}}\n QWidget{{font-family:\"Helvetica Neue\",\"PingFang SC\";font-size:13px;color:{c[\"t1\"]};outline:none;}}\n #Central{{background:{c[\"bg\"]};}}\n QStackedWidget{{background:{c[\"bg\"]};}}\n QStackedWidget>QWidget{{background:{c[\"bg\"]};}}\n QLabel{{background:transparent;border:none;}}\n QToolTip{{background:transparent;border:none;padding:0;}}\n /* ── 侧边栏 ── */\n #Sidebar{{background:{c[\"sb\"]};border-right:0.5px solid {c[\"brd\"]};}}\n #SB{{background:transparent;border:none;border-radius:7px;padding:6px 12px;text-align:left;font-size:13px;color:{c[\"t2\"]};letter-spacing:0.2px;border-left:3px solid transparent;}}\n #SB:hover{{background:{c[\"sb_hov\"]};color:{c[\"t1\"]};}}\n #SB[active=\"true\"]{{background:{c[\"sb_act\"]};color:{c[\"acc\"]};font-weight:600;border-left:3px solid {c[\"acc\"]};border-top-left-radius:0;border-bottom-left-radius:0;}}\n #SBLink{{background:transparent;border:none;font-size:10px;color:{c[\"link\"]};padding:1px 3px;}}\n #SBLink:hover{{color:{c[\"acc\"]};}}\n /* ── 按钮 ── */\n #Pr{{background:{c[\"acc_g\"]};color:white;border:none;border-radius:12px;padding:12px 32px;font-size:15px;font-weight:600;letter-spacing:0.3px;}}\n #Pr:hover{{background:{c[\"acc_h\"]};}}#Pr:pressed{{background:{c[\"acc_p\"]};padding:13px 32px 11px 32px;}}\n #Pr:disabled{{background:{c[\"bg3\"]};color:{c[\"t4\"]};border:1px solid {c[\"brd\"]};}}\n #Sc{{background:{c[\"bg2\"]};color:{c[\"t1\"]};border:0.5px solid {c[\"brd_s\"]};border-radius:8px;padding:8px 18px;font-size:13px;font-weight:500;}}\n #Sc:hover{{background:{c[\"bg3\"]};border-color:{c[\"acc\"]};}}\n #Gh{{background:transparent;color:{c[\"acc\"]};border:none;border-radius:6px;padding:6px 12px;font-size:13px;font-weight:500;}}\n #Gh:hover{{background:{c[\"acc_l\"]};}}\n #GhDanger{{background:transparent;color:{c[\"err\"]};border:none;border-radius:6px;padding:6px 12px;font-size:13px;font-weight:500;}}\n #GhDanger:hover{{background:rgba(255,59,48,0.08);}}\n #TB{{background:transparent;color:{c[\"t2\"]};border:none;border-radius:6px;padding:5px 10px;font-size:13px;font-weight:500;}}\n #TB:hover{{background:{c[\"sb_hov\"]};color:{c[\"t1\"]};}}\n #TB[active=\"true\"]{{background:{c[\"acc_l\"]};color:{c[\"acc\"]};font-weight:600;}}\n /* ── 输入控件 ── */\n QComboBox{{background:{c[\"inp\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;padding:8px 14px;font-size:13px;min-height:22px;color:{c[\"t1\"]};}}\n QComboBox:hover{{border-color:{c[\"brd_s\"]};}}QComboBox:focus{{border-color:{c[\"acc\"]};border-width:1.5px;outline:none;}}\n QComboBox::drop-down{{border:none;width:28px;subcontrol-origin:padding;subcontrol-position:center right;}}\n QComboBox::down-arrow{{image:none;width:8px;height:5px;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid {c[\"t3\"]};margin-right:10px;}}\n QComboBox QAbstractItemView{{background:{c[\"elev\"]};border:none;border-radius:10px;padding:6px 4px;color:{c[\"t1\"]};outline:0;}}\n QComboBox QAbstractItemView::item{{padding:6px 12px;border:none;border-radius:6px;margin:1px 2px;}}\n QComboBox QAbstractItemView::item:hover{{background:{c[\"acc_l\"]};}}\n QComboBox QAbstractItemView::item:selected{{background:{c[\"acc\"]};color:white;}}\n QLineEdit{{background:{c[\"inp\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;padding:8px 14px;font-size:13px;color:{c[\"t1\"]};}}\n QLineEdit:focus{{border-color:{c[\"acc\"]};border-width:2px;background:{c[\"elev\"]};}}\n QLineEdit[readOnly=\"true\"]{{color:{c[\"t2\"]};}}\n QSpinBox{{background:{c[\"inp\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;padding:6px 10px;font-size:13px;color:{c[\"t1\"]};}}\n QSpinBox:focus{{border-color:{c[\"acc\"]};border-width:2px;}}\n QTextEdit{{background:{c[\"inp\"]};color:{c[\"t1\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;}}\n QTextEdit:focus{{border-color:{c[\"acc\"]};border-width:2px;}}\n /* ── 进度条 ── */\n QProgressBar{{background:{c[\"bg3\"]};border:none;border-radius:3px;max-height:6px;min-height:6px;font-size:1px;}}\n QProgressBar::chunk{{background:{c[\"acc_g\"]};border-radius:3px;}}\n /* ── 卡片 ── */\n #Card{{background:{c[\"card\"]};border:0.5px solid {c[\"card_b\"]};border-radius:12px;}}\n #Card:hover{{border-color:{c[\"acc\"]};}}\n #DZ{{background:{c[\"dz_bg\"]};border:2px dashed {c[\"dz_b\"]};border-radius:12px;}}\n #DZ:hover{{border-color:{c[\"acc\"]};}}\n /* ── 列表 ── */\n QListWidget{{background:transparent;border:none;outline:none;}}\n QListWidget::item{{background:{c[\"card\"]};border:0.5px solid {c[\"card_b\"]};border-radius:8px;padding:4px 10px;margin:2px 0;}}\n QListWidget::item:selected{{background:{c[\"acc\"]};color:white;border-color:{c[\"acc\"]};}}\n QListWidget::item:hover:!selected{{background:{c[\"bg2\"]};}}\n /* ── 滚动条 ── */\n QScrollArea{{border:none;background:transparent;}}\n QScrollBar:vertical{{background:transparent;width:10px;margin:4px 2px;}}\n QScrollBar::handle:vertical{{background:{c[\"scr\"]};border-radius:4px;min-height:36px;}}\n QScrollBar::handle:vertical:hover{{background:{c[\"scr_h\"]};}}\n QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical,QScrollBar::add-page:vertical,QScrollBar::sub-page:vertical{{height:0;background:transparent;}}\n QScrollBar:horizontal{{height:0;}}\n /* ── 复选框 ── */\n QCheckBox{{spacing:10px;font-size:13px;}}\n QCheckBox::indicator{{width:18px;height:18px;border-radius:5px;border:1.5px solid {c[\"brd_s\"]};background:{c[\"elev\"]};}}\n QCheckBox::indicator:hover{{border-color:{c[\"acc\"]};background:{c[\"acc_l\"]};}}\n QCheckBox::indicator:checked{{background:{c[\"acc\"]};border-color:{c[\"acc\"]};}}\n /* ── 标签/分隔 ── */\n #Tag{{background:{c[\"tag_bg\"]};color:{c[\"tag_fg\"]};border:none;border-radius:6px;padding:3px 10px;font-size:11px;font-weight:600;}}\n #Div{{background:{c[\"brd\"]};max-height:0.5px;min-height:0.5px;}}\n /* ── 预览区 ── */\n #PA{{background:{c[\"pv_bg\"]};}}\n #PT{{background:{c[\"pv_tb\"]};border-bottom:0.5px solid {c[\"brd\"]};}}\n #PL{{background:{c[\"pv_l_bg\"]};color:{c[\"pv_l_fg\"]};border:none;border-radius:6px;padding:3px 12px;font-size:11px;font-weight:600;}}\n QSplitter::handle{{background:{c[\"brd\"]};width:1px;}}\n QSplitter::handle:hover{{background:{c[\"acc\"]};}}\n QSlider::groove:horizontal{{background:{c[\"bg3\"]};height:4px;border-radius:2px;}}\n QSlider::handle:horizontal{{background:{c[\"acc\"]};width:14px;height:14px;margin:-5px 0;border-radius:7px;}}\n QSlider::handle:horizontal:hover{{background:{c[\"acc_h\"]};width:16px;height:16px;margin:-6px 0;border-radius:8px;}}\n QMenu{{background:{c[\"elev\"]};border:1px solid {c[\"brd_s\"]};border-radius:10px;padding:6px 4px;}}\n QMenu::item{{padding:4px 28px 4px 14px;border-radius:6px;font-size:13px;margin:1px 4px;}}\n QMenu::item:selected{{background:{c[\"acc\"]};color:white;}}\n QMenu::separator{{height:1px;background:{c[\"brd\"]};margin:4px 12px;}}\n /* ── 字体层级: 24 / 15 / 13 / 11 ── */\n #PT0{{font-size:24px;font-weight:700;letter-spacing:-0.3px;}}\n #PT1{{font-size:15px;color:{c[\"t2\"]};}}\n #ST{{font-size:13px;font-weight:600;}}\n #SL{{font-size:11px;font-weight:600;letter-spacing:0.8px;color:{c[\"t3\"]};padding:6px 0 2px 0;}}\n #FL{{font-size:13px;font-weight:500;color:{c[\"t2\"]};padding:2px 0;}}\n #Cap{{font-size:11px;color:{c[\"t3\"]};}}\n #Mono{{font-family:\"Menlo\",monospace;font-size:12px;color:{c[\"t2\"]};}}\n /* ── 进度卡片 ── */\n #ProgPct{{font-family:\"Menlo\",monospace;font-size:20px;font-weight:700;color:{c[\"acc\"]};background:transparent;}}\n #ProgLabel{{font-size:13px;font-weight:600;color:{c[\"t1\"]};}}\n #ProgIcon{{font-size:16px;background:transparent;}}\n #ProgDetail{{font-size:11px;color:{c[\"t2\"]};}}\n #ProgTip{{font-size:11px;color:{c[\"t3\"]};font-style:italic;}}\n #ProgCancel{{background:transparent;color:{c[\"err\"]};border:none;font-size:13px;font-weight:500;padding:6px 12px;border-radius:6px;}}\n #ProgCancel:hover{{background:rgba(255,59,48,0.08);}}\n /* ── 拖拽区 ── */\n #DZTitle{{font-size:15px;font-weight:600;color:{c[\"t1\"]};background:transparent;}}\n #DZSub{{font-size:11px;color:{c[\"t3\"]};background:transparent;}}\n /* ── 历史面板 ── */\n #HistList{{background:transparent;border:none;font-size:11px;color:{c[\"t1\"]};}}\n #HistList::item{{background:transparent;border:none;border-bottom:0.5px solid {c[\"brd\"]};padding:8px 6px;margin:0;border-radius:0;}}\n #HistList::item:selected{{background:{c[\"sb_act\"]};color:{c[\"acc\"]};border-radius:6px;border-bottom:none;}}\n #HistList::item:hover:!selected{{background:{c[\"sb_hov\"]};border-radius:6px;}}\n #HistPanel{{border-right:0.5px solid {c[\"brd\"]};}}\n #HistDetail{{font-size:10px;color:{c[\"t2\"]};background:{c[\"bg2\"]};border:0.5px solid {c[\"brd\"]};border-radius:8px;padding:4px 6px;}}\n /* ── 自定义进度条 ── */\n #ProgBar{{background:{c[\"bg3\"]};border:none;border-radius:6px;max-height:12px;min-height:12px;font-size:1px;}}\n #ProgBar::chunk{{background:{c[\"acc_g\"]};border-radius:6px;}}\n /* ── 预览工具栏控件 ── */\n #PageInput{{font-family:'Menlo',monospace;font-size:13px;font-weight:600;padding:4px;border-radius:6px;border:1px solid {c[\"inp_b\"]};background:{c[\"inp\"]};color:{c[\"t1\"]};}}\n #PageInput:focus{{border-color:{c[\"acc\"]};}}\n #TotalLbl{{font-family:'Menlo',monospace;font-size:11px;color:{c[\"t2\"]};background:transparent;}}\n #ZoomPct{{font-family:'Menlo',monospace;font-size:11px;color:{c[\"t2\"]};background:transparent;}}\n #SBSep{{font-size:10px;color:{c[\"t3\"]};background:transparent;padding:0;}}\n #KBKey{{font-family:'Menlo',monospace;font-size:10px;font-weight:600;color:{c[\"t2\"]};background:{c[\"bg3\"]};border-radius:4px;padding:3px 8px;}}\n \"\"\"\n\n\nclass _EggLogo(QLabel):\n \"\"\"\n 📄 Logo 彩蛋按钮:\n · 悬停 5 秒 → 烟花爆炸\n · 连续点击 5 次 → emoji 飘落\n 所有动画在 MainWindow 层播放。\n \"\"\"\n def __init__(self, text=\"📄\", size=22):\n super().__init__(text)\n self.setStyleSheet(f\"font-size:{size}px;background:transparent;border:none;\")\n self.setCursor(Qt.PointingHandCursor)\n self._click_count = 0\n self._click_timer = QTimer(); self._click_timer.setSingleShot(True)\n self._click_timer.timeout.connect(self._reset_clicks)\n self._hover_timer = QTimer(); self._hover_timer.setSingleShot(True)\n self._hover_timer.timeout.connect(self._on_hover_egg)\n\n def enterEvent(self, e):\n super().enterEvent(e)\n self._hover_timer.start(5000)\n\n def leaveEvent(self, e):\n super().leaveEvent(e)\n self._hover_timer.stop()\n\n def mousePressEvent(self, e):\n super().mousePressEvent(e)\n self._click_count += 1\n self._click_timer.start(1200)\n if self._click_count >= 5:\n self._click_count = 0\n self._trigger_confetti()\n\n def _reset_clicks(self):\n self._click_count = 0\n\n def _main_window(self):\n w = self\n while w:\n if isinstance(w, QMainWindow): return w\n w = w.parentWidget()\n return None\n\n def _on_hover_egg(self):\n \"\"\"悬停 5 秒 → 烟花从 Logo 处爆发\"\"\"\n mw = self._main_window()\n if not mw: return\n import random, math\n from PyQt5.QtCore import QPropertyAnimation, QPoint\n center = self.mapTo(mw, self.rect().center())\n firework_emoji = [\"🎆\",\"✨\",\"💫\",\"⭐\",\"🌟\",\"🎇\",\"💥\",\"🔥\"]\n for _ in range(30):\n lbl = QLabel(random.choice(firework_emoji), mw)\n lbl.setStyleSheet(f\"font-size:{random.randint(14,32)}px;background:transparent;\")\n lbl.move(center); lbl.show()\n angle = random.uniform(0, 2 * math.pi)\n dist = random.randint(120, 350)\n end = QPoint(center.x() + int(math.cos(angle) * dist),\n center.y() + int(math.sin(angle) * dist))\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(800, 1600))\n anim.setStartValue(center); anim.setEndValue(end)\n from PyQt5.QtCore import QEasingCurve\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start(); lbl._anim = anim\n\n def _trigger_confetti(self):\n \"\"\"连续点击 5 次 → emoji 从天而降\"\"\"\n mw = self._main_window()\n if not mw: return\n import random\n from datetime import datetime\n from PyQt5.QtCore import QPropertyAnimation, QPoint, QEasingCurve\n md = (datetime.now().month, datetime.now().day)\n holiday = {\n (1,1):[\"🎆\",\"🥳\",\"🎊\",\"🎉\",\"✨\"], (2,14):[\"💝\",\"💕\",\"💗\",\"🥰\",\"💌\"],\n (5,1):[\"🎊\",\"🎉\",\"🌸\",\"✨\",\"🌟\"], (10,1):[\"🇨🇳\",\"🎆\",\"🎉\",\"✨\",\"💫\"],\n (10,31):[\"🎃\",\"👻\",\"🍬\",\"⭐\",\"✨\"], (12,25):[\"🎄\",\"🎅\",\"❄️\",\"🎁\",\"✨\"],\n }\n pool = holiday.get(md, [\"✨\",\"🎉\",\"🚀\",\"💡\",\"🌟\",\"💎\",\"🦋\",\"🌈\",\"🧸\",\"💫\",\"⭐\",\"🌸\",\"🎈\",\"🎵\",\"💗\",\"🌙\",\"🦄\"])\n for _ in range(25):\n lbl = QLabel(random.choice(pool), mw)\n lbl.setStyleSheet(f\"font-size:{random.randint(18,42)}px;background:transparent;\")\n x = random.randint(0, mw.width() - 40)\n lbl.move(x, -40); lbl.show()\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(1500, 3500))\n anim.setStartValue(lbl.pos())\n anim.setEndValue(QPoint(x + random.randint(-60, 60), mw.height() + 40))\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start(); lbl._anim = anim\n\n\nclass _RoundMenu(QMenu):\n \"\"\"圆角右键菜单 — FramelessWindowHint + 自绘圆角背景 + mask 裁切\"\"\"\n _R = 10 # corner radius\n\n def __init__(self, parent=None):\n super().__init__(parent)\n self.setWindowFlags(\n self.windowFlags() | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint)\n self.setAttribute(Qt.WA_TranslucentBackground)\n # 让 Qt 的默认 QMenu 绘制不画背景,只画菜单项\n self.setStyleSheet(\"QMenu{background:transparent;border:none;}\")\n\n def resizeEvent(self, e):\n super().resizeEvent(e)\n path = QPainterPath()\n path.addRoundedRect(0, 0, self.width(), self.height(),\n self._R, self._R)\n self.setMask(QRegion(path.toFillPolygon().toPolygon()))\n\n def paintEvent(self, e):\n p = QPainter(self)\n p.setRenderHint(QPainter.Antialiasing)\n r = self._R\n rect = self.rect()\n is_dark = isinstance(_C, dict) and _C.get(\"bg\") == \"#1C1C1E\"\n bg = QColor(_C.get(\"elev\", \"#FFF\")) if isinstance(_C, dict) else QColor(\"#FFF\")\n brd = QColor(0, 0, 0, 18) if not is_dark else QColor(255, 255, 255, 18)\n path = QPainterPath()\n path.addRoundedRect(\n rect.x() + 0.5, rect.y() + 0.5,\n rect.width() - 1.0, rect.height() - 1.0,\n float(r), float(r))\n p.fillPath(path, bg)\n p.setPen(QPen(brd, 0.5))\n p.drawPath(path)\n p.end()\n super().paintEvent(e)\n\n\ndef _div():\n d = QFrame(); d.setObjectName(\"Div\"); d.setFrameShape(QFrame.HLine); return d\n\n\ndef _md2html(text):\n \"\"\"Markdown → HTML — 让 AI 回复像 Claude 一样优雅\"\"\"\n import re as _re\n # 转义 HTML\n text = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')\n # 加粗\n text = _re.sub(r'\\*\\*(.+?)\\*\\*', r'<b>\\1</b>', text)\n # 斜体\n text = _re.sub(r'(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)', r'<i>\\1</i>', text)\n # 行内代码\n text = _re.sub(r'`([^`]+)`', r'<code style=\"background:rgba(0,0,0,0.06);border-radius:3px;padding:1px 4px;font-family:Menlo,monospace;font-size:10px;\">\\1</code>', text)\n # 列表项 (- item)\n text = _re.sub(r'^- (.+)$', r'<div style=\"margin:2px 0;padding-left:12px;text-indent:-8px;\">· \\1</div>', text, flags=_re.MULTILINE)\n # 数字列表 (1. item)\n text = _re.sub(r'^(\\d+)\\. (.+)$', r'<div style=\"margin:2px 0;padding-left:12px;text-indent:-12px;\">\\1. \\2</div>', text, flags=_re.MULTILINE)\n # 段落换行\n text = text.replace('\\n\\n', '<div style=\"height:8px;\"></div>')\n text = text.replace('\\n', '<br>')\n return text\n\n\ndef _card(level=\"md\"):\n \"\"\"统一阴影层级: sm=微弱, md=默认卡片, lg=悬浮\"\"\"\n _SHADOW = {\"sm\":(12,2,8), \"md\":(20,4,12), \"lg\":(28,6,16)}\n blur, y, a = _SHADOW.get(level, _SHADOW[\"md\"])\n c = QFrame(); c.setObjectName(\"Card\")\n s = QGraphicsDropShadowEffect(); s.setBlurRadius(blur); s.setOffset(0,y); s.setColor(QColor(0,0,0,a))\n c.setGraphicsEffect(s); return c\n\n\n# ═══════════════════════════════════════════════════════════════\n# 拖拽区\n# ═══════════════════════════════════════════════════════════════\n\nclass DropZone(QFrame):\n files_dropped = pyqtSignal(list)\n def __init__(self):\n super().__init__(); self.setObjectName(\"DZ\"); self.setAcceptDrops(True)\n self.setFixedHeight(140); self.setCursor(Qt.PointingHandCursor)\n\n self._stack = QStackedLayout(self)\n self._stack.setContentsMargins(0,0,0,0)\n\n # ── Page 0: 拖拽提示 ──\n p0 = QWidget(); p0.setStyleSheet(\"background:transparent;\")\n lo0 = QHBoxLayout(p0); lo0.setAlignment(Qt.AlignCenter); lo0.setSpacing(16)\n lo0.setContentsMargins(32,20,32,20)\n ic = QLabel(\"📥\"); ic.setStyleSheet(\"font-size:36px;background:transparent;\"); lo0.addWidget(ic)\n txt = QVBoxLayout(); txt.setSpacing(3)\n t = QLabel(\"将 PDF 拖放至此处\"); t.setObjectName(\"DZTitle\"); txt.addWidget(t)\n s = QLabel(\"或点击浏览 · 支持批量\"); s.setObjectName(\"DZSub\"); txt.addWidget(s)\n lo0.addLayout(txt); lo0.addStretch()\n self._stack.addWidget(p0)\n\n # ── Page 1: 文件列表 ──\n p1 = QWidget(); p1.setStyleSheet(\"background:transparent;\")\n lo1 = QVBoxLayout(p1); lo1.setContentsMargins(10,6,10,4); lo1.setSpacing(3)\n\n self._zotero_hint = QLabel(\"📚 Zotero 文献 · 译文自动保存回原位\")\n self._zotero_hint.setStyleSheet(\n \"font-size:10px;color:#1a56db;background:#e8f0fe;\"\n \"border-radius:3px;padding:2px 6px;\")\n self._zotero_hint.setVisible(False)\n lo1.addWidget(self._zotero_hint)\n\n self.flist = QListWidget()\n self.flist.setSelectionMode(QListWidget.ExtendedSelection)\n lo1.addWidget(self.flist)\n\n fbtn = QHBoxLayout(); fbtn.setSpacing(6)\n self._fcount_label = QLabel(\"\"); self._fcount_label.setObjectName(\"Cap\")\n self._fcount_label.setStyleSheet(\"font-size:10px;background:transparent;\")\n fbtn.addWidget(self._fcount_label); fbtn.addStretch()\n self._add_btn = QPushButton(\"+ 添加\"); self._add_btn.setCursor(Qt.PointingHandCursor)\n self._add_btn.setStyleSheet(\"font-size:10px;padding:1px 8px;border:1px solid #ccc;border-radius:3px;background:transparent;\")\n fbtn.addWidget(self._add_btn)\n self._del_btn = QPushButton(\"删除选中\"); self._del_btn.setObjectName(\"TB\"); self._del_btn.setCursor(Qt.PointingHandCursor)\n self._del_btn.setStyleSheet(\"font-size:10px;padding:1px 6px;\")\n fbtn.addWidget(self._del_btn)\n self._clr_btn = QPushButton(\"清空\"); self._clr_btn.setObjectName(\"GhDanger\"); self._clr_btn.setCursor(Qt.PointingHandCursor)\n self._clr_btn.setStyleSheet(\"font-size:10px;padding:1px 6px;\")\n fbtn.addWidget(self._clr_btn)\n lo1.addLayout(fbtn)\n self._stack.addWidget(p1)\n\n def dragEnterEvent(self, e):\n c = _C\n self.setStyleSheet(\n f\"#DZ{{background:{c['acc_l']};border:2.5px solid {c['acc']};\"\n f\"border-radius:14px;}}\")\n # 呼吸脉动动画\n from PyQt5.QtCore import QPropertyAnimation\n anim = QPropertyAnimation(self, b\"windowOpacity\")\n anim.setDuration(600); anim.setLoopCount(-1) # 无限循环\n anim.setStartValue(1.0); anim.setEndValue(0.85)\n # QWidget 没有 windowOpacity,用 graphicsEffect 模拟缩放\n effect = QGraphicsDropShadowEffect(self)\n effect.setBlurRadius(20); effect.setOffset(0, 0)\n effect.setColor(QColor(c['acc']))\n self.setGraphicsEffect(effect)\n self._drag_effect = effect\n e.acceptProposedAction()\n def dragLeaveEvent(self, e):\n self.setStyleSheet(\"\")\n self.setGraphicsEffect(None)\n self._drag_effect = None\n def dragMoveEvent(self, e):\n e.acceptProposedAction()\n def dropEvent(self, e):\n self.setStyleSheet(\"\")\n self.setGraphicsEffect(None)\n import glob\n fs = []\n md = e.mimeData()\n\n def _collect(p):\n \"\"\"从路径收集 PDF:文件直接加,文件夹递归扫描\"\"\"\n if p.lower().endswith('.pdf') and os.path.isfile(p):\n fs.append(p)\n elif os.path.isdir(p):\n fs.extend(sorted(glob.glob(\n os.path.join(p, '**', '*.pdf'), recursive=True)))\n\n # 1) 标准 file URL(Finder 拖文件/文件夹、Zotero 拖 PDF 附件)\n for u in md.urls():\n p = u.toLocalFile()\n if p:\n _collect(p)\n\n # 检测是否来自 Zotero(macOS 拖多个条目时 URL 只含一个文件)\n _from_zotero = False\n if fs:\n from ui.translate_worker import detect_zotero_source\n _from_zotero = any(detect_zotero_source(f) for f in fs)\n\n # 2) text/plain 中的 file:// 路径或本地路径\n if not fs and md.hasText():\n for line in md.text().strip().splitlines():\n line = line.strip()\n if line.startswith(\"file://\"):\n from PyQt5.QtCore import QUrl\n line = QUrl(line).toLocalFile()\n if line and os.path.exists(line):\n _collect(line)\n\n # 3) Zotero 自定义 MIME(同进程或支持跨进程的平台)\n if not fs:\n from ui.translate_worker import resolve_zotero_items, resolve_zotero_collection\n zot_data = md.data(\"zotero/item\")\n if zot_data and len(zot_data):\n try:\n import json as _json\n raw = bytes(zot_data).decode(\"utf-8\", errors=\"ignore\").strip()\n try:\n parsed = _json.loads(raw)\n if isinstance(parsed, dict) and \"itemIDs\" in parsed:\n item_ids = [int(x) for x in parsed[\"itemIDs\"]]\n elif isinstance(parsed, list):\n item_ids = [int(x) for x in parsed]\n elif isinstance(parsed, int):\n item_ids = [parsed]\n else:\n item_ids = []\n except (ValueError, TypeError):\n item_ids = [int(x) for x in raw.split(\",\")\n if x.strip().isdigit()]\n if item_ids:\n fs = resolve_zotero_items(item_ids)\n except Exception:\n pass\n if not fs:\n zot_coll = md.data(\"zotero/collection\")\n if zot_coll and len(zot_coll):\n try:\n raw = bytes(zot_coll).decode(\"utf-8\", errors=\"ignore\").strip()\n if raw:\n fs = resolve_zotero_collection(raw)\n except Exception:\n pass\n\n # 4) macOS 跨进程 Zotero:text/plain 匹配标题/集合名\n # 也在 layer 1 仅拿到部分 Zotero URL 时运行,补全缺失的文件\n if (not fs or _from_zotero) and md.hasText():\n try:\n from ui.translate_worker import (\n resolve_zotero_by_title, resolve_zotero_collection_by_name)\n txt = md.text().strip()\n if txt:\n extra = resolve_zotero_by_title(txt)\n existing = set(fs)\n for f in extra:\n if f not in existing:\n fs.append(f)\n existing.add(f)\n if not fs and txt:\n fs = resolve_zotero_collection_by_name(txt)\n except Exception:\n pass\n\n self.setStyleSheet(\"\") # 恢复默认样式\n if fs:\n self.files_dropped.emit(fs)\n def mousePressEvent(self, e):\n if self._stack.currentIndex() == 0:\n fs, _ = QFileDialog.getOpenFileNames(self, \"选择 PDF\", \"\", \"PDF (*.pdf)\")\n if fs: self.files_dropped.emit(fs)\n\n\nclass SummaryCard(QFrame):\n \"\"\"智能摘要卡片 — 可折叠,点击按钮调用 AI 生成结构化摘要\"\"\"\n def __init__(self):\n super().__init__()\n self.setObjectName(\"Card\")\n lo = QVBoxLayout(self); lo.setContentsMargins(16, 10, 16, 10); lo.setSpacing(6)\n # 头部\n hdr = QHBoxLayout(); hdr.setSpacing(8)\n ic = QLabel(\"✨\"); ic.setStyleSheet(\"font-size:14px;background:transparent;\")\n hdr.addWidget(ic)\n t = QLabel(\"智能摘要\"); t.setStyleSheet(\"font-size:13px;font-weight:600;background:transparent;\")\n hdr.addWidget(t); hdr.addStretch()\n self._svc_label = QLabel(\"\")\n self._svc_label.setStyleSheet(\"font-size:10px;color:gray;background:transparent;\")\n hdr.addWidget(self._svc_label)\n self.gen_btn = QPushButton(\"生成摘要\"); self.gen_btn.setObjectName(\"Sc\")\n self.gen_btn.setCursor(Qt.PointingHandCursor)\n self.gen_btn.setStyleSheet(\"font-size:11px;padding:3px 12px;\")\n self.gen_btn.clicked.connect(self._generate)\n hdr.addWidget(self.gen_btn)\n self._collapse_btn = QPushButton(\"▼\"); self._collapse_btn.setObjectName(\"Gh\")\n self._collapse_btn.setFixedSize(24, 24); self._collapse_btn.setCursor(Qt.PointingHandCursor)\n self._collapse_btn.setStyleSheet(\"font-size:10px;padding:0;\")\n self._collapse_btn.clicked.connect(self._toggle_collapse)\n hdr.addWidget(self._collapse_btn)\n lo.addLayout(hdr)\n # 内容区\n self._body = QLabel(\"\")\n self._body.setWordWrap(True); self._body.setTextInteractionFlags(Qt.TextSelectableByMouse)\n self._body.setStyleSheet(\"font-size:12px;line-height:160%;background:transparent;padding:4px 0;\")\n self._body.setVisible(False)\n lo.addWidget(self._body)\n self._collapsed = False\n self._worker = None\n self._pdf_path = None\n # 检测 AI 服务(优先使用独立助手配置)\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if svc:\n self._svc_label.setText(f\"via {svc['name']}\")\n else:\n self.gen_btn.setEnabled(False)\n self.gen_btn.setToolTip(\"请在设置中配置 AI 服务 API Key\")\n\n def set_pdf(self, path):\n self._pdf_path = path\n self._body.setText(\"\"); self._body.setVisible(False)\n self._collapse_btn.setText(\"▼\")\n\n def _toggle_collapse(self):\n self._collapsed = not self._collapsed\n self._body.setVisible(not self._collapsed and bool(self._body.text()))\n self._collapse_btn.setText(\"▶\" if self._collapsed else \"▼\")\n\n def _generate(self):\n if not self._pdf_path or not os.path.exists(self._pdf_path):\n return\n # 刷新服务检测(优先使用独立助手配置)\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if not svc:\n self._body.setText(\"⚠️ 未配置 AI 服务。请在设置页配置 API Key(如 DeepSeek、OpenAI 等)\")\n self._body.setVisible(True)\n return\n self._svc_label.setText(f\"via {svc['name']}\")\n self.gen_btn.setEnabled(False); self.gen_btn.setText(\"生成中…\")\n self._body.setText(\"⏳ 正在分析论文,请稍候…\"); self._body.setVisible(True)\n self._collapsed = False; self._collapse_btn.setText(\"▼\")\n self._worker = SummaryWorker(self._pdf_path)\n self._worker.result.connect(self._on_result)\n self._worker.error.connect(self._on_error)\n self._worker.finished.connect(lambda: self._cleanup())\n self._worker.start()\n\n def _on_result(self, text):\n self._body.setText(text)\n self._body.setVisible(True)\n\n def _on_error(self, err):\n self._body.setText(f\"⚠️ {err}\")\n self._body.setVisible(True)\n\n def _cleanup(self):\n self.gen_btn.setEnabled(True); self.gen_btn.setText(\"生成摘要\")\n if self._worker:\n w = self._worker; self._worker = None\n w.quit(); QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n\nclass SB(QWidget):\n \"\"\"侧边栏按钮 — 图标固定宽度 + 文字对齐\"\"\"\n clicked = pyqtSignal()\n def __init__(self, icon, label):\n super().__init__()\n self.setFixedHeight(32); self.setCursor(Qt.PointingHandCursor)\n self.setObjectName(\"SB\"); self.setProperty(\"active\", False)\n lo = QHBoxLayout(self); lo.setContentsMargins(12,0,12,0); lo.setSpacing(0)\n self._icon = QLabel(icon); self._icon.setFixedWidth(24); self._icon.setAlignment(Qt.AlignCenter)\n self._icon.setStyleSheet(\"font-size:16px;background:transparent;border:none;\")\n self._label = QLabel(label)\n self._label.setStyleSheet(\"font-size:13px;background:transparent;border:none;padding-left:8px;\")\n lo.addWidget(self._icon); lo.addWidget(self._label); lo.addStretch()\n def set_active(self, v):\n self.setProperty(\"active\", v); self.style().unpolish(self); self.style().polish(self)\n def mousePressEvent(self, e):\n self.clicked.emit()\n\n\n# ═══════════════════════════════════════════════════════════════\n# PDF 预览 — 支持 MONO / DUAL / Side-by-Side 三模式切换\n# ═══════════════════════════════════════════════════════════════\n\nclass PDFPageWidget(QLabel):\n def __init__(self):\n super().__init__(); self.setAlignment(Qt.AlignCenter)\n def set_pixmap(self, px):\n self.setPixmap(px)\n self.setFixedSize(px.size())\n\n\nfrom PyQt5.QtCore import QObject, QTimer\n\nclass _PinchFilter(QObject):\n \"\"\"全局捕获 macOS 触控板双指缩放 (NativeGesture)\"\"\"\n def __init__(self, preview):\n super().__init__(preview)\n self._preview = preview\n self._pending = False\n\n def _is_preview_child(self, widget):\n \"\"\"检查 widget 是否属于 preview 控件树\"\"\"\n try:\n w = widget\n for _ in range(30):\n if w is self._preview:\n return True\n p = w.parentWidget() if hasattr(w, 'parentWidget') else None\n if p is None:\n return False\n w = p\n except RuntimeError:\n return False\n return False\n\n def _do_zoom(self):\n \"\"\"延迟执行缩放,避免在事件处理期间修改控件树\"\"\"\n self._pending = False\n try:\n acc = self._preview._pinch_accumulator\n if abs(acc) > 0.08:\n new_zoom = self._preview.zoom * (1.0 + acc * 0.15)\n new_zoom = max(0.3, min(new_zoom, 5.0))\n self._preview._pinch_accumulator = 0.0\n self._preview._apply_zoom(new_zoom)\n except (RuntimeError, AttributeError):\n pass\n\n def eventFilter(self, obj, event):\n try:\n if event.type() == QEvent.NativeGesture:\n if event.gestureType() == Qt.ZoomNativeGesture and self._is_preview_child(obj):\n self._preview._pinch_accumulator += event.value()\n if not self._pending and abs(self._preview._pinch_accumulator) > 0.08:\n self._pending = True\n QTimer.singleShot(0, self._do_zoom)\n return True\n except (RuntimeError, AttributeError):\n pass\n return False\n\n\nclass _AutoGrowTextEdit(QWidget):\n \"\"\"自适应高度的输入框 — 内容少时单行,多时自动向上增长(最多 5 行)\"\"\"\n returnPressed = pyqtSignal()\n\n def __init__(self, placeholder=\"\"):\n super().__init__()\n lo = QVBoxLayout(self); lo.setContentsMargins(0, 0, 0, 0); lo.setSpacing(0)\n from PyQt5.QtWidgets import QTextEdit\n self._te = QTextEdit()\n self._te.setPlaceholderText(placeholder)\n self._te.setAcceptRichText(False)\n self._te.setStyleSheet(\n f\"QTextEdit{{background:transparent;border:none;\"\n f\"padding:4px 6px;font-size:13px;font-family:'Helvetica Neue','PingFang SC';\"\n f\"color:{_C['t1']};}}\")\n self._te.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._te.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._te.document().contentsChanged.connect(self._adjust_height)\n self._te.installEventFilter(self)\n lo.addWidget(self._te)\n self._min_h = 36; self._max_h = 120\n self._te.setFixedHeight(self._min_h)\n\n def text(self):\n return self._te.toPlainText()\n\n def clear(self):\n self._te.clear()\n\n def setPlaceholderText(self, t):\n self._te.setPlaceholderText(t)\n\n def _adjust_height(self):\n \"\"\"只在实际行数变化时调整高度(按行跳变,不逐像素增长)\"\"\"\n line_h = 20 # 13px font ≈ 20px line height\n doc_h = self._te.document().size().height()\n new_lines = max(1, round(doc_h / line_h))\n if not hasattr(self, '_cur_lines'):\n self._cur_lines = 1\n if new_lines != self._cur_lines:\n self._cur_lines = new_lines\n target_h = max(self._min_h, min(new_lines * line_h + 8, self._max_h))\n self._te.setFixedHeight(target_h)\n\n def eventFilter(self, obj, event):\n if obj == self._te and event.type() == QEvent.KeyPress:\n if event.key() in (Qt.Key_Return, Qt.Key_Enter) and not event.modifiers():\n self.returnPressed.emit()\n return True\n return super().eventFilter(obj, event)\n\n\nclass _FlowLayout(QLayout):\n \"\"\"自动换行布局 — 按钮放不下时自动折行,避免文字被截断\"\"\"\n def __init__(self, parent=None, margin=0, spacing=-1):\n super().__init__(parent)\n if parent is not None:\n self.setContentsMargins(margin, margin, margin, margin)\n self._items = []\n self._spacing = spacing\n\n def addItem(self, item): self._items.append(item)\n def count(self): return len(self._items)\n def itemAt(self, idx): return self._items[idx] if 0 <= idx < len(self._items) else None\n def takeAt(self, idx):\n if 0 <= idx < len(self._items):\n return self._items.pop(idx)\n return None\n\n def expandingDirections(self): return Qt.Orientations()\n def hasHeightForWidth(self): return True\n def heightForWidth(self, w): return self._do_layout(QRect(0, 0, w, 0), test=True)\n\n def setGeometry(self, rect):\n super().setGeometry(rect)\n self._do_layout(rect, test=False)\n\n def sizeHint(self): return self.minimumSize()\n def minimumSize(self):\n s = QSize()\n for item in self._items:\n s = s.expandedTo(item.minimumSize())\n m = self.contentsMargins()\n s += QSize(m.left() + m.right(), m.top() + m.bottom())\n return s\n\n def _do_layout(self, rect, test=False):\n m = self.contentsMargins()\n effective = rect.adjusted(m.left(), m.top(), -m.right(), -m.bottom())\n x, y = effective.x(), effective.y()\n line_h = 0\n sp = self._spacing if self._spacing >= 0 else 4\n for item in self._items:\n w = item.sizeHint().width()\n h = item.sizeHint().height()\n if x + w > effective.right() + 1 and line_h > 0:\n x = effective.x()\n y += line_h + sp\n line_h = 0\n if not test:\n item.setGeometry(QRect(x, y, w, h))\n x += w + sp\n line_h = max(line_h, h)\n return y + line_h - rect.y() + m.bottom()\n\n\n# ── AI 快捷操作 预设库 ──────────────────────────────────────────\n# id 用于标识,label 显示在按钮上,prompt 是发给 LLM 的指令\n# type=\"summary\" 走 SummaryWorker(内置摘要引擎),其余走 QAWorker\n\n_BUILTIN_PROMPTS = [\n {\n \"id\": \"summary\", \"label\": \"📝 摘要\", \"prompt\": \"\", \"type\": \"summary\",\n \"desc\": \"结构化论文摘要(内置引擎)\",\n },\n {\n \"id\": \"explain\", \"label\": \"📖 讲解\",\n \"desc\": \"通俗讲解论文核心内容\",\n \"prompt\": (\n \"请用通俗易懂的语言讲解这篇论文,假设读者具有本科水平但不熟悉该细分领域。\\n\\n\"\n \"请按以下结构输出:\\n\"\n \"## 一句话总结\\n用一句话概括论文在做什么。\\n\\n\"\n \"## 研究背景\\n这个领域之前遇到了什么问题?为什么需要这项研究?\\n\\n\"\n \"## 核心方法\\n作者提出了什么方法或思路?用类比或直觉解释,避免公式。\\n\\n\"\n \"## 主要发现\\n实验结果说明了什么?和之前的方法相比有什么提升?\\n\\n\"\n \"## 为什么重要\\n这项工作对该领域或实际应用有什么意义?\"\n ),\n },\n {\n \"id\": \"questions\", \"label\": \"❓ 深度提问\",\n \"desc\": \"生成 5 个深度问题帮助理解\",\n \"prompt\": (\n \"请基于这篇论文,提出 5 个有深度的问题,帮助读者批判性地理解论文内容。\\n\\n\"\n \"要求:\\n\"\n \"- 每个问题聚焦论文的不同方面(方法、实验、假设、局限性、应用前景等)\\n\"\n \"- 每个问题后附 2-3 句解释,说明为什么这个问题值得思考\\n\"\n \"- 问题应该有启发性,而非简单的事实复述\\n\\n\"\n \"格式:\\n**Q1: [问题]**\\n[为什么值得思考]\\n\\n依此类推。\"\n ),\n },\n {\n \"id\": \"critique\", \"label\": \"🔍 批判分析\",\n \"desc\": \"评估论文的优缺点和局限性\",\n \"prompt\": (\n \"请对这篇论文进行批判性分析,像一位资深审稿人一样思考。\\n\\n\"\n \"请从以下维度评价:\\n\"\n \"## 主要优点\\n论文在方法、实验设计、写作等方面做得好的地方(2-3 点)\\n\\n\"\n \"## 潜在不足\\n方法论上的局限、实验设计的缺陷、未讨论的边界条件(2-3 点)\\n\\n\"\n \"## 可信度评估\\n实验结果是否充分支撑了论文的结论?有没有过度声称?\\n\\n\"\n \"## 改进建议\\n如果你是作者,你会在哪些方面做进一步的工作?\"\n ),\n },\n {\n \"id\": \"methods\", \"label\": \"🧪 方法详解\",\n \"desc\": \"深入解析论文的技术方法\",\n \"prompt\": (\n \"请深入解析这篇论文的技术方法,适合有一定基础的读者。\\n\\n\"\n \"请涵盖:\\n\"\n \"## 问题形式化\\n论文要解决的问题是如何数学/形式化定义的?\\n\\n\"\n \"## 方法流程\\n按步骤描述方法的完整流程,用编号列表。\\n\\n\"\n \"## 关键创新点\\n和现有方法相比,核心创新在哪里?\\n\\n\"\n \"## 实验设置\\n用了什么数据集、基线方法、评估指标?\\n\\n\"\n \"## 核心结果\\n最重要的实验结论是什么?数据支撑如何?\"\n ),\n },\n {\n \"id\": \"related\", \"label\": \"🔗 相关工作\",\n \"desc\": \"梳理论文引用的相关研究脉络\",\n \"prompt\": (\n \"请梳理这篇论文的相关工作和研究脉络。\\n\\n\"\n \"请输出:\\n\"\n \"## 研究领域定位\\n这篇论文属于哪个研究方向?\\n\\n\"\n \"## 关键前置工作\\n论文建立在哪些重要的前人工作之上?列出 3-5 篇最重要的,并说明关系。\\n\\n\"\n \"## 方法演进\\n该领域的方法是如何一步步发展到本文的?用时间线或逻辑链描述。\\n\\n\"\n \"## 与竞争方法的对比\\n本文方法与最相近的竞争方法有什么本质区别?\"\n ),\n },\n {\n \"id\": \"application\", \"label\": \"💡 应用场景\",\n \"desc\": \"分析论文成果的实际应用可能性\",\n \"prompt\": (\n \"请分析这篇论文的研究成果可以如何应用到实际场景中。\\n\\n\"\n \"请涵盖:\\n\"\n \"## 直接应用\\n基于论文当前的成果,最可能直接落地的应用场景是什么?\\n\\n\"\n \"## 潜在扩展\\n如果进一步发展,还可能应用在哪些领域?\\n\\n\"\n \"## 落地挑战\\n从研究到实际部署,可能遇到哪些技术或工程上的困难?\\n\\n\"\n \"## 商业价值\\n这项技术对行业可能产生什么影响?\"\n ),\n },\n {\n \"id\": \"keyterms\", \"label\": \"📚 术语表\",\n \"desc\": \"提取并解释论文中的关键术语\",\n \"prompt\": (\n \"请提取这篇论文中的关键专业术语和概念,生成一个术语表。\\n\\n\"\n \"要求:\\n\"\n \"- 提取 8-12 个最重要的术语\\n\"\n \"- 每个术语给出:中文翻译(如适用)、一句话定义、在论文中的具体含义\\n\"\n \"- 按照术语在论文中出现的逻辑顺序排列\\n\\n\"\n \"格式:\\n**术语名** (English Term)\\n定义:...\\n论文中的含义:...\"\n ),\n },\n]\n\n# 默认激活的 3 个(显示在快捷栏上)\n_DEFAULT_ACTIVE_IDS = [\"summary\", \"explain\", \"questions\"]\n\n\nclass QAPanelWidget(QWidget):\n \"\"\"AI 论文助手面板 — 可自定义快捷操作库 + 聊天界面\"\"\"\n\n def __init__(self):\n super().__init__()\n self.setMinimumWidth(260); self.setMaximumWidth(420)\n self._busy = False # 是否有 worker 在运行\n lo = QVBoxLayout(self); lo.setContentsMargins(12, 10, 12, 12); lo.setSpacing(0)\n\n # ── 头部 ──\n hdr = QHBoxLayout(); hdr.setSpacing(6)\n t = QLabel(\"AI 助手\"); t.setStyleSheet(\n \"font-size:13px;font-weight:600;background:transparent;letter-spacing:0.3px;\")\n hdr.addWidget(t); hdr.addStretch()\n self._svc_label = QLabel(\"\")\n self._svc_label.setStyleSheet(f\"font-size:10px;color:{_C['t3']};background:transparent;\")\n hdr.addWidget(self._svc_label)\n clr = QPushButton(\"清空\"); clr.setObjectName(\"Gh\")\n clr.setStyleSheet(\"font-size:10px;padding:2px 6px;\")\n clr.setCursor(Qt.PointingHandCursor); clr.clicked.connect(self._clear)\n hdr.addWidget(clr)\n self._cfg_btn = QPushButton(\"⚙\")\n self._cfg_btn.setCursor(Qt.PointingHandCursor)\n self._cfg_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:none;font-size:13px;\"\n f\"padding:2px 5px;color:{_C['t3']};}}\"\n f\"QPushButton:hover{{color:{_C['acc']};}}\")\n self._cfg_btn.clicked.connect(self._open_settings)\n hdr.addWidget(self._cfg_btn)\n lo.addLayout(hdr)\n lo.addSpacing(6)\n\n # ── 快捷操作栏 ──\n self._quick_frame = QFrame()\n self._quick_frame.setStyleSheet(\"QFrame{background:transparent;border:none;}\")\n self._quick_layout = QHBoxLayout(self._quick_frame)\n self._quick_layout.setContentsMargins(0, 0, 0, 0)\n self._quick_layout.setSpacing(6)\n self._quick_btns = []\n lo.addWidget(self._quick_frame)\n lo.addSpacing(8)\n\n # ── 聊天区 ──\n self._chat_scroll = QScrollArea(); self._chat_scroll.setWidgetResizable(True)\n self._chat_scroll.setFrameShape(QFrame.NoFrame)\n self._chat_container = QWidget()\n self._chat_layout = QVBoxLayout(self._chat_container)\n self._chat_layout.setContentsMargins(0, 4, 0, 4); self._chat_layout.setSpacing(12)\n self._chat_layout.setAlignment(Qt.AlignTop)\n self._chat_scroll.setWidget(self._chat_container)\n lo.addWidget(self._chat_scroll, 1)\n lo.addSpacing(8)\n\n # ── 输入区 ──\n self._inp_frame = QFrame()\n self._inp_frame.setStyleSheet(\n f\"QFrame{{background:{_C['card']};border:0.5px solid {_C['brd']};\"\n f\"border-radius:12px;}}\")\n inp_lo = QHBoxLayout(self._inp_frame)\n inp_lo.setContentsMargins(10, 6, 6, 6); inp_lo.setSpacing(6)\n self._input = _AutoGrowTextEdit(\"输入问题…\")\n inp_lo.addWidget(self._input, 1)\n self._send_btn = QPushButton(\"发送\"); self._send_btn.setObjectName(\"Pr\")\n self._send_btn.setStyleSheet(\"font-size:11px;padding:6px 16px;border-radius:8px;\")\n self._send_btn.setCursor(Qt.PointingHandCursor)\n self._send_btn.clicked.connect(self._send)\n self._send_btn.setFixedHeight(32)\n inp_lo.addWidget(self._send_btn, 0, Qt.AlignBottom)\n lo.addWidget(self._inp_frame)\n self._input.returnPressed.connect(self._send)\n self._char_count = None\n\n # ── 状态 ──\n self._paper_text = \"\"\n self._pdf_path = None\n self._messages = []\n self._worker = None\n self._summary_worker = None\n self._current_ai_bubble = None\n self._streaming_text = \"\"\n\n # 加载 prompt 库 & 激活列表\n self._load_library()\n\n # ══════════════════════════════════════════════════════════════\n # Prompt 库 & 持久化\n # ══════════════════════════════════════════════════════════════\n\n def _load_library(self):\n \"\"\"从配置加载 prompt 库和激活列表\"\"\"\n cfg = UserConfigManager.load()\n # 库:内置 + 用户自定义\n saved_lib = cfg.get(\"ai_prompt_library\", None)\n if saved_lib is not None:\n self._library = saved_lib\n else:\n self._library = [dict(p) for p in _BUILTIN_PROMPTS]\n # 激活的 ID 列表(显示在快捷栏,最多 3 个)\n self._active_ids = cfg.get(\"ai_active_ids\", list(_DEFAULT_ACTIVE_IDS))\n self._rebuild_quick_bar()\n\n def _save_library(self):\n cfg = UserConfigManager.load()\n cfg[\"ai_prompt_library\"] = self._library\n cfg[\"ai_active_ids\"] = self._active_ids\n UserConfigManager.save(cfg)\n\n def _get_active_prompts(self):\n \"\"\"返回当前激活的 prompt 列表(按 _active_ids 顺序)\"\"\"\n id_map = {p[\"id\"]: p for p in self._library}\n return [id_map[aid] for aid in self._active_ids if aid in id_map]\n\n # ══════════════════════════════════════════════════════════════\n # 快捷栏\n # ══════════════════════════════════════════════════════════════\n\n def _rebuild_quick_bar(self):\n \"\"\"根据激活列表重建快捷按钮 — 等宽圆角药丸\"\"\"\n while self._quick_layout.count():\n item = self._quick_layout.takeAt(0)\n w = item.widget()\n if w:\n w.deleteLater()\n self._quick_btns = []\n c = _C\n for prompt_data in self._get_active_prompts():\n b = QPushButton(prompt_data[\"label\"])\n b.setCursor(Qt.PointingHandCursor)\n b.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)\n b.setFixedHeight(32)\n b.setStyleSheet(\n f\"QPushButton{{background:{c['card']};border:0.5px solid {c['card_b']};\"\n f\"border-radius:16px;padding:0 10px;font-size:12px;color:{c['t1']};}}\"\n f\"QPushButton:hover{{background:{c['acc_l']};border-color:{c['acc']};}}\"\n f\"QPushButton:pressed{{background:{c['acc']};color:white;\"\n f\"border-color:{c['acc']};}}\"\n f\"QPushButton:disabled{{opacity:0.5;}}\")\n pid = prompt_data[\"id\"]\n b.clicked.connect(lambda _, _pid=pid: self._exec_by_id(_pid))\n self._quick_layout.addWidget(b, 1) # stretch=1 等宽分布\n self._quick_btns.append(b)\n\n def _exec_by_id(self, prompt_id):\n \"\"\"执行指定 ID 的快捷操作\"\"\"\n try:\n if self._busy:\n return\n id_map = {p[\"id\"]: p for p in self._library}\n act = id_map.get(prompt_id)\n if not act:\n return\n if act.get(\"type\") == \"summary\":\n self._run_summary()\n else:\n prompt = act.get(\"prompt\", \"\")\n if prompt:\n self._run_preset(prompt, act.get(\"label\", \"\"))\n except Exception:\n pass\n\n # ══════════════════════════════════════════════════════════════\n # ⚙ 设置弹窗 — prompt 库管理 + 激活选择\n # ══════════════════════════════════════════════════════════════\n\n def _open_settings(self):\n \"\"\"打开 prompt 库配置弹窗\"\"\"\n try:\n self._do_open_settings()\n except Exception:\n pass\n\n def _do_open_settings(self):\n c = _C\n dlg = QDialog(self)\n dlg.setWindowTitle(\"AI 快捷操作配置\")\n dlg.setMinimumSize(520, 580)\n dlg.resize(560, 660)\n dlg.setStyleSheet(f\"\"\"\n QDialog{{background:{c['bg']};}}\n QLabel{{color:{c['t1']};background:transparent;}}\n QScrollArea{{background:{c['bg']};border:none;}}\n QWidget#scroll_inner{{background:{c['bg']};}}\n QLineEdit{{background:{c['inp']};border:1px solid {c['inp_b']};\n border-radius:6px;padding:6px 10px;font-size:13px;color:{c['t1']};\n min-height:20px;}}\n QLineEdit:focus{{border-color:{c['acc']};}}\n QPlainTextEdit{{background:{c['inp']};border:1px solid {c['inp_b']};\n border-radius:8px;padding:8px 10px;font-size:12px;color:{c['t1']};\n line-height:160%;}}\n QPlainTextEdit:focus{{border-color:{c['acc']};}}\n QCheckBox{{color:{c['t1']};spacing:6px;}}\n QCheckBox::indicator{{width:18px;height:18px;border-radius:5px;\n border:1.5px solid {c['inp_b']};background:{c['inp']};}}\n QCheckBox::indicator:checked{{background:{c['acc']};border-color:{c['acc']};}}\n \"\"\")\n dlo = QVBoxLayout(dlg); dlo.setContentsMargins(24, 20, 24, 18); dlo.setSpacing(14)\n\n # ── 标题行 ──\n hdr = QHBoxLayout()\n title = QLabel(\"快捷操作库\"); title.setStyleSheet(\"font-size:16px;font-weight:600;\")\n hdr.addWidget(title); hdr.addStretch()\n hint_l = QLabel(\"勾选最多 3 个显示在快捷栏\")\n hint_l.setStyleSheet(f\"font-size:11px;color:{c['t3']};\")\n hdr.addWidget(hint_l)\n dlo.addLayout(hdr)\n\n # ── 滚动区 ──\n scroll = QScrollArea(); scroll.setWidgetResizable(True)\n scroll.setFrameShape(QFrame.NoFrame)\n container = QWidget(); container.setObjectName(\"scroll_inner\")\n clo = QVBoxLayout(container); clo.setContentsMargins(0, 0, 0, 0); clo.setSpacing(10)\n clo.setAlignment(Qt.AlignTop)\n\n _cards = [] # list of entry dicts\n\n def _make_card(p, parent_layout):\n pid = p.get(\"id\", \"\")\n tp = p.get(\"type\", \"\")\n card = QFrame()\n card.setStyleSheet(\n f\"QFrame{{background:{c['card']};border:0.5px solid {c['card_b']};\"\n f\"border-radius:12px;}}\")\n card_lo = QVBoxLayout(card)\n card_lo.setContentsMargins(14, 12, 14, 12); card_lo.setSpacing(8)\n\n # ── 第一行:勾选 + 名称 + 删除 ──\n r1 = QHBoxLayout(); r1.setSpacing(10)\n cb = QCheckBox()\n cb.setFixedSize(22, 22)\n cb.setChecked(pid in self._active_ids)\n cb.setToolTip(\"勾选后显示在快捷栏\")\n r1.addWidget(cb, 0, Qt.AlignVCenter)\n name_inp = QLineEdit(p.get(\"label\", \"\")); name_inp.setMaxLength(12)\n name_inp.setPlaceholderText(\"按钮名称\")\n name_inp.setFixedHeight(32)\n r1.addWidget(name_inp, 1, Qt.AlignVCenter)\n is_builtin = any(bp[\"id\"] == pid for bp in _BUILTIN_PROMPTS)\n del_btn = QPushButton(\"×\")\n del_btn.setCursor(Qt.PointingHandCursor)\n del_btn.setFixedSize(26, 26)\n del_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:none;color:{c['t3']};\"\n f\"font-size:16px;font-weight:600;border-radius:13px;}}\"\n f\"QPushButton:hover{{background:rgba(255,59,48,0.1);color:{c['err']};}}\")\n if is_builtin:\n del_btn.setVisible(False)\n r1.addWidget(del_btn, 0, Qt.AlignVCenter)\n card_lo.addLayout(r1)\n\n # ── 第二行:描述 ──\n desc = p.get(\"desc\", \"\")\n if desc:\n dl = QLabel(desc)\n dl.setStyleSheet(f\"font-size:11px;color:{c['t3']};padding-left:32px;\")\n card_lo.addWidget(dl)\n\n # ── 第三行:Prompt 编辑 ──\n if tp == \"summary\":\n hl = QLabel(\"🔧 内置摘要引擎,自动分析论文结构并生成结构化摘要\")\n hl.setStyleSheet(\n f\"font-size:11px;color:{c['t3']};padding:6px 10px;\"\n f\"background:{c['bg2']};border-radius:6px;\")\n hl.setWordWrap(True)\n card_lo.addWidget(hl)\n prompt_inp = None\n else:\n prompt_inp = QPlainTextEdit()\n prompt_inp.setPlainText(p.get(\"prompt\", \"\"))\n prompt_inp.setPlaceholderText(\"输入提示词…\\n\\n描述你希望 AI 对论文做什么分析\")\n prompt_inp.setMinimumHeight(90)\n prompt_inp.setMaximumHeight(200)\n card_lo.addWidget(prompt_inp)\n\n parent_layout.addWidget(card)\n entry = {\"cb\": cb, \"name\": name_inp, \"prompt\": prompt_inp,\n \"id\": pid, \"type\": tp, \"card\": card}\n _cards.append(entry)\n\n # 删除\n def _del(_, _e=entry):\n _e[\"card\"].setVisible(False)\n _e[\"card\"].deleteLater()\n _e[\"deleted\"] = True\n del_btn.clicked.connect(_del)\n\n # 勾选约束:最多 3 个\n def _on_check(state, _e=entry):\n checked = [e for e in _cards\n if not e.get(\"deleted\") and e[\"cb\"].isChecked()]\n if len(checked) > 3:\n _e[\"cb\"].setChecked(False)\n cb.stateChanged.connect(_on_check)\n\n for p in self._library:\n _make_card(p, clo)\n\n scroll.setWidget(container)\n dlo.addWidget(scroll, 1)\n\n # ── 底部按钮区 ──\n blo = QHBoxLayout(); blo.setSpacing(8)\n # 新建\n add_btn = QPushButton(\"+ 新建\")\n add_btn.setCursor(Qt.PointingHandCursor)\n add_btn.setStyleSheet(\n f\"QPushButton{{background:{c['acc_l']};border:none;border-radius:8px;\"\n f\"padding:8px 14px;font-size:12px;color:{c['acc']};}}\"\n f\"QPushButton:hover{{background:{c['acc']};color:white;}}\")\n _counter = [0]\n def _on_add():\n _counter[0] += 1\n new_p = {\"id\": f\"custom_{_counter[0]}_{id(dlg)}\",\n \"label\": \"🔧 自定义\", \"prompt\": \"\", \"desc\": \"自定义提示词\"}\n _make_card(new_p, clo)\n QTimer.singleShot(50, lambda: scroll.verticalScrollBar().setValue(\n scroll.verticalScrollBar().maximum()))\n add_btn.clicked.connect(_on_add)\n blo.addWidget(add_btn)\n\n # 导入\n imp_btn = QPushButton(\"📥 导入\")\n imp_btn.setCursor(Qt.PointingHandCursor)\n imp_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:1px solid {c['inp_b']};\"\n f\"border-radius:8px;padding:8px 14px;font-size:12px;color:{c['t2']};}}\"\n f\"QPushButton:hover{{border-color:{c['acc']};color:{c['acc']};}}\")\n def _on_import():\n path, _ = QFileDialog.getOpenFileName(\n dlg, \"导入提示词\",\n str(Path.home()),\n \"文本文件 (*.txt *.md);;JSON (*.json);;所有文件 (*)\")\n if not path:\n return\n try:\n content = Path(path).read_text(encoding=\"utf-8\")\n fname = Path(path).stem\n # 尝试 JSON\n try:\n data = json.loads(content)\n items = data if isinstance(data, list) else [data]\n for item in items:\n _counter[0] += 1\n new_p = {\n \"id\": f\"import_{_counter[0]}_{id(dlg)}\",\n \"label\": item.get(\"label\", f\"📥 {fname}\"),\n \"prompt\": item.get(\"prompt\", content),\n \"desc\": item.get(\"desc\", \"导入的提示词\"),\n }\n _make_card(new_p, clo)\n except (json.JSONDecodeError, AttributeError):\n # 纯文本 → 整个文件内容作为 prompt\n _counter[0] += 1\n new_p = {\n \"id\": f\"import_{_counter[0]}_{id(dlg)}\",\n \"label\": f\"📥 {fname[:8]}\",\n \"prompt\": content,\n \"desc\": \"导入的提示词\",\n }\n _make_card(new_p, clo)\n QTimer.singleShot(50, lambda: scroll.verticalScrollBar().setValue(\n scroll.verticalScrollBar().maximum()))\n except Exception as e:\n QMessageBox.warning(dlg, \"导入失败\", str(e))\n imp_btn.clicked.connect(_on_import)\n blo.addWidget(imp_btn)\n\n # 恢复默认\n reset_btn = QPushButton(\"恢复默认\")\n reset_btn.setCursor(Qt.PointingHandCursor)\n reset_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:1px solid {c['inp_b']};\"\n f\"border-radius:8px;padding:8px 14px;font-size:12px;color:{c['t2']};}}\"\n f\"QPushButton:hover{{border-color:{c['acc']};color:{c['acc']};}}\")\n def _on_reset():\n for entry in _cards:\n entry[\"deleted\"] = True\n entry[\"card\"].setVisible(False)\n entry[\"card\"].deleteLater()\n _cards.clear()\n for p in _BUILTIN_PROMPTS:\n _make_card(dict(p), clo)\n for entry in _cards:\n entry[\"cb\"].setChecked(entry[\"id\"] in _DEFAULT_ACTIVE_IDS)\n reset_btn.clicked.connect(_on_reset)\n blo.addWidget(reset_btn)\n\n blo.addStretch()\n ok_btn = QPushButton(\"保存\")\n ok_btn.setCursor(Qt.PointingHandCursor)\n ok_btn.setStyleSheet(\n f\"QPushButton{{background:{c['acc']};color:white;border:none;\"\n f\"font-size:13px;padding:8px 30px;border-radius:8px;}}\"\n f\"QPushButton:hover{{background:{c['acc_h']};}}\")\n ok_btn.clicked.connect(dlg.accept)\n blo.addWidget(ok_btn)\n dlo.addLayout(blo)\n\n # ── 执行弹窗 ──\n if dlg.exec_() == QDialog.Accepted:\n new_library = []\n new_active = []\n for entry in _cards:\n if entry.get(\"deleted\"):\n continue\n try:\n label = entry[\"name\"].text().strip()\n except RuntimeError:\n continue\n if not label:\n continue\n p = {\"id\": entry[\"id\"], \"label\": label, \"type\": entry[\"type\"]}\n p[\"desc\"] = next((bp.get(\"desc\", \"\") for bp in _BUILTIN_PROMPTS\n if bp[\"id\"] == entry[\"id\"]), \"自定义提示词\")\n if entry[\"type\"] == \"summary\":\n p[\"prompt\"] = \"\"\n else:\n p[\"prompt\"] = entry[\"prompt\"].toPlainText() if entry[\"prompt\"] else \"\"\n new_library.append(p)\n if entry[\"cb\"].isChecked():\n new_active.append(entry[\"id\"])\n if new_active:\n self._library = new_library\n self._active_ids = new_active[:3]\n self._save_library()\n self._rebuild_quick_bar()\n\n # ══════════════════════════════════════════════════════════════\n # 论文上下文 & 聊天\n # ══════════════════════════════════════════════════════════════\n\n def set_paper_context(self, pdf_path):\n \"\"\"提取 PDF 文本作为问答上下文\"\"\"\n self._pdf_path = pdf_path\n try:\n doc = fitz.open(pdf_path)\n text = \"\"\n for i in range(min(15, len(doc))):\n text += doc[i].get_text()\n doc.close()\n self._paper_text = text[:10000]\n except Exception:\n self._paper_text = \"\"\n self._messages = []\n self._clear()\n try:\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n self._svc_label.setText(f\"via {svc['name']} · {svc['model']}\" if svc else \"未配置\")\n self._send_btn.setEnabled(bool(svc))\n except Exception:\n pass\n\n def _clear(self):\n try:\n self._messages = []\n self._current_ai_bubble = None\n self._stop_workers()\n while self._chat_layout.count():\n w = self._chat_layout.takeAt(0).widget()\n if w:\n w.deleteLater()\n except Exception:\n pass\n\n def _add_bubble(self, text, is_user=True):\n \"\"\"添加消息气泡\"\"\"\n row = QWidget(); row.setStyleSheet(\"background:transparent;\")\n row_lo = QHBoxLayout(row)\n row_lo.setContentsMargins(0, 0, 0, 0); row_lo.setSpacing(0)\n bubble = QLabel()\n bubble.setWordWrap(True)\n bubble.setTextInteractionFlags(Qt.TextSelectableByMouse)\n bubble.setMaximumWidth(300)\n c = _C\n if is_user:\n bubble.setText(text)\n bubble.setStyleSheet(\n f\"background:{c['acc']};color:white;border-radius:12px;\"\n f\"border-bottom-right-radius:4px;\"\n f\"padding:8px 12px;font-size:12px;\"\n f\"font-family:'Helvetica Neue','PingFang SC';\")\n row_lo.addStretch(); row_lo.addWidget(bubble)\n else:\n bubble.setTextFormat(Qt.RichText)\n html = _md2html(text) if text and not text.startswith('⏳') else text\n bubble.setText(html)\n bubble.setStyleSheet(\n f\"background:{c['card']};border:0.5px solid {c['card_b']};\"\n f\"border-radius:12px;border-top-left-radius:4px;\"\n f\"padding:10px 14px;font-size:12px;line-height:165%;\"\n f\"font-family:'Helvetica Neue','PingFang SC';\")\n row_lo.addWidget(bubble); row_lo.addStretch()\n self._chat_layout.addWidget(row)\n QTimer.singleShot(50, lambda: self._safe_scroll_bottom())\n return bubble\n\n def _safe_scroll_bottom(self):\n try:\n sb = self._chat_scroll.verticalScrollBar()\n sb.setValue(sb.maximum())\n except Exception:\n pass\n\n # ══════════════════════════════════════════════════════════════\n # AI Worker 管理(摘要 / 问答 / 流式)\n # ══════════════════════════════════════════════════════════════\n\n def _set_busy(self, busy):\n self._busy = busy\n try:\n self._send_btn.setEnabled(not busy)\n for b in self._quick_btns:\n b.setEnabled(not busy)\n except Exception:\n pass\n\n def _run_summary(self):\n \"\"\"使用 SummaryWorker 生成结构化摘要\"\"\"\n try:\n if not self._pdf_path or not os.path.exists(self._pdf_path):\n return\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if not svc:\n self._add_bubble(\"⚠️ 未配置 AI 服务\", is_user=False)\n return\n self._stop_workers()\n self._set_busy(True)\n self._add_bubble(\"📝 生成摘要\", is_user=True)\n self._current_ai_bubble = self._add_bubble(\"⏳ 正在分析论文…\", is_user=False)\n self._summary_worker = SummaryWorker(self._pdf_path)\n self._summary_worker.result.connect(self._on_summary_result)\n self._summary_worker.error.connect(self._on_qa_error)\n self._summary_worker.finished.connect(self._on_summary_done)\n self._summary_worker.start()\n except Exception:\n self._set_busy(False)\n\n def _run_preset(self, prompt, label=\"\"):\n \"\"\"用预设 prompt 发起问答\"\"\"\n try:\n if not self._paper_text:\n return\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if not svc:\n self._add_bubble(\"⚠️ 未配置 AI 服务\", is_user=False)\n return\n self._stop_workers()\n self._set_busy(True)\n self._add_bubble(label or (prompt[:15] + \"…\"), is_user=True)\n self._messages = [\n {\"role\": \"system\", \"content\": (\n \"你是论文阅读助手。以下是论文全文内容(可能被截断),请根据论文内容回答用户问题。\\n\"\n \"回答要准确、有深度,用中文。使用 Markdown 格式化输出。\\n\"\n \"如果论文中没有相关信息,请如实说明。\\n\\n\"\n f\"论文内容:\\n{self._paper_text}\"\n )},\n {\"role\": \"user\", \"content\": prompt},\n ]\n self._current_ai_bubble = self._add_bubble(\"⏳ 思考中…\", is_user=False)\n self._worker = QAWorker(list(self._messages))\n self._worker.chunk.connect(self._on_chunk)\n self._worker.finished.connect(self._on_qa_done)\n self._worker.error.connect(self._on_qa_error)\n self._worker.start()\n self._streaming_text = \"\"\n except Exception:\n self._set_busy(False)\n\n def _stop_workers(self):\n \"\"\"断开并停止所有 AI worker\"\"\"\n for attr in ('_worker', '_summary_worker'):\n w = getattr(self, attr, None)\n if w:\n for sig in ('chunk', 'result', 'finished', 'error'):\n try:\n getattr(w, sig).disconnect()\n except Exception:\n pass\n setattr(self, attr, None)\n try:\n w.quit()\n except Exception:\n pass\n\n @staticmethod\n def _widget_alive(w):\n try:\n w.objectName()\n return True\n except RuntimeError:\n return False\n\n def _on_summary_result(self, text):\n try:\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(_md2html(text))\n QTimer.singleShot(0, self._safe_scroll_bottom)\n except Exception:\n pass\n\n def _on_summary_done(self):\n try:\n self._set_busy(False)\n self._current_ai_bubble = None\n if self._summary_worker:\n w = self._summary_worker; self._summary_worker = None\n w.quit()\n except Exception:\n self._set_busy(False)\n\n def _on_chunk(self, text):\n try:\n self._streaming_text += text\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(_md2html(self._streaming_text))\n QTimer.singleShot(0, self._safe_scroll_bottom)\n except Exception:\n pass\n\n def _on_qa_done(self, full_text):\n try:\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(_md2html(full_text))\n self._messages.append({\"role\": \"assistant\", \"content\": full_text})\n self._set_busy(False)\n self._current_ai_bubble = None\n if self._worker:\n w = self._worker; self._worker = None\n w.quit()\n except Exception:\n self._set_busy(False)\n\n def _on_qa_error(self, err):\n try:\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(f\"⚠️ {err}\")\n self._set_busy(False)\n self._current_ai_bubble = None\n for attr in ('_worker', '_summary_worker'):\n w = getattr(self, attr, None)\n if w:\n setattr(self, attr, None)\n w.quit()\n except Exception:\n self._set_busy(False)\n\n def _send(self):\n try:\n q = self._input.text().strip()\n if not q or not self._paper_text or self._busy:\n return\n self._stop_workers()\n self._input.clear()\n self._set_busy(True)\n self._add_bubble(q, is_user=True)\n if not self._messages:\n self._messages.append({\n \"role\": \"system\",\n \"content\": (\n \"你是论文阅读助手。以下是论文全文内容(可能被截断),请根据论文内容回答用户问题。\\n\"\n \"回答要准确、有深度,用中文。使用 Markdown 格式化输出。\\n\"\n \"如果论文中没有相关信息,请如实说明。\\n\\n\"\n f\"论文内容:\\n{self._paper_text}\"\n )\n })\n self._messages.append({\"role\": \"user\", \"content\": q})\n self._current_ai_bubble = self._add_bubble(\"⏳ 思考中…\", is_user=False)\n self._worker = QAWorker(list(self._messages))\n self._worker.chunk.connect(self._on_chunk)\n self._worker.finished.connect(self._on_qa_done)\n self._worker.error.connect(self._on_qa_error)\n self._worker.start()\n self._streaming_text = \"\"\n except Exception:\n self._set_busy(False)\n\n # ══════════════════════════════════════════════════════════════\n # 主题切换\n # ══════════════════════════════════════════════════════════════\n\n def update_theme(self, c):\n try:\n self._svc_label.setStyleSheet(f\"font-size:10px;color:{c['t3']};background:transparent;\")\n self._inp_frame.setStyleSheet(\n f\"QFrame{{background:{c['card']};border:0.5px solid {c['brd']};\"\n f\"border-radius:12px;}}\")\n self._quick_frame.setStyleSheet(\"QFrame{background:transparent;border:none;}\")\n self._rebuild_quick_bar()\n self._cfg_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:none;font-size:13px;\"\n f\"padding:2px 5px;color:{c['t3']};}}\"\n f\"QPushButton:hover{{color:{c['acc']};}}\")\n except Exception:\n pass\n\n\nclass PreviewPage(QWidget):\n \"\"\"三合一预览: 顶部模式切换按钮 + PDF 显示\"\"\"\n fullscreen_toggled = pyqtSignal(bool)\n history_toggled = pyqtSignal()\n\n def __init__(self):\n super().__init__()\n self.doc = None\n self.current_page = 0\n self.zoom = 1.5\n self.output_files = {}\n self.current_mode = \"side_by_side\" # 默认 Side by Side\n self.setFocusPolicy(Qt.StrongFocus) # 接收键盘事件\n\n lo = QVBoxLayout(self); lo.setContentsMargins(0,0,0,0); lo.setSpacing(0)\n\n # 工具栏\n tb = QWidget(); tb.setObjectName(\"PT\"); tb.setFixedHeight(48)\n tl = QHBoxLayout(tb); tl.setContentsMargins(12,0,12,0); tl.setSpacing(6)\n\n # 模式切换按钮\n self.mode_btns = {}\n for label, mode in [(\"Dual\",\"dual\"),(\"Mono\",\"mono\"),(\"Side by Side\",\"side_by_side\")]:\n b = QPushButton(label); b.setObjectName(\"TB\"); b.setCursor(Qt.PointingHandCursor)\n b.setProperty(\"active\", mode == \"side_by_side\")\n b.clicked.connect(lambda _, m=mode: self.switch_mode(m))\n tl.addWidget(b); self.mode_btns[mode] = b\n\n tl.addSpacing(8)\n # 连续滚动 / 单页 切换\n self._continuous = True\n self.scroll_btn = QPushButton(\"连续\"); self.scroll_btn.setObjectName(\"TB\")\n self.scroll_btn.setCursor(Qt.PointingHandCursor)\n self.scroll_btn.setProperty(\"active\", True)\n self.scroll_btn.clicked.connect(self._toggle_continuous)\n tl.addWidget(self.scroll_btn)\n\n tl.addSpacing(12)\n ob = QPushButton(\"打开\"); ob.setObjectName(\"Sc\"); ob.setCursor(Qt.PointingHandCursor)\n ob.setStyleSheet(\"padding:6px 14px;font-size:13px;\")\n ob.clicked.connect(self.open_file); tl.addWidget(ob)\n\n tl.addStretch()\n\n # 翻页:首页 < 上一页 | 页码输入 / 总页数 | 下一页 > 末页\n first_btn = QPushButton(\"⟨⟨\"); first_btn.setObjectName(\"TB\"); first_btn.setFixedWidth(32)\n first_btn.setCursor(Qt.PointingHandCursor)\n first_btn.clicked.connect(self.first_page); tl.addWidget(first_btn)\n\n prev_btn = QPushButton(\"‹\"); prev_btn.setObjectName(\"TB\"); prev_btn.setFixedWidth(32)\n prev_btn.setCursor(Qt.PointingHandCursor)\n prev_btn.clicked.connect(self.prev_page); tl.addWidget(prev_btn)\n\n # 页码输入框\n self.page_input = QLineEdit(\"1\")\n self.page_input.setFixedWidth(42)\n self.page_input.setAlignment(Qt.AlignCenter)\n self.page_input.setObjectName(\"PageInput\")\n self.page_input.returnPressed.connect(self._jump_to_page)\n tl.addWidget(self.page_input)\n\n self.total_label = QLabel(\"/ —\")\n self.total_label.setObjectName(\"TotalLbl\")\n tl.addWidget(self.total_label)\n\n next_btn = QPushButton(\"›\"); next_btn.setObjectName(\"TB\"); next_btn.setFixedWidth(32)\n next_btn.setCursor(Qt.PointingHandCursor)\n next_btn.clicked.connect(self.next_page); tl.addWidget(next_btn)\n\n last_btn = QPushButton(\"⟩⟩\"); last_btn.setObjectName(\"TB\"); last_btn.setFixedWidth(32)\n last_btn.setCursor(Qt.PointingHandCursor)\n last_btn.clicked.connect(self.last_page); tl.addWidget(last_btn)\n\n tl.addStretch()\n\n # 缩放\n self.zoom_slider = QSlider(Qt.Horizontal)\n self.zoom_slider.setRange(30,400); self.zoom_slider.setValue(150)\n self.zoom_slider.setFixedWidth(90); self.zoom_slider.valueChanged.connect(self.on_zoom)\n tl.addWidget(self.zoom_slider)\n self.zoom_pct = QLabel(\"150%\")\n self.zoom_pct.setObjectName(\"ZoomPct\")\n self.zoom_pct.setFixedWidth(36)\n tl.addWidget(self.zoom_pct)\n # 适配模式:适宽 / 适页 两个按钮\n self._fit_mode = \"width\" # \"width\" 适宽 | \"page\" 适页\n self.fit_btn = QPushButton(\"适宽\"); self.fit_btn.setObjectName(\"TB\")\n self.fit_btn.setCursor(Qt.PointingHandCursor)\n self.fit_btn.setProperty(\"active\", True)\n self.fit_btn.clicked.connect(self._switch_to_fit_width)\n tl.addWidget(self.fit_btn)\n self.fitpage_btn = QPushButton(\"适页\"); self.fitpage_btn.setObjectName(\"TB\")\n self.fitpage_btn.setCursor(Qt.PointingHandCursor)\n self.fitpage_btn.clicked.connect(self._switch_to_fit_page)\n tl.addWidget(self.fitpage_btn)\n\n tl.addSpacing(6)\n self._is_fullscreen = False\n # 全屏中唤出历史面板\n self.hist_btn = QPushButton(\"历史\"); self.hist_btn.setObjectName(\"TB\")\n self.hist_btn.setCursor(Qt.PointingHandCursor)\n self.hist_btn.setVisible(False)\n self.hist_btn.clicked.connect(self.history_toggled.emit)\n tl.addWidget(self.hist_btn)\n # AI 问答按钮\n self.qa_btn = QPushButton(\"AI 问答\"); self.qa_btn.setObjectName(\"TB\")\n self.qa_btn.setCursor(Qt.PointingHandCursor)\n self.qa_btn.clicked.connect(self._toggle_qa_panel)\n tl.addWidget(self.qa_btn)\n # 高亮按钮\n self.hl_btn = QPushButton(\"高亮\"); self.hl_btn.setObjectName(\"TB\")\n self.hl_btn.setCursor(Qt.PointingHandCursor)\n self.hl_btn.setCheckable(True)\n self.hl_btn.clicked.connect(self._toggle_highlight)\n tl.addWidget(self.hl_btn)\n # 擦除按钮\n self.hl_erase_btn = QPushButton(\"擦除\"); self.hl_erase_btn.setObjectName(\"TB\")\n self.hl_erase_btn.setCursor(Qt.PointingHandCursor)\n self.hl_erase_btn.setCheckable(True)\n self.hl_erase_btn.clicked.connect(self._toggle_erase)\n self.hl_erase_btn.setVisible(False) # 进入高亮模式才显示\n tl.addWidget(self.hl_erase_btn)\n # 全屏按钮\n self.fs_btn = QPushButton(\"全屏\"); self.fs_btn.setObjectName(\"TB\")\n self.fs_btn.setCursor(Qt.PointingHandCursor)\n self.fs_btn.clicked.connect(self._toggle_fullscreen)\n tl.addWidget(self.fs_btn)\n\n lo.addWidget(tb)\n\n # 内容区:缩略图 + 主视图(QSplitter 支持拖拽调整缩略图宽度)\n self.body_splitter = QSplitter(Qt.Horizontal)\n self.body_splitter.setHandleWidth(1)\n self.body_splitter.setChildrenCollapsible(True)\n\n # 缩略图面板\n self.thumb_scroll = QScrollArea()\n self.thumb_scroll.setMinimumWidth(60); self.thumb_scroll.setMaximumWidth(240)\n self.thumb_scroll.setWidgetResizable(True); self.thumb_scroll.setFrameShape(QFrame.NoFrame)\n self.thumb_scroll.setObjectName(\"PA\"); self.thumb_scroll.setFocusPolicy(Qt.StrongFocus)\n self.thumb_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self.thumb_container = QWidget(); self.thumb_container.setObjectName(\"PA\")\n self.thumb_layout = QVBoxLayout(self.thumb_container)\n self.thumb_layout.setContentsMargins(4,6,4,6); self.thumb_layout.setSpacing(4)\n self.thumb_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)\n self.thumb_scroll.setWidget(self.thumb_container)\n self.thumb_scroll.setVisible(False)\n self.body_splitter.addWidget(self.thumb_scroll)\n\n # 主视图容器\n self._main_view = QWidget(); self._main_view.setObjectName(\"PA\")\n _mv_lo = QHBoxLayout(self._main_view)\n _mv_lo.setContentsMargins(0,0,0,0); _mv_lo.setSpacing(0)\n\n # 主视图 — 单页模式\n self.scroll = QScrollArea(); self.scroll.setWidgetResizable(True)\n self.scroll.setAlignment(Qt.AlignCenter); self.scroll.setObjectName(\"PA\")\n self.page_widget = PDFPageWidget()\n self.page_widget.setStyleSheet(\"padding:2px;\")\n shadow = QGraphicsDropShadowEffect(); shadow.setBlurRadius(28); shadow.setOffset(0,4)\n shadow.setColor(QColor(0,0,0,30)); self.page_widget.setGraphicsEffect(shadow)\n self.scroll.setWidget(self.page_widget)\n self._highlight_mode = False\n self._hl_erase_mode = False\n self._hl_start = None\n self._hl_overlay = None\n self.scroll.setVisible(False) # 默认连续模式,隐藏单页\n _mv_lo.addWidget(self.scroll)\n\n # 主视图 — 连续滚动模式(默认显示)\n self.cont_scroll = QScrollArea(); self.cont_scroll.setWidgetResizable(True)\n self.cont_scroll.setObjectName(\"PA\"); self.cont_scroll.setVisible(True)\n self.cont_container = QWidget(); self.cont_container.setObjectName(\"PA\")\n self.cont_layout = QVBoxLayout(self.cont_container)\n self.cont_layout.setContentsMargins(0,0,0,0); self.cont_layout.setSpacing(4)\n self.cont_layout.setAlignment(Qt.AlignHCenter)\n self.cont_scroll.setWidget(self.cont_container)\n self.cont_scroll.verticalScrollBar().valueChanged.connect(self._on_cont_scroll)\n _mv_lo.addWidget(self.cont_scroll)\n self._cont_page_widgets = []\n\n self.body_splitter.addWidget(self._main_view)\n # AI 问答面板(右侧,默认隐藏)\n self.qa_panel = QAPanelWidget()\n self.qa_panel.setVisible(False)\n self.body_splitter.addWidget(self.qa_panel)\n self.body_splitter.setSizes([140, 800, 0])\n self.body_splitter.setCollapsible(0, True) # 缩略图可折叠\n self.body_splitter.setCollapsible(1, False)\n self.body_splitter.setCollapsible(2, True) # QA 面板可折叠\n # 拖拽缩略图面板宽度时自动重建缩略图\n self.body_splitter.splitterMoved.connect(self._on_thumb_panel_resized)\n self.body_widget = self.body_splitter # 兼容引用\n lo.addWidget(self.body_splitter)\n\n # 空状态\n self.empty = QWidget(); self.empty.setObjectName(\"PA\")\n el = QVBoxLayout(self.empty); el.setAlignment(Qt.AlignCenter); el.setSpacing(16)\n el.setContentsMargins(40, 40, 40, 40)\n ic = QLabel(); ic.setFixedSize(64, 64); ic.setAlignment(Qt.AlignCenter)\n ic.setStyleSheet(f\"background:{_C['acc_l']};border-radius:32px;font-size:28px;\")\n ic.setText(\"👀\"); self._empty_icon = ic; el.addWidget(ic, alignment=Qt.AlignCenter)\n m = QLabel(\"翻译完成后在此预览\"); m.setStyleSheet(f\"font-size:15px;font-weight:600;color:{_C['t1']};\"); m.setAlignment(Qt.AlignCenter); el.addWidget(m)\n s = QLabel(\"支持 Dual · Mono · Side by Side 三种模式切换\"); s.setObjectName(\"Cap\"); s.setAlignment(Qt.AlignCenter); el.addWidget(s)\n s2 = QLabel(\"← 也可点击左侧历史记录直接打开\"); s2.setStyleSheet(f\"font-size:11px;color:{_C['t3']};\"); s2.setAlignment(Qt.AlignCenter); el.addWidget(s2)\n self.body_widget.setVisible(False); lo.addWidget(self.empty)\n self._thumb_labels = []\n\n # 滚轮缩放\n self.scroll.wheelEvent = self._wheel_event\n self.cont_scroll.wheelEvent = self._wheel_event\n # macOS 触控板双指缩放:用独立 QObject 做全局 eventFilter(安全)\n self._pinch_accumulator = 0.0\n self._pinch_filter = _PinchFilter(self)\n app = QApplication.instance()\n if app:\n app.installEventFilter(self._pinch_filter)\n\n def _apply_zoom(self, new_zoom):\n \"\"\"统一缩放处理\"\"\"\n self.zoom = new_zoom\n slider_val = int(new_zoom * 100)\n self.zoom_slider.blockSignals(True)\n self.zoom_slider.setValue(min(slider_val, 400))\n self.zoom_slider.blockSignals(False)\n self.zoom_pct.setText(f\"{slider_val}%\")\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n\n def _reset_fit(self):\n \"\"\"还原到当前适配模式的最佳贴合\"\"\"\n if self.doc:\n if self._fit_mode == \"page\":\n self._fit_page()\n else:\n self._fit_width()\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n\n def _wheel_event(self, event):\n \"\"\"滚轮翻页 + Ctrl+滚轮缩放\"\"\"\n mods = event.modifiers()\n\n # Ctrl + 滚轮 = 缩放\n if mods & Qt.ControlModifier:\n delta = event.angleDelta().y()\n step = 0.1 if delta > 0 else -0.1\n self._apply_zoom(max(0.5, min(self.zoom + step, 4.0)))\n return\n\n # 连续滚动模式:自然滚动\n if self._continuous:\n target = self.cont_scroll\n QScrollArea.wheelEvent(target, event)\n return\n\n # 单页模式:到边界翻页\n sb = self.scroll.verticalScrollBar()\n delta = event.angleDelta().y()\n at_bottom = sb.value() >= sb.maximum()\n at_top = sb.value() <= sb.minimum()\n\n if delta < 0 and at_bottom:\n self.next_page()\n elif delta > 0 and at_top:\n self.prev_page()\n else:\n QScrollArea.wheelEvent(self.scroll, event)\n\n def set_output_files(self, files: dict):\n \"\"\"翻译完成后设置三种文件路径\"\"\"\n self.output_files = files\n self.switch_mode(self.current_mode)\n\n def switch_mode(self, mode):\n self.current_mode = mode\n for m, b in self.mode_btns.items():\n b.setProperty(\"active\", m == mode)\n b.style().unpolish(b); b.style().polish(b)\n\n path = self.output_files.get(mode, \"\")\n if path and os.path.exists(path):\n self.load_pdf(path)\n return\n if self.output_files:\n # 回退到有文件的模式\n for fallback in [\"dual\", \"mono\", \"side_by_side\"]:\n fp = self.output_files.get(fallback, \"\")\n if fp and os.path.exists(fp):\n self.load_pdf(fp); break\n\n def load_pdf(self, path):\n try:\n same_file = (path == getattr(self, '_loaded_path', None) and self.doc)\n if not same_file:\n self._loaded_path = path\n self.doc = fitz.open(path)\n self.current_page = 0\n self._last_thumb_vp_w = -1 # 强制首次重建缩略图\n self._last_fit_zoom = -1 # 强制首次渲染\n self.empty.setVisible(False)\n self.body_widget.setVisible(True)\n self.thumb_scroll.setVisible(True)\n # 视口已就绪 → 立即贴合渲染(zoom 未变则跳过)\n vp = self.scroll.viewport() if not self._continuous else self.cont_scroll.viewport()\n if vp.width() >= 100:\n self._fit_and_render()\n else:\n QTimer.singleShot(0, self._fit_and_render)\n # 设置 AI 面板上下文\n if not same_file:\n self.qa_panel.set_paper_context(path)\n except Exception as e:\n self.page_widget.setText(f\"加载失败: {e}\")\n\n def _toggle_qa_panel(self):\n \"\"\"切换 AI 问答面板 — 即时更新布局\"\"\"\n vis = not self.qa_panel.isVisible()\n self.qa_panel.setVisible(vis)\n self.qa_btn.setProperty(\"active\", vis)\n self.qa_btn.style().unpolish(self.qa_btn); self.qa_btn.style().polish(self.qa_btn)\n if vis:\n sizes = self.body_splitter.sizes()\n self.body_splitter.setSizes([sizes[0], max(sizes[1] - 300, 400), 300])\n else:\n sizes = self.body_splitter.sizes()\n self.body_splitter.setSizes([sizes[0], sizes[1] + sizes[2], 0])\n # 立即重新适配渲染,确保 PDF 视图即时伸缩\n QTimer.singleShot(0, self._fit_and_render)\n\n def _toggle_highlight(self):\n \"\"\"切换高亮标注模式\"\"\"\n self._highlight_mode = self.hl_btn.isChecked()\n self._hl_erase_mode = False\n self.hl_btn.setProperty(\"active\", self._highlight_mode)\n self.hl_btn.style().unpolish(self.hl_btn); self.hl_btn.style().polish(self.hl_btn)\n # 显示/隐藏擦除按钮\n self.hl_erase_btn.setVisible(self._highlight_mode)\n if not self._highlight_mode:\n self.hl_erase_btn.setChecked(False)\n self.hl_erase_btn.setProperty(\"active\", False)\n self.hl_erase_btn.style().unpolish(self.hl_erase_btn)\n self.hl_erase_btn.style().polish(self.hl_erase_btn)\n cursor = Qt.CrossCursor if self._highlight_mode else Qt.ArrowCursor\n if self._continuous:\n for w in self._cont_page_widgets:\n w.setCursor(cursor)\n else:\n self.page_widget.setCursor(cursor)\n\n def _toggle_erase(self):\n \"\"\"切换擦除模式(高亮模式的子模式)\"\"\"\n self._hl_erase_mode = self.hl_erase_btn.isChecked()\n self.hl_erase_btn.setProperty(\"active\", self._hl_erase_mode)\n self.hl_erase_btn.style().unpolish(self.hl_erase_btn)\n self.hl_erase_btn.style().polish(self.hl_erase_btn)\n\n # ── 高亮标注 ──\n\n def _hl_mouse_press(self, e, page_idx, label):\n if not getattr(self, '_highlight_mode', False):\n return\n if e.button() == Qt.LeftButton:\n self._hl_start = e.pos()\n self._hl_page_idx = page_idx\n self._hl_label = label\n # 擦除模式:按了擦除按钮 或 按住 Option 键\n is_erase = getattr(self, '_hl_erase_mode', False) or (e.modifiers() & Qt.AltModifier)\n self._hl_dragging_delete = bool(is_erase)\n if is_erase:\n # 红色半透明删除覆盖层\n if not hasattr(self, '_hl_del_overlay') or self._hl_del_overlay is None:\n self._hl_del_overlay = QWidget(label)\n self._hl_del_overlay.setStyleSheet(\n \"background:rgba(255,59,48,0.18);border:1.5px solid rgba(255,59,48,0.6);border-radius:3px;\")\n from PyQt5.QtCore import QRect\n self._hl_del_overlay.setGeometry(QRect(self._hl_start, QSize()))\n self._hl_del_overlay.show()\n self._hl_del_overlay.raise_()\n else:\n # 蓝色添加高亮覆盖层\n if not hasattr(self, '_hl_overlay') or self._hl_overlay is None:\n from PyQt5.QtWidgets import QRubberBand\n self._hl_overlay = QRubberBand(QRubberBand.Rectangle, label)\n from PyQt5.QtCore import QRect\n self._hl_overlay.setGeometry(QRect(self._hl_start, QSize()))\n self._hl_overlay.show()\n\n def _hl_mouse_move(self, e, label):\n if not getattr(self, '_highlight_mode', False) or not hasattr(self, '_hl_start'):\n return\n if self._hl_start is None:\n return\n from PyQt5.QtCore import QRect\n if getattr(self, '_hl_dragging_delete', False):\n if hasattr(self, '_hl_del_overlay') and self._hl_del_overlay:\n self._hl_del_overlay.setGeometry(QRect(self._hl_start, e.pos()).normalized())\n elif hasattr(self, '_hl_overlay') and self._hl_overlay:\n self._hl_overlay.setGeometry(QRect(self._hl_start, e.pos()).normalized())\n\n def _hl_mouse_release(self, e, page_idx, label):\n if not getattr(self, '_highlight_mode', False) or not hasattr(self, '_hl_start'):\n return\n try:\n # 隐藏覆盖层\n if hasattr(self, '_hl_overlay') and self._hl_overlay:\n self._hl_overlay.hide()\n if hasattr(self, '_hl_del_overlay') and self._hl_del_overlay:\n self._hl_del_overlay.hide()\n\n end = e.pos()\n start = self._hl_start\n is_delete = getattr(self, '_hl_dragging_delete', False)\n self._hl_start = None\n self._hl_dragging_delete = False\n\n if not self.doc:\n return\n\n is_small = abs(end.x() - start.x()) < 8 and abs(end.y() - start.y()) < 8\n\n if is_delete:\n # 擦除模式\n if is_small:\n # 小范围点击 → 删除点击处的高亮\n self._hl_delete_at(page_idx, end)\n else:\n # 大范围框选 → 批量删除\n self._hl_delete_in_rect(page_idx, start, end)\n return\n\n # 左键:添加高亮\n if is_small:\n return\n # 屏幕坐标 → PDF 坐标\n x0 = min(start.x(), end.x()) / self.zoom\n y0 = min(start.y(), end.y()) / self.zoom\n x1 = max(start.x(), end.x()) / self.zoom\n y1 = max(start.y(), end.y()) / self.zoom\n sel_rect = fitz.Rect(x0, y0, x1, y1)\n page = self.doc[page_idx]\n # 在选区内查找文字,按文字位置创建高亮\n words = page.get_text(\"words\")\n quads = []\n for w in words:\n wr = fitz.Rect(w[:4])\n if sel_rect.intersects(wr):\n quads.append(wr.quad)\n if not quads:\n return # 选区内无文字(如图片 PDF),不添加\n annot = page.add_highlight_annot(quads=quads)\n annot.set_colors(stroke=(1, 0.85, 0.2))\n try:\n annot.set_opacity(0.35)\n except AttributeError:\n pass # 旧版 PyMuPDF 无此方法\n annot.update()\n self._hl_save_and_render(page_idx)\n except Exception:\n pass # 防止任何异常导致闪退\n\n def _hl_delete_in_rect(self, page_idx, start, end):\n \"\"\"批量删除框选区域内的所有高亮\"\"\"\n try:\n if not self.doc:\n return\n x0 = min(start.x(), end.x()) / self.zoom\n y0 = min(start.y(), end.y()) / self.zoom\n x1 = max(start.x(), end.x()) / self.zoom\n y1 = max(start.y(), end.y()) / self.zoom\n sel_rect = fitz.Rect(x0, y0, x1, y1)\n page = self.doc[page_idx]\n annots = page.annots()\n if not annots:\n return\n # 收集要删除的高亮(不能边遍历边删除)\n to_del = [a for a in annots if a.type[0] == 8 and sel_rect.intersects(a.rect)]\n if not to_del:\n return\n for a in to_del:\n page.delete_annot(a)\n self._hl_save_and_render(page_idx)\n except Exception:\n pass\n\n def _hl_save_and_render(self, page_idx):\n \"\"\"高亮变更后:保存文件 + 重新渲染\"\"\"\n path = getattr(self, '_loaded_path', None)\n if path:\n try:\n self.doc.save(path, incremental=True, encryption=fitz.PDF_ENCRYPT_KEEP)\n except Exception:\n try:\n self.doc.save(path)\n except Exception:\n pass\n if self._continuous:\n self._render_single_cont_page(page_idx)\n else:\n self.render_page()\n\n def _hl_delete_at(self, page_idx, pos):\n \"\"\"删除点击位置的高亮注释\"\"\"\n try:\n if not self.doc:\n return\n page = self.doc[page_idx]\n px, py = pos.x() / self.zoom, pos.y() / self.zoom\n point = fitz.Point(px, py)\n annots = page.annots()\n if not annots:\n return\n for annot in annots:\n if annot.type[0] == 8 and annot.rect.contains(point):\n page.delete_annot(annot)\n self._hl_save_and_render(page_idx)\n return\n except Exception:\n pass\n\n def _render_single_cont_page(self, page_idx):\n \"\"\"重新渲染连续模式中的单个页面\"\"\"\n if page_idx < len(self._cont_page_widgets):\n label = self._cont_page_widgets[page_idx]\n dpr = label.devicePixelRatioF() if hasattr(label, 'devicePixelRatioF') else 1.0\n rz = self.zoom * dpr\n page = self.doc[page_idx]\n pix = page.get_pixmap(matrix=fitz.Matrix(rz, rz))\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n pm = QPixmap.fromImage(img)\n pm.setDevicePixelRatio(dpr)\n label.setPixmap(pm)\n label.setFixedSize(pm.size() / dpr)\n\n def _install_highlight_events(self, label, page_idx):\n \"\"\"给页面 widget 安装高亮鼠标事件\n 高亮模式:左键拖拽添加 | 擦除模式/Option+拖拽:框选批量删除\n \"\"\"\n orig_press = label.mousePressEvent\n orig_move = label.mouseMoveEvent\n orig_release = label.mouseReleaseEvent\n def _press(e):\n if getattr(self, '_highlight_mode', False):\n self._hl_mouse_press(e, page_idx, label)\n else:\n orig_press(e)\n def _move(e):\n if getattr(self, '_highlight_mode', False):\n self._hl_mouse_move(e, label)\n else:\n orig_move(e)\n def _release(e):\n if getattr(self, '_highlight_mode', False):\n self._hl_mouse_release(e, page_idx, label)\n else:\n orig_release(e)\n label.mousePressEvent = _press\n label.mouseMoveEvent = _move\n label.mouseReleaseEvent = _release\n\n def _hl_show_delete_menu(self, e, page_idx, label):\n \"\"\"右键菜单:删除点击位置的高亮\"\"\"\n if not self.doc:\n return\n page = self.doc[page_idx]\n px, py = e.pos().x() / self.zoom, e.pos().y() / self.zoom\n point = fitz.Point(px, py)\n # 检查是否有高亮在此位置\n found = False\n for annot in page.annots():\n if annot.type[0] == 8 and annot.rect.contains(point):\n found = True; break\n if not found:\n return\n menu = _RoundMenu(label)\n act = menu.addAction(\"删除高亮\")\n act.triggered.connect(lambda: self._hl_delete_at(page_idx, e.pos()))\n menu.exec_(label.mapToGlobal(e.pos()))\n\n def _fit_and_render(self):\n \"\"\"统一:计算贴合 + 渲染(只在尺寸真正变化时才重新渲染)\"\"\"\n if not self.doc:\n return\n # 缩略图面板宽度变了 → 重建缩略图\n cur_tw = self.thumb_scroll.viewport().width() if self.thumb_scroll.isVisible() else 0\n if cur_tw > 40 and cur_tw != getattr(self, '_last_thumb_vp_w', 0):\n self._last_thumb_vp_w = cur_tw\n self._build_thumbnails()\n # 计算 zoom,只有变了才重新渲染(避免重复工作)\n old_zoom = self.zoom\n if self._fit_mode == \"page\":\n self._fit_page()\n else:\n self._fit_width()\n if self.zoom != getattr(self, '_last_fit_zoom', -1):\n self._last_fit_zoom = self.zoom\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n\n def _build_thumbnails(self):\n \"\"\"生成所有页面的缩略图\"\"\"\n # 清空旧缩略图\n for lbl in self._thumb_labels:\n lbl.setParent(None)\n self._thumb_labels = []\n\n if not self.doc:\n return\n\n dpr = QApplication.instance().devicePixelRatio() if QApplication.instance() else 2.0\n # 缩略图宽度 = 面板宽度 - 左右 margin(4+4) - border(2+2) - 滚动条余量\n panel_w = self.thumb_scroll.viewport().width() if self.thumb_scroll.viewport().width() > 40 else self.thumb_scroll.width()\n thumb_w = max(50, panel_w - 12)\n\n for i in range(len(self.doc)):\n page = self.doc[i]\n # 计算缩略图缩放比例\n scale = (thumb_w * dpr) / page.rect.width\n mat = fitz.Matrix(scale, scale)\n pix = page.get_pixmap(matrix=mat, alpha=False)\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n qpx = QPixmap.fromImage(img)\n qpx.setDevicePixelRatio(dpr)\n\n lbl = QLabel()\n lbl.setPixmap(qpx)\n lbl.setFixedSize(int(pix.width / dpr) + 4, int(pix.height / dpr) + 4)\n lbl.setAlignment(Qt.AlignCenter)\n lbl.setStyleSheet(\n \"border:2px solid transparent;border-radius:4px;padding:1px;\"\n \"background:transparent;\"\n )\n lbl.setCursor(Qt.PointingHandCursor)\n # 点击跳转\n page_idx = i\n lbl.mousePressEvent = lambda e, idx=page_idx: self._thumb_clicked(idx)\n\n self.thumb_layout.addWidget(lbl, alignment=Qt.AlignHCenter)\n self._thumb_labels.append(lbl)\n\n self.thumb_layout.addStretch()\n self._highlight_thumb(0)\n\n def _thumb_clicked(self, idx):\n self.current_page = idx\n if self._continuous and idx < len(self._cont_page_widgets):\n self.cont_scroll.ensureWidgetVisible(self._cont_page_widgets[idx], 0, 0)\n self._update_page_display()\n self._highlight_thumb(idx)\n else:\n self.render_page()\n self.setFocus()\n\n def _highlight_thumb(self, idx):\n \"\"\"高亮当前页缩略图,颜色由 _thumb_color 控制\"\"\"\n color = getattr(self, '_thumb_color', _C['acc'])\n for i, lbl in enumerate(self._thumb_labels):\n if i == idx:\n lbl.setStyleSheet(\n f\"border:2px solid {color};border-radius:4px;padding:1px;\"\n \"background:transparent;\"\n )\n else:\n lbl.setStyleSheet(\n \"border:2px solid transparent;border-radius:4px;padding:1px;\"\n \"background:transparent;\"\n )\n if idx < len(self._thumb_labels):\n self.thumb_scroll.ensureWidgetVisible(self._thumb_labels[idx])\n\n def _fit_width(self):\n \"\"\"自动计算缩放比例 — 所有模式统一 fit width\"\"\"\n if not self.doc or len(self.doc) == 0:\n return\n page = self.doc[0]\n pw = page.rect.width\n vp = self.scroll.viewport() if not self._continuous else self.cont_scroll.viewport()\n avail_w = vp.width() - 8\n if avail_w < 100 or pw <= 0:\n return\n optimal_zoom = avail_w / pw\n optimal_zoom = max(0.3, min(optimal_zoom, 3.0))\n self.zoom = optimal_zoom\n slider_val = int(optimal_zoom * 100)\n self.zoom_slider.blockSignals(True)\n self.zoom_slider.setValue(slider_val)\n self.zoom_slider.blockSignals(False)\n self.zoom_pct.setText(f\"{slider_val}%\")\n\n def _fit_page(self):\n \"\"\"自动计算缩放比例 — 整页适配 (宽高都不超出视口)\"\"\"\n if not self.doc or len(self.doc) == 0:\n return\n page = self.doc[0]\n pw, ph = page.rect.width, page.rect.height\n vp = self.scroll.viewport() if not self._continuous else self.cont_scroll.viewport()\n avail_w = vp.width() - 8\n avail_h = vp.height() - 8\n if avail_w < 100 or avail_h < 100 or pw <= 0 or ph <= 0:\n return\n zoom_w = avail_w / pw\n zoom_h = avail_h / ph\n optimal_zoom = min(zoom_w, zoom_h)\n optimal_zoom = max(0.3, min(optimal_zoom, 3.0))\n self.zoom = optimal_zoom\n slider_val = int(optimal_zoom * 100)\n self.zoom_slider.blockSignals(True)\n self.zoom_slider.setValue(slider_val)\n self.zoom_slider.blockSignals(False)\n self.zoom_pct.setText(f\"{slider_val}%\")\n\n def _switch_to_fit_width(self):\n \"\"\"切换到适宽模式\"\"\"\n self._fit_mode = \"width\"\n self.fit_btn.setProperty(\"active\", True)\n self.fit_btn.style().unpolish(self.fit_btn); self.fit_btn.style().polish(self.fit_btn)\n self.fitpage_btn.setProperty(\"active\", False)\n self.fitpage_btn.style().unpolish(self.fitpage_btn); self.fitpage_btn.style().polish(self.fitpage_btn)\n self._reset_fit()\n\n def _switch_to_fit_page(self):\n \"\"\"切换到适页模式\"\"\"\n self._fit_mode = \"page\"\n self.fitpage_btn.setProperty(\"active\", True)\n self.fitpage_btn.style().unpolish(self.fitpage_btn); self.fitpage_btn.style().polish(self.fitpage_btn)\n self.fit_btn.setProperty(\"active\", False)\n self.fit_btn.style().unpolish(self.fit_btn); self.fit_btn.style().polish(self.fit_btn)\n self._reset_fit()\n\n def render_page(self):\n if not self.doc or self.current_page >= len(self.doc): return\n pg = self.doc[self.current_page]\n # Retina: 渲染 2x 分辨率再缩回,保证清晰\n dpr = QApplication.instance().devicePixelRatio() if QApplication.instance() else 2.0\n render_zoom = self.zoom * dpr\n mat = fitz.Matrix(render_zoom, render_zoom)\n pix = pg.get_pixmap(matrix=mat, alpha=False)\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n qpix = QPixmap.fromImage(img)\n qpix.setDevicePixelRatio(dpr)\n self.page_widget.set_pixmap(qpix)\n # setFixedSize 用逻辑尺寸(物理尺寸 / dpr)\n self.page_widget.setFixedSize(int(pix.width / dpr), int(pix.height / dpr))\n self._install_highlight_events(self.page_widget, self.current_page)\n self._update_page_display()\n self._highlight_thumb(self.current_page)\n self.scroll.verticalScrollBar().setValue(0)\n\n def first_page(self):\n if self.doc:\n self.current_page = 0; self.render_page()\n def prev_page(self):\n if self.doc and self.current_page > 0:\n self.current_page -= 1; self.render_page()\n def next_page(self):\n if self.doc and self.current_page < len(self.doc) - 1:\n self.current_page += 1; self.render_page()\n def last_page(self):\n if self.doc:\n self.current_page = len(self.doc) - 1; self.render_page()\n def _jump_to_page(self):\n \"\"\"页码输入框回车跳转,超范围闪红提示\"\"\"\n try:\n n = int(self.page_input.text()) - 1\n if self.doc and 0 <= n < len(self.doc):\n self.current_page = n; self.render_page()\n return\n except ValueError:\n pass\n # 输入无效:闪红 + 重置\n self.page_input.setStyleSheet(f\"border:1.5px solid {_C['err']};border-radius:6px;\")\n self.page_input.setText(str(self.current_page + 1) if self.doc else \"\")\n QTimer.singleShot(800, lambda: self.page_input.setStyleSheet(\"\"))\n def _update_page_display(self):\n total = len(self.doc) if self.doc else 0\n self.page_input.setText(str(self.current_page + 1) if total else \"\")\n self.total_label.setText(f\"/ {total}\" if total else \"/ —\")\n def on_zoom(self, v):\n self.zoom = v / 100.0; self.zoom_pct.setText(f\"{v}%\")\n if self.doc:\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n def open_file(self):\n p, _ = QFileDialog.getOpenFileName(self, \"选择 PDF\", \"\", \"PDF (*.pdf)\")\n if p: self.load_pdf(p)\n def contextMenuEvent(self, event):\n if not self.doc:\n return\n menu = _RoundMenu(self)\n menu.addAction(\"打开其他 PDF…\", self.open_file)\n menu.addSeparator()\n menu.addAction(\"放大\", lambda: self._apply_zoom(min(self.zoom + 0.1, 4.0)))\n menu.addAction(\"缩小\", lambda: self._apply_zoom(max(self.zoom - 0.1, 0.3)))\n menu.addAction(\"适合宽度\", self._switch_to_fit_width)\n menu.addAction(\"适合页面\", self._switch_to_fit_page)\n menu.addSeparator()\n lp = getattr(self, '_loaded_path', None)\n if lp and os.path.exists(lp):\n menu.addAction(\"在 Finder 中显示\",\n lambda: __import__(\"subprocess\").Popen([\"open\", \"-R\", lp]))\n menu.exec_(event.globalPos())\n def _toggle_fullscreen(self):\n self._is_fullscreen = not self._is_fullscreen\n self.fs_btn.setText(\"退出\" if self._is_fullscreen else \"全屏\")\n self.fs_btn.setProperty(\"active\", self._is_fullscreen)\n self.fs_btn.style().unpolish(self.fs_btn); self.fs_btn.style().polish(self.fs_btn)\n self.hist_btn.setVisible(self._is_fullscreen)\n self.fullscreen_toggled.emit(self._is_fullscreen)\n QTimer.singleShot(80, self._on_resize_done)\n\n def _on_thumb_panel_resized(self):\n \"\"\"拖拽分割线后重建缩略图 + 重新适配 PDF 视图\"\"\"\n if not hasattr(self, '_thumb_resize_timer'):\n self._thumb_resize_timer = QTimer(); self._thumb_resize_timer.setSingleShot(True)\n self._thumb_resize_timer.timeout.connect(self._rebuild_thumbs_fit)\n self._thumb_resize_timer.start(200)\n # 立即适配 PDF 视图大小(QA 面板伸缩时也需要)\n if not hasattr(self, '_fit_timer'):\n self._fit_timer = QTimer(); self._fit_timer.setSingleShot(True)\n self._fit_timer.timeout.connect(self._fit_and_render)\n self._fit_timer.start(50)\n\n def _rebuild_thumbs_fit(self):\n if self.doc and self.thumb_scroll.isVisible():\n self._build_thumbnails()\n\n def keyPressEvent(self, event):\n if event.key() == Qt.Key_Escape and self._is_fullscreen:\n self._toggle_fullscreen(); return\n if event.key() == Qt.Key_Up:\n self.prev_page()\n elif event.key() == Qt.Key_Down:\n self.next_page()\n elif event.key() == Qt.Key_Home:\n self.first_page()\n elif event.key() == Qt.Key_End:\n self.last_page()\n else:\n super().keyPressEvent(event)\n def mousePressEvent(self, event):\n \"\"\"点击预览区域获取焦点,让键盘事件生效\"\"\"\n self.setFocus()\n super().mousePressEvent(event)\n\n def resizeEvent(self, event):\n \"\"\"窗口大小变化时重新适配\"\"\"\n super().resizeEvent(event)\n if self.doc:\n from PyQt5.QtCore import QTimer\n # 用定时器防止频繁重渲染\n if not hasattr(self, '_resize_timer'):\n self._resize_timer = QTimer(); self._resize_timer.setSingleShot(True)\n self._resize_timer.timeout.connect(self._on_resize_done)\n self._resize_timer.start(100)\n\n def _on_resize_done(self):\n self._fit_and_render()\n\n # ── 连续滚动模式 ──\n def _toggle_continuous(self):\n self._continuous = not self._continuous\n self.scroll_btn.setProperty(\"active\", self._continuous)\n self.scroll_btn.style().unpolish(self.scroll_btn)\n self.scroll_btn.style().polish(self.scroll_btn)\n self.scroll.setVisible(not self._continuous)\n self.cont_scroll.setVisible(self._continuous)\n if self.doc:\n self._fit_and_render()\n # 延迟再算一次,等 scroll 显隐切换布局稳定\n QTimer.singleShot(0, self._fit_and_render)\n\n def _render_continuous(self):\n \"\"\"分帧渲染所有页面到连续滚动容器(避免大文件卡 UI)\"\"\"\n if not self.doc:\n return\n # 清空旧内容\n for w in self._cont_page_widgets:\n w.setParent(None)\n self._cont_page_widgets = []\n self._cont_render_idx = 0\n self._cont_render_dpr = QApplication.instance().devicePixelRatio() if QApplication.instance() else 2.0\n\n # 先放置占位标签\n total = len(self.doc)\n for i in range(total):\n lbl = QLabel(\"加载中…\" if i == 0 else \"\")\n lbl.setAlignment(Qt.AlignCenter)\n lbl.setStyleSheet(f\"background:transparent;color:{_C['t3']};font-size:11px;min-height:200px;\")\n self.cont_layout.addWidget(lbl, alignment=Qt.AlignHCenter)\n self._cont_page_widgets.append(lbl)\n self._update_page_display()\n\n # 分帧渲染:每帧渲染 2 页\n self._render_batch_continuous()\n\n def _render_batch_continuous(self):\n \"\"\"每次渲染 2 页,释放 UI 事件循环\"\"\"\n if not self.doc:\n return\n idx = getattr(self, '_cont_render_idx', 0)\n total = len(self.doc)\n dpr = self._cont_render_dpr\n batch = 2 # 每帧渲染页数\n for _ in range(batch):\n if idx >= total:\n return\n pg = self.doc[idx]\n render_zoom = self.zoom * dpr\n mat = fitz.Matrix(render_zoom, render_zoom)\n pix = pg.get_pixmap(matrix=mat, alpha=False)\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n qpix = QPixmap.fromImage(img)\n qpix.setDevicePixelRatio(dpr)\n lbl = self._cont_page_widgets[idx]\n lbl.setPixmap(qpix)\n lbl.setFixedSize(int(pix.width / dpr), int(pix.height / dpr))\n lbl.setStyleSheet(\"background:transparent;\")\n self._install_highlight_events(lbl, idx)\n idx += 1\n self._cont_render_idx = idx\n if idx < total:\n QTimer.singleShot(0, self._render_batch_continuous)\n\n def _on_cont_scroll(self, value):\n \"\"\"连续滚动时更新页码和缩略图高亮\"\"\"\n if not self._continuous or not self._cont_page_widgets:\n return\n vp_center = value + self.cont_scroll.viewport().height() // 2\n for i, w in enumerate(self._cont_page_widgets):\n wy = w.y()\n if wy <= vp_center <= wy + w.height():\n if self.current_page != i:\n self.current_page = i\n self._update_page_display()\n self._highlight_thumb(i)\n break\n\n def update_theme(self, c):\n \"\"\"深色/浅色切换时更新内联样式\"\"\"\n self._empty_icon.setStyleSheet(f\"background:{c['acc_l']};border-radius:32px;font-size:28px;\")\n self._thumb_color = c['acc']\n if self._thumb_labels:\n self._highlight_thumb(self.current_page)\n # 更新 AI 面板\n self.qa_panel.update_theme(c)\n\n\n# ═══════════════════════════════════════════════════════════════\n# 翻译页面 — 全功能\n# ═══════════════════════════════════════════════════════════════\n\nclass TranslatePage(QWidget):\n translation_done = pyqtSignal(dict) # {\"mono\":..., \"dual\":..., \"side_by_side\":...}\n\n def __init__(self):\n super().__init__()\n self.worker = None\n self.pending_files = []\n self.cfg = UserConfigManager.load()\n\n scroll = QScrollArea(); scroll.setWidgetResizable(True); scroll.setFrameShape(QFrame.NoFrame)\n scroll.setObjectName(\"PA\")\n content = QWidget(); content.setObjectName(\"PA\")\n outer_lo = QVBoxLayout(content); outer_lo.setContentsMargins(0,0,0,0)\n outer_lo.addStretch(1) # 上部弹性 — 垂直居中\n\n inner = QWidget()\n lo = QVBoxLayout(inner); lo.setContentsMargins(40,0,40,0); lo.setSpacing(12)\n\n # 标题 + 四叶草徽章 + 人文关怀\n hdr = QHBoxLayout()\n t = QLabel(\"翻译\"); t.setObjectName(\"PT0\"); hdr.addWidget(t)\n self.clover_label = QLabel(\"\")\n self.clover_label.setStyleSheet(\"font-size:16px;background:transparent;\")\n hdr.addWidget(self.clover_label)\n hdr.addStretch()\n # 尝试显示关怀消息,否则显示默认副标题\n _subtitle = \"拖入学术 PDF,保留公式与原始排版\"\n try:\n from ui.caring import get_caring_message\n _cm = get_caring_message()\n if _cm:\n _subtitle = f\"{_cm[0]} {_cm[2]}\"\n except Exception:\n pass\n st = QLabel(_subtitle); st.setObjectName(\"PT1\"); hdr.addWidget(st)\n lo.addLayout(hdr)\n\n # 拖拽区(内含文件列表,高度固定,不影响下方布局)\n self.drop = DropZone(); self.drop.files_dropped.connect(self.on_files_added)\n self.drop._add_btn.clicked.connect(self._browse_more)\n self.drop._del_btn.clicked.connect(self._remove_selected_files)\n self.drop._clr_btn.clicked.connect(self._clear_files)\n lo.addWidget(self.drop)\n # 引用 DropZone 内嵌的控件\n self.flist = self.drop.flist\n self._fcount_label = self.drop._fcount_label\n self._zotero_hint = self.drop._zotero_hint\n # 文件列表右键菜单\n self.flist.setContextMenuPolicy(Qt.CustomContextMenu)\n self.flist.customContextMenuRequested.connect(self._flist_context_menu)\n\n # ── 配置卡片 ──\n card = _card()\n cl = QVBoxLayout(card); cl.setContentsMargins(16,12,16,12); cl.setSpacing(0)\n\n # 语言行\n r1 = QHBoxLayout(); r1.setSpacing(12)\n for label, items, attr, default in [\n (\"源语言\", list(LANG_MAP.keys()), \"src_combo\", self.cfg.get(\"lang_in\",\"自动检测\")),\n (\"目标语言\", list(LANG_MAP.keys()), \"tgt_combo\", self.cfg.get(\"lang_out\",\"中文(简体)\")),\n ]:\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(label); lb.setObjectName(\"FL\"); col.addWidget(lb)\n cb = QComboBox(); cb.addItems(items); cb.setMinimumWidth(160)\n idx = cb.findText(default)\n if idx >= 0: cb.setCurrentIndex(idx)\n col.addWidget(cb); r1.addLayout(col)\n setattr(self, attr, cb)\n # 修 #24:切换源/目标语言后立即持久化,即使用户没点开始翻译退出 app 也不丢\n self.src_combo.currentTextChanged.connect(lambda _: self._save_langs_now())\n self.tgt_combo.currentTextChanged.connect(lambda _: self._save_langs_now())\n r1.addStretch()\n cl.addLayout(r1)\n cl.addSpacing(8); cl.addWidget(_div()); cl.addSpacing(8)\n\n # 服务 + 格式 + 页码\n r2 = QHBoxLayout(); r2.setSpacing(12)\n # 翻译服务\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"翻译服务\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.svc_combo = QComboBox(); self.svc_combo.addItems(list(SERVICE_MAP.keys()))\n self.svc_combo.setMinimumWidth(180)\n saved_svc = self.cfg.get(\"service\", \"Bing 翻译\")\n idx = self.svc_combo.findText(saved_svc)\n if idx >= 0: self.svc_combo.setCurrentIndex(idx)\n col.addWidget(self.svc_combo); r2.addLayout(col)\n\n # 页码范围\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"页码范围\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.page_combo = QComboBox(); self.page_combo.addItems(list(PAGE_PRESETS.keys()))\n self.page_combo.setMinimumWidth(120)\n self.page_combo.currentTextChanged.connect(self._on_page_changed)\n col.addWidget(self.page_combo); r2.addLayout(col)\n\n # 自定义页码输入\n self.custom_page = QLineEdit(); self.custom_page.setPlaceholderText(\"例: 1-5, 8, 10-12\")\n self.custom_page.setVisible(False); self.custom_page.setFixedWidth(160)\n r2.addWidget(self.custom_page)\n\n r2.addStretch()\n cl.addLayout(r2)\n cl.addSpacing(8); cl.addWidget(_div()); cl.addSpacing(8)\n\n # 输出格式 + 线程\n r3 = QHBoxLayout(); r3.setSpacing(12)\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"输出格式\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.fmt_combo = QComboBox(); self.fmt_combo.addItems(list(OUTPUT_MODES.keys()))\n _saved_fmt = self.cfg.get(\"output_format\", \"左右并排 (Side by Side)\")\n _fmt_idx = self.fmt_combo.findText(_saved_fmt)\n self.fmt_combo.setCurrentIndex(_fmt_idx if _fmt_idx >= 0 else 2)\n self.fmt_combo.setMinimumWidth(180)\n col.addWidget(self.fmt_combo); r3.addLayout(col)\n\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"线程数\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.thread_spin = QSpinBox(); self.thread_spin.setRange(1,32)\n self.thread_spin.setValue(self.cfg.get(\"thread_count\", 8))\n col.addWidget(self.thread_spin); r3.addLayout(col)\n\n r3.addStretch()\n cl.addLayout(r3)\n cl.addSpacing(8); cl.addWidget(_div()); cl.addSpacing(8)\n\n # 分块翻译\n r4 = QHBoxLayout(); r4.setSpacing(16)\n self.chunk_check = QCheckBox(\"分块翻译(大文件推荐)\")\n self.chunk_check.setChecked(self.cfg.get(\"chunk_enabled\", False))\n self.chunk_check.toggled.connect(self._on_chunk_toggled)\n r4.addWidget(self.chunk_check)\n\n self.chunk_size_label = QLabel(\"每块\"); self.chunk_size_label.setObjectName(\"Cap\")\n self.chunk_size_spin = QSpinBox(); self.chunk_size_spin.setRange(5,200)\n self.chunk_size_spin.setValue(self.cfg.get(\"chunk_size\", 50))\n self.chunk_size_unit = QLabel(\"页\"); self.chunk_size_unit.setObjectName(\"Cap\")\n\n self.chunk_delay_label = QLabel(\"间隔\"); self.chunk_delay_label.setObjectName(\"Cap\")\n self.chunk_delay_spin = QSpinBox(); self.chunk_delay_spin.setRange(0,120)\n self.chunk_delay_spin.setValue(self.cfg.get(\"chunk_delay\", 10))\n self.chunk_delay_unit = QLabel(\"秒\"); self.chunk_delay_unit.setObjectName(\"Cap\")\n\n for w in [self.chunk_size_label, self.chunk_size_spin, self.chunk_size_unit,\n self.chunk_delay_label, self.chunk_delay_spin, self.chunk_delay_unit]:\n r4.addWidget(w)\n w.setVisible(self.chunk_check.isChecked())\n\n r4.addStretch()\n cl.addLayout(r4)\n\n cl.addSpacing(6)\n # 高级选项:扫描版 / 表格翻译 / OCR(参考 Win 端实现)\n r5 = QHBoxLayout(); r5.setSpacing(16)\n self.scan_mode_check = QCheckBox(\"扫描版 PDF\")\n self.scan_mode_check.setToolTip(\"扫描版 PDF 的文字嵌在图片中,勾选后会在译文区域覆盖底图原文。\\n普通数字 PDF 请勿勾选。\")\n r5.addWidget(self.scan_mode_check)\n self.translate_tables_check = QCheckBox(\"翻译表格内容\")\n self.translate_tables_check.setToolTip(\"按单元格翻译表格,适合专利、检测报告等表格密集型文件。\")\n r5.addWidget(self.translate_tables_check)\n self.ocr_mode_check = QCheckBox(\"OCR 识别\")\n self.ocr_mode_check.setToolTip(\"对纯图片扫描件先进行 OCR 文字识别再翻译。\\n需要额外处理时间,普通 PDF 请勿勾选。\")\n r5.addWidget(self.ocr_mode_check)\n r5.addStretch()\n cl.addLayout(r5)\n\n # 自动检测提示行\n self._scan_hint = QLabel(\"\")\n self._scan_hint.setObjectName(\"Cap\")\n self._scan_hint.setVisible(False)\n cl.addWidget(self._scan_hint)\n\n lo.addWidget(card)\n\n # ── 进度卡片(固定高度占位,避免布局跳动) ──\n self._prog_holder = QWidget()\n self._prog_holder.setFixedHeight(130)\n ph_lo = QVBoxLayout(self._prog_holder)\n ph_lo.setContentsMargins(0,0,0,0); ph_lo.setSpacing(0)\n\n self.prog_card = _card(\"lg\")\n self.prog_card.setVisible(False)\n pc_lo = QVBoxLayout(self.prog_card)\n pc_lo.setContentsMargins(24, 14, 24, 14)\n pc_lo.setSpacing(8)\n\n row1 = QHBoxLayout(); row1.setSpacing(12)\n self.prog_icon = QLabel(\"⏳\"); self.prog_icon.setObjectName(\"ProgIcon\")\n row1.addWidget(self.prog_icon)\n self.prog_label = QLabel(\"正在翻译…\"); self.prog_label.setObjectName(\"ProgLabel\")\n row1.addWidget(self.prog_label)\n row1.addStretch()\n self.prog_pct = QLabel(\"0%\"); self.prog_pct.setObjectName(\"ProgPct\")\n row1.addWidget(self.prog_pct)\n pc_lo.addLayout(row1)\n\n self.prog_bar = QProgressBar(); self.prog_bar.setRange(0, 100)\n self.prog_bar.setObjectName(\"ProgBar\")\n pc_lo.addWidget(self.prog_bar)\n\n self.prog_tip = QLabel(\"\"); self.prog_tip.setObjectName(\"ProgTip\")\n pc_lo.addWidget(self.prog_tip)\n\n row2 = QHBoxLayout(); row2.setSpacing(10)\n self.prog_detail = QLabel(\"\"); self.prog_detail.setObjectName(\"ProgDetail\")\n row2.addWidget(self.prog_detail)\n row2.addStretch()\n self.stop_btn = QPushButton(\"取消\")\n self.stop_btn.setObjectName(\"ProgCancel\")\n self.stop_btn.setCursor(Qt.PointingHandCursor)\n self.stop_btn.clicked.connect(self._cancel)\n row2.addWidget(self.stop_btn)\n pc_lo.addLayout(row2)\n\n ph_lo.addWidget(self.prog_card)\n lo.addWidget(self._prog_holder)\n\n # ── 开始按钮 ──\n br = QHBoxLayout(); br.setSpacing(10)\n br.addStretch()\n self.go_btn = QPushButton(\"开始翻译\"); self.go_btn.setObjectName(\"Pr\")\n self.go_btn.setFixedWidth(200); self.go_btn.setFixedHeight(48)\n self.go_btn.setEnabled(False)\n self.go_btn.clicked.connect(self._start)\n br.addWidget(self.go_btn)\n self.dice_btn = QPushButton(\"🎲\")\n self.dice_btn.setCursor(Qt.PointingHandCursor)\n self.dice_btn.setStyleSheet(\"font-size:18px;padding:6px;border:none;background:transparent;\")\n self.dice_btn.clicked.connect(self._dice_game)\n br.addWidget(self.dice_btn)\n br.addStretch()\n lo.addLayout(br)\n\n outer_lo.addWidget(inner)\n outer_lo.addStretch(2) # 下部弹性稍大 — 视觉重心偏上\n\n self._update_dice() # 初始化骰子状态\n scroll.setWidget(content)\n page_lo = QVBoxLayout(self); page_lo.setContentsMargins(0,0,0,0); page_lo.addWidget(scroll)\n\n def _on_page_changed(self, text):\n self.custom_page.setVisible(text == \"自定义\")\n\n def _on_chunk_toggled(self, checked):\n for w in [self.chunk_size_label, self.chunk_size_spin, self.chunk_size_unit,\n self.chunk_delay_label, self.chunk_delay_spin, self.chunk_delay_unit]:\n w.setVisible(checked)\n\n def on_files_added(self, files):\n new_files = []\n for f in files:\n if not os.path.isfile(f):\n continue\n # 去重\n exists = False\n for i in range(self.flist.count()):\n if self.flist.item(i).data(Qt.UserRole) == f:\n exists = True; break\n if exists:\n continue\n new_files.append(f)\n name = os.path.basename(f)\n if len(name) > 50:\n name = name[:22] + \"…\" + name[-22:]\n sz = os.path.getsize(f)\n s = f\"{sz/1048576:.1f} MB\" if sz > 1048576 else f\"{sz/1024:.0f} KB\"\n item = QListWidgetItem(f\" 📄 {name} {s}\")\n item.setData(Qt.UserRole, f)\n self.flist.addItem(item)\n self._update_fcount()\n self._check_zotero_source()\n if new_files:\n self._auto_detect_pdf_type(new_files)\n\n def _flist_context_menu(self, pos):\n item = self.flist.itemAt(pos)\n menu = _RoundMenu(self)\n menu.addAction(\"添加文件…\", self._browse_more)\n if item:\n fp = item.data(Qt.UserRole)\n menu.addSeparator()\n menu.addAction(\"在 Finder 中显示\", lambda: self._reveal_in_finder(fp))\n menu.addAction(\"移除\", lambda: self._remove_item(item))\n if self.flist.count() > 0:\n menu.addSeparator()\n menu.addAction(\"清空全部\", self._clear_files)\n menu.exec_(self.flist.viewport().mapToGlobal(pos))\n\n def _reveal_in_finder(self, path):\n import subprocess\n subprocess.Popen([\"open\", \"-R\", path])\n\n def _remove_item(self, item):\n self.flist.takeItem(self.flist.row(item))\n self._update_fcount(); self._check_zotero_source()\n self._scan_hint.setVisible(False)\n\n def _browse_more(self):\n fs, _ = QFileDialog.getOpenFileNames(self, \"选择 PDF\", \"\", \"PDF (*.pdf)\")\n if fs: self.on_files_added(fs)\n\n def _update_fcount(self):\n n = self.flist.count()\n if n > 0:\n self.drop._stack.setCurrentIndex(1) # 切换到文件列表\n self.go_btn.setEnabled(True)\n self._fcount_label.setText(f\"共 {n} 个文件\")\n else:\n self.drop._stack.setCurrentIndex(0) # 切换回拖拽提示\n self.go_btn.setEnabled(False)\n self._fcount_label.setText(\"\")\n\n def _check_zotero_source(self):\n has_zotero = False\n for i in range(self.flist.count()):\n p = self.flist.item(i).data(Qt.UserRole)\n if detect_zotero_source(p):\n has_zotero = True\n break\n self._zotero_hint.setVisible(has_zotero)\n\n def _auto_detect_pdf_type(self, new_files):\n \"\"\"检测新加入的 PDF:\n - 纯扫描件(无文字层)→ 自动开启 OCR\n - 扫描+OCR 层(如日文教材)→ 仅作为温和提示,不自动勾选扫描模式\n (scan_mode 会在段落间留白处露出原图,整体观感未必更好,留给用户决定)\n \"\"\"\n need_ocr = False\n suggest_scan = False\n\n for path in new_files:\n try:\n doc = fitz.open(path)\n n = doc.page_count\n if n == 0:\n doc.close()\n continue\n check_pages = min(n, 10)\n text_pages = 0\n scan_pages = 0 # 有覆盖 >50% 页面的大图\n invisible_pages = 0 # 含不可见 OCR 文字层(Tr 3)\n for i in range(check_pages):\n page = doc[i]\n if page.get_text().strip():\n text_pages += 1\n page_area = page.rect.width * page.rect.height\n if page_area > 0:\n for info in page.get_image_info():\n bbox = info.get(\"bbox\", (0, 0, 0, 0))\n img_area = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])\n if img_area > page_area * 0.5:\n scan_pages += 1\n break\n try:\n contents = page.read_contents() or b\"\"\n if b\" 3 Tr\" in contents or b\"\\n3 Tr\" in contents:\n invisible_pages += 1\n except Exception:\n pass\n doc.close()\n\n if text_pages == 0:\n need_ocr = True\n elif invisible_pages > check_pages * 0.3 or scan_pages > check_pages * 0.3:\n suggest_scan = True\n except Exception:\n pass\n\n hints = []\n if need_ocr and not self.ocr_mode_check.isChecked():\n self.ocr_mode_check.setChecked(True)\n hints.append(\"检测到纯扫描件,已自动开启 OCR\")\n if suggest_scan and not self.scan_mode_check.isChecked():\n # 仅提示,不自动勾选\n hints.append(\"检测到扫描版 PDF(含底图),如需去除背景文字可手动勾选扫描模式\")\n\n if hints:\n acc = _C[\"acc\"]\n self._scan_hint.setStyleSheet(f\"color:{acc}; padding-left:2px;\")\n self._scan_hint.setText(\" \".join(hints))\n self._scan_hint.setVisible(True)\n else:\n self._scan_hint.setVisible(False)\n\n def _remove_selected_files(self):\n for item in reversed(self.flist.selectedItems()):\n self.flist.takeItem(self.flist.row(item))\n self._update_fcount()\n self._check_zotero_source()\n self._scan_hint.setVisible(False)\n\n def _clear_files(self):\n self.flist.clear()\n self._update_fcount()\n self._zotero_hint.setVisible(False)\n self._scan_hint.setVisible(False)\n\n def _get_pages(self):\n preset = self.page_combo.currentText()\n if preset == \"自定义\":\n txt = self.custom_page.text().strip()\n return parse_page_range(txt) if txt else None\n return PAGE_PRESETS.get(preset)\n\n def _save_langs_now(self):\n \"\"\"修 #24:切换源/目标语言时立即持久化到 config.json,独立于 _save_config\n (只写 lang_in / lang_out 两个字段,避免读取尚未初始化的其他控件)\"\"\"\n try:\n cfg = UserConfigManager.load() or {}\n cfg[\"lang_in\"] = self.src_combo.currentText()\n cfg[\"lang_out\"] = self.tgt_combo.currentText()\n UserConfigManager.save(cfg)\n # 同步到内存 cfg,避免下次 _save_config 时被旧值覆盖\n self.cfg[\"lang_in\"] = cfg[\"lang_in\"]\n self.cfg[\"lang_out\"] = cfg[\"lang_out\"]\n except Exception:\n pass # 保存失败不影响翻译主流程\n\n def _save_config(self):\n self.cfg[\"service\"] = self.svc_combo.currentText()\n self.cfg[\"lang_in\"] = self.src_combo.currentText()\n self.cfg[\"lang_out\"] = self.tgt_combo.currentText()\n self.cfg[\"output_format\"] = self.fmt_combo.currentText()\n self.cfg[\"thread_count\"] = self.thread_spin.value()\n self.cfg[\"chunk_enabled\"] = self.chunk_check.isChecked()\n self.cfg[\"chunk_size\"] = self.chunk_size_spin.value()\n self.cfg[\"chunk_delay\"] = self.chunk_delay_spin.value()\n UserConfigManager.save(self.cfg)\n\n def _start(self):\n files = []\n for i in range(self.flist.count()):\n p = self.flist.item(i).data(Qt.UserRole)\n if p: files.append(p)\n if not files: return\n # 确保按钮连接正常(重试模式可能改过)\n try: self.go_btn.clicked.disconnect()\n except TypeError: pass\n self.go_btn.clicked.connect(self._start)\n\n # A:Zotero 联动健康检查 — 从 storage 拖入但插件没响应时提前提示\n # v2.3.2: --auto CLI 唤起时跳过所有 modal(避免锁屏/无 UI 场景阻塞主线程)\n if not getattr(self, \"_cli_format\", None):\n try:\n from ui.translate_worker import detect_zotero_source, zotero_plugin_installed\n zotero_sourced = [f for f in files if detect_zotero_source(f)]\n if zotero_sourced and not zotero_plugin_installed():\n from PyQt5.QtWidgets import QMessageBox\n reply = QMessageBox.question(\n self, \"Zotero 联动提示\",\n f\"检测到 {len(zotero_sourced)} 个文件来自 Zotero storage 目录,\\n\"\n \"但 Zotero 插件(pdf2zh Connector)没响应,翻译完不会自动加到 Zotero 库里。\\n\\n\"\n \"可能原因:\\n\"\n \" 1. Zotero 没打开 → 请打开 Zotero 后重试\\n\"\n \" 2. 插件没装 → 下载 pdf2zh-connector-v1.0.7.xpi 手动装到 Zotero\\n\"\n \" 3. 插件启动失败 → 到 Zotero「附加组件」检查有没有错误\\n\\n\"\n \"是否仍要继续翻译?(结果会保存在默认目录,不同步到 Zotero)\",\n QMessageBox.Yes | QMessageBox.No,\n QMessageBox.No,\n )\n if reply != QMessageBox.Yes:\n return\n except Exception:\n pass # 健康检查失败不阻断主流程\n\n # 先保存翻译页配置(先 save 自己,再让设置页覆盖 API Key 字段,避免互相 stomp)\n self._save_config()\n # 修复:强制让设置页保存(防 editingFinished 漏触发导致 API Key 丢失)\n # 必须在 _save_config 之后,因为 _save_config 用 self.cfg 整体覆盖磁盘,会丢失 api_xxx\n try:\n mw = self.window()\n if hasattr(mw, \"pages\") and \"设置\" in mw.pages:\n mw.pages[\"设置\"]._save()\n # 重新加载 self.cfg 让翻译页拿到最新 api_xxx\n self.cfg = UserConfigManager.load()\n except Exception:\n pass\n self.pending_files = files\n self._batch_idx = 0\n self._batch_results = [] # [(file_path, output_files_dict), ...]\n self._output_dir = os.path.expanduser(\"~/Documents/pdf2zh_files\")\n os.makedirs(self._output_dir, exist_ok=True)\n\n self._cancel_pending = False\n self.go_btn.setEnabled(False); self.go_btn.setText(\"翻译中…\")\n self.prog_card.setVisible(True); self.stop_btn.setVisible(True)\n self.prog_bar.setValue(0)\n self.prog_pct.setText(\"0%\")\n self.prog_label.setText(\"正在翻译…\")\n self.prog_icon.setText(\"⏳\")\n self.prog_tip.setText(\"\")\n\n self._translate_next()\n\n def _translate_next(self):\n \"\"\"启动队列中下一个文件的翻译\"\"\"\n idx = self._batch_idx\n total = len(self.pending_files)\n if idx >= total:\n self._on_batch_done()\n return\n\n fp = self.pending_files[idx]\n if total > 1:\n self.prog_label.setText(f\"正在翻译 ({idx+1}/{total})…\")\n self.prog_detail.setText(os.path.basename(fp))\n self.prog_bar.setValue(0); self.prog_pct.setText(\"0%\")\n\n envs = build_service_envs(self.svc_combo.currentText())\n self.worker = TranslateWorker(\n file_path=fp,\n output_dir=self._output_dir,\n lang_in=LANG_MAP.get(self.src_combo.currentText(), \"\"),\n lang_out=LANG_MAP.get(self.tgt_combo.currentText(), \"zh\"),\n service=SERVICE_MAP.get(self.svc_combo.currentText(), \"bing\"),\n pages=self._get_pages(),\n thread_count=self.thread_spin.value(),\n chunk_enabled=self.chunk_check.isChecked(),\n chunk_size=self.chunk_size_spin.value(),\n chunk_delay=self.chunk_delay_spin.value(),\n envs=envs,\n scan_mode=self.scan_mode_check.isChecked(),\n translate_tables=self.translate_tables_check.isChecked(),\n ocr_mode=self.ocr_mode_check.isChecked(),\n )\n # v2.3.0: 传递 output_formats 到 worker(如果单文件模式被激活)\n # 由 _cli_format 属性(Zotero 唤起时设置)决定\n _cli_fmt = getattr(self, \"_cli_format\", None)\n # v2.3.4: 记住本次右键唤起的格式, 供 Zotero 回写跟随(而非只按 config 勾选)\n self._writeback_format = _cli_fmt if _cli_fmt in (\"side_by_side\", \"dual\", \"mono\", \"all\") else None\n if _cli_fmt and _cli_fmt != \"all\":\n self.worker.output_formats = [_cli_fmt]\n # 用完清掉,避免影响下次手动翻译\n self._cli_format = None\n self.worker.progress.connect(self._on_prog)\n self.worker.status.connect(self._on_status)\n self.worker.finished.connect(self._on_single_done)\n self.worker.error.connect(self._on_single_err)\n self.worker.start()\n\n def _cancel(self):\n if not self.worker:\n return\n self.worker.cancel()\n self._cancel_pending = True\n self.prog_label.setText(\"正在取消…\")\n self.stop_btn.setEnabled(False)\n # 最多等 5 秒,超时强制终止\n def _force_cleanup():\n if not self._cancel_pending:\n return # 翻译已正常结束,不需要强制清理\n if self.worker and self.worker.isRunning():\n self.worker.terminate()\n self.worker.wait(2000)\n self.worker = None\n self._cancel_pending = False\n self.go_btn.setEnabled(True)\n self.go_btn.setText(\"开始翻译\")\n self.stop_btn.setVisible(False)\n self.stop_btn.setEnabled(True)\n self.prog_icon.setText(\"⚠️\")\n self.prog_label.setText(\"已取消\")\n self.prog_detail.setText(\"\")\n QTimer.singleShot(5000, _force_cleanup)\n\n _TIPS = [\n \"翻译中,请稍候…\", \"公式和图表会完整保留\",\n \"AI 正在识别文档布局…\", \"保持网络连接以获得最佳速度\",\n \"快好了,再等等…\", \"pdf2zh-desktop · 学术翻译利器\",\n \"排版会和原文一模一样\", \"支持 20+ 翻译引擎\",\n \"每一篇论文,都是知识跨越语言的桥梁\",\n \"科研不易,感谢你的坚持\",\n ]\n\n def _on_prog(self, cur, total):\n if total > 0:\n pct = min(int(cur/total*100), 100)\n self.prog_bar.setValue(pct); self.prog_pct.setText(f\"{pct}%\")\n self.prog_detail.setText(f\"已处理 {cur}/{total} 页\")\n # 趣味提示 + 深夜关怀\n import random\n if cur % 5 == 1 or cur == total:\n from ui.caring import get_caring_message\n caring = get_caring_message()\n if caring:\n tip = f\"{caring[0]} {caring[1]}\"\n elif pct >= 90:\n tip = \"马上就好!\"\n elif pct >= 50:\n tip = \"已经过半了,加油!\"\n else:\n tip = random.choice(self._TIPS)\n self.prog_tip.setText(tip)\n\n def _on_status(self, text):\n self.prog_detail.setText(text)\n\n def _on_single_done(self, output_files):\n \"\"\"单文件翻译完成 — 回写 Zotero + 记录历史 + 推进队列\"\"\"\n self._cancel_pending = False # 正常完成,取消超时回调不再触发\n fp = self.pending_files[self._batch_idx]\n\n if self.worker:\n w = self.worker; self.worker = None\n w.quit()\n QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n # 保存历史\n HistoryManager.add_record({\n \"file\": {\"name\": os.path.basename(fp), \"path\": fp},\n \"translation\": {\n \"service\": self.svc_combo.currentText(),\n \"lang_in\": self.src_combo.currentText(),\n \"lang_out\": self.tgt_combo.currentText(),\n },\n \"output_files\": output_files,\n \"status\": \"success\",\n })\n self.translation_done.emit(output_files)\n\n # Zotero 回写:按此文件自身的来源路径,把译文复制回原位\n self._zotero_writeback(fp, output_files)\n\n self._batch_results.append((fp, output_files))\n\n # 推进到下一个文件\n self._batch_idx += 1\n self._translate_next()\n\n def _on_single_err(self, msg):\n \"\"\"单文件翻译出错 — 记录后继续下一个\"\"\"\n self._cancel_pending = False\n fp = self.pending_files[self._batch_idx]\n\n if self.worker:\n w = self.worker; self.worker = None\n w.quit()\n QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n self._batch_results.append((fp, None)) # None = 失败\n\n total = len(self.pending_files)\n if total > 1:\n # 批量模式:跳过出错的,继续下一个\n self.prog_detail.setText(f\"⚠️ {os.path.basename(fp)} 翻译出错,跳过\")\n self._batch_idx += 1\n QTimer.singleShot(1000, self._translate_next)\n else:\n # 单文件模式:直接报错\n self._on_err(msg)\n\n def _zotero_writeback(self, file_path, output_files):\n \"\"\"把译文复制回 Zotero 原位 + 尝试自动关联附件\"\"\"\n import shutil\n _dbg_write(f\"_zotero_writeback file_path={file_path!r} output_files={output_files}\")\n zotero_dir = detect_zotero_source(file_path)\n _dbg_write(f\" detect_zotero_source → {zotero_dir!r}\")\n if not zotero_dir:\n _dbg_write(\" ✗ not from Zotero storage, skip writeback\")\n return\n cfg = UserConfigManager.load()\n # v2.3.4: 右键唤起指定了格式时, 回写格式跟随右键选择(而非 config 勾选)\n _wb_fmt = getattr(self, \"_writeback_format\", None)\n if _wb_fmt == \"all\":\n modes = [\"side_by_side\", \"dual\", \"mono\"]\n elif _wb_fmt in (\"side_by_side\", \"dual\", \"mono\"):\n modes = [_wb_fmt]\n else:\n modes = cfg.get(\"zotero_output_modes\", [\"side_by_side\"])\n keep_copy = cfg.get(\"zotero_keep_copy\", True)\n item_key = get_zotero_item_key(file_path)\n _dbg_write(f\" modes={modes} item_key={item_key!r}\")\n for mode in modes:\n src = output_files.get(mode)\n _dbg_write(f\" mode={mode} src={src!r}\")\n if not src or not os.path.exists(src):\n _dbg_write(f\" ✗ src missing or not exists, skip\")\n continue\n dst = os.path.join(zotero_dir, os.path.basename(src))\n _dbg_write(f\" dst={dst!r}\")\n if os.path.abspath(src) != os.path.abspath(dst):\n shutil.copy2(src, dst)\n _dbg_write(f\" ✓ copied to storage\")\n if not keep_copy:\n try:\n os.remove(src)\n except OSError:\n pass\n # 尝试通过 pdf2zh Connector 插件自动关联附件\n if item_key:\n mode_label = {\"side_by_side\": \"中外并排\", \"dual\": \"上下双语\", \"mono\": \"纯中文\"}.get(mode, mode)\n base = os.path.splitext(os.path.basename(dst))[0]\n for suffix in (\"-side_by_side\", \"-dual\", \"-mono\"):\n if base.endswith(suffix):\n base = base[: -len(suffix)]\n break\n title = f\"译文 · {mode_label} · {base}\"\n _dbg_write(f\" POST /pdf2zh/attach itemKey={item_key} title={title!r}\")\n try:\n ok, msg = zotero_auto_link(item_key, dst, title)\n _dbg_write(f\" zotero_auto_link → ok={ok} msg={msg!r}\")\n if not ok:\n try: self.prog_detail.setText(f\"⚠️ {msg}\")\n except Exception: pass\n except Exception as e:\n _dbg_write(f\" ✗ zotero_auto_link EXCEPTION: {e}\")\n try: self.prog_detail.setText(f\"⚠️ Zotero 联动异常: {e}\")\n except Exception: pass\n else:\n _dbg_write(f\" ✗ item_key is None → no attach POST\")\n\n def _on_batch_done(self):\n \"\"\"全部文件翻译完成\"\"\"\n self.prog_bar.setValue(100); self.prog_pct.setText(\"100%\")\n self.prog_icon.setText(\"✅\")\n self.go_btn.setEnabled(True); self.go_btn.setText(\"开始翻译\")\n self.stop_btn.setVisible(False)\n\n total = len(self._batch_results)\n ok = sum(1 for _, r in self._batch_results if r is not None)\n failed = total - ok\n has_zotero = any(detect_zotero_source(fp) for fp, _ in self._batch_results)\n\n # 收集失败的文件用于重试\n self._failed_files = [fp for fp, r in self._batch_results if r is None]\n\n if total == 1 and ok == 1:\n self.prog_label.setText(\"翻译完成\")\n if has_zotero:\n self.prog_detail.setText(\"译文已保存回 Zotero 原位\")\n else:\n self.prog_detail.setText(\"输出至 ~/Documents/pdf2zh_files\")\n elif failed == 0:\n self.prog_label.setText(f\"全部完成({ok} 篇)\")\n if has_zotero:\n self.prog_detail.setText(\"所有译文已保存回 Zotero 原位\")\n else:\n self.prog_detail.setText(\"输出至 ~/Documents/pdf2zh_files\")\n else:\n self.prog_label.setText(f\"完成 {ok} 篇,失败 {failed} 篇\")\n self.prog_detail.setText(\"部分文件翻译出错\")\n # 显示重试按钮\n self.go_btn.setText(f\"重试失败 ({failed} 篇)\")\n try: self.go_btn.clicked.disconnect()\n except TypeError: pass\n self.go_btn.clicked.connect(self._retry_failed)\n self.go_btn.setEnabled(True)\n\n # macOS 系统通知 — 翻译完成后通知用户(适合长时间翻译时切到其他 app)\n try:\n import subprocess\n msg = f\"{ok} 篇翻译完成\" if failed == 0 else f\"完成 {ok} 篇,失败 {failed} 篇\"\n subprocess.Popen([\n \"osascript\", \"-e\",\n f'display notification \"{msg}\" with title \"pdf2zh\" sound name \"Glass\"'\n ])\n except Exception:\n pass\n\n # v2.3.4: 后台静默模式 —— 翻译+回写完成后自动关窗(全成功); 有失败则恢复窗口看错误\n _win = self.window()\n if _win is not None and getattr(_win, \"_cli_silent\", False):\n _win._cli_silent = False\n if failed == 0:\n QTimer.singleShot(1800, _win.close)\n else:\n try: _win.showNormal(); _win.raise_(); _win.activateWindow()\n except Exception: pass\n\n # 关怀消息\n from ui.caring import get_caring_message, get_session_tip\n caring = get_caring_message()\n if caring:\n self.prog_tip.setText(f\"{caring[0]} {caring[1]}\")\n else:\n self.prog_tip.setText(get_session_tip())\n\n # 翻译完成后自动清空文件列表\n self._clear_files()\n\n # 骰子系统\n try:\n from datetime import date\n cfg = UserConfigManager.load()\n today = date.today().isoformat()\n if cfg.get(\"dice_date\") != today:\n cfg[\"dice_date\"] = today; cfg[\"dice_today_pages\"] = 0\n cfg[\"dice_used\"] = False; cfg[\"dice_clovers\"] = 0\n cfg[\"dice_today_pages\"] = cfg.get(\"dice_today_pages\", 0) + max(1, self.thread_spin.value())\n UserConfigManager.dice_save(cfg)\n self._update_dice()\n except Exception:\n pass\n\n def _retry_failed(self):\n \"\"\"重试上次批量翻译中失败的文件\"\"\"\n failed = getattr(self, '_failed_files', [])\n if not failed:\n return\n # 恢复按钮到正常状态\n try: self.go_btn.clicked.disconnect()\n except TypeError: pass\n self.go_btn.clicked.connect(self._start)\n # 把失败文件重新加入列表并启动\n self.on_files_added(failed)\n self._failed_files = []\n self._start()\n\n def _on_err(self, msg):\n self._cancel_pending = False # 错误也算结束,取消超时不再触发\n self.prog_icon.setText(\"❌\")\n self.prog_label.setText(\"翻译出错\")\n self.prog_pct.setText(\"!\")\n self.prog_detail.setText(msg)\n self.prog_bar.setValue(0)\n self.go_btn.setEnabled(True); self.go_btn.setText(\"重试翻译\")\n self.stop_btn.setVisible(False)\n if self.worker:\n w = self.worker; self.worker = None\n w.quit()\n QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n # ── 骰子系统(静默、无文字、四叶草) ──\n\n def _update_dice(self):\n \"\"\"骰子 🎲 在按钮区,四叶草 🍀 在标题旁\"\"\"\n from datetime import date\n cfg = UserConfigManager.load()\n today = date.today().isoformat()\n pages = cfg.get(\"dice_today_pages\", 0)\n\n # 新的一天重置\n if cfg.get(\"dice_date\") != today:\n self.clover_label.setText(\"\")\n if pages >= 5:\n self.dice_btn.setText(\"🎲\"); self.dice_btn.setEnabled(True); self.dice_btn.show()\n else:\n self.dice_btn.hide()\n return\n\n # 四叶草:当天翻译不足 5 页则清掉\n clovers = cfg.get(\"dice_clovers\", 0) if cfg.get(\"dice_used\") else 0\n if clovers > 0 and pages < 5:\n clovers = 0\n cfg[\"dice_clovers\"] = 0; UserConfigManager.dice_save(cfg)\n self.clover_label.setText(\"🍀\" * clovers if clovers > 0 else \"\")\n\n # 骰子按钮\n if cfg.get(\"dice_used\"):\n self.dice_btn.hide() # 已掷过,骰子消失\n elif pages >= 5:\n self.dice_btn.setText(\"🎲\"); self.dice_btn.setEnabled(True); self.dice_btn.show()\n else:\n self.dice_btn.hide()\n\n def _dice_game(self):\n \"\"\"点击骰子 → 静默掷骰 → 四叶草或消失\"\"\"\n import random\n from datetime import date\n\n cfg = UserConfigManager.load()\n today = date.today().isoformat()\n\n # 日期重置\n if cfg.get(\"dice_date\") != today:\n cfg[\"dice_date\"] = today; cfg[\"dice_today_pages\"] = 0\n cfg[\"dice_used\"] = False; cfg[\"dice_clovers\"] = 0\n UserConfigManager.dice_save(cfg)\n\n # 签名校验\n if not UserConfigManager.dice_verify(cfg):\n cfg[\"dice_date\"] = today; cfg[\"dice_today_pages\"] = 0\n cfg[\"dice_used\"] = False; cfg[\"dice_clovers\"] = 0\n UserConfigManager.dice_save(cfg)\n self._update_dice(); return\n\n # 静默检查资格\n if cfg.get(\"dice_used\") or cfg.get(\"dice_today_pages\", 0) < 5:\n return\n\n # 标记已用\n cfg[\"dice_used\"] = True; cfg[\"dice_clovers\"] = 0\n UserConfigManager.dice_save(cfg)\n\n self.dice_btn.setEnabled(False)\n self._dice_roll_step(0)\n\n def _dice_roll_step(self, clovers):\n \"\"\"逐次掷骰:6 → 🍀 累加,非 6 → 结束\"\"\"\n import random\n FACES = \"⚀⚁⚂⚃⚄⚅\"\n\n val = 6 if random.random() < 0.5 else random.choice([1,2,3,4,5])\n flicker = [0]\n\n def tick():\n flicker[0] += 1\n if flicker[0] < 10:\n self.dice_btn.setText(FACES[random.randint(0,5)])\n QTimer.singleShot(55, tick)\n else:\n # 定格\n self.dice_btn.setText(FACES[val - 1])\n if val == 6:\n nc = clovers + 1\n self._dice_mini_firework()\n QTimer.singleShot(700, lambda: self._dice_got_six(nc))\n else:\n # 短暂显示骰面,然后骰子消失,四叶草留在标题旁\n QTimer.singleShot(1500, lambda: self._dice_finish(clovers))\n tick()\n\n def _dice_got_six(self, clovers):\n \"\"\"掷中 6:标题旁加 🍀,继续或结束\"\"\"\n # 四叶草实时更新到标题旁\n self.clover_label.setText(\"🍀\" * clovers)\n\n # 保存中间状态\n cfg = UserConfigManager.load()\n cfg[\"dice_clovers\"] = clovers\n UserConfigManager.dice_save(cfg)\n\n if clovers >= 6:\n # 全部 6!终极大奖\n from datetime import date\n today = date.today().isoformat()\n cfg[\"dice_win_code\"] = UserConfigManager.dice_win_code(today)\n cfg[\"theme_unlocked\"] = True\n UserConfigManager.save(cfg)\n self.dice_btn.hide()\n mw = self.window()\n if hasattr(mw, '_midnight_bloom'):\n mw._midnight_bloom()\n return\n\n # ≥3 个 6 静默解锁主题色\n if clovers == 3:\n cfg2 = UserConfigManager.load()\n cfg2[\"theme_unlocked\"] = True\n UserConfigManager.save(cfg2)\n\n # 按钮回到 🎲 继续下一轮\n self.dice_btn.setText(\"🎲\")\n QTimer.singleShot(600, lambda: self._dice_roll_step(clovers))\n\n def _dice_finish(self, clovers):\n \"\"\"掷骰结束:骰子消失,四叶草留在标题旁\"\"\"\n cfg = UserConfigManager.load()\n cfg[\"dice_clovers\"] = clovers\n UserConfigManager.dice_save(cfg)\n # 骰子消失,四叶草已在标题旁\n self.dice_btn.hide()\n self.clover_label.setText(\"🍀\" * clovers if clovers > 0 else \"\")\n\n def _dice_mini_firework(self):\n \"\"\"从骰子按钮位置爆发迷你烟花\"\"\"\n import random, math\n from PyQt5.QtCore import QPropertyAnimation, QPoint, QEasingCurve\n mw = self.window()\n center = self.dice_btn.mapTo(mw, self.dice_btn.rect().center())\n emojis = [\"✨\",\"💫\",\"⭐\",\"🌟\",\"🎇\",\"🔥\",\"🎆\"]\n for _ in range(12):\n lbl = QLabel(random.choice(emojis), mw)\n lbl.setStyleSheet(f\"font-size:{random.randint(12,22)}px;background:transparent;\")\n lbl.move(center); lbl.show(); lbl.raise_()\n angle = random.uniform(0, 2 * math.pi)\n dist = random.randint(50, 160)\n end = QPoint(center.x() + int(math.cos(angle) * dist),\n center.y() + int(math.sin(angle) * dist))\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(600, 1100))\n anim.setStartValue(center); anim.setEndValue(end)\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start(); lbl._anim = anim\n\n\n# ═══════════════════════════════════════════════════════════════\n# 阅读页面 — 历史 + 预览合体\n# ═══════════════════════════════════════════════════════════════\n\nclass ReaderPage(QWidget):\n \"\"\"左侧历史列表 + 右侧 PDF 预览\n 两级导航:历史列表 ←(Left/Right)→ 预览区\n 预览区内上下键翻页,缩略图跟随高亮\"\"\"\n fullscreen_changed = pyqtSignal(bool)\n\n def __init__(self):\n super().__init__()\n self._preview_active = False\n lo = QHBoxLayout(self); lo.setContentsMargins(0,0,0,0); lo.setSpacing(0)\n\n # QSplitter 实现可拖拽分割\n from PyQt5.QtWidgets import QSplitter\n splitter = QSplitter(Qt.Horizontal)\n splitter.setHandleWidth(1)\n splitter.setChildrenCollapsible(True)\n\n # ── 左侧:历史面板 ──\n left = QWidget()\n left.setObjectName(\"HistPanel\"); left.setMinimumWidth(180); left.setMaximumWidth(420)\n ll = QVBoxLayout(left); ll.setContentsMargins(12,12,12,12); ll.setSpacing(8)\n\n # 标题行\n hdr = QHBoxLayout(); hdr.setSpacing(4)\n t = QLabel(\"翻译历史\"); t.setStyleSheet(\"font-size:13px;font-weight:600;\")\n hdr.addWidget(t); hdr.addStretch()\n self.thumb_toggle = QPushButton(\"缩略图\"); self.thumb_toggle.setObjectName(\"TB\")\n self.thumb_toggle.setCursor(Qt.PointingHandCursor)\n self.thumb_toggle.setProperty(\"active\", True)\n self.thumb_toggle.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n self.thumb_toggle.clicked.connect(self._toggle_thumbs)\n hdr.addWidget(self.thumb_toggle)\n cb = QPushButton(\"清空\"); cb.setObjectName(\"Gh\"); cb.setCursor(Qt.PointingHandCursor)\n cb.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n cb.clicked.connect(self._clear); hdr.addWidget(cb)\n ll.addLayout(hdr)\n\n # ── 分组筛选栏:左侧可滚动按钮区 + 右侧固定\"+\" ──\n _group_row = QHBoxLayout(); _group_row.setContentsMargins(0, 0, 0, 0); _group_row.setSpacing(4)\n self._group_bar = QScrollArea()\n self._group_bar.setMaximumHeight(34); self._group_bar.setFrameShape(QFrame.NoFrame)\n self._group_bar.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._group_bar.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._group_bar.setWidgetResizable(True)\n self._group_container = QWidget()\n self._group_layout = QHBoxLayout(self._group_container)\n self._group_layout.setContentsMargins(0, 0, 0, 0); self._group_layout.setSpacing(4)\n self._group_bar.setWidget(self._group_container)\n _group_row.addWidget(self._group_bar, 1)\n # \"+\"按钮 — 独立于 scroll area,绝对不参与 group_layout\n self._add_btn = QPushButton(\"+\"); self._add_btn.setObjectName(\"Gh\")\n self._add_btn.setCursor(Qt.PointingHandCursor)\n self._add_btn.setStyleSheet(\"font-size:10px;padding:4px 8px;\")\n self._add_btn.setToolTip(\"新建分组\")\n self._add_btn.setFixedSize(28, 26)\n self._add_btn.clicked.connect(self._create_group)\n _group_row.addWidget(self._add_btn, 0) # stretch=0, 固定尺寸\n ll.addLayout(_group_row)\n self._current_group_id = None # None = 全部\n self._current_tag_filter = None\n\n # 历史列表\n self.list_w = QListWidget()\n self.list_w.setObjectName(\"HistList\")\n self.list_w.setFocusPolicy(Qt.StrongFocus)\n self.list_w.setSelectionMode(QListWidget.SingleSelection)\n self.list_w.currentItemChanged.connect(self._on_select)\n self.list_w.itemClicked.connect(lambda item: self._on_select(item, None))\n self.list_w.itemDoubleClicked.connect(self._open_in_reader)\n self.list_w.installEventFilter(self)\n self.list_w.setContextMenuPolicy(Qt.CustomContextMenu)\n self.list_w.customContextMenuRequested.connect(self._hist_context_menu)\n ll.addWidget(self.list_w)\n\n # 详情面板\n self.detail_label = QLabel(\"↑ 选择记录查看详情\")\n self.detail_label.setObjectName(\"HistDetail\")\n self.detail_label.setWordWrap(True)\n self.detail_label.setStyleSheet(\n \"#HistDetail{font-size:10px;padding:4px 6px;line-height:125%;}\")\n self.detail_label.setMinimumHeight(48)\n ll.addWidget(self.detail_label)\n # 文件操作按钮 — 紧凑行,紧贴详情面板\n fops = QHBoxLayout(); fops.setSpacing(4); fops.setContentsMargins(6, 2, 6, 0)\n self._btn_reveal = QPushButton(\"📂 Finder\"); self._btn_reveal.setObjectName(\"Gh\")\n self._btn_reveal.setCursor(Qt.PointingHandCursor)\n self._btn_reveal.setStyleSheet(\"font-size:9px;padding:2px 6px;\")\n self._btn_reveal.setToolTip(\"在 Finder 中显示翻译文件\")\n self._btn_reveal.clicked.connect(self._reveal_in_finder)\n self._btn_reveal.setVisible(False)\n fops.addWidget(self._btn_reveal)\n self._btn_open_src = QPushButton(\"📄 源文件\"); self._btn_open_src.setObjectName(\"Gh\")\n self._btn_open_src.setCursor(Qt.PointingHandCursor)\n self._btn_open_src.setStyleSheet(\"font-size:9px;padding:2px 6px;\")\n self._btn_open_src.setToolTip(\"用默认应用打开源 PDF\")\n self._btn_open_src.clicked.connect(self._open_source)\n self._btn_open_src.setVisible(False)\n fops.addWidget(self._btn_open_src)\n fops.addStretch()\n ll.addLayout(fops)\n self._current_record = None\n\n splitter.addWidget(left)\n\n # ── 右侧:预览 ──\n self.preview = PreviewPage()\n self.preview.installEventFilter(self)\n splitter.addWidget(self.preview)\n\n # 初始比例:左 260, 右 stretch\n splitter.setSizes([260, 800])\n self._splitter = splitter\n self._hist_panel = left\n lo.addWidget(splitter)\n\n self._show_thumbs = True\n # 缩略图点击 → 进入预览模式\n self._orig_thumb_clicked = self.preview._thumb_clicked\n self.preview._thumb_clicked = self._on_thumb_clicked\n # 全屏信号\n self.preview.fullscreen_toggled.connect(self._on_fullscreen)\n self.preview.history_toggled.connect(self._toggle_hist_in_fullscreen)\n self._fs_hist_visible = False\n self.refresh()\n\n def showEvent(self, event):\n \"\"\"页面显示时自动选中第一条\"\"\"\n super().showEvent(event)\n if self.list_w.count() > 0 and not self.list_w.currentItem():\n self.list_w.setCurrentRow(0)\n self.list_w.setFocus()\n\n def _on_thumb_clicked(self, idx):\n \"\"\"点击缩略图 → 进入预览模式 + 跳转页面\"\"\"\n self._enter_preview()\n self._orig_thumb_clicked(idx)\n\n def _enter_preview(self):\n \"\"\"进入预览模式:缩略图显示蓝色高亮\"\"\"\n self._preview_active = True\n self.preview._thumb_color = _C[\"acc\"]\n self.preview.setFocus()\n self.preview._highlight_thumb(self.preview.current_page)\n\n def _exit_preview(self):\n \"\"\"退出预览模式:缩略图变灰色,焦点回列表\"\"\"\n self._preview_active = False\n self.preview._thumb_color = _C[\"t3\"]\n self.list_w.setFocus()\n self.preview._highlight_thumb(self.preview.current_page)\n\n def eventFilter(self, obj, event):\n \"\"\"两级导航: 历史列表 ←→ 预览区(一次按键切换)\n 全屏模式下 Left 键会先唤出历史面板\"\"\"\n from PyQt5.QtCore import QEvent\n if event.type() == QEvent.KeyPress:\n key = event.key()\n # 历史列表 → 右键 → 进入预览\n if obj == self.list_w and key == Qt.Key_Right:\n self._enter_preview()\n return True\n # 预览区 → 左键\n if obj == self.preview and key == Qt.Key_Left:\n # 全屏且历史面板隐藏 → 先唤出历史\n if self.preview._is_fullscreen and not self._fs_hist_visible:\n self._toggle_hist_in_fullscreen()\n return True\n self._exit_preview()\n return True\n return super().eventFilter(obj, event)\n\n def _on_fullscreen(self, fs):\n \"\"\"全屏阅读模式:隐藏历史面板+侧边栏\"\"\"\n if fs:\n self._saved_splitter_sizes = self._splitter.sizes()\n self._hist_panel.setVisible(False)\n self._fs_hist_visible = False\n self.preview.hist_btn.setProperty(\"active\", False)\n self.preview.hist_btn.style().unpolish(self.preview.hist_btn)\n self.preview.hist_btn.style().polish(self.preview.hist_btn)\n else:\n self._hist_panel.setVisible(True)\n self._splitter.setSizes(self._saved_splitter_sizes)\n self._fs_hist_visible = False\n self.fullscreen_changed.emit(fs)\n self._quick_refit()\n\n def _toggle_hist_in_fullscreen(self):\n \"\"\"全屏模式下切换历史面板\"\"\"\n if not self.preview._is_fullscreen:\n return\n self._fs_hist_visible = not self._fs_hist_visible\n self._hist_panel.setVisible(self._fs_hist_visible)\n self.preview.hist_btn.setProperty(\"active\", self._fs_hist_visible)\n self.preview.hist_btn.style().unpolish(self.preview.hist_btn)\n self.preview.hist_btn.style().polish(self.preview.hist_btn)\n if self._fs_hist_visible:\n self._splitter.setSizes([self._saved_splitter_sizes[0], 800])\n self.list_w.setFocus()\n self._quick_refit()\n\n def _quick_refit(self):\n \"\"\"快速贴合(不重建缩略图,只调 zoom + 渲染)\"\"\"\n if not self.preview.doc:\n return\n def _do():\n if self.preview._fit_mode == \"page\":\n self.preview._fit_page()\n else:\n self.preview._fit_width()\n if self.preview.zoom != getattr(self.preview, '_last_fit_zoom', -1):\n self.preview._last_fit_zoom = self.preview.zoom\n if self.preview._continuous:\n self.preview._render_continuous()\n else:\n self.preview.render_page()\n QTimer.singleShot(0, _do)\n QTimer.singleShot(80, _do)\n\n def _toggle_thumbs(self):\n \"\"\"切换缩略图显示,自动重新适配宽度\"\"\"\n self._show_thumbs = not self._show_thumbs\n self.thumb_toggle.setProperty(\"active\", self._show_thumbs)\n self.thumb_toggle.style().unpolish(self.thumb_toggle)\n self.thumb_toggle.style().polish(self.thumb_toggle)\n if self._show_thumbs:\n self.preview.thumb_scroll.setVisible(True)\n # 确保 splitter 给缩略图合理宽度\n sizes = self.preview.body_splitter.sizes()\n if sizes[0] < 60:\n self.preview.body_splitter.setSizes([100, sizes[1]])\n else:\n self.preview.thumb_scroll.setVisible(False)\n self._quick_refit()\n\n def refresh(self):\n self.list_w.clear()\n data = HistoryManager.load_all()\n records = data.get(\"records\", [])\n groups = data.get(\"groups\", [])\n all_tags = data.get(\"tags\", [])\n\n # ── 重建分组筛选栏 ──\n self._rebuild_group_bar(groups)\n\n # ── 按分组/标签筛选 ──\n filtered = records\n gid = self._current_group_id\n if gid is not None:\n filtered = [r for r in filtered if r.get(\"group_id\") == gid]\n tid = self._current_tag_filter\n if tid is not None:\n filtered = [r for r in filtered if tid in r.get(\"tags\", [])]\n\n if not filtered:\n item = QListWidgetItem(\"暂无翻译记录\")\n item.setFlags(item.flags() & ~Qt.ItemIsSelectable)\n item.setForeground(QColor(_C[\"t3\"]))\n self.list_w.addItem(item)\n return\n for r in filtered:\n ts = r.get(\"timestamp\", \"\")[:16].replace(\"T\", \" \")\n fname = r.get(\"file\", {}).get(\"name\", \"?\")\n svc = r.get(\"translation\", {}).get(\"service\", \"?\")\n lang_in = r.get(\"translation\", {}).get(\"lang_in\", \"\")\n lang_out = r.get(\"translation\", {}).get(\"lang_out\", \"\")\n status = \"✓\" if r.get(\"status\") == \"success\" else \"✗\"\n # 两行显示:文件名 + 服务·时间\n item = QListWidgetItem()\n item.setData(Qt.UserRole, r)\n item.setToolTip(f\"{fname}\\n{svc} · {lang_in}→{lang_out} · {ts[5:]}\")\n # 有标签时行高多留空间\n has_tags = bool(r.get(\"tags\"))\n item.setSizeHint(QSize(0, 56 if has_tags else 44))\n self.list_w.addItem(item)\n # 自定义 widget 双行布局\n w = QWidget(); wl = QVBoxLayout(w)\n wl.setContentsMargins(2, 2, 2, 2); wl.setSpacing(0)\n l1 = QLabel(f\"{status} {fname}\"); l1.setStyleSheet(f\"font-size:12px;color:{_C['t1']};background:transparent;\")\n l2 = QLabel(f\"{svc} · {lang_in}→{lang_out} · {ts[5:]}\")\n l2.setStyleSheet(f\"font-size:11px;color:{_C['t2']};background:transparent;\")\n wl.addWidget(l1); wl.addWidget(l2)\n # 标签行\n rec_tags = r.get(\"tags\", [])\n if rec_tags and all_tags:\n tag_row = QHBoxLayout(); tag_row.setSpacing(4); tag_row.setContentsMargins(0, 1, 0, 0)\n for td in all_tags:\n if td[\"id\"] in rec_tags:\n dot = QLabel(f\"● {td['name']}\")\n dot.setStyleSheet(f\"font-size:9px;color:{td['color']};background:transparent;\")\n tag_row.addWidget(dot)\n tag_row.addStretch()\n wl.addLayout(tag_row)\n w._l1 = l1; w._l2 = l2\n self.list_w.setItemWidget(item, w)\n\n def _rebuild_group_bar(self, groups):\n \"\"\"重建分组筛选按钮栏 — 支持右键菜单(重命名/删除/图标)\"\"\"\n while self._group_layout.count():\n w = self._group_layout.takeAt(0).widget()\n if w: w.deleteLater()\n self._group_btns = []\n c = _C\n # ── \"全部\"按钮(固定,不可删除) ──\n btn_all = QPushButton(\"全部\"); btn_all.setObjectName(\"TB\")\n btn_all.setCursor(Qt.PointingHandCursor)\n btn_all.setStyleSheet(\"font-size:10px;padding:4px 10px;\")\n btn_all.setProperty(\"active\", self._current_group_id is None)\n btn_all.clicked.connect(lambda: self._select_group(None))\n self._group_layout.addWidget(btn_all)\n # ── 动态分组按钮 ──\n for g in groups:\n b = QPushButton(f\"{g['icon']} {g['name']}\"); b.setObjectName(\"TB\")\n b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:10px;padding:4px 10px;\")\n b.setProperty(\"active\", self._current_group_id == g[\"id\"])\n gid = g[\"id\"]\n b.clicked.connect(lambda checked, _id=gid: self._select_group(_id))\n # 右键菜单\n b.setContextMenuPolicy(Qt.CustomContextMenu)\n b.customContextMenuRequested.connect(\n lambda pos, _id=gid, _btn=b: self._group_context_menu(_id, _btn, pos))\n self._group_layout.addWidget(b)\n self._group_btns.append((gid, b))\n self._group_layout.addStretch()\n\n def _select_group(self, group_id):\n self._current_group_id = group_id\n self.refresh()\n\n def _create_group(self):\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建分组\", \"分组名称:\")\n if ok and name.strip():\n g = HistoryManager.add_group(name.strip())\n # 在 stretch 前面插入新按钮(\"+\"在 layout 外,不受影响)\n stretch_idx = self._group_layout.count() - 1 # 最后一个是 stretch\n b = QPushButton(f\"{g['icon']} {g['name']}\"); b.setObjectName(\"TB\")\n b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:10px;padding:4px 10px;\")\n b.setProperty(\"active\", False)\n gid = g[\"id\"]\n b.clicked.connect(lambda checked, _id=gid: self._select_group(_id))\n b.setContextMenuPolicy(Qt.CustomContextMenu)\n b.customContextMenuRequested.connect(\n lambda pos, _id=gid, _btn=b: self._group_context_menu(_id, _btn, pos))\n self._group_layout.insertWidget(stretch_idx, b)\n self._group_btns.append((gid, b))\n\n def _group_context_menu(self, group_id, btn, pos):\n \"\"\"分组按钮右键菜单:重命名 / 修改图标 / 上移 / 下移 / 删除\"\"\"\n try:\n menu = _RoundMenu(self)\n menu.addAction(\"重命名\", lambda: self._rename_group(group_id))\n menu.addAction(\"修改图标\", lambda: self._change_group_icon(group_id))\n menu.addSeparator()\n # 上移 / 下移\n ids = [gid for gid, _ in self._group_btns]\n idx = ids.index(group_id) if group_id in ids else -1\n if idx > 0:\n menu.addAction(\"↑ 上移\", lambda: self._move_group(group_id, -1))\n if idx < len(ids) - 1:\n menu.addAction(\"↓ 下移\", lambda: self._move_group(group_id, 1))\n menu.addSeparator()\n menu.addAction(\"删除分组\", lambda: self._delete_group(group_id))\n menu.exec_(btn.mapToGlobal(pos))\n except Exception:\n pass\n\n def _rename_group(self, group_id):\n from PyQt5.QtWidgets import QInputDialog\n data = HistoryManager.load_all()\n old_name = \"\"\n for g in data.get(\"groups\", []):\n if g[\"id\"] == group_id:\n old_name = g[\"name\"]\n break\n name, ok = QInputDialog.getText(self, \"重命名分组\", \"新名称:\", text=old_name)\n if ok and name.strip():\n HistoryManager.rename_group(group_id, name.strip())\n self.refresh()\n\n def _change_group_icon(self, group_id):\n from PyQt5.QtWidgets import QInputDialog\n icons = [\"📁\", \"📂\", \"📚\", \"📖\", \"🔬\", \"🧪\", \"💻\", \"🎓\", \"📝\", \"⭐\",\n \"🔖\", \"📌\", \"🏷️\", \"💡\", \"🔍\", \"📊\", \"🧬\", \"🌐\", \"🤖\", \"📐\"]\n icon, ok = QInputDialog.getItem(self, \"选择图标\", \"分组图标:\", icons, 0, False)\n if ok:\n HistoryManager.update_group_icon(group_id, icon)\n self.refresh()\n\n def _move_group(self, group_id, direction):\n \"\"\"上移 direction=-1,下移 direction=1\"\"\"\n data = HistoryManager.load_all()\n groups = data.get(\"groups\", [])\n ids = [g[\"id\"] for g in groups]\n idx = ids.index(group_id) if group_id in ids else -1\n if idx < 0:\n return\n new_idx = idx + direction\n if 0 <= new_idx < len(ids):\n ids[idx], ids[new_idx] = ids[new_idx], ids[idx]\n HistoryManager.reorder_groups(ids)\n self.refresh()\n\n def _delete_group(self, group_id):\n data = HistoryManager.load_all()\n name = \"\"\n count = 0\n for g in data.get(\"groups\", []):\n if g[\"id\"] == group_id:\n name = g[\"name\"]\n break\n for r in data.get(\"records\", []):\n if r.get(\"group_id\") == group_id:\n count += 1\n reply = QMessageBox.question(\n self, \"删除分组\",\n f\"确定删除分组「{name}」吗?\\n该分组下的 {count} 个文件将移至「全部」。\",\n QMessageBox.Yes | QMessageBox.No, QMessageBox.No)\n if reply == QMessageBox.Yes:\n HistoryManager.delete_group(group_id)\n if self._current_group_id == group_id:\n self._current_group_id = None\n self.refresh()\n\n def _move_to_group(self, record_id, group_id):\n HistoryManager.move_to_group(record_id, group_id)\n self.refresh()\n\n def _toggle_tag(self, record_id, tag_id):\n HistoryManager.toggle_record_tag(record_id, tag_id)\n self.refresh()\n\n def _hist_context_menu(self, pos):\n item = self.list_w.itemAt(pos)\n if not item:\n return\n r = item.data(Qt.UserRole)\n if not r:\n return\n menu = _RoundMenu(self)\n # 在 Finder 中显示译文\n files = r.get(\"output_files\", {})\n has_output = any(os.path.exists(files.get(m, \"\")) for m in [\"dual\",\"mono\",\"side_by_side\"])\n if has_output:\n menu.addAction(\"在 Finder 中显示译文\", self._reveal_in_finder)\n # 打开原文\n src = r.get(\"file\", {}).get(\"path\", \"\")\n if src and os.path.exists(src):\n menu.addAction(\"打开原文\", self._open_source)\n menu.addAction(\"在 Finder 中显示原文\",\n lambda: __import__(\"subprocess\").Popen([\"open\", \"-R\", src]))\n menu.addSeparator()\n # 移到分组\n record_id = r.get(\"id\")\n data = HistoryManager.load_all()\n groups = data.get(\"groups\", [])\n if groups:\n grp_menu = _RoundMenu(self); grp_menu.setTitle(\"移到分组\")\n grp_menu.addAction(\"无分组\", lambda: self._move_to_group(record_id, None))\n for g in groups:\n gid = g[\"id\"]\n grp_menu.addAction(f\"{g['icon']} {g['name']}\",\n lambda _id=gid: self._move_to_group(record_id, _id))\n menu.addMenu(grp_menu)\n # 标签\n all_tags = data.get(\"tags\", [])\n tag_menu = _RoundMenu(self); tag_menu.setTitle(\"标签\")\n for t in all_tags:\n a = tag_menu.addAction(f\"● {t['name']}\")\n a.setCheckable(True)\n a.setChecked(t[\"id\"] in r.get(\"tags\", []))\n tid = t[\"id\"]\n a.toggled.connect(lambda checked, _tid=tid: self._toggle_tag(record_id, _tid))\n tag_menu.addSeparator()\n tag_menu.addAction(\"新建标签…\", self._create_tag)\n menu.addMenu(tag_menu)\n menu.addSeparator()\n menu.addAction(\"清空全部历史\", self._clear)\n menu.exec_(self.list_w.viewport().mapToGlobal(pos))\n\n def _create_tag(self):\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建标签\", \"标签名称:\")\n if ok and name.strip():\n colors = [\"#FF3B30\", \"#FF9F0A\", \"#34C759\", \"#007AFF\", \"#AF52DE\", \"#FF2D55\"]\n import random\n HistoryManager.add_tag(name.strip(), random.choice(colors))\n\n def _on_select(self, current, previous):\n # 恢复上一个\n if previous:\n w = self.list_w.itemWidget(previous)\n if w and hasattr(w, '_l1'):\n w._l1.setStyleSheet(f\"font-size:12px;color:{_C['t1']};background:transparent;\")\n w._l2.setStyleSheet(f\"font-size:11px;color:{_C['t2']};background:transparent;\")\n # 当前变白\n if current:\n w = self.list_w.itemWidget(current)\n if w and hasattr(w, '_l1'):\n w._l1.setStyleSheet(\"font-size:12px;color:white;background:transparent;\")\n w._l2.setStyleSheet(\"font-size:11px;color:rgba(255,255,255,0.7);background:transparent;\")\n\n r = current.data(Qt.UserRole) if current else None\n if not r:\n return\n # 更新详情面板\n fname = r.get(\"file\", {}).get(\"name\", \"—\")\n svc = r.get(\"translation\", {}).get(\"service\", \"—\")\n li = r.get(\"translation\", {}).get(\"lang_in\", \"\")\n lo2 = r.get(\"translation\", {}).get(\"lang_out\", \"\")\n ts = r.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n self.detail_label.setText(f\"📄 {fname}\\n🌐 {svc} · {li}→{lo2}\\n🕐 {ts}\")\n self._current_record = r\n # 显示文件操作按钮(有输出文件时)\n has_files = bool(r.get(\"output_files\"))\n self._btn_reveal.setVisible(has_files)\n self._btn_open_src.setVisible(bool(r.get(\"file\", {}).get(\"path\")))\n # 加载预览(翻译刚完成时跳过,避免覆盖最新结果)\n if \"output_files\" in r and not getattr(self, '_skip_preview_on_select', False):\n self.preview.set_output_files(r[\"output_files\"])\n\n def _open_in_reader(self, item):\n \"\"\"双击历史记录 → 加载到预览并聚焦预览区\"\"\"\n r = item.data(Qt.UserRole) if item else None\n if not r or \"output_files\" not in r:\n return\n self.preview.set_output_files(r[\"output_files\"])\n self._preview_active = True\n self.preview.setFocus()\n\n def set_output_files(self, files):\n \"\"\"外部调用(翻译完成后)\"\"\"\n self.preview.set_output_files(files)\n self._skip_preview_on_select = True # 防止 refresh → setCurrentRow → _on_select 覆盖预览\n self.refresh()\n if self.list_w.count() > 0:\n self.list_w.setCurrentRow(0)\n self._skip_preview_on_select = False\n\n def _clear(self):\n HistoryManager.clear(); self.refresh()\n\n def _reveal_in_finder(self):\n \"\"\"在 Finder 中显示翻译输出文件\"\"\"\n r = self._current_record\n if not r:\n return\n import subprocess\n # 优先显示当前预览模式的文件,否则任一输出文件\n files = r.get(\"output_files\", {})\n path = \"\"\n for mode in [self.preview.current_mode, \"dual\", \"mono\", \"side_by_side\"]:\n p = files.get(mode, \"\")\n if p and os.path.exists(p):\n path = p; break\n if path:\n subprocess.run([\"open\", \"-R\", path])\n\n def _open_source(self):\n \"\"\"用系统默认应用打开源 PDF\"\"\"\n r = self._current_record\n if not r:\n return\n import subprocess\n path = r.get(\"file\", {}).get(\"path\", \"\")\n if path and os.path.exists(path):\n subprocess.run([\"open\", path])\n\n def update_theme(self, c):\n \"\"\"深色/浅色切换时更新历史列表颜色\"\"\"\n self.preview.update_theme(c)\n self.preview._thumb_color = c[\"acc\"] if self._preview_active else c[\"t3\"]\n if self.preview._thumb_labels:\n self.preview._highlight_thumb(self.preview.current_page)\n # 刷新所有历史条目颜色\n for i in range(self.list_w.count()):\n item = self.list_w.item(i)\n w = self.list_w.itemWidget(item)\n if w and hasattr(w, '_l1'):\n is_sel = (item == self.list_w.currentItem())\n if is_sel:\n w._l1.setStyleSheet(\"font-size:12px;color:white;background:transparent;\")\n w._l2.setStyleSheet(\"font-size:11px;color:rgba(255,255,255,0.7);background:transparent;\")\n else:\n w._l1.setStyleSheet(f\"font-size:12px;color:{c['t1']};background:transparent;\")\n w._l2.setStyleSheet(f\"font-size:11px;color:{c['t2']};background:transparent;\")\n\n\n# ═══════════════════════════════════════════════════════════════\n# 设置页面\n# ═══════════════════════════════════════════════════════════════\n\nclass SettingsPage(QWidget):\n dark_mode_changed = pyqtSignal(bool)\n theme_color_changed = pyqtSignal(str)\n\n SERVICE_CONFIGS = {\n # 推荐\n \"DeepSeek\": {\"key_ph\":\"密钥\", \"models\":[\"deepseek-v4-flash\",\"deepseek-v4-pro\",\"deepseek-chat\",\"deepseek-reasoner\",\"deepseek-coder\"], \"url\":\"https://api.deepseek.com/v1\"},\n \"OpenAI\": {\"key_ph\":\"sk-...\", \"models\":[\"gpt-4o\",\"gpt-4o-mini\",\"gpt-4-turbo\",\"gpt-3.5-turbo\",\"o1\",\"o1-mini\",\"o1-pro\"], \"url\":\"https://api.openai.com/v1\"},\n # 国际服务\n \"Azure OpenAI\": {\"key_ph\":\"密钥\", \"models\":[\"gpt-4o\",\"gpt-4-turbo\",\"gpt-35-turbo\"], \"url\":\"https://YOUR_RESOURCE.openai.azure.com\"},\n \"DeepL\": {\"key_ph\":\"密钥\", \"models\":[], \"url\":\"\"},\n \"Gemini\": {\"key_ph\":\"密钥\", \"models\":[\"gemini-pro\",\"gemini-1.5-pro\",\"gemini-1.5-flash\",\"gemini-2.0-flash\"], \"url\":\"\"},\n \"Groq\": {\"key_ph\":\"密钥\", \"models\":[\"llama-3.1-70b-versatile\",\"llama-3.1-8b-instant\",\"mixtral-8x7b-32768\",\"llama-3.3-70b-versatile\"], \"url\":\"https://api.groq.com/openai/v1\"},\n # 国产大模型\n \"Zhipu 智谱\": {\"key_ph\":\"密钥\", \"models\":[\"glm-4\",\"glm-4-flash\",\"glm-4-plus\",\"glm-3-turbo\",\"cogview-3\"], \"url\":\"https://open.bigmodel.cn/api/paas/v4\"},\n \"Qwen 通义千问\": {\"key_ph\":\"密钥\", \"models\":[\"qwen-turbo\",\"qwen-plus\",\"qwen-max\",\"qwen-long\"], \"url\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\"},\n \"Tencent 腾讯\": {\"key_ph\":\"密钥\", \"models\":[\"hunyuan-pro\",\"hunyuan-standard\",\"hunyuan-lite\"], \"url\":\"\"},\n \"Silicon 硅基流动\":{\"key_ph\":\"密钥\", \"models\":[\"deepseek-ai/DeepSeek-V3\",\"Qwen/Qwen2.5-72B-Instruct\",\"Pro/THUDM/glm-4-9b-chat\"], \"url\":\"https://api.siliconflow.cn/v1\"},\n \"ModelScope\": {\"key_ph\":\"密钥\", \"models\":[\"qwen-turbo\",\"qwen-plus\"], \"url\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\"},\n # 本地 & 其他\n \"Ollama 本地\": {\"key_ph\":\"留空\", \"models\":[\"qwen2.5:7b\",\"llama3.1:8b\",\"gemma2:9b\",\"deepseek-r1:8b\"], \"url\":\"http://localhost:11434/v1\"},\n # Argos Translate 离线包未集成,桌面版不开放此服务(移除避免用户选择后报 ImportError)\n # \"Argos Translate\": {\"key_ph\":\"留空\", \"models\":[], \"url\":\"\"},\n \"AnythingLLM\": {\"key_ph\":\"密钥\", \"models\":[], \"url\":\"http://localhost:3001/api/v1\"},\n \"Grok\": {\"key_ph\":\"密钥\", \"models\":[\"grok-2\",\"grok-2-mini\"], \"url\":\"https://api.x.ai/v1\"},\n # 自定义\n \"OpenAI 兼容\": {\"key_ph\":\"密钥\", \"models\":[\"自定义模型\"], \"url\":\"\"},\n }\n\n\n def __init__(self):\n super().__init__()\n self.setObjectName(\"PA\")\n lo = QVBoxLayout(self); lo.setContentsMargins(24,10,24,10); lo.setSpacing(4)\n\n hdr = QHBoxLayout()\n t = QLabel(\"设置\"); t.setObjectName(\"PT0\"); hdr.addWidget(t)\n hdr.addStretch()\n st = QLabel(\"翻译服务与偏好设置\"); st.setObjectName(\"PT1\"); hdr.addWidget(st)\n lo.addLayout(hdr)\n\n cols = QHBoxLayout(); cols.setSpacing(20)\n cfg = UserConfigManager.load()\n # 旧版 key 名兼容迁移:v2.2.5 及之前部分服务用了简短 svc 名(如 \"Silicon\"),\n # v2.2.6 SERVICE_CONFIGS 改成全名(如 \"Silicon 硅基流动\"),需把旧 key 值合并过来\n _legacy_alias = {\n \"Silicon 硅基流动\": \"Silicon\",\n \"Tencent 腾讯\": \"Tencent\",\n \"Zhipu 智谱\": \"Zhipu\",\n \"Qwen 通义千问\": \"Qwen\",\n \"Ollama 本地\": \"Ollama\",\n \"OpenAI 兼容\": \"OpenAI-liked\",\n }\n _migrated = False\n for new_k, old_k in _legacy_alias.items():\n for prefix in (\"api_\", \"url_\", \"model_\"):\n new_key = f\"{prefix}{new_k}\"\n old_key = f\"{prefix}{old_k}\"\n if not cfg.get(new_key) and cfg.get(old_key):\n cfg[new_key] = cfg[old_key]\n _migrated = True\n if _migrated:\n UserConfigManager.save(cfg)\n from PyQt5.QtWidgets import QGridLayout, QTextEdit\n\n # ══════════════════════════════════════════\n # 左列:翻译服务 → AI 助手\n # ══════════════════════════════════════════\n left = QVBoxLayout(); left.setSpacing(2)\n\n # ── 翻译服务配置 ──\n sl3 = QLabel(\"翻译服务配置\"); sl3.setObjectName(\"SL\"); left.addWidget(sl3)\n self.svc_card = _card()\n svc_outer = QVBoxLayout(self.svc_card)\n svc_outer.setContentsMargins(16,12,16,12); svc_outer.setSpacing(4)\n self.svc_selector = QComboBox()\n self.svc_selector.addItems(list(self.SERVICE_CONFIGS.keys()))\n self.svc_selector.currentTextChanged.connect(self._show_service_config)\n svc_outer.addWidget(self.svc_selector)\n self.svc_layout = QVBoxLayout(); self.svc_layout.setSpacing(2)\n svc_outer.addLayout(self.svc_layout)\n left.addWidget(self.svc_card)\n\n self.api_inputs = {}\n for svc_name, conf in self.SERVICE_CONFIGS.items():\n key_inp = QLineEdit(); key_inp.setPlaceholderText(f\"API Key: {conf['key_ph']}\")\n key_inp.setEchoMode(QLineEdit.Password)\n saved = cfg.get(f\"api_{svc_name}\", \"\")\n if saved: key_inp.setText(UserConfigManager.decode_sensitive(saved))\n key_inp.editingFinished.connect(self._save)\n self.api_inputs[svc_name] = {\"key\": key_inp}\n if conf[\"models\"]:\n mc = QComboBox(); mc.setEditable(True)\n mc.addItems(conf[\"models\"])\n saved_m = cfg.get(f\"model_{svc_name}\", \"\")\n if saved_m: mc.setCurrentText(saved_m)\n mc.currentTextChanged.connect(self._save)\n self.api_inputs[svc_name][\"model\"] = mc\n if conf[\"url\"]:\n ui = QLineEdit()\n saved_u = cfg.get(f\"url_{svc_name}\", \"\")\n ui.setText(saved_u if saved_u else conf[\"url\"])\n ui.editingFinished.connect(self._save)\n self.api_inputs[svc_name][\"url\"] = ui\n\n # ── AI 助手(摘要 / 问答服务) ──\n sl_ai = QLabel(\"AI 助手\"); sl_ai.setObjectName(\"SL\"); left.addWidget(sl_ai)\n ai_card = _card(); ai_lo = QVBoxLayout(ai_card)\n ai_lo.setContentsMargins(16, 12, 16, 12); ai_lo.setSpacing(6)\n\n ai_desc = QLabel(\"摘要与问答使用的 AI 服务,默认与翻译服务相同\")\n ai_desc.setObjectName(\"Cap\"); ai_desc.setWordWrap(True)\n ai_lo.addWidget(ai_desc)\n\n self._ai_default_row = QWidget()\n _adr_lo = QHBoxLayout(self._ai_default_row)\n _adr_lo.setContentsMargins(0, 2, 0, 2); _adr_lo.setSpacing(8)\n self._ai_status_label = QLabel(\"与翻译服务相同\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['ok']};background:transparent;\")\n _adr_lo.addWidget(self._ai_status_label); _adr_lo.addStretch()\n self._ai_toggle_btn = QPushButton(\"自定义\")\n self._ai_toggle_btn.setObjectName(\"Gh\"); self._ai_toggle_btn.setCursor(Qt.PointingHandCursor)\n self._ai_toggle_btn.setStyleSheet(\"font-size:11px;padding:3px 10px;\")\n self._ai_toggle_btn.clicked.connect(self._toggle_assistant_config)\n _adr_lo.addWidget(self._ai_toggle_btn)\n ai_lo.addWidget(self._ai_default_row)\n\n self._ai_custom_frame = QWidget()\n _acf_lo = QVBoxLayout(self._ai_custom_frame)\n _acf_lo.setContentsMargins(0, 6, 0, 0); _acf_lo.setSpacing(10)\n\n for _f_label, _f_attr, _f_type in [\n (\"服务\", \"_ai_svc_combo\", \"combo\"),\n (\"API Key\", \"_ai_key_input\", \"password\"),\n (\"模型\", \"_ai_model_combo\", \"combo_edit\"),\n (\"Base URL\",\"_ai_url_input\", \"line\"),\n ]:\n _grp = QVBoxLayout(); _grp.setSpacing(3)\n _lbl = QLabel(_f_label); _lbl.setStyleSheet(\n f\"font-size:11px;font-weight:500;color:{_C['t2']};background:transparent;padding:0;\")\n _grp.addWidget(_lbl)\n if _f_type == \"combo\":\n w = QComboBox()\n from ui.ai_client import CHAT_SERVICES\n w.addItems([name for name, _, _ in CHAT_SERVICES])\n w.currentTextChanged.connect(self._on_assistant_svc_changed)\n elif _f_type == \"password\":\n w = QLineEdit(); w.setEchoMode(QLineEdit.Password)\n w.setPlaceholderText(\"密钥\")\n w.editingFinished.connect(self._save_assistant_config)\n elif _f_type == \"combo_edit\":\n w = QComboBox(); w.setEditable(True)\n w.currentTextChanged.connect(self._save_assistant_config)\n else:\n w = QLineEdit(); w.setPlaceholderText(\"留空使用默认\")\n w.editingFinished.connect(self._save_assistant_config)\n setattr(self, _f_attr, w)\n _grp.addWidget(w)\n _acf_lo.addLayout(_grp)\n\n _reset_row = QHBoxLayout(); _reset_row.addStretch()\n _reset_btn = QPushButton(\"恢复默认\"); _reset_btn.setObjectName(\"GhDanger\")\n _reset_btn.setCursor(Qt.PointingHandCursor)\n _reset_btn.setStyleSheet(\"font-size:11px;padding:3px 10px;\")\n _reset_btn.clicked.connect(self._reset_assistant_config)\n _reset_row.addWidget(_reset_btn)\n _acf_lo.addLayout(_reset_row)\n\n self._ai_custom_frame.setVisible(False)\n ai_lo.addWidget(self._ai_custom_frame)\n self._load_assistant_config()\n left.addWidget(ai_card)\n left.addStretch()\n\n # ══════════════════════════════════════════\n # 右列:偏好 → 提示词 → 术语库 → Zotero\n # ══════════════════════════════════════════\n right = QVBoxLayout(); right.setSpacing(2)\n\n # ── 偏好设置 ──\n sl_app = QLabel(\"偏好设置\"); sl_app.setObjectName(\"SL\"); right.addWidget(sl_app)\n c = _card(); cl = QVBoxLayout(c); cl.setContentsMargins(16,12,16,12); cl.setSpacing(6)\n self.dark_check = QCheckBox(\"深色模式\"); self.dark_check.toggled.connect(self.dark_mode_changed.emit)\n self.cache_check = QCheckBox(\"翻译缓存\"); self.cache_check.setChecked(True)\n self.ai_check = QCheckBox(\"AI 布局检测\"); self.ai_check.setChecked(True)\n self.font_check = QCheckBox(\"字体子集化\")\n _pref_grid = QGridLayout(); _pref_grid.setSpacing(6)\n _pref_grid.addWidget(self.dark_check, 0, 0)\n _pref_grid.addWidget(self.cache_check, 0, 1)\n _pref_grid.addWidget(self.ai_check, 1, 0)\n _pref_grid.addWidget(self.font_check, 1, 1)\n cl.addLayout(_pref_grid)\n self.theme_row = QWidget(); tr_lo = QHBoxLayout(self.theme_row)\n tr_lo.setContentsMargins(0,2,0,2); tr_lo.setSpacing(6)\n tr_lbl = QLabel(\"主题色\"); tr_lo.addWidget(tr_lbl)\n THEME_COLORS = [\n (\"#0071E3\",\"蓝\"),(\"#FF6B6B\",\"红\"),(\"#34C759\",\"绿\"),\n (\"#AF52DE\",\"紫\"),(\"#FF9F0A\",\"橙\"),(\"#FF2D55\",\"粉\"),\n (\"#5AC8FA\",\"青\"),(\"#FFD60A\",\"黄\"),\n ]\n self._theme_dots = []\n for hex_c, name in THEME_COLORS:\n dot = QPushButton(); dot.setFixedSize(18, 18); dot.setCursor(Qt.PointingHandCursor)\n dot.setToolTip(name); dot.setProperty(\"hex\", hex_c)\n dot.setStyleSheet(\n f\"QPushButton{{background:{hex_c};border:2px solid transparent;\"\n f\"border-radius:9px;}}QPushButton:hover{{border-color:{hex_c};}}\")\n dot.clicked.connect(lambda _, h=hex_c: self._set_theme_color(h))\n tr_lo.addWidget(dot); self._theme_dots.append(dot)\n tr_lo.addStretch()\n cl.addWidget(self.theme_row)\n self.theme_row.setVisible(UserConfigManager.load().get(\"theme_unlocked\", False))\n right.addWidget(c)\n\n # ── 翻译提示词 ──\n from ui.prompt_manager import PromptTemplateManager\n sl4 = QLabel(\"翻译提示词\"); sl4.setObjectName(\"SL\"); right.addWidget(sl4)\n c4 = _card(); cl4 = QVBoxLayout(c4); cl4.setContentsMargins(16,10,16,10); cl4.setSpacing(2)\n pr = QHBoxLayout(); pr.setSpacing(4)\n self.prompt_preset = QComboBox()\n self._refresh_prompt_list()\n self.prompt_preset.currentTextChanged.connect(self._on_prompt_preset)\n pr.addWidget(self.prompt_preset, 1)\n cl4.addLayout(pr)\n self.prompt_edit = QTextEdit()\n self.prompt_edit.setPlaceholderText(\"提示词… {lang_out} {lang_in} {text}\")\n self.prompt_edit.setMaximumHeight(42)\n saved_prompt = cfg.get(\"prompt\", \"\")\n if saved_prompt: self.prompt_edit.setPlainText(saved_prompt)\n cl4.addWidget(self.prompt_edit)\n btn_row = QHBoxLayout(); btn_row.setSpacing(2)\n for label, slot in [\n (\"保存\", self._save_prompt_template),\n (\"新建\", self._new_prompt_template),\n (\"导入\", self._import_prompts),\n (\"导出\", self._export_prompts),\n ]:\n b = QPushButton(label); b.setObjectName(\"Gh\"); b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:11px;padding:2px 6px;\"); b.clicked.connect(slot)\n btn_row.addWidget(b)\n btn_row.addStretch()\n del_btn = QPushButton(\"删除\"); del_btn.setCursor(Qt.PointingHandCursor)\n del_btn.setObjectName(\"GhDanger\"); del_btn.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n del_btn.clicked.connect(self._delete_prompt_template)\n btn_row.addWidget(del_btn)\n cl4.addLayout(btn_row)\n right.addWidget(c4)\n\n # ── 术语库 ──\n from ui.glossary_manager import GlossaryManager\n sl5 = QLabel(\"术语库\"); sl5.setObjectName(\"SL\"); right.addWidget(sl5)\n c5 = _card(); cl5 = QVBoxLayout(c5); cl5.setContentsMargins(16,10,16,10); cl5.setSpacing(3)\n gr = QHBoxLayout(); gr.setSpacing(6)\n self.gloss_selector = QComboBox()\n self._refresh_gloss_list()\n self.gloss_selector.currentTextChanged.connect(self._on_gloss_changed)\n gr.addWidget(self.gloss_selector, 1)\n self.gloss_count = QLabel(\"\"); self.gloss_count.setObjectName(\"Cap\")\n self.gloss_count.setStyleSheet(\"font-size:10px;\")\n gr.addWidget(self.gloss_count)\n cl5.addLayout(gr); self._update_gloss_count()\n # 内联添加术语\n _add_row = QHBoxLayout(); _add_row.setSpacing(4)\n self._gloss_src = QLineEdit(); self._gloss_src.setPlaceholderText(\"原文\")\n self._gloss_src.setStyleSheet(\"font-size:11px;padding:4px 8px;\")\n self._gloss_dst = QLineEdit(); self._gloss_dst.setPlaceholderText(\"译文\")\n self._gloss_dst.setStyleSheet(\"font-size:11px;padding:4px 8px;\")\n _add_btn = QPushButton(\"添加\"); _add_btn.setObjectName(\"Gh\")\n _add_btn.setCursor(Qt.PointingHandCursor)\n _add_btn.setStyleSheet(\"font-size:11px;padding:2px 8px;\")\n _add_btn.clicked.connect(self._add_gloss_term)\n _add_row.addWidget(self._gloss_src, 1)\n _add_row.addWidget(self._gloss_dst, 1)\n _add_row.addWidget(_add_btn)\n cl5.addLayout(_add_row)\n gbtn = QHBoxLayout(); gbtn.setSpacing(2)\n for label, slot in [\n (\"新建\", self._new_glossary), (\"导入\", self._import_glossary),\n (\"导出\", self._export_glossary),\n ]:\n b = QPushButton(label); b.setObjectName(\"Gh\"); b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:11px;padding:2px 6px;\"); b.clicked.connect(slot)\n gbtn.addWidget(b)\n gbtn.addStretch()\n gdel = QPushButton(\"删除\"); gdel.setCursor(Qt.PointingHandCursor)\n gdel.setObjectName(\"GhDanger\"); gdel.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n gdel.clicked.connect(self._delete_glossary); gbtn.addWidget(gdel)\n cl5.addLayout(gbtn)\n right.addWidget(c5)\n\n # ── Zotero 联动 ──\n sl_zot = QLabel(\"Zotero 联动\"); sl_zot.setObjectName(\"SL\"); right.addWidget(sl_zot)\n zot_card = _card(); zot_lo = QVBoxLayout(zot_card)\n zot_lo.setContentsMargins(14,10,14,10); zot_lo.setSpacing(4)\n self._zot_sbs = QCheckBox(\"左右并排 (Side by Side)\")\n self._zot_dual = QCheckBox(\"双语对照 (Dual)\")\n self._zot_mono = QCheckBox(\"仅翻译 (Mono)\")\n self._zot_keep_copy = QCheckBox(\"保留本地副本\")\n cfg = UserConfigManager.load()\n _zot_modes = cfg.get(\"zotero_output_modes\", [\"side_by_side\"])\n self._zot_sbs.setChecked(\"side_by_side\" in _zot_modes)\n self._zot_dual.setChecked(\"dual\" in _zot_modes)\n self._zot_mono.setChecked(\"mono\" in _zot_modes)\n self._zot_keep_copy.setChecked(cfg.get(\"zotero_keep_copy\", True))\n _zot_grid = QGridLayout(); _zot_grid.setSpacing(4)\n _zot_grid.addWidget(self._zot_sbs, 0, 0)\n _zot_grid.addWidget(self._zot_dual, 0, 1)\n _zot_grid.addWidget(self._zot_mono, 1, 0)\n _zot_grid.addWidget(self._zot_keep_copy, 1, 1)\n for _cb in (self._zot_sbs, self._zot_dual, self._zot_mono, self._zot_keep_copy):\n _cb.stateChanged.connect(self._save_zotero_modes)\n zot_lo.addLayout(_zot_grid)\n zot_lo.addWidget(_div())\n _auto_row = QHBoxLayout(); _auto_row.setSpacing(6)\n _auto_desc = QLabel(\"Zotero 插件\"); _auto_desc.setObjectName(\"Cap\")\n _auto_row.addWidget(_auto_desc)\n self._zot_status = QLabel(\"\"); self._zot_status.setObjectName(\"Cap\")\n _auto_row.addWidget(self._zot_status); _auto_row.addStretch()\n self._zot_install_btn = QPushButton(\"一键安装\")\n self._zot_install_btn.setObjectName(\"Gh\"); self._zot_install_btn.setCursor(Qt.PointingHandCursor)\n self._zot_install_btn.clicked.connect(self._install_zotero_plugin)\n _auto_row.addWidget(self._zot_install_btn)\n zot_lo.addLayout(_auto_row)\n QTimer.singleShot(500, self._check_zotero_plugin)\n right.addWidget(zot_card)\n\n # ── 使用指南(紧凑) ──\n sl_guide = QLabel(\"使用指南\"); sl_guide.setObjectName(\"SL\"); right.addWidget(sl_guide)\n guide_card = _card(\"sm\"); guide_lo = QVBoxLayout(guide_card)\n guide_lo.setContentsMargins(12,8,12,8); guide_lo.setSpacing(1)\n for tip in [\n \"Google / Bing 无需配置,开箱即用\",\n \"OpenAI 兼容接口可对接任意第三方服务\",\n \"Ollama 本地需先启动服务\",\n \"50 页以上建议开启分块翻译\",\n ]:\n tl = QLabel(f\"· {tip}\"); tl.setObjectName(\"Cap\")\n tl.setStyleSheet(\"font-size:10px;padding:1px 0;\")\n guide_lo.addWidget(tl)\n guide_lo.addWidget(_div())\n self._kb_labels = []\n kb_grid = QGridLayout(); kb_grid.setHorizontalSpacing(6); kb_grid.setVerticalSpacing(1)\n for i, (key, desc) in enumerate([\n (\"Ctrl+滚轮\",\"缩放\"), (\"← →\",\"切换\"), (\"页码+回车\",\"跳转\"),\n ]):\n kl = QLabel(key); kl.setObjectName(\"KBKey\"); kl.setStyleSheet(\"font-size:9px;padding:2px 5px;\")\n kd = QLabel(desc); kd.setObjectName(\"Cap\"); kd.setStyleSheet(\"font-size:9px;\")\n kb_grid.addWidget(kl, 0, i * 2); kb_grid.addWidget(kd, 0, i * 2 + 1)\n self._kb_labels.append(kl)\n guide_lo.addLayout(kb_grid)\n guide_lo.addWidget(_div())\n _paths_col = QVBoxLayout(); _paths_col.setSpacing(1)\n for _pname, _pfile in [\n (\"配置\", \"~/.config/pdf2zh/config.json\"),\n (\"历史\", \"~/.config/pdf2zh/history.json\"),\n (\"术语库\", \"~/.config/pdf2zh/glossary.json\"),\n ]:\n _pl = QLabel(f\"{_pname} {_pfile}\")\n _pl.setObjectName(\"Cap\"); _pl.setStyleSheet(\"font-size:9px;padding:0;\")\n _paths_col.addWidget(_pl)\n guide_lo.addLayout(_paths_col)\n _paths_btn_row = QHBoxLayout(); _paths_btn_row.addStretch()\n open_dir_btn = QPushButton(\"打开数据目录\"); open_dir_btn.setObjectName(\"Gh\")\n open_dir_btn.setCursor(Qt.PointingHandCursor)\n open_dir_btn.setStyleSheet(\"font-size:10px;padding:2px 6px;\")\n open_dir_btn.clicked.connect(lambda: __import__('subprocess').run(['open', str(__import__('pathlib').Path.home())]))\n _paths_btn_row.addWidget(open_dir_btn)\n guide_lo.addLayout(_paths_btn_row)\n right.addWidget(guide_card)\n right.addStretch()\n\n cols.addLayout(left, 5)\n cols.addLayout(right, 4)\n lo.addLayout(cols)\n\n # 初始显示第一个服务\n self._show_service_config(self.svc_selector.currentText())\n\n def _show_service_config(self, svc_name):\n \"\"\"切换显示选中服务的配置\"\"\"\n # 清空当前\n while self.svc_layout.count():\n item = self.svc_layout.takeAt(0)\n w = item.widget()\n if w: w.setParent(None)\n sub = item.layout()\n if sub:\n while sub.count():\n si = sub.takeAt(0)\n if si.widget(): si.widget().setParent(None)\n\n if svc_name not in self.api_inputs:\n return\n inputs = self.api_inputs[svc_name]\n\n # API Key\n kl = QLabel(\"API Key\"); kl.setObjectName(\"FL\")\n self.svc_layout.addWidget(kl)\n self.svc_layout.addWidget(inputs[\"key\"])\n inputs[\"key\"].setParent(self.svc_card)\n inputs[\"key\"].show()\n\n # 模型\n if \"model\" in inputs:\n ml = QLabel(\"模型\"); ml.setObjectName(\"FL\")\n self.svc_layout.addWidget(ml)\n self.svc_layout.addWidget(inputs[\"model\"])\n inputs[\"model\"].setParent(self.svc_card)\n inputs[\"model\"].show()\n\n # Base URL\n if \"url\" in inputs:\n ul = QLabel(\"Base URL\"); ul.setObjectName(\"FL\")\n self.svc_layout.addWidget(ul)\n self.svc_layout.addWidget(inputs[\"url\"])\n inputs[\"url\"].setParent(self.svc_card)\n inputs[\"url\"].show()\n\n # 测试连接按钮\n self.svc_layout.addSpacing(4)\n self._test_label = QLabel(\"\")\n self._test_label.setObjectName(\"Cap\")\n test_btn = QPushButton(\"测试连接\")\n test_btn.setObjectName(\"Gh\"); test_btn.setCursor(Qt.PointingHandCursor)\n test_btn.clicked.connect(lambda: self._test_connection(svc_name))\n test_row = QHBoxLayout(); test_row.addStretch()\n test_row.addWidget(self._test_label)\n test_row.addWidget(test_btn)\n self.svc_layout.addLayout(test_row)\n\n def _test_connection(self, svc_name):\n \"\"\"测试翻译服务连接\"\"\"\n self._save() # 先保存,确保 API Key 写入配置\n self._test_label.setText(\"测试中…\")\n self._test_label.setStyleSheet(\"color:#FF9F0A;font-size:11px;\")\n QApplication.processEvents()\n\n inputs = self.api_inputs.get(svc_name, {})\n key = inputs.get(\"key\")\n key_val = key.text().strip() if key else \"\"\n url_w = inputs.get(\"url\")\n url_val = url_w.text().strip() if url_w else \"\"\n\n try:\n import requests\n # 对于 OpenAI 兼容类服务,测试 /models 端点\n if url_val and (\"openai\" in url_val or \"deepseek\" in url_val\n or \"siliconflow\" in url_val or \"groq\" in url_val\n or \"bigmodel\" in url_val or \"dashscope\" in url_val\n or \"localhost\" in url_val or \"x.ai\" in url_val):\n headers = {\"Authorization\": f\"Bearer {key_val}\"} if key_val else {}\n r = requests.get(f\"{url_val}/models\", headers=headers, timeout=10)\n if r.status_code in (200, 401, 403):\n self._test_label.setText(\"✅ 连接成功\")\n self._test_label.setStyleSheet(f\"color:{_C['ok']};font-size:11px;\")\n else:\n self._test_label.setText(f\"⚠️ HTTP {r.status_code}\")\n self._test_label.setStyleSheet(f\"color:{_C['t2']};font-size:11px;\")\n elif \"deepl\" in svc_name.lower():\n r = requests.get(\"https://api-free.deepl.com/v2/usage\",\n headers={\"Authorization\": f\"DeepL-Auth-Key {key_val}\"}, timeout=10)\n self._test_label.setText(\"✅ 连接成功\" if r.ok else f\"⚠️ {r.status_code}\")\n self._test_label.setStyleSheet(f\"color:{_C['ok'] if r.ok else _C['t2']};font-size:11px;\")\n else:\n self._test_label.setText(\"✅ 免费服务,无需测试\")\n self._test_label.setStyleSheet(f\"color:{_C['ok']};font-size:11px;\")\n except requests.exceptions.ConnectionError:\n self._test_label.setText(\"❌ 无法连接\")\n self._test_label.setStyleSheet(f\"color:{_C['err']};font-size:11px;\")\n except Exception as e:\n self._test_label.setText(f\"❌ {str(e)[:30]}\")\n self._test_label.setStyleSheet(f\"color:{_C['err']};font-size:11px;\")\n\n def _refresh_prompt_list(self):\n from ui.prompt_manager import PromptTemplateManager\n self.prompt_preset.blockSignals(True)\n current = self.prompt_preset.currentText()\n self.prompt_preset.clear()\n templates = PromptTemplateManager.load_all()\n self.prompt_preset.addItems(list(templates.keys()))\n idx = self.prompt_preset.findText(current)\n if idx >= 0:\n self.prompt_preset.setCurrentIndex(idx)\n self.prompt_preset.blockSignals(False)\n\n def _on_prompt_preset(self, text):\n from ui.prompt_manager import PromptTemplateManager\n templates = PromptTemplateManager.load_all()\n val = templates.get(text, \"\")\n self.prompt_edit.setPlainText(val)\n self._save()\n\n def _save_prompt_template(self):\n \"\"\"保存当前内容到选中的模板名\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n name = self.prompt_preset.currentText()\n content = self.prompt_edit.toPlainText().strip()\n PromptTemplateManager.save_template(name, content)\n self._refresh_prompt_list()\n\n def _new_prompt_template(self):\n \"\"\"新建模板\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n name, ok = QLineEdit.staticMetaObject, False # placeholder\n # 用简单的输入对话框\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建模板\", \"模板名称:\")\n if ok and name.strip():\n content = self.prompt_edit.toPlainText().strip()\n PromptTemplateManager.save_template(name.strip(), content)\n self._refresh_prompt_list()\n idx = self.prompt_preset.findText(name.strip())\n if idx >= 0:\n self.prompt_preset.setCurrentIndex(idx)\n\n def _delete_prompt_template(self):\n \"\"\"删除用户模板\"\"\"\n from ui.prompt_manager import PromptTemplateManager, DEFAULT_TEMPLATES\n name = self.prompt_preset.currentText()\n if name in DEFAULT_TEMPLATES:\n return # 不能删默认模板\n PromptTemplateManager.delete_template(name)\n self._refresh_prompt_list()\n\n def _import_prompts(self):\n \"\"\"从 JSON 文件导入模板\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n path, _ = QFileDialog.getOpenFileName(self, \"导入模板\", \"\", \"JSON (*.json)\")\n if path:\n count = PromptTemplateManager.import_from_file(path)\n self._refresh_prompt_list()\n\n def _export_prompts(self):\n \"\"\"导出所有模板到 JSON\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n path, _ = QFileDialog.getSaveFileName(self, \"导出模板\", \"pdf2zh_prompts.json\", \"JSON (*.json)\")\n if path:\n PromptTemplateManager.export_to_file(path)\n\n def _set_theme_color(self, hex_color):\n \"\"\"切换主题色并即时应用\"\"\"\n cfg = UserConfigManager.load()\n cfg[\"theme_color\"] = hex_color\n UserConfigManager.save(cfg)\n # 高亮选中的色块\n for dot in self._theme_dots:\n sel = dot.property(\"hex\") == hex_color\n dot.setStyleSheet(\n f\"QPushButton{{background:{dot.property('hex')};\"\n f\"border:2px solid {'#1d1d1f' if sel else 'transparent'};\"\n f\"border-radius:9px;}}QPushButton:hover{{border-color:{dot.property('hex')};}}\")\n # 通知 MainWindow 刷新主题\n self.theme_color_changed.emit(hex_color)\n\n def _save(self, *args):\n cfg = UserConfigManager.load()\n for svc_name, inputs in self.api_inputs.items():\n key_val = inputs[\"key\"].text().strip()\n cfg[f\"api_{svc_name}\"] = UserConfigManager.encode_sensitive(key_val) if key_val else \"\"\n if \"model\" in inputs:\n cfg[f\"model_{svc_name}\"] = inputs[\"model\"].currentText().strip()\n if \"url\" in inputs:\n cfg[f\"url_{svc_name}\"] = inputs[\"url\"].text().strip()\n cfg[\"prompt\"] = self.prompt_edit.toPlainText().strip()\n UserConfigManager.save(cfg)\n\n def _save_zotero_modes(self, *args):\n modes = []\n if self._zot_sbs.isChecked(): modes.append(\"side_by_side\")\n if self._zot_dual.isChecked(): modes.append(\"dual\")\n if self._zot_mono.isChecked(): modes.append(\"mono\")\n if not modes:\n modes = [\"side_by_side\"]\n self._zot_sbs.blockSignals(True)\n self._zot_sbs.setChecked(True)\n self._zot_sbs.blockSignals(False)\n cfg = UserConfigManager.load()\n cfg[\"zotero_output_modes\"] = modes\n cfg[\"zotero_keep_copy\"] = self._zot_keep_copy.isChecked()\n UserConfigManager.save(cfg)\n\n # ── AI 助手配置 ──\n\n def _load_assistant_config(self):\n \"\"\"加载 AI 助手独立配置状态\"\"\"\n cfg = UserConfigManager.load()\n is_custom = cfg.get(\"assistant_custom\", False)\n if is_custom:\n self._ai_custom_frame.setVisible(True)\n self._ai_status_label.setText(\"使用独立配置\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['acc']};background:transparent;\")\n self._ai_toggle_btn.setVisible(False)\n svc = cfg.get(\"assistant_service\", \"\")\n if svc:\n idx = self._ai_svc_combo.findText(svc)\n if idx >= 0: self._ai_svc_combo.setCurrentIndex(idx)\n api_raw = cfg.get(\"assistant_api_key\", \"\")\n if api_raw:\n self._ai_key_input.setText(UserConfigManager.decode_sensitive(api_raw))\n model = cfg.get(\"assistant_model\", \"\")\n if model: self._ai_model_combo.setCurrentText(model)\n url = cfg.get(\"assistant_url\", \"\")\n if url: self._ai_url_input.setText(url)\n else:\n self._ai_custom_frame.setVisible(False)\n self._ai_status_label.setText(\"✓ 使用翻译服务配置\")\n self._ai_toggle_btn.setText(\"自定义\")\n\n def _toggle_assistant_config(self):\n \"\"\"展开自定义 AI 助手配置\"\"\"\n self._ai_custom_frame.setVisible(True)\n self._ai_status_label.setText(\"使用独立配置\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['acc']};background:transparent;\")\n self._ai_toggle_btn.setVisible(False)\n # 预填当前翻译服务配置\n from ui.ai_client import detect_service\n svc = detect_service()\n if svc:\n idx = self._ai_svc_combo.findText(svc[\"name\"])\n if idx >= 0:\n self._ai_svc_combo.blockSignals(True)\n self._ai_svc_combo.setCurrentIndex(idx)\n self._ai_svc_combo.blockSignals(False)\n self._ai_key_input.setText(svc.get(\"api_key\", \"\"))\n self._ai_model_combo.setCurrentText(svc.get(\"model\", \"\"))\n self._ai_url_input.setText(svc.get(\"base_url\", \"\"))\n self._on_assistant_svc_changed(self._ai_svc_combo.currentText())\n self._save_assistant_config()\n\n def _on_assistant_svc_changed(self, svc_name):\n \"\"\"切换 AI 助手服务时更新模型候选列表\"\"\"\n from ui.ai_client import CHAT_SERVICES\n # 查找默认 URL\n default_url = \"\"\n for name, url, _ in CHAT_SERVICES:\n if name == svc_name:\n default_url = url; break\n if not self._ai_url_input.text().strip() or True:\n self._ai_url_input.setPlaceholderText(default_url or \"自定义 URL\")\n # 更新模型列表\n cur_model = self._ai_model_combo.currentText()\n self._ai_model_combo.blockSignals(True)\n self._ai_model_combo.clear()\n svc_conf = self.SERVICE_CONFIGS.get(svc_name, {})\n if svc_conf.get(\"models\"):\n self._ai_model_combo.addItems(svc_conf[\"models\"])\n if cur_model:\n self._ai_model_combo.setCurrentText(cur_model)\n self._ai_model_combo.blockSignals(False)\n self._save_assistant_config()\n\n def _save_assistant_config(self, *args):\n \"\"\"保存 AI 助手独立配置\"\"\"\n if not self._ai_custom_frame.isVisible():\n return\n cfg = UserConfigManager.load()\n cfg[\"assistant_custom\"] = True\n cfg[\"assistant_service\"] = self._ai_svc_combo.currentText()\n key = self._ai_key_input.text().strip()\n cfg[\"assistant_api_key\"] = UserConfigManager.encode_sensitive(key) if key else \"\"\n cfg[\"assistant_model\"] = self._ai_model_combo.currentText()\n cfg[\"assistant_url\"] = self._ai_url_input.text().strip()\n UserConfigManager.save(cfg)\n\n def _reset_assistant_config(self):\n \"\"\"恢复 AI 助手使用翻译服务配置\"\"\"\n cfg = UserConfigManager.load()\n for k in (\"assistant_custom\", \"assistant_service\", \"assistant_api_key\",\n \"assistant_model\", \"assistant_url\"):\n cfg.pop(k, None)\n UserConfigManager.save(cfg)\n self._ai_custom_frame.setVisible(False)\n self._ai_status_label.setText(\"与翻译服务相同\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['ok']};background:transparent;\")\n self._ai_toggle_btn.setVisible(True)\n # 清空输入\n self._ai_key_input.clear()\n self._ai_model_combo.setCurrentIndex(0)\n self._ai_url_input.clear()\n\n def _install_zotero_plugin(self):\n \"\"\"一键安装 pdf2zh Connector 到 Zotero\"\"\"\n import glob, shutil, subprocess\n xpi = _res('assets', 'pdf2zh-connector.xpi')\n if not os.path.exists(xpi):\n self._zot_status.setText(\"插件文件缺失\")\n self._zot_status.setStyleSheet(\"color:#FF3B30;\")\n return\n # 查找 Zotero profile 目录\n profiles_dir = os.path.expanduser(\"~/Library/Application Support/Zotero/Profiles\")\n profiles = glob.glob(os.path.join(profiles_dir, \"*.default*\"))\n if not profiles:\n self._zot_status.setText(\"找不到 Zotero 配置目录\")\n self._zot_status.setStyleSheet(\"color:#FF3B30;\")\n return\n profile = profiles[0]\n # 1. 复制 XPI 到 extensions 目录\n ext_dir = os.path.join(profile, \"extensions\")\n os.makedirs(ext_dir, exist_ok=True)\n dst = os.path.join(ext_dir, \"pdf2zh-connector@aarongig.com.xpi\")\n shutil.copy2(xpi, dst)\n # 2. 设置 autoDisableScopes=0 允许 profile 级别的扩展自动加载\n # v2.3.2 (对齐 issue #26 修复): 改 prefs.js 前先备份 + 出错时 rollback,\n # 保证不会比之前效果差。同时备份 extensions.json 作为 catalog 出问题时的兜底\n prefs_path = os.path.join(profile, \"prefs.js\")\n pref_line = 'user_pref(\"extensions.autoDisableScopes\", 0);\\n'\n # 备份 extensions.json (即使 Mac 版不动它,兜底给用户手动 rollback)\n ext_json_path = os.path.join(profile, \"extensions.json\")\n if os.path.exists(ext_json_path):\n try:\n shutil.copy2(ext_json_path, ext_json_path + \".pdf2zh-backup\")\n except Exception:\n pass\n try:\n prefs_content = open(prefs_path, 'r', encoding='utf-8').read()\n if 'autoDisableScopes' not in prefs_content:\n # 先备份再改\n try:\n shutil.copy2(prefs_path, prefs_path + \".pdf2zh-backup\")\n except Exception:\n pass\n with open(prefs_path, 'a', encoding='utf-8') as f:\n f.write(pref_line)\n except FileNotFoundError:\n with open(prefs_path, 'w', encoding='utf-8') as f:\n f.write(pref_line)\n # 3. 清除启动缓存,强制 Zotero 重新扫描扩展\n cache = os.path.join(profile, \"addonStartup.json.lz4\")\n if os.path.exists(cache):\n os.remove(cache)\n # 询问是否重启 Zotero\n reply = QMessageBox.question(\n self, \"安装成功\",\n \"pdf2zh Connector 已部署到 Zotero,重启 Zotero 后生效。\\n\\n现在重启 Zotero?\",\n QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes\n )\n if reply == QMessageBox.Yes:\n subprocess.run([\"pkill\", \"-x\", \"zotero\"], capture_output=True)\n subprocess.run([\"pkill\", \"-x\", \"Zotero\"], capture_output=True)\n QTimer.singleShot(2000, lambda: subprocess.Popen([\"open\", \"-a\", \"Zotero\"]))\n QTimer.singleShot(10000, self._check_zotero_plugin)\n self._zot_status.setText(\"正在重启 Zotero…\")\n self._zot_status.setStyleSheet(\"color:#0071E3;\")\n else:\n self._zot_status.setText(\"下次启动 Zotero 后生效\")\n self._zot_status.setStyleSheet(\"color:#0071E3;\")\n\n def _check_zotero_plugin(self):\n \"\"\"检测 pdf2zh Connector 插件状态\"\"\"\n if zotero_plugin_installed():\n self._zot_status.setText(\"已安装\")\n self._zot_status.setStyleSheet(\"color:#34C759;font-weight:600;\")\n self._zot_install_btn.hide()\n else:\n self._zot_status.setText(\"未安装\")\n self._zot_install_btn.setText(\"一键安装\")\n self._zot_install_btn.show()\n self._zot_install_btn.setEnabled(True)\n\n # ── 术语库操作 ──\n\n def _add_gloss_term(self):\n \"\"\"内联添加一条术语\"\"\"\n src = self._gloss_src.text().strip()\n dst = self._gloss_dst.text().strip()\n if not src or not dst:\n return\n from ui.glossary_manager import GlossaryManager\n GlossaryManager.add_term(src, dst)\n self._gloss_src.clear(); self._gloss_dst.clear()\n self._update_gloss_count()\n\n def _refresh_gloss_list(self):\n from ui.glossary_manager import GlossaryManager\n self.gloss_selector.blockSignals(True)\n cur = self.gloss_selector.currentText()\n self.gloss_selector.clear()\n all_g = GlossaryManager.load_all_presets()\n self.gloss_selector.addItems(list(all_g.keys()))\n idx = self.gloss_selector.findText(cur)\n if idx >= 0: self.gloss_selector.setCurrentIndex(idx)\n self.gloss_selector.blockSignals(False)\n\n def _on_gloss_changed(self, name):\n from ui.glossary_manager import GlossaryManager\n GlossaryManager.load_preset(name)\n self._update_gloss_count()\n\n def _update_gloss_count(self):\n from ui.glossary_manager import GlossaryManager\n n = GlossaryManager.count()\n self.gloss_count.setText(f\"{n} 条术语已激活\" if n else \"未加载术语\")\n\n def _new_glossary(self):\n \"\"\"新建自定义术语库\"\"\"\n from ui.glossary_manager import GlossaryManager\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建术语库\", \"术语库名称:\")\n if ok and name.strip():\n name = name.strip()\n # 在用户目录下创建空术语库文件\n import json\n from pathlib import Path\n from ui.config_manager import _config_dir\n user_dir = _config_dir() / \"glossaries\"\n user_dir.mkdir(exist_ok=True)\n fp = user_dir / f\"{name}.json\"\n fp.write_text(json.dumps({}, indent=2, ensure_ascii=False), encoding=\"utf-8\")\n self._refresh_gloss_list()\n idx = self.gloss_selector.findText(name)\n if idx >= 0:\n self.gloss_selector.setCurrentIndex(idx)\n\n def _delete_glossary(self):\n \"\"\"删除用户自定义术语库(不能删预设)\"\"\"\n from ui.glossary_manager import GlossaryManager, DEFAULT_GLOSSARIES\n from pathlib import Path\n name = self.gloss_selector.currentText()\n if name in DEFAULT_GLOSSARIES:\n return\n from ui.config_manager import _config_dir\n fp = _config_dir() / \"glossaries\" / f\"{name}.json\"\n if fp.exists():\n fp.unlink()\n self._refresh_gloss_list()\n self._update_gloss_count()\n\n def _import_glossary(self):\n from ui.glossary_manager import GlossaryManager\n path, _ = QFileDialog.getOpenFileName(self, \"导入术语库\", \"\", \"CSV (*.csv);;JSON (*.json)\")\n if path:\n if path.endswith('.csv'):\n GlossaryManager.import_csv(path)\n else:\n GlossaryManager.import_json(path)\n self._update_gloss_count()\n self._refresh_gloss_list()\n\n def _export_glossary(self):\n from ui.glossary_manager import GlossaryManager\n path, _ = QFileDialog.getSaveFileName(self, \"导出术语库\", \"glossary.csv\", \"CSV (*.csv);;JSON (*.json)\")\n if path:\n if path.endswith('.json'):\n GlossaryManager.export_json(path)\n else:\n GlossaryManager.export_csv(path)\n\n def update_theme(self, c):\n \"\"\"深色/浅色切换时更新内联样式\"\"\"\n self.prompt_edit.setStyleSheet(\n f\"font-size:11px;border-radius:6px;background:{c['inp']};color:{c['t1']};\"\n f\"border:1px solid {c['inp_b']};\"\n )\n # 刷新主题色行可见性(可能骰子刚解锁)\n cfg = UserConfigManager.load()\n self.theme_row.setVisible(cfg.get(\"theme_unlocked\", False))\n\n\n# ═══════════════════════════════════════════════════════════════\n# 关于页面\n# ═══════════════════════════════════════════════════════════════\n\nclass AboutPage(QWidget):\n def __init__(self):\n super().__init__()\n from PyQt5.QtWidgets import QGridLayout\n lo = QVBoxLayout(self); lo.setContentsMargins(32,20,32,20); lo.setSpacing(12)\n\n # 顶部:名称 + 版本 + GitHub\n top = QHBoxLayout(); top.setSpacing(8)\n ti = _EggLogo(\"📄\", 22)\n ti.setFixedSize(32, 32); ti.setAlignment(Qt.AlignCenter)\n top.addWidget(ti)\n tn = QPushButton(\"pdf2zh-desktop\"); tn.setObjectName(\"SBLink\")\n tn.setStyleSheet(\"font-size:18px;font-weight:700;padding:0;text-align:left;\")\n tn.setCursor(Qt.PointingHandCursor); tn.setFlat(True)\n tn.clicked.connect(lambda: webbrowser.open(\"https://github.com/AaronGIG/pdf2zh-desktop\"))\n top.addWidget(tn)\n tv = QLabel(\"v2.3.6\"); tv.setObjectName(\"Cap\"); top.addWidget(tv)\n tt = QLabel(\"macOS\"); tt.setObjectName(\"Tag\"); tt.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed); top.addWidget(tt)\n top.addStretch()\n gb = QPushButton(\"GitHub ↗\"); gb.setObjectName(\"Gh\"); gb.setCursor(Qt.PointingHandCursor)\n gb.clicked.connect(lambda: webbrowser.open(\"https://github.com/AaronGIG/pdf2zh-desktop\"))\n top.addWidget(gb)\n lo.addLayout(top)\n\n # 两列布局:左=介绍+增强,右=作者+支持者\n cols = QHBoxLayout(); cols.setSpacing(16)\n\n # ── 左列 ──\n left = QVBoxLayout(); left.setSpacing(6)\n\n dc = _card(); dl = QVBoxLayout(dc); dl.setContentsMargins(16,12,16,12); dl.setSpacing(4)\n dt = QLabel(\"开箱即用的学术 PDF 翻译工具\"); dt.setStyleSheet(\"font-size:12px;font-weight:600;\"); dl.addWidget(dt)\n dd = QLabel(\"保留排版 · 公式无损 · 批量任务 · 历史追踪 · 20+ 翻译服务\")\n dd.setObjectName(\"Cap\"); dd.setStyleSheet(\"font-size:11px;\"); dd.setWordWrap(True); dl.addWidget(dd)\n left.addWidget(dc)\n\n mc = _card(); ml = QVBoxLayout(mc); ml.setContentsMargins(16,10,16,10); ml.setSpacing(3)\n mt = QLabel(\"macOS 版增强\"); mt.setStyleSheet(\"font-size:12px;font-weight:600;\"); ml.addWidget(mt)\n for emoji, text in [\n (\"🎨\",\"原生设计 · 深色模式 · 自定义主题色\"),\n (\"👀\",\"Dual · Mono · Side by Side 预览\"),\n (\"📖\",\"历史即时预览 · 上下键浏览\"),\n (\"✂️\",\"智能分块 · 1000+页\"),\n (\"🖐\",\"触摸板缩放 · 页码跳转\"),\n (\"📝\",\"术语库 · 提示词模板\"),\n (\"🍀\",\"每日骰子 · 隐藏彩蛋\"),\n (\"💬\",\"全时段人文关怀 · 名言语录\"),\n (\"🚀\",\".app 双击启动 · Retina\"),\n ]:\n r = QHBoxLayout(); r.setSpacing(6)\n e = QLabel(emoji); e.setStyleSheet(\"font-size:13px;background:transparent;\"); e.setFixedWidth(18); r.addWidget(e)\n t2 = QLabel(text); t2.setObjectName(\"Cap\"); t2.setStyleSheet(\"font-size:11px;\"); r.addWidget(t2)\n ml.addLayout(r)\n ml.addWidget(_div())\n te = QLabel(\"基于 PDFMathTranslate (EMNLP 2025) · PyQt5 · PyMuPDF · OnnxRuntime\")\n te.setObjectName(\"Cap\"); te.setStyleSheet(\"font-size:10px;\"); te.setWordWrap(True)\n ml.addWidget(te)\n left.addWidget(mc)\n left.addStretch()\n\n cols.addLayout(left, 3)\n\n # ── 右列 ──\n right = QVBoxLayout(); right.setSpacing(6)\n\n # 作者\n ac = _card(); acl = QHBoxLayout(ac); acl.setContentsMargins(16,12,16,12); acl.setSpacing(10)\n author_av = QLabel()\n author_av.setFixedSize(36, 36)\n avatar_path = _res('assets', 'author_avatar.png')\n if os.path.exists(avatar_path):\n apx = QPixmap(avatar_path).scaled(72, 72, Qt.KeepAspectRatio, Qt.SmoothTransformation)\n apx.setDevicePixelRatio(2.0)\n author_av.setPixmap(apx)\n else:\n author_av.setText(\"艾\"); author_av.setAlignment(Qt.AlignCenter)\n author_av.setStyleSheet(\"background:#1a1a2e;border-radius:18px;color:white;font-size:16px;font-weight:700;\")\n acl.addWidget(author_av)\n ai = QVBoxLayout(); ai.setSpacing(0)\n an = QLabel(\"艾伦说\"); an.setStyleSheet(\"font-size:13px;font-weight:700;\"); ai.addWidget(an)\n xhs = QLabel(\"小红书: needsleeeeep\"); xhs.setObjectName(\"Cap\"); xhs.setStyleSheet(\"font-size:11px;\"); ai.addWidget(xhs)\n motto = QLabel(\"希望能做更有意义的事 · 专注交付生产级的垂直学术公共品 🍀\")\n motto.setObjectName(\"Cap\"); motto.setStyleSheet(\"font-size:10px;color:rgba(142,142,147,0.7);\"); ai.addWidget(motto)\n acl.addLayout(ai); acl.addStretch()\n xhs_btn = QPushButton(\"关注 ↗\"); xhs_btn.setObjectName(\"Gh\"); xhs_btn.setCursor(Qt.PointingHandCursor)\n xhs_btn.setStyleSheet(\"font-size:11px;\")\n xhs_btn.clicked.connect(lambda: webbrowser.open(\"https://www.xiaohongshu.com/user/profile/66c6fef7000000001d0315ef\"))\n acl.addWidget(xhs_btn)\n right.addWidget(ac)\n\n # 支持者头像墙\n cc = _card(); ccl = QVBoxLayout(cc); ccl.setContentsMargins(16,12,16,12); ccl.setSpacing(6)\n ct = QLabel(\"感谢小红书社区支持者 ♡\"); ct.setStyleSheet(\"font-size:11px;font-weight:600;\"); ccl.addWidget(ct)\n\n supporters = [\n \"星爷!\",\"卧\",\"Jun warrior\",\"幼儿园鹿小眸\",\"侠禅\",\n \"ol\",\"幼儿园老大\",\"MX\",\"贝斯特宋\",\"Catherine\",\n \"不哩不哩左门卫\",\"momo\",\"李善兰\",\"问道不求仙\",\"hml\",\n \"ThereisTherse\",\"人类与猫\",\"小红薯643C3625\",\"月栖竹\",\"风来\",\n \"Nick\",\"咕噜咕噜🍗\",\"雾散时分起\",\"大脸咪布爱吃鱼\",\"限定 momo\",\n \"麦兜\",\"锅的刚\",\"一颗冒泡的卤蛋\",\"我草莓招了\",\n \"去Nature整点论文\",\"Masker\",\n \"AI maker趣造\",\"帕克的创业日记\",\"思维汪汪\",\"橘座\",\"小白也想学编程\",\n \"宛风Vanfeng\",\"小宝の日常\",\"未来百科\",\"你们的万能小卓\",\"碎银几两\",\n \"创界AIzine\",\"全栈小5\",\"无敌霸王龙\",\"小艾同学\",\"一万块的快乐\",\n \"学习笔记\",\"三丰不是张\",\"丁一\",\"阿漫AIChat\",\"知命不惧\",\n \"脱离社畜体制\",\"一瓢清浅\",\"Crazyang\",\"逛逛GitHub\",\"AI视界AIGC\",\n \"极客梦想家\",\"沐沐子\",\"Topaz\",\"三不沾\",\"EchoAI\",\n \"阳台吹风\",\"Cursor实战派\",\"简单就好\",\"进击的小学生\",\"AI探索家\",\n \"科技小飞侠\",\"数字游民\",\"AI能量站\",\"工程师小灰\",\"AI淘金\",\n \"量子比特\",\"NeonCode\",\"尝试新事物的Cher\",\"有趣的灵魂不需要名字\",\n \"阿杰的编程日记\",\"小明同学\",\"码农翻身\",\"AI大航海\",\"月亮与六便士\",\n \"渡己\",\"数码宝贝\",\"悟空AI\",\"追风少年\",\"Tech小确幸\",\n \"半糖主义\",\"深夜程序员\",\"AI小天才\",\"知行合一\",\"星辰大海\",\n \"比特流\",\"指尖上的代码\",\"未来可期\",\"云端漫步\",\"AI引路人\",\n \"秋风扫落叶\",\"编程小王子\",\"日拱一卒\",\"逆风翻盘\",\"AI百宝箱\",\n \"程序猿日记\",\"数据炼金术\",\"量子纠缠\",\"无限可能\",\"风轻云淡\",\n \"代码人生\",\"AI学徒\",\"机器之心\",\"清风明月\",\"代码诗人\",\n \"零一万物\",\"浮生若梦\",\"技术宅\",\"AI造物主\",\"星河万里\",\n \"小蜗牛\",\"编程少女\",\"数据猎人\",\"逻辑大师\",\"AI前沿\",\n \"梦想家\",\"代码如诗\",\"量子跃迁\",\"技术探路者\",\"AI新青年\",\n \"Tomo\",\"小红薯63BEBA60\",\"Answer\",\n \"C01dSH\",\"小红薯60E9F048\",\"Hittagi\",\"雪儿\",\"デブ\",\n \"根本赢不了\",\"七一\",\"Somnia Flora\",\n ]\n\n grid = QGridLayout(); grid.setSpacing(3); grid.setContentsMargins(0,0,0,0)\n cols_n = 12\n for i, name in enumerate(supporters):\n avatar = QLabel(name[0])\n avatar.setFixedSize(26, 26); avatar.setAlignment(Qt.AlignCenter)\n h = (hash(name) * 137) % 360\n s = 50 + (hash(name) >> 8) % 20; l2 = 55 + (hash(name) >> 16) % 15\n avatar.setStyleSheet(f\"background:hsl({h},{s}%,{l2}%);border-radius:13px;font-size:9px;font-weight:600;color:white;\")\n avatar.setToolTip(name)\n grid.addWidget(avatar, i // cols_n, i % cols_n, Qt.AlignCenter)\n ccl.addLayout(grid)\n sub = QLabel(\"无限迭代,只为更好的服务您\")\n sub.setObjectName(\"Cap\"); sub.setAlignment(Qt.AlignCenter); sub.setStyleSheet(\"font-size:11px;\"); ccl.addWidget(sub)\n qq_group_btn = QPushButton(\"进入 pdf2zh 桌面版交流群\"); qq_group_btn.setObjectName(\"Gh\"); qq_group_btn.setCursor(Qt.PointingHandCursor)\n qq_group_btn.setStyleSheet(\"font-size:11px;\")\n def _show_qq_qr():\n QApplication.clipboard().setText(\"1094195179\")\n c = _C\n qr_dlg = QDialog(self)\n qr_dlg.setWindowTitle(\"加入交流群\")\n qr_dlg.setStyleSheet(f\"QDialog{{background:{c['bg']};}}\")\n qlo = QVBoxLayout(qr_dlg); qlo.setContentsMargins(0, 0, 0, 12); qlo.setSpacing(8)\n qr_label = QLabel()\n qr_label.setAlignment(Qt.AlignCenter)\n qr_path = None\n for candidate in [\n os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'assets', 'qq_group_qr.png'),\n os.path.join(getattr(sys, '_MEIPASS', ''), 'assets', 'qq_group_qr.png'),\n ]:\n if os.path.exists(candidate):\n qr_path = candidate\n break\n if qr_path:\n pix = QPixmap(qr_path)\n # setScaledContents 让图片自动填满 label,不裁切\n qr_label.setPixmap(pix)\n qr_label.setScaledContents(True)\n qr_label.setFixedSize(300, int(300 * pix.height() / pix.width()))\n qr_dlg.setFixedSize(300, int(300 * pix.height() / pix.width()) + 40)\n else:\n qr_label.setText(\"请用 QQ 搜索群号 1094195179\")\n qr_label.setStyleSheet(f\"font-size:13px;color:{c['t2']};padding:30px;\")\n qr_dlg.setFixedSize(300, 100)\n qlo.addWidget(qr_label)\n tip = QLabel(\"群号 1094195179 已复制到剪贴板\")\n tip.setAlignment(Qt.AlignCenter)\n tip.setStyleSheet(f\"font-size:11px;color:{c['acc']};\")\n qlo.addWidget(tip)\n qr_dlg.exec_()\n qq_group_btn.clicked.connect(_show_qq_qr)\n ccl.addWidget(qq_group_btn, 0, Qt.AlignCenter)\n right.addWidget(cc)\n right.addStretch()\n\n cols.addLayout(right, 7)\n\n lo.addLayout(cols)\n\n\n# ═══════════════════════════════════════════════════════════════\n# 主窗口\n# ═══════════════════════════════════════════════════════════════\n\n# ── 桌面宠物:小黑猫 ──────────────────────────────────────────\n\nclass _NekoCat(QWidget):\n \"\"\"会走来走去的小黑猫彩蛋 — 多种行为模式\"\"\"\n\n # 行为模式\n WALK, RUN, SIT, SLEEP = 0, 1, 2, 3\n\n def __init__(self, parent):\n super().__init__(parent)\n self.setFixedSize(48, 36)\n self._frame = 0\n self._dir = 1\n self._mode = self.WALK\n self._speed = 2\n self._sit_counter = 0\n self._step_timer = QTimer(self)\n self._step_timer.timeout.connect(self._on_step)\n self.setCursor(Qt.PointingHandCursor)\n self.hide()\n\n def start_walk(self):\n \"\"\"从窗口一侧走到另一侧\"\"\"\n parent = self.parentWidget()\n if not parent:\n return\n import random\n pw, ph = parent.width(), parent.height()\n self._dir = random.choice([1, -1])\n y = ph - 50\n if self._dir == 1:\n self._x = float(-self.width())\n self._x_end = float(pw + self.width())\n else:\n self._x = float(pw + self.width())\n self._x_end = float(-self.width())\n # 随机选行为\n roll = random.random()\n if roll < 0.60:\n self._mode = self.WALK; self._speed = 1.5\n elif roll < 0.80:\n self._mode = self.SIT; self._speed = 1.5\n # 走到中间附近时会停下来坐一会儿\n self._sit_x = pw * random.uniform(0.3, 0.7)\n self._sit_counter = 0\n else:\n self._mode = self.RUN; self._speed = 4\n self.move(int(self._x), y)\n self.show(); self.raise_()\n self._step_timer.start(40) # 25fps\n\n def _on_step(self):\n self._frame += 1\n # SIT 模式:到达坐下点后停下来\n if self._mode == self.SIT and self._sit_counter > 0:\n self._sit_counter -= 1\n if self._sit_counter == 0:\n self._mode = self.WALK # 坐完了继续走\n self.update()\n return\n if self._mode == self.SIT and \\\n abs(self._x - self._sit_x) < 5 and self._sit_counter == 0:\n self._sit_counter = 100 # 坐 4 秒\n self.update()\n return\n self._x += self._dir * self._speed\n self.move(int(self._x), self.y())\n self.update()\n if (self._dir == 1 and self._x > self._x_end) or \\\n (self._dir == -1 and self._x < self._x_end):\n self._step_timer.stop()\n self.hide()\n\n def paintEvent(self, e):\n p = QPainter(self)\n p.setRenderHint(QPainter.Antialiasing)\n p.setPen(Qt.NoPen)\n if self._dir < 0:\n p.translate(self.width(), 0)\n p.scale(-1, 1)\n cat = QColor(35, 35, 35)\n eye = QColor(180, 210, 50)\n is_sitting = (self._mode == self.SIT and self._sit_counter > 0)\n # ── 尾巴 ──\n p.setPen(QPen(cat, 3, Qt.SolidLine, Qt.RoundCap))\n p.setBrush(Qt.NoBrush)\n tail = QPainterPath()\n sway = 2 if (self._frame // 4) % 2 == 0 else -2\n if is_sitting:\n # 坐着:尾巴自然弯曲放在身后\n tail.moveTo(6, 22); tail.cubicTo(2, 20, -2, 16, 2, 12)\n else:\n tail.moveTo(8, 16); tail.cubicTo(4, 12 + sway, 2, 8 + sway, 5, 4)\n p.drawPath(tail)\n # ── 身体 ──\n p.setPen(Qt.NoPen); p.setBrush(cat)\n if is_sitting:\n p.drawEllipse(10, 12, 20, 18) # 坐姿:更圆\n else:\n p.drawEllipse(8, 14, 24, 14)\n # ── 头 ──\n if is_sitting:\n p.drawEllipse(22, 2, 17, 16)\n else:\n p.drawEllipse(25, 4, 16, 15)\n # ── 耳朵 ──\n hx = 22 if is_sitting else 25\n ear1 = QPainterPath()\n ear1.moveTo(hx+3, 4); ear1.lineTo(hx+5, -3); ear1.lineTo(hx+8, 3)\n ear1.closeSubpath(); p.drawPath(ear1)\n ear2 = QPainterPath()\n ear2.moveTo(hx+9, 2); ear2.lineTo(hx+12, -4); ear2.lineTo(hx+14, 2)\n ear2.closeSubpath(); p.drawPath(ear2)\n # ── 眼睛 ──\n ex = 27 if is_sitting else 30\n ey = 7 if is_sitting else 9\n p.setBrush(eye)\n if is_sitting and (self._frame // 30) % 3 == 0:\n # 坐着时偶尔眯眼\n p.setPen(QPen(eye, 2)); p.setBrush(Qt.NoBrush)\n p.drawLine(ex, ey+2, ex+4, ey+2)\n p.drawLine(ex+6, ey+2, ex+10, ey+2)\n p.setPen(Qt.NoPen)\n else:\n p.drawEllipse(ex, ey, 4, 4)\n p.drawEllipse(ex+6, ey, 4, 4)\n p.setBrush(QColor(10, 10, 10))\n p.drawEllipse(ex+1, ey+1, 2, 2)\n p.drawEllipse(ex+7, ey+1, 2, 2)\n # ── 腿 ──\n p.setPen(QPen(cat, 3, Qt.SolidLine, Qt.RoundCap))\n if is_sitting:\n # 坐姿:前腿伸直,后腿收起\n p.drawLine(18, 28, 16, 34)\n p.drawLine(24, 28, 26, 34)\n else:\n phase = self._frame % 4\n o = [(2,-2,-1,1),(-1,1,2,-2),(-2,2,1,-1),(1,-1,-2,2)][phase]\n p.drawLine(14, 27, 14+o[0], 34)\n p.drawLine(20, 27, 20+o[1], 34)\n p.drawLine(25, 27, 25+o[2], 34)\n p.drawLine(30, 27, 30+o[3], 34)\n p.end()\n\n def enterEvent(self, e):\n \"\"\"鼠标悬停 → 疯狂加速\"\"\"\n if self._step_timer.isActive():\n self._mode = self.RUN\n self._speed = 6\n self._sit_counter = 0\n self._step_timer.setInterval(15)\n\n def leaveEvent(self, e):\n \"\"\"鼠标离开 → 恢复正常速度\"\"\"\n if self._step_timer.isActive() and self._mode == self.RUN:\n self._speed = 1.5\n self._step_timer.setInterval(40)\n\n def mousePressEvent(self, e):\n \"\"\"单击 → 喵一声\"\"\"\n try:\n import subprocess\n subprocess.Popen([\"afplay\", \"/System/Library/Sounds/Pop.aiff\"])\n except Exception:\n pass\n\n def mouseDoubleClickEvent(self, e):\n \"\"\"双击 → 直接消失\"\"\"\n self._step_timer.stop()\n self.hide()\n\n\nclass MainWindow(QMainWindow):\n def __init__(self):\n super().__init__()\n self.is_dark = False\n self.setWindowTitle(\"\")\n self.setMinimumSize(900,600)\n self.setUnifiedTitleAndToolBarOnMac(True)\n # App 图标\n _icon_path = _res('assets', 'app_icon.png')\n if os.path.exists(_icon_path):\n self.setWindowIcon(QIcon(_icon_path))\n # 启动时占满屏幕 90%\n screen = QApplication.primaryScreen()\n if screen:\n geo = screen.availableGeometry()\n w = int(geo.width() * 0.92)\n h = int(geo.height() * 0.90)\n self.resize(w, h)\n self.move(geo.x() + (geo.width() - w) // 2,\n geo.y() + (geo.height() - h) // 2)\n\n central = QWidget(); central.setObjectName(\"Central\"); self.setCentralWidget(central)\n root = QHBoxLayout(central); root.setContentsMargins(0,0,0,0); root.setSpacing(0)\n\n # 侧边栏\n sb = QWidget(); sb.setObjectName(\"Sidebar\"); sb.setFixedWidth(220)\n self.sidebar = sb\n sbl = QVBoxLayout(sb); sbl.setContentsMargins(16,24,16,16); sbl.setSpacing(4)\n\n # Logo: pdf2zh/desktop — 📄 彩蛋 + 可点击标题\n logo_row = QHBoxLayout(); logo_row.setSpacing(10); logo_row.setContentsMargins(4,0,0,0)\n logo_icon = _EggLogo(\"📄\", 28)\n logo_icon.setFixedSize(36, 36); logo_icon.setAlignment(Qt.AlignCenter)\n logo_row.addWidget(logo_icon)\n logo_text = QVBoxLayout(); logo_text.setSpacing(0)\n logo_name = QPushButton(\"pdf2zh-desktop\"); logo_name.setObjectName(\"SBLink\")\n logo_name.setStyleSheet(\"font-size:15px;font-weight:700;letter-spacing:-0.2px;padding:0;text-align:left;\")\n logo_name.setCursor(Qt.PointingHandCursor); logo_name.setFlat(True)\n logo_name.clicked.connect(lambda: webbrowser.open(\"https://github.com/AaronGIG/pdf2zh-desktop\"))\n logo_sub = QLabel(\"macOS Edition\"); logo_sub.setObjectName(\"Cap\")\n logo_text.addWidget(logo_name); logo_text.addWidget(logo_sub)\n logo_row.addLayout(logo_text); logo_row.addStretch()\n sbl.addLayout(logo_row)\n sbl.addSpacing(16); sbl.addWidget(_div()); sbl.addSpacing(12)\n\n self.nav = []\n for icon, label in [(\"🌐\",\"翻译\"),(\"📖\",\"阅读\"),(\"🔧\",\"设置\"),(\"💡\",\"关于\")]:\n b = SB(icon, label)\n b.clicked.connect(lambda l=label: self.switch(l))\n sbl.addWidget(b); self.nav.append((label, b))\n sbl.addStretch()\n\n vl = QLabel(\"v2.3.6 · macOS\"); vl.setObjectName(\"Cap\"); vl.setAlignment(Qt.AlignCenter)\n vl.setStyleSheet(\"font-size:10px;\")\n sbl.addWidget(vl)\n # 底部链接 — 独立按钮,支持 hover 变色\n import webbrowser\n link_row = QHBoxLayout(); link_row.setSpacing(0)\n link_row.setContentsMargins(8,0,8,4); link_row.addStretch()\n for text, url in [\n (\"GitHub\", \"https://github.com/AaronGIG/pdf2zh-desktop\"),\n (\"小红书\", \"https://www.xiaohongshu.com/user/profile/66c6fef7000000001d0315ef\"),\n (\"Feedback\", \"https://github.com/AaronGIG/pdf2zh-desktop/issues\"),\n (\"Star ⭐\", \"https://github.com/AaronGIG/pdf2zh-desktop\"),\n ]:\n lb = QPushButton(text); lb.setObjectName(\"SBLink\"); lb.setCursor(Qt.PointingHandCursor)\n lb.clicked.connect(lambda _, u=url: webbrowser.open(u))\n link_row.addWidget(lb)\n if text != \"Star ⭐\":\n sep = QLabel(\"·\"); sep.setObjectName(\"SBSep\")\n link_row.addWidget(sep)\n link_row.addStretch()\n sbl.addLayout(link_row)\n root.addWidget(sb)\n\n # 内容区\n self.stack = QStackedWidget()\n self.pages = {}\n tp = TranslatePage(); rp = ReaderPage()\n sp = SettingsPage(); ap = AboutPage()\n for name, page in [(\"翻译\",tp),(\"阅读\",rp),(\"设置\",sp),(\"关于\",ap)]:\n self.stack.addWidget(page); self.pages[name] = page\n root.addWidget(self.stack)\n\n # 信号连接\n sp.dark_mode_changed.connect(self._toggle_dark)\n sp.theme_color_changed.connect(self._set_accent)\n tp.translation_done.connect(self._on_translate_done)\n rp.fullscreen_changed.connect(self._on_reader_fullscreen)\n\n # 恢复上次窗口状态\n cfg = UserConfigManager.load()\n self.switch(\"翻译\"); self._apply()\n _install_tip_filter(QApplication.instance())\n # 去掉所有控件的 macOS 聚焦环(小点边框)\n for w in self.findChildren(QWidget):\n w.setAttribute(Qt.WA_MacShowFocusRect, False)\n\n # 恢复窗口尺寸和位置\n if cfg.get(\"window_geometry\"):\n try:\n from PyQt5.QtCore import QByteArray\n geo = QByteArray.fromHex(cfg[\"window_geometry\"].encode())\n self.restoreGeometry(geo)\n except Exception:\n pass\n\n # 静默预加载阅读页:用户点击时已渲染好,无闪烁\n QTimer.singleShot(100, self._preload_reader)\n\n # ── 小黑猫彩蛋 ──\n self._neko = _NekoCat(self)\n self._neko_timer = QTimer(self)\n self._neko_timer.timeout.connect(self._maybe_spawn_cat)\n self._neko_timer.start(1_200_000) # 每 20 分钟出来一次\n QTimer.singleShot(3_000, lambda: self._neko.start_walk()) # 启动 3 秒出来\n\n # ── 凌晨 3:30 彩蛋:披星戴月 ──\n from datetime import datetime\n now = datetime.now()\n if now.hour == 3 and 25 <= now.minute <= 35:\n QTimer.singleShot(800, self._midnight_bloom)\n\n # ─────────────────────────────────────────────\n # 凌晨 3:30 彩蛋 — 烟花 + 暖心寄语\n # ─────────────────────────────────────────────\n def _maybe_spawn_cat(self):\n \"\"\"每 20 分钟让小黑猫出来走一趟\"\"\"\n if self._neko.isVisible():\n return\n self._neko.start_walk()\n\n def _midnight_bloom(self):\n \"\"\"全屏烟花 10 秒 → 暖心寄语浮层\"\"\"\n import random, math\n from PyQt5.QtCore import QPropertyAnimation, QPoint, QEasingCurve\n\n fireworks = [\"🎆\",\"🎇\",\"✨\",\"💫\",\"⭐\",\"🌟\",\"🌠\",\"💛\",\"🧡\",\"💜\",\"💙\",\"🤍\"]\n self._bloom_anims = [] # prevent GC\n\n def _burst():\n \"\"\"一轮烟花:从随机位置爆开\"\"\"\n cx = random.randint(int(self.width() * 0.15), int(self.width() * 0.85))\n cy = random.randint(int(self.height() * 0.15), int(self.height() * 0.55))\n center = QPoint(cx, cy)\n for _ in range(random.randint(12, 20)):\n lbl = QLabel(random.choice(fireworks), self)\n lbl.setStyleSheet(f\"font-size:{random.randint(16, 36)}px;background:transparent;\")\n lbl.move(center); lbl.show(); lbl.raise_()\n angle = random.uniform(0, 2 * math.pi)\n dist = random.randint(80, 300)\n end = QPoint(cx + int(math.cos(angle) * dist),\n cy + int(math.sin(angle) * dist))\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(900, 1800))\n anim.setStartValue(center); anim.setEndValue(end)\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start()\n self._bloom_anims.append(anim)\n\n # 10 秒内间歇放烟花(每 400–700ms 一轮)\n total_bursts = 18\n for i in range(total_bursts):\n delay = int(i * 550 + random.randint(0, 150))\n QTimer.singleShot(delay, _burst)\n\n # 10 秒后显示暖心寄语\n QTimer.singleShot(10500, self._midnight_message)\n\n def _midnight_message(self):\n \"\"\"暖心寄语浮层\"\"\"\n import random\n messages = [\n (\"披星戴月的你,终将凯旋。\",\n \"但现在,请先好好休息。\\n\"\n \"没有什么比你的健康更重要了。\\n\"\n \"科研之外,还有很多美好的事物在等着你。\"),\n (\"凌晨三点半的你,一定很努力。\",\n \"但再重要的事,也比不上你自己。\\n\"\n \"把未完成的留给明天精力充沛的自己吧。\\n\"\n \"这个世界需要健康的你。\"),\n (\"夜深了,星星都在为你亮着。\",\n \"你已经比大多数人都努力了。\\n\"\n \"允许自己休息,不是放弃,是为了走更远的路。\\n\"\n \"照顾好自己,才能照顾好你在乎的一切。\"),\n (\"这个时间还在学习的你,了不起。\",\n \"但了不起的人更要爱惜自己。\\n\"\n \"身体是一切的根基,请好好善待它。\\n\"\n \"早点睡,明天的你会感谢今晚的决定。\"),\n (\"深夜的坚持让人敬佩。\",\n \"但真正的强者,懂得在该休息时放下。\\n\"\n \"你的才华不会因为一晚的休息而消失。\\n\"\n \"去睡吧,梦里什么都有。\"),\n ]\n title, body = random.choice(messages)\n\n # 半透明遮罩 + 居中卡片\n overlay = QWidget(self)\n overlay.setGeometry(self.rect())\n overlay.setStyleSheet(\"background:rgba(0,0,0,0.55);\")\n overlay.show(); overlay.raise_()\n\n card = QFrame(overlay)\n card.setStyleSheet(\n \"QFrame{background:rgba(255,255,255,0.95);border-radius:20px;}\"\n if not self.is_dark else\n \"QFrame{background:rgba(40,40,45,0.95);border-radius:20px;}\"\n )\n card.setFixedSize(420, 280)\n card.move((self.width() - 420) // 2, (self.height() - 280) // 2)\n card.show()\n\n cl = QVBoxLayout(card); cl.setContentsMargins(32, 28, 32, 24); cl.setSpacing(12)\n\n moon = QLabel(\"🌙\"); moon.setStyleSheet(\"font-size:36px;background:transparent;\")\n moon.setAlignment(Qt.AlignCenter); cl.addWidget(moon)\n\n tc = \"color:#1d1d1f;\" if not self.is_dark else \"color:#f5f5f7;\"\n tl = QLabel(title)\n tl.setStyleSheet(f\"font-size:18px;font-weight:700;{tc}background:transparent;\")\n tl.setAlignment(Qt.AlignCenter); tl.setWordWrap(True); cl.addWidget(tl)\n\n bl = QLabel(body)\n bl.setStyleSheet(f\"font-size:13px;line-height:1.6;{tc}background:transparent;\")\n bl.setAlignment(Qt.AlignCenter); bl.setWordWrap(True); cl.addWidget(bl)\n\n cl.addStretch()\n close_btn = QPushButton(\"晚安,去休息了 🌙\")\n close_btn.setObjectName(\"Pr\"); close_btn.setCursor(Qt.PointingHandCursor)\n close_btn.setStyleSheet(\"font-size:14px;padding:10px 24px;\")\n close_btn.clicked.connect(overlay.deleteLater)\n cl.addWidget(close_btn, alignment=Qt.AlignCenter)\n\n # 点击遮罩也可关闭\n overlay.mousePressEvent = lambda e: overlay.deleteLater()\n\n def switch(self, label):\n # 预加载中 → 延迟切换(避免冲突)\n if getattr(self, '_preloading', False):\n QTimer.singleShot(50, lambda: self.switch(label))\n return\n keys = list(self.pages.keys())\n idx = keys.index(label) if label in keys else 0\n # 阅读页:先刷新列表,再显示(避免 showEvent 用旧数据加载后又被 refresh 清掉)\n if label == \"阅读\":\n self.pages[\"阅读\"].refresh()\n self.stack.setCurrentIndex(idx)\n for n, b in self.nav: b.set_active(n == label)\n\n def _preload_reader(self):\n \"\"\"静默预加载阅读页:冻结画面 → 切到阅读页获取真实尺寸 → 渲染 → 切回\"\"\"\n self._preloading = True\n rp = self.pages[\"阅读\"]\n rp.refresh()\n if rp.list_w.count() == 0:\n self._preloading = False\n return\n # 冻结画面(布局仍正常计算,只是不绘制)\n self.setUpdatesEnabled(False)\n self.stack.setCurrentIndex(list(self.pages.keys()).index(\"阅读\"))\n # 下一帧:布局已稳定,加载 + 渲染\n QTimer.singleShot(0, self._preload_step2)\n\n def _preload_step2(self):\n rp = self.pages[\"阅读\"]\n rp.list_w.setCurrentRow(0) # → _on_select → load_pdf\n # 再等一帧让 splitter 稳定\n QTimer.singleShot(30, self._preload_step3)\n\n def _preload_step3(self):\n rp = self.pages[\"阅读\"]\n rp.preview._last_fit_zoom = -1\n rp.preview._fit_and_render()\n # 切回翻译页,解冻\n self.stack.setCurrentIndex(0)\n self.setUpdatesEnabled(True)\n self._preloading = False\n self.repaint()\n\n def _toggle_dark(self, dark):\n self.is_dark = dark\n # 淡入淡出过渡\n from PyQt5.QtCore import QPropertyAnimation\n anim = QPropertyAnimation(self, b\"windowOpacity\")\n anim.setDuration(150)\n anim.setStartValue(1.0); anim.setEndValue(0.85)\n anim.finished.connect(lambda: self._finish_theme_switch(anim))\n self._theme_anim = anim # prevent GC\n anim.start()\n\n def _finish_theme_switch(self, prev_anim):\n self._apply()\n from PyQt5.QtCore import QPropertyAnimation\n anim = QPropertyAnimation(self, b\"windowOpacity\")\n anim.setDuration(200)\n anim.setStartValue(0.85); anim.setEndValue(1.0)\n self._theme_anim = anim\n anim.start()\n\n def _set_accent(self, hex_color):\n \"\"\"应用自定义主题色\"\"\"\n self._custom_accent = hex_color\n self._apply()\n\n def _apply(self):\n global _C\n c = dict(D if self.is_dark else L) # 拷贝,避免修改原始\n # 应用自定义主题色(需已解锁)\n cfg = UserConfigManager.load()\n acc = getattr(self, '_custom_accent', None) or cfg.get(\"theme_color\")\n if acc and cfg.get(\"theme_unlocked\"):\n r, g, b = int(acc[1:3],16), int(acc[3:5],16), int(acc[5:7],16)\n c[\"acc\"] = acc; c[\"acc_h\"] = acc; c[\"acc_p\"] = acc\n c[\"acc_l\"] = f\"rgba({r},{g},{b},0.12)\"\n c[\"acc_g\"] = f\"qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 {acc},stop:1 {acc})\"\n c[\"sb_act\"] = f\"rgba({r},{g},{b},0.15)\"\n c[\"dz_b\"] = f\"rgba({r},{g},{b},0.25)\"\n c[\"tag_bg\"] = f\"rgba({r},{g},{b},0.10)\"; c[\"tag_fg\"] = acc\n c[\"pv_l_bg\"] = f\"rgba({r},{g},{b},0.10)\"; c[\"pv_l_fg\"] = acc\n c[\"link_h\"] = acc\n _C = c\n self.setStyleSheet(S(c))\n # 强制设置 QPalette,修复内联 setStyleSheet 覆盖问题\n from PyQt5.QtGui import QPalette\n pal = self.palette()\n pal.setColor(QPalette.WindowText, QColor(c[\"t1\"]))\n pal.setColor(QPalette.Text, QColor(c[\"t1\"]))\n pal.setColor(QPalette.ButtonText, QColor(c[\"t1\"]))\n pal.setColor(QPalette.PlaceholderText, QColor(c[\"t3\"]))\n pal.setColor(QPalette.Base, QColor(c[\"inp\"]))\n pal.setColor(QPalette.Window, QColor(c[\"bg\"]))\n pal.setColor(QPalette.AlternateBase, QColor(c[\"bg2\"]))\n pal.setColor(QPalette.ToolTipBase, QColor(c[\"elev\"]))\n pal.setColor(QPalette.ToolTipText, QColor(c[\"t1\"]))\n pal.setColor(QPalette.Highlight, QColor(c[\"acc\"]))\n pal.setColor(QPalette.HighlightedText, QColor(\"white\"))\n self.setPalette(pal)\n QApplication.instance().setPalette(pal)\n # 通知子页面更新内联样式\n for page in self.pages.values():\n if hasattr(page, 'update_theme'):\n page.update_theme(c)\n\n def _on_reader_fullscreen(self, fs):\n \"\"\"全屏阅读:隐藏/显示侧边栏\"\"\"\n self.sidebar.setVisible(not fs)\n\n def closeEvent(self, event):\n \"\"\"保存窗口几何尺寸和当前页面\"\"\"\n cfg = UserConfigManager.load()\n cfg[\"window_geometry\"] = bytes(self.saveGeometry().toHex()).decode()\n # 保存当前页面\n for name, page in self.pages.items():\n if self.stack.currentWidget() is page:\n cfg[\"last_page\"] = name; break\n UserConfigManager.save(cfg)\n event.accept()\n\n def _on_translate_done(self, output_files):\n reader = self.pages[\"阅读\"]\n reader.set_output_files(output_files)\n QTimer.singleShot(600, lambda: self.switch(\"阅读\"))\n # 翻译完成后把窗口提到前台(用户可能切走了)\n QTimer.singleShot(700, lambda: (self.raise_(), self.activateWindow()))\n\n\ndef _dbg_write(msg):\n \"\"\"v2.3.2 debug: 写到 /tmp/pdf2zh-cli-debug.log + stderr,双保险\"\"\"\n try:\n from datetime import datetime as _dt\n line = f\"[{_dt.now().strftime('%H:%M:%S.%f')[:-3]}] {msg}\\n\"\n try:\n fd = os.open(\"/tmp/pdf2zh-cli-debug.log\", os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)\n os.write(fd, line.encode(\"utf-8\", errors=\"replace\"))\n os.close(fd)\n except Exception:\n pass\n try:\n sys.stderr.write(line)\n sys.stderr.flush()\n except Exception:\n pass\n except Exception:\n pass\n\n\ndef _parse_cli_args(argv):\n \"\"\"解析命令行参数 (向后兼容 — 老 pdf2zh xxx.pdf 依然工作)\n 支持:\n pdf2zh file.pdf ← 旧行为\n pdf2zh --format=side_by_side --auto file.pdf ← 新行为(Zotero 右键唤起)\n pdf2zh --format=dual file.pdf\n 返回 dict: {file: str|None, format: str|None, auto: bool}\n \"\"\"\n result = {\"file\": None, \"format\": None, \"auto\": False, \"silent\": False}\n try:\n for arg in argv[1:]:\n if arg.startswith(\"--format=\"):\n fmt = arg.split(\"=\", 1)[1].strip()\n if fmt in (\"mono\", \"dual\", \"side_by_side\", \"all\"):\n result[\"format\"] = fmt\n elif arg == \"--auto\":\n result[\"auto\"] = True\n elif arg == \"--silent\":\n result[\"silent\"] = True\n elif arg.lower().endswith(\".pdf\") and os.path.isfile(arg):\n result[\"file\"] = arg\n except Exception:\n pass\n return result\n\n\nclass Pdf2zhApp(QApplication):\n \"\"\"单实例 QApplication:第二个实例把文件+参数发给第一个实例后退出\"\"\"\n file_opened = pyqtSignal(dict) # {file, format, auto}\n _SERVER_NAME = \"com.aarongig.pdf2zh.single\"\n\n def __init__(self, argv):\n # v2.3.2: 最早的 debug log 用 sys.stderr + /tmp 双保险\n _dbg_write(f\"Pdf2zhApp.__init__ START argv[{len(argv)}]={argv!r}\")\n super().__init__(argv)\n _dbg_write(\"Pdf2zhApp.__init__ super() done\")\n from PyQt5.QtNetwork import QLocalServer, QLocalSocket\n self._pending_file = None\n\n # 解析命令行参数\n cli = _parse_cli_args(argv)\n _dbg_write(f\"Pdf2zhApp.__init__ cli parsed = {cli!r}\")\n\n # 尝试连接已有实例\n sock = QLocalSocket()\n sock.connectToServer(self._SERVER_NAME)\n if sock.waitForConnected(500):\n # 已有实例在运行 → 把 payload 发过去,然后退出\n if cli[\"file\"]:\n import json as _json\n payload = _json.dumps(cli).encode(\"utf-8\")\n sock.write(payload)\n sock.waitForBytesWritten(1000)\n sock.disconnectFromServer()\n sys.exit(0)\n\n # 我是第一个实例 → 启动 server 监听\n QLocalServer.removeServer(self._SERVER_NAME)\n self._server = QLocalServer()\n self._server.listen(self._SERVER_NAME)\n self._server.newConnection.connect(self._on_new_connection)\n\n # 记录启动时命令行参数(有文件的话 main() 会用到)\n self._launch_cli = cli\n\n def _on_new_connection(self):\n conn = self._server.nextPendingConnection()\n if conn:\n conn.waitForReadyRead(1000)\n data = conn.readAll().data().decode(\"utf-8\", errors=\"ignore\")\n conn.close()\n # 兼容旧格式(纯文件路径)+ 新格式(JSON payload)\n payload = None\n if data.startswith(\"{\"):\n try:\n import json as _json\n payload = _json.loads(data)\n except Exception:\n pass\n if payload is None and data.lower().endswith(\".pdf\"):\n payload = {\"file\": data, \"format\": None, \"auto\": False}\n if payload and payload.get(\"file\"):\n self.file_opened.emit(payload)\n\n def event(self, e):\n # v2.3.2 修 bug: FileOpen event 收到路径时,之前 emit(str) 与 signal dict 定义不匹配 → 静默丢失\n if e.type() == e.FileOpen:\n path = e.file()\n _dbg_write(f\"Pdf2zhApp.event FileOpen path={path!r}\")\n if path and path.lower().endswith('.pdf'):\n # 用 CLI 里保存的 format/auto 参数(如果启动时带了)\n cli = getattr(self, '_launch_cli', {}) or {}\n payload = {\n \"file\": path,\n \"format\": cli.get(\"format\"),\n \"auto\": cli.get(\"auto\", False),\n \"silent\": cli.get(\"silent\", False),\n }\n self.file_opened.emit(payload)\n return True\n return super().event(e)\n\n\ndef main():\n _dbg_write(\"main() entered\")\n QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)\n QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)\n _dbg_write(\"main() creating Pdf2zhApp\")\n app = Pdf2zhApp(sys.argv); app.setStyle(\"Fusion\")\n _dbg_write(\"main() Pdf2zhApp created, creating MainWindow\")\n try:\n w = MainWindow()\n _dbg_write(f\"main() MainWindow created type={type(w).__name__}\")\n except Exception as _mw_e:\n import traceback as _tb\n _dbg_write(f\"main() MainWindow FAILED: {_mw_e}\\n{_tb.format_exc()}\")\n raise\n _dbg_write(\"main() calling w.show()\")\n w.show()\n _dbg_write(f\"main() after show(), isVisible={w.isVisible()} geom={w.geometry().getRect()}\")\n\n # v2.3.2: debug log 到磁盘方便排查 CLI 唤起失败问题\n _dbg_log = os.path.expanduser(\"~/.config/pdf2zh/cli-debug.log\")\n\n def _dbg(msg):\n try:\n os.makedirs(os.path.dirname(_dbg_log), exist_ok=True)\n with open(_dbg_log, \"a\", encoding=\"utf-8\") as f:\n from datetime import datetime as _dt\n f.write(f\"[{_dt.now().strftime('%H:%M:%S')}] {msg}\\n\")\n except Exception:\n pass\n\n # 处理文件 + 可选参数(format / auto)\n def _handle_payload(payload):\n \"\"\"payload = {file, format, auto}\"\"\"\n _dbg_write(f\"_handle_payload called payload={payload}\")\n try:\n pages_keys = list(w.pages.keys()) if hasattr(w, \"pages\") else \"no pages\"\n _dbg_write(f\" pages_keys={pages_keys}\")\n tp = w.pages.get(\"翻译\")\n _dbg_write(f\" tp={tp}\")\n if not tp:\n _dbg_write(\" ✗ tp is None, giving up\")\n return\n file_path = payload.get(\"file\")\n _dbg_write(f\" file_path={file_path!r}\")\n if file_path:\n exists = os.path.isfile(file_path)\n _dbg_write(f\" isfile={exists}\")\n tp.on_files_added([file_path])\n _dbg_write(f\" after on_files_added, flist count={tp.flist.count()}\")\n w.switch(\"翻译\")\n # v2.3.4: 后台静默模式 —— 最小化不抢焦点, 完成后自动关闭(见 _on_batch_done)\n _silent = bool(payload.get(\"silent\"))\n w._cli_silent = _silent\n if _silent:\n _dbg_write(\" silent mode: showMinimized\")\n try: w.showMinimized()\n except Exception: pass\n else:\n # v2.3.4: showNormal 确保上次静默 close() 隐藏的窗口能重现(仅 raise_ 不足以显示隐藏窗口)\n w.showNormal(); w.raise_(); w.activateWindow()\n\n fmt = payload.get(\"format\")\n if fmt:\n tp._cli_format = fmt\n _dbg_write(f\" set _cli_format={fmt}\")\n\n if payload.get(\"auto\"):\n _dbg_write(f\" scheduling _start() in 800ms\")\n QTimer.singleShot(800, lambda: (_dbg_write(\" calling _start\"), tp._start()) if hasattr(tp, \"_start\") else _dbg_write(\" ✗ no _start\"))\n except Exception as e:\n import traceback\n _dbg_write(f\" ✗ EXCEPTION: {e}\\n{traceback.format_exc()}\")\n\n app.file_opened.connect(_handle_payload)\n\n # 启动时如果就带了文件参数(首次启动直接从 Zotero 唤起),也处理\n if hasattr(app, \"_launch_cli\") and app._launch_cli.get(\"file\"):\n QTimer.singleShot(300, lambda: _handle_payload(app._launch_cli))\n\n sys.exit(app.exec_())\n\n\ndef _early_log(tag):\n \"\"\"v2.3.2: 最早期日志,绕过所有 helper(即使 datetime import 失败也能写)\"\"\"\n try:\n import os as _os, sys as _sys, time as _time\n line = f\"[{_time.strftime('%H:%M:%S')}] {tag} name={__name__!r} argv={_sys.argv!r}\\n\"\n fd = _os.open(\"/tmp/pdf2zh-early.log\", _os.O_WRONLY | _os.O_APPEND | _os.O_CREAT, 0o644)\n _os.write(fd, line.encode(\"utf-8\", errors=\"replace\"))\n _os.close(fd)\n try:\n _sys.stderr.write(line); _sys.stderr.flush()\n except Exception:\n pass\n except Exception:\n pass\n\n\n# v2.3.2: __name__ 会是 \"__main__\"(终端启动)或 \"ui.main_window\"(frozen 打包)\n_early_log(\"module top-level import\")\n\nif __name__ == \"__main__\":\n _early_log(\"__main__ before main()\")\n main()\nelse:\n # PyInstaller frozen 可能在别处启动 main(),但为了保险这里也 log 一下\n _early_log(f\"__name__ != __main__ (={__name__})\")\n"}, "85fa70d5775558a86a14ed0e126e206c02a2327a": {"t": 1785809905.2928336, "n": 266583, "h": "6380f23012856569", "c": "\"\"\"\npdf2zh for Mac — 完整版主窗口\n全功能适配: 三种预览模式 / 分块翻译 / 页码范围 / 历史记录 / 20+翻译服务\n\"\"\"\n\nimport sys\nimport os\nimport json\nimport webbrowser\nimport time\nfrom pathlib import Path\nimport fitz\n\n\ndef _install_pdf2zh_color_fix():\n \"\"\"v2.3.3 运行时热补丁 (import hook, 不改 pdf2zh 模块本身):\n 修复某些期刊 PDF(ICC 颜色空间) 白色矩形 '1 1 1 scn' 被截断成 '1 scn' → 渲染成红色,\n 以及译文文字继承颜色导致变红。在 pdf2zh.pdfinterp / pdf2zh.converter 被导入时自动打补丁。\n \"\"\"\n import sys as _sys\n import importlib.util as _ilu\n from importlib.abc import MetaPathFinder\n\n _patched = set()\n\n def _patch_pdfinterp(mod):\n for cn in (\"PDFPageInterpreterEx\", \"PDFPageInterpreter\"):\n cls = getattr(mod, cn, None)\n if not cls or getattr(cls, \"_p2z_scn_fixed\", False):\n continue\n\n def _mk(is_stroke):\n attr = \"scs\" if is_stroke else \"ncs\"\n cattr = \"scolor\" if is_stroke else \"ncolor\"\n\n def _f(self):\n cs = getattr(self.graphicstate, attr, None) or getattr(self, attr, None)\n n = cs.ncomponents if cs else 1\n _num = 0\n for _v in reversed(self.argstack):\n if isinstance(_v, (int, float)):\n _num += 1\n else:\n break\n if _num > n:\n n = _num\n a = self.pop(n)\n setattr(self.graphicstate, cattr, a)\n return a\n\n return _f\n\n cls.do_scn = _mk(False)\n cls.do_sc = cls.do_scn\n cls.do_SCN = _mk(True)\n cls.do_SC = cls.do_SCN\n cls._p2z_scn_fixed = True\n\n def _patch_converter(mod):\n tc = getattr(mod, \"TranslateConverter\", None)\n if not tc or getattr(tc, \"_p2z_color_fixed\", False):\n return\n _orig = tc.receive_layout\n\n def _wrapped(self, ltpage):\n ops = _orig(self, ltpage)\n if isinstance(ops, str) and ops:\n return \"0 g 0 G \" + ops\n return ops\n\n tc.receive_layout = _wrapped\n tc._p2z_color_fixed = True\n\n def _patch_translator(mod):\n import re as _re\n dt = getattr(mod, \"DeepseekTranslator\", None)\n ot = getattr(mod, \"OpenAITranslator\", None)\n\n # v2.3.5: DeepSeek V4(v4-flash/v4-pro, 及已映射到 v4 的 deepseek-chat 别名)是混合推理模型,\n # 裸调默认走\"思考模式\" → 逐段先推理再输出 → 翻译极慢/偶发漏译/(经代理时)推理泄漏进译文。\n # 对这些实例注入 thinking:disabled 退回直出; deepseek-reasoner 纯推理不动。\n if dt is not None and not getattr(dt, \"_p2z_thinking_fixed\", False):\n _orig_init = dt.__init__\n\n def _new_init(self, *a, **kw):\n _orig_init(self, *a, **kw)\n try:\n _m = (getattr(self, \"model\", \"\") or \"\").lower()\n if (\"v4\" in _m) or (\"chat\" in _m):\n if not getattr(self, \"options\", None):\n self.options = {}\n self.options[\"extra_body\"] = {\"thinking\": {\"type\": \"disabled\"}}\n except Exception:\n pass\n\n dt.__init__ = _new_init\n dt._p2z_thinking_fixed = True\n\n # v2.3.6: 弱模型/推理模型往译文里加译者注(注:...)/前言/思考块(见 issue #27)。\n # A 强约束 system prompt(所有 OpenAI 兼容服务) + B 输出兜底清洗。\n if ot is not None and not getattr(ot, \"_p2z_mt_clean\", False):\n _NOTE = _re.compile(\n r\"\\s*[((]\\s*(注|註|译者注|譯者註|译注|譯注|Note|N\\.?B\\.?)\\s*[::].*$\",\n _re.DOTALL | _re.IGNORECASE,\n )\n _PRE = _re.compile(\n r\"^\\s*(译文|译|翻译|Translation|Translated text)\\s*[::]\\s*\", _re.IGNORECASE\n )\n _THK = _re.compile(r\"<think>.*?</think>\", _re.DOTALL | _re.IGNORECASE)\n _SYS = (\n \"You are a professional translation engine. Output ONLY the translated text — nothing else.\\n\"\n \"STRICT RULES:\\n\"\n \"1. NEVER add explanations, comments, annotations, or translator notes.\\n\"\n \"2. NEVER add parenthetical remarks such as (注:…), (注:…), (Note:…), or [译者注:…].\\n\"\n \"3. NEVER add a prefix like '译文:', '翻译:', or 'Translation:'.\\n\"\n \"4. NEVER comment on terminology or justify your word choices.\\n\"\n \"5. Keep formula placeholders {v*} / {{v*}} exactly unchanged.\\n\"\n \"6. Translate terms and short fragments directly and concisely — still translation only.\"\n )\n\n def _clean(t):\n if not t or not isinstance(t, str):\n return t\n o = t\n t = _THK.sub(\"\", t)\n t = _PRE.sub(\"\", t)\n t = _NOTE.sub(\"\", t)\n t = t.strip()\n return t if t else o.strip()\n\n # A: OpenAITranslator 加 _SYSTEM_PROMPT + prompt override(无 system 则加)\n if not hasattr(ot, \"_SYSTEM_PROMPT\"):\n ot._SYSTEM_PROMPT = _SYS\n _orig_prompt = ot.prompt\n\n def _new_prompt(self, text, prompt_template=None):\n msgs = _orig_prompt(self, text, prompt_template)\n sysp = getattr(self, \"_SYSTEM_PROMPT\", None)\n if sysp and not (\n msgs and isinstance(msgs[0], dict) and msgs[0].get(\"role\") == \"system\"\n ):\n return [{\"role\": \"system\", \"content\": sysp}] + list(msgs)\n return msgs\n\n ot.prompt = _new_prompt\n # DeepSeek 去掉自己的 prompt override → 继承(用 DeepSeek 自己的 _SYSTEM_PROMPT), 避免双 system\n if dt is not None and \"prompt\" in dt.__dict__:\n try:\n del dt.prompt\n except Exception:\n pass\n\n # B: do_translate 后清洗(并防 frozen 版 content=None 崩)\n _orig_do = ot.do_translate\n\n def _new_do(self, text):\n try:\n out = _orig_do(self, text)\n except AttributeError as _e:\n if \"NoneType\" in str(_e):\n return \"\"\n raise\n return _clean(out)\n\n ot.do_translate = _new_do\n ot._p2z_mt_clean = True\n\n _patchers = {\n \"pdf2zh.pdfinterp\": _patch_pdfinterp,\n \"pdf2zh.converter\": _patch_converter,\n \"pdf2zh.translator\": _patch_translator,\n }\n\n class _Finder(MetaPathFinder):\n def find_spec(self, name, path, target=None):\n if name not in _patchers or name in _patched:\n return None\n _patched.add(name) # 防递归\n try:\n spec = _ilu.find_spec(name)\n except Exception:\n return None\n if spec is None or spec.loader is None:\n return None\n _orig_exec = spec.loader.exec_module\n\n def _exec(module):\n _orig_exec(module)\n try:\n _patchers[name](module)\n except Exception:\n pass\n\n spec.loader.exec_module = _exec\n return spec\n\n try:\n _sys.meta_path.insert(0, _Finder())\n except Exception:\n pass\n\n # 兜底: 若目标模块在 Finder 安装前已被导入(import 顺序不定), 直接补一次\n for _n, _p in _patchers.items():\n if _n in _sys.modules and _n not in _patched:\n _patched.add(_n)\n try:\n _p(_sys.modules[_n])\n except Exception:\n pass\n\n\n_install_pdf2zh_color_fix()\n\n\ndef _res(*parts):\n \"\"\"获取资源文件路径,兼容 PyInstaller 打包和开发环境\"\"\"\n if getattr(sys, '_MEIPASS', None):\n return os.path.join(sys._MEIPASS, *parts)\n return os.path.join(os.path.dirname(os.path.dirname(__file__)), *parts)\n\nfrom PyQt5.QtWidgets import (\n QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLayout,\n QPushButton, QLabel, QComboBox, QProgressBar, QFileDialog, QDialog,\n QStackedWidget, QStackedLayout, QFrame, QScrollArea, QGraphicsDropShadowEffect,\n QCheckBox, QListWidget, QListWidgetItem, QLineEdit, QSpinBox, QPlainTextEdit,\n QSizePolicy, QSlider, QSplitter, QTabBar, QMessageBox, QMenu, QAction,\n)\nfrom PyQt5.QtCore import Qt, pyqtSignal, QTimer, QSize, QEvent, QRect\nfrom PyQt5.QtGui import (QColor, QDragEnterEvent, QDropEvent, QImage, QPixmap,\n QScreen, QIcon, QPainter, QPainterPath, QPen, QRegion)\n\nfrom ui.config_manager import UserConfigManager, HistoryManager\nfrom ui.translate_worker import (\n TranslateWorker, LANG_MAP, SERVICE_MAP, PAGE_PRESETS,\n OUTPUT_MODES, parse_page_range, detect_zotero_source,\n get_zotero_item_key, zotero_auto_link, zotero_plugin_installed,\n build_service_envs, SummaryWorker, QAWorker,\n)\n\n# ─── 苹果风配色 ─────────────────────────────────────────────\n\nL = {\n \"bg\":\"#FFF\",\"bg2\":\"#F9F9FB\",\"bg3\":\"#F2F2F7\",\"elev\":\"#FFF\",\n \"sb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #FBFBFD,stop:1 #F2F2F7)\",\n \"sb_act\":\"rgba(0,113,227,0.12)\",\"sb_hov\":\"rgba(0,0,0,0.04)\",\n \"t1\":\"#1D1D1F\",\"t2\":\"#6E6E73\",\"t3\":\"#AEAEB2\",\"t4\":\"#C7C7CC\",\n \"acc\":\"#0071E3\",\"acc_l\":\"rgba(0,113,227,0.10)\",\"acc_h\":\"#0077ED\",\"acc_p\":\"#005BBB\",\n \"acc_g\":\"qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 #0071E3,stop:1 #00A1FF)\",\n \"ok\":\"#34C759\",\"err\":\"#FF3B30\",\n \"brd\":\"rgba(0,0,0,0.06)\",\"brd_s\":\"rgba(0,0,0,0.10)\",\n \"card\":\"#FFF\",\"card_b\":\"rgba(0,0,0,0.04)\",\n \"inp\":\"#F2F2F7\",\"inp_b\":\"rgba(0,0,0,0.08)\",\n \"dz_bg\":\"#FAFBFF\",\"dz_b\":\"rgba(0,113,227,0.20)\",\n \"tag_bg\":\"rgba(0,113,227,0.08)\",\"tag_fg\":\"#0071E3\",\n \"scr\":\"rgba(0,0,0,0.12)\",\"scr_h\":\"rgba(0,0,0,0.24)\",\n \"pv_bg\":\"#FFFFFF\",\"pv_tb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #FBFBFD,stop:1 #F2F2F7)\",\n \"pv_l_bg\":\"rgba(0,113,227,0.07)\",\"pv_l_fg\":\"#0071E3\",\n \"link\":\"#6E6E73\",\"link_h\":\"#0071E3\",\n}\n\nD = {\n \"bg\":\"#1C1C1E\",\"bg2\":\"#2C2C2E\",\"bg3\":\"#3A3A3C\",\"elev\":\"#2C2C2E\",\n \"sb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #242426,stop:1 #1C1C1E)\",\n \"sb_act\":\"rgba(10,132,255,0.20)\",\"sb_hov\":\"rgba(255,255,255,0.06)\",\n \"t1\":\"#F5F5F7\",\"t2\":\"#98989D\",\"t3\":\"#636366\",\"t4\":\"#48484A\",\n \"acc\":\"#0A84FF\",\"acc_l\":\"rgba(10,132,255,0.15)\",\"acc_h\":\"#409CFF\",\"acc_p\":\"#0071E3\",\n \"acc_g\":\"qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 #0A84FF,stop:1 #5AC8FA)\",\n \"ok\":\"#30D158\",\"err\":\"#FF453A\",\n \"brd\":\"rgba(255,255,255,0.06)\",\"brd_s\":\"rgba(255,255,255,0.10)\",\n \"card\":\"#2C2C2E\",\"card_b\":\"rgba(255,255,255,0.12)\",\n \"inp\":\"#3A3A3C\",\"inp_b\":\"rgba(255,255,255,0.08)\",\n \"dz_bg\":\"#2A2A2E\",\"dz_b\":\"rgba(10,132,255,0.35)\",\n \"tag_bg\":\"rgba(10,132,255,0.15)\",\"tag_fg\":\"#0A84FF\",\n \"scr\":\"rgba(255,255,255,0.12)\",\"scr_h\":\"rgba(255,255,255,0.24)\",\n \"pv_bg\":\"#1C1C1E\",\"pv_tb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #2C2C2E,stop:1 #242426)\",\n \"pv_l_bg\":\"rgba(10,132,255,0.12)\",\"pv_l_fg\":\"#0A84FF\",\n \"link\":\"#98989D\",\"link_h\":\"#0A84FF\",\n}\n\n_C = L # 当前活跃配色(深色/浅色切换时更新)\n\n\n# ─── 磨砂提示气泡 ──────────────────────────────────────────\nclass _Tip(QWidget):\n \"\"\"截取屏幕 → 缩放模糊 → 叠加半透明蒙层 → 圆角裁剪 = 磨砂玻璃\"\"\"\n _inst = None\n _PAD = 10\n\n def __init__(self):\n super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint)\n self.setAttribute(Qt.WA_TranslucentBackground)\n self.setAttribute(Qt.WA_ShowWithoutActivating)\n self._text = \"\"\n self._blur_pix = None\n\n def _grab_and_blur(self, x, y, w, h):\n \"\"\"安全截取屏幕区域并模糊\"\"\"\n try:\n screen = QApplication.primaryScreen()\n if not screen:\n return\n pix = screen.grabWindow(0, x, y, w, h)\n if pix.isNull():\n return\n img = pix.toImage()\n # 缩小到 1/8 再放大 = 快速高斯模糊\n from PyQt5.QtCore import QSize\n tiny = img.scaled(QSize(max(1, w // 8), max(1, h // 8)),\n Qt.IgnoreAspectRatio, Qt.SmoothTransformation)\n blurred = tiny.scaled(QSize(w, h),\n Qt.IgnoreAspectRatio, Qt.SmoothTransformation)\n self._blur_pix = QPixmap.fromImage(blurred)\n except Exception:\n self._blur_pix = None\n\n def paintEvent(self, e):\n from PyQt5.QtGui import QPainter, QPainterPath, QFont, QPen\n p = QPainter(self)\n p.setRenderHint(QPainter.Antialiasing)\n pad = self._PAD\n rect = self.rect().adjusted(pad, pad, -pad, -pad)\n radius = 8.0\n is_dark = (_C is D)\n path = QPainterPath()\n path.addRoundedRect(float(rect.x()), float(rect.y()),\n float(rect.width()), float(rect.height()), radius, radius)\n # ── 柔和阴影 ──\n for i in range(5):\n a = max(1, (35 if is_dark else 20) // (i + 1))\n d = (i + 1) * 2.0\n sp = QPainterPath()\n sr = rect.adjusted(-d, -d + 1, d, d + 2)\n sp.addRoundedRect(float(sr.x()), float(sr.y()),\n float(sr.width()), float(sr.height()), radius + d, radius + d)\n p.fillPath(sp, QColor(0, 0, 0, a))\n # ── 裁剪到圆角 ──\n p.setClipPath(path)\n # ── 模糊背景 ──\n if self._blur_pix and not self._blur_pix.isNull():\n p.drawPixmap(rect, self._blur_pix)\n else:\n fb = QColor(45, 45, 48) if is_dark else QColor(245, 245, 247)\n p.fillPath(path, fb)\n # ── 半透明蒙层(磨砂感的关键) ──\n overlay = QColor(30, 30, 32, 140) if is_dark else QColor(255, 255, 255, 178)\n p.fillRect(rect, overlay)\n # ── 顶部高光 ──\n hi = QPainterPath()\n hr = rect.adjusted(1, 1, -1, -rect.height() + 16)\n hi.addRoundedRect(float(hr.x()), float(hr.y()),\n float(hr.width()), float(hr.height()), radius - 1, radius - 1)\n p.fillPath(hi, QColor(255, 255, 255, 25 if is_dark else 120))\n # ── 边框 ──\n p.setClipping(False)\n brd = QColor(255, 255, 255, 20) if is_dark else QColor(0, 0, 0, 15)\n p.setPen(QPen(brd, 0.5))\n p.drawPath(path)\n # ── 文字 ──\n p.setPen(QColor(_C[\"t1\"]))\n font = QFont(\"Helvetica Neue\", 11)\n font.setFamilies([\"Helvetica Neue\", \"PingFang SC\"])\n p.setFont(font)\n tr = rect.adjusted(10, 7, -10, -7)\n p.drawText(tr, Qt.AlignLeft | Qt.AlignVCenter | Qt.TextWordWrap, self._text)\n p.end()\n\n def _calc_size(self):\n from PyQt5.QtGui import QFont, QFontMetrics\n font = QFont(\"Helvetica Neue\", 11)\n font.setFamilies([\"Helvetica Neue\", \"PingFang SC\"])\n fm = QFontMetrics(font)\n br = fm.boundingRect(0, 0, 300, 9999,\n Qt.AlignLeft | Qt.TextWordWrap, self._text)\n pad = self._PAD\n self.setFixedSize(br.width() + 20 + pad * 2, br.height() + 14 + pad * 2)\n\n @classmethod\n def show_tip(cls, pos, text, widget=None):\n if not text:\n cls.hide_tip(); return\n if cls._inst is None:\n cls._inst = cls()\n tip = cls._inst\n tip._text = text\n tip._calc_size()\n # 先确保 tooltip 自身不可见,再截取目标位置的屏幕\n tip.hide()\n pad = tip._PAD\n tx, ty = pos.x() + 8, pos.y() + 14\n # 边界检测:确保不超出屏幕\n screen = QApplication.primaryScreen()\n if screen:\n sg = screen.availableGeometry()\n if tx + tip.width() > sg.right():\n tx = sg.right() - tip.width()\n if ty + tip.height() > sg.bottom():\n ty = pos.y() - tip.height() - 4 # 移到光标上方\n tx = max(sg.left(), tx)\n ty = max(sg.top(), ty)\n bx, by = tx + pad, ty + pad\n tw, th = tip.width() - pad * 2, tip.height() - pad * 2\n tip._grab_and_blur(bx, by, tw, th)\n tip.move(tx, ty)\n tip.show()\n tip.update()\n\n @classmethod\n def hide_tip(cls):\n if cls._inst: cls._inst.hide()\n\n\ndef _fix_combo_popup(combo):\n \"\"\"去掉 QComboBox 的 macOS 焦点虚线框\"\"\"\n combo.setAttribute(Qt.WA_MacShowFocusRect, False)\n\n\ndef _install_tip_filter(app):\n \"\"\"安装全局 tooltip 拦截 + combo 修复\"\"\"\n from PyQt5.QtCore import QObject\n\n class F(QObject):\n def eventFilter(self, obj, event):\n if event.type() == QEvent.ToolTip:\n text = \"\"\n if hasattr(obj, 'toolTip'):\n text = obj.toolTip()\n # QListWidget viewport → item tooltip\n if not text and hasattr(obj, 'parent'):\n p = obj.parent()\n if hasattr(p, 'itemAt'):\n item = p.itemAt(event.pos())\n if item: text = item.toolTip()\n if text:\n from PyQt5.QtGui import QCursor\n _Tip.show_tip(QCursor.pos(), text, obj)\n event.accept()\n return True\n return False\n if event.type() == QEvent.Leave:\n _Tip.hide_tip()\n return False\n\n f = F(app)\n app._tip_filter = f\n app.installEventFilter(f)\n\n\ndef S(c):\n return f\"\"\"\n QMainWindow{{background:{c[\"bg\"]};}}\n QWidget{{font-family:\"Helvetica Neue\",\"PingFang SC\";font-size:13px;color:{c[\"t1\"]};outline:none;}}\n #Central{{background:{c[\"bg\"]};}}\n QStackedWidget{{background:{c[\"bg\"]};}}\n QStackedWidget>QWidget{{background:{c[\"bg\"]};}}\n QLabel{{background:transparent;border:none;}}\n QToolTip{{background:transparent;border:none;padding:0;}}\n /* ── 侧边栏 ── */\n #Sidebar{{background:{c[\"sb\"]};border-right:0.5px solid {c[\"brd\"]};}}\n #SB{{background:transparent;border:none;border-radius:7px;padding:6px 12px;text-align:left;font-size:13px;color:{c[\"t2\"]};letter-spacing:0.2px;border-left:3px solid transparent;}}\n #SB:hover{{background:{c[\"sb_hov\"]};color:{c[\"t1\"]};}}\n #SB[active=\"true\"]{{background:{c[\"sb_act\"]};color:{c[\"acc\"]};font-weight:600;border-left:3px solid {c[\"acc\"]};border-top-left-radius:0;border-bottom-left-radius:0;}}\n #SBLink{{background:transparent;border:none;font-size:10px;color:{c[\"link\"]};padding:1px 3px;}}\n #SBLink:hover{{color:{c[\"acc\"]};}}\n /* ── 按钮 ── */\n #Pr{{background:{c[\"acc_g\"]};color:white;border:none;border-radius:12px;padding:12px 32px;font-size:15px;font-weight:600;letter-spacing:0.3px;}}\n #Pr:hover{{background:{c[\"acc_h\"]};}}#Pr:pressed{{background:{c[\"acc_p\"]};padding:13px 32px 11px 32px;}}\n #Pr:disabled{{background:{c[\"bg3\"]};color:{c[\"t4\"]};border:1px solid {c[\"brd\"]};}}\n #Sc{{background:{c[\"bg2\"]};color:{c[\"t1\"]};border:0.5px solid {c[\"brd_s\"]};border-radius:8px;padding:8px 18px;font-size:13px;font-weight:500;}}\n #Sc:hover{{background:{c[\"bg3\"]};border-color:{c[\"acc\"]};}}\n #Gh{{background:transparent;color:{c[\"acc\"]};border:none;border-radius:6px;padding:6px 12px;font-size:13px;font-weight:500;}}\n #Gh:hover{{background:{c[\"acc_l\"]};}}\n #GhDanger{{background:transparent;color:{c[\"err\"]};border:none;border-radius:6px;padding:6px 12px;font-size:13px;font-weight:500;}}\n #GhDanger:hover{{background:rgba(255,59,48,0.08);}}\n #TB{{background:transparent;color:{c[\"t2\"]};border:none;border-radius:6px;padding:5px 10px;font-size:13px;font-weight:500;}}\n #TB:hover{{background:{c[\"sb_hov\"]};color:{c[\"t1\"]};}}\n #TB[active=\"true\"]{{background:{c[\"acc_l\"]};color:{c[\"acc\"]};font-weight:600;}}\n /* ── 输入控件 ── */\n QComboBox{{background:{c[\"inp\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;padding:8px 14px;font-size:13px;min-height:22px;color:{c[\"t1\"]};}}\n QComboBox:hover{{border-color:{c[\"brd_s\"]};}}QComboBox:focus{{border-color:{c[\"acc\"]};border-width:1.5px;outline:none;}}\n QComboBox::drop-down{{border:none;width:28px;subcontrol-origin:padding;subcontrol-position:center right;}}\n QComboBox::down-arrow{{image:none;width:8px;height:5px;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid {c[\"t3\"]};margin-right:10px;}}\n QComboBox QAbstractItemView{{background:{c[\"elev\"]};border:none;border-radius:10px;padding:6px 4px;color:{c[\"t1\"]};outline:0;}}\n QComboBox QAbstractItemView::item{{padding:6px 12px;border:none;border-radius:6px;margin:1px 2px;}}\n QComboBox QAbstractItemView::item:hover{{background:{c[\"acc_l\"]};}}\n QComboBox QAbstractItemView::item:selected{{background:{c[\"acc\"]};color:white;}}\n QLineEdit{{background:{c[\"inp\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;padding:8px 14px;font-size:13px;color:{c[\"t1\"]};}}\n QLineEdit:focus{{border-color:{c[\"acc\"]};border-width:2px;background:{c[\"elev\"]};}}\n QLineEdit[readOnly=\"true\"]{{color:{c[\"t2\"]};}}\n QSpinBox{{background:{c[\"inp\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;padding:6px 10px;font-size:13px;color:{c[\"t1\"]};}}\n QSpinBox:focus{{border-color:{c[\"acc\"]};border-width:2px;}}\n QTextEdit{{background:{c[\"inp\"]};color:{c[\"t1\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;}}\n QTextEdit:focus{{border-color:{c[\"acc\"]};border-width:2px;}}\n /* ── 进度条 ── */\n QProgressBar{{background:{c[\"bg3\"]};border:none;border-radius:3px;max-height:6px;min-height:6px;font-size:1px;}}\n QProgressBar::chunk{{background:{c[\"acc_g\"]};border-radius:3px;}}\n /* ── 卡片 ── */\n #Card{{background:{c[\"card\"]};border:0.5px solid {c[\"card_b\"]};border-radius:12px;}}\n #Card:hover{{border-color:{c[\"acc\"]};}}\n #DZ{{background:{c[\"dz_bg\"]};border:2px dashed {c[\"dz_b\"]};border-radius:12px;}}\n #DZ:hover{{border-color:{c[\"acc\"]};}}\n /* ── 列表 ── */\n QListWidget{{background:transparent;border:none;outline:none;}}\n QListWidget::item{{background:{c[\"card\"]};border:0.5px solid {c[\"card_b\"]};border-radius:8px;padding:4px 10px;margin:2px 0;}}\n QListWidget::item:selected{{background:{c[\"acc\"]};color:white;border-color:{c[\"acc\"]};}}\n QListWidget::item:hover:!selected{{background:{c[\"bg2\"]};}}\n /* ── 滚动条 ── */\n QScrollArea{{border:none;background:transparent;}}\n QScrollBar:vertical{{background:transparent;width:10px;margin:4px 2px;}}\n QScrollBar::handle:vertical{{background:{c[\"scr\"]};border-radius:4px;min-height:36px;}}\n QScrollBar::handle:vertical:hover{{background:{c[\"scr_h\"]};}}\n QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical,QScrollBar::add-page:vertical,QScrollBar::sub-page:vertical{{height:0;background:transparent;}}\n QScrollBar:horizontal{{height:0;}}\n /* ── 复选框 ── */\n QCheckBox{{spacing:10px;font-size:13px;}}\n QCheckBox::indicator{{width:18px;height:18px;border-radius:5px;border:1.5px solid {c[\"brd_s\"]};background:{c[\"elev\"]};}}\n QCheckBox::indicator:hover{{border-color:{c[\"acc\"]};background:{c[\"acc_l\"]};}}\n QCheckBox::indicator:checked{{background:{c[\"acc\"]};border-color:{c[\"acc\"]};}}\n /* ── 标签/分隔 ── */\n #Tag{{background:{c[\"tag_bg\"]};color:{c[\"tag_fg\"]};border:none;border-radius:6px;padding:3px 10px;font-size:11px;font-weight:600;}}\n #Div{{background:{c[\"brd\"]};max-height:0.5px;min-height:0.5px;}}\n /* ── 预览区 ── */\n #PA{{background:{c[\"pv_bg\"]};}}\n #PT{{background:{c[\"pv_tb\"]};border-bottom:0.5px solid {c[\"brd\"]};}}\n #PL{{background:{c[\"pv_l_bg\"]};color:{c[\"pv_l_fg\"]};border:none;border-radius:6px;padding:3px 12px;font-size:11px;font-weight:600;}}\n QSplitter::handle{{background:{c[\"brd\"]};width:1px;}}\n QSplitter::handle:hover{{background:{c[\"acc\"]};}}\n QSlider::groove:horizontal{{background:{c[\"bg3\"]};height:4px;border-radius:2px;}}\n QSlider::handle:horizontal{{background:{c[\"acc\"]};width:14px;height:14px;margin:-5px 0;border-radius:7px;}}\n QSlider::handle:horizontal:hover{{background:{c[\"acc_h\"]};width:16px;height:16px;margin:-6px 0;border-radius:8px;}}\n QMenu{{background:{c[\"elev\"]};border:1px solid {c[\"brd_s\"]};border-radius:10px;padding:6px 4px;}}\n QMenu::item{{padding:4px 28px 4px 14px;border-radius:6px;font-size:13px;margin:1px 4px;}}\n QMenu::item:selected{{background:{c[\"acc\"]};color:white;}}\n QMenu::separator{{height:1px;background:{c[\"brd\"]};margin:4px 12px;}}\n /* ── 字体层级: 24 / 15 / 13 / 11 ── */\n #PT0{{font-size:24px;font-weight:700;letter-spacing:-0.3px;}}\n #PT1{{font-size:15px;color:{c[\"t2\"]};}}\n #ST{{font-size:13px;font-weight:600;}}\n #SL{{font-size:11px;font-weight:600;letter-spacing:0.8px;color:{c[\"t3\"]};padding:6px 0 2px 0;}}\n #FL{{font-size:13px;font-weight:500;color:{c[\"t2\"]};padding:2px 0;}}\n #Cap{{font-size:11px;color:{c[\"t3\"]};}}\n #Mono{{font-family:\"Menlo\",monospace;font-size:12px;color:{c[\"t2\"]};}}\n /* ── 进度卡片 ── */\n #ProgPct{{font-family:\"Menlo\",monospace;font-size:20px;font-weight:700;color:{c[\"acc\"]};background:transparent;}}\n #ProgLabel{{font-size:13px;font-weight:600;color:{c[\"t1\"]};}}\n #ProgIcon{{font-size:16px;background:transparent;}}\n #ProgDetail{{font-size:11px;color:{c[\"t2\"]};}}\n #ProgTip{{font-size:11px;color:{c[\"t3\"]};font-style:italic;}}\n #ProgCancel{{background:transparent;color:{c[\"err\"]};border:none;font-size:13px;font-weight:500;padding:6px 12px;border-radius:6px;}}\n #ProgCancel:hover{{background:rgba(255,59,48,0.08);}}\n /* ── 拖拽区 ── */\n #DZTitle{{font-size:15px;font-weight:600;color:{c[\"t1\"]};background:transparent;}}\n #DZSub{{font-size:11px;color:{c[\"t3\"]};background:transparent;}}\n /* ── 历史面板 ── */\n #HistList{{background:transparent;border:none;font-size:11px;color:{c[\"t1\"]};}}\n #HistList::item{{background:transparent;border:none;border-bottom:0.5px solid {c[\"brd\"]};padding:8px 6px;margin:0;border-radius:0;}}\n #HistList::item:selected{{background:{c[\"sb_act\"]};color:{c[\"acc\"]};border-radius:6px;border-bottom:none;}}\n #HistList::item:hover:!selected{{background:{c[\"sb_hov\"]};border-radius:6px;}}\n #HistPanel{{border-right:0.5px solid {c[\"brd\"]};}}\n #HistDetail{{font-size:10px;color:{c[\"t2\"]};background:{c[\"bg2\"]};border:0.5px solid {c[\"brd\"]};border-radius:8px;padding:4px 6px;}}\n /* ── 自定义进度条 ── */\n #ProgBar{{background:{c[\"bg3\"]};border:none;border-radius:6px;max-height:12px;min-height:12px;font-size:1px;}}\n #ProgBar::chunk{{background:{c[\"acc_g\"]};border-radius:6px;}}\n /* ── 预览工具栏控件 ── */\n #PageInput{{font-family:'Menlo',monospace;font-size:13px;font-weight:600;padding:4px;border-radius:6px;border:1px solid {c[\"inp_b\"]};background:{c[\"inp\"]};color:{c[\"t1\"]};}}\n #PageInput:focus{{border-color:{c[\"acc\"]};}}\n #TotalLbl{{font-family:'Menlo',monospace;font-size:11px;color:{c[\"t2\"]};background:transparent;}}\n #ZoomPct{{font-family:'Menlo',monospace;font-size:11px;color:{c[\"t2\"]};background:transparent;}}\n #SBSep{{font-size:10px;color:{c[\"t3\"]};background:transparent;padding:0;}}\n #KBKey{{font-family:'Menlo',monospace;font-size:10px;font-weight:600;color:{c[\"t2\"]};background:{c[\"bg3\"]};border-radius:4px;padding:3px 8px;}}\n \"\"\"\n\n\nclass _EggLogo(QLabel):\n \"\"\"\n 📄 Logo 彩蛋按钮:\n · 悬停 5 秒 → 烟花爆炸\n · 连续点击 5 次 → emoji 飘落\n 所有动画在 MainWindow 层播放。\n \"\"\"\n def __init__(self, text=\"📄\", size=22):\n super().__init__(text)\n self.setStyleSheet(f\"font-size:{size}px;background:transparent;border:none;\")\n self.setCursor(Qt.PointingHandCursor)\n self._click_count = 0\n self._click_timer = QTimer(); self._click_timer.setSingleShot(True)\n self._click_timer.timeout.connect(self._reset_clicks)\n self._hover_timer = QTimer(); self._hover_timer.setSingleShot(True)\n self._hover_timer.timeout.connect(self._on_hover_egg)\n\n def enterEvent(self, e):\n super().enterEvent(e)\n self._hover_timer.start(5000)\n\n def leaveEvent(self, e):\n super().leaveEvent(e)\n self._hover_timer.stop()\n\n def mousePressEvent(self, e):\n super().mousePressEvent(e)\n self._click_count += 1\n self._click_timer.start(1200)\n if self._click_count >= 5:\n self._click_count = 0\n self._trigger_confetti()\n\n def _reset_clicks(self):\n self._click_count = 0\n\n def _main_window(self):\n w = self\n while w:\n if isinstance(w, QMainWindow): return w\n w = w.parentWidget()\n return None\n\n def _on_hover_egg(self):\n \"\"\"悬停 5 秒 → 烟花从 Logo 处爆发\"\"\"\n mw = self._main_window()\n if not mw: return\n import random, math\n from PyQt5.QtCore import QPropertyAnimation, QPoint\n center = self.mapTo(mw, self.rect().center())\n firework_emoji = [\"🎆\",\"✨\",\"💫\",\"⭐\",\"🌟\",\"🎇\",\"💥\",\"🔥\"]\n for _ in range(30):\n lbl = QLabel(random.choice(firework_emoji), mw)\n lbl.setStyleSheet(f\"font-size:{random.randint(14,32)}px;background:transparent;\")\n lbl.move(center); lbl.show()\n angle = random.uniform(0, 2 * math.pi)\n dist = random.randint(120, 350)\n end = QPoint(center.x() + int(math.cos(angle) * dist),\n center.y() + int(math.sin(angle) * dist))\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(800, 1600))\n anim.setStartValue(center); anim.setEndValue(end)\n from PyQt5.QtCore import QEasingCurve\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start(); lbl._anim = anim\n\n def _trigger_confetti(self):\n \"\"\"连续点击 5 次 → emoji 从天而降\"\"\"\n mw = self._main_window()\n if not mw: return\n import random\n from datetime import datetime\n from PyQt5.QtCore import QPropertyAnimation, QPoint, QEasingCurve\n md = (datetime.now().month, datetime.now().day)\n holiday = {\n (1,1):[\"🎆\",\"🥳\",\"🎊\",\"🎉\",\"✨\"], (2,14):[\"💝\",\"💕\",\"💗\",\"🥰\",\"💌\"],\n (5,1):[\"🎊\",\"🎉\",\"🌸\",\"✨\",\"🌟\"], (10,1):[\"🇨🇳\",\"🎆\",\"🎉\",\"✨\",\"💫\"],\n (10,31):[\"🎃\",\"👻\",\"🍬\",\"⭐\",\"✨\"], (12,25):[\"🎄\",\"🎅\",\"❄️\",\"🎁\",\"✨\"],\n }\n pool = holiday.get(md, [\"✨\",\"🎉\",\"🚀\",\"💡\",\"🌟\",\"💎\",\"🦋\",\"🌈\",\"🧸\",\"💫\",\"⭐\",\"🌸\",\"🎈\",\"🎵\",\"💗\",\"🌙\",\"🦄\"])\n for _ in range(25):\n lbl = QLabel(random.choice(pool), mw)\n lbl.setStyleSheet(f\"font-size:{random.randint(18,42)}px;background:transparent;\")\n x = random.randint(0, mw.width() - 40)\n lbl.move(x, -40); lbl.show()\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(1500, 3500))\n anim.setStartValue(lbl.pos())\n anim.setEndValue(QPoint(x + random.randint(-60, 60), mw.height() + 40))\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start(); lbl._anim = anim\n\n\nclass _RoundMenu(QMenu):\n \"\"\"圆角右键菜单 — FramelessWindowHint + 自绘圆角背景 + mask 裁切\"\"\"\n _R = 10 # corner radius\n\n def __init__(self, parent=None):\n super().__init__(parent)\n self.setWindowFlags(\n self.windowFlags() | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint)\n self.setAttribute(Qt.WA_TranslucentBackground)\n # 让 Qt 的默认 QMenu 绘制不画背景,只画菜单项\n self.setStyleSheet(\"QMenu{background:transparent;border:none;}\")\n\n def resizeEvent(self, e):\n super().resizeEvent(e)\n path = QPainterPath()\n path.addRoundedRect(0, 0, self.width(), self.height(),\n self._R, self._R)\n self.setMask(QRegion(path.toFillPolygon().toPolygon()))\n\n def paintEvent(self, e):\n p = QPainter(self)\n p.setRenderHint(QPainter.Antialiasing)\n r = self._R\n rect = self.rect()\n is_dark = isinstance(_C, dict) and _C.get(\"bg\") == \"#1C1C1E\"\n bg = QColor(_C.get(\"elev\", \"#FFF\")) if isinstance(_C, dict) else QColor(\"#FFF\")\n brd = QColor(0, 0, 0, 18) if not is_dark else QColor(255, 255, 255, 18)\n path = QPainterPath()\n path.addRoundedRect(\n rect.x() + 0.5, rect.y() + 0.5,\n rect.width() - 1.0, rect.height() - 1.0,\n float(r), float(r))\n p.fillPath(path, bg)\n p.setPen(QPen(brd, 0.5))\n p.drawPath(path)\n p.end()\n super().paintEvent(e)\n\n\ndef _div():\n d = QFrame(); d.setObjectName(\"Div\"); d.setFrameShape(QFrame.HLine); return d\n\n\ndef _md2html(text):\n \"\"\"Markdown → HTML — 让 AI 回复像 Claude 一样优雅\"\"\"\n import re as _re\n # 转义 HTML\n text = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')\n # 加粗\n text = _re.sub(r'\\*\\*(.+?)\\*\\*', r'<b>\\1</b>', text)\n # 斜体\n text = _re.sub(r'(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)', r'<i>\\1</i>', text)\n # 行内代码\n text = _re.sub(r'`([^`]+)`', r'<code style=\"background:rgba(0,0,0,0.06);border-radius:3px;padding:1px 4px;font-family:Menlo,monospace;font-size:10px;\">\\1</code>', text)\n # 列表项 (- item)\n text = _re.sub(r'^- (.+)$', r'<div style=\"margin:2px 0;padding-left:12px;text-indent:-8px;\">· \\1</div>', text, flags=_re.MULTILINE)\n # 数字列表 (1. item)\n text = _re.sub(r'^(\\d+)\\. (.+)$', r'<div style=\"margin:2px 0;padding-left:12px;text-indent:-12px;\">\\1. \\2</div>', text, flags=_re.MULTILINE)\n # 段落换行\n text = text.replace('\\n\\n', '<div style=\"height:8px;\"></div>')\n text = text.replace('\\n', '<br>')\n return text\n\n\ndef _card(level=\"md\"):\n \"\"\"统一阴影层级: sm=微弱, md=默认卡片, lg=悬浮\"\"\"\n _SHADOW = {\"sm\":(12,2,8), \"md\":(20,4,12), \"lg\":(28,6,16)}\n blur, y, a = _SHADOW.get(level, _SHADOW[\"md\"])\n c = QFrame(); c.setObjectName(\"Card\")\n s = QGraphicsDropShadowEffect(); s.setBlurRadius(blur); s.setOffset(0,y); s.setColor(QColor(0,0,0,a))\n c.setGraphicsEffect(s); return c\n\n\n# ═══════════════════════════════════════════════════════════════\n# 拖拽区\n# ═══════════════════════════════════════════════════════════════\n\nclass DropZone(QFrame):\n files_dropped = pyqtSignal(list)\n def __init__(self):\n super().__init__(); self.setObjectName(\"DZ\"); self.setAcceptDrops(True)\n self.setFixedHeight(140); self.setCursor(Qt.PointingHandCursor)\n\n self._stack = QStackedLayout(self)\n self._stack.setContentsMargins(0,0,0,0)\n\n # ── Page 0: 拖拽提示 ──\n p0 = QWidget(); p0.setStyleSheet(\"background:transparent;\")\n lo0 = QHBoxLayout(p0); lo0.setAlignment(Qt.AlignCenter); lo0.setSpacing(16)\n lo0.setContentsMargins(32,20,32,20)\n ic = QLabel(\"📥\"); ic.setStyleSheet(\"font-size:36px;background:transparent;\"); lo0.addWidget(ic)\n txt = QVBoxLayout(); txt.setSpacing(3)\n t = QLabel(\"将 PDF 拖放至此处\"); t.setObjectName(\"DZTitle\"); txt.addWidget(t)\n s = QLabel(\"或点击浏览 · 支持批量\"); s.setObjectName(\"DZSub\"); txt.addWidget(s)\n lo0.addLayout(txt); lo0.addStretch()\n self._stack.addWidget(p0)\n\n # ── Page 1: 文件列表 ──\n p1 = QWidget(); p1.setStyleSheet(\"background:transparent;\")\n lo1 = QVBoxLayout(p1); lo1.setContentsMargins(10,6,10,4); lo1.setSpacing(3)\n\n self._zotero_hint = QLabel(\"📚 Zotero 文献 · 译文自动保存回原位\")\n self._zotero_hint.setStyleSheet(\n \"font-size:10px;color:#1a56db;background:#e8f0fe;\"\n \"border-radius:3px;padding:2px 6px;\")\n self._zotero_hint.setVisible(False)\n lo1.addWidget(self._zotero_hint)\n\n self.flist = QListWidget()\n self.flist.setSelectionMode(QListWidget.ExtendedSelection)\n lo1.addWidget(self.flist)\n\n fbtn = QHBoxLayout(); fbtn.setSpacing(6)\n self._fcount_label = QLabel(\"\"); self._fcount_label.setObjectName(\"Cap\")\n self._fcount_label.setStyleSheet(\"font-size:10px;background:transparent;\")\n fbtn.addWidget(self._fcount_label); fbtn.addStretch()\n self._add_btn = QPushButton(\"+ 添加\"); self._add_btn.setCursor(Qt.PointingHandCursor)\n self._add_btn.setStyleSheet(\"font-size:10px;padding:1px 8px;border:1px solid #ccc;border-radius:3px;background:transparent;\")\n fbtn.addWidget(self._add_btn)\n self._del_btn = QPushButton(\"删除选中\"); self._del_btn.setObjectName(\"TB\"); self._del_btn.setCursor(Qt.PointingHandCursor)\n self._del_btn.setStyleSheet(\"font-size:10px;padding:1px 6px;\")\n fbtn.addWidget(self._del_btn)\n self._clr_btn = QPushButton(\"清空\"); self._clr_btn.setObjectName(\"GhDanger\"); self._clr_btn.setCursor(Qt.PointingHandCursor)\n self._clr_btn.setStyleSheet(\"font-size:10px;padding:1px 6px;\")\n fbtn.addWidget(self._clr_btn)\n lo1.addLayout(fbtn)\n self._stack.addWidget(p1)\n\n def dragEnterEvent(self, e):\n c = _C\n self.setStyleSheet(\n f\"#DZ{{background:{c['acc_l']};border:2.5px solid {c['acc']};\"\n f\"border-radius:14px;}}\")\n # 呼吸脉动动画\n from PyQt5.QtCore import QPropertyAnimation\n anim = QPropertyAnimation(self, b\"windowOpacity\")\n anim.setDuration(600); anim.setLoopCount(-1) # 无限循环\n anim.setStartValue(1.0); anim.setEndValue(0.85)\n # QWidget 没有 windowOpacity,用 graphicsEffect 模拟缩放\n effect = QGraphicsDropShadowEffect(self)\n effect.setBlurRadius(20); effect.setOffset(0, 0)\n effect.setColor(QColor(c['acc']))\n self.setGraphicsEffect(effect)\n self._drag_effect = effect\n e.acceptProposedAction()\n def dragLeaveEvent(self, e):\n self.setStyleSheet(\"\")\n self.setGraphicsEffect(None)\n self._drag_effect = None\n def dragMoveEvent(self, e):\n e.acceptProposedAction()\n def dropEvent(self, e):\n self.setStyleSheet(\"\")\n self.setGraphicsEffect(None)\n import glob\n fs = []\n md = e.mimeData()\n\n def _collect(p):\n \"\"\"从路径收集 PDF:文件直接加,文件夹递归扫描\"\"\"\n if p.lower().endswith('.pdf') and os.path.isfile(p):\n fs.append(p)\n elif os.path.isdir(p):\n fs.extend(sorted(glob.glob(\n os.path.join(p, '**', '*.pdf'), recursive=True)))\n\n # 1) 标准 file URL(Finder 拖文件/文件夹、Zotero 拖 PDF 附件)\n for u in md.urls():\n p = u.toLocalFile()\n if p:\n _collect(p)\n\n # 检测是否来自 Zotero(macOS 拖多个条目时 URL 只含一个文件)\n _from_zotero = False\n if fs:\n from ui.translate_worker import detect_zotero_source\n _from_zotero = any(detect_zotero_source(f) for f in fs)\n\n # 2) text/plain 中的 file:// 路径或本地路径\n if not fs and md.hasText():\n for line in md.text().strip().splitlines():\n line = line.strip()\n if line.startswith(\"file://\"):\n from PyQt5.QtCore import QUrl\n line = QUrl(line).toLocalFile()\n if line and os.path.exists(line):\n _collect(line)\n\n # 3) Zotero 自定义 MIME(同进程或支持跨进程的平台)\n if not fs:\n from ui.translate_worker import resolve_zotero_items, resolve_zotero_collection\n zot_data = md.data(\"zotero/item\")\n if zot_data and len(zot_data):\n try:\n import json as _json\n raw = bytes(zot_data).decode(\"utf-8\", errors=\"ignore\").strip()\n try:\n parsed = _json.loads(raw)\n if isinstance(parsed, dict) and \"itemIDs\" in parsed:\n item_ids = [int(x) for x in parsed[\"itemIDs\"]]\n elif isinstance(parsed, list):\n item_ids = [int(x) for x in parsed]\n elif isinstance(parsed, int):\n item_ids = [parsed]\n else:\n item_ids = []\n except (ValueError, TypeError):\n item_ids = [int(x) for x in raw.split(\",\")\n if x.strip().isdigit()]\n if item_ids:\n fs = resolve_zotero_items(item_ids)\n except Exception:\n pass\n if not fs:\n zot_coll = md.data(\"zotero/collection\")\n if zot_coll and len(zot_coll):\n try:\n raw = bytes(zot_coll).decode(\"utf-8\", errors=\"ignore\").strip()\n if raw:\n fs = resolve_zotero_collection(raw)\n except Exception:\n pass\n\n # 4) macOS 跨进程 Zotero:text/plain 匹配标题/集合名\n # 也在 layer 1 仅拿到部分 Zotero URL 时运行,补全缺失的文件\n if (not fs or _from_zotero) and md.hasText():\n try:\n from ui.translate_worker import (\n resolve_zotero_by_title, resolve_zotero_collection_by_name)\n txt = md.text().strip()\n if txt:\n extra = resolve_zotero_by_title(txt)\n existing = set(fs)\n for f in extra:\n if f not in existing:\n fs.append(f)\n existing.add(f)\n if not fs and txt:\n fs = resolve_zotero_collection_by_name(txt)\n except Exception:\n pass\n\n self.setStyleSheet(\"\") # 恢复默认样式\n if fs:\n self.files_dropped.emit(fs)\n def mousePressEvent(self, e):\n if self._stack.currentIndex() == 0:\n fs, _ = QFileDialog.getOpenFileNames(self, \"选择 PDF\", \"\", \"PDF (*.pdf)\")\n if fs: self.files_dropped.emit(fs)\n\n\nclass SummaryCard(QFrame):\n \"\"\"智能摘要卡片 — 可折叠,点击按钮调用 AI 生成结构化摘要\"\"\"\n def __init__(self):\n super().__init__()\n self.setObjectName(\"Card\")\n lo = QVBoxLayout(self); lo.setContentsMargins(16, 10, 16, 10); lo.setSpacing(6)\n # 头部\n hdr = QHBoxLayout(); hdr.setSpacing(8)\n ic = QLabel(\"✨\"); ic.setStyleSheet(\"font-size:14px;background:transparent;\")\n hdr.addWidget(ic)\n t = QLabel(\"智能摘要\"); t.setStyleSheet(\"font-size:13px;font-weight:600;background:transparent;\")\n hdr.addWidget(t); hdr.addStretch()\n self._svc_label = QLabel(\"\")\n self._svc_label.setStyleSheet(\"font-size:10px;color:gray;background:transparent;\")\n hdr.addWidget(self._svc_label)\n self.gen_btn = QPushButton(\"生成摘要\"); self.gen_btn.setObjectName(\"Sc\")\n self.gen_btn.setCursor(Qt.PointingHandCursor)\n self.gen_btn.setStyleSheet(\"font-size:11px;padding:3px 12px;\")\n self.gen_btn.clicked.connect(self._generate)\n hdr.addWidget(self.gen_btn)\n self._collapse_btn = QPushButton(\"▼\"); self._collapse_btn.setObjectName(\"Gh\")\n self._collapse_btn.setFixedSize(24, 24); self._collapse_btn.setCursor(Qt.PointingHandCursor)\n self._collapse_btn.setStyleSheet(\"font-size:10px;padding:0;\")\n self._collapse_btn.clicked.connect(self._toggle_collapse)\n hdr.addWidget(self._collapse_btn)\n lo.addLayout(hdr)\n # 内容区\n self._body = QLabel(\"\")\n self._body.setWordWrap(True); self._body.setTextInteractionFlags(Qt.TextSelectableByMouse)\n self._body.setStyleSheet(\"font-size:12px;line-height:160%;background:transparent;padding:4px 0;\")\n self._body.setVisible(False)\n lo.addWidget(self._body)\n self._collapsed = False\n self._worker = None\n self._pdf_path = None\n # 检测 AI 服务(优先使用独立助手配置)\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if svc:\n self._svc_label.setText(f\"via {svc['name']}\")\n else:\n self.gen_btn.setEnabled(False)\n self.gen_btn.setToolTip(\"请在设置中配置 AI 服务 API Key\")\n\n def set_pdf(self, path):\n self._pdf_path = path\n self._body.setText(\"\"); self._body.setVisible(False)\n self._collapse_btn.setText(\"▼\")\n\n def _toggle_collapse(self):\n self._collapsed = not self._collapsed\n self._body.setVisible(not self._collapsed and bool(self._body.text()))\n self._collapse_btn.setText(\"▶\" if self._collapsed else \"▼\")\n\n def _generate(self):\n if not self._pdf_path or not os.path.exists(self._pdf_path):\n return\n # 刷新服务检测(优先使用独立助手配置)\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if not svc:\n self._body.setText(\"⚠️ 未配置 AI 服务。请在设置页配置 API Key(如 DeepSeek、OpenAI 等)\")\n self._body.setVisible(True)\n return\n self._svc_label.setText(f\"via {svc['name']}\")\n self.gen_btn.setEnabled(False); self.gen_btn.setText(\"生成中…\")\n self._body.setText(\"⏳ 正在分析论文,请稍候…\"); self._body.setVisible(True)\n self._collapsed = False; self._collapse_btn.setText(\"▼\")\n self._worker = SummaryWorker(self._pdf_path)\n self._worker.result.connect(self._on_result)\n self._worker.error.connect(self._on_error)\n self._worker.finished.connect(lambda: self._cleanup())\n self._worker.start()\n\n def _on_result(self, text):\n self._body.setText(text)\n self._body.setVisible(True)\n\n def _on_error(self, err):\n self._body.setText(f\"⚠️ {err}\")\n self._body.setVisible(True)\n\n def _cleanup(self):\n self.gen_btn.setEnabled(True); self.gen_btn.setText(\"生成摘要\")\n if self._worker:\n w = self._worker; self._worker = None\n w.quit(); QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n\nclass SB(QWidget):\n \"\"\"侧边栏按钮 — 图标固定宽度 + 文字对齐\"\"\"\n clicked = pyqtSignal()\n def __init__(self, icon, label):\n super().__init__()\n self.setFixedHeight(32); self.setCursor(Qt.PointingHandCursor)\n self.setObjectName(\"SB\"); self.setProperty(\"active\", False)\n lo = QHBoxLayout(self); lo.setContentsMargins(12,0,12,0); lo.setSpacing(0)\n self._icon = QLabel(icon); self._icon.setFixedWidth(24); self._icon.setAlignment(Qt.AlignCenter)\n self._icon.setStyleSheet(\"font-size:16px;background:transparent;border:none;\")\n self._label = QLabel(label)\n self._label.setStyleSheet(\"font-size:13px;background:transparent;border:none;padding-left:8px;\")\n lo.addWidget(self._icon); lo.addWidget(self._label); lo.addStretch()\n def set_active(self, v):\n self.setProperty(\"active\", v); self.style().unpolish(self); self.style().polish(self)\n def mousePressEvent(self, e):\n self.clicked.emit()\n\n\n# ═══════════════════════════════════════════════════════════════\n# PDF 预览 — 支持 MONO / DUAL / Side-by-Side 三模式切换\n# ═══════════════════════════════════════════════════════════════\n\nclass PDFPageWidget(QLabel):\n def __init__(self):\n super().__init__(); self.setAlignment(Qt.AlignCenter)\n def set_pixmap(self, px):\n self.setPixmap(px)\n self.setFixedSize(px.size())\n\n\nfrom PyQt5.QtCore import QObject, QTimer\n\nclass _PinchFilter(QObject):\n \"\"\"全局捕获 macOS 触控板双指缩放 (NativeGesture)\"\"\"\n def __init__(self, preview):\n super().__init__(preview)\n self._preview = preview\n self._pending = False\n\n def _is_preview_child(self, widget):\n \"\"\"检查 widget 是否属于 preview 控件树\"\"\"\n try:\n w = widget\n for _ in range(30):\n if w is self._preview:\n return True\n p = w.parentWidget() if hasattr(w, 'parentWidget') else None\n if p is None:\n return False\n w = p\n except RuntimeError:\n return False\n return False\n\n def _do_zoom(self):\n \"\"\"延迟执行缩放,避免在事件处理期间修改控件树\"\"\"\n self._pending = False\n try:\n acc = self._preview._pinch_accumulator\n if abs(acc) > 0.08:\n new_zoom = self._preview.zoom * (1.0 + acc * 0.15)\n new_zoom = max(0.3, min(new_zoom, 5.0))\n self._preview._pinch_accumulator = 0.0\n self._preview._apply_zoom(new_zoom)\n except (RuntimeError, AttributeError):\n pass\n\n def eventFilter(self, obj, event):\n try:\n if event.type() == QEvent.NativeGesture:\n if event.gestureType() == Qt.ZoomNativeGesture and self._is_preview_child(obj):\n self._preview._pinch_accumulator += event.value()\n if not self._pending and abs(self._preview._pinch_accumulator) > 0.08:\n self._pending = True\n QTimer.singleShot(0, self._do_zoom)\n return True\n except (RuntimeError, AttributeError):\n pass\n return False\n\n\nclass _AutoGrowTextEdit(QWidget):\n \"\"\"自适应高度的输入框 — 内容少时单行,多时自动向上增长(最多 5 行)\"\"\"\n returnPressed = pyqtSignal()\n\n def __init__(self, placeholder=\"\"):\n super().__init__()\n lo = QVBoxLayout(self); lo.setContentsMargins(0, 0, 0, 0); lo.setSpacing(0)\n from PyQt5.QtWidgets import QTextEdit\n self._te = QTextEdit()\n self._te.setPlaceholderText(placeholder)\n self._te.setAcceptRichText(False)\n self._te.setStyleSheet(\n f\"QTextEdit{{background:transparent;border:none;\"\n f\"padding:4px 6px;font-size:13px;font-family:'Helvetica Neue','PingFang SC';\"\n f\"color:{_C['t1']};}}\")\n self._te.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._te.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._te.document().contentsChanged.connect(self._adjust_height)\n self._te.installEventFilter(self)\n lo.addWidget(self._te)\n self._min_h = 36; self._max_h = 120\n self._te.setFixedHeight(self._min_h)\n\n def text(self):\n return self._te.toPlainText()\n\n def clear(self):\n self._te.clear()\n\n def setPlaceholderText(self, t):\n self._te.setPlaceholderText(t)\n\n def _adjust_height(self):\n \"\"\"只在实际行数变化时调整高度(按行跳变,不逐像素增长)\"\"\"\n line_h = 20 # 13px font ≈ 20px line height\n doc_h = self._te.document().size().height()\n new_lines = max(1, round(doc_h / line_h))\n if not hasattr(self, '_cur_lines'):\n self._cur_lines = 1\n if new_lines != self._cur_lines:\n self._cur_lines = new_lines\n target_h = max(self._min_h, min(new_lines * line_h + 8, self._max_h))\n self._te.setFixedHeight(target_h)\n\n def eventFilter(self, obj, event):\n if obj == self._te and event.type() == QEvent.KeyPress:\n if event.key() in (Qt.Key_Return, Qt.Key_Enter) and not event.modifiers():\n self.returnPressed.emit()\n return True\n return super().eventFilter(obj, event)\n\n\nclass _FlowLayout(QLayout):\n \"\"\"自动换行布局 — 按钮放不下时自动折行,避免文字被截断\"\"\"\n def __init__(self, parent=None, margin=0, spacing=-1):\n super().__init__(parent)\n if parent is not None:\n self.setContentsMargins(margin, margin, margin, margin)\n self._items = []\n self._spacing = spacing\n\n def addItem(self, item): self._items.append(item)\n def count(self): return len(self._items)\n def itemAt(self, idx): return self._items[idx] if 0 <= idx < len(self._items) else None\n def takeAt(self, idx):\n if 0 <= idx < len(self._items):\n return self._items.pop(idx)\n return None\n\n def expandingDirections(self): return Qt.Orientations()\n def hasHeightForWidth(self): return True\n def heightForWidth(self, w): return self._do_layout(QRect(0, 0, w, 0), test=True)\n\n def setGeometry(self, rect):\n super().setGeometry(rect)\n self._do_layout(rect, test=False)\n\n def sizeHint(self): return self.minimumSize()\n def minimumSize(self):\n s = QSize()\n for item in self._items:\n s = s.expandedTo(item.minimumSize())\n m = self.contentsMargins()\n s += QSize(m.left() + m.right(), m.top() + m.bottom())\n return s\n\n def _do_layout(self, rect, test=False):\n m = self.contentsMargins()\n effective = rect.adjusted(m.left(), m.top(), -m.right(), -m.bottom())\n x, y = effective.x(), effective.y()\n line_h = 0\n sp = self._spacing if self._spacing >= 0 else 4\n for item in self._items:\n w = item.sizeHint().width()\n h = item.sizeHint().height()\n if x + w > effective.right() + 1 and line_h > 0:\n x = effective.x()\n y += line_h + sp\n line_h = 0\n if not test:\n item.setGeometry(QRect(x, y, w, h))\n x += w + sp\n line_h = max(line_h, h)\n return y + line_h - rect.y() + m.bottom()\n\n\n# ── AI 快捷操作 预设库 ──────────────────────────────────────────\n# id 用于标识,label 显示在按钮上,prompt 是发给 LLM 的指令\n# type=\"summary\" 走 SummaryWorker(内置摘要引擎),其余走 QAWorker\n\n_BUILTIN_PROMPTS = [\n {\n \"id\": \"summary\", \"label\": \"📝 摘要\", \"prompt\": \"\", \"type\": \"summary\",\n \"desc\": \"结构化论文摘要(内置引擎)\",\n },\n {\n \"id\": \"explain\", \"label\": \"📖 讲解\",\n \"desc\": \"通俗讲解论文核心内容\",\n \"prompt\": (\n \"请用通俗易懂的语言讲解这篇论文,假设读者具有本科水平但不熟悉该细分领域。\\n\\n\"\n \"请按以下结构输出:\\n\"\n \"## 一句话总结\\n用一句话概括论文在做什么。\\n\\n\"\n \"## 研究背景\\n这个领域之前遇到了什么问题?为什么需要这项研究?\\n\\n\"\n \"## 核心方法\\n作者提出了什么方法或思路?用类比或直觉解释,避免公式。\\n\\n\"\n \"## 主要发现\\n实验结果说明了什么?和之前的方法相比有什么提升?\\n\\n\"\n \"## 为什么重要\\n这项工作对该领域或实际应用有什么意义?\"\n ),\n },\n {\n \"id\": \"questions\", \"label\": \"❓ 深度提问\",\n \"desc\": \"生成 5 个深度问题帮助理解\",\n \"prompt\": (\n \"请基于这篇论文,提出 5 个有深度的问题,帮助读者批判性地理解论文内容。\\n\\n\"\n \"要求:\\n\"\n \"- 每个问题聚焦论文的不同方面(方法、实验、假设、局限性、应用前景等)\\n\"\n \"- 每个问题后附 2-3 句解释,说明为什么这个问题值得思考\\n\"\n \"- 问题应该有启发性,而非简单的事实复述\\n\\n\"\n \"格式:\\n**Q1: [问题]**\\n[为什么值得思考]\\n\\n依此类推。\"\n ),\n },\n {\n \"id\": \"critique\", \"label\": \"🔍 批判分析\",\n \"desc\": \"评估论文的优缺点和局限性\",\n \"prompt\": (\n \"请对这篇论文进行批判性分析,像一位资深审稿人一样思考。\\n\\n\"\n \"请从以下维度评价:\\n\"\n \"## 主要优点\\n论文在方法、实验设计、写作等方面做得好的地方(2-3 点)\\n\\n\"\n \"## 潜在不足\\n方法论上的局限、实验设计的缺陷、未讨论的边界条件(2-3 点)\\n\\n\"\n \"## 可信度评估\\n实验结果是否充分支撑了论文的结论?有没有过度声称?\\n\\n\"\n \"## 改进建议\\n如果你是作者,你会在哪些方面做进一步的工作?\"\n ),\n },\n {\n \"id\": \"methods\", \"label\": \"🧪 方法详解\",\n \"desc\": \"深入解析论文的技术方法\",\n \"prompt\": (\n \"请深入解析这篇论文的技术方法,适合有一定基础的读者。\\n\\n\"\n \"请涵盖:\\n\"\n \"## 问题形式化\\n论文要解决的问题是如何数学/形式化定义的?\\n\\n\"\n \"## 方法流程\\n按步骤描述方法的完整流程,用编号列表。\\n\\n\"\n \"## 关键创新点\\n和现有方法相比,核心创新在哪里?\\n\\n\"\n \"## 实验设置\\n用了什么数据集、基线方法、评估指标?\\n\\n\"\n \"## 核心结果\\n最重要的实验结论是什么?数据支撑如何?\"\n ),\n },\n {\n \"id\": \"related\", \"label\": \"🔗 相关工作\",\n \"desc\": \"梳理论文引用的相关研究脉络\",\n \"prompt\": (\n \"请梳理这篇论文的相关工作和研究脉络。\\n\\n\"\n \"请输出:\\n\"\n \"## 研究领域定位\\n这篇论文属于哪个研究方向?\\n\\n\"\n \"## 关键前置工作\\n论文建立在哪些重要的前人工作之上?列出 3-5 篇最重要的,并说明关系。\\n\\n\"\n \"## 方法演进\\n该领域的方法是如何一步步发展到本文的?用时间线或逻辑链描述。\\n\\n\"\n \"## 与竞争方法的对比\\n本文方法与最相近的竞争方法有什么本质区别?\"\n ),\n },\n {\n \"id\": \"application\", \"label\": \"💡 应用场景\",\n \"desc\": \"分析论文成果的实际应用可能性\",\n \"prompt\": (\n \"请分析这篇论文的研究成果可以如何应用到实际场景中。\\n\\n\"\n \"请涵盖:\\n\"\n \"## 直接应用\\n基于论文当前的成果,最可能直接落地的应用场景是什么?\\n\\n\"\n \"## 潜在扩展\\n如果进一步发展,还可能应用在哪些领域?\\n\\n\"\n \"## 落地挑战\\n从研究到实际部署,可能遇到哪些技术或工程上的困难?\\n\\n\"\n \"## 商业价值\\n这项技术对行业可能产生什么影响?\"\n ),\n },\n {\n \"id\": \"keyterms\", \"label\": \"📚 术语表\",\n \"desc\": \"提取并解释论文中的关键术语\",\n \"prompt\": (\n \"请提取这篇论文中的关键专业术语和概念,生成一个术语表。\\n\\n\"\n \"要求:\\n\"\n \"- 提取 8-12 个最重要的术语\\n\"\n \"- 每个术语给出:中文翻译(如适用)、一句话定义、在论文中的具体含义\\n\"\n \"- 按照术语在论文中出现的逻辑顺序排列\\n\\n\"\n \"格式:\\n**术语名** (English Term)\\n定义:...\\n论文中的含义:...\"\n ),\n },\n]\n\n# 默认激活的 3 个(显示在快捷栏上)\n_DEFAULT_ACTIVE_IDS = [\"summary\", \"explain\", \"questions\"]\n\n\nclass QAPanelWidget(QWidget):\n \"\"\"AI 论文助手面板 — 可自定义快捷操作库 + 聊天界面\"\"\"\n\n def __init__(self):\n super().__init__()\n self.setMinimumWidth(260); self.setMaximumWidth(420)\n self._busy = False # 是否有 worker 在运行\n lo = QVBoxLayout(self); lo.setContentsMargins(12, 10, 12, 12); lo.setSpacing(0)\n\n # ── 头部 ──\n hdr = QHBoxLayout(); hdr.setSpacing(6)\n t = QLabel(\"AI 助手\"); t.setStyleSheet(\n \"font-size:13px;font-weight:600;background:transparent;letter-spacing:0.3px;\")\n hdr.addWidget(t); hdr.addStretch()\n self._svc_label = QLabel(\"\")\n self._svc_label.setStyleSheet(f\"font-size:10px;color:{_C['t3']};background:transparent;\")\n hdr.addWidget(self._svc_label)\n clr = QPushButton(\"清空\"); clr.setObjectName(\"Gh\")\n clr.setStyleSheet(\"font-size:10px;padding:2px 6px;\")\n clr.setCursor(Qt.PointingHandCursor); clr.clicked.connect(self._clear)\n hdr.addWidget(clr)\n self._cfg_btn = QPushButton(\"⚙\")\n self._cfg_btn.setCursor(Qt.PointingHandCursor)\n self._cfg_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:none;font-size:13px;\"\n f\"padding:2px 5px;color:{_C['t3']};}}\"\n f\"QPushButton:hover{{color:{_C['acc']};}}\")\n self._cfg_btn.clicked.connect(self._open_settings)\n hdr.addWidget(self._cfg_btn)\n lo.addLayout(hdr)\n lo.addSpacing(6)\n\n # ── 快捷操作栏 ──\n self._quick_frame = QFrame()\n self._quick_frame.setStyleSheet(\"QFrame{background:transparent;border:none;}\")\n self._quick_layout = QHBoxLayout(self._quick_frame)\n self._quick_layout.setContentsMargins(0, 0, 0, 0)\n self._quick_layout.setSpacing(6)\n self._quick_btns = []\n lo.addWidget(self._quick_frame)\n lo.addSpacing(8)\n\n # ── 聊天区 ──\n self._chat_scroll = QScrollArea(); self._chat_scroll.setWidgetResizable(True)\n self._chat_scroll.setFrameShape(QFrame.NoFrame)\n self._chat_container = QWidget()\n self._chat_layout = QVBoxLayout(self._chat_container)\n self._chat_layout.setContentsMargins(0, 4, 0, 4); self._chat_layout.setSpacing(12)\n self._chat_layout.setAlignment(Qt.AlignTop)\n self._chat_scroll.setWidget(self._chat_container)\n lo.addWidget(self._chat_scroll, 1)\n lo.addSpacing(8)\n\n # ── 输入区 ──\n self._inp_frame = QFrame()\n self._inp_frame.setStyleSheet(\n f\"QFrame{{background:{_C['card']};border:0.5px solid {_C['brd']};\"\n f\"border-radius:12px;}}\")\n inp_lo = QHBoxLayout(self._inp_frame)\n inp_lo.setContentsMargins(10, 6, 6, 6); inp_lo.setSpacing(6)\n self._input = _AutoGrowTextEdit(\"输入问题…\")\n inp_lo.addWidget(self._input, 1)\n self._send_btn = QPushButton(\"发送\"); self._send_btn.setObjectName(\"Pr\")\n self._send_btn.setStyleSheet(\"font-size:11px;padding:6px 16px;border-radius:8px;\")\n self._send_btn.setCursor(Qt.PointingHandCursor)\n self._send_btn.clicked.connect(self._send)\n self._send_btn.setFixedHeight(32)\n inp_lo.addWidget(self._send_btn, 0, Qt.AlignBottom)\n lo.addWidget(self._inp_frame)\n self._input.returnPressed.connect(self._send)\n self._char_count = None\n\n # ── 状态 ──\n self._paper_text = \"\"\n self._pdf_path = None\n self._messages = []\n self._worker = None\n self._summary_worker = None\n self._current_ai_bubble = None\n self._streaming_text = \"\"\n\n # 加载 prompt 库 & 激活列表\n self._load_library()\n\n # ══════════════════════════════════════════════════════════════\n # Prompt 库 & 持久化\n # ══════════════════════════════════════════════════════════════\n\n def _load_library(self):\n \"\"\"从配置加载 prompt 库和激活列表\"\"\"\n cfg = UserConfigManager.load()\n # 库:内置 + 用户自定义\n saved_lib = cfg.get(\"ai_prompt_library\", None)\n if saved_lib is not None:\n self._library = saved_lib\n else:\n self._library = [dict(p) for p in _BUILTIN_PROMPTS]\n # 激活的 ID 列表(显示在快捷栏,最多 3 个)\n self._active_ids = cfg.get(\"ai_active_ids\", list(_DEFAULT_ACTIVE_IDS))\n self._rebuild_quick_bar()\n\n def _save_library(self):\n cfg = UserConfigManager.load()\n cfg[\"ai_prompt_library\"] = self._library\n cfg[\"ai_active_ids\"] = self._active_ids\n UserConfigManager.save(cfg)\n\n def _get_active_prompts(self):\n \"\"\"返回当前激活的 prompt 列表(按 _active_ids 顺序)\"\"\"\n id_map = {p[\"id\"]: p for p in self._library}\n return [id_map[aid] for aid in self._active_ids if aid in id_map]\n\n # ══════════════════════════════════════════════════════════════\n # 快捷栏\n # ══════════════════════════════════════════════════════════════\n\n def _rebuild_quick_bar(self):\n \"\"\"根据激活列表重建快捷按钮 — 等宽圆角药丸\"\"\"\n while self._quick_layout.count():\n item = self._quick_layout.takeAt(0)\n w = item.widget()\n if w:\n w.deleteLater()\n self._quick_btns = []\n c = _C\n for prompt_data in self._get_active_prompts():\n b = QPushButton(prompt_data[\"label\"])\n b.setCursor(Qt.PointingHandCursor)\n b.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)\n b.setFixedHeight(32)\n b.setStyleSheet(\n f\"QPushButton{{background:{c['card']};border:0.5px solid {c['card_b']};\"\n f\"border-radius:16px;padding:0 10px;font-size:12px;color:{c['t1']};}}\"\n f\"QPushButton:hover{{background:{c['acc_l']};border-color:{c['acc']};}}\"\n f\"QPushButton:pressed{{background:{c['acc']};color:white;\"\n f\"border-color:{c['acc']};}}\"\n f\"QPushButton:disabled{{opacity:0.5;}}\")\n pid = prompt_data[\"id\"]\n b.clicked.connect(lambda _, _pid=pid: self._exec_by_id(_pid))\n self._quick_layout.addWidget(b, 1) # stretch=1 等宽分布\n self._quick_btns.append(b)\n\n def _exec_by_id(self, prompt_id):\n \"\"\"执行指定 ID 的快捷操作\"\"\"\n try:\n if self._busy:\n return\n id_map = {p[\"id\"]: p for p in self._library}\n act = id_map.get(prompt_id)\n if not act:\n return\n if act.get(\"type\") == \"summary\":\n self._run_summary()\n else:\n prompt = act.get(\"prompt\", \"\")\n if prompt:\n self._run_preset(prompt, act.get(\"label\", \"\"))\n except Exception:\n pass\n\n # ══════════════════════════════════════════════════════════════\n # ⚙ 设置弹窗 — prompt 库管理 + 激活选择\n # ══════════════════════════════════════════════════════════════\n\n def _open_settings(self):\n \"\"\"打开 prompt 库配置弹窗\"\"\"\n try:\n self._do_open_settings()\n except Exception:\n pass\n\n def _do_open_settings(self):\n c = _C\n dlg = QDialog(self)\n dlg.setWindowTitle(\"AI 快捷操作配置\")\n dlg.setMinimumSize(520, 580)\n dlg.resize(560, 660)\n dlg.setStyleSheet(f\"\"\"\n QDialog{{background:{c['bg']};}}\n QLabel{{color:{c['t1']};background:transparent;}}\n QScrollArea{{background:{c['bg']};border:none;}}\n QWidget#scroll_inner{{background:{c['bg']};}}\n QLineEdit{{background:{c['inp']};border:1px solid {c['inp_b']};\n border-radius:6px;padding:6px 10px;font-size:13px;color:{c['t1']};\n min-height:20px;}}\n QLineEdit:focus{{border-color:{c['acc']};}}\n QPlainTextEdit{{background:{c['inp']};border:1px solid {c['inp_b']};\n border-radius:8px;padding:8px 10px;font-size:12px;color:{c['t1']};\n line-height:160%;}}\n QPlainTextEdit:focus{{border-color:{c['acc']};}}\n QCheckBox{{color:{c['t1']};spacing:6px;}}\n QCheckBox::indicator{{width:18px;height:18px;border-radius:5px;\n border:1.5px solid {c['inp_b']};background:{c['inp']};}}\n QCheckBox::indicator:checked{{background:{c['acc']};border-color:{c['acc']};}}\n \"\"\")\n dlo = QVBoxLayout(dlg); dlo.setContentsMargins(24, 20, 24, 18); dlo.setSpacing(14)\n\n # ── 标题行 ──\n hdr = QHBoxLayout()\n title = QLabel(\"快捷操作库\"); title.setStyleSheet(\"font-size:16px;font-weight:600;\")\n hdr.addWidget(title); hdr.addStretch()\n hint_l = QLabel(\"勾选最多 3 个显示在快捷栏\")\n hint_l.setStyleSheet(f\"font-size:11px;color:{c['t3']};\")\n hdr.addWidget(hint_l)\n dlo.addLayout(hdr)\n\n # ── 滚动区 ──\n scroll = QScrollArea(); scroll.setWidgetResizable(True)\n scroll.setFrameShape(QFrame.NoFrame)\n container = QWidget(); container.setObjectName(\"scroll_inner\")\n clo = QVBoxLayout(container); clo.setContentsMargins(0, 0, 0, 0); clo.setSpacing(10)\n clo.setAlignment(Qt.AlignTop)\n\n _cards = [] # list of entry dicts\n\n def _make_card(p, parent_layout):\n pid = p.get(\"id\", \"\")\n tp = p.get(\"type\", \"\")\n card = QFrame()\n card.setStyleSheet(\n f\"QFrame{{background:{c['card']};border:0.5px solid {c['card_b']};\"\n f\"border-radius:12px;}}\")\n card_lo = QVBoxLayout(card)\n card_lo.setContentsMargins(14, 12, 14, 12); card_lo.setSpacing(8)\n\n # ── 第一行:勾选 + 名称 + 删除 ──\n r1 = QHBoxLayout(); r1.setSpacing(10)\n cb = QCheckBox()\n cb.setFixedSize(22, 22)\n cb.setChecked(pid in self._active_ids)\n cb.setToolTip(\"勾选后显示在快捷栏\")\n r1.addWidget(cb, 0, Qt.AlignVCenter)\n name_inp = QLineEdit(p.get(\"label\", \"\")); name_inp.setMaxLength(12)\n name_inp.setPlaceholderText(\"按钮名称\")\n name_inp.setFixedHeight(32)\n r1.addWidget(name_inp, 1, Qt.AlignVCenter)\n is_builtin = any(bp[\"id\"] == pid for bp in _BUILTIN_PROMPTS)\n del_btn = QPushButton(\"×\")\n del_btn.setCursor(Qt.PointingHandCursor)\n del_btn.setFixedSize(26, 26)\n del_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:none;color:{c['t3']};\"\n f\"font-size:16px;font-weight:600;border-radius:13px;}}\"\n f\"QPushButton:hover{{background:rgba(255,59,48,0.1);color:{c['err']};}}\")\n if is_builtin:\n del_btn.setVisible(False)\n r1.addWidget(del_btn, 0, Qt.AlignVCenter)\n card_lo.addLayout(r1)\n\n # ── 第二行:描述 ──\n desc = p.get(\"desc\", \"\")\n if desc:\n dl = QLabel(desc)\n dl.setStyleSheet(f\"font-size:11px;color:{c['t3']};padding-left:32px;\")\n card_lo.addWidget(dl)\n\n # ── 第三行:Prompt 编辑 ──\n if tp == \"summary\":\n hl = QLabel(\"🔧 内置摘要引擎,自动分析论文结构并生成结构化摘要\")\n hl.setStyleSheet(\n f\"font-size:11px;color:{c['t3']};padding:6px 10px;\"\n f\"background:{c['bg2']};border-radius:6px;\")\n hl.setWordWrap(True)\n card_lo.addWidget(hl)\n prompt_inp = None\n else:\n prompt_inp = QPlainTextEdit()\n prompt_inp.setPlainText(p.get(\"prompt\", \"\"))\n prompt_inp.setPlaceholderText(\"输入提示词…\\n\\n描述你希望 AI 对论文做什么分析\")\n prompt_inp.setMinimumHeight(90)\n prompt_inp.setMaximumHeight(200)\n card_lo.addWidget(prompt_inp)\n\n parent_layout.addWidget(card)\n entry = {\"cb\": cb, \"name\": name_inp, \"prompt\": prompt_inp,\n \"id\": pid, \"type\": tp, \"card\": card}\n _cards.append(entry)\n\n # 删除\n def _del(_, _e=entry):\n _e[\"card\"].setVisible(False)\n _e[\"card\"].deleteLater()\n _e[\"deleted\"] = True\n del_btn.clicked.connect(_del)\n\n # 勾选约束:最多 3 个\n def _on_check(state, _e=entry):\n checked = [e for e in _cards\n if not e.get(\"deleted\") and e[\"cb\"].isChecked()]\n if len(checked) > 3:\n _e[\"cb\"].setChecked(False)\n cb.stateChanged.connect(_on_check)\n\n for p in self._library:\n _make_card(p, clo)\n\n scroll.setWidget(container)\n dlo.addWidget(scroll, 1)\n\n # ── 底部按钮区 ──\n blo = QHBoxLayout(); blo.setSpacing(8)\n # 新建\n add_btn = QPushButton(\"+ 新建\")\n add_btn.setCursor(Qt.PointingHandCursor)\n add_btn.setStyleSheet(\n f\"QPushButton{{background:{c['acc_l']};border:none;border-radius:8px;\"\n f\"padding:8px 14px;font-size:12px;color:{c['acc']};}}\"\n f\"QPushButton:hover{{background:{c['acc']};color:white;}}\")\n _counter = [0]\n def _on_add():\n _counter[0] += 1\n new_p = {\"id\": f\"custom_{_counter[0]}_{id(dlg)}\",\n \"label\": \"🔧 自定义\", \"prompt\": \"\", \"desc\": \"自定义提示词\"}\n _make_card(new_p, clo)\n QTimer.singleShot(50, lambda: scroll.verticalScrollBar().setValue(\n scroll.verticalScrollBar().maximum()))\n add_btn.clicked.connect(_on_add)\n blo.addWidget(add_btn)\n\n # 导入\n imp_btn = QPushButton(\"📥 导入\")\n imp_btn.setCursor(Qt.PointingHandCursor)\n imp_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:1px solid {c['inp_b']};\"\n f\"border-radius:8px;padding:8px 14px;font-size:12px;color:{c['t2']};}}\"\n f\"QPushButton:hover{{border-color:{c['acc']};color:{c['acc']};}}\")\n def _on_import():\n path, _ = QFileDialog.getOpenFileName(\n dlg, \"导入提示词\",\n str(Path.home()),\n \"文本文件 (*.txt *.md);;JSON (*.json);;所有文件 (*)\")\n if not path:\n return\n try:\n content = Path(path).read_text(encoding=\"utf-8\")\n fname = Path(path).stem\n # 尝试 JSON\n try:\n data = json.loads(content)\n items = data if isinstance(data, list) else [data]\n for item in items:\n _counter[0] += 1\n new_p = {\n \"id\": f\"import_{_counter[0]}_{id(dlg)}\",\n \"label\": item.get(\"label\", f\"📥 {fname}\"),\n \"prompt\": item.get(\"prompt\", content),\n \"desc\": item.get(\"desc\", \"导入的提示词\"),\n }\n _make_card(new_p, clo)\n except (json.JSONDecodeError, AttributeError):\n # 纯文本 → 整个文件内容作为 prompt\n _counter[0] += 1\n new_p = {\n \"id\": f\"import_{_counter[0]}_{id(dlg)}\",\n \"label\": f\"📥 {fname[:8]}\",\n \"prompt\": content,\n \"desc\": \"导入的提示词\",\n }\n _make_card(new_p, clo)\n QTimer.singleShot(50, lambda: scroll.verticalScrollBar().setValue(\n scroll.verticalScrollBar().maximum()))\n except Exception as e:\n QMessageBox.warning(dlg, \"导入失败\", str(e))\n imp_btn.clicked.connect(_on_import)\n blo.addWidget(imp_btn)\n\n # 恢复默认\n reset_btn = QPushButton(\"恢复默认\")\n reset_btn.setCursor(Qt.PointingHandCursor)\n reset_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:1px solid {c['inp_b']};\"\n f\"border-radius:8px;padding:8px 14px;font-size:12px;color:{c['t2']};}}\"\n f\"QPushButton:hover{{border-color:{c['acc']};color:{c['acc']};}}\")\n def _on_reset():\n for entry in _cards:\n entry[\"deleted\"] = True\n entry[\"card\"].setVisible(False)\n entry[\"card\"].deleteLater()\n _cards.clear()\n for p in _BUILTIN_PROMPTS:\n _make_card(dict(p), clo)\n for entry in _cards:\n entry[\"cb\"].setChecked(entry[\"id\"] in _DEFAULT_ACTIVE_IDS)\n reset_btn.clicked.connect(_on_reset)\n blo.addWidget(reset_btn)\n\n blo.addStretch()\n ok_btn = QPushButton(\"保存\")\n ok_btn.setCursor(Qt.PointingHandCursor)\n ok_btn.setStyleSheet(\n f\"QPushButton{{background:{c['acc']};color:white;border:none;\"\n f\"font-size:13px;padding:8px 30px;border-radius:8px;}}\"\n f\"QPushButton:hover{{background:{c['acc_h']};}}\")\n ok_btn.clicked.connect(dlg.accept)\n blo.addWidget(ok_btn)\n dlo.addLayout(blo)\n\n # ── 执行弹窗 ──\n if dlg.exec_() == QDialog.Accepted:\n new_library = []\n new_active = []\n for entry in _cards:\n if entry.get(\"deleted\"):\n continue\n try:\n label = entry[\"name\"].text().strip()\n except RuntimeError:\n continue\n if not label:\n continue\n p = {\"id\": entry[\"id\"], \"label\": label, \"type\": entry[\"type\"]}\n p[\"desc\"] = next((bp.get(\"desc\", \"\") for bp in _BUILTIN_PROMPTS\n if bp[\"id\"] == entry[\"id\"]), \"自定义提示词\")\n if entry[\"type\"] == \"summary\":\n p[\"prompt\"] = \"\"\n else:\n p[\"prompt\"] = entry[\"prompt\"].toPlainText() if entry[\"prompt\"] else \"\"\n new_library.append(p)\n if entry[\"cb\"].isChecked():\n new_active.append(entry[\"id\"])\n if new_active:\n self._library = new_library\n self._active_ids = new_active[:3]\n self._save_library()\n self._rebuild_quick_bar()\n\n # ══════════════════════════════════════════════════════════════\n # 论文上下文 & 聊天\n # ══════════════════════════════════════════════════════════════\n\n def set_paper_context(self, pdf_path):\n \"\"\"提取 PDF 文本作为问答上下文\"\"\"\n self._pdf_path = pdf_path\n try:\n doc = fitz.open(pdf_path)\n text = \"\"\n for i in range(min(15, len(doc))):\n text += doc[i].get_text()\n doc.close()\n self._paper_text = text[:10000]\n except Exception:\n self._paper_text = \"\"\n self._messages = []\n self._clear()\n try:\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n self._svc_label.setText(f\"via {svc['name']} · {svc['model']}\" if svc else \"未配置\")\n self._send_btn.setEnabled(bool(svc))\n except Exception:\n pass\n\n def _clear(self):\n try:\n self._messages = []\n self._current_ai_bubble = None\n self._stop_workers()\n while self._chat_layout.count():\n w = self._chat_layout.takeAt(0).widget()\n if w:\n w.deleteLater()\n except Exception:\n pass\n\n def _add_bubble(self, text, is_user=True):\n \"\"\"添加消息气泡\"\"\"\n row = QWidget(); row.setStyleSheet(\"background:transparent;\")\n row_lo = QHBoxLayout(row)\n row_lo.setContentsMargins(0, 0, 0, 0); row_lo.setSpacing(0)\n bubble = QLabel()\n bubble.setWordWrap(True)\n bubble.setTextInteractionFlags(Qt.TextSelectableByMouse)\n bubble.setMaximumWidth(300)\n c = _C\n if is_user:\n bubble.setText(text)\n bubble.setStyleSheet(\n f\"background:{c['acc']};color:white;border-radius:12px;\"\n f\"border-bottom-right-radius:4px;\"\n f\"padding:8px 12px;font-size:12px;\"\n f\"font-family:'Helvetica Neue','PingFang SC';\")\n row_lo.addStretch(); row_lo.addWidget(bubble)\n else:\n bubble.setTextFormat(Qt.RichText)\n html = _md2html(text) if text and not text.startswith('⏳') else text\n bubble.setText(html)\n bubble.setStyleSheet(\n f\"background:{c['card']};border:0.5px solid {c['card_b']};\"\n f\"border-radius:12px;border-top-left-radius:4px;\"\n f\"padding:10px 14px;font-size:12px;line-height:165%;\"\n f\"font-family:'Helvetica Neue','PingFang SC';\")\n row_lo.addWidget(bubble); row_lo.addStretch()\n self._chat_layout.addWidget(row)\n QTimer.singleShot(50, lambda: self._safe_scroll_bottom())\n return bubble\n\n def _safe_scroll_bottom(self):\n try:\n sb = self._chat_scroll.verticalScrollBar()\n sb.setValue(sb.maximum())\n except Exception:\n pass\n\n # ══════════════════════════════════════════════════════════════\n # AI Worker 管理(摘要 / 问答 / 流式)\n # ══════════════════════════════════════════════════════════════\n\n def _set_busy(self, busy):\n self._busy = busy\n try:\n self._send_btn.setEnabled(not busy)\n for b in self._quick_btns:\n b.setEnabled(not busy)\n except Exception:\n pass\n\n def _run_summary(self):\n \"\"\"使用 SummaryWorker 生成结构化摘要\"\"\"\n try:\n if not self._pdf_path or not os.path.exists(self._pdf_path):\n return\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if not svc:\n self._add_bubble(\"⚠️ 未配置 AI 服务\", is_user=False)\n return\n self._stop_workers()\n self._set_busy(True)\n self._add_bubble(\"📝 生成摘要\", is_user=True)\n self._current_ai_bubble = self._add_bubble(\"⏳ 正在分析论文…\", is_user=False)\n self._summary_worker = SummaryWorker(self._pdf_path)\n self._summary_worker.result.connect(self._on_summary_result)\n self._summary_worker.error.connect(self._on_qa_error)\n self._summary_worker.finished.connect(self._on_summary_done)\n self._summary_worker.start()\n except Exception:\n self._set_busy(False)\n\n def _run_preset(self, prompt, label=\"\"):\n \"\"\"用预设 prompt 发起问答\"\"\"\n try:\n if not self._paper_text:\n return\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if not svc:\n self._add_bubble(\"⚠️ 未配置 AI 服务\", is_user=False)\n return\n self._stop_workers()\n self._set_busy(True)\n self._add_bubble(label or (prompt[:15] + \"…\"), is_user=True)\n self._messages = [\n {\"role\": \"system\", \"content\": (\n \"你是论文阅读助手。以下是论文全文内容(可能被截断),请根据论文内容回答用户问题。\\n\"\n \"回答要准确、有深度,用中文。使用 Markdown 格式化输出。\\n\"\n \"如果论文中没有相关信息,请如实说明。\\n\\n\"\n f\"论文内容:\\n{self._paper_text}\"\n )},\n {\"role\": \"user\", \"content\": prompt},\n ]\n self._current_ai_bubble = self._add_bubble(\"⏳ 思考中…\", is_user=False)\n self._worker = QAWorker(list(self._messages))\n self._worker.chunk.connect(self._on_chunk)\n self._worker.finished.connect(self._on_qa_done)\n self._worker.error.connect(self._on_qa_error)\n self._worker.start()\n self._streaming_text = \"\"\n except Exception:\n self._set_busy(False)\n\n def _stop_workers(self):\n \"\"\"断开并停止所有 AI worker\"\"\"\n for attr in ('_worker', '_summary_worker'):\n w = getattr(self, attr, None)\n if w:\n for sig in ('chunk', 'result', 'finished', 'error'):\n try:\n getattr(w, sig).disconnect()\n except Exception:\n pass\n setattr(self, attr, None)\n try:\n w.quit()\n except Exception:\n pass\n\n @staticmethod\n def _widget_alive(w):\n try:\n w.objectName()\n return True\n except RuntimeError:\n return False\n\n def _on_summary_result(self, text):\n try:\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(_md2html(text))\n QTimer.singleShot(0, self._safe_scroll_bottom)\n except Exception:\n pass\n\n def _on_summary_done(self):\n try:\n self._set_busy(False)\n self._current_ai_bubble = None\n if self._summary_worker:\n w = self._summary_worker; self._summary_worker = None\n w.quit()\n except Exception:\n self._set_busy(False)\n\n def _on_chunk(self, text):\n try:\n self._streaming_text += text\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(_md2html(self._streaming_text))\n QTimer.singleShot(0, self._safe_scroll_bottom)\n except Exception:\n pass\n\n def _on_qa_done(self, full_text):\n try:\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(_md2html(full_text))\n self._messages.append({\"role\": \"assistant\", \"content\": full_text})\n self._set_busy(False)\n self._current_ai_bubble = None\n if self._worker:\n w = self._worker; self._worker = None\n w.quit()\n except Exception:\n self._set_busy(False)\n\n def _on_qa_error(self, err):\n try:\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(f\"⚠️ {err}\")\n self._set_busy(False)\n self._current_ai_bubble = None\n for attr in ('_worker', '_summary_worker'):\n w = getattr(self, attr, None)\n if w:\n setattr(self, attr, None)\n w.quit()\n except Exception:\n self._set_busy(False)\n\n def _send(self):\n try:\n q = self._input.text().strip()\n if not q or not self._paper_text or self._busy:\n return\n self._stop_workers()\n self._input.clear()\n self._set_busy(True)\n self._add_bubble(q, is_user=True)\n if not self._messages:\n self._messages.append({\n \"role\": \"system\",\n \"content\": (\n \"你是论文阅读助手。以下是论文全文内容(可能被截断),请根据论文内容回答用户问题。\\n\"\n \"回答要准确、有深度,用中文。使用 Markdown 格式化输出。\\n\"\n \"如果论文中没有相关信息,请如实说明。\\n\\n\"\n f\"论文内容:\\n{self._paper_text}\"\n )\n })\n self._messages.append({\"role\": \"user\", \"content\": q})\n self._current_ai_bubble = self._add_bubble(\"⏳ 思考中…\", is_user=False)\n self._worker = QAWorker(list(self._messages))\n self._worker.chunk.connect(self._on_chunk)\n self._worker.finished.connect(self._on_qa_done)\n self._worker.error.connect(self._on_qa_error)\n self._worker.start()\n self._streaming_text = \"\"\n except Exception:\n self._set_busy(False)\n\n # ══════════════════════════════════════════════════════════════\n # 主题切换\n # ══════════════════════════════════════════════════════════════\n\n def update_theme(self, c):\n try:\n self._svc_label.setStyleSheet(f\"font-size:10px;color:{c['t3']};background:transparent;\")\n self._inp_frame.setStyleSheet(\n f\"QFrame{{background:{c['card']};border:0.5px solid {c['brd']};\"\n f\"border-radius:12px;}}\")\n self._quick_frame.setStyleSheet(\"QFrame{background:transparent;border:none;}\")\n self._rebuild_quick_bar()\n self._cfg_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:none;font-size:13px;\"\n f\"padding:2px 5px;color:{c['t3']};}}\"\n f\"QPushButton:hover{{color:{c['acc']};}}\")\n except Exception:\n pass\n\n\nclass PreviewPage(QWidget):\n \"\"\"三合一预览: 顶部模式切换按钮 + PDF 显示\"\"\"\n fullscreen_toggled = pyqtSignal(bool)\n history_toggled = pyqtSignal()\n\n def __init__(self):\n super().__init__()\n self.doc = None\n self.current_page = 0\n self.zoom = 1.5\n self.output_files = {}\n self.current_mode = \"side_by_side\" # 默认 Side by Side\n self.setFocusPolicy(Qt.StrongFocus) # 接收键盘事件\n\n lo = QVBoxLayout(self); lo.setContentsMargins(0,0,0,0); lo.setSpacing(0)\n\n # 工具栏\n tb = QWidget(); tb.setObjectName(\"PT\"); tb.setFixedHeight(48)\n tl = QHBoxLayout(tb); tl.setContentsMargins(12,0,12,0); tl.setSpacing(6)\n\n # 模式切换按钮\n self.mode_btns = {}\n for label, mode in [(\"Dual\",\"dual\"),(\"Mono\",\"mono\"),(\"Side by Side\",\"side_by_side\")]:\n b = QPushButton(label); b.setObjectName(\"TB\"); b.setCursor(Qt.PointingHandCursor)\n b.setProperty(\"active\", mode == \"side_by_side\")\n b.clicked.connect(lambda _, m=mode: self.switch_mode(m))\n tl.addWidget(b); self.mode_btns[mode] = b\n\n tl.addSpacing(8)\n # 连续滚动 / 单页 切换\n self._continuous = True\n self.scroll_btn = QPushButton(\"连续\"); self.scroll_btn.setObjectName(\"TB\")\n self.scroll_btn.setCursor(Qt.PointingHandCursor)\n self.scroll_btn.setProperty(\"active\", True)\n self.scroll_btn.clicked.connect(self._toggle_continuous)\n tl.addWidget(self.scroll_btn)\n\n tl.addSpacing(12)\n ob = QPushButton(\"打开\"); ob.setObjectName(\"Sc\"); ob.setCursor(Qt.PointingHandCursor)\n ob.setStyleSheet(\"padding:6px 14px;font-size:13px;\")\n ob.clicked.connect(self.open_file); tl.addWidget(ob)\n\n tl.addStretch()\n\n # 翻页:首页 < 上一页 | 页码输入 / 总页数 | 下一页 > 末页\n first_btn = QPushButton(\"⟨⟨\"); first_btn.setObjectName(\"TB\"); first_btn.setFixedWidth(32)\n first_btn.setCursor(Qt.PointingHandCursor)\n first_btn.clicked.connect(self.first_page); tl.addWidget(first_btn)\n\n prev_btn = QPushButton(\"‹\"); prev_btn.setObjectName(\"TB\"); prev_btn.setFixedWidth(32)\n prev_btn.setCursor(Qt.PointingHandCursor)\n prev_btn.clicked.connect(self.prev_page); tl.addWidget(prev_btn)\n\n # 页码输入框\n self.page_input = QLineEdit(\"1\")\n self.page_input.setFixedWidth(42)\n self.page_input.setAlignment(Qt.AlignCenter)\n self.page_input.setObjectName(\"PageInput\")\n self.page_input.returnPressed.connect(self._jump_to_page)\n tl.addWidget(self.page_input)\n\n self.total_label = QLabel(\"/ —\")\n self.total_label.setObjectName(\"TotalLbl\")\n tl.addWidget(self.total_label)\n\n next_btn = QPushButton(\"›\"); next_btn.setObjectName(\"TB\"); next_btn.setFixedWidth(32)\n next_btn.setCursor(Qt.PointingHandCursor)\n next_btn.clicked.connect(self.next_page); tl.addWidget(next_btn)\n\n last_btn = QPushButton(\"⟩⟩\"); last_btn.setObjectName(\"TB\"); last_btn.setFixedWidth(32)\n last_btn.setCursor(Qt.PointingHandCursor)\n last_btn.clicked.connect(self.last_page); tl.addWidget(last_btn)\n\n tl.addStretch()\n\n # 缩放\n self.zoom_slider = QSlider(Qt.Horizontal)\n self.zoom_slider.setRange(30,400); self.zoom_slider.setValue(150)\n self.zoom_slider.setFixedWidth(90); self.zoom_slider.valueChanged.connect(self.on_zoom)\n tl.addWidget(self.zoom_slider)\n self.zoom_pct = QLabel(\"150%\")\n self.zoom_pct.setObjectName(\"ZoomPct\")\n self.zoom_pct.setFixedWidth(36)\n tl.addWidget(self.zoom_pct)\n # 适配模式:适宽 / 适页 两个按钮\n self._fit_mode = \"width\" # \"width\" 适宽 | \"page\" 适页\n self.fit_btn = QPushButton(\"适宽\"); self.fit_btn.setObjectName(\"TB\")\n self.fit_btn.setCursor(Qt.PointingHandCursor)\n self.fit_btn.setProperty(\"active\", True)\n self.fit_btn.clicked.connect(self._switch_to_fit_width)\n tl.addWidget(self.fit_btn)\n self.fitpage_btn = QPushButton(\"适页\"); self.fitpage_btn.setObjectName(\"TB\")\n self.fitpage_btn.setCursor(Qt.PointingHandCursor)\n self.fitpage_btn.clicked.connect(self._switch_to_fit_page)\n tl.addWidget(self.fitpage_btn)\n\n tl.addSpacing(6)\n self._is_fullscreen = False\n # 全屏中唤出历史面板\n self.hist_btn = QPushButton(\"历史\"); self.hist_btn.setObjectName(\"TB\")\n self.hist_btn.setCursor(Qt.PointingHandCursor)\n self.hist_btn.setVisible(False)\n self.hist_btn.clicked.connect(self.history_toggled.emit)\n tl.addWidget(self.hist_btn)\n # AI 问答按钮\n self.qa_btn = QPushButton(\"AI 问答\"); self.qa_btn.setObjectName(\"TB\")\n self.qa_btn.setCursor(Qt.PointingHandCursor)\n self.qa_btn.clicked.connect(self._toggle_qa_panel)\n tl.addWidget(self.qa_btn)\n # 高亮按钮\n self.hl_btn = QPushButton(\"高亮\"); self.hl_btn.setObjectName(\"TB\")\n self.hl_btn.setCursor(Qt.PointingHandCursor)\n self.hl_btn.setCheckable(True)\n self.hl_btn.clicked.connect(self._toggle_highlight)\n tl.addWidget(self.hl_btn)\n # 擦除按钮\n self.hl_erase_btn = QPushButton(\"擦除\"); self.hl_erase_btn.setObjectName(\"TB\")\n self.hl_erase_btn.setCursor(Qt.PointingHandCursor)\n self.hl_erase_btn.setCheckable(True)\n self.hl_erase_btn.clicked.connect(self._toggle_erase)\n self.hl_erase_btn.setVisible(False) # 进入高亮模式才显示\n tl.addWidget(self.hl_erase_btn)\n # 全屏按钮\n self.fs_btn = QPushButton(\"全屏\"); self.fs_btn.setObjectName(\"TB\")\n self.fs_btn.setCursor(Qt.PointingHandCursor)\n self.fs_btn.clicked.connect(self._toggle_fullscreen)\n tl.addWidget(self.fs_btn)\n\n lo.addWidget(tb)\n\n # 内容区:缩略图 + 主视图(QSplitter 支持拖拽调整缩略图宽度)\n self.body_splitter = QSplitter(Qt.Horizontal)\n self.body_splitter.setHandleWidth(1)\n self.body_splitter.setChildrenCollapsible(True)\n\n # 缩略图面板\n self.thumb_scroll = QScrollArea()\n self.thumb_scroll.setMinimumWidth(60); self.thumb_scroll.setMaximumWidth(240)\n self.thumb_scroll.setWidgetResizable(True); self.thumb_scroll.setFrameShape(QFrame.NoFrame)\n self.thumb_scroll.setObjectName(\"PA\"); self.thumb_scroll.setFocusPolicy(Qt.StrongFocus)\n self.thumb_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self.thumb_container = QWidget(); self.thumb_container.setObjectName(\"PA\")\n self.thumb_layout = QVBoxLayout(self.thumb_container)\n self.thumb_layout.setContentsMargins(4,6,4,6); self.thumb_layout.setSpacing(4)\n self.thumb_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)\n self.thumb_scroll.setWidget(self.thumb_container)\n self.thumb_scroll.setVisible(False)\n self.body_splitter.addWidget(self.thumb_scroll)\n\n # 主视图容器\n self._main_view = QWidget(); self._main_view.setObjectName(\"PA\")\n _mv_lo = QHBoxLayout(self._main_view)\n _mv_lo.setContentsMargins(0,0,0,0); _mv_lo.setSpacing(0)\n\n # 主视图 — 单页模式\n self.scroll = QScrollArea(); self.scroll.setWidgetResizable(True)\n self.scroll.setAlignment(Qt.AlignCenter); self.scroll.setObjectName(\"PA\")\n self.page_widget = PDFPageWidget()\n self.page_widget.setStyleSheet(\"padding:2px;\")\n shadow = QGraphicsDropShadowEffect(); shadow.setBlurRadius(28); shadow.setOffset(0,4)\n shadow.setColor(QColor(0,0,0,30)); self.page_widget.setGraphicsEffect(shadow)\n self.scroll.setWidget(self.page_widget)\n self._highlight_mode = False\n self._hl_erase_mode = False\n self._hl_start = None\n self._hl_overlay = None\n self.scroll.setVisible(False) # 默认连续模式,隐藏单页\n _mv_lo.addWidget(self.scroll)\n\n # 主视图 — 连续滚动模式(默认显示)\n self.cont_scroll = QScrollArea(); self.cont_scroll.setWidgetResizable(True)\n self.cont_scroll.setObjectName(\"PA\"); self.cont_scroll.setVisible(True)\n self.cont_container = QWidget(); self.cont_container.setObjectName(\"PA\")\n self.cont_layout = QVBoxLayout(self.cont_container)\n self.cont_layout.setContentsMargins(0,0,0,0); self.cont_layout.setSpacing(4)\n self.cont_layout.setAlignment(Qt.AlignHCenter)\n self.cont_scroll.setWidget(self.cont_container)\n self.cont_scroll.verticalScrollBar().valueChanged.connect(self._on_cont_scroll)\n _mv_lo.addWidget(self.cont_scroll)\n self._cont_page_widgets = []\n\n self.body_splitter.addWidget(self._main_view)\n # AI 问答面板(右侧,默认隐藏)\n self.qa_panel = QAPanelWidget()\n self.qa_panel.setVisible(False)\n self.body_splitter.addWidget(self.qa_panel)\n self.body_splitter.setSizes([140, 800, 0])\n self.body_splitter.setCollapsible(0, True) # 缩略图可折叠\n self.body_splitter.setCollapsible(1, False)\n self.body_splitter.setCollapsible(2, True) # QA 面板可折叠\n # 拖拽缩略图面板宽度时自动重建缩略图\n self.body_splitter.splitterMoved.connect(self._on_thumb_panel_resized)\n self.body_widget = self.body_splitter # 兼容引用\n lo.addWidget(self.body_splitter)\n\n # 空状态\n self.empty = QWidget(); self.empty.setObjectName(\"PA\")\n el = QVBoxLayout(self.empty); el.setAlignment(Qt.AlignCenter); el.setSpacing(16)\n el.setContentsMargins(40, 40, 40, 40)\n ic = QLabel(); ic.setFixedSize(64, 64); ic.setAlignment(Qt.AlignCenter)\n ic.setStyleSheet(f\"background:{_C['acc_l']};border-radius:32px;font-size:28px;\")\n ic.setText(\"👀\"); self._empty_icon = ic; el.addWidget(ic, alignment=Qt.AlignCenter)\n m = QLabel(\"翻译完成后在此预览\"); m.setStyleSheet(f\"font-size:15px;font-weight:600;color:{_C['t1']};\"); m.setAlignment(Qt.AlignCenter); el.addWidget(m)\n s = QLabel(\"支持 Dual · Mono · Side by Side 三种模式切换\"); s.setObjectName(\"Cap\"); s.setAlignment(Qt.AlignCenter); el.addWidget(s)\n s2 = QLabel(\"← 也可点击左侧历史记录直接打开\"); s2.setStyleSheet(f\"font-size:11px;color:{_C['t3']};\"); s2.setAlignment(Qt.AlignCenter); el.addWidget(s2)\n self.body_widget.setVisible(False); lo.addWidget(self.empty)\n self._thumb_labels = []\n\n # 滚轮缩放\n self.scroll.wheelEvent = self._wheel_event\n self.cont_scroll.wheelEvent = self._wheel_event\n # macOS 触控板双指缩放:用独立 QObject 做全局 eventFilter(安全)\n self._pinch_accumulator = 0.0\n self._pinch_filter = _PinchFilter(self)\n app = QApplication.instance()\n if app:\n app.installEventFilter(self._pinch_filter)\n\n def _apply_zoom(self, new_zoom):\n \"\"\"统一缩放处理\"\"\"\n self.zoom = new_zoom\n slider_val = int(new_zoom * 100)\n self.zoom_slider.blockSignals(True)\n self.zoom_slider.setValue(min(slider_val, 400))\n self.zoom_slider.blockSignals(False)\n self.zoom_pct.setText(f\"{slider_val}%\")\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n\n def _reset_fit(self):\n \"\"\"还原到当前适配模式的最佳贴合\"\"\"\n if self.doc:\n if self._fit_mode == \"page\":\n self._fit_page()\n else:\n self._fit_width()\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n\n def _wheel_event(self, event):\n \"\"\"滚轮翻页 + Ctrl+滚轮缩放\"\"\"\n mods = event.modifiers()\n\n # Ctrl + 滚轮 = 缩放\n if mods & Qt.ControlModifier:\n delta = event.angleDelta().y()\n step = 0.1 if delta > 0 else -0.1\n self._apply_zoom(max(0.5, min(self.zoom + step, 4.0)))\n return\n\n # 连续滚动模式:自然滚动\n if self._continuous:\n target = self.cont_scroll\n QScrollArea.wheelEvent(target, event)\n return\n\n # 单页模式:到边界翻页\n sb = self.scroll.verticalScrollBar()\n delta = event.angleDelta().y()\n at_bottom = sb.value() >= sb.maximum()\n at_top = sb.value() <= sb.minimum()\n\n if delta < 0 and at_bottom:\n self.next_page()\n elif delta > 0 and at_top:\n self.prev_page()\n else:\n QScrollArea.wheelEvent(self.scroll, event)\n\n def set_output_files(self, files: dict):\n \"\"\"翻译完成后设置三种文件路径\"\"\"\n self.output_files = files\n self.switch_mode(self.current_mode)\n\n def switch_mode(self, mode):\n self.current_mode = mode\n for m, b in self.mode_btns.items():\n b.setProperty(\"active\", m == mode)\n b.style().unpolish(b); b.style().polish(b)\n\n path = self.output_files.get(mode, \"\")\n if path and os.path.exists(path):\n self.load_pdf(path)\n return\n if self.output_files:\n # 回退到有文件的模式\n for fallback in [\"dual\", \"mono\", \"side_by_side\"]:\n fp = self.output_files.get(fallback, \"\")\n if fp and os.path.exists(fp):\n self.load_pdf(fp); break\n\n def load_pdf(self, path):\n try:\n same_file = (path == getattr(self, '_loaded_path', None) and self.doc)\n if not same_file:\n self._loaded_path = path\n self.doc = fitz.open(path)\n self.current_page = 0\n self._last_thumb_vp_w = -1 # 强制首次重建缩略图\n self._last_fit_zoom = -1 # 强制首次渲染\n self.empty.setVisible(False)\n self.body_widget.setVisible(True)\n self.thumb_scroll.setVisible(True)\n # 视口已就绪 → 立即贴合渲染(zoom 未变则跳过)\n vp = self.scroll.viewport() if not self._continuous else self.cont_scroll.viewport()\n if vp.width() >= 100:\n self._fit_and_render()\n else:\n QTimer.singleShot(0, self._fit_and_render)\n # 设置 AI 面板上下文\n if not same_file:\n self.qa_panel.set_paper_context(path)\n except Exception as e:\n self.page_widget.setText(f\"加载失败: {e}\")\n\n def _toggle_qa_panel(self):\n \"\"\"切换 AI 问答面板 — 即时更新布局\"\"\"\n vis = not self.qa_panel.isVisible()\n self.qa_panel.setVisible(vis)\n self.qa_btn.setProperty(\"active\", vis)\n self.qa_btn.style().unpolish(self.qa_btn); self.qa_btn.style().polish(self.qa_btn)\n if vis:\n sizes = self.body_splitter.sizes()\n self.body_splitter.setSizes([sizes[0], max(sizes[1] - 300, 400), 300])\n else:\n sizes = self.body_splitter.sizes()\n self.body_splitter.setSizes([sizes[0], sizes[1] + sizes[2], 0])\n # 立即重新适配渲染,确保 PDF 视图即时伸缩\n QTimer.singleShot(0, self._fit_and_render)\n\n def _toggle_highlight(self):\n \"\"\"切换高亮标注模式\"\"\"\n self._highlight_mode = self.hl_btn.isChecked()\n self._hl_erase_mode = False\n self.hl_btn.setProperty(\"active\", self._highlight_mode)\n self.hl_btn.style().unpolish(self.hl_btn); self.hl_btn.style().polish(self.hl_btn)\n # 显示/隐藏擦除按钮\n self.hl_erase_btn.setVisible(self._highlight_mode)\n if not self._highlight_mode:\n self.hl_erase_btn.setChecked(False)\n self.hl_erase_btn.setProperty(\"active\", False)\n self.hl_erase_btn.style().unpolish(self.hl_erase_btn)\n self.hl_erase_btn.style().polish(self.hl_erase_btn)\n cursor = Qt.CrossCursor if self._highlight_mode else Qt.ArrowCursor\n if self._continuous:\n for w in self._cont_page_widgets:\n w.setCursor(cursor)\n else:\n self.page_widget.setCursor(cursor)\n\n def _toggle_erase(self):\n \"\"\"切换擦除模式(高亮模式的子模式)\"\"\"\n self._hl_erase_mode = self.hl_erase_btn.isChecked()\n self.hl_erase_btn.setProperty(\"active\", self._hl_erase_mode)\n self.hl_erase_btn.style().unpolish(self.hl_erase_btn)\n self.hl_erase_btn.style().polish(self.hl_erase_btn)\n\n # ── 高亮标注 ──\n\n def _hl_mouse_press(self, e, page_idx, label):\n if not getattr(self, '_highlight_mode', False):\n return\n if e.button() == Qt.LeftButton:\n self._hl_start = e.pos()\n self._hl_page_idx = page_idx\n self._hl_label = label\n # 擦除模式:按了擦除按钮 或 按住 Option 键\n is_erase = getattr(self, '_hl_erase_mode', False) or (e.modifiers() & Qt.AltModifier)\n self._hl_dragging_delete = bool(is_erase)\n if is_erase:\n # 红色半透明删除覆盖层\n if not hasattr(self, '_hl_del_overlay') or self._hl_del_overlay is None:\n self._hl_del_overlay = QWidget(label)\n self._hl_del_overlay.setStyleSheet(\n \"background:rgba(255,59,48,0.18);border:1.5px solid rgba(255,59,48,0.6);border-radius:3px;\")\n from PyQt5.QtCore import QRect\n self._hl_del_overlay.setGeometry(QRect(self._hl_start, QSize()))\n self._hl_del_overlay.show()\n self._hl_del_overlay.raise_()\n else:\n # 蓝色添加高亮覆盖层\n if not hasattr(self, '_hl_overlay') or self._hl_overlay is None:\n from PyQt5.QtWidgets import QRubberBand\n self._hl_overlay = QRubberBand(QRubberBand.Rectangle, label)\n from PyQt5.QtCore import QRect\n self._hl_overlay.setGeometry(QRect(self._hl_start, QSize()))\n self._hl_overlay.show()\n\n def _hl_mouse_move(self, e, label):\n if not getattr(self, '_highlight_mode', False) or not hasattr(self, '_hl_start'):\n return\n if self._hl_start is None:\n return\n from PyQt5.QtCore import QRect\n if getattr(self, '_hl_dragging_delete', False):\n if hasattr(self, '_hl_del_overlay') and self._hl_del_overlay:\n self._hl_del_overlay.setGeometry(QRect(self._hl_start, e.pos()).normalized())\n elif hasattr(self, '_hl_overlay') and self._hl_overlay:\n self._hl_overlay.setGeometry(QRect(self._hl_start, e.pos()).normalized())\n\n def _hl_mouse_release(self, e, page_idx, label):\n if not getattr(self, '_highlight_mode', False) or not hasattr(self, '_hl_start'):\n return\n try:\n # 隐藏覆盖层\n if hasattr(self, '_hl_overlay') and self._hl_overlay:\n self._hl_overlay.hide()\n if hasattr(self, '_hl_del_overlay') and self._hl_del_overlay:\n self._hl_del_overlay.hide()\n\n end = e.pos()\n start = self._hl_start\n is_delete = getattr(self, '_hl_dragging_delete', False)\n self._hl_start = None\n self._hl_dragging_delete = False\n\n if not self.doc:\n return\n\n is_small = abs(end.x() - start.x()) < 8 and abs(end.y() - start.y()) < 8\n\n if is_delete:\n # 擦除模式\n if is_small:\n # 小范围点击 → 删除点击处的高亮\n self._hl_delete_at(page_idx, end)\n else:\n # 大范围框选 → 批量删除\n self._hl_delete_in_rect(page_idx, start, end)\n return\n\n # 左键:添加高亮\n if is_small:\n return\n # 屏幕坐标 → PDF 坐标\n x0 = min(start.x(), end.x()) / self.zoom\n y0 = min(start.y(), end.y()) / self.zoom\n x1 = max(start.x(), end.x()) / self.zoom\n y1 = max(start.y(), end.y()) / self.zoom\n sel_rect = fitz.Rect(x0, y0, x1, y1)\n page = self.doc[page_idx]\n # 在选区内查找文字,按文字位置创建高亮\n words = page.get_text(\"words\")\n quads = []\n for w in words:\n wr = fitz.Rect(w[:4])\n if sel_rect.intersects(wr):\n quads.append(wr.quad)\n if not quads:\n return # 选区内无文字(如图片 PDF),不添加\n annot = page.add_highlight_annot(quads=quads)\n annot.set_colors(stroke=(1, 0.85, 0.2))\n try:\n annot.set_opacity(0.35)\n except AttributeError:\n pass # 旧版 PyMuPDF 无此方法\n annot.update()\n self._hl_save_and_render(page_idx)\n except Exception:\n pass # 防止任何异常导致闪退\n\n def _hl_delete_in_rect(self, page_idx, start, end):\n \"\"\"批量删除框选区域内的所有高亮\"\"\"\n try:\n if not self.doc:\n return\n x0 = min(start.x(), end.x()) / self.zoom\n y0 = min(start.y(), end.y()) / self.zoom\n x1 = max(start.x(), end.x()) / self.zoom\n y1 = max(start.y(), end.y()) / self.zoom\n sel_rect = fitz.Rect(x0, y0, x1, y1)\n page = self.doc[page_idx]\n annots = page.annots()\n if not annots:\n return\n # 收集要删除的高亮(不能边遍历边删除)\n to_del = [a for a in annots if a.type[0] == 8 and sel_rect.intersects(a.rect)]\n if not to_del:\n return\n for a in to_del:\n page.delete_annot(a)\n self._hl_save_and_render(page_idx)\n except Exception:\n pass\n\n def _hl_save_and_render(self, page_idx):\n \"\"\"高亮变更后:保存文件 + 重新渲染\"\"\"\n path = getattr(self, '_loaded_path', None)\n if path:\n try:\n self.doc.save(path, incremental=True, encryption=fitz.PDF_ENCRYPT_KEEP)\n except Exception:\n try:\n self.doc.save(path)\n except Exception:\n pass\n if self._continuous:\n self._render_single_cont_page(page_idx)\n else:\n self.render_page()\n\n def _hl_delete_at(self, page_idx, pos):\n \"\"\"删除点击位置的高亮注释\"\"\"\n try:\n if not self.doc:\n return\n page = self.doc[page_idx]\n px, py = pos.x() / self.zoom, pos.y() / self.zoom\n point = fitz.Point(px, py)\n annots = page.annots()\n if not annots:\n return\n for annot in annots:\n if annot.type[0] == 8 and annot.rect.contains(point):\n page.delete_annot(annot)\n self._hl_save_and_render(page_idx)\n return\n except Exception:\n pass\n\n def _render_single_cont_page(self, page_idx):\n \"\"\"重新渲染连续模式中的单个页面\"\"\"\n if page_idx < len(self._cont_page_widgets):\n label = self._cont_page_widgets[page_idx]\n dpr = label.devicePixelRatioF() if hasattr(label, 'devicePixelRatioF') else 1.0\n rz = self.zoom * dpr\n page = self.doc[page_idx]\n pix = page.get_pixmap(matrix=fitz.Matrix(rz, rz))\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n pm = QPixmap.fromImage(img)\n pm.setDevicePixelRatio(dpr)\n label.setPixmap(pm)\n label.setFixedSize(pm.size() / dpr)\n\n def _install_highlight_events(self, label, page_idx):\n \"\"\"给页面 widget 安装高亮鼠标事件\n 高亮模式:左键拖拽添加 | 擦除模式/Option+拖拽:框选批量删除\n \"\"\"\n orig_press = label.mousePressEvent\n orig_move = label.mouseMoveEvent\n orig_release = label.mouseReleaseEvent\n def _press(e):\n if getattr(self, '_highlight_mode', False):\n self._hl_mouse_press(e, page_idx, label)\n else:\n orig_press(e)\n def _move(e):\n if getattr(self, '_highlight_mode', False):\n self._hl_mouse_move(e, label)\n else:\n orig_move(e)\n def _release(e):\n if getattr(self, '_highlight_mode', False):\n self._hl_mouse_release(e, page_idx, label)\n else:\n orig_release(e)\n label.mousePressEvent = _press\n label.mouseMoveEvent = _move\n label.mouseReleaseEvent = _release\n\n def _hl_show_delete_menu(self, e, page_idx, label):\n \"\"\"右键菜单:删除点击位置的高亮\"\"\"\n if not self.doc:\n return\n page = self.doc[page_idx]\n px, py = e.pos().x() / self.zoom, e.pos().y() / self.zoom\n point = fitz.Point(px, py)\n # 检查是否有高亮在此位置\n found = False\n for annot in page.annots():\n if annot.type[0] == 8 and annot.rect.contains(point):\n found = True; break\n if not found:\n return\n menu = _RoundMenu(label)\n act = menu.addAction(\"删除高亮\")\n act.triggered.connect(lambda: self._hl_delete_at(page_idx, e.pos()))\n menu.exec_(label.mapToGlobal(e.pos()))\n\n def _fit_and_render(self):\n \"\"\"统一:计算贴合 + 渲染(只在尺寸真正变化时才重新渲染)\"\"\"\n if not self.doc:\n return\n # 缩略图面板宽度变了 → 重建缩略图\n cur_tw = self.thumb_scroll.viewport().width() if self.thumb_scroll.isVisible() else 0\n if cur_tw > 40 and cur_tw != getattr(self, '_last_thumb_vp_w', 0):\n self._last_thumb_vp_w = cur_tw\n self._build_thumbnails()\n # 计算 zoom,只有变了才重新渲染(避免重复工作)\n old_zoom = self.zoom\n if self._fit_mode == \"page\":\n self._fit_page()\n else:\n self._fit_width()\n if self.zoom != getattr(self, '_last_fit_zoom', -1):\n self._last_fit_zoom = self.zoom\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n\n def _build_thumbnails(self):\n \"\"\"生成所有页面的缩略图\"\"\"\n # 清空旧缩略图\n for lbl in self._thumb_labels:\n lbl.setParent(None)\n self._thumb_labels = []\n\n if not self.doc:\n return\n\n dpr = QApplication.instance().devicePixelRatio() if QApplication.instance() else 2.0\n # 缩略图宽度 = 面板宽度 - 左右 margin(4+4) - border(2+2) - 滚动条余量\n panel_w = self.thumb_scroll.viewport().width() if self.thumb_scroll.viewport().width() > 40 else self.thumb_scroll.width()\n thumb_w = max(50, panel_w - 12)\n\n for i in range(len(self.doc)):\n page = self.doc[i]\n # 计算缩略图缩放比例\n scale = (thumb_w * dpr) / page.rect.width\n mat = fitz.Matrix(scale, scale)\n pix = page.get_pixmap(matrix=mat, alpha=False)\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n qpx = QPixmap.fromImage(img)\n qpx.setDevicePixelRatio(dpr)\n\n lbl = QLabel()\n lbl.setPixmap(qpx)\n lbl.setFixedSize(int(pix.width / dpr) + 4, int(pix.height / dpr) + 4)\n lbl.setAlignment(Qt.AlignCenter)\n lbl.setStyleSheet(\n \"border:2px solid transparent;border-radius:4px;padding:1px;\"\n \"background:transparent;\"\n )\n lbl.setCursor(Qt.PointingHandCursor)\n # 点击跳转\n page_idx = i\n lbl.mousePressEvent = lambda e, idx=page_idx: self._thumb_clicked(idx)\n\n self.thumb_layout.addWidget(lbl, alignment=Qt.AlignHCenter)\n self._thumb_labels.append(lbl)\n\n self.thumb_layout.addStretch()\n self._highlight_thumb(0)\n\n def _thumb_clicked(self, idx):\n self.current_page = idx\n if self._continuous and idx < len(self._cont_page_widgets):\n self.cont_scroll.ensureWidgetVisible(self._cont_page_widgets[idx], 0, 0)\n self._update_page_display()\n self._highlight_thumb(idx)\n else:\n self.render_page()\n self.setFocus()\n\n def _highlight_thumb(self, idx):\n \"\"\"高亮当前页缩略图,颜色由 _thumb_color 控制\"\"\"\n color = getattr(self, '_thumb_color', _C['acc'])\n for i, lbl in enumerate(self._thumb_labels):\n if i == idx:\n lbl.setStyleSheet(\n f\"border:2px solid {color};border-radius:4px;padding:1px;\"\n \"background:transparent;\"\n )\n else:\n lbl.setStyleSheet(\n \"border:2px solid transparent;border-radius:4px;padding:1px;\"\n \"background:transparent;\"\n )\n if idx < len(self._thumb_labels):\n self.thumb_scroll.ensureWidgetVisible(self._thumb_labels[idx])\n\n def _fit_width(self):\n \"\"\"自动计算缩放比例 — 所有模式统一 fit width\"\"\"\n if not self.doc or len(self.doc) == 0:\n return\n page = self.doc[0]\n pw = page.rect.width\n vp = self.scroll.viewport() if not self._continuous else self.cont_scroll.viewport()\n avail_w = vp.width() - 8\n if avail_w < 100 or pw <= 0:\n return\n optimal_zoom = avail_w / pw\n optimal_zoom = max(0.3, min(optimal_zoom, 3.0))\n self.zoom = optimal_zoom\n slider_val = int(optimal_zoom * 100)\n self.zoom_slider.blockSignals(True)\n self.zoom_slider.setValue(slider_val)\n self.zoom_slider.blockSignals(False)\n self.zoom_pct.setText(f\"{slider_val}%\")\n\n def _fit_page(self):\n \"\"\"自动计算缩放比例 — 整页适配 (宽高都不超出视口)\"\"\"\n if not self.doc or len(self.doc) == 0:\n return\n page = self.doc[0]\n pw, ph = page.rect.width, page.rect.height\n vp = self.scroll.viewport() if not self._continuous else self.cont_scroll.viewport()\n avail_w = vp.width() - 8\n avail_h = vp.height() - 8\n if avail_w < 100 or avail_h < 100 or pw <= 0 or ph <= 0:\n return\n zoom_w = avail_w / pw\n zoom_h = avail_h / ph\n optimal_zoom = min(zoom_w, zoom_h)\n optimal_zoom = max(0.3, min(optimal_zoom, 3.0))\n self.zoom = optimal_zoom\n slider_val = int(optimal_zoom * 100)\n self.zoom_slider.blockSignals(True)\n self.zoom_slider.setValue(slider_val)\n self.zoom_slider.blockSignals(False)\n self.zoom_pct.setText(f\"{slider_val}%\")\n\n def _switch_to_fit_width(self):\n \"\"\"切换到适宽模式\"\"\"\n self._fit_mode = \"width\"\n self.fit_btn.setProperty(\"active\", True)\n self.fit_btn.style().unpolish(self.fit_btn); self.fit_btn.style().polish(self.fit_btn)\n self.fitpage_btn.setProperty(\"active\", False)\n self.fitpage_btn.style().unpolish(self.fitpage_btn); self.fitpage_btn.style().polish(self.fitpage_btn)\n self._reset_fit()\n\n def _switch_to_fit_page(self):\n \"\"\"切换到适页模式\"\"\"\n self._fit_mode = \"page\"\n self.fitpage_btn.setProperty(\"active\", True)\n self.fitpage_btn.style().unpolish(self.fitpage_btn); self.fitpage_btn.style().polish(self.fitpage_btn)\n self.fit_btn.setProperty(\"active\", False)\n self.fit_btn.style().unpolish(self.fit_btn); self.fit_btn.style().polish(self.fit_btn)\n self._reset_fit()\n\n def render_page(self):\n if not self.doc or self.current_page >= len(self.doc): return\n pg = self.doc[self.current_page]\n # Retina: 渲染 2x 分辨率再缩回,保证清晰\n dpr = QApplication.instance().devicePixelRatio() if QApplication.instance() else 2.0\n render_zoom = self.zoom * dpr\n mat = fitz.Matrix(render_zoom, render_zoom)\n pix = pg.get_pixmap(matrix=mat, alpha=False)\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n qpix = QPixmap.fromImage(img)\n qpix.setDevicePixelRatio(dpr)\n self.page_widget.set_pixmap(qpix)\n # setFixedSize 用逻辑尺寸(物理尺寸 / dpr)\n self.page_widget.setFixedSize(int(pix.width / dpr), int(pix.height / dpr))\n self._install_highlight_events(self.page_widget, self.current_page)\n self._update_page_display()\n self._highlight_thumb(self.current_page)\n self.scroll.verticalScrollBar().setValue(0)\n\n def first_page(self):\n if self.doc:\n self.current_page = 0; self.render_page()\n def prev_page(self):\n if self.doc and self.current_page > 0:\n self.current_page -= 1; self.render_page()\n def next_page(self):\n if self.doc and self.current_page < len(self.doc) - 1:\n self.current_page += 1; self.render_page()\n def last_page(self):\n if self.doc:\n self.current_page = len(self.doc) - 1; self.render_page()\n def _jump_to_page(self):\n \"\"\"页码输入框回车跳转,超范围闪红提示\"\"\"\n try:\n n = int(self.page_input.text()) - 1\n if self.doc and 0 <= n < len(self.doc):\n self.current_page = n; self.render_page()\n return\n except ValueError:\n pass\n # 输入无效:闪红 + 重置\n self.page_input.setStyleSheet(f\"border:1.5px solid {_C['err']};border-radius:6px;\")\n self.page_input.setText(str(self.current_page + 1) if self.doc else \"\")\n QTimer.singleShot(800, lambda: self.page_input.setStyleSheet(\"\"))\n def _update_page_display(self):\n total = len(self.doc) if self.doc else 0\n self.page_input.setText(str(self.current_page + 1) if total else \"\")\n self.total_label.setText(f\"/ {total}\" if total else \"/ —\")\n def on_zoom(self, v):\n self.zoom = v / 100.0; self.zoom_pct.setText(f\"{v}%\")\n if self.doc:\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n def open_file(self):\n p, _ = QFileDialog.getOpenFileName(self, \"选择 PDF\", \"\", \"PDF (*.pdf)\")\n if p: self.load_pdf(p)\n def contextMenuEvent(self, event):\n if not self.doc:\n return\n menu = _RoundMenu(self)\n menu.addAction(\"打开其他 PDF…\", self.open_file)\n menu.addSeparator()\n menu.addAction(\"放大\", lambda: self._apply_zoom(min(self.zoom + 0.1, 4.0)))\n menu.addAction(\"缩小\", lambda: self._apply_zoom(max(self.zoom - 0.1, 0.3)))\n menu.addAction(\"适合宽度\", self._switch_to_fit_width)\n menu.addAction(\"适合页面\", self._switch_to_fit_page)\n menu.addSeparator()\n lp = getattr(self, '_loaded_path', None)\n if lp and os.path.exists(lp):\n menu.addAction(\"在 Finder 中显示\",\n lambda: __import__(\"subprocess\").Popen([\"open\", \"-R\", lp]))\n menu.exec_(event.globalPos())\n def _toggle_fullscreen(self):\n self._is_fullscreen = not self._is_fullscreen\n self.fs_btn.setText(\"退出\" if self._is_fullscreen else \"全屏\")\n self.fs_btn.setProperty(\"active\", self._is_fullscreen)\n self.fs_btn.style().unpolish(self.fs_btn); self.fs_btn.style().polish(self.fs_btn)\n self.hist_btn.setVisible(self._is_fullscreen)\n self.fullscreen_toggled.emit(self._is_fullscreen)\n QTimer.singleShot(80, self._on_resize_done)\n\n def _on_thumb_panel_resized(self):\n \"\"\"拖拽分割线后重建缩略图 + 重新适配 PDF 视图\"\"\"\n if not hasattr(self, '_thumb_resize_timer'):\n self._thumb_resize_timer = QTimer(); self._thumb_resize_timer.setSingleShot(True)\n self._thumb_resize_timer.timeout.connect(self._rebuild_thumbs_fit)\n self._thumb_resize_timer.start(200)\n # 立即适配 PDF 视图大小(QA 面板伸缩时也需要)\n if not hasattr(self, '_fit_timer'):\n self._fit_timer = QTimer(); self._fit_timer.setSingleShot(True)\n self._fit_timer.timeout.connect(self._fit_and_render)\n self._fit_timer.start(50)\n\n def _rebuild_thumbs_fit(self):\n if self.doc and self.thumb_scroll.isVisible():\n self._build_thumbnails()\n\n def keyPressEvent(self, event):\n if event.key() == Qt.Key_Escape and self._is_fullscreen:\n self._toggle_fullscreen(); return\n if event.key() == Qt.Key_Up:\n self.prev_page()\n elif event.key() == Qt.Key_Down:\n self.next_page()\n elif event.key() == Qt.Key_Home:\n self.first_page()\n elif event.key() == Qt.Key_End:\n self.last_page()\n else:\n super().keyPressEvent(event)\n def mousePressEvent(self, event):\n \"\"\"点击预览区域获取焦点,让键盘事件生效\"\"\"\n self.setFocus()\n super().mousePressEvent(event)\n\n def resizeEvent(self, event):\n \"\"\"窗口大小变化时重新适配\"\"\"\n super().resizeEvent(event)\n if self.doc:\n from PyQt5.QtCore import QTimer\n # 用定时器防止频繁重渲染\n if not hasattr(self, '_resize_timer'):\n self._resize_timer = QTimer(); self._resize_timer.setSingleShot(True)\n self._resize_timer.timeout.connect(self._on_resize_done)\n self._resize_timer.start(100)\n\n def _on_resize_done(self):\n self._fit_and_render()\n\n # ── 连续滚动模式 ──\n def _toggle_continuous(self):\n self._continuous = not self._continuous\n self.scroll_btn.setProperty(\"active\", self._continuous)\n self.scroll_btn.style().unpolish(self.scroll_btn)\n self.scroll_btn.style().polish(self.scroll_btn)\n self.scroll.setVisible(not self._continuous)\n self.cont_scroll.setVisible(self._continuous)\n if self.doc:\n self._fit_and_render()\n # 延迟再算一次,等 scroll 显隐切换布局稳定\n QTimer.singleShot(0, self._fit_and_render)\n\n def _render_continuous(self):\n \"\"\"分帧渲染所有页面到连续滚动容器(避免大文件卡 UI)\"\"\"\n if not self.doc:\n return\n # 清空旧内容\n for w in self._cont_page_widgets:\n w.setParent(None)\n self._cont_page_widgets = []\n self._cont_render_idx = 0\n self._cont_render_dpr = QApplication.instance().devicePixelRatio() if QApplication.instance() else 2.0\n\n # 先放置占位标签\n total = len(self.doc)\n for i in range(total):\n lbl = QLabel(\"加载中…\" if i == 0 else \"\")\n lbl.setAlignment(Qt.AlignCenter)\n lbl.setStyleSheet(f\"background:transparent;color:{_C['t3']};font-size:11px;min-height:200px;\")\n self.cont_layout.addWidget(lbl, alignment=Qt.AlignHCenter)\n self._cont_page_widgets.append(lbl)\n self._update_page_display()\n\n # 分帧渲染:每帧渲染 2 页\n self._render_batch_continuous()\n\n def _render_batch_continuous(self):\n \"\"\"每次渲染 2 页,释放 UI 事件循环\"\"\"\n if not self.doc:\n return\n idx = getattr(self, '_cont_render_idx', 0)\n total = len(self.doc)\n dpr = self._cont_render_dpr\n batch = 2 # 每帧渲染页数\n for _ in range(batch):\n if idx >= total:\n return\n pg = self.doc[idx]\n render_zoom = self.zoom * dpr\n mat = fitz.Matrix(render_zoom, render_zoom)\n pix = pg.get_pixmap(matrix=mat, alpha=False)\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n qpix = QPixmap.fromImage(img)\n qpix.setDevicePixelRatio(dpr)\n lbl = self._cont_page_widgets[idx]\n lbl.setPixmap(qpix)\n lbl.setFixedSize(int(pix.width / dpr), int(pix.height / dpr))\n lbl.setStyleSheet(\"background:transparent;\")\n self._install_highlight_events(lbl, idx)\n idx += 1\n self._cont_render_idx = idx\n if idx < total:\n QTimer.singleShot(0, self._render_batch_continuous)\n\n def _on_cont_scroll(self, value):\n \"\"\"连续滚动时更新页码和缩略图高亮\"\"\"\n if not self._continuous or not self._cont_page_widgets:\n return\n vp_center = value + self.cont_scroll.viewport().height() // 2\n for i, w in enumerate(self._cont_page_widgets):\n wy = w.y()\n if wy <= vp_center <= wy + w.height():\n if self.current_page != i:\n self.current_page = i\n self._update_page_display()\n self._highlight_thumb(i)\n break\n\n def update_theme(self, c):\n \"\"\"深色/浅色切换时更新内联样式\"\"\"\n self._empty_icon.setStyleSheet(f\"background:{c['acc_l']};border-radius:32px;font-size:28px;\")\n self._thumb_color = c['acc']\n if self._thumb_labels:\n self._highlight_thumb(self.current_page)\n # 更新 AI 面板\n self.qa_panel.update_theme(c)\n\n\n# ═══════════════════════════════════════════════════════════════\n# 翻译页面 — 全功能\n# ═══════════════════════════════════════════════════════════════\n\nclass TranslatePage(QWidget):\n translation_done = pyqtSignal(dict) # {\"mono\":..., \"dual\":..., \"side_by_side\":...}\n\n def __init__(self):\n super().__init__()\n self.worker = None\n self.pending_files = []\n self.cfg = UserConfigManager.load()\n\n scroll = QScrollArea(); scroll.setWidgetResizable(True); scroll.setFrameShape(QFrame.NoFrame)\n scroll.setObjectName(\"PA\")\n content = QWidget(); content.setObjectName(\"PA\")\n outer_lo = QVBoxLayout(content); outer_lo.setContentsMargins(0,0,0,0)\n outer_lo.addStretch(1) # 上部弹性 — 垂直居中\n\n inner = QWidget()\n lo = QVBoxLayout(inner); lo.setContentsMargins(40,0,40,0); lo.setSpacing(12)\n\n # 标题 + 四叶草徽章 + 人文关怀\n hdr = QHBoxLayout()\n t = QLabel(\"翻译\"); t.setObjectName(\"PT0\"); hdr.addWidget(t)\n self.clover_label = QLabel(\"\")\n self.clover_label.setStyleSheet(\"font-size:16px;background:transparent;\")\n hdr.addWidget(self.clover_label)\n hdr.addStretch()\n # 尝试显示关怀消息,否则显示默认副标题\n _subtitle = \"拖入学术 PDF,保留公式与原始排版\"\n try:\n from ui.caring import get_caring_message\n _cm = get_caring_message()\n if _cm:\n _subtitle = f\"{_cm[0]} {_cm[2]}\"\n except Exception:\n pass\n st = QLabel(_subtitle); st.setObjectName(\"PT1\"); hdr.addWidget(st)\n lo.addLayout(hdr)\n\n # 拖拽区(内含文件列表,高度固定,不影响下方布局)\n self.drop = DropZone(); self.drop.files_dropped.connect(self.on_files_added)\n self.drop._add_btn.clicked.connect(self._browse_more)\n self.drop._del_btn.clicked.connect(self._remove_selected_files)\n self.drop._clr_btn.clicked.connect(self._clear_files)\n lo.addWidget(self.drop)\n # 引用 DropZone 内嵌的控件\n self.flist = self.drop.flist\n self._fcount_label = self.drop._fcount_label\n self._zotero_hint = self.drop._zotero_hint\n # 文件列表右键菜单\n self.flist.setContextMenuPolicy(Qt.CustomContextMenu)\n self.flist.customContextMenuRequested.connect(self._flist_context_menu)\n\n # ── 配置卡片 ──\n card = _card()\n cl = QVBoxLayout(card); cl.setContentsMargins(16,12,16,12); cl.setSpacing(0)\n\n # 语言行\n r1 = QHBoxLayout(); r1.setSpacing(12)\n for label, items, attr, default in [\n (\"源语言\", list(LANG_MAP.keys()), \"src_combo\", self.cfg.get(\"lang_in\",\"自动检测\")),\n (\"目标语言\", list(LANG_MAP.keys()), \"tgt_combo\", self.cfg.get(\"lang_out\",\"中文(简体)\")),\n ]:\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(label); lb.setObjectName(\"FL\"); col.addWidget(lb)\n cb = QComboBox(); cb.addItems(items); cb.setMinimumWidth(160)\n idx = cb.findText(default)\n if idx >= 0: cb.setCurrentIndex(idx)\n col.addWidget(cb); r1.addLayout(col)\n setattr(self, attr, cb)\n # 修 #24:切换源/目标语言后立即持久化,即使用户没点开始翻译退出 app 也不丢\n self.src_combo.currentTextChanged.connect(lambda _: self._save_langs_now())\n self.tgt_combo.currentTextChanged.connect(lambda _: self._save_langs_now())\n r1.addStretch()\n cl.addLayout(r1)\n cl.addSpacing(8); cl.addWidget(_div()); cl.addSpacing(8)\n\n # 服务 + 格式 + 页码\n r2 = QHBoxLayout(); r2.setSpacing(12)\n # 翻译服务\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"翻译服务\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.svc_combo = QComboBox(); self.svc_combo.addItems(list(SERVICE_MAP.keys()))\n self.svc_combo.setMinimumWidth(180)\n saved_svc = self.cfg.get(\"service\", \"Bing 翻译\")\n idx = self.svc_combo.findText(saved_svc)\n if idx >= 0: self.svc_combo.setCurrentIndex(idx)\n col.addWidget(self.svc_combo); r2.addLayout(col)\n\n # 页码范围\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"页码范围\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.page_combo = QComboBox(); self.page_combo.addItems(list(PAGE_PRESETS.keys()))\n self.page_combo.setMinimumWidth(120)\n self.page_combo.currentTextChanged.connect(self._on_page_changed)\n col.addWidget(self.page_combo); r2.addLayout(col)\n\n # 自定义页码输入\n self.custom_page = QLineEdit(); self.custom_page.setPlaceholderText(\"例: 1-5, 8, 10-12\")\n self.custom_page.setVisible(False); self.custom_page.setFixedWidth(160)\n r2.addWidget(self.custom_page)\n\n r2.addStretch()\n cl.addLayout(r2)\n cl.addSpacing(8); cl.addWidget(_div()); cl.addSpacing(8)\n\n # 输出格式 + 线程\n r3 = QHBoxLayout(); r3.setSpacing(12)\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"输出格式\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.fmt_combo = QComboBox(); self.fmt_combo.addItems(list(OUTPUT_MODES.keys()))\n _saved_fmt = self.cfg.get(\"output_format\", \"左右并排 (Side by Side)\")\n _fmt_idx = self.fmt_combo.findText(_saved_fmt)\n self.fmt_combo.setCurrentIndex(_fmt_idx if _fmt_idx >= 0 else 2)\n self.fmt_combo.setMinimumWidth(180)\n col.addWidget(self.fmt_combo); r3.addLayout(col)\n\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"线程数\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.thread_spin = QSpinBox(); self.thread_spin.setRange(1,32)\n self.thread_spin.setValue(self.cfg.get(\"thread_count\", 8))\n col.addWidget(self.thread_spin); r3.addLayout(col)\n\n r3.addStretch()\n cl.addLayout(r3)\n cl.addSpacing(8); cl.addWidget(_div()); cl.addSpacing(8)\n\n # 分块翻译\n r4 = QHBoxLayout(); r4.setSpacing(16)\n self.chunk_check = QCheckBox(\"分块翻译(大文件推荐)\")\n self.chunk_check.setChecked(self.cfg.get(\"chunk_enabled\", False))\n self.chunk_check.toggled.connect(self._on_chunk_toggled)\n r4.addWidget(self.chunk_check)\n\n self.chunk_size_label = QLabel(\"每块\"); self.chunk_size_label.setObjectName(\"Cap\")\n self.chunk_size_spin = QSpinBox(); self.chunk_size_spin.setRange(5,200)\n self.chunk_size_spin.setValue(self.cfg.get(\"chunk_size\", 50))\n self.chunk_size_unit = QLabel(\"页\"); self.chunk_size_unit.setObjectName(\"Cap\")\n\n self.chunk_delay_label = QLabel(\"间隔\"); self.chunk_delay_label.setObjectName(\"Cap\")\n self.chunk_delay_spin = QSpinBox(); self.chunk_delay_spin.setRange(0,120)\n self.chunk_delay_spin.setValue(self.cfg.get(\"chunk_delay\", 10))\n self.chunk_delay_unit = QLabel(\"秒\"); self.chunk_delay_unit.setObjectName(\"Cap\")\n\n for w in [self.chunk_size_label, self.chunk_size_spin, self.chunk_size_unit,\n self.chunk_delay_label, self.chunk_delay_spin, self.chunk_delay_unit]:\n r4.addWidget(w)\n w.setVisible(self.chunk_check.isChecked())\n\n r4.addStretch()\n cl.addLayout(r4)\n\n cl.addSpacing(6)\n # 高级选项:扫描版 / 表格翻译 / OCR(参考 Win 端实现)\n r5 = QHBoxLayout(); r5.setSpacing(16)\n self.scan_mode_check = QCheckBox(\"扫描版 PDF\")\n self.scan_mode_check.setToolTip(\"扫描版 PDF 的文字嵌在图片中,勾选后会在译文区域覆盖底图原文。\\n普通数字 PDF 请勿勾选。\")\n r5.addWidget(self.scan_mode_check)\n self.translate_tables_check = QCheckBox(\"翻译表格内容\")\n self.translate_tables_check.setToolTip(\"按单元格翻译表格,适合专利、检测报告等表格密集型文件。\")\n r5.addWidget(self.translate_tables_check)\n self.ocr_mode_check = QCheckBox(\"OCR 识别\")\n self.ocr_mode_check.setToolTip(\"对纯图片扫描件先进行 OCR 文字识别再翻译。\\n需要额外处理时间,普通 PDF 请勿勾选。\")\n r5.addWidget(self.ocr_mode_check)\n r5.addStretch()\n cl.addLayout(r5)\n\n # 自动检测提示行\n self._scan_hint = QLabel(\"\")\n self._scan_hint.setObjectName(\"Cap\")\n self._scan_hint.setVisible(False)\n cl.addWidget(self._scan_hint)\n\n lo.addWidget(card)\n\n # ── 进度卡片(固定高度占位,避免布局跳动) ──\n self._prog_holder = QWidget()\n self._prog_holder.setFixedHeight(130)\n ph_lo = QVBoxLayout(self._prog_holder)\n ph_lo.setContentsMargins(0,0,0,0); ph_lo.setSpacing(0)\n\n self.prog_card = _card(\"lg\")\n self.prog_card.setVisible(False)\n pc_lo = QVBoxLayout(self.prog_card)\n pc_lo.setContentsMargins(24, 14, 24, 14)\n pc_lo.setSpacing(8)\n\n row1 = QHBoxLayout(); row1.setSpacing(12)\n self.prog_icon = QLabel(\"⏳\"); self.prog_icon.setObjectName(\"ProgIcon\")\n row1.addWidget(self.prog_icon)\n self.prog_label = QLabel(\"正在翻译…\"); self.prog_label.setObjectName(\"ProgLabel\")\n row1.addWidget(self.prog_label)\n row1.addStretch()\n self.prog_pct = QLabel(\"0%\"); self.prog_pct.setObjectName(\"ProgPct\")\n row1.addWidget(self.prog_pct)\n pc_lo.addLayout(row1)\n\n self.prog_bar = QProgressBar(); self.prog_bar.setRange(0, 100)\n self.prog_bar.setObjectName(\"ProgBar\")\n pc_lo.addWidget(self.prog_bar)\n\n self.prog_tip = QLabel(\"\"); self.prog_tip.setObjectName(\"ProgTip\")\n pc_lo.addWidget(self.prog_tip)\n\n row2 = QHBoxLayout(); row2.setSpacing(10)\n self.prog_detail = QLabel(\"\"); self.prog_detail.setObjectName(\"ProgDetail\")\n row2.addWidget(self.prog_detail)\n row2.addStretch()\n self.stop_btn = QPushButton(\"取消\")\n self.stop_btn.setObjectName(\"ProgCancel\")\n self.stop_btn.setCursor(Qt.PointingHandCursor)\n self.stop_btn.clicked.connect(self._cancel)\n row2.addWidget(self.stop_btn)\n pc_lo.addLayout(row2)\n\n ph_lo.addWidget(self.prog_card)\n lo.addWidget(self._prog_holder)\n\n # ── 开始按钮 ──\n br = QHBoxLayout(); br.setSpacing(10)\n br.addStretch()\n self.go_btn = QPushButton(\"开始翻译\"); self.go_btn.setObjectName(\"Pr\")\n self.go_btn.setFixedWidth(200); self.go_btn.setFixedHeight(48)\n self.go_btn.setEnabled(False)\n self.go_btn.clicked.connect(self._start)\n br.addWidget(self.go_btn)\n self.dice_btn = QPushButton(\"🎲\")\n self.dice_btn.setCursor(Qt.PointingHandCursor)\n self.dice_btn.setStyleSheet(\"font-size:18px;padding:6px;border:none;background:transparent;\")\n self.dice_btn.clicked.connect(self._dice_game)\n br.addWidget(self.dice_btn)\n br.addStretch()\n lo.addLayout(br)\n\n outer_lo.addWidget(inner)\n outer_lo.addStretch(2) # 下部弹性稍大 — 视觉重心偏上\n\n self._update_dice() # 初始化骰子状态\n scroll.setWidget(content)\n page_lo = QVBoxLayout(self); page_lo.setContentsMargins(0,0,0,0); page_lo.addWidget(scroll)\n\n def _on_page_changed(self, text):\n self.custom_page.setVisible(text == \"自定义\")\n\n def _on_chunk_toggled(self, checked):\n for w in [self.chunk_size_label, self.chunk_size_spin, self.chunk_size_unit,\n self.chunk_delay_label, self.chunk_delay_spin, self.chunk_delay_unit]:\n w.setVisible(checked)\n\n def on_files_added(self, files):\n new_files = []\n for f in files:\n if not os.path.isfile(f):\n continue\n # 去重\n exists = False\n for i in range(self.flist.count()):\n if self.flist.item(i).data(Qt.UserRole) == f:\n exists = True; break\n if exists:\n continue\n new_files.append(f)\n name = os.path.basename(f)\n if len(name) > 50:\n name = name[:22] + \"…\" + name[-22:]\n sz = os.path.getsize(f)\n s = f\"{sz/1048576:.1f} MB\" if sz > 1048576 else f\"{sz/1024:.0f} KB\"\n item = QListWidgetItem(f\" 📄 {name} {s}\")\n item.setData(Qt.UserRole, f)\n self.flist.addItem(item)\n self._update_fcount()\n self._check_zotero_source()\n if new_files:\n self._auto_detect_pdf_type(new_files)\n\n def _flist_context_menu(self, pos):\n item = self.flist.itemAt(pos)\n menu = _RoundMenu(self)\n menu.addAction(\"添加文件…\", self._browse_more)\n if item:\n fp = item.data(Qt.UserRole)\n menu.addSeparator()\n menu.addAction(\"在 Finder 中显示\", lambda: self._reveal_in_finder(fp))\n menu.addAction(\"移除\", lambda: self._remove_item(item))\n if self.flist.count() > 0:\n menu.addSeparator()\n menu.addAction(\"清空全部\", self._clear_files)\n menu.exec_(self.flist.viewport().mapToGlobal(pos))\n\n def _reveal_in_finder(self, path):\n import subprocess\n subprocess.Popen([\"open\", \"-R\", path])\n\n def _remove_item(self, item):\n self.flist.takeItem(self.flist.row(item))\n self._update_fcount(); self._check_zotero_source()\n self._scan_hint.setVisible(False)\n\n def _browse_more(self):\n fs, _ = QFileDialog.getOpenFileNames(self, \"选择 PDF\", \"\", \"PDF (*.pdf)\")\n if fs: self.on_files_added(fs)\n\n def _update_fcount(self):\n n = self.flist.count()\n if n > 0:\n self.drop._stack.setCurrentIndex(1) # 切换到文件列表\n self.go_btn.setEnabled(True)\n self._fcount_label.setText(f\"共 {n} 个文件\")\n else:\n self.drop._stack.setCurrentIndex(0) # 切换回拖拽提示\n self.go_btn.setEnabled(False)\n self._fcount_label.setText(\"\")\n\n def _check_zotero_source(self):\n has_zotero = False\n for i in range(self.flist.count()):\n p = self.flist.item(i).data(Qt.UserRole)\n if detect_zotero_source(p):\n has_zotero = True\n break\n self._zotero_hint.setVisible(has_zotero)\n\n def _auto_detect_pdf_type(self, new_files):\n \"\"\"检测新加入的 PDF:\n - 纯扫描件(无文字层)→ 自动开启 OCR\n - 扫描+OCR 层(如日文教材)→ 仅作为温和提示,不自动勾选扫描模式\n (scan_mode 会在段落间留白处露出原图,整体观感未必更好,留给用户决定)\n \"\"\"\n need_ocr = False\n suggest_scan = False\n\n for path in new_files:\n try:\n doc = fitz.open(path)\n n = doc.page_count\n if n == 0:\n doc.close()\n continue\n check_pages = min(n, 10)\n text_pages = 0\n scan_pages = 0 # 有覆盖 >50% 页面的大图\n invisible_pages = 0 # 含不可见 OCR 文字层(Tr 3)\n for i in range(check_pages):\n page = doc[i]\n if page.get_text().strip():\n text_pages += 1\n page_area = page.rect.width * page.rect.height\n if page_area > 0:\n for info in page.get_image_info():\n bbox = info.get(\"bbox\", (0, 0, 0, 0))\n img_area = (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])\n if img_area > page_area * 0.5:\n scan_pages += 1\n break\n try:\n contents = page.read_contents() or b\"\"\n if b\" 3 Tr\" in contents or b\"\\n3 Tr\" in contents:\n invisible_pages += 1\n except Exception:\n pass\n doc.close()\n\n if text_pages == 0:\n need_ocr = True\n elif invisible_pages > check_pages * 0.3 or scan_pages > check_pages * 0.3:\n suggest_scan = True\n except Exception:\n pass\n\n hints = []\n if need_ocr and not self.ocr_mode_check.isChecked():\n self.ocr_mode_check.setChecked(True)\n hints.append(\"检测到纯扫描件,已自动开启 OCR\")\n if suggest_scan and not self.scan_mode_check.isChecked():\n # 仅提示,不自动勾选\n hints.append(\"检测到扫描版 PDF(含底图),如需去除背景文字可手动勾选扫描模式\")\n\n if hints:\n acc = _C[\"acc\"]\n self._scan_hint.setStyleSheet(f\"color:{acc}; padding-left:2px;\")\n self._scan_hint.setText(\" \".join(hints))\n self._scan_hint.setVisible(True)\n else:\n self._scan_hint.setVisible(False)\n\n def _remove_selected_files(self):\n for item in reversed(self.flist.selectedItems()):\n self.flist.takeItem(self.flist.row(item))\n self._update_fcount()\n self._check_zotero_source()\n self._scan_hint.setVisible(False)\n\n def _clear_files(self):\n self.flist.clear()\n self._update_fcount()\n self._zotero_hint.setVisible(False)\n self._scan_hint.setVisible(False)\n\n def _get_pages(self):\n preset = self.page_combo.currentText()\n if preset == \"自定义\":\n txt = self.custom_page.text().strip()\n return parse_page_range(txt) if txt else None\n return PAGE_PRESETS.get(preset)\n\n def _save_langs_now(self):\n \"\"\"修 #24:切换源/目标语言时立即持久化到 config.json,独立于 _save_config\n (只写 lang_in / lang_out 两个字段,避免读取尚未初始化的其他控件)\"\"\"\n try:\n cfg = UserConfigManager.load() or {}\n cfg[\"lang_in\"] = self.src_combo.currentText()\n cfg[\"lang_out\"] = self.tgt_combo.currentText()\n UserConfigManager.save(cfg)\n # 同步到内存 cfg,避免下次 _save_config 时被旧值覆盖\n self.cfg[\"lang_in\"] = cfg[\"lang_in\"]\n self.cfg[\"lang_out\"] = cfg[\"lang_out\"]\n except Exception:\n pass # 保存失败不影响翻译主流程\n\n def _save_config(self):\n self.cfg[\"service\"] = self.svc_combo.currentText()\n self.cfg[\"lang_in\"] = self.src_combo.currentText()\n self.cfg[\"lang_out\"] = self.tgt_combo.currentText()\n self.cfg[\"output_format\"] = self.fmt_combo.currentText()\n self.cfg[\"thread_count\"] = self.thread_spin.value()\n self.cfg[\"chunk_enabled\"] = self.chunk_check.isChecked()\n self.cfg[\"chunk_size\"] = self.chunk_size_spin.value()\n self.cfg[\"chunk_delay\"] = self.chunk_delay_spin.value()\n UserConfigManager.save(self.cfg)\n\n def _start(self):\n files = []\n for i in range(self.flist.count()):\n p = self.flist.item(i).data(Qt.UserRole)\n if p: files.append(p)\n if not files: return\n # 确保按钮连接正常(重试模式可能改过)\n try: self.go_btn.clicked.disconnect()\n except TypeError: pass\n self.go_btn.clicked.connect(self._start)\n\n # A:Zotero 联动健康检查 — 从 storage 拖入但插件没响应时提前提示\n # v2.3.2: --auto CLI 唤起时跳过所有 modal(避免锁屏/无 UI 场景阻塞主线程)\n if not getattr(self, \"_cli_format\", None):\n try:\n from ui.translate_worker import detect_zotero_source, zotero_plugin_installed\n zotero_sourced = [f for f in files if detect_zotero_source(f)]\n if zotero_sourced and not zotero_plugin_installed():\n from PyQt5.QtWidgets import QMessageBox\n reply = QMessageBox.question(\n self, \"Zotero 联动提示\",\n f\"检测到 {len(zotero_sourced)} 个文件来自 Zotero storage 目录,\\n\"\n \"但 Zotero 插件(pdf2zh Connector)没响应,翻译完不会自动加到 Zotero 库里。\\n\\n\"\n \"可能原因:\\n\"\n \" 1. Zotero 没打开 → 请打开 Zotero 后重试\\n\"\n \" 2. 插件没装 → 下载 pdf2zh-connector-v1.0.7.xpi 手动装到 Zotero\\n\"\n \" 3. 插件启动失败 → 到 Zotero「附加组件」检查有没有错误\\n\\n\"\n \"是否仍要继续翻译?(结果会保存在默认目录,不同步到 Zotero)\",\n QMessageBox.Yes | QMessageBox.No,\n QMessageBox.No,\n )\n if reply != QMessageBox.Yes:\n return\n except Exception:\n pass # 健康检查失败不阻断主流程\n\n # 先保存翻译页配置(先 save 自己,再让设置页覆盖 API Key 字段,避免互相 stomp)\n self._save_config()\n # 修复:强制让设置页保存(防 editingFinished 漏触发导致 API Key 丢失)\n # 必须在 _save_config 之后,因为 _save_config 用 self.cfg 整体覆盖磁盘,会丢失 api_xxx\n try:\n mw = self.window()\n if hasattr(mw, \"pages\") and \"设置\" in mw.pages:\n mw.pages[\"设置\"]._save()\n # 重新加载 self.cfg 让翻译页拿到最新 api_xxx\n self.cfg = UserConfigManager.load()\n except Exception:\n pass\n self.pending_files = files\n self._batch_idx = 0\n self._batch_results = [] # [(file_path, output_files_dict), ...]\n self._output_dir = os.path.expanduser(\"~/Documents/pdf2zh_files\")\n os.makedirs(self._output_dir, exist_ok=True)\n\n self._cancel_pending = False\n self.go_btn.setEnabled(False); self.go_btn.setText(\"翻译中…\")\n self.prog_card.setVisible(True); self.stop_btn.setVisible(True)\n self.prog_bar.setValue(0)\n self.prog_pct.setText(\"0%\")\n self.prog_label.setText(\"正在翻译…\")\n self.prog_icon.setText(\"⏳\")\n self.prog_tip.setText(\"\")\n\n self._translate_next()\n\n def _translate_next(self):\n \"\"\"启动队列中下一个文件的翻译\"\"\"\n idx = self._batch_idx\n total = len(self.pending_files)\n if idx >= total:\n self._on_batch_done()\n return\n\n fp = self.pending_files[idx]\n if total > 1:\n self.prog_label.setText(f\"正在翻译 ({idx+1}/{total})…\")\n self.prog_detail.setText(os.path.basename(fp))\n self.prog_bar.setValue(0); self.prog_pct.setText(\"0%\")\n\n envs = build_service_envs(self.svc_combo.currentText())\n self.worker = TranslateWorker(\n file_path=fp,\n output_dir=self._output_dir,\n lang_in=LANG_MAP.get(self.src_combo.currentText(), \"\"),\n lang_out=LANG_MAP.get(self.tgt_combo.currentText(), \"zh\"),\n service=SERVICE_MAP.get(self.svc_combo.currentText(), \"bing\"),\n pages=self._get_pages(),\n thread_count=self.thread_spin.value(),\n chunk_enabled=self.chunk_check.isChecked(),\n chunk_size=self.chunk_size_spin.value(),\n chunk_delay=self.chunk_delay_spin.value(),\n envs=envs,\n scan_mode=self.scan_mode_check.isChecked(),\n translate_tables=self.translate_tables_check.isChecked(),\n ocr_mode=self.ocr_mode_check.isChecked(),\n )\n # v2.3.0: 传递 output_formats 到 worker(如果单文件模式被激活)\n # 由 _cli_format 属性(Zotero 唤起时设置)决定\n _cli_fmt = getattr(self, \"_cli_format\", None)\n # v2.3.4: 记住本次右键唤起的格式, 供 Zotero 回写跟随(而非只按 config 勾选)\n self._writeback_format = _cli_fmt if _cli_fmt in (\"side_by_side\", \"dual\", \"mono\", \"all\") else None\n if _cli_fmt and _cli_fmt != \"all\":\n self.worker.output_formats = [_cli_fmt]\n # 用完清掉,避免影响下次手动翻译\n self._cli_format = None\n self.worker.progress.connect(self._on_prog)\n self.worker.status.connect(self._on_status)\n self.worker.finished.connect(self._on_single_done)\n self.worker.error.connect(self._on_single_err)\n self.worker.start()\n\n def _cancel(self):\n if not self.worker:\n return\n self.worker.cancel()\n self._cancel_pending = True\n self.prog_label.setText(\"正在取消…\")\n self.stop_btn.setEnabled(False)\n # 最多等 5 秒,超时强制终止\n def _force_cleanup():\n if not self._cancel_pending:\n return # 翻译已正常结束,不需要强制清理\n if self.worker and self.worker.isRunning():\n self.worker.terminate()\n self.worker.wait(2000)\n self.worker = None\n self._cancel_pending = False\n self.go_btn.setEnabled(True)\n self.go_btn.setText(\"开始翻译\")\n self.stop_btn.setVisible(False)\n self.stop_btn.setEnabled(True)\n self.prog_icon.setText(\"⚠️\")\n self.prog_label.setText(\"已取消\")\n self.prog_detail.setText(\"\")\n QTimer.singleShot(5000, _force_cleanup)\n\n _TIPS = [\n \"翻译中,请稍候…\", \"公式和图表会完整保留\",\n \"AI 正在识别文档布局…\", \"保持网络连接以获得最佳速度\",\n \"快好了,再等等…\", \"pdf2zh-desktop · 学术翻译利器\",\n \"排版会和原文一模一样\", \"支持 20+ 翻译引擎\",\n \"每一篇论文,都是知识跨越语言的桥梁\",\n \"科研不易,感谢你的坚持\",\n ]\n\n def _on_prog(self, cur, total):\n if total > 0:\n pct = min(int(cur/total*100), 100)\n self.prog_bar.setValue(pct); self.prog_pct.setText(f\"{pct}%\")\n self.prog_detail.setText(f\"已处理 {cur}/{total} 页\")\n # 趣味提示 + 深夜关怀\n import random\n if cur % 5 == 1 or cur == total:\n from ui.caring import get_caring_message\n caring = get_caring_message()\n if caring:\n tip = f\"{caring[0]} {caring[1]}\"\n elif pct >= 90:\n tip = \"马上就好!\"\n elif pct >= 50:\n tip = \"已经过半了,加油!\"\n else:\n tip = random.choice(self._TIPS)\n self.prog_tip.setText(tip)\n\n def _on_status(self, text):\n self.prog_detail.setText(text)\n\n def _on_single_done(self, output_files):\n \"\"\"单文件翻译完成 — 回写 Zotero + 记录历史 + 推进队列\"\"\"\n self._cancel_pending = False # 正常完成,取消超时回调不再触发\n fp = self.pending_files[self._batch_idx]\n\n if self.worker:\n w = self.worker; self.worker = None\n w.quit()\n QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n # 保存历史\n HistoryManager.add_record({\n \"file\": {\"name\": os.path.basename(fp), \"path\": fp},\n \"translation\": {\n \"service\": self.svc_combo.currentText(),\n \"lang_in\": self.src_combo.currentText(),\n \"lang_out\": self.tgt_combo.currentText(),\n },\n \"output_files\": output_files,\n \"status\": \"success\",\n })\n self.translation_done.emit(output_files)\n\n # Zotero 回写:按此文件自身的来源路径,把译文复制回原位\n self._zotero_writeback(fp, output_files)\n\n self._batch_results.append((fp, output_files))\n\n # 推进到下一个文件\n self._batch_idx += 1\n self._translate_next()\n\n def _on_single_err(self, msg):\n \"\"\"单文件翻译出错 — 记录后继续下一个\"\"\"\n self._cancel_pending = False\n fp = self.pending_files[self._batch_idx]\n\n if self.worker:\n w = self.worker; self.worker = None\n w.quit()\n QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n self._batch_results.append((fp, None)) # None = 失败\n\n total = len(self.pending_files)\n if total > 1:\n # 批量模式:跳过出错的,继续下一个\n self.prog_detail.setText(f\"⚠️ {os.path.basename(fp)} 翻译出错,跳过\")\n self._batch_idx += 1\n QTimer.singleShot(1000, self._translate_next)\n else:\n # 单文件模式:直接报错\n self._on_err(msg)\n\n def _zotero_writeback(self, file_path, output_files):\n \"\"\"把译文复制回 Zotero 原位 + 尝试自动关联附件\"\"\"\n import shutil\n _dbg_write(f\"_zotero_writeback file_path={file_path!r} output_files={output_files}\")\n zotero_dir = detect_zotero_source(file_path)\n _dbg_write(f\" detect_zotero_source → {zotero_dir!r}\")\n if not zotero_dir:\n _dbg_write(\" ✗ not from Zotero storage, skip writeback\")\n return\n cfg = UserConfigManager.load()\n # v2.3.4: 右键唤起指定了格式时, 回写格式跟随右键选择(而非 config 勾选)\n _wb_fmt = getattr(self, \"_writeback_format\", None)\n if _wb_fmt == \"all\":\n modes = [\"side_by_side\", \"dual\", \"mono\"]\n elif _wb_fmt in (\"side_by_side\", \"dual\", \"mono\"):\n modes = [_wb_fmt]\n else:\n modes = cfg.get(\"zotero_output_modes\", [\"side_by_side\"])\n keep_copy = cfg.get(\"zotero_keep_copy\", True)\n item_key = get_zotero_item_key(file_path)\n _dbg_write(f\" modes={modes} item_key={item_key!r}\")\n for mode in modes:\n src = output_files.get(mode)\n _dbg_write(f\" mode={mode} src={src!r}\")\n if not src or not os.path.exists(src):\n _dbg_write(f\" ✗ src missing or not exists, skip\")\n continue\n dst = os.path.join(zotero_dir, os.path.basename(src))\n _dbg_write(f\" dst={dst!r}\")\n if os.path.abspath(src) != os.path.abspath(dst):\n shutil.copy2(src, dst)\n _dbg_write(f\" ✓ copied to storage\")\n if not keep_copy:\n try:\n os.remove(src)\n except OSError:\n pass\n # 尝试通过 pdf2zh Connector 插件自动关联附件\n if item_key:\n mode_label = {\"side_by_side\": \"中外并排\", \"dual\": \"上下双语\", \"mono\": \"纯中文\"}.get(mode, mode)\n base = os.path.splitext(os.path.basename(dst))[0]\n for suffix in (\"-side_by_side\", \"-dual\", \"-mono\"):\n if base.endswith(suffix):\n base = base[: -len(suffix)]\n break\n title = f\"译文 · {mode_label} · {base}\"\n _dbg_write(f\" POST /pdf2zh/attach itemKey={item_key} title={title!r}\")\n try:\n ok, msg = zotero_auto_link(item_key, dst, title)\n _dbg_write(f\" zotero_auto_link → ok={ok} msg={msg!r}\")\n if not ok:\n try: self.prog_detail.setText(f\"⚠️ {msg}\")\n except Exception: pass\n except Exception as e:\n _dbg_write(f\" ✗ zotero_auto_link EXCEPTION: {e}\")\n try: self.prog_detail.setText(f\"⚠️ Zotero 联动异常: {e}\")\n except Exception: pass\n else:\n _dbg_write(f\" ✗ item_key is None → no attach POST\")\n\n def _on_batch_done(self):\n \"\"\"全部文件翻译完成\"\"\"\n self.prog_bar.setValue(100); self.prog_pct.setText(\"100%\")\n self.prog_icon.setText(\"✅\")\n self.go_btn.setEnabled(True); self.go_btn.setText(\"开始翻译\")\n self.stop_btn.setVisible(False)\n\n total = len(self._batch_results)\n ok = sum(1 for _, r in self._batch_results if r is not None)\n failed = total - ok\n has_zotero = any(detect_zotero_source(fp) for fp, _ in self._batch_results)\n\n # 收集失败的文件用于重试\n self._failed_files = [fp for fp, r in self._batch_results if r is None]\n\n if total == 1 and ok == 1:\n self.prog_label.setText(\"翻译完成\")\n if has_zotero:\n self.prog_detail.setText(\"译文已保存回 Zotero 原位\")\n else:\n self.prog_detail.setText(\"输出至 ~/Documents/pdf2zh_files\")\n elif failed == 0:\n self.prog_label.setText(f\"全部完成({ok} 篇)\")\n if has_zotero:\n self.prog_detail.setText(\"所有译文已保存回 Zotero 原位\")\n else:\n self.prog_detail.setText(\"输出至 ~/Documents/pdf2zh_files\")\n else:\n self.prog_label.setText(f\"完成 {ok} 篇,失败 {failed} 篇\")\n self.prog_detail.setText(\"部分文件翻译出错\")\n # 显示重试按钮\n self.go_btn.setText(f\"重试失败 ({failed} 篇)\")\n try: self.go_btn.clicked.disconnect()\n except TypeError: pass\n self.go_btn.clicked.connect(self._retry_failed)\n self.go_btn.setEnabled(True)\n\n # macOS 系统通知 — 翻译完成后通知用户(适合长时间翻译时切到其他 app)\n try:\n import subprocess\n msg = f\"{ok} 篇翻译完成\" if failed == 0 else f\"完成 {ok} 篇,失败 {failed} 篇\"\n subprocess.Popen([\n \"osascript\", \"-e\",\n f'display notification \"{msg}\" with title \"pdf2zh\" sound name \"Glass\"'\n ])\n except Exception:\n pass\n\n # v2.3.4: 后台静默模式 —— 翻译+回写完成后自动关窗(全成功); 有失败则恢复窗口看错误\n _win = self.window()\n if _win is not None and getattr(_win, \"_cli_silent\", False):\n _win._cli_silent = False\n if failed == 0:\n QTimer.singleShot(1800, _win.close)\n else:\n try: _win.showNormal(); _win.raise_(); _win.activateWindow()\n except Exception: pass\n\n # 关怀消息\n from ui.caring import get_caring_message, get_session_tip\n caring = get_caring_message()\n if caring:\n self.prog_tip.setText(f\"{caring[0]} {caring[1]}\")\n else:\n self.prog_tip.setText(get_session_tip())\n\n # 翻译完成后自动清空文件列表\n self._clear_files()\n\n # 骰子系统\n try:\n from datetime import date\n cfg = UserConfigManager.load()\n today = date.today().isoformat()\n if cfg.get(\"dice_date\") != today:\n cfg[\"dice_date\"] = today; cfg[\"dice_today_pages\"] = 0\n cfg[\"dice_used\"] = False; cfg[\"dice_clovers\"] = 0\n cfg[\"dice_today_pages\"] = cfg.get(\"dice_today_pages\", 0) + max(1, self.thread_spin.value())\n UserConfigManager.dice_save(cfg)\n self._update_dice()\n except Exception:\n pass\n\n def _retry_failed(self):\n \"\"\"重试上次批量翻译中失败的文件\"\"\"\n failed = getattr(self, '_failed_files', [])\n if not failed:\n return\n # 恢复按钮到正常状态\n try: self.go_btn.clicked.disconnect()\n except TypeError: pass\n self.go_btn.clicked.connect(self._start)\n # 把失败文件重新加入列表并启动\n self.on_files_added(failed)\n self._failed_files = []\n self._start()\n\n def _on_err(self, msg):\n self._cancel_pending = False # 错误也算结束,取消超时不再触发\n self.prog_icon.setText(\"❌\")\n self.prog_label.setText(\"翻译出错\")\n self.prog_pct.setText(\"!\")\n self.prog_detail.setText(msg)\n self.prog_bar.setValue(0)\n self.go_btn.setEnabled(True); self.go_btn.setText(\"重试翻译\")\n self.stop_btn.setVisible(False)\n if self.worker:\n w = self.worker; self.worker = None\n w.quit()\n QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n # ── 骰子系统(静默、无文字、四叶草) ──\n\n def _update_dice(self):\n \"\"\"骰子 🎲 在按钮区,四叶草 🍀 在标题旁\"\"\"\n from datetime import date\n cfg = UserConfigManager.load()\n today = date.today().isoformat()\n pages = cfg.get(\"dice_today_pages\", 0)\n\n # 新的一天重置\n if cfg.get(\"dice_date\") != today:\n self.clover_label.setText(\"\")\n if pages >= 5:\n self.dice_btn.setText(\"🎲\"); self.dice_btn.setEnabled(True); self.dice_btn.show()\n else:\n self.dice_btn.hide()\n return\n\n # 四叶草:当天翻译不足 5 页则清掉\n clovers = cfg.get(\"dice_clovers\", 0) if cfg.get(\"dice_used\") else 0\n if clovers > 0 and pages < 5:\n clovers = 0\n cfg[\"dice_clovers\"] = 0; UserConfigManager.dice_save(cfg)\n self.clover_label.setText(\"🍀\" * clovers if clovers > 0 else \"\")\n\n # 骰子按钮\n if cfg.get(\"dice_used\"):\n self.dice_btn.hide() # 已掷过,骰子消失\n elif pages >= 5:\n self.dice_btn.setText(\"🎲\"); self.dice_btn.setEnabled(True); self.dice_btn.show()\n else:\n self.dice_btn.hide()\n\n def _dice_game(self):\n \"\"\"点击骰子 → 静默掷骰 → 四叶草或消失\"\"\"\n import random\n from datetime import date\n\n cfg = UserConfigManager.load()\n today = date.today().isoformat()\n\n # 日期重置\n if cfg.get(\"dice_date\") != today:\n cfg[\"dice_date\"] = today; cfg[\"dice_today_pages\"] = 0\n cfg[\"dice_used\"] = False; cfg[\"dice_clovers\"] = 0\n UserConfigManager.dice_save(cfg)\n\n # 签名校验\n if not UserConfigManager.dice_verify(cfg):\n cfg[\"dice_date\"] = today; cfg[\"dice_today_pages\"] = 0\n cfg[\"dice_used\"] = False; cfg[\"dice_clovers\"] = 0\n UserConfigManager.dice_save(cfg)\n self._update_dice(); return\n\n # 静默检查资格\n if cfg.get(\"dice_used\") or cfg.get(\"dice_today_pages\", 0) < 5:\n return\n\n # 标记已用\n cfg[\"dice_used\"] = True; cfg[\"dice_clovers\"] = 0\n UserConfigManager.dice_save(cfg)\n\n self.dice_btn.setEnabled(False)\n self._dice_roll_step(0)\n\n def _dice_roll_step(self, clovers):\n \"\"\"逐次掷骰:6 → 🍀 累加,非 6 → 结束\"\"\"\n import random\n FACES = \"⚀⚁⚂⚃⚄⚅\"\n\n val = 6 if random.random() < 0.5 else random.choice([1,2,3,4,5])\n flicker = [0]\n\n def tick():\n flicker[0] += 1\n if flicker[0] < 10:\n self.dice_btn.setText(FACES[random.randint(0,5)])\n QTimer.singleShot(55, tick)\n else:\n # 定格\n self.dice_btn.setText(FACES[val - 1])\n if val == 6:\n nc = clovers + 1\n self._dice_mini_firework()\n QTimer.singleShot(700, lambda: self._dice_got_six(nc))\n else:\n # 短暂显示骰面,然后骰子消失,四叶草留在标题旁\n QTimer.singleShot(1500, lambda: self._dice_finish(clovers))\n tick()\n\n def _dice_got_six(self, clovers):\n \"\"\"掷中 6:标题旁加 🍀,继续或结束\"\"\"\n # 四叶草实时更新到标题旁\n self.clover_label.setText(\"🍀\" * clovers)\n\n # 保存中间状态\n cfg = UserConfigManager.load()\n cfg[\"dice_clovers\"] = clovers\n UserConfigManager.dice_save(cfg)\n\n if clovers >= 6:\n # 全部 6!终极大奖\n from datetime import date\n today = date.today().isoformat()\n cfg[\"dice_win_code\"] = UserConfigManager.dice_win_code(today)\n cfg[\"theme_unlocked\"] = True\n UserConfigManager.save(cfg)\n self.dice_btn.hide()\n mw = self.window()\n if hasattr(mw, '_midnight_bloom'):\n mw._midnight_bloom()\n return\n\n # ≥3 个 6 静默解锁主题色\n if clovers == 3:\n cfg2 = UserConfigManager.load()\n cfg2[\"theme_unlocked\"] = True\n UserConfigManager.save(cfg2)\n\n # 按钮回到 🎲 继续下一轮\n self.dice_btn.setText(\"🎲\")\n QTimer.singleShot(600, lambda: self._dice_roll_step(clovers))\n\n def _dice_finish(self, clovers):\n \"\"\"掷骰结束:骰子消失,四叶草留在标题旁\"\"\"\n cfg = UserConfigManager.load()\n cfg[\"dice_clovers\"] = clovers\n UserConfigManager.dice_save(cfg)\n # 骰子消失,四叶草已在标题旁\n self.dice_btn.hide()\n self.clover_label.setText(\"🍀\" * clovers if clovers > 0 else \"\")\n\n def _dice_mini_firework(self):\n \"\"\"从骰子按钮位置爆发迷你烟花\"\"\"\n import random, math\n from PyQt5.QtCore import QPropertyAnimation, QPoint, QEasingCurve\n mw = self.window()\n center = self.dice_btn.mapTo(mw, self.dice_btn.rect().center())\n emojis = [\"✨\",\"💫\",\"⭐\",\"🌟\",\"🎇\",\"🔥\",\"🎆\"]\n for _ in range(12):\n lbl = QLabel(random.choice(emojis), mw)\n lbl.setStyleSheet(f\"font-size:{random.randint(12,22)}px;background:transparent;\")\n lbl.move(center); lbl.show(); lbl.raise_()\n angle = random.uniform(0, 2 * math.pi)\n dist = random.randint(50, 160)\n end = QPoint(center.x() + int(math.cos(angle) * dist),\n center.y() + int(math.sin(angle) * dist))\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(600, 1100))\n anim.setStartValue(center); anim.setEndValue(end)\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start(); lbl._anim = anim\n\n\n# ═══════════════════════════════════════════════════════════════\n# 阅读页面 — 历史 + 预览合体\n# ═══════════════════════════════════════════════════════════════\n\nclass ReaderPage(QWidget):\n \"\"\"左侧历史列表 + 右侧 PDF 预览\n 两级导航:历史列表 ←(Left/Right)→ 预览区\n 预览区内上下键翻页,缩略图跟随高亮\"\"\"\n fullscreen_changed = pyqtSignal(bool)\n\n def __init__(self):\n super().__init__()\n self._preview_active = False\n lo = QHBoxLayout(self); lo.setContentsMargins(0,0,0,0); lo.setSpacing(0)\n\n # QSplitter 实现可拖拽分割\n from PyQt5.QtWidgets import QSplitter\n splitter = QSplitter(Qt.Horizontal)\n splitter.setHandleWidth(1)\n splitter.setChildrenCollapsible(True)\n\n # ── 左侧:历史面板 ──\n left = QWidget()\n left.setObjectName(\"HistPanel\"); left.setMinimumWidth(180); left.setMaximumWidth(420)\n ll = QVBoxLayout(left); ll.setContentsMargins(12,12,12,12); ll.setSpacing(8)\n\n # 标题行\n hdr = QHBoxLayout(); hdr.setSpacing(4)\n t = QLabel(\"翻译历史\"); t.setStyleSheet(\"font-size:13px;font-weight:600;\")\n hdr.addWidget(t); hdr.addStretch()\n self.thumb_toggle = QPushButton(\"缩略图\"); self.thumb_toggle.setObjectName(\"TB\")\n self.thumb_toggle.setCursor(Qt.PointingHandCursor)\n self.thumb_toggle.setProperty(\"active\", True)\n self.thumb_toggle.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n self.thumb_toggle.clicked.connect(self._toggle_thumbs)\n hdr.addWidget(self.thumb_toggle)\n cb = QPushButton(\"清空\"); cb.setObjectName(\"Gh\"); cb.setCursor(Qt.PointingHandCursor)\n cb.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n cb.clicked.connect(self._clear); hdr.addWidget(cb)\n ll.addLayout(hdr)\n\n # ── 分组筛选栏:左侧可滚动按钮区 + 右侧固定\"+\" ──\n _group_row = QHBoxLayout(); _group_row.setContentsMargins(0, 0, 0, 0); _group_row.setSpacing(4)\n self._group_bar = QScrollArea()\n self._group_bar.setMaximumHeight(34); self._group_bar.setFrameShape(QFrame.NoFrame)\n self._group_bar.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._group_bar.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._group_bar.setWidgetResizable(True)\n self._group_container = QWidget()\n self._group_layout = QHBoxLayout(self._group_container)\n self._group_layout.setContentsMargins(0, 0, 0, 0); self._group_layout.setSpacing(4)\n self._group_bar.setWidget(self._group_container)\n _group_row.addWidget(self._group_bar, 1)\n # \"+\"按钮 — 独立于 scroll area,绝对不参与 group_layout\n self._add_btn = QPushButton(\"+\"); self._add_btn.setObjectName(\"Gh\")\n self._add_btn.setCursor(Qt.PointingHandCursor)\n self._add_btn.setStyleSheet(\"font-size:10px;padding:4px 8px;\")\n self._add_btn.setToolTip(\"新建分组\")\n self._add_btn.setFixedSize(28, 26)\n self._add_btn.clicked.connect(self._create_group)\n _group_row.addWidget(self._add_btn, 0) # stretch=0, 固定尺寸\n ll.addLayout(_group_row)\n self._current_group_id = None # None = 全部\n self._current_tag_filter = None\n\n # 历史列表\n self.list_w = QListWidget()\n self.list_w.setObjectName(\"HistList\")\n self.list_w.setFocusPolicy(Qt.StrongFocus)\n self.list_w.setSelectionMode(QListWidget.SingleSelection)\n self.list_w.currentItemChanged.connect(self._on_select)\n self.list_w.itemClicked.connect(lambda item: self._on_select(item, None))\n self.list_w.itemDoubleClicked.connect(self._open_in_reader)\n self.list_w.installEventFilter(self)\n self.list_w.setContextMenuPolicy(Qt.CustomContextMenu)\n self.list_w.customContextMenuRequested.connect(self._hist_context_menu)\n ll.addWidget(self.list_w)\n\n # 详情面板\n self.detail_label = QLabel(\"↑ 选择记录查看详情\")\n self.detail_label.setObjectName(\"HistDetail\")\n self.detail_label.setWordWrap(True)\n self.detail_label.setStyleSheet(\n \"#HistDetail{font-size:10px;padding:4px 6px;line-height:125%;}\")\n self.detail_label.setMinimumHeight(48)\n ll.addWidget(self.detail_label)\n # 文件操作按钮 — 紧凑行,紧贴详情面板\n fops = QHBoxLayout(); fops.setSpacing(4); fops.setContentsMargins(6, 2, 6, 0)\n self._btn_reveal = QPushButton(\"📂 Finder\"); self._btn_reveal.setObjectName(\"Gh\")\n self._btn_reveal.setCursor(Qt.PointingHandCursor)\n self._btn_reveal.setStyleSheet(\"font-size:9px;padding:2px 6px;\")\n self._btn_reveal.setToolTip(\"在 Finder 中显示翻译文件\")\n self._btn_reveal.clicked.connect(self._reveal_in_finder)\n self._btn_reveal.setVisible(False)\n fops.addWidget(self._btn_reveal)\n self._btn_open_src = QPushButton(\"📄 源文件\"); self._btn_open_src.setObjectName(\"Gh\")\n self._btn_open_src.setCursor(Qt.PointingHandCursor)\n self._btn_open_src.setStyleSheet(\"font-size:9px;padding:2px 6px;\")\n self._btn_open_src.setToolTip(\"用默认应用打开源 PDF\")\n self._btn_open_src.clicked.connect(self._open_source)\n self._btn_open_src.setVisible(False)\n fops.addWidget(self._btn_open_src)\n fops.addStretch()\n ll.addLayout(fops)\n self._current_record = None\n\n splitter.addWidget(left)\n\n # ── 右侧:预览 ──\n self.preview = PreviewPage()\n self.preview.installEventFilter(self)\n splitter.addWidget(self.preview)\n\n # 初始比例:左 260, 右 stretch\n splitter.setSizes([260, 800])\n self._splitter = splitter\n self._hist_panel = left\n lo.addWidget(splitter)\n\n self._show_thumbs = True\n # 缩略图点击 → 进入预览模式\n self._orig_thumb_clicked = self.preview._thumb_clicked\n self.preview._thumb_clicked = self._on_thumb_clicked\n # 全屏信号\n self.preview.fullscreen_toggled.connect(self._on_fullscreen)\n self.preview.history_toggled.connect(self._toggle_hist_in_fullscreen)\n self._fs_hist_visible = False\n self.refresh()\n\n def showEvent(self, event):\n \"\"\"页面显示时自动选中第一条\"\"\"\n super().showEvent(event)\n if self.list_w.count() > 0 and not self.list_w.currentItem():\n self.list_w.setCurrentRow(0)\n self.list_w.setFocus()\n\n def _on_thumb_clicked(self, idx):\n \"\"\"点击缩略图 → 进入预览模式 + 跳转页面\"\"\"\n self._enter_preview()\n self._orig_thumb_clicked(idx)\n\n def _enter_preview(self):\n \"\"\"进入预览模式:缩略图显示蓝色高亮\"\"\"\n self._preview_active = True\n self.preview._thumb_color = _C[\"acc\"]\n self.preview.setFocus()\n self.preview._highlight_thumb(self.preview.current_page)\n\n def _exit_preview(self):\n \"\"\"退出预览模式:缩略图变灰色,焦点回列表\"\"\"\n self._preview_active = False\n self.preview._thumb_color = _C[\"t3\"]\n self.list_w.setFocus()\n self.preview._highlight_thumb(self.preview.current_page)\n\n def eventFilter(self, obj, event):\n \"\"\"两级导航: 历史列表 ←→ 预览区(一次按键切换)\n 全屏模式下 Left 键会先唤出历史面板\"\"\"\n from PyQt5.QtCore import QEvent\n if event.type() == QEvent.KeyPress:\n key = event.key()\n # 历史列表 → 右键 → 进入预览\n if obj == self.list_w and key == Qt.Key_Right:\n self._enter_preview()\n return True\n # 预览区 → 左键\n if obj == self.preview and key == Qt.Key_Left:\n # 全屏且历史面板隐藏 → 先唤出历史\n if self.preview._is_fullscreen and not self._fs_hist_visible:\n self._toggle_hist_in_fullscreen()\n return True\n self._exit_preview()\n return True\n return super().eventFilter(obj, event)\n\n def _on_fullscreen(self, fs):\n \"\"\"全屏阅读模式:隐藏历史面板+侧边栏\"\"\"\n if fs:\n self._saved_splitter_sizes = self._splitter.sizes()\n self._hist_panel.setVisible(False)\n self._fs_hist_visible = False\n self.preview.hist_btn.setProperty(\"active\", False)\n self.preview.hist_btn.style().unpolish(self.preview.hist_btn)\n self.preview.hist_btn.style().polish(self.preview.hist_btn)\n else:\n self._hist_panel.setVisible(True)\n self._splitter.setSizes(self._saved_splitter_sizes)\n self._fs_hist_visible = False\n self.fullscreen_changed.emit(fs)\n self._quick_refit()\n\n def _toggle_hist_in_fullscreen(self):\n \"\"\"全屏模式下切换历史面板\"\"\"\n if not self.preview._is_fullscreen:\n return\n self._fs_hist_visible = not self._fs_hist_visible\n self._hist_panel.setVisible(self._fs_hist_visible)\n self.preview.hist_btn.setProperty(\"active\", self._fs_hist_visible)\n self.preview.hist_btn.style().unpolish(self.preview.hist_btn)\n self.preview.hist_btn.style().polish(self.preview.hist_btn)\n if self._fs_hist_visible:\n self._splitter.setSizes([self._saved_splitter_sizes[0], 800])\n self.list_w.setFocus()\n self._quick_refit()\n\n def _quick_refit(self):\n \"\"\"快速贴合(不重建缩略图,只调 zoom + 渲染)\"\"\"\n if not self.preview.doc:\n return\n def _do():\n if self.preview._fit_mode == \"page\":\n self.preview._fit_page()\n else:\n self.preview._fit_width()\n if self.preview.zoom != getattr(self.preview, '_last_fit_zoom', -1):\n self.preview._last_fit_zoom = self.preview.zoom\n if self.preview._continuous:\n self.preview._render_continuous()\n else:\n self.preview.render_page()\n QTimer.singleShot(0, _do)\n QTimer.singleShot(80, _do)\n\n def _toggle_thumbs(self):\n \"\"\"切换缩略图显示,自动重新适配宽度\"\"\"\n self._show_thumbs = not self._show_thumbs\n self.thumb_toggle.setProperty(\"active\", self._show_thumbs)\n self.thumb_toggle.style().unpolish(self.thumb_toggle)\n self.thumb_toggle.style().polish(self.thumb_toggle)\n if self._show_thumbs:\n self.preview.thumb_scroll.setVisible(True)\n # 确保 splitter 给缩略图合理宽度\n sizes = self.preview.body_splitter.sizes()\n if sizes[0] < 60:\n self.preview.body_splitter.setSizes([100, sizes[1]])\n else:\n self.preview.thumb_scroll.setVisible(False)\n self._quick_refit()\n\n def refresh(self):\n self.list_w.clear()\n data = HistoryManager.load_all()\n records = data.get(\"records\", [])\n groups = data.get(\"groups\", [])\n all_tags = data.get(\"tags\", [])\n\n # ── 重建分组筛选栏 ──\n self._rebuild_group_bar(groups)\n\n # ── 按分组/标签筛选 ──\n filtered = records\n gid = self._current_group_id\n if gid is not None:\n filtered = [r for r in filtered if r.get(\"group_id\") == gid]\n tid = self._current_tag_filter\n if tid is not None:\n filtered = [r for r in filtered if tid in r.get(\"tags\", [])]\n\n if not filtered:\n item = QListWidgetItem(\"暂无翻译记录\")\n item.setFlags(item.flags() & ~Qt.ItemIsSelectable)\n item.setForeground(QColor(_C[\"t3\"]))\n self.list_w.addItem(item)\n return\n for r in filtered:\n ts = r.get(\"timestamp\", \"\")[:16].replace(\"T\", \" \")\n fname = r.get(\"file\", {}).get(\"name\", \"?\")\n svc = r.get(\"translation\", {}).get(\"service\", \"?\")\n lang_in = r.get(\"translation\", {}).get(\"lang_in\", \"\")\n lang_out = r.get(\"translation\", {}).get(\"lang_out\", \"\")\n status = \"✓\" if r.get(\"status\") == \"success\" else \"✗\"\n # 两行显示:文件名 + 服务·时间\n item = QListWidgetItem()\n item.setData(Qt.UserRole, r)\n item.setToolTip(f\"{fname}\\n{svc} · {lang_in}→{lang_out} · {ts[5:]}\")\n # 有标签时行高多留空间\n has_tags = bool(r.get(\"tags\"))\n item.setSizeHint(QSize(0, 56 if has_tags else 44))\n self.list_w.addItem(item)\n # 自定义 widget 双行布局\n w = QWidget(); wl = QVBoxLayout(w)\n wl.setContentsMargins(2, 2, 2, 2); wl.setSpacing(0)\n l1 = QLabel(f\"{status} {fname}\"); l1.setStyleSheet(f\"font-size:12px;color:{_C['t1']};background:transparent;\")\n l2 = QLabel(f\"{svc} · {lang_in}→{lang_out} · {ts[5:]}\")\n l2.setStyleSheet(f\"font-size:11px;color:{_C['t2']};background:transparent;\")\n wl.addWidget(l1); wl.addWidget(l2)\n # 标签行\n rec_tags = r.get(\"tags\", [])\n if rec_tags and all_tags:\n tag_row = QHBoxLayout(); tag_row.setSpacing(4); tag_row.setContentsMargins(0, 1, 0, 0)\n for td in all_tags:\n if td[\"id\"] in rec_tags:\n dot = QLabel(f\"● {td['name']}\")\n dot.setStyleSheet(f\"font-size:9px;color:{td['color']};background:transparent;\")\n tag_row.addWidget(dot)\n tag_row.addStretch()\n wl.addLayout(tag_row)\n w._l1 = l1; w._l2 = l2\n self.list_w.setItemWidget(item, w)\n\n def _rebuild_group_bar(self, groups):\n \"\"\"重建分组筛选按钮栏 — 支持右键菜单(重命名/删除/图标)\"\"\"\n while self._group_layout.count():\n w = self._group_layout.takeAt(0).widget()\n if w: w.deleteLater()\n self._group_btns = []\n c = _C\n # ── \"全部\"按钮(固定,不可删除) ──\n btn_all = QPushButton(\"全部\"); btn_all.setObjectName(\"TB\")\n btn_all.setCursor(Qt.PointingHandCursor)\n btn_all.setStyleSheet(\"font-size:10px;padding:4px 10px;\")\n btn_all.setProperty(\"active\", self._current_group_id is None)\n btn_all.clicked.connect(lambda: self._select_group(None))\n self._group_layout.addWidget(btn_all)\n # ── 动态分组按钮 ──\n for g in groups:\n b = QPushButton(f\"{g['icon']} {g['name']}\"); b.setObjectName(\"TB\")\n b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:10px;padding:4px 10px;\")\n b.setProperty(\"active\", self._current_group_id == g[\"id\"])\n gid = g[\"id\"]\n b.clicked.connect(lambda checked, _id=gid: self._select_group(_id))\n # 右键菜单\n b.setContextMenuPolicy(Qt.CustomContextMenu)\n b.customContextMenuRequested.connect(\n lambda pos, _id=gid, _btn=b: self._group_context_menu(_id, _btn, pos))\n self._group_layout.addWidget(b)\n self._group_btns.append((gid, b))\n self._group_layout.addStretch()\n\n def _select_group(self, group_id):\n self._current_group_id = group_id\n self.refresh()\n\n def _create_group(self):\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建分组\", \"分组名称:\")\n if ok and name.strip():\n g = HistoryManager.add_group(name.strip())\n # 在 stretch 前面插入新按钮(\"+\"在 layout 外,不受影响)\n stretch_idx = self._group_layout.count() - 1 # 最后一个是 stretch\n b = QPushButton(f\"{g['icon']} {g['name']}\"); b.setObjectName(\"TB\")\n b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:10px;padding:4px 10px;\")\n b.setProperty(\"active\", False)\n gid = g[\"id\"]\n b.clicked.connect(lambda checked, _id=gid: self._select_group(_id))\n b.setContextMenuPolicy(Qt.CustomContextMenu)\n b.customContextMenuRequested.connect(\n lambda pos, _id=gid, _btn=b: self._group_context_menu(_id, _btn, pos))\n self._group_layout.insertWidget(stretch_idx, b)\n self._group_btns.append((gid, b))\n\n def _group_context_menu(self, group_id, btn, pos):\n \"\"\"分组按钮右键菜单:重命名 / 修改图标 / 上移 / 下移 / 删除\"\"\"\n try:\n menu = _RoundMenu(self)\n menu.addAction(\"重命名\", lambda: self._rename_group(group_id))\n menu.addAction(\"修改图标\", lambda: self._change_group_icon(group_id))\n menu.addSeparator()\n # 上移 / 下移\n ids = [gid for gid, _ in self._group_btns]\n idx = ids.index(group_id) if group_id in ids else -1\n if idx > 0:\n menu.addAction(\"↑ 上移\", lambda: self._move_group(group_id, -1))\n if idx < len(ids) - 1:\n menu.addAction(\"↓ 下移\", lambda: self._move_group(group_id, 1))\n menu.addSeparator()\n menu.addAction(\"删除分组\", lambda: self._delete_group(group_id))\n menu.exec_(btn.mapToGlobal(pos))\n except Exception:\n pass\n\n def _rename_group(self, group_id):\n from PyQt5.QtWidgets import QInputDialog\n data = HistoryManager.load_all()\n old_name = \"\"\n for g in data.get(\"groups\", []):\n if g[\"id\"] == group_id:\n old_name = g[\"name\"]\n break\n name, ok = QInputDialog.getText(self, \"重命名分组\", \"新名称:\", text=old_name)\n if ok and name.strip():\n HistoryManager.rename_group(group_id, name.strip())\n self.refresh()\n\n def _change_group_icon(self, group_id):\n from PyQt5.QtWidgets import QInputDialog\n icons = [\"📁\", \"📂\", \"📚\", \"📖\", \"🔬\", \"🧪\", \"💻\", \"🎓\", \"📝\", \"⭐\",\n \"🔖\", \"📌\", \"🏷️\", \"💡\", \"🔍\", \"📊\", \"🧬\", \"🌐\", \"🤖\", \"📐\"]\n icon, ok = QInputDialog.getItem(self, \"选择图标\", \"分组图标:\", icons, 0, False)\n if ok:\n HistoryManager.update_group_icon(group_id, icon)\n self.refresh()\n\n def _move_group(self, group_id, direction):\n \"\"\"上移 direction=-1,下移 direction=1\"\"\"\n data = HistoryManager.load_all()\n groups = data.get(\"groups\", [])\n ids = [g[\"id\"] for g in groups]\n idx = ids.index(group_id) if group_id in ids else -1\n if idx < 0:\n return\n new_idx = idx + direction\n if 0 <= new_idx < len(ids):\n ids[idx], ids[new_idx] = ids[new_idx], ids[idx]\n HistoryManager.reorder_groups(ids)\n self.refresh()\n\n def _delete_group(self, group_id):\n data = HistoryManager.load_all()\n name = \"\"\n count = 0\n for g in data.get(\"groups\", []):\n if g[\"id\"] == group_id:\n name = g[\"name\"]\n break\n for r in data.get(\"records\", []):\n if r.get(\"group_id\") == group_id:\n count += 1\n reply = QMessageBox.question(\n self, \"删除分组\",\n f\"确定删除分组「{name}」吗?\\n该分组下的 {count} 个文件将移至「全部」。\",\n QMessageBox.Yes | QMessageBox.No, QMessageBox.No)\n if reply == QMessageBox.Yes:\n HistoryManager.delete_group(group_id)\n if self._current_group_id == group_id:\n self._current_group_id = None\n self.refresh()\n\n def _move_to_group(self, record_id, group_id):\n HistoryManager.move_to_group(record_id, group_id)\n self.refresh()\n\n def _toggle_tag(self, record_id, tag_id):\n HistoryManager.toggle_record_tag(record_id, tag_id)\n self.refresh()\n\n def _hist_context_menu(self, pos):\n item = self.list_w.itemAt(pos)\n if not item:\n return\n r = item.data(Qt.UserRole)\n if not r:\n return\n menu = _RoundMenu(self)\n # 在 Finder 中显示译文\n files = r.get(\"output_files\", {})\n has_output = any(os.path.exists(files.get(m, \"\")) for m in [\"dual\",\"mono\",\"side_by_side\"])\n if has_output:\n menu.addAction(\"在 Finder 中显示译文\", self._reveal_in_finder)\n # 打开原文\n src = r.get(\"file\", {}).get(\"path\", \"\")\n if src and os.path.exists(src):\n menu.addAction(\"打开原文\", self._open_source)\n menu.addAction(\"在 Finder 中显示原文\",\n lambda: __import__(\"subprocess\").Popen([\"open\", \"-R\", src]))\n menu.addSeparator()\n # 移到分组\n record_id = r.get(\"id\")\n data = HistoryManager.load_all()\n groups = data.get(\"groups\", [])\n if groups:\n grp_menu = _RoundMenu(self); grp_menu.setTitle(\"移到分组\")\n grp_menu.addAction(\"无分组\", lambda: self._move_to_group(record_id, None))\n for g in groups:\n gid = g[\"id\"]\n grp_menu.addAction(f\"{g['icon']} {g['name']}\",\n lambda _id=gid: self._move_to_group(record_id, _id))\n menu.addMenu(grp_menu)\n # 标签\n all_tags = data.get(\"tags\", [])\n tag_menu = _RoundMenu(self); tag_menu.setTitle(\"标签\")\n for t in all_tags:\n a = tag_menu.addAction(f\"● {t['name']}\")\n a.setCheckable(True)\n a.setChecked(t[\"id\"] in r.get(\"tags\", []))\n tid = t[\"id\"]\n a.toggled.connect(lambda checked, _tid=tid: self._toggle_tag(record_id, _tid))\n tag_menu.addSeparator()\n tag_menu.addAction(\"新建标签…\", self._create_tag)\n menu.addMenu(tag_menu)\n menu.addSeparator()\n menu.addAction(\"清空全部历史\", self._clear)\n menu.exec_(self.list_w.viewport().mapToGlobal(pos))\n\n def _create_tag(self):\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建标签\", \"标签名称:\")\n if ok and name.strip():\n colors = [\"#FF3B30\", \"#FF9F0A\", \"#34C759\", \"#007AFF\", \"#AF52DE\", \"#FF2D55\"]\n import random\n HistoryManager.add_tag(name.strip(), random.choice(colors))\n\n def _on_select(self, current, previous):\n # 恢复上一个\n if previous:\n w = self.list_w.itemWidget(previous)\n if w and hasattr(w, '_l1'):\n w._l1.setStyleSheet(f\"font-size:12px;color:{_C['t1']};background:transparent;\")\n w._l2.setStyleSheet(f\"font-size:11px;color:{_C['t2']};background:transparent;\")\n # 当前变白\n if current:\n w = self.list_w.itemWidget(current)\n if w and hasattr(w, '_l1'):\n w._l1.setStyleSheet(\"font-size:12px;color:white;background:transparent;\")\n w._l2.setStyleSheet(\"font-size:11px;color:rgba(255,255,255,0.7);background:transparent;\")\n\n r = current.data(Qt.UserRole) if current else None\n if not r:\n return\n # 更新详情面板\n fname = r.get(\"file\", {}).get(\"name\", \"—\")\n svc = r.get(\"translation\", {}).get(\"service\", \"—\")\n li = r.get(\"translation\", {}).get(\"lang_in\", \"\")\n lo2 = r.get(\"translation\", {}).get(\"lang_out\", \"\")\n ts = r.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n self.detail_label.setText(f\"📄 {fname}\\n🌐 {svc} · {li}→{lo2}\\n🕐 {ts}\")\n self._current_record = r\n # 显示文件操作按钮(有输出文件时)\n has_files = bool(r.get(\"output_files\"))\n self._btn_reveal.setVisible(has_files)\n self._btn_open_src.setVisible(bool(r.get(\"file\", {}).get(\"path\")))\n # 加载预览(翻译刚完成时跳过,避免覆盖最新结果)\n if \"output_files\" in r and not getattr(self, '_skip_preview_on_select', False):\n self.preview.set_output_files(r[\"output_files\"])\n\n def _open_in_reader(self, item):\n \"\"\"双击历史记录 → 加载到预览并聚焦预览区\"\"\"\n r = item.data(Qt.UserRole) if item else None\n if not r or \"output_files\" not in r:\n return\n self.preview.set_output_files(r[\"output_files\"])\n self._preview_active = True\n self.preview.setFocus()\n\n def set_output_files(self, files):\n \"\"\"外部调用(翻译完成后)\"\"\"\n self.preview.set_output_files(files)\n self._skip_preview_on_select = True # 防止 refresh → setCurrentRow → _on_select 覆盖预览\n self.refresh()\n if self.list_w.count() > 0:\n self.list_w.setCurrentRow(0)\n self._skip_preview_on_select = False\n\n def _clear(self):\n HistoryManager.clear(); self.refresh()\n\n def _reveal_in_finder(self):\n \"\"\"在 Finder 中显示翻译输出文件\"\"\"\n r = self._current_record\n if not r:\n return\n import subprocess\n # 优先显示当前预览模式的文件,否则任一输出文件\n files = r.get(\"output_files\", {})\n path = \"\"\n for mode in [self.preview.current_mode, \"dual\", \"mono\", \"side_by_side\"]:\n p = files.get(mode, \"\")\n if p and os.path.exists(p):\n path = p; break\n if path:\n subprocess.run([\"open\", \"-R\", path])\n\n def _open_source(self):\n \"\"\"用系统默认应用打开源 PDF\"\"\"\n r = self._current_record\n if not r:\n return\n import subprocess\n path = r.get(\"file\", {}).get(\"path\", \"\")\n if path and os.path.exists(path):\n subprocess.run([\"open\", path])\n\n def update_theme(self, c):\n \"\"\"深色/浅色切换时更新历史列表颜色\"\"\"\n self.preview.update_theme(c)\n self.preview._thumb_color = c[\"acc\"] if self._preview_active else c[\"t3\"]\n if self.preview._thumb_labels:\n self.preview._highlight_thumb(self.preview.current_page)\n # 刷新所有历史条目颜色\n for i in range(self.list_w.count()):\n item = self.list_w.item(i)\n w = self.list_w.itemWidget(item)\n if w and hasattr(w, '_l1'):\n is_sel = (item == self.list_w.currentItem())\n if is_sel:\n w._l1.setStyleSheet(\"font-size:12px;color:white;background:transparent;\")\n w._l2.setStyleSheet(\"font-size:11px;color:rgba(255,255,255,0.7);background:transparent;\")\n else:\n w._l1.setStyleSheet(f\"font-size:12px;color:{c['t1']};background:transparent;\")\n w._l2.setStyleSheet(f\"font-size:11px;color:{c['t2']};background:transparent;\")\n\n\n# ═══════════════════════════════════════════════════════════════\n# 设置页面\n# ═══════════════════════════════════════════════════════════════\n\nclass SettingsPage(QWidget):\n dark_mode_changed = pyqtSignal(bool)\n theme_color_changed = pyqtSignal(str)\n\n SERVICE_CONFIGS = {\n # 推荐\n \"DeepSeek\": {\"key_ph\":\"密钥\", \"models\":[\"deepseek-v4-flash\",\"deepseek-v4-pro\",\"deepseek-chat\",\"deepseek-reasoner\",\"deepseek-coder\"], \"url\":\"https://api.deepseek.com/v1\"},\n \"OpenAI\": {\"key_ph\":\"sk-...\", \"models\":[\"gpt-4o\",\"gpt-4o-mini\",\"gpt-4-turbo\",\"gpt-3.5-turbo\",\"o1\",\"o1-mini\",\"o1-pro\"], \"url\":\"https://api.openai.com/v1\"},\n # 国际服务\n \"Azure OpenAI\": {\"key_ph\":\"密钥\", \"models\":[\"gpt-4o\",\"gpt-4-turbo\",\"gpt-35-turbo\"], \"url\":\"https://YOUR_RESOURCE.openai.azure.com\"},\n \"DeepL\": {\"key_ph\":\"密钥\", \"models\":[], \"url\":\"\"},\n \"Gemini\": {\"key_ph\":\"密钥\", \"models\":[\"gemini-pro\",\"gemini-1.5-pro\",\"gemini-1.5-flash\",\"gemini-2.0-flash\"], \"url\":\"\"},\n \"Groq\": {\"key_ph\":\"密钥\", \"models\":[\"llama-3.1-70b-versatile\",\"llama-3.1-8b-instant\",\"mixtral-8x7b-32768\",\"llama-3.3-70b-versatile\"], \"url\":\"https://api.groq.com/openai/v1\"},\n # 国产大模型\n \"Zhipu 智谱\": {\"key_ph\":\"密钥\", \"models\":[\"glm-4\",\"glm-4-flash\",\"glm-4-plus\",\"glm-3-turbo\",\"cogview-3\"], \"url\":\"https://open.bigmodel.cn/api/paas/v4\"},\n \"Qwen 通义千问\": {\"key_ph\":\"密钥\", \"models\":[\"qwen-turbo\",\"qwen-plus\",\"qwen-max\",\"qwen-long\"], \"url\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\"},\n \"Tencent 腾讯\": {\"key_ph\":\"密钥\", \"models\":[\"hunyuan-pro\",\"hunyuan-standard\",\"hunyuan-lite\"], \"url\":\"\"},\n \"Silicon 硅基流动\":{\"key_ph\":\"密钥\", \"models\":[\"deepseek-ai/DeepSeek-V3\",\"Qwen/Qwen2.5-72B-Instruct\",\"Pro/THUDM/glm-4-9b-chat\"], \"url\":\"https://api.siliconflow.cn/v1\"},\n \"ModelScope\": {\"key_ph\":\"密钥\", \"models\":[\"qwen-turbo\",\"qwen-plus\"], \"url\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\"},\n # 本地 & 其他\n \"Ollama 本地\": {\"key_ph\":\"留空\", \"models\":[\"qwen2.5:7b\",\"llama3.1:8b\",\"gemma2:9b\",\"deepseek-r1:8b\"], \"url\":\"http://localhost:11434/v1\"},\n # Argos Translate 离线包未集成,桌面版不开放此服务(移除避免用户选择后报 ImportError)\n # \"Argos Translate\": {\"key_ph\":\"留空\", \"models\":[], \"url\":\"\"},\n \"AnythingLLM\": {\"key_ph\":\"密钥\", \"models\":[], \"url\":\"http://localhost:3001/api/v1\"},\n \"Grok\": {\"key_ph\":\"密钥\", \"models\":[\"grok-2\",\"grok-2-mini\"], \"url\":\"https://api.x.ai/v1\"},\n # 自定义\n \"OpenAI 兼容\": {\"key_ph\":\"密钥\", \"models\":[\"自定义模型\"], \"url\":\"\"},\n }\n\n\n def __init__(self):\n super().__init__()\n self.setObjectName(\"PA\")\n lo = QVBoxLayout(self); lo.setContentsMargins(24,10,24,10); lo.setSpacing(4)\n\n hdr = QHBoxLayout()\n t = QLabel(\"设置\"); t.setObjectName(\"PT0\"); hdr.addWidget(t)\n hdr.addStretch()\n st = QLabel(\"翻译服务与偏好设置\"); st.setObjectName(\"PT1\"); hdr.addWidget(st)\n lo.addLayout(hdr)\n\n cols = QHBoxLayout(); cols.setSpacing(20)\n cfg = UserConfigManager.load()\n # 旧版 key 名兼容迁移:v2.2.5 及之前部分服务用了简短 svc 名(如 \"Silicon\"),\n # v2.2.6 SERVICE_CONFIGS 改成全名(如 \"Silicon 硅基流动\"),需把旧 key 值合并过来\n _legacy_alias = {\n \"Silicon 硅基流动\": \"Silicon\",\n \"Tencent 腾讯\": \"Tencent\",\n \"Zhipu 智谱\": \"Zhipu\",\n \"Qwen 通义千问\": \"Qwen\",\n \"Ollama 本地\": \"Ollama\",\n \"OpenAI 兼容\": \"OpenAI-liked\",\n }\n _migrated = False\n for new_k, old_k in _legacy_alias.items():\n for prefix in (\"api_\", \"url_\", \"model_\"):\n new_key = f\"{prefix}{new_k}\"\n old_key = f\"{prefix}{old_k}\"\n if not cfg.get(new_key) and cfg.get(old_key):\n cfg[new_key] = cfg[old_key]\n _migrated = True\n if _migrated:\n UserConfigManager.save(cfg)\n from PyQt5.QtWidgets import QGridLayout, QTextEdit\n\n # ══════════════════════════════════════════\n # 左列:翻译服务 → AI 助手\n # ══════════════════════════════════════════\n left = QVBoxLayout(); left.setSpacing(2)\n\n # ── 翻译服务配置 ──\n sl3 = QLabel(\"翻译服务配置\"); sl3.setObjectName(\"SL\"); left.addWidget(sl3)\n self.svc_card = _card()\n svc_outer = QVBoxLayout(self.svc_card)\n svc_outer.setContentsMargins(16,12,16,12); svc_outer.setSpacing(4)\n self.svc_selector = QComboBox()\n self.svc_selector.addItems(list(self.SERVICE_CONFIGS.keys()))\n self.svc_selector.currentTextChanged.connect(self._show_service_config)\n svc_outer.addWidget(self.svc_selector)\n self.svc_layout = QVBoxLayout(); self.svc_layout.setSpacing(2)\n svc_outer.addLayout(self.svc_layout)\n left.addWidget(self.svc_card)\n\n self.api_inputs = {}\n for svc_name, conf in self.SERVICE_CONFIGS.items():\n key_inp = QLineEdit(); key_inp.setPlaceholderText(f\"API Key: {conf['key_ph']}\")\n key_inp.setEchoMode(QLineEdit.Password)\n saved = cfg.get(f\"api_{svc_name}\", \"\")\n if saved: key_inp.setText(UserConfigManager.decode_sensitive(saved))\n key_inp.editingFinished.connect(self._save)\n self.api_inputs[svc_name] = {\"key\": key_inp}\n if conf[\"models\"]:\n mc = QComboBox(); mc.setEditable(True)\n mc.addItems(conf[\"models\"])\n saved_m = cfg.get(f\"model_{svc_name}\", \"\")\n if saved_m: mc.setCurrentText(saved_m)\n mc.currentTextChanged.connect(self._save)\n self.api_inputs[svc_name][\"model\"] = mc\n if conf[\"url\"]:\n ui = QLineEdit()\n saved_u = cfg.get(f\"url_{svc_name}\", \"\")\n ui.setText(saved_u if saved_u else conf[\"url\"])\n ui.editingFinished.connect(self._save)\n self.api_inputs[svc_name][\"url\"] = ui\n\n # ── AI 助手(摘要 / 问答服务) ──\n sl_ai = QLabel(\"AI 助手\"); sl_ai.setObjectName(\"SL\"); left.addWidget(sl_ai)\n ai_card = _card(); ai_lo = QVBoxLayout(ai_card)\n ai_lo.setContentsMargins(16, 12, 16, 12); ai_lo.setSpacing(6)\n\n ai_desc = QLabel(\"摘要与问答使用的 AI 服务,默认与翻译服务相同\")\n ai_desc.setObjectName(\"Cap\"); ai_desc.setWordWrap(True)\n ai_lo.addWidget(ai_desc)\n\n self._ai_default_row = QWidget()\n _adr_lo = QHBoxLayout(self._ai_default_row)\n _adr_lo.setContentsMargins(0, 2, 0, 2); _adr_lo.setSpacing(8)\n self._ai_status_label = QLabel(\"与翻译服务相同\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['ok']};background:transparent;\")\n _adr_lo.addWidget(self._ai_status_label); _adr_lo.addStretch()\n self._ai_toggle_btn = QPushButton(\"自定义\")\n self._ai_toggle_btn.setObjectName(\"Gh\"); self._ai_toggle_btn.setCursor(Qt.PointingHandCursor)\n self._ai_toggle_btn.setStyleSheet(\"font-size:11px;padding:3px 10px;\")\n self._ai_toggle_btn.clicked.connect(self._toggle_assistant_config)\n _adr_lo.addWidget(self._ai_toggle_btn)\n ai_lo.addWidget(self._ai_default_row)\n\n self._ai_custom_frame = QWidget()\n _acf_lo = QVBoxLayout(self._ai_custom_frame)\n _acf_lo.setContentsMargins(0, 6, 0, 0); _acf_lo.setSpacing(10)\n\n for _f_label, _f_attr, _f_type in [\n (\"服务\", \"_ai_svc_combo\", \"combo\"),\n (\"API Key\", \"_ai_key_input\", \"password\"),\n (\"模型\", \"_ai_model_combo\", \"combo_edit\"),\n (\"Base URL\",\"_ai_url_input\", \"line\"),\n ]:\n _grp = QVBoxLayout(); _grp.setSpacing(3)\n _lbl = QLabel(_f_label); _lbl.setStyleSheet(\n f\"font-size:11px;font-weight:500;color:{_C['t2']};background:transparent;padding:0;\")\n _grp.addWidget(_lbl)\n if _f_type == \"combo\":\n w = QComboBox()\n from ui.ai_client import CHAT_SERVICES\n w.addItems([name for name, _, _ in CHAT_SERVICES])\n w.currentTextChanged.connect(self._on_assistant_svc_changed)\n elif _f_type == \"password\":\n w = QLineEdit(); w.setEchoMode(QLineEdit.Password)\n w.setPlaceholderText(\"密钥\")\n w.editingFinished.connect(self._save_assistant_config)\n elif _f_type == \"combo_edit\":\n w = QComboBox(); w.setEditable(True)\n w.currentTextChanged.connect(self._save_assistant_config)\n else:\n w = QLineEdit(); w.setPlaceholderText(\"留空使用默认\")\n w.editingFinished.connect(self._save_assistant_config)\n setattr(self, _f_attr, w)\n _grp.addWidget(w)\n _acf_lo.addLayout(_grp)\n\n _reset_row = QHBoxLayout(); _reset_row.addStretch()\n _reset_btn = QPushButton(\"恢复默认\"); _reset_btn.setObjectName(\"GhDanger\")\n _reset_btn.setCursor(Qt.PointingHandCursor)\n _reset_btn.setStyleSheet(\"font-size:11px;padding:3px 10px;\")\n _reset_btn.clicked.connect(self._reset_assistant_config)\n _reset_row.addWidget(_reset_btn)\n _acf_lo.addLayout(_reset_row)\n\n self._ai_custom_frame.setVisible(False)\n ai_lo.addWidget(self._ai_custom_frame)\n self._load_assistant_config()\n left.addWidget(ai_card)\n left.addStretch()\n\n # ══════════════════════════════════════════\n # 右列:偏好 → 提示词 → 术语库 → Zotero\n # ══════════════════════════════════════════\n right = QVBoxLayout(); right.setSpacing(2)\n\n # ── 偏好设置 ──\n sl_app = QLabel(\"偏好设置\"); sl_app.setObjectName(\"SL\"); right.addWidget(sl_app)\n c = _card(); cl = QVBoxLayout(c); cl.setContentsMargins(16,12,16,12); cl.setSpacing(6)\n self.dark_check = QCheckBox(\"深色模式\"); self.dark_check.toggled.connect(self.dark_mode_changed.emit)\n self.cache_check = QCheckBox(\"翻译缓存\"); self.cache_check.setChecked(True)\n self.ai_check = QCheckBox(\"AI 布局检测\"); self.ai_check.setChecked(True)\n self.font_check = QCheckBox(\"字体子集化\")\n _pref_grid = QGridLayout(); _pref_grid.setSpacing(6)\n _pref_grid.addWidget(self.dark_check, 0, 0)\n _pref_grid.addWidget(self.cache_check, 0, 1)\n _pref_grid.addWidget(self.ai_check, 1, 0)\n _pref_grid.addWidget(self.font_check, 1, 1)\n cl.addLayout(_pref_grid)\n self.theme_row = QWidget(); tr_lo = QHBoxLayout(self.theme_row)\n tr_lo.setContentsMargins(0,2,0,2); tr_lo.setSpacing(6)\n tr_lbl = QLabel(\"主题色\"); tr_lo.addWidget(tr_lbl)\n THEME_COLORS = [\n (\"#0071E3\",\"蓝\"),(\"#FF6B6B\",\"红\"),(\"#34C759\",\"绿\"),\n (\"#AF52DE\",\"紫\"),(\"#FF9F0A\",\"橙\"),(\"#FF2D55\",\"粉\"),\n (\"#5AC8FA\",\"青\"),(\"#FFD60A\",\"黄\"),\n ]\n self._theme_dots = []\n for hex_c, name in THEME_COLORS:\n dot = QPushButton(); dot.setFixedSize(18, 18); dot.setCursor(Qt.PointingHandCursor)\n dot.setToolTip(name); dot.setProperty(\"hex\", hex_c)\n dot.setStyleSheet(\n f\"QPushButton{{background:{hex_c};border:2px solid transparent;\"\n f\"border-radius:9px;}}QPushButton:hover{{border-color:{hex_c};}}\")\n dot.clicked.connect(lambda _, h=hex_c: self._set_theme_color(h))\n tr_lo.addWidget(dot); self._theme_dots.append(dot)\n tr_lo.addStretch()\n cl.addWidget(self.theme_row)\n self.theme_row.setVisible(UserConfigManager.load().get(\"theme_unlocked\", False))\n right.addWidget(c)\n\n # ── 翻译提示词 ──\n from ui.prompt_manager import PromptTemplateManager\n sl4 = QLabel(\"翻译提示词\"); sl4.setObjectName(\"SL\"); right.addWidget(sl4)\n c4 = _card(); cl4 = QVBoxLayout(c4); cl4.setContentsMargins(16,10,16,10); cl4.setSpacing(2)\n pr = QHBoxLayout(); pr.setSpacing(4)\n self.prompt_preset = QComboBox()\n self._refresh_prompt_list()\n self.prompt_preset.currentTextChanged.connect(self._on_prompt_preset)\n pr.addWidget(self.prompt_preset, 1)\n cl4.addLayout(pr)\n self.prompt_edit = QTextEdit()\n self.prompt_edit.setPlaceholderText(\"提示词… {lang_out} {lang_in} {text}\")\n self.prompt_edit.setMaximumHeight(42)\n saved_prompt = cfg.get(\"prompt\", \"\")\n if saved_prompt: self.prompt_edit.setPlainText(saved_prompt)\n cl4.addWidget(self.prompt_edit)\n btn_row = QHBoxLayout(); btn_row.setSpacing(2)\n for label, slot in [\n (\"保存\", self._save_prompt_template),\n (\"新建\", self._new_prompt_template),\n (\"导入\", self._import_prompts),\n (\"导出\", self._export_prompts),\n ]:\n b = QPushButton(label); b.setObjectName(\"Gh\"); b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:11px;padding:2px 6px;\"); b.clicked.connect(slot)\n btn_row.addWidget(b)\n btn_row.addStretch()\n del_btn = QPushButton(\"删除\"); del_btn.setCursor(Qt.PointingHandCursor)\n del_btn.setObjectName(\"GhDanger\"); del_btn.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n del_btn.clicked.connect(self._delete_prompt_template)\n btn_row.addWidget(del_btn)\n cl4.addLayout(btn_row)\n right.addWidget(c4)\n\n # ── 术语库 ──\n from ui.glossary_manager import GlossaryManager\n sl5 = QLabel(\"术语库\"); sl5.setObjectName(\"SL\"); right.addWidget(sl5)\n c5 = _card(); cl5 = QVBoxLayout(c5); cl5.setContentsMargins(16,10,16,10); cl5.setSpacing(3)\n gr = QHBoxLayout(); gr.setSpacing(6)\n self.gloss_selector = QComboBox()\n self._refresh_gloss_list()\n self.gloss_selector.currentTextChanged.connect(self._on_gloss_changed)\n gr.addWidget(self.gloss_selector, 1)\n self.gloss_count = QLabel(\"\"); self.gloss_count.setObjectName(\"Cap\")\n self.gloss_count.setStyleSheet(\"font-size:10px;\")\n gr.addWidget(self.gloss_count)\n cl5.addLayout(gr); self._update_gloss_count()\n # 内联添加术语\n _add_row = QHBoxLayout(); _add_row.setSpacing(4)\n self._gloss_src = QLineEdit(); self._gloss_src.setPlaceholderText(\"原文\")\n self._gloss_src.setStyleSheet(\"font-size:11px;padding:4px 8px;\")\n self._gloss_dst = QLineEdit(); self._gloss_dst.setPlaceholderText(\"译文\")\n self._gloss_dst.setStyleSheet(\"font-size:11px;padding:4px 8px;\")\n _add_btn = QPushButton(\"添加\"); _add_btn.setObjectName(\"Gh\")\n _add_btn.setCursor(Qt.PointingHandCursor)\n _add_btn.setStyleSheet(\"font-size:11px;padding:2px 8px;\")\n _add_btn.clicked.connect(self._add_gloss_term)\n _add_row.addWidget(self._gloss_src, 1)\n _add_row.addWidget(self._gloss_dst, 1)\n _add_row.addWidget(_add_btn)\n cl5.addLayout(_add_row)\n gbtn = QHBoxLayout(); gbtn.setSpacing(2)\n for label, slot in [\n (\"新建\", self._new_glossary), (\"导入\", self._import_glossary),\n (\"导出\", self._export_glossary),\n ]:\n b = QPushButton(label); b.setObjectName(\"Gh\"); b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:11px;padding:2px 6px;\"); b.clicked.connect(slot)\n gbtn.addWidget(b)\n gbtn.addStretch()\n gdel = QPushButton(\"删除\"); gdel.setCursor(Qt.PointingHandCursor)\n gdel.setObjectName(\"GhDanger\"); gdel.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n gdel.clicked.connect(self._delete_glossary); gbtn.addWidget(gdel)\n cl5.addLayout(gbtn)\n right.addWidget(c5)\n\n # ── Zotero 联动 ──\n sl_zot = QLabel(\"Zotero 联动\"); sl_zot.setObjectName(\"SL\"); right.addWidget(sl_zot)\n zot_card = _card(); zot_lo = QVBoxLayout(zot_card)\n zot_lo.setContentsMargins(14,10,14,10); zot_lo.setSpacing(4)\n self._zot_sbs = QCheckBox(\"左右并排 (Side by Side)\")\n self._zot_dual = QCheckBox(\"双语对照 (Dual)\")\n self._zot_mono = QCheckBox(\"仅翻译 (Mono)\")\n self._zot_keep_copy = QCheckBox(\"保留本地副本\")\n cfg = UserConfigManager.load()\n _zot_modes = cfg.get(\"zotero_output_modes\", [\"side_by_side\"])\n self._zot_sbs.setChecked(\"side_by_side\" in _zot_modes)\n self._zot_dual.setChecked(\"dual\" in _zot_modes)\n self._zot_mono.setChecked(\"mono\" in _zot_modes)\n self._zot_keep_copy.setChecked(cfg.get(\"zotero_keep_copy\", True))\n _zot_grid = QGridLayout(); _zot_grid.setSpacing(4)\n _zot_grid.addWidget(self._zot_sbs, 0, 0)\n _zot_grid.addWidget(self._zot_dual, 0, 1)\n _zot_grid.addWidget(self._zot_mono, 1, 0)\n _zot_grid.addWidget(self._zot_keep_copy, 1, 1)\n for _cb in (self._zot_sbs, self._zot_dual, self._zot_mono, self._zot_keep_copy):\n _cb.stateChanged.connect(self._save_zotero_modes)\n zot_lo.addLayout(_zot_grid)\n zot_lo.addWidget(_div())\n _auto_row = QHBoxLayout(); _auto_row.setSpacing(6)\n _auto_desc = QLabel(\"Zotero 插件\"); _auto_desc.setObjectName(\"Cap\")\n _auto_row.addWidget(_auto_desc)\n self._zot_status = QLabel(\"\"); self._zot_status.setObjectName(\"Cap\")\n _auto_row.addWidget(self._zot_status); _auto_row.addStretch()\n self._zot_install_btn = QPushButton(\"一键安装\")\n self._zot_install_btn.setObjectName(\"Gh\"); self._zot_install_btn.setCursor(Qt.PointingHandCursor)\n self._zot_install_btn.clicked.connect(self._install_zotero_plugin)\n _auto_row.addWidget(self._zot_install_btn)\n zot_lo.addLayout(_auto_row)\n QTimer.singleShot(500, self._check_zotero_plugin)\n right.addWidget(zot_card)\n\n # ── 使用指南(紧凑) ──\n sl_guide = QLabel(\"使用指南\"); sl_guide.setObjectName(\"SL\"); right.addWidget(sl_guide)\n guide_card = _card(\"sm\"); guide_lo = QVBoxLayout(guide_card)\n guide_lo.setContentsMargins(12,8,12,8); guide_lo.setSpacing(1)\n for tip in [\n \"Google / Bing 无需配置,开箱即用\",\n \"OpenAI 兼容接口可对接任意第三方服务\",\n \"Ollama 本地需先启动服务\",\n \"50 页以上建议开启分块翻译\",\n ]:\n tl = QLabel(f\"· {tip}\"); tl.setObjectName(\"Cap\")\n tl.setStyleSheet(\"font-size:10px;padding:1px 0;\")\n guide_lo.addWidget(tl)\n guide_lo.addWidget(_div())\n self._kb_labels = []\n kb_grid = QGridLayout(); kb_grid.setHorizontalSpacing(6); kb_grid.setVerticalSpacing(1)\n for i, (key, desc) in enumerate([\n (\"Ctrl+滚轮\",\"缩放\"), (\"← →\",\"切换\"), (\"页码+回车\",\"跳转\"),\n ]):\n kl = QLabel(key); kl.setObjectName(\"KBKey\"); kl.setStyleSheet(\"font-size:9px;padding:2px 5px;\")\n kd = QLabel(desc); kd.setObjectName(\"Cap\"); kd.setStyleSheet(\"font-size:9px;\")\n kb_grid.addWidget(kl, 0, i * 2); kb_grid.addWidget(kd, 0, i * 2 + 1)\n self._kb_labels.append(kl)\n guide_lo.addLayout(kb_grid)\n guide_lo.addWidget(_div())\n _paths_col = QVBoxLayout(); _paths_col.setSpacing(1)\n for _pname, _pfile in [\n (\"配置\", \"~/.config/pdf2zh/config.json\"),\n (\"历史\", \"~/.config/pdf2zh/history.json\"),\n (\"术语库\", \"~/.config/pdf2zh/glossary.json\"),\n ]:\n _pl = QLabel(f\"{_pname} {_pfile}\")\n _pl.setObjectName(\"Cap\"); _pl.setStyleSheet(\"font-size:9px;padding:0;\")\n _paths_col.addWidget(_pl)\n guide_lo.addLayout(_paths_col)\n _paths_btn_row = QHBoxLayout(); _paths_btn_row.addStretch()\n open_dir_btn = QPushButton(\"打开数据目录\"); open_dir_btn.setObjectName(\"Gh\")\n open_dir_btn.setCursor(Qt.PointingHandCursor)\n open_dir_btn.setStyleSheet(\"font-size:10px;padding:2px 6px;\")\n open_dir_btn.clicked.connect(lambda: __import__('subprocess').run(['open', str(__import__('pathlib').Path.home())]))\n _paths_btn_row.addWidget(open_dir_btn)\n guide_lo.addLayout(_paths_btn_row)\n right.addWidget(guide_card)\n right.addStretch()\n\n cols.addLayout(left, 5)\n cols.addLayout(right, 4)\n lo.addLayout(cols)\n\n # 初始显示第一个服务\n self._show_service_config(self.svc_selector.currentText())\n\n def _show_service_config(self, svc_name):\n \"\"\"切换显示选中服务的配置\"\"\"\n # 清空当前\n while self.svc_layout.count():\n item = self.svc_layout.takeAt(0)\n w = item.widget()\n if w: w.setParent(None)\n sub = item.layout()\n if sub:\n while sub.count():\n si = sub.takeAt(0)\n if si.widget(): si.widget().setParent(None)\n\n if svc_name not in self.api_inputs:\n return\n inputs = self.api_inputs[svc_name]\n\n # API Key\n kl = QLabel(\"API Key\"); kl.setObjectName(\"FL\")\n self.svc_layout.addWidget(kl)\n self.svc_layout.addWidget(inputs[\"key\"])\n inputs[\"key\"].setParent(self.svc_card)\n inputs[\"key\"].show()\n\n # 模型\n if \"model\" in inputs:\n ml = QLabel(\"模型\"); ml.setObjectName(\"FL\")\n self.svc_layout.addWidget(ml)\n self.svc_layout.addWidget(inputs[\"model\"])\n inputs[\"model\"].setParent(self.svc_card)\n inputs[\"model\"].show()\n\n # Base URL\n if \"url\" in inputs:\n ul = QLabel(\"Base URL\"); ul.setObjectName(\"FL\")\n self.svc_layout.addWidget(ul)\n self.svc_layout.addWidget(inputs[\"url\"])\n inputs[\"url\"].setParent(self.svc_card)\n inputs[\"url\"].show()\n\n # 测试连接按钮\n self.svc_layout.addSpacing(4)\n self._test_label = QLabel(\"\")\n self._test_label.setObjectName(\"Cap\")\n test_btn = QPushButton(\"测试连接\")\n test_btn.setObjectName(\"Gh\"); test_btn.setCursor(Qt.PointingHandCursor)\n test_btn.clicked.connect(lambda: self._test_connection(svc_name))\n test_row = QHBoxLayout(); test_row.addStretch()\n test_row.addWidget(self._test_label)\n test_row.addWidget(test_btn)\n self.svc_layout.addLayout(test_row)\n\n def _test_connection(self, svc_name):\n \"\"\"测试翻译服务连接\"\"\"\n self._save() # 先保存,确保 API Key 写入配置\n self._test_label.setText(\"测试中…\")\n self._test_label.setStyleSheet(\"color:#FF9F0A;font-size:11px;\")\n QApplication.processEvents()\n\n inputs = self.api_inputs.get(svc_name, {})\n key = inputs.get(\"key\")\n key_val = key.text().strip() if key else \"\"\n url_w = inputs.get(\"url\")\n url_val = url_w.text().strip() if url_w else \"\"\n\n try:\n import requests\n # 对于 OpenAI 兼容类服务,测试 /models 端点\n if url_val and (\"openai\" in url_val or \"deepseek\" in url_val\n or \"siliconflow\" in url_val or \"groq\" in url_val\n or \"bigmodel\" in url_val or \"dashscope\" in url_val\n or \"localhost\" in url_val or \"x.ai\" in url_val):\n headers = {\"Authorization\": f\"Bearer {key_val}\"} if key_val else {}\n r = requests.get(f\"{url_val}/models\", headers=headers, timeout=10)\n if r.status_code in (200, 401, 403):\n self._test_label.setText(\"✅ 连接成功\")\n self._test_label.setStyleSheet(f\"color:{_C['ok']};font-size:11px;\")\n else:\n self._test_label.setText(f\"⚠️ HTTP {r.status_code}\")\n self._test_label.setStyleSheet(f\"color:{_C['t2']};font-size:11px;\")\n elif \"deepl\" in svc_name.lower():\n r = requests.get(\"https://api-free.deepl.com/v2/usage\",\n headers={\"Authorization\": f\"DeepL-Auth-Key {key_val}\"}, timeout=10)\n self._test_label.setText(\"✅ 连接成功\" if r.ok else f\"⚠️ {r.status_code}\")\n self._test_label.setStyleSheet(f\"color:{_C['ok'] if r.ok else _C['t2']};font-size:11px;\")\n else:\n self._test_label.setText(\"✅ 免费服务,无需测试\")\n self._test_label.setStyleSheet(f\"color:{_C['ok']};font-size:11px;\")\n except requests.exceptions.ConnectionError:\n self._test_label.setText(\"❌ 无法连接\")\n self._test_label.setStyleSheet(f\"color:{_C['err']};font-size:11px;\")\n except Exception as e:\n self._test_label.setText(f\"❌ {str(e)[:30]}\")\n self._test_label.setStyleSheet(f\"color:{_C['err']};font-size:11px;\")\n\n def _refresh_prompt_list(self):\n from ui.prompt_manager import PromptTemplateManager\n self.prompt_preset.blockSignals(True)\n current = self.prompt_preset.currentText()\n self.prompt_preset.clear()\n templates = PromptTemplateManager.load_all()\n self.prompt_preset.addItems(list(templates.keys()))\n idx = self.prompt_preset.findText(current)\n if idx >= 0:\n self.prompt_preset.setCurrentIndex(idx)\n self.prompt_preset.blockSignals(False)\n\n def _on_prompt_preset(self, text):\n from ui.prompt_manager import PromptTemplateManager\n templates = PromptTemplateManager.load_all()\n val = templates.get(text, \"\")\n self.prompt_edit.setPlainText(val)\n self._save()\n\n def _save_prompt_template(self):\n \"\"\"保存当前内容到选中的模板名\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n name = self.prompt_preset.currentText()\n content = self.prompt_edit.toPlainText().strip()\n PromptTemplateManager.save_template(name, content)\n self._refresh_prompt_list()\n\n def _new_prompt_template(self):\n \"\"\"新建模板\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n name, ok = QLineEdit.staticMetaObject, False # placeholder\n # 用简单的输入对话框\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建模板\", \"模板名称:\")\n if ok and name.strip():\n content = self.prompt_edit.toPlainText().strip()\n PromptTemplateManager.save_template(name.strip(), content)\n self._refresh_prompt_list()\n idx = self.prompt_preset.findText(name.strip())\n if idx >= 0:\n self.prompt_preset.setCurrentIndex(idx)\n\n def _delete_prompt_template(self):\n \"\"\"删除用户模板\"\"\"\n from ui.prompt_manager import PromptTemplateManager, DEFAULT_TEMPLATES\n name = self.prompt_preset.currentText()\n if name in DEFAULT_TEMPLATES:\n return # 不能删默认模板\n PromptTemplateManager.delete_template(name)\n self._refresh_prompt_list()\n\n def _import_prompts(self):\n \"\"\"从 JSON 文件导入模板\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n path, _ = QFileDialog.getOpenFileName(self, \"导入模板\", \"\", \"JSON (*.json)\")\n if path:\n count = PromptTemplateManager.import_from_file(path)\n self._refresh_prompt_list()\n\n def _export_prompts(self):\n \"\"\"导出所有模板到 JSON\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n path, _ = QFileDialog.getSaveFileName(self, \"导出模板\", \"pdf2zh_prompts.json\", \"JSON (*.json)\")\n if path:\n PromptTemplateManager.export_to_file(path)\n\n def _set_theme_color(self, hex_color):\n \"\"\"切换主题色并即时应用\"\"\"\n cfg = UserConfigManager.load()\n cfg[\"theme_color\"] = hex_color\n UserConfigManager.save(cfg)\n # 高亮选中的色块\n for dot in self._theme_dots:\n sel = dot.property(\"hex\") == hex_color\n dot.setStyleSheet(\n f\"QPushButton{{background:{dot.property('hex')};\"\n f\"border:2px solid {'#1d1d1f' if sel else 'transparent'};\"\n f\"border-radius:9px;}}QPushButton:hover{{border-color:{dot.property('hex')};}}\")\n # 通知 MainWindow 刷新主题\n self.theme_color_changed.emit(hex_color)\n\n def _save(self, *args):\n cfg = UserConfigManager.load()\n for svc_name, inputs in self.api_inputs.items():\n key_val = inputs[\"key\"].text().strip()\n cfg[f\"api_{svc_name}\"] = UserConfigManager.encode_sensitive(key_val) if key_val else \"\"\n if \"model\" in inputs:\n cfg[f\"model_{svc_name}\"] = inputs[\"model\"].currentText().strip()\n if \"url\" in inputs:\n cfg[f\"url_{svc_name}\"] = inputs[\"url\"].text().strip()\n cfg[\"prompt\"] = self.prompt_edit.toPlainText().strip()\n UserConfigManager.save(cfg)\n\n def _save_zotero_modes(self, *args):\n modes = []\n if self._zot_sbs.isChecked(): modes.append(\"side_by_side\")\n if self._zot_dual.isChecked(): modes.append(\"dual\")\n if self._zot_mono.isChecked(): modes.append(\"mono\")\n if not modes:\n modes = [\"side_by_side\"]\n self._zot_sbs.blockSignals(True)\n self._zot_sbs.setChecked(True)\n self._zot_sbs.blockSignals(False)\n cfg = UserConfigManager.load()\n cfg[\"zotero_output_modes\"] = modes\n cfg[\"zotero_keep_copy\"] = self._zot_keep_copy.isChecked()\n UserConfigManager.save(cfg)\n\n # ── AI 助手配置 ──\n\n def _load_assistant_config(self):\n \"\"\"加载 AI 助手独立配置状态\"\"\"\n cfg = UserConfigManager.load()\n is_custom = cfg.get(\"assistant_custom\", False)\n if is_custom:\n self._ai_custom_frame.setVisible(True)\n self._ai_status_label.setText(\"使用独立配置\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['acc']};background:transparent;\")\n self._ai_toggle_btn.setVisible(False)\n svc = cfg.get(\"assistant_service\", \"\")\n if svc:\n idx = self._ai_svc_combo.findText(svc)\n if idx >= 0: self._ai_svc_combo.setCurrentIndex(idx)\n api_raw = cfg.get(\"assistant_api_key\", \"\")\n if api_raw:\n self._ai_key_input.setText(UserConfigManager.decode_sensitive(api_raw))\n model = cfg.get(\"assistant_model\", \"\")\n if model: self._ai_model_combo.setCurrentText(model)\n url = cfg.get(\"assistant_url\", \"\")\n if url: self._ai_url_input.setText(url)\n else:\n self._ai_custom_frame.setVisible(False)\n self._ai_status_label.setText(\"✓ 使用翻译服务配置\")\n self._ai_toggle_btn.setText(\"自定义\")\n\n def _toggle_assistant_config(self):\n \"\"\"展开自定义 AI 助手配置\"\"\"\n self._ai_custom_frame.setVisible(True)\n self._ai_status_label.setText(\"使用独立配置\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['acc']};background:transparent;\")\n self._ai_toggle_btn.setVisible(False)\n # 预填当前翻译服务配置\n from ui.ai_client import detect_service\n svc = detect_service()\n if svc:\n idx = self._ai_svc_combo.findText(svc[\"name\"])\n if idx >= 0:\n self._ai_svc_combo.blockSignals(True)\n self._ai_svc_combo.setCurrentIndex(idx)\n self._ai_svc_combo.blockSignals(False)\n self._ai_key_input.setText(svc.get(\"api_key\", \"\"))\n self._ai_model_combo.setCurrentText(svc.get(\"model\", \"\"))\n self._ai_url_input.setText(svc.get(\"base_url\", \"\"))\n self._on_assistant_svc_changed(self._ai_svc_combo.currentText())\n self._save_assistant_config()\n\n def _on_assistant_svc_changed(self, svc_name):\n \"\"\"切换 AI 助手服务时更新模型候选列表\"\"\"\n from ui.ai_client import CHAT_SERVICES\n # 查找默认 URL\n default_url = \"\"\n for name, url, _ in CHAT_SERVICES:\n if name == svc_name:\n default_url = url; break\n if not self._ai_url_input.text().strip() or True:\n self._ai_url_input.setPlaceholderText(default_url or \"自定义 URL\")\n # 更新模型列表\n cur_model = self._ai_model_combo.currentText()\n self._ai_model_combo.blockSignals(True)\n self._ai_model_combo.clear()\n svc_conf = self.SERVICE_CONFIGS.get(svc_name, {})\n if svc_conf.get(\"models\"):\n self._ai_model_combo.addItems(svc_conf[\"models\"])\n if cur_model:\n self._ai_model_combo.setCurrentText(cur_model)\n self._ai_model_combo.blockSignals(False)\n self._save_assistant_config()\n\n def _save_assistant_config(self, *args):\n \"\"\"保存 AI 助手独立配置\"\"\"\n if not self._ai_custom_frame.isVisible():\n return\n cfg = UserConfigManager.load()\n cfg[\"assistant_custom\"] = True\n cfg[\"assistant_service\"] = self._ai_svc_combo.currentText()\n key = self._ai_key_input.text().strip()\n cfg[\"assistant_api_key\"] = UserConfigManager.encode_sensitive(key) if key else \"\"\n cfg[\"assistant_model\"] = self._ai_model_combo.currentText()\n cfg[\"assistant_url\"] = self._ai_url_input.text().strip()\n UserConfigManager.save(cfg)\n\n def _reset_assistant_config(self):\n \"\"\"恢复 AI 助手使用翻译服务配置\"\"\"\n cfg = UserConfigManager.load()\n for k in (\"assistant_custom\", \"assistant_service\", \"assistant_api_key\",\n \"assistant_model\", \"assistant_url\"):\n cfg.pop(k, None)\n UserConfigManager.save(cfg)\n self._ai_custom_frame.setVisible(False)\n self._ai_status_label.setText(\"与翻译服务相同\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['ok']};background:transparent;\")\n self._ai_toggle_btn.setVisible(True)\n # 清空输入\n self._ai_key_input.clear()\n self._ai_model_combo.setCurrentIndex(0)\n self._ai_url_input.clear()\n\n def _install_zotero_plugin(self):\n \"\"\"一键安装 pdf2zh Connector 到 Zotero\"\"\"\n import glob, shutil, subprocess\n xpi = _res('assets', 'pdf2zh-connector.xpi')\n if not os.path.exists(xpi):\n self._zot_status.setText(\"插件文件缺失\")\n self._zot_status.setStyleSheet(\"color:#FF3B30;\")\n return\n # 查找 Zotero profile 目录\n profiles_dir = os.path.expanduser(\"~/Library/Application Support/Zotero/Profiles\")\n profiles = glob.glob(os.path.join(profiles_dir, \"*.default*\"))\n if not profiles:\n self._zot_status.setText(\"找不到 Zotero 配置目录\")\n self._zot_status.setStyleSheet(\"color:#FF3B30;\")\n return\n profile = profiles[0]\n # 1. 复制 XPI 到 extensions 目录\n ext_dir = os.path.join(profile, \"extensions\")\n os.makedirs(ext_dir, exist_ok=True)\n dst = os.path.join(ext_dir, \"pdf2zh-connector@aarongig.com.xpi\")\n shutil.copy2(xpi, dst)\n # 2. 设置 autoDisableScopes=0 允许 profile 级别的扩展自动加载\n # v2.3.2 (对齐 issue #26 修复): 改 prefs.js 前先备份 + 出错时 rollback,\n # 保证不会比之前效果差。同时备份 extensions.json 作为 catalog 出问题时的兜底\n prefs_path = os.path.join(profile, \"prefs.js\")\n pref_line = 'user_pref(\"extensions.autoDisableScopes\", 0);\\n'\n # 备份 extensions.json (即使 Mac 版不动它,兜底给用户手动 rollback)\n ext_json_path = os.path.join(profile, \"extensions.json\")\n if os.path.exists(ext_json_path):\n try:\n shutil.copy2(ext_json_path, ext_json_path + \".pdf2zh-backup\")\n except Exception:\n pass\n try:\n prefs_content = open(prefs_path, 'r', encoding='utf-8').read()\n if 'autoDisableScopes' not in prefs_content:\n # 先备份再改\n try:\n shutil.copy2(prefs_path, prefs_path + \".pdf2zh-backup\")\n except Exception:\n pass\n with open(prefs_path, 'a', encoding='utf-8') as f:\n f.write(pref_line)\n except FileNotFoundError:\n with open(prefs_path, 'w', encoding='utf-8') as f:\n f.write(pref_line)\n # 3. 清除启动缓存,强制 Zotero 重新扫描扩展\n cache = os.path.join(profile, \"addonStartup.json.lz4\")\n if os.path.exists(cache):\n os.remove(cache)\n # 询问是否重启 Zotero\n reply = QMessageBox.question(\n self, \"安装成功\",\n \"pdf2zh Connector 已部署到 Zotero,重启 Zotero 后生效。\\n\\n现在重启 Zotero?\",\n QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes\n )\n if reply == QMessageBox.Yes:\n subprocess.run([\"pkill\", \"-x\", \"zotero\"], capture_output=True)\n subprocess.run([\"pkill\", \"-x\", \"Zotero\"], capture_output=True)\n QTimer.singleShot(2000, lambda: subprocess.Popen([\"open\", \"-a\", \"Zotero\"]))\n QTimer.singleShot(10000, self._check_zotero_plugin)\n self._zot_status.setText(\"正在重启 Zotero…\")\n self._zot_status.setStyleSheet(\"color:#0071E3;\")\n else:\n self._zot_status.setText(\"下次启动 Zotero 后生效\")\n self._zot_status.setStyleSheet(\"color:#0071E3;\")\n\n def _check_zotero_plugin(self):\n \"\"\"检测 pdf2zh Connector 插件状态\"\"\"\n if zotero_plugin_installed():\n self._zot_status.setText(\"已安装\")\n self._zot_status.setStyleSheet(\"color:#34C759;font-weight:600;\")\n self._zot_install_btn.hide()\n else:\n self._zot_status.setText(\"未安装\")\n self._zot_install_btn.setText(\"一键安装\")\n self._zot_install_btn.show()\n self._zot_install_btn.setEnabled(True)\n\n # ── 术语库操作 ──\n\n def _add_gloss_term(self):\n \"\"\"内联添加一条术语\"\"\"\n src = self._gloss_src.text().strip()\n dst = self._gloss_dst.text().strip()\n if not src or not dst:\n return\n from ui.glossary_manager import GlossaryManager\n GlossaryManager.add_term(src, dst)\n self._gloss_src.clear(); self._gloss_dst.clear()\n self._update_gloss_count()\n\n def _refresh_gloss_list(self):\n from ui.glossary_manager import GlossaryManager\n self.gloss_selector.blockSignals(True)\n cur = self.gloss_selector.currentText()\n self.gloss_selector.clear()\n all_g = GlossaryManager.load_all_presets()\n self.gloss_selector.addItems(list(all_g.keys()))\n idx = self.gloss_selector.findText(cur)\n if idx >= 0: self.gloss_selector.setCurrentIndex(idx)\n self.gloss_selector.blockSignals(False)\n\n def _on_gloss_changed(self, name):\n from ui.glossary_manager import GlossaryManager\n GlossaryManager.load_preset(name)\n self._update_gloss_count()\n\n def _update_gloss_count(self):\n from ui.glossary_manager import GlossaryManager\n n = GlossaryManager.count()\n self.gloss_count.setText(f\"{n} 条术语已激活\" if n else \"未加载术语\")\n\n def _new_glossary(self):\n \"\"\"新建自定义术语库\"\"\"\n from ui.glossary_manager import GlossaryManager\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建术语库\", \"术语库名称:\")\n if ok and name.strip():\n name = name.strip()\n # 在用户目录下创建空术语库文件\n import json\n from pathlib import Path\n from ui.config_manager import _config_dir\n user_dir = _config_dir() / \"glossaries\"\n user_dir.mkdir(exist_ok=True)\n fp = user_dir / f\"{name}.json\"\n fp.write_text(json.dumps({}, indent=2, ensure_ascii=False), encoding=\"utf-8\")\n self._refresh_gloss_list()\n idx = self.gloss_selector.findText(name)\n if idx >= 0:\n self.gloss_selector.setCurrentIndex(idx)\n\n def _delete_glossary(self):\n \"\"\"删除用户自定义术语库(不能删预设)\"\"\"\n from ui.glossary_manager import GlossaryManager, DEFAULT_GLOSSARIES\n from pathlib import Path\n name = self.gloss_selector.currentText()\n if name in DEFAULT_GLOSSARIES:\n return\n from ui.config_manager import _config_dir\n fp = _config_dir() / \"glossaries\" / f\"{name}.json\"\n if fp.exists():\n fp.unlink()\n self._refresh_gloss_list()\n self._update_gloss_count()\n\n def _import_glossary(self):\n from ui.glossary_manager import GlossaryManager\n path, _ = QFileDialog.getOpenFileName(self, \"导入术语库\", \"\", \"CSV (*.csv);;JSON (*.json)\")\n if path:\n if path.endswith('.csv'):\n GlossaryManager.import_csv(path)\n else:\n GlossaryManager.import_json(path)\n self._update_gloss_count()\n self._refresh_gloss_list()\n\n def _export_glossary(self):\n from ui.glossary_manager import GlossaryManager\n path, _ = QFileDialog.getSaveFileName(self, \"导出术语库\", \"glossary.csv\", \"CSV (*.csv);;JSON (*.json)\")\n if path:\n if path.endswith('.json'):\n GlossaryManager.export_json(path)\n else:\n GlossaryManager.export_csv(path)\n\n def update_theme(self, c):\n \"\"\"深色/浅色切换时更新内联样式\"\"\"\n self.prompt_edit.setStyleSheet(\n f\"font-size:11px;border-radius:6px;background:{c['inp']};color:{c['t1']};\"\n f\"border:1px solid {c['inp_b']};\"\n )\n # 刷新主题色行可见性(可能骰子刚解锁)\n cfg = UserConfigManager.load()\n self.theme_row.setVisible(cfg.get(\"theme_unlocked\", False))\n\n\n# ═══════════════════════════════════════════════════════════════\n# 关于页面\n# ═══════════════════════════════════════════════════════════════\n\nclass AboutPage(QWidget):\n def __init__(self):\n super().__init__()\n from PyQt5.QtWidgets import QGridLayout\n lo = QVBoxLayout(self); lo.setContentsMargins(32,20,32,20); lo.setSpacing(12)\n\n # 顶部:名称 + 版本 + GitHub\n top = QHBoxLayout(); top.setSpacing(8)\n ti = _EggLogo(\"📄\", 22)\n ti.setFixedSize(32, 32); ti.setAlignment(Qt.AlignCenter)\n top.addWidget(ti)\n tn = QPushButton(\"pdf2zh-desktop\"); tn.setObjectName(\"SBLink\")\n tn.setStyleSheet(\"font-size:18px;font-weight:700;padding:0;text-align:left;\")\n tn.setCursor(Qt.PointingHandCursor); tn.setFlat(True)\n tn.clicked.connect(lambda: webbrowser.open(\"https://github.com/AaronGIG/pdf2zh-desktop\"))\n top.addWidget(tn)\n tv = QLabel(\"v2.3.6\"); tv.setObjectName(\"Cap\"); top.addWidget(tv)\n tt = QLabel(\"macOS\"); tt.setObjectName(\"Tag\"); tt.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed); top.addWidget(tt)\n top.addStretch()\n gb = QPushButton(\"GitHub ↗\"); gb.setObjectName(\"Gh\"); gb.setCursor(Qt.PointingHandCursor)\n gb.clicked.connect(lambda: webbrowser.open(\"https://github.com/AaronGIG/pdf2zh-desktop\"))\n top.addWidget(gb)\n lo.addLayout(top)\n\n # 两列布局:左=介绍+增强,右=作者+支持者\n cols = QHBoxLayout(); cols.setSpacing(16)\n\n # ── 左列 ──\n left = QVBoxLayout(); left.setSpacing(6)\n\n dc = _card(); dl = QVBoxLayout(dc); dl.setContentsMargins(16,12,16,12); dl.setSpacing(4)\n dt = QLabel(\"开箱即用的学术 PDF 翻译工具\"); dt.setStyleSheet(\"font-size:12px;font-weight:600;\"); dl.addWidget(dt)\n dd = QLabel(\"保留排版 · 公式无损 · 批量任务 · 历史追踪 · 20+ 翻译服务\")\n dd.setObjectName(\"Cap\"); dd.setStyleSheet(\"font-size:11px;\"); dd.setWordWrap(True); dl.addWidget(dd)\n left.addWidget(dc)\n\n mc = _card(); ml = QVBoxLayout(mc); ml.setContentsMargins(16,10,16,10); ml.setSpacing(3)\n mt = QLabel(\"macOS 版增强\"); mt.setStyleSheet(\"font-size:12px;font-weight:600;\"); ml.addWidget(mt)\n for emoji, text in [\n (\"🎨\",\"原生设计 · 深色模式 · 自定义主题色\"),\n (\"👀\",\"Dual · Mono · Side by Side 预览\"),\n (\"📖\",\"历史即时预览 · 上下键浏览\"),\n (\"✂️\",\"智能分块 · 1000+页\"),\n (\"🖐\",\"触摸板缩放 · 页码跳转\"),\n (\"📝\",\"术语库 · 提示词模板\"),\n (\"🍀\",\"每日骰子 · 隐藏彩蛋\"),\n (\"💬\",\"全时段人文关怀 · 名言语录\"),\n (\"🚀\",\".app 双击启动 · Retina\"),\n ]:\n r = QHBoxLayout(); r.setSpacing(6)\n e = QLabel(emoji); e.setStyleSheet(\"font-size:13px;background:transparent;\"); e.setFixedWidth(18); r.addWidget(e)\n t2 = QLabel(text); t2.setObjectName(\"Cap\"); t2.setStyleSheet(\"font-size:11px;\"); r.addWidget(t2)\n ml.addLayout(r)\n ml.addWidget(_div())\n te = QLabel(\"基于 PDFMathTranslate (EMNLP 2025) · PyQt5 · PyMuPDF · OnnxRuntime\")\n te.setObjectName(\"Cap\"); te.setStyleSheet(\"font-size:10px;\"); te.setWordWrap(True)\n ml.addWidget(te)\n left.addWidget(mc)\n left.addStretch()\n\n cols.addLayout(left, 3)\n\n # ── 右列 ──\n right = QVBoxLayout(); right.setSpacing(6)\n\n # 作者\n ac = _card(); acl = QHBoxLayout(ac); acl.setContentsMargins(16,12,16,12); acl.setSpacing(10)\n author_av = QLabel()\n author_av.setFixedSize(36, 36)\n avatar_path = _res('assets', 'author_avatar.png')\n if os.path.exists(avatar_path):\n apx = QPixmap(avatar_path).scaled(72, 72, Qt.KeepAspectRatio, Qt.SmoothTransformation)\n apx.setDevicePixelRatio(2.0)\n author_av.setPixmap(apx)\n else:\n author_av.setText(\"艾\"); author_av.setAlignment(Qt.AlignCenter)\n author_av.setStyleSheet(\"background:#1a1a2e;border-radius:18px;color:white;font-size:16px;font-weight:700;\")\n acl.addWidget(author_av)\n ai = QVBoxLayout(); ai.setSpacing(0)\n an = QLabel(\"艾伦说\"); an.setStyleSheet(\"font-size:13px;font-weight:700;\"); ai.addWidget(an)\n xhs = QLabel(\"小红书: needsleeeeep\"); xhs.setObjectName(\"Cap\"); xhs.setStyleSheet(\"font-size:11px;\"); ai.addWidget(xhs)\n motto = QLabel(\"希望能做更有意义的事 · 专注交付生产级的垂直学术公共品 🍀\")\n motto.setObjectName(\"Cap\"); motto.setStyleSheet(\"font-size:10px;color:rgba(142,142,147,0.7);\"); ai.addWidget(motto)\n acl.addLayout(ai); acl.addStretch()\n xhs_btn = QPushButton(\"关注 ↗\"); xhs_btn.setObjectName(\"Gh\"); xhs_btn.setCursor(Qt.PointingHandCursor)\n xhs_btn.setStyleSheet(\"font-size:11px;\")\n xhs_btn.clicked.connect(lambda: webbrowser.open(\"https://www.xiaohongshu.com/user/profile/66c6fef7000000001d0315ef\"))\n acl.addWidget(xhs_btn)\n right.addWidget(ac)\n\n # 支持者头像墙\n cc = _card(); ccl = QVBoxLayout(cc); ccl.setContentsMargins(16,12,16,12); ccl.setSpacing(6)\n ct = QLabel(\"感谢小红书社区支持者 ♡\"); ct.setStyleSheet(\"font-size:11px;font-weight:600;\"); ccl.addWidget(ct)\n\n supporters = [\n \"星爷!\",\"卧\",\"Jun warrior\",\"幼儿园鹿小眸\",\"侠禅\",\n \"ol\",\"幼儿园老大\",\"MX\",\"贝斯特宋\",\"Catherine\",\n \"不哩不哩左门卫\",\"momo\",\"李善兰\",\"问道不求仙\",\"hml\",\n \"ThereisTherse\",\"人类与猫\",\"小红薯643C3625\",\"月栖竹\",\"风来\",\n \"Nick\",\"咕噜咕噜🍗\",\"雾散时分起\",\"大脸咪布爱吃鱼\",\"限定 momo\",\n \"麦兜\",\"锅的刚\",\"一颗冒泡的卤蛋\",\"我草莓招了\",\n \"去Nature整点论文\",\"Masker\",\n \"AI maker趣造\",\"帕克的创业日记\",\"思维汪汪\",\"橘座\",\"小白也想学编程\",\n \"宛风Vanfeng\",\"小宝の日常\",\"未来百科\",\"你们的万能小卓\",\"碎银几两\",\n \"创界AIzine\",\"全栈小5\",\"无敌霸王龙\",\"小艾同学\",\"一万块的快乐\",\n \"学习笔记\",\"三丰不是张\",\"丁一\",\"阿漫AIChat\",\"知命不惧\",\n \"脱离社畜体制\",\"一瓢清浅\",\"Crazyang\",\"逛逛GitHub\",\"AI视界AIGC\",\n \"极客梦想家\",\"沐沐子\",\"Topaz\",\"三不沾\",\"EchoAI\",\n \"阳台吹风\",\"Cursor实战派\",\"简单就好\",\"进击的小学生\",\"AI探索家\",\n \"科技小飞侠\",\"数字游民\",\"AI能量站\",\"工程师小灰\",\"AI淘金\",\n \"量子比特\",\"NeonCode\",\"尝试新事物的Cher\",\"有趣的灵魂不需要名字\",\n \"阿杰的编程日记\",\"小明同学\",\"码农翻身\",\"AI大航海\",\"月亮与六便士\",\n \"渡己\",\"数码宝贝\",\"悟空AI\",\"追风少年\",\"Tech小确幸\",\n \"半糖主义\",\"深夜程序员\",\"AI小天才\",\"知行合一\",\"星辰大海\",\n \"比特流\",\"指尖上的代码\",\"未来可期\",\"云端漫步\",\"AI引路人\",\n \"秋风扫落叶\",\"编程小王子\",\"日拱一卒\",\"逆风翻盘\",\"AI百宝箱\",\n \"程序猿日记\",\"数据炼金术\",\"量子纠缠\",\"无限可能\",\"风轻云淡\",\n \"代码人生\",\"AI学徒\",\"机器之心\",\"清风明月\",\"代码诗人\",\n \"零一万物\",\"浮生若梦\",\"技术宅\",\"AI造物主\",\"星河万里\",\n \"小蜗牛\",\"编程少女\",\"数据猎人\",\"逻辑大师\",\"AI前沿\",\n \"梦想家\",\"代码如诗\",\"量子跃迁\",\"技术探路者\",\"AI新青年\",\n \"Tomo\",\"小红薯63BEBA60\",\"Answer\",\n \"C01dSH\",\"小红薯60E9F048\",\"Hittagi\",\"雪儿\",\"デブ\",\n \"根本赢不了\",\"七一\",\"Somnia Flora\",\n ]\n\n grid = QGridLayout(); grid.setSpacing(3); grid.setContentsMargins(0,0,0,0)\n cols_n = 12\n for i, name in enumerate(supporters):\n avatar = QLabel(name[0])\n avatar.setFixedSize(26, 26); avatar.setAlignment(Qt.AlignCenter)\n h = (hash(name) * 137) % 360\n s = 50 + (hash(name) >> 8) % 20; l2 = 55 + (hash(name) >> 16) % 15\n avatar.setStyleSheet(f\"background:hsl({h},{s}%,{l2}%);border-radius:13px;font-size:9px;font-weight:600;color:white;\")\n avatar.setToolTip(name)\n grid.addWidget(avatar, i // cols_n, i % cols_n, Qt.AlignCenter)\n ccl.addLayout(grid)\n sub = QLabel(\"无限迭代,只为更好的服务您\")\n sub.setObjectName(\"Cap\"); sub.setAlignment(Qt.AlignCenter); sub.setStyleSheet(\"font-size:11px;\"); ccl.addWidget(sub)\n qq_group_btn = QPushButton(\"进入 pdf2zh 桌面版交流群\"); qq_group_btn.setObjectName(\"Gh\"); qq_group_btn.setCursor(Qt.PointingHandCursor)\n qq_group_btn.setStyleSheet(\"font-size:11px;\")\n def _show_qq_qr():\n QApplication.clipboard().setText(\"1094195179\")\n c = _C\n qr_dlg = QDialog(self)\n qr_dlg.setWindowTitle(\"加入交流群\")\n qr_dlg.setStyleSheet(f\"QDialog{{background:{c['bg']};}}\")\n qlo = QVBoxLayout(qr_dlg); qlo.setContentsMargins(0, 0, 0, 12); qlo.setSpacing(8)\n qr_label = QLabel()\n qr_label.setAlignment(Qt.AlignCenter)\n qr_path = None\n for candidate in [\n os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'assets', 'qq_group_qr.png'),\n os.path.join(getattr(sys, '_MEIPASS', ''), 'assets', 'qq_group_qr.png'),\n ]:\n if os.path.exists(candidate):\n qr_path = candidate\n break\n if qr_path:\n pix = QPixmap(qr_path)\n # setScaledContents 让图片自动填满 label,不裁切\n qr_label.setPixmap(pix)\n qr_label.setScaledContents(True)\n qr_label.setFixedSize(300, int(300 * pix.height() / pix.width()))\n qr_dlg.setFixedSize(300, int(300 * pix.height() / pix.width()) + 40)\n else:\n qr_label.setText(\"请用 QQ 搜索群号 1094195179\")\n qr_label.setStyleSheet(f\"font-size:13px;color:{c['t2']};padding:30px;\")\n qr_dlg.setFixedSize(300, 100)\n qlo.addWidget(qr_label)\n tip = QLabel(\"群号 1094195179 已复制到剪贴板\")\n tip.setAlignment(Qt.AlignCenter)\n tip.setStyleSheet(f\"font-size:11px;color:{c['acc']};\")\n qlo.addWidget(tip)\n qr_dlg.exec_()\n qq_group_btn.clicked.connect(_show_qq_qr)\n ccl.addWidget(qq_group_btn, 0, Qt.AlignCenter)\n right.addWidget(cc)\n right.addStretch()\n\n cols.addLayout(right, 7)\n\n lo.addLayout(cols)\n\n\n# ═══════════════════════════════════════════════════════════════\n# 主窗口\n# ═══════════════════════════════════════════════════════════════\n\n# ── 桌面宠物:小黑猫 ──────────────────────────────────────────\n\nclass _NekoCat(QWidget):\n \"\"\"会走来走去的小黑猫彩蛋 — 多种行为模式\"\"\"\n\n # 行为模式\n WALK, RUN, SIT, SLEEP = 0, 1, 2, 3\n\n def __init__(self, parent):\n super().__init__(parent)\n self.setFixedSize(48, 36)\n self._frame = 0\n self._dir = 1\n self._mode = self.WALK\n self._speed = 2\n self._sit_counter = 0\n self._step_timer = QTimer(self)\n self._step_timer.timeout.connect(self._on_step)\n self.setCursor(Qt.PointingHandCursor)\n self.hide()\n\n def start_walk(self):\n \"\"\"从窗口一侧走到另一侧\"\"\"\n parent = self.parentWidget()\n if not parent:\n return\n import random\n pw, ph = parent.width(), parent.height()\n self._dir = random.choice([1, -1])\n y = ph - 50\n if self._dir == 1:\n self._x = float(-self.width())\n self._x_end = float(pw + self.width())\n else:\n self._x = float(pw + self.width())\n self._x_end = float(-self.width())\n # 随机选行为\n roll = random.random()\n if roll < 0.60:\n self._mode = self.WALK; self._speed = 1.5\n elif roll < 0.80:\n self._mode = self.SIT; self._speed = 1.5\n # 走到中间附近时会停下来坐一会儿\n self._sit_x = pw * random.uniform(0.3, 0.7)\n self._sit_counter = 0\n else:\n self._mode = self.RUN; self._speed = 4\n self.move(int(self._x), y)\n self.show(); self.raise_()\n self._step_timer.start(40) # 25fps\n\n def _on_step(self):\n self._frame += 1\n # SIT 模式:到达坐下点后停下来\n if self._mode == self.SIT and self._sit_counter > 0:\n self._sit_counter -= 1\n if self._sit_counter == 0:\n self._mode = self.WALK # 坐完了继续走\n self.update()\n return\n if self._mode == self.SIT and \\\n abs(self._x - self._sit_x) < 5 and self._sit_counter == 0:\n self._sit_counter = 100 # 坐 4 秒\n self.update()\n return\n self._x += self._dir * self._speed\n self.move(int(self._x), self.y())\n self.update()\n if (self._dir == 1 and self._x > self._x_end) or \\\n (self._dir == -1 and self._x < self._x_end):\n self._step_timer.stop()\n self.hide()\n\n def paintEvent(self, e):\n p = QPainter(self)\n p.setRenderHint(QPainter.Antialiasing)\n p.setPen(Qt.NoPen)\n if self._dir < 0:\n p.translate(self.width(), 0)\n p.scale(-1, 1)\n cat = QColor(35, 35, 35)\n eye = QColor(180, 210, 50)\n is_sitting = (self._mode == self.SIT and self._sit_counter > 0)\n # ── 尾巴 ──\n p.setPen(QPen(cat, 3, Qt.SolidLine, Qt.RoundCap))\n p.setBrush(Qt.NoBrush)\n tail = QPainterPath()\n sway = 2 if (self._frame // 4) % 2 == 0 else -2\n if is_sitting:\n # 坐着:尾巴自然弯曲放在身后\n tail.moveTo(6, 22); tail.cubicTo(2, 20, -2, 16, 2, 12)\n else:\n tail.moveTo(8, 16); tail.cubicTo(4, 12 + sway, 2, 8 + sway, 5, 4)\n p.drawPath(tail)\n # ── 身体 ──\n p.setPen(Qt.NoPen); p.setBrush(cat)\n if is_sitting:\n p.drawEllipse(10, 12, 20, 18) # 坐姿:更圆\n else:\n p.drawEllipse(8, 14, 24, 14)\n # ── 头 ──\n if is_sitting:\n p.drawEllipse(22, 2, 17, 16)\n else:\n p.drawEllipse(25, 4, 16, 15)\n # ── 耳朵 ──\n hx = 22 if is_sitting else 25\n ear1 = QPainterPath()\n ear1.moveTo(hx+3, 4); ear1.lineTo(hx+5, -3); ear1.lineTo(hx+8, 3)\n ear1.closeSubpath(); p.drawPath(ear1)\n ear2 = QPainterPath()\n ear2.moveTo(hx+9, 2); ear2.lineTo(hx+12, -4); ear2.lineTo(hx+14, 2)\n ear2.closeSubpath(); p.drawPath(ear2)\n # ── 眼睛 ──\n ex = 27 if is_sitting else 30\n ey = 7 if is_sitting else 9\n p.setBrush(eye)\n if is_sitting and (self._frame // 30) % 3 == 0:\n # 坐着时偶尔眯眼\n p.setPen(QPen(eye, 2)); p.setBrush(Qt.NoBrush)\n p.drawLine(ex, ey+2, ex+4, ey+2)\n p.drawLine(ex+6, ey+2, ex+10, ey+2)\n p.setPen(Qt.NoPen)\n else:\n p.drawEllipse(ex, ey, 4, 4)\n p.drawEllipse(ex+6, ey, 4, 4)\n p.setBrush(QColor(10, 10, 10))\n p.drawEllipse(ex+1, ey+1, 2, 2)\n p.drawEllipse(ex+7, ey+1, 2, 2)\n # ── 腿 ──\n p.setPen(QPen(cat, 3, Qt.SolidLine, Qt.RoundCap))\n if is_sitting:\n # 坐姿:前腿伸直,后腿收起\n p.drawLine(18, 28, 16, 34)\n p.drawLine(24, 28, 26, 34)\n else:\n phase = self._frame % 4\n o = [(2,-2,-1,1),(-1,1,2,-2),(-2,2,1,-1),(1,-1,-2,2)][phase]\n p.drawLine(14, 27, 14+o[0], 34)\n p.drawLine(20, 27, 20+o[1], 34)\n p.drawLine(25, 27, 25+o[2], 34)\n p.drawLine(30, 27, 30+o[3], 34)\n p.end()\n\n def enterEvent(self, e):\n \"\"\"鼠标悬停 → 疯狂加速\"\"\"\n if self._step_timer.isActive():\n self._mode = self.RUN\n self._speed = 6\n self._sit_counter = 0\n self._step_timer.setInterval(15)\n\n def leaveEvent(self, e):\n \"\"\"鼠标离开 → 恢复正常速度\"\"\"\n if self._step_timer.isActive() and self._mode == self.RUN:\n self._speed = 1.5\n self._step_timer.setInterval(40)\n\n def mousePressEvent(self, e):\n \"\"\"单击 → 喵一声\"\"\"\n try:\n import subprocess\n subprocess.Popen([\"afplay\", \"/System/Library/Sounds/Pop.aiff\"])\n except Exception:\n pass\n\n def mouseDoubleClickEvent(self, e):\n \"\"\"双击 → 直接消失\"\"\"\n self._step_timer.stop()\n self.hide()\n\n\nclass MainWindow(QMainWindow):\n def __init__(self):\n super().__init__()\n self.is_dark = False\n self.setWindowTitle(\"\")\n self.setMinimumSize(900,600)\n self.setUnifiedTitleAndToolBarOnMac(True)\n # App 图标\n _icon_path = _res('assets', 'app_icon.png')\n if os.path.exists(_icon_path):\n self.setWindowIcon(QIcon(_icon_path))\n # 启动时占满屏幕 90%\n screen = QApplication.primaryScreen()\n if screen:\n geo = screen.availableGeometry()\n w = int(geo.width() * 0.92)\n h = int(geo.height() * 0.90)\n self.resize(w, h)\n self.move(geo.x() + (geo.width() - w) // 2,\n geo.y() + (geo.height() - h) // 2)\n\n central = QWidget(); central.setObjectName(\"Central\"); self.setCentralWidget(central)\n root = QHBoxLayout(central); root.setContentsMargins(0,0,0,0); root.setSpacing(0)\n\n # 侧边栏\n sb = QWidget(); sb.setObjectName(\"Sidebar\"); sb.setFixedWidth(220)\n self.sidebar = sb\n sbl = QVBoxLayout(sb); sbl.setContentsMargins(16,24,16,16); sbl.setSpacing(4)\n\n # Logo: pdf2zh/desktop — 📄 彩蛋 + 可点击标题\n logo_row = QHBoxLayout(); logo_row.setSpacing(10); logo_row.setContentsMargins(4,0,0,0)\n logo_icon = _EggLogo(\"📄\", 28)\n logo_icon.setFixedSize(36, 36); logo_icon.setAlignment(Qt.AlignCenter)\n logo_row.addWidget(logo_icon)\n logo_text = QVBoxLayout(); logo_text.setSpacing(0)\n logo_name = QPushButton(\"pdf2zh-desktop\"); logo_name.setObjectName(\"SBLink\")\n logo_name.setStyleSheet(\"font-size:15px;font-weight:700;letter-spacing:-0.2px;padding:0;text-align:left;\")\n logo_name.setCursor(Qt.PointingHandCursor); logo_name.setFlat(True)\n logo_name.clicked.connect(lambda: webbrowser.open(\"https://github.com/AaronGIG/pdf2zh-desktop\"))\n logo_sub = QLabel(\"macOS Edition\"); logo_sub.setObjectName(\"Cap\")\n logo_text.addWidget(logo_name); logo_text.addWidget(logo_sub)\n logo_row.addLayout(logo_text); logo_row.addStretch()\n sbl.addLayout(logo_row)\n sbl.addSpacing(16); sbl.addWidget(_div()); sbl.addSpacing(12)\n\n self.nav = []\n for icon, label in [(\"🌐\",\"翻译\"),(\"📖\",\"阅读\"),(\"🔧\",\"设置\"),(\"💡\",\"关于\")]:\n b = SB(icon, label)\n b.clicked.connect(lambda l=label: self.switch(l))\n sbl.addWidget(b); self.nav.append((label, b))\n sbl.addStretch()\n\n vl = QLabel(\"v2.3.6 · macOS\"); vl.setObjectName(\"Cap\"); vl.setAlignment(Qt.AlignCenter)\n vl.setStyleSheet(\"font-size:10px;\")\n sbl.addWidget(vl)\n # 底部链接 — 独立按钮,支持 hover 变色\n import webbrowser\n link_row = QHBoxLayout(); link_row.setSpacing(0)\n link_row.setContentsMargins(8,0,8,4); link_row.addStretch()\n for text, url in [\n (\"GitHub\", \"https://github.com/AaronGIG/pdf2zh-desktop\"),\n (\"小红书\", \"https://www.xiaohongshu.com/user/profile/66c6fef7000000001d0315ef\"),\n (\"Feedback\", \"https://github.com/AaronGIG/pdf2zh-desktop/issues\"),\n (\"Star ⭐\", \"https://github.com/AaronGIG/pdf2zh-desktop\"),\n ]:\n lb = QPushButton(text); lb.setObjectName(\"SBLink\"); lb.setCursor(Qt.PointingHandCursor)\n lb.clicked.connect(lambda _, u=url: webbrowser.open(u))\n link_row.addWidget(lb)\n if text != \"Star ⭐\":\n sep = QLabel(\"·\"); sep.setObjectName(\"SBSep\")\n link_row.addWidget(sep)\n link_row.addStretch()\n sbl.addLayout(link_row)\n root.addWidget(sb)\n\n # 内容区\n self.stack = QStackedWidget()\n self.pages = {}\n tp = TranslatePage(); rp = ReaderPage()\n sp = SettingsPage(); ap = AboutPage()\n for name, page in [(\"翻译\",tp),(\"阅读\",rp),(\"设置\",sp),(\"关于\",ap)]:\n self.stack.addWidget(page); self.pages[name] = page\n root.addWidget(self.stack)\n\n # 信号连接\n sp.dark_mode_changed.connect(self._toggle_dark)\n sp.theme_color_changed.connect(self._set_accent)\n tp.translation_done.connect(self._on_translate_done)\n rp.fullscreen_changed.connect(self._on_reader_fullscreen)\n\n # 恢复上次窗口状态\n cfg = UserConfigManager.load()\n self.switch(\"翻译\"); self._apply()\n _install_tip_filter(QApplication.instance())\n # 去掉所有控件的 macOS 聚焦环(小点边框)\n for w in self.findChildren(QWidget):\n w.setAttribute(Qt.WA_MacShowFocusRect, False)\n\n # 恢复窗口尺寸和位置\n if cfg.get(\"window_geometry\"):\n try:\n from PyQt5.QtCore import QByteArray\n geo = QByteArray.fromHex(cfg[\"window_geometry\"].encode())\n self.restoreGeometry(geo)\n except Exception:\n pass\n\n # 静默预加载阅读页:用户点击时已渲染好,无闪烁\n QTimer.singleShot(100, self._preload_reader)\n\n # ── 小黑猫彩蛋 ──\n self._neko = _NekoCat(self)\n self._neko_timer = QTimer(self)\n self._neko_timer.timeout.connect(self._maybe_spawn_cat)\n self._neko_timer.start(1_200_000) # 每 20 分钟出来一次\n QTimer.singleShot(3_000, lambda: self._neko.start_walk()) # 启动 3 秒出来\n\n # ── 凌晨 3:30 彩蛋:披星戴月 ──\n from datetime import datetime\n now = datetime.now()\n if now.hour == 3 and 25 <= now.minute <= 35:\n QTimer.singleShot(800, self._midnight_bloom)\n\n # ─────────────────────────────────────────────\n # 凌晨 3:30 彩蛋 — 烟花 + 暖心寄语\n # ─────────────────────────────────────────────\n def _maybe_spawn_cat(self):\n \"\"\"每 20 分钟让小黑猫出来走一趟\"\"\"\n if self._neko.isVisible():\n return\n self._neko.start_walk()\n\n def _midnight_bloom(self):\n \"\"\"全屏烟花 10 秒 → 暖心寄语浮层\"\"\"\n import random, math\n from PyQt5.QtCore import QPropertyAnimation, QPoint, QEasingCurve\n\n fireworks = [\"🎆\",\"🎇\",\"✨\",\"💫\",\"⭐\",\"🌟\",\"🌠\",\"💛\",\"🧡\",\"💜\",\"💙\",\"🤍\"]\n self._bloom_anims = [] # prevent GC\n\n def _burst():\n \"\"\"一轮烟花:从随机位置爆开\"\"\"\n cx = random.randint(int(self.width() * 0.15), int(self.width() * 0.85))\n cy = random.randint(int(self.height() * 0.15), int(self.height() * 0.55))\n center = QPoint(cx, cy)\n for _ in range(random.randint(12, 20)):\n lbl = QLabel(random.choice(fireworks), self)\n lbl.setStyleSheet(f\"font-size:{random.randint(16, 36)}px;background:transparent;\")\n lbl.move(center); lbl.show(); lbl.raise_()\n angle = random.uniform(0, 2 * math.pi)\n dist = random.randint(80, 300)\n end = QPoint(cx + int(math.cos(angle) * dist),\n cy + int(math.sin(angle) * dist))\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(900, 1800))\n anim.setStartValue(center); anim.setEndValue(end)\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start()\n self._bloom_anims.append(anim)\n\n # 10 秒内间歇放烟花(每 400–700ms 一轮)\n total_bursts = 18\n for i in range(total_bursts):\n delay = int(i * 550 + random.randint(0, 150))\n QTimer.singleShot(delay, _burst)\n\n # 10 秒后显示暖心寄语\n QTimer.singleShot(10500, self._midnight_message)\n\n def _midnight_message(self):\n \"\"\"暖心寄语浮层\"\"\"\n import random\n messages = [\n (\"披星戴月的你,终将凯旋。\",\n \"但现在,请先好好休息。\\n\"\n \"没有什么比你的健康更重要了。\\n\"\n \"科研之外,还有很多美好的事物在等着你。\"),\n (\"凌晨三点半的你,一定很努力。\",\n \"但再重要的事,也比不上你自己。\\n\"\n \"把未完成的留给明天精力充沛的自己吧。\\n\"\n \"这个世界需要健康的你。\"),\n (\"夜深了,星星都在为你亮着。\",\n \"你已经比大多数人都努力了。\\n\"\n \"允许自己休息,不是放弃,是为了走更远的路。\\n\"\n \"照顾好自己,才能照顾好你在乎的一切。\"),\n (\"这个时间还在学习的你,了不起。\",\n \"但了不起的人更要爱惜自己。\\n\"\n \"身体是一切的根基,请好好善待它。\\n\"\n \"早点睡,明天的你会感谢今晚的决定。\"),\n (\"深夜的坚持让人敬佩。\",\n \"但真正的强者,懂得在该休息时放下。\\n\"\n \"你的才华不会因为一晚的休息而消失。\\n\"\n \"去睡吧,梦里什么都有。\"),\n ]\n title, body = random.choice(messages)\n\n # 半透明遮罩 + 居中卡片\n overlay = QWidget(self)\n overlay.setGeometry(self.rect())\n overlay.setStyleSheet(\"background:rgba(0,0,0,0.55);\")\n overlay.show(); overlay.raise_()\n\n card = QFrame(overlay)\n card.setStyleSheet(\n \"QFrame{background:rgba(255,255,255,0.95);border-radius:20px;}\"\n if not self.is_dark else\n \"QFrame{background:rgba(40,40,45,0.95);border-radius:20px;}\"\n )\n card.setFixedSize(420, 280)\n card.move((self.width() - 420) // 2, (self.height() - 280) // 2)\n card.show()\n\n cl = QVBoxLayout(card); cl.setContentsMargins(32, 28, 32, 24); cl.setSpacing(12)\n\n moon = QLabel(\"🌙\"); moon.setStyleSheet(\"font-size:36px;background:transparent;\")\n moon.setAlignment(Qt.AlignCenter); cl.addWidget(moon)\n\n tc = \"color:#1d1d1f;\" if not self.is_dark else \"color:#f5f5f7;\"\n tl = QLabel(title)\n tl.setStyleSheet(f\"font-size:18px;font-weight:700;{tc}background:transparent;\")\n tl.setAlignment(Qt.AlignCenter); tl.setWordWrap(True); cl.addWidget(tl)\n\n bl = QLabel(body)\n bl.setStyleSheet(f\"font-size:13px;line-height:1.6;{tc}background:transparent;\")\n bl.setAlignment(Qt.AlignCenter); bl.setWordWrap(True); cl.addWidget(bl)\n\n cl.addStretch()\n close_btn = QPushButton(\"晚安,去休息了 🌙\")\n close_btn.setObjectName(\"Pr\"); close_btn.setCursor(Qt.PointingHandCursor)\n close_btn.setStyleSheet(\"font-size:14px;padding:10px 24px;\")\n close_btn.clicked.connect(overlay.deleteLater)\n cl.addWidget(close_btn, alignment=Qt.AlignCenter)\n\n # 点击遮罩也可关闭\n overlay.mousePressEvent = lambda e: overlay.deleteLater()\n\n def switch(self, label):\n # 预加载中 → 延迟切换(避免冲突)\n if getattr(self, '_preloading', False):\n QTimer.singleShot(50, lambda: self.switch(label))\n return\n keys = list(self.pages.keys())\n idx = keys.index(label) if label in keys else 0\n # 阅读页:先刷新列表,再显示(避免 showEvent 用旧数据加载后又被 refresh 清掉)\n if label == \"阅读\":\n self.pages[\"阅读\"].refresh()\n self.stack.setCurrentIndex(idx)\n for n, b in self.nav: b.set_active(n == label)\n\n def _preload_reader(self):\n \"\"\"静默预加载阅读页:冻结画面 → 切到阅读页获取真实尺寸 → 渲染 → 切回\"\"\"\n self._preloading = True\n rp = self.pages[\"阅读\"]\n rp.refresh()\n if rp.list_w.count() == 0:\n self._preloading = False\n return\n # 冻结画面(布局仍正常计算,只是不绘制)\n self.setUpdatesEnabled(False)\n self.stack.setCurrentIndex(list(self.pages.keys()).index(\"阅读\"))\n # 下一帧:布局已稳定,加载 + 渲染\n QTimer.singleShot(0, self._preload_step2)\n\n def _preload_step2(self):\n rp = self.pages[\"阅读\"]\n rp.list_w.setCurrentRow(0) # → _on_select → load_pdf\n # 再等一帧让 splitter 稳定\n QTimer.singleShot(30, self._preload_step3)\n\n def _preload_step3(self):\n rp = self.pages[\"阅读\"]\n rp.preview._last_fit_zoom = -1\n rp.preview._fit_and_render()\n # 切回翻译页,解冻\n self.stack.setCurrentIndex(0)\n self.setUpdatesEnabled(True)\n self._preloading = False\n self.repaint()\n\n def _toggle_dark(self, dark):\n self.is_dark = dark\n # 淡入淡出过渡\n from PyQt5.QtCore import QPropertyAnimation\n anim = QPropertyAnimation(self, b\"windowOpacity\")\n anim.setDuration(150)\n anim.setStartValue(1.0); anim.setEndValue(0.85)\n anim.finished.connect(lambda: self._finish_theme_switch(anim))\n self._theme_anim = anim # prevent GC\n anim.start()\n\n def _finish_theme_switch(self, prev_anim):\n self._apply()\n from PyQt5.QtCore import QPropertyAnimation\n anim = QPropertyAnimation(self, b\"windowOpacity\")\n anim.setDuration(200)\n anim.setStartValue(0.85); anim.setEndValue(1.0)\n self._theme_anim = anim\n anim.start()\n\n def _set_accent(self, hex_color):\n \"\"\"应用自定义主题色\"\"\"\n self._custom_accent = hex_color\n self._apply()\n\n def _apply(self):\n global _C\n c = dict(D if self.is_dark else L) # 拷贝,避免修改原始\n # 应用自定义主题色(需已解锁)\n cfg = UserConfigManager.load()\n acc = getattr(self, '_custom_accent', None) or cfg.get(\"theme_color\")\n if acc and cfg.get(\"theme_unlocked\"):\n r, g, b = int(acc[1:3],16), int(acc[3:5],16), int(acc[5:7],16)\n c[\"acc\"] = acc; c[\"acc_h\"] = acc; c[\"acc_p\"] = acc\n c[\"acc_l\"] = f\"rgba({r},{g},{b},0.12)\"\n c[\"acc_g\"] = f\"qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 {acc},stop:1 {acc})\"\n c[\"sb_act\"] = f\"rgba({r},{g},{b},0.15)\"\n c[\"dz_b\"] = f\"rgba({r},{g},{b},0.25)\"\n c[\"tag_bg\"] = f\"rgba({r},{g},{b},0.10)\"; c[\"tag_fg\"] = acc\n c[\"pv_l_bg\"] = f\"rgba({r},{g},{b},0.10)\"; c[\"pv_l_fg\"] = acc\n c[\"link_h\"] = acc\n _C = c\n self.setStyleSheet(S(c))\n # 强制设置 QPalette,修复内联 setStyleSheet 覆盖问题\n from PyQt5.QtGui import QPalette\n pal = self.palette()\n pal.setColor(QPalette.WindowText, QColor(c[\"t1\"]))\n pal.setColor(QPalette.Text, QColor(c[\"t1\"]))\n pal.setColor(QPalette.ButtonText, QColor(c[\"t1\"]))\n pal.setColor(QPalette.PlaceholderText, QColor(c[\"t3\"]))\n pal.setColor(QPalette.Base, QColor(c[\"inp\"]))\n pal.setColor(QPalette.Window, QColor(c[\"bg\"]))\n pal.setColor(QPalette.AlternateBase, QColor(c[\"bg2\"]))\n pal.setColor(QPalette.ToolTipBase, QColor(c[\"elev\"]))\n pal.setColor(QPalette.ToolTipText, QColor(c[\"t1\"]))\n pal.setColor(QPalette.Highlight, QColor(c[\"acc\"]))\n pal.setColor(QPalette.HighlightedText, QColor(\"white\"))\n self.setPalette(pal)\n QApplication.instance().setPalette(pal)\n # 通知子页面更新内联样式\n for page in self.pages.values():\n if hasattr(page, 'update_theme'):\n page.update_theme(c)\n\n def _on_reader_fullscreen(self, fs):\n \"\"\"全屏阅读:隐藏/显示侧边栏\"\"\"\n self.sidebar.setVisible(not fs)\n\n def closeEvent(self, event):\n \"\"\"保存窗口几何尺寸和当前页面\"\"\"\n cfg = UserConfigManager.load()\n cfg[\"window_geometry\"] = bytes(self.saveGeometry().toHex()).decode()\n # 保存当前页面\n for name, page in self.pages.items():\n if self.stack.currentWidget() is page:\n cfg[\"last_page\"] = name; break\n UserConfigManager.save(cfg)\n event.accept()\n\n def _on_translate_done(self, output_files):\n reader = self.pages[\"阅读\"]\n reader.set_output_files(output_files)\n QTimer.singleShot(600, lambda: self.switch(\"阅读\"))\n # 翻译完成后把窗口提到前台(用户可能切走了)\n QTimer.singleShot(700, lambda: (self.raise_(), self.activateWindow()))\n\n\ndef _dbg_write(msg):\n \"\"\"v2.3.2 debug: 写到 /tmp/pdf2zh-cli-debug.log + stderr,双保险\"\"\"\n try:\n from datetime import datetime as _dt\n line = f\"[{_dt.now().strftime('%H:%M:%S.%f')[:-3]}] {msg}\\n\"\n try:\n fd = os.open(\"/tmp/pdf2zh-cli-debug.log\", os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o644)\n os.write(fd, line.encode(\"utf-8\", errors=\"replace\"))\n os.close(fd)\n except Exception:\n pass\n try:\n sys.stderr.write(line)\n sys.stderr.flush()\n except Exception:\n pass\n except Exception:\n pass\n\n\ndef _parse_cli_args(argv):\n \"\"\"解析命令行参数 (向后兼容 — 老 pdf2zh xxx.pdf 依然工作)\n 支持:\n pdf2zh file.pdf ← 旧行为\n pdf2zh --format=side_by_side --auto file.pdf ← 新行为(Zotero 右键唤起)\n pdf2zh --format=dual file.pdf\n 返回 dict: {file: str|None, format: str|None, auto: bool}\n \"\"\"\n result = {\"file\": None, \"format\": None, \"auto\": False, \"silent\": False}\n try:\n for arg in argv[1:]:\n if arg.startswith(\"--format=\"):\n fmt = arg.split(\"=\", 1)[1].strip()\n if fmt in (\"mono\", \"dual\", \"side_by_side\", \"all\"):\n result[\"format\"] = fmt\n elif arg == \"--auto\":\n result[\"auto\"] = True\n elif arg == \"--silent\":\n result[\"silent\"] = True\n elif arg.lower().endswith(\".pdf\") and os.path.isfile(arg):\n result[\"file\"] = arg\n except Exception:\n pass\n return result\n\n\nclass Pdf2zhApp(QApplication):\n \"\"\"单实例 QApplication:第二个实例把文件+参数发给第一个实例后退出\"\"\"\n file_opened = pyqtSignal(dict) # {file, format, auto}\n _SERVER_NAME = \"com.aarongig.pdf2zh.single\"\n\n def __init__(self, argv):\n # v2.3.2: 最早的 debug log 用 sys.stderr + /tmp 双保险\n _dbg_write(f\"Pdf2zhApp.__init__ START argv[{len(argv)}]={argv!r}\")\n super().__init__(argv)\n _dbg_write(\"Pdf2zhApp.__init__ super() done\")\n from PyQt5.QtNetwork import QLocalServer, QLocalSocket\n self._pending_file = None\n\n # 解析命令行参数\n cli = _parse_cli_args(argv)\n _dbg_write(f\"Pdf2zhApp.__init__ cli parsed = {cli!r}\")\n\n # 尝试连接已有实例\n sock = QLocalSocket()\n sock.connectToServer(self._SERVER_NAME)\n if sock.waitForConnected(500):\n # 已有实例在运行 → 把 payload 发过去,然后退出\n if cli[\"file\"]:\n import json as _json\n payload = _json.dumps(cli).encode(\"utf-8\")\n sock.write(payload)\n sock.waitForBytesWritten(1000)\n sock.disconnectFromServer()\n sys.exit(0)\n\n # 我是第一个实例 → 启动 server 监听\n QLocalServer.removeServer(self._SERVER_NAME)\n self._server = QLocalServer()\n self._server.listen(self._SERVER_NAME)\n self._server.newConnection.connect(self._on_new_connection)\n\n # 记录启动时命令行参数(有文件的话 main() 会用到)\n self._launch_cli = cli\n\n def _on_new_connection(self):\n conn = self._server.nextPendingConnection()\n if conn:\n conn.waitForReadyRead(1000)\n data = conn.readAll().data().decode(\"utf-8\", errors=\"ignore\")\n conn.close()\n # 兼容旧格式(纯文件路径)+ 新格式(JSON payload)\n payload = None\n if data.startswith(\"{\"):\n try:\n import json as _json\n payload = _json.loads(data)\n except Exception:\n pass\n if payload is None and data.lower().endswith(\".pdf\"):\n payload = {\"file\": data, \"format\": None, \"auto\": False}\n if payload and payload.get(\"file\"):\n self.file_opened.emit(payload)\n\n def event(self, e):\n # v2.3.2 修 bug: FileOpen event 收到路径时,之前 emit(str) 与 signal dict 定义不匹配 → 静默丢失\n if e.type() == e.FileOpen:\n path = e.file()\n _dbg_write(f\"Pdf2zhApp.event FileOpen path={path!r}\")\n if path and path.lower().endswith('.pdf'):\n # 用 CLI 里保存的 format/auto 参数(如果启动时带了)\n cli = getattr(self, '_launch_cli', {}) or {}\n payload = {\n \"file\": path,\n \"format\": cli.get(\"format\"),\n \"auto\": cli.get(\"auto\", False),\n \"silent\": cli.get(\"silent\", False),\n }\n self.file_opened.emit(payload)\n return True\n return super().event(e)\n\n\ndef main():\n _dbg_write(\"main() entered\")\n QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)\n QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)\n _dbg_write(\"main() creating Pdf2zhApp\")\n app = Pdf2zhApp(sys.argv); app.setStyle(\"Fusion\")\n _dbg_write(\"main() Pdf2zhApp created, creating MainWindow\")\n try:\n w = MainWindow()\n _dbg_write(f\"main() MainWindow created type={type(w).__name__}\")\n except Exception as _mw_e:\n import traceback as _tb\n _dbg_write(f\"main() MainWindow FAILED: {_mw_e}\\n{_tb.format_exc()}\")\n raise\n _dbg_write(\"main() calling w.show()\")\n w.show()\n _dbg_write(f\"main() after show(), isVisible={w.isVisible()} geom={w.geometry().getRect()}\")\n\n # v2.3.2: debug log 到磁盘方便排查 CLI 唤起失败问题\n _dbg_log = os.path.expanduser(\"~/.config/pdf2zh/cli-debug.log\")\n\n def _dbg(msg):\n try:\n os.makedirs(os.path.dirname(_dbg_log), exist_ok=True)\n with open(_dbg_log, \"a\", encoding=\"utf-8\") as f:\n from datetime import datetime as _dt\n f.write(f\"[{_dt.now().strftime('%H:%M:%S')}] {msg}\\n\")\n except Exception:\n pass\n\n # 处理文件 + 可选参数(format / auto)\n def _handle_payload(payload):\n \"\"\"payload = {file, format, auto}\"\"\"\n _dbg_write(f\"_handle_payload called payload={payload}\")\n try:\n pages_keys = list(w.pages.keys()) if hasattr(w, \"pages\") else \"no pages\"\n _dbg_write(f\" pages_keys={pages_keys}\")\n tp = w.pages.get(\"翻译\")\n _dbg_write(f\" tp={tp}\")\n if not tp:\n _dbg_write(\" ✗ tp is None, giving up\")\n return\n file_path = payload.get(\"file\")\n _dbg_write(f\" file_path={file_path!r}\")\n if file_path:\n exists = os.path.isfile(file_path)\n _dbg_write(f\" isfile={exists}\")\n tp.on_files_added([file_path])\n _dbg_write(f\" after on_files_added, flist count={tp.flist.count()}\")\n w.switch(\"翻译\")\n # v2.3.4: 后台静默模式 —— 最小化不抢焦点, 完成后自动关闭(见 _on_batch_done)\n _silent = bool(payload.get(\"silent\"))\n w._cli_silent = _silent\n if _silent:\n _dbg_write(\" silent mode: showMinimized\")\n try: w.showMinimized()\n except Exception: pass\n else:\n # v2.3.4: showNormal 确保上次静默 close() 隐藏的窗口能重现(仅 raise_ 不足以显示隐藏窗口)\n w.showNormal(); w.raise_(); w.activateWindow()\n\n fmt = payload.get(\"format\")\n if fmt:\n tp._cli_format = fmt\n _dbg_write(f\" set _cli_format={fmt}\")\n\n if payload.get(\"auto\"):\n _dbg_write(f\" scheduling _start() in 800ms\")\n QTimer.singleShot(800, lambda: (_dbg_write(\" calling _start\"), tp._start()) if hasattr(tp, \"_start\") else _dbg_write(\" ✗ no _start\"))\n except Exception as e:\n import traceback\n _dbg_write(f\" ✗ EXCEPTION: {e}\\n{traceback.format_exc()}\")\n\n app.file_opened.connect(_handle_payload)\n\n # 启动时如果就带了文件参数(首次启动直接从 Zotero 唤起),也处理\n if hasattr(app, \"_launch_cli\") and app._launch_cli.get(\"file\"):\n QTimer.singleShot(300, lambda: _handle_payload(app._launch_cli))\n\n sys.exit(app.exec_())\n\n\ndef _early_log(tag):\n \"\"\"v2.3.2: 最早期日志,绕过所有 helper(即使 datetime import 失败也能写)\"\"\"\n try:\n import os as _os, sys as _sys, time as _time\n line = f\"[{_time.strftime('%H:%M:%S')}] {tag} name={__name__!r} argv={_sys.argv!r}\\n\"\n fd = _os.open(\"/tmp/pdf2zh-early.log\", _os.O_WRONLY | _os.O_APPEND | _os.O_CREAT, 0o644)\n _os.write(fd, line.encode(\"utf-8\", errors=\"replace\"))\n _os.close(fd)\n try:\n _sys.stderr.write(line); _sys.stderr.flush()\n except Exception:\n pass\n except Exception:\n pass\n\n\n# v2.3.2: __name__ 会是 \"__main__\"(终端启动)或 \"ui.main_window\"(frozen 打包)\n_early_log(\"module top-level import\")\n\nif __name__ == \"__main__\":\n _early_log(\"__main__ before main()\")\n main()\nelse:\n # PyInstaller frozen 可能在别处启动 main(),但为了保险这里也 log 一下\n _early_log(f\"__name__ != __main__ (={__name__})\")\n"}}, "mac/ui/main_window.py": {"ead24a4b52b86c4f7b59958f51c5d72d2641064a": {"t": 1785809869.9914129, "n": 240558, "h": "7a1cff03a1392984", "c": "\"\"\"\npdf2zh for Mac — 完整版主窗口\n全功能适配: 三种预览模式 / 分块翻译 / 页码范围 / 历史记录 / 20+翻译服务\n\"\"\"\n\nimport sys\nimport os\nimport json\nimport webbrowser\nimport time\nfrom pathlib import Path\nimport fitz\n\n\ndef _res(*parts):\n \"\"\"获取资源文件路径,兼容 PyInstaller 打包和开发环境\"\"\"\n if getattr(sys, '_MEIPASS', None):\n return os.path.join(sys._MEIPASS, *parts)\n return os.path.join(os.path.dirname(os.path.dirname(__file__)), *parts)\n\nfrom PyQt5.QtWidgets import (\n QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLayout,\n QPushButton, QLabel, QComboBox, QProgressBar, QFileDialog, QDialog,\n QStackedWidget, QStackedLayout, QFrame, QScrollArea, QGraphicsDropShadowEffect,\n QCheckBox, QListWidget, QListWidgetItem, QLineEdit, QSpinBox, QPlainTextEdit,\n QSizePolicy, QSlider, QSplitter, QTabBar, QMessageBox, QMenu, QAction,\n)\nfrom PyQt5.QtCore import Qt, pyqtSignal, QTimer, QSize, QEvent, QRect\nfrom PyQt5.QtGui import (QColor, QDragEnterEvent, QDropEvent, QImage, QPixmap,\n QScreen, QIcon, QPainter, QPainterPath, QPen, QRegion)\n\nfrom ui.config_manager import UserConfigManager, HistoryManager\nfrom ui.translate_worker import (\n TranslateWorker, LANG_MAP, SERVICE_MAP, PAGE_PRESETS,\n OUTPUT_MODES, parse_page_range, detect_zotero_source,\n get_zotero_item_key, zotero_auto_link, zotero_plugin_installed,\n build_service_envs, SummaryWorker, QAWorker,\n)\n\n# ─── 苹果风配色 ─────────────────────────────────────────────\n\nL = {\n \"bg\":\"#FFF\",\"bg2\":\"#F9F9FB\",\"bg3\":\"#F2F2F7\",\"elev\":\"#FFF\",\n \"sb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #FBFBFD,stop:1 #F2F2F7)\",\n \"sb_act\":\"rgba(0,113,227,0.12)\",\"sb_hov\":\"rgba(0,0,0,0.04)\",\n \"t1\":\"#1D1D1F\",\"t2\":\"#6E6E73\",\"t3\":\"#AEAEB2\",\"t4\":\"#C7C7CC\",\n \"acc\":\"#0071E3\",\"acc_l\":\"rgba(0,113,227,0.10)\",\"acc_h\":\"#0077ED\",\"acc_p\":\"#005BBB\",\n \"acc_g\":\"qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 #0071E3,stop:1 #00A1FF)\",\n \"ok\":\"#34C759\",\"err\":\"#FF3B30\",\n \"brd\":\"rgba(0,0,0,0.06)\",\"brd_s\":\"rgba(0,0,0,0.10)\",\n \"card\":\"#FFF\",\"card_b\":\"rgba(0,0,0,0.04)\",\n \"inp\":\"#F2F2F7\",\"inp_b\":\"rgba(0,0,0,0.08)\",\n \"dz_bg\":\"#FAFBFF\",\"dz_b\":\"rgba(0,113,227,0.20)\",\n \"tag_bg\":\"rgba(0,113,227,0.08)\",\"tag_fg\":\"#0071E3\",\n \"scr\":\"rgba(0,0,0,0.12)\",\"scr_h\":\"rgba(0,0,0,0.24)\",\n \"pv_bg\":\"#FFFFFF\",\"pv_tb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #FBFBFD,stop:1 #F2F2F7)\",\n \"pv_l_bg\":\"rgba(0,113,227,0.07)\",\"pv_l_fg\":\"#0071E3\",\n \"link\":\"#6E6E73\",\"link_h\":\"#0071E3\",\n}\n\nD = {\n \"bg\":\"#1C1C1E\",\"bg2\":\"#2C2C2E\",\"bg3\":\"#3A3A3C\",\"elev\":\"#2C2C2E\",\n \"sb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #242426,stop:1 #1C1C1E)\",\n \"sb_act\":\"rgba(10,132,255,0.20)\",\"sb_hov\":\"rgba(255,255,255,0.06)\",\n \"t1\":\"#F5F5F7\",\"t2\":\"#98989D\",\"t3\":\"#636366\",\"t4\":\"#48484A\",\n \"acc\":\"#0A84FF\",\"acc_l\":\"rgba(10,132,255,0.15)\",\"acc_h\":\"#409CFF\",\"acc_p\":\"#0071E3\",\n \"acc_g\":\"qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 #0A84FF,stop:1 #5AC8FA)\",\n \"ok\":\"#30D158\",\"err\":\"#FF453A\",\n \"brd\":\"rgba(255,255,255,0.06)\",\"brd_s\":\"rgba(255,255,255,0.10)\",\n \"card\":\"#2C2C2E\",\"card_b\":\"rgba(255,255,255,0.12)\",\n \"inp\":\"#3A3A3C\",\"inp_b\":\"rgba(255,255,255,0.08)\",\n \"dz_bg\":\"#2A2A2E\",\"dz_b\":\"rgba(10,132,255,0.35)\",\n \"tag_bg\":\"rgba(10,132,255,0.15)\",\"tag_fg\":\"#0A84FF\",\n \"scr\":\"rgba(255,255,255,0.12)\",\"scr_h\":\"rgba(255,255,255,0.24)\",\n \"pv_bg\":\"#1C1C1E\",\"pv_tb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #2C2C2E,stop:1 #242426)\",\n \"pv_l_bg\":\"rgba(10,132,255,0.12)\",\"pv_l_fg\":\"#0A84FF\",\n \"link\":\"#98989D\",\"link_h\":\"#0A84FF\",\n}\n\n_C = L # 当前活跃配色(深色/浅色切换时更新)\n\n\n# ─── 磨砂提示气泡 ──────────────────────────────────────────\nclass _Tip(QWidget):\n \"\"\"截取屏幕 → 缩放模糊 → 叠加半透明蒙层 → 圆角裁剪 = 磨砂玻璃\"\"\"\n _inst = None\n _PAD = 10\n\n def __init__(self):\n super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint)\n self.setAttribute(Qt.WA_TranslucentBackground)\n self.setAttribute(Qt.WA_ShowWithoutActivating)\n self._text = \"\"\n self._blur_pix = None\n\n def _grab_and_blur(self, x, y, w, h):\n \"\"\"安全截取屏幕区域并模糊\"\"\"\n try:\n screen = QApplication.primaryScreen()\n if not screen:\n return\n pix = screen.grabWindow(0, x, y, w, h)\n if pix.isNull():\n return\n img = pix.toImage()\n # 缩小到 1/8 再放大 = 快速高斯模糊\n from PyQt5.QtCore import QSize\n tiny = img.scaled(QSize(max(1, w // 8), max(1, h // 8)),\n Qt.IgnoreAspectRatio, Qt.SmoothTransformation)\n blurred = tiny.scaled(QSize(w, h),\n Qt.IgnoreAspectRatio, Qt.SmoothTransformation)\n self._blur_pix = QPixmap.fromImage(blurred)\n except Exception:\n self._blur_pix = None\n\n def paintEvent(self, e):\n from PyQt5.QtGui import QPainter, QPainterPath, QFont, QPen\n p = QPainter(self)\n p.setRenderHint(QPainter.Antialiasing)\n pad = self._PAD\n rect = self.rect().adjusted(pad, pad, -pad, -pad)\n radius = 8.0\n is_dark = (_C is D)\n path = QPainterPath()\n path.addRoundedRect(float(rect.x()), float(rect.y()),\n float(rect.width()), float(rect.height()), radius, radius)\n # ── 柔和阴影 ──\n for i in range(5):\n a = max(1, (35 if is_dark else 20) // (i + 1))\n d = (i + 1) * 2.0\n sp = QPainterPath()\n sr = rect.adjusted(-d, -d + 1, d, d + 2)\n sp.addRoundedRect(float(sr.x()), float(sr.y()),\n float(sr.width()), float(sr.height()), radius + d, radius + d)\n p.fillPath(sp, QColor(0, 0, 0, a))\n # ── 裁剪到圆角 ──\n p.setClipPath(path)\n # ── 模糊背景 ──\n if self._blur_pix and not self._blur_pix.isNull():\n p.drawPixmap(rect, self._blur_pix)\n else:\n fb = QColor(45, 45, 48) if is_dark else QColor(245, 245, 247)\n p.fillPath(path, fb)\n # ── 半透明蒙层(磨砂感的关键) ──\n overlay = QColor(30, 30, 32, 140) if is_dark else QColor(255, 255, 255, 178)\n p.fillRect(rect, overlay)\n # ── 顶部高光 ──\n hi = QPainterPath()\n hr = rect.adjusted(1, 1, -1, -rect.height() + 16)\n hi.addRoundedRect(float(hr.x()), float(hr.y()),\n float(hr.width()), float(hr.height()), radius - 1, radius - 1)\n p.fillPath(hi, QColor(255, 255, 255, 25 if is_dark else 120))\n # ── 边框 ──\n p.setClipping(False)\n brd = QColor(255, 255, 255, 20) if is_dark else QColor(0, 0, 0, 15)\n p.setPen(QPen(brd, 0.5))\n p.drawPath(path)\n # ── 文字 ──\n p.setPen(QColor(_C[\"t1\"]))\n font = QFont(\"Helvetica Neue\", 11)\n font.setFamilies([\"Helvetica Neue\", \"PingFang SC\"])\n p.setFont(font)\n tr = rect.adjusted(10, 7, -10, -7)\n p.drawText(tr, Qt.AlignLeft | Qt.AlignVCenter | Qt.TextWordWrap, self._text)\n p.end()\n\n def _calc_size(self):\n from PyQt5.QtGui import QFont, QFontMetrics\n font = QFont(\"Helvetica Neue\", 11)\n font.setFamilies([\"Helvetica Neue\", \"PingFang SC\"])\n fm = QFontMetrics(font)\n br = fm.boundingRect(0, 0, 300, 9999,\n Qt.AlignLeft | Qt.TextWordWrap, self._text)\n pad = self._PAD\n self.setFixedSize(br.width() + 20 + pad * 2, br.height() + 14 + pad * 2)\n\n @classmethod\n def show_tip(cls, pos, text, widget=None):\n if not text:\n cls.hide_tip(); return\n if cls._inst is None:\n cls._inst = cls()\n tip = cls._inst\n tip._text = text\n tip._calc_size()\n # 先确保 tooltip 自身不可见,再截取目标位置的屏幕\n tip.hide()\n pad = tip._PAD\n tx, ty = pos.x() + 8, pos.y() + 14\n # 边界检测:确保不超出屏幕\n screen = QApplication.primaryScreen()\n if screen:\n sg = screen.availableGeometry()\n if tx + tip.width() > sg.right():\n tx = sg.right() - tip.width()\n if ty + tip.height() > sg.bottom():\n ty = pos.y() - tip.height() - 4 # 移到光标上方\n tx = max(sg.left(), tx)\n ty = max(sg.top(), ty)\n bx, by = tx + pad, ty + pad\n tw, th = tip.width() - pad * 2, tip.height() - pad * 2\n tip._grab_and_blur(bx, by, tw, th)\n tip.move(tx, ty)\n tip.show()\n tip.update()\n\n @classmethod\n def hide_tip(cls):\n if cls._inst: cls._inst.hide()\n\n\ndef _fix_combo_popup(combo):\n \"\"\"去掉 QComboBox 的 macOS 焦点虚线框\"\"\"\n combo.setAttribute(Qt.WA_MacShowFocusRect, False)\n\n\ndef _install_tip_filter(app):\n \"\"\"安装全局 tooltip 拦截 + combo 修复\"\"\"\n from PyQt5.QtCore import QObject\n\n class F(QObject):\n def eventFilter(self, obj, event):\n if event.type() == QEvent.ToolTip:\n text = \"\"\n if hasattr(obj, 'toolTip'):\n text = obj.toolTip()\n # QListWidget viewport → item tooltip\n if not text and hasattr(obj, 'parent'):\n p = obj.parent()\n if hasattr(p, 'itemAt'):\n item = p.itemAt(event.pos())\n if item: text = item.toolTip()\n if text:\n from PyQt5.QtGui import QCursor\n _Tip.show_tip(QCursor.pos(), text, obj)\n event.accept()\n return True\n return False\n if event.type() == QEvent.Leave:\n _Tip.hide_tip()\n return False\n\n f = F(app)\n app._tip_filter = f\n app.installEventFilter(f)\n\n\ndef S(c):\n return f\"\"\"\n QMainWindow{{background:{c[\"bg\"]};}}\n QWidget{{font-family:\"Helvetica Neue\",\"PingFang SC\";font-size:13px;color:{c[\"t1\"]};outline:none;}}\n #Central{{background:{c[\"bg\"]};}}\n QStackedWidget{{background:{c[\"bg\"]};}}\n QStackedWidget>QWidget{{background:{c[\"bg\"]};}}\n QLabel{{background:transparent;border:none;}}\n QToolTip{{background:transparent;border:none;padding:0;}}\n /* ── 侧边栏 ── */\n #Sidebar{{background:{c[\"sb\"]};border-right:0.5px solid {c[\"brd\"]};}}\n #SB{{background:transparent;border:none;border-radius:7px;padding:6px 12px;text-align:left;font-size:13px;color:{c[\"t2\"]};letter-spacing:0.2px;border-left:3px solid transparent;}}\n #SB:hover{{background:{c[\"sb_hov\"]};color:{c[\"t1\"]};}}\n #SB[active=\"true\"]{{background:{c[\"sb_act\"]};color:{c[\"acc\"]};font-weight:600;border-left:3px solid {c[\"acc\"]};border-top-left-radius:0;border-bottom-left-radius:0;}}\n #SBLink{{background:transparent;border:none;font-size:10px;color:{c[\"link\"]};padding:1px 3px;}}\n #SBLink:hover{{color:{c[\"acc\"]};}}\n /* ── 按钮 ── */\n #Pr{{background:{c[\"acc_g\"]};color:white;border:none;border-radius:12px;padding:12px 32px;font-size:15px;font-weight:600;letter-spacing:0.3px;}}\n #Pr:hover{{background:{c[\"acc_h\"]};}}#Pr:pressed{{background:{c[\"acc_p\"]};padding:13px 32px 11px 32px;}}\n #Pr:disabled{{background:{c[\"bg3\"]};color:{c[\"t4\"]};border:1px solid {c[\"brd\"]};}}\n #Sc{{background:{c[\"bg2\"]};color:{c[\"t1\"]};border:0.5px solid {c[\"brd_s\"]};border-radius:8px;padding:8px 18px;font-size:13px;font-weight:500;}}\n #Sc:hover{{background:{c[\"bg3\"]};border-color:{c[\"acc\"]};}}\n #Gh{{background:transparent;color:{c[\"acc\"]};border:none;border-radius:6px;padding:6px 12px;font-size:13px;font-weight:500;}}\n #Gh:hover{{background:{c[\"acc_l\"]};}}\n #GhDanger{{background:transparent;color:{c[\"err\"]};border:none;border-radius:6px;padding:6px 12px;font-size:13px;font-weight:500;}}\n #GhDanger:hover{{background:rgba(255,59,48,0.08);}}\n #TB{{background:transparent;color:{c[\"t2\"]};border:none;border-radius:6px;padding:5px 10px;font-size:13px;font-weight:500;}}\n #TB:hover{{background:{c[\"sb_hov\"]};color:{c[\"t1\"]};}}\n #TB[active=\"true\"]{{background:{c[\"acc_l\"]};color:{c[\"acc\"]};font-weight:600;}}\n /* ── 输入控件 ── */\n QComboBox{{background:{c[\"inp\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;padding:8px 14px;font-size:13px;min-height:22px;color:{c[\"t1\"]};}}\n QComboBox:hover{{border-color:{c[\"brd_s\"]};}}QComboBox:focus{{border-color:{c[\"acc\"]};border-width:1.5px;outline:none;}}\n QComboBox::drop-down{{border:none;width:28px;subcontrol-origin:padding;subcontrol-position:center right;}}\n QComboBox::down-arrow{{image:none;width:8px;height:5px;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid {c[\"t3\"]};margin-right:10px;}}\n QComboBox QAbstractItemView{{background:{c[\"elev\"]};border:none;border-radius:10px;padding:6px 4px;color:{c[\"t1\"]};outline:0;}}\n QComboBox QAbstractItemView::item{{padding:6px 12px;border:none;border-radius:6px;margin:1px 2px;}}\n QComboBox QAbstractItemView::item:hover{{background:{c[\"acc_l\"]};}}\n QComboBox QAbstractItemView::item:selected{{background:{c[\"acc\"]};color:white;}}\n QLineEdit{{background:{c[\"inp\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;padding:8px 14px;font-size:13px;color:{c[\"t1\"]};}}\n QLineEdit:focus{{border-color:{c[\"acc\"]};border-width:2px;background:{c[\"elev\"]};}}\n QLineEdit[readOnly=\"true\"]{{color:{c[\"t2\"]};}}\n QSpinBox{{background:{c[\"inp\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;padding:6px 10px;font-size:13px;color:{c[\"t1\"]};}}\n QSpinBox:focus{{border-color:{c[\"acc\"]};border-width:2px;}}\n QTextEdit{{background:{c[\"inp\"]};color:{c[\"t1\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;}}\n QTextEdit:focus{{border-color:{c[\"acc\"]};border-width:2px;}}\n /* ── 进度条 ── */\n QProgressBar{{background:{c[\"bg3\"]};border:none;border-radius:3px;max-height:6px;min-height:6px;font-size:1px;}}\n QProgressBar::chunk{{background:{c[\"acc_g\"]};border-radius:3px;}}\n /* ── 卡片 ── */\n #Card{{background:{c[\"card\"]};border:0.5px solid {c[\"card_b\"]};border-radius:12px;}}\n #Card:hover{{border-color:{c[\"acc\"]};}}\n #DZ{{background:{c[\"dz_bg\"]};border:2px dashed {c[\"dz_b\"]};border-radius:12px;}}\n #DZ:hover{{border-color:{c[\"acc\"]};}}\n /* ── 列表 ── */\n QListWidget{{background:transparent;border:none;outline:none;}}\n QListWidget::item{{background:{c[\"card\"]};border:0.5px solid {c[\"card_b\"]};border-radius:8px;padding:4px 10px;margin:2px 0;}}\n QListWidget::item:selected{{background:{c[\"acc\"]};color:white;border-color:{c[\"acc\"]};}}\n QListWidget::item:hover:!selected{{background:{c[\"bg2\"]};}}\n /* ── 滚动条 ── */\n QScrollArea{{border:none;background:transparent;}}\n QScrollBar:vertical{{background:transparent;width:10px;margin:4px 2px;}}\n QScrollBar::handle:vertical{{background:{c[\"scr\"]};border-radius:4px;min-height:36px;}}\n QScrollBar::handle:vertical:hover{{background:{c[\"scr_h\"]};}}\n QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical,QScrollBar::add-page:vertical,QScrollBar::sub-page:vertical{{height:0;background:transparent;}}\n QScrollBar:horizontal{{height:0;}}\n /* ── 复选框 ── */\n QCheckBox{{spacing:10px;font-size:13px;}}\n QCheckBox::indicator{{width:18px;height:18px;border-radius:5px;border:1.5px solid {c[\"brd_s\"]};background:{c[\"elev\"]};}}\n QCheckBox::indicator:hover{{border-color:{c[\"acc\"]};background:{c[\"acc_l\"]};}}\n QCheckBox::indicator:checked{{background:{c[\"acc\"]};border-color:{c[\"acc\"]};}}\n /* ── 标签/分隔 ── */\n #Tag{{background:{c[\"tag_bg\"]};color:{c[\"tag_fg\"]};border:none;border-radius:6px;padding:3px 10px;font-size:11px;font-weight:600;}}\n #Div{{background:{c[\"brd\"]};max-height:0.5px;min-height:0.5px;}}\n /* ── 预览区 ── */\n #PA{{background:{c[\"pv_bg\"]};}}\n #PT{{background:{c[\"pv_tb\"]};border-bottom:0.5px solid {c[\"brd\"]};}}\n #PL{{background:{c[\"pv_l_bg\"]};color:{c[\"pv_l_fg\"]};border:none;border-radius:6px;padding:3px 12px;font-size:11px;font-weight:600;}}\n QSplitter::handle{{background:{c[\"brd\"]};width:1px;}}\n QSplitter::handle:hover{{background:{c[\"acc\"]};}}\n QSlider::groove:horizontal{{background:{c[\"bg3\"]};height:4px;border-radius:2px;}}\n QSlider::handle:horizontal{{background:{c[\"acc\"]};width:14px;height:14px;margin:-5px 0;border-radius:7px;}}\n QSlider::handle:horizontal:hover{{background:{c[\"acc_h\"]};width:16px;height:16px;margin:-6px 0;border-radius:8px;}}\n QMenu{{background:{c[\"elev\"]};border:1px solid {c[\"brd_s\"]};border-radius:10px;padding:6px 4px;}}\n QMenu::item{{padding:4px 28px 4px 14px;border-radius:6px;font-size:13px;margin:1px 4px;}}\n QMenu::item:selected{{background:{c[\"acc\"]};color:white;}}\n QMenu::separator{{height:1px;background:{c[\"brd\"]};margin:4px 12px;}}\n /* ── 字体层级: 24 / 15 / 13 / 11 ── */\n #PT0{{font-size:24px;font-weight:700;letter-spacing:-0.3px;}}\n #PT1{{font-size:15px;color:{c[\"t2\"]};}}\n #ST{{font-size:13px;font-weight:600;}}\n #SL{{font-size:11px;font-weight:600;letter-spacing:0.8px;color:{c[\"t3\"]};padding:6px 0 2px 0;}}\n #FL{{font-size:13px;font-weight:500;color:{c[\"t2\"]};padding:2px 0;}}\n #Cap{{font-size:11px;color:{c[\"t3\"]};}}\n #Mono{{font-family:\"Menlo\",monospace;font-size:12px;color:{c[\"t2\"]};}}\n /* ── 进度卡片 ── */\n #ProgPct{{font-family:\"Menlo\",monospace;font-size:20px;font-weight:700;color:{c[\"acc\"]};background:transparent;}}\n #ProgLabel{{font-size:13px;font-weight:600;color:{c[\"t1\"]};}}\n #ProgIcon{{font-size:16px;background:transparent;}}\n #ProgDetail{{font-size:11px;color:{c[\"t2\"]};}}\n #ProgTip{{font-size:11px;color:{c[\"t3\"]};font-style:italic;}}\n #ProgCancel{{background:transparent;color:{c[\"err\"]};border:none;font-size:13px;font-weight:500;padding:6px 12px;border-radius:6px;}}\n #ProgCancel:hover{{background:rgba(255,59,48,0.08);}}\n /* ── 拖拽区 ── */\n #DZTitle{{font-size:15px;font-weight:600;color:{c[\"t1\"]};background:transparent;}}\n #DZSub{{font-size:11px;color:{c[\"t3\"]};background:transparent;}}\n /* ── 历史面板 ── */\n #HistList{{background:transparent;border:none;font-size:11px;color:{c[\"t1\"]};}}\n #HistList::item{{background:transparent;border:none;border-bottom:0.5px solid {c[\"brd\"]};padding:8px 6px;margin:0;border-radius:0;}}\n #HistList::item:selected{{background:{c[\"sb_act\"]};color:{c[\"acc\"]};border-radius:6px;border-bottom:none;}}\n #HistList::item:hover:!selected{{background:{c[\"sb_hov\"]};border-radius:6px;}}\n #HistPanel{{border-right:0.5px solid {c[\"brd\"]};}}\n #HistDetail{{font-size:10px;color:{c[\"t2\"]};background:{c[\"bg2\"]};border:0.5px solid {c[\"brd\"]};border-radius:8px;padding:4px 6px;}}\n /* ── 自定义进度条 ── */\n #ProgBar{{background:{c[\"bg3\"]};border:none;border-radius:6px;max-height:12px;min-height:12px;font-size:1px;}}\n #ProgBar::chunk{{background:{c[\"acc_g\"]};border-radius:6px;}}\n /* ── 预览工具栏控件 ── */\n #PageInput{{font-family:'Menlo',monospace;font-size:13px;font-weight:600;padding:4px;border-radius:6px;border:1px solid {c[\"inp_b\"]};background:{c[\"inp\"]};color:{c[\"t1\"]};}}\n #PageInput:focus{{border-color:{c[\"acc\"]};}}\n #TotalLbl{{font-family:'Menlo',monospace;font-size:11px;color:{c[\"t2\"]};background:transparent;}}\n #ZoomPct{{font-family:'Menlo',monospace;font-size:11px;color:{c[\"t2\"]};background:transparent;}}\n #SBSep{{font-size:10px;color:{c[\"t3\"]};background:transparent;padding:0;}}\n #KBKey{{font-family:'Menlo',monospace;font-size:10px;font-weight:600;color:{c[\"t2\"]};background:{c[\"bg3\"]};border-radius:4px;padding:3px 8px;}}\n \"\"\"\n\n\nclass _EggLogo(QLabel):\n \"\"\"\n 📄 Logo 彩蛋按钮:\n · 悬停 5 秒 → 烟花爆炸\n · 连续点击 5 次 → emoji 飘落\n 所有动画在 MainWindow 层播放。\n \"\"\"\n def __init__(self, text=\"📄\", size=22):\n super().__init__(text)\n self.setStyleSheet(f\"font-size:{size}px;background:transparent;border:none;\")\n self.setCursor(Qt.PointingHandCursor)\n self._click_count = 0\n self._click_timer = QTimer(); self._click_timer.setSingleShot(True)\n self._click_timer.timeout.connect(self._reset_clicks)\n self._hover_timer = QTimer(); self._hover_timer.setSingleShot(True)\n self._hover_timer.timeout.connect(self._on_hover_egg)\n\n def enterEvent(self, e):\n super().enterEvent(e)\n self._hover_timer.start(5000)\n\n def leaveEvent(self, e):\n super().leaveEvent(e)\n self._hover_timer.stop()\n\n def mousePressEvent(self, e):\n super().mousePressEvent(e)\n self._click_count += 1\n self._click_timer.start(1200)\n if self._click_count >= 5:\n self._click_count = 0\n self._trigger_confetti()\n\n def _reset_clicks(self):\n self._click_count = 0\n\n def _main_window(self):\n w = self\n while w:\n if isinstance(w, QMainWindow): return w\n w = w.parentWidget()\n return None\n\n def _on_hover_egg(self):\n \"\"\"悬停 5 秒 → 烟花从 Logo 处爆发\"\"\"\n mw = self._main_window()\n if not mw: return\n import random, math\n from PyQt5.QtCore import QPropertyAnimation, QPoint\n center = self.mapTo(mw, self.rect().center())\n firework_emoji = [\"🎆\",\"✨\",\"💫\",\"⭐\",\"🌟\",\"🎇\",\"💥\",\"🔥\"]\n for _ in range(30):\n lbl = QLabel(random.choice(firework_emoji), mw)\n lbl.setStyleSheet(f\"font-size:{random.randint(14,32)}px;background:transparent;\")\n lbl.move(center); lbl.show()\n angle = random.uniform(0, 2 * math.pi)\n dist = random.randint(120, 350)\n end = QPoint(center.x() + int(math.cos(angle) * dist),\n center.y() + int(math.sin(angle) * dist))\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(800, 1600))\n anim.setStartValue(center); anim.setEndValue(end)\n from PyQt5.QtCore import QEasingCurve\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start(); lbl._anim = anim\n\n def _trigger_confetti(self):\n \"\"\"连续点击 5 次 → emoji 从天而降\"\"\"\n mw = self._main_window()\n if not mw: return\n import random\n from datetime import datetime\n from PyQt5.QtCore import QPropertyAnimation, QPoint, QEasingCurve\n md = (datetime.now().month, datetime.now().day)\n holiday = {\n (1,1):[\"🎆\",\"🥳\",\"🎊\",\"🎉\",\"✨\"], (2,14):[\"💝\",\"💕\",\"💗\",\"🥰\",\"💌\"],\n (5,1):[\"🎊\",\"🎉\",\"🌸\",\"✨\",\"🌟\"], (10,1):[\"🇨🇳\",\"🎆\",\"🎉\",\"✨\",\"💫\"],\n (10,31):[\"🎃\",\"👻\",\"🍬\",\"⭐\",\"✨\"], (12,25):[\"🎄\",\"🎅\",\"❄️\",\"🎁\",\"✨\"],\n }\n pool = holiday.get(md, [\"✨\",\"🎉\",\"🚀\",\"💡\",\"🌟\",\"💎\",\"🦋\",\"🌈\",\"🧸\",\"💫\",\"⭐\",\"🌸\",\"🎈\",\"🎵\",\"💗\",\"🌙\",\"🦄\"])\n for _ in range(25):\n lbl = QLabel(random.choice(pool), mw)\n lbl.setStyleSheet(f\"font-size:{random.randint(18,42)}px;background:transparent;\")\n x = random.randint(0, mw.width() - 40)\n lbl.move(x, -40); lbl.show()\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(1500, 3500))\n anim.setStartValue(lbl.pos())\n anim.setEndValue(QPoint(x + random.randint(-60, 60), mw.height() + 40))\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start(); lbl._anim = anim\n\n\nclass _RoundMenu(QMenu):\n \"\"\"圆角右键菜单 — FramelessWindowHint + 自绘圆角背景 + mask 裁切\"\"\"\n _R = 10 # corner radius\n\n def __init__(self, parent=None):\n super().__init__(parent)\n self.setWindowFlags(\n self.windowFlags() | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint)\n self.setAttribute(Qt.WA_TranslucentBackground)\n # 让 Qt 的默认 QMenu 绘制不画背景,只画菜单项\n self.setStyleSheet(\"QMenu{background:transparent;border:none;}\")\n\n def resizeEvent(self, e):\n super().resizeEvent(e)\n path = QPainterPath()\n path.addRoundedRect(0, 0, self.width(), self.height(),\n self._R, self._R)\n self.setMask(QRegion(path.toFillPolygon().toPolygon()))\n\n def paintEvent(self, e):\n p = QPainter(self)\n p.setRenderHint(QPainter.Antialiasing)\n r = self._R\n rect = self.rect()\n is_dark = isinstance(_C, dict) and _C.get(\"bg\") == \"#1C1C1E\"\n bg = QColor(_C.get(\"elev\", \"#FFF\")) if isinstance(_C, dict) else QColor(\"#FFF\")\n brd = QColor(0, 0, 0, 18) if not is_dark else QColor(255, 255, 255, 18)\n path = QPainterPath()\n path.addRoundedRect(\n rect.x() + 0.5, rect.y() + 0.5,\n rect.width() - 1.0, rect.height() - 1.0,\n float(r), float(r))\n p.fillPath(path, bg)\n p.setPen(QPen(brd, 0.5))\n p.drawPath(path)\n p.end()\n super().paintEvent(e)\n\n\ndef _div():\n d = QFrame(); d.setObjectName(\"Div\"); d.setFrameShape(QFrame.HLine); return d\n\n\ndef _md2html(text):\n \"\"\"Markdown → HTML — 让 AI 回复像 Claude 一样优雅\"\"\"\n import re as _re\n # 转义 HTML\n text = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')\n # 加粗\n text = _re.sub(r'\\*\\*(.+?)\\*\\*', r'<b>\\1</b>', text)\n # 斜体\n text = _re.sub(r'(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)', r'<i>\\1</i>', text)\n # 行内代码\n text = _re.sub(r'`([^`]+)`', r'<code style=\"background:rgba(0,0,0,0.06);border-radius:3px;padding:1px 4px;font-family:Menlo,monospace;font-size:10px;\">\\1</code>', text)\n # 列表项 (- item)\n text = _re.sub(r'^- (.+)$', r'<div style=\"margin:2px 0;padding-left:12px;text-indent:-8px;\">· \\1</div>', text, flags=_re.MULTILINE)\n # 数字列表 (1. item)\n text = _re.sub(r'^(\\d+)\\. (.+)$', r'<div style=\"margin:2px 0;padding-left:12px;text-indent:-12px;\">\\1. \\2</div>', text, flags=_re.MULTILINE)\n # 段落换行\n text = text.replace('\\n\\n', '<div style=\"height:8px;\"></div>')\n text = text.replace('\\n', '<br>')\n return text\n\n\ndef _card(level=\"md\"):\n \"\"\"统一阴影层级: sm=微弱, md=默认卡片, lg=悬浮\"\"\"\n _SHADOW = {\"sm\":(12,2,8), \"md\":(20,4,12), \"lg\":(28,6,16)}\n blur, y, a = _SHADOW.get(level, _SHADOW[\"md\"])\n c = QFrame(); c.setObjectName(\"Card\")\n s = QGraphicsDropShadowEffect(); s.setBlurRadius(blur); s.setOffset(0,y); s.setColor(QColor(0,0,0,a))\n c.setGraphicsEffect(s); return c\n\n\n# ═══════════════════════════════════════════════════════════════\n# 拖拽区\n# ═══════════════════════════════════════════════════════════════\n\nclass DropZone(QFrame):\n files_dropped = pyqtSignal(list)\n def __init__(self):\n super().__init__(); self.setObjectName(\"DZ\"); self.setAcceptDrops(True)\n self.setFixedHeight(140); self.setCursor(Qt.PointingHandCursor)\n\n self._stack = QStackedLayout(self)\n self._stack.setContentsMargins(0,0,0,0)\n\n # ── Page 0: 拖拽提示 ──\n p0 = QWidget(); p0.setStyleSheet(\"background:transparent;\")\n lo0 = QHBoxLayout(p0); lo0.setAlignment(Qt.AlignCenter); lo0.setSpacing(16)\n lo0.setContentsMargins(32,20,32,20)\n ic = QLabel(\"📥\"); ic.setStyleSheet(\"font-size:36px;background:transparent;\"); lo0.addWidget(ic)\n txt = QVBoxLayout(); txt.setSpacing(3)\n t = QLabel(\"将 PDF 拖放至此处\"); t.setObjectName(\"DZTitle\"); txt.addWidget(t)\n s = QLabel(\"或点击浏览 · 支持批量\"); s.setObjectName(\"DZSub\"); txt.addWidget(s)\n lo0.addLayout(txt); lo0.addStretch()\n self._stack.addWidget(p0)\n\n # ── Page 1: 文件列表 ──\n p1 = QWidget(); p1.setStyleSheet(\"background:transparent;\")\n lo1 = QVBoxLayout(p1); lo1.setContentsMargins(10,6,10,4); lo1.setSpacing(3)\n\n self._zotero_hint = QLabel(\"📚 Zotero 文献 · 译文自动保存回原位\")\n self._zotero_hint.setStyleSheet(\n \"font-size:10px;color:#1a56db;background:#e8f0fe;\"\n \"border-radius:3px;padding:2px 6px;\")\n self._zotero_hint.setVisible(False)\n lo1.addWidget(self._zotero_hint)\n\n self.flist = QListWidget()\n self.flist.setSelectionMode(QListWidget.ExtendedSelection)\n lo1.addWidget(self.flist)\n\n fbtn = QHBoxLayout(); fbtn.setSpacing(6)\n self._fcount_label = QLabel(\"\"); self._fcount_label.setObjectName(\"Cap\")\n self._fcount_label.setStyleSheet(\"font-size:10px;background:transparent;\")\n fbtn.addWidget(self._fcount_label); fbtn.addStretch()\n self._add_btn = QPushButton(\"+ 添加\"); self._add_btn.setCursor(Qt.PointingHandCursor)\n self._add_btn.setStyleSheet(\"font-size:10px;padding:1px 8px;border:1px solid #ccc;border-radius:3px;background:transparent;\")\n fbtn.addWidget(self._add_btn)\n self._del_btn = QPushButton(\"删除选中\"); self._del_btn.setObjectName(\"TB\"); self._del_btn.setCursor(Qt.PointingHandCursor)\n self._del_btn.setStyleSheet(\"font-size:10px;padding:1px 6px;\")\n fbtn.addWidget(self._del_btn)\n self._clr_btn = QPushButton(\"清空\"); self._clr_btn.setObjectName(\"GhDanger\"); self._clr_btn.setCursor(Qt.PointingHandCursor)\n self._clr_btn.setStyleSheet(\"font-size:10px;padding:1px 6px;\")\n fbtn.addWidget(self._clr_btn)\n lo1.addLayout(fbtn)\n self._stack.addWidget(p1)\n\n def dragEnterEvent(self, e):\n c = _C\n self.setStyleSheet(\n f\"#DZ{{background:{c['acc_l']};border:2.5px solid {c['acc']};\"\n f\"border-radius:14px;}}\")\n # 呼吸脉动动画\n from PyQt5.QtCore import QPropertyAnimation\n anim = QPropertyAnimation(self, b\"windowOpacity\")\n anim.setDuration(600); anim.setLoopCount(-1) # 无限循环\n anim.setStartValue(1.0); anim.setEndValue(0.85)\n # QWidget 没有 windowOpacity,用 graphicsEffect 模拟缩放\n effect = QGraphicsDropShadowEffect(self)\n effect.setBlurRadius(20); effect.setOffset(0, 0)\n effect.setColor(QColor(c['acc']))\n self.setGraphicsEffect(effect)\n self._drag_effect = effect\n e.acceptProposedAction()\n def dragLeaveEvent(self, e):\n self.setStyleSheet(\"\")\n self.setGraphicsEffect(None)\n self._drag_effect = None\n def dragMoveEvent(self, e):\n e.acceptProposedAction()\n def dropEvent(self, e):\n self.setStyleSheet(\"\")\n self.setGraphicsEffect(None)\n import glob\n fs = []\n md = e.mimeData()\n\n def _collect(p):\n \"\"\"从路径收集 PDF:文件直接加,文件夹递归扫描\"\"\"\n if p.lower().endswith('.pdf') and os.path.isfile(p):\n fs.append(p)\n elif os.path.isdir(p):\n fs.extend(sorted(glob.glob(\n os.path.join(p, '**', '*.pdf'), recursive=True)))\n\n # 1) 标准 file URL(Finder 拖文件/文件夹、Zotero 拖 PDF 附件)\n for u in md.urls():\n p = u.toLocalFile()\n if p:\n _collect(p)\n\n # 检测是否来自 Zotero(macOS 拖多个条目时 URL 只含一个文件)\n _from_zotero = False\n if fs:\n from ui.translate_worker import detect_zotero_source\n _from_zotero = any(detect_zotero_source(f) for f in fs)\n\n # 2) text/plain 中的 file:// 路径或本地路径\n if not fs and md.hasText():\n for line in md.text().strip().splitlines():\n line = line.strip()\n if line.startswith(\"file://\"):\n from PyQt5.QtCore import QUrl\n line = QUrl(line).toLocalFile()\n if line and os.path.exists(line):\n _collect(line)\n\n # 3) Zotero 自定义 MIME(同进程或支持跨进程的平台)\n if not fs:\n from ui.translate_worker import resolve_zotero_items, resolve_zotero_collection\n zot_data = md.data(\"zotero/item\")\n if zot_data and len(zot_data):\n try:\n import json as _json\n raw = bytes(zot_data).decode(\"utf-8\", errors=\"ignore\").strip()\n try:\n parsed = _json.loads(raw)\n if isinstance(parsed, dict) and \"itemIDs\" in parsed:\n item_ids = [int(x) for x in parsed[\"itemIDs\"]]\n elif isinstance(parsed, list):\n item_ids = [int(x) for x in parsed]\n elif isinstance(parsed, int):\n item_ids = [parsed]\n else:\n item_ids = []\n except (ValueError, TypeError):\n item_ids = [int(x) for x in raw.split(\",\")\n if x.strip().isdigit()]\n if item_ids:\n fs = resolve_zotero_items(item_ids)\n except Exception:\n pass\n if not fs:\n zot_coll = md.data(\"zotero/collection\")\n if zot_coll and len(zot_coll):\n try:\n raw = bytes(zot_coll).decode(\"utf-8\", errors=\"ignore\").strip()\n if raw:\n fs = resolve_zotero_collection(raw)\n except Exception:\n pass\n\n # 4) macOS 跨进程 Zotero:text/plain 匹配标题/集合名\n # 也在 layer 1 仅拿到部分 Zotero URL 时运行,补全缺失的文件\n if (not fs or _from_zotero) and md.hasText():\n try:\n from ui.translate_worker import (\n resolve_zotero_by_title, resolve_zotero_collection_by_name)\n txt = md.text().strip()\n if txt:\n extra = resolve_zotero_by_title(txt)\n existing = set(fs)\n for f in extra:\n if f not in existing:\n fs.append(f)\n existing.add(f)\n if not fs and txt:\n fs = resolve_zotero_collection_by_name(txt)\n except Exception:\n pass\n\n self.setStyleSheet(\"\") # 恢复默认样式\n if fs:\n self.files_dropped.emit(fs)\n def mousePressEvent(self, e):\n if self._stack.currentIndex() == 0:\n fs, _ = QFileDialog.getOpenFileNames(self, \"选择 PDF\", \"\", \"PDF (*.pdf)\")\n if fs: self.files_dropped.emit(fs)\n\n\nclass SummaryCard(QFrame):\n \"\"\"智能摘要卡片 — 可折叠,点击按钮调用 AI 生成结构化摘要\"\"\"\n def __init__(self):\n super().__init__()\n self.setObjectName(\"Card\")\n lo = QVBoxLayout(self); lo.setContentsMargins(16, 10, 16, 10); lo.setSpacing(6)\n # 头部\n hdr = QHBoxLayout(); hdr.setSpacing(8)\n ic = QLabel(\"✨\"); ic.setStyleSheet(\"font-size:14px;background:transparent;\")\n hdr.addWidget(ic)\n t = QLabel(\"智能摘要\"); t.setStyleSheet(\"font-size:13px;font-weight:600;background:transparent;\")\n hdr.addWidget(t); hdr.addStretch()\n self._svc_label = QLabel(\"\")\n self._svc_label.setStyleSheet(\"font-size:10px;color:gray;background:transparent;\")\n hdr.addWidget(self._svc_label)\n self.gen_btn = QPushButton(\"生成摘要\"); self.gen_btn.setObjectName(\"Sc\")\n self.gen_btn.setCursor(Qt.PointingHandCursor)\n self.gen_btn.setStyleSheet(\"font-size:11px;padding:3px 12px;\")\n self.gen_btn.clicked.connect(self._generate)\n hdr.addWidget(self.gen_btn)\n self._collapse_btn = QPushButton(\"▼\"); self._collapse_btn.setObjectName(\"Gh\")\n self._collapse_btn.setFixedSize(24, 24); self._collapse_btn.setCursor(Qt.PointingHandCursor)\n self._collapse_btn.setStyleSheet(\"font-size:10px;padding:0;\")\n self._collapse_btn.clicked.connect(self._toggle_collapse)\n hdr.addWidget(self._collapse_btn)\n lo.addLayout(hdr)\n # 内容区\n self._body = QLabel(\"\")\n self._body.setWordWrap(True); self._body.setTextInteractionFlags(Qt.TextSelectableByMouse)\n self._body.setStyleSheet(\"font-size:12px;line-height:160%;background:transparent;padding:4px 0;\")\n self._body.setVisible(False)\n lo.addWidget(self._body)\n self._collapsed = False\n self._worker = None\n self._pdf_path = None\n # 检测 AI 服务(优先使用独立助手配置)\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if svc:\n self._svc_label.setText(f\"via {svc['name']}\")\n else:\n self.gen_btn.setEnabled(False)\n self.gen_btn.setToolTip(\"请在设置中配置 AI 服务 API Key\")\n\n def set_pdf(self, path):\n self._pdf_path = path\n self._body.setText(\"\"); self._body.setVisible(False)\n self._collapse_btn.setText(\"▼\")\n\n def _toggle_collapse(self):\n self._collapsed = not self._collapsed\n self._body.setVisible(not self._collapsed and bool(self._body.text()))\n self._collapse_btn.setText(\"▶\" if self._collapsed else \"▼\")\n\n def _generate(self):\n if not self._pdf_path or not os.path.exists(self._pdf_path):\n return\n # 刷新服务检测(优先使用独立助手配置)\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if not svc:\n self._body.setText(\"⚠️ 未配置 AI 服务。请在设置页配置 API Key(如 DeepSeek、OpenAI 等)\")\n self._body.setVisible(True)\n return\n self._svc_label.setText(f\"via {svc['name']}\")\n self.gen_btn.setEnabled(False); self.gen_btn.setText(\"生成中…\")\n self._body.setText(\"⏳ 正在分析论文,请稍候…\"); self._body.setVisible(True)\n self._collapsed = False; self._collapse_btn.setText(\"▼\")\n self._worker = SummaryWorker(self._pdf_path)\n self._worker.result.connect(self._on_result)\n self._worker.error.connect(self._on_error)\n self._worker.finished.connect(lambda: self._cleanup())\n self._worker.start()\n\n def _on_result(self, text):\n self._body.setText(text)\n self._body.setVisible(True)\n\n def _on_error(self, err):\n self._body.setText(f\"⚠️ {err}\")\n self._body.setVisible(True)\n\n def _cleanup(self):\n self.gen_btn.setEnabled(True); self.gen_btn.setText(\"生成摘要\")\n if self._worker:\n w = self._worker; self._worker = None\n w.quit(); QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n\nclass SB(QWidget):\n \"\"\"侧边栏按钮 — 图标固定宽度 + 文字对齐\"\"\"\n clicked = pyqtSignal()\n def __init__(self, icon, label):\n super().__init__()\n self.setFixedHeight(32); self.setCursor(Qt.PointingHandCursor)\n self.setObjectName(\"SB\"); self.setProperty(\"active\", False)\n lo = QHBoxLayout(self); lo.setContentsMargins(12,0,12,0); lo.setSpacing(0)\n self._icon = QLabel(icon); self._icon.setFixedWidth(24); self._icon.setAlignment(Qt.AlignCenter)\n self._icon.setStyleSheet(\"font-size:16px;background:transparent;border:none;\")\n self._label = QLabel(label)\n self._label.setStyleSheet(\"font-size:13px;background:transparent;border:none;padding-left:8px;\")\n lo.addWidget(self._icon); lo.addWidget(self._label); lo.addStretch()\n def set_active(self, v):\n self.setProperty(\"active\", v); self.style().unpolish(self); self.style().polish(self)\n def mousePressEvent(self, e):\n self.clicked.emit()\n\n\n# ═══════════════════════════════════════════════════════════════\n# PDF 预览 — 支持 MONO / DUAL / Side-by-Side 三模式切换\n# ═══════════════════════════════════════════════════════════════\n\nclass PDFPageWidget(QLabel):\n def __init__(self):\n super().__init__(); self.setAlignment(Qt.AlignCenter)\n def set_pixmap(self, px):\n self.setPixmap(px)\n self.setFixedSize(px.size())\n\n\nfrom PyQt5.QtCore import QObject, QTimer\n\nclass _PinchFilter(QObject):\n \"\"\"全局捕获 macOS 触控板双指缩放 (NativeGesture)\"\"\"\n def __init__(self, preview):\n super().__init__(preview)\n self._preview = preview\n self._pending = False\n\n def _is_preview_child(self, widget):\n \"\"\"检查 widget 是否属于 preview 控件树\"\"\"\n try:\n w = widget\n for _ in range(30):\n if w is self._preview:\n return True\n p = w.parentWidget() if hasattr(w, 'parentWidget') else None\n if p is None:\n return False\n w = p\n except RuntimeError:\n return False\n return False\n\n def _do_zoom(self):\n \"\"\"延迟执行缩放,避免在事件处理期间修改控件树\"\"\"\n self._pending = False\n try:\n acc = self._preview._pinch_accumulator\n if abs(acc) > 0.08:\n new_zoom = self._preview.zoom * (1.0 + acc * 0.15)\n new_zoom = max(0.3, min(new_zoom, 5.0))\n self._preview._pinch_accumulator = 0.0\n self._preview._apply_zoom(new_zoom)\n except (RuntimeError, AttributeError):\n pass\n\n def eventFilter(self, obj, event):\n try:\n if event.type() == QEvent.NativeGesture:\n if event.gestureType() == Qt.ZoomNativeGesture and self._is_preview_child(obj):\n self._preview._pinch_accumulator += event.value()\n if not self._pending and abs(self._preview._pinch_accumulator) > 0.08:\n self._pending = True\n QTimer.singleShot(0, self._do_zoom)\n return True\n except (RuntimeError, AttributeError):\n pass\n return False\n\n\nclass _AutoGrowTextEdit(QWidget):\n \"\"\"自适应高度的输入框 — 内容少时单行,多时自动向上增长(最多 5 行)\"\"\"\n returnPressed = pyqtSignal()\n\n def __init__(self, placeholder=\"\"):\n super().__init__()\n lo = QVBoxLayout(self); lo.setContentsMargins(0, 0, 0, 0); lo.setSpacing(0)\n from PyQt5.QtWidgets import QTextEdit\n self._te = QTextEdit()\n self._te.setPlaceholderText(placeholder)\n self._te.setAcceptRichText(False)\n self._te.setStyleSheet(\n f\"QTextEdit{{background:transparent;border:none;\"\n f\"padding:4px 6px;font-size:13px;font-family:'Helvetica Neue','PingFang SC';\"\n f\"color:{_C['t1']};}}\")\n self._te.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._te.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._te.document().contentsChanged.connect(self._adjust_height)\n self._te.installEventFilter(self)\n lo.addWidget(self._te)\n self._min_h = 36; self._max_h = 120\n self._te.setFixedHeight(self._min_h)\n\n def text(self):\n return self._te.toPlainText()\n\n def clear(self):\n self._te.clear()\n\n def setPlaceholderText(self, t):\n self._te.setPlaceholderText(t)\n\n def _adjust_height(self):\n \"\"\"只在实际行数变化时调整高度(按行跳变,不逐像素增长)\"\"\"\n line_h = 20 # 13px font ≈ 20px line height\n doc_h = self._te.document().size().height()\n new_lines = max(1, round(doc_h / line_h))\n if not hasattr(self, '_cur_lines'):\n self._cur_lines = 1\n if new_lines != self._cur_lines:\n self._cur_lines = new_lines\n target_h = max(self._min_h, min(new_lines * line_h + 8, self._max_h))\n self._te.setFixedHeight(target_h)\n\n def eventFilter(self, obj, event):\n if obj == self._te and event.type() == QEvent.KeyPress:\n if event.key() in (Qt.Key_Return, Qt.Key_Enter) and not event.modifiers():\n self.returnPressed.emit()\n return True\n return super().eventFilter(obj, event)\n\n\nclass _FlowLayout(QLayout):\n \"\"\"自动换行布局 — 按钮放不下时自动折行,避免文字被截断\"\"\"\n def __init__(self, parent=None, margin=0, spacing=-1):\n super().__init__(parent)\n if parent is not None:\n self.setContentsMargins(margin, margin, margin, margin)\n self._items = []\n self._spacing = spacing\n\n def addItem(self, item): self._items.append(item)\n def count(self): return len(self._items)\n def itemAt(self, idx): return self._items[idx] if 0 <= idx < len(self._items) else None\n def takeAt(self, idx):\n if 0 <= idx < len(self._items):\n return self._items.pop(idx)\n return None\n\n def expandingDirections(self): return Qt.Orientations()\n def hasHeightForWidth(self): return True\n def heightForWidth(self, w): return self._do_layout(QRect(0, 0, w, 0), test=True)\n\n def setGeometry(self, rect):\n super().setGeometry(rect)\n self._do_layout(rect, test=False)\n\n def sizeHint(self): return self.minimumSize()\n def minimumSize(self):\n s = QSize()\n for item in self._items:\n s = s.expandedTo(item.minimumSize())\n m = self.contentsMargins()\n s += QSize(m.left() + m.right(), m.top() + m.bottom())\n return s\n\n def _do_layout(self, rect, test=False):\n m = self.contentsMargins()\n effective = rect.adjusted(m.left(), m.top(), -m.right(), -m.bottom())\n x, y = effective.x(), effective.y()\n line_h = 0\n sp = self._spacing if self._spacing >= 0 else 4\n for item in self._items:\n w = item.sizeHint().width()\n h = item.sizeHint().height()\n if x + w > effective.right() + 1 and line_h > 0:\n x = effective.x()\n y += line_h + sp\n line_h = 0\n if not test:\n item.setGeometry(QRect(x, y, w, h))\n x += w + sp\n line_h = max(line_h, h)\n return y + line_h - rect.y() + m.bottom()\n\n\n# ── AI 快捷操作 预设库 ──────────────────────────────────────────\n# id 用于标识,label 显示在按钮上,prompt 是发给 LLM 的指令\n# type=\"summary\" 走 SummaryWorker(内置摘要引擎),其余走 QAWorker\n\n_BUILTIN_PROMPTS = [\n {\n \"id\": \"summary\", \"label\": \"📝 摘要\", \"prompt\": \"\", \"type\": \"summary\",\n \"desc\": \"结构化论文摘要(内置引擎)\",\n },\n {\n \"id\": \"explain\", \"label\": \"📖 讲解\",\n \"desc\": \"通俗讲解论文核心内容\",\n \"prompt\": (\n \"请用通俗易懂的语言讲解这篇论文,假设读者具有本科水平但不熟悉该细分领域。\\n\\n\"\n \"请按以下结构输出:\\n\"\n \"## 一句话总结\\n用一句话概括论文在做什么。\\n\\n\"\n \"## 研究背景\\n这个领域之前遇到了什么问题?为什么需要这项研究?\\n\\n\"\n \"## 核心方法\\n作者提出了什么方法或思路?用类比或直觉解释,避免公式。\\n\\n\"\n \"## 主要发现\\n实验结果说明了什么?和之前的方法相比有什么提升?\\n\\n\"\n \"## 为什么重要\\n这项工作对该领域或实际应用有什么意义?\"\n ),\n },\n {\n \"id\": \"questions\", \"label\": \"❓ 深度提问\",\n \"desc\": \"生成 5 个深度问题帮助理解\",\n \"prompt\": (\n \"请基于这篇论文,提出 5 个有深度的问题,帮助读者批判性地理解论文内容。\\n\\n\"\n \"要求:\\n\"\n \"- 每个问题聚焦论文的不同方面(方法、实验、假设、局限性、应用前景等)\\n\"\n \"- 每个问题后附 2-3 句解释,说明为什么这个问题值得思考\\n\"\n \"- 问题应该有启发性,而非简单的事实复述\\n\\n\"\n \"格式:\\n**Q1: [问题]**\\n[为什么值得思考]\\n\\n依此类推。\"\n ),\n },\n {\n \"id\": \"critique\", \"label\": \"🔍 批判分析\",\n \"desc\": \"评估论文的优缺点和局限性\",\n \"prompt\": (\n \"请对这篇论文进行批判性分析,像一位资深审稿人一样思考。\\n\\n\"\n \"请从以下维度评价:\\n\"\n \"## 主要优点\\n论文在方法、实验设计、写作等方面做得好的地方(2-3 点)\\n\\n\"\n \"## 潜在不足\\n方法论上的局限、实验设计的缺陷、未讨论的边界条件(2-3 点)\\n\\n\"\n \"## 可信度评估\\n实验结果是否充分支撑了论文的结论?有没有过度声称?\\n\\n\"\n \"## 改进建议\\n如果你是作者,你会在哪些方面做进一步的工作?\"\n ),\n },\n {\n \"id\": \"methods\", \"label\": \"🧪 方法详解\",\n \"desc\": \"深入解析论文的技术方法\",\n \"prompt\": (\n \"请深入解析这篇论文的技术方法,适合有一定基础的读者。\\n\\n\"\n \"请涵盖:\\n\"\n \"## 问题形式化\\n论文要解决的问题是如何数学/形式化定义的?\\n\\n\"\n \"## 方法流程\\n按步骤描述方法的完整流程,用编号列表。\\n\\n\"\n \"## 关键创新点\\n和现有方法相比,核心创新在哪里?\\n\\n\"\n \"## 实验设置\\n用了什么数据集、基线方法、评估指标?\\n\\n\"\n \"## 核心结果\\n最重要的实验结论是什么?数据支撑如何?\"\n ),\n },\n {\n \"id\": \"related\", \"label\": \"🔗 相关工作\",\n \"desc\": \"梳理论文引用的相关研究脉络\",\n \"prompt\": (\n \"请梳理这篇论文的相关工作和研究脉络。\\n\\n\"\n \"请输出:\\n\"\n \"## 研究领域定位\\n这篇论文属于哪个研究方向?\\n\\n\"\n \"## 关键前置工作\\n论文建立在哪些重要的前人工作之上?列出 3-5 篇最重要的,并说明关系。\\n\\n\"\n \"## 方法演进\\n该领域的方法是如何一步步发展到本文的?用时间线或逻辑链描述。\\n\\n\"\n \"## 与竞争方法的对比\\n本文方法与最相近的竞争方法有什么本质区别?\"\n ),\n },\n {\n \"id\": \"application\", \"label\": \"💡 应用场景\",\n \"desc\": \"分析论文成果的实际应用可能性\",\n \"prompt\": (\n \"请分析这篇论文的研究成果可以如何应用到实际场景中。\\n\\n\"\n \"请涵盖:\\n\"\n \"## 直接应用\\n基于论文当前的成果,最可能直接落地的应用场景是什么?\\n\\n\"\n \"## 潜在扩展\\n如果进一步发展,还可能应用在哪些领域?\\n\\n\"\n \"## 落地挑战\\n从研究到实际部署,可能遇到哪些技术或工程上的困难?\\n\\n\"\n \"## 商业价值\\n这项技术对行业可能产生什么影响?\"\n ),\n },\n {\n \"id\": \"keyterms\", \"label\": \"📚 术语表\",\n \"desc\": \"提取并解释论文中的关键术语\",\n \"prompt\": (\n \"请提取这篇论文中的关键专业术语和概念,生成一个术语表。\\n\\n\"\n \"要求:\\n\"\n \"- 提取 8-12 个最重要的术语\\n\"\n \"- 每个术语给出:中文翻译(如适用)、一句话定义、在论文中的具体含义\\n\"\n \"- 按照术语在论文中出现的逻辑顺序排列\\n\\n\"\n \"格式:\\n**术语名** (English Term)\\n定义:...\\n论文中的含义:...\"\n ),\n },\n]\n\n# 默认激活的 3 个(显示在快捷栏上)\n_DEFAULT_ACTIVE_IDS = [\"summary\", \"explain\", \"questions\"]\n\n\nclass QAPanelWidget(QWidget):\n \"\"\"AI 论文助手面板 — 可自定义快捷操作库 + 聊天界面\"\"\"\n\n def __init__(self):\n super().__init__()\n self.setMinimumWidth(260); self.setMaximumWidth(420)\n self._busy = False # 是否有 worker 在运行\n lo = QVBoxLayout(self); lo.setContentsMargins(12, 10, 12, 12); lo.setSpacing(0)\n\n # ── 头部 ──\n hdr = QHBoxLayout(); hdr.setSpacing(6)\n t = QLabel(\"AI 助手\"); t.setStyleSheet(\n \"font-size:13px;font-weight:600;background:transparent;letter-spacing:0.3px;\")\n hdr.addWidget(t); hdr.addStretch()\n self._svc_label = QLabel(\"\")\n self._svc_label.setStyleSheet(f\"font-size:10px;color:{_C['t3']};background:transparent;\")\n hdr.addWidget(self._svc_label)\n clr = QPushButton(\"清空\"); clr.setObjectName(\"Gh\")\n clr.setStyleSheet(\"font-size:10px;padding:2px 6px;\")\n clr.setCursor(Qt.PointingHandCursor); clr.clicked.connect(self._clear)\n hdr.addWidget(clr)\n self._cfg_btn = QPushButton(\"⚙\")\n self._cfg_btn.setCursor(Qt.PointingHandCursor)\n self._cfg_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:none;font-size:13px;\"\n f\"padding:2px 5px;color:{_C['t3']};}}\"\n f\"QPushButton:hover{{color:{_C['acc']};}}\")\n self._cfg_btn.clicked.connect(self._open_settings)\n hdr.addWidget(self._cfg_btn)\n lo.addLayout(hdr)\n lo.addSpacing(6)\n\n # ── 快捷操作栏 ──\n self._quick_frame = QFrame()\n self._quick_frame.setStyleSheet(\"QFrame{background:transparent;border:none;}\")\n self._quick_layout = QHBoxLayout(self._quick_frame)\n self._quick_layout.setContentsMargins(0, 0, 0, 0)\n self._quick_layout.setSpacing(6)\n self._quick_btns = []\n lo.addWidget(self._quick_frame)\n lo.addSpacing(8)\n\n # ── 聊天区 ──\n self._chat_scroll = QScrollArea(); self._chat_scroll.setWidgetResizable(True)\n self._chat_scroll.setFrameShape(QFrame.NoFrame)\n self._chat_container = QWidget()\n self._chat_layout = QVBoxLayout(self._chat_container)\n self._chat_layout.setContentsMargins(0, 4, 0, 4); self._chat_layout.setSpacing(12)\n self._chat_layout.setAlignment(Qt.AlignTop)\n self._chat_scroll.setWidget(self._chat_container)\n lo.addWidget(self._chat_scroll, 1)\n lo.addSpacing(8)\n\n # ── 输入区 ──\n self._inp_frame = QFrame()\n self._inp_frame.setStyleSheet(\n f\"QFrame{{background:{_C['card']};border:0.5px solid {_C['brd']};\"\n f\"border-radius:12px;}}\")\n inp_lo = QHBoxLayout(self._inp_frame)\n inp_lo.setContentsMargins(10, 6, 6, 6); inp_lo.setSpacing(6)\n self._input = _AutoGrowTextEdit(\"输入问题…\")\n inp_lo.addWidget(self._input, 1)\n self._send_btn = QPushButton(\"发送\"); self._send_btn.setObjectName(\"Pr\")\n self._send_btn.setStyleSheet(\"font-size:11px;padding:6px 16px;border-radius:8px;\")\n self._send_btn.setCursor(Qt.PointingHandCursor)\n self._send_btn.clicked.connect(self._send)\n self._send_btn.setFixedHeight(32)\n inp_lo.addWidget(self._send_btn, 0, Qt.AlignBottom)\n lo.addWidget(self._inp_frame)\n self._input.returnPressed.connect(self._send)\n self._char_count = None\n\n # ── 状态 ──\n self._paper_text = \"\"\n self._pdf_path = None\n self._messages = []\n self._worker = None\n self._summary_worker = None\n self._current_ai_bubble = None\n self._streaming_text = \"\"\n\n # 加载 prompt 库 & 激活列表\n self._load_library()\n\n # ══════════════════════════════════════════════════════════════\n # Prompt 库 & 持久化\n # ══════════════════════════════════════════════════════════════\n\n def _load_library(self):\n \"\"\"从配置加载 prompt 库和激活列表\"\"\"\n cfg = UserConfigManager.load()\n # 库:内置 + 用户自定义\n saved_lib = cfg.get(\"ai_prompt_library\", None)\n if saved_lib is not None:\n self._library = saved_lib\n else:\n self._library = [dict(p) for p in _BUILTIN_PROMPTS]\n # 激活的 ID 列表(显示在快捷栏,最多 3 个)\n self._active_ids = cfg.get(\"ai_active_ids\", list(_DEFAULT_ACTIVE_IDS))\n self._rebuild_quick_bar()\n\n def _save_library(self):\n cfg = UserConfigManager.load()\n cfg[\"ai_prompt_library\"] = self._library\n cfg[\"ai_active_ids\"] = self._active_ids\n UserConfigManager.save(cfg)\n\n def _get_active_prompts(self):\n \"\"\"返回当前激活的 prompt 列表(按 _active_ids 顺序)\"\"\"\n id_map = {p[\"id\"]: p for p in self._library}\n return [id_map[aid] for aid in self._active_ids if aid in id_map]\n\n # ══════════════════════════════════════════════════════════════\n # 快捷栏\n # ══════════════════════════════════════════════════════════════\n\n def _rebuild_quick_bar(self):\n \"\"\"根据激活列表重建快捷按钮 — 等宽圆角药丸\"\"\"\n while self._quick_layout.count():\n item = self._quick_layout.takeAt(0)\n w = item.widget()\n if w:\n w.deleteLater()\n self._quick_btns = []\n c = _C\n for prompt_data in self._get_active_prompts():\n b = QPushButton(prompt_data[\"label\"])\n b.setCursor(Qt.PointingHandCursor)\n b.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)\n b.setFixedHeight(32)\n b.setStyleSheet(\n f\"QPushButton{{background:{c['card']};border:0.5px solid {c['card_b']};\"\n f\"border-radius:16px;padding:0 10px;font-size:12px;color:{c['t1']};}}\"\n f\"QPushButton:hover{{background:{c['acc_l']};border-color:{c['acc']};}}\"\n f\"QPushButton:pressed{{background:{c['acc']};color:white;\"\n f\"border-color:{c['acc']};}}\"\n f\"QPushButton:disabled{{opacity:0.5;}}\")\n pid = prompt_data[\"id\"]\n b.clicked.connect(lambda _, _pid=pid: self._exec_by_id(_pid))\n self._quick_layout.addWidget(b, 1) # stretch=1 等宽分布\n self._quick_btns.append(b)\n\n def _exec_by_id(self, prompt_id):\n \"\"\"执行指定 ID 的快捷操作\"\"\"\n try:\n if self._busy:\n return\n id_map = {p[\"id\"]: p for p in self._library}\n act = id_map.get(prompt_id)\n if not act:\n return\n if act.get(\"type\") == \"summary\":\n self._run_summary()\n else:\n prompt = act.get(\"prompt\", \"\")\n if prompt:\n self._run_preset(prompt, act.get(\"label\", \"\"))\n except Exception:\n pass\n\n # ══════════════════════════════════════════════════════════════\n # ⚙ 设置弹窗 — prompt 库管理 + 激活选择\n # ══════════════════════════════════════════════════════════════\n\n def _open_settings(self):\n \"\"\"打开 prompt 库配置弹窗\"\"\"\n try:\n self._do_open_settings()\n except Exception:\n pass\n\n def _do_open_settings(self):\n c = _C\n dlg = QDialog(self)\n dlg.setWindowTitle(\"AI 快捷操作配置\")\n dlg.setMinimumSize(520, 580)\n dlg.resize(560, 660)\n dlg.setStyleSheet(f\"\"\"\n QDialog{{background:{c['bg']};}}\n QLabel{{color:{c['t1']};background:transparent;}}\n QScrollArea{{background:{c['bg']};border:none;}}\n QWidget#scroll_inner{{background:{c['bg']};}}\n QLineEdit{{background:{c['inp']};border:1px solid {c['inp_b']};\n border-radius:6px;padding:6px 10px;font-size:13px;color:{c['t1']};\n min-height:20px;}}\n QLineEdit:focus{{border-color:{c['acc']};}}\n QPlainTextEdit{{background:{c['inp']};border:1px solid {c['inp_b']};\n border-radius:8px;padding:8px 10px;font-size:12px;color:{c['t1']};\n line-height:160%;}}\n QPlainTextEdit:focus{{border-color:{c['acc']};}}\n QCheckBox{{color:{c['t1']};spacing:6px;}}\n QCheckBox::indicator{{width:18px;height:18px;border-radius:5px;\n border:1.5px solid {c['inp_b']};background:{c['inp']};}}\n QCheckBox::indicator:checked{{background:{c['acc']};border-color:{c['acc']};}}\n \"\"\")\n dlo = QVBoxLayout(dlg); dlo.setContentsMargins(24, 20, 24, 18); dlo.setSpacing(14)\n\n # ── 标题行 ──\n hdr = QHBoxLayout()\n title = QLabel(\"快捷操作库\"); title.setStyleSheet(\"font-size:16px;font-weight:600;\")\n hdr.addWidget(title); hdr.addStretch()\n hint_l = QLabel(\"勾选最多 3 个显示在快捷栏\")\n hint_l.setStyleSheet(f\"font-size:11px;color:{c['t3']};\")\n hdr.addWidget(hint_l)\n dlo.addLayout(hdr)\n\n # ── 滚动区 ──\n scroll = QScrollArea(); scroll.setWidgetResizable(True)\n scroll.setFrameShape(QFrame.NoFrame)\n container = QWidget(); container.setObjectName(\"scroll_inner\")\n clo = QVBoxLayout(container); clo.setContentsMargins(0, 0, 0, 0); clo.setSpacing(10)\n clo.setAlignment(Qt.AlignTop)\n\n _cards = [] # list of entry dicts\n\n def _make_card(p, parent_layout):\n pid = p.get(\"id\", \"\")\n tp = p.get(\"type\", \"\")\n card = QFrame()\n card.setStyleSheet(\n f\"QFrame{{background:{c['card']};border:0.5px solid {c['card_b']};\"\n f\"border-radius:12px;}}\")\n card_lo = QVBoxLayout(card)\n card_lo.setContentsMargins(14, 12, 14, 12); card_lo.setSpacing(8)\n\n # ── 第一行:勾选 + 名称 + 删除 ──\n r1 = QHBoxLayout(); r1.setSpacing(10)\n cb = QCheckBox()\n cb.setFixedSize(22, 22)\n cb.setChecked(pid in self._active_ids)\n cb.setToolTip(\"勾选后显示在快捷栏\")\n r1.addWidget(cb, 0, Qt.AlignVCenter)\n name_inp = QLineEdit(p.get(\"label\", \"\")); name_inp.setMaxLength(12)\n name_inp.setPlaceholderText(\"按钮名称\")\n name_inp.setFixedHeight(32)\n r1.addWidget(name_inp, 1, Qt.AlignVCenter)\n is_builtin = any(bp[\"id\"] == pid for bp in _BUILTIN_PROMPTS)\n del_btn = QPushButton(\"×\")\n del_btn.setCursor(Qt.PointingHandCursor)\n del_btn.setFixedSize(26, 26)\n del_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:none;color:{c['t3']};\"\n f\"font-size:16px;font-weight:600;border-radius:13px;}}\"\n f\"QPushButton:hover{{background:rgba(255,59,48,0.1);color:{c['err']};}}\")\n if is_builtin:\n del_btn.setVisible(False)\n r1.addWidget(del_btn, 0, Qt.AlignVCenter)\n card_lo.addLayout(r1)\n\n # ── 第二行:描述 ──\n desc = p.get(\"desc\", \"\")\n if desc:\n dl = QLabel(desc)\n dl.setStyleSheet(f\"font-size:11px;color:{c['t3']};padding-left:32px;\")\n card_lo.addWidget(dl)\n\n # ── 第三行:Prompt 编辑 ──\n if tp == \"summary\":\n hl = QLabel(\"🔧 内置摘要引擎,自动分析论文结构并生成结构化摘要\")\n hl.setStyleSheet(\n f\"font-size:11px;color:{c['t3']};padding:6px 10px;\"\n f\"background:{c['bg2']};border-radius:6px;\")\n hl.setWordWrap(True)\n card_lo.addWidget(hl)\n prompt_inp = None\n else:\n prompt_inp = QPlainTextEdit()\n prompt_inp.setPlainText(p.get(\"prompt\", \"\"))\n prompt_inp.setPlaceholderText(\"输入提示词…\\n\\n描述你希望 AI 对论文做什么分析\")\n prompt_inp.setMinimumHeight(90)\n prompt_inp.setMaximumHeight(200)\n card_lo.addWidget(prompt_inp)\n\n parent_layout.addWidget(card)\n entry = {\"cb\": cb, \"name\": name_inp, \"prompt\": prompt_inp,\n \"id\": pid, \"type\": tp, \"card\": card}\n _cards.append(entry)\n\n # 删除\n def _del(_, _e=entry):\n _e[\"card\"].setVisible(False)\n _e[\"card\"].deleteLater()\n _e[\"deleted\"] = True\n del_btn.clicked.connect(_del)\n\n # 勾选约束:最多 3 个\n def _on_check(state, _e=entry):\n checked = [e for e in _cards\n if not e.get(\"deleted\") and e[\"cb\"].isChecked()]\n if len(checked) > 3:\n _e[\"cb\"].setChecked(False)\n cb.stateChanged.connect(_on_check)\n\n for p in self._library:\n _make_card(p, clo)\n\n scroll.setWidget(container)\n dlo.addWidget(scroll, 1)\n\n # ── 底部按钮区 ──\n blo = QHBoxLayout(); blo.setSpacing(8)\n # 新建\n add_btn = QPushButton(\"+ 新建\")\n add_btn.setCursor(Qt.PointingHandCursor)\n add_btn.setStyleSheet(\n f\"QPushButton{{background:{c['acc_l']};border:none;border-radius:8px;\"\n f\"padding:8px 14px;font-size:12px;color:{c['acc']};}}\"\n f\"QPushButton:hover{{background:{c['acc']};color:white;}}\")\n _counter = [0]\n def _on_add():\n _counter[0] += 1\n new_p = {\"id\": f\"custom_{_counter[0]}_{id(dlg)}\",\n \"label\": \"🔧 自定义\", \"prompt\": \"\", \"desc\": \"自定义提示词\"}\n _make_card(new_p, clo)\n QTimer.singleShot(50, lambda: scroll.verticalScrollBar().setValue(\n scroll.verticalScrollBar().maximum()))\n add_btn.clicked.connect(_on_add)\n blo.addWidget(add_btn)\n\n # 导入\n imp_btn = QPushButton(\"📥 导入\")\n imp_btn.setCursor(Qt.PointingHandCursor)\n imp_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:1px solid {c['inp_b']};\"\n f\"border-radius:8px;padding:8px 14px;font-size:12px;color:{c['t2']};}}\"\n f\"QPushButton:hover{{border-color:{c['acc']};color:{c['acc']};}}\")\n def _on_import():\n path, _ = QFileDialog.getOpenFileName(\n dlg, \"导入提示词\",\n str(Path.home()),\n \"文本文件 (*.txt *.md);;JSON (*.json);;所有文件 (*)\")\n if not path:\n return\n try:\n content = Path(path).read_text(encoding=\"utf-8\")\n fname = Path(path).stem\n # 尝试 JSON\n try:\n data = json.loads(content)\n items = data if isinstance(data, list) else [data]\n for item in items:\n _counter[0] += 1\n new_p = {\n \"id\": f\"import_{_counter[0]}_{id(dlg)}\",\n \"label\": item.get(\"label\", f\"📥 {fname}\"),\n \"prompt\": item.get(\"prompt\", content),\n \"desc\": item.get(\"desc\", \"导入的提示词\"),\n }\n _make_card(new_p, clo)\n except (json.JSONDecodeError, AttributeError):\n # 纯文本 → 整个文件内容作为 prompt\n _counter[0] += 1\n new_p = {\n \"id\": f\"import_{_counter[0]}_{id(dlg)}\",\n \"label\": f\"📥 {fname[:8]}\",\n \"prompt\": content,\n \"desc\": \"导入的提示词\",\n }\n _make_card(new_p, clo)\n QTimer.singleShot(50, lambda: scroll.verticalScrollBar().setValue(\n scroll.verticalScrollBar().maximum()))\n except Exception as e:\n QMessageBox.warning(dlg, \"导入失败\", str(e))\n imp_btn.clicked.connect(_on_import)\n blo.addWidget(imp_btn)\n\n # 恢复默认\n reset_btn = QPushButton(\"恢复默认\")\n reset_btn.setCursor(Qt.PointingHandCursor)\n reset_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:1px solid {c['inp_b']};\"\n f\"border-radius:8px;padding:8px 14px;font-size:12px;color:{c['t2']};}}\"\n f\"QPushButton:hover{{border-color:{c['acc']};color:{c['acc']};}}\")\n def _on_reset():\n for entry in _cards:\n entry[\"deleted\"] = True\n entry[\"card\"].setVisible(False)\n entry[\"card\"].deleteLater()\n _cards.clear()\n for p in _BUILTIN_PROMPTS:\n _make_card(dict(p), clo)\n for entry in _cards:\n entry[\"cb\"].setChecked(entry[\"id\"] in _DEFAULT_ACTIVE_IDS)\n reset_btn.clicked.connect(_on_reset)\n blo.addWidget(reset_btn)\n\n blo.addStretch()\n ok_btn = QPushButton(\"保存\")\n ok_btn.setCursor(Qt.PointingHandCursor)\n ok_btn.setStyleSheet(\n f\"QPushButton{{background:{c['acc']};color:white;border:none;\"\n f\"font-size:13px;padding:8px 30px;border-radius:8px;}}\"\n f\"QPushButton:hover{{background:{c['acc_h']};}}\")\n ok_btn.clicked.connect(dlg.accept)\n blo.addWidget(ok_btn)\n dlo.addLayout(blo)\n\n # ── 执行弹窗 ──\n if dlg.exec_() == QDialog.Accepted:\n new_library = []\n new_active = []\n for entry in _cards:\n if entry.get(\"deleted\"):\n continue\n try:\n label = entry[\"name\"].text().strip()\n except RuntimeError:\n continue\n if not label:\n continue\n p = {\"id\": entry[\"id\"], \"label\": label, \"type\": entry[\"type\"]}\n p[\"desc\"] = next((bp.get(\"desc\", \"\") for bp in _BUILTIN_PROMPTS\n if bp[\"id\"] == entry[\"id\"]), \"自定义提示词\")\n if entry[\"type\"] == \"summary\":\n p[\"prompt\"] = \"\"\n else:\n p[\"prompt\"] = entry[\"prompt\"].toPlainText() if entry[\"prompt\"] else \"\"\n new_library.append(p)\n if entry[\"cb\"].isChecked():\n new_active.append(entry[\"id\"])\n if new_active:\n self._library = new_library\n self._active_ids = new_active[:3]\n self._save_library()\n self._rebuild_quick_bar()\n\n # ══════════════════════════════════════════════════════════════\n # 论文上下文 & 聊天\n # ══════════════════════════════════════════════════════════════\n\n def set_paper_context(self, pdf_path):\n \"\"\"提取 PDF 文本作为问答上下文\"\"\"\n self._pdf_path = pdf_path\n try:\n doc = fitz.open(pdf_path)\n text = \"\"\n for i in range(min(15, len(doc))):\n text += doc[i].get_text()\n doc.close()\n self._paper_text = text[:10000]\n except Exception:\n self._paper_text = \"\"\n self._messages = []\n self._clear()\n try:\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n self._svc_label.setText(f\"via {svc['name']} · {svc['model']}\" if svc else \"未配置\")\n self._send_btn.setEnabled(bool(svc))\n except Exception:\n pass\n\n def _clear(self):\n try:\n self._messages = []\n self._current_ai_bubble = None\n self._stop_workers()\n while self._chat_layout.count():\n w = self._chat_layout.takeAt(0).widget()\n if w:\n w.deleteLater()\n except Exception:\n pass\n\n def _add_bubble(self, text, is_user=True):\n \"\"\"添加消息气泡\"\"\"\n row = QWidget(); row.setStyleSheet(\"background:transparent;\")\n row_lo = QHBoxLayout(row)\n row_lo.setContentsMargins(0, 0, 0, 0); row_lo.setSpacing(0)\n bubble = QLabel()\n bubble.setWordWrap(True)\n bubble.setTextInteractionFlags(Qt.TextSelectableByMouse)\n bubble.setMaximumWidth(300)\n c = _C\n if is_user:\n bubble.setText(text)\n bubble.setStyleSheet(\n f\"background:{c['acc']};color:white;border-radius:12px;\"\n f\"border-bottom-right-radius:4px;\"\n f\"padding:8px 12px;font-size:12px;\"\n f\"font-family:'Helvetica Neue','PingFang SC';\")\n row_lo.addStretch(); row_lo.addWidget(bubble)\n else:\n bubble.setTextFormat(Qt.RichText)\n html = _md2html(text) if text and not text.startswith('⏳') else text\n bubble.setText(html)\n bubble.setStyleSheet(\n f\"background:{c['card']};border:0.5px solid {c['card_b']};\"\n f\"border-radius:12px;border-top-left-radius:4px;\"\n f\"padding:10px 14px;font-size:12px;line-height:165%;\"\n f\"font-family:'Helvetica Neue','PingFang SC';\")\n row_lo.addWidget(bubble); row_lo.addStretch()\n self._chat_layout.addWidget(row)\n QTimer.singleShot(50, lambda: self._safe_scroll_bottom())\n return bubble\n\n def _safe_scroll_bottom(self):\n try:\n sb = self._chat_scroll.verticalScrollBar()\n sb.setValue(sb.maximum())\n except Exception:\n pass\n\n # ══════════════════════════════════════════════════════════════\n # AI Worker 管理(摘要 / 问答 / 流式)\n # ══════════════════════════════════════════════════════════════\n\n def _set_busy(self, busy):\n self._busy = busy\n try:\n self._send_btn.setEnabled(not busy)\n for b in self._quick_btns:\n b.setEnabled(not busy)\n except Exception:\n pass\n\n def _run_summary(self):\n \"\"\"使用 SummaryWorker 生成结构化摘要\"\"\"\n try:\n if not self._pdf_path or not os.path.exists(self._pdf_path):\n return\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if not svc:\n self._add_bubble(\"⚠️ 未配置 AI 服务\", is_user=False)\n return\n self._stop_workers()\n self._set_busy(True)\n self._add_bubble(\"📝 生成摘要\", is_user=True)\n self._current_ai_bubble = self._add_bubble(\"⏳ 正在分析论文…\", is_user=False)\n self._summary_worker = SummaryWorker(self._pdf_path)\n self._summary_worker.result.connect(self._on_summary_result)\n self._summary_worker.error.connect(self._on_qa_error)\n self._summary_worker.finished.connect(self._on_summary_done)\n self._summary_worker.start()\n except Exception:\n self._set_busy(False)\n\n def _run_preset(self, prompt, label=\"\"):\n \"\"\"用预设 prompt 发起问答\"\"\"\n try:\n if not self._paper_text:\n return\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if not svc:\n self._add_bubble(\"⚠️ 未配置 AI 服务\", is_user=False)\n return\n self._stop_workers()\n self._set_busy(True)\n self._add_bubble(label or (prompt[:15] + \"…\"), is_user=True)\n self._messages = [\n {\"role\": \"system\", \"content\": (\n \"你是论文阅读助手。以下是论文全文内容(可能被截断),请根据论文内容回答用户问题。\\n\"\n \"回答要准确、有深度,用中文。使用 Markdown 格式化输出。\\n\"\n \"如果论文中没有相关信息,请如实说明。\\n\\n\"\n f\"论文内容:\\n{self._paper_text}\"\n )},\n {\"role\": \"user\", \"content\": prompt},\n ]\n self._current_ai_bubble = self._add_bubble(\"⏳ 思考中…\", is_user=False)\n self._worker = QAWorker(list(self._messages))\n self._worker.chunk.connect(self._on_chunk)\n self._worker.finished.connect(self._on_qa_done)\n self._worker.error.connect(self._on_qa_error)\n self._worker.start()\n self._streaming_text = \"\"\n except Exception:\n self._set_busy(False)\n\n def _stop_workers(self):\n \"\"\"断开并停止所有 AI worker\"\"\"\n for attr in ('_worker', '_summary_worker'):\n w = getattr(self, attr, None)\n if w:\n for sig in ('chunk', 'result', 'finished', 'error'):\n try:\n getattr(w, sig).disconnect()\n except Exception:\n pass\n setattr(self, attr, None)\n try:\n w.quit()\n except Exception:\n pass\n\n @staticmethod\n def _widget_alive(w):\n try:\n w.objectName()\n return True\n except RuntimeError:\n return False\n\n def _on_summary_result(self, text):\n try:\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(_md2html(text))\n QTimer.singleShot(0, self._safe_scroll_bottom)\n except Exception:\n pass\n\n def _on_summary_done(self):\n try:\n self._set_busy(False)\n self._current_ai_bubble = None\n if self._summary_worker:\n w = self._summary_worker; self._summary_worker = None\n w.quit()\n except Exception:\n self._set_busy(False)\n\n def _on_chunk(self, text):\n try:\n self._streaming_text += text\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(_md2html(self._streaming_text))\n QTimer.singleShot(0, self._safe_scroll_bottom)\n except Exception:\n pass\n\n def _on_qa_done(self, full_text):\n try:\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(_md2html(full_text))\n self._messages.append({\"role\": \"assistant\", \"content\": full_text})\n self._set_busy(False)\n self._current_ai_bubble = None\n if self._worker:\n w = self._worker; self._worker = None\n w.quit()\n except Exception:\n self._set_busy(False)\n\n def _on_qa_error(self, err):\n try:\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(f\"⚠️ {err}\")\n self._set_busy(False)\n self._current_ai_bubble = None\n for attr in ('_worker', '_summary_worker'):\n w = getattr(self, attr, None)\n if w:\n setattr(self, attr, None)\n w.quit()\n except Exception:\n self._set_busy(False)\n\n def _send(self):\n try:\n q = self._input.text().strip()\n if not q or not self._paper_text or self._busy:\n return\n self._stop_workers()\n self._input.clear()\n self._set_busy(True)\n self._add_bubble(q, is_user=True)\n if not self._messages:\n self._messages.append({\n \"role\": \"system\",\n \"content\": (\n \"你是论文阅读助手。以下是论文全文内容(可能被截断),请根据论文内容回答用户问题。\\n\"\n \"回答要准确、有深度,用中文。使用 Markdown 格式化输出。\\n\"\n \"如果论文中没有相关信息,请如实说明。\\n\\n\"\n f\"论文内容:\\n{self._paper_text}\"\n )\n })\n self._messages.append({\"role\": \"user\", \"content\": q})\n self._current_ai_bubble = self._add_bubble(\"⏳ 思考中…\", is_user=False)\n self._worker = QAWorker(list(self._messages))\n self._worker.chunk.connect(self._on_chunk)\n self._worker.finished.connect(self._on_qa_done)\n self._worker.error.connect(self._on_qa_error)\n self._worker.start()\n self._streaming_text = \"\"\n except Exception:\n self._set_busy(False)\n\n # ══════════════════════════════════════════════════════════════\n # 主题切换\n # ══════════════════════════════════════════════════════════════\n\n def update_theme(self, c):\n try:\n self._svc_label.setStyleSheet(f\"font-size:10px;color:{c['t3']};background:transparent;\")\n self._inp_frame.setStyleSheet(\n f\"QFrame{{background:{c['card']};border:0.5px solid {c['brd']};\"\n f\"border-radius:12px;}}\")\n self._quick_frame.setStyleSheet(\"QFrame{background:transparent;border:none;}\")\n self._rebuild_quick_bar()\n self._cfg_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:none;font-size:13px;\"\n f\"padding:2px 5px;color:{c['t3']};}}\"\n f\"QPushButton:hover{{color:{c['acc']};}}\")\n except Exception:\n pass\n\n\nclass PreviewPage(QWidget):\n \"\"\"三合一预览: 顶部模式切换按钮 + PDF 显示\"\"\"\n fullscreen_toggled = pyqtSignal(bool)\n history_toggled = pyqtSignal()\n\n def __init__(self):\n super().__init__()\n self.doc = None\n self.current_page = 0\n self.zoom = 1.5\n self.output_files = {}\n self.current_mode = \"side_by_side\" # 默认 Side by Side\n self.setFocusPolicy(Qt.StrongFocus) # 接收键盘事件\n\n lo = QVBoxLayout(self); lo.setContentsMargins(0,0,0,0); lo.setSpacing(0)\n\n # 工具栏\n tb = QWidget(); tb.setObjectName(\"PT\"); tb.setFixedHeight(48)\n tl = QHBoxLayout(tb); tl.setContentsMargins(12,0,12,0); tl.setSpacing(6)\n\n # 模式切换按钮\n self.mode_btns = {}\n for label, mode in [(\"Dual\",\"dual\"),(\"Mono\",\"mono\"),(\"Side by Side\",\"side_by_side\")]:\n b = QPushButton(label); b.setObjectName(\"TB\"); b.setCursor(Qt.PointingHandCursor)\n b.setProperty(\"active\", mode == \"side_by_side\")\n b.clicked.connect(lambda _, m=mode: self.switch_mode(m))\n tl.addWidget(b); self.mode_btns[mode] = b\n\n tl.addSpacing(8)\n # 连续滚动 / 单页 切换\n self._continuous = True\n self.scroll_btn = QPushButton(\"连续\"); self.scroll_btn.setObjectName(\"TB\")\n self.scroll_btn.setCursor(Qt.PointingHandCursor)\n self.scroll_btn.setProperty(\"active\", True)\n self.scroll_btn.clicked.connect(self._toggle_continuous)\n tl.addWidget(self.scroll_btn)\n\n tl.addSpacing(12)\n ob = QPushButton(\"打开\"); ob.setObjectName(\"Sc\"); ob.setCursor(Qt.PointingHandCursor)\n ob.setStyleSheet(\"padding:6px 14px;font-size:13px;\")\n ob.clicked.connect(self.open_file); tl.addWidget(ob)\n\n tl.addStretch()\n\n # 翻页:首页 < 上一页 | 页码输入 / 总页数 | 下一页 > 末页\n first_btn = QPushButton(\"⟨⟨\"); first_btn.setObjectName(\"TB\"); first_btn.setFixedWidth(32)\n first_btn.setCursor(Qt.PointingHandCursor)\n first_btn.clicked.connect(self.first_page); tl.addWidget(first_btn)\n\n prev_btn = QPushButton(\"‹\"); prev_btn.setObjectName(\"TB\"); prev_btn.setFixedWidth(32)\n prev_btn.setCursor(Qt.PointingHandCursor)\n prev_btn.clicked.connect(self.prev_page); tl.addWidget(prev_btn)\n\n # 页码输入框\n self.page_input = QLineEdit(\"1\")\n self.page_input.setFixedWidth(42)\n self.page_input.setAlignment(Qt.AlignCenter)\n self.page_input.setObjectName(\"PageInput\")\n self.page_input.returnPressed.connect(self._jump_to_page)\n tl.addWidget(self.page_input)\n\n self.total_label = QLabel(\"/ —\")\n self.total_label.setObjectName(\"TotalLbl\")\n tl.addWidget(self.total_label)\n\n next_btn = QPushButton(\"›\"); next_btn.setObjectName(\"TB\"); next_btn.setFixedWidth(32)\n next_btn.setCursor(Qt.PointingHandCursor)\n next_btn.clicked.connect(self.next_page); tl.addWidget(next_btn)\n\n last_btn = QPushButton(\"⟩⟩\"); last_btn.setObjectName(\"TB\"); last_btn.setFixedWidth(32)\n last_btn.setCursor(Qt.PointingHandCursor)\n last_btn.clicked.connect(self.last_page); tl.addWidget(last_btn)\n\n tl.addStretch()\n\n # 缩放\n self.zoom_slider = QSlider(Qt.Horizontal)\n self.zoom_slider.setRange(30,400); self.zoom_slider.setValue(150)\n self.zoom_slider.setFixedWidth(90); self.zoom_slider.valueChanged.connect(self.on_zoom)\n tl.addWidget(self.zoom_slider)\n self.zoom_pct = QLabel(\"150%\")\n self.zoom_pct.setObjectName(\"ZoomPct\")\n self.zoom_pct.setFixedWidth(36)\n tl.addWidget(self.zoom_pct)\n # 适配模式:适宽 / 适页 两个按钮\n self._fit_mode = \"width\" # \"width\" 适宽 | \"page\" 适页\n self.fit_btn = QPushButton(\"适宽\"); self.fit_btn.setObjectName(\"TB\")\n self.fit_btn.setCursor(Qt.PointingHandCursor)\n self.fit_btn.setProperty(\"active\", True)\n self.fit_btn.clicked.connect(self._switch_to_fit_width)\n tl.addWidget(self.fit_btn)\n self.fitpage_btn = QPushButton(\"适页\"); self.fitpage_btn.setObjectName(\"TB\")\n self.fitpage_btn.setCursor(Qt.PointingHandCursor)\n self.fitpage_btn.clicked.connect(self._switch_to_fit_page)\n tl.addWidget(self.fitpage_btn)\n\n tl.addSpacing(6)\n self._is_fullscreen = False\n # 全屏中唤出历史面板\n self.hist_btn = QPushButton(\"历史\"); self.hist_btn.setObjectName(\"TB\")\n self.hist_btn.setCursor(Qt.PointingHandCursor)\n self.hist_btn.setVisible(False)\n self.hist_btn.clicked.connect(self.history_toggled.emit)\n tl.addWidget(self.hist_btn)\n # AI 问答按钮\n self.qa_btn = QPushButton(\"AI 问答\"); self.qa_btn.setObjectName(\"TB\")\n self.qa_btn.setCursor(Qt.PointingHandCursor)\n self.qa_btn.clicked.connect(self._toggle_qa_panel)\n tl.addWidget(self.qa_btn)\n # 高亮按钮\n self.hl_btn = QPushButton(\"高亮\"); self.hl_btn.setObjectName(\"TB\")\n self.hl_btn.setCursor(Qt.PointingHandCursor)\n self.hl_btn.setCheckable(True)\n self.hl_btn.clicked.connect(self._toggle_highlight)\n tl.addWidget(self.hl_btn)\n # 擦除按钮\n self.hl_erase_btn = QPushButton(\"擦除\"); self.hl_erase_btn.setObjectName(\"TB\")\n self.hl_erase_btn.setCursor(Qt.PointingHandCursor)\n self.hl_erase_btn.setCheckable(True)\n self.hl_erase_btn.clicked.connect(self._toggle_erase)\n self.hl_erase_btn.setVisible(False) # 进入高亮模式才显示\n tl.addWidget(self.hl_erase_btn)\n # 全屏按钮\n self.fs_btn = QPushButton(\"全屏\"); self.fs_btn.setObjectName(\"TB\")\n self.fs_btn.setCursor(Qt.PointingHandCursor)\n self.fs_btn.clicked.connect(self._toggle_fullscreen)\n tl.addWidget(self.fs_btn)\n\n lo.addWidget(tb)\n\n # 内容区:缩略图 + 主视图(QSplitter 支持拖拽调整缩略图宽度)\n self.body_splitter = QSplitter(Qt.Horizontal)\n self.body_splitter.setHandleWidth(1)\n self.body_splitter.setChildrenCollapsible(True)\n\n # 缩略图面板\n self.thumb_scroll = QScrollArea()\n self.thumb_scroll.setMinimumWidth(60); self.thumb_scroll.setMaximumWidth(240)\n self.thumb_scroll.setWidgetResizable(True); self.thumb_scroll.setFrameShape(QFrame.NoFrame)\n self.thumb_scroll.setObjectName(\"PA\"); self.thumb_scroll.setFocusPolicy(Qt.StrongFocus)\n self.thumb_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self.thumb_container = QWidget(); self.thumb_container.setObjectName(\"PA\")\n self.thumb_layout = QVBoxLayout(self.thumb_container)\n self.thumb_layout.setContentsMargins(4,6,4,6); self.thumb_layout.setSpacing(4)\n self.thumb_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)\n self.thumb_scroll.setWidget(self.thumb_container)\n self.thumb_scroll.setVisible(False)\n self.body_splitter.addWidget(self.thumb_scroll)\n\n # 主视图容器\n self._main_view = QWidget(); self._main_view.setObjectName(\"PA\")\n _mv_lo = QHBoxLayout(self._main_view)\n _mv_lo.setContentsMargins(0,0,0,0); _mv_lo.setSpacing(0)\n\n # 主视图 — 单页模式\n self.scroll = QScrollArea(); self.scroll.setWidgetResizable(True)\n self.scroll.setAlignment(Qt.AlignCenter); self.scroll.setObjectName(\"PA\")\n self.page_widget = PDFPageWidget()\n self.page_widget.setStyleSheet(\"padding:2px;\")\n shadow = QGraphicsDropShadowEffect(); shadow.setBlurRadius(28); shadow.setOffset(0,4)\n shadow.setColor(QColor(0,0,0,30)); self.page_widget.setGraphicsEffect(shadow)\n self.scroll.setWidget(self.page_widget)\n self._highlight_mode = False\n self._hl_erase_mode = False\n self._hl_start = None\n self._hl_overlay = None\n self.scroll.setVisible(False) # 默认连续模式,隐藏单页\n _mv_lo.addWidget(self.scroll)\n\n # 主视图 — 连续滚动模式(默认显示)\n self.cont_scroll = QScrollArea(); self.cont_scroll.setWidgetResizable(True)\n self.cont_scroll.setObjectName(\"PA\"); self.cont_scroll.setVisible(True)\n self.cont_container = QWidget(); self.cont_container.setObjectName(\"PA\")\n self.cont_layout = QVBoxLayout(self.cont_container)\n self.cont_layout.setContentsMargins(0,0,0,0); self.cont_layout.setSpacing(4)\n self.cont_layout.setAlignment(Qt.AlignHCenter)\n self.cont_scroll.setWidget(self.cont_container)\n self.cont_scroll.verticalScrollBar().valueChanged.connect(self._on_cont_scroll)\n _mv_lo.addWidget(self.cont_scroll)\n self._cont_page_widgets = []\n\n self.body_splitter.addWidget(self._main_view)\n # AI 问答面板(右侧,默认隐藏)\n self.qa_panel = QAPanelWidget()\n self.qa_panel.setVisible(False)\n self.body_splitter.addWidget(self.qa_panel)\n self.body_splitter.setSizes([140, 800, 0])\n self.body_splitter.setCollapsible(0, True) # 缩略图可折叠\n self.body_splitter.setCollapsible(1, False)\n self.body_splitter.setCollapsible(2, True) # QA 面板可折叠\n # 拖拽缩略图面板宽度时自动重建缩略图\n self.body_splitter.splitterMoved.connect(self._on_thumb_panel_resized)\n self.body_widget = self.body_splitter # 兼容引用\n lo.addWidget(self.body_splitter)\n\n # 空状态\n self.empty = QWidget(); self.empty.setObjectName(\"PA\")\n el = QVBoxLayout(self.empty); el.setAlignment(Qt.AlignCenter); el.setSpacing(16)\n el.setContentsMargins(40, 40, 40, 40)\n ic = QLabel(); ic.setFixedSize(64, 64); ic.setAlignment(Qt.AlignCenter)\n ic.setStyleSheet(f\"background:{_C['acc_l']};border-radius:32px;font-size:28px;\")\n ic.setText(\"👀\"); self._empty_icon = ic; el.addWidget(ic, alignment=Qt.AlignCenter)\n m = QLabel(\"翻译完成后在此预览\"); m.setStyleSheet(f\"font-size:15px;font-weight:600;color:{_C['t1']};\"); m.setAlignment(Qt.AlignCenter); el.addWidget(m)\n s = QLabel(\"支持 Dual · Mono · Side by Side 三种模式切换\"); s.setObjectName(\"Cap\"); s.setAlignment(Qt.AlignCenter); el.addWidget(s)\n s2 = QLabel(\"← 也可点击左侧历史记录直接打开\"); s2.setStyleSheet(f\"font-size:11px;color:{_C['t3']};\"); s2.setAlignment(Qt.AlignCenter); el.addWidget(s2)\n self.body_widget.setVisible(False); lo.addWidget(self.empty)\n self._thumb_labels = []\n\n # 滚轮缩放\n self.scroll.wheelEvent = self._wheel_event\n self.cont_scroll.wheelEvent = self._wheel_event\n # macOS 触控板双指缩放:用独立 QObject 做全局 eventFilter(安全)\n self._pinch_accumulator = 0.0\n self._pinch_filter = _PinchFilter(self)\n app = QApplication.instance()\n if app:\n app.installEventFilter(self._pinch_filter)\n\n def _apply_zoom(self, new_zoom):\n \"\"\"统一缩放处理\"\"\"\n self.zoom = new_zoom\n slider_val = int(new_zoom * 100)\n self.zoom_slider.blockSignals(True)\n self.zoom_slider.setValue(min(slider_val, 400))\n self.zoom_slider.blockSignals(False)\n self.zoom_pct.setText(f\"{slider_val}%\")\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n\n def _reset_fit(self):\n \"\"\"还原到当前适配模式的最佳贴合\"\"\"\n if self.doc:\n if self._fit_mode == \"page\":\n self._fit_page()\n else:\n self._fit_width()\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n\n def _wheel_event(self, event):\n \"\"\"滚轮翻页 + Ctrl+滚轮缩放\"\"\"\n mods = event.modifiers()\n\n # Ctrl + 滚轮 = 缩放\n if mods & Qt.ControlModifier:\n delta = event.angleDelta().y()\n step = 0.1 if delta > 0 else -0.1\n self._apply_zoom(max(0.5, min(self.zoom + step, 4.0)))\n return\n\n # 连续滚动模式:自然滚动\n if self._continuous:\n target = self.cont_scroll\n QScrollArea.wheelEvent(target, event)\n return\n\n # 单页模式:到边界翻页\n sb = self.scroll.verticalScrollBar()\n delta = event.angleDelta().y()\n at_bottom = sb.value() >= sb.maximum()\n at_top = sb.value() <= sb.minimum()\n\n if delta < 0 and at_bottom:\n self.next_page()\n elif delta > 0 and at_top:\n self.prev_page()\n else:\n QScrollArea.wheelEvent(self.scroll, event)\n\n def set_output_files(self, files: dict):\n \"\"\"翻译完成后设置三种文件路径\"\"\"\n self.output_files = files\n self.switch_mode(self.current_mode)\n\n def switch_mode(self, mode):\n self.current_mode = mode\n for m, b in self.mode_btns.items():\n b.setProperty(\"active\", m == mode)\n b.style().unpolish(b); b.style().polish(b)\n\n path = self.output_files.get(mode, \"\")\n if path and os.path.exists(path):\n self.load_pdf(path)\n return\n if self.output_files:\n # 回退到有文件的模式\n for fallback in [\"dual\", \"mono\", \"side_by_side\"]:\n fp = self.output_files.get(fallback, \"\")\n if fp and os.path.exists(fp):\n self.load_pdf(fp); break\n\n def load_pdf(self, path):\n try:\n same_file = (path == getattr(self, '_loaded_path', None) and self.doc)\n if not same_file:\n self._loaded_path = path\n self.doc = fitz.open(path)\n self.current_page = 0\n self._last_thumb_vp_w = -1 # 强制首次重建缩略图\n self._last_fit_zoom = -1 # 强制首次渲染\n self.empty.setVisible(False)\n self.body_widget.setVisible(True)\n self.thumb_scroll.setVisible(True)\n # 视口已就绪 → 立即贴合渲染(zoom 未变则跳过)\n vp = self.scroll.viewport() if not self._continuous else self.cont_scroll.viewport()\n if vp.width() >= 100:\n self._fit_and_render()\n else:\n QTimer.singleShot(0, self._fit_and_render)\n # 设置 AI 面板上下文\n if not same_file:\n self.qa_panel.set_paper_context(path)\n except Exception as e:\n self.page_widget.setText(f\"加载失败: {e}\")\n\n def _toggle_qa_panel(self):\n \"\"\"切换 AI 问答面板 — 即时更新布局\"\"\"\n vis = not self.qa_panel.isVisible()\n self.qa_panel.setVisible(vis)\n self.qa_btn.setProperty(\"active\", vis)\n self.qa_btn.style().unpolish(self.qa_btn); self.qa_btn.style().polish(self.qa_btn)\n if vis:\n sizes = self.body_splitter.sizes()\n self.body_splitter.setSizes([sizes[0], max(sizes[1] - 300, 400), 300])\n else:\n sizes = self.body_splitter.sizes()\n self.body_splitter.setSizes([sizes[0], sizes[1] + sizes[2], 0])\n # 立即重新适配渲染,确保 PDF 视图即时伸缩\n QTimer.singleShot(0, self._fit_and_render)\n\n def _toggle_highlight(self):\n \"\"\"切换高亮标注模式\"\"\"\n self._highlight_mode = self.hl_btn.isChecked()\n self._hl_erase_mode = False\n self.hl_btn.setProperty(\"active\", self._highlight_mode)\n self.hl_btn.style().unpolish(self.hl_btn); self.hl_btn.style().polish(self.hl_btn)\n # 显示/隐藏擦除按钮\n self.hl_erase_btn.setVisible(self._highlight_mode)\n if not self._highlight_mode:\n self.hl_erase_btn.setChecked(False)\n self.hl_erase_btn.setProperty(\"active\", False)\n self.hl_erase_btn.style().unpolish(self.hl_erase_btn)\n self.hl_erase_btn.style().polish(self.hl_erase_btn)\n cursor = Qt.CrossCursor if self._highlight_mode else Qt.ArrowCursor\n if self._continuous:\n for w in self._cont_page_widgets:\n w.setCursor(cursor)\n else:\n self.page_widget.setCursor(cursor)\n\n def _toggle_erase(self):\n \"\"\"切换擦除模式(高亮模式的子模式)\"\"\"\n self._hl_erase_mode = self.hl_erase_btn.isChecked()\n self.hl_erase_btn.setProperty(\"active\", self._hl_erase_mode)\n self.hl_erase_btn.style().unpolish(self.hl_erase_btn)\n self.hl_erase_btn.style().polish(self.hl_erase_btn)\n\n # ── 高亮标注 ──\n\n def _hl_mouse_press(self, e, page_idx, label):\n if not getattr(self, '_highlight_mode', False):\n return\n if e.button() == Qt.LeftButton:\n self._hl_start = e.pos()\n self._hl_page_idx = page_idx\n self._hl_label = label\n # 擦除模式:按了擦除按钮 或 按住 Option 键\n is_erase = getattr(self, '_hl_erase_mode', False) or (e.modifiers() & Qt.AltModifier)\n self._hl_dragging_delete = bool(is_erase)\n if is_erase:\n # 红色半透明删除覆盖层\n if not hasattr(self, '_hl_del_overlay') or self._hl_del_overlay is None:\n self._hl_del_overlay = QWidget(label)\n self._hl_del_overlay.setStyleSheet(\n \"background:rgba(255,59,48,0.18);border:1.5px solid rgba(255,59,48,0.6);border-radius:3px;\")\n from PyQt5.QtCore import QRect\n self._hl_del_overlay.setGeometry(QRect(self._hl_start, QSize()))\n self._hl_del_overlay.show()\n self._hl_del_overlay.raise_()\n else:\n # 蓝色添加高亮覆盖层\n if not hasattr(self, '_hl_overlay') or self._hl_overlay is None:\n from PyQt5.QtWidgets import QRubberBand\n self._hl_overlay = QRubberBand(QRubberBand.Rectangle, label)\n from PyQt5.QtCore import QRect\n self._hl_overlay.setGeometry(QRect(self._hl_start, QSize()))\n self._hl_overlay.show()\n\n def _hl_mouse_move(self, e, label):\n if not getattr(self, '_highlight_mode', False) or not hasattr(self, '_hl_start'):\n return\n if self._hl_start is None:\n return\n from PyQt5.QtCore import QRect\n if getattr(self, '_hl_dragging_delete', False):\n if hasattr(self, '_hl_del_overlay') and self._hl_del_overlay:\n self._hl_del_overlay.setGeometry(QRect(self._hl_start, e.pos()).normalized())\n elif hasattr(self, '_hl_overlay') and self._hl_overlay:\n self._hl_overlay.setGeometry(QRect(self._hl_start, e.pos()).normalized())\n\n def _hl_mouse_release(self, e, page_idx, label):\n if not getattr(self, '_highlight_mode', False) or not hasattr(self, '_hl_start'):\n return\n try:\n # 隐藏覆盖层\n if hasattr(self, '_hl_overlay') and self._hl_overlay:\n self._hl_overlay.hide()\n if hasattr(self, '_hl_del_overlay') and self._hl_del_overlay:\n self._hl_del_overlay.hide()\n\n end = e.pos()\n start = self._hl_start\n is_delete = getattr(self, '_hl_dragging_delete', False)\n self._hl_start = None\n self._hl_dragging_delete = False\n\n if not self.doc:\n return\n\n is_small = abs(end.x() - start.x()) < 8 and abs(end.y() - start.y()) < 8\n\n if is_delete:\n # 擦除模式\n if is_small:\n # 小范围点击 → 删除点击处的高亮\n self._hl_delete_at(page_idx, end)\n else:\n # 大范围框选 → 批量删除\n self._hl_delete_in_rect(page_idx, start, end)\n return\n\n # 左键:添加高亮\n if is_small:\n return\n # 屏幕坐标 → PDF 坐标\n x0 = min(start.x(), end.x()) / self.zoom\n y0 = min(start.y(), end.y()) / self.zoom\n x1 = max(start.x(), end.x()) / self.zoom\n y1 = max(start.y(), end.y()) / self.zoom\n sel_rect = fitz.Rect(x0, y0, x1, y1)\n page = self.doc[page_idx]\n # 在选区内查找文字,按文字位置创建高亮\n words = page.get_text(\"words\")\n quads = []\n for w in words:\n wr = fitz.Rect(w[:4])\n if sel_rect.intersects(wr):\n quads.append(wr.quad)\n if not quads:\n return # 选区内无文字(如图片 PDF),不添加\n annot = page.add_highlight_annot(quads=quads)\n annot.set_colors(stroke=(1, 0.85, 0.2))\n try:\n annot.set_opacity(0.35)\n except AttributeError:\n pass # 旧版 PyMuPDF 无此方法\n annot.update()\n self._hl_save_and_render(page_idx)\n except Exception:\n pass # 防止任何异常导致闪退\n\n def _hl_delete_in_rect(self, page_idx, start, end):\n \"\"\"批量删除框选区域内的所有高亮\"\"\"\n try:\n if not self.doc:\n return\n x0 = min(start.x(), end.x()) / self.zoom\n y0 = min(start.y(), end.y()) / self.zoom\n x1 = max(start.x(), end.x()) / self.zoom\n y1 = max(start.y(), end.y()) / self.zoom\n sel_rect = fitz.Rect(x0, y0, x1, y1)\n page = self.doc[page_idx]\n annots = page.annots()\n if not annots:\n return\n # 收集要删除的高亮(不能边遍历边删除)\n to_del = [a for a in annots if a.type[0] == 8 and sel_rect.intersects(a.rect)]\n if not to_del:\n return\n for a in to_del:\n page.delete_annot(a)\n self._hl_save_and_render(page_idx)\n except Exception:\n pass\n\n def _hl_save_and_render(self, page_idx):\n \"\"\"高亮变更后:保存文件 + 重新渲染\"\"\"\n path = getattr(self, '_loaded_path', None)\n if path:\n try:\n self.doc.save(path, incremental=True, encryption=fitz.PDF_ENCRYPT_KEEP)\n except Exception:\n try:\n self.doc.save(path)\n except Exception:\n pass\n if self._continuous:\n self._render_single_cont_page(page_idx)\n else:\n self.render_page()\n\n def _hl_delete_at(self, page_idx, pos):\n \"\"\"删除点击位置的高亮注释\"\"\"\n try:\n if not self.doc:\n return\n page = self.doc[page_idx]\n px, py = pos.x() / self.zoom, pos.y() / self.zoom\n point = fitz.Point(px, py)\n annots = page.annots()\n if not annots:\n return\n for annot in annots:\n if annot.type[0] == 8 and annot.rect.contains(point):\n page.delete_annot(annot)\n self._hl_save_and_render(page_idx)\n return\n except Exception:\n pass\n\n def _render_single_cont_page(self, page_idx):\n \"\"\"重新渲染连续模式中的单个页面\"\"\"\n if page_idx < len(self._cont_page_widgets):\n label = self._cont_page_widgets[page_idx]\n dpr = label.devicePixelRatioF() if hasattr(label, 'devicePixelRatioF') else 1.0\n rz = self.zoom * dpr\n page = self.doc[page_idx]\n pix = page.get_pixmap(matrix=fitz.Matrix(rz, rz))\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n pm = QPixmap.fromImage(img)\n pm.setDevicePixelRatio(dpr)\n label.setPixmap(pm)\n label.setFixedSize(pm.size() / dpr)\n\n def _install_highlight_events(self, label, page_idx):\n \"\"\"给页面 widget 安装高亮鼠标事件\n 高亮模式:左键拖拽添加 | 擦除模式/Option+拖拽:框选批量删除\n \"\"\"\n orig_press = label.mousePressEvent\n orig_move = label.mouseMoveEvent\n orig_release = label.mouseReleaseEvent\n def _press(e):\n if getattr(self, '_highlight_mode', False):\n self._hl_mouse_press(e, page_idx, label)\n else:\n orig_press(e)\n def _move(e):\n if getattr(self, '_highlight_mode', False):\n self._hl_mouse_move(e, label)\n else:\n orig_move(e)\n def _release(e):\n if getattr(self, '_highlight_mode', False):\n self._hl_mouse_release(e, page_idx, label)\n else:\n orig_release(e)\n label.mousePressEvent = _press\n label.mouseMoveEvent = _move\n label.mouseReleaseEvent = _release\n\n def _hl_show_delete_menu(self, e, page_idx, label):\n \"\"\"右键菜单:删除点击位置的高亮\"\"\"\n if not self.doc:\n return\n page = self.doc[page_idx]\n px, py = e.pos().x() / self.zoom, e.pos().y() / self.zoom\n point = fitz.Point(px, py)\n # 检查是否有高亮在此位置\n found = False\n for annot in page.annots():\n if annot.type[0] == 8 and annot.rect.contains(point):\n found = True; break\n if not found:\n return\n menu = _RoundMenu(label)\n act = menu.addAction(\"删除高亮\")\n act.triggered.connect(lambda: self._hl_delete_at(page_idx, e.pos()))\n menu.exec_(label.mapToGlobal(e.pos()))\n\n def _fit_and_render(self):\n \"\"\"统一:计算贴合 + 渲染(只在尺寸真正变化时才重新渲染)\"\"\"\n if not self.doc:\n return\n # 缩略图面板宽度变了 → 重建缩略图\n cur_tw = self.thumb_scroll.viewport().width() if self.thumb_scroll.isVisible() else 0\n if cur_tw > 40 and cur_tw != getattr(self, '_last_thumb_vp_w', 0):\n self._last_thumb_vp_w = cur_tw\n self._build_thumbnails()\n # 计算 zoom,只有变了才重新渲染(避免重复工作)\n old_zoom = self.zoom\n if self._fit_mode == \"page\":\n self._fit_page()\n else:\n self._fit_width()\n if self.zoom != getattr(self, '_last_fit_zoom', -1):\n self._last_fit_zoom = self.zoom\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n\n def _build_thumbnails(self):\n \"\"\"生成所有页面的缩略图\"\"\"\n # 清空旧缩略图\n for lbl in self._thumb_labels:\n lbl.setParent(None)\n self._thumb_labels = []\n\n if not self.doc:\n return\n\n dpr = QApplication.instance().devicePixelRatio() if QApplication.instance() else 2.0\n # 缩略图宽度 = 面板宽度 - 左右 margin(4+4) - border(2+2) - 滚动条余量\n panel_w = self.thumb_scroll.viewport().width() if self.thumb_scroll.viewport().width() > 40 else self.thumb_scroll.width()\n thumb_w = max(50, panel_w - 12)\n\n for i in range(len(self.doc)):\n page = self.doc[i]\n # 计算缩略图缩放比例\n scale = (thumb_w * dpr) / page.rect.width\n mat = fitz.Matrix(scale, scale)\n pix = page.get_pixmap(matrix=mat, alpha=False)\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n qpx = QPixmap.fromImage(img)\n qpx.setDevicePixelRatio(dpr)\n\n lbl = QLabel()\n lbl.setPixmap(qpx)\n lbl.setFixedSize(int(pix.width / dpr) + 4, int(pix.height / dpr) + 4)\n lbl.setAlignment(Qt.AlignCenter)\n lbl.setStyleSheet(\n \"border:2px solid transparent;border-radius:4px;padding:1px;\"\n \"background:transparent;\"\n )\n lbl.setCursor(Qt.PointingHandCursor)\n # 点击跳转\n page_idx = i\n lbl.mousePressEvent = lambda e, idx=page_idx: self._thumb_clicked(idx)\n\n self.thumb_layout.addWidget(lbl, alignment=Qt.AlignHCenter)\n self._thumb_labels.append(lbl)\n\n self.thumb_layout.addStretch()\n self._highlight_thumb(0)\n\n def _thumb_clicked(self, idx):\n self.current_page = idx\n if self._continuous and idx < len(self._cont_page_widgets):\n self.cont_scroll.ensureWidgetVisible(self._cont_page_widgets[idx], 0, 0)\n self._update_page_display()\n self._highlight_thumb(idx)\n else:\n self.render_page()\n self.setFocus()\n\n def _highlight_thumb(self, idx):\n \"\"\"高亮当前页缩略图,颜色由 _thumb_color 控制\"\"\"\n color = getattr(self, '_thumb_color', _C['acc'])\n for i, lbl in enumerate(self._thumb_labels):\n if i == idx:\n lbl.setStyleSheet(\n f\"border:2px solid {color};border-radius:4px;padding:1px;\"\n \"background:transparent;\"\n )\n else:\n lbl.setStyleSheet(\n \"border:2px solid transparent;border-radius:4px;padding:1px;\"\n \"background:transparent;\"\n )\n if idx < len(self._thumb_labels):\n self.thumb_scroll.ensureWidgetVisible(self._thumb_labels[idx])\n\n def _fit_width(self):\n \"\"\"自动计算缩放比例 — 所有模式统一 fit width\"\"\"\n if not self.doc or len(self.doc) == 0:\n return\n page = self.doc[0]\n pw = page.rect.width\n vp = self.scroll.viewport() if not self._continuous else self.cont_scroll.viewport()\n avail_w = vp.width() - 8\n if avail_w < 100 or pw <= 0:\n return\n optimal_zoom = avail_w / pw\n optimal_zoom = max(0.3, min(optimal_zoom, 3.0))\n self.zoom = optimal_zoom\n slider_val = int(optimal_zoom * 100)\n self.zoom_slider.blockSignals(True)\n self.zoom_slider.setValue(slider_val)\n self.zoom_slider.blockSignals(False)\n self.zoom_pct.setText(f\"{slider_val}%\")\n\n def _fit_page(self):\n \"\"\"自动计算缩放比例 — 整页适配 (宽高都不超出视口)\"\"\"\n if not self.doc or len(self.doc) == 0:\n return\n page = self.doc[0]\n pw, ph = page.rect.width, page.rect.height\n vp = self.scroll.viewport() if not self._continuous else self.cont_scroll.viewport()\n avail_w = vp.width() - 8\n avail_h = vp.height() - 8\n if avail_w < 100 or avail_h < 100 or pw <= 0 or ph <= 0:\n return\n zoom_w = avail_w / pw\n zoom_h = avail_h / ph\n optimal_zoom = min(zoom_w, zoom_h)\n optimal_zoom = max(0.3, min(optimal_zoom, 3.0))\n self.zoom = optimal_zoom\n slider_val = int(optimal_zoom * 100)\n self.zoom_slider.blockSignals(True)\n self.zoom_slider.setValue(slider_val)\n self.zoom_slider.blockSignals(False)\n self.zoom_pct.setText(f\"{slider_val}%\")\n\n def _switch_to_fit_width(self):\n \"\"\"切换到适宽模式\"\"\"\n self._fit_mode = \"width\"\n self.fit_btn.setProperty(\"active\", True)\n self.fit_btn.style().unpolish(self.fit_btn); self.fit_btn.style().polish(self.fit_btn)\n self.fitpage_btn.setProperty(\"active\", False)\n self.fitpage_btn.style().unpolish(self.fitpage_btn); self.fitpage_btn.style().polish(self.fitpage_btn)\n self._reset_fit()\n\n def _switch_to_fit_page(self):\n \"\"\"切换到适页模式\"\"\"\n self._fit_mode = \"page\"\n self.fitpage_btn.setProperty(\"active\", True)\n self.fitpage_btn.style().unpolish(self.fitpage_btn); self.fitpage_btn.style().polish(self.fitpage_btn)\n self.fit_btn.setProperty(\"active\", False)\n self.fit_btn.style().unpolish(self.fit_btn); self.fit_btn.style().polish(self.fit_btn)\n self._reset_fit()\n\n def render_page(self):\n if not self.doc or self.current_page >= len(self.doc): return\n pg = self.doc[self.current_page]\n # Retina: 渲染 2x 分辨率再缩回,保证清晰\n dpr = QApplication.instance().devicePixelRatio() if QApplication.instance() else 2.0\n render_zoom = self.zoom * dpr\n mat = fitz.Matrix(render_zoom, render_zoom)\n pix = pg.get_pixmap(matrix=mat, alpha=False)\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n qpix = QPixmap.fromImage(img)\n qpix.setDevicePixelRatio(dpr)\n self.page_widget.set_pixmap(qpix)\n # setFixedSize 用逻辑尺寸(物理尺寸 / dpr)\n self.page_widget.setFixedSize(int(pix.width / dpr), int(pix.height / dpr))\n self._install_highlight_events(self.page_widget, self.current_page)\n self._update_page_display()\n self._highlight_thumb(self.current_page)\n self.scroll.verticalScrollBar().setValue(0)\n\n def first_page(self):\n if self.doc:\n self.current_page = 0; self.render_page()\n def prev_page(self):\n if self.doc and self.current_page > 0:\n self.current_page -= 1; self.render_page()\n def next_page(self):\n if self.doc and self.current_page < len(self.doc) - 1:\n self.current_page += 1; self.render_page()\n def last_page(self):\n if self.doc:\n self.current_page = len(self.doc) - 1; self.render_page()\n def _jump_to_page(self):\n \"\"\"页码输入框回车跳转,超范围闪红提示\"\"\"\n try:\n n = int(self.page_input.text()) - 1\n if self.doc and 0 <= n < len(self.doc):\n self.current_page = n; self.render_page()\n return\n except ValueError:\n pass\n # 输入无效:闪红 + 重置\n self.page_input.setStyleSheet(f\"border:1.5px solid {_C['err']};border-radius:6px;\")\n self.page_input.setText(str(self.current_page + 1) if self.doc else \"\")\n QTimer.singleShot(800, lambda: self.page_input.setStyleSheet(\"\"))\n def _update_page_display(self):\n total = len(self.doc) if self.doc else 0\n self.page_input.setText(str(self.current_page + 1) if total else \"\")\n self.total_label.setText(f\"/ {total}\" if total else \"/ —\")\n def on_zoom(self, v):\n self.zoom = v / 100.0; self.zoom_pct.setText(f\"{v}%\")\n if self.doc:\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n def open_file(self):\n p, _ = QFileDialog.getOpenFileName(self, \"选择 PDF\", \"\", \"PDF (*.pdf)\")\n if p: self.load_pdf(p)\n def contextMenuEvent(self, event):\n if not self.doc:\n return\n menu = _RoundMenu(self)\n menu.addAction(\"打开其他 PDF…\", self.open_file)\n menu.addSeparator()\n menu.addAction(\"放大\", lambda: self._apply_zoom(min(self.zoom + 0.1, 4.0)))\n menu.addAction(\"缩小\", lambda: self._apply_zoom(max(self.zoom - 0.1, 0.3)))\n menu.addAction(\"适合宽度\", self._switch_to_fit_width)\n menu.addAction(\"适合页面\", self._switch_to_fit_page)\n menu.addSeparator()\n lp = getattr(self, '_loaded_path', None)\n if lp and os.path.exists(lp):\n menu.addAction(\"在 Finder 中显示\",\n lambda: __import__(\"subprocess\").Popen([\"open\", \"-R\", lp]))\n menu.exec_(event.globalPos())\n def _toggle_fullscreen(self):\n self._is_fullscreen = not self._is_fullscreen\n self.fs_btn.setText(\"退出\" if self._is_fullscreen else \"全屏\")\n self.fs_btn.setProperty(\"active\", self._is_fullscreen)\n self.fs_btn.style().unpolish(self.fs_btn); self.fs_btn.style().polish(self.fs_btn)\n self.hist_btn.setVisible(self._is_fullscreen)\n self.fullscreen_toggled.emit(self._is_fullscreen)\n QTimer.singleShot(80, self._on_resize_done)\n\n def _on_thumb_panel_resized(self):\n \"\"\"拖拽分割线后重建缩略图 + 重新适配 PDF 视图\"\"\"\n if not hasattr(self, '_thumb_resize_timer'):\n self._thumb_resize_timer = QTimer(); self._thumb_resize_timer.setSingleShot(True)\n self._thumb_resize_timer.timeout.connect(self._rebuild_thumbs_fit)\n self._thumb_resize_timer.start(200)\n # 立即适配 PDF 视图大小(QA 面板伸缩时也需要)\n if not hasattr(self, '_fit_timer'):\n self._fit_timer = QTimer(); self._fit_timer.setSingleShot(True)\n self._fit_timer.timeout.connect(self._fit_and_render)\n self._fit_timer.start(50)\n\n def _rebuild_thumbs_fit(self):\n if self.doc and self.thumb_scroll.isVisible():\n self._build_thumbnails()\n\n def keyPressEvent(self, event):\n if event.key() == Qt.Key_Escape and self._is_fullscreen:\n self._toggle_fullscreen(); return\n if event.key() == Qt.Key_Up:\n self.prev_page()\n elif event.key() == Qt.Key_Down:\n self.next_page()\n elif event.key() == Qt.Key_Home:\n self.first_page()\n elif event.key() == Qt.Key_End:\n self.last_page()\n else:\n super().keyPressEvent(event)\n def mousePressEvent(self, event):\n \"\"\"点击预览区域获取焦点,让键盘事件生效\"\"\"\n self.setFocus()\n super().mousePressEvent(event)\n\n def resizeEvent(self, event):\n \"\"\"窗口大小变化时重新适配\"\"\"\n super().resizeEvent(event)\n if self.doc:\n from PyQt5.QtCore import QTimer\n # 用定时器防止频繁重渲染\n if not hasattr(self, '_resize_timer'):\n self._resize_timer = QTimer(); self._resize_timer.setSingleShot(True)\n self._resize_timer.timeout.connect(self._on_resize_done)\n self._resize_timer.start(100)\n\n def _on_resize_done(self):\n self._fit_and_render()\n\n # ── 连续滚动模式 ──\n def _toggle_continuous(self):\n self._continuous = not self._continuous\n self.scroll_btn.setProperty(\"active\", self._continuous)\n self.scroll_btn.style().unpolish(self.scroll_btn)\n self.scroll_btn.style().polish(self.scroll_btn)\n self.scroll.setVisible(not self._continuous)\n self.cont_scroll.setVisible(self._continuous)\n if self.doc:\n self._fit_and_render()\n # 延迟再算一次,等 scroll 显隐切换布局稳定\n QTimer.singleShot(0, self._fit_and_render)\n\n def _render_continuous(self):\n \"\"\"分帧渲染所有页面到连续滚动容器(避免大文件卡 UI)\"\"\"\n if not self.doc:\n return\n # 清空旧内容\n for w in self._cont_page_widgets:\n w.setParent(None)\n self._cont_page_widgets = []\n self._cont_render_idx = 0\n self._cont_render_dpr = QApplication.instance().devicePixelRatio() if QApplication.instance() else 2.0\n\n # 先放置占位标签\n total = len(self.doc)\n for i in range(total):\n lbl = QLabel(\"加载中…\" if i == 0 else \"\")\n lbl.setAlignment(Qt.AlignCenter)\n lbl.setStyleSheet(f\"background:transparent;color:{_C['t3']};font-size:11px;min-height:200px;\")\n self.cont_layout.addWidget(lbl, alignment=Qt.AlignHCenter)\n self._cont_page_widgets.append(lbl)\n self._update_page_display()\n\n # 分帧渲染:每帧渲染 2 页\n self._render_batch_continuous()\n\n def _render_batch_continuous(self):\n \"\"\"每次渲染 2 页,释放 UI 事件循环\"\"\"\n if not self.doc:\n return\n idx = getattr(self, '_cont_render_idx', 0)\n total = len(self.doc)\n dpr = self._cont_render_dpr\n batch = 2 # 每帧渲染页数\n for _ in range(batch):\n if idx >= total:\n return\n pg = self.doc[idx]\n render_zoom = self.zoom * dpr\n mat = fitz.Matrix(render_zoom, render_zoom)\n pix = pg.get_pixmap(matrix=mat, alpha=False)\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n qpix = QPixmap.fromImage(img)\n qpix.setDevicePixelRatio(dpr)\n lbl = self._cont_page_widgets[idx]\n lbl.setPixmap(qpix)\n lbl.setFixedSize(int(pix.width / dpr), int(pix.height / dpr))\n lbl.setStyleSheet(\"background:transparent;\")\n self._install_highlight_events(lbl, idx)\n idx += 1\n self._cont_render_idx = idx\n if idx < total:\n QTimer.singleShot(0, self._render_batch_continuous)\n\n def _on_cont_scroll(self, value):\n \"\"\"连续滚动时更新页码和缩略图高亮\"\"\"\n if not self._continuous or not self._cont_page_widgets:\n return\n vp_center = value + self.cont_scroll.viewport().height() // 2\n for i, w in enumerate(self._cont_page_widgets):\n wy = w.y()\n if wy <= vp_center <= wy + w.height():\n if self.current_page != i:\n self.current_page = i\n self._update_page_display()\n self._highlight_thumb(i)\n break\n\n def update_theme(self, c):\n \"\"\"深色/浅色切换时更新内联样式\"\"\"\n self._empty_icon.setStyleSheet(f\"background:{c['acc_l']};border-radius:32px;font-size:28px;\")\n self._thumb_color = c['acc']\n if self._thumb_labels:\n self._highlight_thumb(self.current_page)\n # 更新 AI 面板\n self.qa_panel.update_theme(c)\n\n\n# ═══════════════════════════════════════════════════════════════\n# 翻译页面 — 全功能\n# ═══════════════════════════════════════════════════════════════\n\nclass TranslatePage(QWidget):\n translation_done = pyqtSignal(dict) # {\"mono\":..., \"dual\":..., \"side_by_side\":...}\n\n def __init__(self):\n super().__init__()\n self.worker = None\n self.pending_files = []\n self.cfg = UserConfigManager.load()\n\n scroll = QScrollArea(); scroll.setWidgetResizable(True); scroll.setFrameShape(QFrame.NoFrame)\n scroll.setObjectName(\"PA\")\n content = QWidget(); content.setObjectName(\"PA\")\n outer_lo = QVBoxLayout(content); outer_lo.setContentsMargins(0,0,0,0)\n outer_lo.addStretch(1) # 上部弹性 — 垂直居中\n\n inner = QWidget()\n lo = QVBoxLayout(inner); lo.setContentsMargins(40,0,40,0); lo.setSpacing(12)\n\n # 标题 + 四叶草徽章 + 人文关怀\n hdr = QHBoxLayout()\n t = QLabel(\"翻译\"); t.setObjectName(\"PT0\"); hdr.addWidget(t)\n self.clover_label = QLabel(\"\")\n self.clover_label.setStyleSheet(\"font-size:16px;background:transparent;\")\n hdr.addWidget(self.clover_label)\n hdr.addStretch()\n # 尝试显示关怀消息,否则显示默认副标题\n _subtitle = \"拖入学术 PDF,保留公式与原始排版\"\n try:\n from ui.caring import get_caring_message\n _cm = get_caring_message()\n if _cm:\n _subtitle = f\"{_cm[0]} {_cm[2]}\"\n except Exception:\n pass\n st = QLabel(_subtitle); st.setObjectName(\"PT1\"); hdr.addWidget(st)\n lo.addLayout(hdr)\n\n # 拖拽区(内含文件列表,高度固定,不影响下方布局)\n self.drop = DropZone(); self.drop.files_dropped.connect(self.on_files_added)\n self.drop._add_btn.clicked.connect(self._browse_more)\n self.drop._del_btn.clicked.connect(self._remove_selected_files)\n self.drop._clr_btn.clicked.connect(self._clear_files)\n lo.addWidget(self.drop)\n # 引用 DropZone 内嵌的控件\n self.flist = self.drop.flist\n self._fcount_label = self.drop._fcount_label\n self._zotero_hint = self.drop._zotero_hint\n # 文件列表右键菜单\n self.flist.setContextMenuPolicy(Qt.CustomContextMenu)\n self.flist.customContextMenuRequested.connect(self._flist_context_menu)\n\n # ── 配置卡片 ──\n card = _card()\n cl = QVBoxLayout(card); cl.setContentsMargins(16,12,16,12); cl.setSpacing(0)\n\n # 语言行\n r1 = QHBoxLayout(); r1.setSpacing(12)\n for label, items, attr, default in [\n (\"源语言\", list(LANG_MAP.keys()), \"src_combo\", self.cfg.get(\"lang_in\",\"自动检测\")),\n (\"目标语言\", list(LANG_MAP.keys()), \"tgt_combo\", self.cfg.get(\"lang_out\",\"中文(简体)\")),\n ]:\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(label); lb.setObjectName(\"FL\"); col.addWidget(lb)\n cb = QComboBox(); cb.addItems(items); cb.setMinimumWidth(160)\n idx = cb.findText(default)\n if idx >= 0: cb.setCurrentIndex(idx)\n col.addWidget(cb); r1.addLayout(col)\n setattr(self, attr, cb)\n r1.addStretch()\n cl.addLayout(r1)\n cl.addSpacing(8); cl.addWidget(_div()); cl.addSpacing(8)\n\n # 服务 + 格式 + 页码\n r2 = QHBoxLayout(); r2.setSpacing(12)\n # 翻译服务\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"翻译服务\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.svc_combo = QComboBox(); self.svc_combo.addItems(list(SERVICE_MAP.keys()))\n self.svc_combo.setMinimumWidth(180)\n saved_svc = self.cfg.get(\"service\", \"Bing 翻译\")\n idx = self.svc_combo.findText(saved_svc)\n if idx >= 0: self.svc_combo.setCurrentIndex(idx)\n col.addWidget(self.svc_combo); r2.addLayout(col)\n\n # 页码范围\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"页码范围\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.page_combo = QComboBox(); self.page_combo.addItems(list(PAGE_PRESETS.keys()))\n self.page_combo.setMinimumWidth(120)\n self.page_combo.currentTextChanged.connect(self._on_page_changed)\n col.addWidget(self.page_combo); r2.addLayout(col)\n\n # 自定义页码输入\n self.custom_page = QLineEdit(); self.custom_page.setPlaceholderText(\"例: 1-5, 8, 10-12\")\n self.custom_page.setVisible(False); self.custom_page.setFixedWidth(160)\n r2.addWidget(self.custom_page)\n\n r2.addStretch()\n cl.addLayout(r2)\n cl.addSpacing(8); cl.addWidget(_div()); cl.addSpacing(8)\n\n # 输出格式 + 线程\n r3 = QHBoxLayout(); r3.setSpacing(12)\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"输出格式\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.fmt_combo = QComboBox(); self.fmt_combo.addItems(list(OUTPUT_MODES.keys()))\n _saved_fmt = self.cfg.get(\"output_format\", \"左右并排 (Side by Side)\")\n _fmt_idx = self.fmt_combo.findText(_saved_fmt)\n self.fmt_combo.setCurrentIndex(_fmt_idx if _fmt_idx >= 0 else 2)\n self.fmt_combo.setMinimumWidth(180)\n col.addWidget(self.fmt_combo); r3.addLayout(col)\n\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"线程数\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.thread_spin = QSpinBox(); self.thread_spin.setRange(1,32)\n self.thread_spin.setValue(self.cfg.get(\"thread_count\", 8))\n col.addWidget(self.thread_spin); r3.addLayout(col)\n\n r3.addStretch()\n cl.addLayout(r3)\n cl.addSpacing(8); cl.addWidget(_div()); cl.addSpacing(8)\n\n # 分块翻译\n r4 = QHBoxLayout(); r4.setSpacing(16)\n self.chunk_check = QCheckBox(\"分块翻译(大文件推荐)\")\n self.chunk_check.setChecked(self.cfg.get(\"chunk_enabled\", False))\n self.chunk_check.toggled.connect(self._on_chunk_toggled)\n r4.addWidget(self.chunk_check)\n\n self.chunk_size_label = QLabel(\"每块\"); self.chunk_size_label.setObjectName(\"Cap\")\n self.chunk_size_spin = QSpinBox(); self.chunk_size_spin.setRange(5,200)\n self.chunk_size_spin.setValue(self.cfg.get(\"chunk_size\", 50))\n self.chunk_size_unit = QLabel(\"页\"); self.chunk_size_unit.setObjectName(\"Cap\")\n\n self.chunk_delay_label = QLabel(\"间隔\"); self.chunk_delay_label.setObjectName(\"Cap\")\n self.chunk_delay_spin = QSpinBox(); self.chunk_delay_spin.setRange(0,120)\n self.chunk_delay_spin.setValue(self.cfg.get(\"chunk_delay\", 10))\n self.chunk_delay_unit = QLabel(\"秒\"); self.chunk_delay_unit.setObjectName(\"Cap\")\n\n for w in [self.chunk_size_label, self.chunk_size_spin, self.chunk_size_unit,\n self.chunk_delay_label, self.chunk_delay_spin, self.chunk_delay_unit]:\n r4.addWidget(w)\n w.setVisible(self.chunk_check.isChecked())\n\n r4.addStretch()\n cl.addLayout(r4)\n\n lo.addWidget(card)\n\n # ── 进度卡片(固定高度占位,避免布局跳动) ──\n self._prog_holder = QWidget()\n self._prog_holder.setFixedHeight(130)\n ph_lo = QVBoxLayout(self._prog_holder)\n ph_lo.setContentsMargins(0,0,0,0); ph_lo.setSpacing(0)\n\n self.prog_card = _card(\"lg\")\n self.prog_card.setVisible(False)\n pc_lo = QVBoxLayout(self.prog_card)\n pc_lo.setContentsMargins(24, 14, 24, 14)\n pc_lo.setSpacing(8)\n\n row1 = QHBoxLayout(); row1.setSpacing(12)\n self.prog_icon = QLabel(\"⏳\"); self.prog_icon.setObjectName(\"ProgIcon\")\n row1.addWidget(self.prog_icon)\n self.prog_label = QLabel(\"正在翻译…\"); self.prog_label.setObjectName(\"ProgLabel\")\n row1.addWidget(self.prog_label)\n row1.addStretch()\n self.prog_pct = QLabel(\"0%\"); self.prog_pct.setObjectName(\"ProgPct\")\n row1.addWidget(self.prog_pct)\n pc_lo.addLayout(row1)\n\n self.prog_bar = QProgressBar(); self.prog_bar.setRange(0, 100)\n self.prog_bar.setObjectName(\"ProgBar\")\n pc_lo.addWidget(self.prog_bar)\n\n self.prog_tip = QLabel(\"\"); self.prog_tip.setObjectName(\"ProgTip\")\n pc_lo.addWidget(self.prog_tip)\n\n row2 = QHBoxLayout(); row2.setSpacing(10)\n self.prog_detail = QLabel(\"\"); self.prog_detail.setObjectName(\"ProgDetail\")\n row2.addWidget(self.prog_detail)\n row2.addStretch()\n self.stop_btn = QPushButton(\"取消\")\n self.stop_btn.setObjectName(\"ProgCancel\")\n self.stop_btn.setCursor(Qt.PointingHandCursor)\n self.stop_btn.clicked.connect(self._cancel)\n row2.addWidget(self.stop_btn)\n pc_lo.addLayout(row2)\n\n ph_lo.addWidget(self.prog_card)\n lo.addWidget(self._prog_holder)\n\n # ── 开始按钮 ──\n br = QHBoxLayout(); br.setSpacing(10)\n br.addStretch()\n self.go_btn = QPushButton(\"开始翻译\"); self.go_btn.setObjectName(\"Pr\")\n self.go_btn.setFixedWidth(200); self.go_btn.setFixedHeight(48)\n self.go_btn.setEnabled(False)\n self.go_btn.clicked.connect(self._start)\n br.addWidget(self.go_btn)\n self.dice_btn = QPushButton(\"🎲\")\n self.dice_btn.setCursor(Qt.PointingHandCursor)\n self.dice_btn.setStyleSheet(\"font-size:18px;padding:6px;border:none;background:transparent;\")\n self.dice_btn.clicked.connect(self._dice_game)\n br.addWidget(self.dice_btn)\n br.addStretch()\n lo.addLayout(br)\n\n outer_lo.addWidget(inner)\n outer_lo.addStretch(2) # 下部弹性稍大 — 视觉重心偏上\n\n self._update_dice() # 初始化骰子状态\n scroll.setWidget(content)\n page_lo = QVBoxLayout(self); page_lo.setContentsMargins(0,0,0,0); page_lo.addWidget(scroll)\n\n def _on_page_changed(self, text):\n self.custom_page.setVisible(text == \"自定义\")\n\n def _on_chunk_toggled(self, checked):\n for w in [self.chunk_size_label, self.chunk_size_spin, self.chunk_size_unit,\n self.chunk_delay_label, self.chunk_delay_spin, self.chunk_delay_unit]:\n w.setVisible(checked)\n\n def on_files_added(self, files):\n for f in files:\n if not os.path.isfile(f):\n continue\n # 去重\n exists = False\n for i in range(self.flist.count()):\n if self.flist.item(i).data(Qt.UserRole) == f:\n exists = True; break\n if exists:\n continue\n name = os.path.basename(f)\n if len(name) > 50:\n name = name[:22] + \"…\" + name[-22:]\n sz = os.path.getsize(f)\n s = f\"{sz/1048576:.1f} MB\" if sz > 1048576 else f\"{sz/1024:.0f} KB\"\n item = QListWidgetItem(f\" 📄 {name} {s}\")\n item.setData(Qt.UserRole, f)\n self.flist.addItem(item)\n self._update_fcount()\n self._check_zotero_source()\n\n def _flist_context_menu(self, pos):\n item = self.flist.itemAt(pos)\n menu = _RoundMenu(self)\n menu.addAction(\"添加文件…\", self._browse_more)\n if item:\n fp = item.data(Qt.UserRole)\n menu.addSeparator()\n menu.addAction(\"在 Finder 中显示\", lambda: self._reveal_in_finder(fp))\n menu.addAction(\"移除\", lambda: self._remove_item(item))\n if self.flist.count() > 0:\n menu.addSeparator()\n menu.addAction(\"清空全部\", self._clear_files)\n menu.exec_(self.flist.viewport().mapToGlobal(pos))\n\n def _reveal_in_finder(self, path):\n import subprocess\n subprocess.Popen([\"open\", \"-R\", path])\n\n def _remove_item(self, item):\n self.flist.takeItem(self.flist.row(item))\n self._update_fcount(); self._check_zotero_source()\n\n def _browse_more(self):\n fs, _ = QFileDialog.getOpenFileNames(self, \"选择 PDF\", \"\", \"PDF (*.pdf)\")\n if fs: self.on_files_added(fs)\n\n def _update_fcount(self):\n n = self.flist.count()\n if n > 0:\n self.drop._stack.setCurrentIndex(1) # 切换到文件列表\n self.go_btn.setEnabled(True)\n self._fcount_label.setText(f\"共 {n} 个文件\")\n else:\n self.drop._stack.setCurrentIndex(0) # 切换回拖拽提示\n self.go_btn.setEnabled(False)\n self._fcount_label.setText(\"\")\n\n def _check_zotero_source(self):\n has_zotero = False\n for i in range(self.flist.count()):\n p = self.flist.item(i).data(Qt.UserRole)\n if detect_zotero_source(p):\n has_zotero = True\n break\n self._zotero_hint.setVisible(has_zotero)\n\n def _remove_selected_files(self):\n for item in reversed(self.flist.selectedItems()):\n self.flist.takeItem(self.flist.row(item))\n self._update_fcount()\n self._check_zotero_source()\n\n def _clear_files(self):\n self.flist.clear()\n self._update_fcount()\n self._zotero_hint.setVisible(False)\n\n def _get_pages(self):\n preset = self.page_combo.currentText()\n if preset == \"自定义\":\n txt = self.custom_page.text().strip()\n return parse_page_range(txt) if txt else None\n return PAGE_PRESETS.get(preset)\n\n def _save_config(self):\n self.cfg[\"service\"] = self.svc_combo.currentText()\n self.cfg[\"lang_in\"] = self.src_combo.currentText()\n self.cfg[\"lang_out\"] = self.tgt_combo.currentText()\n self.cfg[\"output_format\"] = self.fmt_combo.currentText()\n self.cfg[\"thread_count\"] = self.thread_spin.value()\n self.cfg[\"chunk_enabled\"] = self.chunk_check.isChecked()\n self.cfg[\"chunk_size\"] = self.chunk_size_spin.value()\n self.cfg[\"chunk_delay\"] = self.chunk_delay_spin.value()\n UserConfigManager.save(self.cfg)\n\n def _start(self):\n files = []\n for i in range(self.flist.count()):\n p = self.flist.item(i).data(Qt.UserRole)\n if p: files.append(p)\n if not files: return\n # 确保按钮连接正常(重试模式可能改过)\n try: self.go_btn.clicked.disconnect()\n except TypeError: pass\n self.go_btn.clicked.connect(self._start)\n\n self._save_config()\n self.pending_files = files\n self._batch_idx = 0\n self._batch_results = [] # [(file_path, output_files_dict), ...]\n self._output_dir = os.path.expanduser(\"~/Documents/pdf2zh_files\")\n os.makedirs(self._output_dir, exist_ok=True)\n\n self.go_btn.setEnabled(False); self.go_btn.setText(\"翻译中…\")\n self.prog_card.setVisible(True); self.stop_btn.setVisible(True)\n self.prog_bar.setValue(0)\n self.prog_pct.setText(\"0%\")\n self.prog_label.setText(\"正在翻译…\")\n self.prog_icon.setText(\"⏳\")\n self.prog_tip.setText(\"\")\n\n self._translate_next()\n\n def _translate_next(self):\n \"\"\"启动队列中下一个文件的翻译\"\"\"\n idx = self._batch_idx\n total = len(self.pending_files)\n if idx >= total:\n self._on_batch_done()\n return\n\n fp = self.pending_files[idx]\n if total > 1:\n self.prog_label.setText(f\"正在翻译 ({idx+1}/{total})…\")\n self.prog_detail.setText(os.path.basename(fp))\n self.prog_bar.setValue(0); self.prog_pct.setText(\"0%\")\n\n envs = build_service_envs(self.svc_combo.currentText())\n self.worker = TranslateWorker(\n file_path=fp,\n output_dir=self._output_dir,\n lang_in=LANG_MAP.get(self.src_combo.currentText(), \"\"),\n lang_out=LANG_MAP.get(self.tgt_combo.currentText(), \"zh\"),\n service=SERVICE_MAP.get(self.svc_combo.currentText(), \"bing\"),\n pages=self._get_pages(),\n thread_count=self.thread_spin.value(),\n chunk_enabled=self.chunk_check.isChecked(),\n chunk_size=self.chunk_size_spin.value(),\n chunk_delay=self.chunk_delay_spin.value(),\n envs=envs,\n )\n self.worker.progress.connect(self._on_prog)\n self.worker.status.connect(self._on_status)\n self.worker.finished.connect(self._on_single_done)\n self.worker.error.connect(self._on_single_err)\n self.worker.start()\n\n def _cancel(self):\n if self.worker: self.worker.cancel()\n self.prog_label.setText(\"正在取消…\")\n\n _TIPS = [\n \"翻译中,请稍候…\", \"公式和图表会完整保留\",\n \"AI 正在识别文档布局…\", \"保持网络连接以获得最佳速度\",\n \"快好了,再等等…\", \"pdf2zh-desktop · 学术翻译利器\",\n \"排版会和原文一模一样\", \"支持 20+ 翻译引擎\",\n \"每一篇论文,都是知识跨越语言的桥梁\",\n \"科研不易,感谢你的坚持\",\n ]\n\n def _on_prog(self, cur, total):\n if total > 0:\n pct = min(int(cur/total*100), 100)\n self.prog_bar.setValue(pct); self.prog_pct.setText(f\"{pct}%\")\n self.prog_detail.setText(f\"已处理 {cur}/{total} 页\")\n # 趣味提示 + 深夜关怀\n import random\n if cur % 5 == 1 or cur == total:\n from ui.caring import get_caring_message\n caring = get_caring_message()\n if caring:\n tip = f\"{caring[0]} {caring[1]}\"\n elif pct >= 90:\n tip = \"马上就好!\"\n elif pct >= 50:\n tip = \"已经过半了,加油!\"\n else:\n tip = random.choice(self._TIPS)\n self.prog_tip.setText(tip)\n\n def _on_status(self, text):\n self.prog_detail.setText(text)\n\n def _on_single_done(self, output_files):\n \"\"\"单文件翻译完成 — 回写 Zotero + 记录历史 + 推进队列\"\"\"\n fp = self.pending_files[self._batch_idx]\n\n if self.worker:\n w = self.worker; self.worker = None\n w.quit()\n QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n # 保存历史\n HistoryManager.add_record({\n \"file\": {\"name\": os.path.basename(fp), \"path\": fp},\n \"translation\": {\n \"service\": self.svc_combo.currentText(),\n \"lang_in\": self.src_combo.currentText(),\n \"lang_out\": self.tgt_combo.currentText(),\n },\n \"output_files\": output_files,\n \"status\": \"success\",\n })\n self.translation_done.emit(output_files)\n\n # Zotero 回写:按此文件自身的来源路径,把译文复制回原位\n self._zotero_writeback(fp, output_files)\n\n self._batch_results.append((fp, output_files))\n\n # 推进到下一个文件\n self._batch_idx += 1\n self._translate_next()\n\n def _on_single_err(self, msg):\n \"\"\"单文件翻译出错 — 记录后继续下一个\"\"\"\n fp = self.pending_files[self._batch_idx]\n\n if self.worker:\n w = self.worker; self.worker = None\n w.quit()\n QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n self._batch_results.append((fp, None)) # None = 失败\n\n total = len(self.pending_files)\n if total > 1:\n # 批量模式:跳过出错的,继续下一个\n self.prog_detail.setText(f\"⚠️ {os.path.basename(fp)} 翻译出错,跳过\")\n self._batch_idx += 1\n QTimer.singleShot(1000, self._translate_next)\n else:\n # 单文件模式:直接报错\n self._on_err(msg)\n\n def _zotero_writeback(self, file_path, output_files):\n \"\"\"把译文复制回 Zotero 原位 + 尝试自动关联附件\"\"\"\n import shutil\n zotero_dir = detect_zotero_source(file_path)\n if not zotero_dir:\n return\n cfg = UserConfigManager.load()\n modes = cfg.get(\"zotero_output_modes\", [\"side_by_side\"])\n keep_copy = cfg.get(\"zotero_keep_copy\", True)\n item_key = get_zotero_item_key(file_path)\n for mode in modes:\n src = output_files.get(mode)\n if not src or not os.path.exists(src):\n continue\n dst = os.path.join(zotero_dir, os.path.basename(src))\n if os.path.abspath(src) != os.path.abspath(dst):\n shutil.copy2(src, dst)\n if not keep_copy:\n try:\n os.remove(src)\n except OSError:\n pass\n # 尝试通过 pdf2zh Connector 插件自动关联附件\n if item_key:\n mode_label = {\"side_by_side\": \"并排\", \"dual\": \"双语\", \"mono\": \"译文\"}.get(mode, mode)\n zotero_auto_link(item_key, dst, f\"翻译 ({mode_label})\")\n\n def _on_batch_done(self):\n \"\"\"全部文件翻译完成\"\"\"\n self.prog_bar.setValue(100); self.prog_pct.setText(\"100%\")\n self.prog_icon.setText(\"✅\")\n self.go_btn.setEnabled(True); self.go_btn.setText(\"开始翻译\")\n self.stop_btn.setVisible(False)\n\n total = len(self._batch_results)\n ok = sum(1 for _, r in self._batch_results if r is not None)\n failed = total - ok\n has_zotero = any(detect_zotero_source(fp) for fp, _ in self._batch_results)\n\n # 收集失败的文件用于重试\n self._failed_files = [fp for fp, r in self._batch_results if r is None]\n\n if total == 1 and ok == 1:\n self.prog_label.setText(\"翻译完成\")\n if has_zotero:\n self.prog_detail.setText(\"译文已保存回 Zotero 原位\")\n else:\n self.prog_detail.setText(\"输出至 ~/Documents/pdf2zh_files\")\n elif failed == 0:\n self.prog_label.setText(f\"全部完成({ok} 篇)\")\n if has_zotero:\n self.prog_detail.setText(\"所有译文已保存回 Zotero 原位\")\n else:\n self.prog_detail.setText(\"输出至 ~/Documents/pdf2zh_files\")\n else:\n self.prog_label.setText(f\"完成 {ok} 篇,失败 {failed} 篇\")\n self.prog_detail.setText(\"部分文件翻译出错\")\n # 显示重试按钮\n self.go_btn.setText(f\"重试失败 ({failed} 篇)\")\n try: self.go_btn.clicked.disconnect()\n except TypeError: pass\n self.go_btn.clicked.connect(self._retry_failed)\n self.go_btn.setEnabled(True)\n\n # macOS 系统通知 — 翻译完成后通知用户(适合长时间翻译时切到其他 app)\n try:\n import subprocess\n msg = f\"{ok} 篇翻译完成\" if failed == 0 else f\"完成 {ok} 篇,失败 {failed} 篇\"\n subprocess.Popen([\n \"osascript\", \"-e\",\n f'display notification \"{msg}\" with title \"pdf2zh\" sound name \"Glass\"'\n ])\n except Exception:\n pass\n\n # 关怀消息\n from ui.caring import get_caring_message, get_session_tip\n caring = get_caring_message()\n if caring:\n self.prog_tip.setText(f\"{caring[0]} {caring[1]}\")\n else:\n self.prog_tip.setText(get_session_tip())\n\n # 翻译完成后自动清空文件列表\n self._clear_files()\n\n # 骰子系统\n try:\n from datetime import date\n cfg = UserConfigManager.load()\n today = date.today().isoformat()\n if cfg.get(\"dice_date\") != today:\n cfg[\"dice_date\"] = today; cfg[\"dice_today_pages\"] = 0\n cfg[\"dice_used\"] = False; cfg[\"dice_clovers\"] = 0\n cfg[\"dice_today_pages\"] = cfg.get(\"dice_today_pages\", 0) + max(1, self.thread_spin.value())\n UserConfigManager.dice_save(cfg)\n self._update_dice()\n except Exception:\n pass\n\n def _retry_failed(self):\n \"\"\"重试上次批量翻译中失败的文件\"\"\"\n failed = getattr(self, '_failed_files', [])\n if not failed:\n return\n # 恢复按钮到正常状态\n try: self.go_btn.clicked.disconnect()\n except TypeError: pass\n self.go_btn.clicked.connect(self._start)\n # 把失败文件重新加入列表并启动\n self.on_files_added(failed)\n self._failed_files = []\n self._start()\n\n def _on_err(self, msg):\n self.prog_icon.setText(\"❌\")\n self.prog_label.setText(\"翻译出错\")\n self.prog_pct.setText(\"!\")\n self.prog_detail.setText(msg)\n self.prog_bar.setValue(0)\n self.go_btn.setEnabled(True); self.go_btn.setText(\"重试翻译\")\n self.stop_btn.setVisible(False)\n if self.worker:\n w = self.worker; self.worker = None\n w.quit()\n QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n # ── 骰子系统(静默、无文字、四叶草) ──\n\n def _update_dice(self):\n \"\"\"骰子 🎲 在按钮区,四叶草 🍀 在标题旁\"\"\"\n from datetime import date\n cfg = UserConfigManager.load()\n today = date.today().isoformat()\n pages = cfg.get(\"dice_today_pages\", 0)\n\n # 新的一天重置\n if cfg.get(\"dice_date\") != today:\n self.clover_label.setText(\"\")\n if pages >= 5:\n self.dice_btn.setText(\"🎲\"); self.dice_btn.setEnabled(True); self.dice_btn.show()\n else:\n self.dice_btn.hide()\n return\n\n # 四叶草:当天翻译不足 5 页则清掉\n clovers = cfg.get(\"dice_clovers\", 0) if cfg.get(\"dice_used\") else 0\n if clovers > 0 and pages < 5:\n clovers = 0\n cfg[\"dice_clovers\"] = 0; UserConfigManager.dice_save(cfg)\n self.clover_label.setText(\"🍀\" * clovers if clovers > 0 else \"\")\n\n # 骰子按钮\n if cfg.get(\"dice_used\"):\n self.dice_btn.hide() # 已掷过,骰子消失\n elif pages >= 5:\n self.dice_btn.setText(\"🎲\"); self.dice_btn.setEnabled(True); self.dice_btn.show()\n else:\n self.dice_btn.hide()\n\n def _dice_game(self):\n \"\"\"点击骰子 → 静默掷骰 → 四叶草或消失\"\"\"\n import random\n from datetime import date\n\n cfg = UserConfigManager.load()\n today = date.today().isoformat()\n\n # 日期重置\n if cfg.get(\"dice_date\") != today:\n cfg[\"dice_date\"] = today; cfg[\"dice_today_pages\"] = 0\n cfg[\"dice_used\"] = False; cfg[\"dice_clovers\"] = 0\n UserConfigManager.dice_save(cfg)\n\n # 签名校验\n if not UserConfigManager.dice_verify(cfg):\n cfg[\"dice_date\"] = today; cfg[\"dice_today_pages\"] = 0\n cfg[\"dice_used\"] = False; cfg[\"dice_clovers\"] = 0\n UserConfigManager.dice_save(cfg)\n self._update_dice(); return\n\n # 静默检查资格\n if cfg.get(\"dice_used\") or cfg.get(\"dice_today_pages\", 0) < 5:\n return\n\n # 标记已用\n cfg[\"dice_used\"] = True; cfg[\"dice_clovers\"] = 0\n UserConfigManager.dice_save(cfg)\n\n self.dice_btn.setEnabled(False)\n self._dice_roll_step(0)\n\n def _dice_roll_step(self, clovers):\n \"\"\"逐次掷骰:6 → 🍀 累加,非 6 → 结束\"\"\"\n import random\n FACES = \"⚀⚁⚂⚃⚄⚅\"\n\n val = 6 if random.random() < 0.5 else random.choice([1,2,3,4,5])\n flicker = [0]\n\n def tick():\n flicker[0] += 1\n if flicker[0] < 10:\n self.dice_btn.setText(FACES[random.randint(0,5)])\n QTimer.singleShot(55, tick)\n else:\n # 定格\n self.dice_btn.setText(FACES[val - 1])\n if val == 6:\n nc = clovers + 1\n self._dice_mini_firework()\n QTimer.singleShot(700, lambda: self._dice_got_six(nc))\n else:\n # 短暂显示骰面,然后骰子消失,四叶草留在标题旁\n QTimer.singleShot(1500, lambda: self._dice_finish(clovers))\n tick()\n\n def _dice_got_six(self, clovers):\n \"\"\"掷中 6:标题旁加 🍀,继续或结束\"\"\"\n # 四叶草实时更新到标题旁\n self.clover_label.setText(\"🍀\" * clovers)\n\n # 保存中间状态\n cfg = UserConfigManager.load()\n cfg[\"dice_clovers\"] = clovers\n UserConfigManager.dice_save(cfg)\n\n if clovers >= 6:\n # 全部 6!终极大奖\n from datetime import date\n today = date.today().isoformat()\n cfg[\"dice_win_code\"] = UserConfigManager.dice_win_code(today)\n cfg[\"theme_unlocked\"] = True\n UserConfigManager.save(cfg)\n self.dice_btn.hide()\n mw = self.window()\n if hasattr(mw, '_midnight_bloom'):\n mw._midnight_bloom()\n return\n\n # ≥3 个 6 静默解锁主题色\n if clovers == 3:\n cfg2 = UserConfigManager.load()\n cfg2[\"theme_unlocked\"] = True\n UserConfigManager.save(cfg2)\n\n # 按钮回到 🎲 继续下一轮\n self.dice_btn.setText(\"🎲\")\n QTimer.singleShot(600, lambda: self._dice_roll_step(clovers))\n\n def _dice_finish(self, clovers):\n \"\"\"掷骰结束:骰子消失,四叶草留在标题旁\"\"\"\n cfg = UserConfigManager.load()\n cfg[\"dice_clovers\"] = clovers\n UserConfigManager.dice_save(cfg)\n # 骰子消失,四叶草已在标题旁\n self.dice_btn.hide()\n self.clover_label.setText(\"🍀\" * clovers if clovers > 0 else \"\")\n\n def _dice_mini_firework(self):\n \"\"\"从骰子按钮位置爆发迷你烟花\"\"\"\n import random, math\n from PyQt5.QtCore import QPropertyAnimation, QPoint, QEasingCurve\n mw = self.window()\n center = self.dice_btn.mapTo(mw, self.dice_btn.rect().center())\n emojis = [\"✨\",\"💫\",\"⭐\",\"🌟\",\"🎇\",\"🔥\",\"🎆\"]\n for _ in range(12):\n lbl = QLabel(random.choice(emojis), mw)\n lbl.setStyleSheet(f\"font-size:{random.randint(12,22)}px;background:transparent;\")\n lbl.move(center); lbl.show(); lbl.raise_()\n angle = random.uniform(0, 2 * math.pi)\n dist = random.randint(50, 160)\n end = QPoint(center.x() + int(math.cos(angle) * dist),\n center.y() + int(math.sin(angle) * dist))\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(600, 1100))\n anim.setStartValue(center); anim.setEndValue(end)\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start(); lbl._anim = anim\n\n\n# ═══════════════════════════════════════════════════════════════\n# 阅读页面 — 历史 + 预览合体\n# ═══════════════════════════════════════════════════════════════\n\nclass ReaderPage(QWidget):\n \"\"\"左侧历史列表 + 右侧 PDF 预览\n 两级导航:历史列表 ←(Left/Right)→ 预览区\n 预览区内上下键翻页,缩略图跟随高亮\"\"\"\n fullscreen_changed = pyqtSignal(bool)\n\n def __init__(self):\n super().__init__()\n self._preview_active = False\n lo = QHBoxLayout(self); lo.setContentsMargins(0,0,0,0); lo.setSpacing(0)\n\n # QSplitter 实现可拖拽分割\n from PyQt5.QtWidgets import QSplitter\n splitter = QSplitter(Qt.Horizontal)\n splitter.setHandleWidth(1)\n splitter.setChildrenCollapsible(True)\n\n # ── 左侧:历史面板 ──\n left = QWidget()\n left.setObjectName(\"HistPanel\"); left.setMinimumWidth(180); left.setMaximumWidth(420)\n ll = QVBoxLayout(left); ll.setContentsMargins(12,12,12,12); ll.setSpacing(8)\n\n # 标题行\n hdr = QHBoxLayout(); hdr.setSpacing(4)\n t = QLabel(\"翻译历史\"); t.setStyleSheet(\"font-size:13px;font-weight:600;\")\n hdr.addWidget(t); hdr.addStretch()\n self.thumb_toggle = QPushButton(\"缩略图\"); self.thumb_toggle.setObjectName(\"TB\")\n self.thumb_toggle.setCursor(Qt.PointingHandCursor)\n self.thumb_toggle.setProperty(\"active\", True)\n self.thumb_toggle.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n self.thumb_toggle.clicked.connect(self._toggle_thumbs)\n hdr.addWidget(self.thumb_toggle)\n cb = QPushButton(\"清空\"); cb.setObjectName(\"Gh\"); cb.setCursor(Qt.PointingHandCursor)\n cb.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n cb.clicked.connect(self._clear); hdr.addWidget(cb)\n ll.addLayout(hdr)\n\n # ── 分组筛选栏:左侧可滚动按钮区 + 右侧固定\"+\" ──\n _group_row = QHBoxLayout(); _group_row.setContentsMargins(0, 0, 0, 0); _group_row.setSpacing(4)\n self._group_bar = QScrollArea()\n self._group_bar.setMaximumHeight(34); self._group_bar.setFrameShape(QFrame.NoFrame)\n self._group_bar.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._group_bar.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._group_bar.setWidgetResizable(True)\n self._group_container = QWidget()\n self._group_layout = QHBoxLayout(self._group_container)\n self._group_layout.setContentsMargins(0, 0, 0, 0); self._group_layout.setSpacing(4)\n self._group_bar.setWidget(self._group_container)\n _group_row.addWidget(self._group_bar, 1)\n # \"+\"按钮 — 独立于 scroll area,绝对不参与 group_layout\n self._add_btn = QPushButton(\"+\"); self._add_btn.setObjectName(\"Gh\")\n self._add_btn.setCursor(Qt.PointingHandCursor)\n self._add_btn.setStyleSheet(\"font-size:10px;padding:4px 8px;\")\n self._add_btn.setToolTip(\"新建分组\")\n self._add_btn.setFixedSize(28, 26)\n self._add_btn.clicked.connect(self._create_group)\n _group_row.addWidget(self._add_btn, 0) # stretch=0, 固定尺寸\n ll.addLayout(_group_row)\n self._current_group_id = None # None = 全部\n self._current_tag_filter = None\n\n # 历史列表\n self.list_w = QListWidget()\n self.list_w.setObjectName(\"HistList\")\n self.list_w.setFocusPolicy(Qt.StrongFocus)\n self.list_w.setSelectionMode(QListWidget.SingleSelection)\n self.list_w.currentItemChanged.connect(self._on_select)\n self.list_w.itemClicked.connect(lambda item: self._on_select(item, None))\n self.list_w.itemDoubleClicked.connect(self._open_in_reader)\n self.list_w.installEventFilter(self)\n self.list_w.setContextMenuPolicy(Qt.CustomContextMenu)\n self.list_w.customContextMenuRequested.connect(self._hist_context_menu)\n ll.addWidget(self.list_w)\n\n # 详情面板\n self.detail_label = QLabel(\"↑ 选择记录查看详情\")\n self.detail_label.setObjectName(\"HistDetail\")\n self.detail_label.setWordWrap(True)\n self.detail_label.setStyleSheet(\n \"#HistDetail{font-size:10px;padding:4px 6px;line-height:125%;}\")\n self.detail_label.setMinimumHeight(48)\n ll.addWidget(self.detail_label)\n # 文件操作按钮 — 紧凑行,紧贴详情面板\n fops = QHBoxLayout(); fops.setSpacing(4); fops.setContentsMargins(6, 2, 6, 0)\n self._btn_reveal = QPushButton(\"📂 Finder\"); self._btn_reveal.setObjectName(\"Gh\")\n self._btn_reveal.setCursor(Qt.PointingHandCursor)\n self._btn_reveal.setStyleSheet(\"font-size:9px;padding:2px 6px;\")\n self._btn_reveal.setToolTip(\"在 Finder 中显示翻译文件\")\n self._btn_reveal.clicked.connect(self._reveal_in_finder)\n self._btn_reveal.setVisible(False)\n fops.addWidget(self._btn_reveal)\n self._btn_open_src = QPushButton(\"📄 源文件\"); self._btn_open_src.setObjectName(\"Gh\")\n self._btn_open_src.setCursor(Qt.PointingHandCursor)\n self._btn_open_src.setStyleSheet(\"font-size:9px;padding:2px 6px;\")\n self._btn_open_src.setToolTip(\"用默认应用打开源 PDF\")\n self._btn_open_src.clicked.connect(self._open_source)\n self._btn_open_src.setVisible(False)\n fops.addWidget(self._btn_open_src)\n fops.addStretch()\n ll.addLayout(fops)\n self._current_record = None\n\n splitter.addWidget(left)\n\n # ── 右侧:预览 ──\n self.preview = PreviewPage()\n self.preview.installEventFilter(self)\n splitter.addWidget(self.preview)\n\n # 初始比例:左 260, 右 stretch\n splitter.setSizes([260, 800])\n self._splitter = splitter\n self._hist_panel = left\n lo.addWidget(splitter)\n\n self._show_thumbs = True\n # 缩略图点击 → 进入预览模式\n self._orig_thumb_clicked = self.preview._thumb_clicked\n self.preview._thumb_clicked = self._on_thumb_clicked\n # 全屏信号\n self.preview.fullscreen_toggled.connect(self._on_fullscreen)\n self.preview.history_toggled.connect(self._toggle_hist_in_fullscreen)\n self._fs_hist_visible = False\n self.refresh()\n\n def showEvent(self, event):\n \"\"\"页面显示时自动选中第一条\"\"\"\n super().showEvent(event)\n if self.list_w.count() > 0 and not self.list_w.currentItem():\n self.list_w.setCurrentRow(0)\n self.list_w.setFocus()\n\n def _on_thumb_clicked(self, idx):\n \"\"\"点击缩略图 → 进入预览模式 + 跳转页面\"\"\"\n self._enter_preview()\n self._orig_thumb_clicked(idx)\n\n def _enter_preview(self):\n \"\"\"进入预览模式:缩略图显示蓝色高亮\"\"\"\n self._preview_active = True\n self.preview._thumb_color = _C[\"acc\"]\n self.preview.setFocus()\n self.preview._highlight_thumb(self.preview.current_page)\n\n def _exit_preview(self):\n \"\"\"退出预览模式:缩略图变灰色,焦点回列表\"\"\"\n self._preview_active = False\n self.preview._thumb_color = _C[\"t3\"]\n self.list_w.setFocus()\n self.preview._highlight_thumb(self.preview.current_page)\n\n def eventFilter(self, obj, event):\n \"\"\"两级导航: 历史列表 ←→ 预览区(一次按键切换)\n 全屏模式下 Left 键会先唤出历史面板\"\"\"\n from PyQt5.QtCore import QEvent\n if event.type() == QEvent.KeyPress:\n key = event.key()\n # 历史列表 → 右键 → 进入预览\n if obj == self.list_w and key == Qt.Key_Right:\n self._enter_preview()\n return True\n # 预览区 → 左键\n if obj == self.preview and key == Qt.Key_Left:\n # 全屏且历史面板隐藏 → 先唤出历史\n if self.preview._is_fullscreen and not self._fs_hist_visible:\n self._toggle_hist_in_fullscreen()\n return True\n self._exit_preview()\n return True\n return super().eventFilter(obj, event)\n\n def _on_fullscreen(self, fs):\n \"\"\"全屏阅读模式:隐藏历史面板+侧边栏\"\"\"\n if fs:\n self._saved_splitter_sizes = self._splitter.sizes()\n self._hist_panel.setVisible(False)\n self._fs_hist_visible = False\n self.preview.hist_btn.setProperty(\"active\", False)\n self.preview.hist_btn.style().unpolish(self.preview.hist_btn)\n self.preview.hist_btn.style().polish(self.preview.hist_btn)\n else:\n self._hist_panel.setVisible(True)\n self._splitter.setSizes(self._saved_splitter_sizes)\n self._fs_hist_visible = False\n self.fullscreen_changed.emit(fs)\n self._quick_refit()\n\n def _toggle_hist_in_fullscreen(self):\n \"\"\"全屏模式下切换历史面板\"\"\"\n if not self.preview._is_fullscreen:\n return\n self._fs_hist_visible = not self._fs_hist_visible\n self._hist_panel.setVisible(self._fs_hist_visible)\n self.preview.hist_btn.setProperty(\"active\", self._fs_hist_visible)\n self.preview.hist_btn.style().unpolish(self.preview.hist_btn)\n self.preview.hist_btn.style().polish(self.preview.hist_btn)\n if self._fs_hist_visible:\n self._splitter.setSizes([self._saved_splitter_sizes[0], 800])\n self.list_w.setFocus()\n self._quick_refit()\n\n def _quick_refit(self):\n \"\"\"快速贴合(不重建缩略图,只调 zoom + 渲染)\"\"\"\n if not self.preview.doc:\n return\n def _do():\n if self.preview._fit_mode == \"page\":\n self.preview._fit_page()\n else:\n self.preview._fit_width()\n if self.preview.zoom != getattr(self.preview, '_last_fit_zoom', -1):\n self.preview._last_fit_zoom = self.preview.zoom\n if self.preview._continuous:\n self.preview._render_continuous()\n else:\n self.preview.render_page()\n QTimer.singleShot(0, _do)\n QTimer.singleShot(80, _do)\n\n def _toggle_thumbs(self):\n \"\"\"切换缩略图显示,自动重新适配宽度\"\"\"\n self._show_thumbs = not self._show_thumbs\n self.thumb_toggle.setProperty(\"active\", self._show_thumbs)\n self.thumb_toggle.style().unpolish(self.thumb_toggle)\n self.thumb_toggle.style().polish(self.thumb_toggle)\n if self._show_thumbs:\n self.preview.thumb_scroll.setVisible(True)\n # 确保 splitter 给缩略图合理宽度\n sizes = self.preview.body_splitter.sizes()\n if sizes[0] < 60:\n self.preview.body_splitter.setSizes([100, sizes[1]])\n else:\n self.preview.thumb_scroll.setVisible(False)\n self._quick_refit()\n\n def refresh(self):\n self.list_w.clear()\n data = HistoryManager.load_all()\n records = data.get(\"records\", [])\n groups = data.get(\"groups\", [])\n all_tags = data.get(\"tags\", [])\n\n # ── 重建分组筛选栏 ──\n self._rebuild_group_bar(groups)\n\n # ── 按分组/标签筛选 ──\n filtered = records\n gid = self._current_group_id\n if gid is not None:\n filtered = [r for r in filtered if r.get(\"group_id\") == gid]\n tid = self._current_tag_filter\n if tid is not None:\n filtered = [r for r in filtered if tid in r.get(\"tags\", [])]\n\n if not filtered:\n item = QListWidgetItem(\"暂无翻译记录\")\n item.setFlags(item.flags() & ~Qt.ItemIsSelectable)\n item.setForeground(QColor(_C[\"t3\"]))\n self.list_w.addItem(item)\n return\n for r in filtered:\n ts = r.get(\"timestamp\", \"\")[:16].replace(\"T\", \" \")\n fname = r.get(\"file\", {}).get(\"name\", \"?\")\n svc = r.get(\"translation\", {}).get(\"service\", \"?\")\n lang_in = r.get(\"translation\", {}).get(\"lang_in\", \"\")\n lang_out = r.get(\"translation\", {}).get(\"lang_out\", \"\")\n status = \"✓\" if r.get(\"status\") == \"success\" else \"✗\"\n # 两行显示:文件名 + 服务·时间\n item = QListWidgetItem()\n item.setData(Qt.UserRole, r)\n item.setToolTip(f\"{fname}\\n{svc} · {lang_in}→{lang_out} · {ts[5:]}\")\n # 有标签时行高多留空间\n has_tags = bool(r.get(\"tags\"))\n item.setSizeHint(QSize(0, 56 if has_tags else 44))\n self.list_w.addItem(item)\n # 自定义 widget 双行布局\n w = QWidget(); wl = QVBoxLayout(w)\n wl.setContentsMargins(2, 2, 2, 2); wl.setSpacing(0)\n l1 = QLabel(f\"{status} {fname}\"); l1.setStyleSheet(f\"font-size:12px;color:{_C['t1']};background:transparent;\")\n l2 = QLabel(f\"{svc} · {lang_in}→{lang_out} · {ts[5:]}\")\n l2.setStyleSheet(f\"font-size:11px;color:{_C['t2']};background:transparent;\")\n wl.addWidget(l1); wl.addWidget(l2)\n # 标签行\n rec_tags = r.get(\"tags\", [])\n if rec_tags and all_tags:\n tag_row = QHBoxLayout(); tag_row.setSpacing(4); tag_row.setContentsMargins(0, 1, 0, 0)\n for td in all_tags:\n if td[\"id\"] in rec_tags:\n dot = QLabel(f\"● {td['name']}\")\n dot.setStyleSheet(f\"font-size:9px;color:{td['color']};background:transparent;\")\n tag_row.addWidget(dot)\n tag_row.addStretch()\n wl.addLayout(tag_row)\n w._l1 = l1; w._l2 = l2\n self.list_w.setItemWidget(item, w)\n\n def _rebuild_group_bar(self, groups):\n \"\"\"重建分组筛选按钮栏 — 支持右键菜单(重命名/删除/图标)\"\"\"\n while self._group_layout.count():\n w = self._group_layout.takeAt(0).widget()\n if w: w.deleteLater()\n self._group_btns = []\n c = _C\n # ── \"全部\"按钮(固定,不可删除) ──\n btn_all = QPushButton(\"全部\"); btn_all.setObjectName(\"TB\")\n btn_all.setCursor(Qt.PointingHandCursor)\n btn_all.setStyleSheet(\"font-size:10px;padding:4px 10px;\")\n btn_all.setProperty(\"active\", self._current_group_id is None)\n btn_all.clicked.connect(lambda: self._select_group(None))\n self._group_layout.addWidget(btn_all)\n # ── 动态分组按钮 ──\n for g in groups:\n b = QPushButton(f\"{g['icon']} {g['name']}\"); b.setObjectName(\"TB\")\n b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:10px;padding:4px 10px;\")\n b.setProperty(\"active\", self._current_group_id == g[\"id\"])\n gid = g[\"id\"]\n b.clicked.connect(lambda checked, _id=gid: self._select_group(_id))\n # 右键菜单\n b.setContextMenuPolicy(Qt.CustomContextMenu)\n b.customContextMenuRequested.connect(\n lambda pos, _id=gid, _btn=b: self._group_context_menu(_id, _btn, pos))\n self._group_layout.addWidget(b)\n self._group_btns.append((gid, b))\n self._group_layout.addStretch()\n\n def _select_group(self, group_id):\n self._current_group_id = group_id\n self.refresh()\n\n def _create_group(self):\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建分组\", \"分组名称:\")\n if ok and name.strip():\n g = HistoryManager.add_group(name.strip())\n # 在 stretch 前面插入新按钮(\"+\"在 layout 外,不受影响)\n stretch_idx = self._group_layout.count() - 1 # 最后一个是 stretch\n b = QPushButton(f\"{g['icon']} {g['name']}\"); b.setObjectName(\"TB\")\n b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:10px;padding:4px 10px;\")\n b.setProperty(\"active\", False)\n gid = g[\"id\"]\n b.clicked.connect(lambda checked, _id=gid: self._select_group(_id))\n b.setContextMenuPolicy(Qt.CustomContextMenu)\n b.customContextMenuRequested.connect(\n lambda pos, _id=gid, _btn=b: self._group_context_menu(_id, _btn, pos))\n self._group_layout.insertWidget(stretch_idx, b)\n self._group_btns.append((gid, b))\n\n def _group_context_menu(self, group_id, btn, pos):\n \"\"\"分组按钮右键菜单:重命名 / 修改图标 / 上移 / 下移 / 删除\"\"\"\n try:\n menu = _RoundMenu(self)\n menu.addAction(\"重命名\", lambda: self._rename_group(group_id))\n menu.addAction(\"修改图标\", lambda: self._change_group_icon(group_id))\n menu.addSeparator()\n # 上移 / 下移\n ids = [gid for gid, _ in self._group_btns]\n idx = ids.index(group_id) if group_id in ids else -1\n if idx > 0:\n menu.addAction(\"↑ 上移\", lambda: self._move_group(group_id, -1))\n if idx < len(ids) - 1:\n menu.addAction(\"↓ 下移\", lambda: self._move_group(group_id, 1))\n menu.addSeparator()\n menu.addAction(\"删除分组\", lambda: self._delete_group(group_id))\n menu.exec_(btn.mapToGlobal(pos))\n except Exception:\n pass\n\n def _rename_group(self, group_id):\n from PyQt5.QtWidgets import QInputDialog\n data = HistoryManager.load_all()\n old_name = \"\"\n for g in data.get(\"groups\", []):\n if g[\"id\"] == group_id:\n old_name = g[\"name\"]\n break\n name, ok = QInputDialog.getText(self, \"重命名分组\", \"新名称:\", text=old_name)\n if ok and name.strip():\n HistoryManager.rename_group(group_id, name.strip())\n self.refresh()\n\n def _change_group_icon(self, group_id):\n from PyQt5.QtWidgets import QInputDialog\n icons = [\"📁\", \"📂\", \"📚\", \"📖\", \"🔬\", \"🧪\", \"💻\", \"🎓\", \"📝\", \"⭐\",\n \"🔖\", \"📌\", \"🏷️\", \"💡\", \"🔍\", \"📊\", \"🧬\", \"🌐\", \"🤖\", \"📐\"]\n icon, ok = QInputDialog.getItem(self, \"选择图标\", \"分组图标:\", icons, 0, False)\n if ok:\n HistoryManager.update_group_icon(group_id, icon)\n self.refresh()\n\n def _move_group(self, group_id, direction):\n \"\"\"上移 direction=-1,下移 direction=1\"\"\"\n data = HistoryManager.load_all()\n groups = data.get(\"groups\", [])\n ids = [g[\"id\"] for g in groups]\n idx = ids.index(group_id) if group_id in ids else -1\n if idx < 0:\n return\n new_idx = idx + direction\n if 0 <= new_idx < len(ids):\n ids[idx], ids[new_idx] = ids[new_idx], ids[idx]\n HistoryManager.reorder_groups(ids)\n self.refresh()\n\n def _delete_group(self, group_id):\n data = HistoryManager.load_all()\n name = \"\"\n count = 0\n for g in data.get(\"groups\", []):\n if g[\"id\"] == group_id:\n name = g[\"name\"]\n break\n for r in data.get(\"records\", []):\n if r.get(\"group_id\") == group_id:\n count += 1\n reply = QMessageBox.question(\n self, \"删除分组\",\n f\"确定删除分组「{name}」吗?\\n该分组下的 {count} 个文件将移至「全部」。\",\n QMessageBox.Yes | QMessageBox.No, QMessageBox.No)\n if reply == QMessageBox.Yes:\n HistoryManager.delete_group(group_id)\n if self._current_group_id == group_id:\n self._current_group_id = None\n self.refresh()\n\n def _move_to_group(self, record_id, group_id):\n HistoryManager.move_to_group(record_id, group_id)\n self.refresh()\n\n def _toggle_tag(self, record_id, tag_id):\n HistoryManager.toggle_record_tag(record_id, tag_id)\n self.refresh()\n\n def _hist_context_menu(self, pos):\n item = self.list_w.itemAt(pos)\n if not item:\n return\n r = item.data(Qt.UserRole)\n if not r:\n return\n menu = _RoundMenu(self)\n # 在 Finder 中显示译文\n files = r.get(\"output_files\", {})\n has_output = any(os.path.exists(files.get(m, \"\")) for m in [\"dual\",\"mono\",\"side_by_side\"])\n if has_output:\n menu.addAction(\"在 Finder 中显示译文\", self._reveal_in_finder)\n # 打开原文\n src = r.get(\"file\", {}).get(\"path\", \"\")\n if src and os.path.exists(src):\n menu.addAction(\"打开原文\", self._open_source)\n menu.addAction(\"在 Finder 中显示原文\",\n lambda: __import__(\"subprocess\").Popen([\"open\", \"-R\", src]))\n menu.addSeparator()\n # 移到分组\n record_id = r.get(\"id\")\n data = HistoryManager.load_all()\n groups = data.get(\"groups\", [])\n if groups:\n grp_menu = _RoundMenu(self); grp_menu.setTitle(\"移到分组\")\n grp_menu.addAction(\"无分组\", lambda: self._move_to_group(record_id, None))\n for g in groups:\n gid = g[\"id\"]\n grp_menu.addAction(f\"{g['icon']} {g['name']}\",\n lambda _id=gid: self._move_to_group(record_id, _id))\n menu.addMenu(grp_menu)\n # 标签\n all_tags = data.get(\"tags\", [])\n tag_menu = _RoundMenu(self); tag_menu.setTitle(\"标签\")\n for t in all_tags:\n a = tag_menu.addAction(f\"● {t['name']}\")\n a.setCheckable(True)\n a.setChecked(t[\"id\"] in r.get(\"tags\", []))\n tid = t[\"id\"]\n a.toggled.connect(lambda checked, _tid=tid: self._toggle_tag(record_id, _tid))\n tag_menu.addSeparator()\n tag_menu.addAction(\"新建标签…\", self._create_tag)\n menu.addMenu(tag_menu)\n menu.addSeparator()\n menu.addAction(\"清空全部历史\", self._clear)\n menu.exec_(self.list_w.viewport().mapToGlobal(pos))\n\n def _create_tag(self):\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建标签\", \"标签名称:\")\n if ok and name.strip():\n colors = [\"#FF3B30\", \"#FF9F0A\", \"#34C759\", \"#007AFF\", \"#AF52DE\", \"#FF2D55\"]\n import random\n HistoryManager.add_tag(name.strip(), random.choice(colors))\n\n def _on_select(self, current, previous):\n # 恢复上一个\n if previous:\n w = self.list_w.itemWidget(previous)\n if w and hasattr(w, '_l1'):\n w._l1.setStyleSheet(f\"font-size:12px;color:{_C['t1']};background:transparent;\")\n w._l2.setStyleSheet(f\"font-size:11px;color:{_C['t2']};background:transparent;\")\n # 当前变白\n if current:\n w = self.list_w.itemWidget(current)\n if w and hasattr(w, '_l1'):\n w._l1.setStyleSheet(\"font-size:12px;color:white;background:transparent;\")\n w._l2.setStyleSheet(\"font-size:11px;color:rgba(255,255,255,0.7);background:transparent;\")\n\n r = current.data(Qt.UserRole) if current else None\n if not r:\n return\n # 更新详情面板\n fname = r.get(\"file\", {}).get(\"name\", \"—\")\n svc = r.get(\"translation\", {}).get(\"service\", \"—\")\n li = r.get(\"translation\", {}).get(\"lang_in\", \"\")\n lo2 = r.get(\"translation\", {}).get(\"lang_out\", \"\")\n ts = r.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n self.detail_label.setText(f\"📄 {fname}\\n🌐 {svc} · {li}→{lo2}\\n🕐 {ts}\")\n self._current_record = r\n # 显示文件操作按钮(有输出文件时)\n has_files = bool(r.get(\"output_files\"))\n self._btn_reveal.setVisible(has_files)\n self._btn_open_src.setVisible(bool(r.get(\"file\", {}).get(\"path\")))\n # 加载预览\n if \"output_files\" in r:\n self.preview.set_output_files(r[\"output_files\"])\n\n def _open_in_reader(self, item):\n \"\"\"双击历史记录 → 加载到预览并聚焦预览区\"\"\"\n r = item.data(Qt.UserRole) if item else None\n if not r or \"output_files\" not in r:\n return\n self.preview.set_output_files(r[\"output_files\"])\n self._preview_active = True\n self.preview.setFocus()\n\n def set_output_files(self, files):\n \"\"\"外部调用(翻译完成后)\"\"\"\n self.preview.set_output_files(files)\n self.refresh()\n if self.list_w.count() > 0:\n self.list_w.setCurrentRow(0)\n\n def _clear(self):\n HistoryManager.clear(); self.refresh()\n\n def _reveal_in_finder(self):\n \"\"\"在 Finder 中显示翻译输出文件\"\"\"\n r = self._current_record\n if not r:\n return\n import subprocess\n # 优先显示当前预览模式的文件,否则任一输出文件\n files = r.get(\"output_files\", {})\n path = \"\"\n for mode in [self.preview.current_mode, \"dual\", \"mono\", \"side_by_side\"]:\n p = files.get(mode, \"\")\n if p and os.path.exists(p):\n path = p; break\n if path:\n subprocess.run([\"open\", \"-R\", path])\n\n def _open_source(self):\n \"\"\"用系统默认应用打开源 PDF\"\"\"\n r = self._current_record\n if not r:\n return\n import subprocess\n path = r.get(\"file\", {}).get(\"path\", \"\")\n if path and os.path.exists(path):\n subprocess.run([\"open\", path])\n\n def update_theme(self, c):\n \"\"\"深色/浅色切换时更新历史列表颜色\"\"\"\n self.preview.update_theme(c)\n self.preview._thumb_color = c[\"acc\"] if self._preview_active else c[\"t3\"]\n if self.preview._thumb_labels:\n self.preview._highlight_thumb(self.preview.current_page)\n # 刷新所有历史条目颜色\n for i in range(self.list_w.count()):\n item = self.list_w.item(i)\n w = self.list_w.itemWidget(item)\n if w and hasattr(w, '_l1'):\n is_sel = (item == self.list_w.currentItem())\n if is_sel:\n w._l1.setStyleSheet(\"font-size:12px;color:white;background:transparent;\")\n w._l2.setStyleSheet(\"font-size:11px;color:rgba(255,255,255,0.7);background:transparent;\")\n else:\n w._l1.setStyleSheet(f\"font-size:12px;color:{c['t1']};background:transparent;\")\n w._l2.setStyleSheet(f\"font-size:11px;color:{c['t2']};background:transparent;\")\n\n\n# ═══════════════════════════════════════════════════════════════\n# 设置页面\n# ═══════════════════════════════════════════════════════════════\n\nclass SettingsPage(QWidget):\n dark_mode_changed = pyqtSignal(bool)\n theme_color_changed = pyqtSignal(str)\n\n SERVICE_CONFIGS = {\n # 推荐\n \"DeepSeek\": {\"key_ph\":\"密钥\", \"models\":[\"deepseek-chat\",\"deepseek-reasoner\",\"deepseek-coder\"], \"url\":\"https://api.deepseek.com/v1\"},\n \"OpenAI\": {\"key_ph\":\"sk-...\", \"models\":[\"gpt-4o\",\"gpt-4o-mini\",\"gpt-4-turbo\",\"gpt-3.5-turbo\",\"o1\",\"o1-mini\",\"o1-pro\"], \"url\":\"https://api.openai.com/v1\"},\n # 国际服务\n \"Azure OpenAI\": {\"key_ph\":\"密钥\", \"models\":[\"gpt-4o\",\"gpt-4-turbo\",\"gpt-35-turbo\"], \"url\":\"https://YOUR_RESOURCE.openai.azure.com\"},\n \"DeepL\": {\"key_ph\":\"密钥\", \"models\":[], \"url\":\"\"},\n \"Gemini\": {\"key_ph\":\"密钥\", \"models\":[\"gemini-pro\",\"gemini-1.5-pro\",\"gemini-1.5-flash\",\"gemini-2.0-flash\"], \"url\":\"\"},\n \"Groq\": {\"key_ph\":\"密钥\", \"models\":[\"llama-3.1-70b-versatile\",\"llama-3.1-8b-instant\",\"mixtral-8x7b-32768\",\"llama-3.3-70b-versatile\"], \"url\":\"https://api.groq.com/openai/v1\"},\n # 国产大模型\n \"Zhipu 智谱\": {\"key_ph\":\"密钥\", \"models\":[\"glm-4\",\"glm-4-flash\",\"glm-4-plus\",\"glm-3-turbo\",\"cogview-3\"], \"url\":\"https://open.bigmodel.cn/api/paas/v4\"},\n \"Qwen 通义千问\": {\"key_ph\":\"密钥\", \"models\":[\"qwen-turbo\",\"qwen-plus\",\"qwen-max\",\"qwen-long\"], \"url\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\"},\n \"Tencent 腾讯\": {\"key_ph\":\"密钥\", \"models\":[\"hunyuan-pro\",\"hunyuan-standard\",\"hunyuan-lite\"], \"url\":\"\"},\n \"Silicon 硅基流动\":{\"key_ph\":\"密钥\", \"models\":[\"deepseek-ai/DeepSeek-V3\",\"Qwen/Qwen2.5-72B-Instruct\",\"Pro/THUDM/glm-4-9b-chat\"], \"url\":\"https://api.siliconflow.cn/v1\"},\n \"ModelScope\": {\"key_ph\":\"密钥\", \"models\":[\"qwen-turbo\",\"qwen-plus\"], \"url\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\"},\n # 本地 & 其他\n \"Ollama 本地\": {\"key_ph\":\"留空\", \"models\":[\"qwen2.5:7b\",\"llama3.1:8b\",\"gemma2:9b\",\"deepseek-r1:8b\"], \"url\":\"http://localhost:11434/v1\"},\n \"Argos Translate\": {\"key_ph\":\"留空\", \"models\":[], \"url\":\"\"},\n \"AnythingLLM\": {\"key_ph\":\"密钥\", \"models\":[], \"url\":\"http://localhost:3001/api/v1\"},\n \"Grok\": {\"key_ph\":\"密钥\", \"models\":[\"grok-2\",\"grok-2-mini\"], \"url\":\"https://api.x.ai/v1\"},\n # 自定义\n \"OpenAI 兼容\": {\"key_ph\":\"密钥\", \"models\":[\"自定义模型\"], \"url\":\"\"},\n }\n\n\n def __init__(self):\n super().__init__()\n self.setObjectName(\"PA\")\n lo = QVBoxLayout(self); lo.setContentsMargins(24,10,24,10); lo.setSpacing(4)\n\n hdr = QHBoxLayout()\n t = QLabel(\"设置\"); t.setObjectName(\"PT0\"); hdr.addWidget(t)\n hdr.addStretch()\n st = QLabel(\"翻译服务与偏好设置\"); st.setObjectName(\"PT1\"); hdr.addWidget(st)\n lo.addLayout(hdr)\n\n cols = QHBoxLayout(); cols.setSpacing(20)\n cfg = UserConfigManager.load()\n from PyQt5.QtWidgets import QGridLayout, QTextEdit\n\n # ══════════════════════════════════════════\n # 左列:翻译服务 → AI 助手\n # ══════════════════════════════════════════\n left = QVBoxLayout(); left.setSpacing(2)\n\n # ── 翻译服务配置 ──\n sl3 = QLabel(\"翻译服务配置\"); sl3.setObjectName(\"SL\"); left.addWidget(sl3)\n self.svc_card = _card()\n svc_outer = QVBoxLayout(self.svc_card)\n svc_outer.setContentsMargins(16,12,16,12); svc_outer.setSpacing(4)\n self.svc_selector = QComboBox()\n self.svc_selector.addItems(list(self.SERVICE_CONFIGS.keys()))\n self.svc_selector.currentTextChanged.connect(self._show_service_config)\n svc_outer.addWidget(self.svc_selector)\n self.svc_layout = QVBoxLayout(); self.svc_layout.setSpacing(2)\n svc_outer.addLayout(self.svc_layout)\n left.addWidget(self.svc_card)\n\n self.api_inputs = {}\n for svc_name, conf in self.SERVICE_CONFIGS.items():\n key_inp = QLineEdit(); key_inp.setPlaceholderText(f\"API Key: {conf['key_ph']}\")\n key_inp.setEchoMode(QLineEdit.Password)\n saved = cfg.get(f\"api_{svc_name}\", \"\")\n if saved: key_inp.setText(UserConfigManager.decode_sensitive(saved))\n key_inp.editingFinished.connect(self._save)\n self.api_inputs[svc_name] = {\"key\": key_inp}\n if conf[\"models\"]:\n mc = QComboBox(); mc.setEditable(True)\n mc.addItems(conf[\"models\"])\n saved_m = cfg.get(f\"model_{svc_name}\", \"\")\n if saved_m: mc.setCurrentText(saved_m)\n mc.currentTextChanged.connect(self._save)\n self.api_inputs[svc_name][\"model\"] = mc\n if conf[\"url\"]:\n ui = QLineEdit()\n saved_u = cfg.get(f\"url_{svc_name}\", \"\")\n ui.setText(saved_u if saved_u else conf[\"url\"])\n ui.editingFinished.connect(self._save)\n self.api_inputs[svc_name][\"url\"] = ui\n\n # ── AI 助手(摘要 / 问答服务) ──\n sl_ai = QLabel(\"AI 助手\"); sl_ai.setObjectName(\"SL\"); left.addWidget(sl_ai)\n ai_card = _card(); ai_lo = QVBoxLayout(ai_card)\n ai_lo.setContentsMargins(16, 12, 16, 12); ai_lo.setSpacing(6)\n\n ai_desc = QLabel(\"摘要与问答使用的 AI 服务,默认与翻译服务相同\")\n ai_desc.setObjectName(\"Cap\"); ai_desc.setWordWrap(True)\n ai_lo.addWidget(ai_desc)\n\n self._ai_default_row = QWidget()\n _adr_lo = QHBoxLayout(self._ai_default_row)\n _adr_lo.setContentsMargins(0, 2, 0, 2); _adr_lo.setSpacing(8)\n self._ai_status_label = QLabel(\"与翻译服务相同\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['ok']};background:transparent;\")\n _adr_lo.addWidget(self._ai_status_label); _adr_lo.addStretch()\n self._ai_toggle_btn = QPushButton(\"自定义\")\n self._ai_toggle_btn.setObjectName(\"Gh\"); self._ai_toggle_btn.setCursor(Qt.PointingHandCursor)\n self._ai_toggle_btn.setStyleSheet(\"font-size:11px;padding:3px 10px;\")\n self._ai_toggle_btn.clicked.connect(self._toggle_assistant_config)\n _adr_lo.addWidget(self._ai_toggle_btn)\n ai_lo.addWidget(self._ai_default_row)\n\n self._ai_custom_frame = QWidget()\n _acf_lo = QVBoxLayout(self._ai_custom_frame)\n _acf_lo.setContentsMargins(0, 6, 0, 0); _acf_lo.setSpacing(10)\n\n for _f_label, _f_attr, _f_type in [\n (\"服务\", \"_ai_svc_combo\", \"combo\"),\n (\"API Key\", \"_ai_key_input\", \"password\"),\n (\"模型\", \"_ai_model_combo\", \"combo_edit\"),\n (\"Base URL\",\"_ai_url_input\", \"line\"),\n ]:\n _grp = QVBoxLayout(); _grp.setSpacing(3)\n _lbl = QLabel(_f_label); _lbl.setStyleSheet(\n f\"font-size:11px;font-weight:500;color:{_C['t2']};background:transparent;padding:0;\")\n _grp.addWidget(_lbl)\n if _f_type == \"combo\":\n w = QComboBox()\n from ui.ai_client import CHAT_SERVICES\n w.addItems([name for name, _, _ in CHAT_SERVICES])\n w.currentTextChanged.connect(self._on_assistant_svc_changed)\n elif _f_type == \"password\":\n w = QLineEdit(); w.setEchoMode(QLineEdit.Password)\n w.setPlaceholderText(\"密钥\")\n w.editingFinished.connect(self._save_assistant_config)\n elif _f_type == \"combo_edit\":\n w = QComboBox(); w.setEditable(True)\n w.currentTextChanged.connect(self._save_assistant_config)\n else:\n w = QLineEdit(); w.setPlaceholderText(\"留空使用默认\")\n w.editingFinished.connect(self._save_assistant_config)\n setattr(self, _f_attr, w)\n _grp.addWidget(w)\n _acf_lo.addLayout(_grp)\n\n _reset_row = QHBoxLayout(); _reset_row.addStretch()\n _reset_btn = QPushButton(\"恢复默认\"); _reset_btn.setObjectName(\"GhDanger\")\n _reset_btn.setCursor(Qt.PointingHandCursor)\n _reset_btn.setStyleSheet(\"font-size:11px;padding:3px 10px;\")\n _reset_btn.clicked.connect(self._reset_assistant_config)\n _reset_row.addWidget(_reset_btn)\n _acf_lo.addLayout(_reset_row)\n\n self._ai_custom_frame.setVisible(False)\n ai_lo.addWidget(self._ai_custom_frame)\n self._load_assistant_config()\n left.addWidget(ai_card)\n left.addStretch()\n\n # ══════════════════════════════════════════\n # 右列:偏好 → 提示词 → 术语库 → Zotero\n # ══════════════════════════════════════════\n right = QVBoxLayout(); right.setSpacing(2)\n\n # ── 偏好设置 ──\n sl_app = QLabel(\"偏好设置\"); sl_app.setObjectName(\"SL\"); right.addWidget(sl_app)\n c = _card(); cl = QVBoxLayout(c); cl.setContentsMargins(16,12,16,12); cl.setSpacing(6)\n self.dark_check = QCheckBox(\"深色模式\"); self.dark_check.toggled.connect(self.dark_mode_changed.emit)\n self.cache_check = QCheckBox(\"翻译缓存\"); self.cache_check.setChecked(True)\n self.ai_check = QCheckBox(\"AI 布局检测\"); self.ai_check.setChecked(True)\n self.font_check = QCheckBox(\"字体子集化\")\n _pref_grid = QGridLayout(); _pref_grid.setSpacing(6)\n _pref_grid.addWidget(self.dark_check, 0, 0)\n _pref_grid.addWidget(self.cache_check, 0, 1)\n _pref_grid.addWidget(self.ai_check, 1, 0)\n _pref_grid.addWidget(self.font_check, 1, 1)\n cl.addLayout(_pref_grid)\n self.theme_row = QWidget(); tr_lo = QHBoxLayout(self.theme_row)\n tr_lo.setContentsMargins(0,2,0,2); tr_lo.setSpacing(6)\n tr_lbl = QLabel(\"主题色\"); tr_lo.addWidget(tr_lbl)\n THEME_COLORS = [\n (\"#0071E3\",\"蓝\"),(\"#FF6B6B\",\"红\"),(\"#34C759\",\"绿\"),\n (\"#AF52DE\",\"紫\"),(\"#FF9F0A\",\"橙\"),(\"#FF2D55\",\"粉\"),\n (\"#5AC8FA\",\"青\"),(\"#FFD60A\",\"黄\"),\n ]\n self._theme_dots = []\n for hex_c, name in THEME_COLORS:\n dot = QPushButton(); dot.setFixedSize(18, 18); dot.setCursor(Qt.PointingHandCursor)\n dot.setToolTip(name); dot.setProperty(\"hex\", hex_c)\n dot.setStyleSheet(\n f\"QPushButton{{background:{hex_c};border:2px solid transparent;\"\n f\"border-radius:9px;}}QPushButton:hover{{border-color:{hex_c};}}\")\n dot.clicked.connect(lambda _, h=hex_c: self._set_theme_color(h))\n tr_lo.addWidget(dot); self._theme_dots.append(dot)\n tr_lo.addStretch()\n cl.addWidget(self.theme_row)\n self.theme_row.setVisible(UserConfigManager.load().get(\"theme_unlocked\", False))\n right.addWidget(c)\n\n # ── 翻译提示词 ──\n from ui.prompt_manager import PromptTemplateManager\n sl4 = QLabel(\"翻译提示词\"); sl4.setObjectName(\"SL\"); right.addWidget(sl4)\n c4 = _card(); cl4 = QVBoxLayout(c4); cl4.setContentsMargins(16,10,16,10); cl4.setSpacing(2)\n pr = QHBoxLayout(); pr.setSpacing(4)\n self.prompt_preset = QComboBox()\n self._refresh_prompt_list()\n self.prompt_preset.currentTextChanged.connect(self._on_prompt_preset)\n pr.addWidget(self.prompt_preset, 1)\n cl4.addLayout(pr)\n self.prompt_edit = QTextEdit()\n self.prompt_edit.setPlaceholderText(\"提示词… {lang_out} {lang_in} {text}\")\n self.prompt_edit.setMaximumHeight(42)\n saved_prompt = cfg.get(\"prompt\", \"\")\n if saved_prompt: self.prompt_edit.setPlainText(saved_prompt)\n cl4.addWidget(self.prompt_edit)\n btn_row = QHBoxLayout(); btn_row.setSpacing(2)\n for label, slot in [\n (\"保存\", self._save_prompt_template),\n (\"新建\", self._new_prompt_template),\n (\"导入\", self._import_prompts),\n (\"导出\", self._export_prompts),\n ]:\n b = QPushButton(label); b.setObjectName(\"Gh\"); b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:11px;padding:2px 6px;\"); b.clicked.connect(slot)\n btn_row.addWidget(b)\n btn_row.addStretch()\n del_btn = QPushButton(\"删除\"); del_btn.setCursor(Qt.PointingHandCursor)\n del_btn.setObjectName(\"GhDanger\"); del_btn.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n del_btn.clicked.connect(self._delete_prompt_template)\n btn_row.addWidget(del_btn)\n cl4.addLayout(btn_row)\n right.addWidget(c4)\n\n # ── 术语库 ──\n from ui.glossary_manager import GlossaryManager\n sl5 = QLabel(\"术语库\"); sl5.setObjectName(\"SL\"); right.addWidget(sl5)\n c5 = _card(); cl5 = QVBoxLayout(c5); cl5.setContentsMargins(16,10,16,10); cl5.setSpacing(3)\n gr = QHBoxLayout(); gr.setSpacing(6)\n self.gloss_selector = QComboBox()\n self._refresh_gloss_list()\n self.gloss_selector.currentTextChanged.connect(self._on_gloss_changed)\n gr.addWidget(self.gloss_selector, 1)\n self.gloss_count = QLabel(\"\"); self.gloss_count.setObjectName(\"Cap\")\n self.gloss_count.setStyleSheet(\"font-size:10px;\")\n gr.addWidget(self.gloss_count)\n cl5.addLayout(gr); self._update_gloss_count()\n # 内联添加术语\n _add_row = QHBoxLayout(); _add_row.setSpacing(4)\n self._gloss_src = QLineEdit(); self._gloss_src.setPlaceholderText(\"原文\")\n self._gloss_src.setStyleSheet(\"font-size:11px;padding:4px 8px;\")\n self._gloss_dst = QLineEdit(); self._gloss_dst.setPlaceholderText(\"译文\")\n self._gloss_dst.setStyleSheet(\"font-size:11px;padding:4px 8px;\")\n _add_btn = QPushButton(\"添加\"); _add_btn.setObjectName(\"Gh\")\n _add_btn.setCursor(Qt.PointingHandCursor)\n _add_btn.setStyleSheet(\"font-size:11px;padding:2px 8px;\")\n _add_btn.clicked.connect(self._add_gloss_term)\n _add_row.addWidget(self._gloss_src, 1)\n _add_row.addWidget(self._gloss_dst, 1)\n _add_row.addWidget(_add_btn)\n cl5.addLayout(_add_row)\n gbtn = QHBoxLayout(); gbtn.setSpacing(2)\n for label, slot in [\n (\"新建\", self._new_glossary), (\"导入\", self._import_glossary),\n (\"导出\", self._export_glossary),\n ]:\n b = QPushButton(label); b.setObjectName(\"Gh\"); b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:11px;padding:2px 6px;\"); b.clicked.connect(slot)\n gbtn.addWidget(b)\n gbtn.addStretch()\n gdel = QPushButton(\"删除\"); gdel.setCursor(Qt.PointingHandCursor)\n gdel.setObjectName(\"GhDanger\"); gdel.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n gdel.clicked.connect(self._delete_glossary); gbtn.addWidget(gdel)\n cl5.addLayout(gbtn)\n right.addWidget(c5)\n\n # ── Zotero 联动 ──\n sl_zot = QLabel(\"Zotero 联动\"); sl_zot.setObjectName(\"SL\"); right.addWidget(sl_zot)\n zot_card = _card(); zot_lo = QVBoxLayout(zot_card)\n zot_lo.setContentsMargins(14,10,14,10); zot_lo.setSpacing(4)\n self._zot_sbs = QCheckBox(\"左右并排 (Side by Side)\")\n self._zot_dual = QCheckBox(\"双语对照 (Dual)\")\n self._zot_mono = QCheckBox(\"仅翻译 (Mono)\")\n self._zot_keep_copy = QCheckBox(\"保留本地副本\")\n cfg = UserConfigManager.load()\n _zot_modes = cfg.get(\"zotero_output_modes\", [\"side_by_side\"])\n self._zot_sbs.setChecked(\"side_by_side\" in _zot_modes)\n self._zot_dual.setChecked(\"dual\" in _zot_modes)\n self._zot_mono.setChecked(\"mono\" in _zot_modes)\n self._zot_keep_copy.setChecked(cfg.get(\"zotero_keep_copy\", True))\n _zot_grid = QGridLayout(); _zot_grid.setSpacing(4)\n _zot_grid.addWidget(self._zot_sbs, 0, 0)\n _zot_grid.addWidget(self._zot_dual, 0, 1)\n _zot_grid.addWidget(self._zot_mono, 1, 0)\n _zot_grid.addWidget(self._zot_keep_copy, 1, 1)\n for _cb in (self._zot_sbs, self._zot_dual, self._zot_mono, self._zot_keep_copy):\n _cb.stateChanged.connect(self._save_zotero_modes)\n zot_lo.addLayout(_zot_grid)\n zot_lo.addWidget(_div())\n _auto_row = QHBoxLayout(); _auto_row.setSpacing(6)\n _auto_desc = QLabel(\"Zotero 插件\"); _auto_desc.setObjectName(\"Cap\")\n _auto_row.addWidget(_auto_desc)\n self._zot_status = QLabel(\"\"); self._zot_status.setObjectName(\"Cap\")\n _auto_row.addWidget(self._zot_status); _auto_row.addStretch()\n self._zot_install_btn = QPushButton(\"一键安装\")\n self._zot_install_btn.setObjectName(\"Gh\"); self._zot_install_btn.setCursor(Qt.PointingHandCursor)\n self._zot_install_btn.clicked.connect(self._install_zotero_plugin)\n _auto_row.addWidget(self._zot_install_btn)\n zot_lo.addLayout(_auto_row)\n QTimer.singleShot(500, self._check_zotero_plugin)\n right.addWidget(zot_card)\n\n # ── 使用指南(紧凑) ──\n sl_guide = QLabel(\"使用指南\"); sl_guide.setObjectName(\"SL\"); right.addWidget(sl_guide)\n guide_card = _card(\"sm\"); guide_lo = QVBoxLayout(guide_card)\n guide_lo.setContentsMargins(12,8,12,8); guide_lo.setSpacing(1)\n for tip in [\n \"Google / Bing 无需配置,开箱即用\",\n \"OpenAI 兼容接口可对接任意第三方服务\",\n \"Ollama 本地需先启动服务\",\n \"50 页以上建议开启分块翻译\",\n ]:\n tl = QLabel(f\"· {tip}\"); tl.setObjectName(\"Cap\")\n tl.setStyleSheet(\"font-size:10px;padding:1px 0;\")\n guide_lo.addWidget(tl)\n guide_lo.addWidget(_div())\n self._kb_labels = []\n kb_grid = QGridLayout(); kb_grid.setHorizontalSpacing(6); kb_grid.setVerticalSpacing(1)\n for i, (key, desc) in enumerate([\n (\"Ctrl+滚轮\",\"缩放\"), (\"← →\",\"切换\"), (\"页码+回车\",\"跳转\"),\n ]):\n kl = QLabel(key); kl.setObjectName(\"KBKey\"); kl.setStyleSheet(\"font-size:9px;padding:2px 5px;\")\n kd = QLabel(desc); kd.setObjectName(\"Cap\"); kd.setStyleSheet(\"font-size:9px;\")\n kb_grid.addWidget(kl, 0, i * 2); kb_grid.addWidget(kd, 0, i * 2 + 1)\n self._kb_labels.append(kl)\n guide_lo.addLayout(kb_grid)\n guide_lo.addWidget(_div())\n _paths_col = QVBoxLayout(); _paths_col.setSpacing(1)\n for _pname, _pfile in [\n (\"配置\", \"~/pdf2zh_gui_config.json\"),\n (\"历史\", \"~/pdf2zh_history.json\"),\n (\"术语库\", \"~/pdf2zh_glossary_*.csv\"),\n ]:\n _pl = QLabel(f\"{_pname} {_pfile}\")\n _pl.setObjectName(\"Cap\"); _pl.setStyleSheet(\"font-size:9px;padding:0;\")\n _paths_col.addWidget(_pl)\n guide_lo.addLayout(_paths_col)\n _paths_btn_row = QHBoxLayout(); _paths_btn_row.addStretch()\n open_dir_btn = QPushButton(\"打开数据目录\"); open_dir_btn.setObjectName(\"Gh\")\n open_dir_btn.setCursor(Qt.PointingHandCursor)\n open_dir_btn.setStyleSheet(\"font-size:10px;padding:2px 6px;\")\n open_dir_btn.clicked.connect(lambda: __import__('subprocess').run(['open', str(__import__('pathlib').Path.home())]))\n _paths_btn_row.addWidget(open_dir_btn)\n guide_lo.addLayout(_paths_btn_row)\n right.addWidget(guide_card)\n right.addStretch()\n\n cols.addLayout(left, 5)\n cols.addLayout(right, 4)\n lo.addLayout(cols)\n\n # 初始显示第一个服务\n self._show_service_config(self.svc_selector.currentText())\n\n def _show_service_config(self, svc_name):\n \"\"\"切换显示选中服务的配置\"\"\"\n # 清空当前\n while self.svc_layout.count():\n item = self.svc_layout.takeAt(0)\n w = item.widget()\n if w: w.setParent(None)\n sub = item.layout()\n if sub:\n while sub.count():\n si = sub.takeAt(0)\n if si.widget(): si.widget().setParent(None)\n\n if svc_name not in self.api_inputs:\n return\n inputs = self.api_inputs[svc_name]\n\n # API Key\n kl = QLabel(\"API Key\"); kl.setObjectName(\"FL\")\n self.svc_layout.addWidget(kl)\n self.svc_layout.addWidget(inputs[\"key\"])\n inputs[\"key\"].setParent(self.svc_card)\n inputs[\"key\"].show()\n\n # 模型\n if \"model\" in inputs:\n ml = QLabel(\"模型\"); ml.setObjectName(\"FL\")\n self.svc_layout.addWidget(ml)\n self.svc_layout.addWidget(inputs[\"model\"])\n inputs[\"model\"].setParent(self.svc_card)\n inputs[\"model\"].show()\n\n # Base URL\n if \"url\" in inputs:\n ul = QLabel(\"Base URL\"); ul.setObjectName(\"FL\")\n self.svc_layout.addWidget(ul)\n self.svc_layout.addWidget(inputs[\"url\"])\n inputs[\"url\"].setParent(self.svc_card)\n inputs[\"url\"].show()\n\n # 测试连接按钮\n self.svc_layout.addSpacing(4)\n self._test_label = QLabel(\"\")\n self._test_label.setObjectName(\"Cap\")\n test_btn = QPushButton(\"测试连接\")\n test_btn.setObjectName(\"Gh\"); test_btn.setCursor(Qt.PointingHandCursor)\n test_btn.clicked.connect(lambda: self._test_connection(svc_name))\n test_row = QHBoxLayout(); test_row.addStretch()\n test_row.addWidget(self._test_label)\n test_row.addWidget(test_btn)\n self.svc_layout.addLayout(test_row)\n\n def _test_connection(self, svc_name):\n \"\"\"测试翻译服务连接\"\"\"\n self._save() # 先保存,确保 API Key 写入配置\n self._test_label.setText(\"测试中…\")\n self._test_label.setStyleSheet(\"color:#FF9F0A;font-size:11px;\")\n QApplication.processEvents()\n\n inputs = self.api_inputs.get(svc_name, {})\n key = inputs.get(\"key\")\n key_val = key.text().strip() if key else \"\"\n url_w = inputs.get(\"url\")\n url_val = url_w.text().strip() if url_w else \"\"\n\n try:\n import requests\n # 对于 OpenAI 兼容类服务,测试 /models 端点\n if url_val and (\"openai\" in url_val or \"deepseek\" in url_val\n or \"siliconflow\" in url_val or \"groq\" in url_val\n or \"bigmodel\" in url_val or \"dashscope\" in url_val\n or \"localhost\" in url_val or \"x.ai\" in url_val):\n headers = {\"Authorization\": f\"Bearer {key_val}\"} if key_val else {}\n r = requests.get(f\"{url_val}/models\", headers=headers, timeout=10)\n if r.status_code in (200, 401, 403):\n self._test_label.setText(\"✅ 连接成功\")\n self._test_label.setStyleSheet(f\"color:{_C['ok']};font-size:11px;\")\n else:\n self._test_label.setText(f\"⚠️ HTTP {r.status_code}\")\n self._test_label.setStyleSheet(f\"color:{_C['t2']};font-size:11px;\")\n elif \"deepl\" in svc_name.lower():\n r = requests.get(\"https://api-free.deepl.com/v2/usage\",\n headers={\"Authorization\": f\"DeepL-Auth-Key {key_val}\"}, timeout=10)\n self._test_label.setText(\"✅ 连接成功\" if r.ok else f\"⚠️ {r.status_code}\")\n self._test_label.setStyleSheet(f\"color:{_C['ok'] if r.ok else _C['t2']};font-size:11px;\")\n else:\n self._test_label.setText(\"✅ 免费服务,无需测试\")\n self._test_label.setStyleSheet(f\"color:{_C['ok']};font-size:11px;\")\n except requests.exceptions.ConnectionError:\n self._test_label.setText(\"❌ 无法连接\")\n self._test_label.setStyleSheet(f\"color:{_C['err']};font-size:11px;\")\n except Exception as e:\n self._test_label.setText(f\"❌ {str(e)[:30]}\")\n self._test_label.setStyleSheet(f\"color:{_C['err']};font-size:11px;\")\n\n def _refresh_prompt_list(self):\n from ui.prompt_manager import PromptTemplateManager\n self.prompt_preset.blockSignals(True)\n current = self.prompt_preset.currentText()\n self.prompt_preset.clear()\n templates = PromptTemplateManager.load_all()\n self.prompt_preset.addItems(list(templates.keys()))\n idx = self.prompt_preset.findText(current)\n if idx >= 0:\n self.prompt_preset.setCurrentIndex(idx)\n self.prompt_preset.blockSignals(False)\n\n def _on_prompt_preset(self, text):\n from ui.prompt_manager import PromptTemplateManager\n templates = PromptTemplateManager.load_all()\n val = templates.get(text, \"\")\n self.prompt_edit.setPlainText(val)\n self._save()\n\n def _save_prompt_template(self):\n \"\"\"保存当前内容到选中的模板名\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n name = self.prompt_preset.currentText()\n content = self.prompt_edit.toPlainText().strip()\n PromptTemplateManager.save_template(name, content)\n self._refresh_prompt_list()\n\n def _new_prompt_template(self):\n \"\"\"新建模板\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n name, ok = QLineEdit.staticMetaObject, False # placeholder\n # 用简单的输入对话框\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建模板\", \"模板名称:\")\n if ok and name.strip():\n content = self.prompt_edit.toPlainText().strip()\n PromptTemplateManager.save_template(name.strip(), content)\n self._refresh_prompt_list()\n idx = self.prompt_preset.findText(name.strip())\n if idx >= 0:\n self.prompt_preset.setCurrentIndex(idx)\n\n def _delete_prompt_template(self):\n \"\"\"删除用户模板\"\"\"\n from ui.prompt_manager import PromptTemplateManager, DEFAULT_TEMPLATES\n name = self.prompt_preset.currentText()\n if name in DEFAULT_TEMPLATES:\n return # 不能删默认模板\n PromptTemplateManager.delete_template(name)\n self._refresh_prompt_list()\n\n def _import_prompts(self):\n \"\"\"从 JSON 文件导入模板\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n path, _ = QFileDialog.getOpenFileName(self, \"导入模板\", \"\", \"JSON (*.json)\")\n if path:\n count = PromptTemplateManager.import_from_file(path)\n self._refresh_prompt_list()\n\n def _export_prompts(self):\n \"\"\"导出所有模板到 JSON\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n path, _ = QFileDialog.getSaveFileName(self, \"导出模板\", \"pdf2zh_prompts.json\", \"JSON (*.json)\")\n if path:\n PromptTemplateManager.export_to_file(path)\n\n def _set_theme_color(self, hex_color):\n \"\"\"切换主题色并即时应用\"\"\"\n cfg = UserConfigManager.load()\n cfg[\"theme_color\"] = hex_color\n UserConfigManager.save(cfg)\n # 高亮选中的色块\n for dot in self._theme_dots:\n sel = dot.property(\"hex\") == hex_color\n dot.setStyleSheet(\n f\"QPushButton{{background:{dot.property('hex')};\"\n f\"border:2px solid {'#1d1d1f' if sel else 'transparent'};\"\n f\"border-radius:9px;}}QPushButton:hover{{border-color:{dot.property('hex')};}}\")\n # 通知 MainWindow 刷新主题\n self.theme_color_changed.emit(hex_color)\n\n def _save(self, *args):\n cfg = UserConfigManager.load()\n for svc_name, inputs in self.api_inputs.items():\n key_val = inputs[\"key\"].text().strip()\n cfg[f\"api_{svc_name}\"] = UserConfigManager.encode_sensitive(key_val) if key_val else \"\"\n if \"model\" in inputs:\n cfg[f\"model_{svc_name}\"] = inputs[\"model\"].currentText().strip()\n if \"url\" in inputs:\n cfg[f\"url_{svc_name}\"] = inputs[\"url\"].text().strip()\n cfg[\"prompt\"] = self.prompt_edit.toPlainText().strip()\n UserConfigManager.save(cfg)\n\n def _save_zotero_modes(self, *args):\n modes = []\n if self._zot_sbs.isChecked(): modes.append(\"side_by_side\")\n if self._zot_dual.isChecked(): modes.append(\"dual\")\n if self._zot_mono.isChecked(): modes.append(\"mono\")\n if not modes:\n modes = [\"side_by_side\"]\n self._zot_sbs.blockSignals(True)\n self._zot_sbs.setChecked(True)\n self._zot_sbs.blockSignals(False)\n cfg = UserConfigManager.load()\n cfg[\"zotero_output_modes\"] = modes\n cfg[\"zotero_keep_copy\"] = self._zot_keep_copy.isChecked()\n UserConfigManager.save(cfg)\n\n # ── AI 助手配置 ──\n\n def _load_assistant_config(self):\n \"\"\"加载 AI 助手独立配置状态\"\"\"\n cfg = UserConfigManager.load()\n is_custom = cfg.get(\"assistant_custom\", False)\n if is_custom:\n self._ai_custom_frame.setVisible(True)\n self._ai_status_label.setText(\"使用独立配置\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['acc']};background:transparent;\")\n self._ai_toggle_btn.setVisible(False)\n svc = cfg.get(\"assistant_service\", \"\")\n if svc:\n idx = self._ai_svc_combo.findText(svc)\n if idx >= 0: self._ai_svc_combo.setCurrentIndex(idx)\n api_raw = cfg.get(\"assistant_api_key\", \"\")\n if api_raw:\n self._ai_key_input.setText(UserConfigManager.decode_sensitive(api_raw))\n model = cfg.get(\"assistant_model\", \"\")\n if model: self._ai_model_combo.setCurrentText(model)\n url = cfg.get(\"assistant_url\", \"\")\n if url: self._ai_url_input.setText(url)\n else:\n self._ai_custom_frame.setVisible(False)\n self._ai_status_label.setText(\"✓ 使用翻译服务配置\")\n self._ai_toggle_btn.setText(\"自定义\")\n\n def _toggle_assistant_config(self):\n \"\"\"展开自定义 AI 助手配置\"\"\"\n self._ai_custom_frame.setVisible(True)\n self._ai_status_label.setText(\"使用独立配置\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['acc']};background:transparent;\")\n self._ai_toggle_btn.setVisible(False)\n # 预填当前翻译服务配置\n from ui.ai_client import detect_service\n svc = detect_service()\n if svc:\n idx = self._ai_svc_combo.findText(svc[\"name\"])\n if idx >= 0:\n self._ai_svc_combo.blockSignals(True)\n self._ai_svc_combo.setCurrentIndex(idx)\n self._ai_svc_combo.blockSignals(False)\n self._ai_key_input.setText(svc.get(\"api_key\", \"\"))\n self._ai_model_combo.setCurrentText(svc.get(\"model\", \"\"))\n self._ai_url_input.setText(svc.get(\"base_url\", \"\"))\n self._on_assistant_svc_changed(self._ai_svc_combo.currentText())\n self._save_assistant_config()\n\n def _on_assistant_svc_changed(self, svc_name):\n \"\"\"切换 AI 助手服务时更新模型候选列表\"\"\"\n from ui.ai_client import CHAT_SERVICES\n # 查找默认 URL\n default_url = \"\"\n for name, url, _ in CHAT_SERVICES:\n if name == svc_name:\n default_url = url; break\n if not self._ai_url_input.text().strip() or True:\n self._ai_url_input.setPlaceholderText(default_url or \"自定义 URL\")\n # 更新模型列表\n cur_model = self._ai_model_combo.currentText()\n self._ai_model_combo.blockSignals(True)\n self._ai_model_combo.clear()\n svc_conf = self.SERVICE_CONFIGS.get(svc_name, {})\n if svc_conf.get(\"models\"):\n self._ai_model_combo.addItems(svc_conf[\"models\"])\n if cur_model:\n self._ai_model_combo.setCurrentText(cur_model)\n self._ai_model_combo.blockSignals(False)\n self._save_assistant_config()\n\n def _save_assistant_config(self, *args):\n \"\"\"保存 AI 助手独立配置\"\"\"\n if not self._ai_custom_frame.isVisible():\n return\n cfg = UserConfigManager.load()\n cfg[\"assistant_custom\"] = True\n cfg[\"assistant_service\"] = self._ai_svc_combo.currentText()\n key = self._ai_key_input.text().strip()\n cfg[\"assistant_api_key\"] = UserConfigManager.encode_sensitive(key) if key else \"\"\n cfg[\"assistant_model\"] = self._ai_model_combo.currentText()\n cfg[\"assistant_url\"] = self._ai_url_input.text().strip()\n UserConfigManager.save(cfg)\n\n def _reset_assistant_config(self):\n \"\"\"恢复 AI 助手使用翻译服务配置\"\"\"\n cfg = UserConfigManager.load()\n for k in (\"assistant_custom\", \"assistant_service\", \"assistant_api_key\",\n \"assistant_model\", \"assistant_url\"):\n cfg.pop(k, None)\n UserConfigManager.save(cfg)\n self._ai_custom_frame.setVisible(False)\n self._ai_status_label.setText(\"与翻译服务相同\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['ok']};background:transparent;\")\n self._ai_toggle_btn.setVisible(True)\n # 清空输入\n self._ai_key_input.clear()\n self._ai_model_combo.setCurrentIndex(0)\n self._ai_url_input.clear()\n\n def _install_zotero_plugin(self):\n \"\"\"一键安装 pdf2zh Connector 到 Zotero\"\"\"\n import glob, shutil, subprocess\n xpi = _res('assets', 'pdf2zh-connector.xpi')\n if not os.path.exists(xpi):\n self._zot_status.setText(\"插件文件缺失\")\n self._zot_status.setStyleSheet(\"color:#FF3B30;\")\n return\n # 查找 Zotero profile 目录\n profiles_dir = os.path.expanduser(\"~/Library/Application Support/Zotero/Profiles\")\n profiles = glob.glob(os.path.join(profiles_dir, \"*.default\"))\n if not profiles:\n self._zot_status.setText(\"找不到 Zotero 配置目录\")\n self._zot_status.setStyleSheet(\"color:#FF3B30;\")\n return\n profile = profiles[0]\n # 1. 复制 XPI 到 extensions 目录\n ext_dir = os.path.join(profile, \"extensions\")\n os.makedirs(ext_dir, exist_ok=True)\n dst = os.path.join(ext_dir, \"pdf2zh-connector@aarongig.com.xpi\")\n shutil.copy2(xpi, dst)\n # 2. 设置 autoDisableScopes=0 允许 profile 级别的扩展自动加载\n prefs_path = os.path.join(profile, \"prefs.js\")\n pref_line = 'user_pref(\"extensions.autoDisableScopes\", 0);\\n'\n try:\n prefs_content = open(prefs_path, 'r', encoding='utf-8').read()\n if 'autoDisableScopes' not in prefs_content:\n with open(prefs_path, 'a', encoding='utf-8') as f:\n f.write(pref_line)\n except FileNotFoundError:\n with open(prefs_path, 'w', encoding='utf-8') as f:\n f.write(pref_line)\n # 3. 清除启动缓存,强制 Zotero 重新扫描扩展\n cache = os.path.join(profile, \"addonStartup.json.lz4\")\n if os.path.exists(cache):\n os.remove(cache)\n # 询问是否重启 Zotero\n reply = QMessageBox.question(\n self, \"安装成功\",\n \"pdf2zh Connector 已部署到 Zotero,重启 Zotero 后生效。\\n\\n现在重启 Zotero?\",\n QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes\n )\n if reply == QMessageBox.Yes:\n subprocess.run([\"pkill\", \"-x\", \"zotero\"], capture_output=True)\n subprocess.run([\"pkill\", \"-x\", \"Zotero\"], capture_output=True)\n QTimer.singleShot(2000, lambda: subprocess.Popen([\"open\", \"-a\", \"Zotero\"]))\n QTimer.singleShot(10000, self._check_zotero_plugin)\n self._zot_status.setText(\"正在重启 Zotero…\")\n self._zot_status.setStyleSheet(\"color:#0071E3;\")\n else:\n self._zot_status.setText(\"下次启动 Zotero 后生效\")\n self._zot_status.setStyleSheet(\"color:#0071E3;\")\n\n def _check_zotero_plugin(self):\n \"\"\"检测 pdf2zh Connector 插件状态\"\"\"\n if zotero_plugin_installed():\n self._zot_status.setText(\"已安装\")\n self._zot_status.setStyleSheet(\"color:#34C759;font-weight:600;\")\n self._zot_install_btn.hide()\n else:\n self._zot_status.setText(\"未安装\")\n self._zot_install_btn.setText(\"一键安装\")\n self._zot_install_btn.show()\n self._zot_install_btn.setEnabled(True)\n\n # ── 术语库操作 ──\n\n def _add_gloss_term(self):\n \"\"\"内联添加一条术语\"\"\"\n src = self._gloss_src.text().strip()\n dst = self._gloss_dst.text().strip()\n if not src or not dst:\n return\n from ui.glossary_manager import GlossaryManager\n GlossaryManager.add_term(src, dst)\n self._gloss_src.clear(); self._gloss_dst.clear()\n self._update_gloss_count()\n\n def _refresh_gloss_list(self):\n from ui.glossary_manager import GlossaryManager\n self.gloss_selector.blockSignals(True)\n cur = self.gloss_selector.currentText()\n self.gloss_selector.clear()\n all_g = GlossaryManager.load_all_presets()\n self.gloss_selector.addItems(list(all_g.keys()))\n idx = self.gloss_selector.findText(cur)\n if idx >= 0: self.gloss_selector.setCurrentIndex(idx)\n self.gloss_selector.blockSignals(False)\n\n def _on_gloss_changed(self, name):\n from ui.glossary_manager import GlossaryManager\n GlossaryManager.load_preset(name)\n self._update_gloss_count()\n\n def _update_gloss_count(self):\n from ui.glossary_manager import GlossaryManager\n n = GlossaryManager.count()\n self.gloss_count.setText(f\"{n} 条术语已激活\" if n else \"未加载术语\")\n\n def _new_glossary(self):\n \"\"\"新建自定义术语库\"\"\"\n from ui.glossary_manager import GlossaryManager\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建术语库\", \"术语库名称:\")\n if ok and name.strip():\n name = name.strip()\n # 在用户目录下创建空术语库文件\n import json\n from pathlib import Path\n user_dir = Path.home() / \"pdf2zh_glossaries\"\n user_dir.mkdir(exist_ok=True)\n fp = user_dir / f\"{name}.json\"\n fp.write_text(json.dumps({}, indent=2, ensure_ascii=False), encoding=\"utf-8\")\n self._refresh_gloss_list()\n idx = self.gloss_selector.findText(name)\n if idx >= 0:\n self.gloss_selector.setCurrentIndex(idx)\n\n def _delete_glossary(self):\n \"\"\"删除用户自定义术语库(不能删预设)\"\"\"\n from ui.glossary_manager import GlossaryManager, DEFAULT_GLOSSARIES\n from pathlib import Path\n name = self.gloss_selector.currentText()\n if name in DEFAULT_GLOSSARIES:\n return\n fp = Path.home() / \"pdf2zh_glossaries\" / f\"{name}.json\"\n if fp.exists():\n fp.unlink()\n self._refresh_gloss_list()\n self._update_gloss_count()\n\n def _import_glossary(self):\n from ui.glossary_manager import GlossaryManager\n path, _ = QFileDialog.getOpenFileName(self, \"导入术语库\", \"\", \"CSV (*.csv);;JSON (*.json)\")\n if path:\n if path.endswith('.csv'):\n GlossaryManager.import_csv(path)\n else:\n GlossaryManager.import_json(path)\n self._update_gloss_count()\n self._refresh_gloss_list()\n\n def _export_glossary(self):\n from ui.glossary_manager import GlossaryManager\n path, _ = QFileDialog.getSaveFileName(self, \"导出术语库\", \"glossary.csv\", \"CSV (*.csv);;JSON (*.json)\")\n if path:\n if path.endswith('.json'):\n GlossaryManager.export_json(path)\n else:\n GlossaryManager.export_csv(path)\n\n def update_theme(self, c):\n \"\"\"深色/浅色切换时更新内联样式\"\"\"\n self.prompt_edit.setStyleSheet(\n f\"font-size:11px;border-radius:6px;background:{c['inp']};color:{c['t1']};\"\n f\"border:1px solid {c['inp_b']};\"\n )\n # 刷新主题色行可见性(可能骰子刚解锁)\n cfg = UserConfigManager.load()\n self.theme_row.setVisible(cfg.get(\"theme_unlocked\", False))\n\n\n# ═══════════════════════════════════════════════════════════════\n# 关于页面\n# ═══════════════════════════════════════════════════════════════\n\nclass AboutPage(QWidget):\n def __init__(self):\n super().__init__()\n from PyQt5.QtWidgets import QGridLayout\n lo = QVBoxLayout(self); lo.setContentsMargins(32,20,32,20); lo.setSpacing(12)\n\n # 顶部:名称 + 版本 + GitHub\n top = QHBoxLayout(); top.setSpacing(8)\n ti = _EggLogo(\"📄\", 22)\n ti.setFixedSize(32, 32); ti.setAlignment(Qt.AlignCenter)\n top.addWidget(ti)\n tn = QPushButton(\"pdf2zh-desktop\"); tn.setObjectName(\"SBLink\")\n tn.setStyleSheet(\"font-size:18px;font-weight:700;padding:0;text-align:left;\")\n tn.setCursor(Qt.PointingHandCursor); tn.setFlat(True)\n tn.clicked.connect(lambda: webbrowser.open(\"https://github.com/AaronGIG/pdf2zh-desktop\"))\n top.addWidget(tn)\n tv = QLabel(\"v2.2.0\"); tv.setObjectName(\"Cap\"); top.addWidget(tv)\n tt = QLabel(\"macOS\"); tt.setObjectName(\"Tag\"); tt.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed); top.addWidget(tt)\n top.addStretch()\n gb = QPushButton(\"GitHub ↗\"); gb.setObjectName(\"Gh\"); gb.setCursor(Qt.PointingHandCursor)\n gb.clicked.connect(lambda: webbrowser.open(\"https://github.com/AaronGIG/pdf2zh-desktop\"))\n top.addWidget(gb)\n lo.addLayout(top)\n\n # 两列布局:左=介绍+增强,右=作者+支持者\n cols = QHBoxLayout(); cols.setSpacing(16)\n\n # ── 左列 ──\n left = QVBoxLayout(); left.setSpacing(6)\n\n dc = _card(); dl = QVBoxLayout(dc); dl.setContentsMargins(16,12,16,12); dl.setSpacing(4)\n dt = QLabel(\"开箱即用的学术 PDF 翻译工具\"); dt.setStyleSheet(\"font-size:12px;font-weight:600;\"); dl.addWidget(dt)\n dd = QLabel(\"保留排版 · 公式无损 · 批量任务 · 历史追踪 · 20+ 翻译服务\")\n dd.setObjectName(\"Cap\"); dd.setStyleSheet(\"font-size:11px;\"); dd.setWordWrap(True); dl.addWidget(dd)\n left.addWidget(dc)\n\n mc = _card(); ml = QVBoxLayout(mc); ml.setContentsMargins(16,10,16,10); ml.setSpacing(3)\n mt = QLabel(\"macOS 版增强\"); mt.setStyleSheet(\"font-size:12px;font-weight:600;\"); ml.addWidget(mt)\n for emoji, text in [\n (\"🎨\",\"原生设计 · 深色模式 · 自定义主题色\"),\n (\"👀\",\"Dual · Mono · Side by Side 预览\"),\n (\"📖\",\"历史即时预览 · 上下键浏览\"),\n (\"✂️\",\"智能分块 · 1000+页\"),\n (\"🖐\",\"触摸板缩放 · 页码跳转\"),\n (\"📝\",\"术语库 · 提示词模板\"),\n (\"🍀\",\"每日骰子 · 隐藏彩蛋\"),\n (\"💬\",\"全时段人文关怀 · 名言语录\"),\n (\"🚀\",\".app 双击启动 · Retina\"),\n ]:\n r = QHBoxLayout(); r.setSpacing(6)\n e = QLabel(emoji); e.setStyleSheet(\"font-size:13px;background:transparent;\"); e.setFixedWidth(18); r.addWidget(e)\n t2 = QLabel(text); t2.setObjectName(\"Cap\"); t2.setStyleSheet(\"font-size:11px;\"); r.addWidget(t2)\n ml.addLayout(r)\n ml.addWidget(_div())\n te = QLabel(\"基于 PDFMathTranslate (EMNLP 2025) · PyQt5 · PyMuPDF · OnnxRuntime\")\n te.setObjectName(\"Cap\"); te.setStyleSheet(\"font-size:10px;\"); te.setWordWrap(True)\n ml.addWidget(te)\n left.addWidget(mc)\n left.addStretch()\n\n cols.addLayout(left, 3)\n\n # ── 右列 ──\n right = QVBoxLayout(); right.setSpacing(6)\n\n # 作者\n ac = _card(); acl = QHBoxLayout(ac); acl.setContentsMargins(16,12,16,12); acl.setSpacing(10)\n author_av = QLabel()\n author_av.setFixedSize(36, 36)\n avatar_path = _res('assets', 'author_avatar.png')\n if os.path.exists(avatar_path):\n apx = QPixmap(avatar_path).scaled(72, 72, Qt.KeepAspectRatio, Qt.SmoothTransformation)\n apx.setDevicePixelRatio(2.0)\n author_av.setPixmap(apx)\n else:\n author_av.setText(\"艾\"); author_av.setAlignment(Qt.AlignCenter)\n author_av.setStyleSheet(\"background:#1a1a2e;border-radius:18px;color:white;font-size:16px;font-weight:700;\")\n acl.addWidget(author_av)\n ai = QVBoxLayout(); ai.setSpacing(0)\n an = QLabel(\"艾伦说\"); an.setStyleSheet(\"font-size:13px;font-weight:700;\"); ai.addWidget(an)\n xhs = QLabel(\"小红书: needsleeeeep\"); xhs.setObjectName(\"Cap\"); xhs.setStyleSheet(\"font-size:11px;\"); ai.addWidget(xhs)\n motto = QLabel(\"希望能做更有意义的事 · 专注交付生产级的垂直学术公共品 🍀\")\n motto.setObjectName(\"Cap\"); motto.setStyleSheet(\"font-size:10px;color:rgba(142,142,147,0.7);\"); ai.addWidget(motto)\n acl.addLayout(ai); acl.addStretch()\n xhs_btn = QPushButton(\"关注 ↗\"); xhs_btn.setObjectName(\"Gh\"); xhs_btn.setCursor(Qt.PointingHandCursor)\n xhs_btn.setStyleSheet(\"font-size:11px;\")\n xhs_btn.clicked.connect(lambda: webbrowser.open(\"https://www.xiaohongshu.com/user/profile/66c6fef7000000001d0315ef\"))\n acl.addWidget(xhs_btn)\n right.addWidget(ac)\n\n # 支持者头像墙\n cc = _card(); ccl = QVBoxLayout(cc); ccl.setContentsMargins(16,12,16,12); ccl.setSpacing(6)\n ct = QLabel(\"感谢小红书社区支持者 ♡\"); ct.setStyleSheet(\"font-size:11px;font-weight:600;\"); ccl.addWidget(ct)\n\n supporters = [\n \"星爷!\",\"卧\",\"Jun warrior\",\"幼儿园鹿小眸\",\"侠禅\",\n \"ol\",\"幼儿园老大\",\"MX\",\"贝斯特宋\",\"Catherine\",\n \"不哩不哩左门卫\",\"momo\",\"李善兰\",\"问道不求仙\",\"hml\",\n \"ThereisTherse\",\"人类与猫\",\"小红薯643C3625\",\"月栖竹\",\"风来\",\n \"Nick\",\"咕噜咕噜🍗\",\"雾散时分起\",\"大脸咪布爱吃鱼\",\"限定 momo\",\n \"麦兜\",\"锅的刚\",\"一颗冒泡的卤蛋\",\"我草莓招了\",\n \"去Nature整点论文\",\"Masker\",\n \"AI maker趣造\",\"帕克的创业日记\",\"思维汪汪\",\"橘座\",\"小白也想学编程\",\n \"宛风Vanfeng\",\"小宝の日常\",\"未来百科\",\"你们的万能小卓\",\"碎银几两\",\n \"创界AIzine\",\"全栈小5\",\"无敌霸王龙\",\"小艾同学\",\"一万块的快乐\",\n \"学习笔记\",\"三丰不是张\",\"丁一\",\"阿漫AIChat\",\"知命不惧\",\n \"脱离社畜体制\",\"一瓢清浅\",\"Crazyang\",\"逛逛GitHub\",\"AI视界AIGC\",\n \"极客梦想家\",\"沐沐子\",\"Topaz\",\"三不沾\",\"EchoAI\",\n \"阳台吹风\",\"Cursor实战派\",\"简单就好\",\"进击的小学生\",\"AI探索家\",\n \"科技小飞侠\",\"数字游民\",\"AI能量站\",\"工程师小灰\",\"AI淘金\",\n \"量子比特\",\"NeonCode\",\"尝试新事物的Cher\",\"有趣的灵魂不需要名字\",\n \"阿杰的编程日记\",\"小明同学\",\"码农翻身\",\"AI大航海\",\"月亮与六便士\",\n \"渡己\",\"数码宝贝\",\"悟空AI\",\"追风少年\",\"Tech小确幸\",\n \"半糖主义\",\"深夜程序员\",\"AI小天才\",\"知行合一\",\"星辰大海\",\n \"比特流\",\"指尖上的代码\",\"未来可期\",\"云端漫步\",\"AI引路人\",\n \"秋风扫落叶\",\"编程小王子\",\"日拱一卒\",\"逆风翻盘\",\"AI百宝箱\",\n \"程序猿日记\",\"数据炼金术\",\"量子纠缠\",\"无限可能\",\"风轻云淡\",\n \"代码人生\",\"AI学徒\",\"机器之心\",\"清风明月\",\"代码诗人\",\n \"零一万物\",\"浮生若梦\",\"技术宅\",\"AI造物主\",\"星河万里\",\n \"小蜗牛\",\"编程少女\",\"数据猎人\",\"逻辑大师\",\"AI前沿\",\n \"梦想家\",\"代码如诗\",\"量子跃迁\",\"技术探路者\",\"AI新青年\",\n ]\n\n grid = QGridLayout(); grid.setSpacing(3); grid.setContentsMargins(0,0,0,0)\n cols_n = 12\n for i, name in enumerate(supporters):\n avatar = QLabel(name[0])\n avatar.setFixedSize(26, 26); avatar.setAlignment(Qt.AlignCenter)\n h = (hash(name) * 137) % 360\n s = 50 + (hash(name) >> 8) % 20; l2 = 55 + (hash(name) >> 16) % 15\n avatar.setStyleSheet(f\"background:hsl({h},{s}%,{l2}%);border-radius:13px;font-size:9px;font-weight:600;color:white;\")\n avatar.setToolTip(name)\n grid.addWidget(avatar, i // cols_n, i % cols_n, Qt.AlignCenter)\n ccl.addLayout(grid)\n sub = QLabel(\"无限迭代,只为更好的服务您\")\n sub.setObjectName(\"Cap\"); sub.setAlignment(Qt.AlignCenter); sub.setStyleSheet(\"font-size:11px;\"); ccl.addWidget(sub)\n qq_group_btn = QPushButton(\"进入 pdf2zh 桌面版交流群\"); qq_group_btn.setObjectName(\"Gh\"); qq_group_btn.setCursor(Qt.PointingHandCursor)\n qq_group_btn.setStyleSheet(\"font-size:11px;\")\n def _show_qq_qr():\n QApplication.clipboard().setText(\"1094195179\")\n c = _C\n qr_dlg = QDialog(self)\n qr_dlg.setWindowTitle(\"加入交流群\")\n qr_dlg.setStyleSheet(f\"QDialog{{background:{c['bg']};}}\")\n qlo = QVBoxLayout(qr_dlg); qlo.setContentsMargins(0, 0, 0, 12); qlo.setSpacing(8)\n qr_label = QLabel()\n qr_label.setAlignment(Qt.AlignCenter)\n qr_path = None\n for candidate in [\n os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'assets', 'qq_group_qr.png'),\n os.path.join(getattr(sys, '_MEIPASS', ''), 'assets', 'qq_group_qr.png'),\n ]:\n if os.path.exists(candidate):\n qr_path = candidate\n break\n if qr_path:\n pix = QPixmap(qr_path)\n # setScaledContents 让图片自动填满 label,不裁切\n qr_label.setPixmap(pix)\n qr_label.setScaledContents(True)\n qr_label.setFixedSize(300, int(300 * pix.height() / pix.width()))\n qr_dlg.setFixedSize(300, int(300 * pix.height() / pix.width()) + 40)\n else:\n qr_label.setText(\"请用 QQ 搜索群号 1094195179\")\n qr_label.setStyleSheet(f\"font-size:13px;color:{c['t2']};padding:30px;\")\n qr_dlg.setFixedSize(300, 100)\n qlo.addWidget(qr_label)\n tip = QLabel(\"群号 1094195179 已复制到剪贴板\")\n tip.setAlignment(Qt.AlignCenter)\n tip.setStyleSheet(f\"font-size:11px;color:{c['acc']};\")\n qlo.addWidget(tip)\n qr_dlg.exec_()\n qq_group_btn.clicked.connect(_show_qq_qr)\n ccl.addWidget(qq_group_btn, 0, Qt.AlignCenter)\n right.addWidget(cc)\n right.addStretch()\n\n cols.addLayout(right, 7)\n\n lo.addLayout(cols)\n\n\n# ═══════════════════════════════════════════════════════════════\n# 主窗口\n# ═══════════════════════════════════════════════════════════════\n\n# ── 桌面宠物:小黑猫 ──────────────────────────────────────────\n\nclass _NekoCat(QWidget):\n \"\"\"会走来走去的小黑猫彩蛋 — 多种行为模式\"\"\"\n\n # 行为模式\n WALK, RUN, SIT, SLEEP = 0, 1, 2, 3\n\n def __init__(self, parent):\n super().__init__(parent)\n self.setFixedSize(48, 36)\n self._frame = 0\n self._dir = 1\n self._mode = self.WALK\n self._speed = 2\n self._sit_counter = 0\n self._step_timer = QTimer(self)\n self._step_timer.timeout.connect(self._on_step)\n self.setCursor(Qt.PointingHandCursor)\n self.hide()\n\n def start_walk(self):\n \"\"\"从窗口一侧走到另一侧\"\"\"\n parent = self.parentWidget()\n if not parent:\n return\n import random\n pw, ph = parent.width(), parent.height()\n self._dir = random.choice([1, -1])\n y = ph - 50\n if self._dir == 1:\n self._x = float(-self.width())\n self._x_end = float(pw + self.width())\n else:\n self._x = float(pw + self.width())\n self._x_end = float(-self.width())\n # 随机选行为\n roll = random.random()\n if roll < 0.60:\n self._mode = self.WALK; self._speed = 1.5\n elif roll < 0.80:\n self._mode = self.SIT; self._speed = 1.5\n # 走到中间附近时会停下来坐一会儿\n self._sit_x = pw * random.uniform(0.3, 0.7)\n self._sit_counter = 0\n else:\n self._mode = self.RUN; self._speed = 4\n self.move(int(self._x), y)\n self.show(); self.raise_()\n self._step_timer.start(40) # 25fps\n\n def _on_step(self):\n self._frame += 1\n # SIT 模式:到达坐下点后停下来\n if self._mode == self.SIT and self._sit_counter > 0:\n self._sit_counter -= 1\n if self._sit_counter == 0:\n self._mode = self.WALK # 坐完了继续走\n self.update()\n return\n if self._mode == self.SIT and \\\n abs(self._x - self._sit_x) < 5 and self._sit_counter == 0:\n self._sit_counter = 100 # 坐 4 秒\n self.update()\n return\n self._x += self._dir * self._speed\n self.move(int(self._x), self.y())\n self.update()\n if (self._dir == 1 and self._x > self._x_end) or \\\n (self._dir == -1 and self._x < self._x_end):\n self._step_timer.stop()\n self.hide()\n\n def paintEvent(self, e):\n p = QPainter(self)\n p.setRenderHint(QPainter.Antialiasing)\n p.setPen(Qt.NoPen)\n if self._dir < 0:\n p.translate(self.width(), 0)\n p.scale(-1, 1)\n cat = QColor(35, 35, 35)\n eye = QColor(180, 210, 50)\n is_sitting = (self._mode == self.SIT and self._sit_counter > 0)\n # ── 尾巴 ──\n p.setPen(QPen(cat, 3, Qt.SolidLine, Qt.RoundCap))\n p.setBrush(Qt.NoBrush)\n tail = QPainterPath()\n sway = 2 if (self._frame // 4) % 2 == 0 else -2\n if is_sitting:\n # 坐着:尾巴自然弯曲放在身后\n tail.moveTo(6, 22); tail.cubicTo(2, 20, -2, 16, 2, 12)\n else:\n tail.moveTo(8, 16); tail.cubicTo(4, 12 + sway, 2, 8 + sway, 5, 4)\n p.drawPath(tail)\n # ── 身体 ──\n p.setPen(Qt.NoPen); p.setBrush(cat)\n if is_sitting:\n p.drawEllipse(10, 12, 20, 18) # 坐姿:更圆\n else:\n p.drawEllipse(8, 14, 24, 14)\n # ── 头 ──\n if is_sitting:\n p.drawEllipse(22, 2, 17, 16)\n else:\n p.drawEllipse(25, 4, 16, 15)\n # ── 耳朵 ──\n hx = 22 if is_sitting else 25\n ear1 = QPainterPath()\n ear1.moveTo(hx+3, 4); ear1.lineTo(hx+5, -3); ear1.lineTo(hx+8, 3)\n ear1.closeSubpath(); p.drawPath(ear1)\n ear2 = QPainterPath()\n ear2.moveTo(hx+9, 2); ear2.lineTo(hx+12, -4); ear2.lineTo(hx+14, 2)\n ear2.closeSubpath(); p.drawPath(ear2)\n # ── 眼睛 ──\n ex = 27 if is_sitting else 30\n ey = 7 if is_sitting else 9\n p.setBrush(eye)\n if is_sitting and (self._frame // 30) % 3 == 0:\n # 坐着时偶尔眯眼\n p.setPen(QPen(eye, 2)); p.setBrush(Qt.NoBrush)\n p.drawLine(ex, ey+2, ex+4, ey+2)\n p.drawLine(ex+6, ey+2, ex+10, ey+2)\n p.setPen(Qt.NoPen)\n else:\n p.drawEllipse(ex, ey, 4, 4)\n p.drawEllipse(ex+6, ey, 4, 4)\n p.setBrush(QColor(10, 10, 10))\n p.drawEllipse(ex+1, ey+1, 2, 2)\n p.drawEllipse(ex+7, ey+1, 2, 2)\n # ── 腿 ──\n p.setPen(QPen(cat, 3, Qt.SolidLine, Qt.RoundCap))\n if is_sitting:\n # 坐姿:前腿伸直,后腿收起\n p.drawLine(18, 28, 16, 34)\n p.drawLine(24, 28, 26, 34)\n else:\n phase = self._frame % 4\n o = [(2,-2,-1,1),(-1,1,2,-2),(-2,2,1,-1),(1,-1,-2,2)][phase]\n p.drawLine(14, 27, 14+o[0], 34)\n p.drawLine(20, 27, 20+o[1], 34)\n p.drawLine(25, 27, 25+o[2], 34)\n p.drawLine(30, 27, 30+o[3], 34)\n p.end()\n\n def enterEvent(self, e):\n \"\"\"鼠标悬停 → 疯狂加速\"\"\"\n if self._step_timer.isActive():\n self._mode = self.RUN\n self._speed = 6\n self._sit_counter = 0\n self._step_timer.setInterval(15)\n\n def leaveEvent(self, e):\n \"\"\"鼠标离开 → 恢复正常速度\"\"\"\n if self._step_timer.isActive() and self._mode == self.RUN:\n self._speed = 1.5\n self._step_timer.setInterval(40)\n\n def mousePressEvent(self, e):\n \"\"\"单击 → 喵一声\"\"\"\n try:\n import subprocess\n subprocess.Popen([\"afplay\", \"/System/Library/Sounds/Pop.aiff\"])\n except Exception:\n pass\n\n def mouseDoubleClickEvent(self, e):\n \"\"\"双击 → 直接消失\"\"\"\n self._step_timer.stop()\n self.hide()\n\n\nclass MainWindow(QMainWindow):\n def __init__(self):\n super().__init__()\n self.is_dark = False\n self.setWindowTitle(\"\")\n self.setMinimumSize(900,600)\n self.setUnifiedTitleAndToolBarOnMac(True)\n # App 图标\n _icon_path = _res('assets', 'app_icon.png')\n if os.path.exists(_icon_path):\n self.setWindowIcon(QIcon(_icon_path))\n # 启动时占满屏幕 90%\n screen = QApplication.primaryScreen()\n if screen:\n geo = screen.availableGeometry()\n w = int(geo.width() * 0.92)\n h = int(geo.height() * 0.90)\n self.resize(w, h)\n self.move(geo.x() + (geo.width() - w) // 2,\n geo.y() + (geo.height() - h) // 2)\n\n central = QWidget(); central.setObjectName(\"Central\"); self.setCentralWidget(central)\n root = QHBoxLayout(central); root.setContentsMargins(0,0,0,0); root.setSpacing(0)\n\n # 侧边栏\n sb = QWidget(); sb.setObjectName(\"Sidebar\"); sb.setFixedWidth(220)\n self.sidebar = sb\n sbl = QVBoxLayout(sb); sbl.setContentsMargins(16,24,16,16); sbl.setSpacing(4)\n\n # Logo: pdf2zh/desktop — 📄 彩蛋 + 可点击标题\n logo_row = QHBoxLayout(); logo_row.setSpacing(10); logo_row.setContentsMargins(4,0,0,0)\n logo_icon = _EggLogo(\"📄\", 28)\n logo_icon.setFixedSize(36, 36); logo_icon.setAlignment(Qt.AlignCenter)\n logo_row.addWidget(logo_icon)\n logo_text = QVBoxLayout(); logo_text.setSpacing(0)\n logo_name = QPushButton(\"pdf2zh-desktop\"); logo_name.setObjectName(\"SBLink\")\n logo_name.setStyleSheet(\"font-size:15px;font-weight:700;letter-spacing:-0.2px;padding:0;text-align:left;\")\n logo_name.setCursor(Qt.PointingHandCursor); logo_name.setFlat(True)\n logo_name.clicked.connect(lambda: webbrowser.open(\"https://github.com/AaronGIG/pdf2zh-desktop\"))\n logo_sub = QLabel(\"macOS Edition\"); logo_sub.setObjectName(\"Cap\")\n logo_text.addWidget(logo_name); logo_text.addWidget(logo_sub)\n logo_row.addLayout(logo_text); logo_row.addStretch()\n sbl.addLayout(logo_row)\n sbl.addSpacing(16); sbl.addWidget(_div()); sbl.addSpacing(12)\n\n self.nav = []\n for icon, label in [(\"🌐\",\"翻译\"),(\"📖\",\"阅读\"),(\"🔧\",\"设置\"),(\"💡\",\"关于\")]:\n b = SB(icon, label)\n b.clicked.connect(lambda l=label: self.switch(l))\n sbl.addWidget(b); self.nav.append((label, b))\n sbl.addStretch()\n\n vl = QLabel(\"v2.2.0 · macOS\"); vl.setObjectName(\"Cap\"); vl.setAlignment(Qt.AlignCenter)\n vl.setStyleSheet(\"font-size:10px;\")\n sbl.addWidget(vl)\n # 底部链接 — 独立按钮,支持 hover 变色\n import webbrowser\n link_row = QHBoxLayout(); link_row.setSpacing(0)\n link_row.setContentsMargins(8,0,8,4); link_row.addStretch()\n for text, url in [\n (\"GitHub\", \"https://github.com/AaronGIG/pdf2zh-desktop\"),\n (\"小红书\", \"https://www.xiaohongshu.com/user/profile/66c6fef7000000001d0315ef\"),\n (\"Feedback\", \"https://github.com/AaronGIG/pdf2zh-desktop/issues\"),\n (\"Star ⭐\", \"https://github.com/AaronGIG/pdf2zh-desktop\"),\n ]:\n lb = QPushButton(text); lb.setObjectName(\"SBLink\"); lb.setCursor(Qt.PointingHandCursor)\n lb.clicked.connect(lambda _, u=url: webbrowser.open(u))\n link_row.addWidget(lb)\n if text != \"Star ⭐\":\n sep = QLabel(\"·\"); sep.setObjectName(\"SBSep\")\n link_row.addWidget(sep)\n link_row.addStretch()\n sbl.addLayout(link_row)\n root.addWidget(sb)\n\n # 内容区\n self.stack = QStackedWidget()\n self.pages = {}\n tp = TranslatePage(); rp = ReaderPage()\n sp = SettingsPage(); ap = AboutPage()\n for name, page in [(\"翻译\",tp),(\"阅读\",rp),(\"设置\",sp),(\"关于\",ap)]:\n self.stack.addWidget(page); self.pages[name] = page\n root.addWidget(self.stack)\n\n # 信号连接\n sp.dark_mode_changed.connect(self._toggle_dark)\n sp.theme_color_changed.connect(self._set_accent)\n tp.translation_done.connect(self._on_translate_done)\n rp.fullscreen_changed.connect(self._on_reader_fullscreen)\n\n # 恢复上次窗口状态\n cfg = UserConfigManager.load()\n self.switch(\"翻译\"); self._apply()\n _install_tip_filter(QApplication.instance())\n # 去掉所有 QComboBox 下拉框的系统矩形外框\n for combo in self.findChildren(QComboBox):\n _fix_combo_popup(combo)\n\n # 恢复窗口尺寸和位置\n if cfg.get(\"window_geometry\"):\n try:\n from PyQt5.QtCore import QByteArray\n geo = QByteArray.fromHex(cfg[\"window_geometry\"].encode())\n self.restoreGeometry(geo)\n except Exception:\n pass\n\n # 静默预加载阅读页:用户点击时已渲染好,无闪烁\n QTimer.singleShot(100, self._preload_reader)\n\n # ── 小黑猫彩蛋 ──\n self._neko = _NekoCat(self)\n self._neko_timer = QTimer(self)\n self._neko_timer.timeout.connect(self._maybe_spawn_cat)\n self._neko_timer.start(1_200_000) # 每 20 分钟出来一次\n QTimer.singleShot(3_000, lambda: self._neko.start_walk()) # 启动 3 秒出来\n\n # ── 凌晨 3:30 彩蛋:披星戴月 ──\n from datetime import datetime\n now = datetime.now()\n if now.hour == 3 and 25 <= now.minute <= 35:\n QTimer.singleShot(800, self._midnight_bloom)\n\n # ─────────────────────────────────────────────\n # 凌晨 3:30 彩蛋 — 烟花 + 暖心寄语\n # ─────────────────────────────────────────────\n def _maybe_spawn_cat(self):\n \"\"\"每 20 分钟让小黑猫出来走一趟\"\"\"\n if self._neko.isVisible():\n return\n self._neko.start_walk()\n\n def _midnight_bloom(self):\n \"\"\"全屏烟花 10 秒 → 暖心寄语浮层\"\"\"\n import random, math\n from PyQt5.QtCore import QPropertyAnimation, QPoint, QEasingCurve\n\n fireworks = [\"🎆\",\"🎇\",\"✨\",\"💫\",\"⭐\",\"🌟\",\"🌠\",\"💛\",\"🧡\",\"💜\",\"💙\",\"🤍\"]\n self._bloom_anims = [] # prevent GC\n\n def _burst():\n \"\"\"一轮烟花:从随机位置爆开\"\"\"\n cx = random.randint(int(self.width() * 0.15), int(self.width() * 0.85))\n cy = random.randint(int(self.height() * 0.15), int(self.height() * 0.55))\n center = QPoint(cx, cy)\n for _ in range(random.randint(12, 20)):\n lbl = QLabel(random.choice(fireworks), self)\n lbl.setStyleSheet(f\"font-size:{random.randint(16, 36)}px;background:transparent;\")\n lbl.move(center); lbl.show(); lbl.raise_()\n angle = random.uniform(0, 2 * math.pi)\n dist = random.randint(80, 300)\n end = QPoint(cx + int(math.cos(angle) * dist),\n cy + int(math.sin(angle) * dist))\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(900, 1800))\n anim.setStartValue(center); anim.setEndValue(end)\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start()\n self._bloom_anims.append(anim)\n\n # 10 秒内间歇放烟花(每 400–700ms 一轮)\n total_bursts = 18\n for i in range(total_bursts):\n delay = int(i * 550 + random.randint(0, 150))\n QTimer.singleShot(delay, _burst)\n\n # 10 秒后显示暖心寄语\n QTimer.singleShot(10500, self._midnight_message)\n\n def _midnight_message(self):\n \"\"\"暖心寄语浮层\"\"\"\n import random\n messages = [\n (\"披星戴月的你,终将凯旋。\",\n \"但现在,请先好好休息。\\n\"\n \"没有什么比你的健康更重要了。\\n\"\n \"科研之外,还有很多美好的事物在等着你。\"),\n (\"凌晨三点半的你,一定很努力。\",\n \"但再重要的事,也比不上你自己。\\n\"\n \"把未完成的留给明天精力充沛的自己吧。\\n\"\n \"这个世界需要健康的你。\"),\n (\"夜深了,星星都在为你亮着。\",\n \"你已经比大多数人都努力了。\\n\"\n \"允许自己休息,不是放弃,是为了走更远的路。\\n\"\n \"照顾好自己,才能照顾好你在乎的一切。\"),\n (\"这个时间还在学习的你,了不起。\",\n \"但了不起的人更要爱惜自己。\\n\"\n \"身体是一切的根基,请好好善待它。\\n\"\n \"早点睡,明天的你会感谢今晚的决定。\"),\n (\"深夜的坚持让人敬佩。\",\n \"但真正的强者,懂得在该休息时放下。\\n\"\n \"你的才华不会因为一晚的休息而消失。\\n\"\n \"去睡吧,梦里什么都有。\"),\n ]\n title, body = random.choice(messages)\n\n # 半透明遮罩 + 居中卡片\n overlay = QWidget(self)\n overlay.setGeometry(self.rect())\n overlay.setStyleSheet(\"background:rgba(0,0,0,0.55);\")\n overlay.show(); overlay.raise_()\n\n card = QFrame(overlay)\n card.setStyleSheet(\n \"QFrame{background:rgba(255,255,255,0.95);border-radius:20px;}\"\n if not self.is_dark else\n \"QFrame{background:rgba(40,40,45,0.95);border-radius:20px;}\"\n )\n card.setFixedSize(420, 280)\n card.move((self.width() - 420) // 2, (self.height() - 280) // 2)\n card.show()\n\n cl = QVBoxLayout(card); cl.setContentsMargins(32, 28, 32, 24); cl.setSpacing(12)\n\n moon = QLabel(\"🌙\"); moon.setStyleSheet(\"font-size:36px;background:transparent;\")\n moon.setAlignment(Qt.AlignCenter); cl.addWidget(moon)\n\n tc = \"color:#1d1d1f;\" if not self.is_dark else \"color:#f5f5f7;\"\n tl = QLabel(title)\n tl.setStyleSheet(f\"font-size:18px;font-weight:700;{tc}background:transparent;\")\n tl.setAlignment(Qt.AlignCenter); tl.setWordWrap(True); cl.addWidget(tl)\n\n bl = QLabel(body)\n bl.setStyleSheet(f\"font-size:13px;line-height:1.6;{tc}background:transparent;\")\n bl.setAlignment(Qt.AlignCenter); bl.setWordWrap(True); cl.addWidget(bl)\n\n cl.addStretch()\n close_btn = QPushButton(\"晚安,去休息了 🌙\")\n close_btn.setObjectName(\"Pr\"); close_btn.setCursor(Qt.PointingHandCursor)\n close_btn.setStyleSheet(\"font-size:14px;padding:10px 24px;\")\n close_btn.clicked.connect(overlay.deleteLater)\n cl.addWidget(close_btn, alignment=Qt.AlignCenter)\n\n # 点击遮罩也可关闭\n overlay.mousePressEvent = lambda e: overlay.deleteLater()\n\n def switch(self, label):\n # 预加载中 → 延迟切换(避免冲突)\n if getattr(self, '_preloading', False):\n QTimer.singleShot(50, lambda: self.switch(label))\n return\n keys = list(self.pages.keys())\n idx = keys.index(label) if label in keys else 0\n # 阅读页:先刷新列表,再显示(避免 showEvent 用旧数据加载后又被 refresh 清掉)\n if label == \"阅读\":\n self.pages[\"阅读\"].refresh()\n self.stack.setCurrentIndex(idx)\n for n, b in self.nav: b.set_active(n == label)\n\n def _preload_reader(self):\n \"\"\"静默预加载阅读页:冻结画面 → 切到阅读页获取真实尺寸 → 渲染 → 切回\"\"\"\n self._preloading = True\n rp = self.pages[\"阅读\"]\n rp.refresh()\n if rp.list_w.count() == 0:\n self._preloading = False\n return\n # 冻结画面(布局仍正常计算,只是不绘制)\n self.setUpdatesEnabled(False)\n self.stack.setCurrentIndex(list(self.pages.keys()).index(\"阅读\"))\n # 下一帧:布局已稳定,加载 + 渲染\n QTimer.singleShot(0, self._preload_step2)\n\n def _preload_step2(self):\n rp = self.pages[\"阅读\"]\n rp.list_w.setCurrentRow(0) # → _on_select → load_pdf\n # 再等一帧让 splitter 稳定\n QTimer.singleShot(30, self._preload_step3)\n\n def _preload_step3(self):\n rp = self.pages[\"阅读\"]\n rp.preview._last_fit_zoom = -1\n rp.preview._fit_and_render()\n # 切回翻译页,解冻\n self.stack.setCurrentIndex(0)\n self.setUpdatesEnabled(True)\n self._preloading = False\n self.repaint()\n\n def _toggle_dark(self, dark):\n self.is_dark = dark\n # 淡入淡出过渡\n from PyQt5.QtCore import QPropertyAnimation\n anim = QPropertyAnimation(self, b\"windowOpacity\")\n anim.setDuration(150)\n anim.setStartValue(1.0); anim.setEndValue(0.85)\n anim.finished.connect(lambda: self._finish_theme_switch(anim))\n self._theme_anim = anim # prevent GC\n anim.start()\n\n def _finish_theme_switch(self, prev_anim):\n self._apply()\n from PyQt5.QtCore import QPropertyAnimation\n anim = QPropertyAnimation(self, b\"windowOpacity\")\n anim.setDuration(200)\n anim.setStartValue(0.85); anim.setEndValue(1.0)\n self._theme_anim = anim\n anim.start()\n\n def _set_accent(self, hex_color):\n \"\"\"应用自定义主题色\"\"\"\n self._custom_accent = hex_color\n self._apply()\n\n def _apply(self):\n global _C\n c = dict(D if self.is_dark else L) # 拷贝,避免修改原始\n # 应用自定义主题色(需已解锁)\n cfg = UserConfigManager.load()\n acc = getattr(self, '_custom_accent', None) or cfg.get(\"theme_color\")\n if acc and cfg.get(\"theme_unlocked\"):\n r, g, b = int(acc[1:3],16), int(acc[3:5],16), int(acc[5:7],16)\n c[\"acc\"] = acc; c[\"acc_h\"] = acc; c[\"acc_p\"] = acc\n c[\"acc_l\"] = f\"rgba({r},{g},{b},0.12)\"\n c[\"acc_g\"] = f\"qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 {acc},stop:1 {acc})\"\n c[\"sb_act\"] = f\"rgba({r},{g},{b},0.15)\"\n c[\"dz_b\"] = f\"rgba({r},{g},{b},0.25)\"\n c[\"tag_bg\"] = f\"rgba({r},{g},{b},0.10)\"; c[\"tag_fg\"] = acc\n c[\"pv_l_bg\"] = f\"rgba({r},{g},{b},0.10)\"; c[\"pv_l_fg\"] = acc\n c[\"link_h\"] = acc\n _C = c\n self.setStyleSheet(S(c))\n # 强制设置 QPalette,修复内联 setStyleSheet 覆盖问题\n from PyQt5.QtGui import QPalette\n pal = self.palette()\n pal.setColor(QPalette.WindowText, QColor(c[\"t1\"]))\n pal.setColor(QPalette.Text, QColor(c[\"t1\"]))\n pal.setColor(QPalette.ButtonText, QColor(c[\"t1\"]))\n pal.setColor(QPalette.PlaceholderText, QColor(c[\"t3\"]))\n pal.setColor(QPalette.Base, QColor(c[\"inp\"]))\n pal.setColor(QPalette.Window, QColor(c[\"bg\"]))\n pal.setColor(QPalette.AlternateBase, QColor(c[\"bg2\"]))\n pal.setColor(QPalette.ToolTipBase, QColor(c[\"elev\"]))\n pal.setColor(QPalette.ToolTipText, QColor(c[\"t1\"]))\n pal.setColor(QPalette.Highlight, QColor(c[\"acc\"]))\n pal.setColor(QPalette.HighlightedText, QColor(\"white\"))\n self.setPalette(pal)\n QApplication.instance().setPalette(pal)\n # 通知子页面更新内联样式\n for page in self.pages.values():\n if hasattr(page, 'update_theme'):\n page.update_theme(c)\n\n def _on_reader_fullscreen(self, fs):\n \"\"\"全屏阅读:隐藏/显示侧边栏\"\"\"\n self.sidebar.setVisible(not fs)\n\n def closeEvent(self, event):\n \"\"\"保存窗口几何尺寸和当前页面\"\"\"\n cfg = UserConfigManager.load()\n cfg[\"window_geometry\"] = bytes(self.saveGeometry().toHex()).decode()\n # 保存当前页面\n for name, page in self.pages.items():\n if self.stack.currentWidget() is page:\n cfg[\"last_page\"] = name; break\n UserConfigManager.save(cfg)\n event.accept()\n\n def _on_translate_done(self, output_files):\n reader = self.pages[\"阅读\"]\n reader.set_output_files(output_files)\n QTimer.singleShot(600, lambda: self.switch(\"阅读\"))\n # 翻译完成后把窗口提到前台(用户可能切走了)\n QTimer.singleShot(700, lambda: (self.raise_(), self.activateWindow()))\n\n\nclass Pdf2zhApp(QApplication):\n \"\"\"单实例 QApplication:第二个实例把文件发给第一个实例后退出\"\"\"\n file_opened = pyqtSignal(str)\n _SERVER_NAME = \"com.aarongig.pdf2zh.single\"\n\n def __init__(self, argv):\n super().__init__(argv)\n from PyQt5.QtNetwork import QLocalServer, QLocalSocket\n self._pending_file = None\n\n # 尝试连接已有实例\n sock = QLocalSocket()\n sock.connectToServer(self._SERVER_NAME)\n if sock.waitForConnected(500):\n # 已有实例在运行 → 把文件路径发过去,然后退出\n for arg in argv[1:]:\n if arg.lower().endswith('.pdf') and os.path.isfile(arg):\n sock.write(arg.encode('utf-8'))\n sock.waitForBytesWritten(1000)\n sock.disconnectFromServer()\n sys.exit(0)\n\n # 我是第一个实例 → 启动 server 监听\n QLocalServer.removeServer(self._SERVER_NAME)\n self._server = QLocalServer()\n self._server.listen(self._SERVER_NAME)\n self._server.newConnection.connect(self._on_new_connection)\n\n def _on_new_connection(self):\n conn = self._server.nextPendingConnection()\n if conn:\n conn.waitForReadyRead(1000)\n data = conn.readAll().data().decode('utf-8', errors='ignore')\n conn.close()\n if data and data.lower().endswith('.pdf'):\n self.file_opened.emit(data)\n\n def event(self, e):\n if e.type() == e.FileOpen:\n path = e.file()\n if path and path.lower().endswith('.pdf'):\n self.file_opened.emit(path)\n return True\n return super().event(e)\n\n\ndef main():\n QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)\n QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)\n app = Pdf2zhApp(sys.argv); app.setStyle(\"Fusion\")\n w = MainWindow(); w.show()\n\n # 文件打开事件 → 送到翻译页\n def _on_file_open(path):\n tp = w.pages.get(\"翻译\")\n if tp:\n tp.on_files_added([path])\n w.switch(\"翻译\")\n w.raise_()\n w.activateWindow()\n app.file_opened.connect(_on_file_open)\n\n sys.exit(app.exec_())\n\n\nif __name__ == \"__main__\":\n main()\n"}, "85fa70d5775558a86a14ed0e126e206c02a2327a": {"t": 1785809905.2448719, "n": 240558, "h": "7a1cff03a1392984", "c": "\"\"\"\npdf2zh for Mac — 完整版主窗口\n全功能适配: 三种预览模式 / 分块翻译 / 页码范围 / 历史记录 / 20+翻译服务\n\"\"\"\n\nimport sys\nimport os\nimport json\nimport webbrowser\nimport time\nfrom pathlib import Path\nimport fitz\n\n\ndef _res(*parts):\n \"\"\"获取资源文件路径,兼容 PyInstaller 打包和开发环境\"\"\"\n if getattr(sys, '_MEIPASS', None):\n return os.path.join(sys._MEIPASS, *parts)\n return os.path.join(os.path.dirname(os.path.dirname(__file__)), *parts)\n\nfrom PyQt5.QtWidgets import (\n QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLayout,\n QPushButton, QLabel, QComboBox, QProgressBar, QFileDialog, QDialog,\n QStackedWidget, QStackedLayout, QFrame, QScrollArea, QGraphicsDropShadowEffect,\n QCheckBox, QListWidget, QListWidgetItem, QLineEdit, QSpinBox, QPlainTextEdit,\n QSizePolicy, QSlider, QSplitter, QTabBar, QMessageBox, QMenu, QAction,\n)\nfrom PyQt5.QtCore import Qt, pyqtSignal, QTimer, QSize, QEvent, QRect\nfrom PyQt5.QtGui import (QColor, QDragEnterEvent, QDropEvent, QImage, QPixmap,\n QScreen, QIcon, QPainter, QPainterPath, QPen, QRegion)\n\nfrom ui.config_manager import UserConfigManager, HistoryManager\nfrom ui.translate_worker import (\n TranslateWorker, LANG_MAP, SERVICE_MAP, PAGE_PRESETS,\n OUTPUT_MODES, parse_page_range, detect_zotero_source,\n get_zotero_item_key, zotero_auto_link, zotero_plugin_installed,\n build_service_envs, SummaryWorker, QAWorker,\n)\n\n# ─── 苹果风配色 ─────────────────────────────────────────────\n\nL = {\n \"bg\":\"#FFF\",\"bg2\":\"#F9F9FB\",\"bg3\":\"#F2F2F7\",\"elev\":\"#FFF\",\n \"sb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #FBFBFD,stop:1 #F2F2F7)\",\n \"sb_act\":\"rgba(0,113,227,0.12)\",\"sb_hov\":\"rgba(0,0,0,0.04)\",\n \"t1\":\"#1D1D1F\",\"t2\":\"#6E6E73\",\"t3\":\"#AEAEB2\",\"t4\":\"#C7C7CC\",\n \"acc\":\"#0071E3\",\"acc_l\":\"rgba(0,113,227,0.10)\",\"acc_h\":\"#0077ED\",\"acc_p\":\"#005BBB\",\n \"acc_g\":\"qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 #0071E3,stop:1 #00A1FF)\",\n \"ok\":\"#34C759\",\"err\":\"#FF3B30\",\n \"brd\":\"rgba(0,0,0,0.06)\",\"brd_s\":\"rgba(0,0,0,0.10)\",\n \"card\":\"#FFF\",\"card_b\":\"rgba(0,0,0,0.04)\",\n \"inp\":\"#F2F2F7\",\"inp_b\":\"rgba(0,0,0,0.08)\",\n \"dz_bg\":\"#FAFBFF\",\"dz_b\":\"rgba(0,113,227,0.20)\",\n \"tag_bg\":\"rgba(0,113,227,0.08)\",\"tag_fg\":\"#0071E3\",\n \"scr\":\"rgba(0,0,0,0.12)\",\"scr_h\":\"rgba(0,0,0,0.24)\",\n \"pv_bg\":\"#FFFFFF\",\"pv_tb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #FBFBFD,stop:1 #F2F2F7)\",\n \"pv_l_bg\":\"rgba(0,113,227,0.07)\",\"pv_l_fg\":\"#0071E3\",\n \"link\":\"#6E6E73\",\"link_h\":\"#0071E3\",\n}\n\nD = {\n \"bg\":\"#1C1C1E\",\"bg2\":\"#2C2C2E\",\"bg3\":\"#3A3A3C\",\"elev\":\"#2C2C2E\",\n \"sb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #242426,stop:1 #1C1C1E)\",\n \"sb_act\":\"rgba(10,132,255,0.20)\",\"sb_hov\":\"rgba(255,255,255,0.06)\",\n \"t1\":\"#F5F5F7\",\"t2\":\"#98989D\",\"t3\":\"#636366\",\"t4\":\"#48484A\",\n \"acc\":\"#0A84FF\",\"acc_l\":\"rgba(10,132,255,0.15)\",\"acc_h\":\"#409CFF\",\"acc_p\":\"#0071E3\",\n \"acc_g\":\"qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 #0A84FF,stop:1 #5AC8FA)\",\n \"ok\":\"#30D158\",\"err\":\"#FF453A\",\n \"brd\":\"rgba(255,255,255,0.06)\",\"brd_s\":\"rgba(255,255,255,0.10)\",\n \"card\":\"#2C2C2E\",\"card_b\":\"rgba(255,255,255,0.12)\",\n \"inp\":\"#3A3A3C\",\"inp_b\":\"rgba(255,255,255,0.08)\",\n \"dz_bg\":\"#2A2A2E\",\"dz_b\":\"rgba(10,132,255,0.35)\",\n \"tag_bg\":\"rgba(10,132,255,0.15)\",\"tag_fg\":\"#0A84FF\",\n \"scr\":\"rgba(255,255,255,0.12)\",\"scr_h\":\"rgba(255,255,255,0.24)\",\n \"pv_bg\":\"#1C1C1E\",\"pv_tb\":\"qlineargradient(x1:0,y1:0,x2:0,y2:1,stop:0 #2C2C2E,stop:1 #242426)\",\n \"pv_l_bg\":\"rgba(10,132,255,0.12)\",\"pv_l_fg\":\"#0A84FF\",\n \"link\":\"#98989D\",\"link_h\":\"#0A84FF\",\n}\n\n_C = L # 当前活跃配色(深色/浅色切换时更新)\n\n\n# ─── 磨砂提示气泡 ──────────────────────────────────────────\nclass _Tip(QWidget):\n \"\"\"截取屏幕 → 缩放模糊 → 叠加半透明蒙层 → 圆角裁剪 = 磨砂玻璃\"\"\"\n _inst = None\n _PAD = 10\n\n def __init__(self):\n super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint)\n self.setAttribute(Qt.WA_TranslucentBackground)\n self.setAttribute(Qt.WA_ShowWithoutActivating)\n self._text = \"\"\n self._blur_pix = None\n\n def _grab_and_blur(self, x, y, w, h):\n \"\"\"安全截取屏幕区域并模糊\"\"\"\n try:\n screen = QApplication.primaryScreen()\n if not screen:\n return\n pix = screen.grabWindow(0, x, y, w, h)\n if pix.isNull():\n return\n img = pix.toImage()\n # 缩小到 1/8 再放大 = 快速高斯模糊\n from PyQt5.QtCore import QSize\n tiny = img.scaled(QSize(max(1, w // 8), max(1, h // 8)),\n Qt.IgnoreAspectRatio, Qt.SmoothTransformation)\n blurred = tiny.scaled(QSize(w, h),\n Qt.IgnoreAspectRatio, Qt.SmoothTransformation)\n self._blur_pix = QPixmap.fromImage(blurred)\n except Exception:\n self._blur_pix = None\n\n def paintEvent(self, e):\n from PyQt5.QtGui import QPainter, QPainterPath, QFont, QPen\n p = QPainter(self)\n p.setRenderHint(QPainter.Antialiasing)\n pad = self._PAD\n rect = self.rect().adjusted(pad, pad, -pad, -pad)\n radius = 8.0\n is_dark = (_C is D)\n path = QPainterPath()\n path.addRoundedRect(float(rect.x()), float(rect.y()),\n float(rect.width()), float(rect.height()), radius, radius)\n # ── 柔和阴影 ──\n for i in range(5):\n a = max(1, (35 if is_dark else 20) // (i + 1))\n d = (i + 1) * 2.0\n sp = QPainterPath()\n sr = rect.adjusted(-d, -d + 1, d, d + 2)\n sp.addRoundedRect(float(sr.x()), float(sr.y()),\n float(sr.width()), float(sr.height()), radius + d, radius + d)\n p.fillPath(sp, QColor(0, 0, 0, a))\n # ── 裁剪到圆角 ──\n p.setClipPath(path)\n # ── 模糊背景 ──\n if self._blur_pix and not self._blur_pix.isNull():\n p.drawPixmap(rect, self._blur_pix)\n else:\n fb = QColor(45, 45, 48) if is_dark else QColor(245, 245, 247)\n p.fillPath(path, fb)\n # ── 半透明蒙层(磨砂感的关键) ──\n overlay = QColor(30, 30, 32, 140) if is_dark else QColor(255, 255, 255, 178)\n p.fillRect(rect, overlay)\n # ── 顶部高光 ──\n hi = QPainterPath()\n hr = rect.adjusted(1, 1, -1, -rect.height() + 16)\n hi.addRoundedRect(float(hr.x()), float(hr.y()),\n float(hr.width()), float(hr.height()), radius - 1, radius - 1)\n p.fillPath(hi, QColor(255, 255, 255, 25 if is_dark else 120))\n # ── 边框 ──\n p.setClipping(False)\n brd = QColor(255, 255, 255, 20) if is_dark else QColor(0, 0, 0, 15)\n p.setPen(QPen(brd, 0.5))\n p.drawPath(path)\n # ── 文字 ──\n p.setPen(QColor(_C[\"t1\"]))\n font = QFont(\"Helvetica Neue\", 11)\n font.setFamilies([\"Helvetica Neue\", \"PingFang SC\"])\n p.setFont(font)\n tr = rect.adjusted(10, 7, -10, -7)\n p.drawText(tr, Qt.AlignLeft | Qt.AlignVCenter | Qt.TextWordWrap, self._text)\n p.end()\n\n def _calc_size(self):\n from PyQt5.QtGui import QFont, QFontMetrics\n font = QFont(\"Helvetica Neue\", 11)\n font.setFamilies([\"Helvetica Neue\", \"PingFang SC\"])\n fm = QFontMetrics(font)\n br = fm.boundingRect(0, 0, 300, 9999,\n Qt.AlignLeft | Qt.TextWordWrap, self._text)\n pad = self._PAD\n self.setFixedSize(br.width() + 20 + pad * 2, br.height() + 14 + pad * 2)\n\n @classmethod\n def show_tip(cls, pos, text, widget=None):\n if not text:\n cls.hide_tip(); return\n if cls._inst is None:\n cls._inst = cls()\n tip = cls._inst\n tip._text = text\n tip._calc_size()\n # 先确保 tooltip 自身不可见,再截取目标位置的屏幕\n tip.hide()\n pad = tip._PAD\n tx, ty = pos.x() + 8, pos.y() + 14\n # 边界检测:确保不超出屏幕\n screen = QApplication.primaryScreen()\n if screen:\n sg = screen.availableGeometry()\n if tx + tip.width() > sg.right():\n tx = sg.right() - tip.width()\n if ty + tip.height() > sg.bottom():\n ty = pos.y() - tip.height() - 4 # 移到光标上方\n tx = max(sg.left(), tx)\n ty = max(sg.top(), ty)\n bx, by = tx + pad, ty + pad\n tw, th = tip.width() - pad * 2, tip.height() - pad * 2\n tip._grab_and_blur(bx, by, tw, th)\n tip.move(tx, ty)\n tip.show()\n tip.update()\n\n @classmethod\n def hide_tip(cls):\n if cls._inst: cls._inst.hide()\n\n\ndef _fix_combo_popup(combo):\n \"\"\"去掉 QComboBox 的 macOS 焦点虚线框\"\"\"\n combo.setAttribute(Qt.WA_MacShowFocusRect, False)\n\n\ndef _install_tip_filter(app):\n \"\"\"安装全局 tooltip 拦截 + combo 修复\"\"\"\n from PyQt5.QtCore import QObject\n\n class F(QObject):\n def eventFilter(self, obj, event):\n if event.type() == QEvent.ToolTip:\n text = \"\"\n if hasattr(obj, 'toolTip'):\n text = obj.toolTip()\n # QListWidget viewport → item tooltip\n if not text and hasattr(obj, 'parent'):\n p = obj.parent()\n if hasattr(p, 'itemAt'):\n item = p.itemAt(event.pos())\n if item: text = item.toolTip()\n if text:\n from PyQt5.QtGui import QCursor\n _Tip.show_tip(QCursor.pos(), text, obj)\n event.accept()\n return True\n return False\n if event.type() == QEvent.Leave:\n _Tip.hide_tip()\n return False\n\n f = F(app)\n app._tip_filter = f\n app.installEventFilter(f)\n\n\ndef S(c):\n return f\"\"\"\n QMainWindow{{background:{c[\"bg\"]};}}\n QWidget{{font-family:\"Helvetica Neue\",\"PingFang SC\";font-size:13px;color:{c[\"t1\"]};outline:none;}}\n #Central{{background:{c[\"bg\"]};}}\n QStackedWidget{{background:{c[\"bg\"]};}}\n QStackedWidget>QWidget{{background:{c[\"bg\"]};}}\n QLabel{{background:transparent;border:none;}}\n QToolTip{{background:transparent;border:none;padding:0;}}\n /* ── 侧边栏 ── */\n #Sidebar{{background:{c[\"sb\"]};border-right:0.5px solid {c[\"brd\"]};}}\n #SB{{background:transparent;border:none;border-radius:7px;padding:6px 12px;text-align:left;font-size:13px;color:{c[\"t2\"]};letter-spacing:0.2px;border-left:3px solid transparent;}}\n #SB:hover{{background:{c[\"sb_hov\"]};color:{c[\"t1\"]};}}\n #SB[active=\"true\"]{{background:{c[\"sb_act\"]};color:{c[\"acc\"]};font-weight:600;border-left:3px solid {c[\"acc\"]};border-top-left-radius:0;border-bottom-left-radius:0;}}\n #SBLink{{background:transparent;border:none;font-size:10px;color:{c[\"link\"]};padding:1px 3px;}}\n #SBLink:hover{{color:{c[\"acc\"]};}}\n /* ── 按钮 ── */\n #Pr{{background:{c[\"acc_g\"]};color:white;border:none;border-radius:12px;padding:12px 32px;font-size:15px;font-weight:600;letter-spacing:0.3px;}}\n #Pr:hover{{background:{c[\"acc_h\"]};}}#Pr:pressed{{background:{c[\"acc_p\"]};padding:13px 32px 11px 32px;}}\n #Pr:disabled{{background:{c[\"bg3\"]};color:{c[\"t4\"]};border:1px solid {c[\"brd\"]};}}\n #Sc{{background:{c[\"bg2\"]};color:{c[\"t1\"]};border:0.5px solid {c[\"brd_s\"]};border-radius:8px;padding:8px 18px;font-size:13px;font-weight:500;}}\n #Sc:hover{{background:{c[\"bg3\"]};border-color:{c[\"acc\"]};}}\n #Gh{{background:transparent;color:{c[\"acc\"]};border:none;border-radius:6px;padding:6px 12px;font-size:13px;font-weight:500;}}\n #Gh:hover{{background:{c[\"acc_l\"]};}}\n #GhDanger{{background:transparent;color:{c[\"err\"]};border:none;border-radius:6px;padding:6px 12px;font-size:13px;font-weight:500;}}\n #GhDanger:hover{{background:rgba(255,59,48,0.08);}}\n #TB{{background:transparent;color:{c[\"t2\"]};border:none;border-radius:6px;padding:5px 10px;font-size:13px;font-weight:500;}}\n #TB:hover{{background:{c[\"sb_hov\"]};color:{c[\"t1\"]};}}\n #TB[active=\"true\"]{{background:{c[\"acc_l\"]};color:{c[\"acc\"]};font-weight:600;}}\n /* ── 输入控件 ── */\n QComboBox{{background:{c[\"inp\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;padding:8px 14px;font-size:13px;min-height:22px;color:{c[\"t1\"]};}}\n QComboBox:hover{{border-color:{c[\"brd_s\"]};}}QComboBox:focus{{border-color:{c[\"acc\"]};border-width:1.5px;outline:none;}}\n QComboBox::drop-down{{border:none;width:28px;subcontrol-origin:padding;subcontrol-position:center right;}}\n QComboBox::down-arrow{{image:none;width:8px;height:5px;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid {c[\"t3\"]};margin-right:10px;}}\n QComboBox QAbstractItemView{{background:{c[\"elev\"]};border:none;border-radius:10px;padding:6px 4px;color:{c[\"t1\"]};outline:0;}}\n QComboBox QAbstractItemView::item{{padding:6px 12px;border:none;border-radius:6px;margin:1px 2px;}}\n QComboBox QAbstractItemView::item:hover{{background:{c[\"acc_l\"]};}}\n QComboBox QAbstractItemView::item:selected{{background:{c[\"acc\"]};color:white;}}\n QLineEdit{{background:{c[\"inp\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;padding:8px 14px;font-size:13px;color:{c[\"t1\"]};}}\n QLineEdit:focus{{border-color:{c[\"acc\"]};border-width:2px;background:{c[\"elev\"]};}}\n QLineEdit[readOnly=\"true\"]{{color:{c[\"t2\"]};}}\n QSpinBox{{background:{c[\"inp\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;padding:6px 10px;font-size:13px;color:{c[\"t1\"]};}}\n QSpinBox:focus{{border-color:{c[\"acc\"]};border-width:2px;}}\n QTextEdit{{background:{c[\"inp\"]};color:{c[\"t1\"]};border:1.5px solid {c[\"inp_b\"]};border-radius:8px;}}\n QTextEdit:focus{{border-color:{c[\"acc\"]};border-width:2px;}}\n /* ── 进度条 ── */\n QProgressBar{{background:{c[\"bg3\"]};border:none;border-radius:3px;max-height:6px;min-height:6px;font-size:1px;}}\n QProgressBar::chunk{{background:{c[\"acc_g\"]};border-radius:3px;}}\n /* ── 卡片 ── */\n #Card{{background:{c[\"card\"]};border:0.5px solid {c[\"card_b\"]};border-radius:12px;}}\n #Card:hover{{border-color:{c[\"acc\"]};}}\n #DZ{{background:{c[\"dz_bg\"]};border:2px dashed {c[\"dz_b\"]};border-radius:12px;}}\n #DZ:hover{{border-color:{c[\"acc\"]};}}\n /* ── 列表 ── */\n QListWidget{{background:transparent;border:none;outline:none;}}\n QListWidget::item{{background:{c[\"card\"]};border:0.5px solid {c[\"card_b\"]};border-radius:8px;padding:4px 10px;margin:2px 0;}}\n QListWidget::item:selected{{background:{c[\"acc\"]};color:white;border-color:{c[\"acc\"]};}}\n QListWidget::item:hover:!selected{{background:{c[\"bg2\"]};}}\n /* ── 滚动条 ── */\n QScrollArea{{border:none;background:transparent;}}\n QScrollBar:vertical{{background:transparent;width:10px;margin:4px 2px;}}\n QScrollBar::handle:vertical{{background:{c[\"scr\"]};border-radius:4px;min-height:36px;}}\n QScrollBar::handle:vertical:hover{{background:{c[\"scr_h\"]};}}\n QScrollBar::add-line:vertical,QScrollBar::sub-line:vertical,QScrollBar::add-page:vertical,QScrollBar::sub-page:vertical{{height:0;background:transparent;}}\n QScrollBar:horizontal{{height:0;}}\n /* ── 复选框 ── */\n QCheckBox{{spacing:10px;font-size:13px;}}\n QCheckBox::indicator{{width:18px;height:18px;border-radius:5px;border:1.5px solid {c[\"brd_s\"]};background:{c[\"elev\"]};}}\n QCheckBox::indicator:hover{{border-color:{c[\"acc\"]};background:{c[\"acc_l\"]};}}\n QCheckBox::indicator:checked{{background:{c[\"acc\"]};border-color:{c[\"acc\"]};}}\n /* ── 标签/分隔 ── */\n #Tag{{background:{c[\"tag_bg\"]};color:{c[\"tag_fg\"]};border:none;border-radius:6px;padding:3px 10px;font-size:11px;font-weight:600;}}\n #Div{{background:{c[\"brd\"]};max-height:0.5px;min-height:0.5px;}}\n /* ── 预览区 ── */\n #PA{{background:{c[\"pv_bg\"]};}}\n #PT{{background:{c[\"pv_tb\"]};border-bottom:0.5px solid {c[\"brd\"]};}}\n #PL{{background:{c[\"pv_l_bg\"]};color:{c[\"pv_l_fg\"]};border:none;border-radius:6px;padding:3px 12px;font-size:11px;font-weight:600;}}\n QSplitter::handle{{background:{c[\"brd\"]};width:1px;}}\n QSplitter::handle:hover{{background:{c[\"acc\"]};}}\n QSlider::groove:horizontal{{background:{c[\"bg3\"]};height:4px;border-radius:2px;}}\n QSlider::handle:horizontal{{background:{c[\"acc\"]};width:14px;height:14px;margin:-5px 0;border-radius:7px;}}\n QSlider::handle:horizontal:hover{{background:{c[\"acc_h\"]};width:16px;height:16px;margin:-6px 0;border-radius:8px;}}\n QMenu{{background:{c[\"elev\"]};border:1px solid {c[\"brd_s\"]};border-radius:10px;padding:6px 4px;}}\n QMenu::item{{padding:4px 28px 4px 14px;border-radius:6px;font-size:13px;margin:1px 4px;}}\n QMenu::item:selected{{background:{c[\"acc\"]};color:white;}}\n QMenu::separator{{height:1px;background:{c[\"brd\"]};margin:4px 12px;}}\n /* ── 字体层级: 24 / 15 / 13 / 11 ── */\n #PT0{{font-size:24px;font-weight:700;letter-spacing:-0.3px;}}\n #PT1{{font-size:15px;color:{c[\"t2\"]};}}\n #ST{{font-size:13px;font-weight:600;}}\n #SL{{font-size:11px;font-weight:600;letter-spacing:0.8px;color:{c[\"t3\"]};padding:6px 0 2px 0;}}\n #FL{{font-size:13px;font-weight:500;color:{c[\"t2\"]};padding:2px 0;}}\n #Cap{{font-size:11px;color:{c[\"t3\"]};}}\n #Mono{{font-family:\"Menlo\",monospace;font-size:12px;color:{c[\"t2\"]};}}\n /* ── 进度卡片 ── */\n #ProgPct{{font-family:\"Menlo\",monospace;font-size:20px;font-weight:700;color:{c[\"acc\"]};background:transparent;}}\n #ProgLabel{{font-size:13px;font-weight:600;color:{c[\"t1\"]};}}\n #ProgIcon{{font-size:16px;background:transparent;}}\n #ProgDetail{{font-size:11px;color:{c[\"t2\"]};}}\n #ProgTip{{font-size:11px;color:{c[\"t3\"]};font-style:italic;}}\n #ProgCancel{{background:transparent;color:{c[\"err\"]};border:none;font-size:13px;font-weight:500;padding:6px 12px;border-radius:6px;}}\n #ProgCancel:hover{{background:rgba(255,59,48,0.08);}}\n /* ── 拖拽区 ── */\n #DZTitle{{font-size:15px;font-weight:600;color:{c[\"t1\"]};background:transparent;}}\n #DZSub{{font-size:11px;color:{c[\"t3\"]};background:transparent;}}\n /* ── 历史面板 ── */\n #HistList{{background:transparent;border:none;font-size:11px;color:{c[\"t1\"]};}}\n #HistList::item{{background:transparent;border:none;border-bottom:0.5px solid {c[\"brd\"]};padding:8px 6px;margin:0;border-radius:0;}}\n #HistList::item:selected{{background:{c[\"sb_act\"]};color:{c[\"acc\"]};border-radius:6px;border-bottom:none;}}\n #HistList::item:hover:!selected{{background:{c[\"sb_hov\"]};border-radius:6px;}}\n #HistPanel{{border-right:0.5px solid {c[\"brd\"]};}}\n #HistDetail{{font-size:10px;color:{c[\"t2\"]};background:{c[\"bg2\"]};border:0.5px solid {c[\"brd\"]};border-radius:8px;padding:4px 6px;}}\n /* ── 自定义进度条 ── */\n #ProgBar{{background:{c[\"bg3\"]};border:none;border-radius:6px;max-height:12px;min-height:12px;font-size:1px;}}\n #ProgBar::chunk{{background:{c[\"acc_g\"]};border-radius:6px;}}\n /* ── 预览工具栏控件 ── */\n #PageInput{{font-family:'Menlo',monospace;font-size:13px;font-weight:600;padding:4px;border-radius:6px;border:1px solid {c[\"inp_b\"]};background:{c[\"inp\"]};color:{c[\"t1\"]};}}\n #PageInput:focus{{border-color:{c[\"acc\"]};}}\n #TotalLbl{{font-family:'Menlo',monospace;font-size:11px;color:{c[\"t2\"]};background:transparent;}}\n #ZoomPct{{font-family:'Menlo',monospace;font-size:11px;color:{c[\"t2\"]};background:transparent;}}\n #SBSep{{font-size:10px;color:{c[\"t3\"]};background:transparent;padding:0;}}\n #KBKey{{font-family:'Menlo',monospace;font-size:10px;font-weight:600;color:{c[\"t2\"]};background:{c[\"bg3\"]};border-radius:4px;padding:3px 8px;}}\n \"\"\"\n\n\nclass _EggLogo(QLabel):\n \"\"\"\n 📄 Logo 彩蛋按钮:\n · 悬停 5 秒 → 烟花爆炸\n · 连续点击 5 次 → emoji 飘落\n 所有动画在 MainWindow 层播放。\n \"\"\"\n def __init__(self, text=\"📄\", size=22):\n super().__init__(text)\n self.setStyleSheet(f\"font-size:{size}px;background:transparent;border:none;\")\n self.setCursor(Qt.PointingHandCursor)\n self._click_count = 0\n self._click_timer = QTimer(); self._click_timer.setSingleShot(True)\n self._click_timer.timeout.connect(self._reset_clicks)\n self._hover_timer = QTimer(); self._hover_timer.setSingleShot(True)\n self._hover_timer.timeout.connect(self._on_hover_egg)\n\n def enterEvent(self, e):\n super().enterEvent(e)\n self._hover_timer.start(5000)\n\n def leaveEvent(self, e):\n super().leaveEvent(e)\n self._hover_timer.stop()\n\n def mousePressEvent(self, e):\n super().mousePressEvent(e)\n self._click_count += 1\n self._click_timer.start(1200)\n if self._click_count >= 5:\n self._click_count = 0\n self._trigger_confetti()\n\n def _reset_clicks(self):\n self._click_count = 0\n\n def _main_window(self):\n w = self\n while w:\n if isinstance(w, QMainWindow): return w\n w = w.parentWidget()\n return None\n\n def _on_hover_egg(self):\n \"\"\"悬停 5 秒 → 烟花从 Logo 处爆发\"\"\"\n mw = self._main_window()\n if not mw: return\n import random, math\n from PyQt5.QtCore import QPropertyAnimation, QPoint\n center = self.mapTo(mw, self.rect().center())\n firework_emoji = [\"🎆\",\"✨\",\"💫\",\"⭐\",\"🌟\",\"🎇\",\"💥\",\"🔥\"]\n for _ in range(30):\n lbl = QLabel(random.choice(firework_emoji), mw)\n lbl.setStyleSheet(f\"font-size:{random.randint(14,32)}px;background:transparent;\")\n lbl.move(center); lbl.show()\n angle = random.uniform(0, 2 * math.pi)\n dist = random.randint(120, 350)\n end = QPoint(center.x() + int(math.cos(angle) * dist),\n center.y() + int(math.sin(angle) * dist))\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(800, 1600))\n anim.setStartValue(center); anim.setEndValue(end)\n from PyQt5.QtCore import QEasingCurve\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start(); lbl._anim = anim\n\n def _trigger_confetti(self):\n \"\"\"连续点击 5 次 → emoji 从天而降\"\"\"\n mw = self._main_window()\n if not mw: return\n import random\n from datetime import datetime\n from PyQt5.QtCore import QPropertyAnimation, QPoint, QEasingCurve\n md = (datetime.now().month, datetime.now().day)\n holiday = {\n (1,1):[\"🎆\",\"🥳\",\"🎊\",\"🎉\",\"✨\"], (2,14):[\"💝\",\"💕\",\"💗\",\"🥰\",\"💌\"],\n (5,1):[\"🎊\",\"🎉\",\"🌸\",\"✨\",\"🌟\"], (10,1):[\"🇨🇳\",\"🎆\",\"🎉\",\"✨\",\"💫\"],\n (10,31):[\"🎃\",\"👻\",\"🍬\",\"⭐\",\"✨\"], (12,25):[\"🎄\",\"🎅\",\"❄️\",\"🎁\",\"✨\"],\n }\n pool = holiday.get(md, [\"✨\",\"🎉\",\"🚀\",\"💡\",\"🌟\",\"💎\",\"🦋\",\"🌈\",\"🧸\",\"💫\",\"⭐\",\"🌸\",\"🎈\",\"🎵\",\"💗\",\"🌙\",\"🦄\"])\n for _ in range(25):\n lbl = QLabel(random.choice(pool), mw)\n lbl.setStyleSheet(f\"font-size:{random.randint(18,42)}px;background:transparent;\")\n x = random.randint(0, mw.width() - 40)\n lbl.move(x, -40); lbl.show()\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(1500, 3500))\n anim.setStartValue(lbl.pos())\n anim.setEndValue(QPoint(x + random.randint(-60, 60), mw.height() + 40))\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start(); lbl._anim = anim\n\n\nclass _RoundMenu(QMenu):\n \"\"\"圆角右键菜单 — FramelessWindowHint + 自绘圆角背景 + mask 裁切\"\"\"\n _R = 10 # corner radius\n\n def __init__(self, parent=None):\n super().__init__(parent)\n self.setWindowFlags(\n self.windowFlags() | Qt.FramelessWindowHint | Qt.NoDropShadowWindowHint)\n self.setAttribute(Qt.WA_TranslucentBackground)\n # 让 Qt 的默认 QMenu 绘制不画背景,只画菜单项\n self.setStyleSheet(\"QMenu{background:transparent;border:none;}\")\n\n def resizeEvent(self, e):\n super().resizeEvent(e)\n path = QPainterPath()\n path.addRoundedRect(0, 0, self.width(), self.height(),\n self._R, self._R)\n self.setMask(QRegion(path.toFillPolygon().toPolygon()))\n\n def paintEvent(self, e):\n p = QPainter(self)\n p.setRenderHint(QPainter.Antialiasing)\n r = self._R\n rect = self.rect()\n is_dark = isinstance(_C, dict) and _C.get(\"bg\") == \"#1C1C1E\"\n bg = QColor(_C.get(\"elev\", \"#FFF\")) if isinstance(_C, dict) else QColor(\"#FFF\")\n brd = QColor(0, 0, 0, 18) if not is_dark else QColor(255, 255, 255, 18)\n path = QPainterPath()\n path.addRoundedRect(\n rect.x() + 0.5, rect.y() + 0.5,\n rect.width() - 1.0, rect.height() - 1.0,\n float(r), float(r))\n p.fillPath(path, bg)\n p.setPen(QPen(brd, 0.5))\n p.drawPath(path)\n p.end()\n super().paintEvent(e)\n\n\ndef _div():\n d = QFrame(); d.setObjectName(\"Div\"); d.setFrameShape(QFrame.HLine); return d\n\n\ndef _md2html(text):\n \"\"\"Markdown → HTML — 让 AI 回复像 Claude 一样优雅\"\"\"\n import re as _re\n # 转义 HTML\n text = text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')\n # 加粗\n text = _re.sub(r'\\*\\*(.+?)\\*\\*', r'<b>\\1</b>', text)\n # 斜体\n text = _re.sub(r'(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)', r'<i>\\1</i>', text)\n # 行内代码\n text = _re.sub(r'`([^`]+)`', r'<code style=\"background:rgba(0,0,0,0.06);border-radius:3px;padding:1px 4px;font-family:Menlo,monospace;font-size:10px;\">\\1</code>', text)\n # 列表项 (- item)\n text = _re.sub(r'^- (.+)$', r'<div style=\"margin:2px 0;padding-left:12px;text-indent:-8px;\">· \\1</div>', text, flags=_re.MULTILINE)\n # 数字列表 (1. item)\n text = _re.sub(r'^(\\d+)\\. (.+)$', r'<div style=\"margin:2px 0;padding-left:12px;text-indent:-12px;\">\\1. \\2</div>', text, flags=_re.MULTILINE)\n # 段落换行\n text = text.replace('\\n\\n', '<div style=\"height:8px;\"></div>')\n text = text.replace('\\n', '<br>')\n return text\n\n\ndef _card(level=\"md\"):\n \"\"\"统一阴影层级: sm=微弱, md=默认卡片, lg=悬浮\"\"\"\n _SHADOW = {\"sm\":(12,2,8), \"md\":(20,4,12), \"lg\":(28,6,16)}\n blur, y, a = _SHADOW.get(level, _SHADOW[\"md\"])\n c = QFrame(); c.setObjectName(\"Card\")\n s = QGraphicsDropShadowEffect(); s.setBlurRadius(blur); s.setOffset(0,y); s.setColor(QColor(0,0,0,a))\n c.setGraphicsEffect(s); return c\n\n\n# ═══════════════════════════════════════════════════════════════\n# 拖拽区\n# ═══════════════════════════════════════════════════════════════\n\nclass DropZone(QFrame):\n files_dropped = pyqtSignal(list)\n def __init__(self):\n super().__init__(); self.setObjectName(\"DZ\"); self.setAcceptDrops(True)\n self.setFixedHeight(140); self.setCursor(Qt.PointingHandCursor)\n\n self._stack = QStackedLayout(self)\n self._stack.setContentsMargins(0,0,0,0)\n\n # ── Page 0: 拖拽提示 ──\n p0 = QWidget(); p0.setStyleSheet(\"background:transparent;\")\n lo0 = QHBoxLayout(p0); lo0.setAlignment(Qt.AlignCenter); lo0.setSpacing(16)\n lo0.setContentsMargins(32,20,32,20)\n ic = QLabel(\"📥\"); ic.setStyleSheet(\"font-size:36px;background:transparent;\"); lo0.addWidget(ic)\n txt = QVBoxLayout(); txt.setSpacing(3)\n t = QLabel(\"将 PDF 拖放至此处\"); t.setObjectName(\"DZTitle\"); txt.addWidget(t)\n s = QLabel(\"或点击浏览 · 支持批量\"); s.setObjectName(\"DZSub\"); txt.addWidget(s)\n lo0.addLayout(txt); lo0.addStretch()\n self._stack.addWidget(p0)\n\n # ── Page 1: 文件列表 ──\n p1 = QWidget(); p1.setStyleSheet(\"background:transparent;\")\n lo1 = QVBoxLayout(p1); lo1.setContentsMargins(10,6,10,4); lo1.setSpacing(3)\n\n self._zotero_hint = QLabel(\"📚 Zotero 文献 · 译文自动保存回原位\")\n self._zotero_hint.setStyleSheet(\n \"font-size:10px;color:#1a56db;background:#e8f0fe;\"\n \"border-radius:3px;padding:2px 6px;\")\n self._zotero_hint.setVisible(False)\n lo1.addWidget(self._zotero_hint)\n\n self.flist = QListWidget()\n self.flist.setSelectionMode(QListWidget.ExtendedSelection)\n lo1.addWidget(self.flist)\n\n fbtn = QHBoxLayout(); fbtn.setSpacing(6)\n self._fcount_label = QLabel(\"\"); self._fcount_label.setObjectName(\"Cap\")\n self._fcount_label.setStyleSheet(\"font-size:10px;background:transparent;\")\n fbtn.addWidget(self._fcount_label); fbtn.addStretch()\n self._add_btn = QPushButton(\"+ 添加\"); self._add_btn.setCursor(Qt.PointingHandCursor)\n self._add_btn.setStyleSheet(\"font-size:10px;padding:1px 8px;border:1px solid #ccc;border-radius:3px;background:transparent;\")\n fbtn.addWidget(self._add_btn)\n self._del_btn = QPushButton(\"删除选中\"); self._del_btn.setObjectName(\"TB\"); self._del_btn.setCursor(Qt.PointingHandCursor)\n self._del_btn.setStyleSheet(\"font-size:10px;padding:1px 6px;\")\n fbtn.addWidget(self._del_btn)\n self._clr_btn = QPushButton(\"清空\"); self._clr_btn.setObjectName(\"GhDanger\"); self._clr_btn.setCursor(Qt.PointingHandCursor)\n self._clr_btn.setStyleSheet(\"font-size:10px;padding:1px 6px;\")\n fbtn.addWidget(self._clr_btn)\n lo1.addLayout(fbtn)\n self._stack.addWidget(p1)\n\n def dragEnterEvent(self, e):\n c = _C\n self.setStyleSheet(\n f\"#DZ{{background:{c['acc_l']};border:2.5px solid {c['acc']};\"\n f\"border-radius:14px;}}\")\n # 呼吸脉动动画\n from PyQt5.QtCore import QPropertyAnimation\n anim = QPropertyAnimation(self, b\"windowOpacity\")\n anim.setDuration(600); anim.setLoopCount(-1) # 无限循环\n anim.setStartValue(1.0); anim.setEndValue(0.85)\n # QWidget 没有 windowOpacity,用 graphicsEffect 模拟缩放\n effect = QGraphicsDropShadowEffect(self)\n effect.setBlurRadius(20); effect.setOffset(0, 0)\n effect.setColor(QColor(c['acc']))\n self.setGraphicsEffect(effect)\n self._drag_effect = effect\n e.acceptProposedAction()\n def dragLeaveEvent(self, e):\n self.setStyleSheet(\"\")\n self.setGraphicsEffect(None)\n self._drag_effect = None\n def dragMoveEvent(self, e):\n e.acceptProposedAction()\n def dropEvent(self, e):\n self.setStyleSheet(\"\")\n self.setGraphicsEffect(None)\n import glob\n fs = []\n md = e.mimeData()\n\n def _collect(p):\n \"\"\"从路径收集 PDF:文件直接加,文件夹递归扫描\"\"\"\n if p.lower().endswith('.pdf') and os.path.isfile(p):\n fs.append(p)\n elif os.path.isdir(p):\n fs.extend(sorted(glob.glob(\n os.path.join(p, '**', '*.pdf'), recursive=True)))\n\n # 1) 标准 file URL(Finder 拖文件/文件夹、Zotero 拖 PDF 附件)\n for u in md.urls():\n p = u.toLocalFile()\n if p:\n _collect(p)\n\n # 检测是否来自 Zotero(macOS 拖多个条目时 URL 只含一个文件)\n _from_zotero = False\n if fs:\n from ui.translate_worker import detect_zotero_source\n _from_zotero = any(detect_zotero_source(f) for f in fs)\n\n # 2) text/plain 中的 file:// 路径或本地路径\n if not fs and md.hasText():\n for line in md.text().strip().splitlines():\n line = line.strip()\n if line.startswith(\"file://\"):\n from PyQt5.QtCore import QUrl\n line = QUrl(line).toLocalFile()\n if line and os.path.exists(line):\n _collect(line)\n\n # 3) Zotero 自定义 MIME(同进程或支持跨进程的平台)\n if not fs:\n from ui.translate_worker import resolve_zotero_items, resolve_zotero_collection\n zot_data = md.data(\"zotero/item\")\n if zot_data and len(zot_data):\n try:\n import json as _json\n raw = bytes(zot_data).decode(\"utf-8\", errors=\"ignore\").strip()\n try:\n parsed = _json.loads(raw)\n if isinstance(parsed, dict) and \"itemIDs\" in parsed:\n item_ids = [int(x) for x in parsed[\"itemIDs\"]]\n elif isinstance(parsed, list):\n item_ids = [int(x) for x in parsed]\n elif isinstance(parsed, int):\n item_ids = [parsed]\n else:\n item_ids = []\n except (ValueError, TypeError):\n item_ids = [int(x) for x in raw.split(\",\")\n if x.strip().isdigit()]\n if item_ids:\n fs = resolve_zotero_items(item_ids)\n except Exception:\n pass\n if not fs:\n zot_coll = md.data(\"zotero/collection\")\n if zot_coll and len(zot_coll):\n try:\n raw = bytes(zot_coll).decode(\"utf-8\", errors=\"ignore\").strip()\n if raw:\n fs = resolve_zotero_collection(raw)\n except Exception:\n pass\n\n # 4) macOS 跨进程 Zotero:text/plain 匹配标题/集合名\n # 也在 layer 1 仅拿到部分 Zotero URL 时运行,补全缺失的文件\n if (not fs or _from_zotero) and md.hasText():\n try:\n from ui.translate_worker import (\n resolve_zotero_by_title, resolve_zotero_collection_by_name)\n txt = md.text().strip()\n if txt:\n extra = resolve_zotero_by_title(txt)\n existing = set(fs)\n for f in extra:\n if f not in existing:\n fs.append(f)\n existing.add(f)\n if not fs and txt:\n fs = resolve_zotero_collection_by_name(txt)\n except Exception:\n pass\n\n self.setStyleSheet(\"\") # 恢复默认样式\n if fs:\n self.files_dropped.emit(fs)\n def mousePressEvent(self, e):\n if self._stack.currentIndex() == 0:\n fs, _ = QFileDialog.getOpenFileNames(self, \"选择 PDF\", \"\", \"PDF (*.pdf)\")\n if fs: self.files_dropped.emit(fs)\n\n\nclass SummaryCard(QFrame):\n \"\"\"智能摘要卡片 — 可折叠,点击按钮调用 AI 生成结构化摘要\"\"\"\n def __init__(self):\n super().__init__()\n self.setObjectName(\"Card\")\n lo = QVBoxLayout(self); lo.setContentsMargins(16, 10, 16, 10); lo.setSpacing(6)\n # 头部\n hdr = QHBoxLayout(); hdr.setSpacing(8)\n ic = QLabel(\"✨\"); ic.setStyleSheet(\"font-size:14px;background:transparent;\")\n hdr.addWidget(ic)\n t = QLabel(\"智能摘要\"); t.setStyleSheet(\"font-size:13px;font-weight:600;background:transparent;\")\n hdr.addWidget(t); hdr.addStretch()\n self._svc_label = QLabel(\"\")\n self._svc_label.setStyleSheet(\"font-size:10px;color:gray;background:transparent;\")\n hdr.addWidget(self._svc_label)\n self.gen_btn = QPushButton(\"生成摘要\"); self.gen_btn.setObjectName(\"Sc\")\n self.gen_btn.setCursor(Qt.PointingHandCursor)\n self.gen_btn.setStyleSheet(\"font-size:11px;padding:3px 12px;\")\n self.gen_btn.clicked.connect(self._generate)\n hdr.addWidget(self.gen_btn)\n self._collapse_btn = QPushButton(\"▼\"); self._collapse_btn.setObjectName(\"Gh\")\n self._collapse_btn.setFixedSize(24, 24); self._collapse_btn.setCursor(Qt.PointingHandCursor)\n self._collapse_btn.setStyleSheet(\"font-size:10px;padding:0;\")\n self._collapse_btn.clicked.connect(self._toggle_collapse)\n hdr.addWidget(self._collapse_btn)\n lo.addLayout(hdr)\n # 内容区\n self._body = QLabel(\"\")\n self._body.setWordWrap(True); self._body.setTextInteractionFlags(Qt.TextSelectableByMouse)\n self._body.setStyleSheet(\"font-size:12px;line-height:160%;background:transparent;padding:4px 0;\")\n self._body.setVisible(False)\n lo.addWidget(self._body)\n self._collapsed = False\n self._worker = None\n self._pdf_path = None\n # 检测 AI 服务(优先使用独立助手配置)\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if svc:\n self._svc_label.setText(f\"via {svc['name']}\")\n else:\n self.gen_btn.setEnabled(False)\n self.gen_btn.setToolTip(\"请在设置中配置 AI 服务 API Key\")\n\n def set_pdf(self, path):\n self._pdf_path = path\n self._body.setText(\"\"); self._body.setVisible(False)\n self._collapse_btn.setText(\"▼\")\n\n def _toggle_collapse(self):\n self._collapsed = not self._collapsed\n self._body.setVisible(not self._collapsed and bool(self._body.text()))\n self._collapse_btn.setText(\"▶\" if self._collapsed else \"▼\")\n\n def _generate(self):\n if not self._pdf_path or not os.path.exists(self._pdf_path):\n return\n # 刷新服务检测(优先使用独立助手配置)\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if not svc:\n self._body.setText(\"⚠️ 未配置 AI 服务。请在设置页配置 API Key(如 DeepSeek、OpenAI 等)\")\n self._body.setVisible(True)\n return\n self._svc_label.setText(f\"via {svc['name']}\")\n self.gen_btn.setEnabled(False); self.gen_btn.setText(\"生成中…\")\n self._body.setText(\"⏳ 正在分析论文,请稍候…\"); self._body.setVisible(True)\n self._collapsed = False; self._collapse_btn.setText(\"▼\")\n self._worker = SummaryWorker(self._pdf_path)\n self._worker.result.connect(self._on_result)\n self._worker.error.connect(self._on_error)\n self._worker.finished.connect(lambda: self._cleanup())\n self._worker.start()\n\n def _on_result(self, text):\n self._body.setText(text)\n self._body.setVisible(True)\n\n def _on_error(self, err):\n self._body.setText(f\"⚠️ {err}\")\n self._body.setVisible(True)\n\n def _cleanup(self):\n self.gen_btn.setEnabled(True); self.gen_btn.setText(\"生成摘要\")\n if self._worker:\n w = self._worker; self._worker = None\n w.quit(); QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n\nclass SB(QWidget):\n \"\"\"侧边栏按钮 — 图标固定宽度 + 文字对齐\"\"\"\n clicked = pyqtSignal()\n def __init__(self, icon, label):\n super().__init__()\n self.setFixedHeight(32); self.setCursor(Qt.PointingHandCursor)\n self.setObjectName(\"SB\"); self.setProperty(\"active\", False)\n lo = QHBoxLayout(self); lo.setContentsMargins(12,0,12,0); lo.setSpacing(0)\n self._icon = QLabel(icon); self._icon.setFixedWidth(24); self._icon.setAlignment(Qt.AlignCenter)\n self._icon.setStyleSheet(\"font-size:16px;background:transparent;border:none;\")\n self._label = QLabel(label)\n self._label.setStyleSheet(\"font-size:13px;background:transparent;border:none;padding-left:8px;\")\n lo.addWidget(self._icon); lo.addWidget(self._label); lo.addStretch()\n def set_active(self, v):\n self.setProperty(\"active\", v); self.style().unpolish(self); self.style().polish(self)\n def mousePressEvent(self, e):\n self.clicked.emit()\n\n\n# ═══════════════════════════════════════════════════════════════\n# PDF 预览 — 支持 MONO / DUAL / Side-by-Side 三模式切换\n# ═══════════════════════════════════════════════════════════════\n\nclass PDFPageWidget(QLabel):\n def __init__(self):\n super().__init__(); self.setAlignment(Qt.AlignCenter)\n def set_pixmap(self, px):\n self.setPixmap(px)\n self.setFixedSize(px.size())\n\n\nfrom PyQt5.QtCore import QObject, QTimer\n\nclass _PinchFilter(QObject):\n \"\"\"全局捕获 macOS 触控板双指缩放 (NativeGesture)\"\"\"\n def __init__(self, preview):\n super().__init__(preview)\n self._preview = preview\n self._pending = False\n\n def _is_preview_child(self, widget):\n \"\"\"检查 widget 是否属于 preview 控件树\"\"\"\n try:\n w = widget\n for _ in range(30):\n if w is self._preview:\n return True\n p = w.parentWidget() if hasattr(w, 'parentWidget') else None\n if p is None:\n return False\n w = p\n except RuntimeError:\n return False\n return False\n\n def _do_zoom(self):\n \"\"\"延迟执行缩放,避免在事件处理期间修改控件树\"\"\"\n self._pending = False\n try:\n acc = self._preview._pinch_accumulator\n if abs(acc) > 0.08:\n new_zoom = self._preview.zoom * (1.0 + acc * 0.15)\n new_zoom = max(0.3, min(new_zoom, 5.0))\n self._preview._pinch_accumulator = 0.0\n self._preview._apply_zoom(new_zoom)\n except (RuntimeError, AttributeError):\n pass\n\n def eventFilter(self, obj, event):\n try:\n if event.type() == QEvent.NativeGesture:\n if event.gestureType() == Qt.ZoomNativeGesture and self._is_preview_child(obj):\n self._preview._pinch_accumulator += event.value()\n if not self._pending and abs(self._preview._pinch_accumulator) > 0.08:\n self._pending = True\n QTimer.singleShot(0, self._do_zoom)\n return True\n except (RuntimeError, AttributeError):\n pass\n return False\n\n\nclass _AutoGrowTextEdit(QWidget):\n \"\"\"自适应高度的输入框 — 内容少时单行,多时自动向上增长(最多 5 行)\"\"\"\n returnPressed = pyqtSignal()\n\n def __init__(self, placeholder=\"\"):\n super().__init__()\n lo = QVBoxLayout(self); lo.setContentsMargins(0, 0, 0, 0); lo.setSpacing(0)\n from PyQt5.QtWidgets import QTextEdit\n self._te = QTextEdit()\n self._te.setPlaceholderText(placeholder)\n self._te.setAcceptRichText(False)\n self._te.setStyleSheet(\n f\"QTextEdit{{background:transparent;border:none;\"\n f\"padding:4px 6px;font-size:13px;font-family:'Helvetica Neue','PingFang SC';\"\n f\"color:{_C['t1']};}}\")\n self._te.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._te.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._te.document().contentsChanged.connect(self._adjust_height)\n self._te.installEventFilter(self)\n lo.addWidget(self._te)\n self._min_h = 36; self._max_h = 120\n self._te.setFixedHeight(self._min_h)\n\n def text(self):\n return self._te.toPlainText()\n\n def clear(self):\n self._te.clear()\n\n def setPlaceholderText(self, t):\n self._te.setPlaceholderText(t)\n\n def _adjust_height(self):\n \"\"\"只在实际行数变化时调整高度(按行跳变,不逐像素增长)\"\"\"\n line_h = 20 # 13px font ≈ 20px line height\n doc_h = self._te.document().size().height()\n new_lines = max(1, round(doc_h / line_h))\n if not hasattr(self, '_cur_lines'):\n self._cur_lines = 1\n if new_lines != self._cur_lines:\n self._cur_lines = new_lines\n target_h = max(self._min_h, min(new_lines * line_h + 8, self._max_h))\n self._te.setFixedHeight(target_h)\n\n def eventFilter(self, obj, event):\n if obj == self._te and event.type() == QEvent.KeyPress:\n if event.key() in (Qt.Key_Return, Qt.Key_Enter) and not event.modifiers():\n self.returnPressed.emit()\n return True\n return super().eventFilter(obj, event)\n\n\nclass _FlowLayout(QLayout):\n \"\"\"自动换行布局 — 按钮放不下时自动折行,避免文字被截断\"\"\"\n def __init__(self, parent=None, margin=0, spacing=-1):\n super().__init__(parent)\n if parent is not None:\n self.setContentsMargins(margin, margin, margin, margin)\n self._items = []\n self._spacing = spacing\n\n def addItem(self, item): self._items.append(item)\n def count(self): return len(self._items)\n def itemAt(self, idx): return self._items[idx] if 0 <= idx < len(self._items) else None\n def takeAt(self, idx):\n if 0 <= idx < len(self._items):\n return self._items.pop(idx)\n return None\n\n def expandingDirections(self): return Qt.Orientations()\n def hasHeightForWidth(self): return True\n def heightForWidth(self, w): return self._do_layout(QRect(0, 0, w, 0), test=True)\n\n def setGeometry(self, rect):\n super().setGeometry(rect)\n self._do_layout(rect, test=False)\n\n def sizeHint(self): return self.minimumSize()\n def minimumSize(self):\n s = QSize()\n for item in self._items:\n s = s.expandedTo(item.minimumSize())\n m = self.contentsMargins()\n s += QSize(m.left() + m.right(), m.top() + m.bottom())\n return s\n\n def _do_layout(self, rect, test=False):\n m = self.contentsMargins()\n effective = rect.adjusted(m.left(), m.top(), -m.right(), -m.bottom())\n x, y = effective.x(), effective.y()\n line_h = 0\n sp = self._spacing if self._spacing >= 0 else 4\n for item in self._items:\n w = item.sizeHint().width()\n h = item.sizeHint().height()\n if x + w > effective.right() + 1 and line_h > 0:\n x = effective.x()\n y += line_h + sp\n line_h = 0\n if not test:\n item.setGeometry(QRect(x, y, w, h))\n x += w + sp\n line_h = max(line_h, h)\n return y + line_h - rect.y() + m.bottom()\n\n\n# ── AI 快捷操作 预设库 ──────────────────────────────────────────\n# id 用于标识,label 显示在按钮上,prompt 是发给 LLM 的指令\n# type=\"summary\" 走 SummaryWorker(内置摘要引擎),其余走 QAWorker\n\n_BUILTIN_PROMPTS = [\n {\n \"id\": \"summary\", \"label\": \"📝 摘要\", \"prompt\": \"\", \"type\": \"summary\",\n \"desc\": \"结构化论文摘要(内置引擎)\",\n },\n {\n \"id\": \"explain\", \"label\": \"📖 讲解\",\n \"desc\": \"通俗讲解论文核心内容\",\n \"prompt\": (\n \"请用通俗易懂的语言讲解这篇论文,假设读者具有本科水平但不熟悉该细分领域。\\n\\n\"\n \"请按以下结构输出:\\n\"\n \"## 一句话总结\\n用一句话概括论文在做什么。\\n\\n\"\n \"## 研究背景\\n这个领域之前遇到了什么问题?为什么需要这项研究?\\n\\n\"\n \"## 核心方法\\n作者提出了什么方法或思路?用类比或直觉解释,避免公式。\\n\\n\"\n \"## 主要发现\\n实验结果说明了什么?和之前的方法相比有什么提升?\\n\\n\"\n \"## 为什么重要\\n这项工作对该领域或实际应用有什么意义?\"\n ),\n },\n {\n \"id\": \"questions\", \"label\": \"❓ 深度提问\",\n \"desc\": \"生成 5 个深度问题帮助理解\",\n \"prompt\": (\n \"请基于这篇论文,提出 5 个有深度的问题,帮助读者批判性地理解论文内容。\\n\\n\"\n \"要求:\\n\"\n \"- 每个问题聚焦论文的不同方面(方法、实验、假设、局限性、应用前景等)\\n\"\n \"- 每个问题后附 2-3 句解释,说明为什么这个问题值得思考\\n\"\n \"- 问题应该有启发性,而非简单的事实复述\\n\\n\"\n \"格式:\\n**Q1: [问题]**\\n[为什么值得思考]\\n\\n依此类推。\"\n ),\n },\n {\n \"id\": \"critique\", \"label\": \"🔍 批判分析\",\n \"desc\": \"评估论文的优缺点和局限性\",\n \"prompt\": (\n \"请对这篇论文进行批判性分析,像一位资深审稿人一样思考。\\n\\n\"\n \"请从以下维度评价:\\n\"\n \"## 主要优点\\n论文在方法、实验设计、写作等方面做得好的地方(2-3 点)\\n\\n\"\n \"## 潜在不足\\n方法论上的局限、实验设计的缺陷、未讨论的边界条件(2-3 点)\\n\\n\"\n \"## 可信度评估\\n实验结果是否充分支撑了论文的结论?有没有过度声称?\\n\\n\"\n \"## 改进建议\\n如果你是作者,你会在哪些方面做进一步的工作?\"\n ),\n },\n {\n \"id\": \"methods\", \"label\": \"🧪 方法详解\",\n \"desc\": \"深入解析论文的技术方法\",\n \"prompt\": (\n \"请深入解析这篇论文的技术方法,适合有一定基础的读者。\\n\\n\"\n \"请涵盖:\\n\"\n \"## 问题形式化\\n论文要解决的问题是如何数学/形式化定义的?\\n\\n\"\n \"## 方法流程\\n按步骤描述方法的完整流程,用编号列表。\\n\\n\"\n \"## 关键创新点\\n和现有方法相比,核心创新在哪里?\\n\\n\"\n \"## 实验设置\\n用了什么数据集、基线方法、评估指标?\\n\\n\"\n \"## 核心结果\\n最重要的实验结论是什么?数据支撑如何?\"\n ),\n },\n {\n \"id\": \"related\", \"label\": \"🔗 相关工作\",\n \"desc\": \"梳理论文引用的相关研究脉络\",\n \"prompt\": (\n \"请梳理这篇论文的相关工作和研究脉络。\\n\\n\"\n \"请输出:\\n\"\n \"## 研究领域定位\\n这篇论文属于哪个研究方向?\\n\\n\"\n \"## 关键前置工作\\n论文建立在哪些重要的前人工作之上?列出 3-5 篇最重要的,并说明关系。\\n\\n\"\n \"## 方法演进\\n该领域的方法是如何一步步发展到本文的?用时间线或逻辑链描述。\\n\\n\"\n \"## 与竞争方法的对比\\n本文方法与最相近的竞争方法有什么本质区别?\"\n ),\n },\n {\n \"id\": \"application\", \"label\": \"💡 应用场景\",\n \"desc\": \"分析论文成果的实际应用可能性\",\n \"prompt\": (\n \"请分析这篇论文的研究成果可以如何应用到实际场景中。\\n\\n\"\n \"请涵盖:\\n\"\n \"## 直接应用\\n基于论文当前的成果,最可能直接落地的应用场景是什么?\\n\\n\"\n \"## 潜在扩展\\n如果进一步发展,还可能应用在哪些领域?\\n\\n\"\n \"## 落地挑战\\n从研究到实际部署,可能遇到哪些技术或工程上的困难?\\n\\n\"\n \"## 商业价值\\n这项技术对行业可能产生什么影响?\"\n ),\n },\n {\n \"id\": \"keyterms\", \"label\": \"📚 术语表\",\n \"desc\": \"提取并解释论文中的关键术语\",\n \"prompt\": (\n \"请提取这篇论文中的关键专业术语和概念,生成一个术语表。\\n\\n\"\n \"要求:\\n\"\n \"- 提取 8-12 个最重要的术语\\n\"\n \"- 每个术语给出:中文翻译(如适用)、一句话定义、在论文中的具体含义\\n\"\n \"- 按照术语在论文中出现的逻辑顺序排列\\n\\n\"\n \"格式:\\n**术语名** (English Term)\\n定义:...\\n论文中的含义:...\"\n ),\n },\n]\n\n# 默认激活的 3 个(显示在快捷栏上)\n_DEFAULT_ACTIVE_IDS = [\"summary\", \"explain\", \"questions\"]\n\n\nclass QAPanelWidget(QWidget):\n \"\"\"AI 论文助手面板 — 可自定义快捷操作库 + 聊天界面\"\"\"\n\n def __init__(self):\n super().__init__()\n self.setMinimumWidth(260); self.setMaximumWidth(420)\n self._busy = False # 是否有 worker 在运行\n lo = QVBoxLayout(self); lo.setContentsMargins(12, 10, 12, 12); lo.setSpacing(0)\n\n # ── 头部 ──\n hdr = QHBoxLayout(); hdr.setSpacing(6)\n t = QLabel(\"AI 助手\"); t.setStyleSheet(\n \"font-size:13px;font-weight:600;background:transparent;letter-spacing:0.3px;\")\n hdr.addWidget(t); hdr.addStretch()\n self._svc_label = QLabel(\"\")\n self._svc_label.setStyleSheet(f\"font-size:10px;color:{_C['t3']};background:transparent;\")\n hdr.addWidget(self._svc_label)\n clr = QPushButton(\"清空\"); clr.setObjectName(\"Gh\")\n clr.setStyleSheet(\"font-size:10px;padding:2px 6px;\")\n clr.setCursor(Qt.PointingHandCursor); clr.clicked.connect(self._clear)\n hdr.addWidget(clr)\n self._cfg_btn = QPushButton(\"⚙\")\n self._cfg_btn.setCursor(Qt.PointingHandCursor)\n self._cfg_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:none;font-size:13px;\"\n f\"padding:2px 5px;color:{_C['t3']};}}\"\n f\"QPushButton:hover{{color:{_C['acc']};}}\")\n self._cfg_btn.clicked.connect(self._open_settings)\n hdr.addWidget(self._cfg_btn)\n lo.addLayout(hdr)\n lo.addSpacing(6)\n\n # ── 快捷操作栏 ──\n self._quick_frame = QFrame()\n self._quick_frame.setStyleSheet(\"QFrame{background:transparent;border:none;}\")\n self._quick_layout = QHBoxLayout(self._quick_frame)\n self._quick_layout.setContentsMargins(0, 0, 0, 0)\n self._quick_layout.setSpacing(6)\n self._quick_btns = []\n lo.addWidget(self._quick_frame)\n lo.addSpacing(8)\n\n # ── 聊天区 ──\n self._chat_scroll = QScrollArea(); self._chat_scroll.setWidgetResizable(True)\n self._chat_scroll.setFrameShape(QFrame.NoFrame)\n self._chat_container = QWidget()\n self._chat_layout = QVBoxLayout(self._chat_container)\n self._chat_layout.setContentsMargins(0, 4, 0, 4); self._chat_layout.setSpacing(12)\n self._chat_layout.setAlignment(Qt.AlignTop)\n self._chat_scroll.setWidget(self._chat_container)\n lo.addWidget(self._chat_scroll, 1)\n lo.addSpacing(8)\n\n # ── 输入区 ──\n self._inp_frame = QFrame()\n self._inp_frame.setStyleSheet(\n f\"QFrame{{background:{_C['card']};border:0.5px solid {_C['brd']};\"\n f\"border-radius:12px;}}\")\n inp_lo = QHBoxLayout(self._inp_frame)\n inp_lo.setContentsMargins(10, 6, 6, 6); inp_lo.setSpacing(6)\n self._input = _AutoGrowTextEdit(\"输入问题…\")\n inp_lo.addWidget(self._input, 1)\n self._send_btn = QPushButton(\"发送\"); self._send_btn.setObjectName(\"Pr\")\n self._send_btn.setStyleSheet(\"font-size:11px;padding:6px 16px;border-radius:8px;\")\n self._send_btn.setCursor(Qt.PointingHandCursor)\n self._send_btn.clicked.connect(self._send)\n self._send_btn.setFixedHeight(32)\n inp_lo.addWidget(self._send_btn, 0, Qt.AlignBottom)\n lo.addWidget(self._inp_frame)\n self._input.returnPressed.connect(self._send)\n self._char_count = None\n\n # ── 状态 ──\n self._paper_text = \"\"\n self._pdf_path = None\n self._messages = []\n self._worker = None\n self._summary_worker = None\n self._current_ai_bubble = None\n self._streaming_text = \"\"\n\n # 加载 prompt 库 & 激活列表\n self._load_library()\n\n # ══════════════════════════════════════════════════════════════\n # Prompt 库 & 持久化\n # ══════════════════════════════════════════════════════════════\n\n def _load_library(self):\n \"\"\"从配置加载 prompt 库和激活列表\"\"\"\n cfg = UserConfigManager.load()\n # 库:内置 + 用户自定义\n saved_lib = cfg.get(\"ai_prompt_library\", None)\n if saved_lib is not None:\n self._library = saved_lib\n else:\n self._library = [dict(p) for p in _BUILTIN_PROMPTS]\n # 激活的 ID 列表(显示在快捷栏,最多 3 个)\n self._active_ids = cfg.get(\"ai_active_ids\", list(_DEFAULT_ACTIVE_IDS))\n self._rebuild_quick_bar()\n\n def _save_library(self):\n cfg = UserConfigManager.load()\n cfg[\"ai_prompt_library\"] = self._library\n cfg[\"ai_active_ids\"] = self._active_ids\n UserConfigManager.save(cfg)\n\n def _get_active_prompts(self):\n \"\"\"返回当前激活的 prompt 列表(按 _active_ids 顺序)\"\"\"\n id_map = {p[\"id\"]: p for p in self._library}\n return [id_map[aid] for aid in self._active_ids if aid in id_map]\n\n # ══════════════════════════════════════════════════════════════\n # 快捷栏\n # ══════════════════════════════════════════════════════════════\n\n def _rebuild_quick_bar(self):\n \"\"\"根据激活列表重建快捷按钮 — 等宽圆角药丸\"\"\"\n while self._quick_layout.count():\n item = self._quick_layout.takeAt(0)\n w = item.widget()\n if w:\n w.deleteLater()\n self._quick_btns = []\n c = _C\n for prompt_data in self._get_active_prompts():\n b = QPushButton(prompt_data[\"label\"])\n b.setCursor(Qt.PointingHandCursor)\n b.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)\n b.setFixedHeight(32)\n b.setStyleSheet(\n f\"QPushButton{{background:{c['card']};border:0.5px solid {c['card_b']};\"\n f\"border-radius:16px;padding:0 10px;font-size:12px;color:{c['t1']};}}\"\n f\"QPushButton:hover{{background:{c['acc_l']};border-color:{c['acc']};}}\"\n f\"QPushButton:pressed{{background:{c['acc']};color:white;\"\n f\"border-color:{c['acc']};}}\"\n f\"QPushButton:disabled{{opacity:0.5;}}\")\n pid = prompt_data[\"id\"]\n b.clicked.connect(lambda _, _pid=pid: self._exec_by_id(_pid))\n self._quick_layout.addWidget(b, 1) # stretch=1 等宽分布\n self._quick_btns.append(b)\n\n def _exec_by_id(self, prompt_id):\n \"\"\"执行指定 ID 的快捷操作\"\"\"\n try:\n if self._busy:\n return\n id_map = {p[\"id\"]: p for p in self._library}\n act = id_map.get(prompt_id)\n if not act:\n return\n if act.get(\"type\") == \"summary\":\n self._run_summary()\n else:\n prompt = act.get(\"prompt\", \"\")\n if prompt:\n self._run_preset(prompt, act.get(\"label\", \"\"))\n except Exception:\n pass\n\n # ══════════════════════════════════════════════════════════════\n # ⚙ 设置弹窗 — prompt 库管理 + 激活选择\n # ══════════════════════════════════════════════════════════════\n\n def _open_settings(self):\n \"\"\"打开 prompt 库配置弹窗\"\"\"\n try:\n self._do_open_settings()\n except Exception:\n pass\n\n def _do_open_settings(self):\n c = _C\n dlg = QDialog(self)\n dlg.setWindowTitle(\"AI 快捷操作配置\")\n dlg.setMinimumSize(520, 580)\n dlg.resize(560, 660)\n dlg.setStyleSheet(f\"\"\"\n QDialog{{background:{c['bg']};}}\n QLabel{{color:{c['t1']};background:transparent;}}\n QScrollArea{{background:{c['bg']};border:none;}}\n QWidget#scroll_inner{{background:{c['bg']};}}\n QLineEdit{{background:{c['inp']};border:1px solid {c['inp_b']};\n border-radius:6px;padding:6px 10px;font-size:13px;color:{c['t1']};\n min-height:20px;}}\n QLineEdit:focus{{border-color:{c['acc']};}}\n QPlainTextEdit{{background:{c['inp']};border:1px solid {c['inp_b']};\n border-radius:8px;padding:8px 10px;font-size:12px;color:{c['t1']};\n line-height:160%;}}\n QPlainTextEdit:focus{{border-color:{c['acc']};}}\n QCheckBox{{color:{c['t1']};spacing:6px;}}\n QCheckBox::indicator{{width:18px;height:18px;border-radius:5px;\n border:1.5px solid {c['inp_b']};background:{c['inp']};}}\n QCheckBox::indicator:checked{{background:{c['acc']};border-color:{c['acc']};}}\n \"\"\")\n dlo = QVBoxLayout(dlg); dlo.setContentsMargins(24, 20, 24, 18); dlo.setSpacing(14)\n\n # ── 标题行 ──\n hdr = QHBoxLayout()\n title = QLabel(\"快捷操作库\"); title.setStyleSheet(\"font-size:16px;font-weight:600;\")\n hdr.addWidget(title); hdr.addStretch()\n hint_l = QLabel(\"勾选最多 3 个显示在快捷栏\")\n hint_l.setStyleSheet(f\"font-size:11px;color:{c['t3']};\")\n hdr.addWidget(hint_l)\n dlo.addLayout(hdr)\n\n # ── 滚动区 ──\n scroll = QScrollArea(); scroll.setWidgetResizable(True)\n scroll.setFrameShape(QFrame.NoFrame)\n container = QWidget(); container.setObjectName(\"scroll_inner\")\n clo = QVBoxLayout(container); clo.setContentsMargins(0, 0, 0, 0); clo.setSpacing(10)\n clo.setAlignment(Qt.AlignTop)\n\n _cards = [] # list of entry dicts\n\n def _make_card(p, parent_layout):\n pid = p.get(\"id\", \"\")\n tp = p.get(\"type\", \"\")\n card = QFrame()\n card.setStyleSheet(\n f\"QFrame{{background:{c['card']};border:0.5px solid {c['card_b']};\"\n f\"border-radius:12px;}}\")\n card_lo = QVBoxLayout(card)\n card_lo.setContentsMargins(14, 12, 14, 12); card_lo.setSpacing(8)\n\n # ── 第一行:勾选 + 名称 + 删除 ──\n r1 = QHBoxLayout(); r1.setSpacing(10)\n cb = QCheckBox()\n cb.setFixedSize(22, 22)\n cb.setChecked(pid in self._active_ids)\n cb.setToolTip(\"勾选后显示在快捷栏\")\n r1.addWidget(cb, 0, Qt.AlignVCenter)\n name_inp = QLineEdit(p.get(\"label\", \"\")); name_inp.setMaxLength(12)\n name_inp.setPlaceholderText(\"按钮名称\")\n name_inp.setFixedHeight(32)\n r1.addWidget(name_inp, 1, Qt.AlignVCenter)\n is_builtin = any(bp[\"id\"] == pid for bp in _BUILTIN_PROMPTS)\n del_btn = QPushButton(\"×\")\n del_btn.setCursor(Qt.PointingHandCursor)\n del_btn.setFixedSize(26, 26)\n del_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:none;color:{c['t3']};\"\n f\"font-size:16px;font-weight:600;border-radius:13px;}}\"\n f\"QPushButton:hover{{background:rgba(255,59,48,0.1);color:{c['err']};}}\")\n if is_builtin:\n del_btn.setVisible(False)\n r1.addWidget(del_btn, 0, Qt.AlignVCenter)\n card_lo.addLayout(r1)\n\n # ── 第二行:描述 ──\n desc = p.get(\"desc\", \"\")\n if desc:\n dl = QLabel(desc)\n dl.setStyleSheet(f\"font-size:11px;color:{c['t3']};padding-left:32px;\")\n card_lo.addWidget(dl)\n\n # ── 第三行:Prompt 编辑 ──\n if tp == \"summary\":\n hl = QLabel(\"🔧 内置摘要引擎,自动分析论文结构并生成结构化摘要\")\n hl.setStyleSheet(\n f\"font-size:11px;color:{c['t3']};padding:6px 10px;\"\n f\"background:{c['bg2']};border-radius:6px;\")\n hl.setWordWrap(True)\n card_lo.addWidget(hl)\n prompt_inp = None\n else:\n prompt_inp = QPlainTextEdit()\n prompt_inp.setPlainText(p.get(\"prompt\", \"\"))\n prompt_inp.setPlaceholderText(\"输入提示词…\\n\\n描述你希望 AI 对论文做什么分析\")\n prompt_inp.setMinimumHeight(90)\n prompt_inp.setMaximumHeight(200)\n card_lo.addWidget(prompt_inp)\n\n parent_layout.addWidget(card)\n entry = {\"cb\": cb, \"name\": name_inp, \"prompt\": prompt_inp,\n \"id\": pid, \"type\": tp, \"card\": card}\n _cards.append(entry)\n\n # 删除\n def _del(_, _e=entry):\n _e[\"card\"].setVisible(False)\n _e[\"card\"].deleteLater()\n _e[\"deleted\"] = True\n del_btn.clicked.connect(_del)\n\n # 勾选约束:最多 3 个\n def _on_check(state, _e=entry):\n checked = [e for e in _cards\n if not e.get(\"deleted\") and e[\"cb\"].isChecked()]\n if len(checked) > 3:\n _e[\"cb\"].setChecked(False)\n cb.stateChanged.connect(_on_check)\n\n for p in self._library:\n _make_card(p, clo)\n\n scroll.setWidget(container)\n dlo.addWidget(scroll, 1)\n\n # ── 底部按钮区 ──\n blo = QHBoxLayout(); blo.setSpacing(8)\n # 新建\n add_btn = QPushButton(\"+ 新建\")\n add_btn.setCursor(Qt.PointingHandCursor)\n add_btn.setStyleSheet(\n f\"QPushButton{{background:{c['acc_l']};border:none;border-radius:8px;\"\n f\"padding:8px 14px;font-size:12px;color:{c['acc']};}}\"\n f\"QPushButton:hover{{background:{c['acc']};color:white;}}\")\n _counter = [0]\n def _on_add():\n _counter[0] += 1\n new_p = {\"id\": f\"custom_{_counter[0]}_{id(dlg)}\",\n \"label\": \"🔧 自定义\", \"prompt\": \"\", \"desc\": \"自定义提示词\"}\n _make_card(new_p, clo)\n QTimer.singleShot(50, lambda: scroll.verticalScrollBar().setValue(\n scroll.verticalScrollBar().maximum()))\n add_btn.clicked.connect(_on_add)\n blo.addWidget(add_btn)\n\n # 导入\n imp_btn = QPushButton(\"📥 导入\")\n imp_btn.setCursor(Qt.PointingHandCursor)\n imp_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:1px solid {c['inp_b']};\"\n f\"border-radius:8px;padding:8px 14px;font-size:12px;color:{c['t2']};}}\"\n f\"QPushButton:hover{{border-color:{c['acc']};color:{c['acc']};}}\")\n def _on_import():\n path, _ = QFileDialog.getOpenFileName(\n dlg, \"导入提示词\",\n str(Path.home()),\n \"文本文件 (*.txt *.md);;JSON (*.json);;所有文件 (*)\")\n if not path:\n return\n try:\n content = Path(path).read_text(encoding=\"utf-8\")\n fname = Path(path).stem\n # 尝试 JSON\n try:\n data = json.loads(content)\n items = data if isinstance(data, list) else [data]\n for item in items:\n _counter[0] += 1\n new_p = {\n \"id\": f\"import_{_counter[0]}_{id(dlg)}\",\n \"label\": item.get(\"label\", f\"📥 {fname}\"),\n \"prompt\": item.get(\"prompt\", content),\n \"desc\": item.get(\"desc\", \"导入的提示词\"),\n }\n _make_card(new_p, clo)\n except (json.JSONDecodeError, AttributeError):\n # 纯文本 → 整个文件内容作为 prompt\n _counter[0] += 1\n new_p = {\n \"id\": f\"import_{_counter[0]}_{id(dlg)}\",\n \"label\": f\"📥 {fname[:8]}\",\n \"prompt\": content,\n \"desc\": \"导入的提示词\",\n }\n _make_card(new_p, clo)\n QTimer.singleShot(50, lambda: scroll.verticalScrollBar().setValue(\n scroll.verticalScrollBar().maximum()))\n except Exception as e:\n QMessageBox.warning(dlg, \"导入失败\", str(e))\n imp_btn.clicked.connect(_on_import)\n blo.addWidget(imp_btn)\n\n # 恢复默认\n reset_btn = QPushButton(\"恢复默认\")\n reset_btn.setCursor(Qt.PointingHandCursor)\n reset_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:1px solid {c['inp_b']};\"\n f\"border-radius:8px;padding:8px 14px;font-size:12px;color:{c['t2']};}}\"\n f\"QPushButton:hover{{border-color:{c['acc']};color:{c['acc']};}}\")\n def _on_reset():\n for entry in _cards:\n entry[\"deleted\"] = True\n entry[\"card\"].setVisible(False)\n entry[\"card\"].deleteLater()\n _cards.clear()\n for p in _BUILTIN_PROMPTS:\n _make_card(dict(p), clo)\n for entry in _cards:\n entry[\"cb\"].setChecked(entry[\"id\"] in _DEFAULT_ACTIVE_IDS)\n reset_btn.clicked.connect(_on_reset)\n blo.addWidget(reset_btn)\n\n blo.addStretch()\n ok_btn = QPushButton(\"保存\")\n ok_btn.setCursor(Qt.PointingHandCursor)\n ok_btn.setStyleSheet(\n f\"QPushButton{{background:{c['acc']};color:white;border:none;\"\n f\"font-size:13px;padding:8px 30px;border-radius:8px;}}\"\n f\"QPushButton:hover{{background:{c['acc_h']};}}\")\n ok_btn.clicked.connect(dlg.accept)\n blo.addWidget(ok_btn)\n dlo.addLayout(blo)\n\n # ── 执行弹窗 ──\n if dlg.exec_() == QDialog.Accepted:\n new_library = []\n new_active = []\n for entry in _cards:\n if entry.get(\"deleted\"):\n continue\n try:\n label = entry[\"name\"].text().strip()\n except RuntimeError:\n continue\n if not label:\n continue\n p = {\"id\": entry[\"id\"], \"label\": label, \"type\": entry[\"type\"]}\n p[\"desc\"] = next((bp.get(\"desc\", \"\") for bp in _BUILTIN_PROMPTS\n if bp[\"id\"] == entry[\"id\"]), \"自定义提示词\")\n if entry[\"type\"] == \"summary\":\n p[\"prompt\"] = \"\"\n else:\n p[\"prompt\"] = entry[\"prompt\"].toPlainText() if entry[\"prompt\"] else \"\"\n new_library.append(p)\n if entry[\"cb\"].isChecked():\n new_active.append(entry[\"id\"])\n if new_active:\n self._library = new_library\n self._active_ids = new_active[:3]\n self._save_library()\n self._rebuild_quick_bar()\n\n # ══════════════════════════════════════════════════════════════\n # 论文上下文 & 聊天\n # ══════════════════════════════════════════════════════════════\n\n def set_paper_context(self, pdf_path):\n \"\"\"提取 PDF 文本作为问答上下文\"\"\"\n self._pdf_path = pdf_path\n try:\n doc = fitz.open(pdf_path)\n text = \"\"\n for i in range(min(15, len(doc))):\n text += doc[i].get_text()\n doc.close()\n self._paper_text = text[:10000]\n except Exception:\n self._paper_text = \"\"\n self._messages = []\n self._clear()\n try:\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n self._svc_label.setText(f\"via {svc['name']} · {svc['model']}\" if svc else \"未配置\")\n self._send_btn.setEnabled(bool(svc))\n except Exception:\n pass\n\n def _clear(self):\n try:\n self._messages = []\n self._current_ai_bubble = None\n self._stop_workers()\n while self._chat_layout.count():\n w = self._chat_layout.takeAt(0).widget()\n if w:\n w.deleteLater()\n except Exception:\n pass\n\n def _add_bubble(self, text, is_user=True):\n \"\"\"添加消息气泡\"\"\"\n row = QWidget(); row.setStyleSheet(\"background:transparent;\")\n row_lo = QHBoxLayout(row)\n row_lo.setContentsMargins(0, 0, 0, 0); row_lo.setSpacing(0)\n bubble = QLabel()\n bubble.setWordWrap(True)\n bubble.setTextInteractionFlags(Qt.TextSelectableByMouse)\n bubble.setMaximumWidth(300)\n c = _C\n if is_user:\n bubble.setText(text)\n bubble.setStyleSheet(\n f\"background:{c['acc']};color:white;border-radius:12px;\"\n f\"border-bottom-right-radius:4px;\"\n f\"padding:8px 12px;font-size:12px;\"\n f\"font-family:'Helvetica Neue','PingFang SC';\")\n row_lo.addStretch(); row_lo.addWidget(bubble)\n else:\n bubble.setTextFormat(Qt.RichText)\n html = _md2html(text) if text and not text.startswith('⏳') else text\n bubble.setText(html)\n bubble.setStyleSheet(\n f\"background:{c['card']};border:0.5px solid {c['card_b']};\"\n f\"border-radius:12px;border-top-left-radius:4px;\"\n f\"padding:10px 14px;font-size:12px;line-height:165%;\"\n f\"font-family:'Helvetica Neue','PingFang SC';\")\n row_lo.addWidget(bubble); row_lo.addStretch()\n self._chat_layout.addWidget(row)\n QTimer.singleShot(50, lambda: self._safe_scroll_bottom())\n return bubble\n\n def _safe_scroll_bottom(self):\n try:\n sb = self._chat_scroll.verticalScrollBar()\n sb.setValue(sb.maximum())\n except Exception:\n pass\n\n # ══════════════════════════════════════════════════════════════\n # AI Worker 管理(摘要 / 问答 / 流式)\n # ══════════════════════════════════════════════════════════════\n\n def _set_busy(self, busy):\n self._busy = busy\n try:\n self._send_btn.setEnabled(not busy)\n for b in self._quick_btns:\n b.setEnabled(not busy)\n except Exception:\n pass\n\n def _run_summary(self):\n \"\"\"使用 SummaryWorker 生成结构化摘要\"\"\"\n try:\n if not self._pdf_path or not os.path.exists(self._pdf_path):\n return\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if not svc:\n self._add_bubble(\"⚠️ 未配置 AI 服务\", is_user=False)\n return\n self._stop_workers()\n self._set_busy(True)\n self._add_bubble(\"📝 生成摘要\", is_user=True)\n self._current_ai_bubble = self._add_bubble(\"⏳ 正在分析论文…\", is_user=False)\n self._summary_worker = SummaryWorker(self._pdf_path)\n self._summary_worker.result.connect(self._on_summary_result)\n self._summary_worker.error.connect(self._on_qa_error)\n self._summary_worker.finished.connect(self._on_summary_done)\n self._summary_worker.start()\n except Exception:\n self._set_busy(False)\n\n def _run_preset(self, prompt, label=\"\"):\n \"\"\"用预设 prompt 发起问答\"\"\"\n try:\n if not self._paper_text:\n return\n from ui.ai_client import detect_assistant_service\n svc = detect_assistant_service()\n if not svc:\n self._add_bubble(\"⚠️ 未配置 AI 服务\", is_user=False)\n return\n self._stop_workers()\n self._set_busy(True)\n self._add_bubble(label or (prompt[:15] + \"…\"), is_user=True)\n self._messages = [\n {\"role\": \"system\", \"content\": (\n \"你是论文阅读助手。以下是论文全文内容(可能被截断),请根据论文内容回答用户问题。\\n\"\n \"回答要准确、有深度,用中文。使用 Markdown 格式化输出。\\n\"\n \"如果论文中没有相关信息,请如实说明。\\n\\n\"\n f\"论文内容:\\n{self._paper_text}\"\n )},\n {\"role\": \"user\", \"content\": prompt},\n ]\n self._current_ai_bubble = self._add_bubble(\"⏳ 思考中…\", is_user=False)\n self._worker = QAWorker(list(self._messages))\n self._worker.chunk.connect(self._on_chunk)\n self._worker.finished.connect(self._on_qa_done)\n self._worker.error.connect(self._on_qa_error)\n self._worker.start()\n self._streaming_text = \"\"\n except Exception:\n self._set_busy(False)\n\n def _stop_workers(self):\n \"\"\"断开并停止所有 AI worker\"\"\"\n for attr in ('_worker', '_summary_worker'):\n w = getattr(self, attr, None)\n if w:\n for sig in ('chunk', 'result', 'finished', 'error'):\n try:\n getattr(w, sig).disconnect()\n except Exception:\n pass\n setattr(self, attr, None)\n try:\n w.quit()\n except Exception:\n pass\n\n @staticmethod\n def _widget_alive(w):\n try:\n w.objectName()\n return True\n except RuntimeError:\n return False\n\n def _on_summary_result(self, text):\n try:\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(_md2html(text))\n QTimer.singleShot(0, self._safe_scroll_bottom)\n except Exception:\n pass\n\n def _on_summary_done(self):\n try:\n self._set_busy(False)\n self._current_ai_bubble = None\n if self._summary_worker:\n w = self._summary_worker; self._summary_worker = None\n w.quit()\n except Exception:\n self._set_busy(False)\n\n def _on_chunk(self, text):\n try:\n self._streaming_text += text\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(_md2html(self._streaming_text))\n QTimer.singleShot(0, self._safe_scroll_bottom)\n except Exception:\n pass\n\n def _on_qa_done(self, full_text):\n try:\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(_md2html(full_text))\n self._messages.append({\"role\": \"assistant\", \"content\": full_text})\n self._set_busy(False)\n self._current_ai_bubble = None\n if self._worker:\n w = self._worker; self._worker = None\n w.quit()\n except Exception:\n self._set_busy(False)\n\n def _on_qa_error(self, err):\n try:\n if self._current_ai_bubble and self._widget_alive(self._current_ai_bubble):\n self._current_ai_bubble.setText(f\"⚠️ {err}\")\n self._set_busy(False)\n self._current_ai_bubble = None\n for attr in ('_worker', '_summary_worker'):\n w = getattr(self, attr, None)\n if w:\n setattr(self, attr, None)\n w.quit()\n except Exception:\n self._set_busy(False)\n\n def _send(self):\n try:\n q = self._input.text().strip()\n if not q or not self._paper_text or self._busy:\n return\n self._stop_workers()\n self._input.clear()\n self._set_busy(True)\n self._add_bubble(q, is_user=True)\n if not self._messages:\n self._messages.append({\n \"role\": \"system\",\n \"content\": (\n \"你是论文阅读助手。以下是论文全文内容(可能被截断),请根据论文内容回答用户问题。\\n\"\n \"回答要准确、有深度,用中文。使用 Markdown 格式化输出。\\n\"\n \"如果论文中没有相关信息,请如实说明。\\n\\n\"\n f\"论文内容:\\n{self._paper_text}\"\n )\n })\n self._messages.append({\"role\": \"user\", \"content\": q})\n self._current_ai_bubble = self._add_bubble(\"⏳ 思考中…\", is_user=False)\n self._worker = QAWorker(list(self._messages))\n self._worker.chunk.connect(self._on_chunk)\n self._worker.finished.connect(self._on_qa_done)\n self._worker.error.connect(self._on_qa_error)\n self._worker.start()\n self._streaming_text = \"\"\n except Exception:\n self._set_busy(False)\n\n # ══════════════════════════════════════════════════════════════\n # 主题切换\n # ══════════════════════════════════════════════════════════════\n\n def update_theme(self, c):\n try:\n self._svc_label.setStyleSheet(f\"font-size:10px;color:{c['t3']};background:transparent;\")\n self._inp_frame.setStyleSheet(\n f\"QFrame{{background:{c['card']};border:0.5px solid {c['brd']};\"\n f\"border-radius:12px;}}\")\n self._quick_frame.setStyleSheet(\"QFrame{background:transparent;border:none;}\")\n self._rebuild_quick_bar()\n self._cfg_btn.setStyleSheet(\n f\"QPushButton{{background:transparent;border:none;font-size:13px;\"\n f\"padding:2px 5px;color:{c['t3']};}}\"\n f\"QPushButton:hover{{color:{c['acc']};}}\")\n except Exception:\n pass\n\n\nclass PreviewPage(QWidget):\n \"\"\"三合一预览: 顶部模式切换按钮 + PDF 显示\"\"\"\n fullscreen_toggled = pyqtSignal(bool)\n history_toggled = pyqtSignal()\n\n def __init__(self):\n super().__init__()\n self.doc = None\n self.current_page = 0\n self.zoom = 1.5\n self.output_files = {}\n self.current_mode = \"side_by_side\" # 默认 Side by Side\n self.setFocusPolicy(Qt.StrongFocus) # 接收键盘事件\n\n lo = QVBoxLayout(self); lo.setContentsMargins(0,0,0,0); lo.setSpacing(0)\n\n # 工具栏\n tb = QWidget(); tb.setObjectName(\"PT\"); tb.setFixedHeight(48)\n tl = QHBoxLayout(tb); tl.setContentsMargins(12,0,12,0); tl.setSpacing(6)\n\n # 模式切换按钮\n self.mode_btns = {}\n for label, mode in [(\"Dual\",\"dual\"),(\"Mono\",\"mono\"),(\"Side by Side\",\"side_by_side\")]:\n b = QPushButton(label); b.setObjectName(\"TB\"); b.setCursor(Qt.PointingHandCursor)\n b.setProperty(\"active\", mode == \"side_by_side\")\n b.clicked.connect(lambda _, m=mode: self.switch_mode(m))\n tl.addWidget(b); self.mode_btns[mode] = b\n\n tl.addSpacing(8)\n # 连续滚动 / 单页 切换\n self._continuous = True\n self.scroll_btn = QPushButton(\"连续\"); self.scroll_btn.setObjectName(\"TB\")\n self.scroll_btn.setCursor(Qt.PointingHandCursor)\n self.scroll_btn.setProperty(\"active\", True)\n self.scroll_btn.clicked.connect(self._toggle_continuous)\n tl.addWidget(self.scroll_btn)\n\n tl.addSpacing(12)\n ob = QPushButton(\"打开\"); ob.setObjectName(\"Sc\"); ob.setCursor(Qt.PointingHandCursor)\n ob.setStyleSheet(\"padding:6px 14px;font-size:13px;\")\n ob.clicked.connect(self.open_file); tl.addWidget(ob)\n\n tl.addStretch()\n\n # 翻页:首页 < 上一页 | 页码输入 / 总页数 | 下一页 > 末页\n first_btn = QPushButton(\"⟨⟨\"); first_btn.setObjectName(\"TB\"); first_btn.setFixedWidth(32)\n first_btn.setCursor(Qt.PointingHandCursor)\n first_btn.clicked.connect(self.first_page); tl.addWidget(first_btn)\n\n prev_btn = QPushButton(\"‹\"); prev_btn.setObjectName(\"TB\"); prev_btn.setFixedWidth(32)\n prev_btn.setCursor(Qt.PointingHandCursor)\n prev_btn.clicked.connect(self.prev_page); tl.addWidget(prev_btn)\n\n # 页码输入框\n self.page_input = QLineEdit(\"1\")\n self.page_input.setFixedWidth(42)\n self.page_input.setAlignment(Qt.AlignCenter)\n self.page_input.setObjectName(\"PageInput\")\n self.page_input.returnPressed.connect(self._jump_to_page)\n tl.addWidget(self.page_input)\n\n self.total_label = QLabel(\"/ —\")\n self.total_label.setObjectName(\"TotalLbl\")\n tl.addWidget(self.total_label)\n\n next_btn = QPushButton(\"›\"); next_btn.setObjectName(\"TB\"); next_btn.setFixedWidth(32)\n next_btn.setCursor(Qt.PointingHandCursor)\n next_btn.clicked.connect(self.next_page); tl.addWidget(next_btn)\n\n last_btn = QPushButton(\"⟩⟩\"); last_btn.setObjectName(\"TB\"); last_btn.setFixedWidth(32)\n last_btn.setCursor(Qt.PointingHandCursor)\n last_btn.clicked.connect(self.last_page); tl.addWidget(last_btn)\n\n tl.addStretch()\n\n # 缩放\n self.zoom_slider = QSlider(Qt.Horizontal)\n self.zoom_slider.setRange(30,400); self.zoom_slider.setValue(150)\n self.zoom_slider.setFixedWidth(90); self.zoom_slider.valueChanged.connect(self.on_zoom)\n tl.addWidget(self.zoom_slider)\n self.zoom_pct = QLabel(\"150%\")\n self.zoom_pct.setObjectName(\"ZoomPct\")\n self.zoom_pct.setFixedWidth(36)\n tl.addWidget(self.zoom_pct)\n # 适配模式:适宽 / 适页 两个按钮\n self._fit_mode = \"width\" # \"width\" 适宽 | \"page\" 适页\n self.fit_btn = QPushButton(\"适宽\"); self.fit_btn.setObjectName(\"TB\")\n self.fit_btn.setCursor(Qt.PointingHandCursor)\n self.fit_btn.setProperty(\"active\", True)\n self.fit_btn.clicked.connect(self._switch_to_fit_width)\n tl.addWidget(self.fit_btn)\n self.fitpage_btn = QPushButton(\"适页\"); self.fitpage_btn.setObjectName(\"TB\")\n self.fitpage_btn.setCursor(Qt.PointingHandCursor)\n self.fitpage_btn.clicked.connect(self._switch_to_fit_page)\n tl.addWidget(self.fitpage_btn)\n\n tl.addSpacing(6)\n self._is_fullscreen = False\n # 全屏中唤出历史面板\n self.hist_btn = QPushButton(\"历史\"); self.hist_btn.setObjectName(\"TB\")\n self.hist_btn.setCursor(Qt.PointingHandCursor)\n self.hist_btn.setVisible(False)\n self.hist_btn.clicked.connect(self.history_toggled.emit)\n tl.addWidget(self.hist_btn)\n # AI 问答按钮\n self.qa_btn = QPushButton(\"AI 问答\"); self.qa_btn.setObjectName(\"TB\")\n self.qa_btn.setCursor(Qt.PointingHandCursor)\n self.qa_btn.clicked.connect(self._toggle_qa_panel)\n tl.addWidget(self.qa_btn)\n # 高亮按钮\n self.hl_btn = QPushButton(\"高亮\"); self.hl_btn.setObjectName(\"TB\")\n self.hl_btn.setCursor(Qt.PointingHandCursor)\n self.hl_btn.setCheckable(True)\n self.hl_btn.clicked.connect(self._toggle_highlight)\n tl.addWidget(self.hl_btn)\n # 擦除按钮\n self.hl_erase_btn = QPushButton(\"擦除\"); self.hl_erase_btn.setObjectName(\"TB\")\n self.hl_erase_btn.setCursor(Qt.PointingHandCursor)\n self.hl_erase_btn.setCheckable(True)\n self.hl_erase_btn.clicked.connect(self._toggle_erase)\n self.hl_erase_btn.setVisible(False) # 进入高亮模式才显示\n tl.addWidget(self.hl_erase_btn)\n # 全屏按钮\n self.fs_btn = QPushButton(\"全屏\"); self.fs_btn.setObjectName(\"TB\")\n self.fs_btn.setCursor(Qt.PointingHandCursor)\n self.fs_btn.clicked.connect(self._toggle_fullscreen)\n tl.addWidget(self.fs_btn)\n\n lo.addWidget(tb)\n\n # 内容区:缩略图 + 主视图(QSplitter 支持拖拽调整缩略图宽度)\n self.body_splitter = QSplitter(Qt.Horizontal)\n self.body_splitter.setHandleWidth(1)\n self.body_splitter.setChildrenCollapsible(True)\n\n # 缩略图面板\n self.thumb_scroll = QScrollArea()\n self.thumb_scroll.setMinimumWidth(60); self.thumb_scroll.setMaximumWidth(240)\n self.thumb_scroll.setWidgetResizable(True); self.thumb_scroll.setFrameShape(QFrame.NoFrame)\n self.thumb_scroll.setObjectName(\"PA\"); self.thumb_scroll.setFocusPolicy(Qt.StrongFocus)\n self.thumb_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self.thumb_container = QWidget(); self.thumb_container.setObjectName(\"PA\")\n self.thumb_layout = QVBoxLayout(self.thumb_container)\n self.thumb_layout.setContentsMargins(4,6,4,6); self.thumb_layout.setSpacing(4)\n self.thumb_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)\n self.thumb_scroll.setWidget(self.thumb_container)\n self.thumb_scroll.setVisible(False)\n self.body_splitter.addWidget(self.thumb_scroll)\n\n # 主视图容器\n self._main_view = QWidget(); self._main_view.setObjectName(\"PA\")\n _mv_lo = QHBoxLayout(self._main_view)\n _mv_lo.setContentsMargins(0,0,0,0); _mv_lo.setSpacing(0)\n\n # 主视图 — 单页模式\n self.scroll = QScrollArea(); self.scroll.setWidgetResizable(True)\n self.scroll.setAlignment(Qt.AlignCenter); self.scroll.setObjectName(\"PA\")\n self.page_widget = PDFPageWidget()\n self.page_widget.setStyleSheet(\"padding:2px;\")\n shadow = QGraphicsDropShadowEffect(); shadow.setBlurRadius(28); shadow.setOffset(0,4)\n shadow.setColor(QColor(0,0,0,30)); self.page_widget.setGraphicsEffect(shadow)\n self.scroll.setWidget(self.page_widget)\n self._highlight_mode = False\n self._hl_erase_mode = False\n self._hl_start = None\n self._hl_overlay = None\n self.scroll.setVisible(False) # 默认连续模式,隐藏单页\n _mv_lo.addWidget(self.scroll)\n\n # 主视图 — 连续滚动模式(默认显示)\n self.cont_scroll = QScrollArea(); self.cont_scroll.setWidgetResizable(True)\n self.cont_scroll.setObjectName(\"PA\"); self.cont_scroll.setVisible(True)\n self.cont_container = QWidget(); self.cont_container.setObjectName(\"PA\")\n self.cont_layout = QVBoxLayout(self.cont_container)\n self.cont_layout.setContentsMargins(0,0,0,0); self.cont_layout.setSpacing(4)\n self.cont_layout.setAlignment(Qt.AlignHCenter)\n self.cont_scroll.setWidget(self.cont_container)\n self.cont_scroll.verticalScrollBar().valueChanged.connect(self._on_cont_scroll)\n _mv_lo.addWidget(self.cont_scroll)\n self._cont_page_widgets = []\n\n self.body_splitter.addWidget(self._main_view)\n # AI 问答面板(右侧,默认隐藏)\n self.qa_panel = QAPanelWidget()\n self.qa_panel.setVisible(False)\n self.body_splitter.addWidget(self.qa_panel)\n self.body_splitter.setSizes([140, 800, 0])\n self.body_splitter.setCollapsible(0, True) # 缩略图可折叠\n self.body_splitter.setCollapsible(1, False)\n self.body_splitter.setCollapsible(2, True) # QA 面板可折叠\n # 拖拽缩略图面板宽度时自动重建缩略图\n self.body_splitter.splitterMoved.connect(self._on_thumb_panel_resized)\n self.body_widget = self.body_splitter # 兼容引用\n lo.addWidget(self.body_splitter)\n\n # 空状态\n self.empty = QWidget(); self.empty.setObjectName(\"PA\")\n el = QVBoxLayout(self.empty); el.setAlignment(Qt.AlignCenter); el.setSpacing(16)\n el.setContentsMargins(40, 40, 40, 40)\n ic = QLabel(); ic.setFixedSize(64, 64); ic.setAlignment(Qt.AlignCenter)\n ic.setStyleSheet(f\"background:{_C['acc_l']};border-radius:32px;font-size:28px;\")\n ic.setText(\"👀\"); self._empty_icon = ic; el.addWidget(ic, alignment=Qt.AlignCenter)\n m = QLabel(\"翻译完成后在此预览\"); m.setStyleSheet(f\"font-size:15px;font-weight:600;color:{_C['t1']};\"); m.setAlignment(Qt.AlignCenter); el.addWidget(m)\n s = QLabel(\"支持 Dual · Mono · Side by Side 三种模式切换\"); s.setObjectName(\"Cap\"); s.setAlignment(Qt.AlignCenter); el.addWidget(s)\n s2 = QLabel(\"← 也可点击左侧历史记录直接打开\"); s2.setStyleSheet(f\"font-size:11px;color:{_C['t3']};\"); s2.setAlignment(Qt.AlignCenter); el.addWidget(s2)\n self.body_widget.setVisible(False); lo.addWidget(self.empty)\n self._thumb_labels = []\n\n # 滚轮缩放\n self.scroll.wheelEvent = self._wheel_event\n self.cont_scroll.wheelEvent = self._wheel_event\n # macOS 触控板双指缩放:用独立 QObject 做全局 eventFilter(安全)\n self._pinch_accumulator = 0.0\n self._pinch_filter = _PinchFilter(self)\n app = QApplication.instance()\n if app:\n app.installEventFilter(self._pinch_filter)\n\n def _apply_zoom(self, new_zoom):\n \"\"\"统一缩放处理\"\"\"\n self.zoom = new_zoom\n slider_val = int(new_zoom * 100)\n self.zoom_slider.blockSignals(True)\n self.zoom_slider.setValue(min(slider_val, 400))\n self.zoom_slider.blockSignals(False)\n self.zoom_pct.setText(f\"{slider_val}%\")\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n\n def _reset_fit(self):\n \"\"\"还原到当前适配模式的最佳贴合\"\"\"\n if self.doc:\n if self._fit_mode == \"page\":\n self._fit_page()\n else:\n self._fit_width()\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n\n def _wheel_event(self, event):\n \"\"\"滚轮翻页 + Ctrl+滚轮缩放\"\"\"\n mods = event.modifiers()\n\n # Ctrl + 滚轮 = 缩放\n if mods & Qt.ControlModifier:\n delta = event.angleDelta().y()\n step = 0.1 if delta > 0 else -0.1\n self._apply_zoom(max(0.5, min(self.zoom + step, 4.0)))\n return\n\n # 连续滚动模式:自然滚动\n if self._continuous:\n target = self.cont_scroll\n QScrollArea.wheelEvent(target, event)\n return\n\n # 单页模式:到边界翻页\n sb = self.scroll.verticalScrollBar()\n delta = event.angleDelta().y()\n at_bottom = sb.value() >= sb.maximum()\n at_top = sb.value() <= sb.minimum()\n\n if delta < 0 and at_bottom:\n self.next_page()\n elif delta > 0 and at_top:\n self.prev_page()\n else:\n QScrollArea.wheelEvent(self.scroll, event)\n\n def set_output_files(self, files: dict):\n \"\"\"翻译完成后设置三种文件路径\"\"\"\n self.output_files = files\n self.switch_mode(self.current_mode)\n\n def switch_mode(self, mode):\n self.current_mode = mode\n for m, b in self.mode_btns.items():\n b.setProperty(\"active\", m == mode)\n b.style().unpolish(b); b.style().polish(b)\n\n path = self.output_files.get(mode, \"\")\n if path and os.path.exists(path):\n self.load_pdf(path)\n return\n if self.output_files:\n # 回退到有文件的模式\n for fallback in [\"dual\", \"mono\", \"side_by_side\"]:\n fp = self.output_files.get(fallback, \"\")\n if fp and os.path.exists(fp):\n self.load_pdf(fp); break\n\n def load_pdf(self, path):\n try:\n same_file = (path == getattr(self, '_loaded_path', None) and self.doc)\n if not same_file:\n self._loaded_path = path\n self.doc = fitz.open(path)\n self.current_page = 0\n self._last_thumb_vp_w = -1 # 强制首次重建缩略图\n self._last_fit_zoom = -1 # 强制首次渲染\n self.empty.setVisible(False)\n self.body_widget.setVisible(True)\n self.thumb_scroll.setVisible(True)\n # 视口已就绪 → 立即贴合渲染(zoom 未变则跳过)\n vp = self.scroll.viewport() if not self._continuous else self.cont_scroll.viewport()\n if vp.width() >= 100:\n self._fit_and_render()\n else:\n QTimer.singleShot(0, self._fit_and_render)\n # 设置 AI 面板上下文\n if not same_file:\n self.qa_panel.set_paper_context(path)\n except Exception as e:\n self.page_widget.setText(f\"加载失败: {e}\")\n\n def _toggle_qa_panel(self):\n \"\"\"切换 AI 问答面板 — 即时更新布局\"\"\"\n vis = not self.qa_panel.isVisible()\n self.qa_panel.setVisible(vis)\n self.qa_btn.setProperty(\"active\", vis)\n self.qa_btn.style().unpolish(self.qa_btn); self.qa_btn.style().polish(self.qa_btn)\n if vis:\n sizes = self.body_splitter.sizes()\n self.body_splitter.setSizes([sizes[0], max(sizes[1] - 300, 400), 300])\n else:\n sizes = self.body_splitter.sizes()\n self.body_splitter.setSizes([sizes[0], sizes[1] + sizes[2], 0])\n # 立即重新适配渲染,确保 PDF 视图即时伸缩\n QTimer.singleShot(0, self._fit_and_render)\n\n def _toggle_highlight(self):\n \"\"\"切换高亮标注模式\"\"\"\n self._highlight_mode = self.hl_btn.isChecked()\n self._hl_erase_mode = False\n self.hl_btn.setProperty(\"active\", self._highlight_mode)\n self.hl_btn.style().unpolish(self.hl_btn); self.hl_btn.style().polish(self.hl_btn)\n # 显示/隐藏擦除按钮\n self.hl_erase_btn.setVisible(self._highlight_mode)\n if not self._highlight_mode:\n self.hl_erase_btn.setChecked(False)\n self.hl_erase_btn.setProperty(\"active\", False)\n self.hl_erase_btn.style().unpolish(self.hl_erase_btn)\n self.hl_erase_btn.style().polish(self.hl_erase_btn)\n cursor = Qt.CrossCursor if self._highlight_mode else Qt.ArrowCursor\n if self._continuous:\n for w in self._cont_page_widgets:\n w.setCursor(cursor)\n else:\n self.page_widget.setCursor(cursor)\n\n def _toggle_erase(self):\n \"\"\"切换擦除模式(高亮模式的子模式)\"\"\"\n self._hl_erase_mode = self.hl_erase_btn.isChecked()\n self.hl_erase_btn.setProperty(\"active\", self._hl_erase_mode)\n self.hl_erase_btn.style().unpolish(self.hl_erase_btn)\n self.hl_erase_btn.style().polish(self.hl_erase_btn)\n\n # ── 高亮标注 ──\n\n def _hl_mouse_press(self, e, page_idx, label):\n if not getattr(self, '_highlight_mode', False):\n return\n if e.button() == Qt.LeftButton:\n self._hl_start = e.pos()\n self._hl_page_idx = page_idx\n self._hl_label = label\n # 擦除模式:按了擦除按钮 或 按住 Option 键\n is_erase = getattr(self, '_hl_erase_mode', False) or (e.modifiers() & Qt.AltModifier)\n self._hl_dragging_delete = bool(is_erase)\n if is_erase:\n # 红色半透明删除覆盖层\n if not hasattr(self, '_hl_del_overlay') or self._hl_del_overlay is None:\n self._hl_del_overlay = QWidget(label)\n self._hl_del_overlay.setStyleSheet(\n \"background:rgba(255,59,48,0.18);border:1.5px solid rgba(255,59,48,0.6);border-radius:3px;\")\n from PyQt5.QtCore import QRect\n self._hl_del_overlay.setGeometry(QRect(self._hl_start, QSize()))\n self._hl_del_overlay.show()\n self._hl_del_overlay.raise_()\n else:\n # 蓝色添加高亮覆盖层\n if not hasattr(self, '_hl_overlay') or self._hl_overlay is None:\n from PyQt5.QtWidgets import QRubberBand\n self._hl_overlay = QRubberBand(QRubberBand.Rectangle, label)\n from PyQt5.QtCore import QRect\n self._hl_overlay.setGeometry(QRect(self._hl_start, QSize()))\n self._hl_overlay.show()\n\n def _hl_mouse_move(self, e, label):\n if not getattr(self, '_highlight_mode', False) or not hasattr(self, '_hl_start'):\n return\n if self._hl_start is None:\n return\n from PyQt5.QtCore import QRect\n if getattr(self, '_hl_dragging_delete', False):\n if hasattr(self, '_hl_del_overlay') and self._hl_del_overlay:\n self._hl_del_overlay.setGeometry(QRect(self._hl_start, e.pos()).normalized())\n elif hasattr(self, '_hl_overlay') and self._hl_overlay:\n self._hl_overlay.setGeometry(QRect(self._hl_start, e.pos()).normalized())\n\n def _hl_mouse_release(self, e, page_idx, label):\n if not getattr(self, '_highlight_mode', False) or not hasattr(self, '_hl_start'):\n return\n try:\n # 隐藏覆盖层\n if hasattr(self, '_hl_overlay') and self._hl_overlay:\n self._hl_overlay.hide()\n if hasattr(self, '_hl_del_overlay') and self._hl_del_overlay:\n self._hl_del_overlay.hide()\n\n end = e.pos()\n start = self._hl_start\n is_delete = getattr(self, '_hl_dragging_delete', False)\n self._hl_start = None\n self._hl_dragging_delete = False\n\n if not self.doc:\n return\n\n is_small = abs(end.x() - start.x()) < 8 and abs(end.y() - start.y()) < 8\n\n if is_delete:\n # 擦除模式\n if is_small:\n # 小范围点击 → 删除点击处的高亮\n self._hl_delete_at(page_idx, end)\n else:\n # 大范围框选 → 批量删除\n self._hl_delete_in_rect(page_idx, start, end)\n return\n\n # 左键:添加高亮\n if is_small:\n return\n # 屏幕坐标 → PDF 坐标\n x0 = min(start.x(), end.x()) / self.zoom\n y0 = min(start.y(), end.y()) / self.zoom\n x1 = max(start.x(), end.x()) / self.zoom\n y1 = max(start.y(), end.y()) / self.zoom\n sel_rect = fitz.Rect(x0, y0, x1, y1)\n page = self.doc[page_idx]\n # 在选区内查找文字,按文字位置创建高亮\n words = page.get_text(\"words\")\n quads = []\n for w in words:\n wr = fitz.Rect(w[:4])\n if sel_rect.intersects(wr):\n quads.append(wr.quad)\n if not quads:\n return # 选区内无文字(如图片 PDF),不添加\n annot = page.add_highlight_annot(quads=quads)\n annot.set_colors(stroke=(1, 0.85, 0.2))\n try:\n annot.set_opacity(0.35)\n except AttributeError:\n pass # 旧版 PyMuPDF 无此方法\n annot.update()\n self._hl_save_and_render(page_idx)\n except Exception:\n pass # 防止任何异常导致闪退\n\n def _hl_delete_in_rect(self, page_idx, start, end):\n \"\"\"批量删除框选区域内的所有高亮\"\"\"\n try:\n if not self.doc:\n return\n x0 = min(start.x(), end.x()) / self.zoom\n y0 = min(start.y(), end.y()) / self.zoom\n x1 = max(start.x(), end.x()) / self.zoom\n y1 = max(start.y(), end.y()) / self.zoom\n sel_rect = fitz.Rect(x0, y0, x1, y1)\n page = self.doc[page_idx]\n annots = page.annots()\n if not annots:\n return\n # 收集要删除的高亮(不能边遍历边删除)\n to_del = [a for a in annots if a.type[0] == 8 and sel_rect.intersects(a.rect)]\n if not to_del:\n return\n for a in to_del:\n page.delete_annot(a)\n self._hl_save_and_render(page_idx)\n except Exception:\n pass\n\n def _hl_save_and_render(self, page_idx):\n \"\"\"高亮变更后:保存文件 + 重新渲染\"\"\"\n path = getattr(self, '_loaded_path', None)\n if path:\n try:\n self.doc.save(path, incremental=True, encryption=fitz.PDF_ENCRYPT_KEEP)\n except Exception:\n try:\n self.doc.save(path)\n except Exception:\n pass\n if self._continuous:\n self._render_single_cont_page(page_idx)\n else:\n self.render_page()\n\n def _hl_delete_at(self, page_idx, pos):\n \"\"\"删除点击位置的高亮注释\"\"\"\n try:\n if not self.doc:\n return\n page = self.doc[page_idx]\n px, py = pos.x() / self.zoom, pos.y() / self.zoom\n point = fitz.Point(px, py)\n annots = page.annots()\n if not annots:\n return\n for annot in annots:\n if annot.type[0] == 8 and annot.rect.contains(point):\n page.delete_annot(annot)\n self._hl_save_and_render(page_idx)\n return\n except Exception:\n pass\n\n def _render_single_cont_page(self, page_idx):\n \"\"\"重新渲染连续模式中的单个页面\"\"\"\n if page_idx < len(self._cont_page_widgets):\n label = self._cont_page_widgets[page_idx]\n dpr = label.devicePixelRatioF() if hasattr(label, 'devicePixelRatioF') else 1.0\n rz = self.zoom * dpr\n page = self.doc[page_idx]\n pix = page.get_pixmap(matrix=fitz.Matrix(rz, rz))\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n pm = QPixmap.fromImage(img)\n pm.setDevicePixelRatio(dpr)\n label.setPixmap(pm)\n label.setFixedSize(pm.size() / dpr)\n\n def _install_highlight_events(self, label, page_idx):\n \"\"\"给页面 widget 安装高亮鼠标事件\n 高亮模式:左键拖拽添加 | 擦除模式/Option+拖拽:框选批量删除\n \"\"\"\n orig_press = label.mousePressEvent\n orig_move = label.mouseMoveEvent\n orig_release = label.mouseReleaseEvent\n def _press(e):\n if getattr(self, '_highlight_mode', False):\n self._hl_mouse_press(e, page_idx, label)\n else:\n orig_press(e)\n def _move(e):\n if getattr(self, '_highlight_mode', False):\n self._hl_mouse_move(e, label)\n else:\n orig_move(e)\n def _release(e):\n if getattr(self, '_highlight_mode', False):\n self._hl_mouse_release(e, page_idx, label)\n else:\n orig_release(e)\n label.mousePressEvent = _press\n label.mouseMoveEvent = _move\n label.mouseReleaseEvent = _release\n\n def _hl_show_delete_menu(self, e, page_idx, label):\n \"\"\"右键菜单:删除点击位置的高亮\"\"\"\n if not self.doc:\n return\n page = self.doc[page_idx]\n px, py = e.pos().x() / self.zoom, e.pos().y() / self.zoom\n point = fitz.Point(px, py)\n # 检查是否有高亮在此位置\n found = False\n for annot in page.annots():\n if annot.type[0] == 8 and annot.rect.contains(point):\n found = True; break\n if not found:\n return\n menu = _RoundMenu(label)\n act = menu.addAction(\"删除高亮\")\n act.triggered.connect(lambda: self._hl_delete_at(page_idx, e.pos()))\n menu.exec_(label.mapToGlobal(e.pos()))\n\n def _fit_and_render(self):\n \"\"\"统一:计算贴合 + 渲染(只在尺寸真正变化时才重新渲染)\"\"\"\n if not self.doc:\n return\n # 缩略图面板宽度变了 → 重建缩略图\n cur_tw = self.thumb_scroll.viewport().width() if self.thumb_scroll.isVisible() else 0\n if cur_tw > 40 and cur_tw != getattr(self, '_last_thumb_vp_w', 0):\n self._last_thumb_vp_w = cur_tw\n self._build_thumbnails()\n # 计算 zoom,只有变了才重新渲染(避免重复工作)\n old_zoom = self.zoom\n if self._fit_mode == \"page\":\n self._fit_page()\n else:\n self._fit_width()\n if self.zoom != getattr(self, '_last_fit_zoom', -1):\n self._last_fit_zoom = self.zoom\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n\n def _build_thumbnails(self):\n \"\"\"生成所有页面的缩略图\"\"\"\n # 清空旧缩略图\n for lbl in self._thumb_labels:\n lbl.setParent(None)\n self._thumb_labels = []\n\n if not self.doc:\n return\n\n dpr = QApplication.instance().devicePixelRatio() if QApplication.instance() else 2.0\n # 缩略图宽度 = 面板宽度 - 左右 margin(4+4) - border(2+2) - 滚动条余量\n panel_w = self.thumb_scroll.viewport().width() if self.thumb_scroll.viewport().width() > 40 else self.thumb_scroll.width()\n thumb_w = max(50, panel_w - 12)\n\n for i in range(len(self.doc)):\n page = self.doc[i]\n # 计算缩略图缩放比例\n scale = (thumb_w * dpr) / page.rect.width\n mat = fitz.Matrix(scale, scale)\n pix = page.get_pixmap(matrix=mat, alpha=False)\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n qpx = QPixmap.fromImage(img)\n qpx.setDevicePixelRatio(dpr)\n\n lbl = QLabel()\n lbl.setPixmap(qpx)\n lbl.setFixedSize(int(pix.width / dpr) + 4, int(pix.height / dpr) + 4)\n lbl.setAlignment(Qt.AlignCenter)\n lbl.setStyleSheet(\n \"border:2px solid transparent;border-radius:4px;padding:1px;\"\n \"background:transparent;\"\n )\n lbl.setCursor(Qt.PointingHandCursor)\n # 点击跳转\n page_idx = i\n lbl.mousePressEvent = lambda e, idx=page_idx: self._thumb_clicked(idx)\n\n self.thumb_layout.addWidget(lbl, alignment=Qt.AlignHCenter)\n self._thumb_labels.append(lbl)\n\n self.thumb_layout.addStretch()\n self._highlight_thumb(0)\n\n def _thumb_clicked(self, idx):\n self.current_page = idx\n if self._continuous and idx < len(self._cont_page_widgets):\n self.cont_scroll.ensureWidgetVisible(self._cont_page_widgets[idx], 0, 0)\n self._update_page_display()\n self._highlight_thumb(idx)\n else:\n self.render_page()\n self.setFocus()\n\n def _highlight_thumb(self, idx):\n \"\"\"高亮当前页缩略图,颜色由 _thumb_color 控制\"\"\"\n color = getattr(self, '_thumb_color', _C['acc'])\n for i, lbl in enumerate(self._thumb_labels):\n if i == idx:\n lbl.setStyleSheet(\n f\"border:2px solid {color};border-radius:4px;padding:1px;\"\n \"background:transparent;\"\n )\n else:\n lbl.setStyleSheet(\n \"border:2px solid transparent;border-radius:4px;padding:1px;\"\n \"background:transparent;\"\n )\n if idx < len(self._thumb_labels):\n self.thumb_scroll.ensureWidgetVisible(self._thumb_labels[idx])\n\n def _fit_width(self):\n \"\"\"自动计算缩放比例 — 所有模式统一 fit width\"\"\"\n if not self.doc or len(self.doc) == 0:\n return\n page = self.doc[0]\n pw = page.rect.width\n vp = self.scroll.viewport() if not self._continuous else self.cont_scroll.viewport()\n avail_w = vp.width() - 8\n if avail_w < 100 or pw <= 0:\n return\n optimal_zoom = avail_w / pw\n optimal_zoom = max(0.3, min(optimal_zoom, 3.0))\n self.zoom = optimal_zoom\n slider_val = int(optimal_zoom * 100)\n self.zoom_slider.blockSignals(True)\n self.zoom_slider.setValue(slider_val)\n self.zoom_slider.blockSignals(False)\n self.zoom_pct.setText(f\"{slider_val}%\")\n\n def _fit_page(self):\n \"\"\"自动计算缩放比例 — 整页适配 (宽高都不超出视口)\"\"\"\n if not self.doc or len(self.doc) == 0:\n return\n page = self.doc[0]\n pw, ph = page.rect.width, page.rect.height\n vp = self.scroll.viewport() if not self._continuous else self.cont_scroll.viewport()\n avail_w = vp.width() - 8\n avail_h = vp.height() - 8\n if avail_w < 100 or avail_h < 100 or pw <= 0 or ph <= 0:\n return\n zoom_w = avail_w / pw\n zoom_h = avail_h / ph\n optimal_zoom = min(zoom_w, zoom_h)\n optimal_zoom = max(0.3, min(optimal_zoom, 3.0))\n self.zoom = optimal_zoom\n slider_val = int(optimal_zoom * 100)\n self.zoom_slider.blockSignals(True)\n self.zoom_slider.setValue(slider_val)\n self.zoom_slider.blockSignals(False)\n self.zoom_pct.setText(f\"{slider_val}%\")\n\n def _switch_to_fit_width(self):\n \"\"\"切换到适宽模式\"\"\"\n self._fit_mode = \"width\"\n self.fit_btn.setProperty(\"active\", True)\n self.fit_btn.style().unpolish(self.fit_btn); self.fit_btn.style().polish(self.fit_btn)\n self.fitpage_btn.setProperty(\"active\", False)\n self.fitpage_btn.style().unpolish(self.fitpage_btn); self.fitpage_btn.style().polish(self.fitpage_btn)\n self._reset_fit()\n\n def _switch_to_fit_page(self):\n \"\"\"切换到适页模式\"\"\"\n self._fit_mode = \"page\"\n self.fitpage_btn.setProperty(\"active\", True)\n self.fitpage_btn.style().unpolish(self.fitpage_btn); self.fitpage_btn.style().polish(self.fitpage_btn)\n self.fit_btn.setProperty(\"active\", False)\n self.fit_btn.style().unpolish(self.fit_btn); self.fit_btn.style().polish(self.fit_btn)\n self._reset_fit()\n\n def render_page(self):\n if not self.doc or self.current_page >= len(self.doc): return\n pg = self.doc[self.current_page]\n # Retina: 渲染 2x 分辨率再缩回,保证清晰\n dpr = QApplication.instance().devicePixelRatio() if QApplication.instance() else 2.0\n render_zoom = self.zoom * dpr\n mat = fitz.Matrix(render_zoom, render_zoom)\n pix = pg.get_pixmap(matrix=mat, alpha=False)\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n qpix = QPixmap.fromImage(img)\n qpix.setDevicePixelRatio(dpr)\n self.page_widget.set_pixmap(qpix)\n # setFixedSize 用逻辑尺寸(物理尺寸 / dpr)\n self.page_widget.setFixedSize(int(pix.width / dpr), int(pix.height / dpr))\n self._install_highlight_events(self.page_widget, self.current_page)\n self._update_page_display()\n self._highlight_thumb(self.current_page)\n self.scroll.verticalScrollBar().setValue(0)\n\n def first_page(self):\n if self.doc:\n self.current_page = 0; self.render_page()\n def prev_page(self):\n if self.doc and self.current_page > 0:\n self.current_page -= 1; self.render_page()\n def next_page(self):\n if self.doc and self.current_page < len(self.doc) - 1:\n self.current_page += 1; self.render_page()\n def last_page(self):\n if self.doc:\n self.current_page = len(self.doc) - 1; self.render_page()\n def _jump_to_page(self):\n \"\"\"页码输入框回车跳转,超范围闪红提示\"\"\"\n try:\n n = int(self.page_input.text()) - 1\n if self.doc and 0 <= n < len(self.doc):\n self.current_page = n; self.render_page()\n return\n except ValueError:\n pass\n # 输入无效:闪红 + 重置\n self.page_input.setStyleSheet(f\"border:1.5px solid {_C['err']};border-radius:6px;\")\n self.page_input.setText(str(self.current_page + 1) if self.doc else \"\")\n QTimer.singleShot(800, lambda: self.page_input.setStyleSheet(\"\"))\n def _update_page_display(self):\n total = len(self.doc) if self.doc else 0\n self.page_input.setText(str(self.current_page + 1) if total else \"\")\n self.total_label.setText(f\"/ {total}\" if total else \"/ —\")\n def on_zoom(self, v):\n self.zoom = v / 100.0; self.zoom_pct.setText(f\"{v}%\")\n if self.doc:\n if self._continuous:\n self._render_continuous()\n else:\n self.render_page()\n def open_file(self):\n p, _ = QFileDialog.getOpenFileName(self, \"选择 PDF\", \"\", \"PDF (*.pdf)\")\n if p: self.load_pdf(p)\n def contextMenuEvent(self, event):\n if not self.doc:\n return\n menu = _RoundMenu(self)\n menu.addAction(\"打开其他 PDF…\", self.open_file)\n menu.addSeparator()\n menu.addAction(\"放大\", lambda: self._apply_zoom(min(self.zoom + 0.1, 4.0)))\n menu.addAction(\"缩小\", lambda: self._apply_zoom(max(self.zoom - 0.1, 0.3)))\n menu.addAction(\"适合宽度\", self._switch_to_fit_width)\n menu.addAction(\"适合页面\", self._switch_to_fit_page)\n menu.addSeparator()\n lp = getattr(self, '_loaded_path', None)\n if lp and os.path.exists(lp):\n menu.addAction(\"在 Finder 中显示\",\n lambda: __import__(\"subprocess\").Popen([\"open\", \"-R\", lp]))\n menu.exec_(event.globalPos())\n def _toggle_fullscreen(self):\n self._is_fullscreen = not self._is_fullscreen\n self.fs_btn.setText(\"退出\" if self._is_fullscreen else \"全屏\")\n self.fs_btn.setProperty(\"active\", self._is_fullscreen)\n self.fs_btn.style().unpolish(self.fs_btn); self.fs_btn.style().polish(self.fs_btn)\n self.hist_btn.setVisible(self._is_fullscreen)\n self.fullscreen_toggled.emit(self._is_fullscreen)\n QTimer.singleShot(80, self._on_resize_done)\n\n def _on_thumb_panel_resized(self):\n \"\"\"拖拽分割线后重建缩略图 + 重新适配 PDF 视图\"\"\"\n if not hasattr(self, '_thumb_resize_timer'):\n self._thumb_resize_timer = QTimer(); self._thumb_resize_timer.setSingleShot(True)\n self._thumb_resize_timer.timeout.connect(self._rebuild_thumbs_fit)\n self._thumb_resize_timer.start(200)\n # 立即适配 PDF 视图大小(QA 面板伸缩时也需要)\n if not hasattr(self, '_fit_timer'):\n self._fit_timer = QTimer(); self._fit_timer.setSingleShot(True)\n self._fit_timer.timeout.connect(self._fit_and_render)\n self._fit_timer.start(50)\n\n def _rebuild_thumbs_fit(self):\n if self.doc and self.thumb_scroll.isVisible():\n self._build_thumbnails()\n\n def keyPressEvent(self, event):\n if event.key() == Qt.Key_Escape and self._is_fullscreen:\n self._toggle_fullscreen(); return\n if event.key() == Qt.Key_Up:\n self.prev_page()\n elif event.key() == Qt.Key_Down:\n self.next_page()\n elif event.key() == Qt.Key_Home:\n self.first_page()\n elif event.key() == Qt.Key_End:\n self.last_page()\n else:\n super().keyPressEvent(event)\n def mousePressEvent(self, event):\n \"\"\"点击预览区域获取焦点,让键盘事件生效\"\"\"\n self.setFocus()\n super().mousePressEvent(event)\n\n def resizeEvent(self, event):\n \"\"\"窗口大小变化时重新适配\"\"\"\n super().resizeEvent(event)\n if self.doc:\n from PyQt5.QtCore import QTimer\n # 用定时器防止频繁重渲染\n if not hasattr(self, '_resize_timer'):\n self._resize_timer = QTimer(); self._resize_timer.setSingleShot(True)\n self._resize_timer.timeout.connect(self._on_resize_done)\n self._resize_timer.start(100)\n\n def _on_resize_done(self):\n self._fit_and_render()\n\n # ── 连续滚动模式 ──\n def _toggle_continuous(self):\n self._continuous = not self._continuous\n self.scroll_btn.setProperty(\"active\", self._continuous)\n self.scroll_btn.style().unpolish(self.scroll_btn)\n self.scroll_btn.style().polish(self.scroll_btn)\n self.scroll.setVisible(not self._continuous)\n self.cont_scroll.setVisible(self._continuous)\n if self.doc:\n self._fit_and_render()\n # 延迟再算一次,等 scroll 显隐切换布局稳定\n QTimer.singleShot(0, self._fit_and_render)\n\n def _render_continuous(self):\n \"\"\"分帧渲染所有页面到连续滚动容器(避免大文件卡 UI)\"\"\"\n if not self.doc:\n return\n # 清空旧内容\n for w in self._cont_page_widgets:\n w.setParent(None)\n self._cont_page_widgets = []\n self._cont_render_idx = 0\n self._cont_render_dpr = QApplication.instance().devicePixelRatio() if QApplication.instance() else 2.0\n\n # 先放置占位标签\n total = len(self.doc)\n for i in range(total):\n lbl = QLabel(\"加载中…\" if i == 0 else \"\")\n lbl.setAlignment(Qt.AlignCenter)\n lbl.setStyleSheet(f\"background:transparent;color:{_C['t3']};font-size:11px;min-height:200px;\")\n self.cont_layout.addWidget(lbl, alignment=Qt.AlignHCenter)\n self._cont_page_widgets.append(lbl)\n self._update_page_display()\n\n # 分帧渲染:每帧渲染 2 页\n self._render_batch_continuous()\n\n def _render_batch_continuous(self):\n \"\"\"每次渲染 2 页,释放 UI 事件循环\"\"\"\n if not self.doc:\n return\n idx = getattr(self, '_cont_render_idx', 0)\n total = len(self.doc)\n dpr = self._cont_render_dpr\n batch = 2 # 每帧渲染页数\n for _ in range(batch):\n if idx >= total:\n return\n pg = self.doc[idx]\n render_zoom = self.zoom * dpr\n mat = fitz.Matrix(render_zoom, render_zoom)\n pix = pg.get_pixmap(matrix=mat, alpha=False)\n img = QImage(pix.samples, pix.width, pix.height, pix.stride, QImage.Format_RGB888)\n qpix = QPixmap.fromImage(img)\n qpix.setDevicePixelRatio(dpr)\n lbl = self._cont_page_widgets[idx]\n lbl.setPixmap(qpix)\n lbl.setFixedSize(int(pix.width / dpr), int(pix.height / dpr))\n lbl.setStyleSheet(\"background:transparent;\")\n self._install_highlight_events(lbl, idx)\n idx += 1\n self._cont_render_idx = idx\n if idx < total:\n QTimer.singleShot(0, self._render_batch_continuous)\n\n def _on_cont_scroll(self, value):\n \"\"\"连续滚动时更新页码和缩略图高亮\"\"\"\n if not self._continuous or not self._cont_page_widgets:\n return\n vp_center = value + self.cont_scroll.viewport().height() // 2\n for i, w in enumerate(self._cont_page_widgets):\n wy = w.y()\n if wy <= vp_center <= wy + w.height():\n if self.current_page != i:\n self.current_page = i\n self._update_page_display()\n self._highlight_thumb(i)\n break\n\n def update_theme(self, c):\n \"\"\"深色/浅色切换时更新内联样式\"\"\"\n self._empty_icon.setStyleSheet(f\"background:{c['acc_l']};border-radius:32px;font-size:28px;\")\n self._thumb_color = c['acc']\n if self._thumb_labels:\n self._highlight_thumb(self.current_page)\n # 更新 AI 面板\n self.qa_panel.update_theme(c)\n\n\n# ═══════════════════════════════════════════════════════════════\n# 翻译页面 — 全功能\n# ═══════════════════════════════════════════════════════════════\n\nclass TranslatePage(QWidget):\n translation_done = pyqtSignal(dict) # {\"mono\":..., \"dual\":..., \"side_by_side\":...}\n\n def __init__(self):\n super().__init__()\n self.worker = None\n self.pending_files = []\n self.cfg = UserConfigManager.load()\n\n scroll = QScrollArea(); scroll.setWidgetResizable(True); scroll.setFrameShape(QFrame.NoFrame)\n scroll.setObjectName(\"PA\")\n content = QWidget(); content.setObjectName(\"PA\")\n outer_lo = QVBoxLayout(content); outer_lo.setContentsMargins(0,0,0,0)\n outer_lo.addStretch(1) # 上部弹性 — 垂直居中\n\n inner = QWidget()\n lo = QVBoxLayout(inner); lo.setContentsMargins(40,0,40,0); lo.setSpacing(12)\n\n # 标题 + 四叶草徽章 + 人文关怀\n hdr = QHBoxLayout()\n t = QLabel(\"翻译\"); t.setObjectName(\"PT0\"); hdr.addWidget(t)\n self.clover_label = QLabel(\"\")\n self.clover_label.setStyleSheet(\"font-size:16px;background:transparent;\")\n hdr.addWidget(self.clover_label)\n hdr.addStretch()\n # 尝试显示关怀消息,否则显示默认副标题\n _subtitle = \"拖入学术 PDF,保留公式与原始排版\"\n try:\n from ui.caring import get_caring_message\n _cm = get_caring_message()\n if _cm:\n _subtitle = f\"{_cm[0]} {_cm[2]}\"\n except Exception:\n pass\n st = QLabel(_subtitle); st.setObjectName(\"PT1\"); hdr.addWidget(st)\n lo.addLayout(hdr)\n\n # 拖拽区(内含文件列表,高度固定,不影响下方布局)\n self.drop = DropZone(); self.drop.files_dropped.connect(self.on_files_added)\n self.drop._add_btn.clicked.connect(self._browse_more)\n self.drop._del_btn.clicked.connect(self._remove_selected_files)\n self.drop._clr_btn.clicked.connect(self._clear_files)\n lo.addWidget(self.drop)\n # 引用 DropZone 内嵌的控件\n self.flist = self.drop.flist\n self._fcount_label = self.drop._fcount_label\n self._zotero_hint = self.drop._zotero_hint\n # 文件列表右键菜单\n self.flist.setContextMenuPolicy(Qt.CustomContextMenu)\n self.flist.customContextMenuRequested.connect(self._flist_context_menu)\n\n # ── 配置卡片 ──\n card = _card()\n cl = QVBoxLayout(card); cl.setContentsMargins(16,12,16,12); cl.setSpacing(0)\n\n # 语言行\n r1 = QHBoxLayout(); r1.setSpacing(12)\n for label, items, attr, default in [\n (\"源语言\", list(LANG_MAP.keys()), \"src_combo\", self.cfg.get(\"lang_in\",\"自动检测\")),\n (\"目标语言\", list(LANG_MAP.keys()), \"tgt_combo\", self.cfg.get(\"lang_out\",\"中文(简体)\")),\n ]:\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(label); lb.setObjectName(\"FL\"); col.addWidget(lb)\n cb = QComboBox(); cb.addItems(items); cb.setMinimumWidth(160)\n idx = cb.findText(default)\n if idx >= 0: cb.setCurrentIndex(idx)\n col.addWidget(cb); r1.addLayout(col)\n setattr(self, attr, cb)\n r1.addStretch()\n cl.addLayout(r1)\n cl.addSpacing(8); cl.addWidget(_div()); cl.addSpacing(8)\n\n # 服务 + 格式 + 页码\n r2 = QHBoxLayout(); r2.setSpacing(12)\n # 翻译服务\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"翻译服务\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.svc_combo = QComboBox(); self.svc_combo.addItems(list(SERVICE_MAP.keys()))\n self.svc_combo.setMinimumWidth(180)\n saved_svc = self.cfg.get(\"service\", \"Bing 翻译\")\n idx = self.svc_combo.findText(saved_svc)\n if idx >= 0: self.svc_combo.setCurrentIndex(idx)\n col.addWidget(self.svc_combo); r2.addLayout(col)\n\n # 页码范围\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"页码范围\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.page_combo = QComboBox(); self.page_combo.addItems(list(PAGE_PRESETS.keys()))\n self.page_combo.setMinimumWidth(120)\n self.page_combo.currentTextChanged.connect(self._on_page_changed)\n col.addWidget(self.page_combo); r2.addLayout(col)\n\n # 自定义页码输入\n self.custom_page = QLineEdit(); self.custom_page.setPlaceholderText(\"例: 1-5, 8, 10-12\")\n self.custom_page.setVisible(False); self.custom_page.setFixedWidth(160)\n r2.addWidget(self.custom_page)\n\n r2.addStretch()\n cl.addLayout(r2)\n cl.addSpacing(8); cl.addWidget(_div()); cl.addSpacing(8)\n\n # 输出格式 + 线程\n r3 = QHBoxLayout(); r3.setSpacing(12)\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"输出格式\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.fmt_combo = QComboBox(); self.fmt_combo.addItems(list(OUTPUT_MODES.keys()))\n _saved_fmt = self.cfg.get(\"output_format\", \"左右并排 (Side by Side)\")\n _fmt_idx = self.fmt_combo.findText(_saved_fmt)\n self.fmt_combo.setCurrentIndex(_fmt_idx if _fmt_idx >= 0 else 2)\n self.fmt_combo.setMinimumWidth(180)\n col.addWidget(self.fmt_combo); r3.addLayout(col)\n\n col = QVBoxLayout(); col.setSpacing(4)\n lb = QLabel(\"线程数\"); lb.setObjectName(\"FL\"); col.addWidget(lb)\n self.thread_spin = QSpinBox(); self.thread_spin.setRange(1,32)\n self.thread_spin.setValue(self.cfg.get(\"thread_count\", 8))\n col.addWidget(self.thread_spin); r3.addLayout(col)\n\n r3.addStretch()\n cl.addLayout(r3)\n cl.addSpacing(8); cl.addWidget(_div()); cl.addSpacing(8)\n\n # 分块翻译\n r4 = QHBoxLayout(); r4.setSpacing(16)\n self.chunk_check = QCheckBox(\"分块翻译(大文件推荐)\")\n self.chunk_check.setChecked(self.cfg.get(\"chunk_enabled\", False))\n self.chunk_check.toggled.connect(self._on_chunk_toggled)\n r4.addWidget(self.chunk_check)\n\n self.chunk_size_label = QLabel(\"每块\"); self.chunk_size_label.setObjectName(\"Cap\")\n self.chunk_size_spin = QSpinBox(); self.chunk_size_spin.setRange(5,200)\n self.chunk_size_spin.setValue(self.cfg.get(\"chunk_size\", 50))\n self.chunk_size_unit = QLabel(\"页\"); self.chunk_size_unit.setObjectName(\"Cap\")\n\n self.chunk_delay_label = QLabel(\"间隔\"); self.chunk_delay_label.setObjectName(\"Cap\")\n self.chunk_delay_spin = QSpinBox(); self.chunk_delay_spin.setRange(0,120)\n self.chunk_delay_spin.setValue(self.cfg.get(\"chunk_delay\", 10))\n self.chunk_delay_unit = QLabel(\"秒\"); self.chunk_delay_unit.setObjectName(\"Cap\")\n\n for w in [self.chunk_size_label, self.chunk_size_spin, self.chunk_size_unit,\n self.chunk_delay_label, self.chunk_delay_spin, self.chunk_delay_unit]:\n r4.addWidget(w)\n w.setVisible(self.chunk_check.isChecked())\n\n r4.addStretch()\n cl.addLayout(r4)\n\n lo.addWidget(card)\n\n # ── 进度卡片(固定高度占位,避免布局跳动) ──\n self._prog_holder = QWidget()\n self._prog_holder.setFixedHeight(130)\n ph_lo = QVBoxLayout(self._prog_holder)\n ph_lo.setContentsMargins(0,0,0,0); ph_lo.setSpacing(0)\n\n self.prog_card = _card(\"lg\")\n self.prog_card.setVisible(False)\n pc_lo = QVBoxLayout(self.prog_card)\n pc_lo.setContentsMargins(24, 14, 24, 14)\n pc_lo.setSpacing(8)\n\n row1 = QHBoxLayout(); row1.setSpacing(12)\n self.prog_icon = QLabel(\"⏳\"); self.prog_icon.setObjectName(\"ProgIcon\")\n row1.addWidget(self.prog_icon)\n self.prog_label = QLabel(\"正在翻译…\"); self.prog_label.setObjectName(\"ProgLabel\")\n row1.addWidget(self.prog_label)\n row1.addStretch()\n self.prog_pct = QLabel(\"0%\"); self.prog_pct.setObjectName(\"ProgPct\")\n row1.addWidget(self.prog_pct)\n pc_lo.addLayout(row1)\n\n self.prog_bar = QProgressBar(); self.prog_bar.setRange(0, 100)\n self.prog_bar.setObjectName(\"ProgBar\")\n pc_lo.addWidget(self.prog_bar)\n\n self.prog_tip = QLabel(\"\"); self.prog_tip.setObjectName(\"ProgTip\")\n pc_lo.addWidget(self.prog_tip)\n\n row2 = QHBoxLayout(); row2.setSpacing(10)\n self.prog_detail = QLabel(\"\"); self.prog_detail.setObjectName(\"ProgDetail\")\n row2.addWidget(self.prog_detail)\n row2.addStretch()\n self.stop_btn = QPushButton(\"取消\")\n self.stop_btn.setObjectName(\"ProgCancel\")\n self.stop_btn.setCursor(Qt.PointingHandCursor)\n self.stop_btn.clicked.connect(self._cancel)\n row2.addWidget(self.stop_btn)\n pc_lo.addLayout(row2)\n\n ph_lo.addWidget(self.prog_card)\n lo.addWidget(self._prog_holder)\n\n # ── 开始按钮 ──\n br = QHBoxLayout(); br.setSpacing(10)\n br.addStretch()\n self.go_btn = QPushButton(\"开始翻译\"); self.go_btn.setObjectName(\"Pr\")\n self.go_btn.setFixedWidth(200); self.go_btn.setFixedHeight(48)\n self.go_btn.setEnabled(False)\n self.go_btn.clicked.connect(self._start)\n br.addWidget(self.go_btn)\n self.dice_btn = QPushButton(\"🎲\")\n self.dice_btn.setCursor(Qt.PointingHandCursor)\n self.dice_btn.setStyleSheet(\"font-size:18px;padding:6px;border:none;background:transparent;\")\n self.dice_btn.clicked.connect(self._dice_game)\n br.addWidget(self.dice_btn)\n br.addStretch()\n lo.addLayout(br)\n\n outer_lo.addWidget(inner)\n outer_lo.addStretch(2) # 下部弹性稍大 — 视觉重心偏上\n\n self._update_dice() # 初始化骰子状态\n scroll.setWidget(content)\n page_lo = QVBoxLayout(self); page_lo.setContentsMargins(0,0,0,0); page_lo.addWidget(scroll)\n\n def _on_page_changed(self, text):\n self.custom_page.setVisible(text == \"自定义\")\n\n def _on_chunk_toggled(self, checked):\n for w in [self.chunk_size_label, self.chunk_size_spin, self.chunk_size_unit,\n self.chunk_delay_label, self.chunk_delay_spin, self.chunk_delay_unit]:\n w.setVisible(checked)\n\n def on_files_added(self, files):\n for f in files:\n if not os.path.isfile(f):\n continue\n # 去重\n exists = False\n for i in range(self.flist.count()):\n if self.flist.item(i).data(Qt.UserRole) == f:\n exists = True; break\n if exists:\n continue\n name = os.path.basename(f)\n if len(name) > 50:\n name = name[:22] + \"…\" + name[-22:]\n sz = os.path.getsize(f)\n s = f\"{sz/1048576:.1f} MB\" if sz > 1048576 else f\"{sz/1024:.0f} KB\"\n item = QListWidgetItem(f\" 📄 {name} {s}\")\n item.setData(Qt.UserRole, f)\n self.flist.addItem(item)\n self._update_fcount()\n self._check_zotero_source()\n\n def _flist_context_menu(self, pos):\n item = self.flist.itemAt(pos)\n menu = _RoundMenu(self)\n menu.addAction(\"添加文件…\", self._browse_more)\n if item:\n fp = item.data(Qt.UserRole)\n menu.addSeparator()\n menu.addAction(\"在 Finder 中显示\", lambda: self._reveal_in_finder(fp))\n menu.addAction(\"移除\", lambda: self._remove_item(item))\n if self.flist.count() > 0:\n menu.addSeparator()\n menu.addAction(\"清空全部\", self._clear_files)\n menu.exec_(self.flist.viewport().mapToGlobal(pos))\n\n def _reveal_in_finder(self, path):\n import subprocess\n subprocess.Popen([\"open\", \"-R\", path])\n\n def _remove_item(self, item):\n self.flist.takeItem(self.flist.row(item))\n self._update_fcount(); self._check_zotero_source()\n\n def _browse_more(self):\n fs, _ = QFileDialog.getOpenFileNames(self, \"选择 PDF\", \"\", \"PDF (*.pdf)\")\n if fs: self.on_files_added(fs)\n\n def _update_fcount(self):\n n = self.flist.count()\n if n > 0:\n self.drop._stack.setCurrentIndex(1) # 切换到文件列表\n self.go_btn.setEnabled(True)\n self._fcount_label.setText(f\"共 {n} 个文件\")\n else:\n self.drop._stack.setCurrentIndex(0) # 切换回拖拽提示\n self.go_btn.setEnabled(False)\n self._fcount_label.setText(\"\")\n\n def _check_zotero_source(self):\n has_zotero = False\n for i in range(self.flist.count()):\n p = self.flist.item(i).data(Qt.UserRole)\n if detect_zotero_source(p):\n has_zotero = True\n break\n self._zotero_hint.setVisible(has_zotero)\n\n def _remove_selected_files(self):\n for item in reversed(self.flist.selectedItems()):\n self.flist.takeItem(self.flist.row(item))\n self._update_fcount()\n self._check_zotero_source()\n\n def _clear_files(self):\n self.flist.clear()\n self._update_fcount()\n self._zotero_hint.setVisible(False)\n\n def _get_pages(self):\n preset = self.page_combo.currentText()\n if preset == \"自定义\":\n txt = self.custom_page.text().strip()\n return parse_page_range(txt) if txt else None\n return PAGE_PRESETS.get(preset)\n\n def _save_config(self):\n self.cfg[\"service\"] = self.svc_combo.currentText()\n self.cfg[\"lang_in\"] = self.src_combo.currentText()\n self.cfg[\"lang_out\"] = self.tgt_combo.currentText()\n self.cfg[\"output_format\"] = self.fmt_combo.currentText()\n self.cfg[\"thread_count\"] = self.thread_spin.value()\n self.cfg[\"chunk_enabled\"] = self.chunk_check.isChecked()\n self.cfg[\"chunk_size\"] = self.chunk_size_spin.value()\n self.cfg[\"chunk_delay\"] = self.chunk_delay_spin.value()\n UserConfigManager.save(self.cfg)\n\n def _start(self):\n files = []\n for i in range(self.flist.count()):\n p = self.flist.item(i).data(Qt.UserRole)\n if p: files.append(p)\n if not files: return\n # 确保按钮连接正常(重试模式可能改过)\n try: self.go_btn.clicked.disconnect()\n except TypeError: pass\n self.go_btn.clicked.connect(self._start)\n\n self._save_config()\n self.pending_files = files\n self._batch_idx = 0\n self._batch_results = [] # [(file_path, output_files_dict), ...]\n self._output_dir = os.path.expanduser(\"~/Documents/pdf2zh_files\")\n os.makedirs(self._output_dir, exist_ok=True)\n\n self.go_btn.setEnabled(False); self.go_btn.setText(\"翻译中…\")\n self.prog_card.setVisible(True); self.stop_btn.setVisible(True)\n self.prog_bar.setValue(0)\n self.prog_pct.setText(\"0%\")\n self.prog_label.setText(\"正在翻译…\")\n self.prog_icon.setText(\"⏳\")\n self.prog_tip.setText(\"\")\n\n self._translate_next()\n\n def _translate_next(self):\n \"\"\"启动队列中下一个文件的翻译\"\"\"\n idx = self._batch_idx\n total = len(self.pending_files)\n if idx >= total:\n self._on_batch_done()\n return\n\n fp = self.pending_files[idx]\n if total > 1:\n self.prog_label.setText(f\"正在翻译 ({idx+1}/{total})…\")\n self.prog_detail.setText(os.path.basename(fp))\n self.prog_bar.setValue(0); self.prog_pct.setText(\"0%\")\n\n envs = build_service_envs(self.svc_combo.currentText())\n self.worker = TranslateWorker(\n file_path=fp,\n output_dir=self._output_dir,\n lang_in=LANG_MAP.get(self.src_combo.currentText(), \"\"),\n lang_out=LANG_MAP.get(self.tgt_combo.currentText(), \"zh\"),\n service=SERVICE_MAP.get(self.svc_combo.currentText(), \"bing\"),\n pages=self._get_pages(),\n thread_count=self.thread_spin.value(),\n chunk_enabled=self.chunk_check.isChecked(),\n chunk_size=self.chunk_size_spin.value(),\n chunk_delay=self.chunk_delay_spin.value(),\n envs=envs,\n )\n self.worker.progress.connect(self._on_prog)\n self.worker.status.connect(self._on_status)\n self.worker.finished.connect(self._on_single_done)\n self.worker.error.connect(self._on_single_err)\n self.worker.start()\n\n def _cancel(self):\n if self.worker: self.worker.cancel()\n self.prog_label.setText(\"正在取消…\")\n\n _TIPS = [\n \"翻译中,请稍候…\", \"公式和图表会完整保留\",\n \"AI 正在识别文档布局…\", \"保持网络连接以获得最佳速度\",\n \"快好了,再等等…\", \"pdf2zh-desktop · 学术翻译利器\",\n \"排版会和原文一模一样\", \"支持 20+ 翻译引擎\",\n \"每一篇论文,都是知识跨越语言的桥梁\",\n \"科研不易,感谢你的坚持\",\n ]\n\n def _on_prog(self, cur, total):\n if total > 0:\n pct = min(int(cur/total*100), 100)\n self.prog_bar.setValue(pct); self.prog_pct.setText(f\"{pct}%\")\n self.prog_detail.setText(f\"已处理 {cur}/{total} 页\")\n # 趣味提示 + 深夜关怀\n import random\n if cur % 5 == 1 or cur == total:\n from ui.caring import get_caring_message\n caring = get_caring_message()\n if caring:\n tip = f\"{caring[0]} {caring[1]}\"\n elif pct >= 90:\n tip = \"马上就好!\"\n elif pct >= 50:\n tip = \"已经过半了,加油!\"\n else:\n tip = random.choice(self._TIPS)\n self.prog_tip.setText(tip)\n\n def _on_status(self, text):\n self.prog_detail.setText(text)\n\n def _on_single_done(self, output_files):\n \"\"\"单文件翻译完成 — 回写 Zotero + 记录历史 + 推进队列\"\"\"\n fp = self.pending_files[self._batch_idx]\n\n if self.worker:\n w = self.worker; self.worker = None\n w.quit()\n QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n # 保存历史\n HistoryManager.add_record({\n \"file\": {\"name\": os.path.basename(fp), \"path\": fp},\n \"translation\": {\n \"service\": self.svc_combo.currentText(),\n \"lang_in\": self.src_combo.currentText(),\n \"lang_out\": self.tgt_combo.currentText(),\n },\n \"output_files\": output_files,\n \"status\": \"success\",\n })\n self.translation_done.emit(output_files)\n\n # Zotero 回写:按此文件自身的来源路径,把译文复制回原位\n self._zotero_writeback(fp, output_files)\n\n self._batch_results.append((fp, output_files))\n\n # 推进到下一个文件\n self._batch_idx += 1\n self._translate_next()\n\n def _on_single_err(self, msg):\n \"\"\"单文件翻译出错 — 记录后继续下一个\"\"\"\n fp = self.pending_files[self._batch_idx]\n\n if self.worker:\n w = self.worker; self.worker = None\n w.quit()\n QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n self._batch_results.append((fp, None)) # None = 失败\n\n total = len(self.pending_files)\n if total > 1:\n # 批量模式:跳过出错的,继续下一个\n self.prog_detail.setText(f\"⚠️ {os.path.basename(fp)} 翻译出错,跳过\")\n self._batch_idx += 1\n QTimer.singleShot(1000, self._translate_next)\n else:\n # 单文件模式:直接报错\n self._on_err(msg)\n\n def _zotero_writeback(self, file_path, output_files):\n \"\"\"把译文复制回 Zotero 原位 + 尝试自动关联附件\"\"\"\n import shutil\n zotero_dir = detect_zotero_source(file_path)\n if not zotero_dir:\n return\n cfg = UserConfigManager.load()\n modes = cfg.get(\"zotero_output_modes\", [\"side_by_side\"])\n keep_copy = cfg.get(\"zotero_keep_copy\", True)\n item_key = get_zotero_item_key(file_path)\n for mode in modes:\n src = output_files.get(mode)\n if not src or not os.path.exists(src):\n continue\n dst = os.path.join(zotero_dir, os.path.basename(src))\n if os.path.abspath(src) != os.path.abspath(dst):\n shutil.copy2(src, dst)\n if not keep_copy:\n try:\n os.remove(src)\n except OSError:\n pass\n # 尝试通过 pdf2zh Connector 插件自动关联附件\n if item_key:\n mode_label = {\"side_by_side\": \"并排\", \"dual\": \"双语\", \"mono\": \"译文\"}.get(mode, mode)\n zotero_auto_link(item_key, dst, f\"翻译 ({mode_label})\")\n\n def _on_batch_done(self):\n \"\"\"全部文件翻译完成\"\"\"\n self.prog_bar.setValue(100); self.prog_pct.setText(\"100%\")\n self.prog_icon.setText(\"✅\")\n self.go_btn.setEnabled(True); self.go_btn.setText(\"开始翻译\")\n self.stop_btn.setVisible(False)\n\n total = len(self._batch_results)\n ok = sum(1 for _, r in self._batch_results if r is not None)\n failed = total - ok\n has_zotero = any(detect_zotero_source(fp) for fp, _ in self._batch_results)\n\n # 收集失败的文件用于重试\n self._failed_files = [fp for fp, r in self._batch_results if r is None]\n\n if total == 1 and ok == 1:\n self.prog_label.setText(\"翻译完成\")\n if has_zotero:\n self.prog_detail.setText(\"译文已保存回 Zotero 原位\")\n else:\n self.prog_detail.setText(\"输出至 ~/Documents/pdf2zh_files\")\n elif failed == 0:\n self.prog_label.setText(f\"全部完成({ok} 篇)\")\n if has_zotero:\n self.prog_detail.setText(\"所有译文已保存回 Zotero 原位\")\n else:\n self.prog_detail.setText(\"输出至 ~/Documents/pdf2zh_files\")\n else:\n self.prog_label.setText(f\"完成 {ok} 篇,失败 {failed} 篇\")\n self.prog_detail.setText(\"部分文件翻译出错\")\n # 显示重试按钮\n self.go_btn.setText(f\"重试失败 ({failed} 篇)\")\n try: self.go_btn.clicked.disconnect()\n except TypeError: pass\n self.go_btn.clicked.connect(self._retry_failed)\n self.go_btn.setEnabled(True)\n\n # macOS 系统通知 — 翻译完成后通知用户(适合长时间翻译时切到其他 app)\n try:\n import subprocess\n msg = f\"{ok} 篇翻译完成\" if failed == 0 else f\"完成 {ok} 篇,失败 {failed} 篇\"\n subprocess.Popen([\n \"osascript\", \"-e\",\n f'display notification \"{msg}\" with title \"pdf2zh\" sound name \"Glass\"'\n ])\n except Exception:\n pass\n\n # 关怀消息\n from ui.caring import get_caring_message, get_session_tip\n caring = get_caring_message()\n if caring:\n self.prog_tip.setText(f\"{caring[0]} {caring[1]}\")\n else:\n self.prog_tip.setText(get_session_tip())\n\n # 翻译完成后自动清空文件列表\n self._clear_files()\n\n # 骰子系统\n try:\n from datetime import date\n cfg = UserConfigManager.load()\n today = date.today().isoformat()\n if cfg.get(\"dice_date\") != today:\n cfg[\"dice_date\"] = today; cfg[\"dice_today_pages\"] = 0\n cfg[\"dice_used\"] = False; cfg[\"dice_clovers\"] = 0\n cfg[\"dice_today_pages\"] = cfg.get(\"dice_today_pages\", 0) + max(1, self.thread_spin.value())\n UserConfigManager.dice_save(cfg)\n self._update_dice()\n except Exception:\n pass\n\n def _retry_failed(self):\n \"\"\"重试上次批量翻译中失败的文件\"\"\"\n failed = getattr(self, '_failed_files', [])\n if not failed:\n return\n # 恢复按钮到正常状态\n try: self.go_btn.clicked.disconnect()\n except TypeError: pass\n self.go_btn.clicked.connect(self._start)\n # 把失败文件重新加入列表并启动\n self.on_files_added(failed)\n self._failed_files = []\n self._start()\n\n def _on_err(self, msg):\n self.prog_icon.setText(\"❌\")\n self.prog_label.setText(\"翻译出错\")\n self.prog_pct.setText(\"!\")\n self.prog_detail.setText(msg)\n self.prog_bar.setValue(0)\n self.go_btn.setEnabled(True); self.go_btn.setText(\"重试翻译\")\n self.stop_btn.setVisible(False)\n if self.worker:\n w = self.worker; self.worker = None\n w.quit()\n QTimer.singleShot(100, lambda: w.deleteLater() if not w.isRunning() else None)\n\n # ── 骰子系统(静默、无文字、四叶草) ──\n\n def _update_dice(self):\n \"\"\"骰子 🎲 在按钮区,四叶草 🍀 在标题旁\"\"\"\n from datetime import date\n cfg = UserConfigManager.load()\n today = date.today().isoformat()\n pages = cfg.get(\"dice_today_pages\", 0)\n\n # 新的一天重置\n if cfg.get(\"dice_date\") != today:\n self.clover_label.setText(\"\")\n if pages >= 5:\n self.dice_btn.setText(\"🎲\"); self.dice_btn.setEnabled(True); self.dice_btn.show()\n else:\n self.dice_btn.hide()\n return\n\n # 四叶草:当天翻译不足 5 页则清掉\n clovers = cfg.get(\"dice_clovers\", 0) if cfg.get(\"dice_used\") else 0\n if clovers > 0 and pages < 5:\n clovers = 0\n cfg[\"dice_clovers\"] = 0; UserConfigManager.dice_save(cfg)\n self.clover_label.setText(\"🍀\" * clovers if clovers > 0 else \"\")\n\n # 骰子按钮\n if cfg.get(\"dice_used\"):\n self.dice_btn.hide() # 已掷过,骰子消失\n elif pages >= 5:\n self.dice_btn.setText(\"🎲\"); self.dice_btn.setEnabled(True); self.dice_btn.show()\n else:\n self.dice_btn.hide()\n\n def _dice_game(self):\n \"\"\"点击骰子 → 静默掷骰 → 四叶草或消失\"\"\"\n import random\n from datetime import date\n\n cfg = UserConfigManager.load()\n today = date.today().isoformat()\n\n # 日期重置\n if cfg.get(\"dice_date\") != today:\n cfg[\"dice_date\"] = today; cfg[\"dice_today_pages\"] = 0\n cfg[\"dice_used\"] = False; cfg[\"dice_clovers\"] = 0\n UserConfigManager.dice_save(cfg)\n\n # 签名校验\n if not UserConfigManager.dice_verify(cfg):\n cfg[\"dice_date\"] = today; cfg[\"dice_today_pages\"] = 0\n cfg[\"dice_used\"] = False; cfg[\"dice_clovers\"] = 0\n UserConfigManager.dice_save(cfg)\n self._update_dice(); return\n\n # 静默检查资格\n if cfg.get(\"dice_used\") or cfg.get(\"dice_today_pages\", 0) < 5:\n return\n\n # 标记已用\n cfg[\"dice_used\"] = True; cfg[\"dice_clovers\"] = 0\n UserConfigManager.dice_save(cfg)\n\n self.dice_btn.setEnabled(False)\n self._dice_roll_step(0)\n\n def _dice_roll_step(self, clovers):\n \"\"\"逐次掷骰:6 → 🍀 累加,非 6 → 结束\"\"\"\n import random\n FACES = \"⚀⚁⚂⚃⚄⚅\"\n\n val = 6 if random.random() < 0.5 else random.choice([1,2,3,4,5])\n flicker = [0]\n\n def tick():\n flicker[0] += 1\n if flicker[0] < 10:\n self.dice_btn.setText(FACES[random.randint(0,5)])\n QTimer.singleShot(55, tick)\n else:\n # 定格\n self.dice_btn.setText(FACES[val - 1])\n if val == 6:\n nc = clovers + 1\n self._dice_mini_firework()\n QTimer.singleShot(700, lambda: self._dice_got_six(nc))\n else:\n # 短暂显示骰面,然后骰子消失,四叶草留在标题旁\n QTimer.singleShot(1500, lambda: self._dice_finish(clovers))\n tick()\n\n def _dice_got_six(self, clovers):\n \"\"\"掷中 6:标题旁加 🍀,继续或结束\"\"\"\n # 四叶草实时更新到标题旁\n self.clover_label.setText(\"🍀\" * clovers)\n\n # 保存中间状态\n cfg = UserConfigManager.load()\n cfg[\"dice_clovers\"] = clovers\n UserConfigManager.dice_save(cfg)\n\n if clovers >= 6:\n # 全部 6!终极大奖\n from datetime import date\n today = date.today().isoformat()\n cfg[\"dice_win_code\"] = UserConfigManager.dice_win_code(today)\n cfg[\"theme_unlocked\"] = True\n UserConfigManager.save(cfg)\n self.dice_btn.hide()\n mw = self.window()\n if hasattr(mw, '_midnight_bloom'):\n mw._midnight_bloom()\n return\n\n # ≥3 个 6 静默解锁主题色\n if clovers == 3:\n cfg2 = UserConfigManager.load()\n cfg2[\"theme_unlocked\"] = True\n UserConfigManager.save(cfg2)\n\n # 按钮回到 🎲 继续下一轮\n self.dice_btn.setText(\"🎲\")\n QTimer.singleShot(600, lambda: self._dice_roll_step(clovers))\n\n def _dice_finish(self, clovers):\n \"\"\"掷骰结束:骰子消失,四叶草留在标题旁\"\"\"\n cfg = UserConfigManager.load()\n cfg[\"dice_clovers\"] = clovers\n UserConfigManager.dice_save(cfg)\n # 骰子消失,四叶草已在标题旁\n self.dice_btn.hide()\n self.clover_label.setText(\"🍀\" * clovers if clovers > 0 else \"\")\n\n def _dice_mini_firework(self):\n \"\"\"从骰子按钮位置爆发迷你烟花\"\"\"\n import random, math\n from PyQt5.QtCore import QPropertyAnimation, QPoint, QEasingCurve\n mw = self.window()\n center = self.dice_btn.mapTo(mw, self.dice_btn.rect().center())\n emojis = [\"✨\",\"💫\",\"⭐\",\"🌟\",\"🎇\",\"🔥\",\"🎆\"]\n for _ in range(12):\n lbl = QLabel(random.choice(emojis), mw)\n lbl.setStyleSheet(f\"font-size:{random.randint(12,22)}px;background:transparent;\")\n lbl.move(center); lbl.show(); lbl.raise_()\n angle = random.uniform(0, 2 * math.pi)\n dist = random.randint(50, 160)\n end = QPoint(center.x() + int(math.cos(angle) * dist),\n center.y() + int(math.sin(angle) * dist))\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(600, 1100))\n anim.setStartValue(center); anim.setEndValue(end)\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start(); lbl._anim = anim\n\n\n# ═══════════════════════════════════════════════════════════════\n# 阅读页面 — 历史 + 预览合体\n# ═══════════════════════════════════════════════════════════════\n\nclass ReaderPage(QWidget):\n \"\"\"左侧历史列表 + 右侧 PDF 预览\n 两级导航:历史列表 ←(Left/Right)→ 预览区\n 预览区内上下键翻页,缩略图跟随高亮\"\"\"\n fullscreen_changed = pyqtSignal(bool)\n\n def __init__(self):\n super().__init__()\n self._preview_active = False\n lo = QHBoxLayout(self); lo.setContentsMargins(0,0,0,0); lo.setSpacing(0)\n\n # QSplitter 实现可拖拽分割\n from PyQt5.QtWidgets import QSplitter\n splitter = QSplitter(Qt.Horizontal)\n splitter.setHandleWidth(1)\n splitter.setChildrenCollapsible(True)\n\n # ── 左侧:历史面板 ──\n left = QWidget()\n left.setObjectName(\"HistPanel\"); left.setMinimumWidth(180); left.setMaximumWidth(420)\n ll = QVBoxLayout(left); ll.setContentsMargins(12,12,12,12); ll.setSpacing(8)\n\n # 标题行\n hdr = QHBoxLayout(); hdr.setSpacing(4)\n t = QLabel(\"翻译历史\"); t.setStyleSheet(\"font-size:13px;font-weight:600;\")\n hdr.addWidget(t); hdr.addStretch()\n self.thumb_toggle = QPushButton(\"缩略图\"); self.thumb_toggle.setObjectName(\"TB\")\n self.thumb_toggle.setCursor(Qt.PointingHandCursor)\n self.thumb_toggle.setProperty(\"active\", True)\n self.thumb_toggle.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n self.thumb_toggle.clicked.connect(self._toggle_thumbs)\n hdr.addWidget(self.thumb_toggle)\n cb = QPushButton(\"清空\"); cb.setObjectName(\"Gh\"); cb.setCursor(Qt.PointingHandCursor)\n cb.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n cb.clicked.connect(self._clear); hdr.addWidget(cb)\n ll.addLayout(hdr)\n\n # ── 分组筛选栏:左侧可滚动按钮区 + 右侧固定\"+\" ──\n _group_row = QHBoxLayout(); _group_row.setContentsMargins(0, 0, 0, 0); _group_row.setSpacing(4)\n self._group_bar = QScrollArea()\n self._group_bar.setMaximumHeight(34); self._group_bar.setFrameShape(QFrame.NoFrame)\n self._group_bar.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._group_bar.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)\n self._group_bar.setWidgetResizable(True)\n self._group_container = QWidget()\n self._group_layout = QHBoxLayout(self._group_container)\n self._group_layout.setContentsMargins(0, 0, 0, 0); self._group_layout.setSpacing(4)\n self._group_bar.setWidget(self._group_container)\n _group_row.addWidget(self._group_bar, 1)\n # \"+\"按钮 — 独立于 scroll area,绝对不参与 group_layout\n self._add_btn = QPushButton(\"+\"); self._add_btn.setObjectName(\"Gh\")\n self._add_btn.setCursor(Qt.PointingHandCursor)\n self._add_btn.setStyleSheet(\"font-size:10px;padding:4px 8px;\")\n self._add_btn.setToolTip(\"新建分组\")\n self._add_btn.setFixedSize(28, 26)\n self._add_btn.clicked.connect(self._create_group)\n _group_row.addWidget(self._add_btn, 0) # stretch=0, 固定尺寸\n ll.addLayout(_group_row)\n self._current_group_id = None # None = 全部\n self._current_tag_filter = None\n\n # 历史列表\n self.list_w = QListWidget()\n self.list_w.setObjectName(\"HistList\")\n self.list_w.setFocusPolicy(Qt.StrongFocus)\n self.list_w.setSelectionMode(QListWidget.SingleSelection)\n self.list_w.currentItemChanged.connect(self._on_select)\n self.list_w.itemClicked.connect(lambda item: self._on_select(item, None))\n self.list_w.itemDoubleClicked.connect(self._open_in_reader)\n self.list_w.installEventFilter(self)\n self.list_w.setContextMenuPolicy(Qt.CustomContextMenu)\n self.list_w.customContextMenuRequested.connect(self._hist_context_menu)\n ll.addWidget(self.list_w)\n\n # 详情面板\n self.detail_label = QLabel(\"↑ 选择记录查看详情\")\n self.detail_label.setObjectName(\"HistDetail\")\n self.detail_label.setWordWrap(True)\n self.detail_label.setStyleSheet(\n \"#HistDetail{font-size:10px;padding:4px 6px;line-height:125%;}\")\n self.detail_label.setMinimumHeight(48)\n ll.addWidget(self.detail_label)\n # 文件操作按钮 — 紧凑行,紧贴详情面板\n fops = QHBoxLayout(); fops.setSpacing(4); fops.setContentsMargins(6, 2, 6, 0)\n self._btn_reveal = QPushButton(\"📂 Finder\"); self._btn_reveal.setObjectName(\"Gh\")\n self._btn_reveal.setCursor(Qt.PointingHandCursor)\n self._btn_reveal.setStyleSheet(\"font-size:9px;padding:2px 6px;\")\n self._btn_reveal.setToolTip(\"在 Finder 中显示翻译文件\")\n self._btn_reveal.clicked.connect(self._reveal_in_finder)\n self._btn_reveal.setVisible(False)\n fops.addWidget(self._btn_reveal)\n self._btn_open_src = QPushButton(\"📄 源文件\"); self._btn_open_src.setObjectName(\"Gh\")\n self._btn_open_src.setCursor(Qt.PointingHandCursor)\n self._btn_open_src.setStyleSheet(\"font-size:9px;padding:2px 6px;\")\n self._btn_open_src.setToolTip(\"用默认应用打开源 PDF\")\n self._btn_open_src.clicked.connect(self._open_source)\n self._btn_open_src.setVisible(False)\n fops.addWidget(self._btn_open_src)\n fops.addStretch()\n ll.addLayout(fops)\n self._current_record = None\n\n splitter.addWidget(left)\n\n # ── 右侧:预览 ──\n self.preview = PreviewPage()\n self.preview.installEventFilter(self)\n splitter.addWidget(self.preview)\n\n # 初始比例:左 260, 右 stretch\n splitter.setSizes([260, 800])\n self._splitter = splitter\n self._hist_panel = left\n lo.addWidget(splitter)\n\n self._show_thumbs = True\n # 缩略图点击 → 进入预览模式\n self._orig_thumb_clicked = self.preview._thumb_clicked\n self.preview._thumb_clicked = self._on_thumb_clicked\n # 全屏信号\n self.preview.fullscreen_toggled.connect(self._on_fullscreen)\n self.preview.history_toggled.connect(self._toggle_hist_in_fullscreen)\n self._fs_hist_visible = False\n self.refresh()\n\n def showEvent(self, event):\n \"\"\"页面显示时自动选中第一条\"\"\"\n super().showEvent(event)\n if self.list_w.count() > 0 and not self.list_w.currentItem():\n self.list_w.setCurrentRow(0)\n self.list_w.setFocus()\n\n def _on_thumb_clicked(self, idx):\n \"\"\"点击缩略图 → 进入预览模式 + 跳转页面\"\"\"\n self._enter_preview()\n self._orig_thumb_clicked(idx)\n\n def _enter_preview(self):\n \"\"\"进入预览模式:缩略图显示蓝色高亮\"\"\"\n self._preview_active = True\n self.preview._thumb_color = _C[\"acc\"]\n self.preview.setFocus()\n self.preview._highlight_thumb(self.preview.current_page)\n\n def _exit_preview(self):\n \"\"\"退出预览模式:缩略图变灰色,焦点回列表\"\"\"\n self._preview_active = False\n self.preview._thumb_color = _C[\"t3\"]\n self.list_w.setFocus()\n self.preview._highlight_thumb(self.preview.current_page)\n\n def eventFilter(self, obj, event):\n \"\"\"两级导航: 历史列表 ←→ 预览区(一次按键切换)\n 全屏模式下 Left 键会先唤出历史面板\"\"\"\n from PyQt5.QtCore import QEvent\n if event.type() == QEvent.KeyPress:\n key = event.key()\n # 历史列表 → 右键 → 进入预览\n if obj == self.list_w and key == Qt.Key_Right:\n self._enter_preview()\n return True\n # 预览区 → 左键\n if obj == self.preview and key == Qt.Key_Left:\n # 全屏且历史面板隐藏 → 先唤出历史\n if self.preview._is_fullscreen and not self._fs_hist_visible:\n self._toggle_hist_in_fullscreen()\n return True\n self._exit_preview()\n return True\n return super().eventFilter(obj, event)\n\n def _on_fullscreen(self, fs):\n \"\"\"全屏阅读模式:隐藏历史面板+侧边栏\"\"\"\n if fs:\n self._saved_splitter_sizes = self._splitter.sizes()\n self._hist_panel.setVisible(False)\n self._fs_hist_visible = False\n self.preview.hist_btn.setProperty(\"active\", False)\n self.preview.hist_btn.style().unpolish(self.preview.hist_btn)\n self.preview.hist_btn.style().polish(self.preview.hist_btn)\n else:\n self._hist_panel.setVisible(True)\n self._splitter.setSizes(self._saved_splitter_sizes)\n self._fs_hist_visible = False\n self.fullscreen_changed.emit(fs)\n self._quick_refit()\n\n def _toggle_hist_in_fullscreen(self):\n \"\"\"全屏模式下切换历史面板\"\"\"\n if not self.preview._is_fullscreen:\n return\n self._fs_hist_visible = not self._fs_hist_visible\n self._hist_panel.setVisible(self._fs_hist_visible)\n self.preview.hist_btn.setProperty(\"active\", self._fs_hist_visible)\n self.preview.hist_btn.style().unpolish(self.preview.hist_btn)\n self.preview.hist_btn.style().polish(self.preview.hist_btn)\n if self._fs_hist_visible:\n self._splitter.setSizes([self._saved_splitter_sizes[0], 800])\n self.list_w.setFocus()\n self._quick_refit()\n\n def _quick_refit(self):\n \"\"\"快速贴合(不重建缩略图,只调 zoom + 渲染)\"\"\"\n if not self.preview.doc:\n return\n def _do():\n if self.preview._fit_mode == \"page\":\n self.preview._fit_page()\n else:\n self.preview._fit_width()\n if self.preview.zoom != getattr(self.preview, '_last_fit_zoom', -1):\n self.preview._last_fit_zoom = self.preview.zoom\n if self.preview._continuous:\n self.preview._render_continuous()\n else:\n self.preview.render_page()\n QTimer.singleShot(0, _do)\n QTimer.singleShot(80, _do)\n\n def _toggle_thumbs(self):\n \"\"\"切换缩略图显示,自动重新适配宽度\"\"\"\n self._show_thumbs = not self._show_thumbs\n self.thumb_toggle.setProperty(\"active\", self._show_thumbs)\n self.thumb_toggle.style().unpolish(self.thumb_toggle)\n self.thumb_toggle.style().polish(self.thumb_toggle)\n if self._show_thumbs:\n self.preview.thumb_scroll.setVisible(True)\n # 确保 splitter 给缩略图合理宽度\n sizes = self.preview.body_splitter.sizes()\n if sizes[0] < 60:\n self.preview.body_splitter.setSizes([100, sizes[1]])\n else:\n self.preview.thumb_scroll.setVisible(False)\n self._quick_refit()\n\n def refresh(self):\n self.list_w.clear()\n data = HistoryManager.load_all()\n records = data.get(\"records\", [])\n groups = data.get(\"groups\", [])\n all_tags = data.get(\"tags\", [])\n\n # ── 重建分组筛选栏 ──\n self._rebuild_group_bar(groups)\n\n # ── 按分组/标签筛选 ──\n filtered = records\n gid = self._current_group_id\n if gid is not None:\n filtered = [r for r in filtered if r.get(\"group_id\") == gid]\n tid = self._current_tag_filter\n if tid is not None:\n filtered = [r for r in filtered if tid in r.get(\"tags\", [])]\n\n if not filtered:\n item = QListWidgetItem(\"暂无翻译记录\")\n item.setFlags(item.flags() & ~Qt.ItemIsSelectable)\n item.setForeground(QColor(_C[\"t3\"]))\n self.list_w.addItem(item)\n return\n for r in filtered:\n ts = r.get(\"timestamp\", \"\")[:16].replace(\"T\", \" \")\n fname = r.get(\"file\", {}).get(\"name\", \"?\")\n svc = r.get(\"translation\", {}).get(\"service\", \"?\")\n lang_in = r.get(\"translation\", {}).get(\"lang_in\", \"\")\n lang_out = r.get(\"translation\", {}).get(\"lang_out\", \"\")\n status = \"✓\" if r.get(\"status\") == \"success\" else \"✗\"\n # 两行显示:文件名 + 服务·时间\n item = QListWidgetItem()\n item.setData(Qt.UserRole, r)\n item.setToolTip(f\"{fname}\\n{svc} · {lang_in}→{lang_out} · {ts[5:]}\")\n # 有标签时行高多留空间\n has_tags = bool(r.get(\"tags\"))\n item.setSizeHint(QSize(0, 56 if has_tags else 44))\n self.list_w.addItem(item)\n # 自定义 widget 双行布局\n w = QWidget(); wl = QVBoxLayout(w)\n wl.setContentsMargins(2, 2, 2, 2); wl.setSpacing(0)\n l1 = QLabel(f\"{status} {fname}\"); l1.setStyleSheet(f\"font-size:12px;color:{_C['t1']};background:transparent;\")\n l2 = QLabel(f\"{svc} · {lang_in}→{lang_out} · {ts[5:]}\")\n l2.setStyleSheet(f\"font-size:11px;color:{_C['t2']};background:transparent;\")\n wl.addWidget(l1); wl.addWidget(l2)\n # 标签行\n rec_tags = r.get(\"tags\", [])\n if rec_tags and all_tags:\n tag_row = QHBoxLayout(); tag_row.setSpacing(4); tag_row.setContentsMargins(0, 1, 0, 0)\n for td in all_tags:\n if td[\"id\"] in rec_tags:\n dot = QLabel(f\"● {td['name']}\")\n dot.setStyleSheet(f\"font-size:9px;color:{td['color']};background:transparent;\")\n tag_row.addWidget(dot)\n tag_row.addStretch()\n wl.addLayout(tag_row)\n w._l1 = l1; w._l2 = l2\n self.list_w.setItemWidget(item, w)\n\n def _rebuild_group_bar(self, groups):\n \"\"\"重建分组筛选按钮栏 — 支持右键菜单(重命名/删除/图标)\"\"\"\n while self._group_layout.count():\n w = self._group_layout.takeAt(0).widget()\n if w: w.deleteLater()\n self._group_btns = []\n c = _C\n # ── \"全部\"按钮(固定,不可删除) ──\n btn_all = QPushButton(\"全部\"); btn_all.setObjectName(\"TB\")\n btn_all.setCursor(Qt.PointingHandCursor)\n btn_all.setStyleSheet(\"font-size:10px;padding:4px 10px;\")\n btn_all.setProperty(\"active\", self._current_group_id is None)\n btn_all.clicked.connect(lambda: self._select_group(None))\n self._group_layout.addWidget(btn_all)\n # ── 动态分组按钮 ──\n for g in groups:\n b = QPushButton(f\"{g['icon']} {g['name']}\"); b.setObjectName(\"TB\")\n b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:10px;padding:4px 10px;\")\n b.setProperty(\"active\", self._current_group_id == g[\"id\"])\n gid = g[\"id\"]\n b.clicked.connect(lambda checked, _id=gid: self._select_group(_id))\n # 右键菜单\n b.setContextMenuPolicy(Qt.CustomContextMenu)\n b.customContextMenuRequested.connect(\n lambda pos, _id=gid, _btn=b: self._group_context_menu(_id, _btn, pos))\n self._group_layout.addWidget(b)\n self._group_btns.append((gid, b))\n self._group_layout.addStretch()\n\n def _select_group(self, group_id):\n self._current_group_id = group_id\n self.refresh()\n\n def _create_group(self):\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建分组\", \"分组名称:\")\n if ok and name.strip():\n g = HistoryManager.add_group(name.strip())\n # 在 stretch 前面插入新按钮(\"+\"在 layout 外,不受影响)\n stretch_idx = self._group_layout.count() - 1 # 最后一个是 stretch\n b = QPushButton(f\"{g['icon']} {g['name']}\"); b.setObjectName(\"TB\")\n b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:10px;padding:4px 10px;\")\n b.setProperty(\"active\", False)\n gid = g[\"id\"]\n b.clicked.connect(lambda checked, _id=gid: self._select_group(_id))\n b.setContextMenuPolicy(Qt.CustomContextMenu)\n b.customContextMenuRequested.connect(\n lambda pos, _id=gid, _btn=b: self._group_context_menu(_id, _btn, pos))\n self._group_layout.insertWidget(stretch_idx, b)\n self._group_btns.append((gid, b))\n\n def _group_context_menu(self, group_id, btn, pos):\n \"\"\"分组按钮右键菜单:重命名 / 修改图标 / 上移 / 下移 / 删除\"\"\"\n try:\n menu = _RoundMenu(self)\n menu.addAction(\"重命名\", lambda: self._rename_group(group_id))\n menu.addAction(\"修改图标\", lambda: self._change_group_icon(group_id))\n menu.addSeparator()\n # 上移 / 下移\n ids = [gid for gid, _ in self._group_btns]\n idx = ids.index(group_id) if group_id in ids else -1\n if idx > 0:\n menu.addAction(\"↑ 上移\", lambda: self._move_group(group_id, -1))\n if idx < len(ids) - 1:\n menu.addAction(\"↓ 下移\", lambda: self._move_group(group_id, 1))\n menu.addSeparator()\n menu.addAction(\"删除分组\", lambda: self._delete_group(group_id))\n menu.exec_(btn.mapToGlobal(pos))\n except Exception:\n pass\n\n def _rename_group(self, group_id):\n from PyQt5.QtWidgets import QInputDialog\n data = HistoryManager.load_all()\n old_name = \"\"\n for g in data.get(\"groups\", []):\n if g[\"id\"] == group_id:\n old_name = g[\"name\"]\n break\n name, ok = QInputDialog.getText(self, \"重命名分组\", \"新名称:\", text=old_name)\n if ok and name.strip():\n HistoryManager.rename_group(group_id, name.strip())\n self.refresh()\n\n def _change_group_icon(self, group_id):\n from PyQt5.QtWidgets import QInputDialog\n icons = [\"📁\", \"📂\", \"📚\", \"📖\", \"🔬\", \"🧪\", \"💻\", \"🎓\", \"📝\", \"⭐\",\n \"🔖\", \"📌\", \"🏷️\", \"💡\", \"🔍\", \"📊\", \"🧬\", \"🌐\", \"🤖\", \"📐\"]\n icon, ok = QInputDialog.getItem(self, \"选择图标\", \"分组图标:\", icons, 0, False)\n if ok:\n HistoryManager.update_group_icon(group_id, icon)\n self.refresh()\n\n def _move_group(self, group_id, direction):\n \"\"\"上移 direction=-1,下移 direction=1\"\"\"\n data = HistoryManager.load_all()\n groups = data.get(\"groups\", [])\n ids = [g[\"id\"] for g in groups]\n idx = ids.index(group_id) if group_id in ids else -1\n if idx < 0:\n return\n new_idx = idx + direction\n if 0 <= new_idx < len(ids):\n ids[idx], ids[new_idx] = ids[new_idx], ids[idx]\n HistoryManager.reorder_groups(ids)\n self.refresh()\n\n def _delete_group(self, group_id):\n data = HistoryManager.load_all()\n name = \"\"\n count = 0\n for g in data.get(\"groups\", []):\n if g[\"id\"] == group_id:\n name = g[\"name\"]\n break\n for r in data.get(\"records\", []):\n if r.get(\"group_id\") == group_id:\n count += 1\n reply = QMessageBox.question(\n self, \"删除分组\",\n f\"确定删除分组「{name}」吗?\\n该分组下的 {count} 个文件将移至「全部」。\",\n QMessageBox.Yes | QMessageBox.No, QMessageBox.No)\n if reply == QMessageBox.Yes:\n HistoryManager.delete_group(group_id)\n if self._current_group_id == group_id:\n self._current_group_id = None\n self.refresh()\n\n def _move_to_group(self, record_id, group_id):\n HistoryManager.move_to_group(record_id, group_id)\n self.refresh()\n\n def _toggle_tag(self, record_id, tag_id):\n HistoryManager.toggle_record_tag(record_id, tag_id)\n self.refresh()\n\n def _hist_context_menu(self, pos):\n item = self.list_w.itemAt(pos)\n if not item:\n return\n r = item.data(Qt.UserRole)\n if not r:\n return\n menu = _RoundMenu(self)\n # 在 Finder 中显示译文\n files = r.get(\"output_files\", {})\n has_output = any(os.path.exists(files.get(m, \"\")) for m in [\"dual\",\"mono\",\"side_by_side\"])\n if has_output:\n menu.addAction(\"在 Finder 中显示译文\", self._reveal_in_finder)\n # 打开原文\n src = r.get(\"file\", {}).get(\"path\", \"\")\n if src and os.path.exists(src):\n menu.addAction(\"打开原文\", self._open_source)\n menu.addAction(\"在 Finder 中显示原文\",\n lambda: __import__(\"subprocess\").Popen([\"open\", \"-R\", src]))\n menu.addSeparator()\n # 移到分组\n record_id = r.get(\"id\")\n data = HistoryManager.load_all()\n groups = data.get(\"groups\", [])\n if groups:\n grp_menu = _RoundMenu(self); grp_menu.setTitle(\"移到分组\")\n grp_menu.addAction(\"无分组\", lambda: self._move_to_group(record_id, None))\n for g in groups:\n gid = g[\"id\"]\n grp_menu.addAction(f\"{g['icon']} {g['name']}\",\n lambda _id=gid: self._move_to_group(record_id, _id))\n menu.addMenu(grp_menu)\n # 标签\n all_tags = data.get(\"tags\", [])\n tag_menu = _RoundMenu(self); tag_menu.setTitle(\"标签\")\n for t in all_tags:\n a = tag_menu.addAction(f\"● {t['name']}\")\n a.setCheckable(True)\n a.setChecked(t[\"id\"] in r.get(\"tags\", []))\n tid = t[\"id\"]\n a.toggled.connect(lambda checked, _tid=tid: self._toggle_tag(record_id, _tid))\n tag_menu.addSeparator()\n tag_menu.addAction(\"新建标签…\", self._create_tag)\n menu.addMenu(tag_menu)\n menu.addSeparator()\n menu.addAction(\"清空全部历史\", self._clear)\n menu.exec_(self.list_w.viewport().mapToGlobal(pos))\n\n def _create_tag(self):\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建标签\", \"标签名称:\")\n if ok and name.strip():\n colors = [\"#FF3B30\", \"#FF9F0A\", \"#34C759\", \"#007AFF\", \"#AF52DE\", \"#FF2D55\"]\n import random\n HistoryManager.add_tag(name.strip(), random.choice(colors))\n\n def _on_select(self, current, previous):\n # 恢复上一个\n if previous:\n w = self.list_w.itemWidget(previous)\n if w and hasattr(w, '_l1'):\n w._l1.setStyleSheet(f\"font-size:12px;color:{_C['t1']};background:transparent;\")\n w._l2.setStyleSheet(f\"font-size:11px;color:{_C['t2']};background:transparent;\")\n # 当前变白\n if current:\n w = self.list_w.itemWidget(current)\n if w and hasattr(w, '_l1'):\n w._l1.setStyleSheet(\"font-size:12px;color:white;background:transparent;\")\n w._l2.setStyleSheet(\"font-size:11px;color:rgba(255,255,255,0.7);background:transparent;\")\n\n r = current.data(Qt.UserRole) if current else None\n if not r:\n return\n # 更新详情面板\n fname = r.get(\"file\", {}).get(\"name\", \"—\")\n svc = r.get(\"translation\", {}).get(\"service\", \"—\")\n li = r.get(\"translation\", {}).get(\"lang_in\", \"\")\n lo2 = r.get(\"translation\", {}).get(\"lang_out\", \"\")\n ts = r.get(\"timestamp\", \"\")[:19].replace(\"T\", \" \")\n self.detail_label.setText(f\"📄 {fname}\\n🌐 {svc} · {li}→{lo2}\\n🕐 {ts}\")\n self._current_record = r\n # 显示文件操作按钮(有输出文件时)\n has_files = bool(r.get(\"output_files\"))\n self._btn_reveal.setVisible(has_files)\n self._btn_open_src.setVisible(bool(r.get(\"file\", {}).get(\"path\")))\n # 加载预览\n if \"output_files\" in r:\n self.preview.set_output_files(r[\"output_files\"])\n\n def _open_in_reader(self, item):\n \"\"\"双击历史记录 → 加载到预览并聚焦预览区\"\"\"\n r = item.data(Qt.UserRole) if item else None\n if not r or \"output_files\" not in r:\n return\n self.preview.set_output_files(r[\"output_files\"])\n self._preview_active = True\n self.preview.setFocus()\n\n def set_output_files(self, files):\n \"\"\"外部调用(翻译完成后)\"\"\"\n self.preview.set_output_files(files)\n self.refresh()\n if self.list_w.count() > 0:\n self.list_w.setCurrentRow(0)\n\n def _clear(self):\n HistoryManager.clear(); self.refresh()\n\n def _reveal_in_finder(self):\n \"\"\"在 Finder 中显示翻译输出文件\"\"\"\n r = self._current_record\n if not r:\n return\n import subprocess\n # 优先显示当前预览模式的文件,否则任一输出文件\n files = r.get(\"output_files\", {})\n path = \"\"\n for mode in [self.preview.current_mode, \"dual\", \"mono\", \"side_by_side\"]:\n p = files.get(mode, \"\")\n if p and os.path.exists(p):\n path = p; break\n if path:\n subprocess.run([\"open\", \"-R\", path])\n\n def _open_source(self):\n \"\"\"用系统默认应用打开源 PDF\"\"\"\n r = self._current_record\n if not r:\n return\n import subprocess\n path = r.get(\"file\", {}).get(\"path\", \"\")\n if path and os.path.exists(path):\n subprocess.run([\"open\", path])\n\n def update_theme(self, c):\n \"\"\"深色/浅色切换时更新历史列表颜色\"\"\"\n self.preview.update_theme(c)\n self.preview._thumb_color = c[\"acc\"] if self._preview_active else c[\"t3\"]\n if self.preview._thumb_labels:\n self.preview._highlight_thumb(self.preview.current_page)\n # 刷新所有历史条目颜色\n for i in range(self.list_w.count()):\n item = self.list_w.item(i)\n w = self.list_w.itemWidget(item)\n if w and hasattr(w, '_l1'):\n is_sel = (item == self.list_w.currentItem())\n if is_sel:\n w._l1.setStyleSheet(\"font-size:12px;color:white;background:transparent;\")\n w._l2.setStyleSheet(\"font-size:11px;color:rgba(255,255,255,0.7);background:transparent;\")\n else:\n w._l1.setStyleSheet(f\"font-size:12px;color:{c['t1']};background:transparent;\")\n w._l2.setStyleSheet(f\"font-size:11px;color:{c['t2']};background:transparent;\")\n\n\n# ═══════════════════════════════════════════════════════════════\n# 设置页面\n# ═══════════════════════════════════════════════════════════════\n\nclass SettingsPage(QWidget):\n dark_mode_changed = pyqtSignal(bool)\n theme_color_changed = pyqtSignal(str)\n\n SERVICE_CONFIGS = {\n # 推荐\n \"DeepSeek\": {\"key_ph\":\"密钥\", \"models\":[\"deepseek-chat\",\"deepseek-reasoner\",\"deepseek-coder\"], \"url\":\"https://api.deepseek.com/v1\"},\n \"OpenAI\": {\"key_ph\":\"sk-...\", \"models\":[\"gpt-4o\",\"gpt-4o-mini\",\"gpt-4-turbo\",\"gpt-3.5-turbo\",\"o1\",\"o1-mini\",\"o1-pro\"], \"url\":\"https://api.openai.com/v1\"},\n # 国际服务\n \"Azure OpenAI\": {\"key_ph\":\"密钥\", \"models\":[\"gpt-4o\",\"gpt-4-turbo\",\"gpt-35-turbo\"], \"url\":\"https://YOUR_RESOURCE.openai.azure.com\"},\n \"DeepL\": {\"key_ph\":\"密钥\", \"models\":[], \"url\":\"\"},\n \"Gemini\": {\"key_ph\":\"密钥\", \"models\":[\"gemini-pro\",\"gemini-1.5-pro\",\"gemini-1.5-flash\",\"gemini-2.0-flash\"], \"url\":\"\"},\n \"Groq\": {\"key_ph\":\"密钥\", \"models\":[\"llama-3.1-70b-versatile\",\"llama-3.1-8b-instant\",\"mixtral-8x7b-32768\",\"llama-3.3-70b-versatile\"], \"url\":\"https://api.groq.com/openai/v1\"},\n # 国产大模型\n \"Zhipu 智谱\": {\"key_ph\":\"密钥\", \"models\":[\"glm-4\",\"glm-4-flash\",\"glm-4-plus\",\"glm-3-turbo\",\"cogview-3\"], \"url\":\"https://open.bigmodel.cn/api/paas/v4\"},\n \"Qwen 通义千问\": {\"key_ph\":\"密钥\", \"models\":[\"qwen-turbo\",\"qwen-plus\",\"qwen-max\",\"qwen-long\"], \"url\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\"},\n \"Tencent 腾讯\": {\"key_ph\":\"密钥\", \"models\":[\"hunyuan-pro\",\"hunyuan-standard\",\"hunyuan-lite\"], \"url\":\"\"},\n \"Silicon 硅基流动\":{\"key_ph\":\"密钥\", \"models\":[\"deepseek-ai/DeepSeek-V3\",\"Qwen/Qwen2.5-72B-Instruct\",\"Pro/THUDM/glm-4-9b-chat\"], \"url\":\"https://api.siliconflow.cn/v1\"},\n \"ModelScope\": {\"key_ph\":\"密钥\", \"models\":[\"qwen-turbo\",\"qwen-plus\"], \"url\":\"https://dashscope.aliyuncs.com/compatible-mode/v1\"},\n # 本地 & 其他\n \"Ollama 本地\": {\"key_ph\":\"留空\", \"models\":[\"qwen2.5:7b\",\"llama3.1:8b\",\"gemma2:9b\",\"deepseek-r1:8b\"], \"url\":\"http://localhost:11434/v1\"},\n \"Argos Translate\": {\"key_ph\":\"留空\", \"models\":[], \"url\":\"\"},\n \"AnythingLLM\": {\"key_ph\":\"密钥\", \"models\":[], \"url\":\"http://localhost:3001/api/v1\"},\n \"Grok\": {\"key_ph\":\"密钥\", \"models\":[\"grok-2\",\"grok-2-mini\"], \"url\":\"https://api.x.ai/v1\"},\n # 自定义\n \"OpenAI 兼容\": {\"key_ph\":\"密钥\", \"models\":[\"自定义模型\"], \"url\":\"\"},\n }\n\n\n def __init__(self):\n super().__init__()\n self.setObjectName(\"PA\")\n lo = QVBoxLayout(self); lo.setContentsMargins(24,10,24,10); lo.setSpacing(4)\n\n hdr = QHBoxLayout()\n t = QLabel(\"设置\"); t.setObjectName(\"PT0\"); hdr.addWidget(t)\n hdr.addStretch()\n st = QLabel(\"翻译服务与偏好设置\"); st.setObjectName(\"PT1\"); hdr.addWidget(st)\n lo.addLayout(hdr)\n\n cols = QHBoxLayout(); cols.setSpacing(20)\n cfg = UserConfigManager.load()\n from PyQt5.QtWidgets import QGridLayout, QTextEdit\n\n # ══════════════════════════════════════════\n # 左列:翻译服务 → AI 助手\n # ══════════════════════════════════════════\n left = QVBoxLayout(); left.setSpacing(2)\n\n # ── 翻译服务配置 ──\n sl3 = QLabel(\"翻译服务配置\"); sl3.setObjectName(\"SL\"); left.addWidget(sl3)\n self.svc_card = _card()\n svc_outer = QVBoxLayout(self.svc_card)\n svc_outer.setContentsMargins(16,12,16,12); svc_outer.setSpacing(4)\n self.svc_selector = QComboBox()\n self.svc_selector.addItems(list(self.SERVICE_CONFIGS.keys()))\n self.svc_selector.currentTextChanged.connect(self._show_service_config)\n svc_outer.addWidget(self.svc_selector)\n self.svc_layout = QVBoxLayout(); self.svc_layout.setSpacing(2)\n svc_outer.addLayout(self.svc_layout)\n left.addWidget(self.svc_card)\n\n self.api_inputs = {}\n for svc_name, conf in self.SERVICE_CONFIGS.items():\n key_inp = QLineEdit(); key_inp.setPlaceholderText(f\"API Key: {conf['key_ph']}\")\n key_inp.setEchoMode(QLineEdit.Password)\n saved = cfg.get(f\"api_{svc_name}\", \"\")\n if saved: key_inp.setText(UserConfigManager.decode_sensitive(saved))\n key_inp.editingFinished.connect(self._save)\n self.api_inputs[svc_name] = {\"key\": key_inp}\n if conf[\"models\"]:\n mc = QComboBox(); mc.setEditable(True)\n mc.addItems(conf[\"models\"])\n saved_m = cfg.get(f\"model_{svc_name}\", \"\")\n if saved_m: mc.setCurrentText(saved_m)\n mc.currentTextChanged.connect(self._save)\n self.api_inputs[svc_name][\"model\"] = mc\n if conf[\"url\"]:\n ui = QLineEdit()\n saved_u = cfg.get(f\"url_{svc_name}\", \"\")\n ui.setText(saved_u if saved_u else conf[\"url\"])\n ui.editingFinished.connect(self._save)\n self.api_inputs[svc_name][\"url\"] = ui\n\n # ── AI 助手(摘要 / 问答服务) ──\n sl_ai = QLabel(\"AI 助手\"); sl_ai.setObjectName(\"SL\"); left.addWidget(sl_ai)\n ai_card = _card(); ai_lo = QVBoxLayout(ai_card)\n ai_lo.setContentsMargins(16, 12, 16, 12); ai_lo.setSpacing(6)\n\n ai_desc = QLabel(\"摘要与问答使用的 AI 服务,默认与翻译服务相同\")\n ai_desc.setObjectName(\"Cap\"); ai_desc.setWordWrap(True)\n ai_lo.addWidget(ai_desc)\n\n self._ai_default_row = QWidget()\n _adr_lo = QHBoxLayout(self._ai_default_row)\n _adr_lo.setContentsMargins(0, 2, 0, 2); _adr_lo.setSpacing(8)\n self._ai_status_label = QLabel(\"与翻译服务相同\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['ok']};background:transparent;\")\n _adr_lo.addWidget(self._ai_status_label); _adr_lo.addStretch()\n self._ai_toggle_btn = QPushButton(\"自定义\")\n self._ai_toggle_btn.setObjectName(\"Gh\"); self._ai_toggle_btn.setCursor(Qt.PointingHandCursor)\n self._ai_toggle_btn.setStyleSheet(\"font-size:11px;padding:3px 10px;\")\n self._ai_toggle_btn.clicked.connect(self._toggle_assistant_config)\n _adr_lo.addWidget(self._ai_toggle_btn)\n ai_lo.addWidget(self._ai_default_row)\n\n self._ai_custom_frame = QWidget()\n _acf_lo = QVBoxLayout(self._ai_custom_frame)\n _acf_lo.setContentsMargins(0, 6, 0, 0); _acf_lo.setSpacing(10)\n\n for _f_label, _f_attr, _f_type in [\n (\"服务\", \"_ai_svc_combo\", \"combo\"),\n (\"API Key\", \"_ai_key_input\", \"password\"),\n (\"模型\", \"_ai_model_combo\", \"combo_edit\"),\n (\"Base URL\",\"_ai_url_input\", \"line\"),\n ]:\n _grp = QVBoxLayout(); _grp.setSpacing(3)\n _lbl = QLabel(_f_label); _lbl.setStyleSheet(\n f\"font-size:11px;font-weight:500;color:{_C['t2']};background:transparent;padding:0;\")\n _grp.addWidget(_lbl)\n if _f_type == \"combo\":\n w = QComboBox()\n from ui.ai_client import CHAT_SERVICES\n w.addItems([name for name, _, _ in CHAT_SERVICES])\n w.currentTextChanged.connect(self._on_assistant_svc_changed)\n elif _f_type == \"password\":\n w = QLineEdit(); w.setEchoMode(QLineEdit.Password)\n w.setPlaceholderText(\"密钥\")\n w.editingFinished.connect(self._save_assistant_config)\n elif _f_type == \"combo_edit\":\n w = QComboBox(); w.setEditable(True)\n w.currentTextChanged.connect(self._save_assistant_config)\n else:\n w = QLineEdit(); w.setPlaceholderText(\"留空使用默认\")\n w.editingFinished.connect(self._save_assistant_config)\n setattr(self, _f_attr, w)\n _grp.addWidget(w)\n _acf_lo.addLayout(_grp)\n\n _reset_row = QHBoxLayout(); _reset_row.addStretch()\n _reset_btn = QPushButton(\"恢复默认\"); _reset_btn.setObjectName(\"GhDanger\")\n _reset_btn.setCursor(Qt.PointingHandCursor)\n _reset_btn.setStyleSheet(\"font-size:11px;padding:3px 10px;\")\n _reset_btn.clicked.connect(self._reset_assistant_config)\n _reset_row.addWidget(_reset_btn)\n _acf_lo.addLayout(_reset_row)\n\n self._ai_custom_frame.setVisible(False)\n ai_lo.addWidget(self._ai_custom_frame)\n self._load_assistant_config()\n left.addWidget(ai_card)\n left.addStretch()\n\n # ══════════════════════════════════════════\n # 右列:偏好 → 提示词 → 术语库 → Zotero\n # ══════════════════════════════════════════\n right = QVBoxLayout(); right.setSpacing(2)\n\n # ── 偏好设置 ──\n sl_app = QLabel(\"偏好设置\"); sl_app.setObjectName(\"SL\"); right.addWidget(sl_app)\n c = _card(); cl = QVBoxLayout(c); cl.setContentsMargins(16,12,16,12); cl.setSpacing(6)\n self.dark_check = QCheckBox(\"深色模式\"); self.dark_check.toggled.connect(self.dark_mode_changed.emit)\n self.cache_check = QCheckBox(\"翻译缓存\"); self.cache_check.setChecked(True)\n self.ai_check = QCheckBox(\"AI 布局检测\"); self.ai_check.setChecked(True)\n self.font_check = QCheckBox(\"字体子集化\")\n _pref_grid = QGridLayout(); _pref_grid.setSpacing(6)\n _pref_grid.addWidget(self.dark_check, 0, 0)\n _pref_grid.addWidget(self.cache_check, 0, 1)\n _pref_grid.addWidget(self.ai_check, 1, 0)\n _pref_grid.addWidget(self.font_check, 1, 1)\n cl.addLayout(_pref_grid)\n self.theme_row = QWidget(); tr_lo = QHBoxLayout(self.theme_row)\n tr_lo.setContentsMargins(0,2,0,2); tr_lo.setSpacing(6)\n tr_lbl = QLabel(\"主题色\"); tr_lo.addWidget(tr_lbl)\n THEME_COLORS = [\n (\"#0071E3\",\"蓝\"),(\"#FF6B6B\",\"红\"),(\"#34C759\",\"绿\"),\n (\"#AF52DE\",\"紫\"),(\"#FF9F0A\",\"橙\"),(\"#FF2D55\",\"粉\"),\n (\"#5AC8FA\",\"青\"),(\"#FFD60A\",\"黄\"),\n ]\n self._theme_dots = []\n for hex_c, name in THEME_COLORS:\n dot = QPushButton(); dot.setFixedSize(18, 18); dot.setCursor(Qt.PointingHandCursor)\n dot.setToolTip(name); dot.setProperty(\"hex\", hex_c)\n dot.setStyleSheet(\n f\"QPushButton{{background:{hex_c};border:2px solid transparent;\"\n f\"border-radius:9px;}}QPushButton:hover{{border-color:{hex_c};}}\")\n dot.clicked.connect(lambda _, h=hex_c: self._set_theme_color(h))\n tr_lo.addWidget(dot); self._theme_dots.append(dot)\n tr_lo.addStretch()\n cl.addWidget(self.theme_row)\n self.theme_row.setVisible(UserConfigManager.load().get(\"theme_unlocked\", False))\n right.addWidget(c)\n\n # ── 翻译提示词 ──\n from ui.prompt_manager import PromptTemplateManager\n sl4 = QLabel(\"翻译提示词\"); sl4.setObjectName(\"SL\"); right.addWidget(sl4)\n c4 = _card(); cl4 = QVBoxLayout(c4); cl4.setContentsMargins(16,10,16,10); cl4.setSpacing(2)\n pr = QHBoxLayout(); pr.setSpacing(4)\n self.prompt_preset = QComboBox()\n self._refresh_prompt_list()\n self.prompt_preset.currentTextChanged.connect(self._on_prompt_preset)\n pr.addWidget(self.prompt_preset, 1)\n cl4.addLayout(pr)\n self.prompt_edit = QTextEdit()\n self.prompt_edit.setPlaceholderText(\"提示词… {lang_out} {lang_in} {text}\")\n self.prompt_edit.setMaximumHeight(42)\n saved_prompt = cfg.get(\"prompt\", \"\")\n if saved_prompt: self.prompt_edit.setPlainText(saved_prompt)\n cl4.addWidget(self.prompt_edit)\n btn_row = QHBoxLayout(); btn_row.setSpacing(2)\n for label, slot in [\n (\"保存\", self._save_prompt_template),\n (\"新建\", self._new_prompt_template),\n (\"导入\", self._import_prompts),\n (\"导出\", self._export_prompts),\n ]:\n b = QPushButton(label); b.setObjectName(\"Gh\"); b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:11px;padding:2px 6px;\"); b.clicked.connect(slot)\n btn_row.addWidget(b)\n btn_row.addStretch()\n del_btn = QPushButton(\"删除\"); del_btn.setCursor(Qt.PointingHandCursor)\n del_btn.setObjectName(\"GhDanger\"); del_btn.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n del_btn.clicked.connect(self._delete_prompt_template)\n btn_row.addWidget(del_btn)\n cl4.addLayout(btn_row)\n right.addWidget(c4)\n\n # ── 术语库 ──\n from ui.glossary_manager import GlossaryManager\n sl5 = QLabel(\"术语库\"); sl5.setObjectName(\"SL\"); right.addWidget(sl5)\n c5 = _card(); cl5 = QVBoxLayout(c5); cl5.setContentsMargins(16,10,16,10); cl5.setSpacing(3)\n gr = QHBoxLayout(); gr.setSpacing(6)\n self.gloss_selector = QComboBox()\n self._refresh_gloss_list()\n self.gloss_selector.currentTextChanged.connect(self._on_gloss_changed)\n gr.addWidget(self.gloss_selector, 1)\n self.gloss_count = QLabel(\"\"); self.gloss_count.setObjectName(\"Cap\")\n self.gloss_count.setStyleSheet(\"font-size:10px;\")\n gr.addWidget(self.gloss_count)\n cl5.addLayout(gr); self._update_gloss_count()\n # 内联添加术语\n _add_row = QHBoxLayout(); _add_row.setSpacing(4)\n self._gloss_src = QLineEdit(); self._gloss_src.setPlaceholderText(\"原文\")\n self._gloss_src.setStyleSheet(\"font-size:11px;padding:4px 8px;\")\n self._gloss_dst = QLineEdit(); self._gloss_dst.setPlaceholderText(\"译文\")\n self._gloss_dst.setStyleSheet(\"font-size:11px;padding:4px 8px;\")\n _add_btn = QPushButton(\"添加\"); _add_btn.setObjectName(\"Gh\")\n _add_btn.setCursor(Qt.PointingHandCursor)\n _add_btn.setStyleSheet(\"font-size:11px;padding:2px 8px;\")\n _add_btn.clicked.connect(self._add_gloss_term)\n _add_row.addWidget(self._gloss_src, 1)\n _add_row.addWidget(self._gloss_dst, 1)\n _add_row.addWidget(_add_btn)\n cl5.addLayout(_add_row)\n gbtn = QHBoxLayout(); gbtn.setSpacing(2)\n for label, slot in [\n (\"新建\", self._new_glossary), (\"导入\", self._import_glossary),\n (\"导出\", self._export_glossary),\n ]:\n b = QPushButton(label); b.setObjectName(\"Gh\"); b.setCursor(Qt.PointingHandCursor)\n b.setStyleSheet(\"font-size:11px;padding:2px 6px;\"); b.clicked.connect(slot)\n gbtn.addWidget(b)\n gbtn.addStretch()\n gdel = QPushButton(\"删除\"); gdel.setCursor(Qt.PointingHandCursor)\n gdel.setObjectName(\"GhDanger\"); gdel.setStyleSheet(\"font-size:11px;padding:2px 6px;\")\n gdel.clicked.connect(self._delete_glossary); gbtn.addWidget(gdel)\n cl5.addLayout(gbtn)\n right.addWidget(c5)\n\n # ── Zotero 联动 ──\n sl_zot = QLabel(\"Zotero 联动\"); sl_zot.setObjectName(\"SL\"); right.addWidget(sl_zot)\n zot_card = _card(); zot_lo = QVBoxLayout(zot_card)\n zot_lo.setContentsMargins(14,10,14,10); zot_lo.setSpacing(4)\n self._zot_sbs = QCheckBox(\"左右并排 (Side by Side)\")\n self._zot_dual = QCheckBox(\"双语对照 (Dual)\")\n self._zot_mono = QCheckBox(\"仅翻译 (Mono)\")\n self._zot_keep_copy = QCheckBox(\"保留本地副本\")\n cfg = UserConfigManager.load()\n _zot_modes = cfg.get(\"zotero_output_modes\", [\"side_by_side\"])\n self._zot_sbs.setChecked(\"side_by_side\" in _zot_modes)\n self._zot_dual.setChecked(\"dual\" in _zot_modes)\n self._zot_mono.setChecked(\"mono\" in _zot_modes)\n self._zot_keep_copy.setChecked(cfg.get(\"zotero_keep_copy\", True))\n _zot_grid = QGridLayout(); _zot_grid.setSpacing(4)\n _zot_grid.addWidget(self._zot_sbs, 0, 0)\n _zot_grid.addWidget(self._zot_dual, 0, 1)\n _zot_grid.addWidget(self._zot_mono, 1, 0)\n _zot_grid.addWidget(self._zot_keep_copy, 1, 1)\n for _cb in (self._zot_sbs, self._zot_dual, self._zot_mono, self._zot_keep_copy):\n _cb.stateChanged.connect(self._save_zotero_modes)\n zot_lo.addLayout(_zot_grid)\n zot_lo.addWidget(_div())\n _auto_row = QHBoxLayout(); _auto_row.setSpacing(6)\n _auto_desc = QLabel(\"Zotero 插件\"); _auto_desc.setObjectName(\"Cap\")\n _auto_row.addWidget(_auto_desc)\n self._zot_status = QLabel(\"\"); self._zot_status.setObjectName(\"Cap\")\n _auto_row.addWidget(self._zot_status); _auto_row.addStretch()\n self._zot_install_btn = QPushButton(\"一键安装\")\n self._zot_install_btn.setObjectName(\"Gh\"); self._zot_install_btn.setCursor(Qt.PointingHandCursor)\n self._zot_install_btn.clicked.connect(self._install_zotero_plugin)\n _auto_row.addWidget(self._zot_install_btn)\n zot_lo.addLayout(_auto_row)\n QTimer.singleShot(500, self._check_zotero_plugin)\n right.addWidget(zot_card)\n\n # ── 使用指南(紧凑) ──\n sl_guide = QLabel(\"使用指南\"); sl_guide.setObjectName(\"SL\"); right.addWidget(sl_guide)\n guide_card = _card(\"sm\"); guide_lo = QVBoxLayout(guide_card)\n guide_lo.setContentsMargins(12,8,12,8); guide_lo.setSpacing(1)\n for tip in [\n \"Google / Bing 无需配置,开箱即用\",\n \"OpenAI 兼容接口可对接任意第三方服务\",\n \"Ollama 本地需先启动服务\",\n \"50 页以上建议开启分块翻译\",\n ]:\n tl = QLabel(f\"· {tip}\"); tl.setObjectName(\"Cap\")\n tl.setStyleSheet(\"font-size:10px;padding:1px 0;\")\n guide_lo.addWidget(tl)\n guide_lo.addWidget(_div())\n self._kb_labels = []\n kb_grid = QGridLayout(); kb_grid.setHorizontalSpacing(6); kb_grid.setVerticalSpacing(1)\n for i, (key, desc) in enumerate([\n (\"Ctrl+滚轮\",\"缩放\"), (\"← →\",\"切换\"), (\"页码+回车\",\"跳转\"),\n ]):\n kl = QLabel(key); kl.setObjectName(\"KBKey\"); kl.setStyleSheet(\"font-size:9px;padding:2px 5px;\")\n kd = QLabel(desc); kd.setObjectName(\"Cap\"); kd.setStyleSheet(\"font-size:9px;\")\n kb_grid.addWidget(kl, 0, i * 2); kb_grid.addWidget(kd, 0, i * 2 + 1)\n self._kb_labels.append(kl)\n guide_lo.addLayout(kb_grid)\n guide_lo.addWidget(_div())\n _paths_col = QVBoxLayout(); _paths_col.setSpacing(1)\n for _pname, _pfile in [\n (\"配置\", \"~/pdf2zh_gui_config.json\"),\n (\"历史\", \"~/pdf2zh_history.json\"),\n (\"术语库\", \"~/pdf2zh_glossary_*.csv\"),\n ]:\n _pl = QLabel(f\"{_pname} {_pfile}\")\n _pl.setObjectName(\"Cap\"); _pl.setStyleSheet(\"font-size:9px;padding:0;\")\n _paths_col.addWidget(_pl)\n guide_lo.addLayout(_paths_col)\n _paths_btn_row = QHBoxLayout(); _paths_btn_row.addStretch()\n open_dir_btn = QPushButton(\"打开数据目录\"); open_dir_btn.setObjectName(\"Gh\")\n open_dir_btn.setCursor(Qt.PointingHandCursor)\n open_dir_btn.setStyleSheet(\"font-size:10px;padding:2px 6px;\")\n open_dir_btn.clicked.connect(lambda: __import__('subprocess').run(['open', str(__import__('pathlib').Path.home())]))\n _paths_btn_row.addWidget(open_dir_btn)\n guide_lo.addLayout(_paths_btn_row)\n right.addWidget(guide_card)\n right.addStretch()\n\n cols.addLayout(left, 5)\n cols.addLayout(right, 4)\n lo.addLayout(cols)\n\n # 初始显示第一个服务\n self._show_service_config(self.svc_selector.currentText())\n\n def _show_service_config(self, svc_name):\n \"\"\"切换显示选中服务的配置\"\"\"\n # 清空当前\n while self.svc_layout.count():\n item = self.svc_layout.takeAt(0)\n w = item.widget()\n if w: w.setParent(None)\n sub = item.layout()\n if sub:\n while sub.count():\n si = sub.takeAt(0)\n if si.widget(): si.widget().setParent(None)\n\n if svc_name not in self.api_inputs:\n return\n inputs = self.api_inputs[svc_name]\n\n # API Key\n kl = QLabel(\"API Key\"); kl.setObjectName(\"FL\")\n self.svc_layout.addWidget(kl)\n self.svc_layout.addWidget(inputs[\"key\"])\n inputs[\"key\"].setParent(self.svc_card)\n inputs[\"key\"].show()\n\n # 模型\n if \"model\" in inputs:\n ml = QLabel(\"模型\"); ml.setObjectName(\"FL\")\n self.svc_layout.addWidget(ml)\n self.svc_layout.addWidget(inputs[\"model\"])\n inputs[\"model\"].setParent(self.svc_card)\n inputs[\"model\"].show()\n\n # Base URL\n if \"url\" in inputs:\n ul = QLabel(\"Base URL\"); ul.setObjectName(\"FL\")\n self.svc_layout.addWidget(ul)\n self.svc_layout.addWidget(inputs[\"url\"])\n inputs[\"url\"].setParent(self.svc_card)\n inputs[\"url\"].show()\n\n # 测试连接按钮\n self.svc_layout.addSpacing(4)\n self._test_label = QLabel(\"\")\n self._test_label.setObjectName(\"Cap\")\n test_btn = QPushButton(\"测试连接\")\n test_btn.setObjectName(\"Gh\"); test_btn.setCursor(Qt.PointingHandCursor)\n test_btn.clicked.connect(lambda: self._test_connection(svc_name))\n test_row = QHBoxLayout(); test_row.addStretch()\n test_row.addWidget(self._test_label)\n test_row.addWidget(test_btn)\n self.svc_layout.addLayout(test_row)\n\n def _test_connection(self, svc_name):\n \"\"\"测试翻译服务连接\"\"\"\n self._save() # 先保存,确保 API Key 写入配置\n self._test_label.setText(\"测试中…\")\n self._test_label.setStyleSheet(\"color:#FF9F0A;font-size:11px;\")\n QApplication.processEvents()\n\n inputs = self.api_inputs.get(svc_name, {})\n key = inputs.get(\"key\")\n key_val = key.text().strip() if key else \"\"\n url_w = inputs.get(\"url\")\n url_val = url_w.text().strip() if url_w else \"\"\n\n try:\n import requests\n # 对于 OpenAI 兼容类服务,测试 /models 端点\n if url_val and (\"openai\" in url_val or \"deepseek\" in url_val\n or \"siliconflow\" in url_val or \"groq\" in url_val\n or \"bigmodel\" in url_val or \"dashscope\" in url_val\n or \"localhost\" in url_val or \"x.ai\" in url_val):\n headers = {\"Authorization\": f\"Bearer {key_val}\"} if key_val else {}\n r = requests.get(f\"{url_val}/models\", headers=headers, timeout=10)\n if r.status_code in (200, 401, 403):\n self._test_label.setText(\"✅ 连接成功\")\n self._test_label.setStyleSheet(f\"color:{_C['ok']};font-size:11px;\")\n else:\n self._test_label.setText(f\"⚠️ HTTP {r.status_code}\")\n self._test_label.setStyleSheet(f\"color:{_C['t2']};font-size:11px;\")\n elif \"deepl\" in svc_name.lower():\n r = requests.get(\"https://api-free.deepl.com/v2/usage\",\n headers={\"Authorization\": f\"DeepL-Auth-Key {key_val}\"}, timeout=10)\n self._test_label.setText(\"✅ 连接成功\" if r.ok else f\"⚠️ {r.status_code}\")\n self._test_label.setStyleSheet(f\"color:{_C['ok'] if r.ok else _C['t2']};font-size:11px;\")\n else:\n self._test_label.setText(\"✅ 免费服务,无需测试\")\n self._test_label.setStyleSheet(f\"color:{_C['ok']};font-size:11px;\")\n except requests.exceptions.ConnectionError:\n self._test_label.setText(\"❌ 无法连接\")\n self._test_label.setStyleSheet(f\"color:{_C['err']};font-size:11px;\")\n except Exception as e:\n self._test_label.setText(f\"❌ {str(e)[:30]}\")\n self._test_label.setStyleSheet(f\"color:{_C['err']};font-size:11px;\")\n\n def _refresh_prompt_list(self):\n from ui.prompt_manager import PromptTemplateManager\n self.prompt_preset.blockSignals(True)\n current = self.prompt_preset.currentText()\n self.prompt_preset.clear()\n templates = PromptTemplateManager.load_all()\n self.prompt_preset.addItems(list(templates.keys()))\n idx = self.prompt_preset.findText(current)\n if idx >= 0:\n self.prompt_preset.setCurrentIndex(idx)\n self.prompt_preset.blockSignals(False)\n\n def _on_prompt_preset(self, text):\n from ui.prompt_manager import PromptTemplateManager\n templates = PromptTemplateManager.load_all()\n val = templates.get(text, \"\")\n self.prompt_edit.setPlainText(val)\n self._save()\n\n def _save_prompt_template(self):\n \"\"\"保存当前内容到选中的模板名\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n name = self.prompt_preset.currentText()\n content = self.prompt_edit.toPlainText().strip()\n PromptTemplateManager.save_template(name, content)\n self._refresh_prompt_list()\n\n def _new_prompt_template(self):\n \"\"\"新建模板\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n name, ok = QLineEdit.staticMetaObject, False # placeholder\n # 用简单的输入对话框\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建模板\", \"模板名称:\")\n if ok and name.strip():\n content = self.prompt_edit.toPlainText().strip()\n PromptTemplateManager.save_template(name.strip(), content)\n self._refresh_prompt_list()\n idx = self.prompt_preset.findText(name.strip())\n if idx >= 0:\n self.prompt_preset.setCurrentIndex(idx)\n\n def _delete_prompt_template(self):\n \"\"\"删除用户模板\"\"\"\n from ui.prompt_manager import PromptTemplateManager, DEFAULT_TEMPLATES\n name = self.prompt_preset.currentText()\n if name in DEFAULT_TEMPLATES:\n return # 不能删默认模板\n PromptTemplateManager.delete_template(name)\n self._refresh_prompt_list()\n\n def _import_prompts(self):\n \"\"\"从 JSON 文件导入模板\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n path, _ = QFileDialog.getOpenFileName(self, \"导入模板\", \"\", \"JSON (*.json)\")\n if path:\n count = PromptTemplateManager.import_from_file(path)\n self._refresh_prompt_list()\n\n def _export_prompts(self):\n \"\"\"导出所有模板到 JSON\"\"\"\n from ui.prompt_manager import PromptTemplateManager\n path, _ = QFileDialog.getSaveFileName(self, \"导出模板\", \"pdf2zh_prompts.json\", \"JSON (*.json)\")\n if path:\n PromptTemplateManager.export_to_file(path)\n\n def _set_theme_color(self, hex_color):\n \"\"\"切换主题色并即时应用\"\"\"\n cfg = UserConfigManager.load()\n cfg[\"theme_color\"] = hex_color\n UserConfigManager.save(cfg)\n # 高亮选中的色块\n for dot in self._theme_dots:\n sel = dot.property(\"hex\") == hex_color\n dot.setStyleSheet(\n f\"QPushButton{{background:{dot.property('hex')};\"\n f\"border:2px solid {'#1d1d1f' if sel else 'transparent'};\"\n f\"border-radius:9px;}}QPushButton:hover{{border-color:{dot.property('hex')};}}\")\n # 通知 MainWindow 刷新主题\n self.theme_color_changed.emit(hex_color)\n\n def _save(self, *args):\n cfg = UserConfigManager.load()\n for svc_name, inputs in self.api_inputs.items():\n key_val = inputs[\"key\"].text().strip()\n cfg[f\"api_{svc_name}\"] = UserConfigManager.encode_sensitive(key_val) if key_val else \"\"\n if \"model\" in inputs:\n cfg[f\"model_{svc_name}\"] = inputs[\"model\"].currentText().strip()\n if \"url\" in inputs:\n cfg[f\"url_{svc_name}\"] = inputs[\"url\"].text().strip()\n cfg[\"prompt\"] = self.prompt_edit.toPlainText().strip()\n UserConfigManager.save(cfg)\n\n def _save_zotero_modes(self, *args):\n modes = []\n if self._zot_sbs.isChecked(): modes.append(\"side_by_side\")\n if self._zot_dual.isChecked(): modes.append(\"dual\")\n if self._zot_mono.isChecked(): modes.append(\"mono\")\n if not modes:\n modes = [\"side_by_side\"]\n self._zot_sbs.blockSignals(True)\n self._zot_sbs.setChecked(True)\n self._zot_sbs.blockSignals(False)\n cfg = UserConfigManager.load()\n cfg[\"zotero_output_modes\"] = modes\n cfg[\"zotero_keep_copy\"] = self._zot_keep_copy.isChecked()\n UserConfigManager.save(cfg)\n\n # ── AI 助手配置 ──\n\n def _load_assistant_config(self):\n \"\"\"加载 AI 助手独立配置状态\"\"\"\n cfg = UserConfigManager.load()\n is_custom = cfg.get(\"assistant_custom\", False)\n if is_custom:\n self._ai_custom_frame.setVisible(True)\n self._ai_status_label.setText(\"使用独立配置\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['acc']};background:transparent;\")\n self._ai_toggle_btn.setVisible(False)\n svc = cfg.get(\"assistant_service\", \"\")\n if svc:\n idx = self._ai_svc_combo.findText(svc)\n if idx >= 0: self._ai_svc_combo.setCurrentIndex(idx)\n api_raw = cfg.get(\"assistant_api_key\", \"\")\n if api_raw:\n self._ai_key_input.setText(UserConfigManager.decode_sensitive(api_raw))\n model = cfg.get(\"assistant_model\", \"\")\n if model: self._ai_model_combo.setCurrentText(model)\n url = cfg.get(\"assistant_url\", \"\")\n if url: self._ai_url_input.setText(url)\n else:\n self._ai_custom_frame.setVisible(False)\n self._ai_status_label.setText(\"✓ 使用翻译服务配置\")\n self._ai_toggle_btn.setText(\"自定义\")\n\n def _toggle_assistant_config(self):\n \"\"\"展开自定义 AI 助手配置\"\"\"\n self._ai_custom_frame.setVisible(True)\n self._ai_status_label.setText(\"使用独立配置\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['acc']};background:transparent;\")\n self._ai_toggle_btn.setVisible(False)\n # 预填当前翻译服务配置\n from ui.ai_client import detect_service\n svc = detect_service()\n if svc:\n idx = self._ai_svc_combo.findText(svc[\"name\"])\n if idx >= 0:\n self._ai_svc_combo.blockSignals(True)\n self._ai_svc_combo.setCurrentIndex(idx)\n self._ai_svc_combo.blockSignals(False)\n self._ai_key_input.setText(svc.get(\"api_key\", \"\"))\n self._ai_model_combo.setCurrentText(svc.get(\"model\", \"\"))\n self._ai_url_input.setText(svc.get(\"base_url\", \"\"))\n self._on_assistant_svc_changed(self._ai_svc_combo.currentText())\n self._save_assistant_config()\n\n def _on_assistant_svc_changed(self, svc_name):\n \"\"\"切换 AI 助手服务时更新模型候选列表\"\"\"\n from ui.ai_client import CHAT_SERVICES\n # 查找默认 URL\n default_url = \"\"\n for name, url, _ in CHAT_SERVICES:\n if name == svc_name:\n default_url = url; break\n if not self._ai_url_input.text().strip() or True:\n self._ai_url_input.setPlaceholderText(default_url or \"自定义 URL\")\n # 更新模型列表\n cur_model = self._ai_model_combo.currentText()\n self._ai_model_combo.blockSignals(True)\n self._ai_model_combo.clear()\n svc_conf = self.SERVICE_CONFIGS.get(svc_name, {})\n if svc_conf.get(\"models\"):\n self._ai_model_combo.addItems(svc_conf[\"models\"])\n if cur_model:\n self._ai_model_combo.setCurrentText(cur_model)\n self._ai_model_combo.blockSignals(False)\n self._save_assistant_config()\n\n def _save_assistant_config(self, *args):\n \"\"\"保存 AI 助手独立配置\"\"\"\n if not self._ai_custom_frame.isVisible():\n return\n cfg = UserConfigManager.load()\n cfg[\"assistant_custom\"] = True\n cfg[\"assistant_service\"] = self._ai_svc_combo.currentText()\n key = self._ai_key_input.text().strip()\n cfg[\"assistant_api_key\"] = UserConfigManager.encode_sensitive(key) if key else \"\"\n cfg[\"assistant_model\"] = self._ai_model_combo.currentText()\n cfg[\"assistant_url\"] = self._ai_url_input.text().strip()\n UserConfigManager.save(cfg)\n\n def _reset_assistant_config(self):\n \"\"\"恢复 AI 助手使用翻译服务配置\"\"\"\n cfg = UserConfigManager.load()\n for k in (\"assistant_custom\", \"assistant_service\", \"assistant_api_key\",\n \"assistant_model\", \"assistant_url\"):\n cfg.pop(k, None)\n UserConfigManager.save(cfg)\n self._ai_custom_frame.setVisible(False)\n self._ai_status_label.setText(\"与翻译服务相同\")\n self._ai_status_label.setStyleSheet(\n f\"font-size:12px;font-weight:500;color:{_C['ok']};background:transparent;\")\n self._ai_toggle_btn.setVisible(True)\n # 清空输入\n self._ai_key_input.clear()\n self._ai_model_combo.setCurrentIndex(0)\n self._ai_url_input.clear()\n\n def _install_zotero_plugin(self):\n \"\"\"一键安装 pdf2zh Connector 到 Zotero\"\"\"\n import glob, shutil, subprocess\n xpi = _res('assets', 'pdf2zh-connector.xpi')\n if not os.path.exists(xpi):\n self._zot_status.setText(\"插件文件缺失\")\n self._zot_status.setStyleSheet(\"color:#FF3B30;\")\n return\n # 查找 Zotero profile 目录\n profiles_dir = os.path.expanduser(\"~/Library/Application Support/Zotero/Profiles\")\n profiles = glob.glob(os.path.join(profiles_dir, \"*.default\"))\n if not profiles:\n self._zot_status.setText(\"找不到 Zotero 配置目录\")\n self._zot_status.setStyleSheet(\"color:#FF3B30;\")\n return\n profile = profiles[0]\n # 1. 复制 XPI 到 extensions 目录\n ext_dir = os.path.join(profile, \"extensions\")\n os.makedirs(ext_dir, exist_ok=True)\n dst = os.path.join(ext_dir, \"pdf2zh-connector@aarongig.com.xpi\")\n shutil.copy2(xpi, dst)\n # 2. 设置 autoDisableScopes=0 允许 profile 级别的扩展自动加载\n prefs_path = os.path.join(profile, \"prefs.js\")\n pref_line = 'user_pref(\"extensions.autoDisableScopes\", 0);\\n'\n try:\n prefs_content = open(prefs_path, 'r', encoding='utf-8').read()\n if 'autoDisableScopes' not in prefs_content:\n with open(prefs_path, 'a', encoding='utf-8') as f:\n f.write(pref_line)\n except FileNotFoundError:\n with open(prefs_path, 'w', encoding='utf-8') as f:\n f.write(pref_line)\n # 3. 清除启动缓存,强制 Zotero 重新扫描扩展\n cache = os.path.join(profile, \"addonStartup.json.lz4\")\n if os.path.exists(cache):\n os.remove(cache)\n # 询问是否重启 Zotero\n reply = QMessageBox.question(\n self, \"安装成功\",\n \"pdf2zh Connector 已部署到 Zotero,重启 Zotero 后生效。\\n\\n现在重启 Zotero?\",\n QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes\n )\n if reply == QMessageBox.Yes:\n subprocess.run([\"pkill\", \"-x\", \"zotero\"], capture_output=True)\n subprocess.run([\"pkill\", \"-x\", \"Zotero\"], capture_output=True)\n QTimer.singleShot(2000, lambda: subprocess.Popen([\"open\", \"-a\", \"Zotero\"]))\n QTimer.singleShot(10000, self._check_zotero_plugin)\n self._zot_status.setText(\"正在重启 Zotero…\")\n self._zot_status.setStyleSheet(\"color:#0071E3;\")\n else:\n self._zot_status.setText(\"下次启动 Zotero 后生效\")\n self._zot_status.setStyleSheet(\"color:#0071E3;\")\n\n def _check_zotero_plugin(self):\n \"\"\"检测 pdf2zh Connector 插件状态\"\"\"\n if zotero_plugin_installed():\n self._zot_status.setText(\"已安装\")\n self._zot_status.setStyleSheet(\"color:#34C759;font-weight:600;\")\n self._zot_install_btn.hide()\n else:\n self._zot_status.setText(\"未安装\")\n self._zot_install_btn.setText(\"一键安装\")\n self._zot_install_btn.show()\n self._zot_install_btn.setEnabled(True)\n\n # ── 术语库操作 ──\n\n def _add_gloss_term(self):\n \"\"\"内联添加一条术语\"\"\"\n src = self._gloss_src.text().strip()\n dst = self._gloss_dst.text().strip()\n if not src or not dst:\n return\n from ui.glossary_manager import GlossaryManager\n GlossaryManager.add_term(src, dst)\n self._gloss_src.clear(); self._gloss_dst.clear()\n self._update_gloss_count()\n\n def _refresh_gloss_list(self):\n from ui.glossary_manager import GlossaryManager\n self.gloss_selector.blockSignals(True)\n cur = self.gloss_selector.currentText()\n self.gloss_selector.clear()\n all_g = GlossaryManager.load_all_presets()\n self.gloss_selector.addItems(list(all_g.keys()))\n idx = self.gloss_selector.findText(cur)\n if idx >= 0: self.gloss_selector.setCurrentIndex(idx)\n self.gloss_selector.blockSignals(False)\n\n def _on_gloss_changed(self, name):\n from ui.glossary_manager import GlossaryManager\n GlossaryManager.load_preset(name)\n self._update_gloss_count()\n\n def _update_gloss_count(self):\n from ui.glossary_manager import GlossaryManager\n n = GlossaryManager.count()\n self.gloss_count.setText(f\"{n} 条术语已激活\" if n else \"未加载术语\")\n\n def _new_glossary(self):\n \"\"\"新建自定义术语库\"\"\"\n from ui.glossary_manager import GlossaryManager\n from PyQt5.QtWidgets import QInputDialog\n name, ok = QInputDialog.getText(self, \"新建术语库\", \"术语库名称:\")\n if ok and name.strip():\n name = name.strip()\n # 在用户目录下创建空术语库文件\n import json\n from pathlib import Path\n user_dir = Path.home() / \"pdf2zh_glossaries\"\n user_dir.mkdir(exist_ok=True)\n fp = user_dir / f\"{name}.json\"\n fp.write_text(json.dumps({}, indent=2, ensure_ascii=False), encoding=\"utf-8\")\n self._refresh_gloss_list()\n idx = self.gloss_selector.findText(name)\n if idx >= 0:\n self.gloss_selector.setCurrentIndex(idx)\n\n def _delete_glossary(self):\n \"\"\"删除用户自定义术语库(不能删预设)\"\"\"\n from ui.glossary_manager import GlossaryManager, DEFAULT_GLOSSARIES\n from pathlib import Path\n name = self.gloss_selector.currentText()\n if name in DEFAULT_GLOSSARIES:\n return\n fp = Path.home() / \"pdf2zh_glossaries\" / f\"{name}.json\"\n if fp.exists():\n fp.unlink()\n self._refresh_gloss_list()\n self._update_gloss_count()\n\n def _import_glossary(self):\n from ui.glossary_manager import GlossaryManager\n path, _ = QFileDialog.getOpenFileName(self, \"导入术语库\", \"\", \"CSV (*.csv);;JSON (*.json)\")\n if path:\n if path.endswith('.csv'):\n GlossaryManager.import_csv(path)\n else:\n GlossaryManager.import_json(path)\n self._update_gloss_count()\n self._refresh_gloss_list()\n\n def _export_glossary(self):\n from ui.glossary_manager import GlossaryManager\n path, _ = QFileDialog.getSaveFileName(self, \"导出术语库\", \"glossary.csv\", \"CSV (*.csv);;JSON (*.json)\")\n if path:\n if path.endswith('.json'):\n GlossaryManager.export_json(path)\n else:\n GlossaryManager.export_csv(path)\n\n def update_theme(self, c):\n \"\"\"深色/浅色切换时更新内联样式\"\"\"\n self.prompt_edit.setStyleSheet(\n f\"font-size:11px;border-radius:6px;background:{c['inp']};color:{c['t1']};\"\n f\"border:1px solid {c['inp_b']};\"\n )\n # 刷新主题色行可见性(可能骰子刚解锁)\n cfg = UserConfigManager.load()\n self.theme_row.setVisible(cfg.get(\"theme_unlocked\", False))\n\n\n# ═══════════════════════════════════════════════════════════════\n# 关于页面\n# ═══════════════════════════════════════════════════════════════\n\nclass AboutPage(QWidget):\n def __init__(self):\n super().__init__()\n from PyQt5.QtWidgets import QGridLayout\n lo = QVBoxLayout(self); lo.setContentsMargins(32,20,32,20); lo.setSpacing(12)\n\n # 顶部:名称 + 版本 + GitHub\n top = QHBoxLayout(); top.setSpacing(8)\n ti = _EggLogo(\"📄\", 22)\n ti.setFixedSize(32, 32); ti.setAlignment(Qt.AlignCenter)\n top.addWidget(ti)\n tn = QPushButton(\"pdf2zh-desktop\"); tn.setObjectName(\"SBLink\")\n tn.setStyleSheet(\"font-size:18px;font-weight:700;padding:0;text-align:left;\")\n tn.setCursor(Qt.PointingHandCursor); tn.setFlat(True)\n tn.clicked.connect(lambda: webbrowser.open(\"https://github.com/AaronGIG/pdf2zh-desktop\"))\n top.addWidget(tn)\n tv = QLabel(\"v2.2.0\"); tv.setObjectName(\"Cap\"); top.addWidget(tv)\n tt = QLabel(\"macOS\"); tt.setObjectName(\"Tag\"); tt.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed); top.addWidget(tt)\n top.addStretch()\n gb = QPushButton(\"GitHub ↗\"); gb.setObjectName(\"Gh\"); gb.setCursor(Qt.PointingHandCursor)\n gb.clicked.connect(lambda: webbrowser.open(\"https://github.com/AaronGIG/pdf2zh-desktop\"))\n top.addWidget(gb)\n lo.addLayout(top)\n\n # 两列布局:左=介绍+增强,右=作者+支持者\n cols = QHBoxLayout(); cols.setSpacing(16)\n\n # ── 左列 ──\n left = QVBoxLayout(); left.setSpacing(6)\n\n dc = _card(); dl = QVBoxLayout(dc); dl.setContentsMargins(16,12,16,12); dl.setSpacing(4)\n dt = QLabel(\"开箱即用的学术 PDF 翻译工具\"); dt.setStyleSheet(\"font-size:12px;font-weight:600;\"); dl.addWidget(dt)\n dd = QLabel(\"保留排版 · 公式无损 · 批量任务 · 历史追踪 · 20+ 翻译服务\")\n dd.setObjectName(\"Cap\"); dd.setStyleSheet(\"font-size:11px;\"); dd.setWordWrap(True); dl.addWidget(dd)\n left.addWidget(dc)\n\n mc = _card(); ml = QVBoxLayout(mc); ml.setContentsMargins(16,10,16,10); ml.setSpacing(3)\n mt = QLabel(\"macOS 版增强\"); mt.setStyleSheet(\"font-size:12px;font-weight:600;\"); ml.addWidget(mt)\n for emoji, text in [\n (\"🎨\",\"原生设计 · 深色模式 · 自定义主题色\"),\n (\"👀\",\"Dual · Mono · Side by Side 预览\"),\n (\"📖\",\"历史即时预览 · 上下键浏览\"),\n (\"✂️\",\"智能分块 · 1000+页\"),\n (\"🖐\",\"触摸板缩放 · 页码跳转\"),\n (\"📝\",\"术语库 · 提示词模板\"),\n (\"🍀\",\"每日骰子 · 隐藏彩蛋\"),\n (\"💬\",\"全时段人文关怀 · 名言语录\"),\n (\"🚀\",\".app 双击启动 · Retina\"),\n ]:\n r = QHBoxLayout(); r.setSpacing(6)\n e = QLabel(emoji); e.setStyleSheet(\"font-size:13px;background:transparent;\"); e.setFixedWidth(18); r.addWidget(e)\n t2 = QLabel(text); t2.setObjectName(\"Cap\"); t2.setStyleSheet(\"font-size:11px;\"); r.addWidget(t2)\n ml.addLayout(r)\n ml.addWidget(_div())\n te = QLabel(\"基于 PDFMathTranslate (EMNLP 2025) · PyQt5 · PyMuPDF · OnnxRuntime\")\n te.setObjectName(\"Cap\"); te.setStyleSheet(\"font-size:10px;\"); te.setWordWrap(True)\n ml.addWidget(te)\n left.addWidget(mc)\n left.addStretch()\n\n cols.addLayout(left, 3)\n\n # ── 右列 ──\n right = QVBoxLayout(); right.setSpacing(6)\n\n # 作者\n ac = _card(); acl = QHBoxLayout(ac); acl.setContentsMargins(16,12,16,12); acl.setSpacing(10)\n author_av = QLabel()\n author_av.setFixedSize(36, 36)\n avatar_path = _res('assets', 'author_avatar.png')\n if os.path.exists(avatar_path):\n apx = QPixmap(avatar_path).scaled(72, 72, Qt.KeepAspectRatio, Qt.SmoothTransformation)\n apx.setDevicePixelRatio(2.0)\n author_av.setPixmap(apx)\n else:\n author_av.setText(\"艾\"); author_av.setAlignment(Qt.AlignCenter)\n author_av.setStyleSheet(\"background:#1a1a2e;border-radius:18px;color:white;font-size:16px;font-weight:700;\")\n acl.addWidget(author_av)\n ai = QVBoxLayout(); ai.setSpacing(0)\n an = QLabel(\"艾伦说\"); an.setStyleSheet(\"font-size:13px;font-weight:700;\"); ai.addWidget(an)\n xhs = QLabel(\"小红书: needsleeeeep\"); xhs.setObjectName(\"Cap\"); xhs.setStyleSheet(\"font-size:11px;\"); ai.addWidget(xhs)\n motto = QLabel(\"希望能做更有意义的事 · 专注交付生产级的垂直学术公共品 🍀\")\n motto.setObjectName(\"Cap\"); motto.setStyleSheet(\"font-size:10px;color:rgba(142,142,147,0.7);\"); ai.addWidget(motto)\n acl.addLayout(ai); acl.addStretch()\n xhs_btn = QPushButton(\"关注 ↗\"); xhs_btn.setObjectName(\"Gh\"); xhs_btn.setCursor(Qt.PointingHandCursor)\n xhs_btn.setStyleSheet(\"font-size:11px;\")\n xhs_btn.clicked.connect(lambda: webbrowser.open(\"https://www.xiaohongshu.com/user/profile/66c6fef7000000001d0315ef\"))\n acl.addWidget(xhs_btn)\n right.addWidget(ac)\n\n # 支持者头像墙\n cc = _card(); ccl = QVBoxLayout(cc); ccl.setContentsMargins(16,12,16,12); ccl.setSpacing(6)\n ct = QLabel(\"感谢小红书社区支持者 ♡\"); ct.setStyleSheet(\"font-size:11px;font-weight:600;\"); ccl.addWidget(ct)\n\n supporters = [\n \"星爷!\",\"卧\",\"Jun warrior\",\"幼儿园鹿小眸\",\"侠禅\",\n \"ol\",\"幼儿园老大\",\"MX\",\"贝斯特宋\",\"Catherine\",\n \"不哩不哩左门卫\",\"momo\",\"李善兰\",\"问道不求仙\",\"hml\",\n \"ThereisTherse\",\"人类与猫\",\"小红薯643C3625\",\"月栖竹\",\"风来\",\n \"Nick\",\"咕噜咕噜🍗\",\"雾散时分起\",\"大脸咪布爱吃鱼\",\"限定 momo\",\n \"麦兜\",\"锅的刚\",\"一颗冒泡的卤蛋\",\"我草莓招了\",\n \"去Nature整点论文\",\"Masker\",\n \"AI maker趣造\",\"帕克的创业日记\",\"思维汪汪\",\"橘座\",\"小白也想学编程\",\n \"宛风Vanfeng\",\"小宝の日常\",\"未来百科\",\"你们的万能小卓\",\"碎银几两\",\n \"创界AIzine\",\"全栈小5\",\"无敌霸王龙\",\"小艾同学\",\"一万块的快乐\",\n \"学习笔记\",\"三丰不是张\",\"丁一\",\"阿漫AIChat\",\"知命不惧\",\n \"脱离社畜体制\",\"一瓢清浅\",\"Crazyang\",\"逛逛GitHub\",\"AI视界AIGC\",\n \"极客梦想家\",\"沐沐子\",\"Topaz\",\"三不沾\",\"EchoAI\",\n \"阳台吹风\",\"Cursor实战派\",\"简单就好\",\"进击的小学生\",\"AI探索家\",\n \"科技小飞侠\",\"数字游民\",\"AI能量站\",\"工程师小灰\",\"AI淘金\",\n \"量子比特\",\"NeonCode\",\"尝试新事物的Cher\",\"有趣的灵魂不需要名字\",\n \"阿杰的编程日记\",\"小明同学\",\"码农翻身\",\"AI大航海\",\"月亮与六便士\",\n \"渡己\",\"数码宝贝\",\"悟空AI\",\"追风少年\",\"Tech小确幸\",\n \"半糖主义\",\"深夜程序员\",\"AI小天才\",\"知行合一\",\"星辰大海\",\n \"比特流\",\"指尖上的代码\",\"未来可期\",\"云端漫步\",\"AI引路人\",\n \"秋风扫落叶\",\"编程小王子\",\"日拱一卒\",\"逆风翻盘\",\"AI百宝箱\",\n \"程序猿日记\",\"数据炼金术\",\"量子纠缠\",\"无限可能\",\"风轻云淡\",\n \"代码人生\",\"AI学徒\",\"机器之心\",\"清风明月\",\"代码诗人\",\n \"零一万物\",\"浮生若梦\",\"技术宅\",\"AI造物主\",\"星河万里\",\n \"小蜗牛\",\"编程少女\",\"数据猎人\",\"逻辑大师\",\"AI前沿\",\n \"梦想家\",\"代码如诗\",\"量子跃迁\",\"技术探路者\",\"AI新青年\",\n ]\n\n grid = QGridLayout(); grid.setSpacing(3); grid.setContentsMargins(0,0,0,0)\n cols_n = 12\n for i, name in enumerate(supporters):\n avatar = QLabel(name[0])\n avatar.setFixedSize(26, 26); avatar.setAlignment(Qt.AlignCenter)\n h = (hash(name) * 137) % 360\n s = 50 + (hash(name) >> 8) % 20; l2 = 55 + (hash(name) >> 16) % 15\n avatar.setStyleSheet(f\"background:hsl({h},{s}%,{l2}%);border-radius:13px;font-size:9px;font-weight:600;color:white;\")\n avatar.setToolTip(name)\n grid.addWidget(avatar, i // cols_n, i % cols_n, Qt.AlignCenter)\n ccl.addLayout(grid)\n sub = QLabel(\"无限迭代,只为更好的服务您\")\n sub.setObjectName(\"Cap\"); sub.setAlignment(Qt.AlignCenter); sub.setStyleSheet(\"font-size:11px;\"); ccl.addWidget(sub)\n qq_group_btn = QPushButton(\"进入 pdf2zh 桌面版交流群\"); qq_group_btn.setObjectName(\"Gh\"); qq_group_btn.setCursor(Qt.PointingHandCursor)\n qq_group_btn.setStyleSheet(\"font-size:11px;\")\n def _show_qq_qr():\n QApplication.clipboard().setText(\"1094195179\")\n c = _C\n qr_dlg = QDialog(self)\n qr_dlg.setWindowTitle(\"加入交流群\")\n qr_dlg.setStyleSheet(f\"QDialog{{background:{c['bg']};}}\")\n qlo = QVBoxLayout(qr_dlg); qlo.setContentsMargins(0, 0, 0, 12); qlo.setSpacing(8)\n qr_label = QLabel()\n qr_label.setAlignment(Qt.AlignCenter)\n qr_path = None\n for candidate in [\n os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'assets', 'qq_group_qr.png'),\n os.path.join(getattr(sys, '_MEIPASS', ''), 'assets', 'qq_group_qr.png'),\n ]:\n if os.path.exists(candidate):\n qr_path = candidate\n break\n if qr_path:\n pix = QPixmap(qr_path)\n # setScaledContents 让图片自动填满 label,不裁切\n qr_label.setPixmap(pix)\n qr_label.setScaledContents(True)\n qr_label.setFixedSize(300, int(300 * pix.height() / pix.width()))\n qr_dlg.setFixedSize(300, int(300 * pix.height() / pix.width()) + 40)\n else:\n qr_label.setText(\"请用 QQ 搜索群号 1094195179\")\n qr_label.setStyleSheet(f\"font-size:13px;color:{c['t2']};padding:30px;\")\n qr_dlg.setFixedSize(300, 100)\n qlo.addWidget(qr_label)\n tip = QLabel(\"群号 1094195179 已复制到剪贴板\")\n tip.setAlignment(Qt.AlignCenter)\n tip.setStyleSheet(f\"font-size:11px;color:{c['acc']};\")\n qlo.addWidget(tip)\n qr_dlg.exec_()\n qq_group_btn.clicked.connect(_show_qq_qr)\n ccl.addWidget(qq_group_btn, 0, Qt.AlignCenter)\n right.addWidget(cc)\n right.addStretch()\n\n cols.addLayout(right, 7)\n\n lo.addLayout(cols)\n\n\n# ═══════════════════════════════════════════════════════════════\n# 主窗口\n# ═══════════════════════════════════════════════════════════════\n\n# ── 桌面宠物:小黑猫 ──────────────────────────────────────────\n\nclass _NekoCat(QWidget):\n \"\"\"会走来走去的小黑猫彩蛋 — 多种行为模式\"\"\"\n\n # 行为模式\n WALK, RUN, SIT, SLEEP = 0, 1, 2, 3\n\n def __init__(self, parent):\n super().__init__(parent)\n self.setFixedSize(48, 36)\n self._frame = 0\n self._dir = 1\n self._mode = self.WALK\n self._speed = 2\n self._sit_counter = 0\n self._step_timer = QTimer(self)\n self._step_timer.timeout.connect(self._on_step)\n self.setCursor(Qt.PointingHandCursor)\n self.hide()\n\n def start_walk(self):\n \"\"\"从窗口一侧走到另一侧\"\"\"\n parent = self.parentWidget()\n if not parent:\n return\n import random\n pw, ph = parent.width(), parent.height()\n self._dir = random.choice([1, -1])\n y = ph - 50\n if self._dir == 1:\n self._x = float(-self.width())\n self._x_end = float(pw + self.width())\n else:\n self._x = float(pw + self.width())\n self._x_end = float(-self.width())\n # 随机选行为\n roll = random.random()\n if roll < 0.60:\n self._mode = self.WALK; self._speed = 1.5\n elif roll < 0.80:\n self._mode = self.SIT; self._speed = 1.5\n # 走到中间附近时会停下来坐一会儿\n self._sit_x = pw * random.uniform(0.3, 0.7)\n self._sit_counter = 0\n else:\n self._mode = self.RUN; self._speed = 4\n self.move(int(self._x), y)\n self.show(); self.raise_()\n self._step_timer.start(40) # 25fps\n\n def _on_step(self):\n self._frame += 1\n # SIT 模式:到达坐下点后停下来\n if self._mode == self.SIT and self._sit_counter > 0:\n self._sit_counter -= 1\n if self._sit_counter == 0:\n self._mode = self.WALK # 坐完了继续走\n self.update()\n return\n if self._mode == self.SIT and \\\n abs(self._x - self._sit_x) < 5 and self._sit_counter == 0:\n self._sit_counter = 100 # 坐 4 秒\n self.update()\n return\n self._x += self._dir * self._speed\n self.move(int(self._x), self.y())\n self.update()\n if (self._dir == 1 and self._x > self._x_end) or \\\n (self._dir == -1 and self._x < self._x_end):\n self._step_timer.stop()\n self.hide()\n\n def paintEvent(self, e):\n p = QPainter(self)\n p.setRenderHint(QPainter.Antialiasing)\n p.setPen(Qt.NoPen)\n if self._dir < 0:\n p.translate(self.width(), 0)\n p.scale(-1, 1)\n cat = QColor(35, 35, 35)\n eye = QColor(180, 210, 50)\n is_sitting = (self._mode == self.SIT and self._sit_counter > 0)\n # ── 尾巴 ──\n p.setPen(QPen(cat, 3, Qt.SolidLine, Qt.RoundCap))\n p.setBrush(Qt.NoBrush)\n tail = QPainterPath()\n sway = 2 if (self._frame // 4) % 2 == 0 else -2\n if is_sitting:\n # 坐着:尾巴自然弯曲放在身后\n tail.moveTo(6, 22); tail.cubicTo(2, 20, -2, 16, 2, 12)\n else:\n tail.moveTo(8, 16); tail.cubicTo(4, 12 + sway, 2, 8 + sway, 5, 4)\n p.drawPath(tail)\n # ── 身体 ──\n p.setPen(Qt.NoPen); p.setBrush(cat)\n if is_sitting:\n p.drawEllipse(10, 12, 20, 18) # 坐姿:更圆\n else:\n p.drawEllipse(8, 14, 24, 14)\n # ── 头 ──\n if is_sitting:\n p.drawEllipse(22, 2, 17, 16)\n else:\n p.drawEllipse(25, 4, 16, 15)\n # ── 耳朵 ──\n hx = 22 if is_sitting else 25\n ear1 = QPainterPath()\n ear1.moveTo(hx+3, 4); ear1.lineTo(hx+5, -3); ear1.lineTo(hx+8, 3)\n ear1.closeSubpath(); p.drawPath(ear1)\n ear2 = QPainterPath()\n ear2.moveTo(hx+9, 2); ear2.lineTo(hx+12, -4); ear2.lineTo(hx+14, 2)\n ear2.closeSubpath(); p.drawPath(ear2)\n # ── 眼睛 ──\n ex = 27 if is_sitting else 30\n ey = 7 if is_sitting else 9\n p.setBrush(eye)\n if is_sitting and (self._frame // 30) % 3 == 0:\n # 坐着时偶尔眯眼\n p.setPen(QPen(eye, 2)); p.setBrush(Qt.NoBrush)\n p.drawLine(ex, ey+2, ex+4, ey+2)\n p.drawLine(ex+6, ey+2, ex+10, ey+2)\n p.setPen(Qt.NoPen)\n else:\n p.drawEllipse(ex, ey, 4, 4)\n p.drawEllipse(ex+6, ey, 4, 4)\n p.setBrush(QColor(10, 10, 10))\n p.drawEllipse(ex+1, ey+1, 2, 2)\n p.drawEllipse(ex+7, ey+1, 2, 2)\n # ── 腿 ──\n p.setPen(QPen(cat, 3, Qt.SolidLine, Qt.RoundCap))\n if is_sitting:\n # 坐姿:前腿伸直,后腿收起\n p.drawLine(18, 28, 16, 34)\n p.drawLine(24, 28, 26, 34)\n else:\n phase = self._frame % 4\n o = [(2,-2,-1,1),(-1,1,2,-2),(-2,2,1,-1),(1,-1,-2,2)][phase]\n p.drawLine(14, 27, 14+o[0], 34)\n p.drawLine(20, 27, 20+o[1], 34)\n p.drawLine(25, 27, 25+o[2], 34)\n p.drawLine(30, 27, 30+o[3], 34)\n p.end()\n\n def enterEvent(self, e):\n \"\"\"鼠标悬停 → 疯狂加速\"\"\"\n if self._step_timer.isActive():\n self._mode = self.RUN\n self._speed = 6\n self._sit_counter = 0\n self._step_timer.setInterval(15)\n\n def leaveEvent(self, e):\n \"\"\"鼠标离开 → 恢复正常速度\"\"\"\n if self._step_timer.isActive() and self._mode == self.RUN:\n self._speed = 1.5\n self._step_timer.setInterval(40)\n\n def mousePressEvent(self, e):\n \"\"\"单击 → 喵一声\"\"\"\n try:\n import subprocess\n subprocess.Popen([\"afplay\", \"/System/Library/Sounds/Pop.aiff\"])\n except Exception:\n pass\n\n def mouseDoubleClickEvent(self, e):\n \"\"\"双击 → 直接消失\"\"\"\n self._step_timer.stop()\n self.hide()\n\n\nclass MainWindow(QMainWindow):\n def __init__(self):\n super().__init__()\n self.is_dark = False\n self.setWindowTitle(\"\")\n self.setMinimumSize(900,600)\n self.setUnifiedTitleAndToolBarOnMac(True)\n # App 图标\n _icon_path = _res('assets', 'app_icon.png')\n if os.path.exists(_icon_path):\n self.setWindowIcon(QIcon(_icon_path))\n # 启动时占满屏幕 90%\n screen = QApplication.primaryScreen()\n if screen:\n geo = screen.availableGeometry()\n w = int(geo.width() * 0.92)\n h = int(geo.height() * 0.90)\n self.resize(w, h)\n self.move(geo.x() + (geo.width() - w) // 2,\n geo.y() + (geo.height() - h) // 2)\n\n central = QWidget(); central.setObjectName(\"Central\"); self.setCentralWidget(central)\n root = QHBoxLayout(central); root.setContentsMargins(0,0,0,0); root.setSpacing(0)\n\n # 侧边栏\n sb = QWidget(); sb.setObjectName(\"Sidebar\"); sb.setFixedWidth(220)\n self.sidebar = sb\n sbl = QVBoxLayout(sb); sbl.setContentsMargins(16,24,16,16); sbl.setSpacing(4)\n\n # Logo: pdf2zh/desktop — 📄 彩蛋 + 可点击标题\n logo_row = QHBoxLayout(); logo_row.setSpacing(10); logo_row.setContentsMargins(4,0,0,0)\n logo_icon = _EggLogo(\"📄\", 28)\n logo_icon.setFixedSize(36, 36); logo_icon.setAlignment(Qt.AlignCenter)\n logo_row.addWidget(logo_icon)\n logo_text = QVBoxLayout(); logo_text.setSpacing(0)\n logo_name = QPushButton(\"pdf2zh-desktop\"); logo_name.setObjectName(\"SBLink\")\n logo_name.setStyleSheet(\"font-size:15px;font-weight:700;letter-spacing:-0.2px;padding:0;text-align:left;\")\n logo_name.setCursor(Qt.PointingHandCursor); logo_name.setFlat(True)\n logo_name.clicked.connect(lambda: webbrowser.open(\"https://github.com/AaronGIG/pdf2zh-desktop\"))\n logo_sub = QLabel(\"macOS Edition\"); logo_sub.setObjectName(\"Cap\")\n logo_text.addWidget(logo_name); logo_text.addWidget(logo_sub)\n logo_row.addLayout(logo_text); logo_row.addStretch()\n sbl.addLayout(logo_row)\n sbl.addSpacing(16); sbl.addWidget(_div()); sbl.addSpacing(12)\n\n self.nav = []\n for icon, label in [(\"🌐\",\"翻译\"),(\"📖\",\"阅读\"),(\"🔧\",\"设置\"),(\"💡\",\"关于\")]:\n b = SB(icon, label)\n b.clicked.connect(lambda l=label: self.switch(l))\n sbl.addWidget(b); self.nav.append((label, b))\n sbl.addStretch()\n\n vl = QLabel(\"v2.2.0 · macOS\"); vl.setObjectName(\"Cap\"); vl.setAlignment(Qt.AlignCenter)\n vl.setStyleSheet(\"font-size:10px;\")\n sbl.addWidget(vl)\n # 底部链接 — 独立按钮,支持 hover 变色\n import webbrowser\n link_row = QHBoxLayout(); link_row.setSpacing(0)\n link_row.setContentsMargins(8,0,8,4); link_row.addStretch()\n for text, url in [\n (\"GitHub\", \"https://github.com/AaronGIG/pdf2zh-desktop\"),\n (\"小红书\", \"https://www.xiaohongshu.com/user/profile/66c6fef7000000001d0315ef\"),\n (\"Feedback\", \"https://github.com/AaronGIG/pdf2zh-desktop/issues\"),\n (\"Star ⭐\", \"https://github.com/AaronGIG/pdf2zh-desktop\"),\n ]:\n lb = QPushButton(text); lb.setObjectName(\"SBLink\"); lb.setCursor(Qt.PointingHandCursor)\n lb.clicked.connect(lambda _, u=url: webbrowser.open(u))\n link_row.addWidget(lb)\n if text != \"Star ⭐\":\n sep = QLabel(\"·\"); sep.setObjectName(\"SBSep\")\n link_row.addWidget(sep)\n link_row.addStretch()\n sbl.addLayout(link_row)\n root.addWidget(sb)\n\n # 内容区\n self.stack = QStackedWidget()\n self.pages = {}\n tp = TranslatePage(); rp = ReaderPage()\n sp = SettingsPage(); ap = AboutPage()\n for name, page in [(\"翻译\",tp),(\"阅读\",rp),(\"设置\",sp),(\"关于\",ap)]:\n self.stack.addWidget(page); self.pages[name] = page\n root.addWidget(self.stack)\n\n # 信号连接\n sp.dark_mode_changed.connect(self._toggle_dark)\n sp.theme_color_changed.connect(self._set_accent)\n tp.translation_done.connect(self._on_translate_done)\n rp.fullscreen_changed.connect(self._on_reader_fullscreen)\n\n # 恢复上次窗口状态\n cfg = UserConfigManager.load()\n self.switch(\"翻译\"); self._apply()\n _install_tip_filter(QApplication.instance())\n # 去掉所有 QComboBox 下拉框的系统矩形外框\n for combo in self.findChildren(QComboBox):\n _fix_combo_popup(combo)\n\n # 恢复窗口尺寸和位置\n if cfg.get(\"window_geometry\"):\n try:\n from PyQt5.QtCore import QByteArray\n geo = QByteArray.fromHex(cfg[\"window_geometry\"].encode())\n self.restoreGeometry(geo)\n except Exception:\n pass\n\n # 静默预加载阅读页:用户点击时已渲染好,无闪烁\n QTimer.singleShot(100, self._preload_reader)\n\n # ── 小黑猫彩蛋 ──\n self._neko = _NekoCat(self)\n self._neko_timer = QTimer(self)\n self._neko_timer.timeout.connect(self._maybe_spawn_cat)\n self._neko_timer.start(1_200_000) # 每 20 分钟出来一次\n QTimer.singleShot(3_000, lambda: self._neko.start_walk()) # 启动 3 秒出来\n\n # ── 凌晨 3:30 彩蛋:披星戴月 ──\n from datetime import datetime\n now = datetime.now()\n if now.hour == 3 and 25 <= now.minute <= 35:\n QTimer.singleShot(800, self._midnight_bloom)\n\n # ─────────────────────────────────────────────\n # 凌晨 3:30 彩蛋 — 烟花 + 暖心寄语\n # ─────────────────────────────────────────────\n def _maybe_spawn_cat(self):\n \"\"\"每 20 分钟让小黑猫出来走一趟\"\"\"\n if self._neko.isVisible():\n return\n self._neko.start_walk()\n\n def _midnight_bloom(self):\n \"\"\"全屏烟花 10 秒 → 暖心寄语浮层\"\"\"\n import random, math\n from PyQt5.QtCore import QPropertyAnimation, QPoint, QEasingCurve\n\n fireworks = [\"🎆\",\"🎇\",\"✨\",\"💫\",\"⭐\",\"🌟\",\"🌠\",\"💛\",\"🧡\",\"💜\",\"💙\",\"🤍\"]\n self._bloom_anims = [] # prevent GC\n\n def _burst():\n \"\"\"一轮烟花:从随机位置爆开\"\"\"\n cx = random.randint(int(self.width() * 0.15), int(self.width() * 0.85))\n cy = random.randint(int(self.height() * 0.15), int(self.height() * 0.55))\n center = QPoint(cx, cy)\n for _ in range(random.randint(12, 20)):\n lbl = QLabel(random.choice(fireworks), self)\n lbl.setStyleSheet(f\"font-size:{random.randint(16, 36)}px;background:transparent;\")\n lbl.move(center); lbl.show(); lbl.raise_()\n angle = random.uniform(0, 2 * math.pi)\n dist = random.randint(80, 300)\n end = QPoint(cx + int(math.cos(angle) * dist),\n cy + int(math.sin(angle) * dist))\n anim = QPropertyAnimation(lbl, b\"pos\")\n anim.setDuration(random.randint(900, 1800))\n anim.setStartValue(center); anim.setEndValue(end)\n anim.setEasingCurve(QEasingCurve.OutQuad)\n anim.finished.connect(lbl.deleteLater)\n anim.start()\n self._bloom_anims.append(anim)\n\n # 10 秒内间歇放烟花(每 400–700ms 一轮)\n total_bursts = 18\n for i in range(total_bursts):\n delay = int(i * 550 + random.randint(0, 150))\n QTimer.singleShot(delay, _burst)\n\n # 10 秒后显示暖心寄语\n QTimer.singleShot(10500, self._midnight_message)\n\n def _midnight_message(self):\n \"\"\"暖心寄语浮层\"\"\"\n import random\n messages = [\n (\"披星戴月的你,终将凯旋。\",\n \"但现在,请先好好休息。\\n\"\n \"没有什么比你的健康更重要了。\\n\"\n \"科研之外,还有很多美好的事物在等着你。\"),\n (\"凌晨三点半的你,一定很努力。\",\n \"但再重要的事,也比不上你自己。\\n\"\n \"把未完成的留给明天精力充沛的自己吧。\\n\"\n \"这个世界需要健康的你。\"),\n (\"夜深了,星星都在为你亮着。\",\n \"你已经比大多数人都努力了。\\n\"\n \"允许自己休息,不是放弃,是为了走更远的路。\\n\"\n \"照顾好自己,才能照顾好你在乎的一切。\"),\n (\"这个时间还在学习的你,了不起。\",\n \"但了不起的人更要爱惜自己。\\n\"\n \"身体是一切的根基,请好好善待它。\\n\"\n \"早点睡,明天的你会感谢今晚的决定。\"),\n (\"深夜的坚持让人敬佩。\",\n \"但真正的强者,懂得在该休息时放下。\\n\"\n \"你的才华不会因为一晚的休息而消失。\\n\"\n \"去睡吧,梦里什么都有。\"),\n ]\n title, body = random.choice(messages)\n\n # 半透明遮罩 + 居中卡片\n overlay = QWidget(self)\n overlay.setGeometry(self.rect())\n overlay.setStyleSheet(\"background:rgba(0,0,0,0.55);\")\n overlay.show(); overlay.raise_()\n\n card = QFrame(overlay)\n card.setStyleSheet(\n \"QFrame{background:rgba(255,255,255,0.95);border-radius:20px;}\"\n if not self.is_dark else\n \"QFrame{background:rgba(40,40,45,0.95);border-radius:20px;}\"\n )\n card.setFixedSize(420, 280)\n card.move((self.width() - 420) // 2, (self.height() - 280) // 2)\n card.show()\n\n cl = QVBoxLayout(card); cl.setContentsMargins(32, 28, 32, 24); cl.setSpacing(12)\n\n moon = QLabel(\"🌙\"); moon.setStyleSheet(\"font-size:36px;background:transparent;\")\n moon.setAlignment(Qt.AlignCenter); cl.addWidget(moon)\n\n tc = \"color:#1d1d1f;\" if not self.is_dark else \"color:#f5f5f7;\"\n tl = QLabel(title)\n tl.setStyleSheet(f\"font-size:18px;font-weight:700;{tc}background:transparent;\")\n tl.setAlignment(Qt.AlignCenter); tl.setWordWrap(True); cl.addWidget(tl)\n\n bl = QLabel(body)\n bl.setStyleSheet(f\"font-size:13px;line-height:1.6;{tc}background:transparent;\")\n bl.setAlignment(Qt.AlignCenter); bl.setWordWrap(True); cl.addWidget(bl)\n\n cl.addStretch()\n close_btn = QPushButton(\"晚安,去休息了 🌙\")\n close_btn.setObjectName(\"Pr\"); close_btn.setCursor(Qt.PointingHandCursor)\n close_btn.setStyleSheet(\"font-size:14px;padding:10px 24px;\")\n close_btn.clicked.connect(overlay.deleteLater)\n cl.addWidget(close_btn, alignment=Qt.AlignCenter)\n\n # 点击遮罩也可关闭\n overlay.mousePressEvent = lambda e: overlay.deleteLater()\n\n def switch(self, label):\n # 预加载中 → 延迟切换(避免冲突)\n if getattr(self, '_preloading', False):\n QTimer.singleShot(50, lambda: self.switch(label))\n return\n keys = list(self.pages.keys())\n idx = keys.index(label) if label in keys else 0\n # 阅读页:先刷新列表,再显示(避免 showEvent 用旧数据加载后又被 refresh 清掉)\n if label == \"阅读\":\n self.pages[\"阅读\"].refresh()\n self.stack.setCurrentIndex(idx)\n for n, b in self.nav: b.set_active(n == label)\n\n def _preload_reader(self):\n \"\"\"静默预加载阅读页:冻结画面 → 切到阅读页获取真实尺寸 → 渲染 → 切回\"\"\"\n self._preloading = True\n rp = self.pages[\"阅读\"]\n rp.refresh()\n if rp.list_w.count() == 0:\n self._preloading = False\n return\n # 冻结画面(布局仍正常计算,只是不绘制)\n self.setUpdatesEnabled(False)\n self.stack.setCurrentIndex(list(self.pages.keys()).index(\"阅读\"))\n # 下一帧:布局已稳定,加载 + 渲染\n QTimer.singleShot(0, self._preload_step2)\n\n def _preload_step2(self):\n rp = self.pages[\"阅读\"]\n rp.list_w.setCurrentRow(0) # → _on_select → load_pdf\n # 再等一帧让 splitter 稳定\n QTimer.singleShot(30, self._preload_step3)\n\n def _preload_step3(self):\n rp = self.pages[\"阅读\"]\n rp.preview._last_fit_zoom = -1\n rp.preview._fit_and_render()\n # 切回翻译页,解冻\n self.stack.setCurrentIndex(0)\n self.setUpdatesEnabled(True)\n self._preloading = False\n self.repaint()\n\n def _toggle_dark(self, dark):\n self.is_dark = dark\n # 淡入淡出过渡\n from PyQt5.QtCore import QPropertyAnimation\n anim = QPropertyAnimation(self, b\"windowOpacity\")\n anim.setDuration(150)\n anim.setStartValue(1.0); anim.setEndValue(0.85)\n anim.finished.connect(lambda: self._finish_theme_switch(anim))\n self._theme_anim = anim # prevent GC\n anim.start()\n\n def _finish_theme_switch(self, prev_anim):\n self._apply()\n from PyQt5.QtCore import QPropertyAnimation\n anim = QPropertyAnimation(self, b\"windowOpacity\")\n anim.setDuration(200)\n anim.setStartValue(0.85); anim.setEndValue(1.0)\n self._theme_anim = anim\n anim.start()\n\n def _set_accent(self, hex_color):\n \"\"\"应用自定义主题色\"\"\"\n self._custom_accent = hex_color\n self._apply()\n\n def _apply(self):\n global _C\n c = dict(D if self.is_dark else L) # 拷贝,避免修改原始\n # 应用自定义主题色(需已解锁)\n cfg = UserConfigManager.load()\n acc = getattr(self, '_custom_accent', None) or cfg.get(\"theme_color\")\n if acc and cfg.get(\"theme_unlocked\"):\n r, g, b = int(acc[1:3],16), int(acc[3:5],16), int(acc[5:7],16)\n c[\"acc\"] = acc; c[\"acc_h\"] = acc; c[\"acc_p\"] = acc\n c[\"acc_l\"] = f\"rgba({r},{g},{b},0.12)\"\n c[\"acc_g\"] = f\"qlineargradient(x1:0,y1:0,x2:1,y2:1,stop:0 {acc},stop:1 {acc})\"\n c[\"sb_act\"] = f\"rgba({r},{g},{b},0.15)\"\n c[\"dz_b\"] = f\"rgba({r},{g},{b},0.25)\"\n c[\"tag_bg\"] = f\"rgba({r},{g},{b},0.10)\"; c[\"tag_fg\"] = acc\n c[\"pv_l_bg\"] = f\"rgba({r},{g},{b},0.10)\"; c[\"pv_l_fg\"] = acc\n c[\"link_h\"] = acc\n _C = c\n self.setStyleSheet(S(c))\n # 强制设置 QPalette,修复内联 setStyleSheet 覆盖问题\n from PyQt5.QtGui import QPalette\n pal = self.palette()\n pal.setColor(QPalette.WindowText, QColor(c[\"t1\"]))\n pal.setColor(QPalette.Text, QColor(c[\"t1\"]))\n pal.setColor(QPalette.ButtonText, QColor(c[\"t1\"]))\n pal.setColor(QPalette.PlaceholderText, QColor(c[\"t3\"]))\n pal.setColor(QPalette.Base, QColor(c[\"inp\"]))\n pal.setColor(QPalette.Window, QColor(c[\"bg\"]))\n pal.setColor(QPalette.AlternateBase, QColor(c[\"bg2\"]))\n pal.setColor(QPalette.ToolTipBase, QColor(c[\"elev\"]))\n pal.setColor(QPalette.ToolTipText, QColor(c[\"t1\"]))\n pal.setColor(QPalette.Highlight, QColor(c[\"acc\"]))\n pal.setColor(QPalette.HighlightedText, QColor(\"white\"))\n self.setPalette(pal)\n QApplication.instance().setPalette(pal)\n # 通知子页面更新内联样式\n for page in self.pages.values():\n if hasattr(page, 'update_theme'):\n page.update_theme(c)\n\n def _on_reader_fullscreen(self, fs):\n \"\"\"全屏阅读:隐藏/显示侧边栏\"\"\"\n self.sidebar.setVisible(not fs)\n\n def closeEvent(self, event):\n \"\"\"保存窗口几何尺寸和当前页面\"\"\"\n cfg = UserConfigManager.load()\n cfg[\"window_geometry\"] = bytes(self.saveGeometry().toHex()).decode()\n # 保存当前页面\n for name, page in self.pages.items():\n if self.stack.currentWidget() is page:\n cfg[\"last_page\"] = name; break\n UserConfigManager.save(cfg)\n event.accept()\n\n def _on_translate_done(self, output_files):\n reader = self.pages[\"阅读\"]\n reader.set_output_files(output_files)\n QTimer.singleShot(600, lambda: self.switch(\"阅读\"))\n # 翻译完成后把窗口提到前台(用户可能切走了)\n QTimer.singleShot(700, lambda: (self.raise_(), self.activateWindow()))\n\n\nclass Pdf2zhApp(QApplication):\n \"\"\"单实例 QApplication:第二个实例把文件发给第一个实例后退出\"\"\"\n file_opened = pyqtSignal(str)\n _SERVER_NAME = \"com.aarongig.pdf2zh.single\"\n\n def __init__(self, argv):\n super().__init__(argv)\n from PyQt5.QtNetwork import QLocalServer, QLocalSocket\n self._pending_file = None\n\n # 尝试连接已有实例\n sock = QLocalSocket()\n sock.connectToServer(self._SERVER_NAME)\n if sock.waitForConnected(500):\n # 已有实例在运行 → 把文件路径发过去,然后退出\n for arg in argv[1:]:\n if arg.lower().endswith('.pdf') and os.path.isfile(arg):\n sock.write(arg.encode('utf-8'))\n sock.waitForBytesWritten(1000)\n sock.disconnectFromServer()\n sys.exit(0)\n\n # 我是第一个实例 → 启动 server 监听\n QLocalServer.removeServer(self._SERVER_NAME)\n self._server = QLocalServer()\n self._server.listen(self._SERVER_NAME)\n self._server.newConnection.connect(self._on_new_connection)\n\n def _on_new_connection(self):\n conn = self._server.nextPendingConnection()\n if conn:\n conn.waitForReadyRead(1000)\n data = conn.readAll().data().decode('utf-8', errors='ignore')\n conn.close()\n if data and data.lower().endswith('.pdf'):\n self.file_opened.emit(data)\n\n def event(self, e):\n if e.type() == e.FileOpen:\n path = e.file()\n if path and path.lower().endswith('.pdf'):\n self.file_opened.emit(path)\n return True\n return super().event(e)\n\n\ndef main():\n QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)\n QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)\n app = Pdf2zhApp(sys.argv); app.setStyle(\"Fusion\")\n w = MainWindow(); w.show()\n\n # 文件打开事件 → 送到翻译页\n def _on_file_open(path):\n tp = w.pages.get(\"翻译\")\n if tp:\n tp.on_files_added([path])\n w.switch(\"翻译\")\n w.raise_()\n w.activateWindow()\n app.file_opened.connect(_on_file_open)\n\n sys.exit(app.exec_())\n\n\nif __name__ == \"__main__\":\n main()\n"}}}}