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..bae913b 100644 --- a/README.md +++ b/README.md @@ -4,50 +4,74 @@

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

-

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

-

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

+

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

+

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

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 +85,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 +154,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 +192,44 @@ npm run dist 3. **数据隐私**: 解密后的数据包含个人隐私信息,请谨慎处理 4. **合法使用**: 请遵守相关法律法规,不得用于非法目的 +## 修改消息 + +支持在聊天页对单条消息进行本地修改(如修改消息文本/字段、修复为我发送、反转本地气泡方向),并在“修改记录”页查看原始与当前对比,支持单条恢复或按会话一键恢复。 + +该功能只修改本机本地数据库(`db_storage` 与解密副本),不会调用远端回写接口。 + +

+ 本地消息修改 +

+ ## 致谢 本项目的开发过程中参考了以下优秀的开源项目和资源: -### 主要参考项目 - 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 +238,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..a88b255 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: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/installer-custom.nsh b/desktop/scripts/installer-custom.nsh index 6dd09fb..33e7240 100644 --- a/desktop/scripts/installer-custom.nsh +++ b/desktop/scripts/installer-custom.nsh @@ -14,6 +14,34 @@ Var WDA_InstallDirPage +!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 + ; Provide a safe, non-junction way for users to locate the real per-user output directory. + ; The actual data is NOT stored inside $INSTDIR (it is wiped on update/reinstall). + ; `open-output.cmd` uses %APPDATA% so it works for the current user. + FileOpen $0 "$INSTDIR\output-location.txt" w + FileWrite $0 "WeChatDataAnalysis output folder (per user):$\r$\n%APPDATA%\\${APP_PACKAGE_NAME}\\output$\r$\n" + FileClose $0 + + FileOpen $1 "$INSTDIR\open-output.cmd" w + ; NSIS escaping: use $\" to output a literal quote character into the .cmd file. + FileWrite $1 "@echo off$\r$\nexplorer $\"%APPDATA%\\${APP_PACKAGE_NAME}\\output$\"$\r$\n" + FileClose $1 +!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). @@ -90,6 +118,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 diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs index a2321bc..ba96a27 100644 --- a/desktop/src/main.cjs +++ b/desktop/src/main.cjs @@ -8,27 +8,183 @@ const { dialog, shell, } = 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 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; + +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 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; @@ -69,7 +225,11 @@ 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(); @@ -77,26 +237,56 @@ function ensureOutputLink() { if (!exeDir || !dataDir) return; const target = path.join(dataDir, "output"); - const linkPath = path.join(exeDir, "output"); + 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, "open-output.cmd"); + const text = `@echo off\r\nexplorer \"${target}\"\r\n`; + fs.writeFileSync(p, text, { encoding: "utf8" }); + } catch {} } function getMainLogPath() { @@ -127,6 +317,10 @@ function loadDesktopSettings() { // '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, }; const p = getDesktopSettingsPath(); @@ -143,6 +337,7 @@ function loadDesktopSettings() { 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; } catch (err) { desktopSettings = { ...defaults }; logMain(`[main] failed to load settings: ${err?.message || err}`); @@ -177,6 +372,336 @@ function setCloseBehavior(next) { 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; +} + +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 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; + }; + + 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 { + 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), + }; + } + + return { hasUpdate: false, enabled: true }; + } catch (err) { + const message = err?.message || String(err); + logMain(`[main] checkForUpdates failed: ${message}`); + return { hasUpdate: false, enabled: true, error: message }; + } +} + +async function downloadAndInstallInternal() { + if (!isAutoUpdateEnabled()) { + throw new Error("自动更新已禁用"); + } + initAutoUpdater(); + + if (updateDownloadInProgress) { + throw new Error("正在下载更新中,请稍候…"); + } + + updateDownloadInProgress = true; + installOnDownload = true; + updateDownloaded = false; + setWindowProgressBar(0); + + try { + // Ensure update info is up-to-date (downloadUpdate relies on the last check). + await autoUpdater.checkForUpdates(); + await autoUpdater.downloadUpdate(); + return { success: true }; + } catch (err) { + updateDownloadInProgress = false; + installOnDownload = false; + setWindowProgressBar(-1); + throw err; + } +} + +function checkForUpdatesOnStartup() { + if (!isAutoUpdateEnabled()) return; + if (!app.isPackaged) return; // keep dev noise-free by default + + 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() { // Prefer an icon shipped in `src/` so it works both in dev and packaged (asar) builds. const shipped = path.join(__dirname, "icon.ico"); @@ -238,6 +763,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 +892,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 +915,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 +931,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", }; @@ -351,8 +965,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 +990,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 +1004,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 +1081,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)); @@ -611,6 +1293,152 @@ 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:getOutputDir", () => { + const dir = resolveDataDir(); + if (!dir) return ""; + return path.join(dir, "output"); + }); + + ipcMain.handle("app:openOutputDir", async () => { + const dir = resolveDataDir(); + if (!dir) throw new Error("无法定位数据目录"); + const outDir = path.join(dir, "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: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() { @@ -619,15 +1447,41 @@ async function main() { 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(); 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 +1489,13 @@ 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"); 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 +1521,22 @@ 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(); + 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(); + }); +} diff --git a/desktop/src/preload.cjs b/desktop/src/preload.cjs index 1f1eea2..938feb4 100644 --- a/desktop/src/preload.cjs +++ b/desktop/src/preload.cjs @@ -1,6 +1,8 @@ const { contextBridge, ipcRenderer } = require("electron"); 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"), @@ -11,4 +13,40 @@ contextBridge.exposeInMainWorld("wechatDesktop", { 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 + getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"), + openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"), + + // 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/frontend/app.vue b/frontend/app.vue index 75ca905..ea71c78 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -1,39 +1,114 @@ diff --git a/frontend/assets/css/tailwind.css b/frontend/assets/css/tailwind.css index 6fb94bf..dc80cee 100644 --- a/frontend/assets/css/tailwind.css +++ b/frontend/assets/css/tailwind.css @@ -10,32 +10,32 @@ --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; @@ -80,6 +80,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 +160,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 +649,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 +705,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; @@ -924,3 +1142,36 @@ opacity: 1; } } + +/* 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..5b68826 100644 --- a/frontend/components/ApiStatus.vue +++ b/frontend/components/ApiStatus.vue @@ -8,7 +8,7 @@

