Compare commits
28 Commits
@@ -21,6 +21,8 @@ wheels/
|
||||
pnpm-lock.yaml
|
||||
/tools/tmp_isaac64_compare.js
|
||||
/.claude/settings.local.json
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Local dev repos and data
|
||||
/WxDatDecrypt/
|
||||
@@ -42,5 +44,10 @@ pnpm-lock.yaml
|
||||
/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
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<div align="center">
|
||||
<h1>WeChatDataAnalysis - 微信数据库解密与分析工具</h1>
|
||||
<p>一个专门用于微信4.x版本数据库解密的工具(支持聊天记录实时更新)</p>
|
||||
<p>微信4.x数据解密并生成年度总结,高仿微信,支持实时更新,导出聊天记录,朋友圈等大量便捷功能</p>
|
||||
<p><b>特别致谢</b>:<a href="https://github.com/H3CoF6">H3CoF6</a>(密钥与朋友圈等核心内容的技术支持)、<a href="https://github.com/ycccccccy/echotrace">echotrace</a>、<a href="https://github.com/hicccc77/WeFlow">WeFlow</a>(本项目大量功能参考其实现)</p>
|
||||
<img src="https://img.shields.io/github/v/tag/LifeArchiveProject/WeChatDataAnalysis" alt="Version" />
|
||||
<img src="https://img.shields.io/github/stars/LifeArchiveProject/WeChatDataAnalysis" alt="Stars" />
|
||||
@@ -16,39 +16,63 @@
|
||||
<img src="https://img.shields.io/badge/SQLite-003B57?logo=SQLite&logoColor=white" alt="SQLite" />
|
||||
</div>
|
||||
|
||||
## 年度总结
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><img src="frontend/public/style1.png" alt="年度总结 Modern" width="800"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="frontend/public/AnnualSummary1.png" alt="AnnualSummary 1" width="400"/></td>
|
||||
<td><img src="frontend/public/AnnualSummary2.png" alt="AnnualSummary 2" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="frontend/public/AnnualSummary3.png" alt="AnnualSummary 3" width="400"/></td>
|
||||
<td><img src="frontend/public/AnnualSummary4.gif" alt="AnnualSummary 4" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="frontend/public/AnnualSummary5.gif" alt="AnnualSummary 5" width="400"/></td>
|
||||
<td><img src="frontend/public/AnnualSummary6.png" alt="AnnualSummary 6" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="frontend/public/AnnualSummary7.png" alt="AnnualSummary 7" width="400"/></td>
|
||||
<td><img src="frontend/public/AnnualSummary8.png" alt="AnnualSummary 8" width="400"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 界面预览
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><b>首页</b></td>
|
||||
<td align="center"><b>检测页面</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="frontend/public/home.png" alt="首页" width="400"/></td>
|
||||
<td><img src="frontend/public/detection.png" alt="微信检测页面" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>解密页面</b></td>
|
||||
<td align="center"><b>图片密钥(填写)</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="frontend/public/decrypt.png" alt="数据库解密页面" width="400"/></td>
|
||||
<td><img src="frontend/public/imageAES.png" alt="图片密钥(填写)" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>图片解密页面</b></td>
|
||||
<td align="center"><b>解密成功页面</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="frontend/public/imageSucces.png" alt="图片解密页面" width="400"/></td>
|
||||
<td><img src="frontend/public/success.png" alt="解密成功页面" width="400"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><b>聊天记录页面</b></td>
|
||||
<td align="center" colspan="2"><b>聊天记录页面</b>(支持多种消息类型展示,样式尽可能与微信保持一致)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><img src="frontend/public/message.png" alt="聊天记录页面" width="800"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><b>修改消息</b>(本地修改,支持恢复)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><img src="frontend/public/edit.gif" alt="修改消息" width="800"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><b>实时消息同步</b>(点击侧边栏闪电图标后,消息会自动刷新)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><img src="frontend/public/RealTimeMessages.gif" alt="实时消息同步" width="800"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><b>设置面板</b>(桌面行为、启动偏好、更新、朋友圈缓存策略)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><img src="frontend/public/setting.png" alt="设置面板" width="800"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><b>朋友圈</b>(支持查看用户之前朋友圈的背景图及时间;本地查看过的朋友圈即使后续不可见也可以查看)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><img src="frontend/public/sns.png" alt="朋友圈" width="800"/></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2"><b>聊天记录搜索</b></td>
|
||||
</tr>
|
||||
@@ -61,22 +85,18 @@
|
||||
<tr>
|
||||
<td colspan="2" align="center"><img src="frontend/public/export.png" alt="聊天记录导出" width="800"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 年度总结
|
||||
|
||||
> ⚠️ **提醒**:年度总结目前还不是最终版本,后续还会增加新总结或新内容。
|
||||
|
||||
也欢迎加入下方 QQ 群一起讨论。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center"><img src="frontend/public/style1.png" alt="年度总结 Modern"/></td>
|
||||
<td align="center" colspan="2"><b>联系人导出</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><img src="frontend/public/Contact.png" alt="联系人导出" width="800"/></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 加入群聊
|
||||
|
||||
也欢迎加入下方 QQ 群一起讨论。
|
||||
|
||||
<p align="center">
|
||||
<a href="https://qm.qq.com/q/VQEQ7PcGkk">
|
||||
<img src="frontend/public/QQImage_1770190010691_1103312318341691201.jpg" alt="WeChatDataAnalysis 加群二维码" width="360" />
|
||||
@@ -134,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 桌面端)
|
||||
|
||||
@@ -171,6 +192,16 @@ npm run dist
|
||||
3. **数据隐私**: 解密后的数据包含个人隐私信息,请谨慎处理
|
||||
4. **合法使用**: 请遵守相关法律法规,不得用于非法目的
|
||||
|
||||
## 修改消息
|
||||
|
||||
支持在聊天页对单条消息进行本地修改(如修改消息文本/字段、修复为我发送、反转本地气泡方向),并在“修改记录”页查看原始与当前对比,支持单条恢复或按会话一键恢复。
|
||||
|
||||
该功能只修改本机本地数据库(`db_storage` 与解密副本),不会调用远端回写接口。
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/edit.gif" alt="本地消息修改" width="800" />
|
||||
</p>
|
||||
|
||||
## 致谢
|
||||
|
||||
本项目的开发过程中参考了以下优秀的开源项目和资源:
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"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",
|
||||
@@ -25,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": [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,23 +8,29 @@ const {
|
||||
dialog,
|
||||
shell,
|
||||
} = require("electron");
|
||||
const { autoUpdater } = require("electron-updater");
|
||||
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) {
|
||||
@@ -46,6 +52,139 @@ 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;
|
||||
|
||||
@@ -86,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();
|
||||
@@ -94,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() {
|
||||
@@ -146,6 +319,8 @@ function loadDesktopSettings() {
|
||||
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();
|
||||
@@ -162,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}`);
|
||||
@@ -223,6 +399,12 @@ function isAutoUpdateEnabled() {
|
||||
|
||||
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.
|
||||
@@ -710,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 {}
|
||||
};
|
||||
|
||||
@@ -733,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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -749,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",
|
||||
};
|
||||
@@ -779,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,
|
||||
@@ -795,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}`);
|
||||
@@ -835,6 +1031,42 @@ function stopBackend() {
|
||||
} 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) => {
|
||||
@@ -849,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));
|
||||
@@ -1051,6 +1294,63 @@ function registerWindowIpc() {
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -1060,6 +1360,30 @@ function registerWindowIpc() {
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -1078,6 +1402,11 @@ function registerWindowIpc() {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -1118,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;
|
||||
@@ -1134,7 +1489,7 @@ 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);
|
||||
|
||||
|
||||
@@ -14,8 +14,15 @@ 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"),
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
<SidebarRail v-if="showSidebar" />
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Desktop titlebar lives above the page content (right column) -->
|
||||
<DesktopTitleBar />
|
||||
<DesktopTitleBar v-if="showDesktopTitleBar" />
|
||||
<div :class="contentClass">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsDialog :open="settingsDialogOpen" @close="closeSettingsDialog" />
|
||||
|
||||
<ClientOnly v-if="isDesktopUpdater">
|
||||
<DesktopUpdateDialog
|
||||
:open="desktopUpdate.open.value"
|
||||
@@ -33,6 +35,7 @@ import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
const route = useRoute()
|
||||
const desktopUpdate = useDesktopUpdate()
|
||||
const { open: settingsDialogOpen, closeDialog: closeSettingsDialog } = useSettingsDialog()
|
||||
|
||||
// In Electron the server/pre-render doesn't know about `window.wechatDesktop`.
|
||||
// If we render different DOM on server vs client, Vue hydration will keep the
|
||||
@@ -87,6 +90,8 @@ const contentClass = computed(() =>
|
||||
: 'flex-1 overflow-auto min-h-0'
|
||||
)
|
||||
|
||||
const showDesktopTitleBar = computed(() => isDesktop.value)
|
||||
|
||||
const showSidebar = computed(() => {
|
||||
const path = String(route.path || '')
|
||||
if (path === '/') return false
|
||||
|
||||
@@ -90,6 +90,46 @@
|
||||
.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;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1772793179663" class="icon" viewBox="0 0 1025 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2488" xmlns:xlink="http://www.w3.org/1999/xlink" width="200.1953125" height="200"><path d="M740.672 37.504c156.352 0 283.52 115.584 283.52 258.496 0 44.416-13.056 87.872-36.608 127.04-35.648 57.216-92.672 99.584-161.664 119.744a161.408 161.408 0 0 1-45.184 7.36 52.8 52.8 0 0 1-53.76-52.928c0-29.76 23.68-52.864 53.76-52.864 2.112 0 6.528 0 11.904-2.048 46.336-12.8 82.944-39.168 103.424-74.24 13.952-22.144 20.48-46.72 20.48-72.064 0-83.84-78.72-152.512-174.72-152.512a197.76 197.76 0 0 0-94.72 24.32c-50.816 28.544-80.896 76.16-80.896 128.192v443.904c0 89.984-50.752 172.672-134.848 219.328-45.184 25.408-96 38.272-147.712 38.272-156.288 0-283.52-115.648-283.52-258.56 0-44.352 13.12-87.872 36.608-127.04 35.648-57.216 92.736-99.584 161.664-119.68 19.328-5.312 32.384-7.36 45.184-7.36 30.272 0 53.824 23.36 53.824 52.864a52.8 52.8 0 0 1-53.76 52.928c-2.176 0-6.592 0-11.904 2.048-46.4 13.76-82.944 40.32-103.424 74.176-14.016 22.208-20.48 46.72-20.48 72.128 0 83.84 78.72 152.448 175.616 152.448a197.76 197.76 0 0 0 94.784-24.256c50.752-28.608 80.832-76.224 80.832-128.192V296.192c0-89.984 50.752-172.608 134.848-219.328a283.52 283.52 0 0 1 146.752-39.36z" fill="#6467f0" p-id="2489"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -8,7 +8,7 @@
|
||||
<div>
|
||||
<h4 class="text-red-800 font-semibold">API连接问题</h4>
|
||||
<p class="text-red-700 text-sm mt-1">{{ appStore.apiMessage || '无法连接到后端服务' }}</p>
|
||||
<p class="text-red-600 text-xs mt-2">请确保后端服务正在运行 (端口: 8000)</p>
|
||||
<p class="text-red-600 text-xs mt-2">请确保后端服务正在运行</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,4 +18,4 @@
|
||||
import { useAppStore } from '~/stores/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div
|
||||
class="wechat-location-card-wrap"
|
||||
:class="isSent ? 'wechat-location-card-wrap--sent' : 'wechat-location-card-wrap--received'"
|
||||
>
|
||||
<div
|
||||
class="wechat-location-card"
|
||||
:class="{ 'wechat-location-card--sent': isSent }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openLocation"
|
||||
@keydown.enter.prevent="openLocation"
|
||||
@keydown.space.prevent="openLocation"
|
||||
>
|
||||
<div class="wechat-location-card__text">
|
||||
<div class="wechat-location-card__title">{{ primaryText }}</div>
|
||||
<div v-if="secondaryText" class="wechat-location-card__subtitle">{{ secondaryText }}</div>
|
||||
</div>
|
||||
|
||||
<div class="wechat-location-card__map" :class="{ 'wechat-location-card__map--placeholder': !mapTileUrl }">
|
||||
<img
|
||||
v-if="mapTileUrl"
|
||||
:src="mapTileUrl"
|
||||
alt="地图预览"
|
||||
class="wechat-location-card__map-image"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
>
|
||||
<div class="wechat-location-card__map-overlay"></div>
|
||||
<div class="wechat-location-card__pin" :style="markerStyle" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 22s7-5.82 7-12a7 7 0 1 0-14 0c0 6.18 7 12 7 12Z" fill="#22c55e" />
|
||||
<circle cx="12" cy="10" r="3.2" fill="#ffffff" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
message: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const TILE_SIZE = 256
|
||||
const MAP_ZOOM = 15
|
||||
|
||||
const cleanText = (value) => String(value || '').replace(/\s+/g, ' ').trim()
|
||||
|
||||
const toFiniteNumber = (value) => {
|
||||
const num = Number.parseFloat(String(value ?? '').trim())
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
const latitude = computed(() => {
|
||||
const num = toFiniteNumber(props.message?.locationLat)
|
||||
return num != null && Math.abs(num) <= 90 ? num : null
|
||||
})
|
||||
|
||||
const longitude = computed(() => {
|
||||
const num = toFiniteNumber(props.message?.locationLng)
|
||||
return num != null && Math.abs(num) <= 180 ? num : null
|
||||
})
|
||||
|
||||
const primaryText = computed(() => {
|
||||
return cleanText(
|
||||
props.message?.locationPoiname
|
||||
|| props.message?.title
|
||||
|| props.message?.content
|
||||
|| '位置'
|
||||
) || '位置'
|
||||
})
|
||||
|
||||
const secondaryText = computed(() => {
|
||||
const label = cleanText(props.message?.locationLabel)
|
||||
return label && label !== primaryText.value ? label : ''
|
||||
})
|
||||
|
||||
const isSent = computed(() => !!props.message?.isSent)
|
||||
|
||||
const mapTileMeta = computed(() => {
|
||||
const lat = latitude.value
|
||||
const lng = longitude.value
|
||||
if (lat == null || lng == null) return null
|
||||
|
||||
const scale = Math.pow(2, MAP_ZOOM)
|
||||
const worldX = ((lng + 180) / 360) * scale * TILE_SIZE
|
||||
const latRad = (lat * Math.PI) / 180
|
||||
const worldY = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * scale * TILE_SIZE
|
||||
const tileX = Math.floor(worldX / TILE_SIZE)
|
||||
const tileY = Math.floor(worldY / TILE_SIZE)
|
||||
const offsetX = worldX - tileX * TILE_SIZE
|
||||
const offsetY = worldY - tileY * TILE_SIZE
|
||||
|
||||
return {
|
||||
tileX,
|
||||
tileY,
|
||||
left: `${(offsetX / TILE_SIZE) * 100}%`,
|
||||
top: `${(offsetY / TILE_SIZE) * 100}%`,
|
||||
}
|
||||
})
|
||||
|
||||
const mapTileUrl = computed(() => {
|
||||
const meta = mapTileMeta.value
|
||||
if (!meta) return ''
|
||||
return `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${meta.tileX}&y=${meta.tileY}&z=${MAP_ZOOM}`
|
||||
})
|
||||
|
||||
const markerStyle = computed(() => {
|
||||
const meta = mapTileMeta.value
|
||||
return {
|
||||
left: meta?.left || '50%',
|
||||
top: meta?.top || '50%',
|
||||
}
|
||||
})
|
||||
|
||||
const mapLink = computed(() => {
|
||||
const name = encodeURIComponent(primaryText.value || secondaryText.value || '位置')
|
||||
const lat = latitude.value
|
||||
const lng = longitude.value
|
||||
if (lat != null && lng != null) {
|
||||
return `https://uri.amap.com/marker?position=${lng},${lat}&name=${name}`
|
||||
}
|
||||
if (name) return `https://uri.amap.com/search?keyword=${name}`
|
||||
return ''
|
||||
})
|
||||
|
||||
const openLocation = () => {
|
||||
if (!process.client) return
|
||||
const href = mapLink.value
|
||||
if (!href) return
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wechat-location-card-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.wechat-location-card-wrap--received::before,
|
||||
.wechat-location-card-wrap--sent::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #fff;
|
||||
transform: rotate(45deg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.wechat-location-card-wrap--received::before {
|
||||
left: -4px;
|
||||
}
|
||||
|
||||
.wechat-location-card-wrap--sent::after {
|
||||
right: -4px;
|
||||
}
|
||||
|
||||
.wechat-location-card {
|
||||
width: 208px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--message-radius);
|
||||
border: none;
|
||||
background: #fff;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.wechat-location-card--sent {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wechat-location-card__text {
|
||||
padding: 10px 12px 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wechat-location-card--sent .wechat-location-card__text {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wechat-location-card__title {
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wechat-location-card__subtitle {
|
||||
margin-top: 4px;
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.wechat-location-card--sent .wechat-location-card__subtitle {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.wechat-location-card__map {
|
||||
position: relative;
|
||||
height: 98px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(0deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.3)),
|
||||
linear-gradient(135deg, #d7eef5 0%, #f6f8fb 45%, #ece7cf 100%);
|
||||
}
|
||||
|
||||
.wechat-location-card__map--placeholder::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(90deg, rgba(255,255,255,0.65) 0 8%, transparent 8% 34%, rgba(255,255,255,0.65) 34% 42%, transparent 42% 100%),
|
||||
linear-gradient(0deg, rgba(255,255,255,0.7) 0 10%, transparent 10% 38%, rgba(255,255,255,0.7) 38% 46%, transparent 46% 100%);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.wechat-location-card__map-image,
|
||||
.wechat-location-card__map-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.wechat-location-card__map-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.wechat-location-card__map-overlay {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.04) 0%, rgba(255,255,255,0) 38%, rgba(17,24,39,0.06) 100%);
|
||||
}
|
||||
|
||||
.wechat-location-card__pin {
|
||||
position: absolute;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
transform: translate(-50%, -92%);
|
||||
filter: drop-shadow(0 4px 8px rgba(34, 197, 94, 0.28));
|
||||
}
|
||||
|
||||
.wechat-location-card__pin svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -83,6 +83,10 @@
|
||||
[语音]
|
||||
</div>
|
||||
|
||||
<div v-else-if="renderType === 'location'" class="max-w-sm">
|
||||
<ChatLocationCard :message="message" />
|
||||
</div>
|
||||
|
||||
<!-- 默认文本消息 -->
|
||||
<div
|
||||
v-else
|
||||
@@ -101,13 +105,13 @@ const props = defineProps({
|
||||
message: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const apiBase = useApiBase()
|
||||
|
||||
const normalizeMaybeUrl = (u) => {
|
||||
const raw = String(u || '').trim()
|
||||
if (!raw) return ''
|
||||
if (/^https?:\/\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw
|
||||
if (/^\/api\//i.test(raw)) return `${mediaBase}${raw}`
|
||||
if (/^\/api\//i.test(raw)) return `${apiBase}${raw.slice(4)}`
|
||||
return raw
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,674 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-[120] flex items-center justify-center bg-black/40 px-4 py-4 backdrop-blur-md sm:py-8"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div class="flex h-[80vh] min-h-[380px] w-full max-w-[760px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
|
||||
<!-- Sidebar -->
|
||||
<aside class="flex w-[180px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
|
||||
<div class="mt-4 mb-2 flex items-center px-4 gap-2">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-[5px] bg-[#e7f5ee] text-[#07b75b]">
|
||||
<svg class="h-[15px] w-[15px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-[14px] font-bold text-[#1f1f1f]">设置</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-0.5 px-3 py-2 overflow-y-auto scrollbar-custom">
|
||||
<button
|
||||
v-for="item in settingNavItems"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="group flex w-full flex-col items-start rounded-[6px] px-3 py-1.5 text-left transition select-none"
|
||||
:class="activeSection === item.key ? 'bg-white shadow-sm ring-1 ring-[#e5e5e5]' : 'hover:bg-[#f0f0f0]/60'"
|
||||
@click="scrollToSection(item.key)"
|
||||
>
|
||||
<div class="text-[12px] font-medium" :class="activeSection === item.key ? 'text-[#111]' : 'text-[#777] group-hover:text-[#333]'">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="relative flex min-w-0 flex-1 flex-col bg-white">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-3 z-10 flex h-6 w-6 items-center justify-center rounded-md text-[#888] transition hover:bg-[#f2f2f2] hover:text-[#222]"
|
||||
title="关闭设置"
|
||||
@click="handleClose"
|
||||
>
|
||||
<svg class="h-[14px] w-[14px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<header class="flex h-12 shrink-0 items-center px-6">
|
||||
<div class="flex items-center gap-1.5 text-[#111]">
|
||||
<svg class="h-[15px] w-[15px] text-[#666]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<h2 class="text-[13px] font-bold">{{ settingNavItems.find(i => i.key === activeSection)?.label || '设置' }}</h2>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div ref="contentScrollRef" class="scrollbar-custom flex-1 overflow-y-auto px-6 pb-8 pt-1 space-y-8" @scroll="onContentScroll">
|
||||
|
||||
<div v-if="!isDesktopEnv" class="rounded-[6px] border border-amber-200 bg-amber-50 px-3 py-1.5 text-[11px] leading-relaxed text-amber-900">
|
||||
当前为浏览器环境:开机自启动/关闭窗口/更新 不可用;“启动偏好”可正常使用;“后端端口”会尝试同步重启本机后端到新端口。
|
||||
</div>
|
||||
|
||||
<section ref="desktopSectionRef">
|
||||
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">桌面行为</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">开机自启动</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">系统登录后自动启动桌面端应用</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="desktopAutoLaunch"
|
||||
class="settings-switch shrink-0"
|
||||
:class="switchTrackClass(desktopAutoLaunch, !isDesktopEnv || desktopAutoLaunchLoading)"
|
||||
:disabled="!isDesktopEnv || desktopAutoLaunchLoading"
|
||||
@click="toggleDesktopAutoLaunch"
|
||||
>
|
||||
<span class="settings-switch-thumb" :class="desktopAutoLaunch ? 'translate-x-[20px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="desktopAutoLaunchError" class="text-xs text-red-600 whitespace-pre-wrap -mt-2">
|
||||
{{ desktopAutoLaunchError }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">关闭窗口行为</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">点击关闭按钮时:默认最小化到托盘</div>
|
||||
</div>
|
||||
<select
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
|
||||
:disabled="!isDesktopEnv || desktopCloseBehaviorLoading"
|
||||
:value="desktopCloseBehavior"
|
||||
@change="onDesktopCloseBehaviorChange"
|
||||
>
|
||||
<option value="tray">最小化到托盘</option>
|
||||
<option value="exit">直接退出</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="desktopCloseBehaviorError" class="text-xs text-red-600 whitespace-pre-wrap -mt-2">
|
||||
{{ desktopCloseBehaviorError }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">后端端口</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">桌面端:重启内置后端并刷新;网页端:尝试切换端口</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<input
|
||||
v-model="desktopBackendPortInput"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="w-16 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-center text-[12px] tabular-nums text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
|
||||
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
|
||||
@keyup.enter="onDesktopBackendPortApply"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
|
||||
@click="onDesktopBackendPortApply"
|
||||
>
|
||||
{{ desktopBackendPortApplying ? '...' : '应用' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
|
||||
@click="onDesktopBackendPortReset"
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="desktopBackendPortError" class="text-xs text-red-600 whitespace-pre-wrap -mt-1.5">
|
||||
{{ desktopBackendPortError }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopOutputDirText }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isDesktopEnv || desktopOutputDirLoading"
|
||||
@click="onDesktopOpenOutputDir"
|
||||
>
|
||||
打开 output
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="desktopOutputDirError" class="text-xs text-red-600 whitespace-pre-wrap -mt-1.5">
|
||||
{{ desktopOutputDirError }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section ref="startupSectionRef">
|
||||
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">启动偏好</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">启动后自动开启实时获取</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">进入聊天页后自动打开“实时开关”</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="desktopAutoRealtime"
|
||||
class="settings-switch shrink-0"
|
||||
:class="switchTrackClass(desktopAutoRealtime)"
|
||||
@click="toggleDesktopAutoRealtime"
|
||||
>
|
||||
<span class="settings-switch-thumb" :class="desktopAutoRealtime ? 'translate-x-[20px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">有数据时默认进入聊天页</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">有已解密账号时,打开应用跳转到 /chat</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="desktopDefaultToChatWhenData"
|
||||
class="settings-switch shrink-0"
|
||||
:class="switchTrackClass(desktopDefaultToChatWhenData)"
|
||||
@click="toggleDesktopDefaultToChat"
|
||||
>
|
||||
<span class="settings-switch-thumb" :class="desktopDefaultToChatWhenData ? 'translate-x-[20px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section ref="updatesSectionRef">
|
||||
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">更新</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">当前版本</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">{{ desktopVersionText }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-[#fafafa] px-2.5 py-1 text-[12px] text-[#222] transition hover:bg-[#f0f0f0] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isDesktopEnv || desktopUpdate.manualCheckLoading.value"
|
||||
@click="onDesktopCheckUpdates"
|
||||
>
|
||||
{{ desktopUpdate.manualCheckLoading.value ? '检查中...' : '检查桌面版更新' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="desktopUpdate.lastCheckMessage.value" class="mt-2 rounded-[6px] bg-[#f9f9f9] border border-[#eee] px-2.5 py-1.5 text-[11px] text-[#666] whitespace-pre-wrap break-words">
|
||||
{{ desktopUpdate.lastCheckMessage.value }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section ref="snsSectionRef">
|
||||
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">朋友圈</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">朋友圈图片使用缓存</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">开启:下载解密失败时回退本地缓存(默认);关闭:始终重新下载</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="snsUseCache"
|
||||
class="settings-switch shrink-0"
|
||||
:class="switchTrackClass(snsUseCache)"
|
||||
@click="toggleSnsUseCache"
|
||||
>
|
||||
<span class="settings-switch-thumb" :class="snsUseCache ? 'translate-x-[20px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
import { readApiBaseOverride, writeApiBaseOverride } from '~/utils/api-settings'
|
||||
|
||||
defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const settingNavItems = [
|
||||
{ key: 'desktop', label: '桌面行为', hint: '启动 / 关闭 / 端口' },
|
||||
{ key: 'startup', label: '启动偏好', hint: '自动实时 / 默认页面' },
|
||||
{ key: 'updates', label: '更新', hint: '版本信息 / 检查更新' },
|
||||
{ key: 'sns', label: '朋友圈', hint: '图片缓存策略' },
|
||||
]
|
||||
|
||||
const activeSection = ref(settingNavItems[0].key)
|
||||
const contentScrollRef = ref(null)
|
||||
const desktopSectionRef = ref(null)
|
||||
const startupSectionRef = ref(null)
|
||||
const updatesSectionRef = ref(null)
|
||||
const snsSectionRef = ref(null)
|
||||
|
||||
const isDesktopEnv = ref(false)
|
||||
const desktopUpdate = useDesktopUpdate()
|
||||
|
||||
const desktopVersionText = computed(() => {
|
||||
if (!isDesktopEnv.value) return '仅桌面端可用'
|
||||
const v = String(desktopUpdate.currentVersion.value || '').trim()
|
||||
return v || '—'
|
||||
})
|
||||
|
||||
const desktopAutoRealtime = ref(false)
|
||||
const desktopDefaultToChatWhenData = ref(false)
|
||||
const snsUseCache = ref(true)
|
||||
|
||||
const desktopAutoLaunch = ref(false)
|
||||
const desktopAutoLaunchLoading = ref(false)
|
||||
const desktopAutoLaunchError = ref('')
|
||||
|
||||
const desktopCloseBehavior = ref('tray')
|
||||
const desktopCloseBehaviorLoading = ref(false)
|
||||
const desktopCloseBehaviorError = ref('')
|
||||
|
||||
const desktopBackendPortInput = ref('')
|
||||
const desktopBackendPortLoading = ref(false)
|
||||
const desktopBackendPortApplying = ref(false)
|
||||
const desktopBackendPortError = ref('')
|
||||
const desktopBackendPortDefault = ref(10392)
|
||||
|
||||
const desktopOutputDir = ref('')
|
||||
const desktopOutputDirLoading = ref(false)
|
||||
const desktopOutputDirError = ref('')
|
||||
const desktopOutputDirText = computed(() => {
|
||||
if (!isDesktopEnv.value) return '仅桌面端可用'
|
||||
const v = String(desktopOutputDir.value || '').trim()
|
||||
return v || '—'
|
||||
})
|
||||
|
||||
const switchTrackClass = (enabled, disabled = false) => {
|
||||
if (disabled) return enabled ? 'bg-[#07b75b] opacity-50 cursor-not-allowed' : 'bg-[#d0d0d0] opacity-50 cursor-not-allowed'
|
||||
return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95'
|
||||
}
|
||||
|
||||
const sectionElements = computed(() => [
|
||||
{ key: 'desktop', el: desktopSectionRef.value },
|
||||
{ key: 'startup', el: startupSectionRef.value },
|
||||
{ key: 'updates', el: updatesSectionRef.value },
|
||||
{ key: 'sns', el: snsSectionRef.value },
|
||||
])
|
||||
|
||||
const scrollToSection = (key) => {
|
||||
const scrollHost = contentScrollRef.value
|
||||
const target = sectionElements.value.find((item) => item.key === key)?.el
|
||||
activeSection.value = key
|
||||
if (!scrollHost || !target) return
|
||||
scrollHost.scrollTo({
|
||||
top: Math.max(0, target.offsetTop - 10),
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
const onContentScroll = () => {
|
||||
const scrollHost = contentScrollRef.value
|
||||
if (!scrollHost) return
|
||||
const position = scrollHost.scrollTop + 120
|
||||
let current = settingNavItems[0].key
|
||||
for (const section of sectionElements.value) {
|
||||
if (!section.el) continue
|
||||
if (section.el.offsetTop <= position) current = section.key
|
||||
}
|
||||
activeSection.value = current
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const onEscKeydown = (event) => {
|
||||
if (event?.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const refreshDesktopAutoLaunch = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getAutoLaunch) return
|
||||
desktopAutoLaunchLoading.value = true
|
||||
desktopAutoLaunchError.value = ''
|
||||
try {
|
||||
desktopAutoLaunch.value = !!(await window.wechatDesktop.getAutoLaunch())
|
||||
} catch (e) {
|
||||
desktopAutoLaunchError.value = e?.message || '读取开机自启动状态失败'
|
||||
} finally {
|
||||
desktopAutoLaunchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDesktopAutoLaunch = async (enabled) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.setAutoLaunch) return
|
||||
desktopAutoLaunchLoading.value = true
|
||||
desktopAutoLaunchError.value = ''
|
||||
try {
|
||||
desktopAutoLaunch.value = !!(await window.wechatDesktop.setAutoLaunch(!!enabled))
|
||||
} catch (e) {
|
||||
desktopAutoLaunchError.value = e?.message || '设置开机自启动失败'
|
||||
await refreshDesktopAutoLaunch()
|
||||
} finally {
|
||||
desktopAutoLaunchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDesktopCloseBehavior = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getCloseBehavior) return
|
||||
desktopCloseBehaviorLoading.value = true
|
||||
desktopCloseBehaviorError.value = ''
|
||||
try {
|
||||
const v = await window.wechatDesktop.getCloseBehavior()
|
||||
desktopCloseBehavior.value = String(v || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
|
||||
} catch (e) {
|
||||
desktopCloseBehaviorError.value = e?.message || '读取关闭窗口行为失败'
|
||||
} finally {
|
||||
desktopCloseBehaviorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDesktopCloseBehavior = async (behavior) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.setCloseBehavior) return
|
||||
const desired = String(behavior || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
|
||||
desktopCloseBehaviorLoading.value = true
|
||||
desktopCloseBehaviorError.value = ''
|
||||
try {
|
||||
const v = await window.wechatDesktop.setCloseBehavior(desired)
|
||||
desktopCloseBehavior.value = String(v || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
|
||||
} catch (e) {
|
||||
desktopCloseBehaviorError.value = e?.message || '设置关闭窗口行为失败'
|
||||
await refreshDesktopCloseBehavior()
|
||||
} finally {
|
||||
desktopCloseBehaviorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDesktopBackendPort = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
desktopBackendPortLoading.value = true
|
||||
desktopBackendPortError.value = ''
|
||||
try {
|
||||
if (window.wechatDesktop?.getBackendPort) {
|
||||
const v = await window.wechatDesktop.getBackendPort()
|
||||
const n = Number(v)
|
||||
if (Number.isInteger(n) && n >= 1 && n <= 65535) {
|
||||
desktopBackendPortInput.value = String(n)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const apiBase = useApiBase()
|
||||
const resp = await $fetch('/admin/port', { baseURL: apiBase })
|
||||
const n = Number(resp?.port)
|
||||
const d = Number(resp?.default_port)
|
||||
if (Number.isInteger(d) && d >= 1 && d <= 65535) desktopBackendPortDefault.value = d
|
||||
if (Number.isInteger(n) && n >= 1 && n <= 65535) {
|
||||
desktopBackendPortInput.value = String(n)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
|
||||
let detectedPort = null
|
||||
const override = readApiBaseOverride()
|
||||
if (override && /^https?:\/\//i.test(override)) {
|
||||
try {
|
||||
const u = new URL(override)
|
||||
const n = Number(u.port)
|
||||
if (Number.isInteger(n) && n >= 1 && n <= 65535) detectedPort = n
|
||||
} catch {}
|
||||
}
|
||||
if (!desktopBackendPortInput.value) desktopBackendPortInput.value = String(detectedPort ?? 10392)
|
||||
} catch (e) {
|
||||
desktopBackendPortError.value = e?.message || '读取后端端口失败'
|
||||
} finally {
|
||||
desktopBackendPortLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDesktopOutputDir = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getOutputDir) return
|
||||
desktopOutputDirLoading.value = true
|
||||
desktopOutputDirError.value = ''
|
||||
try {
|
||||
const v = await window.wechatDesktop.getOutputDir()
|
||||
desktopOutputDir.value = String(v || '').trim()
|
||||
} catch (e) {
|
||||
desktopOutputDirError.value = e?.message || '读取 output 目录失败'
|
||||
} finally {
|
||||
desktopOutputDirLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onDesktopOpenOutputDir = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.openOutputDir) return
|
||||
desktopOutputDirLoading.value = true
|
||||
desktopOutputDirError.value = ''
|
||||
try {
|
||||
const res = await window.wechatDesktop.openOutputDir()
|
||||
if (res?.path) desktopOutputDir.value = String(res.path || '').trim()
|
||||
} catch (e) {
|
||||
desktopOutputDirError.value = e?.message || '打开 output 目录失败'
|
||||
} finally {
|
||||
desktopOutputDirLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyDesktopBackendPort = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
const raw = String(desktopBackendPortInput.value || '').trim()
|
||||
const n = Number(raw)
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
||||
desktopBackendPortError.value = '端口无效:请输入 1-65535 的整数'
|
||||
return
|
||||
}
|
||||
desktopBackendPortApplying.value = true
|
||||
desktopBackendPortError.value = ''
|
||||
try {
|
||||
if (window.wechatDesktop?.setBackendPort) {
|
||||
await window.wechatDesktop.setBackendPort(n)
|
||||
return
|
||||
}
|
||||
|
||||
const currentApiBase = useApiBase()
|
||||
let currentBackendPort = null
|
||||
try {
|
||||
const info = await $fetch('/admin/port', { baseURL: currentApiBase })
|
||||
const p = Number(info?.port)
|
||||
if (Number.isInteger(p) && p >= 1 && p <= 65535) currentBackendPort = p
|
||||
} catch {}
|
||||
const uiPort = (() => {
|
||||
const rawPort = String(window.location?.port || '').trim()
|
||||
if (rawPort) return Number(rawPort)
|
||||
return window.location?.protocol === 'https:' ? 443 : 80
|
||||
})()
|
||||
const isUiServedByBackend = !!(currentBackendPort && uiPort === currentBackendPort)
|
||||
|
||||
await $fetch('/admin/port', {
|
||||
baseURL: currentApiBase,
|
||||
method: 'POST',
|
||||
body: { port: n },
|
||||
})
|
||||
|
||||
let protocol = String(window.location?.protocol || 'http:')
|
||||
if (protocol !== 'http:' && protocol !== 'https:') protocol = 'http:'
|
||||
const host = String(window.location?.hostname || '').trim() || '127.0.0.1'
|
||||
const nextOrigin = `${protocol}//${host}:${n}`
|
||||
writeApiBaseOverride(`${nextOrigin}/api`)
|
||||
|
||||
const waitForHealth = async (healthUrl, timeoutMs = 30_000) => {
|
||||
const startedAt = Date.now()
|
||||
while (true) {
|
||||
try {
|
||||
const r = await fetch(healthUrl, { method: 'GET' })
|
||||
if (r && r.status < 500) return
|
||||
} catch {}
|
||||
if (Date.now() - startedAt > timeoutMs) throw new Error(`后端启动超时:${healthUrl}`)
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
}
|
||||
}
|
||||
await waitForHealth(`${nextOrigin}/api/health`, 30_000)
|
||||
|
||||
if (isUiServedByBackend) {
|
||||
const nextUrl = new URL(window.location.href)
|
||||
nextUrl.port = String(n)
|
||||
window.location.href = nextUrl.toString()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
window.location.reload()
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
desktopBackendPortError.value = e?.message || '设置后端端口失败(若为网页端,请确认后端为本机启动且允许重启)'
|
||||
await refreshDesktopBackendPort()
|
||||
} finally {
|
||||
desktopBackendPortApplying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleDesktopAutoLaunch = async () => {
|
||||
if (!isDesktopEnv.value || desktopAutoLaunchLoading.value) return
|
||||
await setDesktopAutoLaunch(!desktopAutoLaunch.value)
|
||||
}
|
||||
|
||||
const onDesktopCloseBehaviorChange = async (ev) => {
|
||||
const v = String(ev?.target?.value || '').trim()
|
||||
await setDesktopCloseBehavior(v)
|
||||
}
|
||||
|
||||
const onDesktopBackendPortApply = async () => {
|
||||
await applyDesktopBackendPort()
|
||||
}
|
||||
|
||||
const onDesktopBackendPortReset = async () => {
|
||||
desktopBackendPortInput.value = String(desktopBackendPortDefault.value || 10392)
|
||||
await applyDesktopBackendPort()
|
||||
}
|
||||
|
||||
const toggleDesktopAutoRealtime = () => {
|
||||
const next = !desktopAutoRealtime.value
|
||||
desktopAutoRealtime.value = next
|
||||
writeLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, next)
|
||||
}
|
||||
|
||||
const toggleDesktopDefaultToChat = () => {
|
||||
const next = !desktopDefaultToChatWhenData.value
|
||||
desktopDefaultToChatWhenData.value = next
|
||||
writeLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, next)
|
||||
}
|
||||
|
||||
const toggleSnsUseCache = () => {
|
||||
const next = !snsUseCache.value
|
||||
snsUseCache.value = next
|
||||
writeLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, next)
|
||||
}
|
||||
|
||||
const onDesktopCheckUpdates = async () => {
|
||||
await desktopUpdate.manualCheck()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
|
||||
isDesktopEnv.value = isElectron && !!window.wechatDesktop
|
||||
window.addEventListener('keydown', onEscKeydown)
|
||||
}
|
||||
|
||||
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
|
||||
desktopDefaultToChatWhenData.value = readLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, false)
|
||||
snsUseCache.value = readLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, true)
|
||||
|
||||
await refreshDesktopBackendPort()
|
||||
if (isDesktopEnv.value) {
|
||||
void desktopUpdate.initListeners()
|
||||
await refreshDesktopAutoLaunch()
|
||||
await refreshDesktopCloseBehavior()
|
||||
await refreshDesktopOutputDir()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
onContentScroll()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
window.removeEventListener('keydown', onEscKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-switch {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
padding: 2px;
|
||||
transition: background-color 0.16s ease, opacity 0.16s ease, filter 0.16s ease;
|
||||
}
|
||||
|
||||
.settings-switch-thumb {
|
||||
display: block;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24);
|
||||
transition: transform 0.16s ease;
|
||||
}
|
||||
|
||||
/* 自定义右侧滚动条 */
|
||||
.scrollbar-custom::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.scrollbar-custom::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-custom::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.scrollbar-custom::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
</style>
|
||||
@@ -171,7 +171,7 @@
|
||||
title="设置"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isSettingsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="settingsDialogOpen ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -201,17 +201,18 @@ const { privacyMode } = storeToRefs(privacyStore)
|
||||
|
||||
const realtimeStore = useChatRealtimeStore()
|
||||
const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realtimeChecking, statusError: realtimeStatusError, toggling: realtimeToggling } = storeToRefs(realtimeStore)
|
||||
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
|
||||
|
||||
onMounted(async () => {
|
||||
await chatAccounts.ensureLoaded()
|
||||
})
|
||||
|
||||
const sidebarMediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const apiBase = useApiBase()
|
||||
|
||||
const selfAvatarUrl = computed(() => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) return ''
|
||||
return `${sidebarMediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
|
||||
return `${apiBase}/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
|
||||
})
|
||||
|
||||
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
|
||||
@@ -219,8 +220,6 @@ const isEditsRoute = computed(() => route.path?.startsWith('/edits'))
|
||||
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
|
||||
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
|
||||
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
|
||||
const isSettingsRoute = computed(() => route.path?.startsWith('/settings'))
|
||||
|
||||
const goChat = async () => {
|
||||
await navigateTo('/chat')
|
||||
}
|
||||
@@ -241,8 +240,8 @@ const goWrapped = async () => {
|
||||
await navigateTo('/wrapped')
|
||||
}
|
||||
|
||||
const goSettings = async () => {
|
||||
await navigateTo('/settings')
|
||||
const goSettings = () => {
|
||||
openSettingsDialog()
|
||||
}
|
||||
|
||||
const realtimeBusy = computed(() => !!realtimeChecking.value || !!realtimeToggling.value)
|
||||
|
||||
@@ -36,11 +36,8 @@
|
||||
<template v-if="topContact || topGroup">
|
||||
<template v-if="topContact">
|
||||
你发消息最多的人是
|
||||
「<span
|
||||
class="privacy-blur inline-flex items-center gap-2 align-bottom max-w-[12rem]"
|
||||
:title="topContact.displayName"
|
||||
>
|
||||
<span class="w-6 h-6 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
|
||||
「<span class="inline-flex items-center gap-2 align-bottom max-w-[12rem]" :title="topContact.displayName">
|
||||
<span class="w-6 h-6 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0 wrapped-privacy-avatar">
|
||||
<img
|
||||
v-if="topContactAvatarUrl && avatarOk.topContact"
|
||||
:src="topContactAvatarUrl"
|
||||
@@ -52,18 +49,15 @@
|
||||
{{ avatarFallback(topContact.displayName) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="inline-block max-w-[10rem] truncate align-bottom">{{ topContact.displayName }}</span>
|
||||
<span class="wrapped-privacy-name inline-block max-w-[10rem] truncate align-bottom">{{ topContact.displayName }}</span>
|
||||
</span>」
|
||||
(<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(topContact.messages) }}</span> 条)
|
||||
</template>
|
||||
<template v-if="topContact && topGroup">,</template>
|
||||
<template v-if="topGroup">
|
||||
你最常发言的群是
|
||||
「<span
|
||||
class="privacy-blur inline-flex items-center gap-2 align-bottom max-w-[12rem]"
|
||||
:title="topGroup.displayName"
|
||||
>
|
||||
<span class="w-6 h-6 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
|
||||
「<span class="inline-flex items-center gap-2 align-bottom max-w-[12rem]" :title="topGroup.displayName">
|
||||
<span class="w-6 h-6 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0 wrapped-privacy-avatar">
|
||||
<img
|
||||
v-if="topGroupAvatarUrl && avatarOk.topGroup"
|
||||
:src="topGroupAvatarUrl"
|
||||
@@ -75,7 +69,7 @@
|
||||
{{ avatarFallback(topGroup.displayName) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="inline-block max-w-[10rem] truncate align-bottom">{{ topGroup.displayName }}</span>
|
||||
<span class="wrapped-privacy-name inline-block max-w-[10rem] truncate align-bottom">{{ topGroup.displayName }}</span>
|
||||
</span>」
|
||||
(<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(topGroup.messages) }}</span> 条)
|
||||
</template>
|
||||
@@ -87,10 +81,7 @@
|
||||
</template>
|
||||
|
||||
<template v-if="topPhrase && topPhrase.phrase && topPhrase.count > 0">
|
||||
你说得最多的一句话是「<span
|
||||
class="privacy-blur inline-block max-w-[12rem] truncate align-bottom"
|
||||
:title="topPhrase.phrase"
|
||||
>{{ topPhrase.phrase }}</span>」(<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(topPhrase.count) }}</span> 次)。
|
||||
你说得最多的一句话是「<span class="inline-block max-w-[12rem] truncate align-bottom" :title="topPhrase.phrase">{{ topPhrase.phrase }}</span>」(<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(topPhrase.count) }}</span> 次)。
|
||||
</template>
|
||||
|
||||
<span class="hidden sm:inline text-[#00000055]">愿你的每一句分享,都有人回应。</span>
|
||||
@@ -146,8 +137,7 @@ const topGroup = computed(() => {
|
||||
return o && typeof o === 'object' && typeof o.displayName === 'string' ? o : null
|
||||
})
|
||||
|
||||
// Keep the same behavior as the chat page: media (including avatars) comes from backend :8000 in dev.
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const apiBase = useApiBase()
|
||||
const resolveMediaUrl = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
@@ -156,13 +146,13 @@ const resolveMediaUrl = (value) => {
|
||||
try {
|
||||
const host = new URL(raw).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
}
|
||||
} catch {}
|
||||
return raw
|
||||
}
|
||||
// Most backend fields are like "/api/...", so just prefix.
|
||||
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
|
||||
if (/^\/api\//i.test(raw)) return `${apiBase}${raw.slice(4)}`
|
||||
return raw.startsWith('/') ? raw : `/${raw}`
|
||||
}
|
||||
|
||||
const topContactAvatarUrl = computed(() => {
|
||||
|
||||
@@ -66,39 +66,39 @@
|
||||
<!-- 最早最晚消息描述(按一天中的时刻) -->
|
||||
<template v-if="earliestSent && latestSent && totalMessages > 0">
|
||||
<template v-if="sameMomentTarget">
|
||||
最先想起的是「<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>」,
|
||||
最后放不下的也还是「<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>」。
|
||||
最先想起的是「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>」,
|
||||
最后放不下的也还是「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>」。
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="sameMomentDate">
|
||||
在 {{ earliestDateLabel }},最早的一条发给了「<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>」,
|
||||
最晚的一条发给了「<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>」。
|
||||
在 {{ earliestDateLabel }},最早的一条发给了「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>」,
|
||||
最晚的一条发给了「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>」。
|
||||
</template>
|
||||
<template v-else-if="!hasMomentDates">
|
||||
最早的一条发给了
|
||||
<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>,
|
||||
<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>,
|
||||
最晚的一条发给了
|
||||
<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>。
|
||||
<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>。
|
||||
</template>
|
||||
<template v-else-if="momentVariant === 0">
|
||||
最早的一条({{ earliestDateLabel }})发给了「<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>」,
|
||||
最晚的一条({{ latestDateLabel }})发给了「<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>」。
|
||||
最早的一条({{ earliestDateLabel }})发给了「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>」,
|
||||
最晚的一条({{ latestDateLabel }})发给了「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>」。
|
||||
</template>
|
||||
<template v-else-if="momentVariant === 1">
|
||||
最早的收件人是「<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>」({{ earliestDateLabel }}),
|
||||
最晚的收件人是「<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>」({{ latestDateLabel }})。
|
||||
最早的收件人是「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>」({{ earliestDateLabel }}),
|
||||
最晚的收件人是「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>」({{ latestDateLabel }})。
|
||||
</template>
|
||||
<template v-else-if="momentVariant === 2">
|
||||
在 {{ earliestDateLabel }},你把消息发给了「<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>」;
|
||||
在 {{ latestDateLabel }},你又发给了「<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>」。
|
||||
在 {{ earliestDateLabel }},你把消息发给了「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>」;
|
||||
在 {{ latestDateLabel }},你又发给了「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>」。
|
||||
</template>
|
||||
<template v-else-if="momentVariant === 3">
|
||||
最早与最晚,分别写给了「<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>」({{ earliestDateLabel }})
|
||||
和「<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>」({{ latestDateLabel }})。
|
||||
最早与最晚,分别写给了「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>」({{ earliestDateLabel }})
|
||||
和「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>」({{ latestDateLabel }})。
|
||||
</template>
|
||||
<template v-else>
|
||||
最早的一条落在 {{ earliestDateLabel }},发给了「<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>」;
|
||||
最晚的一条落在 {{ latestDateLabel }},发给了「<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>」。
|
||||
最早的一条落在 {{ earliestDateLabel }},发给了「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>」;
|
||||
最晚的一条落在 {{ latestDateLabel }},发给了「<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>」。
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
@@ -111,15 +111,15 @@
|
||||
v-if="yearFirstSent.avatarUrl"
|
||||
:src="yearFirstSent.avatarUrl"
|
||||
:alt="yearFirstSent.displayName"
|
||||
class="inline-block w-5 h-5 rounded align-middle mx-0.5"
|
||||
/><span class="wrapped-number text-[#07C160] font-semibold">{{ yearFirstSent.displayName }}</span>:「{{ yearFirstSent.content || '...' }}」<template v-if="yearLastSent">;
|
||||
class="inline-block w-5 h-5 rounded align-middle mx-0.5 wrapped-privacy-avatar"
|
||||
/><span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ yearFirstSent.displayName }}</span>:「<span class="wrapped-privacy-message">{{ yearFirstSent.content || '...' }}</span>」<template v-if="yearLastSent">;
|
||||
最后一条消息(<span class="wrapped-number text-[#07C160] font-semibold">{{ yearLastDateLabel }} {{ yearLastSent.time }}</span>)发给了
|
||||
<img
|
||||
v-if="yearLastSent.avatarUrl"
|
||||
:src="yearLastSent.avatarUrl"
|
||||
:alt="yearLastSent.displayName"
|
||||
class="inline-block w-5 h-5 rounded align-middle mx-0.5"
|
||||
/><span class="wrapped-number text-[#07C160] font-semibold">{{ yearLastSent.displayName }}</span>:「{{ yearLastSent.content || '...' }}」</template>。
|
||||
class="inline-block w-5 h-5 rounded align-middle mx-0.5 wrapped-privacy-avatar"
|
||||
/><span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ yearLastSent.displayName }}</span>:「<span class="wrapped-privacy-message">{{ yearLastSent.content || '...' }}</span>」</template>。
|
||||
<template v-if="sameYearTarget">
|
||||
<span class="text-[#7F7F7F]">——从年初到年末,始终如一。</span>
|
||||
</template>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
class="inline-flex items-center gap-2 align-bottom px-1.5 py-0.5 rounded-lg bg-[#00000008]"
|
||||
:title="bestBuddy?.displayName || ''"
|
||||
>
|
||||
<span class="w-5 h-5 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
|
||||
<span class="w-5 h-5 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0 wrapped-privacy-avatar">
|
||||
<img
|
||||
v-if="bestBuddyAvatarUrl && avatarOk.best"
|
||||
:src="bestBuddyAvatarUrl"
|
||||
@@ -23,7 +23,7 @@
|
||||
{{ avatarFallback(bestBuddy?.displayName) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="wrapped-body text-sm text-[#000000e6] max-w-[12rem] truncate">
|
||||
<span class="wrapped-body text-sm text-[#000000e6] max-w-[12rem] truncate wrapped-privacy-name">
|
||||
{{ bestBuddy?.displayName || '' }}
|
||||
</span>
|
||||
</span>
|
||||
@@ -35,7 +35,7 @@
|
||||
class="inline-flex items-center gap-1.5 align-bottom px-1.5 py-0.5 rounded-lg bg-[#00000008]"
|
||||
:title="seg.contact?.displayName || ''"
|
||||
>
|
||||
<span class="w-4 h-4 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
|
||||
<span class="w-4 h-4 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0 wrapped-privacy-avatar">
|
||||
<img
|
||||
v-if="resolveMediaUrl(seg.contact?.avatarUrl) && avatarOk[seg.contact?.username] !== false"
|
||||
:src="resolveMediaUrl(seg.contact?.avatarUrl)"
|
||||
@@ -47,7 +47,7 @@
|
||||
{{ avatarFallback(seg.contact?.displayName) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="wrapped-body text-sm text-[#000000e6] max-w-[8rem] truncate">
|
||||
<span class="wrapped-body text-sm text-[#000000e6] max-w-[8rem] truncate wrapped-privacy-name">
|
||||
{{ seg.contact?.displayName || '' }}
|
||||
</span>
|
||||
</span>
|
||||
@@ -95,7 +95,7 @@
|
||||
|
||||
<!-- 主内容:抽奖揭晓 + 右侧年度 Top10 总消息 bar race -->
|
||||
<div v-else class="w-full">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
|
||||
<!-- Left: 抽奖区 -->
|
||||
<div
|
||||
class="reply-buddy-rail flex flex-col items-center justify-center transition-transform duration-500 will-change-transform"
|
||||
@@ -109,7 +109,7 @@
|
||||
<img
|
||||
v-if="shownAvatarUrl && shownAvatarOk"
|
||||
:src="shownAvatarUrl"
|
||||
class="w-full h-full object-cover"
|
||||
class="w-full h-full object-cover wrapped-privacy-avatar"
|
||||
alt="avatar"
|
||||
@error="onShownAvatarError"
|
||||
/>
|
||||
@@ -121,7 +121,7 @@
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
class="w-full h-full flex items-center justify-center wrapped-privacy-avatar"
|
||||
>
|
||||
<span class="wrapped-number text-3xl text-[#00000066]">
|
||||
{{ shownAvatarFallback }}
|
||||
@@ -129,7 +129,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 min-h-[1.75rem] wrapped-body text-base text-[#000000e6] max-w-[18rem] truncate" :title="shownDisplayName">
|
||||
<div class="mt-4 min-h-[1.75rem] wrapped-body text-base text-[#000000e6] max-w-[18rem] truncate wrapped-privacy-name" :title="shownDisplayName">
|
||||
{{ shownDisplayName }}
|
||||
</div>
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-7 h-7 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0"
|
||||
class="w-7 h-7 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0 wrapped-privacy-avatar"
|
||||
>
|
||||
<img
|
||||
v-if="item.avatarUrl && avatarOk[item.username] !== false"
|
||||
@@ -227,7 +227,7 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="wrapped-body text-[#000000e6] text-sm truncate" :title="item.displayName">
|
||||
<div class="wrapped-body text-[#000000e6] text-sm truncate wrapped-privacy-name" :title="item.displayName">
|
||||
{{ item.displayName }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -308,7 +308,7 @@ const indexBuild = computed(() => {
|
||||
})
|
||||
|
||||
// Media URL resolving (same behavior as other wrapped components)
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const apiBase = useApiBase()
|
||||
const resolveMediaUrl = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
@@ -316,12 +316,13 @@ const resolveMediaUrl = (value) => {
|
||||
try {
|
||||
const host = new URL(raw).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
}
|
||||
} catch {}
|
||||
return raw
|
||||
}
|
||||
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
|
||||
if (/^\/api\//i.test(raw)) return `${apiBase}${raw.slice(4)}`
|
||||
return raw.startsWith('/') ? raw : `/${raw}`
|
||||
}
|
||||
|
||||
const avatarFallback = (name) => {
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
class="mt-0.5 inline-flex items-center gap-1.5 rounded-md bg-[#00000008] px-1.5 py-1 max-w-full"
|
||||
:title="heroStickerOwnerName ? `常发送给 ${heroStickerOwnerName}` : '常发送给:未知'"
|
||||
>
|
||||
<span class="w-4 h-4 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
|
||||
<span class="w-4 h-4 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0 wrapped-privacy-avatar">
|
||||
<img
|
||||
v-if="heroStickerOwnerAvatarUrl && avatarOk.topStickerOwner"
|
||||
:src="heroStickerOwnerAvatarUrl"
|
||||
@@ -71,7 +71,7 @@
|
||||
</span>
|
||||
</span>
|
||||
<span class="wrapped-body text-[11px] text-[#00000080] truncate">
|
||||
常发送给 <span class="text-[#07C160] font-semibold">{{ heroStickerOwnerName || '未知' }}</span>
|
||||
常发送给 <span class="text-[#07C160] font-semibold wrapped-privacy-name">{{ heroStickerOwnerName || '未知' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -270,7 +270,7 @@ const props = defineProps({
|
||||
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||||
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const apiBase = useApiBase()
|
||||
const resolveMediaUrl = (value, opts = { backend: false }) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
@@ -278,13 +278,15 @@ const resolveMediaUrl = (value, opts = { backend: false }) => {
|
||||
try {
|
||||
const host = new URL(raw).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
}
|
||||
} catch {}
|
||||
return raw
|
||||
}
|
||||
if (opts.backend || raw.startsWith('/api/')) {
|
||||
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
|
||||
if (/^\/api\//i.test(raw)) return `${apiBase}${raw.slice(4)}`
|
||||
if (opts.backend) {
|
||||
const origin = apiBase.endsWith('/api') ? apiBase.slice(0, -4) : apiBase
|
||||
return `${origin}${raw.startsWith('/') ? '' : '/'}${raw}`
|
||||
}
|
||||
return raw.startsWith('/') ? raw : `/${raw}`
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<template v-if="item.winner">
|
||||
<div class="flex items-start gap-1.5 pt-0.5 px-0.5">
|
||||
<!-- 头像 -->
|
||||
<div class="polaroid-photo flex-shrink-0">
|
||||
<div class="polaroid-photo flex-shrink-0 wrapped-privacy-avatar">
|
||||
<img
|
||||
v-if="winnerAvatar(item) && avatarOk[item.winner.username] !== false"
|
||||
:src="winnerAvatar(item)"
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="flex-1 min-w-0 pt-0.5 flex flex-col justify-between" style="height:70px">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-1 min-w-0">
|
||||
<div class="wrapped-body text-[10px] text-[#000000cc] truncate flex-1 leading-tight" :title="item.winner.displayName">
|
||||
<div class="wrapped-body text-[10px] text-[#000000cc] truncate flex-1 leading-tight wrapped-privacy-name" :title="item.winner.displayName">
|
||||
{{ item.winner.displayName }}
|
||||
</div>
|
||||
<!-- 月份徽章 -->
|
||||
@@ -166,7 +166,7 @@ const formatScore = (n) => {
|
||||
}
|
||||
const clampPct = (n) => Math.max(0, Math.min(100, Math.round(Number(n || 0) * 100)))
|
||||
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const apiBase = useApiBase()
|
||||
const resolveMediaUrl = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
@@ -174,12 +174,13 @@ const resolveMediaUrl = (value) => {
|
||||
try {
|
||||
const host = new URL(raw).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
}
|
||||
} catch {}
|
||||
return raw
|
||||
}
|
||||
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
|
||||
if (/^\/api\//i.test(raw)) return `${apiBase}${raw.slice(4)}`
|
||||
return raw.startsWith('/') ? raw : `/${raw}`
|
||||
}
|
||||
|
||||
const avatarFallback = (name) => {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
v-if="showOverlay"
|
||||
ref="overlayEl"
|
||||
class="kw-overlay fixed inset-0 overflow-hidden"
|
||||
:class="{ 'wrapped-privacy': privacyMode }"
|
||||
:style="{ zIndex: 9999 }"
|
||||
@pointerdown="onStagePointerDown"
|
||||
>
|
||||
@@ -31,13 +32,15 @@
|
||||
<div
|
||||
class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-[#95EC69] text-black bubble-tail-r"
|
||||
>
|
||||
<span v-if="Array.isArray(b.segments) && b.segments.length > 0">
|
||||
<span v-for="(seg, idx) in b.segments" :key="`${b.id}-${idx}`">
|
||||
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
|
||||
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" />
|
||||
<span class="wrapped-privacy-message">
|
||||
<span v-if="Array.isArray(b.segments) && b.segments.length > 0">
|
||||
<span v-for="(seg, idx) in b.segments" :key="`${b.id}-${idx}`">
|
||||
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
|
||||
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" />
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>{{ b.text }}</span>
|
||||
</span>
|
||||
<span v-else>{{ b.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,7 +59,7 @@
|
||||
<template v-else>
|
||||
这一年,你一共发出了 <span class="font-medium text-[#07C160]">{{ card.data?.meta?.matchedCandidates || 0 }}</span> 句简短的表达,其中 <span class="font-medium text-[#07C160]">{{ card.data?.meta?.uniquePhrases || 0 }}</span> 句话成了你的专属口头禅。
|
||||
<template v-if="card.data?.topKeyword">
|
||||
「<span class="font-medium text-[#07C160]">{{ card.data.topKeyword.word }}</span>」是你最常说的话,足足被你重复了 <span class="font-medium text-[#07C160]">{{ card.data.topKeyword.count }}</span> 次。
|
||||
「<span class="font-medium text-[#07C160] wrapped-privacy-keyword">{{ card.data.topKeyword.word }}</span>」是你最常说的话,足足被你重复了 <span class="font-medium text-[#07C160]">{{ card.data.topKeyword.count }}</span> 次。
|
||||
</template>
|
||||
点击气泡,找回当时的心情。
|
||||
</template>
|
||||
@@ -93,15 +96,20 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { gsap } from 'gsap'
|
||||
import KeywordWordCloud from '~/components/wrapped/visualizations/KeywordWordCloud.vue'
|
||||
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
const props = defineProps({
|
||||
card: { type: Object, required: true },
|
||||
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||||
})
|
||||
|
||||
const privacyStore = usePrivacyStore()
|
||||
const { privacyMode } = storeToRefs(privacyStore)
|
||||
|
||||
const cardRoot = ref(null)
|
||||
const stageEl = ref(null)
|
||||
const overlayEl = ref(null)
|
||||
@@ -770,6 +778,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
privacyStore.init()
|
||||
if (!import.meta.client) return
|
||||
detectReducedMotion()
|
||||
|
||||
|
||||
@@ -11,12 +11,6 @@
|
||||
<template v-if="variant === 'slide'">
|
||||
<div class="h-full flex flex-col justify-between">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="wrapped-label text-xs text-[#00000080]">
|
||||
WECHAT WRAPPED
|
||||
</div>
|
||||
<div class="wrapped-body text-xs text-[#00000055]">
|
||||
年度回望
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 sm:mt-14">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Top bar -->
|
||||
<div class="wrapped-chat-replay__top">
|
||||
<div class="wrapped-chat-replay__top-left">
|
||||
<div :class="['wrapped-chat-replay__avatar', { 'privacy-blur': privacyMode }]">
|
||||
<div class="wrapped-chat-replay__avatar wrapped-privacy-avatar">
|
||||
<img
|
||||
v-if="resolvedAvatarUrl && avatarOk"
|
||||
:src="resolvedAvatarUrl"
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="wrapped-label text-[10px] text-[#00000066]">{{ label }}</div>
|
||||
<div class="wrapped-body text-sm text-[#000000e6] truncate" :title="displayName">
|
||||
<div class="wrapped-body text-sm text-[#000000e6] truncate wrapped-privacy-name" :title="displayName">
|
||||
{{ displayNameShown }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
<transition name="wrapped-chat-replay-slide">
|
||||
<div v-if="showBubble" class="wrapped-chat-replay__bubble">
|
||||
<div :class="['wrapped-chat-replay__bubble-text', { 'privacy-blur': privacyMode }]" :title="content">
|
||||
<div class="wrapped-chat-replay__bubble-text wrapped-privacy-message" :title="content">
|
||||
{{ typedText }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,8 +78,7 @@ const onAvatarError = () => { avatarOk.value = false }
|
||||
|
||||
const displayNameShown = computed(() => String(props.displayName || props.maskedName || '').trim())
|
||||
|
||||
// Keep the same behavior as the chat page: media (including avatars) comes from backend :8000 in dev.
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const apiBase = useApiBase()
|
||||
const resolveMediaUrl = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
@@ -88,13 +87,13 @@ const resolveMediaUrl = (value) => {
|
||||
try {
|
||||
const host = new URL(raw).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
}
|
||||
} catch {}
|
||||
return raw
|
||||
}
|
||||
// Most backend fields are like "/api/...", so just prefix.
|
||||
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
|
||||
if (/^\/api\//i.test(raw)) return `${apiBase}${raw.slice(4)}`
|
||||
return raw.startsWith('/') ? raw : `/${raw}`
|
||||
}
|
||||
|
||||
const resolvedAvatarUrl = computed(() => resolveMediaUrl(props.avatarUrl))
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
:title="`${w.word} · ${formatInt(w.count)} 次`"
|
||||
@pointerdown.stop="selectWord(w.word, $event)"
|
||||
>
|
||||
{{ w.word }}
|
||||
<span class="wrapped-privacy-keyword">{{ w.word }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,6 +37,7 @@
|
||||
<div
|
||||
v-if="selectedInfo"
|
||||
class="kw-panel fixed z-[100] w-[min(92%,420px)] rounded-2xl border border-[#EDEDED] bg-white/80 backdrop-blur shadow-[0_16px_40px_rgba(0,0,0,0.14)] overflow-hidden"
|
||||
:class="{ 'wrapped-privacy': privacyMode }"
|
||||
:style="panelStyle"
|
||||
data-no-accel
|
||||
@pointerdown.stop
|
||||
@@ -44,7 +45,7 @@
|
||||
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-2 border-b border-[#F3F3F3]">
|
||||
<div class="min-w-0">
|
||||
<div class="wrapped-title text-base text-[#000000e6] truncate">
|
||||
{{ selectedInfo.word }}
|
||||
<span class="wrapped-privacy-keyword">{{ selectedInfo.word }}</span>
|
||||
<span class="wrapped-number text-sm text-[#07C160] font-semibold">· {{ formatInt(selectedInfo.count) }} 次</span>
|
||||
</div>
|
||||
<div class="mt-0.5 wrapped-body text-xs text-[#7F7F7F]">
|
||||
@@ -77,7 +78,7 @@
|
||||
class="flex justify-end"
|
||||
>
|
||||
<div class="relative bubble-tail-r bg-[#95EC69] msg-radius px-3 py-2 shadow-[0_6px_16px_rgba(0,0,0,0.12)] max-w-[92%]">
|
||||
<div class="wrapped-body text-sm text-[#000000e6] leading-snug whitespace-pre-wrap break-words">
|
||||
<div class="wrapped-body text-sm text-[#000000e6] leading-snug whitespace-pre-wrap break-words wrapped-privacy-message">
|
||||
<span v-if="Array.isArray(m.segments) && m.segments.length > 0">
|
||||
<span v-for="(seg, sidx) in m.segments" :key="`${selectedInfo.word}-${i}-${sidx}`">
|
||||
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
|
||||
@@ -98,7 +99,9 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
const props = defineProps({
|
||||
keywords: { type: Array, default: () => [] }, // [{word,count,weight}]
|
||||
@@ -107,6 +110,9 @@ const props = defineProps({
|
||||
reducedMotion: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const privacyStore = usePrivacyStore()
|
||||
const { privacyMode } = storeToRefs(privacyStore)
|
||||
|
||||
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||||
|
||||
@@ -369,6 +375,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
privacyStore.init()
|
||||
if (!import.meta.client) return
|
||||
updateSize()
|
||||
if (typeof ResizeObserver !== 'undefined' && rootEl.value) {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
// API请求组合式函数
|
||||
export const useApi = () => {
|
||||
const config = useRuntimeConfig()
|
||||
const baseURL = useApiBase()
|
||||
|
||||
// 基础请求函数
|
||||
const request = async (url, options = {}) => {
|
||||
try {
|
||||
// Default to same-origin `/api` so Nuxt devProxy / backend-mounted UI both work.
|
||||
// Override via `NUXT_PUBLIC_API_BASE`, e.g. `http://127.0.0.1:8000/api`.
|
||||
const apiBase = String(config?.public?.apiBase || '').trim()
|
||||
const baseURL = (apiBase ? apiBase : '/api').replace(/\/$/, '')
|
||||
|
||||
const response = await $fetch(url, {
|
||||
baseURL,
|
||||
...options,
|
||||
@@ -530,8 +525,8 @@ export const useApi = () => {
|
||||
}
|
||||
|
||||
// 获取数据库密钥
|
||||
const getDbKey = async () => {
|
||||
return await request('/get_db_key')
|
||||
const getKeys = async () => {
|
||||
return await request('/get_keys')
|
||||
}
|
||||
|
||||
// 获取图片密钥
|
||||
@@ -589,7 +584,7 @@ export const useApi = () => {
|
||||
getWrappedAnnual,
|
||||
getWrappedAnnualMeta,
|
||||
getWrappedAnnualCard,
|
||||
getDbKey,
|
||||
getKeys,
|
||||
getImageKey,
|
||||
getWxStatus,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
// 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 },
|
||||
@@ -6,7 +9,7 @@ export default defineNuxtConfig({
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
// Full API base, including `/api` when needed.
|
||||
// Example: `NUXT_PUBLIC_API_BASE=http://127.0.0.1:8000/api`
|
||||
// Example: `NUXT_PUBLIC_API_BASE=http://127.0.0.1:10392/api`
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api',
|
||||
},
|
||||
},
|
||||
@@ -22,7 +25,7 @@ export default defineNuxtConfig({
|
||||
'/api': {
|
||||
// `h3` strips the matched prefix (`/api`) before calling the middleware,
|
||||
// so the proxy target must include `/api` to preserve backend routes.
|
||||
target: 'http://127.0.0.1:8000/api',
|
||||
target: devProxyTarget,
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -551,6 +551,7 @@
|
||||
:preview="message.preview"
|
||||
:fromAvatar="message.fromAvatar"
|
||||
:from="message.from"
|
||||
:linkType="message.linkType"
|
||||
:isSent="message.isSent"
|
||||
:variant="message.linkCardVariant || 'default'"
|
||||
/>
|
||||
@@ -588,17 +589,16 @@
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
<a
|
||||
<button
|
||||
v-if="message.videoThumbUrl && message.videoUrl"
|
||||
:href="message.videoUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
type="button"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
@click.stop="openVideoPreview(message.videoUrl, message.videoThumbUrl)"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
</a>
|
||||
</button>
|
||||
<div class="absolute inset-0 flex items-center justify-center" v-else-if="message.videoThumbUrl">
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
@@ -814,6 +814,9 @@
|
||||
<span>微信红包</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'location'" class="max-w-sm">
|
||||
<ChatLocationCard :message="message" />
|
||||
</div>
|
||||
<!-- 文本消息 -->
|
||||
<div v-else-if="message.renderType === 'text'"
|
||||
class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
|
||||
@@ -1384,6 +1387,40 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 视频预览弹窗 (全局固定定位) -->
|
||||
<div
|
||||
v-if="previewVideoUrl"
|
||||
class="fixed inset-0 z-[13000] bg-black/90 flex items-center justify-center"
|
||||
@click="closeVideoPreview"
|
||||
>
|
||||
<div class="relative max-w-[92vw] max-h-[92vh] flex flex-col items-center" @click.stop>
|
||||
<video
|
||||
:key="previewVideoUrl"
|
||||
:src="previewVideoUrl"
|
||||
:poster="previewVideoPosterUrl"
|
||||
class="max-w-[90vw] max-h-[90vh] object-contain"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
@error="onPreviewVideoError"
|
||||
></video>
|
||||
<div
|
||||
v-if="previewVideoError"
|
||||
class="mt-3 text-xs text-red-200 whitespace-pre-wrap text-center max-w-[90vw]"
|
||||
>
|
||||
{{ previewVideoError }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="absolute top-4 right-4 text-white/80 hover:text-white p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors"
|
||||
@click.stop="closeVideoPreview"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 浮动窗口(可拖动):合并消息 / 链接卡片 -->
|
||||
<div
|
||||
v-for="win in floatingWindows"
|
||||
@@ -1490,12 +1527,12 @@
|
||||
@contextmenu="openMediaContextMenu($event, rec, 'message')"
|
||||
>
|
||||
<div class="wechat-link-content">
|
||||
<div class="wechat-link-info">
|
||||
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
|
||||
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
|
||||
<div v-if="rec.content || rec.preview" class="wechat-link-summary">
|
||||
<div v-if="rec.content" class="wechat-link-desc">{{ rec.content }}</div>
|
||||
</div>
|
||||
<div v-if="rec.preview" class="wechat-link-thumb">
|
||||
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
|
||||
<div v-if="rec.preview" class="wechat-link-thumb">
|
||||
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-link-from">
|
||||
@@ -1557,17 +1594,16 @@
|
||||
@error="onChatHistoryVideoThumbError(rec)"
|
||||
/>
|
||||
<div v-else class="px-3 py-2 text-sm text-gray-700">{{ rec.content || '[视频]' }}</div>
|
||||
<a
|
||||
<button
|
||||
v-if="rec.videoUrl"
|
||||
:href="rec.videoUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
type="button"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
@click.stop="openVideoPreview(rec.videoUrl, rec.videoThumbUrl)"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
</a>
|
||||
</button>
|
||||
<div
|
||||
v-if="rec.videoDuration"
|
||||
class="absolute bottom-2 right-2 text-xs text-white bg-black/55 px-1.5 py-0.5 rounded"
|
||||
@@ -1602,12 +1638,12 @@
|
||||
@contextmenu="openMediaContextMenu($event, win, 'message')"
|
||||
>
|
||||
<div class="wechat-link-content">
|
||||
<div class="wechat-link-info">
|
||||
<div class="wechat-link-title">{{ win.title || win.url || '链接' }}</div>
|
||||
<div class="wechat-link-title">{{ win.title || win.url || '链接' }}</div>
|
||||
<div v-if="win.content || win.preview" class="wechat-link-summary">
|
||||
<div v-if="win.content" class="wechat-link-desc">{{ win.content }}</div>
|
||||
</div>
|
||||
<div v-if="win.preview" class="wechat-link-thumb">
|
||||
<img :src="win.preview" :alt="win.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(win)" />
|
||||
<div v-if="win.preview" class="wechat-link-thumb">
|
||||
<img :src="win.preview" :alt="win.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(win)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-link-from">
|
||||
@@ -1768,12 +1804,12 @@
|
||||
@contextmenu="openMediaContextMenu($event, rec, 'message')"
|
||||
>
|
||||
<div class="wechat-link-content">
|
||||
<div class="wechat-link-info">
|
||||
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
|
||||
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
|
||||
<div v-if="rec.content || rec.preview" class="wechat-link-summary">
|
||||
<div v-if="rec.content" class="wechat-link-desc">{{ rec.content }}</div>
|
||||
</div>
|
||||
<div v-if="rec.preview" class="wechat-link-thumb">
|
||||
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
|
||||
<div v-if="rec.preview" class="wechat-link-thumb">
|
||||
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-link-from">
|
||||
@@ -1810,17 +1846,16 @@
|
||||
/>
|
||||
<div v-else class="px-3 py-2 text-sm text-gray-700">{{ rec.content || '[视频]' }}</div>
|
||||
|
||||
<a
|
||||
<button
|
||||
v-if="rec.videoThumbUrl && rec.videoUrl"
|
||||
:href="rec.videoUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
type="button"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
@click.stop="openVideoPreview(rec.videoUrl, rec.videoThumbUrl)"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
</a>
|
||||
</button>
|
||||
<div class="absolute inset-0 flex items-center justify-center" v-else-if="rec.videoThumbUrl">
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
@@ -2465,6 +2500,7 @@ import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { useChatRealtimeStore } from '~/stores/chatRealtime'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
import wechatPcLogoUrl from '~/assets/images/wechat/WeChat-Icon-Logo.wine.svg'
|
||||
import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
|
||||
import zipIconUrl from '~/assets/images/wechat/zip.png'
|
||||
import pdfIconUrl from '~/assets/images/wechat/pdf.png'
|
||||
import wordIconUrl from '~/assets/images/wechat/word.png'
|
||||
@@ -3630,8 +3666,8 @@ const startExportPolling = (exportId) => {
|
||||
if (!exportId) return
|
||||
|
||||
if (process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined') {
|
||||
const base = 'http://localhost:8000'
|
||||
const url = `${base}/api/chat/exports/${encodeURIComponent(String(exportId))}/events`
|
||||
const apiBase = useApiBase()
|
||||
const url = `${apiBase}/chat/exports/${encodeURIComponent(String(exportId))}/events`
|
||||
try {
|
||||
exportEventSource = new EventSource(url)
|
||||
exportEventSource.onmessage = (ev) => {
|
||||
@@ -3889,8 +3925,8 @@ watch(
|
||||
)
|
||||
|
||||
const getExportDownloadUrl = (exportId) => {
|
||||
const base = process.client ? 'http://localhost:8000' : ''
|
||||
return `${base}/api/chat/exports/${encodeURIComponent(String(exportId || ''))}/download`
|
||||
const apiBase = useApiBase()
|
||||
return `${apiBase}/chat/exports/${encodeURIComponent(String(exportId || ''))}/download`
|
||||
}
|
||||
|
||||
const startChatExport = async () => {
|
||||
@@ -4065,15 +4101,42 @@ const scrollToMessageId = async (id) => {
|
||||
|
||||
// 图片预览状态
|
||||
const previewImageUrl = ref(null)
|
||||
const previewVideoUrl = ref(null)
|
||||
const previewVideoPosterUrl = ref('')
|
||||
const previewVideoError = ref('')
|
||||
|
||||
const openImagePreview = (url) => {
|
||||
if (!process.client) return
|
||||
previewImageUrl.value = url
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const closeImagePreview = () => {
|
||||
if (!process.client) return
|
||||
previewImageUrl.value = null
|
||||
document.body.style.overflow = ''
|
||||
if (!previewVideoUrl.value) document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
const openVideoPreview = (url, poster) => {
|
||||
if (!process.client) return
|
||||
const u = String(url || '').trim()
|
||||
if (!u) return
|
||||
previewVideoError.value = ''
|
||||
previewVideoPosterUrl.value = String(poster || '').trim()
|
||||
previewVideoUrl.value = u
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const closeVideoPreview = () => {
|
||||
if (!process.client) return
|
||||
previewVideoUrl.value = null
|
||||
previewVideoPosterUrl.value = ''
|
||||
previewVideoError.value = ''
|
||||
if (!previewImageUrl.value) document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
const onPreviewVideoError = () => {
|
||||
previewVideoError.value = '视频加载失败。'
|
||||
}
|
||||
|
||||
const voiceRefs = ref({})
|
||||
@@ -5953,7 +6016,7 @@ const loadSessionsForSelectedAccount = async () => {
|
||||
id: s.id,
|
||||
name: s.name || s.username || s.id,
|
||||
avatar: s.avatar || null,
|
||||
lastMessage: s.lastMessage || '',
|
||||
lastMessage: normalizeSessionPreview(s.lastMessage || ''),
|
||||
lastMessageTime: s.lastMessageTime || '',
|
||||
unreadCount: s.unreadCount || 0,
|
||||
isGroup: !!s.isGroup,
|
||||
@@ -6039,7 +6102,7 @@ const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => {
|
||||
id: s.id,
|
||||
name: s.name || s.username || s.id,
|
||||
avatar: s.avatar || null,
|
||||
lastMessage: s.lastMessage || '',
|
||||
lastMessage: normalizeSessionPreview(s.lastMessage || ''),
|
||||
lastMessageTime: s.lastMessageTime || '',
|
||||
unreadCount: s.unreadCount || 0,
|
||||
isGroup: !!s.isGroup,
|
||||
@@ -6089,7 +6152,7 @@ const normalizeMessage = (msg) => {
|
||||
const sender = isSent ? '我' : (msg.senderDisplayName || msg.senderUsername || selectedContact.value?.name || '')
|
||||
const fallbackAvatar = (!isSent && !selectedContact.value?.isGroup) ? (selectedContact.value?.avatar || null) : null
|
||||
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const apiBase = useApiBase()
|
||||
const normalizeMaybeUrl = (u) => (typeof u === 'string' ? u.trim() : '')
|
||||
const isUsableMediaUrl = (u) => {
|
||||
const v = normalizeMaybeUrl(u)
|
||||
@@ -6121,7 +6184,7 @@ const normalizeMessage = (msg) => {
|
||||
try {
|
||||
const host = new URL(u).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(u)}`
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(u)}`
|
||||
}
|
||||
} catch {}
|
||||
return u
|
||||
@@ -6129,15 +6192,15 @@ const normalizeMessage = (msg) => {
|
||||
|
||||
const fromUsername = String(msg.fromUsername || '').trim()
|
||||
const fromAvatar = fromUsername
|
||||
? `${mediaBase}/api/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}`
|
||||
? `${apiBase}/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}`
|
||||
: (() => {
|
||||
// App/web link shares may not provide `fromUsername` (sourceusername), so we don't have a WeChat avatar.
|
||||
// Fall back to a best-effort website favicon fetched via backend.
|
||||
const href = String(msg.url || '').trim()
|
||||
return href ? `${mediaBase}/api/chat/media/favicon?url=${encodeURIComponent(href)}` : ''
|
||||
return href ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(href)}` : ''
|
||||
})()
|
||||
|
||||
const localEmojiUrl = msg.emojiMd5 ? `${mediaBase}/api/chat/media/emoji?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : ''
|
||||
const localEmojiUrl = msg.emojiMd5 ? `${apiBase}/chat/media/emoji?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : ''
|
||||
const localImageUrl = (() => {
|
||||
if (!msg.imageMd5 && !msg.imageFileId) return ''
|
||||
const parts = [
|
||||
@@ -6146,7 +6209,7 @@ const normalizeMessage = (msg) => {
|
||||
msg.imageFileId ? `file_id=${encodeURIComponent(msg.imageFileId)}` : '',
|
||||
`username=${encodeURIComponent(selectedContact.value?.username || '')}`,
|
||||
].filter(Boolean)
|
||||
return `${mediaBase}/api/chat/media/image?${parts.join('&')}`
|
||||
return `${apiBase}/chat/media/image?${parts.join('&')}`
|
||||
})()
|
||||
const normalizedImageUrl = (() => {
|
||||
const cur = (isUsableMediaUrl(msg.imageUrl) ? normalizeMaybeUrl(msg.imageUrl) : '')
|
||||
@@ -6165,7 +6228,7 @@ const normalizeMessage = (msg) => {
|
||||
msg.videoThumbFileId ? `file_id=${encodeURIComponent(msg.videoThumbFileId)}` : '',
|
||||
`username=${encodeURIComponent(selectedContact.value?.username || '')}`,
|
||||
].filter(Boolean)
|
||||
return `${mediaBase}/api/chat/media/video_thumb?${parts.join('&')}`
|
||||
return `${apiBase}/chat/media/video_thumb?${parts.join('&')}`
|
||||
})()
|
||||
|
||||
const localVideoUrl = (() => {
|
||||
@@ -6176,7 +6239,7 @@ const normalizeMessage = (msg) => {
|
||||
msg.videoFileId ? `file_id=${encodeURIComponent(msg.videoFileId)}` : '',
|
||||
`username=${encodeURIComponent(selectedContact.value?.username || '')}`,
|
||||
].filter(Boolean)
|
||||
return `${mediaBase}/api/chat/media/video?${parts.join('&')}`
|
||||
return `${apiBase}/chat/media/video?${parts.join('&')}`
|
||||
})()
|
||||
|
||||
const normalizedVideoThumbUrl = (isUsableMediaUrl(msg.videoThumbUrl) ? normalizeMaybeUrl(msg.videoThumbUrl) : '') || localVideoThumbUrl
|
||||
@@ -6186,7 +6249,7 @@ const normalizeMessage = (msg) => {
|
||||
if (msg.voiceUrl) return msg.voiceUrl
|
||||
if (!serverIdStr) return ''
|
||||
if (String(msg.renderType || '') !== 'voice') return ''
|
||||
return `${mediaBase}/api/chat/media/voice?account=${encodeURIComponent(selectedAccount.value || '')}&server_id=${encodeURIComponent(serverIdStr)}`
|
||||
return `${apiBase}/chat/media/voice?account=${encodeURIComponent(selectedAccount.value || '')}&server_id=${encodeURIComponent(serverIdStr)}`
|
||||
})()
|
||||
|
||||
const remoteFromServer = (
|
||||
@@ -6228,7 +6291,7 @@ const normalizeMessage = (msg) => {
|
||||
const quoteServerIdStr = String(msg.quoteServerId || '').trim()
|
||||
const quoteTypeStr = String(msg.quoteType || '').trim()
|
||||
const quoteVoiceUrl = quoteServerIdStr
|
||||
? `${mediaBase}/api/chat/media/voice?account=${encodeURIComponent(selectedAccount.value || '')}&server_id=${encodeURIComponent(quoteServerIdStr)}`
|
||||
? `${apiBase}/chat/media/voice?account=${encodeURIComponent(selectedAccount.value || '')}&server_id=${encodeURIComponent(quoteServerIdStr)}`
|
||||
: ''
|
||||
const quoteImageUrl = (() => {
|
||||
if (!quoteServerIdStr) return ''
|
||||
@@ -6239,7 +6302,7 @@ const normalizeMessage = (msg) => {
|
||||
`server_id=${encodeURIComponent(quoteServerIdStr)}`,
|
||||
convUsername ? `username=${encodeURIComponent(convUsername)}` : ''
|
||||
].filter(Boolean)
|
||||
return parts.length ? `${mediaBase}/api/chat/media/image?${parts.join('&')}` : ''
|
||||
return parts.length ? `${apiBase}/chat/media/image?${parts.join('&')}` : ''
|
||||
})()
|
||||
const quoteThumbUrl = (() => {
|
||||
const raw = isUsableMediaUrl(msg.quoteThumbUrl) ? normalizeMaybeUrl(msg.quoteThumbUrl) : ''
|
||||
@@ -6249,7 +6312,7 @@ const normalizeMessage = (msg) => {
|
||||
try {
|
||||
const host = new URL(raw).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
}
|
||||
} catch {}
|
||||
return raw
|
||||
@@ -6308,6 +6371,10 @@ const normalizeMessage = (msg) => {
|
||||
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款' || msg.transferStatus === '已被接收',
|
||||
voiceUrl: normalizedVoiceUrl || '',
|
||||
voiceDuration: msg.voiceLength || msg.voiceDuration || '',
|
||||
locationLat: msg.locationLat ?? null,
|
||||
locationLng: msg.locationLng ?? null,
|
||||
locationPoiname: String(msg.locationPoiname || '').trim(),
|
||||
locationLabel: String(msg.locationLabel || '').trim(),
|
||||
preview: normalizedLinkPreviewUrl || '',
|
||||
linkType: String(msg.linkType || '').trim(),
|
||||
linkStyle: String(msg.linkStyle || '').trim(),
|
||||
@@ -6409,6 +6476,14 @@ const closeTopFloatingWindow = () => {
|
||||
if (top?.id) closeFloatingWindow(top.id)
|
||||
}
|
||||
|
||||
const normalizeSessionPreview = (value) => {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
if (/^\[location\]/i.test(text)) return text.replace(/^\[location\]/i, '[位置]')
|
||||
if (/:\s*\[location\]$/i.test(text)) return text.replace(/\[location\]$/i, '[位置]')
|
||||
return text
|
||||
}
|
||||
|
||||
const openFloatingWindow = (payload) => {
|
||||
if (!process.client) return null
|
||||
const w0 = Number(payload?.width || 0) > 0 ? Number(payload.width) : 560
|
||||
@@ -6744,7 +6819,7 @@ const formatChatHistoryVideoDuration = (value) => {
|
||||
}
|
||||
|
||||
const normalizeChatHistoryRecordItem = (rec) => {
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const apiBase = useApiBase()
|
||||
const account = encodeURIComponent(selectedAccount.value || '')
|
||||
const username = encodeURIComponent(selectedContact.value?.username || '')
|
||||
|
||||
@@ -6768,7 +6843,7 @@ const normalizeChatHistoryRecordItem = (rec) => {
|
||||
})()
|
||||
if (fileId) {
|
||||
previewCandidates.push(
|
||||
`${mediaBase}/api/chat/media/image?account=${account}&file_id=${encodeURIComponent(fileId)}&username=${username}`
|
||||
`${apiBase}/chat/media/image?account=${account}&file_id=${encodeURIComponent(fileId)}&username=${username}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6782,7 +6857,7 @@ const normalizeChatHistoryRecordItem = (rec) => {
|
||||
srcServerId ? `server_id=${encodeURIComponent(srcServerId)}` : '',
|
||||
`username=${username}`
|
||||
].filter(Boolean)
|
||||
previewCandidates.push(`${mediaBase}/api/chat/media/image?${previewParts.join('&')}`)
|
||||
previewCandidates.push(`${apiBase}/chat/media/image?${previewParts.join('&')}`)
|
||||
}
|
||||
|
||||
out._linkPreviewCandidates = previewCandidates
|
||||
@@ -6793,8 +6868,8 @@ const normalizeChatHistoryRecordItem = (rec) => {
|
||||
const fromUsername = String(out.fromUsername || '').trim()
|
||||
out.fromUsername = fromUsername
|
||||
out.fromAvatar = fromUsername
|
||||
? `${mediaBase}/api/chat/avatar?account=${account}&username=${encodeURIComponent(fromUsername)}`
|
||||
: (linkUrl ? `${mediaBase}/api/chat/media/favicon?url=${encodeURIComponent(linkUrl)}` : '')
|
||||
? `${apiBase}/chat/avatar?account=${account}&username=${encodeURIComponent(fromUsername)}`
|
||||
: (linkUrl ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(linkUrl)}` : '')
|
||||
out._fromAvatarLast = out.fromAvatar
|
||||
out._fromAvatarImgOk = false
|
||||
out._fromAvatarImgError = false
|
||||
@@ -6804,17 +6879,17 @@ const normalizeChatHistoryRecordItem = (rec) => {
|
||||
out.videoDuration = String(out.duration || '').trim()
|
||||
const thumbCandidates = []
|
||||
if (out.videoMd5) {
|
||||
thumbCandidates.push(`${mediaBase}/api/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(out.videoMd5)}&username=${username}`)
|
||||
thumbCandidates.push(`${apiBase}/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(out.videoMd5)}&username=${username}`)
|
||||
}
|
||||
if (out.videoThumbMd5 && out.videoThumbMd5 !== out.videoMd5) {
|
||||
thumbCandidates.push(`${mediaBase}/api/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(out.videoThumbMd5)}&username=${username}`)
|
||||
thumbCandidates.push(`${apiBase}/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(out.videoThumbMd5)}&username=${username}`)
|
||||
}
|
||||
out._videoThumbCandidates = thumbCandidates
|
||||
out._videoThumbCandidateIndex = 0
|
||||
out._videoThumbError = false
|
||||
out.videoThumbUrl = thumbCandidates[0] || ''
|
||||
out.videoUrl = out.videoMd5
|
||||
? `${mediaBase}/api/chat/media/video?account=${account}&md5=${encodeURIComponent(out.videoMd5)}&username=${username}`
|
||||
? `${apiBase}/chat/media/video?account=${account}&md5=${encodeURIComponent(out.videoMd5)}&username=${username}`
|
||||
: ''
|
||||
if (!out.content || /^\[.+\]$/.test(String(out.content || '').trim())) out.content = '[视频]'
|
||||
} else if (out.renderType === 'emoji') {
|
||||
@@ -6823,7 +6898,7 @@ const normalizeChatHistoryRecordItem = (rec) => {
|
||||
const remoteAesKey = String(out.aeskey || '').trim()
|
||||
out.emojiRemoteUrl = remoteEmojiUrl
|
||||
out.emojiUrl = out.emojiMd5
|
||||
? `${mediaBase}/api/chat/media/emoji?account=${account}&md5=${encodeURIComponent(out.emojiMd5)}&username=${username}${remoteEmojiUrl ? `&emoji_url=${encodeURIComponent(remoteEmojiUrl)}` : ''}${remoteAesKey ? `&aes_key=${encodeURIComponent(remoteAesKey)}` : ''}`
|
||||
? `${apiBase}/chat/media/emoji?account=${account}&md5=${encodeURIComponent(out.emojiMd5)}&username=${username}${remoteEmojiUrl ? `&emoji_url=${encodeURIComponent(remoteEmojiUrl)}` : ''}${remoteAesKey ? `&aes_key=${encodeURIComponent(remoteAesKey)}` : ''}`
|
||||
: ''
|
||||
if (!out.content || /^\[.+\]$/.test(String(out.content || '').trim())) out.content = '[表情]'
|
||||
} else if (out.renderType === 'image') {
|
||||
@@ -6835,7 +6910,7 @@ const normalizeChatHistoryRecordItem = (rec) => {
|
||||
srcServerId ? `server_id=${encodeURIComponent(srcServerId)}` : '',
|
||||
`username=${username}`
|
||||
].filter(Boolean)
|
||||
out.imageUrl = imgParts.length ? `${mediaBase}/api/chat/media/image?${imgParts.join('&')}` : ''
|
||||
out.imageUrl = imgParts.length ? `${apiBase}/chat/media/image?${imgParts.join('&')}` : ''
|
||||
if (!out.content || /^\[.+\]$/.test(String(out.content || '').trim())) out.content = '[图片]'
|
||||
}
|
||||
|
||||
@@ -7030,9 +7105,7 @@ const openChatHistoryQuote = (rec) => {
|
||||
if (!url) return
|
||||
|
||||
if (kind === 'video') {
|
||||
try {
|
||||
window.open(url, '_blank', 'noreferrer')
|
||||
} catch {}
|
||||
openVideoPreview(url, q?.thumbUrl)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7190,7 +7263,7 @@ const resolveChatHistoryLinkRecord = async (rec) => {
|
||||
const content = String(resp.content || '').trim()
|
||||
const url = String(resp.url || '').trim()
|
||||
const from = String(resp.from || '').trim()
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const apiBase = useApiBase()
|
||||
const normalizePreviewUrl = (u) => {
|
||||
const raw = String(u || '').trim()
|
||||
if (!raw) return ''
|
||||
@@ -7199,7 +7272,7 @@ const resolveChatHistoryLinkRecord = async (rec) => {
|
||||
try {
|
||||
const host = new URL(raw).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
}
|
||||
} catch {}
|
||||
return raw
|
||||
@@ -7214,8 +7287,8 @@ const resolveChatHistoryLinkRecord = async (rec) => {
|
||||
const fromUsername = String(resp.fromUsername || '').trim()
|
||||
if (fromUsername) rec.fromUsername = fromUsername
|
||||
const fromAvatarUrl = fromUsername
|
||||
? `${mediaBase}/api/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}`
|
||||
: (url ? `${mediaBase}/api/chat/media/favicon?url=${encodeURIComponent(url)}` : '')
|
||||
? `${apiBase}/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}`
|
||||
: (url ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(url)}` : '')
|
||||
if (fromAvatarUrl) {
|
||||
const last = String(rec._fromAvatarLast || '').trim()
|
||||
rec.fromAvatar = fromAvatarUrl
|
||||
@@ -7375,6 +7448,7 @@ const onGlobalKeyDown = (e) => {
|
||||
if (key === 'Escape') {
|
||||
if (contextMenu.value.visible) closeContextMenu()
|
||||
if (previewImageUrl.value) closeImagePreview()
|
||||
if (previewVideoUrl.value) closeVideoPreview()
|
||||
if (Array.isArray(floatingWindows.value) && floatingWindows.value.length) closeTopFloatingWindow()
|
||||
if (chatHistoryModalVisible.value) closeChatHistoryModal()
|
||||
if (contactProfileCardOpen.value) {
|
||||
@@ -7843,13 +7917,15 @@ const onMessageScroll = async () => {
|
||||
const LinkCard = defineComponent({
|
||||
name: 'LinkCard',
|
||||
props: {
|
||||
href: { type: String, required: true },
|
||||
href: { type: String, default: '' },
|
||||
heading: { type: String, default: '' },
|
||||
abstract: { type: String, default: '' },
|
||||
preview: { type: String, default: '' },
|
||||
fromAvatar: { type: String, default: '' },
|
||||
from: { type: String, default: '' },
|
||||
linkType: { type: String, default: '' },
|
||||
isSent: { type: Boolean, default: false },
|
||||
badge: { type: String, default: '' },
|
||||
variant: { type: String, default: 'default' }
|
||||
},
|
||||
setup(props) {
|
||||
@@ -7863,7 +7939,9 @@ const LinkCard = defineComponent({
|
||||
// Fallback: when the appmsg XML doesn't provide sourcedisplayname/appname,
|
||||
// show the host so the footer row still matches WeChat's fixed card layout.
|
||||
try {
|
||||
const host = new URL(String(props.href || '')).hostname
|
||||
const href = String(props.href || '').trim()
|
||||
if (!/^https?:\/\//i.test(href)) return ''
|
||||
const host = new URL(href).hostname
|
||||
return String(host || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
@@ -7872,6 +7950,9 @@ const LinkCard = defineComponent({
|
||||
|
||||
return () => {
|
||||
const fromText = getFromText()
|
||||
const href = String(props.href || '').trim()
|
||||
const canNavigate = /^https?:\/\//i.test(href)
|
||||
const badgeText = String(props.badge || '').trim()
|
||||
// WeChat link cards show a small avatar next to the source text. We don't
|
||||
// always have a real image URL, so fall back to the first glyph.
|
||||
const fromAvatarText = (() => {
|
||||
@@ -7879,7 +7960,9 @@ const LinkCard = defineComponent({
|
||||
return t ? (Array.from(t)[0] || '') : ''
|
||||
})()
|
||||
const fromAvatarUrl = String(props.fromAvatar || '').trim()
|
||||
const isCoverVariant = String(props.variant || '').trim() === 'cover'
|
||||
const isMiniProgram = String(props.linkType || '').trim() === 'mini_program'
|
||||
const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
|
||||
const Tag = canNavigate ? 'a' : 'div'
|
||||
|
||||
// Props may change when switching accounts/chats; reset load state per URL.
|
||||
if (fromAvatarUrl !== lastFromAvatarUrl.value) {
|
||||
@@ -7896,6 +7979,12 @@ const LinkCard = defineComponent({
|
||||
color: 'transparent'
|
||||
}
|
||||
: null
|
||||
const miniProgramAvatarStyle = fromAvatarImgOk.value
|
||||
? {
|
||||
background: '#fff',
|
||||
color: 'transparent'
|
||||
}
|
||||
: null
|
||||
const onFromAvatarLoad = () => {
|
||||
fromAvatarImgOk.value = true
|
||||
fromAvatarImgError.value = false
|
||||
@@ -7918,17 +8007,17 @@ const LinkCard = defineComponent({
|
||||
onError: onFromAvatarError
|
||||
}) : null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-cover-from-name' }, fromText || '\u200B')
|
||||
])
|
||||
h('div', { class: 'wechat-link-cover-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
|
||||
badgeText ? h('div', { class: 'wechat-link-cover-badge' }, badgeText) : null,
|
||||
].filter(Boolean))
|
||||
|
||||
return h(
|
||||
'a',
|
||||
Tag,
|
||||
{
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noreferrer',
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card-cover',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
@@ -7958,19 +8047,91 @@ const LinkCard = defineComponent({
|
||||
}),
|
||||
fromRow,
|
||||
]) : fromRow,
|
||||
h('div', { class: 'wechat-link-cover-title' }, props.heading || props.href)
|
||||
h('div', { class: 'wechat-link-cover-title' }, props.heading || href)
|
||||
].filter(Boolean)
|
||||
)
|
||||
}
|
||||
|
||||
const headingText = String(props.heading || href || '').trim()
|
||||
let abstractText = String(props.abstract || '').trim()
|
||||
if (abstractText && headingText && abstractText === headingText) abstractText = ''
|
||||
|
||||
if (isMiniProgram) {
|
||||
return h(
|
||||
Tag,
|
||||
{
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card',
|
||||
'wechat-link-card--mini-program',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
].filter(Boolean).join(' '),
|
||||
style: {
|
||||
width: '210px',
|
||||
minWidth: '210px',
|
||||
maxWidth: '210px',
|
||||
maxHeight: '270px',
|
||||
height: '270px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
outline: 'none'
|
||||
}
|
||||
},
|
||||
[
|
||||
h('div', { class: 'wechat-link-mini-body' }, [
|
||||
h('div', { class: 'wechat-link-mini-header' }, [
|
||||
h('div', { class: 'wechat-link-mini-header-avatar', style: miniProgramAvatarStyle, 'aria-hidden': 'true' }, [
|
||||
showFromAvatarText ? (fromAvatarText || '\u200B') : null,
|
||||
showFromAvatarImg ? h('img', {
|
||||
src: fromAvatarUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-mini-header-avatar-img',
|
||||
referrerpolicy: 'no-referrer',
|
||||
onLoad: onFromAvatarLoad,
|
||||
onError: onFromAvatarError
|
||||
}) : null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-mini-header-name' }, fromText || '\u200B')
|
||||
]),
|
||||
h('div', { class: 'wechat-link-mini-title' }, headingText || abstractText || href),
|
||||
h('div', { class: ['wechat-link-mini-preview', !props.preview ? 'wechat-link-mini-preview--empty' : ''].filter(Boolean).join(' ') }, [
|
||||
props.preview ? h('img', {
|
||||
src: props.preview,
|
||||
alt: props.heading || '小程序预览',
|
||||
class: 'wechat-link-mini-preview-img',
|
||||
referrerpolicy: 'no-referrer'
|
||||
}) : null
|
||||
].filter(Boolean))
|
||||
]),
|
||||
h('div', { class: 'wechat-link-mini-footer' }, [
|
||||
h('img', {
|
||||
src: miniProgramIconUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-mini-footer-icon',
|
||||
'aria-hidden': 'true'
|
||||
}),
|
||||
h('span', { class: 'wechat-link-mini-footer-text' }, '小程序')
|
||||
])
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
return h(
|
||||
'a',
|
||||
Tag,
|
||||
{
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noreferrer',
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
@@ -7995,13 +8156,15 @@ const LinkCard = defineComponent({
|
||||
},
|
||||
[
|
||||
h('div', { class: 'wechat-link-content' }, [
|
||||
h('div', { class: 'wechat-link-info' }, [
|
||||
h('div', { class: 'wechat-link-title' }, props.heading || props.href),
|
||||
props.abstract ? h('div', { class: 'wechat-link-desc' }, props.abstract) : null
|
||||
].filter(Boolean)),
|
||||
props.preview ? h('div', { class: 'wechat-link-thumb' }, [
|
||||
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'wechat-link-thumb-img', referrerpolicy: 'no-referrer' })
|
||||
]) : null
|
||||
h('div', { class: 'wechat-link-title' }, headingText || href),
|
||||
(abstractText || props.preview)
|
||||
? h('div', { class: 'wechat-link-summary' }, [
|
||||
abstractText ? h('div', { class: 'wechat-link-desc' }, abstractText) : null,
|
||||
props.preview ? h('div', { class: 'wechat-link-thumb' }, [
|
||||
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'wechat-link-thumb-img', referrerpolicy: 'no-referrer' })
|
||||
]) : null
|
||||
].filter(Boolean))
|
||||
: null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-from' }, [
|
||||
h('div', { class: 'wechat-link-from-avatar', style: fromAvatarStyle, 'aria-hidden': 'true' }, [
|
||||
@@ -8015,8 +8178,9 @@ const LinkCard = defineComponent({
|
||||
onError: onFromAvatarError
|
||||
}) : null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-from-name' }, fromText || '\u200B')
|
||||
])
|
||||
h('div', { class: 'wechat-link-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
|
||||
badgeText ? h('div', { class: 'wechat-link-badge' }, badgeText) : null
|
||||
].filter(Boolean))
|
||||
].filter(Boolean)
|
||||
)
|
||||
}
|
||||
@@ -8026,6 +8190,35 @@ const LinkCard = defineComponent({
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* LinkCard:小程序标记与无 URL 降级 */
|
||||
::deep(.wechat-link-badge) {
|
||||
margin-left: auto;
|
||||
padding-left: 8px;
|
||||
font-size: 11px;
|
||||
color: #b2b2b2;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
::deep(.wechat-link-cover-badge) {
|
||||
margin-left: auto;
|
||||
padding-left: 8px;
|
||||
font-size: 11px;
|
||||
color: rgba(243, 243, 243, 0.92);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
::deep(.wechat-link-card.wechat-link-card--disabled),
|
||||
::deep(.wechat-link-card-cover.wechat-link-card--disabled) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
::deep(.wechat-link-card.wechat-link-card--disabled:hover),
|
||||
::deep(.wechat-link-card-cover.wechat-link-card--disabled:hover) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -8775,21 +8968,18 @@ const LinkCard = defineComponent({
|
||||
|
||||
:deep(.wechat-link-content) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
/* Keep a small breathing room above the footer divider. */
|
||||
padding: 8px 10px 6px;
|
||||
padding: 10px 10px 8px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-info) {
|
||||
:deep(.wechat-link-summary) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-title) {
|
||||
@@ -8806,24 +8996,24 @@ const LinkCard = defineComponent({
|
||||
:deep(.wechat-link-desc) {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-thumb) {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: #f2f2f2;
|
||||
/* Center the thumbnail in the content area (WeChat desktop style). */
|
||||
align-self: center;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-thumb-img) {
|
||||
@@ -8833,6 +9023,127 @@ const LinkCard = defineComponent({
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-card--mini-program) {
|
||||
max-height: 270px;
|
||||
height: 270px;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header-avatar) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #14c15f;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header-avatar-img) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header-name) {
|
||||
font-size: 13px;
|
||||
color: #7d7d7d;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-title) {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: #1a1a1a;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-preview) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
background: #f2f2f2;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-preview--empty) {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-preview-img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer) {
|
||||
height: 23px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 1px;
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer-icon) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer-text) {
|
||||
font-size: 10px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-from) {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<svg v-else class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ isGettingDbKey ? '获取中...' : '自动获取' }}
|
||||
{{ isGettingDbKey ? '获取中...' : '一键获取全部密钥' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="formErrors.key" class="mt-1 text-sm text-red-600 flex items-center">
|
||||
@@ -71,7 +71,7 @@
|
||||
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
尝试自动获取,或者使用 <a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a> 等工具获取的64位十六进制字符串
|
||||
点击按钮将自动获取【数据库】与【图片】双重密钥。您也可以手动输入已知的64位密钥(使用<a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a>等工具获取)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -183,53 +183,37 @@
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
|
||||
<div class="flex justify-between items-center mb-4 pb-3 border-b border-gray-200">
|
||||
<span class="text-sm font-medium text-gray-500">手动输入或通过微信获取</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="handleGetImageKey"
|
||||
:disabled="isGettingImageKey"
|
||||
class="flex-none inline-flex items-center px-4 py-3 bg-[#07C160] text-white rounded-lg text-sm font-medium hover:bg-[#06AD56] transition-all duration-200 disabled:opacity-50 disabled:cursor-wait whitespace-nowrap"
|
||||
>
|
||||
<svg v-if="isGettingImageKey" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<svg v-else class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
{{ isGettingImageKey ? '正在获取...' : '自动获取' }}
|
||||
</button>
|
||||
<span class="text-sm font-medium text-gray-500">此步骤将为您解密微信聊天中的图片</span>
|
||||
</div>
|
||||
<p class="mt-3 mb-4 text-xs text-[#7F7F7F] flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
如果您在第一步使用了“一键获取”或触发了云端解析,下方输入框已被自动填充。您也可可以使用<a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a>等工具手动获取。
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#000000e6] mb-2">XOR(必填)</label>
|
||||
<input
|
||||
v-model="manualKeys.xor_key"
|
||||
type="text"
|
||||
placeholder="例如:0xA5"
|
||||
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
|
||||
v-model="manualKeys.xor_key"
|
||||
type="text"
|
||||
placeholder="例如:0xA5"
|
||||
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
|
||||
/>
|
||||
<p v-if="manualKeyErrors.xor_key" class="text-xs text-red-500 mt-1">{{ manualKeyErrors.xor_key }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[#000000e6] mb-2">AES(可选)</label>
|
||||
<input
|
||||
v-model="manualKeys.aes_key"
|
||||
type="text"
|
||||
placeholder="16 个字符(V4-V2 需要)"
|
||||
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
|
||||
v-model="manualKeys.aes_key"
|
||||
type="text"
|
||||
placeholder="16 个字符(V4-V2 需要)"
|
||||
class="w-full px-4 py-2 border border-[#EDEDED] rounded-lg focus:ring-2 focus:ring-[#10AEEF] focus:border-transparent font-mono"
|
||||
/>
|
||||
<p v-if="manualKeyErrors.aes_key" class="text-xs text-red-500 mt-1">{{ manualKeyErrors.aes_key }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-3 text-xs text-[#7F7F7F] flex items-center">
|
||||
<svg class="w-4 h-4 mr-1 text-[#10AEEF]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
尝试自动获取,或使用 <a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a> 获取图片密钥;AES 可选(V4-V2 需要)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -450,7 +434,7 @@
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
const { decryptDatabase, saveMediaKeys, getSavedKeys, getDbKey, getImageKey, getWxStatus } = useApi()
|
||||
const { decryptDatabase, saveMediaKeys, getSavedKeys, getKeys, getImageKey, getWxStatus } = useApi()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
@@ -458,7 +442,6 @@ const warning = ref('') // 警告,用于密钥提示
|
||||
const currentStep = ref(0)
|
||||
const mediaAccount = ref('')
|
||||
const isGettingDbKey = ref(false)
|
||||
const isGettingImageKey = ref(false)
|
||||
|
||||
// 步骤定义
|
||||
const steps = [
|
||||
@@ -548,74 +531,45 @@ const handleGetDbKey = async () => {
|
||||
formErrors.key = ''
|
||||
|
||||
try {
|
||||
const statusRes = await getWxStatus() // pid不是主进程,但是没关系
|
||||
const statusRes = await getWxStatus()
|
||||
const wxStatus = statusRes?.wx_status
|
||||
|
||||
if (wxStatus?.is_running) {
|
||||
warning.value = '检测到微信正在运行,5秒后将终止进程并重启以获取密钥!!'
|
||||
warning.value = '检测到微信正在运行,5秒后将终止进程并重启以获取全套密钥!'
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
} else {
|
||||
// 没有逻辑
|
||||
}
|
||||
|
||||
warning.value = '正在启动微信以获取密钥,请确保微信未开启“自动登录”,并在启动后 1 分钟内完成登录操作。'
|
||||
warning.value = '正在启动微信,请确保微信未开启“自动登录”,并在弹窗中正常登录。'
|
||||
|
||||
const res = await getDbKey()
|
||||
const res = await getKeys()
|
||||
|
||||
if (res && res.status === 0) {
|
||||
if (res.data?.db_key) {
|
||||
formData.key = res.data.db_key
|
||||
warning.value = ''
|
||||
}
|
||||
|
||||
if (res.errmsg && res.errmsg !== 'ok') {
|
||||
warning.value = res.errmsg
|
||||
// 直接把图片密钥也存好
|
||||
if (res.data?.xor_key) {
|
||||
manualKeys.xor_key = res.data.xor_key
|
||||
}
|
||||
if (res.data?.aes_key) {
|
||||
manualKeys.aes_key = res.data.aes_key
|
||||
}
|
||||
warning.value = '🎉 数据库与图片密钥均已获取成功!'
|
||||
// 3秒后清除成功提示,保持 UI 干净
|
||||
setTimeout(() => { if(warning.value.includes('获取成功')) warning.value = '' }, 3000)
|
||||
} else {
|
||||
error.value = '获取失败: ' + (res?.errmsg || '未知错误')
|
||||
warning.value = ''
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
error.value = '系统错误: ' + e.message
|
||||
warning.value = ''
|
||||
} finally {
|
||||
isGettingDbKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleGetImageKey = async () => {
|
||||
if (isGettingImageKey.value) return
|
||||
isGettingImageKey.value = true
|
||||
manualKeyErrors.xor_key = ''
|
||||
manualKeyErrors.aes_key = ''
|
||||
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
|
||||
try {
|
||||
const res = await getImageKey()
|
||||
|
||||
if (res && res.status === 0) {
|
||||
if (res.data?.aes_key) {
|
||||
manualKeys.aes_key = res.data.aes_key
|
||||
}
|
||||
if (res.data?.xor_key) {
|
||||
// 后端记得处理为16进制再返回!!!
|
||||
manualKeys.xor_key = res.data.xor_key
|
||||
}
|
||||
|
||||
if (res.errmsg && res.errmsg !== 'ok') {
|
||||
error.value = res.errmsg
|
||||
}
|
||||
} else {
|
||||
error.value = '获取失败: ' + (res?.errmsg || '未知错误')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
error.value = '系统错误: ' + e.message
|
||||
} finally {
|
||||
isGettingImageKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyManualKeys = () => {
|
||||
manualKeyErrors.xor_key = ''
|
||||
@@ -749,13 +703,13 @@ const handleDecrypt = async () => {
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
warning.value = ''
|
||||
|
||||
resetDbDecryptProgress()
|
||||
|
||||
|
||||
try {
|
||||
const canSse = process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined'
|
||||
|
||||
@@ -776,9 +730,26 @@ const handleDecrypt = async () => {
|
||||
if (accounts.length > 0) mediaAccount.value = accounts[0]
|
||||
} catch (e) {}
|
||||
|
||||
clearManualKeys()
|
||||
currentStep.value = 1
|
||||
await prefillKeysForAccount(mediaAccount.value)
|
||||
|
||||
if (!manualKeys.xor_key && !manualKeys.aes_key) {
|
||||
warning.value = '正在通过云端备选方案自动获取图片密钥,请稍候...'
|
||||
try {
|
||||
const imgRes = await getImageKey({ account: mediaAccount.value })
|
||||
if (imgRes && imgRes.status === 0) {
|
||||
if (imgRes.data?.xor_key) manualKeys.xor_key = imgRes.data.xor_key
|
||||
if (imgRes.data?.aes_key) manualKeys.aes_key = imgRes.data.aes_key
|
||||
warning.value = '已通过云端成功获取图片密钥!'
|
||||
setTimeout(() => { if(warning.value.includes('成功获取')) warning.value = '' }, 3000)
|
||||
} else {
|
||||
warning.value = '云端获取图片密钥失败,您可以尝试手动填写。'
|
||||
}
|
||||
} catch (e) {
|
||||
warning.value = '网络请求失败,请手动填写图片密钥。'
|
||||
}
|
||||
}
|
||||
|
||||
} else if (result.status === 'failed') {
|
||||
if (result.failure_count > 0 && result.success_count === 0) {
|
||||
error.value = result.message || '所有文件解密失败'
|
||||
@@ -804,7 +775,8 @@ const handleDecrypt = async () => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('key', formData.key)
|
||||
params.set('db_storage_path', formData.db_storage_path)
|
||||
const url = `http://localhost:8000/api/decrypt_stream?${params.toString()}`
|
||||
const apiBase = useApiBase()
|
||||
const url = `${apiBase}/decrypt_stream?${params.toString()}`
|
||||
|
||||
dbDecryptProgress.message = '连接中...'
|
||||
const eventSource = new EventSource(url)
|
||||
@@ -855,9 +827,26 @@ const handleDecrypt = async () => {
|
||||
loading.value = false
|
||||
|
||||
if (data.status === 'completed') {
|
||||
clearManualKeys()
|
||||
currentStep.value = 1
|
||||
await prefillKeysForAccount(mediaAccount.value)
|
||||
|
||||
// 【重点】如果刚才没有通过双 Hook 拿到图片密钥,触发云端 API 备用方案自动获取
|
||||
if (!manualKeys.xor_key && !manualKeys.aes_key) {
|
||||
warning.value = '正在通过云端备选方案自动获取图片密钥,请稍候...'
|
||||
try {
|
||||
const imgRes = await getImageKey({ account: mediaAccount.value })
|
||||
if (imgRes && imgRes.status === 0) {
|
||||
if (imgRes.data?.xor_key) manualKeys.xor_key = imgRes.data.xor_key
|
||||
if (imgRes.data?.aes_key) manualKeys.aes_key = imgRes.data.aes_key
|
||||
warning.value = '已通过云端成功获取图片密钥!'
|
||||
setTimeout(() => { if(warning.value.includes('成功获取')) warning.value = '' }, 3000)
|
||||
} else {
|
||||
warning.value = '云端获取图片密钥失败,您可以尝试手动填写。'
|
||||
}
|
||||
} catch (e) {
|
||||
warning.value = '网络请求失败,请手动填写图片密钥。'
|
||||
}
|
||||
}
|
||||
} else if (data.status === 'failed') {
|
||||
error.value = data.message || '所有文件解密失败'
|
||||
} else {
|
||||
@@ -916,7 +905,8 @@ const decryptAllImages = async () => {
|
||||
if (mediaAccount.value) params.set('account', mediaAccount.value)
|
||||
if (mediaKeys.xor_key) params.set('xor_key', mediaKeys.xor_key)
|
||||
if (mediaKeys.aes_key) params.set('aes_key', mediaKeys.aes_key)
|
||||
const url = `http://localhost:8000/api/media/decrypt_all_stream?${params.toString()}`
|
||||
const apiBase = useApiBase()
|
||||
const url = `${apiBase}/media/decrypt_all_stream?${params.toString()}`
|
||||
|
||||
// 使用EventSource接收SSE
|
||||
const eventSource = new EventSource(url)
|
||||
|
||||
@@ -257,13 +257,13 @@ watch(selectedAccount, async () => {
|
||||
await loadSessions()
|
||||
})
|
||||
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const apiBase = useApiBase()
|
||||
|
||||
const normalizeMaybeUrl = (u) => {
|
||||
const raw = String(u || '').trim()
|
||||
if (!raw) return ''
|
||||
if (/^https?:\/\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw
|
||||
if (/^\/api\//i.test(raw)) return `${mediaBase}${raw}`
|
||||
if (/^\/api\//i.test(raw)) return `${apiBase}${raw.slice(4)}`
|
||||
return raw
|
||||
}
|
||||
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<div class="flex-1 min-h-0 overflow-auto p-6">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 bg-[#F7F7F7]">
|
||||
<div class="text-lg font-semibold text-gray-900">设置</div>
|
||||
<div class="text-sm text-gray-500 mt-1">桌面端相关行为与启动偏好</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-5">
|
||||
<div v-if="!isDesktopEnv" class="rounded-md border border-amber-200 bg-amber-50 text-amber-900 px-3 py-2 text-xs leading-5">
|
||||
当前为浏览器环境:“桌面行为”分组仅桌面端可用;“启动偏好”分组可正常使用。
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div class="text-sm font-medium text-gray-900">桌面行为</div>
|
||||
</div>
|
||||
<div class="px-4 py-3 space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">开机自启动</div>
|
||||
<div class="text-xs text-gray-500">系统登录后自动启动桌面端</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="desktopAutoLaunch"
|
||||
:disabled="!isDesktopEnv || desktopAutoLaunchLoading"
|
||||
@change="onDesktopAutoLaunchToggle"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="desktopAutoLaunchError" class="text-xs text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopAutoLaunchError }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">关闭窗口行为</div>
|
||||
<div class="text-xs text-gray-500">点击关闭按钮时:默认最小化到托盘</div>
|
||||
</div>
|
||||
<select
|
||||
class="text-sm px-2 py-1 rounded-md border border-gray-200 bg-white"
|
||||
:disabled="!isDesktopEnv || desktopCloseBehaviorLoading"
|
||||
:value="desktopCloseBehavior"
|
||||
@change="onDesktopCloseBehaviorChange"
|
||||
>
|
||||
<option value="tray">最小化到托盘</option>
|
||||
<option value="exit">直接退出</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="desktopCloseBehaviorError" class="text-xs text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopCloseBehaviorError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div class="text-sm font-medium text-gray-900">启动偏好</div>
|
||||
</div>
|
||||
<div class="px-4 py-3 space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">启动后自动开启实时获取</div>
|
||||
<div class="text-xs text-gray-500">进入聊天页后自动打开“实时开关”(默认关闭)</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="desktopAutoRealtime"
|
||||
@change="onDesktopAutoRealtimeToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">有数据时默认进入聊天页</div>
|
||||
<div class="text-xs text-gray-500">有已解密账号时,打开应用默认跳转到 /chat(默认关闭)</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="desktopDefaultToChatWhenData"
|
||||
@change="onDesktopDefaultToChatToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div class="text-sm font-medium text-gray-900">更新</div>
|
||||
</div>
|
||||
<div class="px-4 py-3 space-y-3">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">当前版本</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ desktopVersionText }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm px-3 py-1.5 rounded-md border border-gray-200 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
:disabled="!isDesktopEnv || desktopUpdate.manualCheckLoading.value"
|
||||
@click="onDesktopCheckUpdates"
|
||||
>
|
||||
{{ desktopUpdate.manualCheckLoading.value ? '检查中...' : '检查更新' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="desktopUpdate.lastCheckMessage.value" class="text-xs text-gray-600 whitespace-pre-wrap break-words">
|
||||
{{ desktopUpdate.lastCheckMessage.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div class="text-sm font-medium text-gray-900">朋友圈</div>
|
||||
</div>
|
||||
<div class="px-4 py-3 space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">朋友圈图片使用缓存</div>
|
||||
<div class="text-xs text-gray-500">开启:下载解密失败时回退本地缓存(默认开启);关闭:每次都走下载+解密</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="snsUseCache"
|
||||
@change="onSnsUseCacheToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
|
||||
useHead({ title: '设置 - 微信数据分析助手' })
|
||||
|
||||
const isDesktopEnv = ref(false)
|
||||
const desktopUpdate = useDesktopUpdate()
|
||||
|
||||
const desktopVersionText = computed(() => {
|
||||
if (!isDesktopEnv.value) return '仅桌面端可用'
|
||||
const v = String(desktopUpdate.currentVersion.value || '').trim()
|
||||
return v || '—'
|
||||
})
|
||||
|
||||
const desktopAutoRealtime = ref(false)
|
||||
const desktopDefaultToChatWhenData = ref(false)
|
||||
const snsUseCache = ref(true)
|
||||
|
||||
const desktopAutoLaunch = ref(false)
|
||||
const desktopAutoLaunchLoading = ref(false)
|
||||
const desktopAutoLaunchError = ref('')
|
||||
|
||||
const desktopCloseBehavior = ref('tray')
|
||||
const desktopCloseBehaviorLoading = ref(false)
|
||||
const desktopCloseBehaviorError = ref('')
|
||||
|
||||
const refreshDesktopAutoLaunch = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getAutoLaunch) return
|
||||
desktopAutoLaunchLoading.value = true
|
||||
desktopAutoLaunchError.value = ''
|
||||
try {
|
||||
desktopAutoLaunch.value = !!(await window.wechatDesktop.getAutoLaunch())
|
||||
} catch (e) {
|
||||
desktopAutoLaunchError.value = e?.message || '读取开机自启动状态失败'
|
||||
} finally {
|
||||
desktopAutoLaunchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDesktopAutoLaunch = async (enabled) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.setAutoLaunch) return
|
||||
desktopAutoLaunchLoading.value = true
|
||||
desktopAutoLaunchError.value = ''
|
||||
try {
|
||||
desktopAutoLaunch.value = !!(await window.wechatDesktop.setAutoLaunch(!!enabled))
|
||||
} catch (e) {
|
||||
desktopAutoLaunchError.value = e?.message || '设置开机自启动失败'
|
||||
await refreshDesktopAutoLaunch()
|
||||
} finally {
|
||||
desktopAutoLaunchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDesktopCloseBehavior = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getCloseBehavior) return
|
||||
desktopCloseBehaviorLoading.value = true
|
||||
desktopCloseBehaviorError.value = ''
|
||||
try {
|
||||
const v = await window.wechatDesktop.getCloseBehavior()
|
||||
desktopCloseBehavior.value = String(v || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
|
||||
} catch (e) {
|
||||
desktopCloseBehaviorError.value = e?.message || '读取关闭窗口行为失败'
|
||||
} finally {
|
||||
desktopCloseBehaviorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDesktopCloseBehavior = async (behavior) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.setCloseBehavior) return
|
||||
const desired = String(behavior || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
|
||||
desktopCloseBehaviorLoading.value = true
|
||||
desktopCloseBehaviorError.value = ''
|
||||
try {
|
||||
const v = await window.wechatDesktop.setCloseBehavior(desired)
|
||||
desktopCloseBehavior.value = String(v || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
|
||||
} catch (e) {
|
||||
desktopCloseBehaviorError.value = e?.message || '设置关闭窗口行为失败'
|
||||
await refreshDesktopCloseBehavior()
|
||||
} finally {
|
||||
desktopCloseBehaviorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onDesktopAutoLaunchToggle = async (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
await setDesktopAutoLaunch(checked)
|
||||
}
|
||||
|
||||
const onDesktopCloseBehaviorChange = async (ev) => {
|
||||
const v = String(ev?.target?.value || '').trim()
|
||||
await setDesktopCloseBehavior(v)
|
||||
}
|
||||
|
||||
const onDesktopAutoRealtimeToggle = (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
desktopAutoRealtime.value = checked
|
||||
writeLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, checked)
|
||||
}
|
||||
|
||||
const onDesktopDefaultToChatToggle = (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
desktopDefaultToChatWhenData.value = checked
|
||||
writeLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, checked)
|
||||
}
|
||||
|
||||
const onSnsUseCacheToggle = (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
snsUseCache.value = checked
|
||||
writeLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, checked)
|
||||
}
|
||||
|
||||
const onDesktopCheckUpdates = async () => {
|
||||
await desktopUpdate.manualCheck()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
|
||||
isDesktopEnv.value = isElectron && !!window.wechatDesktop
|
||||
}
|
||||
|
||||
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
|
||||
desktopDefaultToChatWhenData.value = readLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, false)
|
||||
snsUseCache.value = readLocalBoolSetting(SNS_SETTING_USE_CACHE_KEY, true)
|
||||
|
||||
if (isDesktopEnv.value) {
|
||||
void desktopUpdate.initListeners()
|
||||
await refreshDesktopAutoLaunch()
|
||||
await refreshDesktopCloseBehavior()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -638,7 +638,19 @@
|
||||
>
|
||||
<div class="relative max-w-[92vw] max-h-[92vh] flex flex-col items-center" @click.stop>
|
||||
<video
|
||||
v-if="previewLivePhotoVideoSrc && !previewHasLivePhotoVideoError"
|
||||
v-if="previewIsVideo"
|
||||
ref="previewVideoEl"
|
||||
:key="previewVideoKey"
|
||||
:src="previewVideoSrc"
|
||||
:poster="previewVideoPoster"
|
||||
class="max-w-[90vw] max-h-[70vh] object-contain"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
@error="onPreviewVideoError"
|
||||
></video>
|
||||
<video
|
||||
v-else-if="previewLivePhotoVideoSrc && !previewHasLivePhotoVideoError"
|
||||
ref="previewLiveVideoEl"
|
||||
:src="previewLivePhotoVideoSrc"
|
||||
:poster="previewSrc"
|
||||
@@ -651,6 +663,13 @@
|
||||
></video>
|
||||
<img v-else :src="previewSrc" alt="预览" class="max-w-[90vw] max-h-[70vh] object-contain" />
|
||||
|
||||
<div
|
||||
v-if="previewIsVideo && previewVideoError"
|
||||
class="mt-3 text-xs text-red-200 whitespace-pre-wrap text-center max-w-[90vw]"
|
||||
>
|
||||
{{ previewVideoError }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -794,7 +813,7 @@ const filteredSnsUsers = computed(() => {
|
||||
|
||||
const pageSize = 20
|
||||
|
||||
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
const apiBase = useApiBase()
|
||||
|
||||
// 朋友圈导出(HTML 离线 ZIP)
|
||||
const exportJob = ref(null)
|
||||
@@ -835,8 +854,7 @@ const startSnsExportPolling = (exportId) => {
|
||||
if (!exportId) return
|
||||
|
||||
if (process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined') {
|
||||
const base = 'http://localhost:8000'
|
||||
const url = `${base}/api/sns/exports/${encodeURIComponent(String(exportId))}/events`
|
||||
const url = `${apiBase}/sns/exports/${encodeURIComponent(String(exportId))}/events`
|
||||
try {
|
||||
exportEventSource = new EventSource(url)
|
||||
exportEventSource.onmessage = (ev) => {
|
||||
@@ -867,8 +885,7 @@ const downloadSnsExport = (exportId) => {
|
||||
if (!process.client) return
|
||||
const id = String(exportId || '').trim()
|
||||
if (!id) return
|
||||
const base = 'http://localhost:8000'
|
||||
const url = `${base}/api/sns/exports/${encodeURIComponent(id)}/download`
|
||||
const url = `${apiBase}/sns/exports/${encodeURIComponent(id)}/download`
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
@@ -1109,7 +1126,7 @@ const selfInfo = ref({ wxid: '', nickname: '' })
|
||||
const loadSelfInfo = async () => {
|
||||
if (!selectedAccount.value) return
|
||||
try {
|
||||
const resp = await $fetch(`${mediaBase}/api/sns/self_info?account=${encodeURIComponent(selectedAccount.value)}`)
|
||||
const resp = await $fetch(`${apiBase}/sns/self_info?account=${encodeURIComponent(selectedAccount.value)}`)
|
||||
if (resp && resp.wxid) {
|
||||
selfInfo.value = resp
|
||||
}
|
||||
@@ -1145,7 +1162,7 @@ const selectSnsUser = async (username) => {
|
||||
const getArticleThumbProxyUrl = (contentUrl) => {
|
||||
const u = String(contentUrl || '').trim()
|
||||
if (!u) return ''
|
||||
return `${mediaBase}/api/sns/article_thumb?url=${encodeURIComponent(u)}`
|
||||
return `${apiBase}/sns/article_thumb?url=${encodeURIComponent(u)}`
|
||||
}
|
||||
|
||||
const guessOfficialAccountNameFromTitle = (title) => {
|
||||
@@ -1443,7 +1460,7 @@ const postAvatarUrl = (username) => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
const u = String(username || '').trim()
|
||||
if (!acc || !u) return ''
|
||||
return `${mediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(u)}`
|
||||
return `${apiBase}/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(u)}`
|
||||
}
|
||||
|
||||
const cleanLikeName = (v) => String(v ?? '').replace(/\u00A0/g, ' ').trim()
|
||||
@@ -1460,7 +1477,7 @@ const normalizeMediaUrl = (u) => {
|
||||
try {
|
||||
const host = new URL(raw).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
}
|
||||
} catch {}
|
||||
return raw
|
||||
@@ -1515,8 +1532,10 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
|
||||
if (!raw) return ''
|
||||
const rawLower = raw.toLowerCase()
|
||||
|
||||
// If backend already provides a local media endpoint, keep it as-is.
|
||||
if (rawLower.startsWith('/api/') || rawLower.startsWith('blob:') || rawLower.startsWith('data:')) return raw
|
||||
// If backend already provides a local media endpoint, rewrite it to the effective API base
|
||||
// (so web builds with a custom API port still work).
|
||||
if (rawLower.startsWith('/api/')) return `${apiBase}${raw.slice(4)}`
|
||||
if (rawLower.startsWith('blob:') || rawLower.startsWith('data:')) return raw
|
||||
|
||||
// For Moments images/thumbnails, prefer a backend endpoint that can decrypt local cache.
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
@@ -1568,7 +1587,7 @@ const getSnsMediaUrl = (post, m, idx, rawUrl) => {
|
||||
// Bump this when changing backend matching logic to avoid stale cached wrong images.
|
||||
parts.set('v', '9')
|
||||
parts.set('url', raw)
|
||||
return `${mediaBase}/api/sns/media?${parts.toString()}`
|
||||
return `${apiBase}/sns/media?${parts.toString()}`
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -1589,7 +1608,7 @@ const getSnsVideoUrl = (postId, mediaId) => {
|
||||
// 本地缓存视频
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc || !postId || !mediaId) return ''
|
||||
return `${mediaBase}/api/sns/video?account=${encodeURIComponent(acc)}&post_id=${encodeURIComponent(postId)}&media_id=${encodeURIComponent(mediaId)}`
|
||||
return `${apiBase}/sns/video?account=${encodeURIComponent(acc)}&post_id=${encodeURIComponent(postId)}&media_id=${encodeURIComponent(mediaId)}`
|
||||
}
|
||||
|
||||
const getSnsRemoteVideoSrc = (post, m) => {
|
||||
@@ -1610,7 +1629,7 @@ const getSnsRemoteVideoSrc = (post, m) => {
|
||||
// When cache is disabled, bust browser caching so backend really downloads+decrypts each time.
|
||||
if (!snsUseCache.value) parts.set('_t', String(Date.now()))
|
||||
parts.set('v', '1')
|
||||
return `${mediaBase}/api/sns/video_remote?${parts.toString()}`
|
||||
return `${apiBase}/sns/video_remote?${parts.toString()}`
|
||||
}
|
||||
|
||||
const localVideoStatus = ref({})
|
||||
@@ -1726,7 +1745,7 @@ const getLivePhotoVideoSrc = (post, m, idx = 0) => {
|
||||
if (!snsUseCache.value) parts.set('_t', String(Date.now()))
|
||||
// Version bump for frontend cache busting when endpoint changes.
|
||||
parts.set('v', '1')
|
||||
return `${mediaBase}/api/sns/video_remote?${parts.toString()}`
|
||||
return `${apiBase}/sns/video_remote?${parts.toString()}`
|
||||
}
|
||||
|
||||
// 图片预览 + 候选匹配选择
|
||||
@@ -1756,6 +1775,53 @@ const previewSrc = computed(() => {
|
||||
return getMediaPreviewSrc(ctx.post, ctx.media, ctx.idx)
|
||||
})
|
||||
|
||||
const previewVideoEl = ref(null)
|
||||
const previewVideoMode = ref('') // 'local' | 'remote' | 'raw'
|
||||
const previewVideoError = ref('')
|
||||
const previewVideoTried = reactive({ local: false, remote: false, raw: false })
|
||||
|
||||
const resetPreviewVideo = () => {
|
||||
previewVideoMode.value = ''
|
||||
previewVideoError.value = ''
|
||||
previewVideoTried.local = false
|
||||
previewVideoTried.remote = false
|
||||
previewVideoTried.raw = false
|
||||
}
|
||||
|
||||
const previewIsVideo = computed(() => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return false
|
||||
return Number(ctx.media?.type || 0) === 6
|
||||
})
|
||||
|
||||
const previewVideoPoster = computed(() => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return ''
|
||||
if (Number(ctx.media?.type || 0) !== 6) return ''
|
||||
return getMediaThumbSrc(ctx.post, ctx.media, ctx.idx) || ''
|
||||
})
|
||||
|
||||
const previewVideoSrc = computed(() => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return ''
|
||||
if (Number(ctx.media?.type || 0) !== 6) return ''
|
||||
|
||||
const local = getSnsVideoUrl(ctx.post?.id, ctx.media?.id)
|
||||
const remote = getSnsRemoteVideoSrc(ctx.post, ctx.media)
|
||||
const raw = upgradeTencentHttps(String(ctx.media?.url || '').trim())
|
||||
|
||||
const mode = String(previewVideoMode.value || '').toLowerCase()
|
||||
if (mode === 'local') return local
|
||||
if (mode === 'remote') return remote
|
||||
if (mode === 'raw') return raw
|
||||
return local || remote || raw || ''
|
||||
})
|
||||
|
||||
const previewVideoKey = computed(() => {
|
||||
if (!previewIsVideo.value) return ''
|
||||
return `${String(previewVideoMode.value || '')}:${String(previewVideoSrc.value || '')}`
|
||||
})
|
||||
|
||||
const previewLivePhotoVideoSrc = computed(() => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return ''
|
||||
@@ -1879,6 +1945,7 @@ const loadPreviewCandidates = async ({ reset }) => {
|
||||
|
||||
const openImagePreview = async (post, m, idx = 0) => {
|
||||
if (!process.client) return
|
||||
resetPreviewVideo()
|
||||
// Stop any background hover-playing live photo when opening the preview.
|
||||
activeLivePhotoKey.value = ''
|
||||
// Preview is an intentional action; allow retry even if hover playback failed once.
|
||||
@@ -1898,11 +1965,58 @@ const openImagePreview = async (post, m, idx = 0) => {
|
||||
await loadPreviewCandidates({ reset: true })
|
||||
}
|
||||
|
||||
const openVideoPreview = (post, m, idx = 0) => {
|
||||
if (!process.client) return
|
||||
resetPreviewVideo()
|
||||
activeLivePhotoKey.value = ''
|
||||
|
||||
const local = getSnsVideoUrl(post?.id, m?.id)
|
||||
const remote = getSnsRemoteVideoSrc(post, m)
|
||||
const raw = upgradeTencentHttps(String(m?.url || '').trim())
|
||||
|
||||
if (local) previewVideoMode.value = 'local'
|
||||
else if (remote) previewVideoMode.value = 'remote'
|
||||
else if (raw) previewVideoMode.value = 'raw'
|
||||
else previewVideoError.value = '视频地址缺失。'
|
||||
|
||||
previewCtx.value = { post, media: m, idx: Number(idx) || 0 }
|
||||
previewCandidatesOpen.value = false
|
||||
resetPreviewCandidates()
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const onPreviewVideoError = () => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return
|
||||
if (Number(ctx.media?.type || 0) !== 6) return
|
||||
|
||||
const current = String(previewVideoMode.value || '').toLowerCase()
|
||||
if (current === 'local') previewVideoTried.local = true
|
||||
if (current === 'remote') previewVideoTried.remote = true
|
||||
if (current === 'raw') previewVideoTried.raw = true
|
||||
|
||||
// Fallback order: local -> remote -> raw
|
||||
const remote = getSnsRemoteVideoSrc(ctx.post, ctx.media)
|
||||
if (!previewVideoTried.remote && remote) {
|
||||
previewVideoMode.value = 'remote'
|
||||
return
|
||||
}
|
||||
|
||||
const raw = upgradeTencentHttps(String(ctx.media?.url || '').trim())
|
||||
if (!previewVideoTried.raw && raw) {
|
||||
previewVideoMode.value = 'raw'
|
||||
return
|
||||
}
|
||||
|
||||
previewVideoError.value = '视频加载失败:可能是本地缓存不存在,或远程下载/解密失败。'
|
||||
}
|
||||
|
||||
const closeImagePreview = () => {
|
||||
if (!process.client) return
|
||||
previewCtx.value = null
|
||||
previewCandidatesOpen.value = false
|
||||
resetPreviewCandidates()
|
||||
resetPreviewVideo()
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
@@ -1912,16 +2026,7 @@ const onMediaClick = (post, m, idx = 0) => {
|
||||
|
||||
// 视频点击逻辑
|
||||
if (mt === 6) {
|
||||
// Open a playable mp4 via backend (downloads+decrypts as needed).
|
||||
const remoteUrl = getSnsRemoteVideoSrc(post, m)
|
||||
if (remoteUrl) {
|
||||
window.open(remoteUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
// Last-resort: open raw CDN url.
|
||||
const u = String(m?.url || '').trim()
|
||||
if (u) window.open(u, '_blank', 'noopener,noreferrer')
|
||||
openVideoPreview(post, m, idx)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2114,7 +2219,7 @@ const getProxyExternalUrl = (url) => {
|
||||
// 目前难以计算enc,代理获取封面图(thumbnail)
|
||||
const u = String(url || '').trim()
|
||||
if (!u) return ''
|
||||
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(u)}`
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(u)}`
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div
|
||||
ref="deckEl"
|
||||
class="wrapped-deck-root relative h-screen w-full overflow-hidden transition-colors duration-500"
|
||||
:class="{ 'wrapped-privacy': privacyMode }"
|
||||
:style="{ backgroundColor: currentBg }"
|
||||
>
|
||||
<!-- PPT 风格:单张卡片占据全页面,鼠标滚轮切换 -->
|
||||
@@ -64,11 +65,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右上角:年份选择器(主题化) -->
|
||||
<!-- 右上角:隐私模式 + 年份选择器(主题化) -->
|
||||
<div v-show="!deckChromeHidden" class="absolute top-6 right-6 z-20 pointer-events-auto select-none transition-opacity duration-300">
|
||||
<div class="relative">
|
||||
<div class="absolute -inset-6 rounded-full bg-[#07C160]/10 blur-2xl"></div>
|
||||
<div class="relative flex justify-end">
|
||||
<div class="relative flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="pointer-events-auto inline-flex items-center justify-center w-9 h-9 rounded-full bg-transparent text-[#07C160] hover:bg-[#07C160]/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#07C160]/30 transition"
|
||||
:aria-label="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
|
||||
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
|
||||
@click="privacyStore.toggle"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
:class="privacyMode ? 'text-[#07C160]' : 'text-[#00000080]'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
v-if="privacyMode"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
|
||||
/>
|
||||
<path
|
||||
v-else
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
|
||||
/>
|
||||
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<WrappedYearSelector
|
||||
v-if="yearOptions.length > 1"
|
||||
v-model="year"
|
||||
@@ -105,7 +138,7 @@
|
||||
:style="slideStyle"
|
||||
>
|
||||
<WrappedCardShell
|
||||
v-if="!c || c.status !== 'ok'"
|
||||
v-if="!c || (c.status !== 'ok' && !(c.kind === 'global/bento_summary' || c.id === 7))"
|
||||
:card-id="Number(c?.id || (idx + 1))"
|
||||
:title="c?.title || '正在生成…'"
|
||||
:narrative="c?.status === 'error' ? '生成失败' : (c?.status === 'loading' ? '正在生成本页数据…' : '进入该页后将开始生成')"
|
||||
@@ -181,6 +214,12 @@
|
||||
variant="slide"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
<Card07BentoSummary
|
||||
v-else-if="c && (c.kind === 'global/bento_summary' || c.id === 7)"
|
||||
:card="c"
|
||||
variant="slide"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
<WrappedCardShell
|
||||
v-else
|
||||
:card-id="Number(c?.id || (idx + 1))"
|
||||
@@ -201,6 +240,8 @@
|
||||
|
||||
<script setup>
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
useHead({
|
||||
title: '年度总结 · WeChat Wrapped',
|
||||
@@ -211,6 +252,9 @@ const api = useApi()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const privacyStore = usePrivacyStore()
|
||||
const { privacyMode } = storeToRefs(privacyStore)
|
||||
|
||||
const queryYear = Number(route.query?.year)
|
||||
const defaultYear = new Date().getFullYear() - 1
|
||||
const year = ref(Number.isFinite(queryYear) ? queryYear : defaultYear)
|
||||
@@ -478,6 +522,8 @@ const retryCard = async (cardId) => {
|
||||
await ensureCardLoaded(cardId)
|
||||
}
|
||||
|
||||
provide('wrappedRetryCard', retryCard)
|
||||
|
||||
const reload = async (forceRefresh = false, preserveIndex = false) => {
|
||||
const token = ++reportToken
|
||||
const keepIndex = preserveIndex ? activeIndex.value : 0
|
||||
@@ -552,6 +598,7 @@ watch(activeIndex, (i) => {
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
privacyStore.init()
|
||||
applyViewportBg()
|
||||
updateViewport()
|
||||
if (import.meta.client && typeof ResizeObserver !== 'undefined' && deckEl.value) {
|
||||
|
||||
|
After Width: | Height: | Size: 970 KiB |
|
After Width: | Height: | Size: 914 KiB |
|
After Width: | Height: | Size: 810 KiB |
|
After Width: | Height: | Size: 12 MiB |
|
After Width: | Height: | Size: 12 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 978 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 679 KiB |
|
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 404 KiB After Width: | Height: | Size: 424 KiB |
|
Before Width: | Height: | Size: 562 KiB After Width: | Height: | Size: 468 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.2 MiB |
@@ -89,8 +89,8 @@ export const useChatRealtimeStore = defineStore('chatRealtime', () => {
|
||||
if (!account) return
|
||||
if (typeof EventSource === 'undefined') return
|
||||
|
||||
const base = 'http://localhost:8000'
|
||||
const url = `${base}/api/chat/realtime/stream?account=${encodeURIComponent(account)}`
|
||||
const apiBase = useApiBase()
|
||||
const url = `${apiBase}/chat/realtime/stream?account=${encodeURIComponent(account)}`
|
||||
|
||||
try {
|
||||
eventSource = new EventSource(url)
|
||||
@@ -223,4 +223,3 @@ export const useChatRealtimeStore = defineStore('chatRealtime', () => {
|
||||
toggle,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export const API_BASE_OVERRIDE_KEY = 'ui.apiBaseOverride'
|
||||
|
||||
export const readApiBaseOverride = () => {
|
||||
if (!process.client) return ''
|
||||
try {
|
||||
const raw = localStorage.getItem(API_BASE_OVERRIDE_KEY)
|
||||
return String(raw || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const writeApiBaseOverride = (value) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
const v = String(value || '').trim()
|
||||
if (!v) localStorage.removeItem(API_BASE_OVERRIDE_KEY)
|
||||
else localStorage.setItem(API_BASE_OVERRIDE_KEY, v)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export const normalizeApiBase = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return '/api'
|
||||
|
||||
let v = raw.replace(/\/$/, '')
|
||||
|
||||
// If a full origin is provided, auto-append `/api` when missing.
|
||||
if (/^https?:\/\//i.test(v) && !/\/api$/i.test(v)) {
|
||||
v = `${v}/api`
|
||||
}
|
||||
|
||||
return v.replace(/\/$/, '')
|
||||
}
|
||||
|
||||
@@ -5,23 +5,30 @@
|
||||
使用方法:
|
||||
uv run main.py
|
||||
|
||||
默认在8000端口启动API服务
|
||||
默认在10392端口启动API服务
|
||||
"""
|
||||
|
||||
import uvicorn
|
||||
import os
|
||||
from pathlib import Path
|
||||
from wechat_decrypt_tool.runtime_settings import read_effective_backend_port
|
||||
|
||||
def main():
|
||||
"""启动微信解密工具API服务"""
|
||||
host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("WECHAT_TOOL_PORT", "8000"))
|
||||
port, port_source = read_effective_backend_port(default=10392)
|
||||
access_host = "127.0.0.1" if host in {"0.0.0.0", "::"} else host
|
||||
|
||||
print("=" * 60)
|
||||
print("微信解密工具 API 服务")
|
||||
print("=" * 60)
|
||||
print("正在启动服务...")
|
||||
if port_source == "env":
|
||||
print("端口来源: 环境变量 WECHAT_TOOL_PORT")
|
||||
elif port_source == "settings":
|
||||
print("端口来源: 配置文件 output/runtime_settings.json(由网页/桌面设置写入)")
|
||||
else:
|
||||
print("端口来源: 默认值")
|
||||
print(f"API文档: http://{access_host}:{port}/docs")
|
||||
print(f"健康检查: http://{access_host}:{port}/api/health")
|
||||
print("按 Ctrl+C 停止服务")
|
||||
|
||||
@@ -20,7 +20,7 @@ dependencies = [
|
||||
"pilk>=0.2.4",
|
||||
"pypinyin>=0.53.0",
|
||||
"jieba>=0.42.1",
|
||||
"wx_key",
|
||||
"wx_key>=1.1.0",
|
||||
"packaging",
|
||||
"httpx",
|
||||
]
|
||||
|
||||
@@ -10,8 +10,13 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.responses import FileResponse
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from . import __version__ as APP_VERSION
|
||||
from .logging_config import setup_logging, get_logger
|
||||
|
||||
# 初始化日志系统
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
from . import __version__ as APP_VERSION
|
||||
from .path_fix import PathFixRoute
|
||||
from .chat_realtime_autosync import CHAT_REALTIME_AUTOSYNC
|
||||
from .routers.chat import router as _chat_router
|
||||
@@ -20,6 +25,7 @@ from .routers.chat_export import router as _chat_export_router
|
||||
from .routers.chat_media import router as _chat_media_router
|
||||
from .routers.decrypt import router as _decrypt_router
|
||||
from .routers.health import router as _health_router
|
||||
from .routers.admin import router as _admin_router
|
||||
from .routers.keys import router as _keys_router
|
||||
from .routers.media import router as _media_router
|
||||
from .routers.sns import router as _sns_router
|
||||
@@ -29,10 +35,6 @@ from .routers.wrapped import router as _wrapped_router
|
||||
from .sns_stage_timing import add_sns_stage_timing_headers
|
||||
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
|
||||
|
||||
# 初始化日志系统
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="微信数据库解密工具",
|
||||
description="现代化的微信数据库解密工具,支持微信信息检测和数据库解密功能",
|
||||
@@ -75,6 +77,7 @@ async def _add_sns_stage_timing_headers(request: Request, call_next):
|
||||
|
||||
|
||||
app.include_router(_health_router)
|
||||
app.include_router(_admin_router)
|
||||
app.include_router(_wechat_detection_router)
|
||||
app.include_router(_decrypt_router)
|
||||
app.include_router(_keys_router)
|
||||
@@ -192,6 +195,8 @@ async def _shutdown_wcdb_realtime() -> None:
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
from .runtime_settings import read_effective_backend_port
|
||||
|
||||
host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("WECHAT_TOOL_PORT", "8000"))
|
||||
port, _ = read_effective_backend_port(default=10392)
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
|
||||
@@ -9,11 +9,12 @@ import os
|
||||
import uvicorn
|
||||
|
||||
from wechat_decrypt_tool.api import app
|
||||
from wechat_decrypt_tool.runtime_settings import read_effective_backend_port
|
||||
|
||||
|
||||
def main() -> None:
|
||||
host = os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("WECHAT_TOOL_PORT", "8000"))
|
||||
port, _ = read_effective_backend_port(default=10392)
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
|
||||
|
||||
|
||||
@@ -712,6 +712,68 @@ def _extract_xml_tag_or_attr(xml_text: str, name: str) -> str:
|
||||
return _extract_xml_attr(xml_text, name)
|
||||
|
||||
|
||||
def _parse_location_message(text: str) -> dict[str, Any]:
|
||||
raw = html.unescape(str(text or "").strip())
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
candidate = _strip_cdata(str(value or "").strip())
|
||||
if not candidate:
|
||||
return ""
|
||||
candidate = html.unescape(candidate)
|
||||
candidate = re.sub(r"\s+", " ", candidate).strip()
|
||||
return candidate
|
||||
|
||||
def _to_float(value: Any) -> Optional[float]:
|
||||
s = str(value or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
num = float(s)
|
||||
except Exception:
|
||||
return None
|
||||
if not (-180.0 <= num <= 180.0):
|
||||
return None
|
||||
return num
|
||||
|
||||
poiname = _clean(
|
||||
_extract_xml_tag_or_attr(raw, "poiname")
|
||||
or _extract_xml_tag_or_attr(raw, "poiName")
|
||||
or _extract_xml_tag_or_attr(raw, "name")
|
||||
)
|
||||
label = _clean(
|
||||
_extract_xml_tag_or_attr(raw, "label")
|
||||
or _extract_xml_tag_or_attr(raw, "labelname")
|
||||
or _extract_xml_tag_or_attr(raw, "address")
|
||||
)
|
||||
|
||||
lat = _to_float(
|
||||
_extract_xml_tag_or_attr(raw, "x")
|
||||
or _extract_xml_tag_or_attr(raw, "latitude")
|
||||
or _extract_xml_tag_or_attr(raw, "lat")
|
||||
)
|
||||
lng = _to_float(
|
||||
_extract_xml_tag_or_attr(raw, "y")
|
||||
or _extract_xml_tag_or_attr(raw, "longitude")
|
||||
or _extract_xml_tag_or_attr(raw, "lng")
|
||||
or _extract_xml_tag_or_attr(raw, "lon")
|
||||
)
|
||||
|
||||
if lat is not None and not (-90.0 <= lat <= 90.0):
|
||||
lat = None
|
||||
if lng is not None and not (-180.0 <= lng <= 180.0):
|
||||
lng = None
|
||||
|
||||
title = poiname or label or "位置"
|
||||
return {
|
||||
"renderType": "location",
|
||||
"content": title or "[Location]",
|
||||
"locationLat": lat,
|
||||
"locationLng": lng,
|
||||
"locationPoiname": poiname,
|
||||
"locationLabel": label,
|
||||
}
|
||||
|
||||
|
||||
def _parse_system_message_content(raw_text: str) -> str:
|
||||
text = str(raw_text or "").strip()
|
||||
if not text:
|
||||
@@ -941,11 +1003,40 @@ def _parse_quote_message(text: str) -> str:
|
||||
|
||||
|
||||
def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
app_type_raw = _extract_xml_tag_text(text, "type")
|
||||
try:
|
||||
app_type = int(str(app_type_raw or "0").strip() or "0")
|
||||
except Exception:
|
||||
app_type = 0
|
||||
def _extract_appmsg_type(xml_text: str) -> int:
|
||||
"""提取 <appmsg> 直系子节点的 <type>,避免被 refermsg/recorditem/weappinfo 等嵌套块里的 <type> 干扰。"""
|
||||
|
||||
probe = str(xml_text or "")
|
||||
try:
|
||||
m = re.search(r"<appmsg\b[^>]*>(.*?)</appmsg>", probe, flags=re.IGNORECASE | re.DOTALL)
|
||||
except Exception:
|
||||
m = None
|
||||
|
||||
if m:
|
||||
inner = str(m.group(1) or "")
|
||||
# 一些嵌套块内部也会出现 <type>,先剔除再提取。
|
||||
try:
|
||||
inner = re.sub(r"(<refermsg\b[^>]*>.*?</refermsg>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
|
||||
inner = re.sub(r"(<patmsg\b[^>]*>.*?</patmsg>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
|
||||
inner = re.sub(r"(<recorditem\b[^>]*>.*?</recorditem>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
|
||||
inner = re.sub(r"(<weappinfo\b[^>]*>.*?</weappinfo>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
|
||||
inner = re.sub(r"(<wxaappinfo\b[^>]*>.*?</wxaappinfo>)", "", inner, flags=re.IGNORECASE | re.DOTALL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
t = _extract_xml_tag_text(inner, "type")
|
||||
try:
|
||||
return int(str(t or "0").strip() or "0")
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
t = _extract_xml_tag_text(probe, "type")
|
||||
try:
|
||||
return int(str(t or "0").strip() or "0")
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
app_type = _extract_appmsg_type(text)
|
||||
title = _extract_xml_tag_text(text, "title")
|
||||
des = _extract_xml_tag_text(text, "des")
|
||||
url = _normalize_xml_url(_extract_xml_tag_text(text, "url"))
|
||||
@@ -1006,6 +1097,49 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
"linkStyle": link_style,
|
||||
}
|
||||
|
||||
if app_type in (33, 36):
|
||||
# 小程序分享(WeChat v4 常见:local_type = 49 + (33<<32) / 49 + (36<<32))
|
||||
# 注:部分 payload 的 <url> 为空;前端会按需渲染为不可点击卡片。
|
||||
weapp_block = _extract_xml_tag_text(text, "weappinfo") or _extract_xml_tag_text(text, "wxaappinfo")
|
||||
weapp_username = _extract_xml_tag_text(weapp_block, "username") if weapp_block else ""
|
||||
weapp_icon = _normalize_xml_url(
|
||||
_extract_xml_tag_or_attr(weapp_block, "weappiconurl") if weapp_block else ""
|
||||
) or _normalize_xml_url(_extract_xml_tag_or_attr(text, "weappiconurl"))
|
||||
|
||||
thumb_url = _normalize_xml_url(
|
||||
_extract_xml_tag_or_attr(text, "thumburl")
|
||||
or _extract_xml_tag_or_attr(text, "cdnthumburl")
|
||||
or _extract_xml_tag_or_attr(text, "coverurl")
|
||||
or _extract_xml_tag_or_attr(text, "cover")
|
||||
or weapp_icon
|
||||
)
|
||||
|
||||
from_display = str(source_display_name or "").strip()
|
||||
if not from_display and weapp_block:
|
||||
from_display = (
|
||||
_extract_xml_tag_text(weapp_block, "nickname")
|
||||
or _extract_xml_tag_text(weapp_block, "appname")
|
||||
or ""
|
||||
)
|
||||
if not from_display:
|
||||
from_display = str(_extract_xml_tag_text(text, "sourcename") or "").strip()
|
||||
|
||||
from_u = str(weapp_username or source_username or "").strip()
|
||||
|
||||
content_text = (des or title or "[Mini Program]").strip() or "[Mini Program]"
|
||||
title_text = (title or des or "").strip()
|
||||
return {
|
||||
"renderType": "link",
|
||||
"content": content_text,
|
||||
"title": title_text or content_text,
|
||||
"url": url or "",
|
||||
"thumbUrl": thumb_url or "",
|
||||
"from": from_display,
|
||||
"fromUsername": from_u,
|
||||
"linkType": "mini_program",
|
||||
"linkStyle": "default",
|
||||
}
|
||||
|
||||
if app_type in (6, 74):
|
||||
file_name = title or ""
|
||||
total_len = _extract_xml_tag_text(text, "totallen")
|
||||
@@ -1303,6 +1437,14 @@ def _build_latest_message_preview(
|
||||
content_text = "[视频]"
|
||||
elif local_type == 47:
|
||||
content_text = "[动画表情]"
|
||||
elif local_type == 48:
|
||||
parsed = _parse_location_message(raw_text)
|
||||
location_name = (
|
||||
str(parsed.get("locationPoiname") or "").strip()
|
||||
or str(parsed.get("locationLabel") or "").strip()
|
||||
or str(parsed.get("content") or "").strip()
|
||||
)
|
||||
content_text = f"[位置]{location_name}" if location_name else "[位置]"
|
||||
else:
|
||||
if raw_text and (not raw_text.startswith("<")) and (not raw_text.startswith('"<')):
|
||||
content_text = raw_text
|
||||
@@ -1347,6 +1489,7 @@ def _normalize_session_preview_text(
|
||||
return ""
|
||||
|
||||
text = text.replace("[表情]", "[动画表情]")
|
||||
text = re.sub(r"\[location\]", "[位置]", text, flags=re.IGNORECASE)
|
||||
if (not is_group) or text.startswith("[草稿]"):
|
||||
return text
|
||||
|
||||
@@ -2021,6 +2164,10 @@ def _row_to_search_hit(
|
||||
pay_sub_type = ""
|
||||
transfer_status = ""
|
||||
voip_type = ""
|
||||
location_lat: Optional[float] = None
|
||||
location_lng: Optional[float] = None
|
||||
location_poiname = ""
|
||||
location_label = ""
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
@@ -2075,6 +2222,14 @@ def _row_to_search_hit(
|
||||
elif local_type == 47:
|
||||
render_type = "emoji"
|
||||
content_text = "[表情]"
|
||||
elif local_type == 48:
|
||||
parsed = _parse_location_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "location")
|
||||
content_text = str(parsed.get("content") or "[Location]")
|
||||
location_lat = parsed.get("locationLat")
|
||||
location_lng = parsed.get("locationLng")
|
||||
location_poiname = str(parsed.get("locationPoiname") or "")
|
||||
location_label = str(parsed.get("locationLabel") or "")
|
||||
elif local_type == 50:
|
||||
render_type = "voip"
|
||||
try:
|
||||
@@ -2162,4 +2317,8 @@ def _row_to_search_hit(
|
||||
"paySubType": pay_sub_type,
|
||||
"transferStatus": transfer_status,
|
||||
"voipType": voip_type,
|
||||
"locationLat": location_lat,
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
}
|
||||
|
||||
@@ -35,10 +35,12 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class HookConfig:
|
||||
min_version: str
|
||||
pattern: str # 用 00 不要用 ? !!!! 否则C++内存会炸
|
||||
pattern: str
|
||||
mask: str
|
||||
offset: int
|
||||
|
||||
md5_pattern: str = ""
|
||||
md5_mask: str = ""
|
||||
md5_offset: int = 0
|
||||
|
||||
class WeChatKeyFetcher:
|
||||
def __init__(self):
|
||||
@@ -50,13 +52,13 @@ class WeChatKeyFetcher:
|
||||
return " ".join([f"{b:02X}" for b in hex_array])
|
||||
|
||||
def _get_hook_config(self, version_str: str) -> Optional[HookConfig]:
|
||||
"""搬运自wx_key代码,未来用ida脚本直接获取即可"""
|
||||
try:
|
||||
v_curr = pkg_version.parse(version_str)
|
||||
except Exception as e:
|
||||
logger.error(f"版本号解析失败: {version_str} || {e}")
|
||||
return None
|
||||
|
||||
|
||||
if v_curr > pkg_version.parse("4.1.6.14"):
|
||||
return HookConfig(
|
||||
min_version=">4.1.6.14",
|
||||
@@ -66,7 +68,10 @@ class WeChatKeyFetcher:
|
||||
0x89, 0xCE, 0x48, 0x89
|
||||
]),
|
||||
mask="xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
offset=-3
|
||||
offset=-3,
|
||||
md5_pattern="48 8D 4D 00 48 89 4D B0 48 89 45 B8 48 8D 7D 00 48 8D 55 B0 48 89 F9",
|
||||
md5_mask="xxx?xxxxxxxxxxx?xxxxxxx",
|
||||
md5_offset=4
|
||||
)
|
||||
|
||||
if pkg_version.parse("4.1.4") <= v_curr <= pkg_version.parse("4.1.6.14"):
|
||||
@@ -78,10 +83,14 @@ class WeChatKeyFetcher:
|
||||
0x83, 0xec, 0x50, 0x41
|
||||
]),
|
||||
mask="xxxxxxxxxx?xxxx?xxxxxxxx",
|
||||
offset=-3
|
||||
offset=-3,
|
||||
md5_pattern="48 8D 4D 00 48 89 4D B0 48 89 45 B8 48 8D 7D 00 48 8D 55 B0 48 89 F9",
|
||||
md5_mask="xxx?xxxxxxxxxxx?xxxxxxx",
|
||||
md5_offset=4
|
||||
)
|
||||
|
||||
if v_curr < pkg_version.parse("4.1.4"):
|
||||
"""图片密钥可能是错的,版本过低没有测试"""
|
||||
return HookConfig(
|
||||
min_version="<4.1.4",
|
||||
pattern=self._hex_array_to_str([
|
||||
@@ -90,7 +99,10 @@ class WeChatKeyFetcher:
|
||||
0x89, 0xce, 0x48, 0x89
|
||||
]),
|
||||
mask="xxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
offset=-15 # -0xf
|
||||
offset=-15, # -0xf
|
||||
md5_pattern="48 8D 4D 00 48 89 4D B0 48 89 45 B8 48 8D 7D 00 48 8D 55 B0 48 89 F9",
|
||||
md5_mask="xxx?xxxxxxxxxxx?xxxxxxx",
|
||||
md5_offset=4
|
||||
)
|
||||
|
||||
return None
|
||||
@@ -134,13 +146,12 @@ class WeChatKeyFetcher:
|
||||
logger.error(f"启动微信失败: {e}")
|
||||
raise RuntimeError(f"无法启动微信: {e}")
|
||||
|
||||
def fetch_key(self) -> str:
|
||||
"""没有wx_key模块无法自动获取密钥"""
|
||||
def fetch_key(self) -> dict:
|
||||
"""调用 wx_key 获取双密钥"""
|
||||
if wx_key is None:
|
||||
raise RuntimeError("wx_key 模块未安装或加载失败")
|
||||
|
||||
install_info = detect_wechat_installation()
|
||||
|
||||
exe_path = install_info.get('wechat_exe_path')
|
||||
version = install_info.get('wechat_version')
|
||||
|
||||
@@ -151,30 +162,34 @@ class WeChatKeyFetcher:
|
||||
|
||||
config = self._get_hook_config(version)
|
||||
if not config:
|
||||
raise RuntimeError(f"不支持的微信版本: {version}")
|
||||
raise RuntimeError(f"原生获取失败:当前微信版本 ({version}) 过低,为保证稳定性,仅支持 4.1.5 及以上版本使用原生获取。")
|
||||
|
||||
self.kill_wechat()
|
||||
|
||||
pid = self.launch_wechat(exe_path)
|
||||
logger.info(f"WeChat launched, PID: {pid}")
|
||||
|
||||
logger.info(f"Initializing Hook with pattern: {config.pattern[:20]}... Offset: {config.offset}")
|
||||
|
||||
if not wx_key.initialize_hook(pid, "", config.pattern, config.mask, config.offset):
|
||||
if not wx_key.initialize_hook(pid, "", config.pattern, config.mask, config.offset,
|
||||
config.md5_pattern, config.md5_mask, config.md5_offset):
|
||||
err = wx_key.get_last_error_msg()
|
||||
raise RuntimeError(f"Hook初始化失败: {err}")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
found_db_key = None
|
||||
found_md5_data = None
|
||||
|
||||
try:
|
||||
while True:
|
||||
if time.time() - start_time > self.timeout_seconds:
|
||||
raise TimeoutError("获取密钥超时 (60s)")
|
||||
raise TimeoutError("获取密钥超时 (60s),请确保在弹出的微信中完成登录。")
|
||||
|
||||
key = wx_key.poll_key_data()
|
||||
if key:
|
||||
found_key = key
|
||||
key_data = wx_key.poll_key_data()
|
||||
if key_data:
|
||||
if 'key' in key_data:
|
||||
found_db_key = key_data['key']
|
||||
if 'md5' in key_data:
|
||||
found_md5_data = key_data['md5']
|
||||
|
||||
if found_db_key and found_md5_data:
|
||||
break
|
||||
|
||||
while True:
|
||||
@@ -185,15 +200,22 @@ class WeChatKeyFetcher:
|
||||
logger.error(f"[Hook Error] {msg}")
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
finally:
|
||||
logger.info("Cleaning up hook...")
|
||||
wx_key.cleanup_hook()
|
||||
|
||||
if found_key:
|
||||
return found_key
|
||||
else:
|
||||
raise RuntimeError("未知错误,未获取到密钥")
|
||||
aes_key = None # gemini !!! ???
|
||||
xor_key = None
|
||||
|
||||
if found_md5_data and "|" in found_md5_data:
|
||||
aes_key, xor_key_dec = found_md5_data.split("|")
|
||||
xor_key = f"0x{int(xor_key_dec):02X}"
|
||||
|
||||
return {
|
||||
"db_key": found_db_key,
|
||||
"aes_key": aes_key,
|
||||
"xor_key": xor_key
|
||||
}
|
||||
|
||||
def get_db_key_workflow():
|
||||
fetcher = WeChatKeyFetcher()
|
||||
@@ -202,73 +224,11 @@ def get_db_key_workflow():
|
||||
|
||||
# ============================== 以下是图片密钥逻辑 =====================================
|
||||
|
||||
|
||||
# 远程 API 配置
|
||||
REMOTE_URL = "https://view.free.c3o.re/dashboard"
|
||||
BASE_URL = "https://view.free.c3o.re" # 用于拼接js
|
||||
|
||||
# NEXT_ACTION_ID = "7c8f99280c70626ccf5960cc4a68f368197e15f8e9" # 不可以硬编码
|
||||
|
||||
|
||||
async def fetch_js_and_scan(client: httpx.AsyncClient, js_path: str) -> Optional[str]:
|
||||
"""
|
||||
异步下载单个 JS 文件并匹配 Action ID
|
||||
"""
|
||||
full_url = f"{BASE_URL}{js_path}" if js_path.startswith("/") else js_path
|
||||
try:
|
||||
response = await client.get(full_url)
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
content = response.text
|
||||
|
||||
action_id_pattern = re.compile(r'createServerReference.*?["\']([a-f0-9]{42})["\'].*?["\']getUserConfigFromBytes["\']')
|
||||
|
||||
match = action_id_pattern.search(content)
|
||||
if match:
|
||||
found_id = match.group(1)
|
||||
return found_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching {js_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _get_next_action_id_async() -> str:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
|
||||
resp = await client.get(REMOTE_URL)
|
||||
html = resp.text
|
||||
|
||||
js_file_pattern = re.compile(r'src="(/_next/static/chunks/[^"]+\.js)"')
|
||||
js_files = set(js_file_pattern.findall(html))
|
||||
|
||||
if not js_files:
|
||||
raise Exception("未找到任何 Next.js chunk 文件,可能页面结构已变动。")
|
||||
|
||||
tasks = [fetch_js_and_scan(client, js_path) for js_path in js_files]
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
for res in results:
|
||||
if res:
|
||||
return res
|
||||
|
||||
raise Exception("遍历了所有 JS 文件,但未找到匹配的 createServerReference ID。")
|
||||
|
||||
|
||||
def get_wechat_internal_global_config(wx_dir: Path, file_name1) -> bytes:
|
||||
"""
|
||||
读微信目录下的主配置文件
|
||||
"""
|
||||
xwechat_files_root = wx_dir.parent
|
||||
|
||||
target_path = os.path.join(xwechat_files_root, "all_users", "config", file_name1)
|
||||
|
||||
if not os.path.exists(target_path):
|
||||
logger.error(f"未找到微信内部 global_config: {target_path}")
|
||||
raise FileNotFoundError(f"找不到文件: {target_path},请确认微信数据目录结构是否完整")
|
||||
|
||||
raise FileNotFoundError(f"找不到配置文件: {target_path},请确认微信数据目录结构是否完整")
|
||||
return Path(target_path).read_bytes()
|
||||
|
||||
|
||||
@@ -278,90 +238,36 @@ async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str,
|
||||
wx_id_dir = _resolve_account_wxid_dir(account_dir)
|
||||
wxid = wx_id_dir.name
|
||||
|
||||
logger.info("尝试获取next_action_id")
|
||||
try:
|
||||
next_action_id = await _get_next_action_id_async()
|
||||
logger.info(f"获取next_action_id成功: {next_action_id}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"获取next_action_id失败:{e}")
|
||||
url = "https://view.free.c3o.re/api/key"
|
||||
data = {"weixinIDFolder": wxid}
|
||||
|
||||
|
||||
logger.info(f"正在为账号 {wxid} 获取密钥...")
|
||||
logger.info(f"正在为账号 {wxid} 获取云端备选图片密钥...")
|
||||
|
||||
try:
|
||||
blob1_bytes = get_wechat_internal_global_config(wx_id_dir, file_name1= "global_config") # 估计这是唯一有效的数据!!
|
||||
logger.info(f"获取微信内部配置成功,大小: {len(blob1_bytes)} bytes")
|
||||
blob1_bytes = get_wechat_internal_global_config(wx_id_dir, file_name1="global_config")
|
||||
blob2_bytes = get_wechat_internal_global_config(wx_id_dir, file_name1="global_config.crc")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取微信内部文件失败: {e}")
|
||||
|
||||
try:
|
||||
blob2_bytes = get_wechat_internal_global_config(wx_id_dir, file_name1= "global_config.crc")
|
||||
logger.info(f"获取微信内部配置成功,大小: {len(blob2_bytes)} bytes")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"读取微信内部文件失败: {e}")
|
||||
|
||||
blob3_bytes = b""
|
||||
|
||||
headers = {
|
||||
"Accept": "text/x-component",
|
||||
"Next-Action": next_action_id,
|
||||
"Next-Router-State-Tree": "%5B%22%22%2C%7B%22children%22%3A%5B%22dashboard%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D",
|
||||
"Origin": "https://view.free.c3o.re",
|
||||
"Referer": "https://view.free.c3o.re/dashboard",
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
||||
}
|
||||
|
||||
files = {
|
||||
'1': ('blob', blob1_bytes, 'application/octet-stream'),
|
||||
'2': ('blob', blob2_bytes, 'application/octet-stream'),
|
||||
'3': ('blob', blob3_bytes, 'application/octet-stream'),
|
||||
'0': (None, json.dumps([wxid, "$A1", "$A2", "$A3", 0],separators=(",",":")).encode('utf-8')),
|
||||
'fileBytes': ('file', blob1_bytes, 'application/octet-stream'),
|
||||
'crcBytes': ('file.crc', blob2_bytes, 'application/octet-stream'),
|
||||
}
|
||||
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
logger.info("向远程服务器发送请求...")
|
||||
response = await client.post(REMOTE_URL, headers=headers, files=files)
|
||||
logger.info("向云端 API 发送请求...")
|
||||
response = await client.post(url, data=data, files=files)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"远程服务器错误: {response.status_code} - {response.text[:100]}")
|
||||
raise RuntimeError(f"云端服务器错误: {response.status_code} - {response.text[:100]}")
|
||||
|
||||
config = response.json()
|
||||
if not config:
|
||||
raise RuntimeError("云端解析失败: 返回数据为空")
|
||||
|
||||
result_data = {}
|
||||
lines = response.text.split('\n')
|
||||
|
||||
found_config = False
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.startswith('1:'):
|
||||
try:
|
||||
json_part = line[2:] # 去掉 "1:"
|
||||
data_obj = json.loads(json_part)
|
||||
|
||||
if "config" in data_obj:
|
||||
config = data_obj["config"]
|
||||
result_data = {
|
||||
"xor_key": config.get("xor_key", ""),
|
||||
"aes_key": config.get("aes_key", ""),
|
||||
"nick_name": config.get("nick_name", ""),
|
||||
"avatar_url": config.get("avatar_url", "")
|
||||
}
|
||||
found_config = True
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"解析响应行失败: {e}")
|
||||
continue
|
||||
|
||||
if not found_config or not result_data.get("aes_key"):
|
||||
logger.error(f"响应中未找到密钥信息。Full Response: {response.text[:500]}")
|
||||
raise RuntimeError("解析失败: 服务器未返回 config 数据")
|
||||
|
||||
# 6. 处理并保存密钥
|
||||
xor_raw = str(result_data["xor_key"])
|
||||
aes_val = str(result_data["aes_key"])
|
||||
# 新 API 的字段兼容处理
|
||||
xor_raw = str(config.get("xorKey", config.get("xor_key", "")))
|
||||
aes_val = str(config.get("aesKey", config.get("aes_key", "")))
|
||||
|
||||
try:
|
||||
if xor_raw.startswith("0x"):
|
||||
@@ -382,6 +288,5 @@ async def fetch_and_save_remote_keys(account: Optional[str] = None) -> Dict[str,
|
||||
"wxid": wxid,
|
||||
"xor_key": xor_hex_str,
|
||||
"aes_key": aes_val,
|
||||
"nick_name": result_data["nick_name"]
|
||||
}
|
||||
|
||||
"nick_name": config.get("nickName", config.get("nick_name", ""))
|
||||
}
|
||||
@@ -53,9 +53,9 @@ class WeChatLogger:
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not self._initialized:
|
||||
self.setup_logging()
|
||||
WeChatLogger._initialized = True
|
||||
# Lazy-init in `setup_logging()` / accessors to avoid double-initialization when
|
||||
# callers instantiate the manager and then call `setup_logging()` again.
|
||||
pass
|
||||
|
||||
def setup_logging(self, log_level: str = "INFO"):
|
||||
"""设置日志配置"""
|
||||
@@ -66,7 +66,9 @@ class WeChatLogger:
|
||||
|
||||
# 创建日志目录
|
||||
now = datetime.now()
|
||||
log_dir = Path("output/logs") / str(now.year) / f"{now.month:02d}" / f"{now.day:02d}"
|
||||
from .app_paths import get_output_dir
|
||||
|
||||
log_dir = get_output_dir() / "logs" / str(now.year) / f"{now.month:02d}" / f"{now.day:02d}"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 设置日志文件名
|
||||
@@ -77,6 +79,10 @@ class WeChatLogger:
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 配置日志格式
|
||||
# 文件格式(无颜色)
|
||||
@@ -109,22 +115,48 @@ class WeChatLogger:
|
||||
|
||||
# 只为uvicorn日志器添加文件处理器,保持其原有的控制台处理器(带颜色)
|
||||
uvicorn_logger = logging.getLogger("uvicorn")
|
||||
for handler in uvicorn_logger.handlers[:]:
|
||||
if isinstance(handler, logging.FileHandler):
|
||||
uvicorn_logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
uvicorn_logger.addHandler(file_handler)
|
||||
uvicorn_logger.setLevel(level)
|
||||
|
||||
# 只为uvicorn.access日志器添加文件处理器
|
||||
uvicorn_access_logger = logging.getLogger("uvicorn.access")
|
||||
for handler in uvicorn_access_logger.handlers[:]:
|
||||
if isinstance(handler, logging.FileHandler):
|
||||
uvicorn_access_logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
uvicorn_access_logger.addHandler(file_handler)
|
||||
uvicorn_access_logger.setLevel(level)
|
||||
|
||||
# 只为uvicorn.error日志器添加文件处理器
|
||||
uvicorn_error_logger = logging.getLogger("uvicorn.error")
|
||||
for handler in uvicorn_error_logger.handlers[:]:
|
||||
if isinstance(handler, logging.FileHandler):
|
||||
uvicorn_error_logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
uvicorn_error_logger.addHandler(file_handler)
|
||||
uvicorn_error_logger.setLevel(level)
|
||||
|
||||
# 配置FastAPI日志器
|
||||
fastapi_logger = logging.getLogger("fastapi")
|
||||
fastapi_logger.handlers = []
|
||||
for handler in fastapi_logger.handlers[:]:
|
||||
fastapi_logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
fastapi_logger.addHandler(file_handler)
|
||||
fastapi_logger.addHandler(console_handler)
|
||||
fastapi_logger.setLevel(level)
|
||||
@@ -136,6 +168,8 @@ class WeChatLogger:
|
||||
logger.info(f"日志文件: {self.log_file}")
|
||||
logger.info(f"日志级别: {logging.getLevelName(level)}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
WeChatLogger._initialized = True
|
||||
|
||||
return self.log_file
|
||||
|
||||
@@ -145,6 +179,8 @@ class WeChatLogger:
|
||||
|
||||
def get_log_file_path(self) -> Path:
|
||||
"""获取当前日志文件路径"""
|
||||
if not hasattr(self, "log_file"):
|
||||
self.setup_logging()
|
||||
return self.log_file
|
||||
|
||||
|
||||
@@ -157,10 +193,14 @@ def setup_logging(log_level: str = "INFO") -> Path:
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""获取日志器的便捷函数"""
|
||||
logger_manager = WeChatLogger()
|
||||
if not WeChatLogger._initialized:
|
||||
logger_manager.setup_logging()
|
||||
return logger_manager.get_logger(name)
|
||||
|
||||
|
||||
def get_log_file_path() -> Path:
|
||||
"""获取当前日志文件路径的便捷函数"""
|
||||
logger_manager = WeChatLogger()
|
||||
if not WeChatLogger._initialized:
|
||||
logger_manager.setup_logging()
|
||||
return logger_manager.get_log_file_path()
|
||||
|
||||
@@ -1964,6 +1964,114 @@ def _convert_silk_to_wav(silk_data: bytes) -> bytes:
|
||||
return silk_data
|
||||
|
||||
|
||||
def _looks_like_mp3(data: bytes) -> bool:
|
||||
if not data:
|
||||
return False
|
||||
if data.startswith(b"ID3"):
|
||||
return True
|
||||
return len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _find_ffmpeg_executable() -> str:
|
||||
import shutil
|
||||
|
||||
env_value = str(os.environ.get("WECHAT_TOOL_FFMPEG") or "").strip()
|
||||
if env_value:
|
||||
resolved = shutil.which(env_value)
|
||||
if resolved:
|
||||
return resolved
|
||||
candidate = Path(env_value).expanduser()
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
|
||||
return shutil.which("ffmpeg") or ""
|
||||
|
||||
|
||||
def _convert_wav_to_mp3(wav_data: bytes) -> bytes:
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
if not wav_data or not wav_data.startswith(b"RIFF"):
|
||||
return b""
|
||||
|
||||
ffmpeg_exe = _find_ffmpeg_executable()
|
||||
if not ffmpeg_exe:
|
||||
return b""
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
wav_path = tmp_path / "voice.wav"
|
||||
mp3_path = tmp_path / "voice.mp3"
|
||||
wav_path.write_bytes(wav_data)
|
||||
|
||||
proc = subprocess.run(
|
||||
[
|
||||
ffmpeg_exe,
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(wav_path),
|
||||
"-vn",
|
||||
"-codec:a",
|
||||
"libmp3lame",
|
||||
"-q:a",
|
||||
"4",
|
||||
str(mp3_path),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
if proc.returncode != 0 or not mp3_path.exists():
|
||||
err = proc.stderr.decode("utf-8", errors="ignore").strip()
|
||||
if err:
|
||||
logger.warning(f"WAV to MP3 conversion failed: {err}")
|
||||
return b""
|
||||
|
||||
mp3_data = mp3_path.read_bytes()
|
||||
if _looks_like_mp3(mp3_data):
|
||||
return mp3_data
|
||||
except Exception as e:
|
||||
logger.warning(f"WAV to MP3 conversion failed: {e}")
|
||||
|
||||
return b""
|
||||
|
||||
|
||||
def _convert_silk_to_browser_audio(
|
||||
silk_data: bytes,
|
||||
*,
|
||||
preferred_format: str = "mp3",
|
||||
) -> tuple[bytes, str, str]:
|
||||
"""Convert SILK audio to a browser-friendly format.
|
||||
|
||||
Returns `(payload, ext, media_type)`.
|
||||
Preference order:
|
||||
1) MP3 if ffmpeg is available
|
||||
2) WAV if SILK decoding succeeds
|
||||
3) original SILK bytes as a last-resort fallback
|
||||
"""
|
||||
|
||||
data = bytes(silk_data or b"")
|
||||
if not data:
|
||||
return b"", "silk", "audio/silk"
|
||||
|
||||
if _looks_like_mp3(data):
|
||||
return data, "mp3", "audio/mpeg"
|
||||
|
||||
wav_data = data if data.startswith(b"RIFF") else _convert_silk_to_wav(data)
|
||||
if wav_data.startswith(b"RIFF"):
|
||||
if str(preferred_format or "").strip().lower() == "mp3":
|
||||
mp3_data = _convert_wav_to_mp3(wav_data)
|
||||
if mp3_data:
|
||||
return mp3_data, "mp3", "audio/mpeg"
|
||||
return wav_data, "wav", "audio/wav"
|
||||
|
||||
return data, "silk", "audio/silk"
|
||||
|
||||
|
||||
def _resolve_media_path_for_kind(
|
||||
account_dir: Path,
|
||||
kind: str,
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..runtime_settings import read_effective_backend_port, write_backend_port_env_file, write_backend_port_setting
|
||||
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
|
||||
DEFAULT_BACKEND_PORT = 10392
|
||||
_PORT_CHANGE_IN_PROGRESS = False
|
||||
|
||||
|
||||
def _format_host_for_url(host: str) -> str:
|
||||
h = str(host or "").strip() or "127.0.0.1"
|
||||
if ":" in h and not (h.startswith("[") and h.endswith("]")):
|
||||
return f"[{h}]"
|
||||
return h
|
||||
|
||||
|
||||
def _get_backend_bind_host() -> str:
|
||||
return str(os.environ.get("WECHAT_TOOL_HOST", "127.0.0.1") or "").strip() or "127.0.0.1"
|
||||
|
||||
|
||||
def _get_backend_access_host() -> str:
|
||||
host = _get_backend_bind_host()
|
||||
if host in {"0.0.0.0", "::"}:
|
||||
return "127.0.0.1"
|
||||
return host
|
||||
|
||||
|
||||
def _is_loopback_client(request: Request) -> bool:
|
||||
client = request.client
|
||||
host = str(getattr(client, "host", "") or "").strip()
|
||||
if not host:
|
||||
return False
|
||||
try:
|
||||
ip = ipaddress.ip_address(host)
|
||||
if ip.is_loopback:
|
||||
return True
|
||||
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped and ip.ipv4_mapped.is_loopback:
|
||||
return True
|
||||
except ValueError:
|
||||
if host.lower() == "localhost":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_port_available(port: int, host: str) -> bool:
|
||||
try:
|
||||
addr = (host, int(port))
|
||||
family = socket.AF_INET6 if ":" in host else socket.AF_INET
|
||||
with socket.socket(family, socket.SOCK_STREAM) as s:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
|
||||
s.bind(addr)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def _wait_for_backend_ready(health_url: str, timeout_s: float = 30.0) -> bool:
|
||||
started = time.time()
|
||||
async with httpx.AsyncClient(timeout=1.0) as client:
|
||||
while time.time() - started < timeout_s:
|
||||
try:
|
||||
resp = await client.get(health_url)
|
||||
if resp.status_code < 500:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
await asyncio.sleep(0.3)
|
||||
return False
|
||||
|
||||
|
||||
def _spawn_backend_process(next_port: int) -> subprocess.Popen:
|
||||
env = os.environ.copy()
|
||||
env["WECHAT_TOOL_PORT"] = str(int(next_port))
|
||||
env.setdefault("WECHAT_TOOL_HOST", _get_backend_bind_host())
|
||||
|
||||
# Keep the same working directory so output paths remain consistent.
|
||||
# (When `WECHAT_TOOL_DATA_DIR` is not set, the app uses `Path.cwd()`.)
|
||||
cwd = os.getcwd()
|
||||
cwd_path = Path(cwd)
|
||||
|
||||
# Ensure local imports work when running from source (repo root + src layout).
|
||||
src_dir = cwd_path / "src"
|
||||
try:
|
||||
existing_pp = str(env.get("PYTHONPATH", "") or "").strip()
|
||||
if src_dir.is_dir():
|
||||
env["PYTHONPATH"] = str(src_dir) if not existing_pp else f"{src_dir}{os.pathsep}{existing_pp}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
cmd = [sys.executable]
|
||||
spawn_cwd = cwd
|
||||
else:
|
||||
main_py = cwd_path / "main.py"
|
||||
if main_py.is_file():
|
||||
cmd = [sys.executable, str(main_py)]
|
||||
spawn_cwd = cwd
|
||||
else:
|
||||
cmd = [sys.executable, "-m", "wechat_decrypt_tool.backend_entry"]
|
||||
spawn_cwd = cwd
|
||||
|
||||
return subprocess.Popen(cmd, cwd=spawn_cwd, env=env)
|
||||
|
||||
|
||||
async def _exit_process_after(delay_s: float) -> None:
|
||||
try:
|
||||
await asyncio.sleep(max(0.0, float(delay_s)))
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(0) # noqa: S404
|
||||
|
||||
|
||||
@router.get("/api/admin/port", summary="获取后端端口(用于前端设置页)")
|
||||
async def get_backend_port() -> dict:
|
||||
port, source = read_effective_backend_port(default=DEFAULT_BACKEND_PORT)
|
||||
return {"port": port, "source": source, "default_port": DEFAULT_BACKEND_PORT}
|
||||
|
||||
|
||||
@router.post("/api/admin/port", summary="修改后端端口并重启(仅允许本机访问)")
|
||||
async def set_backend_port(payload: dict, request: Request, background_tasks: BackgroundTasks) -> dict:
|
||||
if not _is_loopback_client(request):
|
||||
raise HTTPException(status_code=403, detail="仅允许本机访问该接口")
|
||||
|
||||
global _PORT_CHANGE_IN_PROGRESS
|
||||
if _PORT_CHANGE_IN_PROGRESS:
|
||||
raise HTTPException(status_code=409, detail="端口切换中,请稍后重试")
|
||||
|
||||
raw = payload.get("port") if isinstance(payload, dict) else None
|
||||
try:
|
||||
next_port = int(raw)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="端口无效:请输入 1-65535 的整数")
|
||||
if next_port < 1 or next_port > 65535:
|
||||
raise HTTPException(status_code=400, detail="端口无效:请输入 1-65535 的整数")
|
||||
|
||||
current_port, _ = read_effective_backend_port(default=DEFAULT_BACKEND_PORT)
|
||||
if next_port == int(current_port):
|
||||
write_backend_port_setting(next_port)
|
||||
env_file = write_backend_port_env_file(next_port)
|
||||
host = _format_host_for_url(_get_backend_access_host())
|
||||
return {
|
||||
"success": True,
|
||||
"changed": False,
|
||||
"port": next_port,
|
||||
"ui_url": f"http://{host}:{next_port}/",
|
||||
"env_file": str(env_file) if env_file else None,
|
||||
}
|
||||
|
||||
bind_host = _get_backend_bind_host()
|
||||
if not _is_port_available(next_port, bind_host):
|
||||
raise HTTPException(status_code=409, detail=f"端口 {next_port} 已被占用,请换一个端口")
|
||||
|
||||
proc = None
|
||||
_PORT_CHANGE_IN_PROGRESS = True
|
||||
try:
|
||||
try:
|
||||
proc = _spawn_backend_process(next_port)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"启动新后端进程失败:{e}")
|
||||
|
||||
access_host = _get_backend_access_host()
|
||||
health_url = f"http://{_format_host_for_url(access_host)}:{next_port}/api/health"
|
||||
ok = await _wait_for_backend_ready(health_url, timeout_s=30.0)
|
||||
if not ok:
|
||||
try:
|
||||
if proc and proc.poll() is None:
|
||||
proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(status_code=500, detail=f"新端口启动超时:{health_url}")
|
||||
|
||||
# Persist only after the new backend is confirmed ready.
|
||||
write_backend_port_setting(next_port)
|
||||
env_file = write_backend_port_env_file(next_port)
|
||||
|
||||
background_tasks.add_task(_exit_process_after, 0.2)
|
||||
|
||||
host = _format_host_for_url(access_host)
|
||||
return {
|
||||
"success": True,
|
||||
"changed": True,
|
||||
"port": next_port,
|
||||
"ui_url": f"http://{host}:{next_port}/",
|
||||
"env_file": str(env_file) if env_file else None,
|
||||
}
|
||||
finally:
|
||||
_PORT_CHANGE_IN_PROGRESS = False
|
||||
@@ -50,6 +50,7 @@ from ..chat_helpers import (
|
||||
_lookup_resource_md5,
|
||||
_normalize_xml_url,
|
||||
_parse_app_message,
|
||||
_parse_location_message,
|
||||
_parse_system_message_content,
|
||||
_parse_pat_message,
|
||||
_pick_display_name,
|
||||
@@ -2673,6 +2674,10 @@ def _append_full_messages_from_rows(
|
||||
file_md5 = ""
|
||||
transfer_id = ""
|
||||
voip_type = ""
|
||||
location_lat: Optional[float] = None
|
||||
location_lng: Optional[float] = None
|
||||
location_poiname = ""
|
||||
location_label = ""
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
@@ -2883,6 +2888,14 @@ def _append_full_messages_from_rows(
|
||||
create_time=create_time,
|
||||
)
|
||||
content_text = "[表情]"
|
||||
elif local_type == 48:
|
||||
parsed = _parse_location_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "location")
|
||||
content_text = str(parsed.get("content") or "[Location]")
|
||||
location_lat = parsed.get("locationLat")
|
||||
location_lng = parsed.get("locationLng")
|
||||
location_poiname = str(parsed.get("locationPoiname") or "")
|
||||
location_label = str(parsed.get("locationLabel") or "")
|
||||
elif local_type == 50:
|
||||
render_type = "voip"
|
||||
try:
|
||||
@@ -2929,10 +2942,15 @@ def _append_full_messages_from_rows(
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
from_name = str(parsed.get("from") or from_name)
|
||||
from_username = str(parsed.get("fromUsername") or from_username)
|
||||
file_size = str(parsed.get("size") or file_size)
|
||||
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
||||
file_md5 = str(parsed.get("fileMd5") or file_md5)
|
||||
transfer_id = str(parsed.get("transferId") or transfer_id)
|
||||
quote_username = str(parsed.get("quoteUsername") or quote_username)
|
||||
quote_server_id = str(parsed.get("quoteServerId") or quote_server_id)
|
||||
quote_type = str(parsed.get("quoteType") or quote_type)
|
||||
quote_voice_length = str(parsed.get("quoteVoiceLength") or quote_voice_length)
|
||||
|
||||
if render_type == "transfer":
|
||||
# 如果 transferId 仍为空,尝试从原始 XML 提取
|
||||
@@ -3009,6 +3027,10 @@ def _append_full_messages_from_rows(
|
||||
"paySubType": pay_sub_type,
|
||||
"transferStatus": transfer_status,
|
||||
"transferId": transfer_id,
|
||||
"locationLat": location_lat,
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
"_rawText": raw_text if local_type == 266287972401 else "",
|
||||
}
|
||||
)
|
||||
@@ -3734,8 +3756,19 @@ def list_chat_sessions(
|
||||
except Exception:
|
||||
last_previews = {}
|
||||
|
||||
def _is_generic_location_preview(value: Any) -> bool:
|
||||
text = re.sub(r"\s+", " ", str(value or "").strip()).strip()
|
||||
if not text:
|
||||
return False
|
||||
lowered = text.lower()
|
||||
return lowered in {"[location]", "[位置]"} or lowered.endswith(": [location]") or lowered.endswith(": [位置]")
|
||||
|
||||
if preview_mode in {"latest", "db"}:
|
||||
targets = usernames if preview_mode == "db" else [u for u in usernames if u and (u not in last_previews)]
|
||||
targets = (
|
||||
usernames
|
||||
if preview_mode == "db"
|
||||
else [u for u in usernames if u and ((u not in last_previews) or _is_generic_location_preview(last_previews.get(u)))]
|
||||
)
|
||||
if targets:
|
||||
legacy = _load_latest_message_previews(account_dir, targets)
|
||||
for u, v in legacy.items():
|
||||
@@ -3830,6 +3863,11 @@ def list_chat_sessions(
|
||||
last_msg_sub_type = 0
|
||||
if last_msg_type == 81604378673 or (last_msg_type == 49 and last_msg_sub_type == 19):
|
||||
last_message = "[聊天记录]"
|
||||
elif last_msg_type == 48:
|
||||
text = re.sub(r"\s+", " ", str(last_message or "").strip()).strip()
|
||||
text = re.sub(r"^\[location\]", "", text, flags=re.IGNORECASE).strip()
|
||||
text = re.sub(r"^\[位置\]", "", text).strip()
|
||||
last_message = f"[位置]{text}" if text else "[位置]"
|
||||
|
||||
last_message = _normalize_session_preview_text(
|
||||
last_message,
|
||||
@@ -4065,6 +4103,10 @@ def _collect_chat_messages(
|
||||
file_md5 = ""
|
||||
transfer_id = ""
|
||||
voip_type = ""
|
||||
location_lat: Optional[float] = None
|
||||
location_lng: Optional[float] = None
|
||||
location_poiname = ""
|
||||
location_label = ""
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
@@ -4114,8 +4156,7 @@ def _collect_chat_messages(
|
||||
render_type = "system"
|
||||
template = _extract_xml_tag_text(raw_text, "template")
|
||||
if template:
|
||||
import re
|
||||
|
||||
# import re
|
||||
pat_usernames.update({m.group(1) for m in re.finditer(r"\$\{([^}]+)\}", template) if m.group(1)})
|
||||
content_text = "[拍一拍]"
|
||||
else:
|
||||
@@ -4252,11 +4293,18 @@ def _collect_chat_messages(
|
||||
create_time=create_time,
|
||||
)
|
||||
content_text = "[表情]"
|
||||
elif local_type == 48:
|
||||
parsed = _parse_location_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "location")
|
||||
content_text = str(parsed.get("content") or "[Location]")
|
||||
location_lat = parsed.get("locationLat")
|
||||
location_lng = parsed.get("locationLng")
|
||||
location_poiname = str(parsed.get("locationPoiname") or "")
|
||||
location_label = str(parsed.get("locationLabel") or "")
|
||||
elif local_type == 50:
|
||||
render_type = "voip"
|
||||
try:
|
||||
import re
|
||||
|
||||
# import re
|
||||
block = raw_text
|
||||
m_voip = re.search(
|
||||
r"(<VoIPBubbleMsg[^>]*>.*?</VoIPBubbleMsg>)",
|
||||
@@ -4291,6 +4339,7 @@ def _collect_chat_messages(
|
||||
title = str(parsed.get("title") or title)
|
||||
url = str(parsed.get("url") or url)
|
||||
from_name = str(parsed.get("from") or from_name)
|
||||
from_username = str(parsed.get("fromUsername") or from_username)
|
||||
record_item = str(parsed.get("recordItem") or record_item)
|
||||
quote_title = str(parsed.get("quoteTitle") or quote_title)
|
||||
quote_content = str(parsed.get("quoteContent") or quote_content)
|
||||
@@ -4304,6 +4353,10 @@ def _collect_chat_messages(
|
||||
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
||||
file_md5 = str(parsed.get("fileMd5") or file_md5)
|
||||
transfer_id = str(parsed.get("transferId") or transfer_id)
|
||||
quote_username = str(parsed.get("quoteUsername") or quote_username)
|
||||
quote_server_id = str(parsed.get("quoteServerId") or quote_server_id)
|
||||
quote_type = str(parsed.get("quoteType") or quote_type)
|
||||
quote_voice_length = str(parsed.get("quoteVoiceLength") or quote_voice_length)
|
||||
|
||||
if render_type == "transfer":
|
||||
# 如果 transferId 仍为空,尝试从原始 XML 提取
|
||||
@@ -4387,6 +4440,10 @@ def _collect_chat_messages(
|
||||
"paySubType": pay_sub_type,
|
||||
"transferStatus": transfer_status,
|
||||
"transferId": transfer_id,
|
||||
"locationLat": location_lat,
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
"_rawText": raw_text if local_type == 266287972401 else "",
|
||||
}
|
||||
)
|
||||
@@ -5013,7 +5070,7 @@ def list_chat_messages(
|
||||
render_type = "system"
|
||||
template = _extract_xml_tag_text(raw_text, "template")
|
||||
if template:
|
||||
import re
|
||||
# import re
|
||||
|
||||
pat_usernames.update({m.group(1) for m in re.finditer(r"\$\{([^}]+)\}", template) if m.group(1)})
|
||||
content_text = "[拍一拍]"
|
||||
@@ -5145,7 +5202,7 @@ def list_chat_messages(
|
||||
elif local_type == 50:
|
||||
render_type = "voip"
|
||||
try:
|
||||
import re
|
||||
# import re
|
||||
|
||||
block = raw_text
|
||||
m_voip = re.search(
|
||||
@@ -5494,6 +5551,7 @@ def list_chat_messages(
|
||||
|
||||
if existing_local:
|
||||
try:
|
||||
# import re
|
||||
cur = str(m.get("emojiUrl") or "")
|
||||
if cur and re.match(r"^https?://", cur, flags=re.I) and ("/api/chat/media/emoji" not in cur):
|
||||
m["emojiRemoteUrl"] = cur
|
||||
|
||||
@@ -33,7 +33,7 @@ from ..avatar_cache import (
|
||||
)
|
||||
from ..logging_config import get_logger
|
||||
from ..media_helpers import (
|
||||
_convert_silk_to_wav,
|
||||
_convert_silk_to_browser_audio,
|
||||
_decrypt_emoticon_aes_cbc,
|
||||
_detect_image_extension,
|
||||
_detect_image_media_type,
|
||||
@@ -1762,12 +1762,12 @@ async def get_chat_voice(server_id: int, account: Optional[str] = None):
|
||||
if not isinstance(data, (bytes, bytearray)):
|
||||
data = bytes(data)
|
||||
|
||||
# Try to convert SILK to WAV for browser playback
|
||||
wav_data = _convert_silk_to_wav(data)
|
||||
if wav_data != data:
|
||||
payload, ext, media_type = _convert_silk_to_browser_audio(data, preferred_format="mp3")
|
||||
if payload and ext != "silk":
|
||||
return Response(
|
||||
content=wav_data,
|
||||
media_type="audio/wav",
|
||||
content=payload,
|
||||
media_type=media_type,
|
||||
headers={"Content-Disposition": f"inline; filename=voice_{int(server_id)}.{ext}"},
|
||||
)
|
||||
|
||||
# Fallback to raw SILK if conversion fails
|
||||
@@ -1821,11 +1821,16 @@ async def open_chat_media_folder(
|
||||
if not isinstance(data, (bytes, bytearray)):
|
||||
data = bytes(data)
|
||||
|
||||
payload, ext, _media_type = _convert_silk_to_browser_audio(data, preferred_format="mp3")
|
||||
if not payload:
|
||||
payload = data
|
||||
ext = "silk"
|
||||
|
||||
export_dir = account_dir / "_exports"
|
||||
export_dir.mkdir(parents=True, exist_ok=True)
|
||||
p = export_dir / f"voice_{int(server_id)}.silk"
|
||||
p = export_dir / f"voice_{int(server_id)}.{ext}"
|
||||
try:
|
||||
p.write_bytes(data)
|
||||
p.write_bytes(payload)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to export voice: {e}")
|
||||
else:
|
||||
|
||||
@@ -53,31 +53,28 @@ async def get_saved_keys(account: Optional[str] = None):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/get_db_key", summary="自动获取微信数据库密钥")
|
||||
@router.get("/api/get_keys", summary="自动获取微信数据库与图片密钥")
|
||||
async def get_wechat_db_key():
|
||||
"""
|
||||
自动流程:
|
||||
1. 结束微信进程
|
||||
2. 启动微信
|
||||
3. 根据版本注入 Hook
|
||||
4. 抓取密钥并返回
|
||||
3. 根据版本注入双 Hook
|
||||
4. 抓取 DB 与 图片密钥(AES + XOR)并返回
|
||||
"""
|
||||
try:
|
||||
# 不需要async吧,我相信fastapi的线程池
|
||||
db_key = get_db_key_workflow()
|
||||
keys_data = get_db_key_workflow()
|
||||
|
||||
return {
|
||||
"status": 0,
|
||||
"errmsg": "ok",
|
||||
"data": {
|
||||
"db_key": db_key
|
||||
}
|
||||
"data": keys_data # 现在完美包含了 db_key, aes_key, xor_key
|
||||
}
|
||||
|
||||
except TimeoutError:
|
||||
return {
|
||||
"status": -1,
|
||||
"errmsg": "获取超时,请确保微信没有开启自动登录 或者 加快手速",
|
||||
"errmsg": "获取超时,请确保微信没有开启自动登录并且在弹窗中完成了登录",
|
||||
"data": {}
|
||||
}
|
||||
except Exception as e:
|
||||
@@ -88,6 +85,7 @@ async def get_wechat_db_key():
|
||||
}
|
||||
|
||||
|
||||
|
||||
@router.get("/api/get_image_key", summary="获取并保存微信图片密钥")
|
||||
async def get_image_key(account: Optional[str] = None):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RUNTIME_SETTINGS_FILENAME = "runtime_settings.json"
|
||||
BACKEND_PORT_KEY = "backend_port"
|
||||
ENV_PORT_KEY = "WECHAT_TOOL_PORT"
|
||||
ENV_FILE_KEY = "WECHAT_TOOL_ENV_FILE"
|
||||
DEFAULT_ENV_FILENAME = ".env"
|
||||
|
||||
|
||||
def _parse_port(value: object) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
raw = str(value).strip()
|
||||
except Exception:
|
||||
return None
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
port = int(raw, 10)
|
||||
except Exception:
|
||||
return None
|
||||
if port < 1 or port > 65535:
|
||||
return None
|
||||
return port
|
||||
|
||||
|
||||
def get_runtime_settings_path() -> Path:
|
||||
from .app_paths import get_output_dir
|
||||
|
||||
return get_output_dir() / RUNTIME_SETTINGS_FILENAME
|
||||
|
||||
|
||||
def read_backend_port_setting() -> int | None:
|
||||
path = get_runtime_settings_path()
|
||||
try:
|
||||
if not path.is_file():
|
||||
return None
|
||||
data = json.loads(path.read_text(encoding="utf-8") or "{}")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return _parse_port(data.get(BACKEND_PORT_KEY))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def write_backend_port_setting(port: int | None) -> None:
|
||||
path = get_runtime_settings_path()
|
||||
safe_port = _parse_port(port)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
try:
|
||||
data: dict = {}
|
||||
if path.is_file():
|
||||
try:
|
||||
existing = json.loads(path.read_text(encoding="utf-8") or "{}")
|
||||
if isinstance(existing, dict):
|
||||
data = existing
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
if safe_port is None:
|
||||
data.pop(BACKEND_PORT_KEY, None)
|
||||
else:
|
||||
data[BACKEND_PORT_KEY] = safe_port
|
||||
|
||||
# Keep the file small and stable; remove if empty.
|
||||
if not data:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def read_effective_backend_port(default: int) -> tuple[int, str]:
|
||||
"""Return (port, source) where source is one of: env | settings | default."""
|
||||
|
||||
env_raw = str(os.environ.get("WECHAT_TOOL_PORT", "") or "").strip()
|
||||
env_port = _parse_port(env_raw)
|
||||
if env_port is not None:
|
||||
return env_port, "env"
|
||||
|
||||
settings_port = read_backend_port_setting()
|
||||
if settings_port is not None:
|
||||
return settings_port, "settings"
|
||||
|
||||
return int(default), "default"
|
||||
|
||||
|
||||
def get_env_file_path() -> Path | None:
|
||||
"""Best-effort env file path for `uv run` (defaults to repo root `.env`)."""
|
||||
|
||||
v = str(os.environ.get(ENV_FILE_KEY, "") or "").strip()
|
||||
if v:
|
||||
try:
|
||||
return Path(v)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
cwd = Path.cwd()
|
||||
# Heuristic: only write `.env` in a project root (avoid polluting random dirs).
|
||||
try:
|
||||
if (cwd / "pyproject.toml").is_file():
|
||||
return cwd / DEFAULT_ENV_FILENAME
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _set_env_var_in_file(env_file: Path, key: str, value: str | None) -> bool:
|
||||
try:
|
||||
env_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
pattern = re.compile(rf"^\s*(?:export\s+)?{re.escape(key)}\s*=")
|
||||
try:
|
||||
raw = env_file.read_text(encoding="utf-8") if env_file.is_file() else ""
|
||||
except Exception:
|
||||
raw = ""
|
||||
|
||||
lines = raw.splitlines(keepends=True) if raw else []
|
||||
out: list[str] = []
|
||||
replaced = False
|
||||
for line in lines:
|
||||
if pattern.match(line):
|
||||
if value is None:
|
||||
continue
|
||||
if not replaced:
|
||||
out.append(f"{key}={value}\n")
|
||||
replaced = True
|
||||
continue
|
||||
out.append(line)
|
||||
|
||||
if value is not None and not replaced:
|
||||
if out and not out[-1].endswith("\n"):
|
||||
out[-1] = out[-1] + "\n"
|
||||
out.append(f"{key}={value}\n")
|
||||
|
||||
try:
|
||||
env_file.write_text("".join(out), encoding="utf-8")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def write_backend_port_env_file(port: int | None) -> Path | None:
|
||||
"""Write `WECHAT_TOOL_PORT` into a `.env` file so `uv run main.py` picks it up on restart.
|
||||
|
||||
Note: `uv` doesn't override already-set env vars; `.env` only applies when the variable is not
|
||||
present in the current shell/session.
|
||||
"""
|
||||
|
||||
env_file = get_env_file_path()
|
||||
if not env_file:
|
||||
return None
|
||||
|
||||
safe_port = _parse_port(port)
|
||||
ok = _set_env_var_in_file(env_file, ENV_PORT_KEY, str(safe_port) if safe_port is not None else None)
|
||||
return env_file if ok else None
|
||||
@@ -253,7 +253,9 @@ def _ensure_initialized() -> None:
|
||||
return
|
||||
rc = int(lib.wcdb_init())
|
||||
if rc != 0:
|
||||
raise WCDBRealtimeError(f"wcdb_init failed: {rc}")
|
||||
logs = get_native_logs(require_initialized=False)
|
||||
hint = f" logs={logs[:6]}" if logs else ""
|
||||
raise WCDBRealtimeError(f"wcdb_init failed: {rc}.{hint}")
|
||||
_initialized = True
|
||||
|
||||
|
||||
@@ -315,11 +317,12 @@ def _call_out_error(fn, *args) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def get_native_logs() -> list[str]:
|
||||
try:
|
||||
_ensure_initialized()
|
||||
except Exception:
|
||||
return []
|
||||
def get_native_logs(*, require_initialized: bool = True) -> list[str]:
|
||||
if require_initialized:
|
||||
try:
|
||||
_ensure_initialized()
|
||||
except Exception:
|
||||
return []
|
||||
lib = _load_wcdb_lib()
|
||||
out = ctypes.c_char_p()
|
||||
rc = int(lib.wcdb_get_logs(ctypes.byref(out)))
|
||||
|
||||
@@ -65,6 +65,136 @@ def _format_duration_zh(seconds: int | None) -> str:
|
||||
return f"{d}天{hh}小时" if hh else f"{d}天"
|
||||
|
||||
|
||||
def _compute_streak_days(doys: list[int]) -> int:
|
||||
if not doys:
|
||||
return 0
|
||||
doys_sorted = sorted({int(x) for x in doys if int(x) > 0})
|
||||
if not doys_sorted:
|
||||
return 0
|
||||
|
||||
best = 1
|
||||
cur = 1
|
||||
prev = doys_sorted[0]
|
||||
for d in doys_sorted[1:]:
|
||||
if d == prev + 1:
|
||||
cur += 1
|
||||
else:
|
||||
cur = 1
|
||||
if cur > best:
|
||||
best = cur
|
||||
prev = d
|
||||
return int(best)
|
||||
|
||||
|
||||
def _compute_best_buddy_extras_from_index(*, account_dir: Path, year: int, buddy_username: str) -> dict[str, Any]:
|
||||
"""Compute a few extra fields for Card07 Bento summary.
|
||||
|
||||
- longestStreakDays: longest consecutive days with any interaction
|
||||
- peakHour/peakHourLabel: most active hour of day with this buddy
|
||||
|
||||
Best-effort: returns empty dict on any failure.
|
||||
"""
|
||||
|
||||
buddy = str(buddy_username or "").strip()
|
||||
if not buddy:
|
||||
return {}
|
||||
|
||||
index_path = get_chat_search_index_db_path(account_dir)
|
||||
if not index_path.exists():
|
||||
return {}
|
||||
|
||||
start_ts, end_ts = _year_range_epoch_seconds(int(year))
|
||||
|
||||
ts_expr = (
|
||||
"CASE "
|
||||
"WHEN CAST(create_time AS INTEGER) > 1000000000000 "
|
||||
"THEN CAST(CAST(create_time AS INTEGER)/1000 AS INTEGER) "
|
||||
"ELSE CAST(create_time AS INTEGER) "
|
||||
"END"
|
||||
)
|
||||
where = (
|
||||
f"{ts_expr} >= ? AND {ts_expr} < ? "
|
||||
"AND db_stem NOT LIKE 'biz_message%' "
|
||||
"AND CAST(local_type AS INTEGER) != 10000 "
|
||||
"AND username = ? "
|
||||
"AND username NOT LIKE '%@chatroom'"
|
||||
)
|
||||
|
||||
sql_days = (
|
||||
"SELECT DISTINCT "
|
||||
"CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) AS doy "
|
||||
"FROM ("
|
||||
f" SELECT {ts_expr} AS ts "
|
||||
" FROM message_fts "
|
||||
f" WHERE {where}"
|
||||
") sub "
|
||||
"WHERE ts > 0 "
|
||||
"ORDER BY doy ASC"
|
||||
)
|
||||
|
||||
sql_peak_hour = (
|
||||
"SELECT "
|
||||
"CAST(strftime('%H', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) AS h, "
|
||||
"COUNT(1) AS cnt "
|
||||
"FROM ("
|
||||
f" SELECT {ts_expr} AS ts "
|
||||
" FROM message_fts "
|
||||
f" WHERE {where}"
|
||||
") sub "
|
||||
"WHERE ts > 0 "
|
||||
"GROUP BY h "
|
||||
"ORDER BY cnt DESC, h ASC "
|
||||
"LIMIT 1"
|
||||
)
|
||||
|
||||
conn = sqlite3.connect(str(index_path))
|
||||
try:
|
||||
has_fts = (
|
||||
conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='message_fts' LIMIT 1").fetchone()
|
||||
is not None
|
||||
)
|
||||
if not has_fts:
|
||||
return {}
|
||||
|
||||
params = (start_ts, end_ts, buddy)
|
||||
|
||||
doys: list[int] = []
|
||||
try:
|
||||
rows = conn.execute(sql_days, params).fetchall()
|
||||
except Exception:
|
||||
rows = []
|
||||
for r in rows:
|
||||
if not r or r[0] is None:
|
||||
continue
|
||||
try:
|
||||
doys.append(int(r[0]))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
longest_streak_days = _compute_streak_days(doys)
|
||||
|
||||
peak_hour: int | None = None
|
||||
try:
|
||||
row = conn.execute(sql_peak_hour, params).fetchone()
|
||||
if row and row[0] is not None:
|
||||
peak_hour = int(row[0])
|
||||
except Exception:
|
||||
peak_hour = None
|
||||
|
||||
out: dict[str, Any] = {"longestStreakDays": int(longest_streak_days)}
|
||||
if peak_hour is not None and 0 <= peak_hour <= 23:
|
||||
out["peakHour"] = int(peak_hour)
|
||||
out["peakHourLabel"] = f"{int(peak_hour):02d}:00"
|
||||
return out
|
||||
except Exception:
|
||||
return {}
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ConvAgg:
|
||||
username: str
|
||||
@@ -125,6 +255,9 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
|
||||
global_slowest: int | None = None
|
||||
global_slowest_u: str | None = None
|
||||
|
||||
reply_gaps: list[int] = []
|
||||
reply_stats: dict[str, Any] | None = None
|
||||
|
||||
best_score = -1.0
|
||||
best_agg: _ConvAgg | None = None
|
||||
|
||||
@@ -287,6 +420,7 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
|
||||
total_replies += 1
|
||||
sum_gap += gap
|
||||
sum_gap_capped += min(gap, gap_cap_seconds)
|
||||
reply_gaps.append(int(gap))
|
||||
|
||||
if replies == 1 or gap < min_gap:
|
||||
min_gap = gap
|
||||
@@ -323,6 +457,20 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if reply_gaps:
|
||||
try:
|
||||
reply_gaps.sort()
|
||||
n = int(len(reply_gaps))
|
||||
# Nearest-rank quantiles (deterministic, integer seconds).
|
||||
p50_idx = max(0, min(n - 1, int(math.ceil(0.50 * n) - 1)))
|
||||
p90_idx = max(0, min(n - 1, int(math.ceil(0.90 * n) - 1)))
|
||||
reply_stats = {
|
||||
"p50Seconds": int(reply_gaps[p50_idx]),
|
||||
"p90Seconds": int(reply_gaps[p90_idx]),
|
||||
}
|
||||
except Exception:
|
||||
reply_stats = None
|
||||
|
||||
# -------- Fallback path: no index --------
|
||||
# Best-effort: if the index doesn't exist / isn't ready, auto-start building it (async) so user can
|
||||
# retry this page later. We intentionally do NOT block here.
|
||||
@@ -406,6 +554,14 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
|
||||
best_buddy_obj = None
|
||||
if best_agg is not None:
|
||||
best_buddy_obj = conv_to_obj(best_score, best_agg)
|
||||
if used_index and isinstance(best_buddy_obj, dict) and best_buddy_obj.get("username"):
|
||||
extras = _compute_best_buddy_extras_from_index(
|
||||
account_dir=account_dir,
|
||||
year=int(year),
|
||||
buddy_username=str(best_buddy_obj.get("username") or ""),
|
||||
)
|
||||
if extras:
|
||||
best_buddy_obj.update(extras)
|
||||
|
||||
fastest_obj = None
|
||||
if global_fastest is not None and global_fastest_u:
|
||||
@@ -645,6 +801,7 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
|
||||
"year": int(year),
|
||||
"sentToContacts": int(len(sent_to_contacts)),
|
||||
"replyEvents": int(total_replies),
|
||||
"replyStats": reply_stats,
|
||||
"fastestReplySeconds": int(global_fastest) if global_fastest is not None else None,
|
||||
"longestReplySeconds": int(global_slowest) if global_slowest is not None else None,
|
||||
"bestBuddy": best_buddy_obj,
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _as_data(obj: Any) -> dict[str, Any]:
|
||||
if not isinstance(obj, dict):
|
||||
return {}
|
||||
data = obj.get("data")
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return obj
|
||||
|
||||
|
||||
def _pick_int(x: Any, default: int = 0) -> int:
|
||||
try:
|
||||
return int(x)
|
||||
except Exception:
|
||||
return int(default)
|
||||
|
||||
|
||||
def _pick_float(x: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
v = float(x)
|
||||
return v if v == v else float(default) # NaN guard
|
||||
except Exception:
|
||||
return float(default)
|
||||
|
||||
|
||||
def _pick_str(x: Any, default: str = "") -> str:
|
||||
s = str(x or "").strip()
|
||||
return s if s else str(default)
|
||||
|
||||
|
||||
def _pick_obj(d: Any, keys: tuple[str, ...]) -> dict[str, Any] | None:
|
||||
if not isinstance(d, dict):
|
||||
return None
|
||||
out: dict[str, Any] = {}
|
||||
for k in keys:
|
||||
if k in d:
|
||||
out[k] = d.get(k)
|
||||
return out if out else None
|
||||
|
||||
|
||||
def build_card_07_bento_summary_from_sources(
|
||||
*,
|
||||
year: int,
|
||||
overview: dict[str, Any],
|
||||
heatmap: dict[str, Any],
|
||||
message_chars: dict[str, Any],
|
||||
reply_speed: dict[str, Any],
|
||||
monthly: dict[str, Any],
|
||||
emoji: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Card #7: Bento Summary (prototype style merged into Wrapped deck).
|
||||
|
||||
The frontend expects a stable `data.snapshot` object to render without running extra JS.
|
||||
"""
|
||||
|
||||
overview_d = _as_data(overview)
|
||||
heatmap_d = _as_data(heatmap)
|
||||
message_chars_d = _as_data(message_chars)
|
||||
reply_speed_d = _as_data(reply_speed)
|
||||
monthly_d = _as_data(monthly)
|
||||
emoji_d = _as_data(emoji)
|
||||
|
||||
top_group_raw = overview_d.get("topGroup")
|
||||
top_group = None
|
||||
if isinstance(top_group_raw, dict):
|
||||
display = _pick_str(top_group_raw.get("displayName"), "--")
|
||||
top_group = {
|
||||
"displayName": display,
|
||||
"maskedName": display,
|
||||
"avatarUrl": _pick_str(top_group_raw.get("avatarUrl"), ""),
|
||||
"messages": _pick_int(top_group_raw.get("messages"), 0),
|
||||
}
|
||||
|
||||
best_buddy_raw = reply_speed_d.get("bestBuddy")
|
||||
best_buddy = None
|
||||
if isinstance(best_buddy_raw, dict):
|
||||
display = _pick_str(best_buddy_raw.get("displayName"), "--")
|
||||
best_buddy = {
|
||||
"displayName": display,
|
||||
"maskedName": display,
|
||||
"avatarUrl": _pick_str(best_buddy_raw.get("avatarUrl"), ""),
|
||||
"totalMessages": _pick_int(best_buddy_raw.get("totalMessages"), 0),
|
||||
"longestStreakDays": _pick_int(best_buddy_raw.get("longestStreakDays"), 0),
|
||||
"peakHour": best_buddy_raw.get("peakHour"),
|
||||
"peakHourLabel": _pick_str(best_buddy_raw.get("peakHourLabel"), ""),
|
||||
}
|
||||
|
||||
fastest_raw = reply_speed_d.get("fastest")
|
||||
fastest = None
|
||||
if isinstance(fastest_raw, dict):
|
||||
display = _pick_str(fastest_raw.get("displayName"), "--")
|
||||
fastest = {
|
||||
"displayName": display,
|
||||
"maskedName": display,
|
||||
"avatarUrl": _pick_str(fastest_raw.get("avatarUrl"), ""),
|
||||
"seconds": _pick_int(fastest_raw.get("seconds"), 0),
|
||||
}
|
||||
|
||||
slowest_raw = reply_speed_d.get("slowest")
|
||||
slowest = None
|
||||
if isinstance(slowest_raw, dict):
|
||||
display = _pick_str(slowest_raw.get("displayName"), "--")
|
||||
slowest = {
|
||||
"displayName": display,
|
||||
"maskedName": display,
|
||||
"avatarUrl": _pick_str(slowest_raw.get("avatarUrl"), ""),
|
||||
"seconds": _pick_int(slowest_raw.get("seconds"), 0),
|
||||
}
|
||||
|
||||
reply_stats_raw = reply_speed_d.get("replyStats")
|
||||
reply_stats = None
|
||||
if isinstance(reply_stats_raw, dict):
|
||||
reply_stats = {
|
||||
"p50Seconds": reply_stats_raw.get("p50Seconds"),
|
||||
"p90Seconds": reply_stats_raw.get("p90Seconds"),
|
||||
}
|
||||
|
||||
top_phrase_raw = overview_d.get("topPhrase")
|
||||
top_phrase = None
|
||||
if isinstance(top_phrase_raw, dict):
|
||||
phrase = _pick_str(top_phrase_raw.get("phrase"), "")
|
||||
count = _pick_int(top_phrase_raw.get("count"), 0)
|
||||
if phrase and count > 0:
|
||||
top_phrase = {"phrase": phrase, "count": count}
|
||||
|
||||
sent_sticker_count = _pick_int(emoji_d.get("sentStickerCount"), _pick_int(overview_d.get("sentStickerCount"), 0))
|
||||
top_sticker = None
|
||||
top_stickers = emoji_d.get("topStickers")
|
||||
if isinstance(top_stickers, list) and top_stickers:
|
||||
x0 = top_stickers[0] if isinstance(top_stickers[0], dict) else None
|
||||
if x0:
|
||||
url = _pick_str(x0.get("emojiUrl") or x0.get("imageUrl") or x0.get("url"), "")
|
||||
cnt = _pick_int(x0.get("count"), 0)
|
||||
if url:
|
||||
top_sticker = {"imageUrl": url, "count": cnt}
|
||||
|
||||
top_unicode_emoji = ""
|
||||
top_unicode_emoji_count = 0
|
||||
top_unicode_emojis = emoji_d.get("topUnicodeEmojis")
|
||||
if isinstance(top_unicode_emojis, list) and top_unicode_emojis:
|
||||
x0 = top_unicode_emojis[0] if isinstance(top_unicode_emojis[0], dict) else None
|
||||
if x0:
|
||||
top_unicode_emoji = _pick_str(x0.get("emoji"), "")
|
||||
top_unicode_emoji_count = _pick_int(x0.get("count"), 0)
|
||||
|
||||
# "Top emoji" should be picked across both unicode emoji and WeChat built-in emoji.
|
||||
# The deck has a separate "sticker" card; here we focus on emoji-like items.
|
||||
top_emoji: dict[str, Any] | None = None
|
||||
emoji_candidates: list[dict[str, Any]] = []
|
||||
|
||||
top_wechat_emojis = emoji_d.get("topWechatEmojis")
|
||||
if isinstance(top_wechat_emojis, list) and top_wechat_emojis:
|
||||
for item in top_wechat_emojis:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = _pick_str(item.get("key"), "")
|
||||
cnt = _pick_int(item.get("count"), 0)
|
||||
if key and cnt > 0:
|
||||
emoji_candidates.append(
|
||||
{
|
||||
"kind": "wechat",
|
||||
"key": key,
|
||||
"count": cnt,
|
||||
"assetPath": _pick_str(item.get("assetPath"), ""),
|
||||
}
|
||||
)
|
||||
|
||||
top_text_emojis = emoji_d.get("topTextEmojis")
|
||||
if isinstance(top_text_emojis, list) and top_text_emojis:
|
||||
for item in top_text_emojis:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = _pick_str(item.get("key"), "")
|
||||
cnt = _pick_int(item.get("count"), 0)
|
||||
if key and cnt > 0:
|
||||
emoji_candidates.append(
|
||||
{
|
||||
"kind": "wechat",
|
||||
"key": key,
|
||||
"count": cnt,
|
||||
"assetPath": _pick_str(item.get("assetPath"), ""),
|
||||
}
|
||||
)
|
||||
|
||||
if isinstance(top_unicode_emojis, list) and top_unicode_emojis:
|
||||
for item in top_unicode_emojis:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
emo = _pick_str(item.get("emoji"), "")
|
||||
cnt = _pick_int(item.get("count"), 0)
|
||||
if emo and cnt > 0:
|
||||
emoji_candidates.append({"kind": "unicode", "emoji": emo, "count": cnt})
|
||||
|
||||
if emoji_candidates:
|
||||
best = max(
|
||||
emoji_candidates,
|
||||
key=lambda x: (
|
||||
_pick_int(x.get("count"), 0),
|
||||
1 if str(x.get("kind")) == "wechat" else 0,
|
||||
_pick_str(x.get("key") or x.get("emoji"), ""),
|
||||
),
|
||||
)
|
||||
if str(best.get("kind")) == "wechat":
|
||||
top_emoji = {
|
||||
"kind": "wechat",
|
||||
"key": _pick_str(best.get("key"), ""),
|
||||
"count": _pick_int(best.get("count"), 0),
|
||||
"assetPath": _pick_str(best.get("assetPath"), ""),
|
||||
}
|
||||
else:
|
||||
top_emoji = {
|
||||
"kind": "unicode",
|
||||
"emoji": _pick_str(best.get("emoji"), ""),
|
||||
"count": _pick_int(best.get("count"), 0),
|
||||
}
|
||||
|
||||
monthly_best_buddies: list[dict[str, Any]] = []
|
||||
months = monthly_d.get("months")
|
||||
if isinstance(months, list) and months:
|
||||
for item in months:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
m = _pick_int(item.get("month"), 0)
|
||||
winner = item.get("winner") if isinstance(item.get("winner"), dict) else None
|
||||
metrics = item.get("metrics") if isinstance(item.get("metrics"), dict) else None
|
||||
raw = item.get("raw") if isinstance(item.get("raw"), dict) else None
|
||||
monthly_best_buddies.append(
|
||||
{
|
||||
"month": m,
|
||||
"displayName": _pick_str((winner or {}).get("displayName"), "--"),
|
||||
"maskedName": _pick_str((winner or {}).get("displayName"), "--"),
|
||||
"avatarUrl": _pick_str((winner or {}).get("avatarUrl"), ""),
|
||||
"messages": _pick_int((raw or {}).get("totalMessages"), 0),
|
||||
"metrics": metrics if metrics else None,
|
||||
}
|
||||
)
|
||||
|
||||
# Ensure we always return 12 items for the grid.
|
||||
if len(monthly_best_buddies) != 12:
|
||||
fixed = {int(x.get("month") or 0): x for x in monthly_best_buddies if isinstance(x, dict)}
|
||||
monthly_best_buddies = []
|
||||
for m in range(1, 13):
|
||||
monthly_best_buddies.append(
|
||||
fixed.get(m)
|
||||
or {
|
||||
"month": m,
|
||||
"displayName": "--",
|
||||
"maskedName": "--",
|
||||
"avatarUrl": "",
|
||||
"messages": 0,
|
||||
"metrics": None,
|
||||
}
|
||||
)
|
||||
|
||||
snapshot: dict[str, Any] = {
|
||||
"year": _pick_int(year),
|
||||
"totalMessages": _pick_int(overview_d.get("totalMessages"), _pick_int(heatmap_d.get("totalMessages"), 0)),
|
||||
"messagesPerDay": _pick_float(overview_d.get("messagesPerDay"), 0.0),
|
||||
"sentChars": _pick_int(message_chars_d.get("sentChars"), 0),
|
||||
"addedFriends": _pick_int(overview_d.get("addedFriends"), 0),
|
||||
"mostActiveHour": overview_d.get("mostActiveHour"),
|
||||
"topGroup": top_group,
|
||||
"bestBuddy": best_buddy,
|
||||
"fastest": fastest,
|
||||
"slowest": slowest,
|
||||
"replyStats": reply_stats,
|
||||
"topPhrase": top_phrase,
|
||||
"sentStickerCount": int(sent_sticker_count),
|
||||
"topSticker": top_sticker,
|
||||
"topEmoji": top_emoji,
|
||||
"topUnicodeEmoji": top_unicode_emoji,
|
||||
"topUnicodeEmojiCount": int(top_unicode_emoji_count),
|
||||
"monthlyBestBuddies": monthly_best_buddies,
|
||||
"weekdayLabels": heatmap_d.get("weekdayLabels") or [],
|
||||
"hourLabels": heatmap_d.get("hourLabels") or [],
|
||||
"weekdayHourMatrix": heatmap_d.get("matrix") or [],
|
||||
}
|
||||
|
||||
return {
|
||||
"id": 7,
|
||||
"title": "便当总览:一屏看完这一年",
|
||||
"scope": "global",
|
||||
"category": "A",
|
||||
"status": "ok",
|
||||
"kind": "global/bento_summary",
|
||||
"narrative": "把这一年的关键信息装进一份便当。",
|
||||
"data": {"snapshot": snapshot},
|
||||
}
|
||||
@@ -19,15 +19,16 @@ from .cards.card_05_keywords_wordcloud import build_card_05_keywords_wordcloud
|
||||
from .cards.card_03_reply_speed import build_card_03_reply_speed
|
||||
from .cards.card_04_monthly_best_friends_wall import build_card_04_monthly_best_friends_wall
|
||||
from .cards.card_04_emoji_universe import build_card_04_emoji_universe
|
||||
from .cards.card_07_bento_summary import build_card_07_bento_summary_from_sources
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
# We use this number to version the cache filename so adding more cards won't accidentally serve
|
||||
# an older partial cache.
|
||||
_IMPLEMENTED_UPTO_ID = 6
|
||||
_IMPLEMENTED_UPTO_ID = 7
|
||||
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
|
||||
_CACHE_VERSION = 24
|
||||
_CACHE_VERSION = 26
|
||||
|
||||
|
||||
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.
|
||||
@@ -82,6 +83,13 @@ _WRAPPED_CARD_MANIFEST: tuple[dict[str, Any], ...] = (
|
||||
"category": "B",
|
||||
"kind": "emoji/annual_universe",
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"title": "便当总览:一屏看完这一年",
|
||||
"scope": "global",
|
||||
"category": "A",
|
||||
"kind": "global/bento_summary",
|
||||
},
|
||||
)
|
||||
_WRAPPED_CARD_ID_SET = {int(c["id"]) for c in _WRAPPED_CARD_MANIFEST}
|
||||
|
||||
@@ -300,7 +308,7 @@ def build_wrapped_annual_response(
|
||||
) -> dict[str, Any]:
|
||||
"""Build annual wrapped response for the given account/year.
|
||||
|
||||
For now we implement cards up to id=6 (plus a meta overview card id=0).
|
||||
For now we implement cards up to id=7 (plus a meta overview card id=0).
|
||||
"""
|
||||
|
||||
account_dir = _resolve_account_dir(account)
|
||||
@@ -345,19 +353,37 @@ def build_wrapped_annual_response(
|
||||
# in first-person narratives like "你最常...".
|
||||
heatmap_sent = _get_or_compute_heatmap_sent(account_dir=account_dir, scope=scope, year=y, refresh=refresh)
|
||||
# Page 2: global overview (page 1 is the frontend cover slide).
|
||||
cards.append(build_card_00_global_overview(account_dir=account_dir, year=y, heatmap=heatmap_sent))
|
||||
card_overview = build_card_00_global_overview(account_dir=account_dir, year=y, heatmap=heatmap_sent)
|
||||
cards.append(card_overview)
|
||||
# Page 3: cyber schedule heatmap.
|
||||
cards.append(build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent))
|
||||
card_heatmap = build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent)
|
||||
cards.append(card_heatmap)
|
||||
# Page 4: message char counts (sent vs received).
|
||||
cards.append(build_card_02_message_chars(account_dir=account_dir, year=y))
|
||||
card_message_chars = build_card_02_message_chars(account_dir=account_dir, year=y)
|
||||
cards.append(card_message_chars)
|
||||
# Page 5: annual keywords (bubble storm -> word cloud).
|
||||
cards.append(build_card_05_keywords_wordcloud(account_dir=account_dir, year=y))
|
||||
# Page 6: reply speed / best chat buddy.
|
||||
cards.append(build_card_03_reply_speed(account_dir=account_dir, year=y))
|
||||
card_reply_speed = build_card_03_reply_speed(account_dir=account_dir, year=y)
|
||||
cards.append(card_reply_speed)
|
||||
# Page 7: monthly best friends wall (photo wall).
|
||||
cards.append(build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y))
|
||||
card_monthly = build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y)
|
||||
cards.append(card_monthly)
|
||||
# Page 8: annual emoji universe / meme almanac.
|
||||
cards.append(build_card_04_emoji_universe(account_dir=account_dir, year=y))
|
||||
card_emoji = build_card_04_emoji_universe(account_dir=account_dir, year=y)
|
||||
cards.append(card_emoji)
|
||||
# Page 9: bento summary (prototype). Build from prior cards for consistency.
|
||||
cards.append(
|
||||
build_card_07_bento_summary_from_sources(
|
||||
year=y,
|
||||
overview=card_overview,
|
||||
heatmap=card_heatmap,
|
||||
message_chars=card_message_chars,
|
||||
reply_speed=card_reply_speed,
|
||||
monthly=card_monthly,
|
||||
emoji=card_emoji,
|
||||
)
|
||||
)
|
||||
|
||||
obj: dict[str, Any] = {
|
||||
"account": account_dir.name,
|
||||
@@ -557,6 +583,23 @@ def build_wrapped_annual_card(
|
||||
card = build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y)
|
||||
elif cid == 5:
|
||||
card = build_card_04_emoji_universe(account_dir=account_dir, year=y)
|
||||
elif cid == 7:
|
||||
# Build from already-implemented cards so we can reuse their caches if available.
|
||||
overview = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=0, refresh=refresh)
|
||||
heatmap = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=1, refresh=refresh)
|
||||
message_chars = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=2, refresh=refresh)
|
||||
reply_speed = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=3, refresh=refresh)
|
||||
monthly = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=4, refresh=refresh)
|
||||
emoji = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=5, refresh=refresh)
|
||||
card = build_card_07_bento_summary_from_sources(
|
||||
year=y,
|
||||
overview=overview,
|
||||
heatmap=heatmap,
|
||||
message_chars=message_chars,
|
||||
reply_speed=reply_speed,
|
||||
monthly=monthly,
|
||||
emoji=emoji,
|
||||
)
|
||||
else:
|
||||
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
|
||||
raise ValueError(f"Unknown Wrapped card id: {cid}")
|
||||
|
||||
@@ -206,7 +206,6 @@ class TestChatExportChatHistoryModal(unittest.TestCase):
|
||||
html_path = next((n for n in names if n.endswith("/messages.html")), "")
|
||||
self.assertTrue(html_path)
|
||||
html_text = zf.read(html_path).decode("utf-8")
|
||||
self.assertIn('id="chatHistoryModal"', html_text)
|
||||
self.assertIn('data-wce-chat-history="1"', html_text)
|
||||
self.assertIn('data-record-item-b64="', html_text)
|
||||
self.assertIn('id="wceMediaIndex"', html_text)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
@@ -243,6 +245,22 @@ class TestChatExportHtmlFormat(unittest.TestCase):
|
||||
self._seed_media_files(account_dir)
|
||||
return account_dir
|
||||
|
||||
def _insert_missing_voice_message(self, account_dir: Path, *, username: str, server_id: int, duration_ms: int) -> None:
|
||||
conn = sqlite3.connect(str(account_dir / "message_0.db"))
|
||||
try:
|
||||
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
row = conn.execute(f"SELECT COALESCE(MAX(local_id), 0), COALESCE(MAX(sort_seq), 0) FROM {table_name}").fetchone()
|
||||
next_local_id = int((row[0] or 0)) + 1
|
||||
next_sort_seq = int((row[1] or 0)) + 1
|
||||
voice_xml = f'<msg><voicemsg voicelength="{int(duration_ms)}" /></msg>'
|
||||
conn.execute(
|
||||
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(next_local_id, int(server_id), 34, next_sort_seq, 2, 1735689700, voice_xml, None),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _create_job(self, manager, *, account: str, username: str):
|
||||
job = manager.create_job(
|
||||
account=account,
|
||||
@@ -283,7 +301,14 @@ class TestChatExportHtmlFormat(unittest.TestCase):
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
|
||||
original_converter = svc._convert_silk_to_browser_audio
|
||||
svc._convert_silk_to_browser_audio = (
|
||||
lambda data, preferred_format="mp3": (bytes(data or b""), "silk", "audio/silk")
|
||||
)
|
||||
try:
|
||||
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
|
||||
finally:
|
||||
svc._convert_silk_to_browser_audio = original_converter
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
self.assertTrue(job.zip_path and job.zip_path.exists())
|
||||
@@ -307,7 +332,6 @@ class TestChatExportHtmlFormat(unittest.TestCase):
|
||||
self.assertIn('data-wce-time-divider="1"', html_text)
|
||||
self.assertIn('id="messageTypeFilter"', html_text)
|
||||
self.assertIn('value="chatHistory"', html_text)
|
||||
self.assertIn('id="chatHistoryModal"', html_text)
|
||||
self.assertIn('data-wce-chat-history="1"', html_text)
|
||||
self.assertIn('data-record-item-b64="', html_text)
|
||||
self.assertIn('id="wceMediaIndex"', html_text)
|
||||
@@ -333,7 +357,10 @@ class TestChatExportHtmlFormat(unittest.TestCase):
|
||||
|
||||
css_text = zf.read("assets/wechat-chat-export.css").decode("utf-8", errors="ignore")
|
||||
self.assertIn("wechat-transfer-card", css_text)
|
||||
self.assertRegex(css_text, re.compile(r"\.wechat-voice-sent(?::|::)after"))
|
||||
self.assertRegex(css_text, re.compile(r"\.wechat-voice-received(?::|::)before"))
|
||||
self.assertNotIn("wechat-transfer-card[data-v-", css_text)
|
||||
self.assertNotIn("bento-container", css_text)
|
||||
|
||||
js_text = zf.read("assets/wechat-chat-export.js").decode("utf-8", errors="ignore")
|
||||
self.assertIn("wechat-voice-bubble", js_text)
|
||||
@@ -343,10 +370,90 @@ class TestChatExportHtmlFormat(unittest.TestCase):
|
||||
self.assertIn("assets/images/wechat/wechat-trans-icon1.png", names)
|
||||
self.assertIn("assets/images/wechat/zip.png", names)
|
||||
self.assertIn("assets/images/wechat/WeChat-Icon-Logo.wine.svg", names)
|
||||
self.assertTrue(any(n.startswith("fonts/") and n.endswith(".woff2") for n in names))
|
||||
self.assertIn("wxemoji/Expression_1@2x.png", names)
|
||||
self.assertIn("../../wxemoji/Expression_1@2x.png", html_text)
|
||||
finally:
|
||||
logging.shutdown()
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_html_export_prefers_mp3_for_voice_assets(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
|
||||
original_converter = svc._convert_silk_to_browser_audio
|
||||
svc._convert_silk_to_browser_audio = (
|
||||
lambda data, preferred_format="mp3": (b"ID3FAKE_MP3_DATA", "mp3", "audio/mpeg")
|
||||
)
|
||||
try:
|
||||
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
|
||||
finally:
|
||||
svc._convert_silk_to_browser_audio = original_converter
|
||||
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
self.assertTrue(job.zip_path and job.zip_path.exists())
|
||||
with zipfile.ZipFile(job.zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
voice_path = f"media/voices/voice_{self._VOICE_SERVER_ID}.mp3"
|
||||
self.assertIn(voice_path, names)
|
||||
self.assertNotIn(f"media/voices/voice_{self._VOICE_SERVER_ID}.wav", names)
|
||||
|
||||
html_path = next((n for n in names if n.endswith("/messages.html")), "")
|
||||
self.assertTrue(html_path)
|
||||
html_text = zf.read(html_path).decode("utf-8")
|
||||
self.assertIn(f"../../{voice_path}", html_text)
|
||||
finally:
|
||||
logging.shutdown()
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_html_export_keeps_voice_bubble_when_audio_file_missing(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
account_dir = self._prepare_account(root, account=account, username=username)
|
||||
self._insert_missing_voice_message(account_dir, username=username, server_id=999999, duration_ms=6543)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
original_converter = svc._convert_silk_to_browser_audio
|
||||
svc._convert_silk_to_browser_audio = (
|
||||
lambda data, preferred_format="mp3": (bytes(data or b""), "silk", "audio/silk")
|
||||
)
|
||||
try:
|
||||
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
|
||||
finally:
|
||||
svc._convert_silk_to_browser_audio = original_converter
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
self.assertTrue(job.zip_path and job.zip_path.exists())
|
||||
with zipfile.ZipFile(job.zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
html_path = next((n for n in names if n.endswith("/messages.html")), "")
|
||||
self.assertTrue(html_path)
|
||||
html_text = zf.read(html_path).decode("utf-8")
|
||||
self.assertIn("wechat-voice-wrapper", html_text)
|
||||
self.assertIn('data-render-type="voice"', html_text)
|
||||
self.assertIn('data-voice-id="message_0:msg_d5616d78f22fe35c632f66cabecfc82d:11"', html_text)
|
||||
self.assertIn('class="wechat-voice-duration">7"</span>', html_text)
|
||||
finally:
|
||||
logging.shutdown()
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
@@ -215,6 +216,7 @@ class TestChatExportHtmlPaging(unittest.TestCase):
|
||||
page1_text = zf.read(page1_js).decode("utf-8", errors="ignore")
|
||||
self.assertIn("MSG0001", page1_text)
|
||||
finally:
|
||||
logging.shutdown()
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
|
||||
@@ -132,6 +132,16 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
|
||||
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA[“测试好友”撤回了一条消息]]></replacemsg></revokemsg></sysmsg>',
|
||||
None,
|
||||
),
|
||||
(
|
||||
7,
|
||||
1007,
|
||||
48,
|
||||
7,
|
||||
2,
|
||||
1735689607,
|
||||
'<msg><location x="39.9042" y="116.4074" scale="15" label="北京市东城区东华门街道" poiname="天安门" /></msg>',
|
||||
None,
|
||||
),
|
||||
]
|
||||
conn.executemany(
|
||||
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
@@ -357,6 +367,41 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_checked_location_exports_location_fields(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(
|
||||
svc.CHAT_EXPORT_MANAGER,
|
||||
account=account,
|
||||
username=username,
|
||||
message_types=["location"],
|
||||
include_media=False,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, manifest, _ = self._load_export_payload(job.zip_path)
|
||||
location_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 48), None)
|
||||
self.assertIsNotNone(location_msg)
|
||||
self.assertEqual(str(location_msg.get("renderType") or ""), "location")
|
||||
self.assertEqual(str(location_msg.get("locationPoiname") or ""), "天安门")
|
||||
self.assertEqual(str(location_msg.get("locationLabel") or ""), "北京市东城区东华门街道")
|
||||
self.assertAlmostEqual(float(location_msg.get("locationLat") or 0), 39.9042, places=4)
|
||||
self.assertAlmostEqual(float(location_msg.get("locationLng") or 0), 116.4074, places=4)
|
||||
self.assertEqual(manifest.get("filters", {}).get("messageTypes"), ["location"])
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_privacy_mode_never_exports_media(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
def _close_logging_handlers() -> None:
|
||||
# Close handlers to avoid Windows temp dir cleanup failures (FileHandler holds a lock).
|
||||
import logging
|
||||
|
||||
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
|
||||
lg = logging.getLogger(logger_name)
|
||||
for h in lg.handlers[:]:
|
||||
try:
|
||||
h.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
lg.removeHandler(h)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class TestLoggingConfigDataDir(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
self._td = TemporaryDirectory()
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = self._td.name
|
||||
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.logging_config as logging_config
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(logging_config)
|
||||
|
||||
self.logging_config = logging_config
|
||||
|
||||
def tearDown(self):
|
||||
_close_logging_handlers()
|
||||
|
||||
if self._prev_data_dir is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = self._prev_data_dir
|
||||
self._td.cleanup()
|
||||
|
||||
def test_setup_logging_uses_wechat_tool_data_dir(self):
|
||||
log_file = self.logging_config.setup_logging()
|
||||
|
||||
base = Path(self._td.name) / "output" / "logs"
|
||||
self.assertTrue(log_file.is_relative_to(base))
|
||||
self.assertTrue(log_file.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -10,6 +10,34 @@ from wechat_decrypt_tool.chat_helpers import _parse_app_message
|
||||
|
||||
|
||||
class TestParseAppMessage(unittest.TestCase):
|
||||
def test_mini_program_type_33_parses_as_link(self):
|
||||
# 小程序分享是 appmsg type=33/36。部分 payload 会在 <weappinfo> 内嵌一个 <type>0</type>,
|
||||
# 并且出现在外层 <type>33</type> 之前,因此解析必须避免被嵌套 <type> 误导。
|
||||
raw_text = (
|
||||
"<msg><appmsg appid='' sdkver='0'>"
|
||||
"<title>锦城苑房源详情分享给你,点击查看哦~</title>"
|
||||
"<des></des>"
|
||||
"<weappinfo>"
|
||||
"<type>0</type>"
|
||||
"<username><![CDATA[gh_xxx@app]]></username>"
|
||||
"<weappiconurl><![CDATA[https://example.com/icon.png]]></weappiconurl>"
|
||||
"</weappinfo>"
|
||||
"<type>33</type>"
|
||||
"<url></url>"
|
||||
"<thumburl>https://example.com/thumb.jpg</thumburl>"
|
||||
"<sourcedisplayname><![CDATA[成都购房通]]></sourcedisplayname>"
|
||||
"</appmsg></msg>"
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "link")
|
||||
self.assertEqual(parsed.get("linkType"), "mini_program")
|
||||
self.assertEqual(parsed.get("title"), "锦城苑房源详情分享给你,点击查看哦~")
|
||||
self.assertEqual(parsed.get("from"), "成都购房通")
|
||||
self.assertEqual(parsed.get("fromUsername"), "gh_xxx@app")
|
||||
self.assertEqual(parsed.get("thumbUrl"), "https://example.com/thumb.jpg")
|
||||
|
||||
def test_quote_type_57_nested_refermsg_uses_inner_title(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure "src/" is importable when running tests from repo root.
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestWrappedBentoSummaryTopEmoji(unittest.TestCase):
|
||||
def _build_sources(self, *, emoji_data):
|
||||
# Keep sources minimal: card_07_bento_summary only needs a handful of keys.
|
||||
overview = {"data": {"totalMessages": 100, "addedFriends": 0}}
|
||||
heatmap = {"data": {"totalMessages": 100, "weekdayLabels": [], "hourLabels": [], "matrix": []}}
|
||||
message_chars = {"data": {"sentChars": 0}}
|
||||
reply_speed = {"data": {}}
|
||||
monthly = {"data": {"months": []}}
|
||||
emoji = {"data": emoji_data}
|
||||
return overview, heatmap, message_chars, reply_speed, monthly, emoji
|
||||
|
||||
def test_top_emoji_prefers_wechat_when_count_higher(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_07_bento_summary import build_card_07_bento_summary_from_sources
|
||||
|
||||
overview, heatmap, message_chars, reply_speed, monthly, emoji = self._build_sources(
|
||||
emoji_data={
|
||||
"topWechatEmojis": [{"key": "[微笑]", "count": 5, "assetPath": "/wxemoji/Expression_1@2x.png"}],
|
||||
"topTextEmojis": [],
|
||||
"topUnicodeEmojis": [{"emoji": "🙂", "count": 2}],
|
||||
}
|
||||
)
|
||||
card = build_card_07_bento_summary_from_sources(
|
||||
year=2025,
|
||||
overview=overview,
|
||||
heatmap=heatmap,
|
||||
message_chars=message_chars,
|
||||
reply_speed=reply_speed,
|
||||
monthly=monthly,
|
||||
emoji=emoji,
|
||||
)
|
||||
snap = card["data"]["snapshot"]
|
||||
self.assertEqual(snap["topEmoji"]["kind"], "wechat")
|
||||
self.assertEqual(snap["topEmoji"]["key"], "[微笑]")
|
||||
self.assertEqual(snap["topEmoji"]["count"], 5)
|
||||
self.assertTrue(str(snap["topEmoji"]["assetPath"]).startswith("/wxemoji/"))
|
||||
|
||||
def test_top_emoji_prefers_unicode_when_count_higher(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_07_bento_summary import build_card_07_bento_summary_from_sources
|
||||
|
||||
overview, heatmap, message_chars, reply_speed, monthly, emoji = self._build_sources(
|
||||
emoji_data={
|
||||
"topWechatEmojis": [{"key": "[微笑]", "count": 5, "assetPath": "/wxemoji/Expression_1@2x.png"}],
|
||||
"topTextEmojis": [],
|
||||
"topUnicodeEmojis": [{"emoji": "🙂", "count": 9}],
|
||||
}
|
||||
)
|
||||
card = build_card_07_bento_summary_from_sources(
|
||||
year=2025,
|
||||
overview=overview,
|
||||
heatmap=heatmap,
|
||||
message_chars=message_chars,
|
||||
reply_speed=reply_speed,
|
||||
monthly=monthly,
|
||||
emoji=emoji,
|
||||
)
|
||||
snap = card["data"]["snapshot"]
|
||||
self.assertEqual(snap["topEmoji"]["kind"], "unicode")
|
||||
self.assertEqual(snap["topEmoji"]["emoji"], "🙂")
|
||||
self.assertEqual(snap["topEmoji"]["count"], 9)
|
||||
|
||||
def test_top_emoji_includes_top_text_emojis(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_07_bento_summary import build_card_07_bento_summary_from_sources
|
||||
|
||||
overview, heatmap, message_chars, reply_speed, monthly, emoji = self._build_sources(
|
||||
emoji_data={
|
||||
"topWechatEmojis": [{"key": "[表情1]", "count": 2, "assetPath": "/wxemoji/Expression_1@2x.png"}],
|
||||
"topTextEmojis": [{"key": "[嘿哈]", "count": 4, "assetPath": "/wxemoji/Expression_99@2x.png"}],
|
||||
"topUnicodeEmojis": [{"emoji": "🙂", "count": 3}],
|
||||
}
|
||||
)
|
||||
card = build_card_07_bento_summary_from_sources(
|
||||
year=2025,
|
||||
overview=overview,
|
||||
heatmap=heatmap,
|
||||
message_chars=message_chars,
|
||||
reply_speed=reply_speed,
|
||||
monthly=monthly,
|
||||
emoji=emoji,
|
||||
)
|
||||
snap = card["data"]["snapshot"]
|
||||
self.assertEqual(snap["topEmoji"]["kind"], "wechat")
|
||||
self.assertEqual(snap["topEmoji"]["key"], "[嘿哈]")
|
||||
self.assertEqual(snap["topEmoji"]["count"], 4)
|
||||
self.assertTrue(str(snap["topEmoji"]["assetPath"]).endswith("Expression_99@2x.png"))
|
||||
|
||||
def test_top_emoji_none_when_no_emoji_stats(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_07_bento_summary import build_card_07_bento_summary_from_sources
|
||||
|
||||
overview, heatmap, message_chars, reply_speed, monthly, emoji = self._build_sources(
|
||||
emoji_data={"topWechatEmojis": [], "topTextEmojis": [], "topUnicodeEmojis": []}
|
||||
)
|
||||
card = build_card_07_bento_summary_from_sources(
|
||||
year=2025,
|
||||
overview=overview,
|
||||
heatmap=heatmap,
|
||||
message_chars=message_chars,
|
||||
reply_speed=reply_speed,
|
||||
monthly=monthly,
|
||||
emoji=emoji,
|
||||
)
|
||||
snap = card["data"]["snapshot"]
|
||||
self.assertIsNone(snap.get("topEmoji"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure "src/" is importable when running tests from repo root.
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
class TestWrappedManifestBentoSummary(unittest.TestCase):
|
||||
def test_manifest_appends_bento_summary(self):
|
||||
try:
|
||||
from wechat_decrypt_tool.wrapped.service import _WRAPPED_CARD_MANIFEST
|
||||
except ModuleNotFoundError as e:
|
||||
# Some dev/test environments may not have optional deps installed (e.g. pypinyin).
|
||||
# The manifest itself doesn't depend on them, but importing the service module does.
|
||||
if getattr(e, "name", "") == "pypinyin":
|
||||
self.skipTest("pypinyin is not installed")
|
||||
raise
|
||||
|
||||
self.assertTrue(len(_WRAPPED_CARD_MANIFEST) > 0)
|
||||
last = _WRAPPED_CARD_MANIFEST[-1]
|
||||
self.assertEqual(int(last.get("id")), 7)
|
||||
self.assertEqual(str(last.get("kind")), "global/bento_summary")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,9 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""调试消息类型返回值"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
|
||||
resp = requests.get('http://localhost:8000/api/chat/messages', params={
|
||||
PORT = os.environ.get("WECHAT_TOOL_PORT", "10392")
|
||||
resp = requests.get(f'http://localhost:{PORT}/api/chat/messages', params={
|
||||
'account': 'wxid_v4mbduwqtzpt22',
|
||||
'username': 'wxid_qmzc7q0xfm0j22',
|
||||
'limit': 100
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""测试图片 API"""
|
||||
import os
|
||||
import requests
|
||||
|
||||
r = requests.get(
|
||||
'http://localhost:8000/api/chat/media/image',
|
||||
f'http://localhost:{os.environ.get("WECHAT_TOOL_PORT", "10392")}/api/chat/media/image',
|
||||
params={
|
||||
'account': 'wxid_v4mbduwqtzpt22',
|
||||
'md5': '8753fcd3b1f8c4470b53551e13c5fbc1',
|
||||
|
||||
@@ -919,7 +919,7 @@ requires-dist = [
|
||||
{ name = "requests", specifier = ">=2.32.4" },
|
||||
{ name = "typing-extensions", specifier = ">=4.8.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
|
||||
{ name = "wx-key" },
|
||||
{ name = "wx-key", specifier = ">=1.1.0" },
|
||||
{ name = "zstandard", specifier = ">=0.23.0" },
|
||||
]
|
||||
provides-extras = ["build"]
|
||||
@@ -935,13 +935,13 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "wx-key"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
source = { registry = "tools/key_wheels" }
|
||||
wheels = [
|
||||
{ path = "wx_key-1.0.0-cp311-cp311-win_amd64.whl" },
|
||||
{ path = "wx_key-1.0.0-cp312-cp312-win_amd64.whl" },
|
||||
{ path = "wx_key-1.0.0-cp313-cp313-win_amd64.whl" },
|
||||
{ path = "wx_key-1.0.0-cp314-cp314-win_amd64.whl" },
|
||||
{ path = "wx_key-1.1.0-cp311-cp311-win_amd64.whl" },
|
||||
{ path = "wx_key-1.1.0-cp312-cp312-win_amd64.whl" },
|
||||
{ path = "wx_key-1.1.0-cp313-cp313-win_amd64.whl" },
|
||||
{ path = "wx_key-1.1.0-cp314-cp314-win_amd64.whl" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||