Compare commits

...

12 Commits

33 changed files with 3391 additions and 330 deletions
+28
View File
@@ -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>
@@ -174,6 +192,16 @@ npm run dist
3. **数据隐私**: 解密后的数据包含个人隐私信息,请谨慎处理
4. **合法使用**: 请遵守相关法律法规,不得用于非法目的
## 修改消息
支持在聊天页对单条消息进行本地修改(如修改消息文本/字段、修复为我发送、反转本地气泡方向),并在“修改记录”页查看原始与当前对比,支持单条恢复或按会话一键恢复。
该功能只修改本机本地数据库(`db_storage` 与解密副本),不会调用远端回写接口。
<p align="center">
<img src="frontend/public/edit.gif" alt="本地消息修改" width="800" />
</p>
## 致谢
本项目的开发过程中参考了以下优秀的开源项目和资源:
+32
View File
@@ -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
+405 -13
View File
@@ -7,6 +7,7 @@ const {
globalShortcut,
dialog,
shell,
session,
} = require("electron");
let autoUpdater = null;
let autoUpdaterLoadError = null;
@@ -123,6 +124,68 @@ function isPortAvailable(port, host) {
});
}
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;
@@ -153,6 +216,161 @@ function getUserDataDir() {
return resolveDataDir();
}
function sanitizeAccountName(account) {
const name = String(account || "").trim();
if (!name) throw new Error("缺少账号参数");
if (name === "." || name === "..") throw new Error("账号参数非法");
if (name.includes("/") || name.includes("\\")) throw new Error("账号参数非法");
return name;
}
function listDecryptedAccountsOnDisk(databasesDir) {
try {
if (!fs.existsSync(databasesDir)) return [];
} catch {
return [];
}
let entries = [];
try {
entries = fs.readdirSync(databasesDir, { withFileTypes: true });
} catch {
return [];
}
const accounts = [];
for (const entry of entries) {
try {
if (!entry || !entry.isDirectory()) continue;
const accountDir = path.join(databasesDir, entry.name);
const hasSession = fs.existsSync(path.join(accountDir, "session.db"));
const hasContact = fs.existsSync(path.join(accountDir, "contact.db"));
if (hasSession && hasContact) accounts.push(String(entry.name || ""));
} catch {}
}
accounts.sort((a, b) => a.localeCompare(b));
return accounts;
}
function resolveAccountDirInOutput(account) {
const dataDir = resolveDataDir();
if (!dataDir) throw new Error("无法定位数据目录");
const outputDir = path.join(dataDir, "output");
const databasesDir = path.join(outputDir, "databases");
const accountName = sanitizeAccountName(account);
const base = path.resolve(databasesDir);
const accountDir = path.resolve(path.join(databasesDir, accountName));
if (accountDir !== base && !accountDir.startsWith(base + path.sep)) {
throw new Error("账号路径非法");
}
return {
dataDir,
outputDir,
databasesDir,
accountName,
accountDir,
};
}
function getAccountInfoFromDisk(account) {
const { accountName, accountDir } = resolveAccountDirInOutput(account);
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
throw new Error("账号数据不存在");
}
let entries = [];
try {
entries = fs.readdirSync(accountDir, { withFileTypes: true });
} catch {}
const dbFiles = entries
.filter((e) => !!e && e.isFile() && String(e.name || "").toLowerCase().endsWith(".db"))
.map((e) => String(e.name || ""))
.sort((a, b) => a.localeCompare(b));
let sessionUpdatedAt = 0;
try {
const st = fs.statSync(path.join(accountDir, "session.db"));
sessionUpdatedAt = Math.floor(Number(st?.mtimeMs || 0) / 1000);
} catch {}
return {
status: "success",
account: accountName,
path: accountDir,
database_count: dbFiles.length,
databases: dbFiles,
session_updated_at: sessionUpdatedAt,
};
}
function removeAccountFromKeyStore(dataDir, accountName) {
const keyStorePath = path.join(dataDir, "output", "account_keys.json");
try {
if (!fs.existsSync(keyStorePath)) return false;
const raw = fs.readFileSync(keyStorePath, { encoding: "utf8" });
const parsed = JSON.parse(raw || "{}");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
if (!Object.prototype.hasOwnProperty.call(parsed, accountName)) return false;
delete parsed[accountName];
fs.writeFileSync(keyStorePath, JSON.stringify(parsed, null, 2), { encoding: "utf8" });
return true;
} catch {
return false;
}
}
async function deleteAccountDataFromDisk(account) {
const { dataDir, outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
throw new Error("账号数据不存在");
}
const wasBackendRunning = !!backendProc;
let restartError = null;
let result = null;
if (wasBackendRunning) {
await stopBackendAndWait({ timeoutMs: 10_000 });
}
try {
const exportsDir = path.join(outputDir, "exports", accountName);
try {
fs.rmSync(exportsDir, { recursive: true, force: true });
} catch {}
fs.rmSync(accountDir, { recursive: true, force: true });
const removedKeyCache = removeAccountFromKeyStore(dataDir, accountName);
const accounts = listDecryptedAccountsOnDisk(databasesDir);
result = {
status: "success",
deleted_account: accountName,
accounts,
default_account: accounts.length ? accounts[0] : null,
removed_key_cache: removedKeyCache,
};
} finally {
if (wasBackendRunning) {
try {
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
} catch (err) {
restartError = err;
logMain(`[main] failed to restart backend after deleteAccountData: ${err?.message || err}`);
}
}
}
if (restartError) {
throw new Error(`删除完成,但后端重启失败:${restartError?.message || restartError}`);
}
if (!result) throw new Error("删除账号数据失败");
return result;
}
function getExeDir() {
try {
return path.dirname(process.execPath);
@@ -163,7 +381,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();
@@ -171,26 +393,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() {
@@ -214,6 +466,34 @@ function getDesktopSettingsPath() {
return path.join(dir, "desktop-settings.json");
}
function getPackagedUiDir() {
if (!app.isPackaged) return null;
try {
return path.join(process.resourcesPath, "ui");
} catch {
return null;
}
}
function readPackagedUiBuildId() {
const uiDir = getPackagedUiDir();
if (!uiDir) return "";
try {
const indexPath = path.join(uiDir, "index.html");
if (!fs.existsSync(indexPath)) return "";
const html = fs.readFileSync(indexPath, { encoding: "utf8" });
const match =
html.match(/buildId:"([^"]+)"/) ||
html.match(/\/_payload\.json\?([^"'&<>\s]+)/) ||
html.match(/data-src="\/_payload\.json\?([^"]+)"/);
return String(match?.[1] || "").trim();
} catch (err) {
logMain(`[main] failed to read packaged UI build id: ${err?.message || err}`);
return "";
}
}
function loadDesktopSettings() {
if (desktopSettings) return desktopSettings;
@@ -225,6 +505,9 @@ function loadDesktopSettings() {
ignoredUpdateVersion: "",
// Backend (FastAPI) listens on this port. Used in packaged builds.
backendPort: DEFAULT_BACKEND_PORT,
// Tracks the packaged UI build so we can invalidate Chromium's HTTP cache
// after upgrades without wiping user data/localStorage.
lastSeenUiBuildId: "",
};
const p = getDesktopSettingsPath();
@@ -288,6 +571,33 @@ function setIgnoredUpdateVersion(version) {
return desktopSettings.ignoredUpdateVersion;
}
async function refreshRendererCacheForPackagedUi() {
if (!app.isPackaged) return;
const nextBuildId = readPackagedUiBuildId();
if (!nextBuildId) return;
const prevBuildId = String(loadDesktopSettings()?.lastSeenUiBuildId || "").trim();
if (prevBuildId === nextBuildId) return;
try {
const ses = session?.defaultSession;
if (ses) {
await ses.clearCache();
try {
await ses.clearStorageData({ storages: ["serviceworkers"] });
} catch {}
}
logMain(`[main] cleared renderer cache for UI build change: ${prevBuildId || "(none)"} -> ${nextBuildId}`);
} catch (err) {
logMain(`[main] failed to clear renderer cache for UI build change: ${err?.message || err}`);
}
loadDesktopSettings();
desktopSettings.lastSeenUiBuildId = nextBuildId;
persistDesktopSettings();
}
function parseEnvBool(value) {
if (value == null) return null;
const v = String(value).trim().toLowerCase();
@@ -990,6 +1300,16 @@ async function waitForBackend({ timeoutMs, healthUrl } = {}) {
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(url);
if (code >= 200 && code < 500) return;
@@ -1254,6 +1574,46 @@ 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:getAccountInfo", async (_event, account) => {
try {
return getAccountInfoFromDisk(account);
} catch (e) {
throw new Error(e?.message || String(e));
}
});
ipcMain.handle("app:deleteAccountData", async (_event, account) => {
try {
return await deleteAccountDataFromDisk(account);
} catch (e) {
throw new Error(e?.message || String(e));
}
});
ipcMain.handle("app:checkForUpdates", async () => {
return await checkForUpdatesInternal();
});
@@ -1272,6 +1632,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) {
@@ -1308,19 +1673,46 @@ function registerWindowIpc() {
async function main() {
await app.whenReady();
await refreshRendererCacheForPackagedUi();
Menu.setApplicationMenu(null);
registerWindowIpc();
registerDebugShortcuts();
// Resolve/create the data dir early so we can log reliably and (optionally) place a link
// Resolve/create the data dir early so we can log reliably and place helper files
// next to the installed exe for easier access.
resolveDataDir();
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;
+6
View File
@@ -19,6 +19,12 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
// Data/output folder helpers
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")),
deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")),
// Auto update
getVersion: () => ipcRenderer.invoke("app:getVersion"),
checkForUpdates: () => ipcRenderer.invoke("app:checkForUpdates"),
@@ -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

+261
View File
@@ -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
+295 -143
View File
@@ -63,168 +63,222 @@
</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"
/>
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">桌面行为</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
<div class="px-3.5 py-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"
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"
role="switch"
:aria-checked="desktopAutoLaunch"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopAutoLaunch, !isDesktopEnv || desktopAutoLaunchLoading)"
:disabled="!isDesktopEnv || desktopAutoLaunchLoading"
@click="toggleDesktopAutoLaunch"
>
{{ 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"
>
恢复默认
<span class="settings-switch-thumb" :class="desktopAutoLaunch ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
<div v-if="desktopAutoLaunchError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopAutoLaunchError }}
</div>
</div>
<div v-if="desktopBackendPortError" class="text-xs text-red-600 whitespace-pre-wrap -mt-1.5">
{{ desktopBackendPortError }}
<div class="px-3.5 py-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>
<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="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopCloseBehaviorError }}
</div>
</div>
<div class="px-3.5 py-3">
<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="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopBackendPortError }}
</div>
</div>
<div class="px-3.5 py-3">
<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="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopOutputDirError }}
</div>
</div>
<div class="px-3.5 py-3">
<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] break-words">{{ desktopLogFileText }}</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="desktopLogFileLoading || desktopLogFileOpening"
@click="onOpenBackendLogFile"
>
{{ desktopLogFileOpening ? '打开中...' : '打开日志' }}
</button>
</div>
<div v-if="desktopLogFileError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopLogFileError }}
</div>
</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 class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">启动偏好</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
<div class="px-3.5 py-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>
<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 class="px-3.5 py-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]">有已解密账号时打开应用跳转到 /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>
<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 class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">更新</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
<div class="px-3.5 py-3">
<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>
</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 class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">朋友圈</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
<div class="px-3.5 py-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="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>
</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>
@@ -237,8 +291,9 @@
<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'
import { reportServerErrorFromError } from '~/utils/server-error-logging'
defineProps({
const props = defineProps({
open: {
type: Boolean,
default: false,
@@ -288,6 +343,24 @@ 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 desktopLogFilePath = ref('')
const desktopLogFileLoading = ref(false)
const desktopLogFileOpening = ref(false)
const desktopLogFileError = ref('')
const desktopLogFileText = computed(() => {
const v = String(desktopLogFilePath.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'
@@ -333,6 +406,24 @@ const onEscKeydown = (event) => {
handleClose()
}
const fetchAdminEndpoint = async (url, options = {}) => {
const apiBase = useApiBase()
try {
return await $fetch(url, {
baseURL: apiBase,
...options,
})
} catch (e) {
await reportServerErrorFromError(e, {
method: options?.method || 'GET',
requestUrl: url,
source: 'SettingsDialog',
apiBase,
})
throw e
}
}
const refreshDesktopAutoLaunch = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getAutoLaunch) return
@@ -409,8 +500,7 @@ const refreshDesktopBackendPort = async () => {
}
try {
const apiBase = useApiBase()
const resp = await $fetch('/admin/port', { baseURL: apiBase })
const resp = await fetchAdminEndpoint('/admin/port')
const n = Number(resp?.port)
const d = Number(resp?.default_port)
if (Number.isInteger(d) && d >= 1 && d <= 65535) desktopBackendPortDefault.value = d
@@ -437,6 +527,64 @@ const refreshDesktopBackendPort = async () => {
}
}
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 refreshBackendLogFileInfo = async () => {
if (!process.client || typeof window === 'undefined') return
desktopLogFileLoading.value = true
desktopLogFileError.value = ''
try {
const resp = await fetchAdminEndpoint('/admin/log-file')
desktopLogFilePath.value = String(resp?.path || '').trim()
} catch (e) {
desktopLogFileError.value = e?.message || '读取日志文件失败'
} finally {
desktopLogFileLoading.value = false
}
}
const onOpenBackendLogFile = async () => {
if (!process.client || typeof window === 'undefined') return
desktopLogFileOpening.value = true
desktopLogFileError.value = ''
try {
const resp = await fetchAdminEndpoint('/admin/log-file/open', { method: 'POST' })
if (resp?.path) desktopLogFilePath.value = String(resp.path || '').trim()
} catch (e) {
desktopLogFileError.value = e?.message || '打开日志文件失败'
} finally {
desktopLogFileOpening.value = false
}
}
const applyDesktopBackendPort = async () => {
if (!process.client || typeof window === 'undefined') return
const raw = String(desktopBackendPortInput.value || '').trim()
@@ -453,10 +601,9 @@ const applyDesktopBackendPort = async () => {
return
}
const currentApiBase = useApiBase()
let currentBackendPort = null
try {
const info = await $fetch('/admin/port', { baseURL: currentApiBase })
const info = await fetchAdminEndpoint('/admin/port')
const p = Number(info?.port)
if (Number.isInteger(p) && p >= 1 && p <= 65535) currentBackendPort = p
} catch {}
@@ -467,8 +614,7 @@ const applyDesktopBackendPort = async () => {
})()
const isUiServedByBackend = !!(currentBackendPort && uiPort === currentBackendPort)
await $fetch('/admin/port', {
baseURL: currentApiBase,
await fetchAdminEndpoint('/admin/port', {
method: 'POST',
body: { port: n },
})
@@ -551,6 +697,11 @@ const onDesktopCheckUpdates = async () => {
await desktopUpdate.manualCheck()
}
watch(() => props.open, async (isOpen) => {
if (!isOpen) return
await refreshBackendLogFileInfo()
}, { immediate: true })
onMounted(async () => {
if (process.client && typeof window !== 'undefined') {
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
@@ -567,6 +718,7 @@ onMounted(async () => {
void desktopUpdate.initListeners()
await refreshDesktopAutoLaunch()
await refreshDesktopCloseBehavior()
await refreshDesktopOutputDir()
}
await nextTick()
+300 -17
View File
@@ -6,7 +6,12 @@
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
<!-- Avatar -->
<div class="w-full h-[60px] flex items-center justify-center">
<div class="w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
<button
type="button"
class="group relative w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0 ring-1 ring-transparent transition hover:ring-[#07b75b]/40"
title="账号信息"
@click="openAccountDialog"
>
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
<div
v-else
@@ -15,7 +20,7 @@
>
</div>
</div>
</button>
</div>
<!-- Chat -->
@@ -164,22 +169,116 @@
</div>
</div>
<!-- Settings -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="goSettings"
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="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"
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 stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<div class="mt-auto">
<!-- Guide -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="引导页"
@click="goGuide"
>
<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)] text-[#5d5d5d]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 10.5L12 3l9 7.5" />
<path d="M5 9.5V20h14V9.5" />
<path d="M10 20v-6h4v6" />
</svg>
</div>
</div>
<!-- Settings -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="goSettings"
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="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"
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 stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
</div>
</div>
</div>
</div>
<div
v-if="accountDialogOpen"
class="fixed inset-0 z-[130] flex items-center justify-center bg-black/35 px-4"
@click.self="closeAccountDialog"
>
<div class="w-full max-w-[440px] overflow-hidden rounded-[12px] border border-[#e7e7e7] bg-white shadow-2xl">
<div class="flex items-center justify-between border-b border-[#efefef] px-4 py-3">
<div class="text-[14px] font-semibold text-[#222]">当前账号信息</div>
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-md text-[#888] transition hover:bg-[#f2f2f2] hover:text-[#222]"
title="关闭"
:disabled="accountDeleteLoading"
@click="closeAccountDialog"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="space-y-3 px-4 py-4">
<div v-if="accountInfoLoading" class="text-[12px] text-[#7a7a7a]">正在加载账号信息...</div>
<template v-else>
<div class="flex items-center gap-3">
<div class="w-[42px] h-[42px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
<div
v-else
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
:style="{ backgroundColor: '#4B5563' }"
>
</div>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-[14px] font-semibold text-[#222]">{{ selectedAccount || '未选择账号' }}</div>
<div class="mt-0.5 text-[11px] text-[#8a8a8a]">账号标识wxid</div>
</div>
</div>
<div class="rounded-[8px] border border-[#ededed] bg-[#fafafa] px-3 py-2 text-[12px] text-[#5f5f5f] space-y-1.5">
<div class="flex items-start justify-between gap-3">
<span class="text-[#8a8a8a] shrink-0">数据库数量</span>
<span class="font-medium text-[#333]">{{ accountInfo?.database_count ?? '—' }}</span>
</div>
<div class="flex items-start justify-between gap-3">
<span class="text-[#8a8a8a] shrink-0">数据目录</span>
<span class="break-all text-right text-[#444]">{{ accountInfo?.path || (selectedAccount ? `output/databases/${selectedAccount}` : '—') }}</span>
</div>
<div class="flex items-start justify-between gap-3">
<span class="text-[#8a8a8a] shrink-0">最近会话库更新时间</span>
<span class="text-[#444]">{{ sessionUpdatedAtText }}</span>
</div>
</div>
</template>
<div class="rounded-[8px] border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] leading-relaxed text-amber-900">
仅删除本项目中的该账号解析数据/缓存/编辑记录不会删除微信客户端中的任何聊天内容或账号数据
</div>
<button
type="button"
class="w-full rounded-[8px] border border-red-200 bg-red-50 px-3 py-2 text-[12px] font-medium text-red-700 transition hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!selectedAccount || accountDeleteLoading"
@click="deleteCurrentAccountData"
>
{{ accountDeleteLoading ? '删除中...' : '删除当前账号的项目数据' }}
</button>
<div class="text-[11px] text-[#8a8a8a]">删除成功后将自动返回引导页</div>
<div v-if="accountInfoError" class="text-[11px] text-red-600 whitespace-pre-wrap">{{ accountInfoError }}</div>
<div v-if="accountDeleteError" class="text-[11px] text-red-600 whitespace-pre-wrap">{{ accountDeleteError }}</div>
</div>
</div>
</div>
@@ -202,9 +301,130 @@ 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()
const { getChatAccountInfo, deleteChatAccount } = useApi()
const accountDialogOpen = ref(false)
const accountInfoLoading = ref(false)
const accountInfoError = ref('')
const accountInfo = ref(null)
const accountDeleteLoading = ref(false)
const accountDeleteError = ref('')
const accountInfoApiUnsupported = ref(false)
const deleteAccountApiUnsupported = ref(false)
const sessionUpdatedAtText = computed(() => {
const ts = Number(accountInfo.value?.session_updated_at || 0)
if (!Number.isFinite(ts) || ts <= 0) return '—'
try {
return new Date(ts * 1000).toLocaleString('zh-CN')
} catch {
return '—'
}
})
const isNotFoundError = (error) => {
const status = Number(
error?.statusCode
?? error?.status
?? error?.response?.status
?? error?.data?.statusCode
?? 0
)
return status === 404
}
const loadAccountInfoByDesktopBridge = async (account) => {
if (!process.client || typeof window === 'undefined') return null
if (!window.wechatDesktop?.getAccountInfo) return null
const res = await window.wechatDesktop.getAccountInfo(account)
return res && typeof res === 'object' ? res : null
}
const loadAccountInfo = async () => {
accountInfoLoading.value = true
accountInfoError.value = ''
const account = String(selectedAccount.value || '').trim()
if (!account) {
accountInfo.value = null
accountInfoLoading.value = false
return
}
try {
let lastError = null
if (!accountInfoApiUnsupported.value) {
try {
const res = await getChatAccountInfo({ account })
if (res?.status !== 'success') {
throw new Error(res?.message || '读取账号信息失败')
}
accountInfo.value = res
return
} catch (e) {
lastError = e
if (isNotFoundError(e)) {
accountInfoApiUnsupported.value = true
}
}
}
try {
const fallback = await loadAccountInfoByDesktopBridge(account)
if (fallback?.status === 'success') {
accountInfo.value = fallback
accountInfoError.value = ''
return
}
if (fallback && fallback?.status && fallback.status !== 'success') {
lastError = new Error(fallback?.message || '读取账号信息失败')
} else if (!lastError) {
lastError = new Error('读取账号信息失败')
}
} catch (fallbackErr) {
if (!lastError) {
lastError = fallbackErr
}
}
accountInfo.value = null
accountInfoError.value = lastError?.message || '读取账号信息失败'
} finally {
accountInfoLoading.value = false
}
}
const deleteAccountDataByDesktopBridge = async (account) => {
if (!process.client || typeof window === 'undefined') return null
if (!window.wechatDesktop?.deleteAccountData) return null
const res = await window.wechatDesktop.deleteAccountData(account)
return res && typeof res === 'object' ? res : { status: 'success' }
}
const openAccountDialog = async () => {
accountDialogOpen.value = true
accountDeleteError.value = ''
await loadAccountInfo()
}
const closeAccountDialog = () => {
if (accountDeleteLoading.value) return
accountDialogOpen.value = false
}
watch(selectedAccount, () => {
if (!accountDialogOpen.value) return
void loadAccountInfo()
})
onMounted(async () => {
await chatAccounts.ensureLoaded()
if (process.client && typeof window !== 'undefined') {
window.addEventListener('keydown', onWindowKeydown)
}
})
onBeforeUnmount(() => {
if (!process.client || typeof window === 'undefined') return
window.removeEventListener('keydown', onWindowKeydown)
})
const apiBase = useApiBase()
@@ -240,10 +460,73 @@ const goWrapped = async () => {
await navigateTo('/wrapped')
}
const goGuide = async () => {
await navigateTo('/')
}
const goSettings = () => {
openSettingsDialog()
}
const onWindowKeydown = (event) => {
if (event?.key !== 'Escape') return
if (!accountDialogOpen.value) return
event.preventDefault()
closeAccountDialog()
}
const deleteCurrentAccountData = async () => {
const account = String(selectedAccount.value || '').trim()
if (!account || accountDeleteLoading.value) return
if (process.client && typeof window !== 'undefined') {
const confirmed = window.confirm(
'将删除当前账号在本项目中的数据(解析缓存、编辑记录、导出缓存等),不会删除微信客户端内容。确认删除吗?'
)
if (!confirmed) return
}
accountDeleteLoading.value = true
accountDeleteError.value = ''
try {
let deleted = false
let lastError = null
if (!deleteAccountApiUnsupported.value) {
try {
const apiRes = await deleteChatAccount({ account })
if (apiRes?.status && apiRes.status !== 'success') {
throw new Error(apiRes?.message || '删除账号数据失败')
}
deleted = true
} catch (apiErr) {
lastError = apiErr
if (isNotFoundError(apiErr)) {
deleteAccountApiUnsupported.value = true
}
}
}
if (!deleted) {
const desktopRes = await deleteAccountDataByDesktopBridge(account)
if (!desktopRes) {
throw lastError || new Error('删除账号数据失败')
}
if (desktopRes?.status && desktopRes.status !== 'success') {
throw new Error(desktopRes?.message || '删除账号数据失败')
}
}
accountDialogOpen.value = false
await chatAccounts.ensureLoaded({ force: true })
await navigateTo('/')
} catch (e) {
accountDeleteError.value = e?.message || '删除账号数据失败'
} finally {
accountDeleteLoading.value = false
}
}
const realtimeBusy = computed(() => !!realtimeChecking.value || !!realtimeToggling.value)
const realtimeTitle = computed(() => {
+31 -2
View File
@@ -1,3 +1,5 @@
import { reportServerError } from '~/utils/server-error-logging'
// API请求组合式函数
export const useApi = () => {
const baseURL = useApiBase()
@@ -8,10 +10,19 @@ export const useApi = () => {
const response = await $fetch(url, {
baseURL,
...options,
onResponseError({ response }) {
async onResponseError({ response }) {
if (response.status === 400) {
throw new Error(response._data?.detail || '请求参数错误')
} else if (response.status === 500) {
} else if (response.status >= 500) {
await reportServerError({
status: response.status,
method: options?.method || 'GET',
requestUrl: url,
message: '服务器错误,请稍后重试',
backendDetail: response._data?.detail || '',
source: 'useApi',
apiBase: baseURL,
})
throw new Error('服务器错误,请稍后重试')
}
}
@@ -60,6 +71,22 @@ export const useApi = () => {
return await request('/chat/accounts')
}
const getChatAccountInfo = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
const url = '/chat/account_info' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
const deleteChatAccount = async (params = {}) => {
const account = String(params?.account || '').trim()
if (!account) throw new Error('Missing account')
const query = new URLSearchParams()
query.set('account', account)
const url = '/chat/account' + (query.toString() ? `?${query.toString()}` : '')
return await request(url, { method: 'DELETE' })
}
const listChatSessions = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
@@ -540,6 +567,8 @@ export const useApi = () => {
decryptDatabase,
healthCheck,
listChatAccounts,
getChatAccountInfo,
deleteChatAccount,
listChatSessions,
listChatMessages,
getChatMessageRaw,
+392 -74
View File
@@ -551,6 +551,7 @@
:preview="message.preview"
:fromAvatar="message.fromAvatar"
:from="message.from"
:linkType="message.linkType"
:isSent="message.isSent"
:variant="message.linkCardVariant || 'default'"
/>
@@ -588,17 +589,16 @@
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
{{ message.content }}
</div>
<a
<button
v-if="message.videoThumbUrl && message.videoUrl"
:href="message.videoUrl"
target="_blank"
rel="noreferrer"
type="button"
class="absolute inset-0 flex items-center justify-center"
@click.stop="openVideoPreview(message.videoUrl, message.videoThumbUrl)"
>
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</div>
</a>
</button>
<div class="absolute inset-0 flex items-center justify-center" v-else-if="message.videoThumbUrl">
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
@@ -814,6 +814,9 @@
<span>微信红包</span>
</div>
</div>
<div v-else-if="message.renderType === 'location'" class="max-w-sm">
<ChatLocationCard :message="message" />
</div>
<!-- 文本消息 -->
<div v-else-if="message.renderType === 'text'"
class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
@@ -1384,6 +1387,40 @@
</button>
</div>
<!-- 视频预览弹窗 (全局固定定位) -->
<div
v-if="previewVideoUrl"
class="fixed inset-0 z-[13000] bg-black/90 flex items-center justify-center"
@click="closeVideoPreview"
>
<div class="relative max-w-[92vw] max-h-[92vh] flex flex-col items-center" @click.stop>
<video
:key="previewVideoUrl"
:src="previewVideoUrl"
:poster="previewVideoPosterUrl"
class="max-w-[90vw] max-h-[90vh] object-contain"
controls
autoplay
playsinline
@error="onPreviewVideoError"
></video>
<div
v-if="previewVideoError"
class="mt-3 text-xs text-red-200 whitespace-pre-wrap text-center max-w-[90vw]"
>
{{ previewVideoError }}
</div>
</div>
<button
class="absolute top-4 right-4 text-white/80 hover:text-white p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors"
@click.stop="closeVideoPreview"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- 浮动窗口可拖动合并消息 / 链接卡片 -->
<div
v-for="win in floatingWindows"
@@ -1490,12 +1527,12 @@
@contextmenu="openMediaContextMenu($event, rec, 'message')"
>
<div class="wechat-link-content">
<div class="wechat-link-info">
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
<div v-if="rec.content || rec.preview" class="wechat-link-summary">
<div v-if="rec.content" class="wechat-link-desc">{{ rec.content }}</div>
</div>
<div v-if="rec.preview" class="wechat-link-thumb">
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
<div v-if="rec.preview" class="wechat-link-thumb">
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
</div>
</div>
</div>
<div class="wechat-link-from">
@@ -1557,17 +1594,16 @@
@error="onChatHistoryVideoThumbError(rec)"
/>
<div v-else class="px-3 py-2 text-sm text-gray-700">{{ rec.content || '[视频]' }}</div>
<a
<button
v-if="rec.videoUrl"
:href="rec.videoUrl"
target="_blank"
rel="noreferrer"
type="button"
class="absolute inset-0 flex items-center justify-center"
@click.stop="openVideoPreview(rec.videoUrl, rec.videoThumbUrl)"
>
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</div>
</a>
</button>
<div
v-if="rec.videoDuration"
class="absolute bottom-2 right-2 text-xs text-white bg-black/55 px-1.5 py-0.5 rounded"
@@ -1602,12 +1638,12 @@
@contextmenu="openMediaContextMenu($event, win, 'message')"
>
<div class="wechat-link-content">
<div class="wechat-link-info">
<div class="wechat-link-title">{{ win.title || win.url || '链接' }}</div>
<div class="wechat-link-title">{{ win.title || win.url || '链接' }}</div>
<div v-if="win.content || win.preview" class="wechat-link-summary">
<div v-if="win.content" class="wechat-link-desc">{{ win.content }}</div>
</div>
<div v-if="win.preview" class="wechat-link-thumb">
<img :src="win.preview" :alt="win.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(win)" />
<div v-if="win.preview" class="wechat-link-thumb">
<img :src="win.preview" :alt="win.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(win)" />
</div>
</div>
</div>
<div class="wechat-link-from">
@@ -1768,12 +1804,12 @@
@contextmenu="openMediaContextMenu($event, rec, 'message')"
>
<div class="wechat-link-content">
<div class="wechat-link-info">
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
<div v-if="rec.content || rec.preview" class="wechat-link-summary">
<div v-if="rec.content" class="wechat-link-desc">{{ rec.content }}</div>
</div>
<div v-if="rec.preview" class="wechat-link-thumb">
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
<div v-if="rec.preview" class="wechat-link-thumb">
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
</div>
</div>
</div>
<div class="wechat-link-from">
@@ -1810,17 +1846,16 @@
/>
<div v-else class="px-3 py-2 text-sm text-gray-700">{{ rec.content || '[视频]' }}</div>
<a
<button
v-if="rec.videoThumbUrl && rec.videoUrl"
:href="rec.videoUrl"
target="_blank"
rel="noreferrer"
type="button"
class="absolute inset-0 flex items-center justify-center"
@click.stop="openVideoPreview(rec.videoUrl, rec.videoThumbUrl)"
>
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</div>
</a>
</button>
<div class="absolute inset-0 flex items-center justify-center" v-else-if="rec.videoThumbUrl">
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
@@ -2460,11 +2495,13 @@ definePageMeta({
import { useApi } from '~/composables/useApi'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
import { reportServerErrorFromResponse } from '~/utils/server-error-logging'
import { heatColor } from '~/utils/wrapped/heatmap'
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'
@@ -3542,6 +3579,12 @@ const saveExportToSelectedFolder = async (options = {}) => {
try {
const resp = await fetch(getExportDownloadUrl(exportId))
if (!resp.ok) {
await reportServerErrorFromResponse(resp, {
method: 'GET',
requestUrl: getExportDownloadUrl(exportId),
message: `下载导出文件失败(${resp.status}`,
source: 'chat.exportDownload',
})
throw new Error(`下载导出文件失败(${resp.status}`)
}
const blob = await resp.blob()
@@ -4065,15 +4108,42 @@ const scrollToMessageId = async (id) => {
//
const previewImageUrl = ref(null)
const previewVideoUrl = ref(null)
const previewVideoPosterUrl = ref('')
const previewVideoError = ref('')
const openImagePreview = (url) => {
if (!process.client) return
previewImageUrl.value = url
document.body.style.overflow = 'hidden'
}
const closeImagePreview = () => {
if (!process.client) return
previewImageUrl.value = null
document.body.style.overflow = ''
if (!previewVideoUrl.value) document.body.style.overflow = ''
}
const openVideoPreview = (url, poster) => {
if (!process.client) return
const u = String(url || '').trim()
if (!u) return
previewVideoError.value = ''
previewVideoPosterUrl.value = String(poster || '').trim()
previewVideoUrl.value = u
document.body.style.overflow = 'hidden'
}
const closeVideoPreview = () => {
if (!process.client) return
previewVideoUrl.value = null
previewVideoPosterUrl.value = ''
previewVideoError.value = ''
if (!previewImageUrl.value) document.body.style.overflow = ''
}
const onPreviewVideoError = () => {
previewVideoError.value = '视频加载失败。'
}
const voiceRefs = ref({})
@@ -5953,7 +6023,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 +6109,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,
@@ -6308,6 +6378,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 +6483,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
@@ -7030,9 +7112,7 @@ const openChatHistoryQuote = (rec) => {
if (!url) return
if (kind === 'video') {
try {
window.open(url, '_blank', 'noreferrer')
} catch {}
openVideoPreview(url, q?.thumbUrl)
return
}
@@ -7375,6 +7455,7 @@ const onGlobalKeyDown = (e) => {
if (key === 'Escape') {
if (contextMenu.value.visible) closeContextMenu()
if (previewImageUrl.value) closeImagePreview()
if (previewVideoUrl.value) closeVideoPreview()
if (Array.isArray(floatingWindows.value) && floatingWindows.value.length) closeTopFloatingWindow()
if (chatHistoryModalVisible.value) closeChatHistoryModal()
if (contactProfileCardOpen.value) {
@@ -7843,13 +7924,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 +7946,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 +7957,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 +7967,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 +7986,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 +8014,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 +8054,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 +8163,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 +8185,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 +8197,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 +8975,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 +9003,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 +9030,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;
+123 -11
View File
@@ -638,7 +638,19 @@
>
<div class="relative max-w-[92vw] max-h-[92vh] flex flex-col items-center" @click.stop>
<video
v-if="previewLivePhotoVideoSrc && !previewHasLivePhotoVideoError"
v-if="previewIsVideo"
ref="previewVideoEl"
:key="previewVideoKey"
:src="previewVideoSrc"
:poster="previewVideoPoster"
class="max-w-[90vw] max-h-[70vh] object-contain"
controls
autoplay
playsinline
@error="onPreviewVideoError"
></video>
<video
v-else-if="previewLivePhotoVideoSrc && !previewHasLivePhotoVideoError"
ref="previewLiveVideoEl"
:src="previewLivePhotoVideoSrc"
:poster="previewSrc"
@@ -651,6 +663,13 @@
></video>
<img v-else :src="previewSrc" alt="预览" class="max-w-[90vw] max-h-[70vh] object-contain" />
<div
v-if="previewIsVideo && previewVideoError"
class="mt-3 text-xs text-red-200 whitespace-pre-wrap text-center max-w-[90vw]"
>
{{ previewVideoError }}
</div>
</div>
<button
@@ -688,6 +707,7 @@ import { useChatAccountsStore } from '~/stores/chatAccounts'
import { usePrivacyStore } from '~/stores/privacy'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
import { reportServerErrorFromError } from '~/utils/server-error-logging'
useHead({ title: '朋友圈 - 微信数据分析助手' })
@@ -1112,6 +1132,12 @@ const loadSelfInfo = async () => {
selfInfo.value = resp
}
} catch (e) {
await reportServerErrorFromError(e, {
method: 'GET',
requestUrl: `${apiBase}/sns/self_info?account=${encodeURIComponent(selectedAccount.value)}`,
source: 'sns.loadSelfInfo',
apiBase,
})
console.error('获取个人信息失败', e)
}
}
@@ -1756,6 +1782,53 @@ const previewSrc = computed(() => {
return getMediaPreviewSrc(ctx.post, ctx.media, ctx.idx)
})
const previewVideoEl = ref(null)
const previewVideoMode = ref('') // 'local' | 'remote' | 'raw'
const previewVideoError = ref('')
const previewVideoTried = reactive({ local: false, remote: false, raw: false })
const resetPreviewVideo = () => {
previewVideoMode.value = ''
previewVideoError.value = ''
previewVideoTried.local = false
previewVideoTried.remote = false
previewVideoTried.raw = false
}
const previewIsVideo = computed(() => {
const ctx = previewCtx.value
if (!ctx) return false
return Number(ctx.media?.type || 0) === 6
})
const previewVideoPoster = computed(() => {
const ctx = previewCtx.value
if (!ctx) return ''
if (Number(ctx.media?.type || 0) !== 6) return ''
return getMediaThumbSrc(ctx.post, ctx.media, ctx.idx) || ''
})
const previewVideoSrc = computed(() => {
const ctx = previewCtx.value
if (!ctx) return ''
if (Number(ctx.media?.type || 0) !== 6) return ''
const local = getSnsVideoUrl(ctx.post?.id, ctx.media?.id)
const remote = getSnsRemoteVideoSrc(ctx.post, ctx.media)
const raw = upgradeTencentHttps(String(ctx.media?.url || '').trim())
const mode = String(previewVideoMode.value || '').toLowerCase()
if (mode === 'local') return local
if (mode === 'remote') return remote
if (mode === 'raw') return raw
return local || remote || raw || ''
})
const previewVideoKey = computed(() => {
if (!previewIsVideo.value) return ''
return `${String(previewVideoMode.value || '')}:${String(previewVideoSrc.value || '')}`
})
const previewLivePhotoVideoSrc = computed(() => {
const ctx = previewCtx.value
if (!ctx) return ''
@@ -1879,6 +1952,7 @@ const loadPreviewCandidates = async ({ reset }) => {
const openImagePreview = async (post, m, idx = 0) => {
if (!process.client) return
resetPreviewVideo()
// Stop any background hover-playing live photo when opening the preview.
activeLivePhotoKey.value = ''
// Preview is an intentional action; allow retry even if hover playback failed once.
@@ -1898,11 +1972,58 @@ const openImagePreview = async (post, m, idx = 0) => {
await loadPreviewCandidates({ reset: true })
}
const openVideoPreview = (post, m, idx = 0) => {
if (!process.client) return
resetPreviewVideo()
activeLivePhotoKey.value = ''
const local = getSnsVideoUrl(post?.id, m?.id)
const remote = getSnsRemoteVideoSrc(post, m)
const raw = upgradeTencentHttps(String(m?.url || '').trim())
if (local) previewVideoMode.value = 'local'
else if (remote) previewVideoMode.value = 'remote'
else if (raw) previewVideoMode.value = 'raw'
else previewVideoError.value = '视频地址缺失。'
previewCtx.value = { post, media: m, idx: Number(idx) || 0 }
previewCandidatesOpen.value = false
resetPreviewCandidates()
document.body.style.overflow = 'hidden'
}
const onPreviewVideoError = () => {
const ctx = previewCtx.value
if (!ctx) return
if (Number(ctx.media?.type || 0) !== 6) return
const current = String(previewVideoMode.value || '').toLowerCase()
if (current === 'local') previewVideoTried.local = true
if (current === 'remote') previewVideoTried.remote = true
if (current === 'raw') previewVideoTried.raw = true
// Fallback order: local -> remote -> raw
const remote = getSnsRemoteVideoSrc(ctx.post, ctx.media)
if (!previewVideoTried.remote && remote) {
previewVideoMode.value = 'remote'
return
}
const raw = upgradeTencentHttps(String(ctx.media?.url || '').trim())
if (!previewVideoTried.raw && raw) {
previewVideoMode.value = 'raw'
return
}
previewVideoError.value = '视频加载失败:可能是本地缓存不存在,或远程下载/解密失败。'
}
const closeImagePreview = () => {
if (!process.client) return
previewCtx.value = null
previewCandidatesOpen.value = false
resetPreviewCandidates()
resetPreviewVideo()
document.body.style.overflow = ''
}
@@ -1912,16 +2033,7 @@ const onMediaClick = (post, m, idx = 0) => {
// 视频点击逻辑
if (mt === 6) {
// Open a playable mp4 via backend (downloads+decrypts as needed).
const remoteUrl = getSnsRemoteVideoSrc(post, m)
if (remoteUrl) {
window.open(remoteUrl, '_blank', 'noopener,noreferrer')
return
}
// Last-resort: open raw CDN url.
const u = String(m?.url || '').trim()
if (u) window.open(u, '_blank', 'noopener,noreferrer')
openVideoPreview(post, m, idx)
return
}
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

+206
View File
@@ -0,0 +1,206 @@
import { useApiBase } from '~/composables/useApiBase'
const FRONTEND_SERVER_ERROR_ENDPOINT = '/admin/log-frontend-server-error'
const normalizeStatus = (value) => {
const n = Number(value)
if (!Number.isInteger(n)) return 0
return n
}
const stringifyDetail = (value) => {
if (value == null) return ''
if (typeof value === 'string') return value.trim()
try {
return JSON.stringify(value)
} catch {
return String(value).trim()
}
}
const currentOrigin = () => {
if (!process.client || typeof window === 'undefined') return ''
try {
return String(window.location?.origin || '').trim()
} catch {
return ''
}
}
const normalizeBasePath = (apiBase) => {
const raw = String(apiBase || '').trim()
if (!raw) return '/api'
if (/^https?:\/\//i.test(raw)) {
try {
const u = new URL(raw)
return u.pathname.replace(/\/+$/, '') || '/'
} catch {
return '/api'
}
}
return raw.replace(/\/+$/, '') || '/'
}
const normalizePathname = (value) => {
const raw = String(value || '').trim()
if (!raw) return ''
try {
return new URL(raw).pathname.replace(/\/+$/, '')
} catch {
return raw.split(/[?#]/, 1)[0].replace(/\/+$/, '')
}
}
export const isServerErrorStatus = (status) => normalizeStatus(status) >= 500
export const resolveRequestUrl = (requestUrl, apiBase = '') => {
const raw = String(requestUrl || '').trim()
if (!raw) return ''
if (/^https?:\/\//i.test(raw)) return raw
const origin = currentOrigin()
if (!origin) return raw
if (raw.startsWith('/')) {
const prefix = normalizeBasePath(apiBase)
const combined = raw === prefix || raw.startsWith(`${prefix}/`) ? raw : `${prefix}${raw}`
if (/^https?:\/\//i.test(String(apiBase || '').trim())) {
try {
const baseUrl = new URL(String(apiBase).trim())
return new URL(combined, `${baseUrl.origin}/`).toString()
} catch {
return new URL(combined, origin).toString()
}
}
return new URL(combined, origin).toString()
}
if (/^https?:\/\//i.test(String(apiBase || '').trim())) {
try {
const base = String(apiBase).trim()
return new URL(raw, base.endsWith('/') ? base : `${base}/`).toString()
} catch {
return new URL(raw, origin).toString()
}
}
return new URL(raw, origin).toString()
}
const isFrontendServerLogUrl = (requestUrl) => {
const path = normalizePathname(requestUrl)
return path.endsWith('/api/admin/log-frontend-server-error') || path.endsWith('/admin/log-frontend-server-error')
}
const extractBackendDetail = (data) => {
if (data == null) return ''
if (typeof data === 'string') return data.trim()
if (typeof data === 'object' && !Array.isArray(data) && Object.prototype.hasOwnProperty.call(data, 'detail')) {
return stringifyDetail(data.detail)
}
return stringifyDetail(data)
}
const resolveApiBase = (apiBase) => {
const raw = String(apiBase || '').trim()
if (raw) return raw
if (!process.client) return ''
try {
return String(useApiBase() || '').trim()
} catch {
return ''
}
}
export const extractServerErrorFromError = (error) => {
const response = error?.response
return {
status: normalizeStatus(error?.status ?? response?.status),
backendDetail: extractBackendDetail(response?._data ?? response?.data ?? error?.data),
message: String(error?.message || '').trim(),
requestUrl: String(response?.url || error?.request || '').trim(),
}
}
export const extractServerErrorDetailFromResponse = async (response) => {
if (!response || typeof response.clone !== 'function') return ''
try {
const clone = response.clone()
const contentType = String(clone.headers?.get?.('content-type') || '').toLowerCase()
if (contentType.includes('json')) {
try {
const payload = await clone.json()
return extractBackendDetail(payload)
} catch {}
}
const text = String(await clone.text()).trim()
if (!text) return ''
if (contentType.includes('json')) {
try {
return extractBackendDetail(JSON.parse(text))
} catch {}
}
return text
} catch {
return ''
}
}
export const reportServerError = async (context = {}) => {
if (!process.client || typeof window === 'undefined') return false
const status = normalizeStatus(context.status)
if (!isServerErrorStatus(status)) return false
const apiBase = resolveApiBase(context.apiBase)
const requestUrl = resolveRequestUrl(context.requestUrl, apiBase)
if (!requestUrl || isFrontendServerLogUrl(requestUrl)) return false
const endpointUrl = resolveRequestUrl(FRONTEND_SERVER_ERROR_ENDPOINT, apiBase)
if (!endpointUrl) return false
const payload = {
status,
method: String(context.method || 'GET').trim().toUpperCase() || 'GET',
request_url: requestUrl,
message: String(context.message || '').trim(),
backend_detail: String(context.backendDetail || '').trim(),
source: String(context.source || '').trim(),
page_url: String(window.location?.href || '').trim(),
}
try {
await fetch(endpointUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
})
return true
} catch {
return false
}
}
export const reportServerErrorFromError = async (error, context = {}) => {
const info = extractServerErrorFromError(error)
return await reportServerError({
...context,
status: context.status ?? info.status,
requestUrl: context.requestUrl || info.requestUrl,
message: context.message || info.message,
backendDetail: context.backendDetail || info.backendDetail,
})
}
export const reportServerErrorFromResponse = async (response, context = {}) => {
const status = normalizeStatus(context.status ?? response?.status)
if (!isServerErrorStatus(status)) return false
const backendDetail = context.backendDetail || (await extractServerErrorDetailFromResponse(response))
return await reportServerError({
...context,
status,
requestUrl: context.requestUrl || response?.url || '',
backendDetail,
})
}
+44 -9
View File
@@ -10,8 +10,14 @@ 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__)
request_logger = get_logger("wechat_decrypt_tool.request")
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
@@ -27,13 +33,10 @@ from .routers.sns import router as _sns_router
from .routers.sns_export import router as _sns_export_router
from .routers.wechat_detection import router as _wechat_detection_router
from .routers.wrapped import router as _wrapped_router
from .request_logging import log_server_errors_middleware
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 +78,11 @@ async def _add_sns_stage_timing_headers(request: Request, call_next):
return response
@app.middleware("http")
async def _log_server_errors(request: Request, call_next):
return await log_server_errors_middleware(request_logger, request, call_next)
app.include_router(_health_router)
app.include_router(_admin_router)
app.include_router(_wechat_detection_router)
@@ -98,9 +106,36 @@ class _SPAStaticFiles(StaticFiles):
self._fallback_200 = Path(str(self.directory)) / "200.html"
self._fallback_index = Path(str(self.directory)) / "index.html"
async def get_response(self, path: str, scope): # type: ignore[override]
@staticmethod
def _normalize_path(path: str) -> str:
return str(path or "").strip().lstrip("/")
@classmethod
def _is_shell_path(cls, path: str) -> bool:
normalized = cls._normalize_path(path)
return normalized in {"", "index.html", "200.html", "_payload.json"} or normalized.startswith(
"_payload.json/"
)
@classmethod
def _apply_cache_headers(cls, path: str, response):
normalized = cls._normalize_path(path)
try:
return await super().get_response(path, scope)
if cls._is_shell_path(normalized):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
elif normalized.startswith("_nuxt/"):
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
except Exception:
pass
return response
async def get_response(self, path: str, scope): # type: ignore[override]
normalized = self._normalize_path(path)
try:
response = await super().get_response(path, scope)
return self._apply_cache_headers(normalized, response)
except StarletteHTTPException as exc:
if exc.status_code != 404:
raise
@@ -111,8 +146,8 @@ class _SPAStaticFiles(StaticFiles):
raise
if self._fallback_200.exists():
return FileResponse(str(self._fallback_200))
return FileResponse(str(self._fallback_index))
return self._apply_cache_headers("200.html", FileResponse(str(self._fallback_200)))
return self._apply_cache_headers("index.html", FileResponse(str(self._fallback_index)))
def _maybe_mount_frontend() -> None:
@@ -485,3 +485,30 @@ def update_message_edit_local_id(
conn.close()
except Exception:
pass
def delete_account_edits(account: str) -> int:
a = str(account or "").strip()
if not a:
return 0
conn: Optional[sqlite3.Connection] = None
try:
conn = _connect()
cur = conn.execute(
"""
DELETE FROM message_edits
WHERE account = ?
""",
(a,),
)
conn.commit()
return int(getattr(cur, "rowcount", 0) or 0)
except Exception:
return 0
finally:
try:
if conn is not None:
conn.close()
except Exception:
pass
+115 -41
View File
@@ -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,
@@ -52,7 +53,7 @@ from .chat_helpers import (
)
from .logging_config import get_logger
from .media_helpers import (
_convert_silk_to_wav,
_convert_silk_to_browser_audio,
_detect_image_media_type,
_fallback_search_media_by_file_id,
_read_and_maybe_decrypt_media,
@@ -120,9 +121,10 @@ def _resolve_ui_public_dir() -> Optional[Path]:
if ui_dir_env:
candidates.append(Path(ui_dir_env))
# Repo default: `frontend/.output/public` after `npm --prefix frontend run generate`.
# Repo defaults: generated Nuxt output or checked-in desktop UI assets.
repo_root = Path(__file__).resolve().parents[2]
candidates.append(repo_root / "frontend" / ".output" / "public")
candidates.append(repo_root / "desktop" / "resources" / "ui")
for p in candidates:
try:
@@ -621,6 +623,68 @@ body { background: #EDEDED; }
.wce-audio-actions a { font-size: 0.75rem; color: #07c160; text-decoration: none; }
.wce-audio-actions a:hover { text-decoration: underline; }
/* Voice message fallback styles (keep close to `frontend/pages/chat/[[username]].vue`). */
.wechat-voice-wrapper { display: flex; width: 100%; position: relative; }
.wechat-voice-bubble {
border-radius: var(--message-radius);
position: relative;
transition: opacity 0.15s ease;
min-width: 80px;
max-width: 200px;
cursor: pointer;
}
.wechat-voice-bubble:hover { opacity: 0.85; }
.wechat-voice-bubble:active { opacity: 0.7; }
.wechat-voice-sent { background: #95EC69; }
.wechat-voice-sent::after {
content: '';
position: absolute;
top: 50%;
right: -4px;
transform: translateY(-50%) rotate(45deg);
width: 10px;
height: 10px;
background: #95EC69;
border-radius: 2px;
}
.wechat-voice-received { background: #fff; }
.wechat-voice-received::before {
content: '';
position: absolute;
top: 50%;
left: -4px;
transform: translateY(-50%) rotate(45deg);
width: 10px;
height: 10px;
background: #fff;
border-radius: 2px;
}
.wechat-voice-content { display: flex; align-items: center; padding: 8px 12px; gap: 8px; }
.wechat-voice-icon { width: 18px; height: 18px; flex-shrink: 0; color: #1a1a1a; }
.wechat-quote-voice-icon { width: 14px; height: 14px; color: inherit; }
.voice-icon-sent { transform: scaleX(-1); }
.wechat-voice-icon.voice-playing .voice-wave-2 { animation: voice-wave-2 1s infinite; }
.wechat-voice-icon.voice-playing .voice-wave-3 { animation: voice-wave-3 1s infinite; }
@keyframes voice-wave-2 {
0%, 33% { opacity: 0; }
34%, 100% { opacity: 1; }
}
@keyframes voice-wave-3 {
0%, 66% { opacity: 0; }
67%, 100% { opacity: 1; }
}
.wechat-voice-duration { font-size: 14px; color: #1a1a1a; }
.wechat-voice-unread {
position: absolute;
top: 50%;
right: -20px;
transform: translateY(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
background: #e75e58;
}
/* Index page helpers. */
.wce-index { min-height: 100vh; background: #EDEDED; }
.wce-index-container { max-width: 880px; margin: 0 auto; padding: 24px; }
@@ -3378,6 +3442,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 +3505,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 +3784,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,
}
@@ -4941,40 +5021,38 @@ def _write_conversation_html(
tw.write(f' <div class="{esc_attr(bubble_base_cls + " " + bubble_dir_cls)}">{render_text_with_emojis(msg.get("content") or "")}</div>\n')
elif rt == "voice":
voice = offline_path(msg, "voice")
if voice:
duration_ms = msg.get("voiceLength")
width = get_voice_width(duration_ms)
seconds = get_voice_duration_in_seconds(duration_ms)
voice_dir_cls = "wechat-voice-sent" if is_sent else "wechat-voice-received"
content_dir_cls = " flex-row-reverse" if is_sent else ""
icon_dir_cls = "voice-icon-sent" if is_sent else "voice-icon-received"
voice_id = str(msg.get("id") or "").strip()
duration_ms = msg.get("voiceLength")
width = get_voice_width(duration_ms)
seconds = get_voice_duration_in_seconds(duration_ms)
voice_dir_cls = "wechat-voice-sent" if is_sent else "wechat-voice-received"
content_dir_cls = " flex-row-reverse" if is_sent else ""
icon_dir_cls = "voice-icon-sent" if is_sent else "voice-icon-received"
voice_id = str(msg.get("id") or "").strip()
tw.write(' <div class="wechat-voice-wrapper">\n')
tw.write(
f' <div class="wechat-voice-bubble msg-radius {esc_attr(voice_dir_cls)}" style="width: {esc_attr(width)}" data-voice-id="{esc_attr(voice_id)}">\n'
)
tw.write(f' <div class="wechat-voice-content{esc_attr(content_dir_cls)}">\n')
tw.write(
f' <svg class="wechat-voice-icon {esc_attr(icon_dir_cls)}" viewBox="0 0 32 32" fill="currentColor">\n'
)
tw.write(
' <path d="M10.24 11.616l-4.224 4.192 4.224 4.192c1.088-1.056 1.76-2.56 1.76-4.192s-0.672-3.136-1.76-4.192z"></path>\n'
)
tw.write(
' <path class="voice-wave-2" d="M15.199 6.721l-1.791 1.76c1.856 1.888 3.008 4.48 3.008 7.328s-1.152 5.44-3.008 7.328l1.791 1.76c2.336-2.304 3.809-5.536 3.809-9.088s-1.473-6.784-3.809-9.088z"></path>\n'
)
tw.write(
' <path class="voice-wave-3" d="M20.129 1.793l-1.762 1.76c3.104 3.168 5.025 7.488 5.025 12.256s-1.921 9.088-5.025 12.256l1.762 1.76c3.648-3.616 5.887-8.544 5.887-14.016s-2.239-10.432-5.887-14.016z"></path>\n'
)
tw.write(" </svg>\n")
tw.write(f' <span class="wechat-voice-duration">{esc_text(seconds)}"</span>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(' <div class="wechat-voice-wrapper">\n')
tw.write(
f' <div class="wechat-voice-bubble msg-radius {esc_attr(voice_dir_cls)}" style="width: {esc_attr(width)}" data-voice-id="{esc_attr(voice_id)}">\n'
)
tw.write(f' <div class="wechat-voice-content{esc_attr(content_dir_cls)}">\n')
tw.write(
f' <svg class="wechat-voice-icon {esc_attr(icon_dir_cls)}" viewBox="0 0 32 32" fill="currentColor">\n'
)
tw.write(
' <path d="M10.24 11.616l-4.224 4.192 4.224 4.192c1.088-1.056 1.76-2.56 1.76-4.192s-0.672-3.136-1.76-4.192z"></path>\n'
)
tw.write(
' <path class="voice-wave-2" d="M15.199 6.721l-1.791 1.76c1.856 1.888 3.008 4.48 3.008 7.328s-1.152 5.44-3.008 7.328l1.791 1.76c2.336-2.304 3.809-5.536 3.809-9.088s-1.473-6.784-3.809-9.088z"></path>\n'
)
tw.write(
' <path class="voice-wave-3" d="M20.129 1.793l-1.762 1.76c3.104 3.168 5.025 7.488 5.025 12.256s-1.921 9.088-5.025 12.256l1.762 1.76c3.648-3.616 5.887-8.544 5.887-14.016s-2.239-10.432-5.887-14.016z"></path>\n'
)
tw.write(" </svg>\n")
tw.write(f' <span class="wechat-voice-duration">{esc_text(seconds)}"</span>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
if voice:
tw.write(f' <audio src="{esc_attr(voice)}" preload="none" class="hidden"></audio>\n')
tw.write(" </div>\n")
else:
tw.write(f' <div class="{esc_attr(bubble_base_cls + " " + bubble_dir_cls)}">{render_text_with_emojis(msg.get("content") or "")}</div>\n')
tw.write(" </div>\n")
elif rt == "file":
fsrc = offline_path(msg, "file")
title = str(msg.get("title") or msg.get("content") or "文件").strip()
@@ -5965,13 +6043,9 @@ def _materialize_voice(
if not isinstance(data, (bytes, bytearray)):
data = bytes(data)
wav = _convert_silk_to_wav(data)
if wav != data and wav[:4] == b"RIFF":
ext = "wav"
payload = wav
else:
ext = "silk"
payload = data
payload, ext, _media_type = _convert_silk_to_browser_audio(data, preferred_format="mp3")
if not payload:
return "", False
arc = f"media/voices/voice_{int(server_id)}.{ext}"
zf.writestr(arc, payload)
+164 -5
View File
@@ -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,
}
+17
View File
@@ -67,3 +67,20 @@ def upsert_account_keys_in_store(
pass
return item
def remove_account_keys_from_store(account: str) -> bool:
account = str(account or "").strip()
if not account:
return False
store = load_account_keys_store()
if account not in store:
return False
try:
store.pop(account, None)
_atomic_write_json(_KEY_STORE_PATH, store)
return True
except Exception:
return False
+45 -5
View File
@@ -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()
+108
View File
@@ -1964,6 +1964,114 @@ def _convert_silk_to_wav(silk_data: bytes) -> bytes:
return silk_data
def _looks_like_mp3(data: bytes) -> bool:
if not data:
return False
if data.startswith(b"ID3"):
return True
return len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0
@lru_cache(maxsize=1)
def _find_ffmpeg_executable() -> str:
import shutil
env_value = str(os.environ.get("WECHAT_TOOL_FFMPEG") or "").strip()
if env_value:
resolved = shutil.which(env_value)
if resolved:
return resolved
candidate = Path(env_value).expanduser()
if candidate.is_file():
return str(candidate)
return shutil.which("ffmpeg") or ""
def _convert_wav_to_mp3(wav_data: bytes) -> bytes:
import subprocess
import tempfile
if not wav_data or not wav_data.startswith(b"RIFF"):
return b""
ffmpeg_exe = _find_ffmpeg_executable()
if not ffmpeg_exe:
return b""
try:
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
wav_path = tmp_path / "voice.wav"
mp3_path = tmp_path / "voice.mp3"
wav_path.write_bytes(wav_data)
proc = subprocess.run(
[
ffmpeg_exe,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(wav_path),
"-vn",
"-codec:a",
"libmp3lame",
"-q:a",
"4",
str(mp3_path),
],
check=False,
capture_output=True,
)
if proc.returncode != 0 or not mp3_path.exists():
err = proc.stderr.decode("utf-8", errors="ignore").strip()
if err:
logger.warning(f"WAV to MP3 conversion failed: {err}")
return b""
mp3_data = mp3_path.read_bytes()
if _looks_like_mp3(mp3_data):
return mp3_data
except Exception as e:
logger.warning(f"WAV to MP3 conversion failed: {e}")
return b""
def _convert_silk_to_browser_audio(
silk_data: bytes,
*,
preferred_format: str = "mp3",
) -> tuple[bytes, str, str]:
"""Convert SILK audio to a browser-friendly format.
Returns `(payload, ext, media_type)`.
Preference order:
1) MP3 if ffmpeg is available
2) WAV if SILK decoding succeeds
3) original SILK bytes as a last-resort fallback
"""
data = bytes(silk_data or b"")
if not data:
return b"", "silk", "audio/silk"
if _looks_like_mp3(data):
return data, "mp3", "audio/mpeg"
wav_data = data if data.startswith(b"RIFF") else _convert_silk_to_wav(data)
if wav_data.startswith(b"RIFF"):
if str(preferred_format or "").strip().lower() == "mp3":
mp3_data = _convert_wav_to_mp3(wav_data)
if mp3_data:
return mp3_data, "mp3", "audio/mpeg"
return wav_data, "wav", "audio/wav"
return data, "silk", "audio/silk"
def _resolve_media_path_for_kind(
account_dir: Path,
kind: str,
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import json
from typing import Any
from starlette.requests import Request
from starlette.responses import Response
def _stringify_detail(detail: Any) -> str:
if detail is None:
return ""
if isinstance(detail, str):
return detail.strip()
try:
return json.dumps(detail, ensure_ascii=False)
except Exception:
return str(detail).strip()
def _extract_response_detail(response: Response) -> str:
body = getattr(response, "body", None)
if body is None:
return ""
try:
raw = body.tobytes() if isinstance(body, memoryview) else body
except Exception:
raw = body
if isinstance(raw, bytes):
text = raw.decode("utf-8", errors="ignore").strip()
else:
text = str(raw).strip()
if not text:
return ""
content_type = str(response.headers.get("content-type") or "").lower()
if "json" not in content_type:
return ""
try:
payload = json.loads(text)
except Exception:
return ""
if not isinstance(payload, dict):
return ""
return _stringify_detail(payload.get("detail"))
async def _buffer_response_body(response: Response) -> tuple[Response, bytes]:
body = getattr(response, "body", None)
if body is not None:
try:
raw = body.tobytes() if isinstance(body, memoryview) else body
except Exception:
raw = body
if isinstance(raw, bytes):
return response, raw
if isinstance(raw, str):
return response, raw.encode("utf-8")
return response, bytes(raw)
chunks: list[bytes] = []
body_iterator = getattr(response, "body_iterator", None)
if body_iterator is not None:
async for chunk in body_iterator:
if isinstance(chunk, memoryview):
chunks.append(chunk.tobytes())
elif isinstance(chunk, bytes):
chunks.append(chunk)
else:
chunks.append(str(chunk).encode("utf-8"))
body_bytes = b"".join(chunks)
rebuilt = Response(
content=body_bytes,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type,
background=response.background,
)
return rebuilt, body_bytes
def _extract_response_detail_from_body(response: Response, body: bytes) -> str:
if not body:
return ""
try:
text = body.decode("utf-8", errors="ignore").strip()
except Exception:
return ""
if not text:
return ""
content_type = str(response.headers.get("content-type") or "").lower()
if "json" not in content_type:
return ""
try:
payload = json.loads(text)
except Exception:
return ""
if not isinstance(payload, dict):
return ""
return _stringify_detail(payload.get("detail"))
async def log_server_errors_middleware(logger, request: Request, call_next):
method = str(request.method or "").upper() or "GET"
path = str(request.url.path or "").strip() or "/"
try:
response = await call_next(request)
except Exception as exc:
logger.exception("[server-exception] method=%s path=%s error=%s", method, path, exc)
raise
status = int(getattr(response, "status_code", 0) or 0)
if status >= 500:
response, body = await _buffer_response_body(response)
detail = _extract_response_detail_from_body(response, body) or _extract_response_detail(response)
if detail:
logger.error("[server-5xx] status=%s method=%s path=%s detail=%s", status, method, path, detail)
else:
logger.error("[server-5xx] status=%s method=%s path=%s", status, method, path)
return response
+80
View File
@@ -13,11 +13,13 @@ import httpx
from fastapi import APIRouter, BackgroundTasks, HTTPException
from starlette.requests import Request
from ..logging_config import get_log_file_path, get_logger
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)
logger = get_logger(__name__)
DEFAULT_BACKEND_PORT = 10392
_PORT_CHANGE_IN_PROGRESS = False
@@ -58,6 +60,36 @@ def _is_loopback_client(request: Request) -> bool:
return False
def _get_current_log_file_path() -> Path:
log_file = Path(get_log_file_path())
try:
log_file.parent.mkdir(parents=True, exist_ok=True)
except Exception:
pass
if not log_file.exists():
try:
log_file.touch(exist_ok=True)
except Exception:
pass
return log_file
def _open_path_with_default_app(path: Path) -> None:
target = str(path)
if os.name == "nt":
opener = getattr(os, "startfile", None)
if opener is None:
raise RuntimeError("当前系统不支持默认打开文件")
opener(target)
return
if sys.platform == "darwin":
subprocess.Popen(["open", target])
return
subprocess.Popen(["xdg-open", target])
def _is_port_available(port: int, host: str) -> bool:
try:
addr = (host, int(port))
@@ -126,6 +158,54 @@ async def _exit_process_after(delay_s: float) -> None:
os._exit(0) # noqa: S404
@router.get("/api/admin/log-file", summary="获取当前后端日志文件路径")
async def get_backend_log_file() -> dict:
log_file = _get_current_log_file_path()
return {"path": str(log_file), "exists": log_file.exists()}
@router.post("/api/admin/log-file/open", summary="打开当前后端日志文件(仅允许本机访问)")
async def open_backend_log_file(request: Request) -> dict:
if not _is_loopback_client(request):
raise HTTPException(status_code=403, detail="仅允许本机访问该接口")
log_file = _get_current_log_file_path()
try:
_open_path_with_default_app(log_file)
except Exception as e:
logger.error("open_backend_log_file failed path=%s err=%s", log_file, e)
raise HTTPException(status_code=500, detail=f"打开日志文件失败:{e}")
return {"success": True, "path": str(log_file)}
@router.post("/api/admin/log-frontend-server-error", summary="记录前端感知到的服务器错误")
async def log_frontend_server_error(payload: dict) -> dict:
data = payload if isinstance(payload, dict) else {}
try:
status = int(data.get("status"))
except Exception:
status = 0
method = str(data.get("method") or "").strip().upper() or "GET"
request_url = str(data.get("request_url") or "").strip()
message = str(data.get("message") or "").strip()
backend_detail = str(data.get("backend_detail") or "").strip()
source = str(data.get("source") or "").strip()
page_url = str(data.get("page_url") or "").strip()
logger.error(
"[frontend-server-error] status=%s method=%s request_url=%s message=%s backend_detail=%s source=%s page_url=%s",
status,
method,
request_url,
message,
backend_detail,
source,
page_url,
)
return {"success": True, "path": str(_get_current_log_file_path())}
@router.get("/api/admin/port", summary="获取后端端口(用于前端设置页)")
async def get_backend_port() -> dict:
port, source = read_effective_backend_port(default=DEFAULT_BACKEND_PORT)
+142 -1
View File
@@ -3,6 +3,7 @@ import re
import sqlite3
import asyncio
import json
import shutil
import time
import threading
from datetime import datetime, timedelta
@@ -50,6 +51,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,
@@ -66,6 +68,8 @@ from ..chat_helpers import (
)
from ..media_helpers import _resolve_account_db_storage_dir, _try_find_decrypted_resource
from .. import chat_edit_store
from ..app_paths import get_output_dir
from ..key_store import remove_account_keys_from_store
from ..path_fix import PathFixRoute
from ..session_last_message import (
build_session_last_message_table,
@@ -2673,6 +2677,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 +2891,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 +2945,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 +3030,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 "",
}
)
@@ -3474,6 +3499,85 @@ async def list_chat_accounts():
}
@router.get("/api/chat/account_info", summary="获取当前账号信息")
def get_chat_account_info(account: Optional[str] = None):
account_dir = _resolve_account_dir(account)
db_files = sorted([p.name for p in account_dir.glob("*.db") if p.is_file()])
session_db = account_dir / "session.db"
session_updated_at = 0
try:
session_updated_at = int(session_db.stat().st_mtime)
except Exception:
session_updated_at = 0
return {
"status": "success",
"account": account_dir.name,
"path": str(account_dir),
"database_count": len(db_files),
"databases": db_files,
"session_updated_at": session_updated_at,
}
@router.delete("/api/chat/account", summary="删除当前账号在本项目中的数据")
def delete_chat_account(account: str):
account_name = str(account or "").strip()
if not account_name:
raise HTTPException(status_code=400, detail="Missing account.")
account_dir = _resolve_account_dir(account_name)
# Best-effort: close realtime connections first, otherwise Windows may keep db files locked.
try:
WCDB_REALTIME.disconnect(account_name)
except Exception:
pass
with _REALTIME_SYNC_MU:
_REALTIME_SYNC_ALL_LOCKS.pop(account_name, None)
stale_lock_keys = [k for k in _REALTIME_SYNC_LOCKS.keys() if k and k[0] == account_name]
for k in stale_lock_keys:
_REALTIME_SYNC_LOCKS.pop(k, None)
removed_edit_count = 0
try:
removed_edit_count = int(chat_edit_store.delete_account_edits(account_name) or 0)
except Exception:
removed_edit_count = 0
removed_key_cache = False
try:
removed_key_cache = bool(remove_account_keys_from_store(account_name))
except Exception:
removed_key_cache = False
output_dir = get_output_dir()
exports_dir = output_dir / "exports" / account_name
if exports_dir.exists():
try:
shutil.rmtree(exports_dir)
except Exception:
# Ignore export cleanup failure; account dir removal is the core operation.
pass
try:
shutil.rmtree(account_dir)
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除账号数据失败:{e}")
accounts = _list_decrypted_accounts()
return {
"status": "success",
"deleted_account": account_name,
"accounts": accounts,
"default_account": accounts[0] if accounts else None,
"removed_edit_count": removed_edit_count,
"removed_key_cache": removed_key_cache,
}
@router.get("/api/chat/sessions", summary="获取会话列表(聊天左侧列表)")
def list_chat_sessions(
request: Request,
@@ -3734,8 +3838,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 +3945,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 +4185,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"
@@ -4251,6 +4375,14 @@ 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:
@@ -4289,6 +4421,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)
@@ -4302,6 +4435,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 提取
@@ -4385,6 +4522,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 "",
}
)
+13 -8
View File
@@ -33,7 +33,7 @@ from ..avatar_cache import (
)
from ..logging_config import get_logger
from ..media_helpers import (
_convert_silk_to_wav,
_convert_silk_to_browser_audio,
_decrypt_emoticon_aes_cbc,
_detect_image_extension,
_detect_image_media_type,
@@ -1762,12 +1762,12 @@ async def get_chat_voice(server_id: int, account: Optional[str] = None):
if not isinstance(data, (bytes, bytearray)):
data = bytes(data)
# Try to convert SILK to WAV for browser playback
wav_data = _convert_silk_to_wav(data)
if wav_data != data:
payload, ext, media_type = _convert_silk_to_browser_audio(data, preferred_format="mp3")
if payload and ext != "silk":
return Response(
content=wav_data,
media_type="audio/wav",
content=payload,
media_type=media_type,
headers={"Content-Disposition": f"inline; filename=voice_{int(server_id)}.{ext}"},
)
# Fallback to raw SILK if conversion fails
@@ -1821,11 +1821,16 @@ async def open_chat_media_folder(
if not isinstance(data, (bytes, bytearray)):
data = bytes(data)
payload, ext, _media_type = _convert_silk_to_browser_audio(data, preferred_format="mp3")
if not payload:
payload = data
ext = "silk"
export_dir = account_dir / "_exports"
export_dir.mkdir(parents=True, exist_ok=True)
p = export_dir / f"voice_{int(server_id)}.silk"
p = export_dir / f"voice_{int(server_id)}.{ext}"
try:
p.write_bytes(data)
p.write_bytes(payload)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to export voice: {e}")
else:
+174
View File
@@ -0,0 +1,174 @@
import importlib
import os
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def _close_logging_handlers() -> None:
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 TestAdminServerErrorLogging(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
import wechat_decrypt_tool.request_logging as request_logging
import wechat_decrypt_tool.routers.admin as admin_router
importlib.reload(app_paths)
importlib.reload(logging_config)
importlib.reload(request_logging)
importlib.reload(admin_router)
self.logging_config = logging_config
self.request_logging = request_logging
self.admin_router = admin_router
self.log_file = self.logging_config.setup_logging()
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 _read_log(self) -> str:
return self.log_file.read_text(encoding="utf-8")
def _make_admin_app(self) -> FastAPI:
app = FastAPI()
app.include_router(self.admin_router.router)
return app
def _make_logged_app(self) -> FastAPI:
app = FastAPI()
@app.middleware("http")
async def _log_server_errors(request, call_next):
return await self.request_logging.log_server_errors_middleware(
self.logging_config.get_logger("tests.server_error_logging"),
request,
call_next,
)
@app.get("/boom-http")
async def _boom_http():
raise HTTPException(status_code=500, detail="planned http failure")
@app.get("/boom-exception")
async def _boom_exception():
raise RuntimeError("planned unhandled failure")
return app
def test_get_log_file_returns_current_backend_log_path(self):
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52000))
resp = client.get("/api/admin/log-file")
self.assertEqual(resp.status_code, 200)
payload = resp.json()
self.assertEqual(Path(payload["path"]), self.log_file)
self.assertTrue(payload["exists"])
self.assertTrue(self.log_file.is_relative_to(Path(self._td.name) / "output" / "logs"))
def test_open_log_file_requires_loopback(self):
client = TestClient(self._make_admin_app(), client=("203.0.113.8", 52001))
resp = client.post("/api/admin/log-file/open")
self.assertEqual(resp.status_code, 403)
def test_open_log_file_uses_default_opener_for_loopback(self):
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52002))
with patch.object(self.admin_router, "_open_path_with_default_app") as mocked_open:
resp = client.post("/api/admin/log-file/open")
self.assertEqual(resp.status_code, 200)
mocked_open.assert_called_once_with(self.log_file)
self.assertEqual(resp.json()["path"], str(self.log_file))
def test_frontend_server_error_endpoint_writes_log(self):
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52003))
resp = client.post(
"/api/admin/log-frontend-server-error",
json={
"status": 503,
"method": "GET",
"request_url": "http://127.0.0.1:10392/api/chat/accounts",
"message": "fetch failed",
"backend_detail": "upstream timeout",
"source": "useApi",
"page_url": "http://127.0.0.1:10392/chat",
},
)
self.assertEqual(resp.status_code, 200)
text = self._read_log()
self.assertIn("[frontend-server-error]", text)
self.assertIn("status=503", text)
self.assertIn("source=useApi", text)
self.assertIn("upstream timeout", text)
def test_http_500_response_is_logged(self):
client = TestClient(self._make_logged_app(), client=("127.0.0.1", 52004))
resp = client.get("/boom-http")
self.assertEqual(resp.status_code, 500)
text = self._read_log()
self.assertIn("[server-5xx]", text)
self.assertIn("status=500", text)
self.assertIn("path=/boom-http", text)
self.assertIn("planned http failure", text)
def test_unhandled_exception_is_logged_with_traceback(self):
client = TestClient(
self._make_logged_app(),
client=("127.0.0.1", 52005),
raise_server_exceptions=False,
)
resp = client.get("/boom-exception")
self.assertEqual(resp.status_code, 500)
text = self._read_log()
self.assertIn("[server-exception]", text)
self.assertIn("path=/boom-exception", text)
self.assertIn("planned unhandled failure", text)
self.assertIn("Traceback", text)
if __name__ == "__main__":
unittest.main()
+109 -1
View File
@@ -1,6 +1,8 @@
import os
import json
import hashlib
import logging
import re
import sqlite3
import sys
import unittest
@@ -243,6 +245,22 @@ class TestChatExportHtmlFormat(unittest.TestCase):
self._seed_media_files(account_dir)
return account_dir
def _insert_missing_voice_message(self, account_dir: Path, *, username: str, server_id: int, duration_ms: int) -> None:
conn = sqlite3.connect(str(account_dir / "message_0.db"))
try:
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
row = conn.execute(f"SELECT COALESCE(MAX(local_id), 0), COALESCE(MAX(sort_seq), 0) FROM {table_name}").fetchone()
next_local_id = int((row[0] or 0)) + 1
next_sort_seq = int((row[1] or 0)) + 1
voice_xml = f'<msg><voicemsg voicelength="{int(duration_ms)}" /></msg>'
conn.execute(
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(next_local_id, int(server_id), 34, next_sort_seq, 2, 1735689700, voice_xml, None),
)
conn.commit()
finally:
conn.close()
def _create_job(self, manager, *, account: str, username: str):
job = manager.create_job(
account=account,
@@ -283,7 +301,14 @@ class TestChatExportHtmlFormat(unittest.TestCase):
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
svc = self._reload_export_modules()
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
original_converter = svc._convert_silk_to_browser_audio
svc._convert_silk_to_browser_audio = (
lambda data, preferred_format="mp3": (bytes(data or b""), "silk", "audio/silk")
)
try:
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
finally:
svc._convert_silk_to_browser_audio = original_converter
self.assertEqual(job.status, "done", msg=job.error)
self.assertTrue(job.zip_path and job.zip_path.exists())
@@ -332,6 +357,8 @@ class TestChatExportHtmlFormat(unittest.TestCase):
css_text = zf.read("assets/wechat-chat-export.css").decode("utf-8", errors="ignore")
self.assertIn("wechat-transfer-card", css_text)
self.assertRegex(css_text, re.compile(r"\.wechat-voice-sent(?::|::)after"))
self.assertRegex(css_text, re.compile(r"\.wechat-voice-received(?::|::)before"))
self.assertNotIn("wechat-transfer-card[data-v-", css_text)
self.assertNotIn("bento-container", css_text)
@@ -346,6 +373,87 @@ class TestChatExportHtmlFormat(unittest.TestCase):
self.assertIn("wxemoji/Expression_1@2x.png", names)
self.assertIn("../../wxemoji/Expression_1@2x.png", html_text)
finally:
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
def test_html_export_prefers_mp3_for_voice_assets(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
self._prepare_account(root, account=account, username=username)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
svc = self._reload_export_modules()
original_converter = svc._convert_silk_to_browser_audio
svc._convert_silk_to_browser_audio = (
lambda data, preferred_format="mp3": (b"ID3FAKE_MP3_DATA", "mp3", "audio/mpeg")
)
try:
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
finally:
svc._convert_silk_to_browser_audio = original_converter
self.assertEqual(job.status, "done", msg=job.error)
self.assertTrue(job.zip_path and job.zip_path.exists())
with zipfile.ZipFile(job.zip_path, "r") as zf:
names = set(zf.namelist())
voice_path = f"media/voices/voice_{self._VOICE_SERVER_ID}.mp3"
self.assertIn(voice_path, names)
self.assertNotIn(f"media/voices/voice_{self._VOICE_SERVER_ID}.wav", names)
html_path = next((n for n in names if n.endswith("/messages.html")), "")
self.assertTrue(html_path)
html_text = zf.read(html_path).decode("utf-8")
self.assertIn(f"../../{voice_path}", html_text)
finally:
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
def test_html_export_keeps_voice_bubble_when_audio_file_missing(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
account_dir = self._prepare_account(root, account=account, username=username)
self._insert_missing_voice_message(account_dir, username=username, server_id=999999, duration_ms=6543)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
svc = self._reload_export_modules()
original_converter = svc._convert_silk_to_browser_audio
svc._convert_silk_to_browser_audio = (
lambda data, preferred_format="mp3": (bytes(data or b""), "silk", "audio/silk")
)
try:
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
finally:
svc._convert_silk_to_browser_audio = original_converter
self.assertEqual(job.status, "done", msg=job.error)
self.assertTrue(job.zip_path and job.zip_path.exists())
with zipfile.ZipFile(job.zip_path, "r") as zf:
names = set(zf.namelist())
html_path = next((n for n in names if n.endswith("/messages.html")), "")
self.assertTrue(html_path)
html_text = zf.read(html_path).decode("utf-8")
self.assertIn("wechat-voice-wrapper", html_text)
self.assertIn('data-render-type="voice"', html_text)
self.assertIn('data-voice-id="message_0:msg_d5616d78f22fe35c632f66cabecfc82d:11"', html_text)
self.assertIn('class="wechat-voice-duration">7"</span>', html_text)
finally:
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
+2
View File
@@ -1,6 +1,7 @@
import os
import json
import hashlib
import logging
import sqlite3
import sys
import unittest
@@ -215,6 +216,7 @@ class TestChatExportHtmlPaging(unittest.TestCase):
page1_text = zf.read(page1_js).decode("utf-8", errors="ignore")
self.assertIn("MSG0001", page1_text)
finally:
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
@@ -132,6 +132,16 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA[“测试好友”撤回了一条消息]]></replacemsg></revokemsg></sysmsg>',
None,
),
(
7,
1007,
48,
7,
2,
1735689607,
'<msg><location x="39.9042" y="116.4074" scale="15" label="北京市东城区东华门街道" poiname="天安门" /></msg>',
None,
),
]
conn.executemany(
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
@@ -357,6 +367,41 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
def test_checked_location_exports_location_fields(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
self._prepare_account(root, account=account, username=username)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
svc = self._reload_export_modules()
job = self._create_job(
svc.CHAT_EXPORT_MANAGER,
account=account,
username=username,
message_types=["location"],
include_media=False,
)
self.assertEqual(job.status, "done", msg=job.error)
payload, manifest, _ = self._load_export_payload(job.zip_path)
location_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 48), None)
self.assertIsNotNone(location_msg)
self.assertEqual(str(location_msg.get("renderType") or ""), "location")
self.assertEqual(str(location_msg.get("locationPoiname") or ""), "天安门")
self.assertEqual(str(location_msg.get("locationLabel") or ""), "北京市东城区东华门街道")
self.assertAlmostEqual(float(location_msg.get("locationLat") or 0), 39.9042, places=4)
self.assertAlmostEqual(float(location_msg.get("locationLng") or 0), 116.4074, places=4)
self.assertEqual(manifest.get("filters", {}).get("messageTypes"), ["location"])
finally:
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
def test_privacy_mode_never_exports_media(self):
with TemporaryDirectory() as td:
root = Path(td)
+63
View File
@@ -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()
+28
View File
@@ -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">'