API连接问题

{{ appStore.apiMessage || '无法连接到后端服务' }}

-

请确保后端服务正在运行 (端口: 8000)

+

请确保后端服务正在运行

@@ -18,4 +18,4 @@ import { useAppStore } from '~/stores/app' const appStore = useAppStore() - \ No newline at end of file + diff --git a/frontend/components/ChatLocationCard.vue b/frontend/components/ChatLocationCard.vue new file mode 100644 index 0000000..c39c81c --- /dev/null +++ b/frontend/components/ChatLocationCard.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/frontend/components/DesktopUpdateDialog.vue b/frontend/components/DesktopUpdateDialog.vue new file mode 100644 index 0000000..72a5050 --- /dev/null +++ b/frontend/components/DesktopUpdateDialog.vue @@ -0,0 +1,255 @@ + + + diff --git a/frontend/components/EditedMessagePreview.vue b/frontend/components/EditedMessagePreview.vue new file mode 100644 index 0000000..0e8160e --- /dev/null +++ b/frontend/components/EditedMessagePreview.vue @@ -0,0 +1,138 @@ + + + diff --git a/frontend/components/LivePhotoIcon.vue b/frontend/components/LivePhotoIcon.vue new file mode 100644 index 0000000..79ae903 --- /dev/null +++ b/frontend/components/LivePhotoIcon.vue @@ -0,0 +1,29 @@ + + + + diff --git a/frontend/components/SettingsDialog.vue b/frontend/components/SettingsDialog.vue new file mode 100644 index 0000000..7d13c2d --- /dev/null +++ b/frontend/components/SettingsDialog.vue @@ -0,0 +1,674 @@ + + + + + diff --git a/frontend/components/SidebarRail.vue b/frontend/components/SidebarRail.vue new file mode 100644 index 0000000..7612e7b --- /dev/null +++ b/frontend/components/SidebarRail.vue @@ -0,0 +1,259 @@ + + + diff --git a/frontend/components/wrapped/cards/Card00GlobalOverview.vue b/frontend/components/wrapped/cards/Card00GlobalOverview.vue new file mode 100644 index 0000000..d9c483b --- /dev/null +++ b/frontend/components/wrapped/cards/Card00GlobalOverview.vue @@ -0,0 +1,192 @@ + + + diff --git a/frontend/components/wrapped/cards/Card01CyberSchedule.vue b/frontend/components/wrapped/cards/Card01CyberSchedule.vue new file mode 100644 index 0000000..7bdf2e0 --- /dev/null +++ b/frontend/components/wrapped/cards/Card01CyberSchedule.vue @@ -0,0 +1,346 @@ + + + diff --git a/frontend/components/wrapped/cards/Card02MessageChars.vue b/frontend/components/wrapped/cards/Card02MessageChars.vue new file mode 100644 index 0000000..bb5fd0a --- /dev/null +++ b/frontend/components/wrapped/cards/Card02MessageChars.vue @@ -0,0 +1,42 @@ + + + + diff --git a/frontend/components/wrapped/cards/Card03ReplySpeed.vue b/frontend/components/wrapped/cards/Card03ReplySpeed.vue new file mode 100644 index 0000000..4a06a62 --- /dev/null +++ b/frontend/components/wrapped/cards/Card03ReplySpeed.vue @@ -0,0 +1,873 @@ + + + + + diff --git a/frontend/components/wrapped/cards/Card04EmojiUniverse.vue b/frontend/components/wrapped/cards/Card04EmojiUniverse.vue new file mode 100644 index 0000000..dc940a5 --- /dev/null +++ b/frontend/components/wrapped/cards/Card04EmojiUniverse.vue @@ -0,0 +1,795 @@ + + + + + diff --git a/frontend/components/wrapped/cards/Card04MonthlyBestFriendsWall.vue b/frontend/components/wrapped/cards/Card04MonthlyBestFriendsWall.vue new file mode 100644 index 0000000..e30a888 --- /dev/null +++ b/frontend/components/wrapped/cards/Card04MonthlyBestFriendsWall.vue @@ -0,0 +1,434 @@ + + + + + diff --git a/frontend/components/wrapped/cards/Card06KeywordsWordCloud.vue b/frontend/components/wrapped/cards/Card06KeywordsWordCloud.vue new file mode 100644 index 0000000..585c810 --- /dev/null +++ b/frontend/components/wrapped/cards/Card06KeywordsWordCloud.vue @@ -0,0 +1,861 @@ + + + + + diff --git a/frontend/components/wrapped/cards/Card07BentoSummary.vue b/frontend/components/wrapped/cards/Card07BentoSummary.vue new file mode 100644 index 0000000..d399942 --- /dev/null +++ b/frontend/components/wrapped/cards/Card07BentoSummary.vue @@ -0,0 +1,2794 @@ + + + + + diff --git a/frontend/components/wrapped/shared/BitsCardSwap.vue b/frontend/components/wrapped/shared/BitsCardSwap.vue new file mode 100644 index 0000000..768e4df --- /dev/null +++ b/frontend/components/wrapped/shared/BitsCardSwap.vue @@ -0,0 +1,291 @@ + + + diff --git a/frontend/components/wrapped/shared/BitsGridMotion.vue b/frontend/components/wrapped/shared/BitsGridMotion.vue new file mode 100644 index 0000000..5a09be2 --- /dev/null +++ b/frontend/components/wrapped/shared/BitsGridMotion.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/frontend/components/wrapped/shared/BitsSplitText.vue b/frontend/components/wrapped/shared/BitsSplitText.vue new file mode 100644 index 0000000..df2a139 --- /dev/null +++ b/frontend/components/wrapped/shared/BitsSplitText.vue @@ -0,0 +1,160 @@ + + + diff --git a/frontend/components/wrapped/shared/VueBitsImageTrail.vue b/frontend/components/wrapped/shared/VueBitsImageTrail.vue new file mode 100644 index 0000000..612b5cc --- /dev/null +++ b/frontend/components/wrapped/shared/VueBitsImageTrail.vue @@ -0,0 +1,1225 @@ + + + + + + diff --git a/frontend/components/wrapped/shared/VueBitsStack.vue b/frontend/components/wrapped/shared/VueBitsStack.vue new file mode 100644 index 0000000..c191c7e --- /dev/null +++ b/frontend/components/wrapped/shared/VueBitsStack.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/frontend/components/wrapped/shared/WrappedCardShell.vue b/frontend/components/wrapped/shared/WrappedCardShell.vue new file mode 100644 index 0000000..77274e7 --- /dev/null +++ b/frontend/components/wrapped/shared/WrappedCardShell.vue @@ -0,0 +1,62 @@ + + + diff --git a/frontend/components/wrapped/shared/WrappedControls.vue b/frontend/components/wrapped/shared/WrappedControls.vue new file mode 100644 index 0000000..9cdb4ad --- /dev/null +++ b/frontend/components/wrapped/shared/WrappedControls.vue @@ -0,0 +1,84 @@ + + + diff --git a/frontend/components/wrapped/shared/WrappedDeckBackground.vue b/frontend/components/wrapped/shared/WrappedDeckBackground.vue new file mode 100644 index 0000000..7e621c6 --- /dev/null +++ b/frontend/components/wrapped/shared/WrappedDeckBackground.vue @@ -0,0 +1,21 @@ + diff --git a/frontend/components/wrapped/shared/WrappedHero.vue b/frontend/components/wrapped/shared/WrappedHero.vue new file mode 100644 index 0000000..ae7bf94 --- /dev/null +++ b/frontend/components/wrapped/shared/WrappedHero.vue @@ -0,0 +1,403 @@ + + + + + diff --git a/frontend/components/wrapped/shared/WrappedYearSelector.vue b/frontend/components/wrapped/shared/WrappedYearSelector.vue new file mode 100644 index 0000000..f296e3b --- /dev/null +++ b/frontend/components/wrapped/shared/WrappedYearSelector.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/frontend/components/wrapped/visualizations/AnnualCalendarHeatmap.vue b/frontend/components/wrapped/visualizations/AnnualCalendarHeatmap.vue new file mode 100644 index 0000000..9450e81 --- /dev/null +++ b/frontend/components/wrapped/visualizations/AnnualCalendarHeatmap.vue @@ -0,0 +1,417 @@ + + + + + diff --git a/frontend/components/wrapped/visualizations/ChatReplayAnimation.vue b/frontend/components/wrapped/visualizations/ChatReplayAnimation.vue new file mode 100644 index 0000000..3ded96b --- /dev/null +++ b/frontend/components/wrapped/visualizations/ChatReplayAnimation.vue @@ -0,0 +1,311 @@ + + + + + diff --git a/frontend/components/wrapped/visualizations/GlobalOverviewChart.vue b/frontend/components/wrapped/visualizations/GlobalOverviewChart.vue new file mode 100644 index 0000000..cb0bdad --- /dev/null +++ b/frontend/components/wrapped/visualizations/GlobalOverviewChart.vue @@ -0,0 +1,44 @@ + + + diff --git a/frontend/components/wrapped/visualizations/KeywordWordCloud.vue b/frontend/components/wrapped/visualizations/KeywordWordCloud.vue new file mode 100644 index 0000000..0cacf85 --- /dev/null +++ b/frontend/components/wrapped/visualizations/KeywordWordCloud.vue @@ -0,0 +1,484 @@ + + + + + diff --git a/frontend/components/wrapped/visualizations/MessageCharsChart.vue b/frontend/components/wrapped/visualizations/MessageCharsChart.vue new file mode 100644 index 0000000..8a0c423 --- /dev/null +++ b/frontend/components/wrapped/visualizations/MessageCharsChart.vue @@ -0,0 +1,760 @@ + + + + + diff --git a/frontend/components/wrapped/visualizations/WeekdayHourHeatmap.vue b/frontend/components/wrapped/visualizations/WeekdayHourHeatmap.vue new file mode 100644 index 0000000..8f824c0 --- /dev/null +++ b/frontend/components/wrapped/visualizations/WeekdayHourHeatmap.vue @@ -0,0 +1,114 @@ + + + diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index 9c4aa57..416e5e8 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -1,13 +1,10 @@ // API请求组合式函数 export const useApi = () => { - const config = useRuntimeConfig() + const baseURL = useApiBase() // 基础请求函数 const request = async (url, options = {}) => { try { - // 在客户端使用完整的API路径 - const baseURL = process.client ? 'http://localhost:8000/api' : '/api' - const response = await $fetch(url, { baseURL, ...options, @@ -87,6 +84,75 @@ export const useApi = () => { return await request(url) } + const getChatMessageRaw = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.username) query.set('username', params.username) + if (params && params.message_id) query.set('message_id', params.message_id) + const url = '/chat/messages/raw' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + const editChatMessage = async (payload = {}) => { + return await request('/chat/messages/edit', { + method: 'POST', + body: payload + }) + } + + const repairChatMessageSender = async (payload = {}) => { + return await request('/chat/messages/repair_sender', { + method: 'POST', + body: payload + }) + } + + // Flip message direction in the WeChat client by swapping packed_info_data (unsafe, but undoable via reset). + const flipChatMessageDirection = async (payload = {}) => { + return await request('/chat/messages/flip_direction', { + method: 'POST', + body: payload + }) + } + + const listChatEditedSessions = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + const url = '/chat/edits/sessions' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + const listChatEditedMessages = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.username) query.set('username', params.username) + const url = '/chat/edits/messages' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + const getChatEditStatus = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.username) query.set('username', params.username) + if (params && params.message_id) query.set('message_id', params.message_id) + const url = '/chat/edits/message_status' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + const resetChatEditedMessage = async (payload = {}) => { + return await request('/chat/edits/reset_message', { + method: 'POST', + body: payload + }) + } + + const resetChatEditedSession = async (payload = {}) => { + return await request('/chat/edits/reset_session', { + method: 'POST', + body: payload + }) + } + const getChatRealtimeStatus = async (params = {}) => { const query = new URLSearchParams() if (params && params.account) query.set('account', params.account) @@ -99,6 +165,7 @@ export const useApi = () => { if (params && params.account) query.set('account', params.account) if (params && params.username) query.set('username', params.username) if (params && params.max_scan != null) query.set('max_scan', String(params.max_scan)) + if (params && params.backfill_limit != null) query.set('backfill_limit', String(params.backfill_limit)) const url = '/chat/realtime/sync' + (query.toString() ? `?${query.toString()}` : '') return await request(url, { method: 'POST' }) } @@ -179,6 +246,96 @@ export const useApi = () => { return await request(url) } + // 聊天记录日历热力图:某月每日消息数 + const getChatMessageDailyCounts = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.username) query.set('username', params.username) + if (params && params.year != null) query.set('year', String(params.year)) + if (params && params.month != null) query.set('month', String(params.month)) + const url = '/chat/messages/daily_counts' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + // 聊天记录定位锚点:某日第一条 / 会话最早一条 + const getChatMessageAnchor = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.username) query.set('username', params.username) + if (params && params.kind) query.set('kind', String(params.kind)) + if (params && params.date) query.set('date', String(params.date)) + const url = '/chat/messages/anchor' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + // 解析嵌套合并转发聊天记录(通过 server_id) + const resolveNestedChatHistory = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.server_id != null) query.set('server_id', String(params.server_id)) + const url = '/chat/chat_history/resolve' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + // 解析卡片/小程序等 App 消息(通过 server_id) + const resolveAppMsg = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.server_id != null) query.set('server_id', String(params.server_id)) + const url = '/chat/appmsg/resolve' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + // 朋友圈时间线 + const listSnsTimeline = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.limit != null) query.set('limit', String(params.limit)) + if (params && params.offset != null) query.set('offset', String(params.offset)) + if (params && params.usernames && Array.isArray(params.usernames) && params.usernames.length > 0) { + query.set('usernames', params.usernames.join(',')) + } else if (params && params.usernames && typeof params.usernames === 'string') { + query.set('usernames', params.usernames) + } + if (params && params.keyword) query.set('keyword', params.keyword) + const url = '/sns/timeline' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + // 朋友圈联系人列表(按发圈数统计) + const listSnsUsers = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.keyword) query.set('keyword', String(params.keyword)) + if (params && params.limit != null) query.set('limit', String(params.limit)) + const url = '/sns/users' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + // 朋友圈图片本地缓存候选(用于错图时手动选择) + const listSnsMediaCandidates = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.create_time != null) query.set('create_time', String(params.create_time)) + if (params && params.width != null) query.set('width', String(params.width)) + if (params && params.height != null) query.set('height', String(params.height)) + if (params && params.limit != null) query.set('limit', String(params.limit)) + if (params && params.offset != null) query.set('offset', String(params.offset)) + const url = '/sns/media_candidates' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + // 保存朋友圈图片手动匹配结果(本机) + const saveSnsMediaPicks = async (data = {}) => { + return await request('/sns/media_picks', { + method: 'POST', + body: { + account: data.account || null, + picks: (data && data.picks && typeof data.picks === 'object' && !Array.isArray(data.picks)) ? data.picks : {} + } + }) + } + const openChatMediaFolder = async (params = {}) => { const query = new URLSearchParams() if (params && params.account) query.set('account', params.account) @@ -251,7 +408,10 @@ export const useApi = () => { message_types: Array.isArray(data.message_types) ? data.message_types : [], include_media: data.include_media == null ? true : !!data.include_media, media_kinds: Array.isArray(data.media_kinds) ? data.media_kinds : ['image', 'emoji', 'video', 'video_thumb', 'voice', 'file'], + output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(), allow_process_key_extract: !!data.allow_process_key_extract, + download_remote_media: !!data.download_remote_media, + html_page_size: data.html_page_size != null ? Number(data.html_page_size) : 1000, privacy_mode: !!data.privacy_mode, file_name: data.file_name || null } @@ -271,7 +431,109 @@ export const useApi = () => { if (!exportId) throw new Error('Missing exportId') return await request(`/chat/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' }) } - + + // 朋友圈导出(离线 HTML zip) + const createSnsExport = async (data = {}) => { + return await request('/sns/exports', { + method: 'POST', + body: { + account: data.account || null, + scope: data.scope || 'selected', + usernames: Array.isArray(data.usernames) ? data.usernames : [], + use_cache: data.use_cache == null ? true : !!data.use_cache, + output_dir: data.output_dir == null ? null : String(data.output_dir || '').trim(), + file_name: data.file_name || null + } + }) + } + + const getSnsExport = async (exportId) => { + if (!exportId) throw new Error('Missing exportId') + return await request(`/sns/exports/${encodeURIComponent(String(exportId))}`) + } + + const cancelSnsExport = async (exportId) => { + if (!exportId) throw new Error('Missing exportId') + return await request(`/sns/exports/${encodeURIComponent(String(exportId))}`, { method: 'DELETE' }) + } + + // 联系人 + const listChatContacts = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.account) query.set('account', params.account) + if (params && params.keyword) query.set('keyword', params.keyword) + if (params && params.include_friends != null) query.set('include_friends', String(!!params.include_friends)) + if (params && params.include_groups != null) query.set('include_groups', String(!!params.include_groups)) + if (params && params.include_officials != null) query.set('include_officials', String(!!params.include_officials)) + const url = '/chat/contacts' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + const exportChatContacts = async (payload = {}) => { + return await request('/chat/contacts/export', { + method: 'POST', + body: { + account: payload.account || null, + output_dir: payload.output_dir || '', + format: payload.format || 'json', + include_avatar_link: payload.include_avatar_link == null ? true : !!payload.include_avatar_link, + keyword: payload.keyword || null, + contact_types: { + friends: payload?.contact_types?.friends == null ? true : !!payload.contact_types.friends, + groups: payload?.contact_types?.groups == null ? true : !!payload.contact_types.groups, + officials: payload?.contact_types?.officials == null ? true : !!payload.contact_types.officials, + } + } + }) + } + + // WeChat Wrapped(年度总结) + const getWrappedAnnual = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.year != null) query.set('year', String(params.year)) + if (params && params.account) query.set('account', String(params.account)) + if (params && params.refresh != null) query.set('refresh', String(!!params.refresh)) + const url = '/wrapped/annual' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + // WeChat Wrapped(年度总结)- 目录/元信息(轻量,用于按页懒加载) + const getWrappedAnnualMeta = async (params = {}) => { + const query = new URLSearchParams() + if (params && params.year != null) query.set('year', String(params.year)) + if (params && params.account) query.set('account', String(params.account)) + if (params && params.refresh != null) query.set('refresh', String(!!params.refresh)) + const url = '/wrapped/annual/meta' + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + // WeChat Wrapped(年度总结)- 单张卡片(按页加载) + const getWrappedAnnualCard = async (cardId, params = {}) => { + if (cardId == null) throw new Error('Missing cardId') + const query = new URLSearchParams() + if (params && params.year != null) query.set('year', String(params.year)) + if (params && params.account) query.set('account', String(params.account)) + if (params && params.refresh != null) query.set('refresh', String(!!params.refresh)) + const safeId = encodeURIComponent(String(cardId)) + const url = `/wrapped/annual/cards/${safeId}` + (query.toString() ? `?${query.toString()}` : '') + return await request(url) + } + + // 获取微信进程状态 + const getWxStatus = async () => { + return await request('/wechat/status') + } + + // 获取数据库密钥 + const getKeys = async () => { + return await request('/get_keys') + } + + // 获取图片密钥 + const getImageKey = async () => { + return await request('/get_image_key') + } + return { detectWechat, detectCurrentAccount, @@ -280,6 +542,15 @@ export const useApi = () => { listChatAccounts, listChatSessions, listChatMessages, + getChatMessageRaw, + editChatMessage, + repairChatMessageSender, + flipChatMessageDirection, + listChatEditedSessions, + listChatEditedMessages, + getChatEditStatus, + resetChatEditedMessage, + resetChatEditedSession, getChatRealtimeStatus, syncChatRealtimeMessages, syncChatRealtimeAll, @@ -288,6 +559,14 @@ export const useApi = () => { buildChatSearchIndex, listChatSearchSenders, getChatMessagesAround, + getChatMessageDailyCounts, + getChatMessageAnchor, + resolveNestedChatHistory, + resolveAppMsg, + listSnsTimeline, + listSnsUsers, + listSnsMediaCandidates, + saveSnsMediaPicks, openChatMediaFolder, downloadChatEmoji, saveMediaKeys, @@ -296,6 +575,17 @@ export const useApi = () => { createChatExport, getChatExport, listChatExports, - cancelChatExport + cancelChatExport, + createSnsExport, + getSnsExport, + cancelSnsExport, + listChatContacts, + exportChatContacts, + getWrappedAnnual, + getWrappedAnnualMeta, + getWrappedAnnualCard, + getKeys, + getImageKey, + getWxStatus, } } diff --git a/frontend/composables/useApiBase.js b/frontend/composables/useApiBase.js new file mode 100644 index 0000000..91c2b21 --- /dev/null +++ b/frontend/composables/useApiBase.js @@ -0,0 +1,14 @@ +import { normalizeApiBase, readApiBaseOverride } from '~/utils/api-settings' + +export const useApiBase = () => { + const config = useRuntimeConfig() + + // Default to same-origin `/api` so Nuxt devProxy / backend-mounted UI both work. + // Override priority: + // 1) Local UI setting (web + desktop) + // 2) NUXT_PUBLIC_API_BASE env/runtime config + // 3) `/api` + const override = process.client ? readApiBaseOverride() : '' + const runtime = String(config?.public?.apiBase || '').trim() + return normalizeApiBase(override || runtime || '/api') +} diff --git a/frontend/composables/useDesktopUpdate.js b/frontend/composables/useDesktopUpdate.js new file mode 100644 index 0000000..676682e --- /dev/null +++ b/frontend/composables/useDesktopUpdate.js @@ -0,0 +1,236 @@ +let listenersInitialized = false; +let removeListeners = []; + +const getDesktopApi = () => { + if (!process.client) return null; + if (typeof window === "undefined") return null; + return window?.wechatDesktop || null; +}; + +const isDesktopShell = () => !!getDesktopApi(); + +const isUpdaterSupported = () => { + const api = getDesktopApi(); + if (!api) return false; + + // If the bridge exposes a brand marker, ensure it's our Electron shell. + if (api.__brand && api.__brand !== "WeChatDataAnalysisDesktop") return false; + + // Require updater IPC to avoid showing update UI in the pure web build. + return ( + typeof api.getVersion === "function" && + typeof api.checkForUpdates === "function" && + typeof api.downloadAndInstall === "function" + ); +}; + +export const useDesktopUpdate = () => { + const info = useState("desktopUpdate.info", () => null); + const open = useState("desktopUpdate.open", () => false); + const isDownloading = useState("desktopUpdate.isDownloading", () => false); + const readyToInstall = useState("desktopUpdate.readyToInstall", () => false); + const progress = useState("desktopUpdate.progress", () => ({ percent: 0 })); + const error = useState("desktopUpdate.error", () => ""); + const currentVersion = useState("desktopUpdate.currentVersion", () => ""); + + const manualCheckLoading = useState("desktopUpdate.manualCheckLoading", () => false); + const lastCheckMessage = useState("desktopUpdate.lastCheckMessage", () => ""); + const lastCheckAt = useState("desktopUpdate.lastCheckAt", () => 0); + + const setUpdateInfo = (payload) => { + if (!payload) return; + const version = String(payload?.version || "").trim(); + const releaseNotes = String(payload?.releaseNotes || ""); + if (!version) return; + info.value = { version, releaseNotes }; + readyToInstall.value = false; + }; + + const dismiss = () => { + open.value = false; + }; + + const refreshVersion = async () => { + if (!isUpdaterSupported()) return ""; + try { + const v = await getDesktopApi()?.getVersion?.(); + currentVersion.value = String(v || ""); + return currentVersion.value; + } catch { + return currentVersion.value || ""; + } + }; + + const initListeners = async () => { + if (!isUpdaterSupported()) return; + if (listenersInitialized) return; + listenersInitialized = true; + + await refreshVersion(); + + const unsubs = []; + + const unUpdate = window.wechatDesktop?.onUpdateAvailable?.((payload) => { + error.value = ""; + isDownloading.value = false; + readyToInstall.value = false; + progress.value = { percent: 0 }; + setUpdateInfo(payload); + open.value = true; + }); + if (typeof unUpdate === "function") unsubs.push(unUpdate); + + const unProgress = window.wechatDesktop?.onDownloadProgress?.((p) => { + progress.value = p || { percent: 0 }; + const percent = Number(progress.value?.percent || 0); + if (Number.isFinite(percent) && percent > 0) { + isDownloading.value = true; + } + }); + if (typeof unProgress === "function") unsubs.push(unProgress); + + const unDownloaded = window.wechatDesktop?.onUpdateDownloaded?.((payload) => { + // Download finished. Keep the dialog open and let the user decide when to install. + setUpdateInfo(payload || info.value || {}); + isDownloading.value = false; + readyToInstall.value = true; + progress.value = { ...(progress.value || {}), percent: 100 }; + open.value = true; + }); + if (typeof unDownloaded === "function") unsubs.push(unDownloaded); + + const unError = window.wechatDesktop?.onUpdateError?.((payload) => { + const msg = String(payload?.message || ""); + if (msg) error.value = msg; + isDownloading.value = false; + readyToInstall.value = false; + }); + if (typeof unError === "function") unsubs.push(unError); + + removeListeners = unsubs; + }; + + const startUpdate = async () => { + if (!isUpdaterSupported()) return; + + error.value = ""; + isDownloading.value = true; + readyToInstall.value = false; + progress.value = { percent: 0 }; + + try { + await getDesktopApi()?.downloadAndInstall?.(); + } catch (e) { + const msg = e?.message || String(e); + error.value = msg; + isDownloading.value = false; + } + }; + + const installUpdate = async () => { + if (!isUpdaterSupported()) return; + if (!getDesktopApi()?.installUpdate) return; + + error.value = ""; + try { + await getDesktopApi()?.installUpdate?.(); + } catch (e) { + const msg = e?.message || String(e); + error.value = msg; + } + }; + + const ignore = async () => { + if (!isUpdaterSupported()) return; + const version = String(info.value?.version || "").trim(); + if (!version) return; + + try { + await getDesktopApi()?.ignoreUpdate?.(version); + } catch (e) { + const msg = e?.message || String(e); + error.value = msg; + } finally { + // Hide the dialog locally; startup auto-check will also respect the ignore. + open.value = false; + info.value = null; + } + }; + + const manualCheck = async () => { + if (!isDesktopShell()) { + lastCheckMessage.value = "仅桌面端可用。"; + return { hasUpdate: false }; + } + if (!isUpdaterSupported()) { + lastCheckMessage.value = "当前桌面端版本不支持自动更新。"; + return { hasUpdate: false }; + } + + manualCheckLoading.value = true; + error.value = ""; + lastCheckMessage.value = ""; + + try { + await refreshVersion(); + + const res = await getDesktopApi()?.checkForUpdates?.(); + lastCheckAt.value = Date.now(); + + if (res?.enabled === false) { + lastCheckMessage.value = "自动更新已禁用(仅打包版本可用)。"; + return res; + } + + if (res?.error) { + lastCheckMessage.value = `检查更新失败:${String(res.error)}`; + return res; + } + + if (res?.hasUpdate && res?.version) { + setUpdateInfo({ version: res.version, releaseNotes: res.releaseNotes || "" }); + open.value = true; + lastCheckMessage.value = `发现新版本:${String(res.version)}`; + return res; + } + + lastCheckMessage.value = "当前已是最新版本。"; + return res; + } catch (e) { + const msg = e?.message || String(e); + lastCheckMessage.value = `检查更新失败:${msg}`; + return { hasUpdate: false, error: msg }; + } finally { + manualCheckLoading.value = false; + } + }; + + const cleanup = () => { + try { + for (const fn of removeListeners) fn?.(); + } catch {} + removeListeners = []; + listenersInitialized = false; + }; + + return { + info, + open, + isDownloading, + readyToInstall, + progress, + error, + currentVersion, + manualCheckLoading, + lastCheckMessage, + lastCheckAt, + initListeners, + refreshVersion, + manualCheck, + startUpdate, + installUpdate, + ignore, + dismiss, + cleanup, + }; +}; diff --git a/frontend/composables/useSettingsDialog.js b/frontend/composables/useSettingsDialog.js new file mode 100644 index 0000000..6727c56 --- /dev/null +++ b/frontend/composables/useSettingsDialog.js @@ -0,0 +1,17 @@ +export const useSettingsDialog = () => { + const open = useState('settings-dialog-open', () => false) + + const openDialog = () => { + open.value = true + } + + const closeDialog = () => { + open.value = false + } + + return { + open, + openDialog, + closeDialog, + } +} diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 3e6f203..7a03351 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -1,7 +1,18 @@ // https://nuxt.com/docs/api/configuration/nuxt-config +const backendPort = String(process.env.WECHAT_TOOL_PORT || '10392').trim() || '10392' +const devProxyTarget = `http://127.0.0.1:${backendPort}/api` + export default defineNuxtConfig({ compatibilityDate: '2025-07-15', devtools: { enabled: false }, + + runtimeConfig: { + public: { + // Full API base, including `/api` when needed. + // Example: `NUXT_PUBLIC_API_BASE=http://127.0.0.1:10392/api` + apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api', + }, + }, // 配置前端开发服务器端口 devServer: { @@ -12,7 +23,9 @@ export default defineNuxtConfig({ nitro: { devProxy: { '/api': { - target: 'http://localhost:8000', + // `h3` strips the matched prefix (`/api`) before calling the middleware, + // so the proxy target must include `/api` to preserve backend routes. + target: devProxyTarget, changeOrigin: true } } @@ -28,10 +41,10 @@ export default defineNuxtConfig({ { name: 'description', content: '微信4.x版本数据库解密工具' } ], link: [ - { rel: 'icon', type: 'image/png', href: '/logo.png' } + { rel: 'icon', type: 'image/png', href: '/logo.png' }, + { rel: 'stylesheet', href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css' } ] - }, - pageTransition: { name: 'page', mode: 'out-in' } + } }, // 模块配置 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2139597..2333d65 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,8 +9,11 @@ "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", "@pinia/nuxt": "^0.11.2", + "@vueuse/motion": "^3.0.3", "axios": "^1.11.0", + "gsap": "^3.14.2", "nuxt": "^4.0.1", + "ogl": "^1.0.11", "vue": "^3.5.17", "vue-router": "^4.5.1" } @@ -1036,6 +1039,16 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1056,9 +1069,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -3898,6 +3911,12 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -4328,6 +4347,114 @@ "integrity": "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==", "license": "MIT" }, + "node_modules/@vueuse/core": { + "version": "13.9.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-13.9.0.tgz", + "integrity": "sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "13.9.0", + "@vueuse/shared": "13.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "13.9.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-13.9.0.tgz", + "integrity": "sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/motion": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@vueuse/motion/-/motion-3.0.3.tgz", + "integrity": "sha512-4B+ITsxCI9cojikvrpaJcLXyq0spj3sdlzXjzesWdMRd99hhtFI6OJ/1JsqwtF73YooLe0hUn/xDR6qCtmn5GQ==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "^13.0.0", + "@vueuse/shared": "^13.0.0", + "defu": "^6.1.4", + "framesync": "^6.1.2", + "popmotion": "^11.0.5", + "style-value-types": "^5.1.2" + }, + "optionalDependencies": { + "@nuxt/kit": "^3.13.0" + }, + "peerDependencies": { + "vue": ">=3.0.0" + } + }, + "node_modules/@vueuse/motion/node_modules/@nuxt/kit": { + "version": "3.21.0", + "resolved": "https://registry.npmmirror.com/@nuxt/kit/-/kit-3.21.0.tgz", + "integrity": "sha512-KMTLK/dsGaQioZzkYUvgfN9le4grNW54aNcA1jqzgVZLcFVy4jJfrJr5WZio9NT2EMfajdoZ+V28aD7BRr4Zfw==", + "license": "MIT", + "optional": true, + "dependencies": { + "c12": "^3.3.3", + "consola": "^3.4.2", + "defu": "^6.1.4", + "destr": "^2.0.5", + "errx": "^0.1.0", + "exsolve": "^1.0.8", + "ignore": "^7.0.5", + "jiti": "^2.6.1", + "klona": "^2.0.6", + "knitwork": "^1.3.0", + "mlly": "^1.8.0", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "rc9": "^2.1.2", + "scule": "^1.3.0", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ufo": "^1.6.3", + "unctx": "^2.5.0", + "untyped": "^2.0.0" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@vueuse/motion/node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/@vueuse/shared": { + "version": "13.9.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-13.9.0.tgz", + "integrity": "sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/@whatwg-node/disposablestack": { "version": "0.0.6", "resolved": "https://registry.npmmirror.com/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", @@ -4916,26 +5043,26 @@ } }, "node_modules/c12": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/c12/-/c12-3.1.0.tgz", - "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/c12/-/c12-3.3.3.tgz", + "integrity": "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==", "license": "MIT", "dependencies": { - "chokidar": "^4.0.3", + "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", - "dotenv": "^16.6.1", - "exsolve": "^1.0.7", + "dotenv": "^17.2.3", + "exsolve": "^1.0.8", "giget": "^2.0.0", - "jiti": "^2.4.2", + "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", - "perfect-debounce": "^1.0.0", - "pkg-types": "^2.2.0", + "perfect-debounce": "^2.0.0", + "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { - "magicast": "^0.3.5" + "magicast": "*" }, "peerDependenciesMeta": { "magicast": { @@ -4943,6 +5070,52 @@ } } }, + "node_modules/c12/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/c12/node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/c12/node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", @@ -6583,9 +6756,9 @@ } }, "node_modules/exsolve": { - "version": "1.0.7", - "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.7.tgz", - "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", "license": "MIT" }, "node_modules/extract-zip": { @@ -6673,10 +6846,13 @@ } }, "node_modules/fdir": { - "version": "6.4.6", - "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.4.6.tgz", - "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -6878,6 +7054,21 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framesync": { + "version": "6.1.2", + "resolved": "https://registry.npmmirror.com/framesync/-/framesync-6.1.2.tgz", + "integrity": "sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==", + "license": "MIT", + "dependencies": { + "tslib": "2.4.0" + } + }, + "node_modules/framesync/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "license": "0BSD" + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", @@ -7174,6 +7365,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/gsap": { + "version": "3.14.2", + "resolved": "https://registry.npmmirror.com/gsap/-/gsap-3.14.2.tgz", + "integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, "node_modules/gzip-size": { "version": "7.0.0", "resolved": "https://registry.npmmirror.com/gzip-size/-/gzip-size-7.0.0.tgz", @@ -7260,6 +7457,12 @@ "node": ">= 0.4" } }, + "node_modules/hey-listen": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/hey-listen/-/hey-listen-1.0.8.tgz", + "integrity": "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==", + "license": "MIT" + }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", @@ -7842,9 +8045,9 @@ } }, "node_modules/jiti": { - "version": "2.5.1", - "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.5.1.tgz", - "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "version": "2.6.1", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -7944,9 +8147,9 @@ } }, "node_modules/knitwork": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/knitwork/-/knitwork-1.2.0.tgz", - "integrity": "sha512-xYSH7AvuQ6nXkq42x0v5S8/Iry+cfulBz/DJQzhIyESdLD7425jXsPy4vn5cCXU+HhRN2kVw51Vd1K6/By4BQg==", + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/knitwork/-/knitwork-1.3.0.tgz", + "integrity": "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==", "license": "MIT" }, "node_modules/koa": { @@ -8199,6 +8402,279 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "license": "MPL-2.0", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss/node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", @@ -8254,14 +8730,14 @@ "license": "MIT" }, "node_modules/local-pkg": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.1.tgz", - "integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==", + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", "license": "MIT", "dependencies": { "mlly": "^1.7.4", - "pkg-types": "^2.0.1", - "quansync": "^0.2.8" + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" }, "engines": { "node": ">=14" @@ -8378,12 +8854,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/magic-string-ast": { @@ -8618,15 +9094,15 @@ } }, "node_modules/mlly": { - "version": "1.7.4", - "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.7.4.tgz", - "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", "license": "MIT", "dependencies": { - "acorn": "^8.14.0", - "pathe": "^2.0.1", - "pkg-types": "^1.3.0", - "ufo": "^1.5.4" + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" } }, "node_modules/mlly/node_modules/confbox": { @@ -9211,6 +9687,12 @@ "ufo": "^1.5.4" } }, + "node_modules/ogl": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/ogl/-/ogl-1.0.11.tgz", + "integrity": "sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==", + "license": "Unlicense" + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz", @@ -9739,9 +10221,9 @@ } }, "node_modules/pkg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.2.0.tgz", - "integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==", + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "license": "MIT", "dependencies": { "confbox": "^0.2.2", @@ -9749,6 +10231,24 @@ "pathe": "^2.0.3" } }, + "node_modules/popmotion": { + "version": "11.0.5", + "resolved": "https://registry.npmmirror.com/popmotion/-/popmotion-11.0.5.tgz", + "integrity": "sha512-la8gPM1WYeFznb/JqF4GiTkRRPZsfaj2+kCxqQgr2MJylMmIKUwBfWW8Wa5fml/8gmtlD5yI01MP1QCZPWmppA==", + "license": "MIT", + "dependencies": { + "framesync": "6.1.2", + "hey-listen": "^1.0.8", + "style-value-types": "5.1.2", + "tslib": "2.4.0" + } + }, + "node_modules/popmotion/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "license": "0BSD" + }, "node_modules/portfinder": { "version": "1.0.37", "resolved": "https://registry.npmmirror.com/portfinder/-/portfinder-1.0.37.tgz", @@ -10527,9 +11027,9 @@ } }, "node_modules/quansync": { - "version": "0.2.10", - "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.10.tgz", - "integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==", + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", "funding": [ { "type": "individual", @@ -11071,9 +11571,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -11584,6 +12084,22 @@ "integrity": "sha512-FL8EeKFFyNQv5cMnXI31CIMCsFarSVI2bF0U0ImeNE3g/F1IvJQyqzOXxPBRXiwQfyBTlbNe88jh1jFW0O/jiQ==", "license": "ISC" }, + "node_modules/style-value-types": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/style-value-types/-/style-value-types-5.1.2.tgz", + "integrity": "sha512-Vs9fNreYF9j6W2VvuDTP7kepALi7sk0xtk2Tu8Yxi9UoajJdEVpNpCov0HsLTqXvNGKX+Uv09pkozVITi1jf3Q==", + "license": "MIT", + "dependencies": { + "hey-listen": "^1.0.8", + "tslib": "2.4.0" + } + }, + "node_modules/style-value-types/node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "license": "0BSD" + }, "node_modules/stylehacks": { "version": "7.0.6", "resolved": "https://registry.npmmirror.com/stylehacks/-/stylehacks-7.0.6.tgz", @@ -12235,9 +12751,9 @@ } }, "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "version": "1.6.3", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, "node_modules/ultrahtml": { @@ -12253,15 +12769,15 @@ "license": "MIT" }, "node_modules/unctx": { - "version": "2.4.1", - "resolved": "https://registry.npmmirror.com/unctx/-/unctx-2.4.1.tgz", - "integrity": "sha512-AbaYw0Nm4mK4qjhns67C+kgxR2YWiwlDBPzxrN8h8C6VtAdCgditAY5Dezu3IJy4XVqAnbrXt9oQJvsn3fyozg==", + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/unctx/-/unctx-2.5.0.tgz", + "integrity": "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg==", "license": "MIT", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "estree-walker": "^3.0.3", - "magic-string": "^0.30.17", - "unplugin": "^2.1.0" + "magic-string": "^0.30.21", + "unplugin": "^2.3.11" } }, "node_modules/undici-types": { @@ -12367,13 +12883,14 @@ } }, "node_modules/unplugin": { - "version": "2.3.5", - "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-2.3.5.tgz", - "integrity": "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw==", + "version": "2.3.11", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", "license": "MIT", "dependencies": { - "acorn": "^8.14.1", - "picomatch": "^4.0.2", + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" }, "engines": { diff --git a/frontend/package.json b/frontend/package.json index 685e338..b527734 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,9 +12,15 @@ "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", "@pinia/nuxt": "^0.11.2", + "@vueuse/motion": "^3.0.3", "axios": "^1.11.0", + "gsap": "^3.14.2", "nuxt": "^4.0.1", + "ogl": "^1.0.11", "vue": "^3.5.17", "vue-router": "^4.5.1" + }, + "devDependencies": { + "tailwindcss": "3.4.17" } } diff --git a/frontend/pages/chat/[[username]].vue b/frontend/pages/chat/[[username]].vue index 48ed479..d740d74 100644 --- a/frontend/pages/chat/[[username]].vue +++ b/frontend/pages/chat/[[username]].vue @@ -1,52 +1,181 @@
+ :class="[{ 'wechat-transfer-received': message.transferReceived, 'wechat-transfer-returned': isTransferReturned(message), 'wechat-transfer-overdue': isTransferOverdue(message) }, message.isSent ? 'wechat-transfer-sent-side' : 'wechat-transfer-received-side']">
+
@@ -536,6 +815,9 @@ 微信红包
+
+ +
+ + +
+
+
+ + + + + 按日期定位 +
+ +
+ +
+
+ +
+ + +
+ +
+ +
+ {{ timeSidebarError }} +
+
+ 加载中... + 本月 {{ timeSidebarTotal }} 条消息,{{ timeSidebarActiveDays }} 天有聊天 +
+ +
+
{{ w }}
+
+ +
+ +
+ +
+ +
+
+
+
+
@@ -978,7 +1376,7 @@
预览
- +
-
-
{{ chatHistoryModalTitle || '聊天记录' }}
+
+
{{ win.title || (win.kind === 'link' ? '链接' : '聊天记录') }}
-
-
- 没有可显示的聊天记录 -
-