From 471acd185ccc6595ab53c5ab3eeb89f2ea586c42 Mon Sep 17 00:00:00 2001 From: 2977094657 <104895427+2977094657@users.noreply.github.com> Date: Mon, 16 Mar 2026 19:57:23 +0800 Subject: [PATCH 01/39] Add sponsorship and support section to README Added sponsorship section with payment options and acknowledgments. --- README.md | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bae913b..4cbc27e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Stars Downloads Forks - QQ Group + QQ Group Python Vue.js SQLite @@ -202,6 +202,33 @@ npm run dist 本地消息修改

+## 赞助与支持 + +如果本项目对你有帮助,欢迎通过以下方式赞助。付款时请在备注中填写“希望公开展示的链接”(如个人主页、B 站空间、GitHub 仓库等),我们会在 README 的“赞助鸣谢”表格中展示。 + +
+ + + + + +
+ 微信收款码
+ 微信赞助 +
+ 支付宝收款码
+ 支付宝赞助 +
+
+ +### 赞助鸣谢 + +| 联系内容 | 付款金额 | +| ----------------------------------------------------- | -------- | +| [惜囍的个人空间-哔哩哔哩](https://space.bilibili.com/291501729) | ¥29.99 | + +提示:已赞助但未收录,请在 Issues 提交凭证与备注链接;如需匿名可说明。 + ## 致谢 本项目的开发过程中参考了以下优秀的开源项目和资源: From 9b88c006eec7f18db720e4860977f4da2690ed5b Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Tue, 17 Mar 2026 12:13:50 +0800 Subject: [PATCH 02/39] =?UTF-8?q?fix(decrypt):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E8=B4=A6=E5=8F=B7=E7=9B=AE=E5=BD=95=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E7=9A=84=E5=9D=8F=E5=BA=93=E8=AF=BB=E5=8F=96=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正 db_storage 路径扫描逻辑,避免将多账号上级目录误识别为单个账号 - 统一普通解密和流式解密的账号识别与来源写入逻辑 - 为聊天和媒体账号列表增加 SQLite 文件头校验 - 跳过损坏的解密目录,避免聊天页和头像接口继续读取坏库 --- src/wechat_decrypt_tool/chat_helpers.py | 17 +- src/wechat_decrypt_tool/media_helpers.py | 17 +- src/wechat_decrypt_tool/routers/decrypt.py | 58 ++--- src/wechat_decrypt_tool/wechat_decrypt.py | 248 +++++++++++++++------ 4 files changed, 225 insertions(+), 115 deletions(-) diff --git a/src/wechat_decrypt_tool/chat_helpers.py b/src/wechat_decrypt_tool/chat_helpers.py index 2926205..88a6368 100644 --- a/src/wechat_decrypt_tool/chat_helpers.py +++ b/src/wechat_decrypt_tool/chat_helpers.py @@ -24,6 +24,17 @@ _OUTPUT_DATABASES_DIR = get_output_databases_dir() _DEBUG_SESSIONS = os.environ.get("WECHAT_TOOL_DEBUG_SESSIONS", "0") == "1" +_SQLITE_HEADER = b"SQLite format 3\x00" + + +def _is_valid_decrypted_sqlite(path: Path) -> bool: + try: + if not path.exists() or (not path.is_file()): + return False + with path.open("rb") as f: + return f.read(len(_SQLITE_HEADER)) == _SQLITE_HEADER + except Exception: + return False def _list_decrypted_accounts() -> list[str]: @@ -34,7 +45,7 @@ def _list_decrypted_accounts() -> list[str]: for p in _OUTPUT_DATABASES_DIR.iterdir(): if not p.is_dir(): continue - if (p / "session.db").exists() and (p / "contact.db").exists(): + if _is_valid_decrypted_sqlite(p / "session.db") and _is_valid_decrypted_sqlite(p / "contact.db"): accounts.append(p.name) accounts.sort() @@ -49,7 +60,9 @@ def _resolve_account_dir(account: Optional[str]) -> Path: detail="No decrypted databases found. Please decrypt first.", ) - selected = account or accounts[0] + selected = str(account or "").strip() or accounts[0] + if selected not in accounts: + raise HTTPException(status_code=404, detail="Account not found.") base = _OUTPUT_DATABASES_DIR.resolve() candidate = (_OUTPUT_DATABASES_DIR / selected).resolve() diff --git a/src/wechat_decrypt_tool/media_helpers.py b/src/wechat_decrypt_tool/media_helpers.py index 3b247b9..f9ee3d2 100644 --- a/src/wechat_decrypt_tool/media_helpers.py +++ b/src/wechat_decrypt_tool/media_helpers.py @@ -24,6 +24,17 @@ # 运行时输出目录(桌面端可通过 WECHAT_TOOL_DATA_DIR 指向可写目录) _PACKAGE_ROOT = Path(__file__).resolve().parent +_SQLITE_HEADER = b"SQLite format 3\x00" + + +def _is_valid_decrypted_sqlite(path: Path) -> bool: + try: + if not path.exists() or (not path.is_file()): + return False + with path.open("rb") as f: + return f.read(len(_SQLITE_HEADER)) == _SQLITE_HEADER + except Exception: + return False def _list_decrypted_accounts() -> list[str]: @@ -36,7 +47,7 @@ def _list_decrypted_accounts() -> list[str]: for p in output_db_dir.iterdir(): if not p.is_dir(): continue - if (p / "session.db").exists() and (p / "contact.db").exists(): + if _is_valid_decrypted_sqlite(p / "session.db") and _is_valid_decrypted_sqlite(p / "contact.db"): accounts.append(p.name) accounts.sort() @@ -53,7 +64,9 @@ def _resolve_account_dir(account: Optional[str]) -> Path: detail="No decrypted databases found. Please decrypt first.", ) - selected = account or accounts[0] + selected = str(account or "").strip() or accounts[0] + if selected not in accounts: + raise HTTPException(status_code=404, detail="Account not found.") base = output_db_dir.resolve() candidate = (output_db_dir / selected).resolve() diff --git a/src/wechat_decrypt_tool/routers/decrypt.py b/src/wechat_decrypt_tool/routers/decrypt.py index bbf7b46..d797441 100644 --- a/src/wechat_decrypt_tool/routers/decrypt.py +++ b/src/wechat_decrypt_tool/routers/decrypt.py @@ -14,7 +14,7 @@ from ..logging_config import get_logger from ..path_fix import PathFixRoute from ..key_store import upsert_account_keys_in_store -from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases +from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases, scan_account_databases_from_path logger = get_logger(__name__) @@ -79,6 +79,8 @@ async def decrypt_databases(request: DecryptRequest): "account_results": results.get("account_results", {}), } + except HTTPException: + raise except Exception as e: logger.error(f"解密API异常: {str(e)}") raise HTTPException(status_code=500, detail=str(e)) @@ -126,44 +128,17 @@ async def generate_progress(): yield _sse({"type": "scanning", "message": "正在扫描数据库文件..."}) await asyncio.sleep(0) - account_name = "unknown_account" - path_parts = storage_path.parts - account_patterns = ["wxid_"] - for part in path_parts: - for pattern in account_patterns: - if part.startswith(pattern): - parts = part.split("_") - if len(parts) >= 3: - account_name = "_".join(parts[:-1]) - else: - account_name = part - break - if account_name != "unknown_account": - break - - if account_name == "unknown_account": - for part in reversed(path_parts): - if part != "db_storage" and len(part) > 3: - account_name = part - break - - databases: list[dict] = [] - for root, _dirs, files in os.walk(storage_path): - if "db_storage" not in str(root): - continue - for file_name in files: - if not file_name.endswith(".db"): - continue - if file_name in ["key_info.db"]: - continue - db_path = os.path.join(root, file_name) - databases.append({"path": db_path, "name": file_name, "account": account_name}) - - if not databases: - yield _sse({"type": "error", "message": "未找到微信数据库文件!请检查 db_storage_path 是否正确"}) + scan_result = scan_account_databases_from_path(p) + if scan_result["status"] == "error": + payload = {"type": "error", "message": scan_result["message"]} + detected_accounts = scan_result.get("detected_accounts") or [] + if detected_accounts: + payload["detected_accounts"] = detected_accounts + yield _sse(payload) return - account_databases = {account_name: databases} + account_databases = scan_result.get("account_databases", {}) + account_sources = scan_result.get("account_sources", {}) total_databases = sum(len(dbs) for dbs in account_databases.values()) yield _sse({"type": "start", "total": total_databases, "message": f"开始解密 {total_databases} 个数据库"}) @@ -193,12 +168,9 @@ async def generate_progress(): # Save a hint for later UI (same as non-stream endpoint). try: - source_db_storage_path = p - wxid_dir = "" - if storage_path.name.lower() == "db_storage": - wxid_dir = str(storage_path.parent) - else: - wxid_dir = str(storage_path) + source_info = account_sources.get(account, {}) + source_db_storage_path = str(source_info.get("db_storage_path") or p) + wxid_dir = str(source_info.get("wxid_dir") or "") (account_output_dir / "_source.json").write_text( json.dumps({"db_storage_path": source_db_storage_path, "wxid_dir": wxid_dir}, ensure_ascii=False, indent=2), encoding="utf-8", diff --git a/src/wechat_decrypt_tool/wechat_decrypt.py b/src/wechat_decrypt_tool/wechat_decrypt.py index b6aa38a..8afcb13 100644 --- a/src/wechat_decrypt_tool/wechat_decrypt.py +++ b/src/wechat_decrypt_tool/wechat_decrypt.py @@ -27,6 +27,169 @@ # SQLite文件头 SQLITE_HEADER = b"SQLite format 3\x00" + +def _normalize_account_name(name: str) -> str: + value = str(name or "").strip() + if not value: + return "unknown_account" + + if value.startswith("wxid_"): + parts = value.split("_") + if len(parts) >= 3: + trimmed = "_".join(parts[:-1]).strip() + if trimmed: + return trimmed + + return value + + +def _derive_account_name_from_path(path: Path) -> str: + try: + target = path.resolve() + except Exception: + target = path + + for part in target.parts: + part_str = str(part or "").strip() + if part_str.startswith("wxid_"): + return _normalize_account_name(part_str) + + for part in reversed(target.parts): + part_str = str(part or "").strip() + if not part_str or part_str.lower() == "db_storage" or len(part_str) <= 3: + continue + return _normalize_account_name(part_str) + + return "unknown_account" + + +def _resolve_db_storage_roots(storage_path: Path) -> list[Path]: + try: + target = storage_path.resolve() + except Exception: + target = storage_path + + if not target.exists(): + return [] + + current = target if target.is_dir() else target.parent + probe = current + while True: + if probe.name.lower() == "db_storage": + return [probe] + parent = probe.parent + if parent == probe: + break + probe = parent + + roots: list[Path] = [] + try: + for root, dirs, _files in os.walk(current): + root_path = Path(root) + if root_path.name.lower() != "db_storage": + continue + roots.append(root_path) + dirs[:] = [] + except Exception: + return [] + + uniq: list[Path] = [] + seen: set[str] = set() + for root in roots: + key = str(root) + if key in seen: + continue + seen.add(key) + uniq.append(root) + uniq.sort(key=lambda p: str(p).lower()) + return uniq + + +def scan_account_databases_from_path(db_storage_path: str) -> dict: + storage_path = Path(str(db_storage_path or "").strip()) + if not storage_path.exists(): + return { + "status": "error", + "message": f"指定的数据库路径不存在: {db_storage_path}", + "account_databases": {}, + "account_sources": {}, + "detected_accounts": [], + } + + db_roots = _resolve_db_storage_roots(storage_path) + if not db_roots: + return { + "status": "error", + "message": "未找到微信数据库文件!请确保路径指向具体账号的 db_storage 目录。", + "account_databases": {}, + "account_sources": {}, + "detected_accounts": [], + } + + detected_accounts = [ + { + "account": _derive_account_name_from_path(root), + "db_storage_path": str(root), + "wxid_dir": str(root.parent), + } + for root in db_roots + ] + + if len(db_roots) > 1: + account_names = ", ".join( + [str(item.get("account") or item.get("db_storage_path") or "").strip() for item in detected_accounts] + ) + return { + "status": "error", + "message": ( + "检测到多个账号目录,请选择具体账号的 db_storage 目录后再解密," + f"不要直接选择上级目录。当前检测到: {account_names}" + ), + "account_databases": {}, + "account_sources": {}, + "detected_accounts": detected_accounts, + } + + db_root = db_roots[0] + account_name = _derive_account_name_from_path(db_root) + databases: list[dict] = [] + for root, _dirs, files in os.walk(db_root): + for file_name in files: + if not file_name.endswith(".db"): + continue + if file_name in ["key_info.db"]: + continue + db_path = os.path.join(root, file_name) + databases.append( + { + "path": db_path, + "name": file_name, + "account": account_name, + } + ) + + if not databases: + return { + "status": "error", + "message": "未找到微信数据库文件!请检查 db_storage_path 是否正确", + "account_databases": {}, + "account_sources": {}, + "detected_accounts": detected_accounts, + } + + return { + "status": "success", + "message": "", + "account_databases": {account_name: databases}, + "account_sources": { + account_name: { + "db_storage_path": str(db_root), + "wxid_dir": str(db_root.parent), + } + }, + "detected_accounts": detected_accounts, + } + def setup_logging(): """设置日志配置 - 已弃用,使用统一的日志配置""" from .logging_config import setup_logging as unified_setup_logging @@ -259,75 +422,28 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di # 查找数据库文件并按账号组织 account_databases = {} # {account_name: [db_info, ...]} + account_sources = {} + detected_accounts = [] if db_storage_path: - # 使用指定路径查找数据库 - storage_path = Path(db_storage_path) - - if storage_path.exists(): - # 尝试从路径中提取账号名 - account_name = "unknown_account" - path_parts = storage_path.parts - - # 常见的微信账号格式模式 - account_patterns = ['wxid_'] - - for part in path_parts: - # 检查是否匹配已知的账号格式 - for pattern in account_patterns: - if part.startswith(pattern): - # 提取主要部分,去掉后面的随机后缀 - # 例如:wxid_v4mbduwqtzpt22_1e7a -> wxid_v4mbduwqtzpt22 - parts = part.split('_') - if len(parts) >= 3: # wxid_主要部分_随机后缀 - account_name = '_'.join(parts[:-1]) # 去掉最后一个随机部分 - else: - account_name = part # 如果格式不符合预期,保留原名 - break - if account_name != "unknown_account": - break - - # 如果没有匹配到已知格式,使用包含数据库的目录名 - if account_name == "unknown_account": - # 查找包含db_storage的父目录作为账号名 - for part in reversed(path_parts): - if part != "db_storage" and len(part) > 3: - account_name = part - break - - databases = [] - # 使用递归查找,与自动检测逻辑一致 - for root, dirs, files in os.walk(storage_path): - # 只处理db_storage目录下的数据库文件 - if "db_storage" not in str(root): - continue - for file_name in files: - if not file_name.endswith(".db"): - continue - # 排除不需要解密的数据库 - if file_name in ["key_info.db"]: - continue - db_path = os.path.join(root, file_name) - databases.append({ - 'path': db_path, - 'name': file_name, - 'account': account_name - }) - - if databases: - account_databases[account_name] = databases - logger.info(f"在指定路径找到账号 {account_name} 的 {len(databases)} 个数据库文件") - else: + scan_result = scan_account_databases_from_path(db_storage_path) + detected_accounts = scan_result.get("detected_accounts", []) + if scan_result["status"] == "error": return { "status": "error", - "message": f"指定的数据库路径不存在: {db_storage_path}", + "message": scan_result["message"], "total_databases": 0, "successful_count": 0, "failed_count": 0, "output_directory": str(base_output_dir.absolute()), "processed_files": [], - "failed_files": [] + "failed_files": [], + "detected_accounts": scan_result.get("detected_accounts", []), } + account_databases = scan_result.get("account_databases", {}) + account_sources = scan_result.get("account_sources", {}) + for account_name, databases in account_databases.items(): + logger.info(f"在指定路径找到账号 {account_name} 的 {len(databases)} 个数据库文件") else: # 不再支持自动检测,要求用户提供具体的db_storage_path return { @@ -387,14 +503,9 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di logger.info(f"账号 {account_name} 输出目录: {account_output_dir}") try: - source_db_storage_path = str(db_storage_path or "") - wxid_dir = "" - if db_storage_path: - sp = Path(db_storage_path) - if sp.name.lower() == "db_storage": - wxid_dir = str(sp.parent) - else: - wxid_dir = str(sp) + source_info = account_sources.get(account_name, {}) + source_db_storage_path = str(source_info.get("db_storage_path") or db_storage_path or "") + wxid_dir = str(source_info.get("wxid_dir") or "") (account_output_dir / "_source.json").write_text( json.dumps( { @@ -473,7 +584,8 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di "output_directory": str(base_output_dir.absolute()), "processed_files": processed_files, "failed_files": failed_files, - "account_results": account_results # 新增:按账号的详细结果 + "account_results": account_results, # 新增:按账号的详细结果 + "detected_accounts": detected_accounts, } logger.info("=" * 60) From 0615ab6875ed34b9a86f2eedb967403bf4edf3f3 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Sat, 21 Mar 2026 14:21:01 +0800 Subject: [PATCH 03/39] =?UTF-8?q?fix(chat-export):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=AF=BC=E5=87=BA=E9=BB=98=E8=AE=A4=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E4=B8=8E=E4=BC=9A=E8=AF=9D=E9=80=89=E6=8B=A9=E5=BC=82?= =?UTF-8?q?=E5=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复未选中会话时导出弹窗落入无效默认范围的问题 - 支持按全部、群聊、单聊快速切换批量导出范围,并默认选中当前筛选结果 - 优化会话列表整行点击、高亮反馈和选择去重逻辑 --- frontend/components/chat/ChatOverlays.vue | 98 ++++++++++----------- frontend/composables/chat/useChatExport.js | 99 ++++++++++++++++++++-- 2 files changed, 141 insertions(+), 56 deletions(-) diff --git a/frontend/components/chat/ChatOverlays.vue b/frontend/components/chat/ChatOverlays.vue index 0b8999e..efd53e2 100644 --- a/frontend/components/chat/ChatOverlays.vue +++ b/frontend/components/chat/ChatOverlays.vue @@ -1290,52 +1290,77 @@
-
+
范围
-
- - +
+ + + +
格式
-
-
+
时间范围(可选)
-
+
-
@@ -1367,32 +1392,8 @@
-
- - - -
点击 tab 筛选
+
+ 点击上方范围可筛选并默认全选当前结果,再次点击可取消全选;下方整行可点选会话
-
- +
{{ (c.name || c.username || '?').charAt(0) }}
-
+
{{ c.name }} {{ c.isGroup ? '(群)' : '' }}
{{ c.username }}
-
+
无匹配会话
diff --git a/frontend/composables/chat/useChatExport.js b/frontend/composables/chat/useChatExport.js index bad2c48..5843f76 100644 --- a/frontend/composables/chat/useChatExport.js +++ b/frontend/composables/chat/useChatExport.js @@ -73,20 +73,35 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte return Math.round(clamp01(done / total) * 100) }) - const exportFilteredContacts = computed(() => { - const query = String(exportSearchQuery.value || '').trim().toLowerCase() + const normalizeExportSelectedUsernames = (list) => { + const seen = new Set() + return (Array.isArray(list) ? list : []).reduce((acc, item) => { + const username = String(item || '').trim() + if (!username || seen.has(username)) return acc + seen.add(username) + acc.push(username) + return acc + }, []) + } + + const getExportFilteredContacts = ({ tab = exportListTab.value, query = exportSearchQuery.value } = {}) => { + const normalizedQuery = String(query || '').trim().toLowerCase() let list = Array.isArray(contacts.value) ? contacts.value : [] - const tab = String(exportListTab.value || 'all') - if (tab === 'groups') list = list.filter((contact) => !!contact?.isGroup) - if (tab === 'singles') list = list.filter((contact) => !contact?.isGroup) + const normalizedTab = String(tab || 'all') + if (normalizedTab === 'groups') list = list.filter((contact) => !!contact?.isGroup) + if (normalizedTab === 'singles') list = list.filter((contact) => !contact?.isGroup) - if (!query) return list + if (!normalizedQuery) return list return list.filter((contact) => { const name = String(contact?.name || '').toLowerCase() const username = String(contact?.username || '').toLowerCase() - return name.includes(query) || username.includes(query) + return name.includes(normalizedQuery) || username.includes(normalizedQuery) }) + } + + const exportFilteredContacts = computed(() => { + return getExportFilteredContacts() }) const exportContactCounts = computed(() => { @@ -96,6 +111,60 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte return { total, groups, singles: total - groups } }) + const exportSelectedUsernameSet = computed(() => { + return new Set(normalizeExportSelectedUsernames(exportSelectedUsernames.value)) + }) + + const setExportSelectedUsernames = (list) => { + exportSelectedUsernames.value = normalizeExportSelectedUsernames(list) + } + + const getExportFilteredUsernames = (tab = exportListTab.value) => { + return getExportFilteredContacts({ tab }) + .map((contact) => String(contact?.username || '').trim()) + .filter(Boolean) + } + + const selectExportFilteredContacts = (tab = exportListTab.value) => { + setExportSelectedUsernames(getExportFilteredUsernames(tab)) + } + + const clearExportFilteredContacts = () => { + setExportSelectedUsernames([]) + } + + const areExportFilteredContactsAllSelected = (tab = exportListTab.value) => { + const usernames = getExportFilteredUsernames(tab) + if (usernames.length !== exportSelectedUsernameSet.value.size) return false + return usernames.every((username) => exportSelectedUsernameSet.value.has(username)) + } + + const onExportListTabClick = (tab) => { + const nextTab = String(tab || 'all') + const isSameTab = String(exportListTab.value || 'all') === nextTab + exportListTab.value = nextTab + + if (isSameTab) { + if (areExportFilteredContactsAllSelected(nextTab)) { + clearExportFilteredContacts(nextTab) + } else { + selectExportFilteredContacts(nextTab) + } + return + } + + selectExportFilteredContacts(nextTab) + } + + const isExportContactSelected = (username) => { + return exportSelectedUsernameSet.value.has(String(username || '').trim()) + } + + const onExportBatchScopeClick = (tab) => { + exportScope.value = 'selected' + onExportListTabClick(tab) + } + const isDesktopExportRuntime = () => { return !!(process.client && window?.wechatDesktop?.chooseDirectory) } @@ -269,12 +338,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte exportModalOpen.value = true exportError.value = '' exportSaveMsg.value = '' + exportSearchQuery.value = '' exportListTab.value = 'all' + exportSelectedUsernames.value = [] exportStartLocal.value = '' exportEndLocal.value = '' exportMessageTypes.value = exportMessageTypeOptions.map((item) => item.value) exportAutoSavedFor.value = '' - exportScope.value = selectedContact.value?.username ? 'current' : 'all' + exportScope.value = selectedContact.value?.username ? 'current' : 'selected' + if (!selectedContact.value?.username) { + selectExportFilteredContacts('all') + } } const closeExportModal = () => { @@ -296,6 +370,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte } }) + watch(exportScope, (scope, previousScope) => { + if (scope !== 'selected' || previousScope === 'selected') return + if (exportSelectedUsernames.value.length > 0) return + selectExportFilteredContacts(exportListTab.value) + }) + watch( () => ({ exportId: String(exportJob.value?.exportId || ''), @@ -447,6 +527,9 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte exportCurrentPercent, exportFilteredContacts, exportContactCounts, + onExportBatchScopeClick, + onExportListTabClick, + isExportContactSelected, hasWebExportFolder, chooseExportFolder, getExportDownloadUrl, From e507024ef658788bb9effda2300cfbee3dffae5c Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Sun, 22 Mar 2026 15:34:05 +0800 Subject: [PATCH 04/39] =?UTF-8?q?feat(ui):=20=E6=96=B0=E5=A2=9E=E6=B5=85?= =?UTF-8?q?=E6=B7=B1=E8=89=B2=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2=E5=B9=B6?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=95=8C=E9=9D=A2=E9=85=8D=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增主题 store 与本地持久化能力,支持侧边栏切换浅色/深色模式 - 将聊天页、会话列表、标题栏、弹窗等配色改为 CSS 变量统一管理 - 适配定位卡片、引用气泡、系统提示等聊天消息组件在不同主题下的可读性 - 同步整理首页、解密页、联系人页、朋友圈页等页面背景与交互样式 --- frontend/app.vue | 20 +- frontend/assets/css/chat.css | 81 +- frontend/assets/css/tailwind.css | 1581 +++++++++++++++++ frontend/components/ApiStatus.vue | 5 +- frontend/components/ChatLocationCard.vue | 25 +- frontend/components/DesktopTitleBar.vue | 12 +- frontend/components/DesktopUpdateDialog.vue | 2 +- frontend/components/SettingsDialog.vue | 4 +- frontend/components/SidebarRail.vue | 131 +- frontend/components/chat/ChatOverlays.vue | 28 +- frontend/components/chat/ConversationPane.vue | 12 +- frontend/components/chat/MessageContent.vue | 2 +- frontend/components/chat/MessageItem.vue | 6 +- frontend/components/chat/MessageList.vue | 8 +- frontend/components/chat/SessionListPanel.vue | 31 +- frontend/lib/ui-theme.js | 36 + frontend/pages/chat/[[username]].vue | 8 +- frontend/pages/contacts.vue | 8 +- frontend/pages/decrypt-result.vue | 4 +- frontend/pages/decrypt.vue | 2 +- frontend/pages/detection-result.vue | 2 +- frontend/pages/edits/[[username]].vue | 6 +- frontend/pages/index.vue | 2 +- frontend/pages/sns.vue | 6 +- frontend/pages/wrapped/index.vue | 7 +- frontend/stores/theme.js | 46 + 26 files changed, 1928 insertions(+), 147 deletions(-) create mode 100644 frontend/lib/ui-theme.js create mode 100644 frontend/stores/theme.js diff --git a/frontend/app.vue b/frontend/app.vue index ea71c78..bfa818a 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -30,12 +30,18 @@ + + diff --git a/frontend/components/chat/ChatOverlays.vue b/frontend/components/chat/ChatOverlays.vue index efd53e2..98f6131 100644 --- a/frontend/components/chat/ChatOverlays.vue +++ b/frontend/components/chat/ChatOverlays.vue @@ -826,51 +826,51 @@
-
+
-
{{ chatHistoryModalTitle || '聊天记录' }}
+
{{ chatHistoryModalTitle || '聊天记录' }}
-
-
+
+
没有可显示的聊天记录