Compare commits

...

14 Commits

60 changed files with 14555 additions and 14226 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
"version": "1.3.0",
"main": "src/main.cjs",
"scripts": {
"dev": "concurrently -k -s first \"cd ..\\\\frontend && npm run dev\" \"cross-env ELECTRON_START_URL=http://localhost:3000 electron .\"",
"dev": "node scripts/dev.cjs",
"dev:static": "pushd ..\\\\frontend && npm run generate && popd && cross-env ELECTRON_START_URL=http://127.0.0.1:10392 electron .",
"build:ui": "pushd ..\\\\frontend && npm run generate && popd && node scripts\\\\copy-ui.cjs",
"build:backend": "uv sync --extra build && node scripts/build-backend.cjs",
+179
View File
@@ -0,0 +1,179 @@
const http = require("http");
const net = require("net");
const path = require("path");
const { spawn, spawnSync } = require("child_process");
const repoRoot = path.resolve(__dirname, "..", "..");
const frontendDir = path.join(repoRoot, "frontend");
const desktopDir = path.join(repoRoot, "desktop");
function parsePort(value) {
const n = Number.parseInt(String(value || "").trim(), 10);
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
}
function log(message) {
process.stdout.write(`[dev] ${message}\n`);
}
function prefixPipe(stream, prefix) {
if (!stream) return;
let pending = "";
stream.setEncoding("utf8");
stream.on("data", (chunk) => {
pending += chunk;
const lines = pending.split(/\r?\n/);
pending = lines.pop() || "";
for (const line of lines) {
process.stdout.write(`${prefix} ${line}\n`);
}
});
stream.on("end", () => {
const tail = pending.trim();
if (tail) process.stdout.write(`${prefix} ${tail}\n`);
});
}
function isPortAvailable(port, host) {
return new Promise((resolve) => {
const server = net.createServer();
const done = (ok) => {
try {
server.close();
} catch {}
resolve(ok);
};
server.once("error", () => done(false));
server.once("listening", () => done(true));
server.listen(port, host);
});
}
async function choosePort({ label, envName, preferredPort, host, searchLimit = 20 }) {
if (preferredPort != null) {
const ok = await isPortAvailable(preferredPort, host);
if (!ok) throw new Error(`${label}端口 ${preferredPort} 已被占用,请修改环境变量 ${envName}`);
return preferredPort;
}
const startPort = envName === "NUXT_PORT" ? 3000 : 10392;
for (let port = startPort; port <= startPort + searchLimit; port += 1) {
if (await isPortAvailable(port, host)) return port;
}
throw new Error(`未找到可用的${label}端口(起始 ${startPort}`);
}
function httpReady(url) {
return new Promise((resolve) => {
const req = http.get(url, (res) => {
res.resume();
resolve(true);
});
req.on("error", () => resolve(false));
req.setTimeout(1000, () => {
req.destroy();
resolve(false);
});
});
}
async function waitForUrl(url, child, timeoutMs) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (child.exitCode != null) {
throw new Error(`前端进程提前退出,exitCode=${child.exitCode}`);
}
if (await httpReady(url)) return;
await new Promise((resolve) => setTimeout(resolve, 300));
}
throw new Error(`等待前端启动超时:${url}`);
}
function killChild(child) {
if (!child || child.killed || child.exitCode != null) return;
if (process.platform === "win32") {
spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
return;
}
try {
child.kill("SIGTERM");
} catch {}
}
function spawnLogged(command, args, options, prefix) {
const child = spawn(command, args, {
...options,
shell: process.platform === "win32",
stdio: ["inherit", "pipe", "pipe"],
});
prefixPipe(child.stdout, `${prefix}`);
prefixPipe(child.stderr, `${prefix}`);
return child;
}
async function main() {
const frontendHost = String(process.env.NUXT_HOST || "127.0.0.1").trim() || "127.0.0.1";
const requestedFrontendPort = parsePort(process.env.NUXT_PORT);
const requestedBackendPort = parsePort(process.env.WECHAT_TOOL_PORT);
const frontendPort = await choosePort({
label: "前端",
envName: "NUXT_PORT",
preferredPort: requestedFrontendPort,
host: frontendHost,
});
const backendPort = await choosePort({
label: "后端",
envName: "WECHAT_TOOL_PORT",
preferredPort: requestedBackendPort,
host: "127.0.0.1",
});
const startUrl = `http://${frontendHost}:${frontendPort}`;
log(`frontend=${startUrl}`);
log(`backend=http://127.0.0.1:${backendPort}/api`);
const sharedEnv = {
...process.env,
NUXT_HOST: frontendHost,
NUXT_PORT: String(frontendPort),
WECHAT_TOOL_PORT: String(backendPort),
ELECTRON_START_URL: startUrl,
};
const npmCommand = "npm";
const electronCommand = "electron";
const children = new Set();
let shuttingDown = false;
const shutdown = (exitCode) => {
if (shuttingDown) return;
shuttingDown = true;
for (const child of children) killChild(child);
process.exitCode = exitCode;
};
process.on("SIGINT", () => shutdown(130));
process.on("SIGTERM", () => shutdown(143));
const frontend = spawnLogged(npmCommand, ["run", "dev"], { cwd: frontendDir, env: sharedEnv }, "[frontend]");
children.add(frontend);
frontend.once("exit", (code, signal) => {
log(`frontend exited code=${code} signal=${signal}`);
shutdown(code == null ? 1 : code);
});
await waitForUrl(startUrl, frontend, 60_000);
log("frontend is ready, starting Electron");
const electron = spawnLogged(electronCommand, ["."], { cwd: desktopDir, env: sharedEnv }, "[electron]");
children.add(electron);
electron.once("exit", (code, signal) => {
log(`electron exited code=${code} signal=${signal}`);
shutdown(code == null ? 0 : code);
});
}
main().catch((err) => {
process.stderr.write(`[dev] ${err?.stack || err}\n`);
process.exit(1);
});
+236
View File
@@ -7,6 +7,7 @@ const {
globalShortcut,
dialog,
shell,
session,
} = require("electron");
let autoUpdater = null;
let autoUpdaterLoadError = null;
@@ -82,6 +83,11 @@ function getBackendAccessHost() {
}
function getBackendPort() {
const envPort = parsePort(process.env.WECHAT_TOOL_PORT);
if (envPort != null) return envPort;
// In dev we intentionally ignore persisted packaged-app settings so the
// launcher can keep Electron, Nuxt devProxy and the backend child aligned.
if (!app.isPackaged) return DEFAULT_BACKEND_PORT;
const settingsPort = parsePort(loadDesktopSettings()?.backendPort);
return settingsPort ?? DEFAULT_BACKEND_PORT;
}
@@ -215,6 +221,161 @@ function getUserDataDir() {
return resolveDataDir();
}
function sanitizeAccountName(account) {
const name = String(account || "").trim();
if (!name) throw new Error("缺少账号参数");
if (name === "." || name === "..") throw new Error("账号参数非法");
if (name.includes("/") || name.includes("\\")) throw new Error("账号参数非法");
return name;
}
function listDecryptedAccountsOnDisk(databasesDir) {
try {
if (!fs.existsSync(databasesDir)) return [];
} catch {
return [];
}
let entries = [];
try {
entries = fs.readdirSync(databasesDir, { withFileTypes: true });
} catch {
return [];
}
const accounts = [];
for (const entry of entries) {
try {
if (!entry || !entry.isDirectory()) continue;
const accountDir = path.join(databasesDir, entry.name);
const hasSession = fs.existsSync(path.join(accountDir, "session.db"));
const hasContact = fs.existsSync(path.join(accountDir, "contact.db"));
if (hasSession && hasContact) accounts.push(String(entry.name || ""));
} catch {}
}
accounts.sort((a, b) => a.localeCompare(b));
return accounts;
}
function resolveAccountDirInOutput(account) {
const dataDir = resolveDataDir();
if (!dataDir) throw new Error("无法定位数据目录");
const outputDir = path.join(dataDir, "output");
const databasesDir = path.join(outputDir, "databases");
const accountName = sanitizeAccountName(account);
const base = path.resolve(databasesDir);
const accountDir = path.resolve(path.join(databasesDir, accountName));
if (accountDir !== base && !accountDir.startsWith(base + path.sep)) {
throw new Error("账号路径非法");
}
return {
dataDir,
outputDir,
databasesDir,
accountName,
accountDir,
};
}
function getAccountInfoFromDisk(account) {
const { accountName, accountDir } = resolveAccountDirInOutput(account);
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
throw new Error("账号数据不存在");
}
let entries = [];
try {
entries = fs.readdirSync(accountDir, { withFileTypes: true });
} catch {}
const dbFiles = entries
.filter((e) => !!e && e.isFile() && String(e.name || "").toLowerCase().endsWith(".db"))
.map((e) => String(e.name || ""))
.sort((a, b) => a.localeCompare(b));
let sessionUpdatedAt = 0;
try {
const st = fs.statSync(path.join(accountDir, "session.db"));
sessionUpdatedAt = Math.floor(Number(st?.mtimeMs || 0) / 1000);
} catch {}
return {
status: "success",
account: accountName,
path: accountDir,
database_count: dbFiles.length,
databases: dbFiles,
session_updated_at: sessionUpdatedAt,
};
}
function removeAccountFromKeyStore(dataDir, accountName) {
const keyStorePath = path.join(dataDir, "output", "account_keys.json");
try {
if (!fs.existsSync(keyStorePath)) return false;
const raw = fs.readFileSync(keyStorePath, { encoding: "utf8" });
const parsed = JSON.parse(raw || "{}");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
if (!Object.prototype.hasOwnProperty.call(parsed, accountName)) return false;
delete parsed[accountName];
fs.writeFileSync(keyStorePath, JSON.stringify(parsed, null, 2), { encoding: "utf8" });
return true;
} catch {
return false;
}
}
async function deleteAccountDataFromDisk(account) {
const { dataDir, outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
throw new Error("账号数据不存在");
}
const wasBackendRunning = !!backendProc;
let restartError = null;
let result = null;
if (wasBackendRunning) {
await stopBackendAndWait({ timeoutMs: 10_000 });
}
try {
const exportsDir = path.join(outputDir, "exports", accountName);
try {
fs.rmSync(exportsDir, { recursive: true, force: true });
} catch {}
fs.rmSync(accountDir, { recursive: true, force: true });
const removedKeyCache = removeAccountFromKeyStore(dataDir, accountName);
const accounts = listDecryptedAccountsOnDisk(databasesDir);
result = {
status: "success",
deleted_account: accountName,
accounts,
default_account: accounts.length ? accounts[0] : null,
removed_key_cache: removedKeyCache,
};
} finally {
if (wasBackendRunning) {
try {
startBackend();
await waitForBackend({ timeoutMs: 30_000 });
} catch (err) {
restartError = err;
logMain(`[main] failed to restart backend after deleteAccountData: ${err?.message || err}`);
}
}
}
if (restartError) {
throw new Error(`删除完成,但后端重启失败:${restartError?.message || restartError}`);
}
if (!result) throw new Error("删除账号数据失败");
return result;
}
function getExeDir() {
try {
return path.dirname(process.execPath);
@@ -310,6 +471,34 @@ function getDesktopSettingsPath() {
return path.join(dir, "desktop-settings.json");
}
function getPackagedUiDir() {
if (!app.isPackaged) return null;
try {
return path.join(process.resourcesPath, "ui");
} catch {
return null;
}
}
function readPackagedUiBuildId() {
const uiDir = getPackagedUiDir();
if (!uiDir) return "";
try {
const indexPath = path.join(uiDir, "index.html");
if (!fs.existsSync(indexPath)) return "";
const html = fs.readFileSync(indexPath, { encoding: "utf8" });
const match =
html.match(/buildId:"([^"]+)"/) ||
html.match(/\/_payload\.json\?([^"'&<>\s]+)/) ||
html.match(/data-src="\/_payload\.json\?([^"]+)"/);
return String(match?.[1] || "").trim();
} catch (err) {
logMain(`[main] failed to read packaged UI build id: ${err?.message || err}`);
return "";
}
}
function loadDesktopSettings() {
if (desktopSettings) return desktopSettings;
@@ -321,6 +510,9 @@ function loadDesktopSettings() {
ignoredUpdateVersion: "",
// Backend (FastAPI) listens on this port. Used in packaged builds.
backendPort: DEFAULT_BACKEND_PORT,
// Tracks the packaged UI build so we can invalidate Chromium's HTTP cache
// after upgrades without wiping user data/localStorage.
lastSeenUiBuildId: "",
};
const p = getDesktopSettingsPath();
@@ -384,6 +576,33 @@ function setIgnoredUpdateVersion(version) {
return desktopSettings.ignoredUpdateVersion;
}
async function refreshRendererCacheForPackagedUi() {
if (!app.isPackaged) return;
const nextBuildId = readPackagedUiBuildId();
if (!nextBuildId) return;
const prevBuildId = String(loadDesktopSettings()?.lastSeenUiBuildId || "").trim();
if (prevBuildId === nextBuildId) return;
try {
const ses = session?.defaultSession;
if (ses) {
await ses.clearCache();
try {
await ses.clearStorageData({ storages: ["serviceworkers"] });
} catch {}
}
logMain(`[main] cleared renderer cache for UI build change: ${prevBuildId || "(none)"} -> ${nextBuildId}`);
} catch (err) {
logMain(`[main] failed to clear renderer cache for UI build change: ${err?.message || err}`);
}
loadDesktopSettings();
desktopSettings.lastSeenUiBuildId = nextBuildId;
persistDesktopSettings();
}
function parseEnvBool(value) {
if (value == null) return null;
const v = String(value).trim().toLowerCase();
@@ -1384,6 +1603,22 @@ function registerWindowIpc() {
}
});
ipcMain.handle("app:getAccountInfo", async (_event, account) => {
try {
return getAccountInfoFromDisk(account);
} catch (e) {
throw new Error(e?.message || String(e));
}
});
ipcMain.handle("app:deleteAccountData", async (_event, account) => {
try {
return await deleteAccountDataFromDisk(account);
} catch (e) {
throw new Error(e?.message || String(e));
}
});
ipcMain.handle("app:checkForUpdates", async () => {
return await checkForUpdatesInternal();
});
@@ -1443,6 +1678,7 @@ function registerWindowIpc() {
async function main() {
await app.whenReady();
await refreshRendererCacheForPackagedUi();
Menu.setApplicationMenu(null);
registerWindowIpc();
registerDebugShortcuts();
+2
View File
@@ -22,6 +22,8 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
// Data/output folder helpers
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")),
deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")),
// Auto update
getVersion: () => ipcRenderer.invoke("app:getVersion"),
File diff suppressed because it is too large Load Diff
+256 -160
View File
@@ -63,186 +63,222 @@
</div>
<section ref="desktopSectionRef">
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">桌面行为</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">开机自启动</div>
<div class="mt-0.5 text-[11px] text-[#909090]">系统登录后自动启动桌面端应用</div>
</div>
<button
type="button"
role="switch"
:aria-checked="desktopAutoLaunch"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopAutoLaunch, !isDesktopEnv || desktopAutoLaunchLoading)"
:disabled="!isDesktopEnv || desktopAutoLaunchLoading"
@click="toggleDesktopAutoLaunch"
>
<span class="settings-switch-thumb" :class="desktopAutoLaunch ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
<div v-if="desktopAutoLaunchError" class="text-xs text-red-600 whitespace-pre-wrap -mt-2">
{{ desktopAutoLaunchError }}
</div>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">关闭窗口行为</div>
<div class="mt-0.5 text-[11px] text-[#909090]">点击关闭按钮时默认最小化到托盘</div>
</div>
<select
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="!isDesktopEnv || desktopCloseBehaviorLoading"
:value="desktopCloseBehavior"
@change="onDesktopCloseBehaviorChange"
>
<option value="tray">最小化到托盘</option>
<option value="exit">直接退出</option>
</select>
</div>
<div v-if="desktopCloseBehaviorError" class="text-xs text-red-600 whitespace-pre-wrap -mt-2">
{{ desktopCloseBehaviorError }}
</div>
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">后端端口</div>
<div class="mt-0.5 text-[11px] text-[#909090]">桌面端重启内置后端并刷新网页端尝试切换端口</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<input
v-model="desktopBackendPortInput"
type="number"
min="1"
max="65535"
class="w-16 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-center text-[12px] tabular-nums text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
@keyup.enter="onDesktopBackendPortApply"
/>
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">桌面行为</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
<div class="px-3.5 py-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">开机自启动</div>
<div class="mt-0.5 text-[11px] text-[#909090]">系统登录后自动启动桌面端应用</div>
</div>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
@click="onDesktopBackendPortApply"
role="switch"
:aria-checked="desktopAutoLaunch"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopAutoLaunch, !isDesktopEnv || desktopAutoLaunchLoading)"
:disabled="!isDesktopEnv || desktopAutoLaunchLoading"
@click="toggleDesktopAutoLaunch"
>
{{ desktopBackendPortApplying ? '...' : '应用' }}
</button>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
@click="onDesktopBackendPortReset"
>
恢复默认
<span class="settings-switch-thumb" :class="desktopAutoLaunch ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
</div>
<div v-if="desktopBackendPortError" class="text-xs text-red-600 whitespace-pre-wrap -mt-1.5">
{{ desktopBackendPortError }}
<div v-if="desktopAutoLaunchError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopAutoLaunchError }}
</div>
</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 class="px-3.5 py-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">关闭窗口行为</div>
<div class="mt-0.5 text-[11px] text-[#909090]">点击关闭按钮时默认最小化到托盘</div>
</div>
<select
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="!isDesktopEnv || desktopCloseBehaviorLoading"
:value="desktopCloseBehavior"
@change="onDesktopCloseBehaviorChange"
>
<option value="tray">最小化到托盘</option>
<option value="exit">直接退出</option>
</select>
</div>
<div v-if="desktopCloseBehaviorError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopCloseBehaviorError }}
</div>
<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 class="px-3.5 py-3">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">后端端口</div>
<div class="mt-0.5 text-[11px] text-[#909090]">桌面端重启内置后端并刷新网页端尝试切换端口</div>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<input
v-model="desktopBackendPortInput"
type="number"
min="1"
max="65535"
class="w-16 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-center text-[12px] tabular-nums text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
@keyup.enter="onDesktopBackendPortApply"
/>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
@click="onDesktopBackendPortApply"
>
{{ desktopBackendPortApplying ? '...' : '应用' }}
</button>
<button
type="button"
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
@click="onDesktopBackendPortReset"
>
恢复默认
</button>
</div>
</div>
<div v-if="desktopBackendPortError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopBackendPortError }}
</div>
</div>
<div class="px-3.5 py-3">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopOutputDirText }}</div>
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopOutputDirLoading"
@click="onDesktopOpenOutputDir"
>
打开 output
</button>
</div>
<div v-if="desktopOutputDirError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopOutputDirError }}
</div>
</div>
<div class="px-3.5 py-3">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">日志文件</div>
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopLogFileText }}</div>
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="desktopLogFileLoading || desktopLogFileOpening"
@click="onOpenBackendLogFile"
>
{{ desktopLogFileOpening ? '打开中...' : '打开日志' }}
</button>
</div>
<div v-if="desktopLogFileError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
{{ desktopLogFileError }}
</div>
</div>
</div>
</section>
<section ref="startupSectionRef">
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">启动偏好</div>
<div class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">启动后自动开启实时获取</div>
<div class="mt-0.5 text-[11px] text-[#909090]">进入聊天页后自动打开实时开关</div>
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">启动偏好</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
<div class="px-3.5 py-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">启动后自动开启实时获取</div>
<div class="mt-0.5 text-[11px] text-[#909090]">进入聊天页后自动打开实时开关</div>
</div>
<button
type="button"
role="switch"
:aria-checked="desktopAutoRealtime"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopAutoRealtime)"
@click="toggleDesktopAutoRealtime"
>
<span class="settings-switch-thumb" :class="desktopAutoRealtime ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
<button
type="button"
role="switch"
:aria-checked="desktopAutoRealtime"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopAutoRealtime)"
@click="toggleDesktopAutoRealtime"
>
<span class="settings-switch-thumb" :class="desktopAutoRealtime ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">有数据时默认进入聊天页</div>
<div class="mt-0.5 text-[11px] text-[#909090]">已解密账号时打开应用跳转到 /chat</div>
<div class="px-3.5 py-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">数据时默认进入聊天页</div>
<div class="mt-0.5 text-[11px] text-[#909090]">有已解密账号时打开应用跳转到 /chat</div>
</div>
<button
type="button"
role="switch"
:aria-checked="desktopDefaultToChatWhenData"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopDefaultToChatWhenData)"
@click="toggleDesktopDefaultToChat"
>
<span class="settings-switch-thumb" :class="desktopDefaultToChatWhenData ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
<button
type="button"
role="switch"
:aria-checked="desktopDefaultToChatWhenData"
class="settings-switch shrink-0"
:class="switchTrackClass(desktopDefaultToChatWhenData)"
@click="toggleDesktopDefaultToChat"
>
<span class="settings-switch-thumb" :class="desktopDefaultToChatWhenData ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
</div>
</section>
<section ref="updatesSectionRef">
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">更新</div>
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">当前版本</div>
<div class="mt-0.5 text-[11px] text-[#909090]">{{ desktopVersionText }}</div>
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">更新</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
<div class="px-3.5 py-3">
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">当前版本</div>
<div class="mt-0.5 text-[11px] text-[#909090]">{{ desktopVersionText }}</div>
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-[#fafafa] px-2.5 py-1 text-[12px] text-[#222] transition hover:bg-[#f0f0f0] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopUpdate.manualCheckLoading.value"
@click="onDesktopCheckUpdates"
>
{{ desktopUpdate.manualCheckLoading.value ? '检查中...' : '检查桌面版更新' }}
</button>
</div>
<div v-if="desktopUpdate.lastCheckMessage.value" class="mt-2 rounded-[6px] bg-[#f9f9f9] border border-[#eee] px-2.5 py-1.5 text-[11px] text-[#666] whitespace-pre-wrap break-words">
{{ desktopUpdate.lastCheckMessage.value }}
</div>
</div>
<button
type="button"
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-[#fafafa] px-2.5 py-1 text-[12px] text-[#222] transition hover:bg-[#f0f0f0] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isDesktopEnv || desktopUpdate.manualCheckLoading.value"
@click="onDesktopCheckUpdates"
>
{{ desktopUpdate.manualCheckLoading.value ? '检查中...' : '检查桌面版更新' }}
</button>
</div>
<div v-if="desktopUpdate.lastCheckMessage.value" class="mt-2 rounded-[6px] bg-[#f9f9f9] border border-[#eee] px-2.5 py-1.5 text-[11px] text-[#666] whitespace-pre-wrap break-words">
{{ desktopUpdate.lastCheckMessage.value }}
</div>
</section>
<section ref="snsSectionRef">
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">朋友圈</div>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">朋友圈图片使用缓存</div>
<div class="mt-0.5 text-[11px] text-[#909090]">开启下载解密失败时回退本地缓存默认关闭始终重新下载</div>
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">朋友圈</div>
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
<div class="px-3.5 py-3">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="text-[13px] font-medium text-[#222]">朋友圈图片使用缓存</div>
<div class="mt-0.5 text-[11px] text-[#909090]">开启下载解密失败时回退本地缓存默认关闭始终重新下载</div>
</div>
<button
type="button"
role="switch"
:aria-checked="snsUseCache"
class="settings-switch shrink-0"
:class="switchTrackClass(snsUseCache)"
@click="toggleSnsUseCache"
>
<span class="settings-switch-thumb" :class="snsUseCache ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
</div>
<button
type="button"
role="switch"
:aria-checked="snsUseCache"
class="settings-switch shrink-0"
:class="switchTrackClass(snsUseCache)"
@click="toggleSnsUseCache"
>
<span class="settings-switch-thumb" :class="snsUseCache ? 'translate-x-[20px]' : 'translate-x-0'" />
</button>
</div>
</section>
@@ -253,10 +289,12 @@
</template>
<script setup>
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
import { readApiBaseOverride, writeApiBaseOverride } from '~/utils/api-settings'
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/lib/desktop-settings'
import { readApiBaseOverride, writeApiBaseOverride } from '~/lib/api-settings'
import { invalidateApiBaseCache } from '~/composables/useApiBase'
import { reportServerErrorFromError } from '~/lib/server-error-logging'
defineProps({
const props = defineProps({
open: {
type: Boolean,
default: false,
@@ -315,6 +353,15 @@ const desktopOutputDirText = computed(() => {
return v || '—'
})
const desktopLogFilePath = ref('')
const desktopLogFileLoading = ref(false)
const desktopLogFileOpening = ref(false)
const desktopLogFileError = ref('')
const desktopLogFileText = computed(() => {
const v = String(desktopLogFilePath.value || '').trim()
return v || '—'
})
const switchTrackClass = (enabled, disabled = false) => {
if (disabled) return enabled ? 'bg-[#07b75b] opacity-50 cursor-not-allowed' : 'bg-[#d0d0d0] opacity-50 cursor-not-allowed'
return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95'
@@ -360,6 +407,24 @@ const onEscKeydown = (event) => {
handleClose()
}
const fetchAdminEndpoint = async (url, options = {}) => {
const apiBase = useApiBase()
try {
return await $fetch(url, {
baseURL: apiBase,
...options,
})
} catch (e) {
await reportServerErrorFromError(e, {
method: options?.method || 'GET',
requestUrl: url,
source: 'SettingsDialog',
apiBase,
})
throw e
}
}
const refreshDesktopAutoLaunch = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getAutoLaunch) return
@@ -436,8 +501,7 @@ const refreshDesktopBackendPort = async () => {
}
try {
const apiBase = useApiBase()
const resp = await $fetch('/admin/port', { baseURL: apiBase })
const resp = await fetchAdminEndpoint('/admin/port')
const n = Number(resp?.port)
const d = Number(resp?.default_port)
if (Number.isInteger(d) && d >= 1 && d <= 65535) desktopBackendPortDefault.value = d
@@ -494,6 +558,34 @@ const onDesktopOpenOutputDir = async () => {
}
}
const refreshBackendLogFileInfo = async () => {
if (!process.client || typeof window === 'undefined') return
desktopLogFileLoading.value = true
desktopLogFileError.value = ''
try {
const resp = await fetchAdminEndpoint('/admin/log-file')
desktopLogFilePath.value = String(resp?.path || '').trim()
} catch (e) {
desktopLogFileError.value = e?.message || '读取日志文件失败'
} finally {
desktopLogFileLoading.value = false
}
}
const onOpenBackendLogFile = async () => {
if (!process.client || typeof window === 'undefined') return
desktopLogFileOpening.value = true
desktopLogFileError.value = ''
try {
const resp = await fetchAdminEndpoint('/admin/log-file/open', { method: 'POST' })
if (resp?.path) desktopLogFilePath.value = String(resp.path || '').trim()
} catch (e) {
desktopLogFileError.value = e?.message || '打开日志文件失败'
} finally {
desktopLogFileOpening.value = false
}
}
const applyDesktopBackendPort = async () => {
if (!process.client || typeof window === 'undefined') return
const raw = String(desktopBackendPortInput.value || '').trim()
@@ -510,10 +602,9 @@ const applyDesktopBackendPort = async () => {
return
}
const currentApiBase = useApiBase()
let currentBackendPort = null
try {
const info = await $fetch('/admin/port', { baseURL: currentApiBase })
const info = await fetchAdminEndpoint('/admin/port')
const p = Number(info?.port)
if (Number.isInteger(p) && p >= 1 && p <= 65535) currentBackendPort = p
} catch {}
@@ -524,8 +615,7 @@ const applyDesktopBackendPort = async () => {
})()
const isUiServedByBackend = !!(currentBackendPort && uiPort === currentBackendPort)
await $fetch('/admin/port', {
baseURL: currentApiBase,
await fetchAdminEndpoint('/admin/port', {
method: 'POST',
body: { port: n },
})
@@ -535,6 +625,7 @@ const applyDesktopBackendPort = async () => {
const host = String(window.location?.hostname || '').trim() || '127.0.0.1'
const nextOrigin = `${protocol}//${host}:${n}`
writeApiBaseOverride(`${nextOrigin}/api`)
invalidateApiBaseCache()
const waitForHealth = async (healthUrl, timeoutMs = 30_000) => {
const startedAt = Date.now()
@@ -608,6 +699,11 @@ const onDesktopCheckUpdates = async () => {
await desktopUpdate.manualCheck()
}
watch(() => props.open, async (isOpen) => {
if (!isOpen) return
await refreshBackendLogFileInfo()
}, { immediate: true })
onMounted(async () => {
if (process.client && typeof window !== 'undefined') {
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
+300 -17
View File
@@ -6,7 +6,12 @@
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
<!-- Avatar -->
<div class="w-full h-[60px] flex items-center justify-center">
<div class="w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
<button
type="button"
class="group relative w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0 ring-1 ring-transparent transition hover:ring-[#07b75b]/40"
title="账号信息"
@click="openAccountDialog"
>
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
<div
v-else
@@ -15,7 +20,7 @@
>
</div>
</div>
</button>
</div>
<!-- Chat -->
@@ -164,22 +169,116 @@
</div>
</div>
<!-- Settings -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="goSettings"
title="设置"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="settingsDialogOpen ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<div class="mt-auto">
<!-- Guide -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="引导页"
@click="goGuide"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#5d5d5d]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 10.5L12 3l9 7.5" />
<path d="M5 9.5V20h14V9.5" />
<path d="M10 20v-6h4v6" />
</svg>
</div>
</div>
<!-- Settings -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="goSettings"
title="设置"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="settingsDialogOpen ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
</div>
</div>
</div>
</div>
<div
v-if="accountDialogOpen"
class="fixed inset-0 z-[130] flex items-center justify-center bg-black/35 px-4"
@click.self="closeAccountDialog"
>
<div class="w-full max-w-[440px] overflow-hidden rounded-[12px] border border-[#e7e7e7] bg-white shadow-2xl">
<div class="flex items-center justify-between border-b border-[#efefef] px-4 py-3">
<div class="text-[14px] font-semibold text-[#222]">当前账号信息</div>
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-md text-[#888] transition hover:bg-[#f2f2f2] hover:text-[#222]"
title="关闭"
:disabled="accountDeleteLoading"
@click="closeAccountDialog"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="space-y-3 px-4 py-4">
<div v-if="accountInfoLoading" class="text-[12px] text-[#7a7a7a]">正在加载账号信息...</div>
<template v-else>
<div class="flex items-center gap-3">
<div class="w-[42px] h-[42px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
<div
v-else
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
:style="{ backgroundColor: '#4B5563' }"
>
</div>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-[14px] font-semibold text-[#222]">{{ selectedAccount || '未选择账号' }}</div>
<div class="mt-0.5 text-[11px] text-[#8a8a8a]">账号标识wxid</div>
</div>
</div>
<div class="rounded-[8px] border border-[#ededed] bg-[#fafafa] px-3 py-2 text-[12px] text-[#5f5f5f] space-y-1.5">
<div class="flex items-start justify-between gap-3">
<span class="text-[#8a8a8a] shrink-0">数据库数量</span>
<span class="font-medium text-[#333]">{{ accountInfo?.database_count ?? '—' }}</span>
</div>
<div class="flex items-start justify-between gap-3">
<span class="text-[#8a8a8a] shrink-0">数据目录</span>
<span class="break-all text-right text-[#444]">{{ accountInfo?.path || (selectedAccount ? `output/databases/${selectedAccount}` : '—') }}</span>
</div>
<div class="flex items-start justify-between gap-3">
<span class="text-[#8a8a8a] shrink-0">最近会话库更新时间</span>
<span class="text-[#444]">{{ sessionUpdatedAtText }}</span>
</div>
</div>
</template>
<div class="rounded-[8px] border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] leading-relaxed text-amber-900">
仅删除本项目中的该账号解析数据/缓存/编辑记录不会删除微信客户端中的任何聊天内容或账号数据
</div>
<button
type="button"
class="w-full rounded-[8px] border border-red-200 bg-red-50 px-3 py-2 text-[12px] font-medium text-red-700 transition hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!selectedAccount || accountDeleteLoading"
@click="deleteCurrentAccountData"
>
{{ accountDeleteLoading ? '删除中...' : '删除当前账号的项目数据' }}
</button>
<div class="text-[11px] text-[#8a8a8a]">删除成功后将自动返回引导页</div>
<div v-if="accountInfoError" class="text-[11px] text-red-600 whitespace-pre-wrap">{{ accountInfoError }}</div>
<div v-if="accountDeleteError" class="text-[11px] text-red-600 whitespace-pre-wrap">{{ accountDeleteError }}</div>
</div>
</div>
</div>
@@ -202,9 +301,130 @@ const { privacyMode } = storeToRefs(privacyStore)
const realtimeStore = useChatRealtimeStore()
const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realtimeChecking, statusError: realtimeStatusError, toggling: realtimeToggling } = storeToRefs(realtimeStore)
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
const { getChatAccountInfo, deleteChatAccount } = useApi()
const accountDialogOpen = ref(false)
const accountInfoLoading = ref(false)
const accountInfoError = ref('')
const accountInfo = ref(null)
const accountDeleteLoading = ref(false)
const accountDeleteError = ref('')
const accountInfoApiUnsupported = ref(false)
const deleteAccountApiUnsupported = ref(false)
const sessionUpdatedAtText = computed(() => {
const ts = Number(accountInfo.value?.session_updated_at || 0)
if (!Number.isFinite(ts) || ts <= 0) return '—'
try {
return new Date(ts * 1000).toLocaleString('zh-CN')
} catch {
return '—'
}
})
const isNotFoundError = (error) => {
const status = Number(
error?.statusCode
?? error?.status
?? error?.response?.status
?? error?.data?.statusCode
?? 0
)
return status === 404
}
const loadAccountInfoByDesktopBridge = async (account) => {
if (!process.client || typeof window === 'undefined') return null
if (!window.wechatDesktop?.getAccountInfo) return null
const res = await window.wechatDesktop.getAccountInfo(account)
return res && typeof res === 'object' ? res : null
}
const loadAccountInfo = async () => {
accountInfoLoading.value = true
accountInfoError.value = ''
const account = String(selectedAccount.value || '').trim()
if (!account) {
accountInfo.value = null
accountInfoLoading.value = false
return
}
try {
let lastError = null
if (!accountInfoApiUnsupported.value) {
try {
const res = await getChatAccountInfo({ account })
if (res?.status !== 'success') {
throw new Error(res?.message || '读取账号信息失败')
}
accountInfo.value = res
return
} catch (e) {
lastError = e
if (isNotFoundError(e)) {
accountInfoApiUnsupported.value = true
}
}
}
try {
const fallback = await loadAccountInfoByDesktopBridge(account)
if (fallback?.status === 'success') {
accountInfo.value = fallback
accountInfoError.value = ''
return
}
if (fallback && fallback?.status && fallback.status !== 'success') {
lastError = new Error(fallback?.message || '读取账号信息失败')
} else if (!lastError) {
lastError = new Error('读取账号信息失败')
}
} catch (fallbackErr) {
if (!lastError) {
lastError = fallbackErr
}
}
accountInfo.value = null
accountInfoError.value = lastError?.message || '读取账号信息失败'
} finally {
accountInfoLoading.value = false
}
}
const deleteAccountDataByDesktopBridge = async (account) => {
if (!process.client || typeof window === 'undefined') return null
if (!window.wechatDesktop?.deleteAccountData) return null
const res = await window.wechatDesktop.deleteAccountData(account)
return res && typeof res === 'object' ? res : { status: 'success' }
}
const openAccountDialog = async () => {
accountDialogOpen.value = true
accountDeleteError.value = ''
await loadAccountInfo()
}
const closeAccountDialog = () => {
if (accountDeleteLoading.value) return
accountDialogOpen.value = false
}
watch(selectedAccount, () => {
if (!accountDialogOpen.value) return
void loadAccountInfo()
})
onMounted(async () => {
await chatAccounts.ensureLoaded()
if (process.client && typeof window !== 'undefined') {
window.addEventListener('keydown', onWindowKeydown)
}
})
onBeforeUnmount(() => {
if (!process.client || typeof window === 'undefined') return
window.removeEventListener('keydown', onWindowKeydown)
})
const apiBase = useApiBase()
@@ -240,10 +460,73 @@ const goWrapped = async () => {
await navigateTo('/wrapped')
}
const goGuide = async () => {
await navigateTo('/')
}
const goSettings = () => {
openSettingsDialog()
}
const onWindowKeydown = (event) => {
if (event?.key !== 'Escape') return
if (!accountDialogOpen.value) return
event.preventDefault()
closeAccountDialog()
}
const deleteCurrentAccountData = async () => {
const account = String(selectedAccount.value || '').trim()
if (!account || accountDeleteLoading.value) return
if (process.client && typeof window !== 'undefined') {
const confirmed = window.confirm(
'将删除当前账号在本项目中的数据(解析缓存、编辑记录、导出缓存等),不会删除微信客户端内容。确认删除吗?'
)
if (!confirmed) return
}
accountDeleteLoading.value = true
accountDeleteError.value = ''
try {
let deleted = false
let lastError = null
if (!deleteAccountApiUnsupported.value) {
try {
const apiRes = await deleteChatAccount({ account })
if (apiRes?.status && apiRes.status !== 'success') {
throw new Error(apiRes?.message || '删除账号数据失败')
}
deleted = true
} catch (apiErr) {
lastError = apiErr
if (isNotFoundError(apiErr)) {
deleteAccountApiUnsupported.value = true
}
}
}
if (!deleted) {
const desktopRes = await deleteAccountDataByDesktopBridge(account)
if (!desktopRes) {
throw lastError || new Error('删除账号数据失败')
}
if (desktopRes?.status && desktopRes.status !== 'success') {
throw new Error(desktopRes?.message || '删除账号数据失败')
}
}
accountDialogOpen.value = false
await chatAccounts.ensureLoaded({ force: true })
await navigateTo('/')
} catch (e) {
accountDeleteError.value = e?.message || '删除账号数据失败'
} finally {
accountDeleteLoading.value = false
}
}
const realtimeBusy = computed(() => !!realtimeChecking.value || !!realtimeToggling.value)
const realtimeTitle = computed(() => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,116 @@
<template>
<div class="flex-1 flex flex-col min-h-0 min-w-0">
<div v-if="selectedContact" class="flex-1 flex flex-col min-h-0 relative">
<div class="chat-header">
<div class="flex items-center gap-3">
<h2 class="text-base font-medium text-gray-900" :class="{ 'privacy-blur': privacyMode }">
{{ selectedContact ? selectedContact.name : '' }}
</h2>
</div>
<div class="ml-auto flex items-center gap-2">
<button class="header-btn-icon" @click="refreshSelectedMessages" :disabled="isLoadingMessages" title="刷新消息">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
<button class="header-btn-icon" @click="openExportModal" :disabled="isExportCreating" title="导出聊天记录">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
</button>
<button class="header-btn-icon" :class="{ 'header-btn-icon-active': reverseMessageSides }" @click="toggleReverseMessageSides" :disabled="!selectedContact" :title="reverseMessageSides ? '取消反转消息位置' : '反转消息位置'">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 7h14" />
<path d="M14 3l4 4-4 4" />
<path d="M20 17H6" />
<path d="M10 13l-4 4 4 4" />
</svg>
</button>
<button class="header-btn-icon" :class="{ 'header-btn-icon-active': messageSearchOpen }" @click="toggleMessageSearch" :title="messageSearchOpen ? '关闭搜索 (Esc)' : '搜索聊天记录 (Ctrl+F)'">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 16 16">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 14L11.1 11.1" />
</svg>
</button>
<button class="header-btn-icon" :class="{ 'header-btn-icon-active': timeSidebarOpen }" @click="toggleTimeSidebar" :disabled="!selectedContact || isLoadingMessages" title="按日期定位">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M8 7V3m8 4V3M3 11h18" />
<rect x="4" y="5" width="16" height="16" rx="2" ry="2" stroke-width="1.8" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M7 14h2m3 0h2m3 0h2M7 18h2m3 0h2" />
</svg>
</button>
<select
v-model="messageTypeFilter"
class="message-filter-select"
:disabled="isLoadingMessages || searchContext.active"
:title="searchContext.active ? '上下文模式下暂不可筛选' : '筛选消息类型'"
>
<option v-for="opt in messageTypeFilterOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</div>
<div v-if="searchContext.active" class="px-6 py-2 border-b border-emerald-200 bg-emerald-50 flex items-center gap-3">
<div class="text-sm text-emerald-900">
{{ searchContextBannerText }}
</div>
<div class="ml-auto flex items-center gap-2">
<button type="button" class="text-xs px-3 py-1 rounded-md bg-white border border-emerald-200 hover:bg-emerald-100" @click="exitSearchContext">
退出定位
</button>
<button type="button" class="text-xs px-3 py-1 rounded-md bg-white border border-gray-200 hover:bg-gray-50" @click="refreshSelectedMessages">
返回最新
</button>
</div>
</div>
<MessageList :state="state" />
<button
v-if="showJumpToBottom"
type="button"
class="absolute bottom-6 right-6 z-20 w-10 h-10 rounded-full bg-white/90 border border-gray-200 shadow hover:bg-white flex items-center justify-center"
title="回到最新"
@click="scrollToBottom"
>
<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="w-20 h-20 mx-auto mb-5 rounded-2xl bg-gradient-to-br from-[#03C160]/10 to-[#03C160]/5 flex items-center justify-center">
<svg class="w-10 h-10 text-[#03C160]/60" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z"/>
</svg>
</div>
<h3 class="text-base font-medium text-gray-700 mb-1.5">选择一个会话</h3>
<p class="text-sm text-gray-400">
从左侧列表选择联系人查看聊天记录
</p>
</div>
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue'
import MessageList from '~/components/chat/MessageList.vue'
export default defineComponent({
name: 'ConversationPane',
components: { MessageList },
props: {
state: { type: Object, required: true }
},
setup(props) {
return {
...props.state
}
}
})
</script>
+33
View File
@@ -0,0 +1,33 @@
<template>
<img
v-if="iconUrl"
:src="iconUrl"
alt=""
class="wechat-file-icon"
/>
<svg v-else-if="kind === 'ppt'" viewBox="0 0 24 24" fill="none" class="wechat-file-icon text-orange-500">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" stroke="currentColor" stroke-width="1.5" fill="none" />
<path d="M14 2v6h6" stroke="currentColor" stroke-width="1.5" />
<text x="6" y="17" font-size="5" fill="currentColor" font-weight="bold">PPT</text>
</svg>
<svg v-else-if="kind === 'txt'" viewBox="0 0 24 24" fill="none" class="wechat-file-icon text-gray-500">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" stroke="currentColor" stroke-width="1.5" fill="none" />
<path d="M14 2v6h6" stroke="currentColor" stroke-width="1.5" />
<text x="6" y="17" font-size="5" fill="currentColor" font-weight="bold">TXT</text>
</svg>
<svg v-else viewBox="0 0 24 24" fill="currentColor" class="wechat-file-icon text-gray-400">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h6v6h6v10H6z" />
</svg>
</template>
<script setup>
import { computed } from 'vue'
import { getFileIconKind, getFileIconUrl } from '~/lib/chat/file-icons'
const props = defineProps({
fileName: { type: String, default: '' }
})
const kind = computed(() => getFileIconKind(props.fileName))
const iconUrl = computed(() => getFileIconUrl(props.fileName))
</script>
+285
View File
@@ -0,0 +1,285 @@
<script>
import { defineComponent, h, ref } from 'vue'
import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
export default defineComponent({
name: 'LinkCard',
props: {
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) {
const fromAvatarImgOk = ref(false)
const fromAvatarImgError = ref(false)
const lastFromAvatarUrl = ref('')
const getFromText = () => {
const raw = String(props.from || '').trim()
if (raw) return raw
try {
const href = String(props.href || '').trim()
if (!/^https?:\/\//i.test(href)) return ''
return String(new URL(href).hostname || '').trim()
} catch {
return ''
}
}
return () => {
const fromText = getFromText()
const href = String(props.href || '').trim()
const canNavigate = /^https?:\/\//i.test(href)
const badgeText = String(props.badge || '').trim()
const fromAvatarText = (() => {
const text = String(fromText || '').trim()
return text ? (Array.from(text)[0] || '') : ''
})()
const fromAvatarUrl = String(props.fromAvatar || '').trim()
const isMiniProgram = String(props.linkType || '').trim() === 'mini_program'
const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
const Tag = canNavigate ? 'a' : 'div'
if (fromAvatarUrl !== lastFromAvatarUrl.value) {
lastFromAvatarUrl.value = fromAvatarUrl
fromAvatarImgOk.value = false
fromAvatarImgError.value = false
}
const showFromAvatarImg = Boolean(fromAvatarUrl) && !fromAvatarImgError.value
const showFromAvatarText = (!fromAvatarUrl) || (!fromAvatarImgOk.value)
const fromAvatarStyle = fromAvatarImgOk.value
? {
background: isCoverVariant ? 'rgba(255, 255, 255, 0.92)' : '#fff',
color: 'transparent'
}
: null
const miniProgramAvatarStyle = fromAvatarImgOk.value
? {
background: '#fff',
color: 'transparent'
}
: null
const onFromAvatarLoad = () => {
fromAvatarImgOk.value = true
fromAvatarImgError.value = false
}
const onFromAvatarError = () => {
fromAvatarImgOk.value = false
fromAvatarImgError.value = true
}
if (isCoverVariant) {
const fromRow = h('div', { class: 'wechat-link-cover-from' }, [
h('div', { class: 'wechat-link-cover-from-avatar', style: fromAvatarStyle, 'aria-hidden': 'true' }, [
showFromAvatarText ? (fromAvatarText || '\u200B') : null,
showFromAvatarImg
? h('img', {
src: fromAvatarUrl,
alt: '',
class: 'wechat-link-cover-from-avatar-img',
referrerpolicy: 'no-referrer',
onLoad: onFromAvatarLoad,
onError: onFromAvatarError
})
: null
].filter(Boolean)),
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(
Tag,
{
...(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' : ''
].filter(Boolean).join(' '),
style: {
width: '137px',
minWidth: '137px',
maxWidth: '137px',
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
flex: '0 0 auto',
background: '#fff',
border: 'none',
boxShadow: 'none',
textDecoration: 'none',
outline: 'none'
}
},
[
props.preview
? h('div', { class: 'wechat-link-cover-image-wrap' }, [
h('img', {
src: props.preview,
alt: props.heading || '链接封面',
class: 'wechat-link-cover-image',
referrerpolicy: 'no-referrer'
}),
fromRow
])
: fromRow,
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(
Tag,
{
...(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' : ''
].filter(Boolean).join(' '),
style: {
width: '210px',
minWidth: '210px',
maxWidth: '210px',
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-content' }, [
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' }, [
showFromAvatarText ? (fromAvatarText || '\u200B') : null,
showFromAvatarImg
? h('img', {
src: fromAvatarUrl,
alt: '',
class: 'wechat-link-from-avatar-img',
referrerpolicy: 'no-referrer',
onLoad: onFromAvatarLoad,
onError: onFromAvatarError
})
: null
].filter(Boolean)),
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)
)
}
}
})
</script>
+316
View File
@@ -0,0 +1,316 @@
<template>
<LinkCard
v-if="message.renderType === 'link'"
:href="message.url"
:heading="message.title || message.content"
:abstract="message.content"
:preview="message.preview"
:fromAvatar="message.fromAvatar"
:from="message.from"
:linkType="message.linkType"
:isSent="message.isSent"
:variant="message.linkCardVariant || 'default'"
/>
<div v-else-if="message.renderType === 'file'"
class="wechat-redpacket-card wechat-special-card wechat-file-card msg-radius"
:class="message.isSent ? 'wechat-special-sent-side' : ''"
@click="onFileClick(message)"
@contextmenu="openMediaContextMenu($event, message, 'file')">
<div class="wechat-redpacket-content">
<div class="wechat-redpacket-info wechat-file-info">
<span class="wechat-file-name">{{ message.title || message.content || '文件' }}</span>
<span class="wechat-file-size" v-if="message.fileSize">{{ formatFileSize(message.fileSize) }}</span>
</div>
<FileTypeIcon :file-name="message.title" />
</div>
<div class="wechat-redpacket-bottom wechat-file-bottom">
<img :src="wechatPcLogoUrl" alt="" class="wechat-file-logo" />
<span>微信电脑版</span>
</div>
</div>
<div v-else-if="message.renderType === 'image'"
class="max-w-sm">
<div class="msg-radius overflow-hidden cursor-pointer" :class="message.isSent ? '' : ''" @click="message.imageUrl && openImagePreview(message.imageUrl)" @contextmenu="openMediaContextMenu($event, message, 'image')">
<img v-if="message.imageUrl" :src="message.imageUrl" alt="图片" class="max-w-[240px] max-h-[240px] object-cover hover:opacity-90 transition-opacity">
<div v-else class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
{{ message.content }}
</div>
</div>
</div>
<div v-else-if="message.renderType === 'video'" class="max-w-sm">
<div class="msg-radius overflow-hidden relative bg-black/5" @contextmenu="openMediaContextMenu($event, message, 'video')">
<img v-if="message.videoThumbUrl" :src="message.videoThumbUrl" alt="视频" class="block w-[220px] max-w-[260px] h-auto max-h-[260px] object-cover">
<div v-else class="px-3 py-2 text-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
{{ message.content }}
</div>
<button
v-if="message.videoThumbUrl && message.videoUrl"
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>
</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>
</div>
</div>
</div>
</div>
<div v-else-if="message.renderType === 'voice'"
class="wechat-voice-wrapper"
@contextmenu="openMediaContextMenu($event, message, 'voice')">
<div
class="wechat-voice-bubble msg-radius"
:class="message.isSent ? 'wechat-voice-sent' : 'wechat-voice-received'"
:style="{ width: getVoiceWidth(message.voiceDuration) }"
@click="message.voiceUrl && playVoice(message)"
>
<div class="wechat-voice-content" :class="message.isSent ? 'flex-row-reverse' : ''">
<svg class="wechat-voice-icon" :class="[message.isSent ? 'voice-icon-sent' : 'voice-icon-received', { 'voice-playing': playingVoiceId === message.id }]" viewBox="0 0 32 32" fill="currentColor">
<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>
<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>
<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>
</svg>
<span class="wechat-voice-duration">{{ getVoiceDurationInSeconds(message.voiceDuration) }}"</span>
</div>
<span v-if="!message.voiceRead && !message.isSent" class="wechat-voice-unread"></span>
</div>
<audio
v-if="message.voiceUrl"
:ref="el => setVoiceRef(message.id, el)"
:src="message.voiceUrl"
preload="none"
class="hidden"
></audio>
</div>
<div v-else-if="message.renderType === 'voip'"
class="wechat-voip-bubble msg-radius"
:class="message.isSent ? 'wechat-voip-sent' : 'wechat-voip-received'">
<div class="wechat-voip-content" :class="message.isSent ? 'flex-row-reverse' : ''">
<img v-if="message.voipType === 'video'" src="/assets/images/wechat/wechat-video-light.png" class="wechat-voip-icon" alt="">
<img v-else src="/assets/images/wechat/wechat-audio-light.png" class="wechat-voip-icon" alt="">
<span class="wechat-voip-text">{{ message.content || '通话' }}</span>
</div>
</div>
<div v-else-if="message.renderType === 'emoji'" class="max-w-sm flex items-center group" :class="message.isSent ? 'flex-row-reverse' : ''">
<template v-if="message.emojiUrl">
<img :src="message.emojiUrl" alt="表情" class="w-24 h-24 object-contain" @contextmenu="openMediaContextMenu($event, message, 'emoji')">
<button
v-if="shouldShowEmojiDownload(message)"
class="text-xs px-2 py-1 rounded bg-white border border-gray-200 text-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
:class="message.isSent ? 'mr-2' : 'ml-2'"
:disabled="!!message._emojiDownloading"
@click.stop="onEmojiDownloadClick(message)"
>
{{ message._emojiDownloading ? '下载中...' : (message._emojiDownloaded ? '已下载' : '下载') }}
</button>
</template>
<div v-else class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
{{ message.content }}
</div>
</div>
<template v-else-if="message.renderType === 'quote'">
<div
class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
<span v-for="(seg, idx) in parseTextWithEmoji(message.content)" :key="idx">
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px">
</span>
</div>
<div
v-if="message.quoteTitle || message.quoteContent"
class="mt-[5px] px-2 text-xs text-neutral-600 rounded max-w-[404px] max-h-[65px] overflow-hidden flex items-start bg-[#e1e1e1]">
<div class="py-2 min-w-0 flex-1">
<div v-if="isQuotedVoice(message)" class="flex items-center gap-1 min-w-0">
<span v-if="message.quoteTitle" class="truncate flex-shrink-0">{{ message.quoteTitle }}:</span>
<button
type="button"
class="flex items-center gap-1 min-w-0 hover:opacity-80"
:disabled="!message.quoteVoiceUrl"
:class="!message.quoteVoiceUrl ? 'opacity-60 cursor-not-allowed' : ''"
@click.stop="message.quoteVoiceUrl && playQuoteVoice(message)"
>
<svg
class="wechat-voice-icon wechat-quote-voice-icon"
:class="{ 'voice-playing': playingVoiceId === getQuoteVoiceId(message) }"
viewBox="0 0 32 32"
fill="currentColor"
>
<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>
<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>
<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>
</svg>
<span v-if="getVoiceDurationInSeconds(message.quoteVoiceLength) > 0" class="flex-shrink-0">{{ getVoiceDurationInSeconds(message.quoteVoiceLength) }}"</span>
<span v-else class="flex-shrink-0">语音</span>
</button>
<audio
v-if="message.quoteVoiceUrl"
:ref="el => setVoiceRef(getQuoteVoiceId(message), el)"
:src="message.quoteVoiceUrl"
preload="none"
class="hidden"
></audio>
</div>
<div v-else class="min-w-0 flex items-start">
<template v-if="isQuotedLink(message)">
<div class="line-clamp-2 min-w-0 flex-1">
<span v-if="message.quoteTitle">{{ message.quoteTitle }}:</span>
<span
v-if="getQuotedLinkText(message)"
:class="message.quoteTitle ? 'ml-1' : ''"
>
🔗 {{ getQuotedLinkText(message) }}
</span>
</div>
</template>
<template v-else>
<div class="line-clamp-2 min-w-0 flex-1">
<span v-if="message.quoteTitle">{{ message.quoteTitle }}:</span>
<span
v-if="message.quoteContent && !(isQuotedImage(message) && message.quoteTitle && message.quoteImageUrl && !message._quoteImageError)"
:class="message.quoteTitle ? 'ml-1' : ''"
>
{{ message.quoteContent }}
</span>
</div>
</template>
</div>
</div>
<div
v-if="isQuotedLink(message) && message.quoteThumbUrl && !message._quoteThumbError"
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
@click.stop="openImagePreview(message.quoteThumbUrl)"
>
<img
:src="message.quoteThumbUrl"
alt="引用链接缩略图"
class="max-h-[49px] w-auto max-w-[98px] object-contain"
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
@error="onQuoteThumbError(message)"
/>
</div>
<div
v-if="!isQuotedLink(message) && isQuotedImage(message) && message.quoteImageUrl && !message._quoteImageError"
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
@click.stop="openImagePreview(message.quoteImageUrl)"
>
<img
:src="message.quoteImageUrl"
alt="引用图片"
class="max-h-[49px] w-auto max-w-[98px] object-contain"
loading="lazy"
decoding="async"
@error="onQuoteImageError(message)"
/>
</div>
</div>
</template>
<!-- 合并转发聊天记录Chat History -->
<div
v-else-if="message.renderType === 'chatHistory'"
class="wechat-chat-history-card wechat-special-card msg-radius"
:class="message.isSent ? 'wechat-special-sent-side' : ''"
@click.stop="openChatHistoryModal(message)"
>
<div class="wechat-chat-history-body">
<div class="wechat-chat-history-title">{{ message.title || '聊天记录' }}</div>
<div class="wechat-chat-history-preview" v-if="getChatHistoryPreviewLines(message).length">
<div
v-for="(line, idx) in getChatHistoryPreviewLines(message)"
:key="idx"
class="wechat-chat-history-line"
>
{{ line }}
</div>
</div>
</div>
<div class="wechat-chat-history-bottom">
<span>聊天记录</span>
</div>
</div>
<div v-else-if="message.renderType === 'transfer'"
class="wechat-transfer-card msg-radius"
:class="[{ 'wechat-transfer-received': message.transferReceived, 'wechat-transfer-returned': isTransferReturned(message), 'wechat-transfer-overdue': isTransferOverdue(message) }, message.isSent ? 'wechat-transfer-sent-side' : 'wechat-transfer-received-side']">
<div class="wechat-transfer-content">
<img src="/assets/images/wechat/wechat-returned.png" v-if="isTransferReturned(message)" class="wechat-transfer-icon" alt="">
<img src="/assets/images/wechat/overdue.png" v-else-if="isTransferOverdue(message)" class="wechat-transfer-icon" alt="">
<img src="/assets/images/wechat/wechat-trans-icon2.png" v-else-if="message.transferReceived" class="wechat-transfer-icon" alt="">
<img src="/assets/images/wechat/wechat-trans-icon1.png" v-else class="wechat-transfer-icon" alt="">
<div class="wechat-transfer-info">
<span class="wechat-transfer-amount" v-if="message.amount">¥{{ formatTransferAmount(message.amount) }}</span>
<span class="wechat-transfer-status">{{ getTransferTitle(message) }}</span>
</div>
</div>
<div class="wechat-transfer-bottom">
<span>微信转账</span>
</div>
</div>
<!-- 红包消息 - 微信风格橙色卡片 -->
<div v-else-if="message.renderType === 'redPacket'" class="wechat-redpacket-card wechat-special-card msg-radius"
:class="[{ 'wechat-redpacket-received': message.redPacketReceived }, message.isSent ? 'wechat-special-sent-side' : '']">
<div class="wechat-redpacket-content">
<img src="/assets/images/wechat/wechat-trans-icon3.png" v-if="!message.redPacketReceived" class="wechat-redpacket-icon" alt="">
<img src="/assets/images/wechat/wechat-trans-icon4.png" v-else class="wechat-redpacket-icon" alt="">
<div class="wechat-redpacket-info">
<span class="wechat-redpacket-text">{{ getRedPacketText(message) }}</span>
<span class="wechat-redpacket-status" v-if="message.redPacketReceived">已领取</span>
</div>
</div>
<div class="wechat-redpacket-bottom">
<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"
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
<span v-for="(seg, idx) in parseTextWithEmoji(message.content)" :key="idx">
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px">
</span>
</div>
<!-- 表情消息 -->
<!-- 其他类型统一降级为普通文本展示 -->
<div v-else
class="px-3 py-2 text-xs max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed text-gray-700"
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
{{ message.content || ('[' + (message.type || 'unknown') + '] 消息组件已移除') }}
</div>
</template>
<script>
import { defineComponent } from 'vue'
import wechatPcLogoUrl from '~/assets/images/wechat/WeChat-Icon-Logo.wine.svg'
import ChatLocationCard from '~/components/ChatLocationCard.vue'
import FileTypeIcon from '~/components/chat/FileTypeIcon.vue'
import LinkCard from '~/components/chat/LinkCard.vue'
export default defineComponent({
name: 'MessageContent',
components: { ChatLocationCard, FileTypeIcon, LinkCard },
props: {
state: { type: Object, required: true },
message: { type: Object, required: true }
},
setup(props) {
return {
...props.state,
message: props.message,
wechatPcLogoUrl
}
}
})
</script>
+148
View File
@@ -0,0 +1,148 @@
<template>
<div
class="mb-6"
:class="[
(highlightServerIdStr && message.serverIdStr && highlightServerIdStr === message.serverIdStr) ? 'message-locate-highlight' : '',
(highlightMessageId === message.id) ? 'bg-emerald-100/50 rounded-md px-2 py-1 -mx-2' : ''
]"
:data-server-id="message.serverIdStr || ''"
:data-msg-id="message.id"
:data-create-time="message.createTime"
>
<div v-if="message.showTimeDivider" class="flex justify-center mb-4">
<div class="px-3 py-1 text-xs text-[#9e9e9e]">
{{ message.timeDivider }}
</div>
</div>
<div v-if="message.renderType === 'system'" class="flex justify-center">
<div class="px-3 py-1 text-xs text-[#9e9e9e]">
{{ message.content }}
</div>
</div>
<div v-else class="flex items-center" :class="message.isSent ? 'justify-end' : 'justify-start'">
<div class="flex items-start max-w-md" :class="message.isSent ? 'flex-row-reverse' : ''">
<div
class="relative"
@mouseenter="onMessageAvatarMouseEnter(message)"
@mouseleave="onMessageAvatarMouseLeave"
>
<div class="w-[calc(42px/var(--dpr))] h-[calc(42px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
<div v-if="message.avatar" class="w-full h-full">
<img
:src="message.avatar"
:alt="message.sender + '的头像'"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
@error="onAvatarError($event, message)"
>
</div>
<div
v-else
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
:style="{ backgroundColor: message.avatarColor || (message.isSent ? '#4B5563' : '#6B7280') }"
>
{{ message.sender.charAt(0) }}
</div>
</div>
<div
v-if="contactProfileCardOpen && contactProfileCardMessageId === String(message.id ?? '')"
class="absolute z-40 w-[360px] max-w-[88vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden"
:class="message.isSent ? 'right-0 top-[calc(100%+8px)]' : 'left-0 top-[calc(100%+8px)]'"
@mouseenter="onContactCardMouseEnter"
@mouseleave="onMessageAvatarMouseLeave"
>
<div class="px-3 py-2 border-b border-gray-200 text-sm font-medium text-gray-900">联系人资料</div>
<div class="p-3 space-y-3 bg-[#F6F6F6]">
<div v-if="contactProfileLoading" class="text-sm text-gray-500 text-center py-4">资料加载中...</div>
<div v-else-if="contactProfileError" class="text-sm text-red-500 whitespace-pre-wrap">{{ contactProfileError }}</div>
<div v-else class="bg-white rounded-md border border-gray-100 overflow-hidden">
<div class="p-3 flex items-center gap-3 border-b border-gray-100">
<div class="w-12 h-12 rounded-md overflow-hidden bg-gray-200 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<img v-if="contactProfileResolvedAvatar" :src="contactProfileResolvedAvatar" alt="头像" class="w-full h-full object-cover" referrerpolicy="no-referrer" />
<div v-else class="w-full h-full flex items-center justify-center text-white text-sm font-bold" style="background-color:#4B5563">{{ contactProfileResolvedName.charAt(0) || '?' }}</div>
</div>
<div class="min-w-0 flex-1" :class="{ 'privacy-blur': privacyMode }">
<div class="text-sm text-gray-900 truncate">{{ contactProfileResolvedName || '未知联系人' }}</div>
<div class="text-xs text-gray-500 truncate">{{ contactProfileResolvedUsername }}</div>
</div>
</div>
<div class="text-sm">
<div class="px-3 py-2.5 flex items-start gap-3 border-b border-gray-100">
<div class="w-12 text-gray-500 shrink-0">昵称</div>
<div class="text-gray-900 break-all" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedNickname || '-' }}</div>
</div>
<div class="px-3 py-2.5 flex items-start gap-3 border-b border-gray-100">
<div class="w-12 text-gray-500 shrink-0">微信号</div>
<div class="text-gray-900 break-all" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedAlias || '-' }}</div>
</div>
<div class="px-3 py-2.5 flex items-start gap-3 border-b border-gray-100">
<div class="w-12 text-gray-500 shrink-0">性别</div>
<div class="text-gray-900 break-all" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedGender || '-' }}</div>
</div>
<div class="px-3 py-2.5 flex items-start gap-3 border-b border-gray-100">
<div class="w-12 text-gray-500 shrink-0">地区</div>
<div class="text-gray-900 break-all" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedRegion || '-' }}</div>
</div>
<div class="px-3 py-2.5 flex items-start gap-3 border-b border-gray-100">
<div class="w-12 text-gray-500 shrink-0">备注</div>
<div class="text-gray-900 break-all" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedRemark || '-' }}</div>
</div>
<div class="px-3 py-2.5 flex items-start gap-3 border-b border-gray-100">
<div class="w-12 text-gray-500 shrink-0">签名</div>
<div class="text-gray-900 whitespace-pre-wrap break-words" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedSignature || '-' }}</div>
</div>
<div class="px-3 py-2.5 flex items-start gap-3" :title="contactProfileResolvedSourceScene != null ? `来源场景码:${contactProfileResolvedSourceScene}` : ''">
<div class="w-12 text-gray-500 shrink-0">来源</div>
<div class="text-gray-900 break-all" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedSource || '-' }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="flex flex-col relative group"
:class="[message.isSent ? 'items-end' : 'items-start', { 'privacy-blur': privacyMode }]"
@contextmenu="openMediaContextMenu($event, message, 'message')"
>
<div v-if="message.isGroup && !message.isSent && message.senderDisplayName" class="text-[11px] text-gray-500 mb-1" :class="message.isSent ? 'text-right' : 'text-left'">
{{ message.senderDisplayName }}
</div>
<div
class="absolute -top-6 z-10 rounded bg-black/70 text-white text-[10px] px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap"
:class="message.isSent ? 'right-0' : 'left-0'"
>
{{ message.fullTime }}
</div>
<MessageContent :message="message" :state="state" />
</div>
</div>
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue'
import MessageContent from '~/components/chat/MessageContent.vue'
export default defineComponent({
name: 'MessageItem',
components: { MessageContent },
props: {
state: { type: Object, required: true },
message: { type: Object, required: true }
},
setup(props) {
return {
...props.state,
message: props.message
}
}
})
</script>
+48
View File
@@ -0,0 +1,48 @@
<template>
<div ref="messageContainerRef" class="flex-1 overflow-y-auto p-4 min-h-0" @scroll="onMessageScroll">
<div v-if="selectedContact && hasMoreMessages" class="flex justify-center mb-4">
<div
class="text-xs px-3 py-1 rounded-md bg-white border border-gray-200 text-gray-700 select-none"
:class="isLoadingMessages ? 'opacity-60' : 'hover:bg-gray-50 cursor-pointer'"
@click="!isLoadingMessages && loadMoreMessages()"
>
{{ isLoadingMessages ? '加载中...' : '继续上滑加载更多' }}
</div>
</div>
<div v-if="isLoadingMessages && messages.length === 0" class="text-center text-sm text-gray-500 py-6">
加载中...
</div>
<div v-else-if="messagesError" class="text-center text-sm text-red-500 py-6 whitespace-pre-wrap">
{{ messagesError }}
</div>
<div v-else-if="messages.length === 0" class="text-center text-sm text-gray-500 py-6">
暂无聊天记录
</div>
<MessageItem
v-for="message in renderMessages"
:key="message.id"
:message="message"
:state="state"
/>
</div>
</template>
<script>
import { defineComponent } from 'vue'
import MessageItem from '~/components/chat/MessageItem.vue'
export default defineComponent({
name: 'MessageList',
components: { MessageItem },
props: {
state: { type: Object, required: true }
},
setup(props) {
return {
...props.state
}
}
})
</script>
@@ -0,0 +1,144 @@
<template>
<div
class="session-list-panel border-r border-gray-200 flex flex-col min-h-0 shrink-0 relative"
:style="{ backgroundColor: '#F7F7F7', '--session-list-width': sessionListWidth + 'px' }"
>
<!-- 拖动调整会话列表宽度 -->
<div
class="session-list-resizer"
:class="{ 'session-list-resizer-active': sessionListResizing }"
title="拖动调整会话列表宽度"
@pointerdown="onSessionListResizerPointerDown"
@dblclick="resetSessionListWidth"
/>
<!-- 聊天列表 -->
<div class="h-full flex flex-col min-h-0">
<!-- 搜索栏 -->
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
<div class="flex items-center gap-2">
<div class="contact-search-wrapper flex-1">
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 14L11.1 11.1" />
</svg>
<input
type="text"
placeholder="搜索联系人"
v-model="searchQuery"
class="contact-search-input"
:class="{ 'privacy-blur': privacyMode }"
>
<button
v-if="searchQuery"
type="button"
class="contact-search-clear"
@click="searchQuery = ''"
>
<svg class="w-3.5 h-3.5" 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>
<select
v-if="showSearchAccountSwitcher"
v-model="selectedAccount"
@change="onAccountChange"
class="account-select"
>
<option v-if="!availableAccounts.length" disabled value="">{{ chatAccounts.loading ? '加载中...' : (chatAccounts.error || '无数据库') }}</option>
<option v-for="acc in availableAccounts" :key="acc" :value="acc">{{ acc }}</option>
</select>
</div>
</div>
<!-- 联系人列表 -->
<div class="flex-1 overflow-y-auto min-h-0">
<div v-if="isLoadingContacts" class="px-3 py-4 h-full overflow-hidden">
<div v-for="i in 15" :key="i" class="flex items-center space-x-3 h-[calc(80px/var(--dpr))]">
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md bg-gray-200 skeleton-pulse"></div>
<div class="flex-1 space-y-2">
<div class="h-3.5 bg-gray-200 rounded skeleton-pulse" :style="{ width: (60 + (i % 4) * 15) + 'px' }"></div>
<div class="h-3 bg-gray-200 rounded skeleton-pulse" :style="{ width: (80 + (i % 3) * 20) + 'px' }"></div>
</div>
</div>
</div>
<div v-else-if="contactsError" class="px-3 py-2 text-sm text-red-500 whitespace-pre-wrap">
{{ contactsError }}
</div>
<div v-else-if="contacts.length === 0" class="px-3 py-2 text-sm text-gray-500">
暂无会话
</div>
<template v-else>
<div v-for="contact in filteredContacts" :key="contact.id"
class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(80px/var(--dpr))] flex items-center"
:class="contact.isTop
? (selectedContact?.id === contact.id
? 'bg-[#D2D2D2] hover:bg-[#C7C7C7]'
: 'bg-[#EAEAEA] hover:bg-[#DEDEDE]')
: (selectedContact?.id === contact.id
? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]'
: 'hover:bg-[#eaeaea]')"
@click="selectContact(contact)">
<div class="flex items-center space-x-3 w-full">
<!-- 联系人头像 -->
<div class="relative flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md overflow-hidden bg-gray-300">
<div v-if="contact.avatar" class="w-full h-full">
<img :src="contact.avatar" :alt="contact.name" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer" @error="onAvatarError($event, contact)">
</div>
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
:style="{ backgroundColor: contact.avatarColor || '#4B5563' }">
{{ contact.name.charAt(0) }}
</div>
</div>
<span
v-if="contact.unreadCount > 0"
class="absolute z-10 -top-[calc(4px/var(--dpr))] -right-[calc(4px/var(--dpr))] w-[calc(10px/var(--dpr))] h-[calc(10px/var(--dpr))] bg-[#ed4d4d] rounded-full"
></span>
</div>
<!-- 联系人信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-900 truncate" :class="{ 'privacy-blur': privacyMode }">{{ contact.name }}</h3>
<div class="flex items-center flex-shrink-0 ml-2">
<span class="text-xs text-gray-500">{{ contact.lastMessageTime }}</span>
</div>
</div>
<p class="text-xs text-gray-500 truncate mt-0.5 leading-tight" :class="{ 'privacy-blur': privacyMode }">
<span
v-for="(seg, idx) in parseTextWithEmoji(
(contact.unreadCount > 0 ? `[${contact.unreadCount > 99 ? '99+' : contact.unreadCount}条] ` : '') +
String(contact.lastMessage || '')
)"
:key="idx"
>
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" />
</span>
</p>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 样式展示列表已移除 -->
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'SessionListPanel',
props: {
state: { type: Object, required: true }
},
setup(props) {
return { ...props.state }
}
})
</script>
@@ -260,7 +260,7 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import Stack from '~/components/wrapped/shared/VueBitsStack.vue'
import WechatEmojiTable, { parseTextWithEmoji } from '~/utils/wechat-emojis'
import WechatEmojiTable, { parseTextWithEmoji } from '~/lib/wechat-emojis'
const props = defineProps({
card: { type: Object, required: true },
@@ -99,7 +99,7 @@ import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { gsap } from 'gsap'
import KeywordWordCloud from '~/components/wrapped/visualizations/KeywordWordCloud.vue'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
import { usePrivacyStore } from '~/stores/privacy'
const props = defineProps({
@@ -107,7 +107,7 @@
</template>
<script setup>
import { heatColor } from '~/utils/wrapped/heatmap'
import { heatColor } from '~/lib/wrapped/heatmap'
const props = defineProps({
year: { type: Number, default: new Date().getFullYear() },
@@ -100,7 +100,7 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
import { usePrivacyStore } from '~/stores/privacy'
const props = defineProps({
@@ -59,7 +59,7 @@
</template>
<script setup>
import { heatColor, maxInMatrix, formatHourRange } from '~/utils/wrapped/heatmap'
import { heatColor, maxInMatrix, formatHourRange } from '~/lib/wrapped/heatmap'
const props = defineProps({
weekdayLabels: { type: Array, default: () => ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] },
+545
View File
@@ -0,0 +1,545 @@
import { ref, toRaw } from 'vue'
const initialContextMenu = () => ({
visible: false,
x: 0,
y: 0,
message: null,
kind: '',
disabled: false,
editStatus: null,
editStatusLoading: false
})
const initialMessageEditModal = () => ({
open: false,
loading: false,
saving: false,
error: '',
mode: 'content',
sessionId: '',
messageId: '',
draft: '',
rawRow: null
})
const initialMessageFieldsModal = () => ({
open: false,
loading: false,
saving: false,
error: '',
sessionId: '',
messageId: '',
unsafe: false,
editsJson: '',
rawRow: null
})
export const useChatEditing = ({
api,
selectedAccount,
selectedContact,
refreshSelectedMessages,
normalizeMessage,
allMessages,
locateMessageByServerId
}) => {
const contextMenu = ref(initialContextMenu())
const messageEditModal = ref(initialMessageEditModal())
const messageFieldsModal = ref(initialMessageFieldsModal())
const closeContextMenu = () => {
contextMenu.value = initialContextMenu()
}
const loadContextMenuEditStatus = async (params) => {
if (!process.client) return
const account = String(params?.account || '').trim()
const username = String(params?.username || '').trim()
const messageId = String(params?.message_id || '').trim()
if (!account || !username || !messageId) {
contextMenu.value.editStatusLoading = false
return
}
try {
const response = await api.getChatEditStatus({ account, username, message_id: messageId })
const current = String(contextMenu.value?.message?.id || '').trim()
if (contextMenu.value.visible && current === messageId) {
contextMenu.value.editStatus = response || { modified: false }
}
} catch {
const current = String(contextMenu.value?.message?.id || '').trim()
if (contextMenu.value.visible && current === messageId) {
contextMenu.value.editStatus = null
}
} finally {
const current = String(contextMenu.value?.message?.id || '').trim()
if (contextMenu.value.visible && current === messageId) {
contextMenu.value.editStatusLoading = false
}
}
}
const openMediaContextMenu = (event, message, kind) => {
if (!process.client) return
event.preventDefault()
event.stopPropagation()
let actualKind = kind
let disabled = true
if (kind === 'voice') {
disabled = !(message?.serverIdStr || message?.serverId)
} else if (kind === 'file') {
disabled = !message?.fileMd5
} else if (kind === 'image') {
disabled = !(message?.imageMd5 || message?.imageFileId)
} else if (kind === 'emoji') {
disabled = !message?.emojiMd5
} else if (kind === 'video') {
if (message?.videoMd5 || message?.videoFileId) {
disabled = false
actualKind = 'video'
} else if (message?.videoThumbMd5 || message?.videoThumbFileId) {
disabled = false
actualKind = 'video_thumb'
}
}
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
message,
kind: actualKind,
disabled,
editStatus: null,
editStatusLoading: false
}
try {
const account = String(selectedAccount.value || '').trim()
const username = String(selectedContact.value?.username || '').trim()
const messageId = String(message?.id || '').trim()
if (account && username && messageId) {
contextMenu.value.editStatusLoading = true
void loadContextMenuEditStatus({ account, username, message_id: messageId })
}
} catch {}
}
const prettyJson = (value) => {
try {
return JSON.stringify(value ?? null, null, 2)
} catch {
return String(value ?? '')
}
}
const isLikelyTextMessage = (message) => {
if (!message) return false
const renderType = String(message?.renderType || '').trim()
if (renderType && renderType !== 'text') return false
if (message?.imageUrl || message?.emojiUrl || message?.videoUrl || message?.voiceUrl) return false
return true
}
const closeMessageEditModal = () => {
messageEditModal.value = initialMessageEditModal()
}
const openMessageEditModal = async ({ message, mode }) => {
if (!process.client) return
const account = String(selectedAccount.value || '').trim()
const sessionId = String(selectedContact.value?.username || '').trim()
const messageId = String(message?.id || '').trim()
if (!account || !sessionId || !messageId) return
const resolvedMode = mode === 'raw' ? 'raw' : 'content'
const initialDraft = resolvedMode === 'content'
? (typeof message?.content === 'string' ? message.content : String(message?.content ?? ''))
: ''
messageEditModal.value = {
open: true,
loading: true,
saving: false,
error: '',
mode: resolvedMode,
sessionId,
messageId,
draft: initialDraft,
rawRow: null
}
try {
const response = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId })
const row = response?.row || null
const rawContent = row?.message_content
const rawDraft = typeof rawContent === 'string' ? rawContent : String(rawContent ?? '')
const draft = resolvedMode === 'raw' ? rawDraft : messageEditModal.value.draft
messageEditModal.value = { ...messageEditModal.value, loading: false, rawRow: row, draft }
} catch (error) {
messageEditModal.value = { ...messageEditModal.value, loading: false, error: error?.message || '加载失败' }
}
}
const saveMessageEditModal = async () => {
if (!process.client) return
if (messageEditModal.value.saving || messageEditModal.value.loading) return
const account = String(selectedAccount.value || '').trim()
const sessionId = String(messageEditModal.value.sessionId || '').trim()
const messageId = String(messageEditModal.value.messageId || '').trim()
if (!account || !sessionId || !messageId) return
messageEditModal.value = { ...messageEditModal.value, saving: true, error: '' }
try {
const response = await api.editChatMessage({
account,
session_id: sessionId,
message_id: messageId,
edits: {
message_content: String(messageEditModal.value.draft ?? '')
},
unsafe: false
})
if (response?.updated_message) {
try {
const updated = normalizeMessage(response.updated_message)
const username = String(selectedContact.value?.username || '').trim()
const list = allMessages.value[username] || []
const index = list.findIndex((message) => String(message?.id || '') === String(updated?.id || ''))
if (index >= 0) {
const next = [...list]
next[index] = updated
allMessages.value = { ...allMessages.value, [username]: next }
} else {
await refreshSelectedMessages()
}
} catch {
await refreshSelectedMessages()
}
} else {
await refreshSelectedMessages()
}
closeMessageEditModal()
} catch (error) {
messageEditModal.value = { ...messageEditModal.value, saving: false, error: error?.message || '保存失败' }
return
} finally {
messageEditModal.value = { ...messageEditModal.value, saving: false }
}
}
const closeMessageFieldsModal = () => {
messageFieldsModal.value = initialMessageFieldsModal()
}
const openMessageFieldsModal = async (message) => {
if (!process.client) return
const account = String(selectedAccount.value || '').trim()
const sessionId = String(selectedContact.value?.username || '').trim()
const messageId = String(message?.id || '').trim()
if (!account || !sessionId || !messageId) return
messageFieldsModal.value = {
open: true,
loading: true,
saving: false,
error: '',
sessionId,
messageId,
unsafe: false,
editsJson: '',
rawRow: null
}
try {
const response = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId })
const row = response?.row || null
const seed = {}
for (const key of ['message_content', 'local_type', 'create_time', 'server_id', 'origin_source', 'source']) {
if (row && Object.prototype.hasOwnProperty.call(row, key)) seed[key] = row[key]
}
messageFieldsModal.value = {
...messageFieldsModal.value,
loading: false,
rawRow: row,
editsJson: JSON.stringify(seed, null, 2)
}
} catch (error) {
messageFieldsModal.value = { ...messageFieldsModal.value, loading: false, error: error?.message || '加载失败' }
}
}
const saveMessageFieldsModal = async () => {
if (!process.client) return
if (messageFieldsModal.value.saving || messageFieldsModal.value.loading) return
const account = String(selectedAccount.value || '').trim()
const sessionId = String(messageFieldsModal.value.sessionId || '').trim()
const messageId = String(messageFieldsModal.value.messageId || '').trim()
if (!account || !sessionId || !messageId) return
let edits = null
try {
edits = JSON.parse(String(messageFieldsModal.value.editsJson || '').trim() || 'null')
} catch {
messageFieldsModal.value = { ...messageFieldsModal.value, error: 'JSON 格式错误' }
return
}
if (!edits || typeof edits !== 'object' || Array.isArray(edits)) {
messageFieldsModal.value = { ...messageFieldsModal.value, error: 'edits 必须是 JSON 对象' }
return
}
if (!Object.keys(edits).length) {
messageFieldsModal.value = { ...messageFieldsModal.value, error: 'edits 不能为空' }
return
}
messageFieldsModal.value = { ...messageFieldsModal.value, saving: true, error: '' }
try {
await api.editChatMessage({
account,
session_id: sessionId,
message_id: messageId,
edits,
unsafe: !!messageFieldsModal.value.unsafe
})
await refreshSelectedMessages()
closeMessageFieldsModal()
} catch (error) {
messageFieldsModal.value = { ...messageFieldsModal.value, saving: false, error: error?.message || '保存失败' }
return
} finally {
messageFieldsModal.value = { ...messageFieldsModal.value, saving: false }
}
}
const copyTextToClipboard = async (text) => {
if (!process.client) return false
const value = String(text ?? '').trim()
if (!value) return false
try {
await navigator.clipboard.writeText(value)
return true
} catch {}
try {
const element = document.createElement('textarea')
element.value = value
element.setAttribute('readonly', 'true')
element.style.position = 'fixed'
element.style.left = '-9999px'
element.style.top = '-9999px'
document.body.appendChild(element)
element.select()
const ok = document.execCommand('copy')
document.body.removeChild(element)
if (ok) return true
} catch {}
try {
window.prompt('复制内容:', value)
return true
} catch {
return false
}
}
const onCopyMessageTextClick = async () => {
if (!process.client) return
const message = contextMenu.value.message
if (!message) return
try {
const text = String(message?.content || '').trim()
if (!text) {
window.alert('该消息没有可复制的文本')
return
}
const ok = await copyTextToClipboard(text)
if (!ok) window.alert('复制失败:无法写入剪贴板')
} catch {
window.alert('复制失败')
} finally {
closeContextMenu()
}
}
const onCopyMessageJsonClick = async () => {
if (!process.client) return
const message = contextMenu.value.message
if (!message) return
try {
const raw = toRaw(message) || message
const json = JSON.stringify(raw, (_key, value) => (typeof value === 'bigint' ? value.toString() : value), 2)
const ok = await copyTextToClipboard(json)
if (!ok) window.alert('复制失败:无法写入剪贴板')
} catch {
window.alert('复制失败')
} finally {
closeContextMenu()
}
}
const onOpenFolderClick = async () => {
if (contextMenu.value.disabled) return
const message = contextMenu.value.message
const kind = contextMenu.value.kind
try {
if (!selectedAccount.value || !selectedContact.value?.username) return
const params = {
account: selectedAccount.value,
username: selectedContact.value.username,
kind
}
if (kind === 'voice') {
params.server_id = message.serverIdStr || message.serverId
} else if (kind === 'file') {
params.md5 = message.fileMd5
} else if (kind === 'image') {
if (message.imageMd5) params.md5 = message.imageMd5
else if (message.imageFileId) params.file_id = message.imageFileId
} else if (kind === 'emoji') {
params.md5 = message.emojiMd5
} else if (kind === 'video') {
params.md5 = message.videoMd5
if (message.videoFileId) params.file_id = message.videoFileId
} else if (kind === 'video_thumb') {
params.md5 = message.videoThumbMd5
if (message.videoThumbFileId) params.file_id = message.videoThumbFileId
}
await api.openChatMediaFolder(params)
} finally {
closeContextMenu()
}
}
const onEditMessageClick = async () => {
const message = contextMenu.value.message
if (!message) return
const mode = isLikelyTextMessage(message) ? 'content' : 'raw'
closeContextMenu()
await openMessageEditModal({ message, mode })
}
const onEditMessageFieldsClick = async () => {
const message = contextMenu.value.message
if (!message) return
closeContextMenu()
await openMessageFieldsModal(message)
}
const onResetEditedMessageClick = async () => {
if (!process.client) return
const message = contextMenu.value.message
const account = String(selectedAccount.value || '').trim()
const sessionId = String(selectedContact.value?.username || '').trim()
const messageId = String(message?.id || '').trim()
if (!message || !account || !sessionId || !messageId) return
const ok = window.confirm('确认恢复该条消息到首次快照吗?')
if (!ok) return
try {
await api.resetChatEditedMessage({ account, session_id: sessionId, message_id: messageId })
closeContextMenu()
await refreshSelectedMessages()
} catch (error) {
window.alert(error?.message || '恢复失败')
} finally {
closeContextMenu()
}
}
const onRepairMessageSenderAsMeClick = async () => {
if (!process.client) return
const message = contextMenu.value.message
const account = String(selectedAccount.value || '').trim()
const sessionId = String(selectedContact.value?.username || '').trim()
const messageId = String(message?.id || '').trim()
if (!message || !account || !sessionId || !messageId) return
const ok = window.confirm('确认将该消息修复为“我发送”吗?这会修改 real_sender_id 字段。')
if (!ok) return
try {
await api.repairChatMessageSender({ account, session_id: sessionId, message_id: messageId, mode: 'me' })
closeContextMenu()
await refreshSelectedMessages()
} catch (error) {
window.alert(error?.message || '修复失败')
} finally {
closeContextMenu()
}
}
const onFlipWechatMessageDirectionClick = async () => {
if (!process.client) return
const message = contextMenu.value.message
const account = String(selectedAccount.value || '').trim()
const sessionId = String(selectedContact.value?.username || '').trim()
const messageId = String(message?.id || '').trim()
if (!message || !account || !sessionId || !messageId) return
const ok = window.confirm(
'确认反转该消息在微信客户端的左右气泡位置吗?\n\n这会修改 packed_info_data 字段(有风险)。\n可通过“恢复原消息”撤销。'
)
if (!ok) return
try {
await api.flipChatMessageDirection({ account, session_id: sessionId, message_id: messageId })
closeContextMenu()
await refreshSelectedMessages()
} catch (error) {
window.alert(error?.message || '反转失败')
} finally {
closeContextMenu()
}
}
const onLocateQuotedMessageClick = async () => {
const message = contextMenu.value.message
if (!message?.quoteServerId) return
closeContextMenu()
const ok = await locateMessageByServerId(message.quoteServerId)
if (!ok && process.client) {
window.alert('定位引用消息失败')
}
}
return {
contextMenu,
messageEditModal,
messageFieldsModal,
closeContextMenu,
openMediaContextMenu,
prettyJson,
isLikelyTextMessage,
closeMessageEditModal,
openMessageEditModal,
saveMessageEditModal,
closeMessageFieldsModal,
openMessageFieldsModal,
saveMessageFieldsModal,
copyTextToClipboard,
onCopyMessageTextClick,
onCopyMessageJsonClick,
onOpenFolderClick,
onEditMessageClick,
onEditMessageFieldsClick,
onResetEditedMessageClick,
onRepairMessageSenderAsMeClick,
onFlipWechatMessageDirectionClick,
onLocateQuotedMessageClick
}
}
+460
View File
@@ -0,0 +1,460 @@
import { computed, ref, watch } from 'vue'
import { reportServerErrorFromResponse } from '~/lib/server-error-logging'
import { toUnixSeconds } from '~/lib/chat/formatters'
export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selectedContact, privacyMode }) => {
const exportModalOpen = ref(false)
const isExportCreating = ref(false)
const exportError = ref('')
const exportScope = ref('current')
const exportFormat = ref('json')
const exportDownloadRemoteMedia = ref(true)
const exportHtmlPageSize = ref(1000)
const exportMessageTypeOptions = [
{ value: 'text', label: '文本' },
{ value: 'image', label: '图片' },
{ value: 'emoji', label: '表情' },
{ value: 'video', label: '视频' },
{ value: 'voice', label: '语音' },
{ value: 'chatHistory', label: '聊天记录' },
{ value: 'transfer', label: '转账' },
{ value: 'redPacket', label: '红包' },
{ value: 'file', label: '文件' },
{ value: 'link', label: '链接' },
{ value: 'quote', label: '引用' },
{ value: 'system', label: '系统' },
{ value: 'voip', label: '通话' }
]
const exportMessageTypes = ref(exportMessageTypeOptions.map((item) => item.value))
const exportStartLocal = ref('')
const exportEndLocal = ref('')
const exportFileName = ref('')
const exportFolder = ref('')
const exportFolderHandle = ref(null)
const exportSaveBusy = ref(false)
const exportSaveMsg = ref('')
const exportAutoSavedFor = ref('')
const exportSearchQuery = ref('')
const exportListTab = ref('all')
const exportSelectedUsernames = ref([])
const exportJob = ref(null)
let exportPollTimer = null
let exportEventSource = null
const clamp01 = (value) => Math.min(1, Math.max(0, value))
const asNumber = (value) => {
const next = Number(value)
return Number.isFinite(next) ? next : 0
}
const exportOverallPercent = computed(() => {
const job = exportJob.value
const progress = job?.progress || {}
const total = asNumber(progress.conversationsTotal)
const done = asNumber(progress.conversationsDone)
if (total <= 0) return 0
const currentTotal = asNumber(progress.currentConversationMessagesTotal)
const currentDone = asNumber(progress.currentConversationMessagesExported)
const currentFraction = currentTotal > 0 ? clamp01(currentDone / currentTotal) : 0
const overall = clamp01((done + (job?.status === 'running' ? currentFraction : 0)) / total)
return Math.round(overall * 100)
})
const exportCurrentPercent = computed(() => {
const progress = exportJob.value?.progress || {}
const total = asNumber(progress.currentConversationMessagesTotal)
const done = asNumber(progress.currentConversationMessagesExported)
if (total <= 0) return null
return Math.round(clamp01(done / total) * 100)
})
const exportFilteredContacts = computed(() => {
const query = String(exportSearchQuery.value || '').trim().toLowerCase()
let list = Array.isArray(contacts.value) ? contacts.value : []
const tab = String(exportListTab.value || 'all')
if (tab === 'groups') list = list.filter((contact) => !!contact?.isGroup)
if (tab === 'singles') list = list.filter((contact) => !contact?.isGroup)
if (!query) return list
return list.filter((contact) => {
const name = String(contact?.name || '').toLowerCase()
const username = String(contact?.username || '').toLowerCase()
return name.includes(query) || username.includes(query)
})
})
const exportContactCounts = computed(() => {
const list = Array.isArray(contacts.value) ? contacts.value : []
const total = list.length
const groups = list.filter((contact) => !!contact?.isGroup).length
return { total, groups, singles: total - groups }
})
const isDesktopExportRuntime = () => {
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
}
const isWebDirectoryPickerSupported = () => {
return !!(process.client && typeof window.showDirectoryPicker === 'function')
}
const hasWebExportFolder = computed(() => {
return !!(isWebDirectoryPickerSupported() && exportFolderHandle.value)
})
const chooseExportFolder = async () => {
exportError.value = ''
exportSaveMsg.value = ''
try {
if (!process.client) {
exportError.value = '当前环境不支持选择导出目录'
return
}
if (isDesktopExportRuntime()) {
const result = await window.wechatDesktop.chooseDirectory({ title: '选择导出目录' })
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
exportFolder.value = String(result.filePaths[0] || '').trim()
exportFolderHandle.value = null
}
return
}
if (isWebDirectoryPickerSupported()) {
const handle = await window.showDirectoryPicker()
if (handle) {
exportFolderHandle.value = handle
exportFolder.value = `浏览器目录:${String(handle.name || '已选择')}`
}
return
}
exportError.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器'
} catch (error) {
exportError.value = error?.message || '选择导出目录失败'
}
}
const guessExportZipName = (job) => {
const raw = String(job?.zipPath || '').trim()
if (raw) {
const name = raw.replace(/\\/g, '/').split('/').pop()
if (name && name.toLowerCase().endsWith('.zip')) return name
}
const exportId = String(job?.exportId || '').trim() || 'export'
return `wechat_chat_export_${exportId}.zip`
}
const getExportDownloadUrl = (exportId) => {
return `${apiBase}/chat/exports/${encodeURIComponent(String(exportId || ''))}/download`
}
const saveExportToSelectedFolder = async (options = {}) => {
const autoSave = !!options?.auto
exportError.value = ''
exportSaveMsg.value = ''
if (!process.client || !isWebDirectoryPickerSupported()) {
exportError.value = '当前环境不支持保存到浏览器目录'
return
}
const handle = exportFolderHandle.value
if (!handle || typeof handle.getFileHandle !== 'function') {
exportError.value = '请先选择浏览器导出目录'
return
}
const exportId = exportJob.value?.exportId
if (!exportId || String(exportJob.value?.status || '') !== 'done') {
exportError.value = '导出任务尚未完成'
return
}
exportSaveBusy.value = true
try {
const response = await fetch(getExportDownloadUrl(exportId))
if (!response.ok) {
await reportServerErrorFromResponse(response, {
method: 'GET',
requestUrl: getExportDownloadUrl(exportId),
message: `下载导出文件失败(${response.status}`,
source: 'chat.exportDownload'
})
throw new Error(`下载导出文件失败(${response.status}`)
}
const blob = await response.blob()
const fileName = guessExportZipName(exportJob.value)
const fileHandle = await handle.getFileHandle(fileName, { create: true })
const writable = await fileHandle.createWritable()
await writable.write(blob)
await writable.close()
exportAutoSavedFor.value = String(exportId)
exportSaveMsg.value = autoSave
? `已自动保存到已选目录:${fileName}`
: `已保存到已选目录:${fileName}`
} catch (error) {
exportError.value = error?.message || '保存到浏览器目录失败'
} finally {
exportSaveBusy.value = false
}
}
const stopExportPolling = () => {
if (exportEventSource) {
try {
exportEventSource.close()
} catch {}
exportEventSource = null
}
if (exportPollTimer) {
clearInterval(exportPollTimer)
exportPollTimer = null
}
}
const startExportHttpPolling = (exportId) => {
if (!exportId) return
exportPollTimer = setInterval(async () => {
try {
const response = await api.getChatExport(exportId)
exportJob.value = response?.job || exportJob.value
const status = String(exportJob.value?.status || '')
if (status === 'done' || status === 'error' || status === 'cancelled') {
stopExportPolling()
}
} catch {}
}, 1200)
}
const startExportPolling = (exportId) => {
stopExportPolling()
if (!exportId) return
if (process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined') {
const url = `${apiBase}/chat/exports/${encodeURIComponent(String(exportId))}/events`
try {
exportEventSource = new EventSource(url)
exportEventSource.onmessage = (event) => {
try {
const next = JSON.parse(String(event.data || '{}'))
exportJob.value = next || exportJob.value
const status = String(exportJob.value?.status || '')
if (status === 'done' || status === 'error' || status === 'cancelled') {
stopExportPolling()
}
} catch {}
}
exportEventSource.onerror = () => {
try {
exportEventSource?.close()
} catch {}
exportEventSource = null
if (!exportPollTimer) startExportHttpPolling(exportId)
}
return
} catch {
exportEventSource = null
}
}
startExportHttpPolling(exportId)
}
const openExportModal = () => {
exportModalOpen.value = true
exportError.value = ''
exportSaveMsg.value = ''
exportListTab.value = 'all'
exportStartLocal.value = ''
exportEndLocal.value = ''
exportMessageTypes.value = exportMessageTypeOptions.map((item) => item.value)
exportAutoSavedFor.value = ''
exportScope.value = selectedContact.value?.username ? 'current' : 'all'
}
const closeExportModal = () => {
exportModalOpen.value = false
exportError.value = ''
}
watch(exportModalOpen, (open) => {
if (!process.client) return
if (!open) {
stopExportPolling()
return
}
const exportId = exportJob.value?.exportId
const status = String(exportJob.value?.status || '')
if (exportId && (status === 'queued' || status === 'running')) {
startExportPolling(exportId)
}
})
watch(
() => ({
exportId: String(exportJob.value?.exportId || ''),
status: String(exportJob.value?.status || '')
}),
async ({ exportId, status }) => {
if (!process.client || status !== 'done' || !exportId) return
if (!hasWebExportFolder.value) return
if (exportAutoSavedFor.value === exportId) return
if (exportSaveBusy.value) return
await saveExportToSelectedFolder({ auto: true })
}
)
const startChatExport = async () => {
exportError.value = ''
exportSaveMsg.value = ''
if (!selectedAccount.value) {
exportError.value = '未选择账号'
return
}
let scope = exportScope.value
let usernames = []
if (scope === 'current') {
scope = 'selected'
if (selectedContact.value?.username) {
usernames = [selectedContact.value.username]
}
} else if (scope === 'selected') {
usernames = Array.isArray(exportSelectedUsernames.value) ? exportSelectedUsernames.value.filter(Boolean) : []
}
if (scope === 'selected' && (!usernames || usernames.length === 0)) {
exportError.value = '请选择至少一个会话'
return
}
const hasDesktopFolder = isDesktopExportRuntime() && !!String(exportFolder.value || '').trim()
const hasWebFolder = !isDesktopExportRuntime() && !!exportFolderHandle.value
if (!hasDesktopFolder && !hasWebFolder) {
exportError.value = '请先选择导出目录'
return
}
const startTime = toUnixSeconds(exportStartLocal.value)
const endTime = toUnixSeconds(exportEndLocal.value)
if (startTime && endTime && startTime > endTime) {
exportError.value = '时间范围不合法:开始时间不能晚于结束时间'
return
}
const messageTypes = Array.isArray(exportMessageTypes.value) ? exportMessageTypes.value.filter(Boolean) : []
if (messageTypes.length === 0) {
exportError.value = '请至少勾选一个消息类型'
return
}
const selectedTypeSet = new Set(messageTypes.map((item) => String(item || '').trim()))
const mediaKindSet = new Set()
if (selectedTypeSet.has('chatHistory')) {
mediaKindSet.add('image')
mediaKindSet.add('emoji')
mediaKindSet.add('video')
mediaKindSet.add('video_thumb')
mediaKindSet.add('voice')
mediaKindSet.add('file')
}
if (selectedTypeSet.has('image')) mediaKindSet.add('image')
if (selectedTypeSet.has('emoji')) mediaKindSet.add('emoji')
if (selectedTypeSet.has('video')) {
mediaKindSet.add('video')
mediaKindSet.add('video_thumb')
}
if (selectedTypeSet.has('voice')) mediaKindSet.add('voice')
if (selectedTypeSet.has('file')) mediaKindSet.add('file')
const mediaKinds = Array.from(mediaKindSet)
const includeMedia = !privacyMode.value && mediaKinds.length > 0
isExportCreating.value = true
exportAutoSavedFor.value = ''
try {
const response = await api.createChatExport({
account: selectedAccount.value,
scope,
usernames,
format: exportFormat.value,
start_time: startTime,
end_time: endTime,
include_hidden: false,
include_official: false,
message_types: messageTypes,
include_media: includeMedia,
media_kinds: mediaKinds,
download_remote_media: exportFormat.value === 'html' && !!exportDownloadRemoteMedia.value,
html_page_size: Math.max(0, Math.floor(Number(exportHtmlPageSize.value || 1000))),
output_dir: isDesktopExportRuntime() ? String(exportFolder.value || '').trim() : null,
privacy_mode: !!privacyMode.value,
file_name: exportFileName.value || null
})
exportJob.value = response?.job || null
const exportId = exportJob.value?.exportId
if (exportId) startExportPolling(exportId)
} catch (error) {
exportError.value = error?.message || '创建导出任务失败'
} finally {
isExportCreating.value = false
}
}
const cancelCurrentExport = async () => {
const exportId = exportJob.value?.exportId
if (!exportId) return
try {
await api.cancelChatExport(exportId)
const response = await api.getChatExport(exportId)
exportJob.value = response?.job || exportJob.value
} catch (error) {
exportError.value = error?.message || '取消导出失败'
}
}
return {
exportModalOpen,
isExportCreating,
exportError,
exportScope,
exportFormat,
exportDownloadRemoteMedia,
exportHtmlPageSize,
exportMessageTypeOptions,
exportMessageTypes,
exportStartLocal,
exportEndLocal,
exportFileName,
exportFolder,
exportFolderHandle,
exportSaveBusy,
exportSaveMsg,
exportAutoSavedFor,
exportSearchQuery,
exportListTab,
exportSelectedUsernames,
exportJob,
exportOverallPercent,
exportCurrentPercent,
exportFilteredContacts,
exportContactCounts,
hasWebExportFolder,
chooseExportFolder,
getExportDownloadUrl,
saveExportToSelectedFolder,
openExportModal,
closeExportModal,
startChatExport,
cancelCurrentExport,
stopExportPolling
}
}
@@ -0,0 +1,488 @@
import { ref } from 'vue'
import {
buildChatHistoryWindowPayload,
createChatHistoryRecordNormalizer,
enhanceChatHistoryRecords,
formatChatHistoryVideoDuration,
getChatHistoryPreviewLines,
isChatHistoryRecordItemIncomplete,
normalizeChatHistoryUrl,
parseChatHistoryRecord,
pickFirstMd5,
stripWeChatInvisible
} from '~/lib/chat/chat-history'
export const useChatHistoryWindows = ({
api,
apiBase,
selectedAccount,
selectedContact,
openImagePreview,
openVideoPreview
}) => {
const floatingWindows = ref([])
let floatingWindowSeq = 0
let floatingWindowZ = 70
const floatingDragState = { id: '', offsetX: 0, offsetY: 0 }
const clampNumber = (value, min, max) => Math.min(max, Math.max(min, value))
const normalizeRecordItem = createChatHistoryRecordNormalizer({
apiBase,
getSelectedAccount: () => selectedAccount.value,
getSelectedContact: () => selectedContact.value
})
const getFloatingWindowById = (id) => {
const list = Array.isArray(floatingWindows.value) ? floatingWindows.value : []
return list.find((item) => String(item?.id || '') === String(id || '')) || null
}
const focusFloatingWindow = (id) => {
const windowItem = getFloatingWindowById(id)
if (!windowItem) return
floatingWindowZ += 1
windowItem.zIndex = floatingWindowZ
}
const closeFloatingWindow = (id) => {
const key = String(id || '')
floatingWindows.value = (Array.isArray(floatingWindows.value) ? floatingWindows.value : []).filter((item) => String(item?.id || '') !== key)
if (floatingDragState.id && String(floatingDragState.id) === key) {
floatingDragState.id = ''
}
}
const closeTopFloatingWindow = () => {
const list = Array.isArray(floatingWindows.value) ? floatingWindows.value : []
if (!list.length) return
const top = [...list].sort((a, b) => Number(b?.zIndex || 0) - Number(a?.zIndex || 0))[0]
if (top?.id) closeFloatingWindow(top.id)
}
const openFloatingWindow = (payload) => {
if (!process.client || typeof window === 'undefined') return null
floatingWindowSeq += 1
floatingWindowZ += 1
const width = clampNumber(Number(payload?.width || 520), 360, Math.max(360, (window.innerWidth || 1200) - 48))
const height = clampNumber(Number(payload?.height || 420), 320, Math.max(320, (window.innerHeight || 900) - 48))
const x = clampNumber(Number(payload?.x || Math.round(((window.innerWidth || width) - width) / 2)), 16, Math.max(16, (window.innerWidth || width) - width - 16))
const y = clampNumber(Number(payload?.y || Math.round(((window.innerHeight || height) - height) / 2)), 16, Math.max(16, (window.innerHeight || height) - height - 16))
const windowItem = {
id: `chat-floating-${floatingWindowSeq}`,
kind: String(payload?.kind || 'chatHistory'),
title: String(payload?.title || ''),
info: payload?.info || { isChatRoom: false },
records: Array.isArray(payload?.records) ? payload.records : [],
url: String(payload?.url || ''),
content: String(payload?.content || ''),
preview: String(payload?.preview || ''),
from: String(payload?.from || ''),
fromAvatar: String(payload?.fromAvatar || ''),
loading: !!payload?.loading,
width,
height,
x,
y,
zIndex: floatingWindowZ
}
floatingWindows.value = [...floatingWindows.value, windowItem]
return windowItem
}
const startFloatingWindowDrag = (id, event) => {
if (!process.client) return
const windowItem = getFloatingWindowById(id)
if (!windowItem) return
focusFloatingWindow(id)
const point = 'touches' in event ? event.touches?.[0] : event
floatingDragState.id = id
floatingDragState.offsetX = Number(point?.clientX || 0) - Number(windowItem.x || 0)
floatingDragState.offsetY = Number(point?.clientY || 0) - Number(windowItem.y || 0)
}
const onFloatingWindowMouseMove = (event) => {
if (!process.client) return
if (!floatingDragState.id) return
const windowItem = getFloatingWindowById(floatingDragState.id)
if (!windowItem) return
const point = 'touches' in event ? event.touches?.[0] : event
const nextX = Number(point?.clientX || 0) - floatingDragState.offsetX
const nextY = Number(point?.clientY || 0) - floatingDragState.offsetY
windowItem.x = clampNumber(nextX, 8, Math.max(8, (window.innerWidth || nextX) - windowItem.width - 8))
windowItem.y = clampNumber(nextY, 8, Math.max(8, (window.innerHeight || nextY) - windowItem.height - 8))
}
const onFloatingWindowMouseUp = () => {
floatingDragState.id = ''
}
const chatHistoryModalVisible = ref(false)
const chatHistoryModalTitle = ref('')
const chatHistoryModalRecords = ref([])
const chatHistoryModalInfo = ref({ isChatRoom: false })
const chatHistoryModalStack = ref([])
const goBackChatHistoryModal = () => {}
const closeChatHistoryModal = () => {
chatHistoryModalVisible.value = false
chatHistoryModalTitle.value = ''
chatHistoryModalRecords.value = []
chatHistoryModalInfo.value = { isChatRoom: false }
chatHistoryModalStack.value = []
}
const onChatHistoryVideoThumbError = (record) => {
if (!record) return
const candidates = record._videoThumbCandidates
if (!Array.isArray(candidates) || candidates.length <= 1) {
record._videoThumbError = true
return
}
const current = Math.max(0, Number(record._videoThumbCandidateIndex || 0))
const next = current + 1
if (next < candidates.length) {
record._videoThumbCandidateIndex = next
record.videoThumbUrl = candidates[next]
return
}
record._videoThumbError = true
}
const onChatHistoryLinkPreviewError = (record) => {
if (!record) return
const candidates = record._linkPreviewCandidates
if (!Array.isArray(candidates) || candidates.length <= 1) {
record._linkPreviewError = true
return
}
const current = Math.max(0, Number(record._linkPreviewCandidateIndex || 0))
const next = current + 1
if (next < candidates.length) {
record._linkPreviewCandidateIndex = next
record.preview = candidates[next]
record._linkPreviewError = false
return
}
record._linkPreviewError = true
}
const onChatHistoryFromAvatarLoad = (record) => {
try {
if (record) {
record._fromAvatarImgOk = true
record._fromAvatarImgError = false
record._fromAvatarLast = String(record.fromAvatar || '').trim()
}
} catch {}
}
const onChatHistoryFromAvatarError = (record) => {
try {
if (record) {
record._fromAvatarImgOk = false
record._fromAvatarImgError = true
record._fromAvatarLast = String(record.fromAvatar || '').trim()
}
} catch {}
}
const onChatHistoryQuoteThumbError = (record) => {
if (!record || !record.quote) return
const candidates = record._quoteThumbCandidates
if (!Array.isArray(candidates) || candidates.length <= 1) {
record._quoteThumbError = true
return
}
const current = Math.max(0, Number(record._quoteThumbCandidateIndex || 0))
const next = current + 1
if (next < candidates.length) {
record._quoteThumbCandidateIndex = next
record.quote.thumbUrl = candidates[next]
return
}
record._quoteThumbError = true
}
const openChatHistoryQuote = (record) => {
if (!process.client) return
const quote = record?.quote
if (!quote) return
const kind = String(quote.kind || '')
const url = String(quote.url || '').trim()
if (!url) return
if (kind === 'video') {
openVideoPreview(url, quote?.thumbUrl)
return
}
if (kind === 'image' || kind === 'emoji') {
openImagePreview(url)
}
}
const getChatHistoryLinkFromText = (record) => {
const from = String(record?.from || '').trim()
if (from) return from
const url = String(record?.url || '').trim()
if (!url) return ''
try { return new URL(url).hostname || '' } catch { return '' }
}
const getChatHistoryLinkFromAvatarText = (record) => {
const text = String(getChatHistoryLinkFromText(record) || '').trim()
return text ? (Array.from(text)[0] || '') : ''
}
const openUrlInBrowser = (url) => {
const next = String(url || '').trim()
if (!next) return
try { window.open(next, '_blank', 'noopener,noreferrer') } catch {}
}
const resolveChatHistoryLinkRecord = async (record) => {
if (!process.client || !record || !selectedAccount.value) return null
const serverId = String(record?.fromnewmsgid || '').trim()
if (!serverId || record._linkResolving) return null
record._linkResolving = true
try {
const response = await api.resolveAppMsg({
account: selectedAccount.value,
server_id: serverId
})
if (response && typeof response === 'object') {
const title = String(response.title || '').trim()
const content = String(response.content || '').trim()
const url = String(response.url || '').trim()
const from = String(response.from || '').trim()
const normalizePreviewUrl = (value) => {
const raw = String(value || '').trim()
if (!raw) return ''
if (/^\/api\/chat\/media\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw
if (!/^https?:\/\//i.test(raw)) return ''
try {
const host = new URL(raw).hostname.toLowerCase()
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
}
} catch {}
return raw
}
if (title) record.title = title
if (content && !stripWeChatInvisible(record.content)) record.content = content
if (url) record.url = url
if (from) record.from = from
if (response.linkStyle) record.linkStyle = String(response.linkStyle || '').trim()
if (response.linkType) record.linkType = String(response.linkType || '').trim()
const fromUsername = String(response.fromUsername || '').trim()
if (fromUsername) record.fromUsername = fromUsername
const fromAvatarUrl = fromUsername
? `${apiBase}/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}`
: (url ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(url)}` : '')
if (fromAvatarUrl) {
const last = String(record._fromAvatarLast || '').trim()
record.fromAvatar = fromAvatarUrl
if (String(fromAvatarUrl).trim() !== last) {
record._fromAvatarLast = String(fromAvatarUrl).trim()
record._fromAvatarImgOk = false
record._fromAvatarImgError = false
}
}
const style = String(response.linkStyle || '').trim()
const thumb = String(response.thumbUrl || '').trim()
const cover = String(response.coverUrl || '').trim()
const picked = style === 'cover' ? (cover || thumb) : (thumb || cover)
const previewResolved = normalizePreviewUrl(picked)
if (previewResolved) {
const currentPreview = String(record.preview || '').trim()
const candidates = Array.isArray(record._linkPreviewCandidates) ? record._linkPreviewCandidates.slice() : []
if (currentPreview && !candidates.includes(currentPreview)) candidates.push(currentPreview)
if (!candidates.includes(previewResolved)) candidates.push(previewResolved)
record._linkPreviewCandidates = candidates
if (!currentPreview || record._linkPreviewError) {
record.preview = previewResolved
record._linkPreviewCandidateIndex = candidates.indexOf(previewResolved)
record._linkPreviewError = false
}
}
return response
}
} catch {}
finally {
try { record._linkResolving = false } catch {}
}
return null
}
const resolveChatHistoryLinkRecords = (windowItem) => {
if (!process.client) return
const records = Array.isArray(windowItem?.records) ? windowItem.records : []
const targets = records.filter((record) => {
if (!record) return false
if (String(record.renderType || '') !== 'link') return false
if (!String(record.fromnewmsgid || '').trim()) return false
const fromMissing = String(record.from || '').trim() === ''
const previewMissing = !String(record.preview || '').trim()
const urlMissing = !String(record.url || '').trim()
const fromAvatarMissing = !String(record.fromAvatar || '').trim()
return fromMissing || previewMissing || urlMissing || fromAvatarMissing
})
if (!targets.length) return
;(async () => {
for (const target of targets.slice(0, 12)) {
await resolveChatHistoryLinkRecord(target)
}
})()
}
const openChatHistoryLinkWindow = (record) => {
if (!process.client) return
const title = String(record?.title || record?.content || '链接').trim()
const url = String(record?.url || '').trim()
const preview = String(record?.preview || '').trim()
const from = String(record?.from || '').trim()
const fromAvatar = String(record?.fromAvatar || '').trim()
const needResolve = !!String(record?.fromnewmsgid || '').trim() && (!url || !from || !preview || !fromAvatar)
const windowItem = openFloatingWindow({
kind: 'link',
title: title || '链接',
url,
content: String(record?.content || '').trim(),
preview,
from,
fromAvatar,
width: 520,
height: 420,
loading: needResolve
})
if (!windowItem) return
focusFloatingWindow(windowItem.id)
try {
windowItem._linkPreviewCandidates = Array.isArray(record?._linkPreviewCandidates) ? record._linkPreviewCandidates.slice() : (preview ? [preview] : [])
windowItem._linkPreviewCandidateIndex = Math.max(0, Number(record?._linkPreviewCandidateIndex || 0))
windowItem._linkPreviewError = false
windowItem._fromAvatarLast = fromAvatar
windowItem._fromAvatarImgOk = !!record?._fromAvatarImgOk
windowItem._fromAvatarImgError = !!record?._fromAvatarImgError
windowItem.fromnewmsgid = String(record?.fromnewmsgid || '').trim()
} catch {}
if (needResolve) {
;(async () => {
await resolveChatHistoryLinkRecord(windowItem)
windowItem.loading = false
})()
}
}
const openChatHistoryModal = (message) => {
if (!process.client) return
const { title0, info0, records0 } = buildChatHistoryWindowPayload(message, normalizeRecordItem)
const windowItem = openFloatingWindow({
kind: 'chatHistory',
title: title0 || '聊天记录',
info: info0,
records: records0,
width: 560,
height: Math.round(Math.max(420, (window.innerHeight || 700) * 0.78))
})
if (!windowItem) return
try { resolveChatHistoryLinkRecords(windowItem) } catch {}
}
const openNestedChatHistory = (record) => {
if (!process.client) return
const title = String(record?.title || '聊天记录')
const content = String(record?.content || '')
const recordItem = String(record?.recordItem || '').trim()
const serverId = String(record?.fromnewmsgid || '').trim()
const { info0, records0 } = buildChatHistoryWindowPayload({ title, content, recordItem }, normalizeRecordItem)
const windowItem = openFloatingWindow({
kind: 'chatHistory',
title: title || '聊天记录',
info: info0,
records: records0,
width: 560,
height: Math.round(Math.max(420, (window.innerHeight || 700) * 0.78)),
loading: false
})
if (!windowItem) return
try { resolveChatHistoryLinkRecords(windowItem) } catch {}
if (!serverId || !selectedAccount.value || record?._nestedResolving || !isChatHistoryRecordItemIncomplete(recordItem)) return
record._nestedResolving = true
windowItem.loading = true
;(async () => {
try {
const response = await api.resolveNestedChatHistory({
account: selectedAccount.value,
server_id: serverId
})
const resolved = String(response?.recordItem || '').trim()
if (!resolved) return
windowItem.title = String(response?.title || title || '聊天记录')
const parsed = parseChatHistoryRecord(resolved)
windowItem.info = parsed?.info || { isChatRoom: false, count: 0 }
const items = Array.isArray(parsed?.items) ? parsed.items : []
windowItem.records = items.length ? enhanceChatHistoryRecords(items.map(normalizeRecordItem)) : []
if (!windowItem.records.length) {
const lines = String(response?.content || content || '').trim().split(/\r?\n/).map((item) => item.trim()).filter(Boolean)
windowItem.info = { isChatRoom: false, count: 0 }
windowItem.records = lines.map((line, idx) => normalizeRecordItem({
id: String(idx),
datatype: '1',
sourcename: '',
sourcetime: '',
content: line,
renderType: 'text'
}))
}
try { resolveChatHistoryLinkRecords(windowItem) } catch {}
} catch {}
finally {
windowItem.loading = false
try { record._nestedResolving = false } catch {}
}
})()
}
return {
floatingWindows,
chatHistoryModalVisible,
chatHistoryModalTitle,
chatHistoryModalRecords,
chatHistoryModalInfo,
chatHistoryModalStack,
goBackChatHistoryModal,
closeChatHistoryModal,
getFloatingWindowById,
focusFloatingWindow,
closeFloatingWindow,
closeTopFloatingWindow,
openFloatingWindow,
startFloatingWindowDrag,
onFloatingWindowMouseMove,
onFloatingWindowMouseUp,
formatChatHistoryVideoDuration,
getChatHistoryPreviewLines,
onChatHistoryVideoThumbError,
onChatHistoryLinkPreviewError,
onChatHistoryFromAvatarLoad,
onChatHistoryFromAvatarError,
onChatHistoryQuoteThumbError,
openChatHistoryQuote,
getChatHistoryLinkFromText,
getChatHistoryLinkFromAvatarText,
openUrlInBrowser,
resolveChatHistoryLinkRecord,
resolveChatHistoryLinkRecords,
openChatHistoryLinkWindow,
openChatHistoryModal,
openNestedChatHistory
}
}
@@ -0,0 +1,786 @@
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import {
formatFileSize,
formatTimeDivider,
getVoiceDurationInSeconds,
getVoiceWidth
} from '~/lib/chat/formatters'
import { createMessageNormalizer, dedupeMessagesById } from '~/lib/chat/message-normalizer'
export const useChatMessages = ({
api,
apiBase,
selectedAccount,
selectedContact,
realtimeStore,
realtimeEnabled,
desktopAutoRealtime,
privacyMode,
searchContext
}) => {
const messagePageSize = 50
const allMessages = ref({})
const messagesMeta = ref({})
const isLoadingMessages = ref(false)
const messagesError = ref('')
const messageContainerRef = ref(null)
const activeMessagesFor = ref('')
const showJumpToBottom = ref(false)
const previewImageUrl = ref(null)
const previewVideoUrl = ref(null)
const previewVideoPosterUrl = ref('')
const previewVideoError = ref('')
const voiceRefs = ref({})
const currentPlayingVoice = ref(null)
const playingVoiceId = ref(null)
const highlightServerIdStr = ref('')
const highlightMessageId = ref('')
let highlightTimer = null
const messageTypeFilter = ref('all')
const messageTypeFilterOptions = [
{ value: 'all', label: '全部' },
{ value: 'text', label: '文本' },
{ value: 'image', label: '图片' },
{ value: 'emoji', label: '表情' },
{ value: 'video', label: '视频' },
{ value: 'voice', label: '语音' },
{ value: 'file', label: '文件' },
{ value: 'link', label: '链接' },
{ value: 'quote', label: '引用' },
{ value: 'chatHistory', label: '聊天记录' },
{ value: 'transfer', label: '转账' },
{ value: 'redPacket', label: '红包' },
{ value: 'location', label: '位置' },
{ value: 'voip', label: '通话' },
{ value: 'system', label: '系统' }
]
const normalizeMessage = createMessageNormalizer({
apiBase,
getSelectedAccount: () => selectedAccount.value,
getSelectedContact: () => selectedContact.value
})
const messages = computed(() => {
if (!selectedContact.value) return []
return allMessages.value[selectedContact.value.username] || []
})
const hasMoreMessages = computed(() => {
if (!selectedContact.value) return false
const key = selectedContact.value.username
const meta = messagesMeta.value[key]
if (!meta) return false
if (meta.hasMore != null) return !!meta.hasMore
const total = Number(meta.total || 0)
const loaded = messages.value.length
return total > loaded
})
const reverseMessageSides = ref(false)
const reverseSidesStorageKey = computed(() => {
const account = String(selectedAccount.value || '').trim()
const username = String(selectedContact.value?.username || '').trim()
if (account && username) return `wechatda:reverse_message_sides:${account}:${username}`
return 'wechatda:reverse_message_sides:global'
})
const loadReverseMessageSides = () => {
if (!process.client) return
try {
const value = localStorage.getItem(reverseSidesStorageKey.value)
reverseMessageSides.value = value === '1'
} catch {}
}
watch(reverseSidesStorageKey, () => loadReverseMessageSides(), { immediate: true })
watch(reverseMessageSides, (value) => {
if (!process.client) return
try {
localStorage.setItem(reverseSidesStorageKey.value, value ? '1' : '0')
} catch {}
})
const toggleReverseMessageSides = () => {
reverseMessageSides.value = !reverseMessageSides.value
}
const renderMessages = computed(() => {
const list = messages.value || []
const reverseSides = !!reverseMessageSides.value
let previousTs = 0
return list.map((message) => {
const ts = Number(message.createTime || 0)
const show = !previousTs || (ts && Math.abs(ts - previousTs) >= 300)
if (ts) previousTs = ts
const originalIsSent = !!message?.isSent
return {
...message,
_originalIsSent: originalIsSent,
isSent: reverseSides ? !originalIsSent : originalIsSent,
showTimeDivider: !!show,
timeDivider: formatTimeDivider(ts)
}
})
})
const updateJumpToBottomState = () => {
const container = messageContainerRef.value
if (!container) {
showJumpToBottom.value = false
return
}
const distance = container.scrollHeight - container.scrollTop - container.clientHeight
showJumpToBottom.value = distance > 160
}
const scrollToBottom = () => {
const container = messageContainerRef.value
if (!container) return
container.scrollTop = container.scrollHeight
updateJumpToBottomState()
}
const flashMessage = (id) => {
highlightMessageId.value = String(id || '').trim()
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = setTimeout(() => {
highlightMessageId.value = ''
highlightServerIdStr.value = ''
highlightTimer = null
}, 2200)
}
const scrollToMessageId = async (id) => {
const target = String(id || '').trim()
if (!target) return false
await nextTick()
const container = messageContainerRef.value
const element = container?.querySelector?.(`[data-msg-id="${CSS.escape(target)}"]`)
if (!element || typeof element.scrollIntoView !== 'function') return false
element.scrollIntoView({ block: 'center', behavior: 'smooth' })
return true
}
const openImagePreview = (url) => {
previewImageUrl.value = String(url || '').trim() || null
}
const closeImagePreview = () => {
previewImageUrl.value = null
}
const openVideoPreview = (url, poster) => {
previewVideoUrl.value = String(url || '').trim() || null
previewVideoPosterUrl.value = String(poster || '').trim()
previewVideoError.value = ''
}
const closeVideoPreview = () => {
previewVideoUrl.value = null
previewVideoPosterUrl.value = ''
previewVideoError.value = ''
}
const onPreviewVideoError = () => {
previewVideoError.value = '视频加载失败,可能是资源不存在或无法访问。'
}
const setVoiceRef = (id, element) => {
const key = String(id || '').trim()
if (!key) return
if (element) {
voiceRefs.value = { ...voiceRefs.value, [key]: element }
} else if (voiceRefs.value[key]) {
const next = { ...voiceRefs.value }
delete next[key]
voiceRefs.value = next
}
}
const playVoiceById = async (voiceId) => {
const key = String(voiceId || '').trim()
if (!key) return
const audio = voiceRefs.value[key]
if (!audio) return
try {
if (currentPlayingVoice.value && currentPlayingVoice.value !== audio) {
currentPlayingVoice.value.pause()
currentPlayingVoice.value.currentTime = 0
}
} catch {}
if (currentPlayingVoice.value === audio && !audio.paused) {
try {
audio.pause()
audio.currentTime = 0
} catch {}
currentPlayingVoice.value = null
playingVoiceId.value = null
return
}
try {
await audio.play()
currentPlayingVoice.value = audio
playingVoiceId.value = key
audio.onended = () => {
if (playingVoiceId.value === key) {
currentPlayingVoice.value = null
playingVoiceId.value = null
}
}
} catch {}
}
const playVoice = async (message) => {
await playVoiceById(message?.id)
}
const getQuoteVoiceId = (message) => `quote-${String(message?.quoteServerId || message?.id || '')}`
const playQuoteVoice = async (message) => {
await playVoiceById(getQuoteVoiceId(message))
}
const isQuotedVoice = (message) => String(message?.quoteType || '').trim() === '34'
const isQuotedImage = (message) => {
return !!String(message?.quoteImageUrl || '').trim() || String(message?.quoteContent || '').trim() === '[图片]'
}
const isQuotedLink = (message) => {
return String(message?.quoteType || '').trim() === '5' || !!String(message?.quoteThumbUrl || '').trim()
}
const getQuotedLinkText = (message) => {
const title = String(message?.quoteTitle || '').trim()
const content = String(message?.quoteContent || '').trim()
return content || title || ''
}
const onQuoteImageError = (message) => {
if (message) message._quoteImageError = true
}
const onQuoteThumbError = (message) => {
if (message) message._quoteThumbError = true
}
const onAvatarError = (event, target) => {
try { event?.target && (event.target.style.display = 'none') } catch {}
try { if (target) target.avatar = null } catch {}
}
const shouldShowEmojiDownload = (message) => {
if (!message?.emojiMd5) return false
const url = String(message?.emojiRemoteUrl || '').trim()
if (!url) return false
if (!/^https?:\/\//i.test(url)) return false
return true
}
const onEmojiDownloadClick = async (message) => {
if (!process.client) return
if (!message?.emojiMd5) return
if (!selectedAccount.value) return
const emojiUrl = String(message?.emojiRemoteUrl || '').trim()
if (!emojiUrl) {
window.alert('该表情没有可用的下载地址')
return
}
if (message._emojiDownloading) return
message._emojiDownloading = true
try {
await api.downloadChatEmoji({
account: selectedAccount.value,
md5: message.emojiMd5,
emoji_url: emojiUrl,
force: false
})
message._emojiDownloaded = true
if (message.emojiLocalUrl) {
message.emojiUrl = message.emojiLocalUrl
}
} catch (error) {
window.alert(error?.message || '下载失败')
} finally {
message._emojiDownloading = false
}
}
const onFileClick = async (message) => {
if (!message?.fileMd5) return
try {
if (!selectedAccount.value) return
if (!selectedContact.value?.username) return
await api.openChatMediaFolder({
account: selectedAccount.value,
username: selectedContact.value.username,
kind: 'file',
md5: message.fileMd5
})
} catch (error) {
console.error('打开文件夹失败:', error)
}
}
const loadMessages = async ({ username, reset }) => {
if (!username || !selectedAccount.value) return
messagesError.value = ''
isLoadingMessages.value = true
activeMessagesFor.value = username
try {
const existing = allMessages.value[username] || []
const container = messageContainerRef.value
const beforeScrollHeight = container ? container.scrollHeight : 0
const beforeScrollTop = container ? container.scrollTop : 0
const offset = reset ? 0 : existing.length
const params = {
account: selectedAccount.value,
username,
limit: messagePageSize,
offset,
order: 'asc'
}
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
params.render_types = messageTypeFilter.value
}
if (realtimeEnabled.value) {
params.source = 'realtime'
}
const response = await api.listChatMessages(params)
const raw = response?.messages || []
const mapped = dedupeMessagesById(raw.map(normalizeMessage))
if (activeMessagesFor.value !== username) return
if (reset) {
allMessages.value = { ...allMessages.value, [username]: mapped }
} else {
const existingIds = new Set(existing.map((message) => String(message?.id || '')))
const older = mapped.filter((message) => {
const id = String(message?.id || '')
if (!id) return true
if (existingIds.has(id)) return false
existingIds.add(id)
return true
})
allMessages.value = {
...allMessages.value,
[username]: [...older, ...existing]
}
}
messagesMeta.value = {
...messagesMeta.value,
[username]: {
total: Number(response?.total || 0),
hasMore: response?.hasMore
}
}
await nextTick()
const nextContainer = messageContainerRef.value
if (nextContainer) {
if (reset) {
nextContainer.scrollTop = nextContainer.scrollHeight
} else {
const afterScrollHeight = nextContainer.scrollHeight
nextContainer.scrollTop = beforeScrollTop + (afterScrollHeight - beforeScrollHeight)
}
}
updateJumpToBottomState()
} catch (error) {
messagesError.value = error?.message || '加载聊天记录失败'
} finally {
isLoadingMessages.value = false
}
}
const loadMoreMessages = async () => {
if (!selectedContact.value) return
if (searchContext.value?.active) return
await loadMessages({ username: selectedContact.value.username, reset: false })
}
const refreshSelectedMessages = async () => {
if (!selectedContact.value) return
await loadMessages({ username: selectedContact.value.username, reset: true })
}
const refreshRealtimeIncremental = async () => {
if (!realtimeEnabled.value || !selectedAccount.value || !selectedContact.value?.username) return
if (searchContext.value?.active || isLoadingMessages.value) return
const username = selectedContact.value.username
const existing = allMessages.value[username] || []
if (!existing.length) return
const container = messageContainerRef.value
const atBottom = !!container && (container.scrollHeight - container.scrollTop - container.clientHeight) < 80
const params = {
account: selectedAccount.value,
username,
limit: 30,
offset: 0,
order: 'asc',
source: 'realtime'
}
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
params.render_types = messageTypeFilter.value
}
const response = await api.listChatMessages(params)
if (selectedContact.value?.username !== username) return
const latest = (response?.messages || []).map(normalizeMessage)
const seenIds = new Set(existing.map((message) => String(message?.id || '')))
const newOnes = []
for (const message of latest) {
const id = String(message?.id || '')
if (!id || seenIds.has(id)) continue
seenIds.add(id)
newOnes.push(message)
}
if (!newOnes.length) return
allMessages.value = { ...allMessages.value, [username]: [...existing, ...newOnes] }
await nextTick()
const nextContainer = messageContainerRef.value
if (nextContainer && atBottom) {
nextContainer.scrollTop = nextContainer.scrollHeight
}
updateJumpToBottomState()
}
let realtimeRefreshFuture = null
let realtimeRefreshQueued = false
const queueRealtimeRefresh = () => {
if (realtimeRefreshFuture) {
realtimeRefreshQueued = true
return
}
realtimeRefreshFuture = refreshRealtimeIncremental().finally(() => {
realtimeRefreshFuture = null
if (realtimeRefreshQueued) {
realtimeRefreshQueued = false
queueRealtimeRefresh()
}
})
}
const tryEnableRealtimeAuto = async () => {
if (!process.client || typeof window === 'undefined') return
if (!desktopAutoRealtime.value || realtimeEnabled.value || !selectedAccount.value) return
try {
await realtimeStore.enable({ silent: true })
} catch {}
}
const resetMessageState = () => {
allMessages.value = {}
messagesMeta.value = {}
messagesError.value = ''
highlightMessageId.value = ''
highlightServerIdStr.value = ''
}
const contactProfileCardOpen = ref(false)
const contactProfileCardMessageId = ref('')
const contactProfileLoading = ref(false)
const contactProfileError = ref('')
const contactProfileData = ref(null)
let contactProfileHoverHideTimer = null
const contactProfileResolvedName = computed(() => {
const profile = contactProfileData.value || {}
const displayName = String(profile?.displayName || '').trim()
if (displayName) return displayName
const contactName = String(selectedContact.value?.name || '').trim()
if (contactName) return contactName
return String(profile?.username || selectedContact.value?.username || '').trim()
})
const contactProfileResolvedUsername = computed(() => {
const profile = contactProfileData.value || {}
return String(profile?.username || selectedContact.value?.username || '').trim()
})
const contactProfileResolvedNickname = computed(() => String(contactProfileData.value?.nickname || '').trim())
const contactProfileResolvedAlias = computed(() => String(contactProfileData.value?.alias || '').trim())
const contactProfileResolvedRegion = computed(() => String(contactProfileData.value?.region || '').trim())
const contactProfileResolvedRemark = computed(() => String(contactProfileData.value?.remark || '').trim())
const contactProfileResolvedSignature = computed(() => String(contactProfileData.value?.signature || '').trim())
const contactProfileResolvedSource = computed(() => String(contactProfileData.value?.source || '').trim())
const contactProfileResolvedAvatar = computed(() => {
const avatar = String(contactProfileData.value?.avatar || '').trim()
if (avatar) return avatar
return String(selectedContact.value?.avatar || '').trim()
})
const contactProfileResolvedGender = computed(() => {
const value = contactProfileData.value?.gender
if (value == null || value === '') return ''
const gender = Number(value)
if (!Number.isFinite(gender)) return ''
if (gender === 1) return '男'
if (gender === 2) return '女'
if (gender === 0) return '未知'
return String(gender)
})
const contactProfileResolvedSourceScene = computed(() => {
const value = contactProfileData.value?.sourceScene
if (value == null || value === '') return null
const scene = Number(value)
return Number.isFinite(scene) ? scene : null
})
const fetchContactProfile = async (options = {}) => {
const username = String(options?.username || contactProfileData.value?.username || selectedContact.value?.username || '').trim()
const displayNameFallback = String(options?.displayName || '').trim()
const avatarFallback = String(options?.avatar || '').trim()
const account = String(selectedAccount.value || '').trim()
if (!username || !account) {
contactProfileData.value = null
return
}
contactProfileLoading.value = true
contactProfileError.value = ''
try {
const response = await api.listChatContacts({
account,
include_friends: true,
include_groups: true,
include_officials: true
})
const list = Array.isArray(response?.contacts) ? response.contacts : []
const matched = list.find((item) => String(item?.username || '').trim() === username)
if (matched) {
const normalized = { ...matched, username }
if (!String(normalized.displayName || '').trim() && displayNameFallback) {
normalized.displayName = displayNameFallback
}
if (!String(normalized.avatar || '').trim() && avatarFallback) {
normalized.avatar = avatarFallback
}
contactProfileData.value = normalized
} else {
contactProfileData.value = {
username,
displayName: displayNameFallback || selectedContact.value?.name || username,
avatar: avatarFallback || selectedContact.value?.avatar || '',
nickname: '',
alias: '',
gender: null,
region: '',
remark: '',
signature: '',
source: '',
sourceScene: null
}
}
} catch (error) {
contactProfileData.value = {
username,
displayName: displayNameFallback || selectedContact.value?.name || username,
avatar: avatarFallback || selectedContact.value?.avatar || '',
nickname: '',
alias: '',
gender: null,
region: '',
remark: '',
signature: '',
source: '',
sourceScene: null
}
contactProfileError.value = error?.message || '加载联系人资料失败'
} finally {
contactProfileLoading.value = false
}
}
const clearContactProfileHoverHideTimer = () => {
if (contactProfileHoverHideTimer) {
clearTimeout(contactProfileHoverHideTimer)
contactProfileHoverHideTimer = null
}
}
const closeContactProfileCard = () => {
contactProfileCardOpen.value = false
contactProfileCardMessageId.value = ''
}
const onMessageAvatarMouseEnter = async (message) => {
if (!!message?.isSent) return
const messageId = String(message?.id ?? '').trim()
if (!messageId) return
const username = String(message?.senderUsername || '').trim()
if (!username || username === 'self') return
const senderName = String(message?.senderDisplayName || message?.sender || '').trim()
const senderAvatar = String(message?.avatar || '').trim()
if (!contactProfileData.value || String(contactProfileData.value?.username || '').trim() !== username) {
contactProfileData.value = {
username,
displayName: senderName || username,
avatar: senderAvatar,
nickname: '',
alias: '',
gender: null,
region: '',
remark: '',
signature: '',
source: '',
sourceScene: null
}
} else {
if (!String(contactProfileData.value?.displayName || '').trim() && senderName) {
contactProfileData.value.displayName = senderName
}
if (!String(contactProfileData.value?.avatar || '').trim() && senderAvatar) {
contactProfileData.value.avatar = senderAvatar
}
}
clearContactProfileHoverHideTimer()
contactProfileCardMessageId.value = messageId
contactProfileCardOpen.value = true
await fetchContactProfile({ username, displayName: senderName, avatar: senderAvatar })
}
const onMessageAvatarMouseLeave = () => {
clearContactProfileHoverHideTimer()
contactProfileHoverHideTimer = setTimeout(() => {
closeContactProfileCard()
}, 120)
}
const onContactCardMouseEnter = () => {
clearContactProfileHoverHideTimer()
}
watch(
() => selectedContact.value?.username,
() => {
clearContactProfileHoverHideTimer()
closeContactProfileCard()
contactProfileError.value = ''
contactProfileData.value = null
}
)
watch(
() => selectedAccount.value,
() => {
clearContactProfileHoverHideTimer()
closeContactProfileCard()
contactProfileError.value = ''
contactProfileData.value = null
}
)
onUnmounted(() => {
if (highlightTimer) clearTimeout(highlightTimer)
highlightTimer = null
clearContactProfileHoverHideTimer()
})
return {
allMessages,
messagesMeta,
messages,
renderMessages,
hasMoreMessages,
isLoadingMessages,
messagesError,
messageContainerRef,
showJumpToBottom,
messagePageSize,
messageTypeFilter,
messageTypeFilterOptions,
reverseMessageSides,
previewImageUrl,
previewVideoUrl,
previewVideoPosterUrl,
previewVideoError,
voiceRefs,
currentPlayingVoice,
playingVoiceId,
highlightServerIdStr,
highlightMessageId,
contactProfileCardOpen,
contactProfileCardMessageId,
contactProfileLoading,
contactProfileError,
contactProfileData,
contactProfileResolvedName,
contactProfileResolvedUsername,
contactProfileResolvedNickname,
contactProfileResolvedAlias,
contactProfileResolvedGender,
contactProfileResolvedRegion,
contactProfileResolvedRemark,
contactProfileResolvedSignature,
contactProfileResolvedSource,
contactProfileResolvedSourceScene,
contactProfileResolvedAvatar,
normalizeMessage,
updateJumpToBottomState,
scrollToBottom,
flashMessage,
scrollToMessageId,
openImagePreview,
closeImagePreview,
openVideoPreview,
closeVideoPreview,
onPreviewVideoError,
setVoiceRef,
playVoice,
playQuoteVoice,
getQuoteVoiceId,
getVoiceDurationInSeconds,
getVoiceWidth,
isQuotedVoice,
isQuotedImage,
isQuotedLink,
getQuotedLinkText,
onQuoteImageError,
onQuoteThumbError,
onAvatarError,
shouldShowEmojiDownload,
onEmojiDownloadClick,
onFileClick,
toggleReverseMessageSides,
loadMessages,
loadMoreMessages,
refreshSelectedMessages,
refreshRealtimeIncremental,
queueRealtimeRefresh,
tryEnableRealtimeAuto,
resetMessageState,
fetchContactProfile,
clearContactProfileHoverHideTimer,
closeContactProfileCard,
onMessageAvatarMouseEnter,
onMessageAvatarMouseLeave,
onContactCardMouseEnter,
formatFileSize
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,289 @@
import { computed, onMounted, ref } from 'vue'
import { normalizeSessionPreview } from '~/lib/chat/formatters'
const SESSION_LIST_WIDTH_KEY = 'ui.chat.session_list_width_physical'
const SESSION_LIST_WIDTH_KEY_LEGACY = 'ui.chat.session_list_width'
const SESSION_LIST_WIDTH_DEFAULT = 295
const SESSION_LIST_WIDTH_MIN = 220
const SESSION_LIST_WIDTH_MAX = 520
export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled, api }) => {
const showSearchAccountSwitcher = false
const contacts = ref([])
const selectedContact = ref(null)
const searchQuery = ref('')
const isLoadingContacts = ref(false)
const contactsError = ref('')
const sessionListWidth = ref(SESSION_LIST_WIDTH_DEFAULT)
const sessionListResizing = ref(false)
let sessionListResizeStartX = 0
let sessionListResizeStartWidth = SESSION_LIST_WIDTH_DEFAULT
let sessionListResizeStartDpr = 1
let sessionListResizePrevCursor = ''
let sessionListResizePrevUserSelect = ''
const availableAccounts = computed(() => {
return Array.isArray(chatAccounts?.accounts) ? chatAccounts.accounts : []
})
const clampSessionListWidth = (value) => {
const next = Number.isFinite(value) ? value : SESSION_LIST_WIDTH_DEFAULT
return Math.min(SESSION_LIST_WIDTH_MAX, Math.max(SESSION_LIST_WIDTH_MIN, Math.round(next)))
}
const loadSessionListWidth = () => {
if (!process.client) return
try {
const raw = localStorage.getItem(SESSION_LIST_WIDTH_KEY)
const value = parseInt(String(raw || ''), 10)
if (!Number.isNaN(value)) {
sessionListWidth.value = clampSessionListWidth(value)
return
}
const legacy = localStorage.getItem(SESSION_LIST_WIDTH_KEY_LEGACY)
const legacyValue = parseInt(String(legacy || ''), 10)
if (!Number.isNaN(legacyValue)) {
const dpr = window.devicePixelRatio || 1
const converted = clampSessionListWidth(legacyValue * dpr)
sessionListWidth.value = converted
try {
localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(converted))
localStorage.removeItem(SESSION_LIST_WIDTH_KEY_LEGACY)
} catch {}
}
} catch {}
}
const saveSessionListWidth = () => {
if (!process.client) return
try {
localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(clampSessionListWidth(sessionListWidth.value)))
} catch {}
}
const setSessionListResizingActive = (active) => {
if (!process.client) return
try {
const body = document.body
if (!body) return
if (active) {
sessionListResizePrevCursor = body.style.cursor || ''
sessionListResizePrevUserSelect = body.style.userSelect || ''
body.style.cursor = 'col-resize'
body.style.userSelect = 'none'
} else {
body.style.cursor = sessionListResizePrevCursor
body.style.userSelect = sessionListResizePrevUserSelect
sessionListResizePrevCursor = ''
sessionListResizePrevUserSelect = ''
}
} catch {}
}
const onSessionListResizerPointerMove = (event) => {
if (!sessionListResizing.value) return
const clientX = Number(event?.clientX || 0)
sessionListWidth.value = clampSessionListWidth(
sessionListResizeStartWidth + (clientX - sessionListResizeStartX) * (sessionListResizeStartDpr || 1)
)
}
const stopSessionListResize = () => {
if (!process.client) return
if (!sessionListResizing.value) return
sessionListResizing.value = false
setSessionListResizingActive(false)
try {
window.removeEventListener('pointermove', onSessionListResizerPointerMove)
} catch {}
saveSessionListWidth()
}
const onSessionListResizerPointerUp = () => {
stopSessionListResize()
}
const onSessionListResizerPointerDown = (event) => {
if (!process.client) return
try {
event?.preventDefault?.()
} catch {}
sessionListResizing.value = true
sessionListResizeStartX = Number(event?.clientX || 0)
sessionListResizeStartWidth = Number(sessionListWidth.value || SESSION_LIST_WIDTH_DEFAULT)
sessionListResizeStartDpr = window.devicePixelRatio || 1
setSessionListResizingActive(true)
try {
window.addEventListener('pointermove', onSessionListResizerPointerMove)
window.addEventListener('pointerup', onSessionListResizerPointerUp, { once: true })
} catch {}
}
const resetSessionListWidth = () => {
sessionListWidth.value = SESSION_LIST_WIDTH_DEFAULT
saveSessionListWidth()
}
onMounted(() => {
loadSessionListWidth()
})
const filteredContacts = computed(() => {
const query = String(searchQuery.value || '').trim().toLowerCase()
if (!query) return contacts.value
return contacts.value.filter((contact) => {
const name = String(contact?.name || '').toLowerCase()
const username = String(contact?.username || '').toLowerCase()
return name.includes(query) || username.includes(query)
})
})
const mapSessions = (sessions) => {
return sessions.map((session) => ({
id: session.id,
name: session.name || session.username || session.id,
avatar: session.avatar || null,
lastMessage: normalizeSessionPreview(session.lastMessage || ''),
lastMessageTime: session.lastMessageTime || '',
unreadCount: session.unreadCount || 0,
isGroup: !!session.isGroup,
isTop: !!session.isTop,
username: session.username
}))
}
const clearContactsState = (errorMessage = '') => {
contacts.value = []
selectedContact.value = null
contactsError.value = errorMessage
}
const loadSessionsForSelectedAccount = async () => {
if (!selectedAccount.value) {
clearContactsState('')
return []
}
const fetchSessions = async (source) => {
const params = {
account: selectedAccount.value,
limit: 400,
include_hidden: false,
include_official: false
}
if (source) params.source = source
return api.listChatSessions(params)
}
let sessionsResp = null
if (realtimeEnabled?.value) {
try {
sessionsResp = await fetchSessions('realtime')
} catch {
sessionsResp = null
}
}
if (!sessionsResp) {
sessionsResp = await fetchSessions('')
}
const sessions = Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions : []
contacts.value = mapSessions(sessions)
contactsError.value = ''
return contacts.value
}
const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => {
if (!process.client || typeof window === 'undefined') return
if (!selectedAccount.value) return
if (isLoadingContacts.value) return
const previousUsername = selectedContact.value?.username || ''
const desiredSource = (sourceOverride != null)
? String(sourceOverride || '').trim()
: (realtimeEnabled?.value ? 'realtime' : '')
const params = {
account: selectedAccount.value,
limit: 400,
include_hidden: false,
include_official: false
}
let sessionsResp = null
if (desiredSource) {
try {
sessionsResp = await api.listChatSessions({ ...params, source: desiredSource })
} catch {
sessionsResp = null
}
}
if (!sessionsResp) {
try {
sessionsResp = await api.listChatSessions(params)
} catch {
return
}
}
const sessions = Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions : []
const nextContacts = mapSessions(sessions)
contacts.value = nextContacts
if (previousUsername) {
const matched = nextContacts.find((contact) => contact.username === previousUsername)
if (matched) selectedContact.value = matched
}
}
const loadContacts = async () => {
if (contacts.value.length && !isLoadingContacts.value) {
return { usedPrefetched: true }
}
isLoadingContacts.value = true
contactsError.value = ''
try {
await chatAccounts.ensureLoaded()
if (!selectedAccount.value) {
clearContactsState(chatAccounts.error || '未检测到已解密账号,请先解密数据库。')
return { usedPrefetched: false }
}
await loadSessionsForSelectedAccount()
return { usedPrefetched: false }
} catch (error) {
clearContactsState(error?.message || '加载联系人失败')
return { usedPrefetched: false }
} finally {
isLoadingContacts.value = false
}
}
return {
showSearchAccountSwitcher,
availableAccounts,
contacts,
selectedContact,
searchQuery,
filteredContacts,
isLoadingContacts,
contactsError,
sessionListWidth,
sessionListResizing,
clearContactsState,
loadContacts,
loadSessionsForSelectedAccount,
refreshSessionsForSelectedAccount,
onSessionListResizerPointerDown,
stopSessionListResize,
resetSessionListWidth
}
}
+31 -2
View File
@@ -1,3 +1,5 @@
import { reportServerError } from '~/lib/server-error-logging'
// API请求组合式函数
export const useApi = () => {
const baseURL = useApiBase()
@@ -8,10 +10,19 @@ export const useApi = () => {
const response = await $fetch(url, {
baseURL,
...options,
onResponseError({ response }) {
async onResponseError({ response }) {
if (response.status === 400) {
throw new Error(response._data?.detail || '请求参数错误')
} else if (response.status === 500) {
} else if (response.status >= 500) {
await reportServerError({
status: response.status,
method: options?.method || 'GET',
requestUrl: url,
message: '服务器错误,请稍后重试',
backendDetail: response._data?.detail || '',
source: 'useApi',
apiBase: baseURL,
})
throw new Error('服务器错误,请稍后重试')
}
}
@@ -60,6 +71,22 @@ export const useApi = () => {
return await request('/chat/accounts')
}
const getChatAccountInfo = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
const url = '/chat/account_info' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
const deleteChatAccount = async (params = {}) => {
const account = String(params?.account || '').trim()
if (!account) throw new Error('Missing account')
const query = new URLSearchParams()
query.set('account', account)
const url = '/chat/account' + (query.toString() ? `?${query.toString()}` : '')
return await request(url, { method: 'DELETE' })
}
const listChatSessions = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
@@ -540,6 +567,8 @@ export const useApi = () => {
decryptDatabase,
healthCheck,
listChatAccounts,
getChatAccountInfo,
deleteChatAccount,
listChatSessions,
listChatMessages,
getChatMessageRaw,
+35 -4
View File
@@ -1,14 +1,45 @@
import { normalizeApiBase, readApiBaseOverride } from '~/utils/api-settings'
import { normalizeApiBase, readApiBaseOverride } from '~/lib/api-settings'
// Client-side cache so that useApiBase() can be called safely outside
// the Nuxt composable context (e.g. inside async callbacks / onMounted chains).
let _clientCache = ''
const shouldIgnoreStoredOverride = () => {
if (!process.client || !import.meta.dev) return false
return typeof window !== 'undefined' && !!window.wechatDesktop?.__brand
}
export const useApiBase = () => {
const config = useRuntimeConfig()
if (process.client && _clientCache) return _clientCache
// useRuntimeConfig() requires the Nuxt app context, which is only
// guaranteed during synchronous setup. On the client we cache the
// result so later (context-less) calls still work.
let config
try {
config = useRuntimeConfig()
} catch {
// Context unavailable fall back to cached value or default.
return _clientCache || '/api'
}
// Default to same-origin `/api` so Nuxt devProxy / backend-mounted UI both work.
// Override priority:
// 1) Local UI setting (web + desktop)
// 2) NUXT_PUBLIC_API_BASE env/runtime config
// 3) `/api`
const override = process.client ? readApiBaseOverride() : ''
const override = process.client && !shouldIgnoreStoredOverride() ? readApiBaseOverride() : ''
const runtime = String(config?.public?.apiBase || '').trim()
return normalizeApiBase(override || runtime || '/api')
const result = normalizeApiBase(override || runtime || '/api')
if (process.client) _clientCache = result
return result
}
/**
* Call this when the user changes the API base override in settings
* so the cached value is refreshed.
*/
export const invalidateApiBaseCache = () => {
_clientCache = ''
}
+474
View File
@@ -0,0 +1,474 @@
import { getChatHistoryPreviewLines } from '~/lib/chat/formatters'
export const isMaybeMd5 = (value) => /^[0-9a-f]{32}$/i.test(String(value || '').trim())
export const pickFirstMd5 = (...values) => {
for (const value of values) {
const text = String(value || '').trim()
if (isMaybeMd5(text)) return text.toLowerCase()
}
return ''
}
export const normalizeChatHistoryUrl = (value) => String(value || '').trim().replace(/\s+/g, '')
export const stripWeChatInvisible = (value) => {
return String(value || '').replace(/[\u3164\u2800]/g, '').trim()
}
export const parseChatHistoryRecord = (recordItemXml) => {
if (!process.client) return { info: null, items: [] }
const xml = String(recordItemXml || '').trim()
if (!xml) return { info: null, items: [] }
const normalized = xml
.replace(/&#x20;/g, ' ')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
.replace(/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[\da-fA-F]+;)/g, '&amp;')
let doc
try {
doc = new DOMParser().parseFromString(normalized, 'text/xml')
} catch {
return { info: null, items: [] }
}
const parserErrors = doc.getElementsByTagName('parsererror')
if (parserErrors && parserErrors.length) return { info: null, items: [] }
const getText = (node, tag) => {
try {
if (!node) return ''
const elements = Array.from(node.getElementsByTagName(tag) || [])
const direct = elements.find((el) => el && el.parentNode === node)
const target = direct || elements[0]
return String(target?.textContent || '').trim()
} catch {
return ''
}
}
const getDirectChildXml = (node, tag) => {
try {
if (!node) return ''
const children = Array.from(node.children || [])
const target = children.find((child) => String(child?.tagName || '').toLowerCase() === String(tag || '').toLowerCase())
if (!target) return ''
const raw = String(target.textContent || '').trim()
if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw
if (typeof XMLSerializer !== 'undefined') {
return new XMLSerializer().serializeToString(target)
}
} catch {}
return ''
}
const getAnyXml = (node, tag) => {
try {
if (!node) return ''
const elements = Array.from(node.getElementsByTagName(tag) || [])
const direct = elements.find((el) => el && el.parentNode === node)
const target = direct || elements[0]
if (!target) return ''
const raw = String(target.textContent || '').trim()
if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw
if (typeof XMLSerializer !== 'undefined') return new XMLSerializer().serializeToString(target)
} catch {}
return ''
}
const sameTag = (element, tag) => String(element?.tagName || '').toLowerCase() === String(tag || '').toLowerCase()
const closestAncestorByTag = (node, tag) => {
const lower = String(tag || '').toLowerCase()
let current = node
while (current) {
if (current.nodeType === 1 && String(current.tagName || '').toLowerCase() === lower) return current
current = current.parentNode
}
return null
}
const root = doc?.documentElement
const isChatRoom = String(getText(root, 'isChatRoom') || '').trim() === '1'
const title = getText(root, 'title')
const desc = getText(root, 'desc') || getText(root, 'info')
const datalist = (() => {
try {
const all = Array.from(doc.getElementsByTagName('datalist') || [])
const top = root ? all.find((el) => closestAncestorByTag(el, 'recorditem') === root) : null
return top || all[0] || null
} catch {
return null
}
})()
const datalistCount = (() => {
try {
if (!datalist) return 0
const value = String(datalist.getAttribute('count') || '').trim()
return Math.max(0, parseInt(value, 10) || 0)
} catch {
return 0
}
})()
const itemNodes = (() => {
if (datalist) return Array.from(datalist.children || []).filter((el) => sameTag(el, 'dataitem'))
return Array.from(root?.children || []).filter((el) => sameTag(el, 'dataitem'))
})()
const parsed = itemNodes.map((node, idx) => {
const datatype = String(node.getAttribute('datatype') || getText(node, 'datatype') || '').trim()
const dataid = String(node.getAttribute('dataid') || getText(node, 'dataid') || '').trim() || String(idx)
const sourcename = getText(node, 'sourcename')
const sourcetime = getText(node, 'sourcetime')
const sourceheadurl = normalizeChatHistoryUrl(getText(node, 'sourceheadurl'))
const datatitle = getText(node, 'datatitle')
const datadesc = getText(node, 'datadesc')
const link = normalizeChatHistoryUrl(getText(node, 'link') || getText(node, 'dataurl') || getText(node, 'url'))
const datafmt = getText(node, 'datafmt')
const duration = getText(node, 'duration')
const fullmd5 = getText(node, 'fullmd5')
const thumbfullmd5 = getText(node, 'thumbfullmd5')
const md5 = getText(node, 'md5') || getText(node, 'emoticonmd5') || getText(node, 'emojiMd5')
const fromnewmsgid = getText(node, 'fromnewmsgid')
const srcMsgLocalid = getText(node, 'srcMsgLocalid') || getText(node, 'srcMsgLocalId')
const srcMsgCreateTime = getText(node, 'srcMsgCreateTime')
const cdnurlstring = normalizeChatHistoryUrl(getText(node, 'cdnurlstring'))
const encrypturlstring = normalizeChatHistoryUrl(getText(node, 'encrypturlstring'))
const externurl = normalizeChatHistoryUrl(getText(node, 'externurl'))
const aeskey = getText(node, 'aeskey')
const nestedRecordItem = getAnyXml(node, 'recorditem') || getDirectChildXml(node, 'recorditem') || getText(node, 'recorditem')
let content = datatitle || datadesc
if (!content) {
if (datatype === '4') content = '[视频]'
else if (datatype === '2' || datatype === '3') content = '[图片]'
else if (datatype === '47' || datatype === '37') content = '[表情]'
else if (datatype) content = `[消息 ${datatype}]`
else content = '[消息]'
}
const fmt = String(datafmt || '').trim().toLowerCase().replace(/^\./, '')
const imageFormats = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'])
let renderType = 'text'
if (datatype === '17') {
renderType = 'chatHistory'
} else if (datatype === '5' || link) {
renderType = 'link'
} else if (datatype === '4' || String(duration || '').trim() || fmt === 'mp4') {
renderType = 'video'
} else if (datatype === '47' || datatype === '37') {
renderType = 'emoji'
} else if (
datatype === '2'
|| datatype === '3'
|| imageFormats.has(fmt)
|| (datatype !== '1' && isMaybeMd5(fullmd5))
) {
renderType = 'image'
} else if (isMaybeMd5(md5) && /表情/.test(String(content || ''))) {
renderType = 'emoji'
}
let outTitle = ''
let outUrl = ''
let recordItem = ''
if (renderType === 'chatHistory') {
outTitle = datatitle || content || '聊天记录'
content = datadesc || ''
recordItem = nestedRecordItem
} else if (renderType === 'link') {
outTitle = datatitle || content || ''
outUrl = link || externurl || ''
const cleanDesc = stripWeChatInvisible(datadesc)
const cleanTitle = stripWeChatInvisible(outTitle)
if (!cleanDesc || (cleanTitle && cleanDesc === cleanTitle)) {
content = ''
} else {
content = String(datadesc || '').trim()
}
}
return {
id: dataid,
datatype,
sourcename,
sourcetime,
sourceheadurl,
datafmt,
duration,
fullmd5,
thumbfullmd5,
md5,
fromnewmsgid,
srcMsgLocalid,
srcMsgCreateTime,
cdnurlstring,
encrypturlstring,
externurl,
aeskey,
renderType,
title: outTitle,
recordItem,
url: outUrl,
content
}
})
return {
info: { isChatRoom, title, desc, count: datalistCount },
items: parsed
}
}
export const formatChatHistoryVideoDuration = (value) => {
const total = Math.max(0, parseInt(String(value || '').trim(), 10) || 0)
const minutes = Math.floor(total / 60)
const seconds = total % 60
if (minutes <= 0) return `0:${String(seconds).padStart(2, '0')}`
return `${minutes}:${String(seconds).padStart(2, '0')}`
}
export const createChatHistoryRecordNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => {
return (record) => {
const account = encodeURIComponent(String(getSelectedAccount?.() || '').trim())
const username = encodeURIComponent(String(getSelectedContact?.()?.username || '').trim())
const output = { ...(record || {}) }
output.senderDisplayName = String(output.sourcename || '').trim()
output.senderAvatar = normalizeChatHistoryUrl(output.sourceheadurl)
output.fullTime = String(output.sourcetime || '').trim()
if (output.renderType === 'link') {
const linkUrl = String(output.url || output.externurl || '').trim()
output.url = linkUrl
output.from = String(output.from || '').trim()
const previewCandidates = []
const fileId = (() => {
const localId = parseInt(String(output.srcMsgLocalid || '').trim(), 10) || 0
const createTime = parseInt(String(output.srcMsgCreateTime || '').trim(), 10) || 0
if (localId > 0 && createTime > 0) return `${localId}_${createTime}`
return ''
})()
if (fileId) {
previewCandidates.push(
`${apiBase}/chat/media/image?account=${account}&file_id=${encodeURIComponent(fileId)}&username=${username}`
)
}
output.previewMd5 = pickFirstMd5(output.fullmd5, output.thumbfullmd5, output.md5)
const srcServerId = String(output.fromnewmsgid || '').trim()
if (output.previewMd5) {
const previewParts = [
`account=${account}`,
`md5=${encodeURIComponent(output.previewMd5)}`,
srcServerId ? `server_id=${encodeURIComponent(srcServerId)}` : '',
`username=${username}`
].filter(Boolean)
previewCandidates.push(`${apiBase}/chat/media/image?${previewParts.join('&')}`)
}
output._linkPreviewCandidates = previewCandidates
output._linkPreviewCandidateIndex = 0
output._linkPreviewError = false
output.preview = previewCandidates[0] || ''
const fromUsername = String(output.fromUsername || '').trim()
output.fromUsername = fromUsername
output.fromAvatar = fromUsername
? `${apiBase}/chat/avatar?account=${account}&username=${encodeURIComponent(fromUsername)}`
: (linkUrl ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(linkUrl)}` : '')
output._fromAvatarLast = output.fromAvatar
output._fromAvatarImgOk = false
output._fromAvatarImgError = false
} else if (output.renderType === 'video') {
output.videoMd5 = pickFirstMd5(output.fullmd5, output.md5)
output.videoThumbMd5 = pickFirstMd5(output.thumbfullmd5)
output.videoDuration = String(output.duration || '').trim()
const thumbCandidates = []
if (output.videoMd5) {
thumbCandidates.push(`${apiBase}/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(output.videoMd5)}&username=${username}`)
}
if (output.videoThumbMd5 && output.videoThumbMd5 !== output.videoMd5) {
thumbCandidates.push(`${apiBase}/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(output.videoThumbMd5)}&username=${username}`)
}
output._videoThumbCandidates = thumbCandidates
output._videoThumbCandidateIndex = 0
output._videoThumbError = false
output.videoThumbUrl = thumbCandidates[0] || ''
output.videoUrl = output.videoMd5
? `${apiBase}/chat/media/video?account=${account}&md5=${encodeURIComponent(output.videoMd5)}&username=${username}`
: ''
if (!output.content || /^\[.+\]$/.test(String(output.content || '').trim())) output.content = '[视频]'
} else if (output.renderType === 'emoji') {
output.emojiMd5 = pickFirstMd5(output.md5, output.fullmd5, output.thumbfullmd5)
const remoteEmojiUrl = String(output.cdnurlstring || output.externurl || output.encrypturlstring || '').trim()
const remoteAesKey = String(output.aeskey || '').trim()
output.emojiRemoteUrl = remoteEmojiUrl
output.emojiUrl = output.emojiMd5
? `${apiBase}/chat/media/emoji?account=${account}&md5=${encodeURIComponent(output.emojiMd5)}&username=${username}${remoteEmojiUrl ? `&emoji_url=${encodeURIComponent(remoteEmojiUrl)}` : ''}${remoteAesKey ? `&aes_key=${encodeURIComponent(remoteAesKey)}` : ''}`
: ''
if (!output.content || /^\[.+\]$/.test(String(output.content || '').trim())) output.content = '[表情]'
} else if (output.renderType === 'image') {
output.imageMd5 = pickFirstMd5(output.fullmd5, output.thumbfullmd5, output.md5)
const srcServerId = String(output.fromnewmsgid || '').trim()
const imageParts = [
`account=${account}`,
output.imageMd5 ? `md5=${encodeURIComponent(output.imageMd5)}` : '',
srcServerId ? `server_id=${encodeURIComponent(srcServerId)}` : '',
`username=${username}`
].filter(Boolean)
output.imageUrl = imageParts.length ? `${apiBase}/chat/media/image?${imageParts.join('&')}` : ''
if (!output.content || /^\[.+\]$/.test(String(output.content || '').trim())) output.content = '[图片]'
}
return output
}
}
export const enhanceChatHistoryRecords = (records) => {
const list = Array.isArray(records) ? records : []
const videoByThumbMd5 = new Map()
const videoByMd5 = new Map()
const imageByMd5 = new Map()
const emojiByMd5 = new Map()
for (const record of list) {
if (!record) continue
if (record.renderType === 'video' && record.videoThumbMd5) {
videoByThumbMd5.set(String(record.videoThumbMd5).toLowerCase(), record)
}
if (record.renderType === 'video' && record.videoMd5) {
videoByMd5.set(String(record.videoMd5).toLowerCase(), record)
}
if (record.renderType === 'image') {
const keys = [
pickFirstMd5(record.imageMd5),
pickFirstMd5(record.fullmd5),
pickFirstMd5(record.thumbfullmd5)
].filter(Boolean)
for (const key of keys) imageByMd5.set(key, record)
}
if (record.renderType === 'emoji') {
const keys = [
pickFirstMd5(record.emojiMd5),
pickFirstMd5(record.md5),
pickFirstMd5(record.fullmd5),
pickFirstMd5(record.thumbfullmd5)
].filter(Boolean)
for (const key of keys) emojiByMd5.set(key, record)
}
}
for (const record of list) {
if (!record || String(record.renderType || '') !== 'text') continue
const refKey = pickFirstMd5(record.thumbfullmd5) || pickFirstMd5(record.fullmd5)
if (!refKey) continue
const video = videoByThumbMd5.get(refKey) || videoByMd5.get(refKey)
if (video) {
const quoteThumbCandidates = Array.isArray(video._videoThumbCandidates) ? video._videoThumbCandidates.slice() : []
record._quoteThumbCandidates = quoteThumbCandidates
record._quoteThumbCandidateIndex = 0
record._quoteThumbError = false
const quoteThumbUrl = quoteThumbCandidates[0] || video.videoThumbUrl || ''
record.renderType = 'quote'
record.quote = {
kind: 'video',
thumbUrl: quoteThumbUrl,
url: video.videoUrl || '',
duration: video.videoDuration || '',
label: video.content || '[视频]',
targetId: video.id || ''
}
record.quoteMedia = {
videoMd5: video.videoMd5,
videoThumbMd5: video.videoThumbMd5,
videoUrl: video.videoUrl,
videoThumbUrl: quoteThumbUrl
}
continue
}
const image = imageByMd5.get(refKey)
if (image) {
record.renderType = 'quote'
record.quote = {
kind: 'image',
thumbUrl: image.imageUrl || '',
url: image.imageUrl || '',
label: image.content || '[图片]',
targetId: image.id || ''
}
record.quoteMedia = {
imageMd5: image.imageMd5,
imageUrl: image.imageUrl
}
continue
}
const emoji = emojiByMd5.get(refKey)
if (emoji) {
record.renderType = 'quote'
record.quote = {
kind: 'emoji',
thumbUrl: emoji.emojiUrl || '',
url: emoji.emojiUrl || '',
label: emoji.content || '[表情]',
targetId: emoji.id || ''
}
record.quoteMedia = {
emojiMd5: emoji.emojiMd5,
emojiUrl: emoji.emojiUrl
}
}
}
return list
}
export const isChatHistoryRecordItemIncomplete = (recordItemXml) => {
const recordItem = String(recordItemXml || '').trim()
if (!recordItem) return true
try {
const parsed = parseChatHistoryRecord(recordItem)
const got = Array.isArray(parsed?.items) ? parsed.items.length : 0
const expected = Math.max(0, parseInt(String(parsed?.info?.count || '0'), 10) || 0)
if (expected > 0 && got < expected) return true
if (got <= 0) return true
} catch {
return true
}
return false
}
export const buildChatHistoryWindowPayload = (payload, normalizeRecordItem) => {
const title0 = String(payload?.title || '聊天记录')
const content0 = String(payload?.content || '')
const recordItem0 = String(payload?.recordItem || '').trim()
const parsed = parseChatHistoryRecord(recordItem0)
const info0 = parsed?.info || { isChatRoom: false, count: 0 }
const items = Array.isArray(parsed?.items) ? parsed.items : []
let records0 = items.length ? enhanceChatHistoryRecords(items.map(normalizeRecordItem)) : []
if (!records0.length) {
const lines = content0.trim().split(/\r?\n/).map((item) => item.trim()).filter(Boolean)
records0 = lines.map((line, idx) => normalizeRecordItem({
id: String(idx),
datatype: '1',
sourcename: '',
sourcetime: '',
content: line,
renderType: 'text'
}))
}
return { title0, content0, recordItem0, info0, records0 }
}
export { getChatHistoryPreviewLines }
+50
View File
@@ -0,0 +1,50 @@
import zipIconUrl from '~/assets/images/wechat/zip.png'
import pdfIconUrl from '~/assets/images/wechat/pdf.png'
import wordIconUrl from '~/assets/images/wechat/word.png'
import excelIconUrl from '~/assets/images/wechat/excel.png'
export const getFileIconKind = (fileName) => {
if (!fileName) return 'default'
const ext = String(fileName).split('.').pop()?.toLowerCase() || ''
switch (ext) {
case 'pdf':
return 'pdf'
case 'zip':
case 'rar':
case '7z':
case 'tar':
case 'gz':
return 'zip'
case 'doc':
case 'docx':
return 'doc'
case 'xls':
case 'xlsx':
case 'csv':
return 'xls'
case 'ppt':
case 'pptx':
return 'ppt'
case 'txt':
case 'md':
case 'log':
return 'txt'
default:
return 'default'
}
}
export const getFileIconUrl = (fileName) => {
switch (getFileIconKind(fileName)) {
case 'pdf':
return pdfIconUrl
case 'doc':
return wordIconUrl
case 'xls':
return excelIconUrl
case 'zip':
return zipIconUrl
default:
return ''
}
}
+211
View File
@@ -0,0 +1,211 @@
export const normalizeSessionPreview = (value) => {
const text = String(value || '').trim()
if (/^\[location\]/i.test(text)) return text.replace(/^\[location\]/i, '[位置]')
if (/:\s*\[location\]$/i.test(text)) return text.replace(/\[location\]$/i, '[位置]')
return text
}
export const formatSmartTime = (ts) => {
if (!ts) return ''
try {
const date = new Date(Number(ts) * 1000)
const now = new Date()
const hh = String(date.getHours()).padStart(2, '0')
const mm = String(date.getMinutes()).padStart(2, '0')
const timeStr = `${hh}:${mm}`
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const targetStart = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const dayDiff = Math.floor((todayStart - targetStart) / (1000 * 60 * 60 * 24))
if (dayDiff === 0) return timeStr
if (dayDiff === 1) return `昨天 ${timeStr}`
if (dayDiff >= 2 && dayDiff <= 6) {
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return `${weekdays[date.getDay()]} ${timeStr}`
}
const month = date.getMonth() + 1
const day = date.getDate()
if (date.getFullYear() === now.getFullYear()) {
return `${month}${day}${timeStr}`
}
return `${date.getFullYear()}${month}${day}${timeStr}`
} catch {
return ''
}
}
export const formatTimeDivider = (ts) => formatSmartTime(ts)
export const formatMessageTime = (ts) => {
if (!ts) return ''
try {
const date = new Date(Number(ts) * 1000)
const hh = String(date.getHours()).padStart(2, '0')
const mm = String(date.getMinutes()).padStart(2, '0')
return `${hh}:${mm}`
} catch {
return ''
}
}
export const formatMessageFullTime = (ts) => {
if (!ts) return ''
try {
const date = new Date(Number(ts) * 1000)
const yyyy = String(date.getFullYear())
const MM = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const mm = String(date.getMinutes()).padStart(2, '0')
const ss = String(date.getSeconds()).padStart(2, '0')
return `${yyyy}-${MM}-${dd} ${hh}:${mm}:${ss}`
} catch {
return ''
}
}
export const formatFileSize = (size) => {
if (!size) return ''
const text = String(size).trim()
const value = parseFloat(text)
if (Number.isNaN(value)) return text
if (value < 1024) return `${value} B`
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`
return `${(value / 1024 / 1024).toFixed(2)} MB`
}
export const formatTransferAmount = (amount) => {
const text = String(amount ?? '').trim()
if (!text) return ''
return text.replace(/[¥¥]/g, '').trim()
}
export const getRedPacketText = (message) => {
const text = String(message?.content ?? '').trim()
if (!text || text === '[Red Packet]') return '恭喜发财,大吉大利'
return text
}
export const isTransferReturned = (message) => {
const paySubType = String(message?.paySubType || '').trim()
if (paySubType === '4' || paySubType === '9') return true
const status = String(message?.transferStatus || '').trim()
const content = String(message?.content || '').trim()
const text = `${status} ${content}`.trim()
if (!text) return false
return text.includes('退回') || text.includes('退还')
}
export const isTransferOverdue = (message) => {
const paySubType = String(message?.paySubType || '').trim()
if (paySubType === '10') return true
const status = String(message?.transferStatus || '').trim()
const content = String(message?.content || '').trim()
const text = `${status} ${content}`.trim()
if (!text) return false
return text.includes('过期')
}
export const getTransferTitle = (message) => {
const paySubType = String(message?.paySubType || '').trim()
if (message?.transferStatus) return message.transferStatus
switch (paySubType) {
case '1':
return '转账'
case '3':
return message?.isSent ? '已被接收' : '已收款'
case '8':
return '发起转账'
case '4':
return '已退还'
case '9':
return '已被退还'
case '10':
return '已过期'
default:
break
}
if (message?.content && message.content !== '转账' && message.content !== '[转账]') {
return message.content
}
return '转账'
}
export const formatCount = (count) => {
const value = Number(count || 0)
if (!Number.isFinite(value) || value <= 0) return ''
try {
return value.toLocaleString()
} catch {
return String(value)
}
}
export const escapeHtml = (value) => {
if (!value) return ''
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
export const highlightKeyword = (text, keyword) => {
if (!text || !keyword) return escapeHtml(text || '')
const escaped = escapeHtml(text)
const kw = String(keyword || '').trim()
if (!kw) return escaped
try {
const escapedKw = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`(${escapedKw})`, 'gi')
return escaped.replace(regex, '<mark class="search-highlight">$1</mark>')
} catch {
return escaped
}
}
export const getVoiceDurationInSeconds = (durationMs) => {
const value = Number(durationMs || 0)
if (!Number.isFinite(value) || value <= 0) return 0
return Math.max(1, Math.round(value / 1000))
}
export const getVoiceWidth = (durationMs) => {
const seconds = getVoiceDurationInSeconds(durationMs)
const clamped = Math.min(60, Math.max(1, seconds))
return `${80 + clamped * 4}px`
}
export const toUnixSeconds = (datetimeLocal) => {
const value = String(datetimeLocal || '').trim()
if (!value) return null
const date = new Date(value)
const ms = date.getTime()
if (Number.isNaN(ms)) return null
return Math.floor(ms / 1000)
}
export const dateToUnixSeconds = (dateStr, endOfDay = false) => {
const value = String(dateStr || '').trim()
if (!value) return null
const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (!matched) return null
const year = Number(matched[1])
const month = Number(matched[2])
const day = Number(matched[3])
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
const date = new Date(year, month - 1, day, endOfDay ? 23 : 0, endOfDay ? 59 : 0, endOfDay ? 59 : 0)
const ms = date.getTime()
if (Number.isNaN(ms)) return null
return Math.floor(ms / 1000)
}
export const getChatHistoryPreviewLines = (message) => {
const raw = String(message?.content || '').trim()
if (!raw) return []
return raw.split(/\r?\n/).map((item) => item.trim()).filter(Boolean).slice(0, 4)
}
+265
View File
@@ -0,0 +1,265 @@
import { formatMessageFullTime, formatMessageTime } from '~/lib/chat/formatters'
const normalizeMaybeUrl = (value) => (typeof value === 'string' ? value.trim() : '')
const isUsableMediaUrl = (value) => {
const text = normalizeMaybeUrl(value)
if (!text) return false
return (
/^https?:\/\//i.test(text)
|| /^blob:/i.test(text)
|| /^data:/i.test(text)
|| /^\/api\/chat\/media\//i.test(text)
)
}
const buildAccountMediaUrl = (apiBase, path, parts) => {
return `${apiBase}${path}?${parts.filter(Boolean).join('&')}`
}
export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => {
return (msg) => {
const account = String(getSelectedAccount?.() || '').trim()
const contact = getSelectedContact?.() || null
const username = String(contact?.username || '').trim()
const isSent = !!msg.isSent
const sender = isSent ? '我' : (msg.senderDisplayName || msg.senderUsername || contact?.name || '')
const fallbackAvatar = (!isSent && !contact?.isGroup) ? (contact?.avatar || null) : null
const normalizedThumbUrl = (() => {
const candidates = [msg.thumbUrl, msg.preview]
for (const candidate of candidates) {
if (isUsableMediaUrl(candidate)) return normalizeMaybeUrl(candidate)
}
return ''
})()
const normalizedLinkPreviewUrl = (() => {
const url = normalizedThumbUrl
if (!url) return ''
if (/^\/api\/chat\/media\//i.test(url) || /^blob:/i.test(url) || /^data:/i.test(url)) return url
if (!/^https?:\/\//i.test(url)) return url
try {
const host = new URL(url).hostname.toLowerCase()
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(url)}`
}
} catch {}
return url
})()
const fromUsername = String(msg.fromUsername || '').trim()
const fromAvatar = fromUsername
? `${apiBase}/chat/avatar?account=${encodeURIComponent(account)}&username=${encodeURIComponent(fromUsername)}`
: (() => {
const href = String(msg.url || '').trim()
return href ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(href)}` : ''
})()
const localEmojiUrl = msg.emojiMd5
? `${apiBase}/chat/media/emoji?account=${encodeURIComponent(account)}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(username)}`
: ''
const localImageUrl = (() => {
if (!msg.imageMd5 && !msg.imageFileId) return ''
return buildAccountMediaUrl(apiBase, '/chat/media/image', [
`account=${encodeURIComponent(account)}`,
msg.imageMd5 ? `md5=${encodeURIComponent(msg.imageMd5)}` : '',
msg.imageFileId ? `file_id=${encodeURIComponent(msg.imageFileId)}` : '',
`username=${encodeURIComponent(username)}`
])
})()
const normalizedImageUrl = (() => {
const current = isUsableMediaUrl(msg.imageUrl) ? normalizeMaybeUrl(msg.imageUrl) : ''
if (current && /\/api\/chat\/media\/image\b/i.test(current) && localImageUrl) {
return localImageUrl
}
return current || localImageUrl || ''
})()
const normalizedEmojiUrl = msg.emojiUrl || localEmojiUrl
const localVideoThumbUrl = (() => {
if (!msg.videoThumbMd5 && !msg.videoThumbFileId) return ''
return buildAccountMediaUrl(apiBase, '/chat/media/video_thumb', [
`account=${encodeURIComponent(account)}`,
msg.videoThumbMd5 ? `md5=${encodeURIComponent(msg.videoThumbMd5)}` : '',
msg.videoThumbFileId ? `file_id=${encodeURIComponent(msg.videoThumbFileId)}` : '',
`username=${encodeURIComponent(username)}`
])
})()
const localVideoUrl = (() => {
if (!msg.videoMd5 && !msg.videoFileId) return ''
return buildAccountMediaUrl(apiBase, '/chat/media/video', [
`account=${encodeURIComponent(account)}`,
msg.videoMd5 ? `md5=${encodeURIComponent(msg.videoMd5)}` : '',
msg.videoFileId ? `file_id=${encodeURIComponent(msg.videoFileId)}` : '',
`username=${encodeURIComponent(username)}`
])
})()
const normalizedVideoThumbUrl = (isUsableMediaUrl(msg.videoThumbUrl) ? normalizeMaybeUrl(msg.videoThumbUrl) : '') || localVideoThumbUrl
const normalizedVideoUrl = (isUsableMediaUrl(msg.videoUrl) ? normalizeMaybeUrl(msg.videoUrl) : '') || localVideoUrl
const serverIdStr = String(msg.serverIdStr || (msg.serverId != null ? String(msg.serverId) : '')).trim()
const normalizedVoiceUrl = (() => {
if (msg.voiceUrl) return msg.voiceUrl
if (!serverIdStr) return ''
if (String(msg.renderType || '') !== 'voice') return ''
return `${apiBase}/chat/media/voice?account=${encodeURIComponent(account)}&server_id=${encodeURIComponent(serverIdStr)}`
})()
const remoteFromServer = (
typeof msg.emojiRemoteUrl === 'string'
&& /^https?:\/\//i.test(msg.emojiRemoteUrl)
&& !/\/api\/chat\/media\/emoji\b/i.test(msg.emojiRemoteUrl)
&& !/\blocalhost\b/i.test(msg.emojiRemoteUrl)
&& !/\b127\.0\.0\.1\b/i.test(msg.emojiRemoteUrl)
) ? msg.emojiRemoteUrl : ''
const remoteFromEmojiUrl = (
typeof msg.emojiUrl === 'string'
&& /^https?:\/\//i.test(msg.emojiUrl)
&& !/\/api\/chat\/media\/emoji\b/i.test(msg.emojiUrl)
&& !/\blocalhost\b/i.test(msg.emojiUrl)
&& !/\b127\.0\.0\.1\b/i.test(msg.emojiUrl)
) ? msg.emojiUrl : ''
const emojiRemoteUrl = remoteFromServer || remoteFromEmojiUrl
const emojiIsLocal = typeof normalizedEmojiUrl === 'string' && /\/api\/chat\/media\/emoji\b/i.test(normalizedEmojiUrl)
const emojiDownloaded = !!emojiRemoteUrl && !!emojiIsLocal
const replyText = String(msg.content || '').trim()
let quoteContent = String(msg.quoteContent || '')
const trimmedQuoteContent = quoteContent.trim()
if (replyText && trimmedQuoteContent) {
if (trimmedQuoteContent === replyText) {
quoteContent = ''
} else {
const lines = trimmedQuoteContent.split(/\r?\n/).map((item) => item.trim())
if (lines.length && (lines[0] === replyText || lines[0] === replyText.split(/\r?\n/)[0]?.trim())) {
quoteContent = trimmedQuoteContent.split(/\r?\n/).slice(1).join('\n').trim()
} else if (trimmedQuoteContent.startsWith(replyText)) {
quoteContent = trimmedQuoteContent.slice(replyText.length).trim()
}
}
}
const quoteServerIdStr = String(msg.quoteServerId || '').trim()
const quoteTypeStr = String(msg.quoteType || '').trim()
const quoteVoiceUrl = quoteServerIdStr
? `${apiBase}/chat/media/voice?account=${encodeURIComponent(account)}&server_id=${encodeURIComponent(quoteServerIdStr)}`
: ''
const quoteImageUrl = (() => {
if (!quoteServerIdStr) return ''
if (quoteTypeStr !== '3' && String(msg.quoteContent || '').trim() !== '[图片]') return ''
return buildAccountMediaUrl(apiBase, '/chat/media/image', [
`account=${encodeURIComponent(account)}`,
`server_id=${encodeURIComponent(quoteServerIdStr)}`,
username ? `username=${encodeURIComponent(username)}` : ''
])
})()
const quoteThumbUrl = (() => {
const raw = isUsableMediaUrl(msg.quoteThumbUrl) ? normalizeMaybeUrl(msg.quoteThumbUrl) : ''
if (!raw) return ''
if (/^\/api\/chat\/media\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw
if (!/^https?:\/\//i.test(raw)) return raw
try {
const host = new URL(raw).hostname.toLowerCase()
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
}
} catch {}
return raw
})()
return {
id: msg.id,
serverId: msg.serverId || 0,
serverIdStr,
sender,
senderUsername: msg.senderUsername || '',
senderDisplayName: msg.senderDisplayName || '',
content: msg.content || '',
time: formatMessageTime(msg.createTime),
fullTime: formatMessageFullTime(msg.createTime),
createTime: Number(msg.createTime || 0),
isSent,
type: 'text',
renderType: msg.renderType || 'text',
voipType: msg.voipType || '',
title: msg.title || '',
url: msg.url || '',
recordItem: msg.recordItem || '',
imageMd5: msg.imageMd5 || '',
imageFileId: msg.imageFileId || '',
emojiMd5: msg.emojiMd5 || '',
emojiUrl: normalizedEmojiUrl || '',
emojiLocalUrl: localEmojiUrl || '',
emojiRemoteUrl,
_emojiDownloaded: !!emojiDownloaded,
thumbUrl: msg.thumbUrl || '',
imageUrl: normalizedImageUrl || '',
videoMd5: msg.videoMd5 || '',
videoThumbMd5: msg.videoThumbMd5 || '',
videoFileId: msg.videoFileId || '',
videoThumbFileId: msg.videoThumbFileId || '',
videoThumbUrl: normalizedVideoThumbUrl || '',
videoUrl: normalizedVideoUrl || '',
quoteTitle: msg.quoteTitle || '',
quoteContent,
quoteUsername: msg.quoteUsername || '',
quoteServerId: quoteServerIdStr,
quoteType: quoteTypeStr,
quoteVoiceLength: msg.quoteVoiceLength || '',
quoteVoiceUrl,
quoteImageUrl: quoteImageUrl || '',
quoteThumbUrl: quoteThumbUrl || '',
_quoteImageError: false,
_quoteThumbError: false,
amount: msg.amount || '',
coverUrl: msg.coverUrl || '',
fileSize: msg.fileSize || '',
fileMd5: msg.fileMd5 || '',
paySubType: msg.paySubType || '',
transferStatus: msg.transferStatus || '',
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(),
linkCardVariant: String(msg.linkStyle || '').trim() === 'cover' ? 'cover' : 'default',
from: String(msg.from || '').trim(),
fromUsername,
fromAvatar,
isGroup: !!contact?.isGroup,
avatar: msg.senderAvatar || msg.avatar || fallbackAvatar || null,
avatarColor: null
}
}
}
export const dedupeMessagesById = (list) => {
const input = Array.isArray(list) ? list : []
const seen = new Set()
const output = []
for (const item of input) {
const id = String(item?.id || '')
if (!id) {
output.push(item)
continue
}
if (seen.has(id)) continue
seen.add(id)
output.push(item)
}
return output
}
+206
View File
@@ -0,0 +1,206 @@
import { useApiBase } from '~/composables/useApiBase'
const FRONTEND_SERVER_ERROR_ENDPOINT = '/admin/log-frontend-server-error'
const normalizeStatus = (value) => {
const n = Number(value)
if (!Number.isInteger(n)) return 0
return n
}
const stringifyDetail = (value) => {
if (value == null) return ''
if (typeof value === 'string') return value.trim()
try {
return JSON.stringify(value)
} catch {
return String(value).trim()
}
}
const currentOrigin = () => {
if (!process.client || typeof window === 'undefined') return ''
try {
return String(window.location?.origin || '').trim()
} catch {
return ''
}
}
const normalizeBasePath = (apiBase) => {
const raw = String(apiBase || '').trim()
if (!raw) return '/api'
if (/^https?:\/\//i.test(raw)) {
try {
const u = new URL(raw)
return u.pathname.replace(/\/+$/, '') || '/'
} catch {
return '/api'
}
}
return raw.replace(/\/+$/, '') || '/'
}
const normalizePathname = (value) => {
const raw = String(value || '').trim()
if (!raw) return ''
try {
return new URL(raw).pathname.replace(/\/+$/, '')
} catch {
return raw.split(/[?#]/, 1)[0].replace(/\/+$/, '')
}
}
export const isServerErrorStatus = (status) => normalizeStatus(status) >= 500
export const resolveRequestUrl = (requestUrl, apiBase = '') => {
const raw = String(requestUrl || '').trim()
if (!raw) return ''
if (/^https?:\/\//i.test(raw)) return raw
const origin = currentOrigin()
if (!origin) return raw
if (raw.startsWith('/')) {
const prefix = normalizeBasePath(apiBase)
const combined = raw === prefix || raw.startsWith(`${prefix}/`) ? raw : `${prefix}${raw}`
if (/^https?:\/\//i.test(String(apiBase || '').trim())) {
try {
const baseUrl = new URL(String(apiBase).trim())
return new URL(combined, `${baseUrl.origin}/`).toString()
} catch {
return new URL(combined, origin).toString()
}
}
return new URL(combined, origin).toString()
}
if (/^https?:\/\//i.test(String(apiBase || '').trim())) {
try {
const base = String(apiBase).trim()
return new URL(raw, base.endsWith('/') ? base : `${base}/`).toString()
} catch {
return new URL(raw, origin).toString()
}
}
return new URL(raw, origin).toString()
}
const isFrontendServerLogUrl = (requestUrl) => {
const path = normalizePathname(requestUrl)
return path.endsWith('/api/admin/log-frontend-server-error') || path.endsWith('/admin/log-frontend-server-error')
}
const extractBackendDetail = (data) => {
if (data == null) return ''
if (typeof data === 'string') return data.trim()
if (typeof data === 'object' && !Array.isArray(data) && Object.prototype.hasOwnProperty.call(data, 'detail')) {
return stringifyDetail(data.detail)
}
return stringifyDetail(data)
}
const resolveApiBase = (apiBase) => {
const raw = String(apiBase || '').trim()
if (raw) return raw
if (!process.client) return ''
try {
return String(useApiBase() || '').trim()
} catch {
return ''
}
}
export const extractServerErrorFromError = (error) => {
const response = error?.response
return {
status: normalizeStatus(error?.status ?? response?.status),
backendDetail: extractBackendDetail(response?._data ?? response?.data ?? error?.data),
message: String(error?.message || '').trim(),
requestUrl: String(response?.url || error?.request || '').trim(),
}
}
export const extractServerErrorDetailFromResponse = async (response) => {
if (!response || typeof response.clone !== 'function') return ''
try {
const clone = response.clone()
const contentType = String(clone.headers?.get?.('content-type') || '').toLowerCase()
if (contentType.includes('json')) {
try {
const payload = await clone.json()
return extractBackendDetail(payload)
} catch {}
}
const text = String(await clone.text()).trim()
if (!text) return ''
if (contentType.includes('json')) {
try {
return extractBackendDetail(JSON.parse(text))
} catch {}
}
return text
} catch {
return ''
}
}
export const reportServerError = async (context = {}) => {
if (!process.client || typeof window === 'undefined') return false
const status = normalizeStatus(context.status)
if (!isServerErrorStatus(status)) return false
const apiBase = resolveApiBase(context.apiBase)
const requestUrl = resolveRequestUrl(context.requestUrl, apiBase)
if (!requestUrl || isFrontendServerLogUrl(requestUrl)) return false
const endpointUrl = resolveRequestUrl(FRONTEND_SERVER_ERROR_ENDPOINT, apiBase)
if (!endpointUrl) return false
const payload = {
status,
method: String(context.method || 'GET').trim().toUpperCase() || 'GET',
request_url: requestUrl,
message: String(context.message || '').trim(),
backend_detail: String(context.backendDetail || '').trim(),
source: String(context.source || '').trim(),
page_url: String(window.location?.href || '').trim(),
}
try {
await fetch(endpointUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
})
return true
} catch {
return false
}
}
export const reportServerErrorFromError = async (error, context = {}) => {
const info = extractServerErrorFromError(error)
return await reportServerError({
...context,
status: context.status ?? info.status,
requestUrl: context.requestUrl || info.requestUrl,
message: context.message || info.message,
backendDetail: context.backendDetail || info.backendDetail,
})
}
export const reportServerErrorFromResponse = async (response, context = {}) => {
const status = normalizeStatus(context.status ?? response?.status)
if (!isServerErrorStatus(status)) return false
const backendDetail = context.backendDetail || (await extractServerErrorDetailFromResponse(response))
return await reportServerError({
...context,
status,
requestUrl: context.requestUrl || response?.url || '',
backendDetail,
})
}
+15 -1
View File
@@ -1,10 +1,18 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
const frontendHost = String(process.env.NUXT_HOST || '').trim()
const frontendPort = Number.parseInt(String(process.env.NUXT_PORT || process.env.PORT || '3000').trim(), 10)
const backendPort = String(process.env.WECHAT_TOOL_PORT || '10392').trim() || '10392'
const devProxyTarget = `http://127.0.0.1:${backendPort}/api`
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
experimental: {
// This app does not use Nuxt route rules on the client, so disabling
// the app manifest avoids an unnecessary `/_nuxt/builds/meta/dev.json`
// preload request and the related Chrome warning in dev mode.
appManifest: false,
},
runtimeConfig: {
public: {
@@ -16,7 +24,8 @@ export default defineNuxtConfig({
// 配置前端开发服务器端口
devServer: {
port: 3000
...(frontendHost ? { host: frontendHost } : {}),
port: Number.isInteger(frontendPort) && frontendPort >= 1 && frontendPort <= 65535 ? frontendPort : 3000
},
// 配置API代理,解决跨域问题
@@ -31,6 +40,11 @@ export default defineNuxtConfig({
}
},
// 应用配置
css: [
'~/assets/css/chat.css'
],
// 应用配置
app: {
head: {
+2754 -4856
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -68,7 +68,7 @@
<script setup>
import { onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
onMounted(async () => {
if (!process.client || typeof window === 'undefined') return
+125 -13
View File
@@ -638,7 +638,19 @@
>
<div class="relative max-w-[92vw] max-h-[92vh] flex flex-col items-center" @click.stop>
<video
v-if="previewLivePhotoVideoSrc && !previewHasLivePhotoVideoError"
v-if="previewIsVideo"
ref="previewVideoEl"
:key="previewVideoKey"
:src="previewVideoSrc"
:poster="previewVideoPoster"
class="max-w-[90vw] max-h-[70vh] object-contain"
controls
autoplay
playsinline
@error="onPreviewVideoError"
></video>
<video
v-else-if="previewLivePhotoVideoSrc && !previewHasLivePhotoVideoError"
ref="previewLiveVideoEl"
:src="previewLivePhotoVideoSrc"
:poster="previewSrc"
@@ -651,6 +663,13 @@
></video>
<img v-else :src="previewSrc" alt="预览" class="max-w-[90vw] max-h-[70vh] object-contain" />
<div
v-if="previewIsVideo && previewVideoError"
class="mt-3 text-xs text-red-200 whitespace-pre-wrap text-center max-w-[90vw]"
>
{{ previewVideoError }}
</div>
</div>
<button
@@ -686,8 +705,9 @@
import { storeToRefs } from 'pinia'
import { useChatAccountsStore } from '~/stores/chatAccounts'
import { usePrivacyStore } from '~/stores/privacy'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
import { reportServerErrorFromError } from '~/lib/server-error-logging'
useHead({ title: '朋友圈 - 微信数据分析助手' })
@@ -1112,6 +1132,12 @@ const loadSelfInfo = async () => {
selfInfo.value = resp
}
} catch (e) {
await reportServerErrorFromError(e, {
method: 'GET',
requestUrl: `${apiBase}/sns/self_info?account=${encodeURIComponent(selectedAccount.value)}`,
source: 'sns.loadSelfInfo',
apiBase,
})
console.error('获取个人信息失败', e)
}
}
@@ -1756,6 +1782,53 @@ const previewSrc = computed(() => {
return getMediaPreviewSrc(ctx.post, ctx.media, ctx.idx)
})
const previewVideoEl = ref(null)
const previewVideoMode = ref('') // 'local' | 'remote' | 'raw'
const previewVideoError = ref('')
const previewVideoTried = reactive({ local: false, remote: false, raw: false })
const resetPreviewVideo = () => {
previewVideoMode.value = ''
previewVideoError.value = ''
previewVideoTried.local = false
previewVideoTried.remote = false
previewVideoTried.raw = false
}
const previewIsVideo = computed(() => {
const ctx = previewCtx.value
if (!ctx) return false
return Number(ctx.media?.type || 0) === 6
})
const previewVideoPoster = computed(() => {
const ctx = previewCtx.value
if (!ctx) return ''
if (Number(ctx.media?.type || 0) !== 6) return ''
return getMediaThumbSrc(ctx.post, ctx.media, ctx.idx) || ''
})
const previewVideoSrc = computed(() => {
const ctx = previewCtx.value
if (!ctx) return ''
if (Number(ctx.media?.type || 0) !== 6) return ''
const local = getSnsVideoUrl(ctx.post?.id, ctx.media?.id)
const remote = getSnsRemoteVideoSrc(ctx.post, ctx.media)
const raw = upgradeTencentHttps(String(ctx.media?.url || '').trim())
const mode = String(previewVideoMode.value || '').toLowerCase()
if (mode === 'local') return local
if (mode === 'remote') return remote
if (mode === 'raw') return raw
return local || remote || raw || ''
})
const previewVideoKey = computed(() => {
if (!previewIsVideo.value) return ''
return `${String(previewVideoMode.value || '')}:${String(previewVideoSrc.value || '')}`
})
const previewLivePhotoVideoSrc = computed(() => {
const ctx = previewCtx.value
if (!ctx) return ''
@@ -1879,6 +1952,7 @@ const loadPreviewCandidates = async ({ reset }) => {
const openImagePreview = async (post, m, idx = 0) => {
if (!process.client) return
resetPreviewVideo()
// Stop any background hover-playing live photo when opening the preview.
activeLivePhotoKey.value = ''
// Preview is an intentional action; allow retry even if hover playback failed once.
@@ -1898,11 +1972,58 @@ const openImagePreview = async (post, m, idx = 0) => {
await loadPreviewCandidates({ reset: true })
}
const openVideoPreview = (post, m, idx = 0) => {
if (!process.client) return
resetPreviewVideo()
activeLivePhotoKey.value = ''
const local = getSnsVideoUrl(post?.id, m?.id)
const remote = getSnsRemoteVideoSrc(post, m)
const raw = upgradeTencentHttps(String(m?.url || '').trim())
if (local) previewVideoMode.value = 'local'
else if (remote) previewVideoMode.value = 'remote'
else if (raw) previewVideoMode.value = 'raw'
else previewVideoError.value = '视频地址缺失。'
previewCtx.value = { post, media: m, idx: Number(idx) || 0 }
previewCandidatesOpen.value = false
resetPreviewCandidates()
document.body.style.overflow = 'hidden'
}
const onPreviewVideoError = () => {
const ctx = previewCtx.value
if (!ctx) return
if (Number(ctx.media?.type || 0) !== 6) return
const current = String(previewVideoMode.value || '').toLowerCase()
if (current === 'local') previewVideoTried.local = true
if (current === 'remote') previewVideoTried.remote = true
if (current === 'raw') previewVideoTried.raw = true
// Fallback order: local -> remote -> raw
const remote = getSnsRemoteVideoSrc(ctx.post, ctx.media)
if (!previewVideoTried.remote && remote) {
previewVideoMode.value = 'remote'
return
}
const raw = upgradeTencentHttps(String(ctx.media?.url || '').trim())
if (!previewVideoTried.raw && raw) {
previewVideoMode.value = 'raw'
return
}
previewVideoError.value = '视频加载失败:可能是本地缓存不存在,或远程下载/解密失败。'
}
const closeImagePreview = () => {
if (!process.client) return
previewCtx.value = null
previewCandidatesOpen.value = false
resetPreviewCandidates()
resetPreviewVideo()
document.body.style.overflow = ''
}
@@ -1912,16 +2033,7 @@ const onMediaClick = (post, m, idx = 0) => {
// 视频点击逻辑
if (mt === 6) {
// Open a playable mp4 via backend (downloads+decrypts as needed).
const remoteUrl = getSnsRemoteVideoSrc(post, m)
if (remoteUrl) {
window.open(remoteUrl, '_blank', 'noopener,noreferrer')
return
}
// Last-resort: open raw CDN url.
const u = String(m?.url || '').trim()
if (u) window.open(u, '_blank', 'noopener,noreferrer')
openVideoPreview(post, m, idx)
return
}
+13 -8
View File
@@ -1,7 +1,8 @@
// 客户端插件:检查API连接状态
export default defineNuxtPlugin(async (nuxtApp) => {
export default defineNuxtPlugin((nuxtApp) => {
const { healthCheck } = useApi()
const appStore = useAppStore()
let intervalId = 0
// 检查API连接
const checkApiConnection = async () => {
@@ -17,10 +18,14 @@ export default defineNuxtPlugin(async (nuxtApp) => {
console.error('API连接失败:', error)
}
}
// 初始检查
await checkApiConnection()
// 定期检查(每30秒)
setInterval(checkApiConnection, 30000)
})
nuxtApp.hook('app:mounted', () => {
void checkApiConnection()
if (!intervalId) {
intervalId = window.setInterval(() => {
void checkApiConnection()
}, 30000)
}
})
})
+6 -2
View File
@@ -9,6 +9,11 @@ export const useChatAccountsStore = defineStore('chatAccounts', () => {
const error = ref('')
const loaded = ref(false)
// Capture apiBase during synchronous store setup when Nuxt context is available.
// useApiBase() calls useRuntimeConfig() which requires the Nuxt app context;
// that context can be lost inside deferred async functions (e.g. onMounted callbacks).
const _apiBase = useApiBase()
let loadPromise = null
const readSelectedAccount = () => {
@@ -64,8 +69,7 @@ export const useChatAccountsStore = defineStore('chatAccounts', () => {
}
try {
const api = useApi()
const resp = await api.listChatAccounts()
const resp = await $fetch('/chat/accounts', { baseURL: _apiBase })
const nextAccounts = Array.isArray(resp?.accounts) ? resp.accounts : []
accounts.value = nextAccounts
+1 -1
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { readPrivacyMode, writePrivacyMode } from '~/utils/privacy-mode'
import { readPrivacyMode, writePrivacyMode } from '~/lib/privacy-mode'
export const usePrivacyStore = defineStore('privacy', () => {
const privacyMode = ref(false)
+38 -4
View File
@@ -15,6 +15,7 @@ from .logging_config import setup_logging, get_logger
# 初始化日志系统
setup_logging()
logger = get_logger(__name__)
request_logger = get_logger("wechat_decrypt_tool.request")
from . import __version__ as APP_VERSION
from .path_fix import PathFixRoute
@@ -32,6 +33,7 @@ from .routers.sns import router as _sns_router
from .routers.sns_export import router as _sns_export_router
from .routers.wechat_detection import router as _wechat_detection_router
from .routers.wrapped import router as _wrapped_router
from .request_logging import log_server_errors_middleware
from .sns_stage_timing import add_sns_stage_timing_headers
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
@@ -76,6 +78,11 @@ async def _add_sns_stage_timing_headers(request: Request, call_next):
return response
@app.middleware("http")
async def _log_server_errors(request: Request, call_next):
return await log_server_errors_middleware(request_logger, request, call_next)
app.include_router(_health_router)
app.include_router(_admin_router)
app.include_router(_wechat_detection_router)
@@ -99,9 +106,36 @@ class _SPAStaticFiles(StaticFiles):
self._fallback_200 = Path(str(self.directory)) / "200.html"
self._fallback_index = Path(str(self.directory)) / "index.html"
async def get_response(self, path: str, scope): # type: ignore[override]
@staticmethod
def _normalize_path(path: str) -> str:
return str(path or "").strip().lstrip("/")
@classmethod
def _is_shell_path(cls, path: str) -> bool:
normalized = cls._normalize_path(path)
return normalized in {"", "index.html", "200.html", "_payload.json"} or normalized.startswith(
"_payload.json/"
)
@classmethod
def _apply_cache_headers(cls, path: str, response):
normalized = cls._normalize_path(path)
try:
return await super().get_response(path, scope)
if cls._is_shell_path(normalized):
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
elif normalized.startswith("_nuxt/"):
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
except Exception:
pass
return response
async def get_response(self, path: str, scope): # type: ignore[override]
normalized = self._normalize_path(path)
try:
response = await super().get_response(path, scope)
return self._apply_cache_headers(normalized, response)
except StarletteHTTPException as exc:
if exc.status_code != 404:
raise
@@ -112,8 +146,8 @@ class _SPAStaticFiles(StaticFiles):
raise
if self._fallback_200.exists():
return FileResponse(str(self._fallback_200))
return FileResponse(str(self._fallback_index))
return self._apply_cache_headers("200.html", FileResponse(str(self._fallback_200)))
return self._apply_cache_headers("index.html", FileResponse(str(self._fallback_index)))
def _maybe_mount_frontend() -> None:
@@ -485,3 +485,30 @@ def update_message_edit_local_id(
conn.close()
except Exception:
pass
def delete_account_edits(account: str) -> int:
a = str(account or "").strip()
if not a:
return 0
conn: Optional[sqlite3.Connection] = None
try:
conn = _connect()
cur = conn.execute(
"""
DELETE FROM message_edits
WHERE account = ?
""",
(a,),
)
conn.commit()
return int(getattr(cur, "rowcount", 0) or 0)
except Exception:
return 0
finally:
try:
if conn is not None:
conn.close()
except Exception:
pass
+98 -41
View File
@@ -53,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,
@@ -121,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:
@@ -622,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; }
@@ -4958,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()
@@ -5982,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)
+17
View File
@@ -67,3 +67,20 @@ def upsert_account_keys_in_store(
pass
return item
def remove_account_keys_from_store(account: str) -> bool:
account = str(account or "").strip()
if not account:
return False
store = load_account_keys_store()
if account not in store:
return False
try:
store.pop(account, None)
_atomic_write_json(_KEY_STORE_PATH, store)
return True
except Exception:
return False
+108
View File
@@ -1964,6 +1964,114 @@ def _convert_silk_to_wav(silk_data: bytes) -> bytes:
return silk_data
def _looks_like_mp3(data: bytes) -> bool:
if not data:
return False
if data.startswith(b"ID3"):
return True
return len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0
@lru_cache(maxsize=1)
def _find_ffmpeg_executable() -> str:
import shutil
env_value = str(os.environ.get("WECHAT_TOOL_FFMPEG") or "").strip()
if env_value:
resolved = shutil.which(env_value)
if resolved:
return resolved
candidate = Path(env_value).expanduser()
if candidate.is_file():
return str(candidate)
return shutil.which("ffmpeg") or ""
def _convert_wav_to_mp3(wav_data: bytes) -> bytes:
import subprocess
import tempfile
if not wav_data or not wav_data.startswith(b"RIFF"):
return b""
ffmpeg_exe = _find_ffmpeg_executable()
if not ffmpeg_exe:
return b""
try:
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
wav_path = tmp_path / "voice.wav"
mp3_path = tmp_path / "voice.mp3"
wav_path.write_bytes(wav_data)
proc = subprocess.run(
[
ffmpeg_exe,
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(wav_path),
"-vn",
"-codec:a",
"libmp3lame",
"-q:a",
"4",
str(mp3_path),
],
check=False,
capture_output=True,
)
if proc.returncode != 0 or not mp3_path.exists():
err = proc.stderr.decode("utf-8", errors="ignore").strip()
if err:
logger.warning(f"WAV to MP3 conversion failed: {err}")
return b""
mp3_data = mp3_path.read_bytes()
if _looks_like_mp3(mp3_data):
return mp3_data
except Exception as e:
logger.warning(f"WAV to MP3 conversion failed: {e}")
return b""
def _convert_silk_to_browser_audio(
silk_data: bytes,
*,
preferred_format: str = "mp3",
) -> tuple[bytes, str, str]:
"""Convert SILK audio to a browser-friendly format.
Returns `(payload, ext, media_type)`.
Preference order:
1) MP3 if ffmpeg is available
2) WAV if SILK decoding succeeds
3) original SILK bytes as a last-resort fallback
"""
data = bytes(silk_data or b"")
if not data:
return b"", "silk", "audio/silk"
if _looks_like_mp3(data):
return data, "mp3", "audio/mpeg"
wav_data = data if data.startswith(b"RIFF") else _convert_silk_to_wav(data)
if wav_data.startswith(b"RIFF"):
if str(preferred_format or "").strip().lower() == "mp3":
mp3_data = _convert_wav_to_mp3(wav_data)
if mp3_data:
return mp3_data, "mp3", "audio/mpeg"
return wav_data, "wav", "audio/wav"
return data, "silk", "audio/silk"
def _resolve_media_path_for_kind(
account_dir: Path,
kind: str,
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import json
from typing import Any
from starlette.requests import Request
from starlette.responses import Response
def _stringify_detail(detail: Any) -> str:
if detail is None:
return ""
if isinstance(detail, str):
return detail.strip()
try:
return json.dumps(detail, ensure_ascii=False)
except Exception:
return str(detail).strip()
def _extract_response_detail(response: Response) -> str:
body = getattr(response, "body", None)
if body is None:
return ""
try:
raw = body.tobytes() if isinstance(body, memoryview) else body
except Exception:
raw = body
if isinstance(raw, bytes):
text = raw.decode("utf-8", errors="ignore").strip()
else:
text = str(raw).strip()
if not text:
return ""
content_type = str(response.headers.get("content-type") or "").lower()
if "json" not in content_type:
return ""
try:
payload = json.loads(text)
except Exception:
return ""
if not isinstance(payload, dict):
return ""
return _stringify_detail(payload.get("detail"))
async def _buffer_response_body(response: Response) -> tuple[Response, bytes]:
body = getattr(response, "body", None)
if body is not None:
try:
raw = body.tobytes() if isinstance(body, memoryview) else body
except Exception:
raw = body
if isinstance(raw, bytes):
return response, raw
if isinstance(raw, str):
return response, raw.encode("utf-8")
return response, bytes(raw)
chunks: list[bytes] = []
body_iterator = getattr(response, "body_iterator", None)
if body_iterator is not None:
async for chunk in body_iterator:
if isinstance(chunk, memoryview):
chunks.append(chunk.tobytes())
elif isinstance(chunk, bytes):
chunks.append(chunk)
else:
chunks.append(str(chunk).encode("utf-8"))
body_bytes = b"".join(chunks)
rebuilt = Response(
content=body_bytes,
status_code=response.status_code,
headers=dict(response.headers),
media_type=response.media_type,
background=response.background,
)
return rebuilt, body_bytes
def _extract_response_detail_from_body(response: Response, body: bytes) -> str:
if not body:
return ""
try:
text = body.decode("utf-8", errors="ignore").strip()
except Exception:
return ""
if not text:
return ""
content_type = str(response.headers.get("content-type") or "").lower()
if "json" not in content_type:
return ""
try:
payload = json.loads(text)
except Exception:
return ""
if not isinstance(payload, dict):
return ""
return _stringify_detail(payload.get("detail"))
async def log_server_errors_middleware(logger, request: Request, call_next):
method = str(request.method or "").upper() or "GET"
path = str(request.url.path or "").strip() or "/"
try:
response = await call_next(request)
except Exception as exc:
logger.exception("[server-exception] method=%s path=%s error=%s", method, path, exc)
raise
status = int(getattr(response, "status_code", 0) or 0)
if status >= 500:
response, body = await _buffer_response_body(response)
detail = _extract_response_detail_from_body(response, body) or _extract_response_detail(response)
if detail:
logger.error("[server-5xx] status=%s method=%s path=%s detail=%s", status, method, path, detail)
else:
logger.error("[server-5xx] status=%s method=%s path=%s", status, method, path)
return response
+80
View File
@@ -13,11 +13,13 @@ import httpx
from fastapi import APIRouter, BackgroundTasks, HTTPException
from starlette.requests import Request
from ..logging_config import get_log_file_path, get_logger
from ..path_fix import PathFixRoute
from ..runtime_settings import read_effective_backend_port, write_backend_port_env_file, write_backend_port_setting
router = APIRouter(route_class=PathFixRoute)
logger = get_logger(__name__)
DEFAULT_BACKEND_PORT = 10392
_PORT_CHANGE_IN_PROGRESS = False
@@ -58,6 +60,36 @@ def _is_loopback_client(request: Request) -> bool:
return False
def _get_current_log_file_path() -> Path:
log_file = Path(get_log_file_path())
try:
log_file.parent.mkdir(parents=True, exist_ok=True)
except Exception:
pass
if not log_file.exists():
try:
log_file.touch(exist_ok=True)
except Exception:
pass
return log_file
def _open_path_with_default_app(path: Path) -> None:
target = str(path)
if os.name == "nt":
opener = getattr(os, "startfile", None)
if opener is None:
raise RuntimeError("当前系统不支持默认打开文件")
opener(target)
return
if sys.platform == "darwin":
subprocess.Popen(["open", target])
return
subprocess.Popen(["xdg-open", target])
def _is_port_available(port: int, host: str) -> bool:
try:
addr = (host, int(port))
@@ -126,6 +158,54 @@ async def _exit_process_after(delay_s: float) -> None:
os._exit(0) # noqa: S404
@router.get("/api/admin/log-file", summary="获取当前后端日志文件路径")
async def get_backend_log_file() -> dict:
log_file = _get_current_log_file_path()
return {"path": str(log_file), "exists": log_file.exists()}
@router.post("/api/admin/log-file/open", summary="打开当前后端日志文件(仅允许本机访问)")
async def open_backend_log_file(request: Request) -> dict:
if not _is_loopback_client(request):
raise HTTPException(status_code=403, detail="仅允许本机访问该接口")
log_file = _get_current_log_file_path()
try:
_open_path_with_default_app(log_file)
except Exception as e:
logger.error("open_backend_log_file failed path=%s err=%s", log_file, e)
raise HTTPException(status_code=500, detail=f"打开日志文件失败:{e}")
return {"success": True, "path": str(log_file)}
@router.post("/api/admin/log-frontend-server-error", summary="记录前端感知到的服务器错误")
async def log_frontend_server_error(payload: dict) -> dict:
data = payload if isinstance(payload, dict) else {}
try:
status = int(data.get("status"))
except Exception:
status = 0
method = str(data.get("method") or "").strip().upper() or "GET"
request_url = str(data.get("request_url") or "").strip()
message = str(data.get("message") or "").strip()
backend_detail = str(data.get("backend_detail") or "").strip()
source = str(data.get("source") or "").strip()
page_url = str(data.get("page_url") or "").strip()
logger.error(
"[frontend-server-error] status=%s method=%s request_url=%s message=%s backend_detail=%s source=%s page_url=%s",
status,
method,
request_url,
message,
backend_detail,
source,
page_url,
)
return {"success": True, "path": str(_get_current_log_file_path())}
@router.get("/api/admin/port", summary="获取后端端口(用于前端设置页)")
async def get_backend_port() -> dict:
port, source = read_effective_backend_port(default=DEFAULT_BACKEND_PORT)
+82
View File
@@ -3,6 +3,7 @@ import re
import sqlite3
import asyncio
import json
import shutil
import time
import threading
from datetime import datetime, timedelta
@@ -67,6 +68,8 @@ from ..chat_helpers import (
)
from ..media_helpers import _resolve_account_db_storage_dir, _try_find_decrypted_resource
from .. import chat_edit_store
from ..app_paths import get_output_dir
from ..key_store import remove_account_keys_from_store
from ..path_fix import PathFixRoute
from ..session_last_message import (
build_session_last_message_table,
@@ -3496,6 +3499,85 @@ async def list_chat_accounts():
}
@router.get("/api/chat/account_info", summary="获取当前账号信息")
def get_chat_account_info(account: Optional[str] = None):
account_dir = _resolve_account_dir(account)
db_files = sorted([p.name for p in account_dir.glob("*.db") if p.is_file()])
session_db = account_dir / "session.db"
session_updated_at = 0
try:
session_updated_at = int(session_db.stat().st_mtime)
except Exception:
session_updated_at = 0
return {
"status": "success",
"account": account_dir.name,
"path": str(account_dir),
"database_count": len(db_files),
"databases": db_files,
"session_updated_at": session_updated_at,
}
@router.delete("/api/chat/account", summary="删除当前账号在本项目中的数据")
def delete_chat_account(account: str):
account_name = str(account or "").strip()
if not account_name:
raise HTTPException(status_code=400, detail="Missing account.")
account_dir = _resolve_account_dir(account_name)
# Best-effort: close realtime connections first, otherwise Windows may keep db files locked.
try:
WCDB_REALTIME.disconnect(account_name)
except Exception:
pass
with _REALTIME_SYNC_MU:
_REALTIME_SYNC_ALL_LOCKS.pop(account_name, None)
stale_lock_keys = [k for k in _REALTIME_SYNC_LOCKS.keys() if k and k[0] == account_name]
for k in stale_lock_keys:
_REALTIME_SYNC_LOCKS.pop(k, None)
removed_edit_count = 0
try:
removed_edit_count = int(chat_edit_store.delete_account_edits(account_name) or 0)
except Exception:
removed_edit_count = 0
removed_key_cache = False
try:
removed_key_cache = bool(remove_account_keys_from_store(account_name))
except Exception:
removed_key_cache = False
output_dir = get_output_dir()
exports_dir = output_dir / "exports" / account_name
if exports_dir.exists():
try:
shutil.rmtree(exports_dir)
except Exception:
# Ignore export cleanup failure; account dir removal is the core operation.
pass
try:
shutil.rmtree(account_dir)
except Exception as e:
raise HTTPException(status_code=500, detail=f"删除账号数据失败:{e}")
accounts = _list_decrypted_accounts()
return {
"status": "success",
"deleted_account": account_name,
"accounts": accounts,
"default_account": accounts[0] if accounts else None,
"removed_edit_count": removed_edit_count,
"removed_key_cache": removed_key_cache,
}
@router.get("/api/chat/sessions", summary="获取会话列表(聊天左侧列表)")
def list_chat_sessions(
request: Request,
+13 -8
View File
@@ -33,7 +33,7 @@ from ..avatar_cache import (
)
from ..logging_config import get_logger
from ..media_helpers import (
_convert_silk_to_wav,
_convert_silk_to_browser_audio,
_decrypt_emoticon_aes_cbc,
_detect_image_extension,
_detect_image_media_type,
@@ -1762,12 +1762,12 @@ async def get_chat_voice(server_id: int, account: Optional[str] = None):
if not isinstance(data, (bytes, bytearray)):
data = bytes(data)
# Try to convert SILK to WAV for browser playback
wav_data = _convert_silk_to_wav(data)
if wav_data != data:
payload, ext, media_type = _convert_silk_to_browser_audio(data, preferred_format="mp3")
if payload and ext != "silk":
return Response(
content=wav_data,
media_type="audio/wav",
content=payload,
media_type=media_type,
headers={"Content-Disposition": f"inline; filename=voice_{int(server_id)}.{ext}"},
)
# Fallback to raw SILK if conversion fails
@@ -1821,11 +1821,16 @@ async def open_chat_media_folder(
if not isinstance(data, (bytes, bytearray)):
data = bytes(data)
payload, ext, _media_type = _convert_silk_to_browser_audio(data, preferred_format="mp3")
if not payload:
payload = data
ext = "silk"
export_dir = account_dir / "_exports"
export_dir.mkdir(parents=True, exist_ok=True)
p = export_dir / f"voice_{int(server_id)}.silk"
p = export_dir / f"voice_{int(server_id)}.{ext}"
try:
p.write_bytes(data)
p.write_bytes(payload)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to export voice: {e}")
else:
+57 -5
View File
@@ -787,10 +787,14 @@ class WCDBRealtimeConnection:
class WCDBRealtimeManager:
_FAILED_TTL = 60.0 # seconds before retrying a failed connection
def __init__(self) -> None:
self._mu = threading.Lock()
self._conns: dict[str, WCDBRealtimeConnection] = {}
self._connecting: dict[str, threading.Event] = {}
# Negative cache: accounts that failed to connect recently (avoids repeated timeouts).
self._failed: dict[str, float] = {} # account -> monotonic timestamp of failure
def get_status(self, account_dir: Path) -> dict[str, Any]:
account = str(account_dir.name)
@@ -830,9 +834,19 @@ class WCDBRealtimeManager:
conn = self._conns.get(str(account))
return bool(conn and conn.handle > 0)
def ensure_connected(self, account_dir: Path, *, key_hex: Optional[str] = None) -> WCDBRealtimeConnection:
def ensure_connected(
self, account_dir: Path, *, key_hex: Optional[str] = None, timeout: float = 5.0
) -> WCDBRealtimeConnection:
account = str(account_dir.name)
# Fast-reject if this account failed recently to avoid repeated timeouts.
with self._mu:
failed_at = self._failed.get(account)
if failed_at is not None and (time.monotonic() - failed_at) < self._FAILED_TTL:
raise WCDBRealtimeError("WCDB connection recently failed; retry after 60s.")
deadline = time.monotonic() + timeout
while True:
with self._mu:
existing = self._conns.get(account)
@@ -846,22 +860,59 @@ class WCDBRealtimeManager:
break
# Another thread is connecting; wait a bit and retry.
waiter.wait(timeout=10.0)
remaining = deadline - time.monotonic()
if remaining <= 0:
raise WCDBRealtimeError("Timed out waiting for WCDB connection.")
waiter.wait(timeout=min(remaining, 10.0))
if time.monotonic() >= deadline:
raise WCDBRealtimeError("Timed out waiting for WCDB connection.")
key = str(key_hex or "").strip()
if not key:
key_item = get_account_keys_from_store(account)
key = str((key_item or {}).get("db_key") or "").strip()
if len(key) != 64:
raise WCDBRealtimeError("Missing db key for this account (call /api/keys or decrypt first).")
try:
if len(key) != 64:
with self._mu:
self._failed[account] = time.monotonic()
raise WCDBRealtimeError("Missing db key for this account (call /api/keys or decrypt first).")
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
if db_storage_dir is None:
raise WCDBRealtimeError("Cannot resolve db_storage directory for this account.")
session_db_path = _resolve_session_db_path(db_storage_dir)
handle = open_account(session_db_path, key)
# Run open_account in a daemon thread with a timeout to avoid
# blocking indefinitely when the native library hangs (locked DB).
_handle_box: list[int] = []
_open_err: list[Exception] = []
def _do_open() -> None:
try:
_handle_box.append(open_account(session_db_path, key))
except Exception as exc:
_open_err.append(exc)
remaining = max(0.1, deadline - time.monotonic())
open_thread = threading.Thread(target=_do_open, daemon=True)
open_thread.start()
open_thread.join(timeout=remaining)
if open_thread.is_alive():
with self._mu:
self._failed[account] = time.monotonic()
raise WCDBRealtimeError(
f"open_account timed out after {timeout:.0f}s for {session_db_path}"
)
if _open_err:
with self._mu:
self._failed[account] = time.monotonic()
raise _open_err[0]
if not _handle_box:
raise WCDBRealtimeError("open_account returned no handle.")
handle = _handle_box[0]
# Some WCDB APIs (e.g. exec_query on non-session DBs) may require this context.
try:
set_my_wxid(handle, account)
@@ -893,6 +944,7 @@ class WCDBRealtimeManager:
return
with self._mu:
conn = self._conns.pop(a, None)
self._failed.pop(a, None) # clear negative cache on explicit disconnect
if conn is None:
return
try:
+174
View File
@@ -0,0 +1,174 @@
import importlib
import os
import sys
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def _close_logging_handlers() -> None:
import logging
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
lg = logging.getLogger(logger_name)
for h in lg.handlers[:]:
try:
h.close()
except Exception:
pass
try:
lg.removeHandler(h)
except Exception:
pass
class TestAdminServerErrorLogging(unittest.TestCase):
def setUp(self):
self._prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
self._td = TemporaryDirectory()
os.environ["WECHAT_TOOL_DATA_DIR"] = self._td.name
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.logging_config as logging_config
import wechat_decrypt_tool.request_logging as request_logging
import wechat_decrypt_tool.routers.admin as admin_router
importlib.reload(app_paths)
importlib.reload(logging_config)
importlib.reload(request_logging)
importlib.reload(admin_router)
self.logging_config = logging_config
self.request_logging = request_logging
self.admin_router = admin_router
self.log_file = self.logging_config.setup_logging()
def tearDown(self):
_close_logging_handlers()
if self._prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = self._prev_data_dir
self._td.cleanup()
def _read_log(self) -> str:
return self.log_file.read_text(encoding="utf-8")
def _make_admin_app(self) -> FastAPI:
app = FastAPI()
app.include_router(self.admin_router.router)
return app
def _make_logged_app(self) -> FastAPI:
app = FastAPI()
@app.middleware("http")
async def _log_server_errors(request, call_next):
return await self.request_logging.log_server_errors_middleware(
self.logging_config.get_logger("tests.server_error_logging"),
request,
call_next,
)
@app.get("/boom-http")
async def _boom_http():
raise HTTPException(status_code=500, detail="planned http failure")
@app.get("/boom-exception")
async def _boom_exception():
raise RuntimeError("planned unhandled failure")
return app
def test_get_log_file_returns_current_backend_log_path(self):
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52000))
resp = client.get("/api/admin/log-file")
self.assertEqual(resp.status_code, 200)
payload = resp.json()
self.assertEqual(Path(payload["path"]), self.log_file)
self.assertTrue(payload["exists"])
self.assertTrue(self.log_file.is_relative_to(Path(self._td.name) / "output" / "logs"))
def test_open_log_file_requires_loopback(self):
client = TestClient(self._make_admin_app(), client=("203.0.113.8", 52001))
resp = client.post("/api/admin/log-file/open")
self.assertEqual(resp.status_code, 403)
def test_open_log_file_uses_default_opener_for_loopback(self):
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52002))
with patch.object(self.admin_router, "_open_path_with_default_app") as mocked_open:
resp = client.post("/api/admin/log-file/open")
self.assertEqual(resp.status_code, 200)
mocked_open.assert_called_once_with(self.log_file)
self.assertEqual(resp.json()["path"], str(self.log_file))
def test_frontend_server_error_endpoint_writes_log(self):
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52003))
resp = client.post(
"/api/admin/log-frontend-server-error",
json={
"status": 503,
"method": "GET",
"request_url": "http://127.0.0.1:10392/api/chat/accounts",
"message": "fetch failed",
"backend_detail": "upstream timeout",
"source": "useApi",
"page_url": "http://127.0.0.1:10392/chat",
},
)
self.assertEqual(resp.status_code, 200)
text = self._read_log()
self.assertIn("[frontend-server-error]", text)
self.assertIn("status=503", text)
self.assertIn("source=useApi", text)
self.assertIn("upstream timeout", text)
def test_http_500_response_is_logged(self):
client = TestClient(self._make_logged_app(), client=("127.0.0.1", 52004))
resp = client.get("/boom-http")
self.assertEqual(resp.status_code, 500)
text = self._read_log()
self.assertIn("[server-5xx]", text)
self.assertIn("status=500", text)
self.assertIn("path=/boom-http", text)
self.assertIn("planned http failure", text)
def test_unhandled_exception_is_logged_with_traceback(self):
client = TestClient(
self._make_logged_app(),
client=("127.0.0.1", 52005),
raise_server_exceptions=False,
)
resp = client.get("/boom-exception")
self.assertEqual(resp.status_code, 500)
text = self._read_log()
self.assertIn("[server-exception]", text)
self.assertIn("path=/boom-exception", text)
self.assertIn("planned unhandled failure", text)
self.assertIn("Traceback", text)
if __name__ == "__main__":
unittest.main()
+109 -1
View File
@@ -1,6 +1,8 @@
import os
import json
import hashlib
import logging
import re
import sqlite3
import sys
import unittest
@@ -243,6 +245,22 @@ class TestChatExportHtmlFormat(unittest.TestCase):
self._seed_media_files(account_dir)
return account_dir
def _insert_missing_voice_message(self, account_dir: Path, *, username: str, server_id: int, duration_ms: int) -> None:
conn = sqlite3.connect(str(account_dir / "message_0.db"))
try:
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
row = conn.execute(f"SELECT COALESCE(MAX(local_id), 0), COALESCE(MAX(sort_seq), 0) FROM {table_name}").fetchone()
next_local_id = int((row[0] or 0)) + 1
next_sort_seq = int((row[1] or 0)) + 1
voice_xml = f'<msg><voicemsg voicelength="{int(duration_ms)}" /></msg>'
conn.execute(
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(next_local_id, int(server_id), 34, next_sort_seq, 2, 1735689700, voice_xml, None),
)
conn.commit()
finally:
conn.close()
def _create_job(self, manager, *, account: str, username: str):
job = manager.create_job(
account=account,
@@ -283,7 +301,14 @@ class TestChatExportHtmlFormat(unittest.TestCase):
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
svc = self._reload_export_modules()
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
original_converter = svc._convert_silk_to_browser_audio
svc._convert_silk_to_browser_audio = (
lambda data, preferred_format="mp3": (bytes(data or b""), "silk", "audio/silk")
)
try:
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
finally:
svc._convert_silk_to_browser_audio = original_converter
self.assertEqual(job.status, "done", msg=job.error)
self.assertTrue(job.zip_path and job.zip_path.exists())
@@ -332,6 +357,8 @@ class TestChatExportHtmlFormat(unittest.TestCase):
css_text = zf.read("assets/wechat-chat-export.css").decode("utf-8", errors="ignore")
self.assertIn("wechat-transfer-card", css_text)
self.assertRegex(css_text, re.compile(r"\.wechat-voice-sent(?::|::)after"))
self.assertRegex(css_text, re.compile(r"\.wechat-voice-received(?::|::)before"))
self.assertNotIn("wechat-transfer-card[data-v-", css_text)
self.assertNotIn("bento-container", css_text)
@@ -346,6 +373,87 @@ class TestChatExportHtmlFormat(unittest.TestCase):
self.assertIn("wxemoji/Expression_1@2x.png", names)
self.assertIn("../../wxemoji/Expression_1@2x.png", html_text)
finally:
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
def test_html_export_prefers_mp3_for_voice_assets(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
self._prepare_account(root, account=account, username=username)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
svc = self._reload_export_modules()
original_converter = svc._convert_silk_to_browser_audio
svc._convert_silk_to_browser_audio = (
lambda data, preferred_format="mp3": (b"ID3FAKE_MP3_DATA", "mp3", "audio/mpeg")
)
try:
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
finally:
svc._convert_silk_to_browser_audio = original_converter
self.assertEqual(job.status, "done", msg=job.error)
self.assertTrue(job.zip_path and job.zip_path.exists())
with zipfile.ZipFile(job.zip_path, "r") as zf:
names = set(zf.namelist())
voice_path = f"media/voices/voice_{self._VOICE_SERVER_ID}.mp3"
self.assertIn(voice_path, names)
self.assertNotIn(f"media/voices/voice_{self._VOICE_SERVER_ID}.wav", names)
html_path = next((n for n in names if n.endswith("/messages.html")), "")
self.assertTrue(html_path)
html_text = zf.read(html_path).decode("utf-8")
self.assertIn(f"../../{voice_path}", html_text)
finally:
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
def test_html_export_keeps_voice_bubble_when_audio_file_missing(self):
with TemporaryDirectory() as td:
root = Path(td)
account = "wxid_test"
username = "wxid_friend"
account_dir = self._prepare_account(root, account=account, username=username)
self._insert_missing_voice_message(account_dir, username=username, server_id=999999, duration_ms=6543)
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
svc = self._reload_export_modules()
original_converter = svc._convert_silk_to_browser_audio
svc._convert_silk_to_browser_audio = (
lambda data, preferred_format="mp3": (bytes(data or b""), "silk", "audio/silk")
)
try:
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
finally:
svc._convert_silk_to_browser_audio = original_converter
self.assertEqual(job.status, "done", msg=job.error)
self.assertTrue(job.zip_path and job.zip_path.exists())
with zipfile.ZipFile(job.zip_path, "r") as zf:
names = set(zf.namelist())
html_path = next((n for n in names if n.endswith("/messages.html")), "")
self.assertTrue(html_path)
html_text = zf.read(html_path).decode("utf-8")
self.assertIn("wechat-voice-wrapper", html_text)
self.assertIn('data-render-type="voice"', html_text)
self.assertIn('data-voice-id="message_0:msg_d5616d78f22fe35c632f66cabecfc82d:11"', html_text)
self.assertIn('class="wechat-voice-duration">7"</span>', html_text)
finally:
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
+2
View File
@@ -1,6 +1,7 @@
import os
import json
import hashlib
import logging
import sqlite3
import sys
import unittest
@@ -215,6 +216,7 @@ class TestChatExportHtmlPaging(unittest.TestCase):
page1_text = zf.read(page1_js).decode("utf-8", errors="ignore")
self.assertIn("MSG0001", page1_text)
finally:
logging.shutdown()
if prev_data is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else: