mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
9 Commits
@@ -44,6 +44,8 @@ pnpm-lock.yaml
|
||||
/desktop/resources/ui/*
|
||||
!/desktop/resources/ui/.gitkeep
|
||||
/desktop/resources/backend/*.exe
|
||||
/desktop/resources/backend/native/*
|
||||
/desktop/resources/backend/pyproject.toml
|
||||
!/desktop/resources/backend/.gitkeep
|
||||
/desktop/resources/icon.ico
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
## 致谢
|
||||
|
||||
本项目的开发过程中参考了以下优秀的开源项目和资源:
|
||||
|
||||
+23
-1
@@ -25,7 +25,29 @@
|
||||
},
|
||||
"files": [
|
||||
"src/**/*",
|
||||
"package.json"
|
||||
"package.json",
|
||||
{
|
||||
"from": "node_modules",
|
||||
"to": "node_modules",
|
||||
"filter": [
|
||||
"electron-updater/**/*",
|
||||
"builder-util-runtime/**/*",
|
||||
"debug/**/*",
|
||||
"ms/**/*",
|
||||
"sax/**/*",
|
||||
"js-yaml/**/*",
|
||||
"argparse/**/*",
|
||||
"lazy-val/**/*",
|
||||
"lodash.escaperegexp/**/*",
|
||||
"lodash.isequal/**/*",
|
||||
"tiny-typed-emitter/**/*",
|
||||
"fs-extra/**/*",
|
||||
"graceful-fs/**/*",
|
||||
"jsonfile/**/*",
|
||||
"universalify/**/*",
|
||||
"semver/**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
|
||||
@@ -13,8 +13,63 @@ fs.mkdirSync(distDir, { recursive: true });
|
||||
fs.mkdirSync(workDir, { recursive: true });
|
||||
fs.mkdirSync(specDir, { recursive: true });
|
||||
|
||||
function parseVersionTuple(rawVersion) {
|
||||
const nums = String(rawVersion || "")
|
||||
.split(/[^\d]+/)
|
||||
.map((x) => Number.parseInt(x, 10))
|
||||
.filter((n) => Number.isInteger(n) && n >= 0);
|
||||
while (nums.length < 4) nums.push(0);
|
||||
return nums.slice(0, 4);
|
||||
}
|
||||
|
||||
function buildVersionInfoText(versionTuple, versionDot) {
|
||||
const [a, b, c, d] = versionTuple;
|
||||
return `# UTF-8
|
||||
VSVersionInfo(
|
||||
ffi=FixedFileInfo(
|
||||
filevers=(${a}, ${b}, ${c}, ${d}),
|
||||
prodvers=(${a}, ${b}, ${c}, ${d}),
|
||||
mask=0x3f,
|
||||
flags=0x0,
|
||||
OS=0x4,
|
||||
fileType=0x1,
|
||||
subtype=0x0,
|
||||
date=(0, 0)
|
||||
),
|
||||
kids=[
|
||||
StringFileInfo([
|
||||
StringTable(
|
||||
'080404B0',
|
||||
[StringStruct('CompanyName', 'LifeArchiveProject'),
|
||||
StringStruct('FileDescription', 'WeFlow'),
|
||||
StringStruct('FileVersion', '${versionDot}'),
|
||||
StringStruct('InternalName', 'weflow'),
|
||||
StringStruct('LegalCopyright', 'github.com/hicccc77/WeFlow'),
|
||||
StringStruct('OriginalFilename', 'weflow.exe'),
|
||||
StringStruct('ProductName', 'WeFlow'),
|
||||
StringStruct('ProductVersion', '${versionDot}')])
|
||||
]),
|
||||
VarFileInfo([VarStruct('Translation', [2052, 1200])])
|
||||
]
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const nativeDir = path.join(repoRoot, "src", "wechat_decrypt_tool", "native");
|
||||
const addData = `${nativeDir};wechat_decrypt_tool/native`;
|
||||
const projectToml = path.join(repoRoot, "pyproject.toml");
|
||||
|
||||
const desktopPackageJsonPath = path.join(repoRoot, "desktop", "package.json");
|
||||
let desktopVersion = "1.3.0";
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(desktopPackageJsonPath, { encoding: "utf8" }));
|
||||
const v = String(pkg?.version || "").trim();
|
||||
if (v) desktopVersion = v;
|
||||
} catch {}
|
||||
const versionTuple = parseVersionTuple(desktopVersion);
|
||||
const versionDot = versionTuple.join(".");
|
||||
const versionFilePath = path.join(workDir, "weflow-version.txt");
|
||||
fs.writeFileSync(versionFilePath, buildVersionInfoText(versionTuple, versionDot), { encoding: "utf8" });
|
||||
|
||||
const args = [
|
||||
"run",
|
||||
@@ -30,11 +85,42 @@ const args = [
|
||||
workDir,
|
||||
"--specpath",
|
||||
specDir,
|
||||
"--version-file",
|
||||
versionFilePath,
|
||||
"--add-data",
|
||||
addData,
|
||||
entry,
|
||||
];
|
||||
|
||||
const r = spawnSync("uv", args, { cwd: repoRoot, stdio: "inherit" });
|
||||
process.exit(r.status ?? 1);
|
||||
if ((r.status ?? 1) !== 0) {
|
||||
process.exit(r.status ?? 1);
|
||||
}
|
||||
|
||||
// Keep a stable external native folder for packaged runtime to avoid relying on
|
||||
// onefile temp extraction paths when wcdb_api.dll performs environment checks.
|
||||
const packagedNativeDir = path.join(distDir, "native");
|
||||
try {
|
||||
fs.rmSync(packagedNativeDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
fs.mkdirSync(packagedNativeDir, { recursive: true });
|
||||
|
||||
for (const name of fs.readdirSync(nativeDir)) {
|
||||
const src = path.join(nativeDir, name);
|
||||
const dst = path.join(packagedNativeDir, name);
|
||||
try {
|
||||
if (fs.statSync(src).isFile()) {
|
||||
fs.copyFileSync(src, dst);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Provide the project marker next to packaged backend resources.
|
||||
if (fs.existsSync(projectToml)) {
|
||||
try {
|
||||
fs.copyFileSync(projectToml, path.join(distDir, "pyproject.toml"));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
|
||||
|
||||
@@ -14,6 +14,34 @@
|
||||
|
||||
Var WDA_InstallDirPage
|
||||
|
||||
!macro customInit
|
||||
; Safety: older versions created an `output` junction inside the install directory that points to the
|
||||
; per-user AppData `output` folder. Some uninstall/update flows may traverse that junction and delete
|
||||
; real user data. Remove it as early as possible during install/update.
|
||||
Call WDA_RemoveLegacyOutputLink
|
||||
!macroend
|
||||
|
||||
!macro customInstall
|
||||
; Provide a safe, non-junction way for users to locate the real per-user output directory.
|
||||
; The actual data is NOT stored inside $INSTDIR (it is wiped on update/reinstall).
|
||||
; `open-output.cmd` uses %APPDATA% so it works for the current user.
|
||||
FileOpen $0 "$INSTDIR\output-location.txt" w
|
||||
FileWrite $0 "WeChatDataAnalysis output folder (per user):$\r$\n%APPDATA%\\${APP_PACKAGE_NAME}\\output$\r$\n"
|
||||
FileClose $0
|
||||
|
||||
FileOpen $1 "$INSTDIR\open-output.cmd" w
|
||||
; NSIS escaping: use $\" to output a literal quote character into the .cmd file.
|
||||
FileWrite $1 "@echo off$\r$\nexplorer $\"%APPDATA%\\${APP_PACKAGE_NAME}\\output$\"$\r$\n"
|
||||
FileClose $1
|
||||
!macroend
|
||||
|
||||
Function WDA_RemoveLegacyOutputLink
|
||||
; $INSTDIR is usually the full install directory. Be defensive and also try the nested path
|
||||
; in case the installer is running before electron-builder appends "\${APP_FILENAME}".
|
||||
RMDir "$INSTDIR\output"
|
||||
RMDir "$INSTDIR\${APP_FILENAME}\output"
|
||||
FunctionEnd
|
||||
|
||||
!macro customPageAfterChangeDir
|
||||
; Add a confirmation page after the directory picker so users clearly see
|
||||
; the final install location (includes the app sub-folder).
|
||||
@@ -90,6 +118,10 @@ Var /GLOBAL WDA_DeleteUserData
|
||||
!macro customUnInit
|
||||
; Default: keep user data (also applies to silent uninstall / update uninstall).
|
||||
StrCpy $WDA_DeleteUserData "0"
|
||||
|
||||
; Safety: if an older build created an `output` junction inside the install dir, remove it early so
|
||||
; directory cleanup can't traverse it and delete the real per-user output folder.
|
||||
RMDir "$INSTDIR\output"
|
||||
!macroend
|
||||
|
||||
!macro customUnWelcomePage
|
||||
|
||||
+201
-15
@@ -8,7 +8,13 @@ const {
|
||||
dialog,
|
||||
shell,
|
||||
} = require("electron");
|
||||
const { autoUpdater } = require("electron-updater");
|
||||
let autoUpdater = null;
|
||||
let autoUpdaterLoadError = null;
|
||||
try {
|
||||
({ autoUpdater } = require("electron-updater"));
|
||||
} catch (err) {
|
||||
autoUpdaterLoadError = err;
|
||||
}
|
||||
const { spawn, spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
@@ -117,6 +123,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;
|
||||
|
||||
@@ -157,7 +225,11 @@ function getExeDir() {
|
||||
|
||||
function ensureOutputLink() {
|
||||
// Users often expect an `output/` folder near the installed exe. We keep the real data
|
||||
// in the per-user data dir, and (when possible) create a Windows junction next to the exe.
|
||||
// in the per-user data dir.
|
||||
//
|
||||
// NOTE: We intentionally avoid creating a junction/symlink inside the install directory.
|
||||
// Some uninstall/update flows may traverse reparse points and delete the target directory,
|
||||
// causing data loss (the install dir is removed on every update/reinstall).
|
||||
if (!app.isPackaged) return;
|
||||
|
||||
const exeDir = getExeDir();
|
||||
@@ -165,26 +237,56 @@ function ensureOutputLink() {
|
||||
if (!exeDir || !dataDir) return;
|
||||
|
||||
const target = path.join(dataDir, "output");
|
||||
const linkPath = path.join(exeDir, "output");
|
||||
const legacyLinkPath = path.join(exeDir, "output");
|
||||
|
||||
// If the target doesn't exist yet, create it so the link points somewhere real.
|
||||
// Ensure the real output dir exists.
|
||||
try {
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
} catch {}
|
||||
|
||||
// If something already exists at linkPath, do not overwrite it.
|
||||
// Best-effort: remove a legacy junction/symlink at `exeDir/output` so uninstallers can't
|
||||
// accidentally traverse it and delete the real per-user output directory.
|
||||
try {
|
||||
if (fs.existsSync(linkPath)) return;
|
||||
const st = fs.lstatSync(legacyLinkPath);
|
||||
if (st.isSymbolicLink()) {
|
||||
try {
|
||||
fs.unlinkSync(legacyLinkPath);
|
||||
logMain(`[main] removed legacy output link: ${legacyLinkPath}`);
|
||||
} catch (err) {
|
||||
logMain(`[main] failed to remove legacy output link: ${err?.message || err}`);
|
||||
}
|
||||
} else if (st.isDirectory()) {
|
||||
const entries = fs.readdirSync(legacyLinkPath);
|
||||
if (Array.isArray(entries) && entries.length === 0) {
|
||||
// Remove an empty real directory to reduce confusion (it will be recreated by the backend if needed).
|
||||
fs.rmdirSync(legacyLinkPath);
|
||||
} else {
|
||||
// Do not overwrite non-empty directories to avoid data loss.
|
||||
// Note: data stored here will be wiped on update/reinstall.
|
||||
logMain(
|
||||
`[main] output dir exists in install dir (not a link): ${legacyLinkPath}. real data dir output: ${target}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logMain(`[main] output path exists and is not a directory/link: ${legacyLinkPath}`);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
// Doesn't exist yet.
|
||||
}
|
||||
|
||||
// Best-effort: drop a helper file next to the exe so users can find the real data.
|
||||
// This avoids the data-loss risks of using junctions/symlinks under the install directory.
|
||||
try {
|
||||
fs.symlinkSync(target, linkPath, "junction");
|
||||
logMain(`[main] created output link: ${linkPath} -> ${target}`);
|
||||
} catch (err) {
|
||||
logMain(`[main] failed to create output link: ${err?.message || err}`);
|
||||
}
|
||||
const p = path.join(exeDir, "output-location.txt");
|
||||
const text = `WeChatDataAnalysis data directory\n\nOutput folder:\n${target}\n`;
|
||||
fs.writeFileSync(p, text, { encoding: "utf8" });
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const p = path.join(exeDir, "open-output.cmd");
|
||||
const text = `@echo off\r\nexplorer \"${target}\"\r\n`;
|
||||
fs.writeFileSync(p, text, { encoding: "utf8" });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function getMainLogPath() {
|
||||
@@ -297,6 +399,12 @@ function isAutoUpdateEnabled() {
|
||||
|
||||
const forced = parseEnvBool(process.env.AUTO_UPDATE_ENABLED);
|
||||
let enabled = forced != null ? forced : !!app.isPackaged;
|
||||
if (enabled && !autoUpdater) {
|
||||
enabled = false;
|
||||
logMain(
|
||||
`[main] auto-update disabled: electron-updater unavailable: ${autoUpdaterLoadError?.message || "unknown error"}`
|
||||
);
|
||||
}
|
||||
|
||||
// In packaged builds electron-updater reads update config from app-update.yml.
|
||||
// If missing, treat auto-update as disabled to avoid noisy errors.
|
||||
@@ -823,6 +931,10 @@ function getPackagedBackendPath() {
|
||||
return path.join(process.resourcesPath, "backend", "wechat-backend.exe");
|
||||
}
|
||||
|
||||
function getPackagedWcdbDllPath() {
|
||||
return path.join(process.resourcesPath, "backend", "native", "wcdb_api.dll");
|
||||
}
|
||||
|
||||
function startBackend() {
|
||||
if (backendProc) return backendProc;
|
||||
|
||||
@@ -853,8 +965,17 @@ function startBackend() {
|
||||
`Packaged backend not found: ${backendExe}. Build it into desktop/resources/backend/wechat-backend.exe`
|
||||
);
|
||||
}
|
||||
const packagedWcdbDll = getPackagedWcdbDllPath();
|
||||
if (fs.existsSync(packagedWcdbDll)) {
|
||||
env.WECHAT_TOOL_WCDB_API_DLL_PATH = packagedWcdbDll;
|
||||
logMain(`[main] using packaged wcdb_api.dll: ${packagedWcdbDll}`);
|
||||
} else {
|
||||
logMain(`[main] packaged wcdb_api.dll not found: ${packagedWcdbDll}`);
|
||||
}
|
||||
|
||||
const backendCwd = path.dirname(backendExe);
|
||||
backendProc = spawn(backendExe, [], {
|
||||
cwd: env.WECHAT_TOOL_DATA_DIR,
|
||||
cwd: backendCwd,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
@@ -965,6 +1086,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;
|
||||
@@ -1229,6 +1360,30 @@ function registerWindowIpc() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getOutputDir", () => {
|
||||
const dir = resolveDataDir();
|
||||
if (!dir) return "";
|
||||
return path.join(dir, "output");
|
||||
});
|
||||
|
||||
ipcMain.handle("app:openOutputDir", async () => {
|
||||
const dir = resolveDataDir();
|
||||
if (!dir) throw new Error("无法定位数据目录");
|
||||
const outDir = path.join(dir, "output");
|
||||
try {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
} catch {}
|
||||
try {
|
||||
const err = await shell.openPath(outDir);
|
||||
if (err) throw new Error(err);
|
||||
return { success: true, path: outDir };
|
||||
} catch (e) {
|
||||
const message = e?.message || String(e);
|
||||
logMain(`[main] openOutputDir failed: ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:checkForUpdates", async () => {
|
||||
return await checkForUpdatesInternal();
|
||||
});
|
||||
@@ -1247,6 +1402,11 @@ function registerWindowIpc() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Safety: remove legacy `output` junctions in the install dir before triggering the NSIS update/uninstall.
|
||||
// Some uninstall flows may traverse reparse points and delete the real per-user output directory.
|
||||
try {
|
||||
ensureOutputLink();
|
||||
} catch {}
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
@@ -1287,15 +1447,41 @@ async function main() {
|
||||
registerWindowIpc();
|
||||
registerDebugShortcuts();
|
||||
|
||||
// Resolve/create the data dir early so we can log reliably and (optionally) place a link
|
||||
// Resolve/create the data dir early so we can log reliably and place helper files
|
||||
// next to the installed exe for easier access.
|
||||
resolveDataDir();
|
||||
ensureOutputLink();
|
||||
await ensureBackendPortAvailableOnStartup();
|
||||
|
||||
logMain(`[main] app.isPackaged=${app.isPackaged} argv=${JSON.stringify(process.argv)}`);
|
||||
|
||||
startBackend();
|
||||
await waitForBackend({ timeoutMs: 30_000 });
|
||||
try {
|
||||
await waitForBackend({ timeoutMs: 30_000 });
|
||||
} catch (err) {
|
||||
// In some environments a specific port may be blocked/reserved (WSAEACCES) or taken.
|
||||
// Best-effort: pick a new port and retry once so the app can still start.
|
||||
if (app.isPackaged) {
|
||||
const prevPort = getBackendPort();
|
||||
const bindHost = getBackendBindHost();
|
||||
const nextPort = await chooseAvailablePort(prevPort + 1, bindHost);
|
||||
if (nextPort != null && nextPort !== prevPort) {
|
||||
logMain(`[main] backend not ready on port ${prevPort}; retrying on ${nextPort}`);
|
||||
try {
|
||||
setBackendPortSetting(nextPort);
|
||||
await restartBackend({ timeoutMs: 30_000 });
|
||||
logMain(`[main] backend retry succeeded on port ${nextPort}`);
|
||||
} catch (retryErr) {
|
||||
logMain(`[main] backend retry failed: ${retryErr?.stack || String(retryErr)}`);
|
||||
throw retryErr;
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const win = createMainWindow();
|
||||
mainWindow = win;
|
||||
|
||||
@@ -19,6 +19,10 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
|
||||
chooseDirectory: (options = {}) => ipcRenderer.invoke("dialog:chooseDirectory", options),
|
||||
|
||||
// Data/output folder helpers
|
||||
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
|
||||
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
|
||||
|
||||
// Auto update
|
||||
getVersion: () => ipcRenderer.invoke("app:getVersion"),
|
||||
checkForUpdates: () => ipcRenderer.invoke("app:checkForUpdates"),
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -142,6 +142,24 @@
|
||||
<div v-if="desktopBackendPortError" class="text-xs text-red-600 whitespace-pre-wrap -mt-1.5">
|
||||
{{ desktopBackendPortError }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopOutputDirText }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isDesktopEnv || desktopOutputDirLoading"
|
||||
@click="onDesktopOpenOutputDir"
|
||||
>
|
||||
打开 output
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="desktopOutputDirError" class="text-xs text-red-600 whitespace-pre-wrap -mt-1.5">
|
||||
{{ desktopOutputDirError }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -288,6 +306,15 @@ const desktopBackendPortApplying = ref(false)
|
||||
const desktopBackendPortError = ref('')
|
||||
const desktopBackendPortDefault = ref(10392)
|
||||
|
||||
const desktopOutputDir = ref('')
|
||||
const desktopOutputDirLoading = ref(false)
|
||||
const desktopOutputDirError = ref('')
|
||||
const desktopOutputDirText = computed(() => {
|
||||
if (!isDesktopEnv.value) return '仅桌面端可用'
|
||||
const v = String(desktopOutputDir.value || '').trim()
|
||||
return v || '—'
|
||||
})
|
||||
|
||||
const switchTrackClass = (enabled, disabled = false) => {
|
||||
if (disabled) return enabled ? 'bg-[#07b75b] opacity-50 cursor-not-allowed' : 'bg-[#d0d0d0] opacity-50 cursor-not-allowed'
|
||||
return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95'
|
||||
@@ -437,6 +464,36 @@ 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 applyDesktopBackendPort = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
const raw = String(desktopBackendPortInput.value || '').trim()
|
||||
@@ -567,6 +624,7 @@ onMounted(async () => {
|
||||
void desktopUpdate.initListeners()
|
||||
await refreshDesktopAutoLaunch()
|
||||
await refreshDesktopCloseBehavior()
|
||||
await refreshDesktopOutputDir()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
@@ -551,6 +551,7 @@
|
||||
:preview="message.preview"
|
||||
:fromAvatar="message.fromAvatar"
|
||||
:from="message.from"
|
||||
:linkType="message.linkType"
|
||||
:isSent="message.isSent"
|
||||
:variant="message.linkCardVariant || 'default'"
|
||||
/>
|
||||
@@ -588,17 +589,16 @@
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
<a
|
||||
<button
|
||||
v-if="message.videoThumbUrl && message.videoUrl"
|
||||
:href="message.videoUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
type="button"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
@click.stop="openVideoPreview(message.videoUrl, message.videoThumbUrl)"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
</a>
|
||||
</button>
|
||||
<div class="absolute inset-0 flex items-center justify-center" v-else-if="message.videoThumbUrl">
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
@@ -814,6 +814,9 @@
|
||||
<span>微信红包</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'location'" class="max-w-sm">
|
||||
<ChatLocationCard :message="message" />
|
||||
</div>
|
||||
<!-- 文本消息 -->
|
||||
<div v-else-if="message.renderType === 'text'"
|
||||
class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
|
||||
@@ -1384,6 +1387,40 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 视频预览弹窗 (全局固定定位) -->
|
||||
<div
|
||||
v-if="previewVideoUrl"
|
||||
class="fixed inset-0 z-[13000] bg-black/90 flex items-center justify-center"
|
||||
@click="closeVideoPreview"
|
||||
>
|
||||
<div class="relative max-w-[92vw] max-h-[92vh] flex flex-col items-center" @click.stop>
|
||||
<video
|
||||
:key="previewVideoUrl"
|
||||
:src="previewVideoUrl"
|
||||
:poster="previewVideoPosterUrl"
|
||||
class="max-w-[90vw] max-h-[90vh] object-contain"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
@error="onPreviewVideoError"
|
||||
></video>
|
||||
<div
|
||||
v-if="previewVideoError"
|
||||
class="mt-3 text-xs text-red-200 whitespace-pre-wrap text-center max-w-[90vw]"
|
||||
>
|
||||
{{ previewVideoError }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="absolute top-4 right-4 text-white/80 hover:text-white p-2 rounded-full bg-black/30 hover:bg-black/50 transition-colors"
|
||||
@click.stop="closeVideoPreview"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 浮动窗口(可拖动):合并消息 / 链接卡片 -->
|
||||
<div
|
||||
v-for="win in floatingWindows"
|
||||
@@ -1490,12 +1527,12 @@
|
||||
@contextmenu="openMediaContextMenu($event, rec, 'message')"
|
||||
>
|
||||
<div class="wechat-link-content">
|
||||
<div class="wechat-link-info">
|
||||
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
|
||||
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
|
||||
<div v-if="rec.content || rec.preview" class="wechat-link-summary">
|
||||
<div v-if="rec.content" class="wechat-link-desc">{{ rec.content }}</div>
|
||||
</div>
|
||||
<div v-if="rec.preview" class="wechat-link-thumb">
|
||||
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
|
||||
<div v-if="rec.preview" class="wechat-link-thumb">
|
||||
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-link-from">
|
||||
@@ -1557,17 +1594,16 @@
|
||||
@error="onChatHistoryVideoThumbError(rec)"
|
||||
/>
|
||||
<div v-else class="px-3 py-2 text-sm text-gray-700">{{ rec.content || '[视频]' }}</div>
|
||||
<a
|
||||
<button
|
||||
v-if="rec.videoUrl"
|
||||
:href="rec.videoUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
type="button"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
@click.stop="openVideoPreview(rec.videoUrl, rec.videoThumbUrl)"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
</a>
|
||||
</button>
|
||||
<div
|
||||
v-if="rec.videoDuration"
|
||||
class="absolute bottom-2 right-2 text-xs text-white bg-black/55 px-1.5 py-0.5 rounded"
|
||||
@@ -1602,12 +1638,12 @@
|
||||
@contextmenu="openMediaContextMenu($event, win, 'message')"
|
||||
>
|
||||
<div class="wechat-link-content">
|
||||
<div class="wechat-link-info">
|
||||
<div class="wechat-link-title">{{ win.title || win.url || '链接' }}</div>
|
||||
<div class="wechat-link-title">{{ win.title || win.url || '链接' }}</div>
|
||||
<div v-if="win.content || win.preview" class="wechat-link-summary">
|
||||
<div v-if="win.content" class="wechat-link-desc">{{ win.content }}</div>
|
||||
</div>
|
||||
<div v-if="win.preview" class="wechat-link-thumb">
|
||||
<img :src="win.preview" :alt="win.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(win)" />
|
||||
<div v-if="win.preview" class="wechat-link-thumb">
|
||||
<img :src="win.preview" :alt="win.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(win)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-link-from">
|
||||
@@ -1768,12 +1804,12 @@
|
||||
@contextmenu="openMediaContextMenu($event, rec, 'message')"
|
||||
>
|
||||
<div class="wechat-link-content">
|
||||
<div class="wechat-link-info">
|
||||
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
|
||||
<div class="wechat-link-title">{{ rec.title || rec.content || rec.url || '链接' }}</div>
|
||||
<div v-if="rec.content || rec.preview" class="wechat-link-summary">
|
||||
<div v-if="rec.content" class="wechat-link-desc">{{ rec.content }}</div>
|
||||
</div>
|
||||
<div v-if="rec.preview" class="wechat-link-thumb">
|
||||
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
|
||||
<div v-if="rec.preview" class="wechat-link-thumb">
|
||||
<img :src="rec.preview" :alt="rec.title || '链接预览'" class="wechat-link-thumb-img" referrerpolicy="no-referrer" loading="lazy" decoding="async" @error="onChatHistoryLinkPreviewError(rec)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-link-from">
|
||||
@@ -1810,17 +1846,16 @@
|
||||
/>
|
||||
<div v-else class="px-3 py-2 text-sm text-gray-700">{{ rec.content || '[视频]' }}</div>
|
||||
|
||||
<a
|
||||
<button
|
||||
v-if="rec.videoThumbUrl && rec.videoUrl"
|
||||
:href="rec.videoUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
type="button"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
@click.stop="openVideoPreview(rec.videoUrl, rec.videoThumbUrl)"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
</a>
|
||||
</button>
|
||||
<div class="absolute inset-0 flex items-center justify-center" v-else-if="rec.videoThumbUrl">
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
@@ -2465,6 +2500,7 @@ import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { useChatRealtimeStore } from '~/stores/chatRealtime'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
import wechatPcLogoUrl from '~/assets/images/wechat/WeChat-Icon-Logo.wine.svg'
|
||||
import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
|
||||
import zipIconUrl from '~/assets/images/wechat/zip.png'
|
||||
import pdfIconUrl from '~/assets/images/wechat/pdf.png'
|
||||
import wordIconUrl from '~/assets/images/wechat/word.png'
|
||||
@@ -4065,15 +4101,42 @@ const scrollToMessageId = async (id) => {
|
||||
|
||||
// 图片预览状态
|
||||
const previewImageUrl = ref(null)
|
||||
const previewVideoUrl = ref(null)
|
||||
const previewVideoPosterUrl = ref('')
|
||||
const previewVideoError = ref('')
|
||||
|
||||
const openImagePreview = (url) => {
|
||||
if (!process.client) return
|
||||
previewImageUrl.value = url
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const closeImagePreview = () => {
|
||||
if (!process.client) return
|
||||
previewImageUrl.value = null
|
||||
document.body.style.overflow = ''
|
||||
if (!previewVideoUrl.value) document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
const openVideoPreview = (url, poster) => {
|
||||
if (!process.client) return
|
||||
const u = String(url || '').trim()
|
||||
if (!u) return
|
||||
previewVideoError.value = ''
|
||||
previewVideoPosterUrl.value = String(poster || '').trim()
|
||||
previewVideoUrl.value = u
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const closeVideoPreview = () => {
|
||||
if (!process.client) return
|
||||
previewVideoUrl.value = null
|
||||
previewVideoPosterUrl.value = ''
|
||||
previewVideoError.value = ''
|
||||
if (!previewImageUrl.value) document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
const onPreviewVideoError = () => {
|
||||
previewVideoError.value = '视频加载失败。'
|
||||
}
|
||||
|
||||
const voiceRefs = ref({})
|
||||
@@ -5953,7 +6016,7 @@ const loadSessionsForSelectedAccount = async () => {
|
||||
id: s.id,
|
||||
name: s.name || s.username || s.id,
|
||||
avatar: s.avatar || null,
|
||||
lastMessage: s.lastMessage || '',
|
||||
lastMessage: normalizeSessionPreview(s.lastMessage || ''),
|
||||
lastMessageTime: s.lastMessageTime || '',
|
||||
unreadCount: s.unreadCount || 0,
|
||||
isGroup: !!s.isGroup,
|
||||
@@ -6039,7 +6102,7 @@ const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => {
|
||||
id: s.id,
|
||||
name: s.name || s.username || s.id,
|
||||
avatar: s.avatar || null,
|
||||
lastMessage: s.lastMessage || '',
|
||||
lastMessage: normalizeSessionPreview(s.lastMessage || ''),
|
||||
lastMessageTime: s.lastMessageTime || '',
|
||||
unreadCount: s.unreadCount || 0,
|
||||
isGroup: !!s.isGroup,
|
||||
@@ -6308,6 +6371,10 @@ const normalizeMessage = (msg) => {
|
||||
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款' || msg.transferStatus === '已被接收',
|
||||
voiceUrl: normalizedVoiceUrl || '',
|
||||
voiceDuration: msg.voiceLength || msg.voiceDuration || '',
|
||||
locationLat: msg.locationLat ?? null,
|
||||
locationLng: msg.locationLng ?? null,
|
||||
locationPoiname: String(msg.locationPoiname || '').trim(),
|
||||
locationLabel: String(msg.locationLabel || '').trim(),
|
||||
preview: normalizedLinkPreviewUrl || '',
|
||||
linkType: String(msg.linkType || '').trim(),
|
||||
linkStyle: String(msg.linkStyle || '').trim(),
|
||||
@@ -6409,6 +6476,14 @@ const closeTopFloatingWindow = () => {
|
||||
if (top?.id) closeFloatingWindow(top.id)
|
||||
}
|
||||
|
||||
const normalizeSessionPreview = (value) => {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return ''
|
||||
if (/^\[location\]/i.test(text)) return text.replace(/^\[location\]/i, '[位置]')
|
||||
if (/:\s*\[location\]$/i.test(text)) return text.replace(/\[location\]$/i, '[位置]')
|
||||
return text
|
||||
}
|
||||
|
||||
const openFloatingWindow = (payload) => {
|
||||
if (!process.client) return null
|
||||
const w0 = Number(payload?.width || 0) > 0 ? Number(payload.width) : 560
|
||||
@@ -7030,9 +7105,7 @@ const openChatHistoryQuote = (rec) => {
|
||||
if (!url) return
|
||||
|
||||
if (kind === 'video') {
|
||||
try {
|
||||
window.open(url, '_blank', 'noreferrer')
|
||||
} catch {}
|
||||
openVideoPreview(url, q?.thumbUrl)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7375,6 +7448,7 @@ const onGlobalKeyDown = (e) => {
|
||||
if (key === 'Escape') {
|
||||
if (contextMenu.value.visible) closeContextMenu()
|
||||
if (previewImageUrl.value) closeImagePreview()
|
||||
if (previewVideoUrl.value) closeVideoPreview()
|
||||
if (Array.isArray(floatingWindows.value) && floatingWindows.value.length) closeTopFloatingWindow()
|
||||
if (chatHistoryModalVisible.value) closeChatHistoryModal()
|
||||
if (contactProfileCardOpen.value) {
|
||||
@@ -7843,13 +7917,15 @@ const onMessageScroll = async () => {
|
||||
const LinkCard = defineComponent({
|
||||
name: 'LinkCard',
|
||||
props: {
|
||||
href: { type: String, required: true },
|
||||
href: { type: String, default: '' },
|
||||
heading: { type: String, default: '' },
|
||||
abstract: { type: String, default: '' },
|
||||
preview: { type: String, default: '' },
|
||||
fromAvatar: { type: String, default: '' },
|
||||
from: { type: String, default: '' },
|
||||
linkType: { type: String, default: '' },
|
||||
isSent: { type: Boolean, default: false },
|
||||
badge: { type: String, default: '' },
|
||||
variant: { type: String, default: 'default' }
|
||||
},
|
||||
setup(props) {
|
||||
@@ -7863,7 +7939,9 @@ const LinkCard = defineComponent({
|
||||
// Fallback: when the appmsg XML doesn't provide sourcedisplayname/appname,
|
||||
// show the host so the footer row still matches WeChat's fixed card layout.
|
||||
try {
|
||||
const host = new URL(String(props.href || '')).hostname
|
||||
const href = String(props.href || '').trim()
|
||||
if (!/^https?:\/\//i.test(href)) return ''
|
||||
const host = new URL(href).hostname
|
||||
return String(host || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
@@ -7872,6 +7950,9 @@ const LinkCard = defineComponent({
|
||||
|
||||
return () => {
|
||||
const fromText = getFromText()
|
||||
const href = String(props.href || '').trim()
|
||||
const canNavigate = /^https?:\/\//i.test(href)
|
||||
const badgeText = String(props.badge || '').trim()
|
||||
// WeChat link cards show a small avatar next to the source text. We don't
|
||||
// always have a real image URL, so fall back to the first glyph.
|
||||
const fromAvatarText = (() => {
|
||||
@@ -7879,7 +7960,9 @@ const LinkCard = defineComponent({
|
||||
return t ? (Array.from(t)[0] || '') : ''
|
||||
})()
|
||||
const fromAvatarUrl = String(props.fromAvatar || '').trim()
|
||||
const isCoverVariant = String(props.variant || '').trim() === 'cover'
|
||||
const isMiniProgram = String(props.linkType || '').trim() === 'mini_program'
|
||||
const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
|
||||
const Tag = canNavigate ? 'a' : 'div'
|
||||
|
||||
// Props may change when switching accounts/chats; reset load state per URL.
|
||||
if (fromAvatarUrl !== lastFromAvatarUrl.value) {
|
||||
@@ -7896,6 +7979,12 @@ const LinkCard = defineComponent({
|
||||
color: 'transparent'
|
||||
}
|
||||
: null
|
||||
const miniProgramAvatarStyle = fromAvatarImgOk.value
|
||||
? {
|
||||
background: '#fff',
|
||||
color: 'transparent'
|
||||
}
|
||||
: null
|
||||
const onFromAvatarLoad = () => {
|
||||
fromAvatarImgOk.value = true
|
||||
fromAvatarImgError.value = false
|
||||
@@ -7918,17 +8007,17 @@ const LinkCard = defineComponent({
|
||||
onError: onFromAvatarError
|
||||
}) : null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-cover-from-name' }, fromText || '\u200B')
|
||||
])
|
||||
h('div', { class: 'wechat-link-cover-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
|
||||
badgeText ? h('div', { class: 'wechat-link-cover-badge' }, badgeText) : null,
|
||||
].filter(Boolean))
|
||||
|
||||
return h(
|
||||
'a',
|
||||
Tag,
|
||||
{
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noreferrer',
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card-cover',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
@@ -7958,19 +8047,91 @@ const LinkCard = defineComponent({
|
||||
}),
|
||||
fromRow,
|
||||
]) : fromRow,
|
||||
h('div', { class: 'wechat-link-cover-title' }, props.heading || props.href)
|
||||
h('div', { class: 'wechat-link-cover-title' }, props.heading || href)
|
||||
].filter(Boolean)
|
||||
)
|
||||
}
|
||||
|
||||
const headingText = String(props.heading || href || '').trim()
|
||||
let abstractText = String(props.abstract || '').trim()
|
||||
if (abstractText && headingText && abstractText === headingText) abstractText = ''
|
||||
|
||||
if (isMiniProgram) {
|
||||
return h(
|
||||
Tag,
|
||||
{
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card',
|
||||
'wechat-link-card--mini-program',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
].filter(Boolean).join(' '),
|
||||
style: {
|
||||
width: '210px',
|
||||
minWidth: '210px',
|
||||
maxWidth: '210px',
|
||||
maxHeight: '270px',
|
||||
height: '270px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
outline: 'none'
|
||||
}
|
||||
},
|
||||
[
|
||||
h('div', { class: 'wechat-link-mini-body' }, [
|
||||
h('div', { class: 'wechat-link-mini-header' }, [
|
||||
h('div', { class: 'wechat-link-mini-header-avatar', style: miniProgramAvatarStyle, 'aria-hidden': 'true' }, [
|
||||
showFromAvatarText ? (fromAvatarText || '\u200B') : null,
|
||||
showFromAvatarImg ? h('img', {
|
||||
src: fromAvatarUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-mini-header-avatar-img',
|
||||
referrerpolicy: 'no-referrer',
|
||||
onLoad: onFromAvatarLoad,
|
||||
onError: onFromAvatarError
|
||||
}) : null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-mini-header-name' }, fromText || '\u200B')
|
||||
]),
|
||||
h('div', { class: 'wechat-link-mini-title' }, headingText || abstractText || href),
|
||||
h('div', { class: ['wechat-link-mini-preview', !props.preview ? 'wechat-link-mini-preview--empty' : ''].filter(Boolean).join(' ') }, [
|
||||
props.preview ? h('img', {
|
||||
src: props.preview,
|
||||
alt: props.heading || '小程序预览',
|
||||
class: 'wechat-link-mini-preview-img',
|
||||
referrerpolicy: 'no-referrer'
|
||||
}) : null
|
||||
].filter(Boolean))
|
||||
]),
|
||||
h('div', { class: 'wechat-link-mini-footer' }, [
|
||||
h('img', {
|
||||
src: miniProgramIconUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-mini-footer-icon',
|
||||
'aria-hidden': 'true'
|
||||
}),
|
||||
h('span', { class: 'wechat-link-mini-footer-text' }, '小程序')
|
||||
])
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
return h(
|
||||
'a',
|
||||
Tag,
|
||||
{
|
||||
href: props.href,
|
||||
target: '_blank',
|
||||
rel: 'noreferrer',
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
@@ -7995,13 +8156,15 @@ const LinkCard = defineComponent({
|
||||
},
|
||||
[
|
||||
h('div', { class: 'wechat-link-content' }, [
|
||||
h('div', { class: 'wechat-link-info' }, [
|
||||
h('div', { class: 'wechat-link-title' }, props.heading || props.href),
|
||||
props.abstract ? h('div', { class: 'wechat-link-desc' }, props.abstract) : null
|
||||
].filter(Boolean)),
|
||||
props.preview ? h('div', { class: 'wechat-link-thumb' }, [
|
||||
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'wechat-link-thumb-img', referrerpolicy: 'no-referrer' })
|
||||
]) : null
|
||||
h('div', { class: 'wechat-link-title' }, headingText || href),
|
||||
(abstractText || props.preview)
|
||||
? h('div', { class: 'wechat-link-summary' }, [
|
||||
abstractText ? h('div', { class: 'wechat-link-desc' }, abstractText) : null,
|
||||
props.preview ? h('div', { class: 'wechat-link-thumb' }, [
|
||||
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'wechat-link-thumb-img', referrerpolicy: 'no-referrer' })
|
||||
]) : null
|
||||
].filter(Boolean))
|
||||
: null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-from' }, [
|
||||
h('div', { class: 'wechat-link-from-avatar', style: fromAvatarStyle, 'aria-hidden': 'true' }, [
|
||||
@@ -8015,8 +8178,9 @@ const LinkCard = defineComponent({
|
||||
onError: onFromAvatarError
|
||||
}) : null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-from-name' }, fromText || '\u200B')
|
||||
])
|
||||
h('div', { class: 'wechat-link-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
|
||||
badgeText ? h('div', { class: 'wechat-link-badge' }, badgeText) : null
|
||||
].filter(Boolean))
|
||||
].filter(Boolean)
|
||||
)
|
||||
}
|
||||
@@ -8026,6 +8190,35 @@ const LinkCard = defineComponent({
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* LinkCard:小程序标记与无 URL 降级 */
|
||||
::deep(.wechat-link-badge) {
|
||||
margin-left: auto;
|
||||
padding-left: 8px;
|
||||
font-size: 11px;
|
||||
color: #b2b2b2;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
::deep(.wechat-link-cover-badge) {
|
||||
margin-left: auto;
|
||||
padding-left: 8px;
|
||||
font-size: 11px;
|
||||
color: rgba(243, 243, 243, 0.92);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
::deep(.wechat-link-card.wechat-link-card--disabled),
|
||||
::deep(.wechat-link-card-cover.wechat-link-card--disabled) {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
::deep(.wechat-link-card.wechat-link-card--disabled:hover),
|
||||
::deep(.wechat-link-card-cover.wechat-link-card--disabled:hover) {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
@@ -8775,21 +8968,18 @@ const LinkCard = defineComponent({
|
||||
|
||||
:deep(.wechat-link-content) {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
/* Keep a small breathing room above the footer divider. */
|
||||
padding: 8px 10px 6px;
|
||||
padding: 10px 10px 8px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-info) {
|
||||
:deep(.wechat-link-summary) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-title) {
|
||||
@@ -8806,24 +8996,24 @@ const LinkCard = defineComponent({
|
||||
:deep(.wechat-link-desc) {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-thumb) {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex-shrink: 0;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: #f2f2f2;
|
||||
/* Center the thumbnail in the content area (WeChat desktop style). */
|
||||
align-self: center;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-thumb-img) {
|
||||
@@ -8833,6 +9023,127 @@ const LinkCard = defineComponent({
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-card--mini-program) {
|
||||
max-height: 270px;
|
||||
height: 270px;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-body) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header-avatar) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #14c15f;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header-avatar-img) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-header-name) {
|
||||
font-size: 13px;
|
||||
color: #7d7d7d;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-title) {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: #1a1a1a;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-preview) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
background: #f2f2f2;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-preview--empty) {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-preview-img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
display: block;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer) {
|
||||
height: 23px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
height: 1px;
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer-icon) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-mini-footer-text) {
|
||||
font-size: 10px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
:deep(.wechat-link-from) {
|
||||
height: 30px;
|
||||
display: flex;
|
||||
|
||||
+116
-11
@@ -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
|
||||
@@ -1756,6 +1775,53 @@ const previewSrc = computed(() => {
|
||||
return getMediaPreviewSrc(ctx.post, ctx.media, ctx.idx)
|
||||
})
|
||||
|
||||
const previewVideoEl = ref(null)
|
||||
const previewVideoMode = ref('') // 'local' | 'remote' | 'raw'
|
||||
const previewVideoError = ref('')
|
||||
const previewVideoTried = reactive({ local: false, remote: false, raw: false })
|
||||
|
||||
const resetPreviewVideo = () => {
|
||||
previewVideoMode.value = ''
|
||||
previewVideoError.value = ''
|
||||
previewVideoTried.local = false
|
||||
previewVideoTried.remote = false
|
||||
previewVideoTried.raw = false
|
||||
}
|
||||
|
||||
const previewIsVideo = computed(() => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return false
|
||||
return Number(ctx.media?.type || 0) === 6
|
||||
})
|
||||
|
||||
const previewVideoPoster = computed(() => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return ''
|
||||
if (Number(ctx.media?.type || 0) !== 6) return ''
|
||||
return getMediaThumbSrc(ctx.post, ctx.media, ctx.idx) || ''
|
||||
})
|
||||
|
||||
const previewVideoSrc = computed(() => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return ''
|
||||
if (Number(ctx.media?.type || 0) !== 6) return ''
|
||||
|
||||
const local = getSnsVideoUrl(ctx.post?.id, ctx.media?.id)
|
||||
const remote = getSnsRemoteVideoSrc(ctx.post, ctx.media)
|
||||
const raw = upgradeTencentHttps(String(ctx.media?.url || '').trim())
|
||||
|
||||
const mode = String(previewVideoMode.value || '').toLowerCase()
|
||||
if (mode === 'local') return local
|
||||
if (mode === 'remote') return remote
|
||||
if (mode === 'raw') return raw
|
||||
return local || remote || raw || ''
|
||||
})
|
||||
|
||||
const previewVideoKey = computed(() => {
|
||||
if (!previewIsVideo.value) return ''
|
||||
return `${String(previewVideoMode.value || '')}:${String(previewVideoSrc.value || '')}`
|
||||
})
|
||||
|
||||
const previewLivePhotoVideoSrc = computed(() => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return ''
|
||||
@@ -1879,6 +1945,7 @@ const loadPreviewCandidates = async ({ reset }) => {
|
||||
|
||||
const openImagePreview = async (post, m, idx = 0) => {
|
||||
if (!process.client) return
|
||||
resetPreviewVideo()
|
||||
// Stop any background hover-playing live photo when opening the preview.
|
||||
activeLivePhotoKey.value = ''
|
||||
// Preview is an intentional action; allow retry even if hover playback failed once.
|
||||
@@ -1898,11 +1965,58 @@ const openImagePreview = async (post, m, idx = 0) => {
|
||||
await loadPreviewCandidates({ reset: true })
|
||||
}
|
||||
|
||||
const openVideoPreview = (post, m, idx = 0) => {
|
||||
if (!process.client) return
|
||||
resetPreviewVideo()
|
||||
activeLivePhotoKey.value = ''
|
||||
|
||||
const local = getSnsVideoUrl(post?.id, m?.id)
|
||||
const remote = getSnsRemoteVideoSrc(post, m)
|
||||
const raw = upgradeTencentHttps(String(m?.url || '').trim())
|
||||
|
||||
if (local) previewVideoMode.value = 'local'
|
||||
else if (remote) previewVideoMode.value = 'remote'
|
||||
else if (raw) previewVideoMode.value = 'raw'
|
||||
else previewVideoError.value = '视频地址缺失。'
|
||||
|
||||
previewCtx.value = { post, media: m, idx: Number(idx) || 0 }
|
||||
previewCandidatesOpen.value = false
|
||||
resetPreviewCandidates()
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const onPreviewVideoError = () => {
|
||||
const ctx = previewCtx.value
|
||||
if (!ctx) return
|
||||
if (Number(ctx.media?.type || 0) !== 6) return
|
||||
|
||||
const current = String(previewVideoMode.value || '').toLowerCase()
|
||||
if (current === 'local') previewVideoTried.local = true
|
||||
if (current === 'remote') previewVideoTried.remote = true
|
||||
if (current === 'raw') previewVideoTried.raw = true
|
||||
|
||||
// Fallback order: local -> remote -> raw
|
||||
const remote = getSnsRemoteVideoSrc(ctx.post, ctx.media)
|
||||
if (!previewVideoTried.remote && remote) {
|
||||
previewVideoMode.value = 'remote'
|
||||
return
|
||||
}
|
||||
|
||||
const raw = upgradeTencentHttps(String(ctx.media?.url || '').trim())
|
||||
if (!previewVideoTried.raw && raw) {
|
||||
previewVideoMode.value = 'raw'
|
||||
return
|
||||
}
|
||||
|
||||
previewVideoError.value = '视频加载失败:可能是本地缓存不存在,或远程下载/解密失败。'
|
||||
}
|
||||
|
||||
const closeImagePreview = () => {
|
||||
if (!process.client) return
|
||||
previewCtx.value = null
|
||||
previewCandidatesOpen.value = false
|
||||
resetPreviewCandidates()
|
||||
resetPreviewVideo()
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
@@ -1912,16 +2026,7 @@ const onMediaClick = (post, m, idx = 0) => {
|
||||
|
||||
// 视频点击逻辑
|
||||
if (mt === 6) {
|
||||
// Open a playable mp4 via backend (downloads+decrypts as needed).
|
||||
const remoteUrl = getSnsRemoteVideoSrc(post, m)
|
||||
if (remoteUrl) {
|
||||
window.open(remoteUrl, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
|
||||
// Last-resort: open raw CDN url.
|
||||
const u = String(m?.url || '').trim()
|
||||
if (u) window.open(u, '_blank', 'noopener,noreferrer')
|
||||
openVideoPreview(post, m, idx)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
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 |
@@ -10,8 +10,13 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
from starlette.responses import FileResponse
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from . import __version__ as APP_VERSION
|
||||
from .logging_config import setup_logging, get_logger
|
||||
|
||||
# 初始化日志系统
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
from . import __version__ as APP_VERSION
|
||||
from .path_fix import PathFixRoute
|
||||
from .chat_realtime_autosync import CHAT_REALTIME_AUTOSYNC
|
||||
from .routers.chat import router as _chat_router
|
||||
@@ -30,10 +35,6 @@ from .routers.wrapped import router as _wrapped_router
|
||||
from .sns_stage_timing import add_sns_stage_timing_headers
|
||||
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
|
||||
|
||||
# 初始化日志系统
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
app = FastAPI(
|
||||
title="微信数据库解密工具",
|
||||
description="现代化的微信数据库解密工具,支持微信信息检测和数据库解密功能",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -53,9 +53,9 @@ class WeChatLogger:
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if not self._initialized:
|
||||
self.setup_logging()
|
||||
WeChatLogger._initialized = True
|
||||
# Lazy-init in `setup_logging()` / accessors to avoid double-initialization when
|
||||
# callers instantiate the manager and then call `setup_logging()` again.
|
||||
pass
|
||||
|
||||
def setup_logging(self, log_level: str = "INFO"):
|
||||
"""设置日志配置"""
|
||||
@@ -66,7 +66,9 @@ class WeChatLogger:
|
||||
|
||||
# 创建日志目录
|
||||
now = datetime.now()
|
||||
log_dir = Path("output/logs") / str(now.year) / f"{now.month:02d}" / f"{now.day:02d}"
|
||||
from .app_paths import get_output_dir
|
||||
|
||||
log_dir = get_output_dir() / "logs" / str(now.year) / f"{now.month:02d}" / f"{now.day:02d}"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 设置日志文件名
|
||||
@@ -77,6 +79,10 @@ class WeChatLogger:
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 配置日志格式
|
||||
# 文件格式(无颜色)
|
||||
@@ -109,22 +115,48 @@ class WeChatLogger:
|
||||
|
||||
# 只为uvicorn日志器添加文件处理器,保持其原有的控制台处理器(带颜色)
|
||||
uvicorn_logger = logging.getLogger("uvicorn")
|
||||
for handler in uvicorn_logger.handlers[:]:
|
||||
if isinstance(handler, logging.FileHandler):
|
||||
uvicorn_logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
uvicorn_logger.addHandler(file_handler)
|
||||
uvicorn_logger.setLevel(level)
|
||||
|
||||
# 只为uvicorn.access日志器添加文件处理器
|
||||
uvicorn_access_logger = logging.getLogger("uvicorn.access")
|
||||
for handler in uvicorn_access_logger.handlers[:]:
|
||||
if isinstance(handler, logging.FileHandler):
|
||||
uvicorn_access_logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
uvicorn_access_logger.addHandler(file_handler)
|
||||
uvicorn_access_logger.setLevel(level)
|
||||
|
||||
# 只为uvicorn.error日志器添加文件处理器
|
||||
uvicorn_error_logger = logging.getLogger("uvicorn.error")
|
||||
for handler in uvicorn_error_logger.handlers[:]:
|
||||
if isinstance(handler, logging.FileHandler):
|
||||
uvicorn_error_logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
uvicorn_error_logger.addHandler(file_handler)
|
||||
uvicorn_error_logger.setLevel(level)
|
||||
|
||||
# 配置FastAPI日志器
|
||||
fastapi_logger = logging.getLogger("fastapi")
|
||||
fastapi_logger.handlers = []
|
||||
for handler in fastapi_logger.handlers[:]:
|
||||
fastapi_logger.removeHandler(handler)
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
fastapi_logger.addHandler(file_handler)
|
||||
fastapi_logger.addHandler(console_handler)
|
||||
fastapi_logger.setLevel(level)
|
||||
@@ -136,6 +168,8 @@ class WeChatLogger:
|
||||
logger.info(f"日志文件: {self.log_file}")
|
||||
logger.info(f"日志级别: {logging.getLevelName(level)}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
WeChatLogger._initialized = True
|
||||
|
||||
return self.log_file
|
||||
|
||||
@@ -145,6 +179,8 @@ class WeChatLogger:
|
||||
|
||||
def get_log_file_path(self) -> Path:
|
||||
"""获取当前日志文件路径"""
|
||||
if not hasattr(self, "log_file"):
|
||||
self.setup_logging()
|
||||
return self.log_file
|
||||
|
||||
|
||||
@@ -157,10 +193,14 @@ def setup_logging(log_level: str = "INFO") -> Path:
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""获取日志器的便捷函数"""
|
||||
logger_manager = WeChatLogger()
|
||||
if not WeChatLogger._initialized:
|
||||
logger_manager.setup_logging()
|
||||
return logger_manager.get_logger(name)
|
||||
|
||||
|
||||
def get_log_file_path() -> Path:
|
||||
"""获取当前日志文件路径的便捷函数"""
|
||||
logger_manager = WeChatLogger()
|
||||
if not WeChatLogger._initialized:
|
||||
logger_manager.setup_logging()
|
||||
return logger_manager.get_log_file_path()
|
||||
|
||||
@@ -1964,6 +1964,114 @@ def _convert_silk_to_wav(silk_data: bytes) -> bytes:
|
||||
return silk_data
|
||||
|
||||
|
||||
def _looks_like_mp3(data: bytes) -> bool:
|
||||
if not data:
|
||||
return False
|
||||
if data.startswith(b"ID3"):
|
||||
return True
|
||||
return len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _find_ffmpeg_executable() -> str:
|
||||
import shutil
|
||||
|
||||
env_value = str(os.environ.get("WECHAT_TOOL_FFMPEG") or "").strip()
|
||||
if env_value:
|
||||
resolved = shutil.which(env_value)
|
||||
if resolved:
|
||||
return resolved
|
||||
candidate = Path(env_value).expanduser()
|
||||
if candidate.is_file():
|
||||
return str(candidate)
|
||||
|
||||
return shutil.which("ffmpeg") or ""
|
||||
|
||||
|
||||
def _convert_wav_to_mp3(wav_data: bytes) -> bytes:
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
if not wav_data or not wav_data.startswith(b"RIFF"):
|
||||
return b""
|
||||
|
||||
ffmpeg_exe = _find_ffmpeg_executable()
|
||||
if not ffmpeg_exe:
|
||||
return b""
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
tmp_path = Path(tmp_dir)
|
||||
wav_path = tmp_path / "voice.wav"
|
||||
mp3_path = tmp_path / "voice.mp3"
|
||||
wav_path.write_bytes(wav_data)
|
||||
|
||||
proc = subprocess.run(
|
||||
[
|
||||
ffmpeg_exe,
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(wav_path),
|
||||
"-vn",
|
||||
"-codec:a",
|
||||
"libmp3lame",
|
||||
"-q:a",
|
||||
"4",
|
||||
str(mp3_path),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
if proc.returncode != 0 or not mp3_path.exists():
|
||||
err = proc.stderr.decode("utf-8", errors="ignore").strip()
|
||||
if err:
|
||||
logger.warning(f"WAV to MP3 conversion failed: {err}")
|
||||
return b""
|
||||
|
||||
mp3_data = mp3_path.read_bytes()
|
||||
if _looks_like_mp3(mp3_data):
|
||||
return mp3_data
|
||||
except Exception as e:
|
||||
logger.warning(f"WAV to MP3 conversion failed: {e}")
|
||||
|
||||
return b""
|
||||
|
||||
|
||||
def _convert_silk_to_browser_audio(
|
||||
silk_data: bytes,
|
||||
*,
|
||||
preferred_format: str = "mp3",
|
||||
) -> tuple[bytes, str, str]:
|
||||
"""Convert SILK audio to a browser-friendly format.
|
||||
|
||||
Returns `(payload, ext, media_type)`.
|
||||
Preference order:
|
||||
1) MP3 if ffmpeg is available
|
||||
2) WAV if SILK decoding succeeds
|
||||
3) original SILK bytes as a last-resort fallback
|
||||
"""
|
||||
|
||||
data = bytes(silk_data or b"")
|
||||
if not data:
|
||||
return b"", "silk", "audio/silk"
|
||||
|
||||
if _looks_like_mp3(data):
|
||||
return data, "mp3", "audio/mpeg"
|
||||
|
||||
wav_data = data if data.startswith(b"RIFF") else _convert_silk_to_wav(data)
|
||||
if wav_data.startswith(b"RIFF"):
|
||||
if str(preferred_format or "").strip().lower() == "mp3":
|
||||
mp3_data = _convert_wav_to_mp3(wav_data)
|
||||
if mp3_data:
|
||||
return mp3_data, "mp3", "audio/mpeg"
|
||||
return wav_data, "wav", "audio/wav"
|
||||
|
||||
return data, "silk", "audio/silk"
|
||||
|
||||
|
||||
def _resolve_media_path_for_kind(
|
||||
account_dir: Path,
|
||||
kind: str,
|
||||
|
||||
@@ -50,6 +50,7 @@ from ..chat_helpers import (
|
||||
_lookup_resource_md5,
|
||||
_normalize_xml_url,
|
||||
_parse_app_message,
|
||||
_parse_location_message,
|
||||
_parse_system_message_content,
|
||||
_parse_pat_message,
|
||||
_pick_display_name,
|
||||
@@ -2673,6 +2674,10 @@ def _append_full_messages_from_rows(
|
||||
file_md5 = ""
|
||||
transfer_id = ""
|
||||
voip_type = ""
|
||||
location_lat: Optional[float] = None
|
||||
location_lng: Optional[float] = None
|
||||
location_poiname = ""
|
||||
location_label = ""
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
@@ -2883,6 +2888,14 @@ def _append_full_messages_from_rows(
|
||||
create_time=create_time,
|
||||
)
|
||||
content_text = "[表情]"
|
||||
elif local_type == 48:
|
||||
parsed = _parse_location_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "location")
|
||||
content_text = str(parsed.get("content") or "[Location]")
|
||||
location_lat = parsed.get("locationLat")
|
||||
location_lng = parsed.get("locationLng")
|
||||
location_poiname = str(parsed.get("locationPoiname") or "")
|
||||
location_label = str(parsed.get("locationLabel") or "")
|
||||
elif local_type == 50:
|
||||
render_type = "voip"
|
||||
try:
|
||||
@@ -2929,10 +2942,15 @@ def _append_full_messages_from_rows(
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
from_name = str(parsed.get("from") or from_name)
|
||||
from_username = str(parsed.get("fromUsername") or from_username)
|
||||
file_size = str(parsed.get("size") or file_size)
|
||||
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
||||
file_md5 = str(parsed.get("fileMd5") or file_md5)
|
||||
transfer_id = str(parsed.get("transferId") or transfer_id)
|
||||
quote_username = str(parsed.get("quoteUsername") or quote_username)
|
||||
quote_server_id = str(parsed.get("quoteServerId") or quote_server_id)
|
||||
quote_type = str(parsed.get("quoteType") or quote_type)
|
||||
quote_voice_length = str(parsed.get("quoteVoiceLength") or quote_voice_length)
|
||||
|
||||
if render_type == "transfer":
|
||||
# 如果 transferId 仍为空,尝试从原始 XML 提取
|
||||
@@ -3009,6 +3027,10 @@ def _append_full_messages_from_rows(
|
||||
"paySubType": pay_sub_type,
|
||||
"transferStatus": transfer_status,
|
||||
"transferId": transfer_id,
|
||||
"locationLat": location_lat,
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
"_rawText": raw_text if local_type == 266287972401 else "",
|
||||
}
|
||||
)
|
||||
@@ -3734,8 +3756,19 @@ def list_chat_sessions(
|
||||
except Exception:
|
||||
last_previews = {}
|
||||
|
||||
def _is_generic_location_preview(value: Any) -> bool:
|
||||
text = re.sub(r"\s+", " ", str(value or "").strip()).strip()
|
||||
if not text:
|
||||
return False
|
||||
lowered = text.lower()
|
||||
return lowered in {"[location]", "[位置]"} or lowered.endswith(": [location]") or lowered.endswith(": [位置]")
|
||||
|
||||
if preview_mode in {"latest", "db"}:
|
||||
targets = usernames if preview_mode == "db" else [u for u in usernames if u and (u not in last_previews)]
|
||||
targets = (
|
||||
usernames
|
||||
if preview_mode == "db"
|
||||
else [u for u in usernames if u and ((u not in last_previews) or _is_generic_location_preview(last_previews.get(u)))]
|
||||
)
|
||||
if targets:
|
||||
legacy = _load_latest_message_previews(account_dir, targets)
|
||||
for u, v in legacy.items():
|
||||
@@ -3830,6 +3863,11 @@ def list_chat_sessions(
|
||||
last_msg_sub_type = 0
|
||||
if last_msg_type == 81604378673 or (last_msg_type == 49 and last_msg_sub_type == 19):
|
||||
last_message = "[聊天记录]"
|
||||
elif last_msg_type == 48:
|
||||
text = re.sub(r"\s+", " ", str(last_message or "").strip()).strip()
|
||||
text = re.sub(r"^\[location\]", "", text, flags=re.IGNORECASE).strip()
|
||||
text = re.sub(r"^\[位置\]", "", text).strip()
|
||||
last_message = f"[位置]{text}" if text else "[位置]"
|
||||
|
||||
last_message = _normalize_session_preview_text(
|
||||
last_message,
|
||||
@@ -4065,6 +4103,10 @@ def _collect_chat_messages(
|
||||
file_md5 = ""
|
||||
transfer_id = ""
|
||||
voip_type = ""
|
||||
location_lat: Optional[float] = None
|
||||
location_lng: Optional[float] = None
|
||||
location_poiname = ""
|
||||
location_label = ""
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
@@ -4251,6 +4293,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 +4339,7 @@ def _collect_chat_messages(
|
||||
title = str(parsed.get("title") or title)
|
||||
url = str(parsed.get("url") or url)
|
||||
from_name = str(parsed.get("from") or from_name)
|
||||
from_username = str(parsed.get("fromUsername") or from_username)
|
||||
record_item = str(parsed.get("recordItem") or record_item)
|
||||
quote_title = str(parsed.get("quoteTitle") or quote_title)
|
||||
quote_content = str(parsed.get("quoteContent") or quote_content)
|
||||
@@ -4302,6 +4353,10 @@ def _collect_chat_messages(
|
||||
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
||||
file_md5 = str(parsed.get("fileMd5") or file_md5)
|
||||
transfer_id = str(parsed.get("transferId") or transfer_id)
|
||||
quote_username = str(parsed.get("quoteUsername") or quote_username)
|
||||
quote_server_id = str(parsed.get("quoteServerId") or quote_server_id)
|
||||
quote_type = str(parsed.get("quoteType") or quote_type)
|
||||
quote_voice_length = str(parsed.get("quoteVoiceLength") or quote_voice_length)
|
||||
|
||||
if render_type == "transfer":
|
||||
# 如果 transferId 仍为空,尝试从原始 XML 提取
|
||||
@@ -4385,6 +4440,10 @@ def _collect_chat_messages(
|
||||
"paySubType": pay_sub_type,
|
||||
"transferStatus": transfer_status,
|
||||
"transferId": transfer_id,
|
||||
"locationLat": location_lat,
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
"_rawText": raw_text if local_type == 266287972401 else "",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -253,7 +253,9 @@ def _ensure_initialized() -> None:
|
||||
return
|
||||
rc = int(lib.wcdb_init())
|
||||
if rc != 0:
|
||||
raise WCDBRealtimeError(f"wcdb_init failed: {rc}")
|
||||
logs = get_native_logs(require_initialized=False)
|
||||
hint = f" logs={logs[:6]}" if logs else ""
|
||||
raise WCDBRealtimeError(f"wcdb_init failed: {rc}.{hint}")
|
||||
_initialized = True
|
||||
|
||||
|
||||
@@ -315,11 +317,12 @@ def _call_out_error(fn, *args) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def get_native_logs() -> list[str]:
|
||||
try:
|
||||
_ensure_initialized()
|
||||
except Exception:
|
||||
return []
|
||||
def get_native_logs(*, require_initialized: bool = True) -> list[str]:
|
||||
if require_initialized:
|
||||
try:
|
||||
_ensure_initialized()
|
||||
except Exception:
|
||||
return []
|
||||
lib = _load_wcdb_lib()
|
||||
out = ctypes.c_char_p()
|
||||
rc = int(lib.wcdb_get_logs(ctypes.byref(out)))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
@@ -215,6 +216,7 @@ class TestChatExportHtmlPaging(unittest.TestCase):
|
||||
page1_text = zf.read(page1_js).decode("utf-8", errors="ignore")
|
||||
self.assertIn("MSG0001", page1_text)
|
||||
finally:
|
||||
logging.shutdown()
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
|
||||
@@ -132,6 +132,16 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
|
||||
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA[“测试好友”撤回了一条消息]]></replacemsg></revokemsg></sysmsg>',
|
||||
None,
|
||||
),
|
||||
(
|
||||
7,
|
||||
1007,
|
||||
48,
|
||||
7,
|
||||
2,
|
||||
1735689607,
|
||||
'<msg><location x="39.9042" y="116.4074" scale="15" label="北京市东城区东华门街道" poiname="天安门" /></msg>',
|
||||
None,
|
||||
),
|
||||
]
|
||||
conn.executemany(
|
||||
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
@@ -357,6 +367,41 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_checked_location_exports_location_fields(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(
|
||||
svc.CHAT_EXPORT_MANAGER,
|
||||
account=account,
|
||||
username=username,
|
||||
message_types=["location"],
|
||||
include_media=False,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, manifest, _ = self._load_export_payload(job.zip_path)
|
||||
location_msg = next((m for m in payload.get("messages", []) if int(m.get("type") or 0) == 48), None)
|
||||
self.assertIsNotNone(location_msg)
|
||||
self.assertEqual(str(location_msg.get("renderType") or ""), "location")
|
||||
self.assertEqual(str(location_msg.get("locationPoiname") or ""), "天安门")
|
||||
self.assertEqual(str(location_msg.get("locationLabel") or ""), "北京市东城区东华门街道")
|
||||
self.assertAlmostEqual(float(location_msg.get("locationLat") or 0), 39.9042, places=4)
|
||||
self.assertAlmostEqual(float(location_msg.get("locationLng") or 0), 116.4074, places=4)
|
||||
self.assertEqual(manifest.get("filters", {}).get("messageTypes"), ["location"])
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_privacy_mode_never_exports_media(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
def _close_logging_handlers() -> None:
|
||||
# Close handlers to avoid Windows temp dir cleanup failures (FileHandler holds a lock).
|
||||
import logging
|
||||
|
||||
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
|
||||
lg = logging.getLogger(logger_name)
|
||||
for h in lg.handlers[:]:
|
||||
try:
|
||||
h.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
lg.removeHandler(h)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class TestLoggingConfigDataDir(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
self._td = TemporaryDirectory()
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = self._td.name
|
||||
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.logging_config as logging_config
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(logging_config)
|
||||
|
||||
self.logging_config = logging_config
|
||||
|
||||
def tearDown(self):
|
||||
_close_logging_handlers()
|
||||
|
||||
if self._prev_data_dir is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = self._prev_data_dir
|
||||
self._td.cleanup()
|
||||
|
||||
def test_setup_logging_uses_wechat_tool_data_dir(self):
|
||||
log_file = self.logging_config.setup_logging()
|
||||
|
||||
base = Path(self._td.name) / "output" / "logs"
|
||||
self.assertTrue(log_file.is_relative_to(base))
|
||||
self.assertTrue(log_file.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -10,6 +10,34 @@ from wechat_decrypt_tool.chat_helpers import _parse_app_message
|
||||
|
||||
|
||||
class TestParseAppMessage(unittest.TestCase):
|
||||
def test_mini_program_type_33_parses_as_link(self):
|
||||
# 小程序分享是 appmsg type=33/36。部分 payload 会在 <weappinfo> 内嵌一个 <type>0</type>,
|
||||
# 并且出现在外层 <type>33</type> 之前,因此解析必须避免被嵌套 <type> 误导。
|
||||
raw_text = (
|
||||
"<msg><appmsg appid='' sdkver='0'>"
|
||||
"<title>锦城苑房源详情分享给你,点击查看哦~</title>"
|
||||
"<des></des>"
|
||||
"<weappinfo>"
|
||||
"<type>0</type>"
|
||||
"<username><![CDATA[gh_xxx@app]]></username>"
|
||||
"<weappiconurl><![CDATA[https://example.com/icon.png]]></weappiconurl>"
|
||||
"</weappinfo>"
|
||||
"<type>33</type>"
|
||||
"<url></url>"
|
||||
"<thumburl>https://example.com/thumb.jpg</thumburl>"
|
||||
"<sourcedisplayname><![CDATA[成都购房通]]></sourcedisplayname>"
|
||||
"</appmsg></msg>"
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "link")
|
||||
self.assertEqual(parsed.get("linkType"), "mini_program")
|
||||
self.assertEqual(parsed.get("title"), "锦城苑房源详情分享给你,点击查看哦~")
|
||||
self.assertEqual(parsed.get("from"), "成都购房通")
|
||||
self.assertEqual(parsed.get("fromUsername"), "gh_xxx@app")
|
||||
self.assertEqual(parsed.get("thumbUrl"), "https://example.com/thumb.jpg")
|
||||
|
||||
def test_quote_type_57_nested_refermsg_uses_inner_title(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
|
||||
Reference in New Issue
Block a user