diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a302de8..3c42f0d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,6 +14,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Derive version from tag shell: pwsh @@ -26,6 +28,67 @@ jobs: "TAG_NAME=$tag" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 "VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + - name: Generate release notes from commits + shell: pwsh + run: | + git fetch --force --tags + + $tag = $env:TAG_NAME + if ([string]::IsNullOrWhiteSpace($tag)) { + throw "TAG_NAME is empty" + } + + $repo = "${{ github.repository }}" + + $prev = "" + try { + $commit = (git rev-list -n 1 $tag).Trim() + if (-not [string]::IsNullOrWhiteSpace($commit)) { + $prev = (git describe --tags --abbrev=0 "$commit^" 2>$null).Trim() + } + } catch {} + + if ([string]::IsNullOrWhiteSpace($prev)) { + # Fallback: best-effort previous version tag by semver-ish sorting. + $prev = (git tag --list "v*" --sort=-v:refname | Where-Object { $_ -ne $tag } | Select-Object -First 1) + } + + $range = "" + if (-not [string]::IsNullOrWhiteSpace($prev)) { + $range = "$prev..$tag" + } + + $lines = @() + if (-not [string]::IsNullOrWhiteSpace($range)) { + $lines = @(git log --no-merges --pretty=format:"- %s (%h)" --reverse $range) + } else { + # First release tag / missing history: include a small recent window. + $lines = @(git log --no-merges --pretty=format:"- %s (%h)" --reverse -n 50) + } + + if (-not $lines -or $lines.Count -eq 0) { + $lines = @("- 修复了一些已知问题,提升了稳定性。") + } + + $max = 60 + if ($lines.Count -gt $max) { + $total = $lines.Count + $lines = @($lines | Select-Object -First $max) + $lines += "- ...(共 $total 条提交,更多请查看完整变更链接)" + } + + $body = @() + $body += "## 更新内容 ($tag)" + $body += "" + $body += $lines + + if (-not [string]::IsNullOrWhiteSpace($prev)) { + $body += "" + $body += "完整变更: https://github.com/$repo/compare/$prev...$tag" + } + + ($body -join "`n") | Out-File -FilePath release-notes.md -Encoding utf8 + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -71,7 +134,8 @@ jobs: with: tag_name: ${{ env.TAG_NAME }} name: ${{ env.TAG_NAME }} - generate_release_notes: true + body_path: release-notes.md files: | desktop/dist/*Setup*.exe desktop/dist/*Setup*.exe.blockmap + desktop/dist/latest.yml diff --git a/.gitignore b/.gitignore index 0d3413b..9fb4c41 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,13 @@ wheels/ # Local config templates /wechat_db_config_template.json +/wechat_db_config.json .ace-tool/ +pnpm-lock.yaml +/tools/tmp_isaac64_compare.js +/.claude/settings.local.json +.env +.env.* # Local dev repos and data /WxDatDecrypt/ @@ -25,13 +31,23 @@ wheels/ /vue3-wechat-tool/ /wechatDataBackup/ /wx_key/ +/refs/ +/WeFlow/ +/win95/ +/py_wx_key/ # Electron desktop app /desktop/node_modules/ /desktop/dist/ +/desktop/dist-updater-test/ /desktop/build/ /desktop/resources/ui/* !/desktop/resources/ui/.gitkeep /desktop/resources/backend/*.exe +/desktop/resources/backend/native/* +/desktop/resources/backend/pyproject.toml !/desktop/resources/backend/.gitkeep /desktop/resources/icon.ico + +# Local scratch file accidentally generated during development +/bento-summary.html diff --git a/README.md b/README.md index 392cc83..1debb54 100644 --- a/README.md +++ b/README.md @@ -4,50 +4,75 @@

WeChatDataAnalysis - 微信数据库解密与分析工具

-

一个专门用于微信4.x版本数据库解密的工具(支持聊天记录实时更新)

-

特别致谢echotrace(本项目大量功能参考其实现,提供了重要技术支持)

+

微信4.x数据解密并生成年度总结,高仿微信,支持实时更新,导出聊天记录,朋友圈等大量便捷功能

+

特别致谢H3CoF6(密钥与朋友圈等核心内容的技术支持)、echotraceWeFlow(本项目大量功能参考其实现)

+

如需定制功能,请联系 QQ:2977094657。

Version Stars + Downloads Forks - License + QQ Group Python - FastAPI Vue.js SQLite
+## 年度总结 + + + + + + + + + + + + + + + + + + + + + +
年度总结 Modern
AnnualSummary 1AnnualSummary 2
AnnualSummary 3AnnualSummary 4
AnnualSummary 5AnnualSummary 6
AnnualSummary 7AnnualSummary 8
+ ## 界面预览 - - + - - + - - + - - + - - + - - + - + - + + + + + + + @@ -61,28 +86,23 @@ + + + + + +
首页检测页面聊天记录页面(支持多种消息类型展示,样式尽可能与微信保持一致)
首页微信检测页面聊天记录页面
解密页面图片密钥(填写)修改消息(本地修改,支持恢复)
数据库解密页面图片密钥(填写)修改消息
图片解密页面解密成功页面实时消息同步(点击侧边栏闪电图标后,消息会自动刷新)
图片解密页面解密成功页面实时消息同步
聊天记录页面设置面板(桌面行为、启动偏好、更新、朋友圈缓存策略)
聊天记录页面设置面板
朋友圈(支持查看用户之前朋友圈的背景图及时间;本地查看过的朋友圈即使后续不可见也可以查看)
朋友圈
聊天记录搜索
聊天记录导出
联系人导出
联系人导出
-## 功能特性 - -### 已实现功能 +## 加入群聊 -- **数据库解密**: 支持微信4.x版本数据库文件的解密 -- **多账户检测**: 自动检测并处理多个微信账户的数据库文件 -- **API接口**: 提供RESTful API接口进行数据库解密操作 -- **Web界面**: 提供现代化的Web操作界面 -- **聊天记录查看**: 支持查看解密后的聊天记录、消息搜索与离线导出 -- **实时更新(SSE)**: 支持开启实时模式,监听 `db_storage` 变更,增量同步新消息并自动刷新会话/消息列表 -- **聊天图片展示**: 支持部分版本图片消息无MD5时通过 file_id 兜底定位本地资源 +也欢迎加入下方 QQ 群一起讨论。 -### 开发计划 - -- **数据分析**: 对解密后的数据进行深度分析 -- **数据可视化**: 提供图表、统计报告等可视化展示 -- **聊天记录分析**: 消息频率、活跃时间、关键词分析等 -- **聊天记录优化**: 高级筛选、统计报表等功能 - -> **项目进展**: 查看 [GitHub项目面板](https://github.com/orgs/LifeArchiveProject/projects/1/views/1) 了解当前开发状态和后续功能规划 +

+ + WeChatDataAnalysis 加群二维码 + +

