mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
4 Commits
@@ -215,6 +215,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);
|
||||
@@ -1384,6 +1539,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();
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -63,186 +63,202 @@
|
||||
</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>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -60,6 +60,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 +556,8 @@ export const useApi = () => {
|
||||
decryptDatabase,
|
||||
healthCheck,
|
||||
listChatAccounts,
|
||||
getChatAccountInfo,
|
||||
deleteChatAccount,
|
||||
listChatSessions,
|
||||
listChatMessages,
|
||||
getChatMessageRaw,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
@@ -243,6 +245,22 @@ class TestChatExportHtmlFormat(unittest.TestCase):
|
||||
self._seed_media_files(account_dir)
|
||||
return account_dir
|
||||
|
||||
def _insert_missing_voice_message(self, account_dir: Path, *, username: str, server_id: int, duration_ms: int) -> None:
|
||||
conn = sqlite3.connect(str(account_dir / "message_0.db"))
|
||||
try:
|
||||
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||
row = conn.execute(f"SELECT COALESCE(MAX(local_id), 0), COALESCE(MAX(sort_seq), 0) FROM {table_name}").fetchone()
|
||||
next_local_id = int((row[0] or 0)) + 1
|
||||
next_sort_seq = int((row[1] or 0)) + 1
|
||||
voice_xml = f'<msg><voicemsg voicelength="{int(duration_ms)}" /></msg>'
|
||||
conn.execute(
|
||||
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(next_local_id, int(server_id), 34, next_sort_seq, 2, 1735689700, voice_xml, None),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _create_job(self, manager, *, account: str, username: str):
|
||||
job = manager.create_job(
|
||||
account=account,
|
||||
@@ -283,7 +301,14 @@ class TestChatExportHtmlFormat(unittest.TestCase):
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
|
||||
original_converter = svc._convert_silk_to_browser_audio
|
||||
svc._convert_silk_to_browser_audio = (
|
||||
lambda data, preferred_format="mp3": (bytes(data or b""), "silk", "audio/silk")
|
||||
)
|
||||
try:
|
||||
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
|
||||
finally:
|
||||
svc._convert_silk_to_browser_audio = original_converter
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
self.assertTrue(job.zip_path and job.zip_path.exists())
|
||||
@@ -332,6 +357,8 @@ class TestChatExportHtmlFormat(unittest.TestCase):
|
||||
|
||||
css_text = zf.read("assets/wechat-chat-export.css").decode("utf-8", errors="ignore")
|
||||
self.assertIn("wechat-transfer-card", css_text)
|
||||
self.assertRegex(css_text, re.compile(r"\.wechat-voice-sent(?::|::)after"))
|
||||
self.assertRegex(css_text, re.compile(r"\.wechat-voice-received(?::|::)before"))
|
||||
self.assertNotIn("wechat-transfer-card[data-v-", css_text)
|
||||
self.assertNotIn("bento-container", css_text)
|
||||
|
||||
@@ -346,6 +373,87 @@ class TestChatExportHtmlFormat(unittest.TestCase):
|
||||
self.assertIn("wxemoji/Expression_1@2x.png", names)
|
||||
self.assertIn("../../wxemoji/Expression_1@2x.png", html_text)
|
||||
finally:
|
||||
logging.shutdown()
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_html_export_prefers_mp3_for_voice_assets(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
|
||||
original_converter = svc._convert_silk_to_browser_audio
|
||||
svc._convert_silk_to_browser_audio = (
|
||||
lambda data, preferred_format="mp3": (b"ID3FAKE_MP3_DATA", "mp3", "audio/mpeg")
|
||||
)
|
||||
try:
|
||||
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
|
||||
finally:
|
||||
svc._convert_silk_to_browser_audio = original_converter
|
||||
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
self.assertTrue(job.zip_path and job.zip_path.exists())
|
||||
with zipfile.ZipFile(job.zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
voice_path = f"media/voices/voice_{self._VOICE_SERVER_ID}.mp3"
|
||||
self.assertIn(voice_path, names)
|
||||
self.assertNotIn(f"media/voices/voice_{self._VOICE_SERVER_ID}.wav", names)
|
||||
|
||||
html_path = next((n for n in names if n.endswith("/messages.html")), "")
|
||||
self.assertTrue(html_path)
|
||||
html_text = zf.read(html_path).decode("utf-8")
|
||||
self.assertIn(f"../../{voice_path}", html_text)
|
||||
finally:
|
||||
logging.shutdown()
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_html_export_keeps_voice_bubble_when_audio_file_missing(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
account_dir = self._prepare_account(root, account=account, username=username)
|
||||
self._insert_missing_voice_message(account_dir, username=username, server_id=999999, duration_ms=6543)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
original_converter = svc._convert_silk_to_browser_audio
|
||||
svc._convert_silk_to_browser_audio = (
|
||||
lambda data, preferred_format="mp3": (bytes(data or b""), "silk", "audio/silk")
|
||||
)
|
||||
try:
|
||||
job = self._create_job(svc.CHAT_EXPORT_MANAGER, account=account, username=username)
|
||||
finally:
|
||||
svc._convert_silk_to_browser_audio = original_converter
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
self.assertTrue(job.zip_path and job.zip_path.exists())
|
||||
with zipfile.ZipFile(job.zip_path, "r") as zf:
|
||||
names = set(zf.namelist())
|
||||
html_path = next((n for n in names if n.endswith("/messages.html")), "")
|
||||
self.assertTrue(html_path)
|
||||
html_text = zf.read(html_path).decode("utf-8")
|
||||
self.assertIn("wechat-voice-wrapper", html_text)
|
||||
self.assertIn('data-render-type="voice"', html_text)
|
||||
self.assertIn('data-voice-id="message_0:msg_d5616d78f22fe35c632f66cabecfc82d:11"', html_text)
|
||||
self.assertIn('class="wechat-voice-duration">7"</span>', html_text)
|
||||
finally:
|
||||
logging.shutdown()
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
@@ -215,6 +216,7 @@ class TestChatExportHtmlPaging(unittest.TestCase):
|
||||
page1_text = zf.read(page1_js).decode("utf-8", errors="ignore")
|
||||
self.assertIn("MSG0001", page1_text)
|
||||
finally:
|
||||
logging.shutdown()
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user