12 Commits
v0.1.4 ... main

Author SHA1 Message Date
2977094657
950fb4c7b4 improvement(chat): 会话列表可拖拽调宽并优化 realtime 关闭同步
- 中间栏新增拖拽调宽/双击重置;宽度按物理 px 持久化(兼容旧 key,并按 dpr 换算)

- 关闭 realtime 前触发 syncChatRealtimeMessages(max_scan=5000),避免回退到过期解密快照

- 按 dpr 调整联系人/消息头像与 skeleton 尺寸
2026-01-28 18:19:58 +08:00
2977094657
891d4b8a1b improvement(chat): WCDB 回退补全昵称/头像
- contact.db 缺失(企业/开放平台/openim/群等)时,回退 WCDB realtime 查询 displayName/avatarUrl

- 覆盖消息/会话:senderDisplayName/senderAvatar、link card from、quoteTitle、会话列表 name/avatar

- realtime 场景尽量复用已建立的 WCDB 连接;best-effort,失败不影响主流程
2026-01-28 18:19:38 +08:00
2977094657
55dc455921 feat(sns): 前端新增朋友圈页面并接入候选匹配
- 新增 /sns 页面:时间线列表、账号切换、隐私模式、复制动态 JSON

- 图片预览支持候选匹配切换并保存(localStorage + /api/sns/media_picks)

- 聊天页侧边栏增加头像/朋友圈入口,隐私模式开关持久化(chat/sns 共用)

- app.vue 增加 --dpr 与 sidebar rail CSS 变量,并在 resize 时刷新

- useApi 补充 sns 相关接口封装
2026-01-27 16:27:40 +08:00
2977094657
ba9eb5e267 feat(sns): 增加朋友圈时间线与图片本地缓存接口
- 新增 /api/sns/timeline:优先走 WCDB realtime 读取 sns.db,支持分页/用户过滤/关键字

- 新增 /api/sns/media:本地缓存(cache/.../Sns/Img)解密优先,支持手动 pick/避开重复

- 新增 /api/sns/media_candidates 与 /api/sns/media_picks:候选 key 列表与本机持久化匹配表

- wcdb_realtime 增加 exec_query/get_sns_timeline 封装,并在连接时 set_my_wxid 上下文

- 更新 wcdb_api.dll 并补齐 MSVC runtime 依赖
2026-01-27 16:27:19 +08:00
2977094657
d0d518aed9 fix(chat): proxy_image 兼容 tc.qq.com 并增强防盗链 Referer
- proxy_image 放开 .tc.qq.com 白名单,兼容朋友圈/CDN 图片

- 下载时按多组 Referer/Origin 轮询,提高成功率

- 保持 host 校验与 10MB 限制
2026-01-27 16:26:53 +08:00
2977094657
ae2d7f128d improvement(chat): realtime 刷新去抖并绕过后台全量同步
- realtime 模式拉取消息时传 source=realtime,直接从 WCDB 读取

- SSE change 事件增加 500ms debounce,减少频繁刷新/请求抖动

- 停止 realtime 时清理 debounce timer
2026-01-24 18:47:29 +08:00
2977094657
93ad7b7a1c improvement(chat): realtime 直读 WCDB 并完善追踪日志
- SSE 变更扫描改用 asyncio.to_thread,避免阻塞事件循环

- sessions/messages 支持 source=realtime;realtime 下会话预览改用 session 信息避免缓存陈旧

- realtime sync/sync_all 增加 trace_id 与关键步骤日志,便于定位卡顿/锁竞争

- 支持通过 WECHAT_TOOL_LOG_LEVEL 环境变量覆盖日志级别
2026-01-24 18:47:06 +08:00
2977094657
c0cddca307 Merge branch 'main' of https://github.com/2977094657/WeChatDataAnalysis 2026-01-24 10:53:49 +08:00
2977094657
c523036a10 fix(chat): 链接卡片补全公众号来源并解决缩略图防盗链
- appmsg 解析补全 from/fromUsername,并规范化 url/thumbUrl
- contact.db 兜底反查 fromUsername(仅有 sourcedisplayname 时)
- 新增 /api/chat/media/proxy_image,仅允许 qpic/qlogo,带 mp.weixin.qq.com Referer(10MB 限制)
- 前端 LinkCard 增加来源头像/host 兜底,qpic/qlogo 预览图走代理;头像加载失败回退
- 导出消息补充 from 字段
2026-01-24 10:51:35 +08:00
2977094657
7d4ac67fc2 Add downloads badge to README
Add a downloads badge to the README.
2026-01-20 17:21:13 +08:00
2977094657
d3d1c8dc7d feat(installer): allow deleting user data on uninstall 2026-01-18 14:58:04 +08:00
2977094657
d4828b1a0a feat(desktop): close-to-tray setting 2026-01-18 14:43:43 +08:00
22 changed files with 4118 additions and 116 deletions

View File

@@ -8,6 +8,7 @@
<p><b>特别致谢</b><a href="https://github.com/ycccccccy/echotrace">echotrace</a>(本项目大量功能参考其实现,提供了重要技术支持)</p>
<img src="https://img.shields.io/github/v/tag/LifeArchiveProject/WeChatDataAnalysis" alt="Version" />
<img src="https://img.shields.io/github/stars/LifeArchiveProject/WeChatDataAnalysis" alt="Stars" />
<img src="https://gh-down-badges.linkof.link/LifeArchiveProject/WeChatDataAnalysis" alt="Downloads" />
<img src="https://img.shields.io/github/forks/LifeArchiveProject/WeChatDataAnalysis" alt="Forks" />
<img src="https://img.shields.io/github/license/LifeArchiveProject/WeChatDataAnalysis" alt="License" />
<img src="https://img.shields.io/badge/Python-3776AB?logo=Python&logoColor=white" alt="Python" />

View File

@@ -78,3 +78,78 @@ Function WDA_InstallDirPageLeave
FunctionEnd
!endif
!ifdef BUILD_UNINSTALLER
!include nsDialogs.nsh
!include LogicLib.nsh
Var WDA_UninstallOptionsPage
Var WDA_UninstallDeleteDataCheckbox
Var /GLOBAL WDA_DeleteUserData
!macro customUnInit
; Default: keep user data (also applies to silent uninstall / update uninstall).
StrCpy $WDA_DeleteUserData "0"
!macroend
!macro customUnWelcomePage
!insertmacro MUI_UNPAGE_WELCOME
; Optional page: allow user to choose whether to delete app data.
UninstPage custom un.WDA_UninstallOptionsCreate un.WDA_UninstallOptionsLeave
!macroend
Function un.WDA_UninstallOptionsCreate
nsDialogs::Create 1018
Pop $WDA_UninstallOptionsPage
${If} $WDA_UninstallOptionsPage == error
Abort
${EndIf}
${NSD_CreateLabel} 0u 0u 100% 24u "卸载选项:"
Pop $0
${NSD_CreateCheckbox} 0u 24u 100% 12u "同时删除用户数据(导出的聊天记录、日志、配置等)"
Pop $WDA_UninstallDeleteDataCheckbox
; Safer default: do not delete.
${NSD_Uncheck} $WDA_UninstallDeleteDataCheckbox
nsDialogs::Show
FunctionEnd
Function un.WDA_UninstallOptionsLeave
${NSD_GetState} $WDA_UninstallDeleteDataCheckbox $0
${If} $0 == ${BST_CHECKED}
StrCpy $WDA_DeleteUserData "1"
${Else}
StrCpy $WDA_DeleteUserData "0"
${EndIf}
FunctionEnd
!macro customUnInstall
; If this is an update uninstall, never delete user data.
${ifNot} ${isUpdated}
${if} $WDA_DeleteUserData == "1"
; Electron always stores user data per-user. If the app was installed for all users,
; switch to current user context to remove the correct AppData directory.
${if} $installMode == "all"
SetShellVarContext current
${endif}
RMDir /r "$APPDATA\${APP_FILENAME}"
!ifdef APP_PRODUCT_FILENAME
RMDir /r "$APPDATA\${APP_PRODUCT_FILENAME}"
!endif
; Electron may use package.json "name" for some storage (cache, indexeddb, etc.).
!ifdef APP_PACKAGE_NAME
RMDir /r "$APPDATA\${APP_PACKAGE_NAME}"
!endif
${if} $installMode == "all"
SetShellVarContext all
${endif}
${endif}
${endif}
!macroend
!endif