## 快速开始 @@ -135,8 +155,9 @@ npm run dev #### 2.5 访问应用 - 前端界面: http://localhost:3000 -- API服务: http://localhost:8000 -- API文档: http://localhost:8000/docs +- API服务(默认): http://localhost:10392 (可通过环境变量 WECHAT_TOOL_PORT 修改) +- API文档(默认): http://localhost:10392/docs +- 也可在应用内“设置 -> 后端端口”修改(支持“恢复默认”一键回到 10392):网页端会尝试重启本机后端到新端口并刷新(并写入 `output/runtime_settings.json`,开发模式下也会写入项目根目录 `.env` 供 `uv run` 下次启动使用);桌面端会重启内置后端并刷新 ## 打包为 EXE(Windows 桌面端) @@ -172,30 +193,62 @@ npm run dist 3. **数据隐私**: 解密后的数据包含个人隐私信息,请谨慎处理 4. **合法使用**: 请遵守相关法律法规,不得用于非法目的 +## 赞助与支持 + +如果本项目对你有帮助,欢迎通过以下方式赞助。付款时请在备注中填写“希望公开展示的链接”(如个人主页、B 站空间、GitHub 仓库等),我们会在 README 的“赞助鸣谢”表格中展示。 + +
+ + + + + +
+ 微信收款码
+ 微信赞助 +
+ 支付宝收款码
+ 支付宝赞助 +
+
+ +### 赞助鸣谢 + +| 联系内容 | 付款金额 | +| ----------------------------------------------------- | -------- | +| [惜囍的个人空间-哔哩哔哩](https://space.bilibili.com/291501729) | ¥29.99 | +| 匿名用户 | ¥168 | + +提示:已赞助但未收录,请在 Issues 提交凭证与备注链接;如需匿名可说明。 + ## 致谢 本项目的开发过程中参考了以下优秀的开源项目和资源: -### 主要参考项目 - 1. **[echotrace](https://github.com/ycccccccy/echotrace)** - 微信数据解析/取证工具 - 本项目大量功能参考并复用其实现思路,提供了重要技术支持 -2. **[wx_key](https://github.com/ycccccccy/wx_key)** - 微信数据库与图片密钥提取工具 +2. **[WeFlow](https://github.com/hicccc77/WeFlow)** - 微信数据分析工具 + - 提供了重要的功能参考和技术支持 + +3. **[wx_key](https://github.com/ycccccccy/wx_key)** - 微信数据库与图片密钥提取工具 - 支持获取微信 4.x 数据库密钥与缓存图片密钥 - 本项目推荐使用此工具获取密钥 -3. **[wechat-dump-rs](https://github.com/0xlane/wechat-dump-rs)** - Rust实现的微信数据库解密工具 +4. **[wechat-dump-rs](https://github.com/0xlane/wechat-dump-rs)** - Rust实现的微信数据库解密工具 - 提供了SQLCipher 4.0解密的正确实现参考 - 本项目的HMAC验证和页面处理逻辑基于此项目的实现 -4. **[oh-my-wechat](https://github.com/chclt/oh-my-wechat)** - 微信聊天记录查看工具 +5. **[oh-my-wechat](https://github.com/chclt/oh-my-wechat)** - 微信聊天记录查看工具 - 提供了优秀的聊天记录界面设计参考 - 本项目的聊天界面风格参考了此项目的实现 -5. **[vue3-wechat-tool](https://github.com/Ele-Cat/vue3-wechat-tool)** - 微信聊天记录工具(Vue3) +6. **[vue3-wechat-tool](https://github.com/Ele-Cat/vue3-wechat-tool)** - 微信聊天记录工具(Vue3) - 提供了聊天记录展示与交互的实现参考 +7. **[wx-dat](https://github.com/waaaaashi/wx-dat)** - 微信图片密钥获取工具 + - 实现真正的无头获取图片密钥,不再依赖扫描微信内存与点击朋友圈大图 + ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=LifeArchiveProject/WeChatDataAnalysis&type=Date)](https://www.star-history.com/#LifeArchiveProject/WeChatDataAnalysis&Date) @@ -204,10 +257,7 @@ npm run dist 欢迎提交Issue和Pull Request来改进这个项目。 -## 许可证 - -本项目仅供学习和个人使用。请遵守相关法律法规。 - --- **免责声明**: 本工具仅供学习研究使用,使用者需自行承担使用风险。开发者不对因使用本工具造成的任何损失负责。 + diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 4bb8122..11a8eb8 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,15 @@ { "name": "wechat-data-analysis-desktop", - "version": "0.1.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wechat-data-analysis-desktop", - "version": "0.1.0", + "version": "1.3.0", + "dependencies": { + "electron-updater": "^6.7.3" + }, "devDependencies": { "concurrently": "^9.2.1", "cross-env": "^10.1.0", @@ -1105,7 +1108,6 @@ "version": "2.0.1", "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/assert-plus": { @@ -1295,7 +1297,6 @@ "version": "9.5.1", "resolved": "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -1796,7 +1797,6 @@ "version": "4.4.3", "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2252,6 +2252,69 @@ "node": ">= 10.0.0" } }, + "node_modules/electron-updater": { + "version": "6.7.3", + "resolved": "https://registry.npmmirror.com/electron-updater/-/electron-updater-6.7.3.tgz", + "integrity": "sha512-EgkT8Z9noqXKbwc3u5FkJA+r48jwZ5DTUiOkJMOTEEH//n5Am6wfQGz7nvSFEA2oIAMv9jRzn5JKTyWeSKOPgg==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.5.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmmirror.com/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -2816,7 +2879,6 @@ "version": "4.2.11", "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -3139,7 +3201,6 @@ "version": "4.1.1", "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3207,7 +3268,6 @@ "version": "1.0.5", "resolved": "https://registry.npmmirror.com/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash": { @@ -3217,6 +3277,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", @@ -3535,7 +3608,6 @@ "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/negotiator": { @@ -4272,7 +4344,6 @@ "version": "1.4.4", "resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.4.tgz", "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -4767,6 +4838,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", diff --git a/desktop/package.json b/desktop/package.json index 19bd486..2709dce 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,19 +1,23 @@ { "name": "wechat-data-analysis-desktop", "private": true, - "version": "0.1.0", + "version": "1.3.0", "main": "src/main.cjs", "scripts": { - "dev": "concurrently -k -s first \"cd ..\\\\frontend && npm run dev\" \"cross-env ELECTRON_START_URL=http://localhost:3000 electron .\"", - "dev:static": "pushd ..\\\\frontend && npm run generate && popd && cross-env ELECTRON_START_URL=http://127.0.0.1:8000 electron .", + "dev": "node scripts/dev.cjs", + "dev:static": "pushd ..\\\\frontend && npm run generate && popd && cross-env ELECTRON_START_URL=http://127.0.0.1:10392 electron .", "build:ui": "pushd ..\\\\frontend && npm run generate && popd && node scripts\\\\copy-ui.cjs", "build:backend": "uv sync --extra build && node scripts/build-backend.cjs", "build:icon": "node scripts/build-icon.cjs", - "dist": "npm run build:ui && npm run build:backend && npm run build:icon && electron-builder --win --x64" + "dist": "npm run build:ui && npm run build:backend && npm run build:icon && electron-builder --win --x64 --publish never" + }, + "dependencies": { + "electron-updater": "^6.7.3" }, "build": { "appId": "com.lifearchive.wechatdataanalysis", "productName": "WeChatDataAnalysis", + "artifactName": "${productName}-${version}-Setup.${ext}", "icon": "build/icon.ico", "asar": true, "directories": { @@ -21,7 +25,29 @@ }, "files": [ "src/**/*", - "package.json" + "package.json", + { + "from": "node_modules", + "to": "node_modules", + "filter": [ + "electron-updater/**/*", + "builder-util-runtime/**/*", + "debug/**/*", + "ms/**/*", + "sax/**/*", + "js-yaml/**/*", + "argparse/**/*", + "lazy-val/**/*", + "lodash.escaperegexp/**/*", + "lodash.isequal/**/*", + "tiny-typed-emitter/**/*", + "fs-extra/**/*", + "graceful-fs/**/*", + "jsonfile/**/*", + "universalify/**/*", + "semver/**/*" + ] + } ], "extraResources": [ { @@ -39,6 +65,12 @@ "nsis" ] }, + "publish": { + "provider": "github", + "owner": "LifeArchiveProject", + "repo": "WeChatDataAnalysis", + "releaseType": "release" + }, "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true, diff --git a/desktop/scripts/build-backend.cjs b/desktop/scripts/build-backend.cjs index 4e32f8a..df7c9bb 100644 --- a/desktop/scripts/build-backend.cjs +++ b/desktop/scripts/build-backend.cjs @@ -13,8 +13,63 @@ fs.mkdirSync(distDir, { recursive: true }); fs.mkdirSync(workDir, { recursive: true }); fs.mkdirSync(specDir, { recursive: true }); +function parseVersionTuple(rawVersion) { + const nums = String(rawVersion || "") + .split(/[^\d]+/) + .map((x) => Number.parseInt(x, 10)) + .filter((n) => Number.isInteger(n) && n >= 0); + while (nums.length < 4) nums.push(0); + return nums.slice(0, 4); +} + +function buildVersionInfoText(versionTuple, versionDot) { + const [a, b, c, d] = versionTuple; + return `# UTF-8 +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(${a}, ${b}, ${c}, ${d}), + prodvers=(${a}, ${b}, ${c}, ${d}), + mask=0x3f, + flags=0x0, + OS=0x4, + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo([ + StringTable( + '080404B0', + [StringStruct('CompanyName', 'LifeArchiveProject'), + StringStruct('FileDescription', 'WeFlow'), + StringStruct('FileVersion', '${versionDot}'), + StringStruct('InternalName', 'weflow'), + StringStruct('LegalCopyright', 'github.com/hicccc77/WeFlow'), + StringStruct('OriginalFilename', 'weflow.exe'), + StringStruct('ProductName', 'WeFlow'), + StringStruct('ProductVersion', '${versionDot}')]) + ]), + VarFileInfo([VarStruct('Translation', [2052, 1200])]) + ] +) +`; +} + const nativeDir = path.join(repoRoot, "src", "wechat_decrypt_tool", "native"); const addData = `${nativeDir};wechat_decrypt_tool/native`; +const projectToml = path.join(repoRoot, "pyproject.toml"); + +const desktopPackageJsonPath = path.join(repoRoot, "desktop", "package.json"); +let desktopVersion = "1.3.0"; +try { + const pkg = JSON.parse(fs.readFileSync(desktopPackageJsonPath, { encoding: "utf8" })); + const v = String(pkg?.version || "").trim(); + if (v) desktopVersion = v; +} catch {} +const versionTuple = parseVersionTuple(desktopVersion); +const versionDot = versionTuple.join("."); +const versionFilePath = path.join(workDir, "weflow-version.txt"); +fs.writeFileSync(versionFilePath, buildVersionInfoText(versionTuple, versionDot), { encoding: "utf8" }); const args = [ "run", @@ -30,11 +85,42 @@ const args = [ workDir, "--specpath", specDir, + "--version-file", + versionFilePath, "--add-data", addData, entry, ]; const r = spawnSync("uv", args, { cwd: repoRoot, stdio: "inherit" }); -process.exit(r.status ?? 1); +if ((r.status ?? 1) !== 0) { + process.exit(r.status ?? 1); +} + +// Keep a stable external native folder for packaged runtime to avoid relying on +// onefile temp extraction paths when wcdb_api.dll performs environment checks. +const packagedNativeDir = path.join(distDir, "native"); +try { + fs.rmSync(packagedNativeDir, { recursive: true, force: true }); +} catch {} +fs.mkdirSync(packagedNativeDir, { recursive: true }); + +for (const name of fs.readdirSync(nativeDir)) { + const src = path.join(nativeDir, name); + const dst = path.join(packagedNativeDir, name); + try { + if (fs.statSync(src).isFile()) { + fs.copyFileSync(src, dst); + } + } catch {} +} + +// Provide the project marker next to packaged backend resources. +if (fs.existsSync(projectToml)) { + try { + fs.copyFileSync(projectToml, path.join(distDir, "pyproject.toml")); + } catch {} +} + +process.exit(0); diff --git a/desktop/scripts/dev.cjs b/desktop/scripts/dev.cjs new file mode 100644 index 0000000..1e2f1ab --- /dev/null +++ b/desktop/scripts/dev.cjs @@ -0,0 +1,179 @@ +const http = require("http"); +const net = require("net"); +const path = require("path"); +const { spawn, spawnSync } = require("child_process"); + +const repoRoot = path.resolve(__dirname, "..", ".."); +const frontendDir = path.join(repoRoot, "frontend"); +const desktopDir = path.join(repoRoot, "desktop"); + +function parsePort(value) { + const n = Number.parseInt(String(value || "").trim(), 10); + return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null; +} + +function log(message) { + process.stdout.write(`[dev] ${message}\n`); +} + +function prefixPipe(stream, prefix) { + if (!stream) return; + let pending = ""; + stream.setEncoding("utf8"); + stream.on("data", (chunk) => { + pending += chunk; + const lines = pending.split(/\r?\n/); + pending = lines.pop() || ""; + for (const line of lines) { + process.stdout.write(`${prefix} ${line}\n`); + } + }); + stream.on("end", () => { + const tail = pending.trim(); + if (tail) process.stdout.write(`${prefix} ${tail}\n`); + }); +} + +function isPortAvailable(port, host) { + return new Promise((resolve) => { + const server = net.createServer(); + const done = (ok) => { + try { + server.close(); + } catch {} + resolve(ok); + }; + server.once("error", () => done(false)); + server.once("listening", () => done(true)); + server.listen(port, host); + }); +} + +async function choosePort({ label, envName, preferredPort, host, searchLimit = 20 }) { + if (preferredPort != null) { + const ok = await isPortAvailable(preferredPort, host); + if (!ok) throw new Error(`${label}端口 ${preferredPort} 已被占用,请修改环境变量 ${envName}`); + return preferredPort; + } + + const startPort = envName === "NUXT_PORT" ? 3000 : 10392; + for (let port = startPort; port <= startPort + searchLimit; port += 1) { + if (await isPortAvailable(port, host)) return port; + } + throw new Error(`未找到可用的${label}端口(起始 ${startPort})`); +} + +function httpReady(url) { + return new Promise((resolve) => { + const req = http.get(url, (res) => { + res.resume(); + resolve(true); + }); + req.on("error", () => resolve(false)); + req.setTimeout(1000, () => { + req.destroy(); + resolve(false); + }); + }); +} + +async function waitForUrl(url, child, timeoutMs) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (child.exitCode != null) { + throw new Error(`前端进程提前退出,exitCode=${child.exitCode}`); + } + if (await httpReady(url)) return; + await new Promise((resolve) => setTimeout(resolve, 300)); + } + throw new Error(`等待前端启动超时:${url}`); +} + +function killChild(child) { + if (!child || child.killed || child.exitCode != null) return; + if (process.platform === "win32") { + spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" }); + return; + } + try { + child.kill("SIGTERM"); + } catch {} +} + +function spawnLogged(command, args, options, prefix) { + const child = spawn(command, args, { + ...options, + shell: process.platform === "win32", + stdio: ["inherit", "pipe", "pipe"], + }); + prefixPipe(child.stdout, `${prefix}`); + prefixPipe(child.stderr, `${prefix}`); + return child; +} + +async function main() { + const frontendHost = String(process.env.NUXT_HOST || "127.0.0.1").trim() || "127.0.0.1"; + const requestedFrontendPort = parsePort(process.env.NUXT_PORT); + const requestedBackendPort = parsePort(process.env.WECHAT_TOOL_PORT); + const frontendPort = await choosePort({ + label: "前端", + envName: "NUXT_PORT", + preferredPort: requestedFrontendPort, + host: frontendHost, + }); + const backendPort = await choosePort({ + label: "后端", + envName: "WECHAT_TOOL_PORT", + preferredPort: requestedBackendPort, + host: "127.0.0.1", + }); + const startUrl = `http://${frontendHost}:${frontendPort}`; + + log(`frontend=${startUrl}`); + log(`backend=http://127.0.0.1:${backendPort}/api`); + + const sharedEnv = { + ...process.env, + NUXT_HOST: frontendHost, + NUXT_PORT: String(frontendPort), + WECHAT_TOOL_PORT: String(backendPort), + ELECTRON_START_URL: startUrl, + }; + + const npmCommand = "npm"; + const electronCommand = "electron"; + const children = new Set(); + let shuttingDown = false; + + const shutdown = (exitCode) => { + if (shuttingDown) return; + shuttingDown = true; + for (const child of children) killChild(child); + process.exitCode = exitCode; + }; + + process.on("SIGINT", () => shutdown(130)); + process.on("SIGTERM", () => shutdown(143)); + + const frontend = spawnLogged(npmCommand, ["run", "dev"], { cwd: frontendDir, env: sharedEnv }, "[frontend]"); + children.add(frontend); + frontend.once("exit", (code, signal) => { + log(`frontend exited code=${code} signal=${signal}`); + shutdown(code == null ? 1 : code); + }); + + await waitForUrl(startUrl, frontend, 60_000); + log("frontend is ready, starting Electron"); + + const electron = spawnLogged(electronCommand, ["."], { cwd: desktopDir, env: sharedEnv }, "[electron]"); + children.add(electron); + electron.once("exit", (code, signal) => { + log(`electron exited code=${code} signal=${signal}`); + shutdown(code == null ? 0 : code); + }); +} + +main().catch((err) => { + process.stderr.write(`[dev] ${err?.stack || err}\n`); + process.exit(1); +}); diff --git a/desktop/scripts/installer-custom.nsh b/desktop/scripts/installer-custom.nsh index 6dd09fb..9938492 100644 --- a/desktop/scripts/installer-custom.nsh +++ b/desktop/scripts/installer-custom.nsh @@ -1,6 +1,22 @@ ; This file is included for both installer and uninstaller builds. ; Guard installer-only pages/functions to avoid "function not referenced" warnings ; when electron-builder compiles the standalone uninstaller. +!define /ifndef WDA_DEFAULT_SETTINGS_PATH "$APPDATA\${APP_FILENAME}\desktop-settings.json" +!define /ifndef WDA_DEFAULT_OUTPUT_DIR "$APPDATA\${APP_FILENAME}\output" +!ifdef APP_PRODUCT_FILENAME +!define /ifndef WDA_PRODUCT_SETTINGS_PATH "$APPDATA\${APP_PRODUCT_FILENAME}\desktop-settings.json" +!define /ifndef WDA_PRODUCT_OUTPUT_DIR "$APPDATA\${APP_PRODUCT_FILENAME}\output" +!else +!define /ifndef WDA_PRODUCT_SETTINGS_PATH "" +!define /ifndef WDA_PRODUCT_OUTPUT_DIR "" +!endif +!ifdef APP_PACKAGE_NAME +!define /ifndef WDA_PACKAGE_SETTINGS_PATH "$APPDATA\${APP_PACKAGE_NAME}\desktop-settings.json" +!define /ifndef WDA_PACKAGE_OUTPUT_DIR "$APPDATA\${APP_PACKAGE_NAME}\output" +!else +!define /ifndef WDA_PACKAGE_SETTINGS_PATH "" +!define /ifndef WDA_PACKAGE_OUTPUT_DIR "" +!endif !ifndef BUILD_UNINSTALLER !include nsDialogs.nsh !include LogicLib.nsh @@ -13,15 +29,58 @@ !define /ifndef MUI_DIRECTORYPAGE_TEXT_DESTINATION "安装位置:" Var WDA_InstallDirPage +Var WDA_OutputDirPage +Var WDA_OutputDirInput +Var WDA_OutputDirBrowseButton +Var WDA_SelectedOutputDir + +!macro customInit + ; Safety: older versions created an `output` junction inside the install directory that points to the + ; per-user AppData `output` folder. Some uninstall/update flows may traverse that junction and delete + ; real user data. Remove it as early as possible during install/update. + Call WDA_RemoveLegacyOutputLink +!macroend + +!macro customInstall + ${If} $WDA_SelectedOutputDir == "" + Call WDA_InitOutputDirSelection + ${EndIf} + Call WDA_WritePendingOutputDirSetting +!macroend + +Function WDA_RemoveLegacyOutputLink + ; $INSTDIR is usually the full install directory. Be defensive and also try the nested path + ; in case the installer is running before electron-builder appends "\${APP_FILENAME}". + RMDir "$INSTDIR\output" + RMDir "$INSTDIR\${APP_FILENAME}\output" +FunctionEnd !macro customPageAfterChangeDir ; Add a confirmation page after the directory picker so users clearly see ; the final install location (includes the app sub-folder). !ifdef allowToChangeInstallationDirectory Page custom WDA_InstallDirPageCreate WDA_InstallDirPageLeave + Page custom WDA_OutputDirPageCreate WDA_OutputDirPageLeave !endif !macroend +Function WDA_InitOutputDirSelection + StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}" + nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$settingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$settingsPath = $$candidate; break } }; $$result = $$defaultOutputPath; if (Test-Path -LiteralPath $$settingsPath) { try { $$json = Get-Content -LiteralPath $$settingsPath -Raw | ConvertFrom-Json; $$value = [string] $$json.pendingOutputDir; if ([string]::IsNullOrWhiteSpace($$value)) { $$value = [string] $$json.outputDir }; if ($$value -eq '''') { $$result = $$defaultOutputPath } elseif (-not [string]::IsNullOrWhiteSpace($$value)) { $$result = $$value } } catch {} }; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::Write($$result) }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"' + Pop $0 + Pop $1 + ${If} $0 == "0" + ${AndIf} $1 != "" + StrCpy $WDA_SelectedOutputDir "$1" + ${EndIf} +FunctionEnd + +Function WDA_WritePendingOutputDirSetting + nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$defaultSettingsPath, [string] $$defaultOutputPath, [string] $$selectedOutputPath, [string] $$legacySettingsPath1, [string] $$legacySettingsPath2) $$candidates = @($$defaultSettingsPath, $$legacySettingsPath1, $$legacySettingsPath2) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) } | Select-Object -Unique; $$sourceSettingsPath = $$defaultSettingsPath; foreach ($$candidate in $$candidates) { if (Test-Path -LiteralPath $$candidate) { $$sourceSettingsPath = $$candidate; break } }; if ([string]::IsNullOrWhiteSpace($$selectedOutputPath)) { $$selectedOutputPath = $$defaultOutputPath }; $$pending = if ([string]::Equals($$selectedOutputPath, $$defaultOutputPath, [System.StringComparison]::OrdinalIgnoreCase)) { '''' } else { $$selectedOutputPath }; $$obj = @{}; if (Test-Path -LiteralPath $$sourceSettingsPath) { try { $$existing = Get-Content -LiteralPath $$sourceSettingsPath -Raw | ConvertFrom-Json; if ($$null -ne $$existing) { $$existing.PSObject.Properties | ForEach-Object { $$obj[$$_.Name] = $$_.Value } } } catch {} }; $$obj[''pendingOutputDir''] = $$pending; $$dir = Split-Path -Parent $$defaultSettingsPath; New-Item -ItemType Directory -Force -Path $$dir | Out-Null; $$json = [PSCustomObject] $$obj | ConvertTo-Json -Depth 10; Set-Content -LiteralPath $$defaultSettingsPath -Value $$json -Encoding UTF8 }" "${WDA_DEFAULT_SETTINGS_PATH}" "${WDA_DEFAULT_OUTPUT_DIR}" "$WDA_SelectedOutputDir" "${WDA_PRODUCT_SETTINGS_PATH}" "${WDA_PACKAGE_SETTINGS_PATH}"' + Pop $0 + Pop $1 +FunctionEnd + Function WDA_EnsureAppSubDir ; Normalize $INSTDIR to always end with "\${APP_FILENAME}" (avoid cluttering a parent folder). StrCpy $0 "$INSTDIR" @@ -77,6 +136,48 @@ FunctionEnd Function WDA_InstallDirPageLeave FunctionEnd +Function WDA_OutputDirBrowse + nsDialogs::SelectFolderDialog "选择 output 目录" "$WDA_SelectedOutputDir" + Pop $0 + ${If} $0 != error + StrCpy $WDA_SelectedOutputDir "$0" + ${NSD_SetText} $WDA_OutputDirInput "$0" + ${EndIf} +FunctionEnd + +Function WDA_OutputDirPageCreate + Call WDA_InitOutputDirSelection + + nsDialogs::Create 1018 + Pop $WDA_OutputDirPage + + ${If} $WDA_OutputDirPage == error + Abort + ${EndIf} + + ${NSD_CreateLabel} 0u 0u 100% 24u "请选择 output 目录(保存解密数据库、导出内容、缓存、日志等)。" + Pop $0 + + ${NSD_CreateText} 0u 28u 78% 12u "$WDA_SelectedOutputDir" + Pop $WDA_OutputDirInput + + ${NSD_CreateButton} 82% 27u 18% 14u "浏览..." + Pop $WDA_OutputDirBrowseButton + ${NSD_OnClick} $WDA_OutputDirBrowseButton WDA_OutputDirBrowse + + ${NSD_CreateLabel} 0u 52u 100% 28u "安装器只记录你的选择;真正的数据迁移会在首次启动应用时执行。若目标目录已有内容,应用会阻止切换并提示处理。" + Pop $0 + + nsDialogs::Show +FunctionEnd + +Function WDA_OutputDirPageLeave + ${NSD_GetText} $WDA_OutputDirInput $WDA_SelectedOutputDir + ${If} $WDA_SelectedOutputDir == "" + StrCpy $WDA_SelectedOutputDir "${WDA_DEFAULT_OUTPUT_DIR}" + ${EndIf} +FunctionEnd + !endif !ifdef BUILD_UNINSTALLER @@ -90,6 +191,10 @@ Var /GLOBAL WDA_DeleteUserData !macro customUnInit ; Default: keep user data (also applies to silent uninstall / update uninstall). StrCpy $WDA_DeleteUserData "0" + + ; Safety: if an older build created an `output` junction inside the install dir, remove it early so + ; directory cleanup can't traverse it and delete the real per-user output folder. + RMDir "$INSTDIR\output" !macroend !macro customUnWelcomePage @@ -145,6 +250,12 @@ FunctionEnd RMDir /r "$APPDATA\${APP_PACKAGE_NAME}" !endif + IfFileExists "$INSTDIR\output-location.path" 0 WDA_SkipCustomOutputDelete + nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -Command "& { param([string] $$pathFile, [string] $$defaultPath1, [string] $$defaultPath2, [string] $$defaultPath3) if (Test-Path -LiteralPath $$pathFile) { $$target = (Get-Content -LiteralPath $$pathFile -Raw).Trim(); $$defaults = @($$defaultPath1, $$defaultPath2, $$defaultPath3) | Where-Object { -not [string]::IsNullOrWhiteSpace($$_) }; $$isDefault = $$false; foreach ($$defaultPath in $$defaults) { if ([string]::Equals($$target, $$defaultPath, [System.StringComparison]::OrdinalIgnoreCase)) { $$isDefault = $$true; break } }; if (-not $$isDefault -and -not [string]::IsNullOrWhiteSpace($$target) -and (Test-Path -LiteralPath $$target)) { Remove-Item -LiteralPath $$target -Recurse -Force -ErrorAction SilentlyContinue } } }" "$INSTDIR\output-location.path" "${WDA_DEFAULT_OUTPUT_DIR}" "${WDA_PRODUCT_OUTPUT_DIR}" "${WDA_PACKAGE_OUTPUT_DIR}"' + Pop $0 + Pop $1 + WDA_SkipCustomOutputDelete: + ${if} $installMode == "all" SetShellVarContext all ${endif} diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index a2321bc..7f4bc51 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -7,28 +7,198 @@ const { globalShortcut, dialog, shell, + session, } = require("electron"); -const { spawn } = require("child_process"); +let autoUpdater = null; +let autoUpdaterLoadError = null; +try { + ({ autoUpdater } = require("electron-updater")); +} catch (err) { + autoUpdaterLoadError = err; +} +const { spawn, spawnSync } = require("child_process"); const fs = require("fs"); const http = require("http"); +const net = require("net"); const path = require("path"); +const { + getDefaultOutputDirPath, + getEffectiveOutputDirPath, + migrateOutputDirectory, + normalizeDirectoryPath, + rollbackOutputDirectoryChange, +} = require("./output-dir.cjs"); -const BACKEND_HOST = process.env.WECHAT_TOOL_HOST || "127.0.0.1"; -const BACKEND_PORT = Number(process.env.WECHAT_TOOL_PORT || "8000"); -const BACKEND_HEALTH_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}/api/health`; +const DEFAULT_BACKEND_HOST = String(process.env.WECHAT_TOOL_HOST || "127.0.0.1").trim() || "127.0.0.1"; +const DEFAULT_BACKEND_PORT = parsePort(process.env.WECHAT_TOOL_PORT) ?? 10392; let backendProc = null; -let backendStdioStream = null; let resolvedDataDir = null; let mainWindow = null; let tray = null; let isQuitting = false; let desktopSettings = null; +let backendPortChangeInProgress = false; +let outputDirChangeInProgress = false; + +const gotSingleInstanceLock = app.requestSingleInstanceLock(); +if (!gotSingleInstanceLock) { + // If we allow a second instance to boot it will try to spawn another backend on the same port. + // Quit early to avoid leaving orphan backend processes around. + try { + app.quit(); + } catch {} +} else { + app.on("second-instance", () => { + try { + if (app.isReady()) showMainWindow(); + else app.whenReady().then(() => showMainWindow()); + } catch {} + }); +} function nowIso() { return new Date().toISOString(); } +function parsePort(value) { + if (value == null) return null; + const raw = String(value).trim(); + if (!raw) return null; + const n = Number(raw); + if (!Number.isInteger(n)) return null; + if (n < 1 || n > 65535) return null; + return n; +} + +function formatHostForUrl(host) { + const h = String(host || "").trim(); + if (!h) return "127.0.0.1"; + // IPv6 literals must be wrapped in brackets in URLs. + if (h.includes(":") && !(h.startsWith("[") && h.endsWith("]"))) return `[${h}]`; + return h; +} + +function getBackendBindHost() { + return DEFAULT_BACKEND_HOST; +} + +function getBackendAccessHost() { + // 0.0.0.0 / :: are fine bind hosts, but not a reachable client destination. + const host = String(getBackendBindHost() || "").trim(); + if (host === "0.0.0.0" || host === "::") return "127.0.0.1"; + return host || "127.0.0.1"; +} + +function getBackendPort() { + const envPort = parsePort(process.env.WECHAT_TOOL_PORT); + if (envPort != null) return envPort; + // In dev we intentionally ignore persisted packaged-app settings so the + // launcher can keep Electron, Nuxt devProxy and the backend child aligned. + if (!app.isPackaged) return DEFAULT_BACKEND_PORT; + const settingsPort = parsePort(loadDesktopSettings()?.backendPort); + return settingsPort ?? DEFAULT_BACKEND_PORT; +} + +function setBackendPortSetting(nextPort) { + const p = parsePort(nextPort); + if (p == null) throw new Error("端口无效,请输入 1-65535 的整数"); + loadDesktopSettings(); + desktopSettings.backendPort = p; + persistDesktopSettings(); + process.env.WECHAT_TOOL_PORT = String(p); + return p; +} + +function getBackendHealthUrl() { + const host = formatHostForUrl(getBackendAccessHost()); + const port = getBackendPort(); + return `http://${host}:${port}/api/health`; +} + +function getBackendUiUrl() { + const host = formatHostForUrl(getBackendAccessHost()); + const port = getBackendPort(); + return `http://${host}:${port}/`; +} + +function isPortAvailable(port, host) { + return new Promise((resolve) => { + try { + const srv = net.createServer(); + srv.unref(); + srv.once("error", () => resolve(false)); + srv.listen({ port, host }, () => { + srv.close(() => resolve(true)); + }); + } catch { + resolve(false); + } + }); +} + +function getEphemeralPort(host) { + return new Promise((resolve) => { + try { + const srv = net.createServer(); + srv.unref(); + srv.once("error", () => resolve(null)); + srv.listen({ port: 0, host }, () => { + const addr = srv.address(); + const p = addr && typeof addr === "object" ? Number(addr.port) : null; + srv.close(() => resolve(Number.isInteger(p) ? p : null)); + }); + } catch { + resolve(null); + } + }); +} + +async function chooseAvailablePort(preferredPort, host) { + const preferred = parsePort(preferredPort); + if (preferred != null && (await isPortAvailable(preferred, host))) return preferred; + + // Keep the port close to the user's expectation when possible. + if (preferred != null) { + for (let i = 1; i <= 50; i += 1) { + const cand = preferred + i; + if (cand > 65535) break; + if (await isPortAvailable(cand, host)) return cand; + } + } + + // Fall back to an OS-chosen ephemeral port. + const random = await getEphemeralPort(host); + if (random != null && (await isPortAvailable(random, host))) return random; + + return null; +} + +async function ensureBackendPortAvailableOnStartup() { + // Avoid surprising behavior in dev: the frontend dev server expects a stable backend port. + if (!app.isPackaged) return getBackendPort(); + + const bindHost = getBackendBindHost(); + const currentPort = getBackendPort(); + const ok = await isPortAvailable(currentPort, bindHost); + if (ok) return currentPort; + + const chosen = await chooseAvailablePort(currentPort, bindHost); + if (chosen == null) { + logMain(`[main] backend port unavailable: ${currentPort} host=${bindHost}; failed to find a free port`); + return currentPort; + } + + try { + setBackendPortSetting(chosen); + logMain(`[main] backend port ${currentPort} unavailable; switched to ${chosen}`); + } catch (err) { + logMain(`[main] failed to persist backend port ${chosen}: ${err?.message || err}`); + } + + return getBackendPort(); +} + function resolveDataDir() { if (resolvedDataDir) return resolvedDataDir; @@ -54,9 +224,232 @@ function resolveDataDir() { } function getUserDataDir() { - // Backwards-compat: we historically used Electron's userData directory for runtime storage. - // Keep this name but resolve to the effective data dir (can be overridden via env). - return resolveDataDir(); + try { + const dir = app.getPath("userData"); + if (!dir) return null; + fs.mkdirSync(dir, { recursive: true }); + return dir; + } catch { + return null; + } +} + +function safeNormalizeDirectory(value) { + try { + return normalizeDirectoryPath(value || ""); + } catch { + return ""; + } +} + +function getDefaultOutputDir() { + const dataDir = resolveDataDir(); + if (!dataDir) return null; + try { + return getDefaultOutputDirPath(dataDir); + } catch { + return null; + } +} + +function syncOutputDirEnv(nextDir) { + const normalized = safeNormalizeDirectory(nextDir); + if (normalized) process.env.WECHAT_TOOL_OUTPUT_DIR = normalized; + else delete process.env.WECHAT_TOOL_OUTPUT_DIR; +} + +function normalizePendingOutputDirValue(value) { + if (value == null) return null; + const text = String(value).trim(); + if (!text) return ""; + try { + return normalizeDirectoryPath(text); + } catch { + return null; + } +} + +function resolveOutputDir() { + const dataDir = resolveDataDir(); + if (!dataDir) return null; + + const envOutputDir = safeNormalizeDirectory(process.env.WECHAT_TOOL_OUTPUT_DIR || ""); + const settingsOutputDir = app.isPackaged ? safeNormalizeDirectory(loadDesktopSettings()?.outputDir || "") : ""; + + let chosen = null; + try { + chosen = getEffectiveOutputDirPath({ + dataDir, + envOutputDir, + settingsOutputDir, + }); + } catch { + chosen = getDefaultOutputDir(); + } + if (!chosen) return null; + + try { + fs.mkdirSync(chosen, { recursive: true }); + } catch {} + + syncOutputDirEnv(chosen); + return chosen; +} + +function sanitizeAccountName(account) { + const name = String(account || "").trim(); + if (!name) throw new Error("缺少账号参数"); + if (name === "." || name === "..") throw new Error("账号参数非法"); + if (name.includes("/") || name.includes("\\")) throw new Error("账号参数非法"); + return name; +} + +function listDecryptedAccountsOnDisk(databasesDir) { + try { + if (!fs.existsSync(databasesDir)) return []; + } catch { + return []; + } + + let entries = []; + try { + entries = fs.readdirSync(databasesDir, { withFileTypes: true }); + } catch { + return []; + } + + const accounts = []; + for (const entry of entries) { + try { + if (!entry || !entry.isDirectory()) continue; + const accountDir = path.join(databasesDir, entry.name); + const hasSession = fs.existsSync(path.join(accountDir, "session.db")); + const hasContact = fs.existsSync(path.join(accountDir, "contact.db")); + if (hasSession && hasContact) accounts.push(String(entry.name || "")); + } catch {} + } + accounts.sort((a, b) => a.localeCompare(b)); + return accounts; +} + +function resolveAccountDirInOutput(account) { + const dataDir = resolveDataDir(); + if (!dataDir) throw new Error("无法定位数据目录"); + + const outputDir = resolveOutputDir(); + if (!outputDir) throw new Error("无法定位 output 目录"); + const databasesDir = path.join(outputDir, "databases"); + const accountName = sanitizeAccountName(account); + + const base = path.resolve(databasesDir); + const accountDir = path.resolve(path.join(databasesDir, accountName)); + if (accountDir !== base && !accountDir.startsWith(base + path.sep)) { + throw new Error("账号路径非法"); + } + + return { + dataDir, + outputDir, + databasesDir, + accountName, + accountDir, + }; +} + +function getAccountInfoFromDisk(account) { + const { accountName, accountDir } = resolveAccountDirInOutput(account); + if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) { + throw new Error("账号数据不存在"); + } + + let entries = []; + try { + entries = fs.readdirSync(accountDir, { withFileTypes: true }); + } catch {} + const dbFiles = entries + .filter((e) => !!e && e.isFile() && String(e.name || "").toLowerCase().endsWith(".db")) + .map((e) => String(e.name || "")) + .sort((a, b) => a.localeCompare(b)); + + let sessionUpdatedAt = 0; + try { + const st = fs.statSync(path.join(accountDir, "session.db")); + sessionUpdatedAt = Math.floor(Number(st?.mtimeMs || 0) / 1000); + } catch {} + + return { + status: "success", + account: accountName, + path: accountDir, + database_count: dbFiles.length, + databases: dbFiles, + session_updated_at: sessionUpdatedAt, + }; +} + +function removeAccountFromKeyStore(outputDir, accountName) { + const keyStorePath = path.join(outputDir, "account_keys.json"); + try { + if (!fs.existsSync(keyStorePath)) return false; + const raw = fs.readFileSync(keyStorePath, { encoding: "utf8" }); + const parsed = JSON.parse(raw || "{}"); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false; + if (!Object.prototype.hasOwnProperty.call(parsed, accountName)) return false; + delete parsed[accountName]; + fs.writeFileSync(keyStorePath, JSON.stringify(parsed, null, 2), { encoding: "utf8" }); + return true; + } catch { + return false; + } +} + +async function deleteAccountDataFromDisk(account) { + const { outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account); + if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) { + throw new Error("账号数据不存在"); + } + + const wasBackendRunning = !!backendProc; + let restartError = null; + let result = null; + + if (wasBackendRunning) { + await stopBackendAndWait({ timeoutMs: 10_000 }); + } + + try { + const exportsDir = path.join(outputDir, "exports", accountName); + try { + fs.rmSync(exportsDir, { recursive: true, force: true }); + } catch {} + + fs.rmSync(accountDir, { recursive: true, force: true }); + const removedKeyCache = removeAccountFromKeyStore(outputDir, accountName); + const accounts = listDecryptedAccountsOnDisk(databasesDir); + result = { + status: "success", + deleted_account: accountName, + accounts, + default_account: accounts.length ? accounts[0] : null, + removed_key_cache: removedKeyCache, + }; + } finally { + if (wasBackendRunning) { + try { + startBackend(); + await waitForBackend({ timeoutMs: 30_000 }); + } catch (err) { + restartError = err; + logMain(`[main] failed to restart backend after deleteAccountData: ${err?.message || err}`); + } + } + } + + if (restartError) { + throw new Error(`删除完成,但后端重启失败:${restartError?.message || restartError}`); + } + if (!result) throw new Error("删除账号数据失败"); + return result; } function getExeDir() { @@ -69,34 +462,71 @@ function getExeDir() { function ensureOutputLink() { // Users often expect an `output/` folder near the installed exe. We keep the real data - // in the per-user data dir, and (when possible) create a Windows junction next to the exe. + // in the per-user data dir. + // + // NOTE: We intentionally avoid creating a junction/symlink inside the install directory. + // Some uninstall/update flows may traverse reparse points and delete the target directory, + // causing data loss (the install dir is removed on every update/reinstall). if (!app.isPackaged) return; const exeDir = getExeDir(); - const dataDir = resolveDataDir(); - if (!exeDir || !dataDir) return; - - const target = path.join(dataDir, "output"); - const linkPath = path.join(exeDir, "output"); + const target = resolveOutputDir(); + if (!exeDir || !target) return; + const legacyLinkPath = path.join(exeDir, "output"); - // If the target doesn't exist yet, create it so the link points somewhere real. + // Ensure the real output dir exists. try { fs.mkdirSync(target, { recursive: true }); } catch {} - // If something already exists at linkPath, do not overwrite it. + // Best-effort: remove a legacy junction/symlink at `exeDir/output` so uninstallers can't + // accidentally traverse it and delete the real per-user output directory. try { - if (fs.existsSync(linkPath)) return; + const st = fs.lstatSync(legacyLinkPath); + if (st.isSymbolicLink()) { + try { + fs.unlinkSync(legacyLinkPath); + logMain(`[main] removed legacy output link: ${legacyLinkPath}`); + } catch (err) { + logMain(`[main] failed to remove legacy output link: ${err?.message || err}`); + } + } else if (st.isDirectory()) { + const entries = fs.readdirSync(legacyLinkPath); + if (Array.isArray(entries) && entries.length === 0) { + // Remove an empty real directory to reduce confusion (it will be recreated by the backend if needed). + fs.rmdirSync(legacyLinkPath); + } else { + // Do not overwrite non-empty directories to avoid data loss. + // Note: data stored here will be wiped on update/reinstall. + logMain( + `[main] output dir exists in install dir (not a link): ${legacyLinkPath}. real data dir output: ${target}` + ); + } + } else { + logMain(`[main] output path exists and is not a directory/link: ${legacyLinkPath}`); + } } catch { - return; + // Doesn't exist yet. } + // Best-effort: drop a helper file next to the exe so users can find the real data. + // This avoids the data-loss risks of using junctions/symlinks under the install directory. try { - fs.symlinkSync(target, linkPath, "junction"); - logMain(`[main] created output link: ${linkPath} -> ${target}`); - } catch (err) { - logMain(`[main] failed to create output link: ${err?.message || err}`); - } + const p = path.join(exeDir, "output-location.txt"); + const text = `WeChatDataAnalysis data directory\n\nOutput folder:\n${target}\n`; + fs.writeFileSync(p, text, { encoding: "utf8" }); + } catch {} + + try { + const p = path.join(exeDir, "output-location.path"); + fs.writeFileSync(p, `${target}\n`, { encoding: "utf8" }); + } catch {} + + try { + const p = path.join(exeDir, "open-output.cmd"); + const text = `@echo off\r\nexplorer \"${target}\"\r\n`; + fs.writeFileSync(p, text, { encoding: "utf8" }); + } catch {} } function getMainLogPath() { @@ -120,61 +550,673 @@ function getDesktopSettingsPath() { return path.join(dir, "desktop-settings.json"); } -function loadDesktopSettings() { - if (desktopSettings) return desktopSettings; +function getPackagedUiDir() { + if (!app.isPackaged) return null; + try { + return path.join(process.resourcesPath, "ui"); + } catch { + return null; + } +} + +function readPackagedUiBuildId() { + const uiDir = getPackagedUiDir(); + if (!uiDir) return ""; + + try { + const indexPath = path.join(uiDir, "index.html"); + if (!fs.existsSync(indexPath)) return ""; + const html = fs.readFileSync(indexPath, { encoding: "utf8" }); + const match = + html.match(/buildId:"([^"]+)"/) || + html.match(/\/_payload\.json\?([^"'&<>\s]+)/) || + html.match(/data-src="\/_payload\.json\?([^"]+)"/); + return String(match?.[1] || "").trim(); + } catch (err) { + logMain(`[main] failed to read packaged UI build id: ${err?.message || err}`); + return ""; + } +} + +function loadDesktopSettings() { + if (desktopSettings) return desktopSettings; + + const defaults = { + // 'tray' (default): closing the window hides it to the system tray. + // 'exit': closing the window quits the app. + closeBehavior: "tray", + // When set, suppress the auto-update prompt for this exact version. + ignoredUpdateVersion: "", + // Backend (FastAPI) listens on this port. Used in packaged builds. + backendPort: DEFAULT_BACKEND_PORT, + // Custom output dir; empty string means use the default dataDir/output. + outputDir: "", + // Pending output dir written by the installer before the next app startup. + pendingOutputDir: null, + // Last startup/apply failure when changing output dir. + lastOutputDirError: "", + // Tracks the packaged UI build so we can invalidate Chromium's HTTP cache + // after upgrades without wiping user data/localStorage. + lastSeenUiBuildId: "", + }; + + const p = getDesktopSettingsPath(); + if (!p) { + desktopSettings = { ...defaults }; + return desktopSettings; + } + + try { + if (!fs.existsSync(p)) { + desktopSettings = { ...defaults }; + return desktopSettings; + } + const raw = fs.readFileSync(p, { encoding: "utf8" }); + const parsed = JSON.parse(raw || "{}"); + desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) }; + desktopSettings.backendPort = parsePort(desktopSettings.backendPort) ?? defaults.backendPort; + desktopSettings.outputDir = safeNormalizeDirectory(desktopSettings.outputDir || ""); + desktopSettings.pendingOutputDir = + parsed && typeof parsed === "object" && Object.prototype.hasOwnProperty.call(parsed, "pendingOutputDir") + ? normalizePendingOutputDirValue(parsed.pendingOutputDir) + : defaults.pendingOutputDir; + desktopSettings.lastOutputDirError = String(desktopSettings.lastOutputDirError || "").trim(); + } catch (err) { + desktopSettings = { ...defaults }; + logMain(`[main] failed to load settings: ${err?.message || err}`); + } + + return desktopSettings; +} + +function persistDesktopSettings() { + const p = getDesktopSettingsPath(); + if (!p) return; + if (!desktopSettings) return; + + try { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(desktopSettings, null, 2), { encoding: "utf8" }); + } catch (err) { + logMain(`[main] failed to persist settings: ${err?.message || err}`); + } +} + +function snapshotOutputDirSettings() { + loadDesktopSettings(); + return { + outputDir: desktopSettings.outputDir, + pendingOutputDir: desktopSettings.pendingOutputDir, + lastOutputDirError: desktopSettings.lastOutputDirError, + }; +} + +function restoreOutputDirSettings(snapshot) { + loadDesktopSettings(); + desktopSettings.outputDir = safeNormalizeDirectory(snapshot?.outputDir || ""); + desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(snapshot?.pendingOutputDir); + desktopSettings.lastOutputDirError = String(snapshot?.lastOutputDirError || "").trim(); + const effectiveOutputDir = desktopSettings.outputDir || getDefaultOutputDir() || ""; + syncOutputDirEnv(effectiveOutputDir); + persistDesktopSettings(); +} + +function setOutputDirSetting(nextDir) { + loadDesktopSettings(); + const defaultDir = getDefaultOutputDir(); + const normalized = safeNormalizeDirectory(nextDir || ""); + if (!normalized || (defaultDir && normalized === defaultDir)) { + desktopSettings.outputDir = ""; + } else { + desktopSettings.outputDir = normalized; + } + syncOutputDirEnv(desktopSettings.outputDir || defaultDir || ""); + persistDesktopSettings(); + return desktopSettings.outputDir; +} + +function setPendingOutputDirSetting(nextDir) { + loadDesktopSettings(); + desktopSettings.pendingOutputDir = normalizePendingOutputDirValue(nextDir); + persistDesktopSettings(); + return desktopSettings.pendingOutputDir; +} + +function clearPendingOutputDirSetting() { + loadDesktopSettings(); + desktopSettings.pendingOutputDir = null; + persistDesktopSettings(); +} + +function setOutputDirLastError(message) { + loadDesktopSettings(); + desktopSettings.lastOutputDirError = String(message || "").trim(); + persistDesktopSettings(); + return desktopSettings.lastOutputDirError; +} + +function getOutputDirInfo() { + loadDesktopSettings(); + const defaultPath = getDefaultOutputDir() || ""; + const currentPath = resolveOutputDir() || defaultPath; + const hasPending = desktopSettings.pendingOutputDir !== null; + const pendingPath = + desktopSettings.pendingOutputDir === null + ? "" + : desktopSettings.pendingOutputDir === "" + ? defaultPath + : safeNormalizeDirectory(desktopSettings.pendingOutputDir); + return { + path: currentPath || "", + defaultPath, + isDefault: !!currentPath && !!defaultPath && currentPath === defaultPath, + pendingPath, + hasPending, + lastError: String(desktopSettings.lastOutputDirError || "").trim(), + canChange: !!app.isPackaged, + changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录", + }; +} + +function getCloseBehavior() { + const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase(); + return v === "exit" ? "exit" : "tray"; +} + +function setCloseBehavior(next) { + const v = String(next || "").trim().toLowerCase(); + loadDesktopSettings(); + desktopSettings.closeBehavior = v === "exit" ? "exit" : "tray"; + persistDesktopSettings(); + return desktopSettings.closeBehavior; +} + +function getIgnoredUpdateVersion() { + const v = String(loadDesktopSettings()?.ignoredUpdateVersion || "").trim(); + return v || ""; +} + +function setIgnoredUpdateVersion(version) { + loadDesktopSettings(); + desktopSettings.ignoredUpdateVersion = String(version || "").trim(); + persistDesktopSettings(); + return desktopSettings.ignoredUpdateVersion; +} + +async function applyOutputDirChange(nextValue) { + if (!app.isPackaged) { + throw new Error("开发模式不支持界面修改 output 目录"); + } + + const defaultPath = getDefaultOutputDir(); + const currentPath = resolveOutputDir(); + if (!defaultPath || !currentPath) { + throw new Error("无法定位 output 目录"); + } + + const rawText = String(nextValue ?? "").trim(); + const nextPath = rawText ? normalizeDirectoryPath(rawText) : defaultPath; + const previousSettings = snapshotOutputDirSettings(); + + if (nextPath === currentPath) { + setOutputDirSetting(nextPath); + clearPendingOutputDirSetting(); + setOutputDirLastError(""); + ensureOutputLink(); + const info = getOutputDirInfo(); + return { + success: true, + changed: false, + path: info.path, + defaultPath: info.defaultPath, + isDefault: info.isDefault, + pendingPath: info.pendingPath, + backupPath: "", + sourceWasEmpty: false, + message: "output 目录未变化", + }; + } + + const wasBackendRunning = !!backendProc; + let migration = null; + let settingsSwitched = false; + + try { + if (wasBackendRunning) { + await stopBackendAndWait({ timeoutMs: 10_000 }); + } + + migration = migrateOutputDirectory({ + currentDir: currentPath, + nextDir: nextPath, + }); + + setOutputDirSetting(nextPath); + clearPendingOutputDirSetting(); + setOutputDirLastError(""); + settingsSwitched = true; + ensureOutputLink(); + + if (wasBackendRunning) { + startBackend(); + await waitForBackend({ timeoutMs: 30_000 }); + } + + const info = getOutputDirInfo(); + return { + success: true, + changed: true, + path: info.path, + defaultPath: info.defaultPath, + isDefault: info.isDefault, + pendingPath: info.pendingPath, + backupPath: migration?.backupDir || "", + sourceWasEmpty: !!migration?.sourceWasEmpty, + message: migration?.sourceWasEmpty ? "output 目录已切换" : "output 目录已迁移并切换", + }; + } catch (err) { + const message = err?.message || String(err); + let rollbackMessage = ""; + if (migration?.changed) { + try { + rollbackOutputDirectoryChange({ + previousDir: currentPath, + currentDir: nextPath, + backupDir: migration.backupDir, + sourceWasEmpty: migration.sourceWasEmpty, + }); + } catch (rollbackErr) { + logMain(`[main] output dir rollback failed: ${rollbackErr?.message || rollbackErr}`); + rollbackMessage = `;回滚失败:${rollbackErr?.message || rollbackErr}`; + if (migration?.backupDir) { + rollbackMessage += `;备份目录:${migration.backupDir}`; + } + } + } + + if (settingsSwitched) { + restoreOutputDirSettings(previousSettings); + } else { + syncOutputDirEnv(currentPath); + } + ensureOutputLink(); + + if (wasBackendRunning) { + try { + startBackend(); + await waitForBackend({ timeoutMs: 30_000 }); + } catch (restartErr) { + throw new Error( + `切换 output 目录失败:${message}${rollbackMessage};且旧后端恢复失败:${restartErr?.message || restartErr}` + ); + } + } + + if (rollbackMessage) { + throw new Error(`切换 output 目录失败:${message}${rollbackMessage}`); + } + throw err; + } +} + +async function applyPendingOutputDirOnStartup() { + if (!app.isPackaged) return; + loadDesktopSettings(); + if (desktopSettings.pendingOutputDir === null) return; + + try { + await applyOutputDirChange(desktopSettings.pendingOutputDir); + } catch (err) { + clearPendingOutputDirSetting(); + setOutputDirLastError(`安装时设置的 output 目录未能应用:${err?.message || err}`); + ensureOutputLink(); + logMain(`[main] failed to apply pending output dir: ${err?.message || err}`); + } +} + +async function refreshRendererCacheForPackagedUi() { + if (!app.isPackaged) return; + + const nextBuildId = readPackagedUiBuildId(); + if (!nextBuildId) return; + + const prevBuildId = String(loadDesktopSettings()?.lastSeenUiBuildId || "").trim(); + if (prevBuildId === nextBuildId) return; + + try { + const ses = session?.defaultSession; + if (ses) { + await ses.clearCache(); + try { + await ses.clearStorageData({ storages: ["serviceworkers"] }); + } catch {} + } + logMain(`[main] cleared renderer cache for UI build change: ${prevBuildId || "(none)"} -> ${nextBuildId}`); + } catch (err) { + logMain(`[main] failed to clear renderer cache for UI build change: ${err?.message || err}`); + } + + loadDesktopSettings(); + desktopSettings.lastSeenUiBuildId = nextBuildId; + persistDesktopSettings(); +} + +function parseEnvBool(value) { + if (value == null) return null; + const v = String(value).trim().toLowerCase(); + if (!v) return null; + if (v === "1" || v === "true" || v === "yes" || v === "y" || v === "on") return true; + if (v === "0" || v === "false" || v === "no" || v === "n" || v === "off") return false; + return null; +} + +let autoUpdateEnabledCache = null; +function isAutoUpdateEnabled() { + if (autoUpdateEnabledCache != null) return !!autoUpdateEnabledCache; + + const forced = parseEnvBool(process.env.AUTO_UPDATE_ENABLED); + let enabled = forced != null ? forced : !!app.isPackaged; + if (enabled && !autoUpdater) { + enabled = false; + logMain( + `[main] auto-update disabled: electron-updater unavailable: ${autoUpdaterLoadError?.message || "unknown error"}` + ); + } + + // In packaged builds electron-updater reads update config from app-update.yml. + // If missing, treat auto-update as disabled to avoid noisy errors. + if (enabled && app.isPackaged) { + try { + const updateConfigPath = path.join(process.resourcesPath, "app-update.yml"); + if (!fs.existsSync(updateConfigPath)) { + enabled = false; + logMain(`[main] auto-update disabled: missing ${updateConfigPath}`); + } + } catch (err) { + enabled = false; + logMain(`[main] auto-update disabled: failed to check app-update.yml: ${err?.message || err}`); + } + } + + autoUpdateEnabledCache = enabled; + return enabled; +} + +let autoUpdaterInitialized = false; +let updateDownloadInProgress = false; +let installOnDownload = false; +let updateDownloaded = false; +let lastUpdateInfo = null; + +function sendToRenderer(channel, payload) { + try { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send(channel, payload); + } catch (err) { + logMain(`[main] failed to send ${channel}: ${err?.message || err}`); + } +} + +function setWindowProgressBar(value) { + try { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.setProgressBar(value); + } catch {} +} + +function looksLikeHtml(input) { + if (!input) return false; + const s = String(input); + if (!s.includes("<") || !s.includes(">")) return false; + // Be conservative: only treat the note as HTML if it contains common tags we expect from GitHub-rendered bodies. + return /<(p|div|br|ul|ol|li|a|strong|em|tt|code|pre|h[1-6])\b/i.test(s); +} + +function htmlToPlainText(html) { + if (!html) return ""; + + let text = String(html); + + // Drop script/style blocks entirely. + text = text.replace(/]*>[\s\S]*?<\/script>/gi, ""); + text = text.replace(/]*>[\s\S]*?<\/style>/gi, ""); + + // Keep links readable after stripping tags. + text = text.replace( + /]*href=(["'])([^"']+)\1[^>]*>([\s\S]*?)<\/a>/gi, + (_m, _q, href, inner) => { + const innerText = String(inner).replace(/<[^>]*>/g, "").trim(); + const url = String(href || "").trim(); + if (!url) return innerText; + if (!innerText) return url; + return `${innerText} (${url})`; + } + ); + + // Preserve line breaks / list structure before stripping remaining tags. + text = text.replace(/<\s*br\s*\/?>/gi, "\n"); + text = text.replace(/<\/\s*(p|div|h1|h2|h3|h4|h5|h6)\s*>/gi, "\n"); + text = text.replace(/<\s*li[^>]*>/gi, "- "); + text = text.replace(/<\/\s*li\s*>/gi, "\n"); + text = text.replace(/<\/\s*(ul|ol)\s*>/gi, "\n"); + + // Strip remaining tags. + text = text.replace(/<[^>]*>/g, ""); + + // Decode the handful of entities we commonly see from GitHub-rendered HTML. + const named = { + nbsp: " ", + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + "#39": "'", + }; + text = text.replace(/&([a-z0-9#]+);/gi, (m, name) => { + const key = String(name || "").toLowerCase(); + if (named[key] != null) return named[key]; + + // Numeric entities (decimal / hex). + const decMatch = key.match(/^#(\d+)$/); + if (decMatch) { + const n = Number(decMatch[1]); + if (Number.isFinite(n) && n >= 0 && n <= 0x10ffff) { + try { + return String.fromCodePoint(n); + } catch { + return m; + } + } + return m; + } + + const hexMatch = key.match(/^#x([0-9a-f]+)$/i); + if (hexMatch) { + const n = Number.parseInt(hexMatch[1], 16); + if (Number.isFinite(n) && n >= 0 && n <= 0x10ffff) { + try { + return String.fromCodePoint(n); + } catch { + return m; + } + } + return m; + } + + return m; + }); + + // Normalize whitespace/newlines. + text = text.replace(/\r\n/g, "\n"); + text = text.replace(/\n{3,}/g, "\n\n"); + return text.trim(); +} + +function normalizeReleaseNotes(releaseNotes) { + if (!releaseNotes) return ""; - const defaults = { - // 'tray' (default): closing the window hides it to the system tray. - // 'exit': closing the window quits the app. - closeBehavior: "tray", + const normalizeText = (value) => { + if (value == null) return ""; + const raw = typeof value === "string" ? value : String(value); + const trimmed = raw.trim(); + if (!trimmed) return ""; + if (looksLikeHtml(trimmed)) return htmlToPlainText(trimmed); + return trimmed; }; - const p = getDesktopSettingsPath(); - if (!p) { - desktopSettings = { ...defaults }; - return desktopSettings; + if (typeof releaseNotes === "string") return normalizeText(releaseNotes); + if (Array.isArray(releaseNotes)) { + const parts = []; + for (const item of releaseNotes) { + const version = item?.version ? String(item.version) : ""; + const note = item?.note; + const noteText = + typeof note === "string" ? note : note != null ? JSON.stringify(note, null, 2) : ""; + const block = [version ? `v${version}` : "", normalizeText(noteText)] + .filter(Boolean) + .join("\n"); + if (block) parts.push(block); + } + return parts.join("\n\n"); + } + try { + return normalizeText(JSON.stringify(releaseNotes, null, 2)); + } catch { + return normalizeText(releaseNotes); } +} + +function initAutoUpdater() { + if (autoUpdaterInitialized) return; + autoUpdaterInitialized = true; + + // Configure auto-updater (align with WeFlow). + autoUpdater.autoDownload = false; + // Don't install automatically on quit; let the user choose when to restart/install. + autoUpdater.autoInstallOnAppQuit = false; + autoUpdater.disableDifferentialDownload = true; + + autoUpdater.on("download-progress", (progress) => { + sendToRenderer("app:downloadProgress", progress); + const percent = Number(progress?.percent || 0); + if (Number.isFinite(percent) && percent > 0) { + setWindowProgressBar(Math.max(0, Math.min(1, percent / 100))); + } + }); + + autoUpdater.on("update-downloaded", () => { + updateDownloadInProgress = false; + updateDownloaded = true; + installOnDownload = false; + setWindowProgressBar(-1); + + const payload = { + version: lastUpdateInfo?.version ? String(lastUpdateInfo.version) : "", + releaseNotes: normalizeReleaseNotes(lastUpdateInfo?.releaseNotes), + }; + sendToRenderer("app:updateDownloaded", payload); + + try { + // If the window is hidden to tray, show a lightweight hint instead of forcing UI focus. + tray?.displayBalloon?.({ + title: "更新已下载完成", + content: "可在弹窗中选择“立即重启安装”,或稍后再安装。", + }); + } catch {} + }); + + autoUpdater.on("error", (err) => { + updateDownloadInProgress = false; + installOnDownload = false; + updateDownloaded = false; + setWindowProgressBar(-1); + const message = err?.message || String(err); + logMain(`[main] autoUpdater error: ${message}`); + sendToRenderer("app:updateError", { message }); + }); +} + +async function checkForUpdatesInternal() { + const enabled = isAutoUpdateEnabled(); + if (!enabled) return { hasUpdate: false, enabled: false }; + + initAutoUpdater(); try { - if (!fs.existsSync(p)) { - desktopSettings = { ...defaults }; - return desktopSettings; + const result = await autoUpdater.checkForUpdates(); + const updateInfo = result?.updateInfo; + lastUpdateInfo = updateInfo || null; + const latestVersion = updateInfo?.version ? String(updateInfo.version) : ""; + const currentVersion = (() => { + try { + return app.getVersion(); + } catch { + return ""; + } + })(); + + if (latestVersion && currentVersion && latestVersion !== currentVersion) { + return { + hasUpdate: true, + enabled: true, + version: latestVersion, + releaseNotes: normalizeReleaseNotes(updateInfo?.releaseNotes), + }; } - const raw = fs.readFileSync(p, { encoding: "utf8" }); - const parsed = JSON.parse(raw || "{}"); - desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) }; + + return { hasUpdate: false, enabled: true }; } catch (err) { - desktopSettings = { ...defaults }; - logMain(`[main] failed to load settings: ${err?.message || err}`); + const message = err?.message || String(err); + logMain(`[main] checkForUpdates failed: ${message}`); + return { hasUpdate: false, enabled: true, error: message }; } - - return desktopSettings; } -function persistDesktopSettings() { - const p = getDesktopSettingsPath(); - if (!p) return; - if (!desktopSettings) return; +async function downloadAndInstallInternal() { + if (!isAutoUpdateEnabled()) { + throw new Error("自动更新已禁用"); + } + initAutoUpdater(); + + if (updateDownloadInProgress) { + throw new Error("正在下载更新中,请稍候…"); + } + + updateDownloadInProgress = true; + installOnDownload = true; + updateDownloaded = false; + setWindowProgressBar(0); try { - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.writeFileSync(p, JSON.stringify(desktopSettings, null, 2), { encoding: "utf8" }); + // Ensure update info is up-to-date (downloadUpdate relies on the last check). + await autoUpdater.checkForUpdates(); + await autoUpdater.downloadUpdate(); + return { success: true }; } catch (err) { - logMain(`[main] failed to persist settings: ${err?.message || err}`); + updateDownloadInProgress = false; + installOnDownload = false; + setWindowProgressBar(-1); + throw err; } } -function getCloseBehavior() { - const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase(); - return v === "exit" ? "exit" : "tray"; -} +function checkForUpdatesOnStartup() { + if (!isAutoUpdateEnabled()) return; + if (!app.isPackaged) return; // keep dev noise-free by default -function setCloseBehavior(next) { - const v = String(next || "").trim().toLowerCase(); - loadDesktopSettings(); - desktopSettings.closeBehavior = v === "exit" ? "exit" : "tray"; - persistDesktopSettings(); - return desktopSettings.closeBehavior; + setTimeout(async () => { + const result = await checkForUpdatesInternal(); + if (!result?.hasUpdate) return; + + const ignored = getIgnoredUpdateVersion(); + if (ignored && ignored === result.version) return; + + sendToRenderer("app:updateAvailable", { + version: result.version, + releaseNotes: result.releaseNotes || "", + }); + }, 3000); } function getTrayIconPath() { @@ -238,6 +1280,91 @@ function createTray() { label: "显示", click: () => showMainWindow(), }, + { + label: "检查更新...", + click: async () => { + try { + if (!isAutoUpdateEnabled()) { + await dialog.showMessageBox({ + type: "info", + title: "检查更新", + message: "自动更新已禁用(仅打包版本可用)。", + buttons: ["确定"], + noLink: true, + }); + return; + } + + const result = await checkForUpdatesInternal(); + if (result?.error) { + await dialog.showMessageBox({ + type: "error", + title: "检查更新失败", + message: result.error, + buttons: ["确定"], + noLink: true, + }); + return; + } + + if (result?.hasUpdate && result?.version) { + const { response } = await dialog.showMessageBox({ + type: "info", + title: "发现新版本", + message: `发现新版本 ${result.version},是否立即更新?`, + detail: result.releaseNotes ? `更新内容:\n${result.releaseNotes}` : undefined, + buttons: ["立即更新", "稍后", "忽略此版本"], + defaultId: 0, + cancelId: 1, + noLink: true, + }); + + if (response === 0) { + try { + await downloadAndInstallInternal(); + } catch (err) { + const message = err?.message || String(err); + logMain(`[main] downloadAndInstall failed (tray): ${message}`); + await dialog.showMessageBox({ + type: "error", + title: "更新失败", + message, + buttons: ["确定"], + noLink: true, + }); + } + } else if (response === 2) { + try { + setIgnoredUpdateVersion(result.version); + } catch {} + } + + return; + } + + await dialog.showMessageBox({ + type: "info", + title: "检查更新", + message: "当前已是最新版本。", + buttons: ["确定"], + noLink: true, + }); + } catch (err) { + const message = err?.message || String(err); + logMain(`[main] tray check updates failed: ${message}`); + await dialog.showMessageBox({ + type: "error", + title: "检查更新失败", + message, + buttons: ["确定"], + noLink: true, + }); + } + }, + }, + { + type: "separator", + }, { label: "退出", click: () => { @@ -282,20 +1409,20 @@ function attachBackendStdio(proc, logPath) { fs.mkdirSync(path.dirname(logPath), { recursive: true }); } catch {} + let stream = null; try { - backendStdioStream = fs.createWriteStream(logPath, { flags: "a" }); - backendStdioStream.write(`[${nowIso()}] [main] backend stdio -> ${logPath}\n`); + stream = fs.createWriteStream(logPath, { flags: "a" }); + stream.write(`[${nowIso()}] [main] backend stdio -> ${logPath}\n`); } catch { - backendStdioStream = null; return; } const write = (prefix, chunk) => { - if (!backendStdioStream) return; + if (!stream) return; try { const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); - backendStdioStream.write(`[${nowIso()}] ${prefix} ${text}`); - if (!text.endsWith("\n")) backendStdioStream.write("\n"); + stream.write(`[${nowIso()}] ${prefix} ${text}`); + if (!text.endsWith("\n")) stream.write("\n"); } catch {} }; @@ -305,9 +1432,9 @@ function attachBackendStdio(proc, logPath) { proc.on("close", (code, signal) => { write("[backend:close]", `code=${code} signal=${signal}`); try { - backendStdioStream?.end(); + stream?.end(); } catch {} - backendStdioStream = null; + stream = null; }); } @@ -321,13 +1448,17 @@ function getPackagedBackendPath() { return path.join(process.resourcesPath, "backend", "wechat-backend.exe"); } +function getPackagedWcdbDllPath() { + return path.join(process.resourcesPath, "backend", "native", "wcdb_api.dll"); +} + function startBackend() { if (backendProc) return backendProc; const env = { ...process.env, - WECHAT_TOOL_HOST: BACKEND_HOST, - WECHAT_TOOL_PORT: String(BACKEND_PORT), + WECHAT_TOOL_HOST: getBackendBindHost(), + WECHAT_TOOL_PORT: String(getBackendPort()), // Make sure Python prints UTF-8 to stdout/stderr. PYTHONIOENCODING: process.env.PYTHONIOENCODING || "utf-8", }; @@ -338,11 +1469,11 @@ function startBackend() { } if (app.isPackaged) { - if (!env.WECHAT_TOOL_DATA_DIR) { - env.WECHAT_TOOL_DATA_DIR = app.getPath("userData"); - } + env.WECHAT_TOOL_DATA_DIR = resolveDataDir() || app.getPath("userData"); + env.WECHAT_TOOL_OUTPUT_DIR = resolveOutputDir() || getDefaultOutputDir() || path.join(env.WECHAT_TOOL_DATA_DIR, "output"); try { fs.mkdirSync(env.WECHAT_TOOL_DATA_DIR, { recursive: true }); + fs.mkdirSync(env.WECHAT_TOOL_OUTPUT_DIR, { recursive: true }); } catch {} const backendExe = getPackagedBackendPath(); @@ -351,8 +1482,17 @@ function startBackend() { `Packaged backend not found: ${backendExe}. Build it into desktop/resources/backend/wechat-backend.exe` ); } + const packagedWcdbDll = getPackagedWcdbDllPath(); + if (fs.existsSync(packagedWcdbDll)) { + env.WECHAT_TOOL_WCDB_API_DLL_PATH = packagedWcdbDll; + logMain(`[main] using packaged wcdb_api.dll: ${packagedWcdbDll}`); + } else { + logMain(`[main] packaged wcdb_api.dll not found: ${packagedWcdbDll}`); + } + + const backendCwd = path.dirname(backendExe); backendProc = spawn(backendExe, [], { - cwd: env.WECHAT_TOOL_DATA_DIR, + cwd: backendCwd, env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, @@ -367,8 +1507,9 @@ function startBackend() { }); } - backendProc.on("exit", (code, signal) => { - backendProc = null; + const proc = backendProc; + proc.on("exit", (code, signal) => { + if (backendProc === proc) backendProc = null; // eslint-disable-next-line no-console console.log(`[backend] exited code=${code} signal=${signal}`); logMain(`[backend] exited code=${code} signal=${signal}`); @@ -380,22 +1521,69 @@ function startBackend() { function stopBackend() { if (!backendProc) return; - try { - if (process.platform === "win32" && backendProc.pid) { - // Ensure child tree is killed on Windows. - spawn("taskkill", ["/pid", String(backendProc.pid), "/T", "/F"], { - stdio: "ignore", - windowsHide: true, - }); - return; + const pid = backendProc.pid; + logMain(`[main] stopBackend pid=${pid || "?"}`); + + // Best-effort: ensure process tree is gone on Windows. Use spawnSync so the kill + // isn't aborted by the app quitting immediately after "before-quit". + if (process.platform === "win32" && pid) { + const systemRoot = process.env.SystemRoot || process.env.WINDIR || "C:\\Windows"; + const taskkillExe = path.join(systemRoot, "System32", "taskkill.exe"); + const args = ["/pid", String(pid), "/T", "/F"]; + + try { + const exe = fs.existsSync(taskkillExe) ? taskkillExe : "taskkill"; + const r = spawnSync(exe, args, { stdio: "ignore", windowsHide: true, timeout: 5000 }); + if (r?.error) logMain(`[main] taskkill failed: ${r.error?.message || r.error}`); + else if (typeof r?.status === "number" && r.status !== 0) + logMain(`[main] taskkill exit code=${r.status}`); + } catch (err) { + logMain(`[main] taskkill exception: ${err?.message || err}`); } - } catch {} + } + // Fallback: kill the direct process (taskkill might be missing from PATH in some envs). try { backendProc.kill(); } catch {} } +async function stopBackendAndWait({ timeoutMs = 10_000 } = {}) { + if (!backendProc) return; + const proc = backendProc; + + await new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + resolve(); + }; + + const timer = setTimeout(finish, timeoutMs); + + try { + proc.once("exit", () => { + clearTimeout(timer); + finish(); + }); + } catch {} + + try { + stopBackend(); + } catch { + clearTimeout(timer); + finish(); + } + }); +} + +async function restartBackend({ timeoutMs = 30_000 } = {}) { + await stopBackendAndWait({ timeoutMs: 10_000 }); + startBackend(); + await waitForBackend({ timeoutMs }); +} + function httpGet(url) { return new Promise((resolve, reject) => { const req = http.get(url, (res) => { @@ -410,17 +1598,28 @@ function httpGet(url) { }); } -async function waitForBackend({ timeoutMs }) { +async function waitForBackend({ timeoutMs, healthUrl } = {}) { + const url = String(healthUrl || getBackendHealthUrl()).trim(); const startedAt = Date.now(); // eslint-disable-next-line no-constant-condition while (true) { + // If the backend process died, fail fast (otherwise we'd wait for the full timeout). + if (!backendProc) { + throw new Error(`Backend process exited before becoming ready: ${url}`); + } + if (backendProc.exitCode != null) { + throw new Error( + `Backend process exited (code=${backendProc.exitCode} signal=${backendProc.signalCode || "null"}): ${url}` + ); + } + try { - const code = await httpGet(BACKEND_HEALTH_URL); + const code = await httpGet(url); if (code >= 200 && code < 500) return; } catch {} if (Date.now() - startedAt > timeoutMs) { - throw new Error(`Backend did not become ready in ${timeoutMs}ms: ${BACKEND_HEALTH_URL}`); + throw new Error(`Backend did not become ready in ${timeoutMs}ms: ${url}`); } await new Promise((r) => setTimeout(r, 300)); @@ -458,6 +1657,34 @@ function getRendererConsoleLogPath() { } } +function getRendererDebugLogPath() { + try { + const dir = app.getPath("userData"); + fs.mkdirSync(dir, { recursive: true }); + return path.join(dir, "renderer-debug.log"); + } catch { + return null; + } +} + +function appendRendererDebugLog(line) { + const logPath = getRendererDebugLogPath(); + if (!logPath) return; + try { + fs.appendFileSync(logPath, line, { encoding: "utf8" }); + } catch {} +} + +function stringifyDebugDetails(details) { + if (details == null) return ""; + if (typeof details === "string") return details; + try { + return JSON.stringify(details); + } catch (err) { + return `[unserializable:${err?.message || err}]`; + } +} + function setupRendererConsoleLogging(win) { if (!debugEnabled()) return; @@ -479,6 +1706,62 @@ function setupRendererConsoleLogging(win) { }); } +function setupRendererLifecycleLogging(win) { + if (!debugEnabled()) return; + + const logRendererLifecycle = (message) => { + logMain(`[renderer] ${message}`); + }; + + logRendererLifecycle(`window-created id=${win.id}`); + + win.webContents.on("did-start-loading", () => { + logRendererLifecycle("did-start-loading"); + }); + + win.webContents.on("dom-ready", () => { + logRendererLifecycle(`dom-ready url=${win.webContents.getURL()}`); + }); + + win.webContents.on("did-stop-loading", () => { + logRendererLifecycle("did-stop-loading"); + }); + + win.webContents.on("did-finish-load", () => { + logRendererLifecycle(`did-finish-load url=${win.webContents.getURL()}`); + }); + + win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + logRendererLifecycle( + `did-fail-load code=${errorCode} mainFrame=${!!isMainFrame} url=${validatedURL} error=${errorDescription}` + ); + }); + + win.webContents.on("did-navigate", (_event, url, httpResponseCode, httpStatusText) => { + logRendererLifecycle( + `did-navigate url=${url} code=${httpResponseCode || 0} status=${httpStatusText || ""}` + ); + }); + + win.webContents.on("did-navigate-in-page", (_event, url, isMainFrame) => { + logRendererLifecycle(`did-navigate-in-page mainFrame=${!!isMainFrame} url=${url}`); + }); + + win.webContents.on("render-process-gone", (_event, details) => { + logRendererLifecycle( + `render-process-gone reason=${details?.reason || ""} exitCode=${details?.exitCode ?? ""}` + ); + }); + + win.on("unresponsive", () => { + logRendererLifecycle("window-unresponsive"); + }); + + win.on("responsive", () => { + logRendererLifecycle("window-responsive"); + }); +} + function createMainWindow() { const win = new BrowserWindow({ width: 1200, @@ -522,18 +1805,26 @@ function createMainWindow() { }); setupRendererConsoleLogging(win); + setupRendererLifecycleLogging(win); return win; } async function loadWithRetry(win, url) { const startedAt = Date.now(); + let attempt = 0; // eslint-disable-next-line no-constant-condition while (true) { + attempt += 1; + logMain(`[main] loadWithRetry attempt=${attempt} url=${url}`); try { await win.loadURL(url); + logMain(`[main] loadWithRetry success attempt=${attempt} elapsedMs=${Date.now() - startedAt} url=${url}`); return; - } catch { + } catch (err) { + logMain( + `[main] loadWithRetry failure attempt=${attempt} elapsedMs=${Date.now() - startedAt} url=${url} error=${err?.message || err}` + ); if (Date.now() - startedAt > 60_000) throw new Error(`Failed to load URL in time: ${url}`); await new Promise((r) => setTimeout(r, 500)); } @@ -601,6 +1892,24 @@ function registerWindowIpc() { } }); + ipcMain.handle("app:isDebugEnabled", () => { + try { + return debugEnabled(); + } catch (err) { + logMain(`[main] app:isDebugEnabled failed: ${err?.message || err}`); + return false; + } + }); + + ipcMain.on("debug:log", (event, payload) => { + const scope = String(payload?.scope || "renderer").trim() || "renderer"; + const message = String(payload?.message || "").trim() || "(empty)"; + const url = String(payload?.url || event?.sender?.getURL?.() || "").trim(); + const details = stringifyDebugDetails(payload?.details); + const suffix = details ? ` details=${details}` : ""; + appendRendererDebugLog(`[${nowIso()}] [${scope}] ${message} url=${url}${suffix}\n`); + }); + ipcMain.handle("app:setCloseBehavior", (_event, behavior) => { try { const next = setCloseBehavior(behavior); @@ -611,23 +1920,251 @@ function registerWindowIpc() { return getCloseBehavior(); } }); + + ipcMain.handle("backend:getPort", () => { + try { + return getBackendPort(); + } catch (err) { + logMain(`[main] backend:getPort failed: ${err?.message || err}`); + return DEFAULT_BACKEND_PORT; + } + }); + + ipcMain.handle("backend:setPort", async (_event, port) => { + if (backendPortChangeInProgress) throw new Error("端口切换中,请稍后重试"); + if (!app.isPackaged) { + throw new Error("开发模式不支持界面修改端口;请设置 WECHAT_TOOL_PORT 环境变量后重启"); + } + + const nextPort = parsePort(port); + if (nextPort == null) throw new Error("端口无效,请输入 1-65535 的整数"); + + const prevPort = getBackendPort(); + if (nextPort === prevPort) { + return { success: true, changed: false, port: prevPort, uiUrl: getBackendUiUrl() }; + } + + const bindHost = getBackendBindHost(); + const ok = await isPortAvailable(nextPort, bindHost); + if (!ok) throw new Error(`端口 ${nextPort} 已被占用,请换一个端口`); + + backendPortChangeInProgress = true; + try { + setBackendPortSetting(nextPort); + try { + await restartBackend({ timeoutMs: 30_000 }); + } catch (err) { + // Roll back to the previous port so the UI can keep working. + setBackendPortSetting(prevPort); + try { + await restartBackend({ timeoutMs: 30_000 }); + } catch {} + throw err; + } + + const uiUrl = getBackendUiUrl(); + setTimeout(() => { + try { + if (!mainWindow || mainWindow.isDestroyed()) return; + void loadWithRetry(mainWindow, uiUrl); + } catch (err) { + logMain(`[main] failed to reload UI after backend port change: ${err?.message || err}`); + } + }, 50); + + return { success: true, changed: true, port: nextPort, uiUrl }; + } finally { + backendPortChangeInProgress = false; + } + }); + + ipcMain.handle("app:getVersion", () => { + try { + return app.getVersion(); + } catch (err) { + logMain(`[main] getVersion failed: ${err?.message || err}`); + return ""; + } + }); + + ipcMain.handle("app:getOutputDirInfo", () => { + try { + return getOutputDirInfo(); + } catch (err) { + logMain(`[main] app:getOutputDirInfo failed: ${err?.message || err}`); + return { + path: "", + defaultPath: "", + isDefault: true, + pendingPath: "", + hasPending: false, + lastError: err?.message || String(err), + canChange: !!app.isPackaged, + changeUnavailableReason: app.isPackaged ? "" : "开发模式不支持界面修改 output 目录", + }; + } + }); + + ipcMain.handle("app:getOutputDir", () => { + return resolveOutputDir() || ""; + }); + + ipcMain.handle("app:openOutputDir", async () => { + const outDir = resolveOutputDir(); + if (!outDir) throw new Error("无法定位 output 目录"); + try { + fs.mkdirSync(outDir, { recursive: true }); + } catch {} + try { + const err = await shell.openPath(outDir); + if (err) throw new Error(err); + return { success: true, path: outDir }; + } catch (e) { + const message = e?.message || String(e); + logMain(`[main] openOutputDir failed: ${message}`); + throw new Error(message); + } + }); + + ipcMain.handle("app:setOutputDir", async (_event, nextDir) => { + if (outputDirChangeInProgress) { + return { + success: false, + error: "output 目录切换中,请稍后重试", + }; + } + outputDirChangeInProgress = true; + try { + return await applyOutputDirChange(nextDir); + } catch (err) { + const message = err?.message || String(err); + logMain(`[main] app:setOutputDir failed: ${message}`); + return { + success: false, + error: message, + }; + } finally { + outputDirChangeInProgress = false; + } + }); + + ipcMain.handle("app:getAccountInfo", async (_event, account) => { + try { + return getAccountInfoFromDisk(account); + } catch (e) { + throw new Error(e?.message || String(e)); + } + }); + + ipcMain.handle("app:deleteAccountData", async (_event, account) => { + try { + return await deleteAccountDataFromDisk(account); + } catch (e) { + throw new Error(e?.message || String(e)); + } + }); + + ipcMain.handle("app:checkForUpdates", async () => { + return await checkForUpdatesInternal(); + }); + + ipcMain.handle("app:downloadAndInstall", async () => { + return await downloadAndInstallInternal(); + }); + + ipcMain.handle("app:installUpdate", async () => { + if (!isAutoUpdateEnabled()) { + throw new Error("自动更新已禁用"); + } + initAutoUpdater(); + if (!updateDownloaded) { + throw new Error("更新尚未下载完成"); + } + + try { + // Safety: remove legacy `output` junctions in the install dir before triggering the NSIS update/uninstall. + // Some uninstall flows may traverse reparse points and delete the real per-user output directory. + try { + ensureOutputLink(); + } catch {} + autoUpdater.quitAndInstall(false, true); + return { success: true }; + } catch (err) { + const message = err?.message || String(err); + logMain(`[main] installUpdate failed: ${message}`); + throw new Error(message); + } + }); + + ipcMain.handle("app:ignoreUpdate", async (_event, version) => { + setIgnoredUpdateVersion(version); + return { success: true }; + }); + + ipcMain.handle("dialog:chooseDirectory", async (_event, options) => { + try { + const result = await dialog.showOpenDialog({ + title: String(options?.title || "选择文件夹"), + properties: ["openDirectory", "createDirectory"], + }); + return { + canceled: !!result?.canceled, + filePaths: Array.isArray(result?.filePaths) ? result.filePaths : [], + }; + } catch (err) { + logMain(`[main] dialog:chooseDirectory failed: ${err?.message || err}`); + return { + canceled: true, + filePaths: [], + }; + } + }); } async function main() { await app.whenReady(); + await refreshRendererCacheForPackagedUi(); Menu.setApplicationMenu(null); registerWindowIpc(); registerDebugShortcuts(); - // Resolve/create the data dir early so we can log reliably and (optionally) place a link + // Resolve/create the data dir early so we can log reliably and place helper files // next to the installed exe for easier access. resolveDataDir(); + loadDesktopSettings(); + await applyPendingOutputDirOnStartup(); ensureOutputLink(); + await ensureBackendPortAvailableOnStartup(); logMain(`[main] app.isPackaged=${app.isPackaged} argv=${JSON.stringify(process.argv)}`); startBackend(); - await waitForBackend({ timeoutMs: 30_000 }); + try { + await waitForBackend({ timeoutMs: 30_000 }); + } catch (err) { + // In some environments a specific port may be blocked/reserved (WSAEACCES) or taken. + // Best-effort: pick a new port and retry once so the app can still start. + if (app.isPackaged) { + const prevPort = getBackendPort(); + const bindHost = getBackendBindHost(); + const nextPort = await chooseAvailablePort(prevPort + 1, bindHost); + if (nextPort != null && nextPort !== prevPort) { + logMain(`[main] backend not ready on port ${prevPort}; retrying on ${nextPort}`); + try { + setBackendPortSetting(nextPort); + await restartBackend({ timeoutMs: 30_000 }); + logMain(`[main] backend retry succeeded on port ${nextPort}`); + } catch (retryErr) { + logMain(`[main] backend retry failed: ${retryErr?.stack || String(retryErr)}`); + throw retryErr; + } + } else { + throw err; + } + } else { + throw err; + } + } const win = createMainWindow(); mainWindow = win; @@ -635,10 +2172,14 @@ async function main() { const startUrl = process.env.ELECTRON_START_URL || - (app.isPackaged ? `http://${BACKEND_HOST}:${BACKEND_PORT}/` : "http://localhost:3000"); + (app.isPackaged ? getBackendUiUrl() : "http://localhost:3000"); + logMain(`[main] debugEnabled=${debugEnabled()} startUrl=${startUrl}`); await loadWithRetry(win, startUrl); + // Auto-check updates after the UI has loaded (packaged builds only). + checkForUpdatesOnStartup(); + // If debug mode is enabled, auto-open DevTools so the user doesn't need menu/shortcuts. if (debugEnabled()) { try { @@ -664,20 +2205,32 @@ app.on("before-quit", () => { stopBackend(); }); -main().catch((err) => { - // eslint-disable-next-line no-console - console.error(err); - logMain(`[main] fatal: ${err?.stack || String(err)}`); - stopBackend(); - try { - const dir = getUserDataDir(); - if (dir) { - dialog.showErrorBox( - "WeChatDataAnalysis 启动失败", - `启动失败:${err?.message || err}\n\n请查看日志目录:\n${dir}\n\n文件:desktop-main.log / backend-stdio.log / output\\\\logs\\\\...` - ); - shell.openPath(dir); - } - } catch {} - app.quit(); -}); +if (gotSingleInstanceLock) { + main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + logMain(`[main] fatal: ${err?.stack || String(err)}`); + stopBackend(); + try { + const dir = getUserDataDir(); + const outputDir = resolveOutputDir(); + if (dir) { + const detailLines = [ + `启动失败:${err?.message || err}`, + "", + `桌面日志目录:${dir}`, + "文件:desktop-main.log / backend-stdio.log", + ]; + if (outputDir) { + detailLines.push("", `当前 output 目录:${outputDir}`, "其中 output\\logs\\... 也在这里"); + } + dialog.showErrorBox( + "WeChatDataAnalysis 启动失败", + detailLines.join("\n") + ); + shell.openPath(dir); + } + } catch {} + app.quit(); + }); +} diff --git a/desktop/src/output-dir.cjs b/desktop/src/output-dir.cjs new file mode 100644 index 0000000..fe2881c --- /dev/null +++ b/desktop/src/output-dir.cjs @@ -0,0 +1,252 @@ +const fs = require("fs"); +const path = require("path"); + +const SENTINEL_NAMES = [ + "account_keys.json", + "runtime_settings.json", + "message_edits.db", + "databases", + "exports", + "logs", +]; + +function normalizeDirectoryPath(value) { + const text = String(value || "").trim(); + if (!text) return ""; + const expanded = text.replace(/^~(?=$|[\\/])/, process.env.USERPROFILE || process.env.HOME || "~"); + if (!path.isAbsolute(expanded)) { + throw new Error("output 目录必须使用绝对路径"); + } + return path.resolve(expanded); +} + +function getDefaultOutputDirPath(dataDir) { + const base = normalizeDirectoryPath(dataDir); + if (!base) throw new Error("无法定位数据目录"); + return path.join(base, "output"); +} + +function getEffectiveOutputDirPath({ dataDir, envOutputDir, settingsOutputDir }) { + const envPath = normalizeDirectoryPath(envOutputDir || ""); + if (envPath) return envPath; + + const settingsPath = normalizeDirectoryPath(settingsOutputDir || ""); + if (settingsPath) return settingsPath; + + return getDefaultOutputDirPath(dataDir); +} + +function hasDirectoryContents(dirPath) { + try { + return fs.readdirSync(dirPath).length > 0; + } catch (err) { + if (err && err.code === "ENOENT") return false; + throw err; + } +} + +function pathExists(dirPath) { + try { + fs.accessSync(dirPath); + return true; + } catch { + return false; + } +} + +function isDirectory(dirPath) { + try { + return fs.statSync(dirPath).isDirectory(); + } catch { + return false; + } +} + +function isPathInside(parentPath, candidatePath) { + const parent = path.resolve(parentPath); + const candidate = path.resolve(candidatePath); + if (parent === candidate) return false; + const relative = path.relative(parent, candidate); + return !!relative && !relative.startsWith("..") && !path.isAbsolute(relative); +} + +function collectSentinels(sourceDir) { + const sentinels = []; + for (const name of SENTINEL_NAMES) { + const sourcePath = path.join(sourceDir, name); + if (!pathExists(sourcePath)) continue; + sentinels.push({ + name, + isDir: isDirectory(sourcePath), + size: !isDirectory(sourcePath) ? fs.statSync(sourcePath).size : null, + }); + } + return sentinels; +} + +function verifyCopiedOutputTree(sourceDir, copiedDir) { + const sentinels = collectSentinels(sourceDir); + for (const item of sentinels) { + const copiedPath = path.join(copiedDir, item.name); + if (!pathExists(copiedPath)) { + throw new Error(`迁移校验失败:缺少 ${item.name}`); + } + if (item.isDir) { + if (!isDirectory(copiedPath)) { + throw new Error(`迁移校验失败:${item.name} 不是目录`); + } + continue; + } + const copiedStat = fs.statSync(copiedPath); + if (copiedStat.size !== item.size) { + throw new Error(`迁移校验失败:${item.name} 大小不一致`); + } + } +} + +function makeTimestamp(now = new Date()) { + const parts = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0"), + String(now.getSeconds()).padStart(2, "0"), + ]; + return parts.join(""); +} + +function makeUniqueSiblingPath(basePath, suffix, now = new Date()) { + const stamp = makeTimestamp(now); + let attempt = 0; + while (true) { + const candidate = `${basePath}.${suffix}-${stamp}${attempt ? `-${attempt}` : ""}`; + if (!pathExists(candidate)) return candidate; + attempt += 1; + } +} + +function ensureTargetIsUsable(targetDir) { + if (!pathExists(targetDir)) return; + if (!isDirectory(targetDir)) { + throw new Error("目标 output 路径已存在且不是目录"); + } + if (hasDirectoryContents(targetDir)) { + throw new Error("目标 output 目录已有内容,请先清空后再重试"); + } +} + +function migrateOutputDirectory({ currentDir, nextDir, now = new Date() }) { + const currentPath = normalizeDirectoryPath(currentDir); + const targetPath = normalizeDirectoryPath(nextDir); + if (!currentPath || !targetPath) { + throw new Error("output 路径不能为空"); + } + if (currentPath === targetPath) { + return { + changed: false, + currentDir: currentPath, + targetDir: targetPath, + sourceWasEmpty: !hasDirectoryContents(currentPath), + backupDir: "", + }; + } + if (isPathInside(currentPath, targetPath) || isPathInside(targetPath, currentPath)) { + throw new Error("新旧 output 路径不能互相包含"); + } + + ensureTargetIsUsable(targetPath); + + const sourceExists = pathExists(currentPath); + if (sourceExists && !isDirectory(currentPath)) { + throw new Error("当前 output 路径不是目录"); + } + const sourceWasEmpty = !sourceExists || !hasDirectoryContents(currentPath); + if (sourceWasEmpty) { + fs.mkdirSync(targetPath, { recursive: true }); + return { + changed: true, + currentDir: currentPath, + targetDir: targetPath, + sourceWasEmpty: true, + backupDir: "", + }; + } + + const tempTarget = makeUniqueSiblingPath(targetPath, "migrating", now); + const backupDir = makeUniqueSiblingPath(currentPath, "backup", now); + + fs.cpSync(currentPath, tempTarget, { + recursive: true, + force: false, + errorOnExist: true, + preserveTimestamps: true, + }); + + try { + verifyCopiedOutputTree(currentPath, tempTarget); + if (pathExists(targetPath)) { + fs.rmSync(targetPath, { recursive: true, force: true }); + } + + fs.renameSync(currentPath, backupDir); + try { + fs.renameSync(tempTarget, targetPath); + } catch (err) { + try { + if (!pathExists(currentPath) && pathExists(backupDir)) { + fs.renameSync(backupDir, currentPath); + } + } catch {} + throw err; + } + } catch (err) { + try { + if (pathExists(tempTarget)) { + fs.rmSync(tempTarget, { recursive: true, force: true }); + } + } catch {} + throw err; + } + + return { + changed: true, + currentDir: currentPath, + targetDir: targetPath, + sourceWasEmpty: false, + backupDir, + }; +} + +function rollbackOutputDirectoryChange({ previousDir, currentDir, backupDir, sourceWasEmpty }) { + const previousPath = normalizeDirectoryPath(previousDir); + const currentPath = normalizeDirectoryPath(currentDir); + + try { + if (currentPath && pathExists(currentPath)) { + fs.rmSync(currentPath, { recursive: true, force: true }); + } + } catch {} + + if (sourceWasEmpty) { + return; + } + + const backupPath = normalizeDirectoryPath(backupDir); + if (!backupPath || !pathExists(backupPath)) return; + + try { + if (!pathExists(previousPath)) { + fs.renameSync(backupPath, previousPath); + } + } catch {} +} + +module.exports = { + getDefaultOutputDirPath, + getEffectiveOutputDirPath, + hasDirectoryContents, + migrateOutputDirectory, + normalizeDirectoryPath, + rollbackOutputDirectoryChange, +}; diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index 1f1eea2..418f08a 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -1,14 +1,118 @@ const { contextBridge, ipcRenderer } = require("electron"); +function sendDebugLog(scope, message, details) { + try { + ipcRenderer.send("debug:log", { + scope: String(scope || "renderer"), + message: String(message || ""), + details: details == null ? {} : details, + url: typeof location !== "undefined" ? String(location.href || "") : "", + }); + } catch {} +} + +sendDebugLog("preload", "script-start", { + userAgent: typeof navigator !== "undefined" ? String(navigator.userAgent || "") : "", +}); + +if (typeof document !== "undefined") { + document.addEventListener("readystatechange", () => { + sendDebugLog("preload", "document-readystate", { + readyState: String(document.readyState || ""), + }); + }); +} + +if (typeof window !== "undefined") { + window.addEventListener("DOMContentLoaded", () => { + sendDebugLog("preload", "dom-content-loaded"); + }); + + window.addEventListener("load", () => { + sendDebugLog("preload", "window-load"); + }); + + window.addEventListener("error", (event) => { + sendDebugLog("preload", "window-error", { + message: String(event?.message || ""), + filename: String(event?.filename || ""), + lineno: Number(event?.lineno || 0), + colno: Number(event?.colno || 0), + }); + }); + + window.addEventListener("unhandledrejection", (event) => { + const reason = event?.reason; + sendDebugLog("preload", "window-unhandledrejection", { + reason: + reason instanceof Error + ? { + name: String(reason.name || "Error"), + message: String(reason.message || ""), + stack: String(reason.stack || ""), + } + : String(reason || ""), + }); + }); + + window.setTimeout(() => { + sendDebugLog("preload", "set-timeout-0"); + }, 0); +} + contextBridge.exposeInMainWorld("wechatDesktop", { + // Marker used by the frontend to distinguish the Electron desktop shell from the pure web build. + __brand: "WeChatDataAnalysisDesktop", minimize: () => ipcRenderer.invoke("window:minimize"), toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"), close: () => ipcRenderer.invoke("window:close"), isMaximized: () => ipcRenderer.invoke("window:isMaximized"), + isDebugEnabled: () => ipcRenderer.invoke("app:isDebugEnabled"), + logDebug: (scope, message, details = {}) => sendDebugLog(scope, message, details), getAutoLaunch: () => ipcRenderer.invoke("app:getAutoLaunch"), setAutoLaunch: (enabled) => ipcRenderer.invoke("app:setAutoLaunch", !!enabled), getCloseBehavior: () => ipcRenderer.invoke("app:getCloseBehavior"), setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")), + + getBackendPort: () => ipcRenderer.invoke("backend:getPort"), + setBackendPort: (port) => ipcRenderer.invoke("backend:setPort", Number(port)), + + chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options), + + // Data/output folder helpers + getOutputDirInfo: () => ipcRenderer.invoke("app:getOutputDirInfo"), + getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"), + setOutputDir: (dir) => ipcRenderer.invoke("app:setOutputDir", String(dir ?? "")), + openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"), + getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")), + deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")), + + // Auto update + getVersion: () => ipcRenderer.invoke("app:getVersion"), + checkForUpdates: () => ipcRenderer.invoke("app:checkForUpdates"), + downloadAndInstall: () => ipcRenderer.invoke("app:downloadAndInstall"), + installUpdate: () => ipcRenderer.invoke("app:installUpdate"), + ignoreUpdate: (version) => ipcRenderer.invoke("app:ignoreUpdate", String(version || "")), + onDownloadProgress: (callback) => { + const handler = (_event, progress) => callback(progress); + ipcRenderer.on("app:downloadProgress", handler); + return () => ipcRenderer.removeListener("app:downloadProgress", handler); + }, + onUpdateAvailable: (callback) => { + const handler = (_event, info) => callback(info); + ipcRenderer.on("app:updateAvailable", handler); + return () => ipcRenderer.removeListener("app:updateAvailable", handler); + }, + onUpdateDownloaded: (callback) => { + const handler = (_event, info) => callback(info); + ipcRenderer.on("app:updateDownloaded", handler); + return () => ipcRenderer.removeListener("app:updateDownloaded", handler); + }, + onUpdateError: (callback) => { + const handler = (_event, payload) => callback(payload); + ipcRenderer.on("app:updateError", handler); + return () => ipcRenderer.removeListener("app:updateError", handler); + }, }); diff --git a/desktop/tests/output-dir.test.cjs b/desktop/tests/output-dir.test.cjs new file mode 100644 index 0000000..3b7b908 --- /dev/null +++ b/desktop/tests/output-dir.test.cjs @@ -0,0 +1,162 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + getDefaultOutputDirPath, + getEffectiveOutputDirPath, + migrateOutputDirectory, + normalizeDirectoryPath, + rollbackOutputDirectoryChange, +} = require("../src/output-dir.cjs"); + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), "wda-output-")); +} + +function cleanupDir(dirPath) { + try { + fs.rmSync(dirPath, { recursive: true, force: true }); + } catch {} +} + +test("normalizeDirectoryPath requires absolute paths", () => { + assert.throws(() => normalizeDirectoryPath("relative/path"), /绝对路径/); +}); + +test("getEffectiveOutputDirPath prefers env, then settings, then default", () => { + const root = makeTempDir(); + const envDir = path.join(root, "env-output"); + const settingsDir = path.join(root, "settings-output"); + const defaultDir = path.join(root, "data", "output"); + + try { + assert.equal( + getEffectiveOutputDirPath({ + dataDir: path.join(root, "data"), + envOutputDir: envDir, + settingsOutputDir: settingsDir, + }), + path.resolve(envDir) + ); + assert.equal( + getEffectiveOutputDirPath({ + dataDir: path.join(root, "data"), + envOutputDir: "", + settingsOutputDir: settingsDir, + }), + path.resolve(settingsDir) + ); + assert.equal(getDefaultOutputDirPath(path.join(root, "data")), path.resolve(defaultDir)); + } finally { + cleanupDir(root); + } +}); + +test("migrateOutputDirectory switches empty source to a new directory", () => { + const root = makeTempDir(); + const currentDir = path.join(root, "current-output"); + const nextDir = path.join(root, "custom-output"); + + try { + fs.mkdirSync(currentDir, { recursive: true }); + const result = migrateOutputDirectory({ currentDir, nextDir }); + assert.equal(result.changed, true); + assert.equal(result.sourceWasEmpty, true); + assert.equal(result.backupDir, ""); + assert.ok(fs.existsSync(nextDir)); + assert.equal(fs.existsSync(currentDir), true); + } finally { + cleanupDir(root); + } +}); + +test("migrateOutputDirectory blocks non-empty targets", () => { + const root = makeTempDir(); + const currentDir = path.join(root, "current-output"); + const nextDir = path.join(root, "custom-output"); + + try { + fs.mkdirSync(path.join(currentDir, "logs"), { recursive: true }); + fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{}"); + fs.mkdirSync(nextDir, { recursive: true }); + fs.writeFileSync(path.join(nextDir, "existing.txt"), "occupied"); + + assert.throws( + () => migrateOutputDirectory({ currentDir, nextDir }), + /已有内容/ + ); + } finally { + cleanupDir(root); + } +}); + +test("migrateOutputDirectory blocks invalid current paths", () => { + const root = makeTempDir(); + const currentDir = path.join(root, "current-output"); + const nextDir = path.join(root, "custom-output"); + + try { + fs.writeFileSync(currentDir, "not-a-directory"); + assert.throws( + () => migrateOutputDirectory({ currentDir, nextDir }), + /不是目录/ + ); + } finally { + cleanupDir(root); + } +}); + +test("migrateOutputDirectory copies data and leaves the old directory as a backup", () => { + const root = makeTempDir(); + const currentDir = path.join(root, "current-output"); + const nextDir = path.join(root, "custom-output"); + + try { + fs.mkdirSync(path.join(currentDir, "databases", "wxid_test"), { recursive: true }); + fs.writeFileSync(path.join(currentDir, "runtime_settings.json"), "{\"backend_port\":10392}"); + fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "session.db"), "session"); + fs.writeFileSync(path.join(currentDir, "databases", "wxid_test", "contact.db"), "contact"); + + const result = migrateOutputDirectory({ currentDir, nextDir, now: new Date("2026-03-30T08:00:00Z") }); + assert.equal(result.changed, true); + assert.equal(result.sourceWasEmpty, false); + assert.match(path.basename(result.backupDir), /^current-output\.backup-\d{14}$/); + assert.ok(fs.existsSync(nextDir)); + assert.ok(fs.existsSync(path.join(nextDir, "runtime_settings.json"))); + assert.ok(fs.existsSync(path.join(nextDir, "databases", "wxid_test", "session.db"))); + assert.ok(fs.existsSync(result.backupDir)); + assert.equal(fs.existsSync(currentDir), false); + } finally { + cleanupDir(root); + } +}); + +test("rollbackOutputDirectoryChange restores the previous directory", () => { + const root = makeTempDir(); + const previousDir = path.join(root, "current-output"); + const currentDir = path.join(root, "custom-output"); + const backupDir = path.join(root, "current-output.backup-20260330080100"); + + try { + fs.mkdirSync(path.join(currentDir, "databases"), { recursive: true }); + fs.writeFileSync(path.join(currentDir, "databases", "temp.db"), "temp"); + fs.mkdirSync(path.join(backupDir, "databases"), { recursive: true }); + fs.writeFileSync(path.join(backupDir, "databases", "session.db"), "restored"); + + rollbackOutputDirectoryChange({ + previousDir, + currentDir, + backupDir, + sourceWasEmpty: false, + }); + + assert.equal(fs.existsSync(currentDir), false); + assert.ok(fs.existsSync(path.join(previousDir, "databases", "session.db"))); + assert.equal(fs.existsSync(backupDir), false); + } finally { + cleanupDir(root); + } +}); diff --git a/frontend/app.vue b/frontend/app.vue index 75ca905..bfa818a 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -1,39 +1,121 @@ diff --git a/frontend/assets/css/chat.css b/frontend/assets/css/chat.css new file mode 100644 index 0000000..39e2c25 --- /dev/null +++ b/frontend/assets/css/chat.css @@ -0,0 +1,1334 @@ +/* LinkCard:小程序标记与无 URL 降级 */ +.wechat-link-badge { + margin-left: auto; + padding-left: 8px; + font-size: 11px; + color: #b2b2b2; + white-space: nowrap; + flex-shrink: 0; +} + +.wechat-link-cover-badge { + margin-left: auto; + padding-left: 8px; + font-size: 11px; + color: rgba(243, 243, 243, 0.92); + white-space: nowrap; + flex-shrink: 0; +} + +.wechat-link-card.wechat-link-card--disabled, +.wechat-link-card-cover.wechat-link-card--disabled { + cursor: default; +} + +.wechat-link-card.wechat-link-card--disabled:hover, +.wechat-link-card-cover.wechat-link-card--disabled:hover { + background: var(--merged-history-bg); +} + +/* 滚动条样式 */ +.overflow-y-auto::-webkit-scrollbar { + width: 6px; +} + +.overflow-y-auto::-webkit-scrollbar-track { + background: var(--scrollbar-track); + border-radius: 3px; +} + +.overflow-y-auto::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 3px; +} + +.overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +/* 会话列表宽度:按物理像素(px)配置,按 dpr 换算为 CSS px */ +.session-list-panel { + width: calc(var(--session-list-width, 295px) / var(--dpr)); +} + +/* 会话列表拖动条(中间栏右侧) */ +.session-list-resizer { + position: absolute; + top: 0; + right: -3px; /* 覆盖在 border 上,便于拖动 */ + width: 6px; + height: 100%; + cursor: col-resize; + z-index: 50; +} + +.session-list-resizer::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 2px; + width: 2px; + background: transparent; + transition: background-color 0.15s ease; +} + +.session-list-resizer:hover::after, +.session-list-resizer-active::after { + background: var(--session-list-resizer); +} + +.msg-bubble.bubble-tail-r { + background-color: var(--chat-bubble-sent) !important; + color: var(--chat-bubble-sent-text) !important; +} + +.msg-bubble.bubble-tail-l { + background-color: var(--chat-bubble-received) !important; + color: var(--chat-bubble-received-text) !important; +} + +.bubble-tail-r::after { + background: var(--chat-bubble-sent); +} + +.bubble-tail-l::after { + background: var(--chat-bubble-received); +} + +/* 消息气泡样式 */ +.message-bubble { + border-radius: var(--message-radius); + position: relative; + z-index: 1; +} + +/* 发送的消息(右侧绿色气泡) */ +.sent-message { + background-color: var(--chat-bubble-sent) !important; + border-radius: var(--message-radius); +} + +.sent-message::after { + content: ''; + position: absolute; + top: 50%; + right: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background-color: var(--chat-bubble-sent); + border-radius: 2px; +} + +/* 接收的消息(左侧白色气泡) */ +.received-message { + background-color: var(--chat-bubble-received) !important; + border-radius: var(--message-radius); +} + +.received-message::before { + content: ''; + position: absolute; + top: 50%; + left: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background-color: var(--chat-bubble-received); + border-radius: 2px; +} + +/* 聊天标签页样式 */ +.chat-tab { + cursor: pointer; + transition: all 0.2s ease; + color: #606060; +} + +.chat-tab:hover:not(.selected) { + background-color: #E5E5E5; +} + +.chat-tab.selected { + color: #07b75b !important; +} + +.chat-tab:not(.selected):hover { + color: #07b75b; +} + +/* 语音消息样式 */ +.voice-message-wrap { + display: flex; + width: 100%; +} + +.voice-bubble { + border-radius: var(--message-radius); + position: relative; + transition: opacity 0.15s ease; +} + +.voice-bubble:hover { + opacity: 0.85; +} + +.voice-bubble:active { + opacity: 0.7; +} + +.voice-sent { + border-radius: var(--message-radius); +} + +.voice-sent::after { + content: ''; + position: absolute; + top: 50%; + right: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background-color: var(--chat-bubble-sent); + border-radius: 2px; +} + +.voice-received { + border-radius: var(--message-radius); +} + +.voice-received::before { + content: ''; + position: absolute; + top: 50%; + left: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background-color: var(--chat-bubble-received); + border-radius: 2px; +} + +/* 语音消息样式 - 微信风格 */ +.wechat-voice-wrapper { + display: flex; + width: 100%; + position: relative; +} + +.wechat-voice-bubble { + border-radius: var(--message-radius); + position: relative; + transition: opacity 0.15s ease; + min-width: 80px; + max-width: 200px; +} + +.wechat-voice-bubble:hover { + opacity: 0.85; +} + +.wechat-voice-bubble:active { + opacity: 0.7; +} + +.wechat-voice-sent { + background: var(--chat-bubble-sent); + color: var(--chat-bubble-sent-text); +} + +.wechat-voice-sent::after { + content: ''; + position: absolute; + top: 50%; + right: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background: var(--chat-bubble-sent); + border-radius: 2px; +} + +.wechat-voice-received { + background: var(--chat-bubble-received); + color: var(--chat-bubble-received-text); +} + +.wechat-voice-received::before { + content: ''; + position: absolute; + top: 50%; + left: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background: var(--chat-bubble-received); + border-radius: 2px; +} + +.wechat-voice-content { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 8px; +} + +/* 语音图标样式 */ +.wechat-voice-icon { + width: 18px; + height: 18px; + flex-shrink: 0; + color: currentColor; +} + +.wechat-quote-voice-icon { + width: 14px; + height: 14px; + color: inherit; +} + +.voice-icon-sent { + transform: scaleX(-1); +} + +/* 播放时的波动动画 */ +.wechat-voice-icon.voice-playing .voice-wave-2 { + animation: voice-wave-2 1s infinite; +} + +.wechat-voice-icon.voice-playing .voice-wave-3 { + animation: voice-wave-3 1s infinite; +} + +@keyframes voice-wave-2 { + 0%, 33% { opacity: 0; } + 34%, 100% { opacity: 1; } +} + +@keyframes voice-wave-3 { + 0%, 66% { opacity: 0; } + 67%, 100% { opacity: 1; } +} + +.wechat-voice-duration { + font-size: 14px; + color: inherit; +} + +.wechat-voice-unread { + position: absolute; + top: 50%; + right: -20px; + transform: translateY(-50%); + width: 8px; + height: 8px; + border-radius: 50%; + background: #e75e58; +} + +/* 音视频通话消息样式 - 微信风格 */ +.wechat-voip-bubble { + border-radius: var(--message-radius); + position: relative; + min-width: 120px; +} + +.wechat-voip-sent { + background: var(--chat-bubble-sent); + color: var(--chat-bubble-sent-text); +} + +.wechat-voip-sent::after { + content: ''; + position: absolute; + top: 50%; + right: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background: var(--chat-bubble-sent); + border-radius: 2px; +} + +.wechat-voip-received { + background: var(--chat-bubble-received); + color: var(--chat-bubble-received-text); +} + +.wechat-voip-received::before { + content: ''; + position: absolute; + top: 50%; + left: -4px; + transform: translateY(-50%) rotate(45deg); + width: 10px; + height: 10px; + background: var(--chat-bubble-received); + border-radius: 2px; +} + +.wechat-voip-content { + display: flex; + align-items: center; + padding: 8px 14px; + gap: 8px; +} + +.wechat-voip-icon { + width: 22px; + height: 14px; + flex-shrink: 0; + object-fit: contain; +} + +.wechat-voip-text { + font-size: 14px; + color: inherit; +} + +/* 统一特殊消息尾巴(红包 / 文件等) */ +.wechat-special-card { + position: relative; + overflow: visible; +} + +.wechat-special-card::after { + content: ''; + position: absolute; + top: 12px; + left: -4px; + width: 12px; + height: 12px; + background-color: inherit; + transform: rotate(45deg); + border-radius: 2px; +} + +.wechat-special-sent-side::after { + left: auto; + right: -4px; +} + +.wechat-chat-history-card { + width: 210px; + background: var(--merged-history-bg); + border-radius: var(--message-radius); + cursor: pointer; + transition: background-color 0.15s ease; +} + +.wechat-chat-history-card:hover { + background: var(--merged-history-hover); +} + +.wechat-chat-history-body { + padding: 10px 12px; +} + +.wechat-chat-history-title { + font-size: 14px; + font-weight: 400; + color: var(--merged-history-title); + margin-bottom: 6px; +} + +.wechat-chat-history-preview { + font-size: 12px; + color: var(--merged-history-preview); + line-height: 1.4; +} + +.wechat-chat-history-line { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wechat-chat-history-bottom { + height: 27px; + display: flex; + align-items: center; + padding: 0 12px; + border-top: none; + position: relative; +} + +.wechat-chat-history-bottom::before { + content: ''; + position: absolute; + top: 0; + left: 13px; + right: 13px; + height: 1.5px; + background: var(--merged-history-divider); +} + +.wechat-chat-history-bottom span { + font-size: 12px; + color: var(--merged-history-footer); +} + +.wechat-quote-preview { + background: var(--quote-bubble-bg); + color: var(--quote-bubble-text); +} + +/* 转账消息样式 - 微信风格 */ +.wechat-transfer-card { + width: 210px; + background: #f79c46; + border-radius: var(--message-radius); + overflow: visible; + position: relative; +} + +.wechat-transfer-card::after { + content: ''; + position: absolute; + top: 16px; + left: -4px; + width: 10px; + height: 10px; + background: #f79c46; + transform: rotate(45deg); + border-radius: 2px; +} + +.wechat-transfer-sent-side::after { + left: auto; + right: -4px; +} + +.wechat-transfer-content { + display: flex; + align-items: center; + padding: 10px 12px; + min-height: 58px; +} + +.wechat-transfer-icon { + width: 36px; + height: 36px; + flex-shrink: 0; + object-fit: contain; +} + +.wechat-transfer-info { + flex: 1; + margin-left: 10px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.wechat-transfer-amount { + font-size: 16px; + font-weight: 500; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wechat-transfer-status { + font-size: 12px; + color: #fff; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wechat-transfer-bottom { + height: 27px; + display: flex; + align-items: center; + padding: 0 12px; + border-top: none; + position: relative; +} + +.wechat-transfer-bottom::before { + content: ''; + position: absolute; + top: 0; + left: 13px; + right: 13px; + height: 1px; + background: rgba(255,255,255,0.2); +} + +.wechat-transfer-bottom span { + font-size: 11px; + color: #fff; +} + +/* 已领取的转账样式 */ +.wechat-transfer-received { + background: #FDCE9D; +} + +.wechat-transfer-received::after { + background: #FDCE9D; +} + +.wechat-transfer-received .wechat-transfer-amount, +.wechat-transfer-received .wechat-transfer-status { + color: #fff; +} + +.wechat-transfer-received .wechat-transfer-bottom span { + color: #fff; +} + +/* 退回的转账样式 */ +.wechat-transfer-returned { + background: #fde1c3; +} + +.wechat-transfer-returned::after { + background: #fde1c3; +} + +.wechat-transfer-returned .wechat-transfer-amount, +.wechat-transfer-returned .wechat-transfer-status { + color: #fff; +} + +.wechat-transfer-returned .wechat-transfer-bottom span { + color: #fff; +} + +/* 过期的转账样式 */ +.wechat-transfer-overdue { + background: #E9CFB3; +} + +.wechat-transfer-overdue::after { + background: #E9CFB3; +} + +.wechat-transfer-overdue .wechat-transfer-amount, +.wechat-transfer-overdue .wechat-transfer-status { + color: #fff; +} + +.wechat-transfer-overdue .wechat-transfer-bottom span { + color: #fff; +} + +/* 红包消息样式 - 微信风格 */ +.wechat-redpacket-card { + width: 210px; + background: #fa9d3b; + border-radius: var(--message-radius); + overflow: visible; + position: relative; +} + +.wechat-redpacket-content { + display: flex; + align-items: center; + padding: 10px 12px; + min-height: 58px; +} + +.wechat-redpacket-icon { + width: 32px; + height: 36px; + flex-shrink: 0; + object-fit: contain; +} + +.wechat-redpacket-info { + flex: 1; + margin-left: 10px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.wechat-redpacket-text { + font-size: 14px; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wechat-redpacket-status { + font-size: 12px; + color: #fff; + margin-top: 2px; +} + +.wechat-redpacket-bottom { + height: 27px; + display: flex; + align-items: center; + padding: 0 12px; + border-top: none; + position: relative; +} + +.wechat-redpacket-bottom::before { + content: ''; + position: absolute; + top: 0; + left: 13px; + right: 13px; + height: 1px; + background: rgba(255,255,255,0.2); +} + +.wechat-redpacket-bottom span { + font-size: 11px; + color: #faecda; +} + +/* 已领取的红包样式 */ +.wechat-redpacket-received { + background: #f8e2c6; +} + +.wechat-redpacket-received .wechat-redpacket-text, +.wechat-redpacket-received .wechat-redpacket-status { + color: #b88550; +} + +.wechat-redpacket-received .wechat-redpacket-bottom span { + color: #c9a67a; +} + +/* 文件消息样式 - 基于红包样式覆盖 */ +.wechat-file-card { + width: 210px; + background: var(--merged-history-bg); + cursor: pointer; + transition: background-color 0.15s ease; +} + +.wechat-file-card .wechat-redpacket-content { + padding: 10px 12px; + min-height: 58px; +} + +.wechat-file-card .wechat-redpacket-bottom { + height: 27px; + padding: 0 12px; + border-top: none; + position: relative; +} + +.wechat-file-card .wechat-redpacket-bottom::before { + content: ''; + position: absolute; + top: 0; + left: 13px; + right: 13px; + height: 1.5px; + background: var(--merged-history-divider); +} + +.wechat-file-card:hover { + background: var(--merged-history-hover); +} + +.wechat-file-card .wechat-file-info { + margin-left: 0; + margin-right: 10px; +} + +.wechat-file-name { + font-size: 14px; + color: var(--merged-history-title); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-all; + line-height: 1.4; +} + +.wechat-file-size { + font-size: 12px; + color: var(--merged-history-footer); + margin-top: 4px; +} + +.wechat-file-icon { + width: 40px; + height: 40px; + flex-shrink: 0; + object-fit: contain; +} + +.wechat-file-bottom { + border-top: 1px solid var(--merged-history-divider); +} + +.wechat-file-bottom span { + font-size: 12px; + color: var(--merged-history-footer); +} + +.wechat-file-card :is(.text-gray-500, .text-gray-400) { + color: var(--merged-history-preview); +} + +.wechat-file-logo { + width: 18px; + height: 18px; + object-fit: contain; + margin-right: 4px; +} + +/* 链接消息样式 - 微信风格 */ +.wechat-link-card { + width: 210px; + min-width: 210px; + max-width: 210px; + background: var(--merged-history-bg); + display: flex; + flex-direction: column; + box-sizing: border-box; + border: none; + box-shadow: none; + outline: none; + cursor: pointer; + text-decoration: none; + transition: background-color 0.15s ease; +} + +.wechat-link-card:hover { + background: var(--merged-history-hover); +} + +.wechat-link-content { + display: flex; + flex-direction: column; + gap: 8px; + box-sizing: border-box; + padding: 10px 10px 8px; + flex: 1 1 auto; +} + +.wechat-link-summary { + display: flex; + align-items: flex-start; + gap: 10px; + min-height: 42px; +} + +.wechat-link-title { + font-size: 14px; + color: var(--merged-history-title); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; + word-break: break-word; +} + +.wechat-link-desc { + font-size: 12px; + color: var(--merged-history-preview); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; + word-break: break-word; + flex: 1 1 auto; + min-width: 0; +} + +.wechat-link-thumb { + width: 42px; + height: 42px; + flex: 0 0 auto; + border-radius: 0; + overflow: hidden; + background: var(--app-surface-muted); + align-self: flex-start; +} + +.wechat-link-thumb-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.wechat-link-card--mini-program { + max-height: 270px; + height: 270px; +} + +.wechat-link-mini-body { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + box-sizing: border-box; + flex: 1 1 auto; + min-height: 0; +} + +.wechat-link-mini-header { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.wechat-link-mini-header-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + background: #14c15f; + color: #fff; + font-size: 11px; + line-height: 20px; + text-align: center; + flex-shrink: 0; + position: relative; + overflow: hidden; +} + +.wechat-link-mini-header-avatar-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.wechat-link-mini-header-name { + font-size: 13px; + color: var(--merged-history-preview); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1 1 auto; +} + +.wechat-link-mini-title { + font-size: 13px; + line-height: 1.45; + color: var(--merged-history-title); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; +} + +.wechat-link-mini-preview { + width: 100%; + height: auto; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; + background: var(--app-surface-muted); + margin-top: auto; +} + +.wechat-link-mini-preview--empty { + background: var(--app-surface-soft); +} + +.wechat-link-mini-preview-img { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; + display: block; +} + +.wechat-link-mini-footer { + height: 23px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 12px; + box-sizing: border-box; + position: relative; + flex-shrink: 0; +} + +.wechat-link-mini-footer::before { + content: ''; + position: absolute; + top: 0; + left: 12px; + right: 12px; + height: 1px; + background: var(--merged-history-divider); +} + +.wechat-link-mini-footer-icon { + width: 12px; + height: 12px; + object-fit: contain; + flex-shrink: 0; +} + +.wechat-link-mini-footer-text { + font-size: 10px; + color: var(--merged-history-preview); +} + +.wechat-link-from { + height: 30px; + display: flex; + align-items: center; + gap: 5px; + padding: 0 10px; + position: relative; + flex-shrink: 0; +} + +.wechat-link-from::before { + content: ''; + position: absolute; + top: 0; + left: 11px; + right: 11px; + height: 1.5px; + background: var(--merged-history-divider); +} + +.wechat-link-from-avatar { + width: 16px; + height: 16px; + border-radius: 50%; + background: #111; + color: #fff; + font-size: 11px; + line-height: 16px; + text-align: center; + flex-shrink: 0; + position: relative; + overflow: hidden; +} + +.wechat-link-from-avatar-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.wechat-link-from-name { + font-size: 12px; + color: var(--merged-history-footer); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 链接封面卡片(170x230 图 + 60 底栏) */ +.wechat-link-card-cover { + width: 137px; + min-width: 137px; + max-width: 137px; + background: var(--merged-history-bg); + display: flex; + flex-direction: column; + box-sizing: border-box; + border: none; + box-shadow: none; + outline: none; + cursor: pointer; + text-decoration: none; + transition: background-color 0.15s ease; +} + +.wechat-link-card-cover:hover { + background: var(--merged-history-hover); +} + +.wechat-link-cover-image-wrap { + width: 137px; + height: 180px; + position: relative; + overflow: hidden; + border-radius: 4px 4px 0 0; + background: var(--app-surface-muted); + flex-shrink: 0; +} + +.wechat-link-cover-image { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + display: block; +} + +/* 仅公众号封面卡片去掉菱形尖角,其它消息保持原样 */ +.wechat-link-card-cover.wechat-special-card::after { + content: none !important; +} + +.wechat-link-cover-from { + height: 30px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px; + box-sizing: border-box; + position: absolute; + left: 0; + right: 0; + bottom: 0; + background: transparent; + flex-shrink: 0; +} + +.wechat-link-cover-from-avatar { + width: 18px; + height: 18px; + border-radius: 50%; + background: #111; + color: #fff; + font-size: 11px; + line-height: 18px; + text-align: center; + flex-shrink: 0; + position: relative; + overflow: hidden; +} + +.wechat-link-cover-from-avatar-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.wechat-link-cover-from-name { + font-size: 12px; + color: #f3f3f3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.wechat-link-cover-title { + height: 50px; + padding: 7px 10px 0; + box-sizing: border-box; + font-size: 12px; + line-height: 1.24; + color: var(--merged-history-title); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: break-word; + flex-shrink: 0; +} + +.wechat-link-card-finder { + width: 135px; + min-width: 135px; + max-width: 135px; + border: none; + box-shadow: none; + outline: none; + cursor: pointer; + text-decoration: none; +} + +.wechat-link-card-finder.wechat-link-card--disabled { + cursor: default; +} + +.wechat-link-finder-cover { + width: 135px; + height: 185px; + position: relative; + overflow: hidden; + border-radius: 4px; + background: var(--app-surface-muted); +} + +.wechat-link-finder-cover--empty { + background: linear-gradient(180deg, #37cc6a 0%, #118f42 100%); +} + +.wechat-link-finder-cover-img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + display: block; +} + +.wechat-link-finder-cover-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.92); +} + +.wechat-link-finder-cover-placeholder svg { + width: 34px; + height: 34px; +} + +.wechat-link-finder-cover-shade { + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.12) 42%, rgba(0, 0, 0, 0.68) 100%); +} + +.wechat-link-finder-play { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -66%); + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.42); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); +} + +.wechat-link-finder-play svg { + width: 20px; + height: 20px; + margin-left: 2px; +} + +.wechat-link-finder-meta { + position: absolute; + left: 8px; + right: 8px; + bottom: 8px; + display: flex; + flex-direction: column; + gap: 0; +} + +.wechat-link-finder-author { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + padding: 5px 7px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.28); + backdrop-filter: blur(6px); +} + +.wechat-link-finder-author-avatar { + width: 18px; + height: 18px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.wechat-link-finder-author-avatar-img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +.wechat-link-finder-author-name { + min-width: 0; + flex: 1 1 auto; + font-size: 10px; + color: rgba(255, 255, 255, 0.96); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.28); +} + +/* 隐私模式模糊效果 */ +.privacy-blur { + filter: blur(9px); + transition: filter 0.2s ease; +} + +.privacy-blur:hover { + filter: none; +} + +/* 定位引用消息的高亮效果 */ +.message-locate-highlight { + position: relative; + animation: locate-pulse 1.8s ease-out; +} + +.message-locate-highlight::before { + content: ''; + position: absolute; + inset: -4px -8px; + border-radius: 8px; + background: rgba(3, 193, 96, 0.12); + pointer-events: none; + animation: locate-fade 1.8s ease-out forwards; +} + +@keyframes locate-pulse { + 0% { + transform: scale(1.02); + } + 15% { + transform: scale(1); + } + 100% { + transform: scale(1); + } +} + +@keyframes locate-fade { + 0% { + opacity: 1; + background: rgba(3, 193, 96, 0.15); + } + 70% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +/* 骨架屏加载动画 */ +.skeleton-pulse { + animation: skeleton-loading 1.5s ease-in-out infinite; +} + +@keyframes skeleton-loading { + 0% { + opacity: 0.6; + } + 50% { + opacity: 0.3; + } + 100% { + opacity: 0.6; + } +} diff --git a/frontend/assets/css/tailwind.css b/frontend/assets/css/tailwind.css index 6fb94bf..d653392 100644 --- a/frontend/assets/css/tailwind.css +++ b/frontend/assets/css/tailwind.css @@ -10,38 +10,272 @@ --wechat-green-hover: #06ad56; --wechat-green-light: #e6f7f0; --wechat-green-dark: #059341; - + /* 主色调 */ --primary-color: #07c160; --primary-hover: #06ad56; --secondary-color: #4c9e5f; - + /* 危险色 */ --danger-color: #fa5151; --danger-hover: #e94848; - + /* 警告色 */ --warning-color: #ffc300; --warning-hover: #e6ad00; - + /* 背景色 */ --bg-primary: #f7f8fa; --bg-secondary: #ffffff; --bg-gray: #ededed; --bg-dark: #191919; - + /* 文字颜色 */ --text-primary: #191919; --text-secondary: #576b95; --text-light: #888888; --text-white: #ffffff; - + /* 边框颜色 */ --border-color: #e7e7e7; --border-light: #f4f4f4; /* 统一消息圆角(聊天所有消息共用) */ --message-radius: 4px; + + /* 主题色板 */ + --sidebar-rail-bg: #e8e7e7; + --sidebar-rail-border: #dadada; + --sidebar-rail-hover: #e1e1e1; + --sidebar-rail-icon-color: #5d5d5d; + --sidebar-rail-icon-active-color: #07b75b; + --desktop-titlebar-bg: #ededed; + --desktop-titlebar-icon: #111111; + --desktop-titlebar-hover: rgba(0, 0, 0, 0.06); + --desktop-titlebar-active: rgba(0, 0, 0, 0.1); + + --app-shell-bg: #ededed; + --app-surface-bg: #ffffff; + --app-surface-soft: #f7f7f7; + --app-surface-muted: #f3f3f3; + --app-surface-overlay: rgba(255, 255, 255, 0.92); + --app-border: #e7e7e7; + --app-border-soft: #ececec; + --app-border-subtle: #f1f1f1; + --app-text-primary: #191919; + --app-text-secondary: #5f5f5f; + --app-text-muted: #909090; + --app-text-faint: #9e9e9e; + --app-input-bg: #ffffff; + --app-input-border: #e7e7e7; + --app-input-hover: #f7f7f7; + --app-neutral-btn-bg: #ffffff; + --app-neutral-btn-hover: #f7f7f7; + --app-list-hover: #eaeaea; + --app-list-active: #dedede; + --app-list-active-hover: #d3d3d3; + --app-accent: #07c160; + --app-accent-hover: #06ad56; + + --session-list-bg: #f7f7f7; + --session-list-search-bg: #f7f7f7; + --session-list-border: #e5e7eb; + --session-list-item-border: #f1f1f1; + --session-list-item-bg: transparent; + --session-list-item-hover: #eaeaea; + --session-list-item-selected: #dedede; + --session-list-item-selected-hover: #d3d3d3; + --session-list-item-top-bg: #eaeaea; + --session-list-item-top-hover: #dedede; + --session-list-item-top-selected: #d2d2d2; + --session-list-item-top-selected-hover: #c7c7c7; + --session-list-name: #111827; + --session-list-meta: #6b7280; + --session-list-preview: #6b7280; + --session-list-resizer: rgba(0, 0, 0, 0.12); + + --contact-search-bg: #eaeaea; + --contact-search-focus-bg: #ffffff; + --contact-search-focus-ring: rgba(3, 193, 96, 0.2); + --contact-search-icon: #9ca3af; + --contact-search-text: #1f2937; + --contact-search-placeholder: #9ca3af; + --contact-search-clear: #9ca3af; + --account-select-bg: #eaeaea; + + --chat-page-bg: #ededed; + --chat-header-bg: #ededed; + --chat-header-border: #e5e7eb; + --chat-header-title: #111827; + --chat-header-icon: #4b5563; + --chat-header-icon-hover: #1f2937; + --message-filter-color: #374151; + --message-list-status: #6b7280; + --message-load-more-bg: #ffffff; + --message-load-more-border: #e5e7eb; + --message-load-more-text: #374151; + --jump-to-bottom-bg: rgba(255, 255, 255, 0.9); + --jump-to-bottom-border: #e5e7eb; + --jump-to-bottom-text: #374151; + --chat-date-text: #9e9e9e; + --chat-sender-name: #6b7280; + --chat-bubble-sent: #95ec69; + --chat-bubble-sent-text: #000000; + --chat-bubble-received: #ffffff; + --chat-bubble-received-text: #1f2937; + --quote-bubble-bg: #e1e1e1; + --quote-bubble-text: #525252; + --merged-history-bg: #ffffff; + --merged-history-hover: #f5f5f5; + --merged-history-title: #161616; + --merged-history-preview: #6b7280; + --merged-history-divider: #e8e8e8; + --merged-history-footer: #b2b2b2; + + --search-panel-bg: #ffffff; + --search-panel-header-bg: #f9fafb; + --search-panel-border: #e5e7eb; + --search-panel-soft: #f9fafb; + --search-item-hover: #f3f4f6; + --search-panel-text: #1f2937; + --search-panel-muted: #9ca3af; + --search-result-selected: rgba(3, 193, 96, 0.08); + --search-result-selected-hover: rgba(3, 193, 96, 0.12); + --search-input-bg: #ffffff; + --search-input-border: #e5e7eb; + + --calendar-day-bg: #ffffff; + --calendar-day-border: #e5e7eb; + --calendar-day-text: #374151; + --calendar-day-empty-bg: #f3f4f6; + --calendar-day-empty-border: #f3f4f6; + --calendar-day-empty-text: #9ca3af; + + --scrollbar-track: #f1f1f1; + --scrollbar-thumb: #c1c1c1; + --scrollbar-thumb-hover: #a1a1a1; + } + + html[data-theme='dark'] { + --bg-primary: #191919; + --bg-secondary: #242424; + --bg-gray: #242424; + --text-primary: #f5f5f5; + --text-secondary: #c7c7c7; + --text-light: #9f9f9f; + --border-color: #303030; + --border-light: #262626; + + --sidebar-rail-bg: #2d2d2d; + --sidebar-rail-border: #2d2d2d; + --sidebar-rail-hover: #373737; + --sidebar-rail-icon-color: #a0a0a0; + --sidebar-rail-icon-active-color: #3eb575; + --desktop-titlebar-bg: #191919; + --desktop-titlebar-icon: #d0d0d0; + --desktop-titlebar-hover: rgba(255, 255, 255, 0.08); + --desktop-titlebar-active: rgba(255, 255, 255, 0.14); + + --app-shell-bg: #191919; + --app-surface-bg: #242424; + --app-surface-soft: #2e2e2e; + --app-surface-muted: #2f2f2f; + --app-surface-overlay: rgba(46, 46, 46, 0.92); + --app-border: #373737; + --app-border-soft: #3a3a3a; + --app-border-subtle: #2d2d2d; + --app-text-primary: #f5f5f5; + --app-text-secondary: #c7c7c7; + --app-text-muted: #9f9f9f; + --app-text-faint: #9f9f9f; + --app-input-bg: #2f2f2f; + --app-input-border: #3a3a3a; + --app-input-hover: #373737; + --app-neutral-btn-bg: #2f2f2f; + --app-neutral-btn-hover: #3a3a3a; + --app-list-hover: #2f2f2f; + --app-list-active: #3a3a3a; + --app-list-active-hover: #444444; + --app-accent: #3eb575; + --app-accent-hover: #35a86b; + + --session-list-bg: #191919; + --session-list-search-bg: #191919; + --session-list-border: #2d2d2d; + --session-list-item-border: #2d2d2d; + --session-list-item-bg: #242424; + --session-list-item-hover: #2f2f2f; + --session-list-item-selected: #3a3a3a; + --session-list-item-selected-hover: #444444; + --session-list-item-top-bg: #2f2f2f; + --session-list-item-top-hover: #3a3a3a; + --session-list-item-top-selected: #444444; + --session-list-item-top-selected-hover: #4e4e4e; + --session-list-name: #f5f5f5; + --session-list-meta: #c4c4c4; + --session-list-preview: #b8b8b8; + --session-list-resizer: rgba(255, 255, 255, 0.18); + + --contact-search-bg: #2f2f2f; + --contact-search-focus-bg: #2f2f2f; + --contact-search-focus-ring: rgba(62, 181, 117, 0.22); + --contact-search-icon: #9f9f9f; + --contact-search-text: #f5f5f5; + --contact-search-placeholder: #9f9f9f; + --contact-search-clear: #9f9f9f; + --account-select-bg: #2f2f2f; + + --chat-page-bg: #191919; + --chat-header-bg: #191919; + --chat-header-border: #2b2b2b; + --chat-header-title: #f5f5f5; + --chat-header-icon: #d0d0d0; + --chat-header-icon-hover: #ffffff; + --message-filter-color: #d0d0d0; + --message-list-status: #9f9f9f; + --message-load-more-bg: #242424; + --message-load-more-border: #373737; + --message-load-more-text: #d8d8d8; + --jump-to-bottom-bg: rgba(36, 36, 36, 0.94); + --jump-to-bottom-border: #373737; + --jump-to-bottom-text: #d8d8d8; + --chat-date-text: #9f9f9f; + --chat-sender-name: #b9b9b9; + --chat-bubble-sent: #3eb575; + --chat-bubble-sent-text: #ffffff; + --chat-bubble-received: #2e2e2e; + --chat-bubble-received-text: #f5f5f5; + --quote-bubble-bg: #252525; + --quote-bubble-text: #c9c9c9; + --merged-history-bg: #2e2e2e; + --merged-history-hover: #383838; + --merged-history-title: #f5f5f5; + --merged-history-preview: #c4c4c4; + --merged-history-divider: #3a3a3a; + --merged-history-footer: #a8a8a8; + + --search-panel-bg: #191919; + --search-panel-header-bg: #191919; + --search-panel-border: #2b2b2b; + --search-panel-soft: #2f2f2f; + --search-item-hover: #2f2f2f; + --search-panel-text: #f5f5f5; + --search-panel-muted: #9f9f9f; + --search-result-selected: #3a3a3a; + --search-result-selected-hover: #444444; + --search-input-bg: #2f2f2f; + --search-input-border: #3a3a3a; + + --calendar-day-bg: #242424; + --calendar-day-border: #373737; + --calendar-day-text: #e5e5e5; + --calendar-day-empty-bg: #1f1f1f; + --calendar-day-empty-border: #1f1f1f; + --calendar-day-empty-text: #7f7f7f; + + --scrollbar-track: #232323; + --scrollbar-thumb: #4b4b4b; + --scrollbar-thumb-hover: #5b5b5b; } body { @@ -50,6 +284,7 @@ color: var(--text-primary); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + transition: background-color 0.2s ease, color 0.2s ease; } } @@ -80,6 +315,56 @@ /* 统一的消息圆角工具类 */ .msg-radius { border-radius: var(--message-radius); } .msg-bubble { @apply leading-normal break-words text-pretty; border-radius: var(--message-radius); } + + /* 隐私模式(通用):默认模糊,悬停显示 */ + .privacy-blur { + filter: blur(9px); + transition: filter 0.2s ease; + } + + .privacy-blur:hover { + filter: none; + } + + /* Wrapped 隐私模式:仅模糊“用户名文本”,头像不模糊(避免把头像也 blur 掉) */ + .wrapped-privacy .wrapped-privacy-name { + filter: blur(9px); + transition: filter 0.2s ease; + } + + .wrapped-privacy .wrapped-privacy-name:hover { + filter: none; + } + + /* Wrapped 隐私模式:模糊“消息内容文本”(仅在被标记为 message 的节点上生效) */ + .wrapped-privacy .wrapped-privacy-message { + filter: blur(9px); + transition: filter 0.2s ease; + } + + .wrapped-privacy .wrapped-privacy-message:hover { + filter: none; + } + + /* Wrapped 隐私模式:模糊“词云关键词” */ + .wrapped-privacy .wrapped-privacy-keyword { + filter: blur(9px); + transition: filter 0.2s ease; + } + + .wrapped-privacy .wrapped-privacy-keyword:hover { + filter: none; + } + + /* Wrapped 隐私模式:模糊头像(含 fallback 字符) */ + .wrapped-privacy .wrapped-privacy-avatar { + filter: blur(9px); + transition: filter 0.2s ease; + } + + .wrapped-privacy .wrapped-privacy-avatar:hover { + filter: none; + } /* 按钮样式 */ .btn { @apply px-6 py-3 rounded-full font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 transform active:scale-95; @@ -110,6 +395,48 @@ @apply hover:transform hover:scale-[1.02] transition-all duration-300; } + /* Wrapped (年度总结) 背景纹理 */ + .wrapped-noise { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNjAiIGhlaWdodD0iMTYwIj4KICA8ZmlsdGVyIGlkPSJuIj4KICAgIDxmZVR1cmJ1bGVuY2UgdHlwZT0iZnJhY3RhbE5vaXNlIiBiYXNlRnJlcXVlbmN5PSIwLjgiIG51bU9jdGF2ZXM9IjQiIHN0aXRjaFRpbGVzPSJzdGl0Y2giLz4KICAgIDxmZUNvbG9yTWF0cml4IHR5cGU9InNhdHVyYXRlIiB2YWx1ZXM9IjAiLz4KICA8L2ZpbHRlcj4KICA8cmVjdCB3aWR0aD0iMTYwIiBoZWlnaHQ9IjE2MCIgZmlsdGVyPSJ1cmwoI24pIiBvcGFjaXR5PSIwLjQ1Ii8+Cjwvc3ZnPg=="); + background-repeat: repeat; + background-size: 320px 320px; + mix-blend-mode: multiply; + } + + /* Wrapped 增强噪点纹理(动态抖动) */ + .wrapped-noise-enhanced { + background-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIj4KICA8ZmlsdGVyIGlkPSJuIj4KICAgIDxmZVR1cmJ1bGVuY2UgdHlwZT0iZnJhY3RhbE5vaXNlIiBiYXNlRnJlcXVlbmN5PSIwLjkiIG51bU9jdGF2ZXM9IjUiIHN0aXRjaFRpbGVzPSJzdGl0Y2giLz4KICAgIDxmZUNvbG9yTWF0cml4IHR5cGU9InNhdHVyYXRlIiB2YWx1ZXM9IjAiLz4KICA8L2ZpbHRlcj4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsdGVyPSJ1cmwoI24pIiBvcGFjaXR5PSIwLjUiLz4KPC9zdmc+"); + background-repeat: repeat; + background-size: 200px 200px; + mix-blend-mode: multiply; + animation: noise-jitter 0.5s steps(3) infinite; + } + + /* Wrapped typography */ + .wrapped-title { + font-weight: 700; + letter-spacing: 0.02em; + } + + .wrapped-title-en { + font-weight: 700; + letter-spacing: 0.04em; + } + + .wrapped-body { + line-height: 1.8; + } + + .wrapped-label { + font-weight: 600; + letter-spacing: 0.15em; + text-transform: uppercase; + } + + .wrapped-number { + font-variant-numeric: tabular-nums; + } + /* 输入框样式 */ .input { @apply w-full px-4 py-3 bg-[#f7f8fa] border border-transparent rounded-xl focus:outline-none focus:ring-2 focus:ring-[#07c160] focus:bg-white focus:border-[#07c160] transition-all duration-200; @@ -557,35 +884,39 @@ } .header-btn { - @apply flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-white border border-gray-200 text-gray-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed; + @apply flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md bg-white border border-gray-200 text-gray-700 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed shadow-sm; } .header-btn:hover:not(:disabled) { - @apply bg-gray-50 border-gray-300; + @apply bg-gray-50 border-gray-300 shadow; } .header-btn:active:not(:disabled) { - @apply bg-gray-100; + @apply bg-gray-100 scale-95; + } + + .header-btn svg { + @apply w-3.5 h-3.5; } .header-btn-icon { - @apply w-8 h-8 flex items-center justify-center rounded-lg bg-white border border-gray-200 text-gray-600 transition-all duration-200; + @apply w-8 h-8 flex items-center justify-center rounded-lg bg-transparent border border-transparent text-gray-600 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed; } .header-btn-icon:hover { - @apply bg-gray-50 border-gray-300 text-gray-800; + @apply bg-transparent border-transparent text-gray-800; } .header-btn-icon-active { - @apply bg-[#03C160]/10 border-[#03C160] text-[#03C160]; + @apply bg-transparent border-transparent text-[#03C160]; } .header-btn-icon-active:hover { - @apply bg-[#03C160]/15; + @apply bg-transparent; } .message-filter-select { - @apply text-xs px-2 py-1.5 rounded-lg bg-white border border-gray-200 text-gray-700 focus:outline-none focus:ring-2 focus:ring-[#03C160]/20 focus:border-[#03C160] transition-all disabled:opacity-50 disabled:cursor-not-allowed; + @apply text-xs px-2 py-1.5 rounded-lg bg-transparent border-0 text-gray-700 focus:outline-none focus:ring-0 transition-all disabled:opacity-50 disabled:cursor-not-allowed; } /* 搜索侧边栏样式 */ @@ -609,6 +940,128 @@ @apply px-3 py-3 border-b border-gray-100; } + /* 时间侧边栏(按日期定位) */ + .time-sidebar { + @apply w-[420px] h-full flex flex-col bg-white border-l border-gray-200 flex-shrink-0; + } + + .time-sidebar-header { + @apply flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50; + } + + .time-sidebar-title { + @apply flex items-center gap-2 text-sm font-medium text-gray-800; + } + + .time-sidebar-close { + @apply p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-200 rounded-md transition-colors; + } + + .time-sidebar-body { + @apply flex-1 overflow-y-auto min-h-0; + } + + .time-sidebar-status { + @apply px-4 py-2 text-xs text-gray-600 border-b border-gray-100; + } + + .time-sidebar-status-error { + @apply text-red-600; + } + + .calendar-header { + @apply flex items-center justify-between px-4 py-3; + } + + .calendar-nav-btn { + @apply p-1.5 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed; + } + + .calendar-month-label { + @apply text-sm font-medium text-gray-800; + } + + .calendar-month-label-selects { + @apply flex items-center gap-2; + } + + .calendar-ym-select { + @apply text-xs px-2 py-1 rounded-md border border-gray-200 bg-white text-gray-800 hover:border-gray-300 focus:outline-none focus:ring-2 focus:ring-[#03C160]/20 disabled:opacity-60 disabled:cursor-not-allowed; + } + + .calendar-weekdays { + @apply grid grid-cols-7 gap-1 px-4 pt-1; + } + + .calendar-weekday { + @apply text-[11px] text-gray-400 text-center py-1; + } + + .calendar-grid { + @apply grid grid-cols-7 gap-1 px-4 pb-4; + } + + .calendar-day { + @apply h-9 rounded-md flex items-center justify-center text-xs font-medium transition-colors border border-gray-200 bg-white disabled:cursor-not-allowed; + } + + .calendar-day-outside { + @apply bg-transparent border-transparent; + } + + .calendar-day-empty { + @apply bg-gray-100 text-gray-400 border-gray-100; + } + + .calendar-day-selected { + /* Keep background as-is (heatmap), but emphasize with a ring/outline. */ + box-shadow: 0 0 0 2px rgba(3, 193, 96, 0.85); + border-color: rgba(3, 193, 96, 0.95) !important; + } + + .calendar-day-l1 { + background: rgba(3, 193, 96, 0.12); + border-color: rgba(3, 193, 96, 0.18); + color: #065f46; + } + + .calendar-day-l2 { + background: rgba(3, 193, 96, 0.24); + border-color: rgba(3, 193, 96, 0.28); + color: #065f46; + } + + .calendar-day-l3 { + background: rgba(3, 193, 96, 0.38); + border-color: rgba(3, 193, 96, 0.40); + color: #064e3b; + } + + .calendar-day-l4 { + background: rgba(3, 193, 96, 0.55); + border-color: rgba(3, 193, 96, 0.55); + color: #053d2e; + } + + .calendar-day-l1:hover, + .calendar-day-l2:hover, + .calendar-day-l3:hover, + .calendar-day-l4:hover { + filter: brightness(0.98); + } + + .calendar-day-number { + @apply select-none; + } + + .time-sidebar-actions { + @apply px-4 pb-4; + } + + .time-sidebar-action-btn { + @apply w-full text-xs px-3 py-2 rounded-md bg-[#03C160] text-white hover:bg-[#02a650] transition-colors disabled:opacity-60 disabled:cursor-not-allowed; + } + /* 整合搜索框样式 */ .search-input-combined { @apply flex items-center bg-white border-2 border-gray-200 rounded-lg overflow-hidden transition-all duration-200; @@ -923,4 +1376,1537 @@ transform: translateX(0); opacity: 1; } + + .chat-page-shell, + .chat-page-main, + .conversation-pane, + .message-list { + background-color: var(--chat-page-bg); + } + + .session-list-panel { + background-color: var(--session-list-bg); + border-color: var(--session-list-border); + } + + .session-list-search { + background-color: var(--session-list-search-bg); + border-color: var(--session-list-border); + } + + .session-list-scroll { + background-color: var(--session-list-bg); + } + + .session-list-status { + color: var(--session-list-meta); + } + + .session-list-item { + background-color: var(--session-list-item-bg); + border-bottom: 1px solid var(--session-list-item-border); + } + + .session-list-item:hover { + background-color: var(--session-list-item-hover); + } + + .session-list-item--selected { + background-color: var(--session-list-item-selected); + } + + .session-list-item--selected:hover { + background-color: var(--session-list-item-selected-hover); + } + + .session-list-item--top { + background-color: var(--session-list-item-top-bg); + } + + .session-list-item--top:hover { + background-color: var(--session-list-item-top-hover); + } + + .session-list-item--top.session-list-item--selected { + background-color: var(--session-list-item-top-selected); + } + + .session-list-item--top.session-list-item--selected:hover { + background-color: var(--session-list-item-top-selected-hover); + } + + .session-list-item-name { + color: var(--session-list-name); + font-weight: 400; + font-synthesis: none; + } + + .session-list-item-time { + color: var(--session-list-meta); + font-weight: 400; + font-synthesis: none; + } + + .session-list-item-preview { + color: var(--session-list-preview); + font-weight: 400; + font-synthesis: none; + } + + .contact-search-wrapper { + background-color: var(--contact-search-bg); + } + + .contact-search-wrapper:focus-within { + background-color: var(--contact-search-focus-bg); + box-shadow: 0 0 0 2px var(--contact-search-focus-ring); + } + + .contact-search-icon { + color: var(--contact-search-icon); + } + + .contact-search-input { + color: var(--contact-search-text); + } + + .contact-search-input::placeholder { + color: var(--contact-search-placeholder); + } + + .contact-search-clear { + color: var(--contact-search-clear); + } + + .contact-search-clear:hover { + color: var(--contact-search-text); + } + + .account-select { + background-color: var(--account-select-bg); + border-color: var(--search-input-border); + color: var(--contact-search-text); + } + + .chat-header { + background-color: var(--chat-header-bg); + border-bottom-color: var(--chat-header-border); + } + + .chat-header-title { + color: var(--chat-header-title); + } + + .header-btn-icon { + color: var(--chat-header-icon); + } + + .header-btn-icon:hover { + color: var(--chat-header-icon-hover); + } + + .message-filter-select { + color: var(--message-filter-color); + } + + .conversation-empty-title { + color: var(--chat-header-title); + } + + .conversation-empty-text, + .message-list-status { + color: var(--message-list-status); + } + + .message-list-load-more { + background-color: var(--message-load-more-bg); + border-color: var(--message-load-more-border); + color: var(--message-load-more-text); + } + + .message-list-load-more:hover { + background-color: var(--message-load-more-bg); + filter: brightness(1.06); + } + + .jump-to-bottom-btn { + background-color: var(--jump-to-bottom-bg); + border-color: var(--jump-to-bottom-border); + color: var(--jump-to-bottom-text); + } + + .jump-to-bottom-btn:hover { + filter: brightness(1.06); + } + + .message-time-divider { + color: var(--chat-date-text); + } + + .message-sender-name { + color: var(--chat-sender-name); + } + + .search-sidebar, + .time-sidebar { + background-color: var(--search-panel-bg); + border-left-color: var(--search-panel-border); + color: var(--search-panel-text); + } + + .search-sidebar-header, + .time-sidebar-header { + background-color: var(--search-panel-header-bg); + border-bottom-color: var(--search-panel-border); + } + + .search-sidebar-title, + .time-sidebar-title, + .calendar-month-label, + .sidebar-result-contact, + .sidebar-result-content, + .sidebar-result-sender, + .sidebar-empty-text, + .sidebar-initial-text { + color: var(--search-panel-text); + } + + .search-sidebar-close, + .time-sidebar-close, + .calendar-nav-btn, + .sidebar-clear-btn, + .search-clear-inline { + color: var(--search-panel-muted); + } + + .search-sidebar-close:hover, + .time-sidebar-close:hover, + .calendar-nav-btn:hover:not(:disabled), + .sidebar-clear-btn:hover, + .search-clear-inline:hover { + color: var(--search-panel-text); + background-color: var(--search-panel-soft); + } + + .search-sidebar-input-section, + .search-sidebar-history, + .search-sidebar-status, + .search-sidebar-scope, + .search-sidebar-filters, + .search-sidebar-advanced, + .time-sidebar-status { + border-color: var(--search-panel-border); + } + + .search-sidebar-results, + .time-sidebar-body { + background-color: var(--search-panel-bg); + } + + .chat-overlay-dropdown, + .chat-context-menu, + .chat-floating-window, + .chat-edit-modal { + background-color: var(--app-surface-bg); + border: 1px solid var(--app-border); + color: var(--app-text-primary); + box-shadow: 0 20px 48px rgba(15, 23, 42, 0.16); + } + + html[data-theme='dark'] :is(.chat-overlay-dropdown, .chat-context-menu, .chat-floating-window, .chat-edit-modal) { + box-shadow: 0 24px 56px rgba(0, 0, 0, 0.42); + } + + .chat-overlay-dropdown input { + background-color: var(--app-input-bg); + border-color: var(--app-input-border); + color: var(--app-text-primary); + } + + .chat-overlay-dropdown input::placeholder { + color: var(--app-text-muted); + } + + .chat-overlay-dropdown :is(.bg-gray-200, .bg-gray-300), + .chat-floating-window :is(.bg-gray-200, .bg-gray-300) { + background-color: var(--app-border-soft); + } + + .chat-overlay-dropdown :is(.border-gray-100, .border-gray-200, .border-gray-300), + .chat-floating-window :is(.border-gray-100, .border-gray-200, .border-gray-300) { + border-color: var(--app-border); + } + + .chat-overlay-dropdown :is(.text-gray-900, .text-gray-800, .text-gray-700), + .chat-floating-window :is(.text-gray-900, .text-gray-800, .text-gray-700) { + color: var(--app-text-primary); + } + + .chat-overlay-dropdown :is(.text-gray-600, .text-gray-500, .text-gray-400), + .chat-floating-window :is(.text-gray-600, .text-gray-500, .text-gray-400) { + color: var(--app-text-muted); + } + + .chat-overlay-option { + color: var(--app-text-primary); + transition: background-color 0.15s ease; + } + + .chat-overlay-option:hover { + background-color: var(--app-neutral-btn-hover); + } + + .chat-overlay-option--active { + background-color: var(--app-surface-soft); + } + + .chat-overlay-option--active:hover { + background-color: var(--app-neutral-btn-hover); + } + + .chat-context-menu__item { + color: inherit; + transition: background-color 0.15s ease; + } + + .chat-context-menu__item:hover { + background-color: var(--app-neutral-btn-hover); + } + + .chat-context-menu :is(.border-gray-100, .border-gray-200, .border-gray-300) { + border-color: var(--app-border); + } + + .chat-floating-window__header, + .chat-floating-window__body, + .chat-floating-window__row { + background-color: var(--app-surface-soft); + } + + .chat-floating-window__header { + border-bottom: 1px solid var(--app-border); + } + + .chat-floating-window__row { + border-bottom: 1px solid var(--app-border); + } + + .chat-floating-window__title { + color: var(--app-text-primary); + } + + .chat-floating-window__close { + color: var(--app-text-secondary); + transition: background-color 0.15s ease, color 0.15s ease; + } + + .chat-floating-window__close:hover { + background-color: var(--app-neutral-btn-hover); + color: var(--app-text-primary); + } + + .chat-floating-window .bg-white { + background-color: var(--app-surface-bg); + } + + .chat-floating-window .bg-gray-50 { + background-color: var(--app-surface-soft); + } + + .chat-floating-window [class*='hover:bg-gray-50']:hover { + background-color: var(--app-neutral-btn-hover); + } + + .chat-edit-modal :is(.border-gray-100, .border-gray-200, .border-gray-300) { + border-color: var(--app-border); + } + + .chat-edit-modal .bg-white { + background-color: var(--app-surface-bg); + } + + .chat-edit-modal .bg-gray-50 { + background-color: var(--app-surface-soft); + } + + .chat-edit-modal :is(.text-gray-900, .text-gray-800, .text-gray-700) { + color: var(--app-text-primary); + } + + .chat-edit-modal :is(.text-gray-600, .text-gray-500, .text-gray-400) { + color: var(--app-text-muted); + } + + .chat-edit-modal :is(input:not([type='checkbox']):not([type='radio']), textarea, select) { + background-color: var(--app-input-bg); + border-color: var(--app-input-border); + color: var(--app-text-primary); + } + + .chat-edit-modal :is(input:not([type='checkbox']):not([type='radio']), textarea, select)::placeholder { + color: var(--app-text-muted); + } + + .chat-edit-modal [class*='hover:bg-gray-50']:hover { + background-color: var(--app-neutral-btn-hover); + } + + .search-input-combined { + background-color: var(--search-input-bg); + border-color: var(--search-input-border); + } + + .search-input-combined:hover { + border-color: var(--search-input-border); + filter: brightness(1.04); + } + + .search-scope-inline { + background-color: var(--search-panel-soft); + border-right-color: var(--search-panel-border); + } + + .scope-inline-btn, + .scope-inline-divider, + .sidebar-section-title, + .sidebar-status-info, + .sidebar-status-detail, + .calendar-weekday, + .time-sidebar-status, + .sidebar-empty-hint, + .sidebar-initial-hint, + .sidebar-result-time, + .sidebar-result-header { + color: var(--search-panel-muted); + } + + .search-input-inline, + .search-filter-select, + .search-filter-input, + .search-date-input, + .sidebar-filter-select, + .calendar-ym-select { + color: var(--search-panel-text); + } + + .search-input-inline::placeholder, + .search-filter-input::placeholder { + color: var(--search-panel-muted); + } + + .search-filter-select, + .search-date-input, + .sidebar-filter-select, + .calendar-ym-select, + .search-session-type-btn, + .sidebar-history-item, + .sidebar-index-btn, + .sidebar-load-more-btn, + .sidebar-initial-hint kbd { + background-color: var(--search-panel-soft); + border-color: var(--search-input-border); + color: var(--search-panel-text); + } + + .search-session-type-btn:hover, + .sidebar-history-item:hover, + .sidebar-index-btn:hover, + .sidebar-load-more-btn:hover { + background-color: var(--search-item-hover); + } + + .sidebar-result-card:hover { + background-color: var(--search-item-hover); + } + + .sidebar-result-card-selected { + background-color: var(--search-result-selected); + } + + .sidebar-result-card-selected:hover { + background-color: var(--search-result-selected-hover); + } + + .calendar-day { + background-color: var(--calendar-day-bg); + border-color: var(--calendar-day-border); + color: var(--calendar-day-text); + } + + .calendar-day-empty { + background-color: var(--calendar-day-empty-bg); + border-color: var(--calendar-day-empty-border); + color: var(--calendar-day-empty-text); + } + + .calendar-day-outside { + background-color: transparent; + border-color: transparent; + } + + .chat-history-modal-panel, + .chat-history-modal-body, + .chat-history-modal-header, + .chat-history-modal-row { + background-color: var(--chat-page-bg); + } + + .chat-history-modal-header, + .chat-history-modal-row { + border-color: var(--chat-header-border); + } + + .chat-history-modal-title, + .chat-history-modal-icon { + color: var(--chat-header-title); + } + + .chat-history-modal-icon-btn:hover { + background-color: var(--search-panel-soft); + } + + .chat-history-modal-empty, + .chat-history-modal-sender, + .chat-history-modal-time { + color: var(--message-list-status); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) { + color: var(--app-text-primary); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page + ) { + background-color: var(--app-shell-bg); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) .bg-white { + background-color: var(--app-surface-bg); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="bg-white/"] { + background-color: var(--app-surface-overlay); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) .bg-gray-50, + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="bg-[#f9f9f9]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="bg-[#fafafa]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="bg-[#fcfcfc]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="bg-[#F7F7F7]"] { + background-color: var(--app-surface-soft); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) .bg-gray-100, + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="bg-[#EDEDED]"] { + background-color: var(--app-surface-muted); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) .bg-gray-200 { + background-color: var(--app-border-soft); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) .bg-gray-300 { + background-color: #4a4a4a; + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) :is(.border-gray-100, .border-gray-200, .border-gray-300) { + border-color: var(--app-border); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="border-[#EDEDED]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="border-[#e2e2e2]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="border-[#e7e7e7]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="border-[#eeeeee]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="border-[#ededed]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="border-[#efefef]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="border-[#ececec]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="border-[#F3F3F3]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="border-[#eee]"] { + border-color: var(--app-border); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) .divide-y > :not([hidden]) ~ :not([hidden]) { + border-color: var(--app-border-soft); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) :is(.text-gray-900, .text-gray-800, .text-gray-700) { + color: var(--app-text-primary); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) :is(.text-gray-600, .text-gray-500) { + color: var(--app-text-secondary); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) :is(.text-gray-400, .text-gray-300) { + color: var(--app-text-muted); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#000000e6]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#1f1f1f]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#222]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#111]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#333]"] { + color: var(--app-text-primary); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#444]"] { + color: var(--app-text-secondary); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#7F7F7F]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#666]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#777]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#5f5f5f]"] { + color: var(--app-text-secondary); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#909090]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#999]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#888]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#8a8a8a]"], + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="text-[#9e9e9e]"] { + color: var(--app-text-muted); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) :is(input:not([type='checkbox']):not([type='radio']), textarea, select) { + background-color: var(--app-input-bg); + border-color: var(--app-input-border); + color: var(--app-text-primary); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) :is(input:not([type='checkbox']):not([type='radio']), textarea, select)::placeholder { + color: var(--app-text-muted); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) :is(code, pre) { + background-color: var(--app-surface-muted); + color: var(--app-text-primary); + border-color: var(--app-border); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="hover:bg-gray-50"]:hover, + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="hover:bg-gray-100"]:hover, + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="hover:bg-[#f0f0f0]"]:hover, + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="hover:bg-[#f2f2f2]"]:hover, + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="hover:bg-[#f7f7f7]"]:hover, + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="hover:bg-[#F7F7F7]"]:hover, + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) [class*="hover:bg-[#EFEFEF]"]:hover { + background-color: var(--app-neutral-btn-hover); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) .bg-amber-50 { + background-color: rgba(242, 170, 0, 0.14); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) :is(.border-amber-200, .border-amber-300) { + border-color: rgba(242, 170, 0, 0.28); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) :is(.text-amber-900, .text-amber-800, .text-amber-700) { + color: #f7d27a; + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) .bg-red-50 { + background-color: rgba(250, 81, 81, 0.14); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) :is(.border-red-100, .border-red-200, .border-red-300) { + border-color: rgba(250, 81, 81, 0.28); + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) :is(.text-red-700, .text-red-600, .text-red-500) { + color: #ff8a8a; + } + + html[data-theme='dark'] .api-status-banner { + background-color: rgba(250, 81, 81, 0.14); + border-color: rgba(250, 81, 81, 0.28); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.32); + } + + html[data-theme='dark'] .api-status-banner :is(.text-red-800, .text-red-700, .text-red-600) { + color: #ff9b9b; + } + + html[data-theme='dark'] :is( + .landing-page, + .decrypt-page, + .decrypt-result-page, + .detection-result-page, + .contacts-page, + .sns-page, + .edits-page, + .settings-dialog-panel, + .desktop-update-dialog-panel, + .account-info-dialog-panel + ) .text-green-600 { + color: #7cd8a3; + } + + html[data-theme='dark'] .contacts-page .bg-blue-100 { + background-color: rgba(96, 122, 255, 0.18); + color: #bfd0ff; + } + + html[data-theme='dark'] .contacts-page .bg-green-100 { + background-color: rgba(62, 181, 117, 0.18); + color: #8edcaf; + } + + html[data-theme='dark'] .contacts-page .bg-orange-100 { + background-color: rgba(242, 170, 0, 0.18); + color: #f7d27a; + } + + html[data-theme='dark'] .sns-page [class*="bg-[#F7F7F7]"], + html[data-theme='dark'] .sns-page [class*="bg-[#EFEFEF]"] { + background-color: var(--app-surface-soft); + } + + html[data-theme='dark'] .sns-page [class*="hover:bg-[#EFEFEF]"]:hover { + background-color: var(--app-neutral-btn-hover); + } + + html[data-theme='dark'] .detection-result-page [class*="bg-[#07C160]/5"] { + background-color: rgba(62, 181, 117, 0.14); + } + + html[data-theme='dark'] .detection-result-page [class*="bg-[#07C160]/5"]:hover { + background-color: rgba(62, 181, 117, 0.18); + } + + html[data-theme='dark'] .settings-dialog-panel [class*="bg-[#e7f5ee]"] { + background-color: rgba(62, 181, 117, 0.16); + } + + html[data-theme='dark'] .settings-dialog-panel [class*="ring-[#e5e5e5]"] { + --tw-ring-color: var(--app-border); + } + + html[data-theme='dark'] .settings-dialog-panel [class*="bg-[#d0d0d0]"] { + background-color: #4a4a4a; + } + + html[data-theme='dark'] .settings-dialog .settings-dialog-panel { + border-width: 0 !important; + border-color: transparent !important; + outline: none !important; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.46) !important; + } + + html[data-theme='dark'] .contacts-export-panel { + background-color: var(--app-surface-bg); + border-color: var(--app-border); + box-shadow: 0 18px 36px rgba(0, 0, 0, 0.2); + } + + html[data-theme='dark'] .chat-export-modal { + background-color: var(--app-surface-bg); + border-color: var(--app-border); + color: var(--app-text-primary); + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.42); + } + + html[data-theme='dark'] .chat-export-modal .bg-white { + background-color: var(--app-surface-bg); + } + + html[data-theme='dark'] .chat-export-modal .bg-gray-50 { + background-color: var(--app-surface-soft); + } + + html[data-theme='dark'] .chat-export-modal :is(.border-gray-100, .border-gray-200) { + border-color: var(--app-border); + } + + html[data-theme='dark'] .chat-export-modal :is(.text-gray-900, .text-gray-800, .text-gray-700) { + color: var(--app-text-primary); + } + + html[data-theme='dark'] .chat-export-modal :is(.text-gray-600, .text-gray-500, .text-gray-400) { + color: var(--app-text-muted); + } + + html[data-theme='dark'] .chat-export-modal .bg-amber-50 { + background-color: rgba(242, 170, 0, 0.14); + } + + html[data-theme='dark'] .chat-export-modal .border-amber-200 { + border-color: rgba(242, 170, 0, 0.28); + } + + html[data-theme='dark'] .chat-export-modal .text-amber-800 { + color: #f7d27a; + } + + html[data-theme='dark'] .chat-export-modal :is(.text-green-600, .text-green-500) { + color: #7cd8a3; + } + + html[data-theme='dark'] .chat-export-modal :is(.text-red-600, .text-red-500) { + color: #ff8a8a; + } + + html[data-theme='dark'] .chat-export-modal :is(input:not([type='checkbox']):not([type='radio']), textarea, select) { + background-color: var(--app-input-bg); + border-color: var(--app-input-border); + color: var(--app-text-primary); + } + + html[data-theme='dark'] .chat-export-modal :is(input:not([type='checkbox']):not([type='radio']), textarea, select)::placeholder { + color: var(--app-text-muted); + } + + html[data-theme='dark'] .chat-export-modal [class*="hover:bg-gray-50"]:hover { + background-color: var(--app-neutral-btn-hover); + } + + html[data-theme='dark'] .chat-export-modal [class*="bg-[#03C160]/5"] { + background-color: rgba(62, 181, 117, 0.14); + } + + html[data-theme='dark'] .chat-export-modal [class*="hover:bg-[#03C160]/10"]:hover { + background-color: rgba(62, 181, 117, 0.18); + } + + html[data-theme='dark'] .settings-dialog .scrollbar-custom::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.18); + } + + html[data-theme='dark'] .settings-dialog .scrollbar-custom::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.28); + } + + html[data-theme='dark'] .edits-page .edits-sidebar { + background-color: var(--app-surface-soft); + border-color: var(--app-border-subtle); + } + + html[data-theme='dark'] .edits-page .edits-item { + border-top-color: var(--app-border-subtle); + } + + html[data-theme='dark'] .edits-page .edits-item:last-child { + border-bottom-color: var(--app-border-subtle); + } + + html[data-theme='dark'] .edits-page .edits-divider-arrow { + background: var(--app-surface-muted); + color: var(--app-accent); + } + + html[data-theme='dark'] .edits-page .edits-divider-arrow:hover:not(:disabled) { + background: var(--app-neutral-btn-hover); + color: var(--app-accent); + } + + html[data-theme='dark'] .edits-page [class*="bg-[#DEDEDE]"] { + background-color: var(--app-list-active); + } + + html[data-theme='dark'] .edits-page [class*="hover:bg-[#d3d3d3]"]:hover { + background-color: var(--app-list-active-hover); + } + + html[data-theme='dark'] .edits-page [class*="hover:bg-[#eaeaea]"]:hover { + background-color: var(--app-list-hover); + } + + html[data-theme='dark'] .edits-page .edits-diff-pane::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.18); + } + + html[data-theme='dark'] .edits-page .edits-diff-pane::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.28); + } + + html[data-theme='dark'] .edits-dialog-card { + background: var(--app-surface-bg); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); + } + + html[data-theme='dark'] .edits-dialog-title { + color: var(--app-text-primary); + } + + html[data-theme='dark'] .edits-dialog-msg, + html[data-theme='dark'] .edits-dialog-cancel { + color: var(--app-text-secondary); + } + + html[data-theme='dark'] .edits-dialog-actions, + html[data-theme='dark'] .edits-dialog-cancel { + border-color: var(--app-border); + } + + html[data-theme='dark'] .edits-dialog-btn:active { + background: var(--app-neutral-btn-hover); + } +} + +/* Wrapped 动画关键帧 */ + +@keyframes noise-jitter { + 0% { + transform: translate(0, 0); + } + 33% { + transform: translate(-1px, 1px); + } + 66% { + transform: translate(1px, -1px); + } + 100% { + transform: translate(0, 0); + } +} + +/* Wrapped 入场动画 */ +@keyframes wrapped-fade-in { + 0% { + opacity: 0; + transform: translateY(20px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.wrapped-animate-in { + animation: wrapped-fade-in 0.6s ease-out forwards; } diff --git a/frontend/assets/images/wechat/mini-program.svg b/frontend/assets/images/wechat/mini-program.svg new file mode 100644 index 0000000..e24435f --- /dev/null +++ b/frontend/assets/images/wechat/mini-program.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/assets/images/wechat/wechat-trans-icon2.png b/frontend/assets/images/wechat/wechat-trans-icon2.png index b9f72da..6d500a2 100644 Binary files a/frontend/assets/images/wechat/wechat-trans-icon2.png and b/frontend/assets/images/wechat/wechat-trans-icon2.png differ diff --git a/frontend/components/ApiStatus.vue b/frontend/components/ApiStatus.vue index d866a1e..ae03fe4 100644 --- a/frontend/components/ApiStatus.vue +++ b/frontend/components/ApiStatus.vue @@ -1,6 +1,7 @@