From 557700db5566fc6211dc1eba4ea4027eeb85e2cf Mon Sep 17 00:00:00 2001 From: Double-Cloth Date: Mon, 17 Aug 2026 00:19:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(export):=20HTML=20=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=80=82=E9=85=8D=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E6=A0=B7=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat_export_service.py | 297 +++++++++++++++++- tests/test_chat_export_html_format.py | 27 ++ 2 files changed, 313 insertions(+), 11 deletions(-) diff --git a/src/wechat_decrypt_tool/chat_export_service.py b/src/wechat_decrypt_tool/chat_export_service.py index d154835d..9eab3fb7 100644 --- a/src/wechat_decrypt_tool/chat_export_service.py +++ b/src/wechat_decrypt_tool/chat_export_service.py @@ -333,9 +333,269 @@ def _iter_chat_history_media_refs(record_item: str) -> list[tuple[str, str]]: """.strip() +_MOBILE_EXPORT_CSS = """ +/* 离线导出页的移动端布局:桌面端保留三栏结构,窄屏切换为微信式聊天界面。 */ +.wce-mobile-back, +.wce-mobile-tools-button { display: none; } +.wce-chat-heading { display: flex; align-items: center; min-width: 0; } + +@supports(height: 100dvh) { + .wce-root { height: 100dvh; } +} + +@media(max-width: 767px) { + html, body { width: 100%; height: 100%; overflow: hidden; } + body { background: #ededed; overscroll-behavior: none; } + .wce-root { + width: 100%; + height: 100vh; + height: 100dvh; + min-height: 0; + overflow: hidden; + } + .wce-rail, + .wce-session-panel { display: none !important; } + .wce-chat-area, + .wce-chat-main, + .wce-chat-col { width: 100%; min-width: 0; } + .wce-chat-header { + position: relative; + z-index: 30; + flex: 0 0 auto; + justify-content: center; + box-sizing: content-box; + height: 48px; + min-height: 48px; + padding: env(safe-area-inset-top, 0px) 52px 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + background: #ededed; + } + .wce-chat-heading { width: 100%; justify-content: center; } + .wce-chat-title { + max-width: 100%; + overflow: hidden; + color: #191919; + font-size: 17px; + font-weight: 600; + line-height: 1.2; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + } + .wce-mobile-back, + .wce-mobile-tools-button { + position: absolute; + bottom: 0; + display: flex; + width: 48px; + height: 48px; + align-items: center; + justify-content: center; + color: #181818; + -webkit-tap-highlight-color: transparent; + } + .wce-mobile-back { left: max(0px, env(safe-area-inset-left, 0px)); } + .wce-mobile-tools-button { + right: max(0px, env(safe-area-inset-right, 0px)); + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + } + .wce-mobile-back:active, + .wce-mobile-tools-button:active { opacity: 0.45; } + .wce-mobile-back:focus, + .wce-mobile-tools-button:focus { outline: none; } + .wce-mobile-back:focus-visible, + .wce-mobile-tools-button:focus-visible { + border-radius: 6px; + outline: 2px solid rgba(7, 193, 96, 0.75); + outline-offset: -5px; + } + .wce-chat-tools { + position: absolute; + top: 100%; + margin-top: 6px; + right: max(8px, env(safe-area-inset-right, 0px)); + left: max(8px, env(safe-area-inset-left, 0px)); + z-index: 40; + display: none; + max-height: calc(100dvh - 72px - env(safe-area-inset-top, 0px)); + overflow-y: auto; + flex-direction: column; + align-items: stretch; + gap: 10px; + padding: 12px; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 8px; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18); + } + .wce-chat-tools[data-mobile-open="1"] { display: flex; } + .wce-tool-group { display: flex; width: 100%; gap: 6px; } + .wce-tool-input, + .wce-tool-btn, + .message-filter-select { min-height: 36px; font-size: 13px; } + .wce-tool-input { min-width: 0; flex: 1 1 auto; background: #f7f7f7; } + .wce-tool-search, + .wce-tool-date { width: auto; max-width: none; } + .wce-tool-status { max-width: 72px; flex: 0 1 auto; } + .message-filter-select { + width: 100%; + padding: 0 10px; + border: 1px solid #d1d5db; + background: #f7f7f7; + } + #messageContainer { + padding: 14px 10px max(34px, env(safe-area-inset-bottom, 0px)); + overflow-x: hidden; + overscroll-behavior-y: contain; + -webkit-overflow-scrolling: touch; + } + .wce-pager { padding: 2px 0 16px; } + .wce-msg-row { margin-bottom: 18px; } + .wce-msg { max-width: 100%; min-width: 0; } + .wce-avatar { width: 40px; height: 40px; border-radius: 5px; } + .wce-avatar-sent { margin-left: 8px; } + .wce-avatar-received { margin-right: 8px; } + .wce-msg-content { + min-width: 0; + max-width: calc(100vw - 68px - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px)); + } + .msg-bubble, + .wce-bubble { + max-width: min(72vw, 320px); + padding: 9px 12px; + font-size: 16px; + line-height: 1.45; + } + .wechat-special-card, + .wechat-voice-wrapper, + .wechat-voice-transcript, + .wce-file, + .wce-audio, + .wce-media-img, + .wce-video-wrap, + .wce-video-thumb { max-width: 100%; } + .wce-msg-content > .absolute { display: none; } + .wce-system { margin: 12px 0; } + .wce-brand-attribution { + right: max(6px, env(safe-area-inset-right, 0px)); + bottom: max(5px, env(safe-area-inset-bottom, 0px)); + max-width: calc(100vw - 12px); + padding: 4px 8px; + font-size: 9px; + } + + .wce-index { height: 100vh; height: 100dvh; overflow-y: auto; background: #f7f7f7; } + .wce-index-container { max-width: none; padding: 0 0 max(34px, env(safe-area-inset-bottom, 0px)); } + .wce-index-heading { + position: sticky; + top: 0; + z-index: 10; + display: flex; + box-sizing: content-box; + height: 48px; + align-items: center; + justify-content: center; + padding: env(safe-area-inset-top, 0px) 16px 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + background: #ededed; + } + .wce-index-title { + max-width: 100%; + margin: 0; + overflow: hidden; + font-size: 17px; + font-weight: 600; + line-height: 1.2; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; + } + .wce-index-heading .wce-index-sub { display: none; } + .wce-index-card { border: 0; border-radius: 0; background: #fff; } + .wce-index-item { + min-height: 68px; + gap: 12px; + padding: 9px 12px; + border-bottom-color: #ededed; + } + .wce-session-avatar { width: 48px; height: 48px; border-radius: 5px; } + .wce-session-name { font-size: 16px; font-weight: 500; } + .wce-session-sub { margin-top: 3px; font-size: 13px; color: #999; } + .wce-index-container > .wce-index-sub { margin: 14px 16px 0 !important; line-height: 1.55; } +} + +@media(max-width: 374px) { + .wce-tool-status { display: none; } + .msg-bubble, + .wce-bubble { max-width: 70vw; } + .wce-brand-attribution span:first-of-type, + .wce-brand-dot { display: none; } +} + +@media(prefers-reduced-motion: reduce) { + .wce-mobile-back, + .wce-mobile-tools-button { transition: none; } +} +""".strip() + + +_MOBILE_EXPORT_JS = r""" +(() => { + const setupMobileTools = () => { + const button = document.getElementById("wceMobileToolsButton"); + const tools = document.getElementById("wceChatTools"); + if (!button || !tools || button.dataset.mobileToolsReady === "1") return; + button.dataset.mobileToolsReady = "1"; + + const setOpen = (open) => { + if (open) tools.setAttribute("data-mobile-open", "1"); + else tools.removeAttribute("data-mobile-open"); + button.setAttribute("aria-expanded", open ? "true" : "false"); + }; + + button.addEventListener("click", (event) => { + event.stopPropagation(); + setOpen(tools.getAttribute("data-mobile-open") !== "1"); + }); + document.addEventListener("click", (event) => { + if (!tools.contains(event.target)) setOpen(false); + }); + document.addEventListener("keydown", (event) => { + if (event.key !== "Escape" || tools.getAttribute("data-mobile-open") !== "1") return; + setOpen(false); + button.focus(); + }); + + const mobileQuery = window.matchMedia("(max-width: 767px)"); + const closeOnDesktop = (event) => { + if (!event.matches) setOpen(false); + }; + if (typeof mobileQuery.addEventListener === "function") { + mobileQuery.addEventListener("change", closeOnDesktop); + } else if (typeof mobileQuery.addListener === "function") { + mobileQuery.addListener(closeOnDesktop); + } + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", setupMobileTools, { once: true }); + } else { + setupMobileTools(); + } +})(); +""".strip() + + def _load_ui_css_bundle(*, ui_public_dir: Optional[Path], report: dict[str, Any]) -> str: del ui_public_dir, report - return f'{_native_export_css("chat").rstrip()}\n{_VOICE_TRANSCRIPT_EXPORT_CSS}\n' + return ( + f'{_native_export_css("chat").rstrip()}\n' + f'{_VOICE_TRANSCRIPT_EXPORT_CSS}\n' + f'{_MOBILE_EXPORT_CSS}\n' + ) @functools.lru_cache(maxsize=1) @@ -650,7 +910,7 @@ def _native_json_list( def _html_export_runtime_js(native_integrity: Any) -> str: - return _native_text(native_integrity, "runtime_js") + return f'{_native_text(native_integrity, "runtime_js").rstrip()}\n{_MOBILE_EXPORT_JS}\n' def _html_export_asset_paths(export_id: str) -> tuple[str, str, str]: @@ -2069,7 +2329,7 @@ def esc_attr(v: Any) -> str: parts.append('\n') parts.append("\n") parts.append(' \n') - parts.append(' \n') + parts.append(' \n') parts.append(f" {esc_text(archive_title)}导出\n") html_assets = dict(job.options.get("_htmlAssets") or {}) css_asset_path = str(html_assets.get("cssPath") or _html_export_asset_paths(job.export_id)[0]) @@ -2092,10 +2352,12 @@ def esc_attr(v: Any) -> str: ) parts.append('
\n') parts.append('
\n') - parts.append(f'

{esc_text(archive_title)}导出(HTML)

\n') + parts.append('
\n') + parts.append(f'

{esc_text(archive_title)}导出(HTML)

\n') parts.append( - f'

账号: {esc_text("hidden" if privacy_mode else account_dir.name)} · 会话数: {len(html_index_items)} · 导出时间: {esc_text(_now_iso())}

\n' + f'

账号: {esc_text("hidden" if privacy_mode else account_dir.name)} · 会话数: {len(html_index_items)} · 导出时间: {esc_text(_now_iso())}

\n' ) + parts.append("
\n") parts.append('
\n') for item in html_index_items: @@ -4697,7 +4959,7 @@ def flush(self) -> None: tw.write('\n') tw.write("\n") tw.write(' \n') - tw.write(' \n') + tw.write(' \n') tw.write(f" {esc_text(page_title)}\n") tw.write(_html_export_gate_style()) # Do not use native `integrity=` for offline file:// exports; Chrome blocks @@ -4863,11 +5125,24 @@ def flush(self) -> None: tw.write('
\n') tw.write('
\n') - tw.write('
\n') - tw.write('
\n') - tw.write(f'

{esc_text(chat_title)}

\n') + tw.write('
\n') + tw.write( + f' \n' + ) + tw.write(' \n") + tw.write(" \n") + tw.write('
\n') + tw.write(f'

{esc_text(chat_title)}

\n') tw.write("
\n") - tw.write('
\n') + tw.write(' \n") + tw.write('
\n') tw.write('
\n') tw.write(' \n') tw.write(' \n') @@ -5116,7 +5391,7 @@ def _mark_exported() -> None: tw.write(" " + build_avatar_html(src=avatar_src, fallback_text=fallback_char, extra_class=avatar_extra) + "\n") align_cls = "items-end" if is_sent else "items-start" - tw.write(f'
\n') + tw.write(f'
\n') if conv_is_group and (not is_sent) and display_name: tw.write(f'
{esc_text(display_name)}
\n') diff --git a/tests/test_chat_export_html_format.py b/tests/test_chat_export_html_format.py index 0bcdb469..dbbc6c96 100644 --- a/tests/test_chat_export_html_format.py +++ b/tests/test_chat_export_html_format.py @@ -33,6 +33,20 @@ def _reload_export_modules(self): importlib.reload(chat_export_service) return chat_export_service + def test_mobile_export_assets_define_responsive_contract(self): + svc = self._reload_export_modules() + css_text = svc._minify_css_for_export(svc._MOBILE_EXPORT_CSS) + + self.assertIn("@media(max-width:767px)", css_text) + self.assertIn(".wce-rail,.wce-session-panel{display:none !important}", css_text) + self.assertIn(".wce-chat-tools[data-mobile-open=\"1\"]", css_text) + self.assertIn("height:48px", css_text) + self.assertIn("height:100dvh", css_text) + self.assertIn("env(safe-area-inset-bottom,0px)", css_text) + self.assertNotIn("calc(48px+env", css_text) + self.assertIn("wceMobileToolsButton", svc._MOBILE_EXPORT_JS) + self.assertIn('event.key !== "Escape"', svc._MOBILE_EXPORT_JS) + def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None: conn = sqlite3.connect(str(path)) try: @@ -349,7 +363,15 @@ def test_html_export_contains_index_and_conversation_page(self): html_path = next((n for n in names if n.endswith("/messages.html")), "") self.assertTrue(html_path) + index_text = zf.read("index.html").decode("utf-8") html_text = zf.read(html_path).decode("utf-8") + self.assertIn("viewport-fit=cover", index_text) + self.assertIn('class="wce-index-heading"', index_text) + self.assertIn("viewport-fit=cover", html_text) + self.assertIn('class="wce-mobile-back"', html_text) + self.assertIn('id="wceMobileToolsButton"', html_text) + self.assertIn('id="wceChatTools"', html_text) + self.assertIn("wce-msg-content", html_text) self.assertIn('data-wce-rail-avatar="1"', html_text) self.assertIn('data-wce-session-list="1"', html_text) self.assertIn('id="sessionSearchInput"', html_text) @@ -395,6 +417,10 @@ def test_html_export_contains_index_and_conversation_page(self): self.assertIn(".wechat-voip-icon--video", css_text) self.assertIn(".wechat-voip-icon--mirrored", css_text) self.assertNotIn("wechat-transfer-card[data-v-", css_text) + self.assertIn("@media(max-width:767px)", css_text) + self.assertIn('.wce-chat-tools[data-mobile-open="1"]', css_text) + self.assertIn("env(safe-area-inset-bottom,0px)", css_text) + self.assertIn("height:100dvh", css_text) self.assertIn("assets/images/wechat/wechat-audio-call.svg", names) self.assertIn("assets/images/wechat/wechat-video-call.svg", names) @@ -403,6 +429,7 @@ def test_html_export_contains_index_and_conversation_page(self): js_text = zf.read(js_asset).decode("utf-8", errors="ignore") self.assertIn("Function", js_text) self.assertIn("atob", js_text) + self.assertIn("wceMobileToolsButton", js_text) self.assertNotIn("wechat-voice-bubble", js_text) self.assertNotIn("voice-playing", js_text) self.assertNotIn("data-wce-quote-voice-btn", js_text) From 7c2e5be5c1e1a4d26bddb5f993ca6d21fd299f8d Mon Sep 17 00:00:00 2001 From: Double-Cloth Date: Mon, 17 Aug 2026 00:37:04 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(export):=20=E4=BF=AE=E5=A4=8D=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=20HTML=20=E5=AF=BC=E5=87=BA=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E6=80=A7=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat_export_service.py | 81 ++++++------------- tests/test_chat_export_html_format.py | 20 +++-- 2 files changed, 39 insertions(+), 62 deletions(-) diff --git a/src/wechat_decrypt_tool/chat_export_service.py b/src/wechat_decrypt_tool/chat_export_service.py index 9eab3fb7..7f0a6456 100644 --- a/src/wechat_decrypt_tool/chat_export_service.py +++ b/src/wechat_decrypt_tool/chat_export_service.py @@ -336,6 +336,7 @@ def _iter_chat_history_media_refs(record_item: str) -> list[tuple[str, str]]: _MOBILE_EXPORT_CSS = """ /* 离线导出页的移动端布局:桌面端保留三栏结构,窄屏切换为微信式聊天界面。 */ .wce-mobile-back, +.wce-mobile-tools-toggle, .wce-mobile-tools-button { display: none; } .wce-chat-heading { display: flex; align-items: center; min-width: 0; } @@ -395,6 +396,20 @@ def _iter_chat_history_media_refs(record_item: str) -> list[tuple[str, str]]: -webkit-tap-highlight-color: transparent; } .wce-mobile-back { left: max(0px, env(safe-area-inset-left, 0px)); } + .wce-mobile-tools-toggle { + position: absolute; + right: max(0px, env(safe-area-inset-right, 0px)); + bottom: 0; + z-index: 2; + display: block; + width: 48px; + height: 48px; + margin: 0; + opacity: 0; + cursor: pointer; + -webkit-appearance: none; + appearance: none; + } .wce-mobile-tools-button { right: max(0px, env(safe-area-inset-right, 0px)); padding: 0; @@ -403,11 +418,10 @@ def _iter_chat_history_media_refs(record_item: str) -> list[tuple[str, str]]: cursor: pointer; } .wce-mobile-back:active, - .wce-mobile-tools-button:active { opacity: 0.45; } - .wce-mobile-back:focus, - .wce-mobile-tools-button:focus { outline: none; } + .wce-mobile-tools-toggle:active + .wce-mobile-tools-button { opacity: 0.45; } + .wce-mobile-back:focus { outline: none; } .wce-mobile-back:focus-visible, - .wce-mobile-tools-button:focus-visible { + .wce-mobile-tools-toggle:focus-visible + .wce-mobile-tools-button { border-radius: 6px; outline: 2px solid rgba(7, 193, 96, 0.75); outline-offset: -5px; @@ -431,7 +445,7 @@ def _iter_chat_history_media_refs(record_item: str) -> list[tuple[str, str]]: background: rgba(255, 255, 255, 0.98); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18); } - .wce-chat-tools[data-mobile-open="1"] { display: flex; } + .wce-mobile-tools-toggle:checked ~ .wce-chat-tools { display: flex; } .wce-tool-group { display: flex; width: 100%; gap: 6px; } .wce-tool-input, .wce-tool-btn, @@ -537,58 +551,11 @@ def _iter_chat_history_media_refs(record_item: str) -> list[tuple[str, str]]: @media(prefers-reduced-motion: reduce) { .wce-mobile-back, - .wce-mobile-tools-button { transition: none; } + .wce-mobile-tools-toggle + .wce-mobile-tools-button { transition: none; } } """.strip() -_MOBILE_EXPORT_JS = r""" -(() => { - const setupMobileTools = () => { - const button = document.getElementById("wceMobileToolsButton"); - const tools = document.getElementById("wceChatTools"); - if (!button || !tools || button.dataset.mobileToolsReady === "1") return; - button.dataset.mobileToolsReady = "1"; - - const setOpen = (open) => { - if (open) tools.setAttribute("data-mobile-open", "1"); - else tools.removeAttribute("data-mobile-open"); - button.setAttribute("aria-expanded", open ? "true" : "false"); - }; - - button.addEventListener("click", (event) => { - event.stopPropagation(); - setOpen(tools.getAttribute("data-mobile-open") !== "1"); - }); - document.addEventListener("click", (event) => { - if (!tools.contains(event.target)) setOpen(false); - }); - document.addEventListener("keydown", (event) => { - if (event.key !== "Escape" || tools.getAttribute("data-mobile-open") !== "1") return; - setOpen(false); - button.focus(); - }); - - const mobileQuery = window.matchMedia("(max-width: 767px)"); - const closeOnDesktop = (event) => { - if (!event.matches) setOpen(false); - }; - if (typeof mobileQuery.addEventListener === "function") { - mobileQuery.addEventListener("change", closeOnDesktop); - } else if (typeof mobileQuery.addListener === "function") { - mobileQuery.addListener(closeOnDesktop); - } - }; - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", setupMobileTools, { once: true }); - } else { - setupMobileTools(); - } -})(); -""".strip() - - def _load_ui_css_bundle(*, ui_public_dir: Optional[Path], report: dict[str, Any]) -> str: del ui_public_dir, report return ( @@ -910,7 +877,8 @@ def _native_json_list( def _html_export_runtime_js(native_integrity: Any) -> str: - return f'{_native_text(native_integrity, "runtime_js").rstrip()}\n{_MOBILE_EXPORT_JS}\n' + # 原生运行时会参与导出页的自校验,必须逐字节保持原样。 + return _native_text(native_integrity, "runtime_js") def _html_export_asset_paths(export_id: str) -> tuple[str, str, str]: @@ -5137,11 +5105,12 @@ def flush(self) -> None: tw.write('
\n') tw.write(f'

{esc_text(chat_title)}

\n') tw.write("
\n") - tw.write(' \n") + tw.write(" \n") tw.write('
\n') tw.write('
\n') tw.write(' \n') diff --git a/tests/test_chat_export_html_format.py b/tests/test_chat_export_html_format.py index dbbc6c96..86889f58 100644 --- a/tests/test_chat_export_html_format.py +++ b/tests/test_chat_export_html_format.py @@ -39,13 +39,20 @@ def test_mobile_export_assets_define_responsive_contract(self): self.assertIn("@media(max-width:767px)", css_text) self.assertIn(".wce-rail,.wce-session-panel{display:none !important}", css_text) - self.assertIn(".wce-chat-tools[data-mobile-open=\"1\"]", css_text) + self.assertIn(".wce-mobile-tools-toggle:checked~.wce-chat-tools", css_text) self.assertIn("height:48px", css_text) self.assertIn("height:100dvh", css_text) self.assertIn("env(safe-area-inset-bottom,0px)", css_text) self.assertNotIn("calc(48px+env", css_text) - self.assertIn("wceMobileToolsButton", svc._MOBILE_EXPORT_JS) - self.assertIn('event.key !== "Escape"', svc._MOBILE_EXPORT_JS) + + def test_html_export_runtime_keeps_native_integrity_payload_unchanged(self): + svc = self._reload_export_modules() + native_integrity = svc._load_wce_integrity_native() + + self.assertEqual( + svc._html_export_runtime_js(native_integrity), + svc._native_text(native_integrity, "runtime_js"), + ) def _seed_contact_db(self, path: Path, *, account: str, username: str) -> None: conn = sqlite3.connect(str(path)) @@ -369,7 +376,8 @@ def test_html_export_contains_index_and_conversation_page(self): self.assertIn('class="wce-index-heading"', index_text) self.assertIn("viewport-fit=cover", html_text) self.assertIn('class="wce-mobile-back"', html_text) - self.assertIn('id="wceMobileToolsButton"', html_text) + self.assertIn('id="wceMobileToolsToggle"', html_text) + self.assertIn('for="wceMobileToolsToggle"', html_text) self.assertIn('id="wceChatTools"', html_text) self.assertIn("wce-msg-content", html_text) self.assertIn('data-wce-rail-avatar="1"', html_text) @@ -418,7 +426,7 @@ def test_html_export_contains_index_and_conversation_page(self): self.assertIn(".wechat-voip-icon--mirrored", css_text) self.assertNotIn("wechat-transfer-card[data-v-", css_text) self.assertIn("@media(max-width:767px)", css_text) - self.assertIn('.wce-chat-tools[data-mobile-open="1"]', css_text) + self.assertIn('.wce-mobile-tools-toggle:checked~.wce-chat-tools', css_text) self.assertIn("env(safe-area-inset-bottom,0px)", css_text) self.assertIn("height:100dvh", css_text) @@ -429,7 +437,7 @@ def test_html_export_contains_index_and_conversation_page(self): js_text = zf.read(js_asset).decode("utf-8", errors="ignore") self.assertIn("Function", js_text) self.assertIn("atob", js_text) - self.assertIn("wceMobileToolsButton", js_text) + self.assertNotIn("wceMobileToolsToggle", js_text) self.assertNotIn("wechat-voice-bubble", js_text) self.assertNotIn("voice-playing", js_text) self.assertNotIn("data-wce-quote-voice-btn", js_text) From be1491c5deb3c2eec2cb18d1c4ccd6de7adbcb8f Mon Sep 17 00:00:00 2001 From: Double-Cloth Date: Mon, 17 Aug 2026 20:32:37 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(export):=20=E7=A7=BB=E5=8A=A8=E7=AB=AF?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E6=8C=89=E9=92=AE=E5=88=87=E6=8D=A2=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chat_export_service.py | 87 ++++++++++++++++--- tests/test_chat_export_html_format.py | 9 +- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/wechat_decrypt_tool/chat_export_service.py b/src/wechat_decrypt_tool/chat_export_service.py index 7f0a6456..05bbb155 100644 --- a/src/wechat_decrypt_tool/chat_export_service.py +++ b/src/wechat_decrypt_tool/chat_export_service.py @@ -336,6 +336,8 @@ def _iter_chat_history_media_refs(record_item: str) -> list[tuple[str, str]]: _MOBILE_EXPORT_CSS = """ /* 离线导出页的移动端布局:桌面端保留三栏结构,窄屏切换为微信式聊天界面。 */ .wce-mobile-back, +.wce-mobile-sessions-toggle, +.wce-mobile-session-heading, .wce-mobile-tools-toggle, .wce-mobile-tools-button { display: none; } .wce-chat-heading { display: flex; align-items: center; min-width: 0; } @@ -354,8 +356,70 @@ def _iter_chat_history_media_refs(record_item: str) -> list[tuple[str, str]]: min-height: 0; overflow: hidden; } - .wce-rail, + .wce-rail { display: none !important; } .wce-session-panel { display: none !important; } + .wce-mobile-sessions-toggle { + position: absolute; + top: env(safe-area-inset-top, 0px); + left: max(0px, env(safe-area-inset-left, 0px)); + z-index: 60; + display: block; + width: 48px; + height: 48px; + margin: 0; + opacity: 0; + cursor: pointer; + -webkit-appearance: none; + appearance: none; + } + .wce-mobile-sessions-toggle:checked ~ .wce-session-panel { + position: absolute; + inset: 0; + z-index: 50; + display: flex !important; + width: 100% !important; + max-width: none !important; + height: 100%; + border-right: 0; + } + .wce-mobile-session-heading { + position: relative; + box-sizing: content-box; + display: flex; + flex: 0 0 auto; + height: 48px; + align-items: center; + justify-content: center; + padding-top: env(safe-area-inset-top, 0px); + border-bottom: 1px solid rgba(0, 0, 0, 0.08); + background: #ededed; + } + .wce-mobile-session-close { + position: absolute; + bottom: 0; + left: max(0px, env(safe-area-inset-left, 0px)); + display: flex; + width: 48px; + height: 48px; + align-items: center; + justify-content: center; + color: #181818; + } + .wce-mobile-session-title { + margin: 0; + color: #191919; + font-size: 17px; + font-weight: 600; + line-height: 1.2; + } + .wce-mobile-sessions-toggle:active ~ .wce-session-panel .wce-mobile-session-close, + .wce-mobile-sessions-toggle:not(:checked):active ~ .wce-chat-area .wce-mobile-back { opacity: 0.45; } + .wce-mobile-sessions-toggle:focus-visible ~ .wce-session-panel .wce-mobile-session-close, + .wce-mobile-sessions-toggle:not(:checked):focus-visible ~ .wce-chat-area .wce-mobile-back { + border-radius: 6px; + outline: 2px solid rgba(7, 193, 96, 0.75); + outline-offset: -5px; + } .wce-chat-area, .wce-chat-main, .wce-chat-col { width: 100%; min-width: 0; } @@ -417,10 +481,7 @@ def _iter_chat_history_media_refs(record_item: str) -> list[tuple[str, str]]: background: transparent; cursor: pointer; } - .wce-mobile-back:active, .wce-mobile-tools-toggle:active + .wce-mobile-tools-button { opacity: 0.45; } - .wce-mobile-back:focus { outline: none; } - .wce-mobile-back:focus-visible, .wce-mobile-tools-toggle:focus-visible + .wce-mobile-tools-button { border-radius: 6px; outline: 2px solid rgba(7, 193, 96, 0.75); @@ -4944,6 +5005,7 @@ def flush(self) -> None: # Root tw.write('
\n') + tw.write(' \n') # Left rail (avatar + chat icon) tw.write( @@ -4983,8 +5045,16 @@ def flush(self) -> None: # Middle session list (all exported conversations) tw.write( - '
\n' + '
\n' ) + tw.write('
\n') + tw.write(' \n") + tw.write('

聊天

\n') + tw.write("
\n") tw.write('
\n') tw.write( '
\n' @@ -5094,14 +5164,11 @@ def flush(self) -> None: tw.write('
\n') tw.write('
\n') - tw.write( - f' \n' - ) + tw.write(' \n") + tw.write(" \n") tw.write('
\n') tw.write(f'

{esc_text(chat_title)}

\n') tw.write("
\n") diff --git a/tests/test_chat_export_html_format.py b/tests/test_chat_export_html_format.py index 86889f58..1edb75ce 100644 --- a/tests/test_chat_export_html_format.py +++ b/tests/test_chat_export_html_format.py @@ -38,7 +38,9 @@ def test_mobile_export_assets_define_responsive_contract(self): css_text = svc._minify_css_for_export(svc._MOBILE_EXPORT_CSS) self.assertIn("@media(max-width:767px)", css_text) - self.assertIn(".wce-rail,.wce-session-panel{display:none !important}", css_text) + self.assertIn(".wce-rail{display:none !important}", css_text) + self.assertIn(".wce-mobile-sessions-toggle:checked~.wce-session-panel", css_text) + self.assertIn("display:flex !important", css_text) self.assertIn(".wce-mobile-tools-toggle:checked~.wce-chat-tools", css_text) self.assertIn("height:48px", css_text) self.assertIn("height:100dvh", css_text) @@ -376,6 +378,10 @@ def test_html_export_contains_index_and_conversation_page(self): self.assertIn('class="wce-index-heading"', index_text) self.assertIn("viewport-fit=cover", html_text) self.assertIn('class="wce-mobile-back"', html_text) + self.assertIn('id="wceMobileSessionsToggle"', html_text) + self.assertIn('id="wceSessionPanel"', html_text) + self.assertIn('for="wceMobileSessionsToggle"', html_text) + self.assertNotIn('class="wce-mobile-back" href=', html_text) self.assertIn('id="wceMobileToolsToggle"', html_text) self.assertIn('for="wceMobileToolsToggle"', html_text) self.assertIn('id="wceChatTools"', html_text) @@ -426,6 +432,7 @@ def test_html_export_contains_index_and_conversation_page(self): self.assertIn(".wechat-voip-icon--mirrored", css_text) self.assertNotIn("wechat-transfer-card[data-v-", css_text) self.assertIn("@media(max-width:767px)", css_text) + self.assertIn('.wce-mobile-sessions-toggle:checked~.wce-session-panel', css_text) self.assertIn('.wce-mobile-tools-toggle:checked~.wce-chat-tools', css_text) self.assertIn("env(safe-area-inset-bottom,0px)", css_text) self.assertIn("height:100dvh", css_text)