BIN
desktop/src/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@@ -2,6 +2,7 @@ const {
app,
BrowserWindow,
Menu,
Tray,
ipcMain,
globalShortcut,
dialog,
@@ -19,6 +20,10 @@ const BACKEND_HEALTH_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}/api/health`;
let backendProc = null;
let backendStdioStream = null;
let resolvedDataDir = null;
let mainWindow = null;
let tray = null;
let isQuitting = false;
let desktopSettings = null;
function nowIso() {
return new Date().toISOString();
@@ -109,6 +114,163 @@ function logMain(line) {
} catch {}
}
function getDesktopSettingsPath() {
const dir = getUserDataDir();
if (!dir) return null;
return path.join(dir, "desktop-settings.json");
}
function loadDesktopSettings() {
if (desktopSettings) return desktopSettings;
const defaults = {
// 'tray' (default): closing the window hides it to the system tray.
// 'exit': closing the window quits the app.
closeBehavior: "tray",
};
const p = getDesktopSettingsPath();
if (!p) {
desktopSettings = { ...defaults };
return desktopSettings;
}
try {
if (!fs.existsSync(p)) {
desktopSettings = { ...defaults };
return desktopSettings;
}
const raw = fs.readFileSync(p, { encoding: "utf8" });
const parsed = JSON.parse(raw || "{}");
desktopSettings = { ...defaults, ...(parsed && typeof parsed === "object" ? parsed : {}) };
} catch (err) {
desktopSettings = { ...defaults };
logMain(`[main] failed to load settings: ${err?.message || err}`);
}
return desktopSettings;
}
function persistDesktopSettings() {
const p = getDesktopSettingsPath();
if (!p) return;
if (!desktopSettings) return;
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(desktopSettings, null, 2), { encoding: "utf8" });
} catch (err) {
logMain(`[main] failed to persist settings: ${err?.message || err}`);
}
}
function getCloseBehavior() {
const v = String(loadDesktopSettings()?.closeBehavior || "").trim().toLowerCase();
return v === "exit" ? "exit" : "tray";
}
function setCloseBehavior(next) {
const v = String(next || "").trim().toLowerCase();
loadDesktopSettings();
desktopSettings.closeBehavior = v === "exit" ? "exit" : "tray";
persistDesktopSettings();
return desktopSettings.closeBehavior;
}
function getTrayIconPath() {
// Prefer an icon shipped in `src/` so it works both in dev and packaged (asar) builds.
const shipped = path.join(__dirname, "icon.ico");
try {
if (fs.existsSync(shipped)) return shipped;
} catch {}
// Dev fallback (not available in packaged builds).
const dev = path.resolve(__dirname, "..", "build", "icon.ico");
try {
if (fs.existsSync(dev)) return dev;
} catch {}
return null;
}
function showMainWindow() {
if (!mainWindow) return;
try {
mainWindow.setSkipTaskbar(false);
} catch {}
try {
if (mainWindow.isMinimized()) mainWindow.restore();
} catch {}
try {
mainWindow.show();
} catch {}
try {
mainWindow.focus();
} catch {}
}
function createTray() {
if (tray) return tray;
if (!app.isPackaged) return null;
const iconPath = getTrayIconPath();
if (!iconPath) {
logMain("[main] tray icon not found; disabling tray behavior");
return null;
}
try {
tray = new Tray(iconPath);
} catch (err) {
tray = null;
logMain(`[main] failed to create tray: ${err?.message || err}`);
return null;
}
try {
tray.setToolTip("WeChatDataAnalysis");
} catch {}
try {
tray.setContextMenu(
Menu.buildFromTemplate([
{
label: "显示",
click: () => showMainWindow(),
},
{
label: "退出",
click: () => {
isQuitting = true;
app.quit();
},
},
])
);
} catch {}
try {
tray.on("click", () => showMainWindow());
tray.on("double-click", () => showMainWindow());
} catch {}
return tray;
}
function destroyTray() {
if (!tray) return;
try {
tray.destroy();
} catch {}
tray = null;
}
function ensureTrayForCloseBehavior() {
const behavior = getCloseBehavior();
if (behavior === "tray") createTray();
else destroyTray();
}
function getBackendStdioLogPath(dataDir) {
return path.join(dataDir, "backend-stdio.log");
}
@@ -335,6 +497,26 @@ function createMainWindow() {
},
});
win.on("close", (event) => {
// In packaged builds, we default to "close -> minimize to tray" unless the user opts out.
if (!app.isPackaged) return;
if (isQuitting) return;
if (getCloseBehavior() !== "tray") return;
if (!tray) return;
try {
event.preventDefault();
win.setSkipTaskbar(true);
win.hide();
try {
tray.displayBalloon({
title: "WeChatDataAnalysis",
content: "已最小化到托盘,可从托盘图标再次打开。",
});
} catch {}
} catch {}
});
win.on("closed", () => {
stopBackend();
});
@@ -409,6 +591,26 @@ function registerWindowIpc() {
return on;
}
});
ipcMain.handle("app:getCloseBehavior", () => {
try {
return getCloseBehavior();
} catch (err) {
logMain(`[main] getCloseBehavior failed: ${err?.message || err}`);
return "tray";
}
});
ipcMain.handle("app:setCloseBehavior", (_event, behavior) => {
try {
const next = setCloseBehavior(behavior);
ensureTrayForCloseBehavior();
return next;
} catch (err) {
logMain(`[main] setCloseBehavior failed: ${err?.message || err}`);
return getCloseBehavior();
}
});
}
async function main() {
@@ -428,6 +630,8 @@ async function main() {
await waitForBackend({ timeoutMs: 30_000 });
const win = createMainWindow();
mainWindow = win;
ensureTrayForCloseBehavior();
const startUrl =
process.env.ELECTRON_START_URL ||
@@ -455,6 +659,8 @@ app.on("will-quit", () => {
});
app.on("before-quit", () => {
isQuitting = true;
destroyTray();
stopBackend();
});

View File

@@ -8,4 +8,7 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
getAutoLaunch: () => ipcRenderer.invoke("app:getAutoLaunch"),
setAutoLaunch: (enabled) => ipcRenderer.invoke("app:setAutoLaunch", !!enabled),
getCloseBehavior: () => ipcRenderer.invoke("app:getCloseBehavior"),
setCloseBehavior: (behavior) => ipcRenderer.invoke("app:setCloseBehavior", String(behavior || "")),
});

View File

@@ -14,12 +14,23 @@
// So we detect desktop onMounted and update reactively.
const isDesktop = ref(false)
const updateDprVar = () => {
const dpr = window.devicePixelRatio || 1
document.documentElement.style.setProperty('--dpr', String(dpr))
}
onMounted(() => {
isDesktop.value = !!window?.wechatDesktop
updateDprVar()
window.addEventListener('resize', updateDprVar)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updateDprVar)
})
const route = useRoute()
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
const isChatRoute = computed(() => route.path?.startsWith('/chat') || route.path?.startsWith('/sns'))
const rootClass = computed(() => {
const base = 'bg-gradient-to-br from-green-50 via-emerald-50 to-green-100'
@@ -34,6 +45,14 @@ const contentClass = computed(() =>
</script>
<style>
:root {
--dpr: 1;
/* Left sidebar rail (chat/sns): icon size + spacing */
--sidebar-rail-step: 48px;
--sidebar-rail-btn: 32px;
--sidebar-rail-icon: 24px;
}
/* Electron 桌面端使用自绘标题栏frame: false
* 页面里如果继续用 Tailwind 的 h-screen/min-h-screen100vh会把标题栏高度叠加进去从而出现外层滚动条。
* 这里把 “screen” 在桌面端视为内容区高度100%),让标题栏高度自然内嵌在布局里。 */

View File

@@ -179,6 +179,46 @@ export const useApi = () => {
return await request(url)
}
// 朋友圈时间线
const listSnsTimeline = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.offset != null) query.set('offset', String(params.offset))
if (params && params.usernames && Array.isArray(params.usernames) && params.usernames.length > 0) {
query.set('usernames', params.usernames.join(','))
} else if (params && params.usernames && typeof params.usernames === 'string') {
query.set('usernames', params.usernames)
}
if (params && params.keyword) query.set('keyword', params.keyword)
const url = '/sns/timeline' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 朋友圈图片本地缓存候选(用于错图时手动选择)
const listSnsMediaCandidates = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.create_time != null) query.set('create_time', String(params.create_time))
if (params && params.width != null) query.set('width', String(params.width))
if (params && params.height != null) query.set('height', String(params.height))
if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.offset != null) query.set('offset', String(params.offset))
const url = '/sns/media_candidates' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 保存朋友圈图片手动匹配结果(本机)
const saveSnsMediaPicks = async (data = {}) => {
return await request('/sns/media_picks', {
method: 'POST',
body: {
account: data.account || null,
picks: (data && data.picks && typeof data.picks === 'object' && !Array.isArray(data.picks)) ? data.picks : {}
}
})
}
const openChatMediaFolder = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
@@ -288,6 +328,9 @@ export const useApi = () => {
buildChatSearchIndex,
listChatSearchSenders,
getChatMessagesAround,
listSnsTimeline,
listSnsMediaCandidates,
saveSnsMediaPicks,
openChatMediaFolder,
downloadChatEmoji,
saveMediaKeys,

View File

@@ -1,39 +1,91 @@
<template>
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
<!-- 左侧边栏 -->
<div class="w-16 border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7">
<div class="flex-1 flex flex-col justify-start pt-0">
<div class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
<!-- 头像类似微信侧边栏 -->
<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">
<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="background-color: #4B5563"
>
</div>
</div>
</div>
<!-- 聊天图标 ( oh-my-wechat 一致) -->
<div class="w-16 h-16 flex items-center justify-center chat-tab selected text-[#07b75b]">
<div class="w-7 h-7">
<div class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group">
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md bg-transparent group-hover:bg-[#E1E1E1] flex items-center justify-center transition-colors">
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#07b75b]">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<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>
</div>
</div>
<!-- 朋友圈图标Aperture 风格 -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="朋友圈"
@click="goSns"
>
<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]"
>
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isSnsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
<svg
class="w-full h-full"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="14.31" y1="8" x2="20.05" y2="17.94" />
<line x1="9.69" y1="8" x2="21.17" y2="8" />
<line x1="7.38" y1="12" x2="13.12" y2="2.06" />
<line x1="9.69" y1="16" x2="3.95" y2="6.06" />
<line x1="14.31" y1="16" x2="2.83" y2="16" />
<line x1="16.62" y1="12" x2="10.88" y2="21.94" />
</svg>
</div>
</div>
</div>
<!-- 隐私模式按钮 -->
<div
class="w-16 h-12 flex items-center justify-center cursor-pointer transition-colors"
:class="privacyMode ? 'text-[#03C160]' : 'text-gray-500 hover:text-gray-700'"
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="privacyMode = !privacyMode"
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
>
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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="privacyMode ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path v-if="privacyMode" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
<path v-else stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
</svg>
</div>
</div>
<!-- 设置按钮仅桌面端 -->
<div
v-if="isDesktopEnv"
class="w-16 h-12 flex items-center justify-center cursor-pointer transition-colors text-gray-500 hover:text-gray-700"
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="openDesktopSettings"
title="设置"
>
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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.5">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -44,9 +96,21 @@
</div>
</div>
</div>
</div>
<!-- 中间列表区域 -->
<div class="w-80 border-r border-gray-200 flex flex-col min-h-0" style="background-color: #F7F7F7">
<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">
<!-- 搜索栏 -->
@@ -90,8 +154,8 @@
<!-- 联系人列表 -->
<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 py-2">
<div class="w-10 h-10 rounded-md bg-gray-200 skeleton-pulse"></div>
<div v-for="i in 15" :key="i" class="flex items-center space-x-3 h-[calc(85px/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>
@@ -106,12 +170,12 @@
</div>
<template v-else>
<div v-for="contact in filteredContacts" :key="contact.id"
class="px-3 py-2 cursor-pointer transition-colors duration-150 border-b border-gray-100"
class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(85px/var(--dpr))] flex items-center"
:class="selectedContact?.id === contact.id ? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]' : 'hover:bg-[#eaeaea]'"
@click="selectContact(contact)">
<div class="flex items-center space-x-3">
<div class="flex items-center space-x-3 w-full">
<!-- 联系人头像 -->
<div class="w-10 h-10 rounded-md overflow-hidden bg-gray-300 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 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<div v-if="contact.avatar" class="w-full h-full">
<img :src="contact.avatar" :alt="contact.name" class="w-full h-full object-cover">
</div>
@@ -287,9 +351,15 @@
<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="w-[36px] h-[36px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
<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">
<img
:src="message.avatar"
:alt="message.sender + '的头像'"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
@error="onMessageAvatarError($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') }">
@@ -319,7 +389,9 @@
:heading="message.title || message.content"
:abstract="message.content"
:preview="message.preview"
:fromAvatar="message.fromAvatar"
:from="message.from"
:isSent="message.isSent"
/>
<div v-else-if="message.renderType === 'file'"
class="wechat-redpacket-card wechat-special-card wechat-file-card msg-radius"
@@ -1600,6 +1672,25 @@
{{ desktopAutoLaunchError }}
</div>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">关闭窗口行为</div>
<div class="text-xs text-gray-500">点击关闭按钮时默认最小化到托盘</div>
</div>
<select
class="text-sm px-2 py-1 rounded-md border border-gray-200"
:disabled="desktopCloseBehaviorLoading"
:value="desktopCloseBehavior"
@change="onDesktopCloseBehaviorChange"
>
<option value="tray">最小化到托盘</option>
<option value="exit">直接退出</option>
</select>
</div>
<div v-if="desktopCloseBehaviorError" class="text-xs text-red-600 whitespace-pre-wrap">
{{ desktopCloseBehaviorError }}
</div>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">启动后自动开启实时获取</div>
@@ -1687,6 +1778,7 @@ useHead({
})
const route = useRoute()
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
const routeUsername = computed(() => {
const raw = route.params.username
@@ -1702,6 +1794,147 @@ const selectedContact = ref(null)
// 隐私模式
const privacyMode = ref(false)
const PRIVACY_MODE_KEY = 'ui.privacy_mode'
onMounted(() => {
if (!process.client) return
try {
privacyMode.value = localStorage.getItem(PRIVACY_MODE_KEY) === '1'
} catch {}
})
watch(
() => privacyMode.value,
(v) => {
if (!process.client) return
try {
localStorage.setItem(PRIVACY_MODE_KEY, v ? '1' : '0')
} catch {}
}
)
// 会话列表(中间栏)宽度(按物理像素 px 配置):默认 295px支持拖动调整并持久化
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
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 clampSessionListWidth = (n) => {
const v = Number.isFinite(n) ? n : SESSION_LIST_WIDTH_DEFAULT
return Math.min(SESSION_LIST_WIDTH_MAX, Math.max(SESSION_LIST_WIDTH_MIN, Math.round(v)))
}
const loadSessionListWidth = () => {
if (!process.client) return
try {
const raw = localStorage.getItem(SESSION_LIST_WIDTH_KEY)
const v = parseInt(String(raw || ''), 10)
if (!Number.isNaN(v)) {
sessionListWidth.value = clampSessionListWidth(v)
return
}
// Legacy: value was stored as CSS px. Convert to physical px using current dpr.
const legacy = localStorage.getItem(SESSION_LIST_WIDTH_KEY_LEGACY)
const legacyV = parseInt(String(legacy || ''), 10)
if (!Number.isNaN(legacyV)) {
const dpr = window.devicePixelRatio || 1
const converted = clampSessionListWidth(legacyV * 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 = (ev) => {
if (!sessionListResizing.value) return
const clientX = Number(ev?.clientX || 0)
// `clientX` delta is in CSS px. We store width as physical px, so multiply by dpr.
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 = (ev) => {
if (!process.client) return
try {
ev?.preventDefault?.()
} catch {}
sessionListResizing.value = true
sessionListResizeStartX = Number(ev?.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()
})
// 桌面端设置(仅 Electron 环境可见)
const isDesktopEnv = ref(false)
@@ -1717,6 +1950,10 @@ const desktopAutoLaunch = ref(false)
const desktopAutoLaunchLoading = ref(false)
const desktopAutoLaunchError = ref('')
const desktopCloseBehavior = ref('tray') // tray | exit
const desktopCloseBehaviorLoading = ref(false)
const desktopCloseBehaviorError = ref('')
const readLocalBool = (key) => {
if (!process.client) return false
try {
@@ -1768,9 +2005,42 @@ const setDesktopAutoLaunch = async (enabled) => {
}
}
const refreshDesktopCloseBehavior = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getCloseBehavior) return
desktopCloseBehaviorLoading.value = true
desktopCloseBehaviorError.value = ''
try {
const v = await window.wechatDesktop.getCloseBehavior()
desktopCloseBehavior.value = (String(v || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
} catch (e) {
desktopCloseBehaviorError.value = e?.message || '读取关闭窗口行为失败'
} finally {
desktopCloseBehaviorLoading.value = false
}
}
const setDesktopCloseBehavior = async (behavior) => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.setCloseBehavior) return
const desired = (String(behavior || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
desktopCloseBehaviorLoading.value = true
desktopCloseBehaviorError.value = ''
try {
const v = await window.wechatDesktop.setCloseBehavior(desired)
desktopCloseBehavior.value = (String(v || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
} catch (e) {
desktopCloseBehaviorError.value = e?.message || '设置关闭窗口行为失败'
await refreshDesktopCloseBehavior()
} finally {
desktopCloseBehaviorLoading.value = false
}
}
const openDesktopSettings = async () => {
desktopSettingsOpen.value = true
await refreshDesktopAutoLaunch()
await refreshDesktopCloseBehavior()
}
const closeDesktopSettings = () => {
@@ -1782,6 +2052,11 @@ const onDesktopAutoLaunchToggle = async (ev) => {
await setDesktopAutoLaunch(checked)
}
const onDesktopCloseBehaviorChange = async (ev) => {
const v = String(ev?.target?.value || '').trim()
await setDesktopCloseBehavior(v)
}
const onDesktopAutoRealtimeToggle = async (ev) => {
const checked = !!ev?.target?.checked
desktopAutoRealtime.value = checked
@@ -1811,6 +2086,18 @@ const selectedAccount = ref(null)
const availableAccounts = ref([])
const sidebarMediaBase = process.client ? 'http://localhost:8000' : ''
const selfAvatarUrl = computed(() => {
const acc = String(selectedAccount.value || '').trim()
if (!acc) return ''
return `${sidebarMediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
})
const goSns = async () => {
await navigateTo('/sns')
}
// 实时更新WCDB DLL + db_storage watcher
const realtimeEnabled = ref(false)
const realtimeAvailable = ref(false)
@@ -1825,6 +2112,7 @@ let realtimeSessionsRefreshQueued = false
let realtimeFullSyncFuture = null
let realtimeFullSyncQueued = false
let realtimeFullSyncPriority = ''
let realtimeChangeDebounceTimer = null
const allMessages = ref({})
@@ -3851,6 +4139,36 @@ const normalizeMessage = (msg) => {
)
}
// WeChat public account thumbnails (mmbiz.qpic.cn, wx.qlogo.cn...) are hotlink-protected:
// the browser will get a placeholder image ("此图片来自微信公众号平台").
// Proxy them via backend with a mp.weixin.qq.com Referer to fetch the real image.
const normalizedThumbUrl = (() => {
// Backend may provide either `thumbUrl` (appmsg) or `preview` (some exports). Use the first usable one.
const candidates = [msg.thumbUrl, msg.preview]
for (const cand of candidates) {
if (isUsableMediaUrl(cand)) return normalizeMaybeUrl(cand)
}
return ''
})()
const normalizedLinkPreviewUrl = (() => {
const u = normalizedThumbUrl
if (!u) return ''
if (/^\/api\/chat\/media\//i.test(u) || /^blob:/i.test(u) || /^data:/i.test(u)) return u
if (!/^https?:\/\//i.test(u)) return u
try {
const host = new URL(u).hostname.toLowerCase()
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(u)}`
}
} catch {}
return u
})()
const fromUsername = String(msg.fromUsername || '').trim()
const fromAvatar = fromUsername
? `${mediaBase}/api/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}`
: ''
const localEmojiUrl = msg.emojiMd5 ? `${mediaBase}/api/chat/media/emoji?account=${encodeURIComponent(selectedAccount.value || '')}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(selectedContact.value?.username || '')}` : ''
const localImageUrl = (() => {
if (!msg.imageMd5 && !msg.imageFileId) return ''
@@ -3990,14 +4308,23 @@ const normalizeMessage = (msg) => {
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款',
voiceUrl: normalizedVoiceUrl || '',
voiceDuration: msg.voiceLength || msg.voiceDuration || '',
preview: msg.thumbUrl || '',
from: '',
preview: normalizedLinkPreviewUrl || '',
from: String(msg.from || '').trim(),
fromUsername,
fromAvatar,
isGroup: !!selectedContact.value?.isGroup,
avatar: msg.senderAvatar || fallbackAvatar || null,
// Backends may use either `senderAvatar` (our API) or `avatar` (exported JSON).
avatar: msg.senderAvatar || msg.avatar || fallbackAvatar || null,
avatarColor: null
}
}
const onMessageAvatarError = (e, message) => {
// Make sure we fall back to the initial avatar if the URL 404s/blocks.
try { e?.target && (e.target.style.display = 'none') } catch {}
try { if (message) message.avatar = null } catch {}
}
const shouldShowEmojiDownload = (message) => {
if (!message?.emojiMd5) return false
const u = String(message?.emojiRemoteUrl || '').trim()
@@ -4484,6 +4811,7 @@ onUnmounted(() => {
if (!process.client) return
document.removeEventListener('click', onGlobalClick)
document.removeEventListener('keydown', onGlobalKeyDown)
stopSessionListResize()
if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer)
messageSearchDebounceTimer = null
if (highlightMessageTimer) clearTimeout(highlightMessageTimer)
@@ -4536,9 +4864,9 @@ const loadMessages = async ({ username, reset }) => {
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
params.render_types = messageTypeFilter.value
}
if (reset) {
await queueRealtimeFullSync(username)
if (realtimeEnabled.value) {
// In realtime mode, read directly from WCDB to avoid blocking on background sync.
params.source = 'realtime'
}
const resp = await api.listChatMessages(params)
@@ -4639,6 +4967,12 @@ const stopRealtimeStream = () => {
} catch {}
realtimeEventSource = null
}
if (realtimeChangeDebounceTimer) {
try {
clearTimeout(realtimeChangeDebounceTimer)
} catch {}
realtimeChangeDebounceTimer = null
}
}
const refreshRealtimeIncremental = async () => {
@@ -4666,8 +5000,8 @@ const refreshRealtimeIncremental = async () => {
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
params.render_types = messageTypeFilter.value
}
params.source = 'realtime'
await queueRealtimeFullSync(username)
const resp = await api.listChatMessages(params)
if (selectedContact.value?.username !== username) return
@@ -4712,6 +5046,19 @@ const queueRealtimeRefresh = () => {
})
}
const queueRealtimeChange = () => {
if (!process.client || typeof window === 'undefined') return
if (!realtimeEnabled.value) return
if (realtimeChangeDebounceTimer) return
// Debounce noisy db_storage change events to avoid hammering the backend.
realtimeChangeDebounceTimer = setTimeout(() => {
realtimeChangeDebounceTimer = null
queueRealtimeRefresh()
queueRealtimeSessionsRefresh()
}, 500)
}
const startRealtimeStream = () => {
stopRealtimeStream()
if (!process.client || typeof window === 'undefined') return
@@ -4732,9 +5079,7 @@ const startRealtimeStream = () => {
try {
const data = JSON.parse(String(ev.data || '{}'))
if (String(data?.type || '') === 'change') {
queueRealtimeFullSync(selectedContact.value?.username || '')
queueRealtimeRefresh()
queueRealtimeSessionsRefresh()
queueRealtimeChange()
}
} catch {}
}
@@ -4766,8 +5111,22 @@ const toggleRealtime = async (opts = {}) => {
return true
}
// Turning off realtime: sync the latest WCDB rows into the decrypted sqlite DB first,
// otherwise the UI will fall back to an outdated decrypted snapshot.
realtimeEnabled.value = false
stopRealtimeStream()
try {
const api = useApi()
const u = String(selectedContact.value?.username || '').trim()
if (u) {
// Use a larger scan window on shutdown to reduce the chance of missing a backlog.
await api.syncChatRealtimeMessages({
account: selectedAccount.value,
username: u,
max_scan: 5000
})
}
} catch {}
await refreshSessionsForSelectedAccount({ sourceOverride: '' })
if (selectedContact.value?.username) {
await refreshSelectedMessages()
@@ -4928,29 +5287,90 @@ const LinkCard = defineComponent({
heading: { type: String, default: '' },
abstract: { type: String, default: '' },
preview: { type: String, default: '' },
from: { type: String, default: '' }
fromAvatar: { type: String, default: '' },
from: { type: String, default: '' },
isSent: { type: Boolean, default: false }
},
setup(props) {
return () => h(
const getFromText = () => {
const raw = String(props.from || '').trim()
if (raw) return raw
// Fallback: when the appmsg XML doesn't provide sourcedisplayname/appname,
// show the host so the footer row still matches WeChat's fixed card layout.
try {
const host = new URL(String(props.href || '')).hostname
return String(host || '').trim()
} catch {
return ''
}
}
return () => {
const fromText = getFromText()
// WeChat link cards show a small avatar next to the source text. We don't
// always have a real image URL, so fall back to the first glyph.
const fromAvatarText = (() => {
const t = String(fromText || '').trim()
return t ? (Array.from(t)[0] || '') : ''
})()
const fromAvatarUrl = String(props.fromAvatar || '').trim()
return h(
'a',
{
href: props.href,
target: '_blank',
rel: 'noreferrer',
class: 'block max-w-sm w-full bg-white msg-radius border border-neutral-200 overflow-hidden hover:bg-gray-50 transition-colors'
class: [
'wechat-link-card',
'wechat-special-card',
'msg-radius',
props.isSent ? 'wechat-special-sent-side' : ''
].filter(Boolean).join(' '),
// Inline size is intentional: LinkCard is a local component rendered via `h()` and
// does not inherit the SFC scoped CSS attribute, so relying on scoped CSS for exact
// sizing is fragile. Keep width in sync with the WeChat desktop card size.
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'
}
},
[
props.preview ? h('div', { class: 'w-full bg-black/5' }, [
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'w-full max-h-40 object-cover' })
]) : null,
h('div', { class: 'px-3 py-2' }, [
h('div', { class: 'text-sm font-medium text-gray-900 line-clamp-2' }, props.heading || props.href),
props.abstract ? h('div', { class: 'text-xs text-gray-600 mt-1 line-clamp-2' }, props.abstract) : null,
props.from ? h('div', { class: 'text-[10px] text-gray-400 mt-1 truncate' }, props.from) : null
h('div', { class: 'wechat-link-content' }, [
h('div', { class: 'wechat-link-info' }, [
h('div', { class: 'wechat-link-title' }, props.heading || props.href),
props.abstract ? h('div', { class: 'wechat-link-desc' }, props.abstract) : null
].filter(Boolean)),
props.preview ? h('div', { class: 'wechat-link-thumb' }, [
h('img', { src: props.preview, alt: props.heading || '链接预览', class: 'wechat-link-thumb-img', referrerpolicy: 'no-referrer' })
]) : null
].filter(Boolean)),
h('div', { class: 'wechat-link-from' }, [
h('div', { class: 'wechat-link-from-avatar', 'aria-hidden': 'true' }, [
fromAvatarText || '\u200B',
fromAvatarUrl ? h('img', {
src: fromAvatarUrl,
alt: '',
class: 'wechat-link-from-avatar-img',
referrerpolicy: 'no-referrer',
onError: (e) => { try { e?.target && (e.target.style.display = 'none') } catch {} }
}) : null
].filter(Boolean)),
h('div', { class: 'wechat-link-from-name' }, fromText || '\u200B')
])
].filter(Boolean)
)
}
}
})
</script>
@@ -4975,6 +5395,38 @@ const LinkCard = defineComponent({
background: #a1a1a1;
}
/* 会话列表宽度:按物理像素(px)配置,按 dpr 换算为 CSS px */
.session-list-panel {
width: calc(var(--session-list-width, 295px) / var(--dpr));
}
/* 会话列表拖动条(中间栏右侧) */
.session-list-resizer {
position: absolute;
top: 0;
right: -3px; /* 覆盖在 border 上,便于拖动 */
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 50;
}
.session-list-resizer::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 2px;
width: 2px;
background: transparent;
transition: background-color 0.15s ease;
}
.session-list-resizer:hover::after,
.session-list-resizer-active::after {
background: rgba(0, 0, 0, 0.12);
}
/* 消息气泡样式 */
.message-bubble {
border-radius: var(--message-radius);
@@ -5263,24 +5715,24 @@ const LinkCard = defineComponent({
}
/* 统一特殊消息尾巴(红包 / 文件等) */
.wechat-special-card {
:deep(.wechat-special-card) {
position: relative;
overflow: visible;
}
.wechat-special-card::after {
:deep(.wechat-special-card)::after {
content: '';
position: absolute;
top: 16px;
top: 12px;
left: -4px;
width: 10px;
height: 10px;
width: 12px;
height: 12px;
background-color: inherit;
transform: rotate(45deg);
border-radius: 2px;
}
.wechat-special-sent-side::after {
:deep(.wechat-special-sent-side)::after {
left: auto;
right: -4px;
}
@@ -5632,6 +6084,138 @@ const LinkCard = defineComponent({
margin-right: 4px;
}
/* 链接消息样式 - 微信风格 */
:deep(.wechat-link-card) {
width: 210px;
min-width: 210px;
max-width: 210px;
background: #fff;
display: flex;
flex-direction: column;
box-sizing: border-box;
border: none;
box-shadow: none;
outline: none;
cursor: pointer;
text-decoration: none;
transition: background-color 0.15s ease;
}
:deep(.wechat-link-card:hover) {
background: #f5f5f5;
}
:deep(.wechat-link-content) {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
box-sizing: border-box;
/* Keep a small breathing room above the footer divider. */
padding: 8px 10px 6px;
flex: 1 1 auto;
}
:deep(.wechat-link-info) {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1 1 auto;
min-width: 0;
}
:deep(.wechat-link-title) {
font-size: 14px;
color: #1a1a1a;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
word-break: break-word;
}
:deep(.wechat-link-desc) {
font-size: 12px;
color: #8c8c8c;
margin-top: 4px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.4;
word-break: break-word;
}
:deep(.wechat-link-thumb) {
width: 42px;
height: 42px;
flex-shrink: 0;
border-radius: 0;
overflow: hidden;
background: #f2f2f2;
/* Center the thumbnail in the content area (WeChat desktop style). */
align-self: center;
}
:deep(.wechat-link-thumb-img) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
:deep(.wechat-link-from) {
height: 30px;
display: flex;
align-items: center;
gap: 5px;
padding: 0 10px;
position: relative;
flex-shrink: 0;
}
:deep(.wechat-link-from)::before {
content: '';
position: absolute;
top: 0;
left: 11px;
right: 11px;
height: 1.5px;
background: #e8e8e8;
}
:deep(.wechat-link-from-avatar) {
width: 16px;
height: 16px;
border-radius: 50%;
background: #111;
color: #fff;
font-size: 11px;
line-height: 16px;
text-align: center;
flex-shrink: 0;
position: relative;
overflow: hidden;
}
:deep(.wechat-link-from-avatar-img) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
:deep(.wechat-link-from-name) {
font-size: 12px;
color: #b2b2b2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 隐私模式模糊效果 */
.privacy-blur {
filter: blur(9px);

1092
frontend/pages/sns.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ from .routers.decrypt import router as _decrypt_router
from .routers.health import router as _health_router
from .routers.keys import router as _keys_router
from .routers.media import router as _media_router
from .routers.sns import router as _sns_router
from .routers.wechat_detection import router as _wechat_detection_router
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
@@ -51,6 +52,7 @@ app.include_router(_media_router)
app.include_router(_chat_router)
app.include_router(_chat_export_router)
app.include_router(_chat_media_router)
app.include_router(_sns_router)
class _SPAStaticFiles(StaticFiles):

View File

@@ -894,6 +894,7 @@ def _parse_message_for_export(
content_text = raw_text
title = ""
url = ""
from_name = ""
record_item = ""
image_md5 = ""
image_file_id = ""
@@ -934,6 +935,7 @@ def _parse_message_for_export(
content_text = str(parsed.get("content") or "")
title = str(parsed.get("title") or "")
url = str(parsed.get("url") or "")
from_name = str(parsed.get("from") or "")
record_item = str(parsed.get("recordItem") or "")
quote_title = str(parsed.get("quoteTitle") or "")
quote_content = str(parsed.get("quoteContent") or "")
@@ -1162,6 +1164,7 @@ def _parse_message_for_export(
"content": content_text,
"title": title,
"url": url,
"from": from_name,
"recordItem": record_item,
"thumbUrl": thumb_url,
"imageMd5": image_md5,

View File

@@ -773,7 +773,21 @@ def _parse_app_message(text: str) -> dict[str, Any]:
app_type = 0
title = _extract_xml_tag_text(text, "title")
des = _extract_xml_tag_text(text, "des")
url = _extract_xml_tag_text(text, "url")
url = _normalize_xml_url(_extract_xml_tag_text(text, "url"))
# Some appmsg payloads (notably mp.weixin.qq.com link shares) include a "source" block:
# <sourceusername>gh_xxx</sourceusername>
# <sourcedisplayname>公众号名</sourcedisplayname>
# We'll surface that as `from` so the frontend can render the publisher line like WeChat.
source_display_name = (
_extract_xml_tag_text(text, "sourcedisplayname")
or _extract_xml_tag_text(text, "sourceDisplayName")
or _extract_xml_tag_text(text, "appname")
)
source_username = (
_extract_xml_tag_text(text, "sourceusername")
or _extract_xml_tag_text(text, "sourceUsername")
)
lower = text.lower()
@@ -794,13 +808,15 @@ def _parse_app_message(text: str) -> dict[str, Any]:
}
if app_type in (5, 68) and url:
thumb_url = _extract_xml_tag_text(text, "thumburl")
thumb_url = _normalize_xml_url(_extract_xml_tag_text(text, "thumburl"))
return {
"renderType": "link",
"content": des or title or "[链接]",
"title": title or des or "",
"url": url,
"thumbUrl": thumb_url or "",
"from": str(source_display_name or "").strip(),
"fromUsername": str(source_username or "").strip(),
}
if app_type in (6, 74):
@@ -1322,6 +1338,58 @@ def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str,
conn.close()
def _load_usernames_by_display_names(contact_db_path: Path, names: list[str]) -> dict[str, str]:
"""Best-effort mapping from display name -> username using contact.db.
Some appmsg/link payloads only provide `sourcedisplayname` (surfaced as `from`) but not
`sourceusername` (`fromUsername`). We use this mapping to recover `fromUsername` so the
frontend can render the publisher avatar via `/api/chat/avatar`.
"""
uniq = list(dict.fromkeys([str(n or "").strip() for n in names if str(n or "").strip()]))
if not uniq:
return {}
placeholders = ",".join(["?"] * len(uniq))
hits: dict[str, set[str]] = {}
conn = sqlite3.connect(str(contact_db_path))
conn.row_factory = sqlite3.Row
try:
def query_table(table: str) -> None:
for col in ("remark", "nick_name", "alias"):
sql = f"""
SELECT username, {col} AS display_name
FROM {table}
WHERE {col} IN ({placeholders})
"""
try:
rows = conn.execute(sql, uniq).fetchall()
except Exception:
rows = []
for r in rows:
try:
dn = str(r["display_name"] or "").strip()
u = str(r["username"] or "").strip()
except Exception:
continue
if not dn or not u:
continue
hits.setdefault(dn, set()).add(u)
query_table("contact")
query_table("stranger")
# Only return unambiguous mappings (display name -> exactly 1 username).
out: dict[str, str] = {}
for dn, users in hits.items():
if len(users) == 1:
out[dn] = next(iter(users))
return out
finally:
conn.close()
def _make_search_tokens(q: str) -> list[str]:
tokens = [t for t in re.split(r"\s+", str(q or "").strip()) if t]
if len(tokens) > 8:

View File

@@ -3,6 +3,7 @@
"""
import logging
import os
import sys
from datetime import datetime
from pathlib import Path
@@ -58,6 +59,11 @@ class WeChatLogger:
def setup_logging(self, log_level: str = "INFO"):
"""设置日志配置"""
# Allow overriding via env var for easier debugging (e.g. WECHAT_TOOL_LOG_LEVEL=DEBUG)
env_level = str(os.environ.get("WECHAT_TOOL_LOG_LEVEL", "") or "").strip()
if env_level:
log_level = env_level
# 创建日志目录
now = datetime.now()
log_dir = Path("output/logs") / str(now.year) / f"{now.month:02d}" / f"{now.day:02d}"
@@ -88,46 +94,47 @@ class WeChatLogger:
# 文件处理器
file_handler = logging.FileHandler(self.log_file, encoding='utf-8')
file_handler.setFormatter(file_formatter)
file_handler.setLevel(getattr(logging, log_level.upper()))
level = getattr(logging, str(log_level or "INFO").upper(), logging.INFO)
file_handler.setLevel(level)
# 控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(console_formatter)
console_handler.setLevel(getattr(logging, log_level.upper()))
console_handler.setLevel(level)
# 配置根日志器
root_logger.setLevel(getattr(logging, log_level.upper()))
root_logger.setLevel(level)
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
# 只为uvicorn日志器添加文件处理器保持其原有的控制台处理器带颜色
uvicorn_logger = logging.getLogger("uvicorn")
uvicorn_logger.addHandler(file_handler)
uvicorn_logger.setLevel(getattr(logging, log_level.upper()))
uvicorn_logger.setLevel(level)
# 只为uvicorn.access日志器添加文件处理器
uvicorn_access_logger = logging.getLogger("uvicorn.access")
uvicorn_access_logger.addHandler(file_handler)
uvicorn_access_logger.setLevel(getattr(logging, log_level.upper()))
uvicorn_access_logger.setLevel(level)
# 只为uvicorn.error日志器添加文件处理器
uvicorn_error_logger = logging.getLogger("uvicorn.error")
uvicorn_error_logger.addHandler(file_handler)
uvicorn_error_logger.setLevel(getattr(logging, log_level.upper()))
uvicorn_error_logger.setLevel(level)
# 配置FastAPI日志器
fastapi_logger = logging.getLogger("fastapi")
fastapi_logger.handlers = []
fastapi_logger.addHandler(file_handler)
fastapi_logger.addHandler(console_handler)
fastapi_logger.setLevel(getattr(logging, log_level.upper()))
fastapi_logger.setLevel(level)
# 记录初始化信息
logger = logging.getLogger(__name__)
logger.info("=" * 60)
logger.info("微信解密工具日志系统初始化完成")
logger.info(f"日志文件: {self.log_file}")
logger.info(f"日志级别: {log_level}")
logger.info(f"日志级别: {logging.getLevelName(level)}")
logger.info("=" * 60)
return self.log_file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -408,6 +408,110 @@ def _detect_media_type_and_ext(data: bytes) -> tuple[bytes, str, str]:
return payload, media_type, ext
def _is_allowed_proxy_image_host(host: str) -> bool:
"""Allowlist hosts for proxying images to avoid turning this into a general SSRF gadget."""
h = str(host or "").strip().lower()
if not h:
return False
# WeChat public account/article thumbnails and avatars commonly live on these CDNs.
return h.endswith(".qpic.cn") or h.endswith(".qlogo.cn") or h.endswith(".tc.qq.com")
@router.get("/api/chat/media/proxy_image", summary="代理获取远程图片(解决微信公众号图片防盗链)")
async def proxy_image(url: str):
u = html.unescape(str(url or "")).strip()
if not u:
raise HTTPException(status_code=400, detail="Missing url.")
if not _is_safe_http_url(u):
raise HTTPException(status_code=400, detail="Invalid url (only public http/https allowed).")
try:
p = urlparse(u)
except Exception:
raise HTTPException(status_code=400, detail="Invalid url.")
host = (p.hostname or "").strip().lower()
if not _is_allowed_proxy_image_host(host):
raise HTTPException(status_code=400, detail="Unsupported url host for proxy_image.")
def _download_bytes() -> tuple[bytes, str]:
base_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
}
# Different Tencent CDNs enforce different anti-hotlink rules.
# Try a couple of safe referers so Moments(qpic) and MP(qpic) both work.
header_variants = [
{"Referer": "https://wx.qq.com/", "Origin": "https://wx.qq.com"},
{"Referer": "https://mp.weixin.qq.com/", "Origin": "https://mp.weixin.qq.com"},
{"Referer": "https://www.baidu.com/", "Origin": "https://www.baidu.com"},
{},
]
last_err: Exception | None = None
for extra in header_variants:
headers = dict(base_headers)
headers.update(extra)
r = requests.get(u, headers=headers, timeout=20, stream=True)
try:
r.raise_for_status()
content_type = str(r.headers.get("Content-Type") or "").strip()
max_bytes = 10 * 1024 * 1024
chunks: list[bytes] = []
total = 0
for ch in r.iter_content(chunk_size=64 * 1024):
if not ch:
continue
chunks.append(ch)
total += len(ch)
if total > max_bytes:
raise HTTPException(status_code=400, detail="Proxy image too large (>10MB).")
return b"".join(chunks), content_type
except HTTPException:
# Hard failure, don't retry with another referer.
raise
except Exception as e:
last_err = e
finally:
try:
r.close()
except Exception:
pass
# All variants failed.
raise last_err or RuntimeError("proxy_image download failed")
try:
data, ct = await asyncio.to_thread(_download_bytes)
except HTTPException:
raise
except Exception as e:
logger.warning(f"proxy_image failed: url={u} err={e}")
raise HTTPException(status_code=502, detail=f"Proxy image failed: {e}")
if not data:
raise HTTPException(status_code=502, detail="Proxy returned empty body.")
payload, media_type, _ext = _detect_media_type_and_ext(data)
# Prefer upstream Content-Type when it looks like an image (sniffing may fail for some formats).
if media_type == "application/octet-stream" and ct:
try:
mt = ct.split(";")[0].strip()
if mt.startswith("image/"):
media_type = mt
except Exception:
pass
if not str(media_type or "").startswith("image/"):
raise HTTPException(status_code=502, detail="Proxy did not return an image.")
resp = Response(content=payload, media_type=media_type)
resp.headers["Cache-Control"] = "public, max-age=86400"
return resp
@router.post("/api/chat/media/emoji/download", summary="下载表情消息资源到本地 resource")
async def download_chat_emoji(req: EmojiDownloadRequest):
md5 = str(req.md5 or "").strip().lower()

File diff suppressed because it is too large Load Diff

View File

@@ -68,6 +68,13 @@ def _load_wcdb_lib() -> ctypes.CDLL:
lib.wcdb_close_account.argtypes = [ctypes.c_int64]
lib.wcdb_close_account.restype = ctypes.c_int
# Optional: wcdb_set_my_wxid(handle, wxid)
try:
lib.wcdb_set_my_wxid.argtypes = [ctypes.c_int64, ctypes.c_char_p]
lib.wcdb_set_my_wxid.restype = ctypes.c_int
except Exception:
pass
lib.wcdb_get_sessions.argtypes = [ctypes.c_int64, ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_sessions.restype = ctypes.c_int
@@ -95,6 +102,37 @@ def _load_wcdb_lib() -> ctypes.CDLL:
lib.wcdb_get_group_members.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_group_members.restype = ctypes.c_int
# Optional: execute arbitrary SQL on a selected database kind/path.
# Signature: wcdb_exec_query(handle, kind, path, sql, out_json)
try:
lib.wcdb_exec_query.argtypes = [
ctypes.c_int64,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.POINTER(ctypes.c_char_p),
]
lib.wcdb_exec_query.restype = ctypes.c_int
except Exception:
pass
# Optional (newer DLLs): wcdb_get_sns_timeline(handle, limit, offset, usernames_json, keyword, start_time, end_time, out_json)
try:
lib.wcdb_get_sns_timeline.argtypes = [
ctypes.c_int64,
ctypes.c_int32,
ctypes.c_int32,
ctypes.c_char_p,
ctypes.c_char_p,
ctypes.c_int32,
ctypes.c_int32,
ctypes.POINTER(ctypes.c_char_p),
]
lib.wcdb_get_sns_timeline.restype = ctypes.c_int
except Exception:
# Older wcdb_api.dll may not expose this export.
pass
lib.wcdb_get_logs.argtypes = [ctypes.POINTER(ctypes.c_char_p)]
lib.wcdb_get_logs.restype = ctypes.c_int
@@ -195,6 +233,30 @@ def open_account(session_db_path: Path, key_hex: str) -> int:
return int(out_handle.value)
def set_my_wxid(handle: int, wxid: str) -> bool:
"""Best-effort set the "my wxid" context for some WCDB APIs."""
try:
_ensure_initialized()
except Exception:
return False
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_set_my_wxid", None)
if not fn:
return False
w = str(wxid or "").strip()
if not w:
return False
try:
rc = int(fn(ctypes.c_int64(int(handle)), w.encode("utf-8")))
except Exception:
return False
return rc == 0
def close_account(handle: int) -> None:
try:
h = int(handle)
@@ -293,6 +355,93 @@ def get_avatar_urls(handle: int, usernames: list[str]) -> dict[str, str]:
return {}
def exec_query(handle: int, *, kind: str, path: Optional[str], sql: str) -> list[dict[str, Any]]:
"""Execute raw SQL on a specific db kind/path via WCDB.
This is primarily used for SNS/other dbs that are not directly exposed by dedicated APIs.
"""
_ensure_initialized()
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_exec_query", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support exec_query.")
k = str(kind or "").strip()
if not k:
raise WCDBRealtimeError("Missing kind for exec_query.")
s = str(sql or "").strip()
if not s:
return []
p = None if path is None else str(path or "").strip()
out_json = _call_out_json(
fn,
ctypes.c_int64(int(handle)),
k.encode("utf-8"),
None if p is None else p.encode("utf-8"),
s.encode("utf-8"),
)
decoded = _safe_load_json(out_json)
if isinstance(decoded, list):
out: list[dict[str, Any]] = []
for x in decoded:
if isinstance(x, dict):
out.append(x)
return out
return []
def get_sns_timeline(
handle: int,
*,
limit: int = 20,
offset: int = 0,
usernames: Optional[list[str]] = None,
keyword: str | None = None,
start_time: int = 0,
end_time: int = 0,
) -> list[dict[str, Any]]:
"""Read Moments (SnsTimeLine) from the live encrypted db_storage via WCDB.
Requires a newer wcdb_api.dll export: wcdb_get_sns_timeline.
"""
_ensure_initialized()
lib = _load_wcdb_lib()
fn = getattr(lib, "wcdb_get_sns_timeline", None)
if not fn:
raise WCDBRealtimeError("Current wcdb_api.dll does not support sns timeline.")
lim = max(0, int(limit or 0))
off = max(0, int(offset or 0))
users = [str(u or "").strip() for u in (usernames or []) if str(u or "").strip()]
users = list(dict.fromkeys(users))
users_json = json.dumps(users, ensure_ascii=False) if users else ""
kw = str(keyword or "").strip()
payload = _call_out_json(
fn,
ctypes.c_int64(int(handle)),
ctypes.c_int32(lim),
ctypes.c_int32(off),
users_json.encode("utf-8"),
kw.encode("utf-8"),
ctypes.c_int32(int(start_time or 0)),
ctypes.c_int32(int(end_time or 0)),
)
decoded = _safe_load_json(payload)
if isinstance(decoded, list):
out: list[dict[str, Any]] = []
for x in decoded:
if isinstance(x, dict):
out.append(x)
return out
return []
def shutdown() -> None:
global _initialized
lib = _load_wcdb_lib()
@@ -427,6 +576,11 @@ class WCDBRealtimeManager:
session_db_path = _resolve_session_db_path(db_storage_dir)
handle = open_account(session_db_path, key)
# Some WCDB APIs (e.g. exec_query on non-session DBs) may require this context.
try:
set_my_wxid(handle, account)
except Exception:
pass
conn = WCDBRealtimeConnection(
account=account,