mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
19 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
|
||||
|
||||
@@ -49,6 +49,24 @@
|
||||
<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>
|
||||
@@ -136,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 桌面端)
|
||||
|
||||
@@ -173,6 +192,16 @@ npm run dist
|
||||
3. **数据隐私**: 解密后的数据包含个人隐私信息,请谨慎处理
|
||||
4. **合法使用**: 请遵守相关法律法规,不得用于非法目的
|
||||
|
||||
## 修改消息
|
||||
|
||||
支持在聊天页对单条消息进行本地修改(如修改消息文本/字段、修复为我发送、反转本地气泡方向),并在“修改记录”页查看原始与当前对比,支持单条恢复或按会话一键恢复。
|
||||
|
||||
该功能只修改本机本地数据库(`db_storage` 与解密副本),不会调用远端回写接口。
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/edit.gif" alt="本地消息修改" width="800" />
|
||||
</p>
|
||||
|
||||
## 致谢
|
||||
|
||||
本项目的开发过程中参考了以下优秀的开源项目和资源:
|
||||
|
||||
+24
-2
@@ -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
|
||||
|
||||
+390
-35
@@ -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"),
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -137,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 ''
|
||||
@@ -147,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(() => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -944,6 +944,8 @@ const formatDurationZh = (seconds) => {
|
||||
return h > 0 ? `${d}天${h}小时` : `${d}天`
|
||||
}
|
||||
|
||||
const apiBase = useApiBase()
|
||||
|
||||
const resolveMediaUrl = (value, opts = { backend: false }) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
@@ -952,13 +954,12 @@ const resolveMediaUrl = (value, opts = { backend: false }) => {
|
||||
try {
|
||||
const host = new URL(raw).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
// Keep same-origin `/api` so Nuxt devProxy / backend-mounted UI both work.
|
||||
return `/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
}
|
||||
} catch {}
|
||||
return raw
|
||||
}
|
||||
// Keep `/api/...` as same-origin (avoid hardcoding backend host like `localhost:8000`).
|
||||
if (/^\/api\//i.test(raw)) return `${apiBase}${raw.slice(4)}`
|
||||
return raw.startsWith('/') ? raw : `/${raw}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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'"
|
||||
/>
|
||||
@@ -814,6 +815,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"
|
||||
@@ -1490,12 +1494,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">
|
||||
@@ -1602,12 +1606,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 +1772,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">
|
||||
@@ -2465,6 +2469,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 +3635,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 +3894,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 () => {
|
||||
@@ -5953,7 +5958,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 +6044,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 +6094,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 +6126,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 +6134,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 +6151,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 +6170,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 +6181,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 +6191,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 +6233,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 +6244,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 +6254,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 +6313,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 +6418,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 +6761,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 +6785,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 +6799,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 +6810,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 +6821,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 +6840,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 +6852,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 = '[图片]'
|
||||
}
|
||||
|
||||
@@ -7190,7 +7207,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 +7216,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 +7231,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
|
||||
@@ -7843,13 +7860,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 +7882,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 +7893,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 +7903,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 +7922,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 +7950,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 +7990,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 +8099,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 +8121,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 +8133,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 +8911,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 +8939,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 +8966,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;
|
||||
@@ -9057,3 +9311,4 @@ const LinkCard = defineComponent({
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
+75
-85
@@ -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>
|
||||
+16
-16
@@ -794,7 +794,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 +835,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 +866,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 +1107,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 +1143,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 +1441,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 +1458,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 +1513,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 +1568,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 +1589,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 +1610,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 +1726,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()}`
|
||||
}
|
||||
|
||||
// 图片预览 + 候选匹配选择
|
||||
@@ -2114,7 +2114,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)}`
|
||||
}
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 679 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 324 KiB |
@@ -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 停止服务")
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ from .chat_helpers import (
|
||||
_load_latest_message_previews,
|
||||
_lookup_resource_md5,
|
||||
_parse_app_message,
|
||||
_parse_location_message,
|
||||
_parse_system_message_content,
|
||||
_parse_pat_message,
|
||||
_pick_display_name,
|
||||
@@ -3378,6 +3379,10 @@ def _parse_message_for_export(
|
||||
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"
|
||||
@@ -3437,6 +3442,14 @@ def _parse_message_for_export(
|
||||
quote_voice_length = str(parsed.get("quoteVoiceLength") or "")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
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 == 3:
|
||||
render_type = "image"
|
||||
def add_md5(v: Any) -> None:
|
||||
@@ -3708,6 +3721,10 @@ def _parse_message_for_export(
|
||||
"transferStatus": transfer_status,
|
||||
"transferId": transfer_id,
|
||||
"voipType": voip_type,
|
||||
"locationLat": location_lat,
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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">'
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user