mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
12 Commits
@@ -10,7 +10,7 @@
|
||||
<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" />
|
||||
<a href="https://qm.qq.com/q/VQEQ7PcGkk"><img src="https://img.shields.io/badge/QQ%20Group-WeChatDataAnalysis-12B7F5?logo=tencentqq&logoColor=white" alt="QQ Group" /></a>
|
||||
<a href="https://qm.qq.com/q/VQEQ7PcGkk"><img src="https://img.shields.io/badge/QQ Group-WeChatDataAnalysis-12B7F5?logo=tencentqq&logoColor=white" alt="QQ Group" /></a>
|
||||
<img src="https://img.shields.io/badge/Python-3776AB?logo=Python&logoColor=white" alt="Python" />
|
||||
<img src="https://img.shields.io/badge/Vue.js-4FC08D?logo=Vue.js&logoColor=white" alt="Vue.js" />
|
||||
<img src="https://img.shields.io/badge/SQLite-003B57?logo=SQLite&logoColor=white" alt="SQLite" />
|
||||
|
||||
+112
-1
@@ -1359,6 +1359,34 @@ function getRendererConsoleLogPath() {
|
||||
}
|
||||
}
|
||||
|
||||
function getRendererDebugLogPath() {
|
||||
try {
|
||||
const dir = app.getPath("userData");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return path.join(dir, "renderer-debug.log");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendRendererDebugLog(line) {
|
||||
const logPath = getRendererDebugLogPath();
|
||||
if (!logPath) return;
|
||||
try {
|
||||
fs.appendFileSync(logPath, line, { encoding: "utf8" });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function stringifyDebugDetails(details) {
|
||||
if (details == null) return "";
|
||||
if (typeof details === "string") return details;
|
||||
try {
|
||||
return JSON.stringify(details);
|
||||
} catch (err) {
|
||||
return `[unserializable:${err?.message || err}]`;
|
||||
}
|
||||
}
|
||||
|
||||
function setupRendererConsoleLogging(win) {
|
||||
if (!debugEnabled()) return;
|
||||
|
||||
@@ -1380,6 +1408,62 @@ function setupRendererConsoleLogging(win) {
|
||||
});
|
||||
}
|
||||
|
||||
function setupRendererLifecycleLogging(win) {
|
||||
if (!debugEnabled()) return;
|
||||
|
||||
const logRendererLifecycle = (message) => {
|
||||
logMain(`[renderer] ${message}`);
|
||||
};
|
||||
|
||||
logRendererLifecycle(`window-created id=${win.id}`);
|
||||
|
||||
win.webContents.on("did-start-loading", () => {
|
||||
logRendererLifecycle("did-start-loading");
|
||||
});
|
||||
|
||||
win.webContents.on("dom-ready", () => {
|
||||
logRendererLifecycle(`dom-ready url=${win.webContents.getURL()}`);
|
||||
});
|
||||
|
||||
win.webContents.on("did-stop-loading", () => {
|
||||
logRendererLifecycle("did-stop-loading");
|
||||
});
|
||||
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
logRendererLifecycle(`did-finish-load url=${win.webContents.getURL()}`);
|
||||
});
|
||||
|
||||
win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
logRendererLifecycle(
|
||||
`did-fail-load code=${errorCode} mainFrame=${!!isMainFrame} url=${validatedURL} error=${errorDescription}`
|
||||
);
|
||||
});
|
||||
|
||||
win.webContents.on("did-navigate", (_event, url, httpResponseCode, httpStatusText) => {
|
||||
logRendererLifecycle(
|
||||
`did-navigate url=${url} code=${httpResponseCode || 0} status=${httpStatusText || ""}`
|
||||
);
|
||||
});
|
||||
|
||||
win.webContents.on("did-navigate-in-page", (_event, url, isMainFrame) => {
|
||||
logRendererLifecycle(`did-navigate-in-page mainFrame=${!!isMainFrame} url=${url}`);
|
||||
});
|
||||
|
||||
win.webContents.on("render-process-gone", (_event, details) => {
|
||||
logRendererLifecycle(
|
||||
`render-process-gone reason=${details?.reason || ""} exitCode=${details?.exitCode ?? ""}`
|
||||
);
|
||||
});
|
||||
|
||||
win.on("unresponsive", () => {
|
||||
logRendererLifecycle("window-unresponsive");
|
||||
});
|
||||
|
||||
win.on("responsive", () => {
|
||||
logRendererLifecycle("window-responsive");
|
||||
});
|
||||
}
|
||||
|
||||
function createMainWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
@@ -1423,18 +1507,26 @@ function createMainWindow() {
|
||||
});
|
||||
|
||||
setupRendererConsoleLogging(win);
|
||||
setupRendererLifecycleLogging(win);
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
async function loadWithRetry(win, url) {
|
||||
const startedAt = Date.now();
|
||||
let attempt = 0;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
attempt += 1;
|
||||
logMain(`[main] loadWithRetry attempt=${attempt} url=${url}`);
|
||||
try {
|
||||
await win.loadURL(url);
|
||||
logMain(`[main] loadWithRetry success attempt=${attempt} elapsedMs=${Date.now() - startedAt} url=${url}`);
|
||||
return;
|
||||
} catch {
|
||||
} catch (err) {
|
||||
logMain(
|
||||
`[main] loadWithRetry failure attempt=${attempt} elapsedMs=${Date.now() - startedAt} url=${url} error=${err?.message || err}`
|
||||
);
|
||||
if (Date.now() - startedAt > 60_000) throw new Error(`Failed to load URL in time: ${url}`);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
@@ -1502,6 +1594,24 @@ function registerWindowIpc() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:isDebugEnabled", () => {
|
||||
try {
|
||||
return debugEnabled();
|
||||
} catch (err) {
|
||||
logMain(`[main] app:isDebugEnabled failed: ${err?.message || err}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("debug:log", (event, payload) => {
|
||||
const scope = String(payload?.scope || "renderer").trim() || "renderer";
|
||||
const message = String(payload?.message || "").trim() || "(empty)";
|
||||
const url = String(payload?.url || event?.sender?.getURL?.() || "").trim();
|
||||
const details = stringifyDebugDetails(payload?.details);
|
||||
const suffix = details ? ` details=${details}` : "";
|
||||
appendRendererDebugLog(`[${nowIso()}] [${scope}] ${message} url=${url}${suffix}\n`);
|
||||
});
|
||||
|
||||
ipcMain.handle("app:setCloseBehavior", (_event, behavior) => {
|
||||
try {
|
||||
const next = setCloseBehavior(behavior);
|
||||
@@ -1727,6 +1837,7 @@ async function main() {
|
||||
process.env.ELECTRON_START_URL ||
|
||||
(app.isPackaged ? getBackendUiUrl() : "http://localhost:3000");
|
||||
|
||||
logMain(`[main] debugEnabled=${debugEnabled()} startUrl=${startUrl}`);
|
||||
await loadWithRetry(win, startUrl);
|
||||
|
||||
// Auto-check updates after the UI has loaded (packaged builds only).
|
||||
|
||||
@@ -1,5 +1,65 @@
|
||||
const { contextBridge, ipcRenderer } = require("electron");
|
||||
|
||||
function sendDebugLog(scope, message, details) {
|
||||
try {
|
||||
ipcRenderer.send("debug:log", {
|
||||
scope: String(scope || "renderer"),
|
||||
message: String(message || ""),
|
||||
details: details == null ? {} : details,
|
||||
url: typeof location !== "undefined" ? String(location.href || "") : "",
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
sendDebugLog("preload", "script-start", {
|
||||
userAgent: typeof navigator !== "undefined" ? String(navigator.userAgent || "") : "",
|
||||
});
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("readystatechange", () => {
|
||||
sendDebugLog("preload", "document-readystate", {
|
||||
readyState: String(document.readyState || ""),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
sendDebugLog("preload", "dom-content-loaded");
|
||||
});
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
sendDebugLog("preload", "window-load");
|
||||
});
|
||||
|
||||
window.addEventListener("error", (event) => {
|
||||
sendDebugLog("preload", "window-error", {
|
||||
message: String(event?.message || ""),
|
||||
filename: String(event?.filename || ""),
|
||||
lineno: Number(event?.lineno || 0),
|
||||
colno: Number(event?.colno || 0),
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
const reason = event?.reason;
|
||||
sendDebugLog("preload", "window-unhandledrejection", {
|
||||
reason:
|
||||
reason instanceof Error
|
||||
? {
|
||||
name: String(reason.name || "Error"),
|
||||
message: String(reason.message || ""),
|
||||
stack: String(reason.stack || ""),
|
||||
}
|
||||
: String(reason || ""),
|
||||
});
|
||||
});
|
||||
|
||||
window.setTimeout(() => {
|
||||
sendDebugLog("preload", "set-timeout-0");
|
||||
}, 0);
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
// Marker used by the frontend to distinguish the Electron desktop shell from the pure web build.
|
||||
__brand: "WeChatDataAnalysisDesktop",
|
||||
@@ -7,6 +67,8 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"),
|
||||
close: () => ipcRenderer.invoke("window:close"),
|
||||
isMaximized: () => ipcRenderer.invoke("window:isMaximized"),
|
||||
isDebugEnabled: () => ipcRenderer.invoke("app:isDebugEnabled"),
|
||||
logDebug: (scope, message, details = {}) => sendDebugLog(scope, message, details),
|
||||
|
||||
getAutoLaunch: () => ipcRenderer.invoke("app:getAutoLaunch"),
|
||||
setAutoLaunch: (enabled) => ipcRenderer.invoke("app:setAutoLaunch", !!enabled),
|
||||
|
||||
+19
-1
@@ -30,12 +30,18 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useThemeStore } from '~/stores/theme'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
const route = useRoute()
|
||||
const desktopUpdate = useDesktopUpdate()
|
||||
const { open: settingsDialogOpen, closeDialog: closeSettingsDialog } = useSettingsDialog()
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
if (process.client) {
|
||||
themeStore.init()
|
||||
}
|
||||
|
||||
// In Electron the server/pre-render doesn't know about `window.wechatDesktop`.
|
||||
// If we render different DOM on server vs client, Vue hydration will keep the
|
||||
@@ -71,6 +77,7 @@ onMounted(() => {
|
||||
const privacy = usePrivacyStore()
|
||||
void chatAccounts.ensureLoaded()
|
||||
privacy.init()
|
||||
themeStore.init()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -78,7 +85,7 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
|
||||
const rootClass = computed(() => {
|
||||
const base = 'bg-gradient-to-br from-green-50 via-emerald-50 to-green-100'
|
||||
const base = 'theme-app-shell'
|
||||
return isDesktop.value
|
||||
? `wechat-desktop h-screen flex overflow-hidden ${base}`
|
||||
: `h-screen flex overflow-hidden ${base}`
|
||||
@@ -126,4 +133,15 @@ const showSidebar = computed(() => {
|
||||
.wechat-desktop .wechat-desktop-content > .min-h-screen {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.theme-app-shell {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(7, 193, 96, 0.08), transparent 32%),
|
||||
radial-gradient(circle at top right, rgba(16, 174, 239, 0.08), transparent 36%),
|
||||
linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 45%, #dcfce7 100%);
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .theme-app-shell {
|
||||
background: var(--app-shell-bg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,17 +33,17 @@
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
background: var(--scrollbar-track);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1a1;
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* 会话列表宽度:按物理像素(px)配置,按 dpr 换算为 CSS px */
|
||||
@@ -75,7 +75,25 @@
|
||||
|
||||
.session-list-resizer:hover::after,
|
||||
.session-list-resizer-active::after {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
background: var(--session-list-resizer);
|
||||
}
|
||||
|
||||
.msg-bubble.bubble-tail-r {
|
||||
background-color: var(--chat-bubble-sent) !important;
|
||||
color: var(--chat-bubble-sent-text) !important;
|
||||
}
|
||||
|
||||
.msg-bubble.bubble-tail-l {
|
||||
background-color: var(--chat-bubble-received) !important;
|
||||
color: var(--chat-bubble-received-text) !important;
|
||||
}
|
||||
|
||||
.bubble-tail-r::after {
|
||||
background: var(--chat-bubble-sent);
|
||||
}
|
||||
|
||||
.bubble-tail-l::after {
|
||||
background: var(--chat-bubble-received);
|
||||
}
|
||||
|
||||
/* 消息气泡样式 */
|
||||
@@ -87,7 +105,7 @@
|
||||
|
||||
/* 发送的消息(右侧绿色气泡) */
|
||||
.sent-message {
|
||||
background-color: #95EB69 !important;
|
||||
background-color: var(--chat-bubble-sent) !important;
|
||||
border-radius: var(--message-radius);
|
||||
}
|
||||
|
||||
@@ -99,13 +117,13 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: #95EB69;
|
||||
background-color: var(--chat-bubble-sent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 接收的消息(左侧白色气泡) */
|
||||
.received-message {
|
||||
background-color: white !important;
|
||||
background-color: var(--chat-bubble-received) !important;
|
||||
border-radius: var(--message-radius);
|
||||
}
|
||||
|
||||
@@ -117,7 +135,7 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: white;
|
||||
background-color: var(--chat-bubble-received);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -172,7 +190,7 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: #95EC69;
|
||||
background-color: var(--chat-bubble-sent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -188,7 +206,7 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: white;
|
||||
background-color: var(--chat-bubble-received);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -216,7 +234,8 @@
|
||||
}
|
||||
|
||||
.wechat-voice-sent {
|
||||
background: #95EC69;
|
||||
background: var(--chat-bubble-sent);
|
||||
color: var(--chat-bubble-sent-text);
|
||||
}
|
||||
|
||||
.wechat-voice-sent::after {
|
||||
@@ -227,12 +246,13 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #95EC69;
|
||||
background: var(--chat-bubble-sent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.wechat-voice-received {
|
||||
background: white;
|
||||
background: var(--chat-bubble-received);
|
||||
color: var(--chat-bubble-received-text);
|
||||
}
|
||||
|
||||
.wechat-voice-received::before {
|
||||
@@ -243,7 +263,7 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
background: var(--chat-bubble-received);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -259,7 +279,7 @@
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
color: #1a1a1a;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.wechat-quote-voice-icon {
|
||||
@@ -293,7 +313,7 @@
|
||||
|
||||
.wechat-voice-duration {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.wechat-voice-unread {
|
||||
@@ -315,7 +335,8 @@
|
||||
}
|
||||
|
||||
.wechat-voip-sent {
|
||||
background: #95EC69;
|
||||
background: var(--chat-bubble-sent);
|
||||
color: var(--chat-bubble-sent-text);
|
||||
}
|
||||
|
||||
.wechat-voip-sent::after {
|
||||
@@ -326,12 +347,13 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #95EC69;
|
||||
background: var(--chat-bubble-sent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.wechat-voip-received {
|
||||
background: white;
|
||||
background: var(--chat-bubble-received);
|
||||
color: var(--chat-bubble-received-text);
|
||||
}
|
||||
|
||||
.wechat-voip-received::before {
|
||||
@@ -342,7 +364,7 @@
|
||||
transform: translateY(-50%) rotate(45deg);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
background: var(--chat-bubble-received);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -362,7 +384,7 @@
|
||||
|
||||
.wechat-voip-text {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 统一特殊消息尾巴(红包 / 文件等) */
|
||||
@@ -390,14 +412,14 @@
|
||||
|
||||
.wechat-chat-history-card {
|
||||
width: 210px;
|
||||
background: #ffffff;
|
||||
background: var(--merged-history-bg);
|
||||
border-radius: var(--message-radius);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.wechat-chat-history-card:hover {
|
||||
background: #f5f5f5;
|
||||
background: var(--merged-history-hover);
|
||||
}
|
||||
|
||||
.wechat-chat-history-body {
|
||||
@@ -407,13 +429,13 @@
|
||||
.wechat-chat-history-title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #161616;
|
||||
color: var(--merged-history-title);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wechat-chat-history-preview {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
color: var(--merged-history-preview);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -439,12 +461,17 @@
|
||||
left: 13px;
|
||||
right: 13px;
|
||||
height: 1.5px;
|
||||
background: #e8e8e8;
|
||||
background: var(--merged-history-divider);
|
||||
}
|
||||
|
||||
.wechat-chat-history-bottom span {
|
||||
font-size: 12px;
|
||||
color: #b2b2b2;
|
||||
color: var(--merged-history-footer);
|
||||
}
|
||||
|
||||
.wechat-quote-preview {
|
||||
background: var(--quote-bubble-bg);
|
||||
color: var(--quote-bubble-text);
|
||||
}
|
||||
|
||||
/* 转账消息样式 - 微信风格 */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div v-if="appStore.apiStatus !== 'connected'"
|
||||
class="fixed top-4 right-4 bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg max-w-sm z-50">
|
||||
<div v-if="appStore.apiStatus !== 'connected'"
|
||||
class="api-status-banner fixed top-4 right-4 bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg max-w-sm z-50"
|
||||
>
|
||||
<div class="flex items-start">
|
||||
<svg class="h-5 w-5 text-red-600 mr-2 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
|
||||
@@ -136,10 +136,19 @@ const openLocation = () => {
|
||||
|
||||
<style scoped>
|
||||
.wechat-location-card-wrap {
|
||||
--location-card-bg: var(--chat-bubble-received);
|
||||
--location-card-text: var(--chat-bubble-received-text);
|
||||
--location-card-muted: var(--chat-sender-name);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.wechat-location-card-wrap--sent {
|
||||
--location-card-bg: var(--chat-bubble-sent);
|
||||
--location-card-text: var(--chat-bubble-sent-text);
|
||||
--location-card-muted: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.wechat-location-card-wrap--received::before,
|
||||
.wechat-location-card-wrap--sent::after {
|
||||
content: '';
|
||||
@@ -147,7 +156,7 @@ const openLocation = () => {
|
||||
top: 12px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #fff;
|
||||
background: var(--location-card-bg);
|
||||
transform: rotate(45deg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -165,27 +174,27 @@ const openLocation = () => {
|
||||
overflow: hidden;
|
||||
border-radius: var(--message-radius);
|
||||
border: none;
|
||||
background: #fff;
|
||||
background: var(--location-card-bg);
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.wechat-location-card--sent {
|
||||
background: #fff;
|
||||
background: var(--location-card-bg);
|
||||
}
|
||||
|
||||
.wechat-location-card__text {
|
||||
padding: 10px 12px 8px;
|
||||
background: #fff;
|
||||
background: var(--location-card-bg);
|
||||
}
|
||||
|
||||
.wechat-location-card--sent .wechat-location-card__text {
|
||||
background: #fff;
|
||||
background: var(--location-card-bg);
|
||||
}
|
||||
|
||||
.wechat-location-card__title {
|
||||
color: #111827;
|
||||
color: var(--location-card-text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
@@ -197,7 +206,7 @@ const openLocation = () => {
|
||||
|
||||
.wechat-location-card__subtitle {
|
||||
margin-top: 4px;
|
||||
color: #9ca3af;
|
||||
color: var(--location-card-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
@@ -206,7 +215,7 @@ const openLocation = () => {
|
||||
}
|
||||
|
||||
.wechat-location-card--sent .wechat-location-card__subtitle {
|
||||
color: #9ca3af;
|
||||
color: var(--location-card-muted);
|
||||
}
|
||||
|
||||
.wechat-location-card__map {
|
||||
|
||||
@@ -60,7 +60,7 @@ const closeWindow = () => {
|
||||
<style scoped>
|
||||
.desktop-titlebar {
|
||||
height: var(--desktop-titlebar-height, 32px);
|
||||
background: #ededed;
|
||||
background: var(--desktop-titlebar-bg);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-shrink: 0;
|
||||
@@ -92,11 +92,11 @@ const closeWindow = () => {
|
||||
}
|
||||
|
||||
.desktop-titlebar-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
background: var(--desktop-titlebar-hover);
|
||||
}
|
||||
|
||||
.desktop-titlebar-btn:active {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
background: var(--desktop-titlebar-active);
|
||||
}
|
||||
|
||||
.desktop-titlebar-btn-close:hover {
|
||||
@@ -122,7 +122,7 @@ const closeWindow = () => {
|
||||
/* Optical centering: the glyph was anchored to the bottom, so it looked low. */
|
||||
top: 5px;
|
||||
height: 1px;
|
||||
background: #111;
|
||||
background: var(--desktop-titlebar-icon);
|
||||
}
|
||||
|
||||
.desktop-titlebar-icon-maximize::before {
|
||||
@@ -132,7 +132,7 @@ const closeWindow = () => {
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
border: 1px solid #111;
|
||||
border: 1px solid var(--desktop-titlebar-icon);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ const closeWindow = () => {
|
||||
right: 1px;
|
||||
top: 50%;
|
||||
height: 1px;
|
||||
background: #111;
|
||||
background: var(--desktop-titlebar-icon);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div v-if="open && info" class="fixed inset-0 z-[9999] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40" @click="onBackdropClick" />
|
||||
|
||||
<div class="relative w-[min(520px,calc(100vw-32px))] rounded-lg bg-white shadow-xl border border-gray-200">
|
||||
<div class="desktop-update-dialog-panel relative w-[min(520px,calc(100vw-32px))] rounded-lg bg-white shadow-xl border border-gray-200">
|
||||
<button
|
||||
class="absolute right-3 top-3 h-8 w-8 rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700"
|
||||
type="button"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="open"
|
||||
class="fixed inset-0 z-[120] flex items-center justify-center bg-black/40 px-4 py-4 backdrop-blur-md sm:py-8"
|
||||
class="settings-dialog fixed inset-0 z-[120] flex items-center justify-center bg-black/40 px-4 py-4 backdrop-blur-md sm:py-8"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div class="flex h-[80vh] min-h-[380px] w-full max-w-[760px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
|
||||
<div class="settings-dialog-panel flex h-[80vh] min-h-[380px] w-full max-w-[760px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
|
||||
<!-- Sidebar -->
|
||||
<aside class="flex w-[180px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
|
||||
<div class="mt-4 mb-2 flex items-center px-4 gap-2">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="border-r border-gray-200 flex flex-col"
|
||||
:style="{ backgroundColor: '#e8e7e7', width: '60px', minWidth: '60px', maxWidth: '60px' }"
|
||||
class="sidebar-rail border-r flex flex-col"
|
||||
>
|
||||
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
|
||||
<!-- Avatar -->
|
||||
@@ -25,12 +24,12 @@
|
||||
|
||||
<!-- Chat -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="聊天"
|
||||
@click="goChat"
|
||||
>
|
||||
<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="isChatRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isChatRoute }">
|
||||
<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>
|
||||
@@ -40,12 +39,12 @@
|
||||
|
||||
<!-- Edits -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="修改记录"
|
||||
@click="goEdits"
|
||||
>
|
||||
<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="isEditsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isEditsRoute }">
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
@@ -56,12 +55,12 @@
|
||||
|
||||
<!-- Moments -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action 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]'">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isSnsRoute }">
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -86,12 +85,12 @@
|
||||
|
||||
<!-- Contacts -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="联系人"
|
||||
@click="goContacts"
|
||||
>
|
||||
<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="isContactsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isContactsRoute }">
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="10" cy="7" r="4" />
|
||||
@@ -104,12 +103,12 @@
|
||||
|
||||
<!-- Wrapped -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="年度总结"
|
||||
@click="goWrapped"
|
||||
>
|
||||
<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="isWrappedRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isWrappedRoute }">
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -132,15 +131,15 @@
|
||||
|
||||
<!-- Realtime -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group"
|
||||
:class="realtimeBusy ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'"
|
||||
:title="realtimeTitle"
|
||||
@click="toggleRealtime"
|
||||
>
|
||||
<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="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<svg
|
||||
class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
|
||||
:class="realtimeEnabled ? 'text-[#07b75b]' : 'text-[#5d5d5d]'"
|
||||
class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
|
||||
:class="{ 'sidebar-rail-icon-active': realtimeEnabled }"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -156,12 +155,12 @@
|
||||
|
||||
<!-- Privacy -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="privacyStore.toggle"
|
||||
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
|
||||
>
|
||||
<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">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<svg class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': privacyMode }" 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" />
|
||||
@@ -169,15 +168,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme -->
|
||||
<div
|
||||
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
:title="themeStore.isDark ? '切换浅色模式' : '切换深色模式'"
|
||||
@click="themeStore.toggle"
|
||||
>
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<svg
|
||||
v-if="themeStore.isDark"
|
||||
class="sidebar-rail-icon sidebar-rail-icon-active w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4.5" />
|
||||
<path d="M12 2.5v2.2M12 19.3v2.2M4.93 4.93l1.56 1.56M17.51 17.51l1.56 1.56M2.5 12h2.2M19.3 12h2.2M4.93 19.07l1.56-1.56M17.51 6.49l1.56-1.56" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3c-.08.5-.12 1.01-.12 1.54a8.25 8.25 0 0 0 8.37 8.25c.52 0 1.03-.04 1.54-.12Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto">
|
||||
<!-- Guide -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action 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">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<svg class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" 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" />
|
||||
@@ -187,12 +223,12 @@
|
||||
|
||||
<!-- Settings -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
class="sidebar-rail-action 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">
|
||||
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
|
||||
<svg class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': settingsDialogOpen }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -208,10 +244,10 @@
|
||||
|
||||
<div
|
||||
v-if="accountDialogOpen"
|
||||
class="fixed inset-0 z-[130] flex items-center justify-center bg-black/35 px-4"
|
||||
class="account-info-dialog 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="account-info-dialog-panel 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
|
||||
@@ -289,6 +325,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { useChatRealtimeStore } from '~/stores/chatRealtime'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
import { useThemeStore } from '~/stores/theme'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -298,6 +335,9 @@ const { selectedAccount } = storeToRefs(chatAccounts)
|
||||
const privacyStore = usePrivacyStore()
|
||||
const { privacyMode } = storeToRefs(privacyStore)
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
themeStore.init()
|
||||
|
||||
const realtimeStore = useChatRealtimeStore()
|
||||
const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realtimeChecking, statusError: realtimeStatusError, toggling: realtimeToggling } = storeToRefs(realtimeStore)
|
||||
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
|
||||
@@ -540,3 +580,30 @@ const toggleRealtime = async () => {
|
||||
await realtimeStore.toggle({ silent: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-rail {
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
max-width: 60px;
|
||||
background-color: var(--sidebar-rail-bg);
|
||||
border-color: var(--sidebar-rail-border);
|
||||
}
|
||||
|
||||
.sidebar-rail-plate {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-rail-action:hover .sidebar-rail-plate {
|
||||
background-color: var(--sidebar-rail-hover);
|
||||
}
|
||||
|
||||
.sidebar-rail-icon {
|
||||
color: var(--sidebar-rail-icon-color);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-rail-icon-active {
|
||||
color: var(--sidebar-rail-icon-active-color);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -826,51 +826,51 @@
|
||||
<!-- 合并转发聊天记录弹窗 -->
|
||||
<div
|
||||
v-if="chatHistoryModalVisible"
|
||||
class="fixed inset-0 z-50 bg-black/40 flex items-center justify-center"
|
||||
class="chat-history-modal-overlay fixed inset-0 z-50 bg-black/40 flex items-center justify-center"
|
||||
@click="closeChatHistoryModal"
|
||||
>
|
||||
<div
|
||||
class="w-[92vw] max-w-[560px] max-h-[80vh] bg-[#f7f7f7] rounded-xl shadow-xl overflow-hidden flex flex-col"
|
||||
class="chat-history-modal-panel w-[92vw] max-w-[560px] max-h-[80vh] rounded-xl shadow-xl overflow-hidden flex flex-col"
|
||||
@click.stop
|
||||
>
|
||||
<div class="px-4 py-3 bg-[#f7f7f7] border-b border-gray-200 flex items-center justify-between">
|
||||
<div class="chat-history-modal-header px-4 py-3 border-b flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
v-if="chatHistoryModalStack.length"
|
||||
type="button"
|
||||
class="p-2 rounded hover:bg-black/5 flex-shrink-0"
|
||||
class="chat-history-modal-icon-btn p-2 rounded flex-shrink-0"
|
||||
@click="goBackChatHistoryModal"
|
||||
aria-label="返回"
|
||||
title="返回"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="chat-history-modal-icon w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="text-sm text-[#161616] truncate">{{ chatHistoryModalTitle || '聊天记录' }}</div>
|
||||
<div class="chat-history-modal-title text-sm truncate">{{ chatHistoryModalTitle || '聊天记录' }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 rounded hover:bg-black/5"
|
||||
class="chat-history-modal-icon-btn p-2 rounded"
|
||||
@click="closeChatHistoryModal"
|
||||
aria-label="关闭"
|
||||
title="关闭"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg class="chat-history-modal-icon w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto bg-[#f7f7f7]">
|
||||
<div v-if="!chatHistoryModalRecords.length" class="text-sm text-gray-500 text-center py-10">
|
||||
<div class="chat-history-modal-body flex-1 overflow-auto">
|
||||
<div v-if="!chatHistoryModalRecords.length" class="chat-history-modal-empty text-sm text-center py-10">
|
||||
没有可显示的聊天记录
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(rec, idx) in chatHistoryModalRecords"
|
||||
:key="rec.id || idx"
|
||||
class="px-4 py-3 flex gap-3 border-b border-gray-100 bg-[#f7f7f7]"
|
||||
class="chat-history-modal-row px-4 py-3 flex gap-3 border-b"
|
||||
>
|
||||
<div class="w-9 h-9 rounded-md overflow-hidden bg-gray-200 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<img
|
||||
@@ -892,12 +892,12 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div
|
||||
v-if="chatHistoryModalInfo?.isChatRoom && (rec.senderDisplayName || rec.sourcename)"
|
||||
class="text-xs text-gray-500 leading-none truncate mb-1"
|
||||
class="chat-history-modal-sender text-xs leading-none truncate mb-1"
|
||||
>
|
||||
{{ rec.senderDisplayName || rec.sourcename }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="rec.fullTime || rec.sourcetime" class="text-xs text-gray-400 flex-shrink-0 leading-none">
|
||||
<div v-if="rec.fullTime || rec.sourcetime" class="chat-history-modal-time text-xs flex-shrink-0 leading-none">
|
||||
{{ rec.fullTime || rec.sourcetime }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1273,7 +1273,7 @@
|
||||
<!-- 导出弹窗 -->
|
||||
<div v-if="exportModalOpen" class="fixed inset-0 z-[11000] flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40" @click="closeExportModal"></div>
|
||||
<div class="relative w-[960px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
|
||||
<div class="chat-export-modal relative w-[960px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex items-center">
|
||||
<div class="text-base font-medium text-gray-900">导出聊天记录(离线 ZIP)</div>
|
||||
<button class="ml-auto text-gray-400 hover:text-gray-700" type="button" @click="closeExportModal">
|
||||
@@ -1290,52 +1290,77 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="flex flex-wrap items-end gap-6">
|
||||
<div class="flex flex-wrap items-end gap-3 xl:flex-nowrap">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-800 mb-2">范围</div>
|
||||
<div class="flex flex-wrap gap-2 text-sm text-gray-700">
|
||||
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportScope === 'current' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<input type="radio" value="current" v-model="exportScope" class="hidden" />
|
||||
<span>当前会话</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportScope === 'selected' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<input type="radio" value="selected" v-model="exportScope" class="hidden" />
|
||||
<span>选择会话(批量)</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs rounded-md border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="exportScope === 'current' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
:disabled="!selectedContact?.username"
|
||||
@click="exportScope = 'current'"
|
||||
>
|
||||
当前会话
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs rounded-md border transition-colors"
|
||||
:class="exportScope === 'selected' && exportListTab === 'all' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
@click="onExportBatchScopeClick('all')"
|
||||
>
|
||||
全部 {{ exportContactCounts.total }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs rounded-md border transition-colors"
|
||||
:class="exportScope === 'selected' && exportListTab === 'groups' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
@click="onExportBatchScopeClick('groups')"
|
||||
>
|
||||
群聊 {{ exportContactCounts.groups }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-2.5 py-1 text-xs rounded-md border transition-colors"
|
||||
:class="exportScope === 'selected' && exportListTab === 'singles' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'"
|
||||
@click="onExportBatchScopeClick('singles')"
|
||||
>
|
||||
单聊 {{ exportContactCounts.singles }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-800 mb-2">格式</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'json' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<label class="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'json' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<input type="radio" value="json" v-model="exportFormat" class="hidden" />
|
||||
<span>JSON</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'txt' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<label class="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'txt' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<input type="radio" value="txt" v-model="exportFormat" class="hidden" />
|
||||
<span>TXT</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 px-3 py-1.5 rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'html' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<label class="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border cursor-pointer transition-colors" :class="exportFormat === 'html' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white border-gray-200 text-gray-700 hover:bg-gray-50'">
|
||||
<input type="radio" value="html" v-model="exportFormat" class="hidden" />
|
||||
<span>HTML</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-[320px]">
|
||||
<div class="flex-1 min-w-[280px]">
|
||||
<div class="text-sm font-medium text-gray-800 mb-2">时间范围(可选)</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<input
|
||||
v-model="exportStartLocal"
|
||||
type="datetime-local"
|
||||
class="px-2.5 py-1.5 text-sm rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
|
||||
class="px-2 py-1 text-xs rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
|
||||
/>
|
||||
<span class="text-gray-400">-</span>
|
||||
<input
|
||||
v-model="exportEndLocal"
|
||||
type="datetime-local"
|
||||
class="px-2.5 py-1.5 text-sm rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
|
||||
class="px-2 py-1 text-xs rounded-md border border-gray-200 focus:outline-none focus:ring-2 focus:ring-[#03C160]/30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1367,32 +1392,8 @@
|
||||
</div>
|
||||
|
||||
<div v-if="exportScope === 'selected'" class="mt-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200"
|
||||
:class="exportListTab === 'all' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white hover:bg-gray-50 text-gray-700'"
|
||||
@click="exportListTab = 'all'"
|
||||
>
|
||||
全部 {{ exportContactCounts.total }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200"
|
||||
:class="exportListTab === 'groups' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white hover:bg-gray-50 text-gray-700'"
|
||||
@click="exportListTab = 'groups'"
|
||||
>
|
||||
群聊 {{ exportContactCounts.groups }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs px-2 py-1 rounded border border-gray-200"
|
||||
:class="exportListTab === 'singles' ? 'bg-[#03C160] text-white border-[#03C160]' : 'bg-white hover:bg-gray-50 text-gray-700'"
|
||||
@click="exportListTab = 'singles'"
|
||||
>
|
||||
单聊 {{ exportContactCounts.singles }}
|
||||
</button>
|
||||
<div class="ml-auto text-xs text-gray-500">点击 tab 筛选</div>
|
||||
<div class="mb-2 text-xs text-gray-500">
|
||||
点击上方范围可筛选并默认全选当前结果,再次点击可取消全选;下方整行可点选会话
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
@@ -1404,26 +1405,27 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="border border-gray-200 rounded-md max-h-56 overflow-y-auto">
|
||||
<div
|
||||
<label
|
||||
v-for="c in exportFilteredContacts"
|
||||
:key="c.username"
|
||||
class="px-3 py-2 border-b border-gray-100 flex items-center gap-2 hover:bg-gray-50"
|
||||
class="px-3 py-2 border-b border-gray-100 flex items-center gap-2 cursor-pointer transition-colors"
|
||||
:class="isExportContactSelected(c.username) ? 'bg-[#03C160]/5 hover:bg-[#03C160]/10' : 'hover:bg-gray-50'"
|
||||
>
|
||||
<input type="checkbox" :value="c.username" v-model="exportSelectedUsernames" />
|
||||
<input type="checkbox" :value="c.username" v-model="exportSelectedUsernames" class="cursor-pointer" />
|
||||
<div class="w-9 h-9 rounded-md overflow-hidden bg-gray-200 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<img v-if="c.avatar" :src="c.avatar" :alt="c.name + '头像'" class="w-full h-full object-cover" referrerpolicy="no-referrer" @error="onAvatarError($event, c)" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-xs font-bold text-gray-600">
|
||||
{{ (c.name || c.username || '?').charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div class="min-w-0 flex-1" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div class="text-sm text-gray-800 truncate">
|
||||
{{ c.name }}
|
||||
<span class="text-xs text-gray-500">{{ c.isGroup ? '(群)' : '' }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 truncate">{{ c.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<div v-if="exportFilteredContacts.length === 0" class="px-3 py-3 text-sm text-gray-500">
|
||||
无匹配会话
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<div class="conversation-pane flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<div v-if="selectedContact" class="flex-1 flex flex-col min-h-0 relative">
|
||||
<div class="chat-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-base font-medium text-gray-900" :class="{ 'privacy-blur': privacyMode }">
|
||||
<h2 class="chat-header-title text-base font-medium" :class="{ 'privacy-blur': privacyMode }">
|
||||
{{ selectedContact ? selectedContact.name : '' }}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -71,7 +71,7 @@
|
||||
<button
|
||||
v-if="showJumpToBottom"
|
||||
type="button"
|
||||
class="absolute bottom-6 right-6 z-20 w-10 h-10 rounded-full bg-white/90 border border-gray-200 shadow hover:bg-white flex items-center justify-center"
|
||||
class="jump-to-bottom-btn absolute bottom-6 right-6 z-20 w-10 h-10 rounded-full border shadow flex items-center justify-center"
|
||||
title="回到最新"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
@@ -81,15 +81,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 flex items-center justify-center">
|
||||
<div v-else class="conversation-empty flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-20 h-20 mx-auto mb-5 rounded-2xl bg-gradient-to-br from-[#03C160]/10 to-[#03C160]/5 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-[#03C160]/60" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-medium text-gray-700 mb-1.5">选择一个会话</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
<h3 class="conversation-empty-title text-base font-medium mb-1.5">选择一个会话</h3>
|
||||
<p class="conversation-empty-text text-sm">
|
||||
从左侧列表选择联系人查看聊天记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { defineComponent, h, ref, watch } from 'vue'
|
||||
import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
|
||||
|
||||
export default defineComponent({
|
||||
@@ -19,7 +19,15 @@ export default defineComponent({
|
||||
setup(props) {
|
||||
const fromAvatarImgOk = ref(false)
|
||||
const fromAvatarImgError = ref(false)
|
||||
const lastFromAvatarUrl = ref('')
|
||||
|
||||
watch(
|
||||
() => String(props.fromAvatar || '').trim(),
|
||||
() => {
|
||||
fromAvatarImgOk.value = false
|
||||
fromAvatarImgError.value = false
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const getFromText = () => {
|
||||
const raw = String(props.from || '').trim()
|
||||
@@ -47,12 +55,6 @@ export default defineComponent({
|
||||
const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
|
||||
const Tag = canNavigate ? 'a' : 'div'
|
||||
|
||||
if (fromAvatarUrl !== lastFromAvatarUrl.value) {
|
||||
lastFromAvatarUrl.value = fromAvatarUrl
|
||||
fromAvatarImgOk.value = false
|
||||
fromAvatarImgError.value = false
|
||||
}
|
||||
|
||||
const showFromAvatarImg = Boolean(fromAvatarUrl) && !fromAvatarImgError.value
|
||||
const showFromAvatarText = (!fromAvatarUrl) || (!fromAvatarImgOk.value)
|
||||
const fromAvatarStyle = fromAvatarImgOk.value
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="message.quoteTitle || message.quoteContent"
|
||||
class="mt-[5px] px-2 text-xs text-neutral-600 rounded max-w-[404px] max-h-[65px] overflow-hidden flex items-start bg-[#e1e1e1]">
|
||||
class="wechat-quote-preview mt-[5px] px-2 text-xs rounded max-w-[404px] max-h-[65px] overflow-hidden flex items-start">
|
||||
<div class="py-2 min-w-0 flex-1">
|
||||
<div v-if="isQuotedVoice(message)" class="flex items-center gap-1 min-w-0">
|
||||
<span v-if="message.quoteTitle" class="truncate flex-shrink-0">{{ message.quoteTitle }}:</span>
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
:data-create-time="message.createTime"
|
||||
>
|
||||
<div v-if="message.showTimeDivider" class="flex justify-center mb-4">
|
||||
<div class="px-3 py-1 text-xs text-[#9e9e9e]">
|
||||
<div class="message-time-divider px-3 py-1 text-xs">
|
||||
{{ message.timeDivider }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="message.renderType === 'system'" class="flex justify-center">
|
||||
<div class="px-3 py-1 text-xs text-[#9e9e9e]">
|
||||
<div class="message-time-divider px-3 py-1 text-xs">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +110,7 @@
|
||||
:class="[message.isSent ? 'items-end' : 'items-start', { 'privacy-blur': privacyMode }]"
|
||||
@contextmenu="openMediaContextMenu($event, message, 'message')"
|
||||
>
|
||||
<div v-if="message.isGroup && !message.isSent && message.senderDisplayName" class="text-[11px] text-gray-500 mb-1" :class="message.isSent ? 'text-right' : 'text-left'">
|
||||
<div v-if="message.isGroup && !message.isSent && message.senderDisplayName" class="message-sender-name text-[11px] mb-1" :class="message.isSent ? 'text-right' : 'text-left'">
|
||||
{{ message.senderDisplayName }}
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div ref="messageContainerRef" class="flex-1 overflow-y-auto p-4 min-h-0" @scroll="onMessageScroll">
|
||||
<div ref="messageContainerRef" class="message-list flex-1 overflow-y-auto p-4 min-h-0" @scroll="onMessageScroll">
|
||||
<div v-if="selectedContact && hasMoreMessages" class="flex justify-center mb-4">
|
||||
<div
|
||||
class="text-xs px-3 py-1 rounded-md bg-white border border-gray-200 text-gray-700 select-none"
|
||||
class="message-list-load-more text-xs px-3 py-1 rounded-md border select-none"
|
||||
:class="isLoadingMessages ? 'opacity-60' : 'hover:bg-gray-50 cursor-pointer'"
|
||||
@click="!isLoadingMessages && loadMoreMessages()"
|
||||
>
|
||||
@@ -10,13 +10,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingMessages && messages.length === 0" class="text-center text-sm text-gray-500 py-6">
|
||||
<div v-if="isLoadingMessages && messages.length === 0" class="message-list-status text-center text-sm py-6">
|
||||
加载中...
|
||||
</div>
|
||||
<div v-else-if="messagesError" class="text-center text-sm text-red-500 py-6 whitespace-pre-wrap">
|
||||
{{ messagesError }}
|
||||
</div>
|
||||
<div v-else-if="messages.length === 0" class="text-center text-sm text-gray-500 py-6">
|
||||
<div v-else-if="messages.length === 0" class="message-list-status text-center text-sm py-6">
|
||||
暂无聊天记录
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="session-list-panel border-r border-gray-200 flex flex-col min-h-0 shrink-0 relative"
|
||||
:style="{ backgroundColor: '#F7F7F7', '--session-list-width': sessionListWidth + 'px' }"
|
||||
class="session-list-panel border-r flex flex-col min-h-0 shrink-0 relative"
|
||||
:style="{ '--session-list-width': sessionListWidth + 'px' }"
|
||||
>
|
||||
<!-- 拖动调整会话列表宽度 -->
|
||||
<div
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- 聊天列表 -->
|
||||
<div class="h-full flex flex-col min-h-0">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
|
||||
<div class="session-list-search p-3 border-b">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="contact-search-wrapper flex-1">
|
||||
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
|
||||
@@ -53,7 +53,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 联系人列表 -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<div class="session-list-scroll flex-1 overflow-y-auto min-h-0">
|
||||
<div v-if="isLoadingContacts" class="px-3 py-4 h-full overflow-hidden">
|
||||
<div v-for="i in 15" :key="i" class="flex items-center space-x-3 h-[calc(80px/var(--dpr))]">
|
||||
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md bg-gray-200 skeleton-pulse"></div>
|
||||
@@ -63,22 +63,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="contactsError" class="px-3 py-2 text-sm text-red-500 whitespace-pre-wrap">
|
||||
<div v-else-if="contactsError" class="session-list-status px-3 py-2 text-sm text-red-500 whitespace-pre-wrap">
|
||||
{{ contactsError }}
|
||||
</div>
|
||||
<div v-else-if="contacts.length === 0" class="px-3 py-2 text-sm text-gray-500">
|
||||
<div v-else-if="contacts.length === 0" class="session-list-status px-3 py-2 text-sm">
|
||||
暂无会话
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-for="contact in filteredContacts" :key="contact.id"
|
||||
class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(80px/var(--dpr))] flex items-center"
|
||||
:class="contact.isTop
|
||||
? (selectedContact?.id === contact.id
|
||||
? 'bg-[#D2D2D2] hover:bg-[#C7C7C7]'
|
||||
: 'bg-[#EAEAEA] hover:bg-[#DEDEDE]')
|
||||
: (selectedContact?.id === contact.id
|
||||
? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]'
|
||||
: 'hover:bg-[#eaeaea]')"
|
||||
class="session-list-item px-3 cursor-pointer transition-colors duration-150 h-[calc(80px/var(--dpr))] flex items-center"
|
||||
:class="{
|
||||
'session-list-item--top': contact.isTop,
|
||||
'session-list-item--selected': selectedContact?.id === contact.id
|
||||
}"
|
||||
@click="selectContact(contact)">
|
||||
<div class="flex items-center space-x-3 w-full">
|
||||
<!-- 联系人头像 -->
|
||||
@@ -101,12 +98,12 @@
|
||||
<!-- 联系人信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-900 truncate" :class="{ 'privacy-blur': privacyMode }">{{ contact.name }}</h3>
|
||||
<h3 class="session-list-item-name text-sm font-medium truncate" :class="{ 'privacy-blur': privacyMode }">{{ contact.name }}</h3>
|
||||
<div class="flex items-center flex-shrink-0 ml-2">
|
||||
<span class="text-xs text-gray-500">{{ contact.lastMessageTime }}</span>
|
||||
<span class="session-list-item-time text-xs">{{ contact.lastMessageTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 truncate mt-0.5 leading-tight" :class="{ 'privacy-blur': privacyMode }">
|
||||
<p class="session-list-item-preview text-xs truncate mt-0.5 leading-tight" :class="{ 'privacy-blur': privacyMode }">
|
||||
<span
|
||||
v-for="(seg, idx) in parseTextWithEmoji(
|
||||
(contact.unreadCount > 0 ? `[${contact.unreadCount > 99 ? '99+' : contact.unreadCount}条] ` : '') +
|
||||
|
||||
@@ -73,20 +73,35 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
return Math.round(clamp01(done / total) * 100)
|
||||
})
|
||||
|
||||
const exportFilteredContacts = computed(() => {
|
||||
const query = String(exportSearchQuery.value || '').trim().toLowerCase()
|
||||
const normalizeExportSelectedUsernames = (list) => {
|
||||
const seen = new Set()
|
||||
return (Array.isArray(list) ? list : []).reduce((acc, item) => {
|
||||
const username = String(item || '').trim()
|
||||
if (!username || seen.has(username)) return acc
|
||||
seen.add(username)
|
||||
acc.push(username)
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
const getExportFilteredContacts = ({ tab = exportListTab.value, query = exportSearchQuery.value } = {}) => {
|
||||
const normalizedQuery = String(query || '').trim().toLowerCase()
|
||||
let list = Array.isArray(contacts.value) ? contacts.value : []
|
||||
|
||||
const tab = String(exportListTab.value || 'all')
|
||||
if (tab === 'groups') list = list.filter((contact) => !!contact?.isGroup)
|
||||
if (tab === 'singles') list = list.filter((contact) => !contact?.isGroup)
|
||||
const normalizedTab = String(tab || 'all')
|
||||
if (normalizedTab === 'groups') list = list.filter((contact) => !!contact?.isGroup)
|
||||
if (normalizedTab === 'singles') list = list.filter((contact) => !contact?.isGroup)
|
||||
|
||||
if (!query) return list
|
||||
if (!normalizedQuery) return list
|
||||
return list.filter((contact) => {
|
||||
const name = String(contact?.name || '').toLowerCase()
|
||||
const username = String(contact?.username || '').toLowerCase()
|
||||
return name.includes(query) || username.includes(query)
|
||||
return name.includes(normalizedQuery) || username.includes(normalizedQuery)
|
||||
})
|
||||
}
|
||||
|
||||
const exportFilteredContacts = computed(() => {
|
||||
return getExportFilteredContacts()
|
||||
})
|
||||
|
||||
const exportContactCounts = computed(() => {
|
||||
@@ -96,6 +111,60 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
return { total, groups, singles: total - groups }
|
||||
})
|
||||
|
||||
const exportSelectedUsernameSet = computed(() => {
|
||||
return new Set(normalizeExportSelectedUsernames(exportSelectedUsernames.value))
|
||||
})
|
||||
|
||||
const setExportSelectedUsernames = (list) => {
|
||||
exportSelectedUsernames.value = normalizeExportSelectedUsernames(list)
|
||||
}
|
||||
|
||||
const getExportFilteredUsernames = (tab = exportListTab.value) => {
|
||||
return getExportFilteredContacts({ tab })
|
||||
.map((contact) => String(contact?.username || '').trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
const selectExportFilteredContacts = (tab = exportListTab.value) => {
|
||||
setExportSelectedUsernames(getExportFilteredUsernames(tab))
|
||||
}
|
||||
|
||||
const clearExportFilteredContacts = () => {
|
||||
setExportSelectedUsernames([])
|
||||
}
|
||||
|
||||
const areExportFilteredContactsAllSelected = (tab = exportListTab.value) => {
|
||||
const usernames = getExportFilteredUsernames(tab)
|
||||
if (usernames.length !== exportSelectedUsernameSet.value.size) return false
|
||||
return usernames.every((username) => exportSelectedUsernameSet.value.has(username))
|
||||
}
|
||||
|
||||
const onExportListTabClick = (tab) => {
|
||||
const nextTab = String(tab || 'all')
|
||||
const isSameTab = String(exportListTab.value || 'all') === nextTab
|
||||
exportListTab.value = nextTab
|
||||
|
||||
if (isSameTab) {
|
||||
if (areExportFilteredContactsAllSelected(nextTab)) {
|
||||
clearExportFilteredContacts(nextTab)
|
||||
} else {
|
||||
selectExportFilteredContacts(nextTab)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
selectExportFilteredContacts(nextTab)
|
||||
}
|
||||
|
||||
const isExportContactSelected = (username) => {
|
||||
return exportSelectedUsernameSet.value.has(String(username || '').trim())
|
||||
}
|
||||
|
||||
const onExportBatchScopeClick = (tab) => {
|
||||
exportScope.value = 'selected'
|
||||
onExportListTabClick(tab)
|
||||
}
|
||||
|
||||
const isDesktopExportRuntime = () => {
|
||||
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
|
||||
}
|
||||
@@ -269,12 +338,17 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
exportModalOpen.value = true
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
exportSearchQuery.value = ''
|
||||
exportListTab.value = 'all'
|
||||
exportSelectedUsernames.value = []
|
||||
exportStartLocal.value = ''
|
||||
exportEndLocal.value = ''
|
||||
exportMessageTypes.value = exportMessageTypeOptions.map((item) => item.value)
|
||||
exportAutoSavedFor.value = ''
|
||||
exportScope.value = selectedContact.value?.username ? 'current' : 'all'
|
||||
exportScope.value = selectedContact.value?.username ? 'current' : 'selected'
|
||||
if (!selectedContact.value?.username) {
|
||||
selectExportFilteredContacts('all')
|
||||
}
|
||||
}
|
||||
|
||||
const closeExportModal = () => {
|
||||
@@ -296,6 +370,12 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
}
|
||||
})
|
||||
|
||||
watch(exportScope, (scope, previousScope) => {
|
||||
if (scope !== 'selected' || previousScope === 'selected') return
|
||||
if (exportSelectedUsernames.value.length > 0) return
|
||||
selectExportFilteredContacts(exportListTab.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
exportId: String(exportJob.value?.exportId || ''),
|
||||
@@ -447,6 +527,9 @@ export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selecte
|
||||
exportCurrentPercent,
|
||||
exportFilteredContacts,
|
||||
exportContactCounts,
|
||||
onExportBatchScopeClick,
|
||||
onExportListTabClick,
|
||||
isExportContactSelected,
|
||||
hasWebExportFolder,
|
||||
chooseExportFolder,
|
||||
getExportDownloadUrl,
|
||||
|
||||
@@ -27,13 +27,45 @@ export const useChatMessages = ({
|
||||
const messageContainerRef = ref(null)
|
||||
const activeMessagesFor = ref('')
|
||||
const showJumpToBottom = ref(false)
|
||||
let lastRenderMessagesFingerprint = ''
|
||||
|
||||
const isDesktopRenderer = () => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
const logMessagePhase = (phase, details = {}) => {
|
||||
const payload = {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
selectedUsername: String(selectedContact.value?.username || '').trim(),
|
||||
activeMessagesFor: String(activeMessagesFor.value || '').trim(),
|
||||
...details
|
||||
}
|
||||
|
||||
if (isDesktopRenderer()) {
|
||||
try {
|
||||
window.wechatDesktop?.logDebug?.('chat-messages', phase, payload)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
console.info(`[chat-messages] ${phase}`, payload)
|
||||
}
|
||||
|
||||
const summarizeRenderTypes = (list) => {
|
||||
const counts = {}
|
||||
for (const item of Array.isArray(list) ? list : []) {
|
||||
const key = String(item?.renderType || 'unknown').trim() || 'unknown'
|
||||
counts[key] = Number(counts[key] || 0) + 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
const previewImageUrl = ref(null)
|
||||
const previewVideoUrl = ref(null)
|
||||
const previewVideoPosterUrl = ref('')
|
||||
const previewVideoError = ref('')
|
||||
|
||||
const voiceRefs = ref({})
|
||||
const voiceRefs = new Map()
|
||||
const currentPlayingVoice = ref(null)
|
||||
const playingVoiceId = ref(null)
|
||||
|
||||
@@ -113,8 +145,16 @@ export const useChatMessages = ({
|
||||
const renderMessages = computed(() => {
|
||||
const list = messages.value || []
|
||||
const reverseSides = !!reverseMessageSides.value
|
||||
const fingerprint = `${String(selectedContact.value?.username || '').trim()}:${list.length}:${reverseSides ? '1' : '0'}`
|
||||
const shouldLogRender = isDesktopRenderer() && fingerprint !== lastRenderMessagesFingerprint
|
||||
if (shouldLogRender) {
|
||||
logMessagePhase('renderMessages:start', {
|
||||
count: list.length,
|
||||
reverseSides
|
||||
})
|
||||
}
|
||||
let previousTs = 0
|
||||
return list.map((message) => {
|
||||
const rendered = list.map((message) => {
|
||||
const ts = Number(message.createTime || 0)
|
||||
const show = !previousTs || (ts && Math.abs(ts - previousTs) >= 300)
|
||||
if (ts) previousTs = ts
|
||||
@@ -127,6 +167,14 @@ export const useChatMessages = ({
|
||||
timeDivider: formatTimeDivider(ts)
|
||||
}
|
||||
})
|
||||
if (shouldLogRender) {
|
||||
lastRenderMessagesFingerprint = fingerprint
|
||||
logMessagePhase('renderMessages:end', {
|
||||
count: rendered.length,
|
||||
reverseSides
|
||||
})
|
||||
}
|
||||
return rendered
|
||||
})
|
||||
|
||||
const updateJumpToBottomState = () => {
|
||||
@@ -195,18 +243,16 @@ export const useChatMessages = ({
|
||||
const key = String(id || '').trim()
|
||||
if (!key) return
|
||||
if (element) {
|
||||
voiceRefs.value = { ...voiceRefs.value, [key]: element }
|
||||
} else if (voiceRefs.value[key]) {
|
||||
const next = { ...voiceRefs.value }
|
||||
delete next[key]
|
||||
voiceRefs.value = next
|
||||
voiceRefs.set(key, element)
|
||||
} else {
|
||||
voiceRefs.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const playVoiceById = async (voiceId) => {
|
||||
const key = String(voiceId || '').trim()
|
||||
if (!key) return
|
||||
const audio = voiceRefs.value[key]
|
||||
const audio = voiceRefs.get(key)
|
||||
if (!audio) return
|
||||
|
||||
try {
|
||||
@@ -333,6 +379,10 @@ export const useChatMessages = ({
|
||||
const loadMessages = async ({ username, reset }) => {
|
||||
if (!username || !selectedAccount.value) return
|
||||
|
||||
logMessagePhase('loadMessages:enter', {
|
||||
username,
|
||||
reset
|
||||
})
|
||||
messagesError.value = ''
|
||||
isLoadingMessages.value = true
|
||||
activeMessagesFor.value = username
|
||||
@@ -357,13 +407,48 @@ export const useChatMessages = ({
|
||||
if (realtimeEnabled.value) {
|
||||
params.source = 'realtime'
|
||||
}
|
||||
logMessagePhase('loadMessages:request:start', {
|
||||
username,
|
||||
reset,
|
||||
offset,
|
||||
existingCount: existing.length,
|
||||
renderTypeFilter: messageTypeFilter.value,
|
||||
realtime: !!realtimeEnabled.value
|
||||
})
|
||||
const response = await api.listChatMessages(params)
|
||||
logMessagePhase('loadMessages:request:end', {
|
||||
username,
|
||||
reset,
|
||||
rawCount: Array.isArray(response?.messages) ? response.messages.length : 0,
|
||||
total: Number(response?.total || 0),
|
||||
hasMore: response?.hasMore
|
||||
})
|
||||
|
||||
const raw = response?.messages || []
|
||||
logMessagePhase('loadMessages:normalize:start', {
|
||||
username,
|
||||
rawCount: raw.length
|
||||
})
|
||||
const mapped = dedupeMessagesById(raw.map(normalizeMessage))
|
||||
logMessagePhase('loadMessages:normalize:end', {
|
||||
username,
|
||||
mappedCount: mapped.length,
|
||||
renderTypeCounts: summarizeRenderTypes(mapped)
|
||||
})
|
||||
|
||||
if (activeMessagesFor.value !== username) return
|
||||
if (activeMessagesFor.value !== username) {
|
||||
logMessagePhase('loadMessages:abort-stale', {
|
||||
username,
|
||||
activeMessagesFor: activeMessagesFor.value
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logMessagePhase('loadMessages:state-commit:start', {
|
||||
username,
|
||||
reset,
|
||||
mappedCount: mapped.length
|
||||
})
|
||||
if (reset) {
|
||||
allMessages.value = { ...allMessages.value, [username]: mapped }
|
||||
} else {
|
||||
@@ -380,6 +465,10 @@ export const useChatMessages = ({
|
||||
[username]: [...older, ...existing]
|
||||
}
|
||||
}
|
||||
logMessagePhase('loadMessages:state-commit:end', {
|
||||
username,
|
||||
storedCount: (allMessages.value[username] || []).length
|
||||
})
|
||||
|
||||
messagesMeta.value = {
|
||||
...messagesMeta.value,
|
||||
@@ -388,8 +477,20 @@ export const useChatMessages = ({
|
||||
hasMore: response?.hasMore
|
||||
}
|
||||
}
|
||||
logMessagePhase('loadMessages:meta-commit:end', {
|
||||
username,
|
||||
total: Number(response?.total || 0),
|
||||
hasMore: response?.hasMore
|
||||
})
|
||||
|
||||
logMessagePhase('loadMessages:nextTick:start', {
|
||||
username
|
||||
})
|
||||
await nextTick()
|
||||
logMessagePhase('loadMessages:nextTick:end', {
|
||||
username,
|
||||
renderedCount: (allMessages.value[username] || []).length
|
||||
})
|
||||
const nextContainer = messageContainerRef.value
|
||||
if (nextContainer) {
|
||||
if (reset) {
|
||||
@@ -400,10 +501,28 @@ export const useChatMessages = ({
|
||||
}
|
||||
}
|
||||
updateJumpToBottomState()
|
||||
logMessagePhase('loadMessages:scroll:end', {
|
||||
username,
|
||||
hasContainer: !!nextContainer,
|
||||
scrollTop: nextContainer ? nextContainer.scrollTop : null,
|
||||
scrollHeight: nextContainer ? nextContainer.scrollHeight : null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[chat-messages] loadMessages:error', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
username: String(username || '').trim(),
|
||||
reset: !!reset,
|
||||
error
|
||||
})
|
||||
messagesError.value = error?.message || '加载聊天记录失败'
|
||||
} finally {
|
||||
isLoadingMessages.value = false
|
||||
logMessagePhase('loadMessages:exit', {
|
||||
username,
|
||||
reset,
|
||||
loading: isLoadingMessages.value,
|
||||
error: messagesError.value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,28 +560,38 @@ export const useChatMessages = ({
|
||||
params.render_types = messageTypeFilter.value
|
||||
}
|
||||
|
||||
const response = await api.listChatMessages(params)
|
||||
if (selectedContact.value?.username !== username) return
|
||||
try {
|
||||
const response = await api.listChatMessages(params)
|
||||
if (selectedContact.value?.username !== username) return
|
||||
|
||||
const latest = (response?.messages || []).map(normalizeMessage)
|
||||
const seenIds = new Set(existing.map((message) => String(message?.id || '')))
|
||||
const newOnes = []
|
||||
for (const message of latest) {
|
||||
const id = String(message?.id || '')
|
||||
if (!id || seenIds.has(id)) continue
|
||||
seenIds.add(id)
|
||||
newOnes.push(message)
|
||||
const rawMessages = response?.messages || []
|
||||
const latest = rawMessages.map(normalizeMessage)
|
||||
|
||||
const seenIds = new Set(existing.map((message) => String(message?.id || '')))
|
||||
const newOnes = []
|
||||
for (const message of latest) {
|
||||
const id = String(message?.id || '')
|
||||
if (!id || seenIds.has(id)) continue
|
||||
seenIds.add(id)
|
||||
newOnes.push(message)
|
||||
}
|
||||
if (!newOnes.length) return
|
||||
|
||||
allMessages.value = { ...allMessages.value, [username]: [...existing, ...newOnes] }
|
||||
|
||||
await nextTick()
|
||||
const nextContainer = messageContainerRef.value
|
||||
if (nextContainer && atBottom) {
|
||||
nextContainer.scrollTop = nextContainer.scrollHeight
|
||||
}
|
||||
updateJumpToBottomState()
|
||||
} catch (error) {
|
||||
console.error('[chat-messages] refreshRealtimeIncremental:error', {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
username: String(username || '').trim(),
|
||||
error
|
||||
})
|
||||
}
|
||||
if (!newOnes.length) return
|
||||
|
||||
allMessages.value = { ...allMessages.value, [username]: [...existing, ...newOnes] }
|
||||
|
||||
await nextTick()
|
||||
const nextContainer = messageContainerRef.value
|
||||
if (nextContainer && atBottom) {
|
||||
nextContainer.scrollTop = nextContainer.scrollHeight
|
||||
}
|
||||
updateJumpToBottomState()
|
||||
}
|
||||
|
||||
let realtimeRefreshFuture = null
|
||||
@@ -491,7 +620,18 @@ export const useChatMessages = ({
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const clearVoicePlaybackState = () => {
|
||||
try {
|
||||
currentPlayingVoice.value?.pause?.()
|
||||
if (currentPlayingVoice.value) currentPlayingVoice.value.currentTime = 0
|
||||
} catch {}
|
||||
currentPlayingVoice.value = null
|
||||
playingVoiceId.value = null
|
||||
voiceRefs.clear()
|
||||
}
|
||||
|
||||
const resetMessageState = () => {
|
||||
clearVoicePlaybackState()
|
||||
allMessages.value = {}
|
||||
messagesMeta.value = {}
|
||||
messagesError.value = ''
|
||||
@@ -700,6 +840,7 @@ export const useChatMessages = ({
|
||||
if (highlightTimer) clearTimeout(highlightTimer)
|
||||
highlightTimer = null
|
||||
clearContactProfileHoverHideTimer()
|
||||
clearVoicePlaybackState()
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -250,7 +250,11 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
|
||||
isLoadingContacts.value = true
|
||||
contactsError.value = ''
|
||||
try {
|
||||
const hadLoadedAccountSnapshot = !!chatAccounts.loaded
|
||||
await chatAccounts.ensureLoaded()
|
||||
if (!selectedAccount.value && hadLoadedAccountSnapshot) {
|
||||
await chatAccounts.ensureLoaded({ force: true })
|
||||
}
|
||||
|
||||
if (!selectedAccount.value) {
|
||||
clearContactsState(chatAccounts.error || '未检测到已解密账号,请先解密数据库。')
|
||||
|
||||
@@ -178,8 +178,10 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
|
||||
|
||||
return {
|
||||
id: msg.id,
|
||||
localId: Number(msg.localId || 0),
|
||||
serverId: msg.serverId || 0,
|
||||
serverIdStr,
|
||||
type: Number(msg.type || 0),
|
||||
sender,
|
||||
senderUsername: msg.senderUsername || '',
|
||||
senderDisplayName: msg.senderDisplayName || '',
|
||||
@@ -188,7 +190,6 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
|
||||
fullTime: formatMessageFullTime(msg.createTime),
|
||||
createTime: Number(msg.createTime || 0),
|
||||
isSent,
|
||||
type: 'text',
|
||||
renderType: msg.renderType || 'text',
|
||||
voipType: msg.voipType || '',
|
||||
title: msg.title || '',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export const UI_THEME_KEY = 'ui.theme'
|
||||
export const UI_THEME_LIGHT = 'light'
|
||||
export const UI_THEME_DARK = 'dark'
|
||||
|
||||
export const normalizeUiTheme = (value, fallback = UI_THEME_LIGHT) => {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
if (normalized === UI_THEME_DARK) return UI_THEME_DARK
|
||||
if (normalized === UI_THEME_LIGHT) return UI_THEME_LIGHT
|
||||
return fallback === UI_THEME_DARK ? UI_THEME_DARK : UI_THEME_LIGHT
|
||||
}
|
||||
|
||||
export const readUiTheme = (fallback = UI_THEME_LIGHT) => {
|
||||
if (!process.client) return normalizeUiTheme(fallback)
|
||||
try {
|
||||
const raw = localStorage.getItem(UI_THEME_KEY)
|
||||
return normalizeUiTheme(raw, fallback)
|
||||
} catch {
|
||||
return normalizeUiTheme(fallback)
|
||||
}
|
||||
}
|
||||
|
||||
export const writeUiTheme = (theme) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(UI_THEME_KEY, normalizeUiTheme(theme))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export const applyUiTheme = (theme) => {
|
||||
if (!process.client || typeof document === 'undefined') return
|
||||
const normalized = normalizeUiTheme(theme)
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = normalized
|
||||
root.classList.toggle('theme-dark', normalized === UI_THEME_DARK)
|
||||
root.style.colorScheme = normalized === UI_THEME_DARK ? 'dark' : 'light'
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<div class="chat-page-shell h-screen flex overflow-hidden">
|
||||
<SessionListPanel :state="chatState" />
|
||||
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<div class="chat-page-main flex-1 flex flex-col min-h-0">
|
||||
<div class="flex-1 flex min-h-0">
|
||||
<ConversationPane :state="chatState" />
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@ definePageMeta({
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: '??????? - ????????'
|
||||
title: '聊天记录 - 微信数据库解密工具'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
@@ -58,6 +58,67 @@ const routeUsername = computed(() => {
|
||||
return (Array.isArray(raw) ? raw[0] : raw) || ''
|
||||
})
|
||||
|
||||
const isDesktopShell = () => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
const desktopDebugEnabled = ref(false)
|
||||
const chatBootstrapStartedAt = process.client && typeof performance !== 'undefined' ? performance.now() : 0
|
||||
let messageLoadSequence = 0
|
||||
let firstSelectContactLogged = false
|
||||
let firstLoadMessagesLogged = false
|
||||
|
||||
const resolveDesktopDebugEnabled = async () => {
|
||||
if (!isDesktopShell() || typeof window.wechatDesktop?.isDebugEnabled !== 'function') {
|
||||
desktopDebugEnabled.value = false
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
desktopDebugEnabled.value = !!(await window.wechatDesktop.isDebugEnabled())
|
||||
} catch {
|
||||
desktopDebugEnabled.value = false
|
||||
}
|
||||
|
||||
return desktopDebugEnabled.value
|
||||
}
|
||||
|
||||
const chatBootstrapElapsedMs = () => {
|
||||
if (!process.client || typeof performance === 'undefined') return null
|
||||
const elapsed = performance.now() - chatBootstrapStartedAt
|
||||
return Number.isFinite(elapsed) ? Number(elapsed.toFixed(1)) : null
|
||||
}
|
||||
|
||||
const shouldLogChatBootstrap = () => isDesktopShell() || desktopDebugEnabled.value
|
||||
|
||||
const logChatBootstrap = (phase, details = {}) => {
|
||||
if (!shouldLogChatBootstrap()) return
|
||||
try {
|
||||
window.wechatDesktop?.logDebug?.('chat-bootstrap', phase, details)
|
||||
} catch {}
|
||||
console.info(`[chat-bootstrap] ${phase}`, {
|
||||
elapsedMs: chatBootstrapElapsedMs(),
|
||||
route: route.fullPath,
|
||||
...details
|
||||
})
|
||||
}
|
||||
|
||||
const waitForNextPaint = async () => {
|
||||
await nextTick()
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
await new Promise((resolve) => {
|
||||
window.requestAnimationFrame(() => {
|
||||
window.setTimeout(resolve, 0)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const nextMessageLoadToken = () => {
|
||||
messageLoadSequence += 1
|
||||
return messageLoadSequence
|
||||
}
|
||||
|
||||
const buildChatPath = (username) => {
|
||||
return username ? `/chat/${encodeURIComponent(username)}` : '/chat'
|
||||
}
|
||||
@@ -184,17 +245,83 @@ const {
|
||||
|
||||
let exitSearchContext = async () => {}
|
||||
|
||||
const runMessageLoad = async ({ username, reset = true, deferUntilPaint = false, reason = '', token = nextMessageLoadToken() } = {}) => {
|
||||
const nextUsername = String(username || '').trim()
|
||||
if (!nextUsername) return false
|
||||
|
||||
if (deferUntilPaint) {
|
||||
logChatBootstrap('loadMessages:scheduled', {
|
||||
username: nextUsername,
|
||||
reason,
|
||||
token
|
||||
})
|
||||
await waitForNextPaint()
|
||||
if (token !== messageLoadSequence) {
|
||||
logChatBootstrap('loadMessages:skipped-stale', {
|
||||
username: nextUsername,
|
||||
reason,
|
||||
token
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isFirstLoad = !firstLoadMessagesLogged
|
||||
if (isFirstLoad) {
|
||||
firstLoadMessagesLogged = true
|
||||
}
|
||||
|
||||
logChatBootstrap(isFirstLoad ? 'loadMessages:first:start' : 'loadMessages:start', {
|
||||
username: nextUsername,
|
||||
reason,
|
||||
token,
|
||||
reset
|
||||
})
|
||||
|
||||
await loadMessages({ username: nextUsername, reset })
|
||||
|
||||
logChatBootstrap(isFirstLoad ? 'loadMessages:first:end' : 'loadMessages:end', {
|
||||
username: nextUsername,
|
||||
reason,
|
||||
token,
|
||||
renderedMessages: messages.value.length
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const selectContact = async (contact, options = {}) => {
|
||||
if (!contact) return
|
||||
const selectionReason = String(options.reason || 'manual-select').trim() || 'manual-select'
|
||||
const loadToken = nextMessageLoadToken()
|
||||
const nextUsername = contact?.username || ''
|
||||
if (searchContext.value?.active && searchContext.value.username && searchContext.value.username !== nextUsername) {
|
||||
await exitSearchContext()
|
||||
}
|
||||
|
||||
const isFirstSelect = !firstSelectContactLogged
|
||||
if (isFirstSelect) {
|
||||
firstSelectContactLogged = true
|
||||
}
|
||||
logChatBootstrap(isFirstSelect ? 'selectContact:first' : 'selectContact', {
|
||||
username: nextUsername,
|
||||
reason: selectionReason,
|
||||
deferLoadMessages: !!options.deferLoadMessages,
|
||||
skipLoadMessages: !!options.skipLoadMessages,
|
||||
syncRoute: options.syncRoute !== false
|
||||
})
|
||||
|
||||
selectedContact.value = contact
|
||||
if (!nextUsername) return
|
||||
|
||||
if (!options.skipLoadMessages) {
|
||||
loadMessages({ username: nextUsername, reset: true })
|
||||
void runMessageLoad({
|
||||
username: nextUsername,
|
||||
reset: true,
|
||||
deferUntilPaint: !!options.deferLoadMessages,
|
||||
reason: selectionReason,
|
||||
token: loadToken
|
||||
})
|
||||
}
|
||||
|
||||
if (options.syncRoute !== false && nextUsername) {
|
||||
@@ -205,24 +332,34 @@ const selectContact = async (contact, options = {}) => {
|
||||
}
|
||||
}
|
||||
|
||||
const applyRouteSelection = async () => {
|
||||
const applyRouteSelection = async (options = {}) => {
|
||||
if (!contacts.value || contacts.value.length === 0) {
|
||||
selectedContact.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const selectionReason = String(options.reason || 'route-selection').trim() || 'route-selection'
|
||||
const requested = routeUsername.value || ''
|
||||
if (requested) {
|
||||
const matched = contacts.value.find((contact) => contact.username === requested)
|
||||
if (matched) {
|
||||
if (selectedContact.value?.username !== matched.username) {
|
||||
await selectContact(matched, { syncRoute: false })
|
||||
await selectContact(matched, {
|
||||
syncRoute: false,
|
||||
deferLoadMessages: !!options.deferLoadMessages,
|
||||
reason: `${selectionReason}:matched-route`
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await selectContact(contacts.value[0], { syncRoute: true, replaceRoute: true })
|
||||
await selectContact(contacts.value[0], {
|
||||
syncRoute: true,
|
||||
replaceRoute: true,
|
||||
deferLoadMessages: !!options.deferLoadMessages,
|
||||
reason: `${selectionReason}:fallback-first-contact`
|
||||
})
|
||||
}
|
||||
|
||||
const searchState = useChatSearch({
|
||||
@@ -363,18 +500,32 @@ const queueRealtimeSessionsRefresh = () => {
|
||||
}
|
||||
|
||||
const onAccountChange = async () => {
|
||||
logChatBootstrap('accountChange:start', {
|
||||
selectedAccount: selectedAccount.value
|
||||
})
|
||||
try {
|
||||
isLoadingContacts.value = true
|
||||
contactsError.value = ''
|
||||
await loadSessionsForSelectedAccount()
|
||||
} catch (error) {
|
||||
contactsError.value = error?.message || '???????'
|
||||
contactsError.value = error?.message || '加载会话失败'
|
||||
} finally {
|
||||
isLoadingContacts.value = false
|
||||
}
|
||||
|
||||
resetAccountScopedState()
|
||||
await applyRouteSelection()
|
||||
logChatBootstrap('accountChange:applyRouteSelection:start', {
|
||||
selectedAccount: selectedAccount.value,
|
||||
contactCount: contacts.value.length
|
||||
})
|
||||
await applyRouteSelection({
|
||||
reason: 'account-change'
|
||||
})
|
||||
logChatBootstrap('accountChange:end', {
|
||||
selectedAccount: selectedAccount.value,
|
||||
selectedUsername: selectedContact.value?.username || '',
|
||||
contactCount: contacts.value.length
|
||||
})
|
||||
}
|
||||
|
||||
const onGlobalClick = (event) => {
|
||||
@@ -420,6 +571,13 @@ const onGlobalKeyDown = (event) => {
|
||||
onMounted(async () => {
|
||||
if (!process.client) return
|
||||
|
||||
await resolveDesktopDebugEnabled()
|
||||
logChatBootstrap('route mount start', {
|
||||
requestedUsername: routeUsername.value,
|
||||
selectedAccount: selectedAccount.value,
|
||||
desktopShell: isDesktopShell()
|
||||
})
|
||||
|
||||
document.addEventListener('click', onGlobalClick)
|
||||
document.addEventListener('keydown', onGlobalKeyDown)
|
||||
document.addEventListener('mousemove', onFloatingWindowMouseMove)
|
||||
@@ -428,9 +586,43 @@ onMounted(async () => {
|
||||
document.addEventListener('touchend', onFloatingWindowMouseUp)
|
||||
document.addEventListener('touchcancel', onFloatingWindowMouseUp)
|
||||
|
||||
logChatBootstrap('loadContacts:start', {
|
||||
selectedAccount: selectedAccount.value
|
||||
})
|
||||
await loadContacts()
|
||||
await applyRouteSelection()
|
||||
logChatBootstrap('loadContacts:end', {
|
||||
selectedAccount: selectedAccount.value,
|
||||
contactCount: contacts.value.length
|
||||
})
|
||||
|
||||
const deferInitialConversationBoot = isDesktopShell()
|
||||
await waitForNextPaint()
|
||||
logChatBootstrap('first render completion', {
|
||||
contactCount: contacts.value.length,
|
||||
deferInitialConversationBoot
|
||||
})
|
||||
|
||||
logChatBootstrap('applyRouteSelection:start', {
|
||||
requestedUsername: routeUsername.value,
|
||||
deferLoadMessages: deferInitialConversationBoot
|
||||
})
|
||||
await applyRouteSelection({
|
||||
deferLoadMessages: deferInitialConversationBoot,
|
||||
reason: deferInitialConversationBoot ? 'initial-route-post-paint' : 'initial-route'
|
||||
})
|
||||
logChatBootstrap('applyRouteSelection:end', {
|
||||
selectedUsername: selectedContact.value?.username || '',
|
||||
requestedUsername: routeUsername.value
|
||||
})
|
||||
|
||||
logChatBootstrap('tryEnableRealtimeAuto:start', {
|
||||
selectedAccount: selectedAccount.value,
|
||||
realtimeEnabled: realtimeEnabled.value
|
||||
})
|
||||
await tryEnableRealtimeAuto()
|
||||
logChatBootstrap('tryEnableRealtimeAuto:end', {
|
||||
realtimeEnabled: realtimeEnabled.value
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -488,11 +680,17 @@ watch(messageTypeFilter, async (next, prev) => {
|
||||
|
||||
watch(
|
||||
routeUsername,
|
||||
async () => {
|
||||
async (next, prev) => {
|
||||
if (!process.client) return
|
||||
if (isLoadingContacts.value) return
|
||||
if (!contacts.value.length) return
|
||||
await applyRouteSelection()
|
||||
logChatBootstrap('routeUsername:change', {
|
||||
previousUsername: prev || '',
|
||||
nextUsername: next || ''
|
||||
})
|
||||
await applyRouteSelection({
|
||||
reason: 'route-watch'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<div class="contacts-page h-screen flex overflow-hidden" style="background-color: var(--app-shell-bg)">
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: var(--app-shell-bg)">
|
||||
<div class="flex-1 min-h-0 overflow-hidden p-4">
|
||||
<div class="h-full grid grid-cols-1 lg:grid-cols-[400px_minmax(0,1fr)] gap-4">
|
||||
<div class="bg-white border border-gray-200 rounded-lg flex flex-col min-h-0 overflow-hidden">
|
||||
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
|
||||
<div class="p-3 border-b border-gray-200" style="background-color: var(--app-surface-muted)">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="contact-search-wrapper flex-1" :class="{ 'privacy-blur': privacyMode }">
|
||||
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
|
||||
@@ -80,7 +80,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-gray-200 rounded-lg p-4 flex flex-col gap-3">
|
||||
<div class="contacts-export-panel bg-white border border-gray-200 rounded-lg p-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<div class="text-base font-medium text-gray-900">导出联系人</div>
|
||||
<div class="text-xs text-gray-500 mt-1">支持 JSON / CSV,默认包含头像链接</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="min-h-screen relative overflow-hidden flex items-center justify-center">
|
||||
<div class="decrypt-result-page min-h-screen relative overflow-hidden flex items-center justify-center">
|
||||
<!-- 网格背景 -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5 pointer-events-none"></div>
|
||||
|
||||
@@ -171,4 +171,4 @@ onMounted(() => {
|
||||
linear-gradient(90deg, rgba(7, 193, 96, 0.1) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center py-8">
|
||||
<div class="decrypt-page min-h-screen flex items-center justify-center py-8">
|
||||
|
||||
<div class="max-w-4xl mx-auto px-6 w-full">
|
||||
<!-- 步骤指示器 -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="min-h-screen relative overflow-hidden flex items-center">
|
||||
<div class="detection-result-page min-h-screen relative overflow-hidden flex items-center">
|
||||
<!-- 网格背景 -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5 pointer-events-none"></div>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<div class="edits-page h-screen flex overflow-hidden" style="background-color: var(--app-shell-bg)">
|
||||
<!-- 左侧:会话列表(与聊天页统一风格) -->
|
||||
<div class="edits-sidebar border-r border-gray-200 flex flex-col">
|
||||
<!-- 搜索栏区域 -->
|
||||
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
|
||||
<div class="p-3 border-b border-gray-200" style="background-color: var(--app-surface-muted)">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="contact-search-wrapper flex-1">
|
||||
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
|
||||
@@ -137,7 +137,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div class="flex-1 overflow-y-auto" style="background-color: #EDEDED">
|
||||
<div class="flex-1 overflow-y-auto" style="background-color: var(--app-shell-bg)">
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="itemsError" class="mx-5 mt-4 text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-4 py-3 whitespace-pre-wrap">{{ itemsError }}</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||
<div class="landing-page min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||
<!-- 网格背景 -->
|
||||
<div class="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<div class="sns-page h-screen flex overflow-hidden" style="background-color: var(--app-shell-bg)">
|
||||
<!-- 左侧朋友圈联系人 -->
|
||||
<div class="w-[280px] flex flex-col min-h-0 border-r border-gray-200 bg-[#EDEDED]">
|
||||
<div class="w-[280px] flex flex-col min-h-0 border-r border-gray-200 bg-[#EDEDED]" style="background-color: var(--app-shell-bg)">
|
||||
<div class="p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm font-semibold text-gray-700">朋友圈联系人</div>
|
||||
@@ -104,7 +104,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 右侧朋友圈区域 -->
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: var(--app-shell-bg)">
|
||||
<div ref="timelineScrollEl" class="flex-1 overflow-auto min-h-0 bg-white" @scroll="onScroll">
|
||||
<div class="max-w-2xl mx-auto px-4 py-4">
|
||||
<div class="relative w-full mb-12 -mt-4 bg-white">
|
||||
|
||||
@@ -303,14 +303,13 @@ const slides = computed(() => {
|
||||
return out
|
||||
})
|
||||
|
||||
const currentBg = computed(() => '#F3FFF8')
|
||||
const currentBg = '#F3FFF8'
|
||||
const deckTrackClass = computed(() => 'z-10')
|
||||
|
||||
const applyViewportBg = () => {
|
||||
if (!import.meta.client) return
|
||||
const bg = currentBg.value
|
||||
document.documentElement.style.backgroundColor = bg
|
||||
document.body.style.backgroundColor = bg
|
||||
document.documentElement.style.backgroundColor = currentBg
|
||||
document.body.style.backgroundColor = currentBg
|
||||
}
|
||||
|
||||
const slideStyle = computed(() => (
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
const isDesktopShell = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
const formatError = (error) => {
|
||||
if (!error) return ''
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: String(error.name || 'Error'),
|
||||
message: String(error.message || ''),
|
||||
stack: String(error.stack || '')
|
||||
}
|
||||
}
|
||||
if (typeof error === 'object') {
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(error))
|
||||
} catch {}
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
const logDesktopDebug = (phase, details = {}) => {
|
||||
if (!isDesktopShell()) return
|
||||
try {
|
||||
window.wechatDesktop?.logDebug?.('nuxt-bootstrap', phase, {
|
||||
href: String(window.location?.href || ''),
|
||||
...details
|
||||
})
|
||||
} catch {}
|
||||
try {
|
||||
console.info(`[nuxt-bootstrap] ${phase}`, details)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
logDesktopDebug('plugin:setup')
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('error', (event) => {
|
||||
logDesktopDebug('window:error', {
|
||||
message: String(event?.message || ''),
|
||||
filename: String(event?.filename || ''),
|
||||
lineno: Number(event?.lineno || 0),
|
||||
colno: Number(event?.colno || 0),
|
||||
error: formatError(event?.error)
|
||||
})
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
logDesktopDebug('window:unhandledrejection', {
|
||||
reason: formatError(event?.reason)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
nuxtApp.hook('app:created', () => {
|
||||
logDesktopDebug('app:created')
|
||||
})
|
||||
|
||||
nuxtApp.hook('app:beforeMount', () => {
|
||||
logDesktopDebug('app:beforeMount')
|
||||
})
|
||||
|
||||
nuxtApp.hook('app:mounted', () => {
|
||||
logDesktopDebug('app:mounted')
|
||||
})
|
||||
|
||||
nuxtApp.hook('page:start', () => {
|
||||
logDesktopDebug('page:start')
|
||||
})
|
||||
|
||||
nuxtApp.hook('page:finish', () => {
|
||||
logDesktopDebug('page:finish')
|
||||
})
|
||||
|
||||
nuxtApp.hook('vue:error', (error, _instance, info) => {
|
||||
logDesktopDebug('vue:error', {
|
||||
info: String(info || ''),
|
||||
error: formatError(error)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import {
|
||||
UI_THEME_DARK,
|
||||
UI_THEME_LIGHT,
|
||||
applyUiTheme,
|
||||
normalizeUiTheme,
|
||||
readUiTheme,
|
||||
writeUiTheme,
|
||||
} from '~/lib/ui-theme'
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const theme = ref(UI_THEME_LIGHT)
|
||||
const initialized = ref(false)
|
||||
|
||||
const isDark = computed(() => theme.value === UI_THEME_DARK)
|
||||
|
||||
const set = (nextTheme) => {
|
||||
theme.value = normalizeUiTheme(nextTheme, UI_THEME_LIGHT)
|
||||
writeUiTheme(theme.value)
|
||||
applyUiTheme(theme.value)
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
if (initialized.value) {
|
||||
applyUiTheme(theme.value)
|
||||
return
|
||||
}
|
||||
initialized.value = true
|
||||
theme.value = readUiTheme(UI_THEME_LIGHT)
|
||||
applyUiTheme(theme.value)
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
set(isDark.value ? UI_THEME_LIGHT : UI_THEME_DARK)
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
initialized,
|
||||
isDark,
|
||||
init,
|
||||
set,
|
||||
toggle,
|
||||
}
|
||||
})
|
||||
@@ -19,7 +19,7 @@ import zipfile
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Literal, Optional
|
||||
from typing import Any, Callable, Iterable, Literal, Optional
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
@@ -3386,6 +3386,7 @@ def _parse_message_for_export(
|
||||
resource_conn: Optional[sqlite3.Connection],
|
||||
resource_chat_id: Optional[int],
|
||||
sender_alias: str = "",
|
||||
resolve_display_name: Optional[Callable[[str], str]] = None,
|
||||
) -> dict[str, Any]:
|
||||
raw_text = row.raw_text or ""
|
||||
sender_username = str(row.sender_username or "").strip()
|
||||
@@ -3449,7 +3450,18 @@ def _parse_message_for_export(
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
content_text = _parse_system_message_content(raw_text)
|
||||
system_display_name_resolver = None
|
||||
if resolve_display_name is not None:
|
||||
def system_display_name_resolver(username: str, fallback_display_name: str) -> str:
|
||||
resolved = str(resolve_display_name(username) or "").strip()
|
||||
if resolved and resolved != username:
|
||||
return resolved
|
||||
fallback = str(fallback_display_name or "").strip()
|
||||
return fallback or resolved or username
|
||||
content_text = _parse_system_message_content(
|
||||
raw_text,
|
||||
resolve_display_name=system_display_name_resolver,
|
||||
)
|
||||
elif local_type == 49:
|
||||
parsed = _parse_app_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "text")
|
||||
@@ -3923,6 +3935,7 @@ def _write_conversation_json(
|
||||
resource_conn=resource_conn,
|
||||
resource_chat_id=resource_chat_id,
|
||||
sender_alias=sender_alias,
|
||||
resolve_display_name=resolve_display_name,
|
||||
)
|
||||
if not _is_render_type_selected(msg.get("renderType"), want_types):
|
||||
continue
|
||||
@@ -4101,6 +4114,7 @@ def _write_conversation_txt(
|
||||
resource_conn=resource_conn,
|
||||
resource_chat_id=resource_chat_id,
|
||||
sender_alias=sender_alias,
|
||||
resolve_display_name=resolve_display_name,
|
||||
)
|
||||
if not _is_render_type_selected(msg.get("renderType"), want_types):
|
||||
continue
|
||||
@@ -4859,6 +4873,7 @@ def _write_conversation_html(
|
||||
resource_conn=resource_conn,
|
||||
resource_chat_id=resource_chat_id,
|
||||
sender_alias="",
|
||||
resolve_display_name=resolve_display_name,
|
||||
)
|
||||
if not _is_render_type_selected(msg.get("renderType"), want_types):
|
||||
continue
|
||||
|
||||
@@ -7,7 +7,7 @@ import sqlite3
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Callable, Optional
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -24,6 +24,17 @@ logger = get_logger(__name__)
|
||||
|
||||
_OUTPUT_DATABASES_DIR = get_output_databases_dir()
|
||||
_DEBUG_SESSIONS = os.environ.get("WECHAT_TOOL_DEBUG_SESSIONS", "0") == "1"
|
||||
_SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
|
||||
|
||||
def _is_valid_decrypted_sqlite(path: Path) -> bool:
|
||||
try:
|
||||
if not path.exists() or (not path.is_file()):
|
||||
return False
|
||||
with path.open("rb") as f:
|
||||
return f.read(len(_SQLITE_HEADER)) == _SQLITE_HEADER
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _list_decrypted_accounts() -> list[str]:
|
||||
@@ -34,7 +45,7 @@ def _list_decrypted_accounts() -> list[str]:
|
||||
for p in _OUTPUT_DATABASES_DIR.iterdir():
|
||||
if not p.is_dir():
|
||||
continue
|
||||
if (p / "session.db").exists() and (p / "contact.db").exists():
|
||||
if _is_valid_decrypted_sqlite(p / "session.db") and _is_valid_decrypted_sqlite(p / "contact.db"):
|
||||
accounts.append(p.name)
|
||||
|
||||
accounts.sort()
|
||||
@@ -49,7 +60,9 @@ def _resolve_account_dir(account: Optional[str]) -> Path:
|
||||
detail="No decrypted databases found. Please decrypt first.",
|
||||
)
|
||||
|
||||
selected = account or accounts[0]
|
||||
selected = str(account or "").strip() or accounts[0]
|
||||
if selected not in accounts:
|
||||
raise HTTPException(status_code=404, detail="Account not found.")
|
||||
base = _OUTPUT_DATABASES_DIR.resolve()
|
||||
candidate = (_OUTPUT_DATABASES_DIR / selected).resolve()
|
||||
|
||||
@@ -774,7 +787,112 @@ def _parse_location_message(text: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _parse_system_message_content(raw_text: str) -> str:
|
||||
def _extract_chatroom_top_message_metadata(raw_text: str) -> dict[str, str]:
|
||||
text = str(raw_text or "").strip()
|
||||
if not text:
|
||||
return {}
|
||||
|
||||
lower_text = text.lower()
|
||||
if "<mmchatroomtopmsg" in lower_text or "<sysmsg" in lower_text:
|
||||
chatroom_id = str(_extract_xml_tag_text(text, "chatroomname") or "").strip()
|
||||
operation = str(_extract_xml_tag_text(text, "op") or "").strip()
|
||||
operator_username = str(_extract_xml_tag_text(text, "username") or "").strip()
|
||||
operator_display_name = str(_extract_xml_tag_text(text, "nickname") or "").strip()
|
||||
if chatroom_id.endswith("@chatroom") and operation in {"1", "2"} and operator_username:
|
||||
return {
|
||||
"operation": operation,
|
||||
"operatorUsername": operator_username,
|
||||
"operatorDisplayName": operator_display_name,
|
||||
}
|
||||
|
||||
def _is_int_token(value: str) -> bool:
|
||||
candidate = str(value or "").strip()
|
||||
if not candidate:
|
||||
return False
|
||||
if candidate[0] in {"+", "-"}:
|
||||
candidate = candidate[1:]
|
||||
return candidate.isdigit()
|
||||
|
||||
normalized = re.sub(r"<!--\s*ChatRoomTopMsgRequest\s*-->", " ", text, flags=re.IGNORECASE)
|
||||
normalized = re.sub(r"<!--\s*ChatRoomTopMsgResponse\s*-->", " ", normalized, flags=re.IGNORECASE)
|
||||
normalized = re.sub(r"\s+", " ", normalized).strip()
|
||||
if not normalized:
|
||||
return {}
|
||||
|
||||
parts = normalized.split(" ")
|
||||
has_markers = ("chatroomtopmsgrequest" in lower_text) or ("chatroomtopmsgresponse" in lower_text)
|
||||
if len(parts) < 5:
|
||||
return {}
|
||||
|
||||
chatroom_id = str(parts[0] or "").strip()
|
||||
operation = str(parts[1] or "").strip()
|
||||
if not chatroom_id.endswith("@chatroom"):
|
||||
return {}
|
||||
if operation not in {"1", "2"}:
|
||||
return {}
|
||||
|
||||
if not has_markers:
|
||||
if len(parts) < 6:
|
||||
return {}
|
||||
if not _is_int_token(parts[2]) or not _is_int_token(parts[3]) or not _is_int_token(parts[5]):
|
||||
return {}
|
||||
|
||||
operator_username = str(parts[4] or "").strip()
|
||||
if not operator_username:
|
||||
return {}
|
||||
|
||||
operator_display_name = ""
|
||||
if len(parts) >= 6 and _is_int_token(parts[5]):
|
||||
response_tokens = parts[6:]
|
||||
if len(response_tokens) >= 2 and _is_int_token(response_tokens[-1]):
|
||||
response_tokens = response_tokens[:-1]
|
||||
operator_display_name = " ".join(response_tokens).strip()
|
||||
|
||||
return {
|
||||
"operation": operation,
|
||||
"operatorUsername": operator_username,
|
||||
"operatorDisplayName": operator_display_name,
|
||||
}
|
||||
|
||||
|
||||
def _parse_chatroom_top_message(
|
||||
raw_text: str,
|
||||
resolve_display_name: Optional[Callable[[str, str], str]] = None,
|
||||
) -> str:
|
||||
meta = _extract_chatroom_top_message_metadata(raw_text)
|
||||
if not meta:
|
||||
return ""
|
||||
|
||||
operation = str(meta.get("operation") or "").strip()
|
||||
operator_username = str(meta.get("operatorUsername") or "").strip()
|
||||
operator_display_name = str(meta.get("operatorDisplayName") or "").strip()
|
||||
|
||||
if resolve_display_name is not None and operator_username:
|
||||
try:
|
||||
resolved = str(resolve_display_name(operator_username, operator_display_name) or "").strip()
|
||||
except Exception:
|
||||
resolved = ""
|
||||
if resolved:
|
||||
operator_display_name = resolved
|
||||
|
||||
if not operator_display_name:
|
||||
operator_display_name = operator_username or "有人"
|
||||
|
||||
action_map = {
|
||||
"1": "置顶了一条消息",
|
||||
"2": "移除了一条置顶消息",
|
||||
}
|
||||
action = action_map.get(operation)
|
||||
if not action:
|
||||
return ""
|
||||
|
||||
return f"{operator_display_name}{action}"
|
||||
|
||||
|
||||
def _parse_system_message_content(
|
||||
raw_text: str,
|
||||
resolve_display_name: Optional[Callable[[str, str], str]] = None,
|
||||
) -> str:
|
||||
text = str(raw_text or "").strip()
|
||||
if not text:
|
||||
return "[系统消息]"
|
||||
@@ -788,12 +906,17 @@ def _parse_system_message_content(raw_text: str) -> str:
|
||||
if nested_content:
|
||||
candidate = nested_content
|
||||
|
||||
candidate = re.sub(r"<!--.*?-->", " ", candidate, flags=re.IGNORECASE | re.DOTALL)
|
||||
candidate = re.sub(r"<!\[CDATA\[", "", candidate, flags=re.IGNORECASE)
|
||||
candidate = re.sub(r"\]\]>", "", candidate)
|
||||
candidate = re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", candidate)
|
||||
candidate = re.sub(r"\s+", " ", candidate).strip()
|
||||
return candidate
|
||||
|
||||
top_message_text = _parse_chatroom_top_message(text, resolve_display_name=resolve_display_name)
|
||||
if top_message_text:
|
||||
return top_message_text
|
||||
|
||||
if "revokemsg" in text.lower():
|
||||
replace_msg = _extract_xml_tag_text(text, "replacemsg")
|
||||
cleaned_replace_msg = _clean_system_text(replace_msg)
|
||||
@@ -2321,4 +2444,5 @@ def _row_to_search_hit(
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
"_rawText": raw_text if local_type in (10000, 266287972401) else "",
|
||||
}
|
||||
|
||||
@@ -24,6 +24,17 @@ logger = get_logger(__name__)
|
||||
|
||||
# 运行时输出目录(桌面端可通过 WECHAT_TOOL_DATA_DIR 指向可写目录)
|
||||
_PACKAGE_ROOT = Path(__file__).resolve().parent
|
||||
_SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
|
||||
|
||||
def _is_valid_decrypted_sqlite(path: Path) -> bool:
|
||||
try:
|
||||
if not path.exists() or (not path.is_file()):
|
||||
return False
|
||||
with path.open("rb") as f:
|
||||
return f.read(len(_SQLITE_HEADER)) == _SQLITE_HEADER
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _list_decrypted_accounts() -> list[str]:
|
||||
@@ -36,7 +47,7 @@ def _list_decrypted_accounts() -> list[str]:
|
||||
for p in output_db_dir.iterdir():
|
||||
if not p.is_dir():
|
||||
continue
|
||||
if (p / "session.db").exists() and (p / "contact.db").exists():
|
||||
if _is_valid_decrypted_sqlite(p / "session.db") and _is_valid_decrypted_sqlite(p / "contact.db"):
|
||||
accounts.append(p.name)
|
||||
|
||||
accounts.sort()
|
||||
@@ -53,7 +64,9 @@ def _resolve_account_dir(account: Optional[str]) -> Path:
|
||||
detail="No decrypted databases found. Please decrypt first.",
|
||||
)
|
||||
|
||||
selected = account or accounts[0]
|
||||
selected = str(account or "").strip() or accounts[0]
|
||||
if selected not in accounts:
|
||||
raise HTTPException(status_code=404, detail="Account not found.")
|
||||
base = output_db_dir.resolve()
|
||||
candidate = (output_db_dir / selected).resolve()
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from ..chat_helpers import (
|
||||
_build_fts_query,
|
||||
_decode_message_content,
|
||||
_decode_sqlite_text,
|
||||
_extract_chatroom_top_message_metadata,
|
||||
_extract_md5_from_packed_info,
|
||||
_extract_sender_from_group_xml,
|
||||
_extract_xml_attr,
|
||||
@@ -514,6 +515,61 @@ def _resolve_sender_display_name(
|
||||
return display_name
|
||||
|
||||
|
||||
def _resolve_system_message_display_name(
|
||||
*,
|
||||
sender_username: str,
|
||||
fallback_display_name: str,
|
||||
sender_contact_rows: dict[str, sqlite3.Row],
|
||||
wcdb_display_names: dict[str, str],
|
||||
) -> str:
|
||||
su = str(sender_username or "").strip()
|
||||
fallback = str(fallback_display_name or "").strip()
|
||||
if not su:
|
||||
return fallback or "有人"
|
||||
|
||||
row = sender_contact_rows.get(su)
|
||||
display_name = _pick_display_name(row, su)
|
||||
if display_name != su:
|
||||
return display_name
|
||||
|
||||
if fallback and fallback != su:
|
||||
return fallback
|
||||
|
||||
wd = str(wcdb_display_names.get(su) or "").strip()
|
||||
if wd and wd != su:
|
||||
return wd
|
||||
|
||||
return fallback or wd or su
|
||||
|
||||
|
||||
def _postprocess_special_message_content(
|
||||
*,
|
||||
message: dict[str, Any],
|
||||
sender_contact_rows: dict[str, sqlite3.Row],
|
||||
wcdb_display_names: dict[str, str],
|
||||
) -> None:
|
||||
raw = str(message.get("_rawText") or "")
|
||||
if not raw:
|
||||
message.pop("_rawText", None)
|
||||
return
|
||||
|
||||
local_type = int(message.get("type") or 0)
|
||||
if local_type == 266287972401:
|
||||
message["content"] = _parse_pat_message(raw, sender_contact_rows)
|
||||
elif local_type == 10000:
|
||||
message["content"] = _parse_system_message_content(
|
||||
raw,
|
||||
resolve_display_name=lambda sender_username, fallback_display_name="": _resolve_system_message_display_name(
|
||||
sender_username=sender_username,
|
||||
fallback_display_name=fallback_display_name,
|
||||
sender_contact_rows=sender_contact_rows,
|
||||
wcdb_display_names=wcdb_display_names,
|
||||
),
|
||||
)
|
||||
|
||||
message.pop("_rawText", None)
|
||||
|
||||
|
||||
def _realtime_sync_lock(account: str, username: str) -> threading.Lock:
|
||||
key = (str(account or "").strip(), str(username or "").strip())
|
||||
with _REALTIME_SYNC_MU:
|
||||
@@ -3034,7 +3090,7 @@ def _append_full_messages_from_rows(
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
"_rawText": raw_text if local_type == 266287972401 else "",
|
||||
"_rawText": raw_text if local_type in (10000, 266287972401) else "",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3271,9 +3327,20 @@ def _postprocess_full_messages(
|
||||
if fn and fn in name_to_username:
|
||||
m["fromUsername"] = name_to_username[fn]
|
||||
|
||||
system_usernames: set[str] = set()
|
||||
for m in merged:
|
||||
if int(m.get("type") or 0) != 10000:
|
||||
continue
|
||||
meta = _extract_chatroom_top_message_metadata(str(m.get("_rawText") or ""))
|
||||
operator_username = str(meta.get("operatorUsername") or "").strip()
|
||||
if operator_username:
|
||||
system_usernames.add(operator_username)
|
||||
|
||||
from_usernames = [str(m.get("fromUsername") or "").strip() for m in merged]
|
||||
uniq_senders = list(
|
||||
dict.fromkeys([u for u in (sender_usernames + list(pat_usernames) + quote_usernames + from_usernames) if u])
|
||||
dict.fromkeys(
|
||||
[u for u in (sender_usernames + list(pat_usernames) + quote_usernames + from_usernames + list(system_usernames)) if u]
|
||||
)
|
||||
)
|
||||
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
|
||||
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
|
||||
@@ -3327,20 +3394,19 @@ def _postprocess_full_messages(
|
||||
m["from"] = wd
|
||||
|
||||
su = str(m.get("senderUsername") or "")
|
||||
if not su:
|
||||
continue
|
||||
m["senderDisplayName"] = _resolve_sender_display_name(
|
||||
sender_username=su,
|
||||
sender_contact_rows=sender_contact_rows,
|
||||
wcdb_display_names=wcdb_display_names,
|
||||
group_nicknames=group_nicknames,
|
||||
)
|
||||
avatar_url = base_url + _avatar_url_unified(
|
||||
account_dir=account_dir,
|
||||
username=su,
|
||||
local_avatar_usernames=local_sender_avatars,
|
||||
)
|
||||
m["senderAvatar"] = avatar_url
|
||||
if su:
|
||||
m["senderDisplayName"] = _resolve_sender_display_name(
|
||||
sender_username=su,
|
||||
sender_contact_rows=sender_contact_rows,
|
||||
wcdb_display_names=wcdb_display_names,
|
||||
group_nicknames=group_nicknames,
|
||||
)
|
||||
avatar_url = base_url + _avatar_url_unified(
|
||||
account_dir=account_dir,
|
||||
username=su,
|
||||
local_avatar_usernames=local_sender_avatars,
|
||||
)
|
||||
m["senderAvatar"] = avatar_url
|
||||
|
||||
qu = str(m.get("quoteUsername") or "").strip()
|
||||
if qu:
|
||||
@@ -3471,13 +3537,11 @@ def _postprocess_full_messages(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if int(m.get("type") or 0) == 266287972401:
|
||||
raw = str(m.get("_rawText") or "")
|
||||
if raw:
|
||||
m["content"] = _parse_pat_message(raw, sender_contact_rows)
|
||||
|
||||
if "_rawText" in m:
|
||||
m.pop("_rawText", None)
|
||||
_postprocess_special_message_content(
|
||||
message=m,
|
||||
sender_contact_rows=sender_contact_rows,
|
||||
wcdb_display_names=wcdb_display_names,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/chat/accounts", summary="列出已解密账号")
|
||||
@@ -4526,7 +4590,7 @@ def _collect_chat_messages(
|
||||
"locationLng": location_lng,
|
||||
"locationPoiname": location_poiname,
|
||||
"locationLabel": location_label,
|
||||
"_rawText": raw_text if local_type == 266287972401 else "",
|
||||
"_rawText": raw_text if local_type in (10000, 266287972401) else "",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
@@ -5409,7 +5473,7 @@ def list_chat_messages(
|
||||
"paySubType": pay_sub_type,
|
||||
"transferStatus": transfer_status,
|
||||
"transferId": transfer_id,
|
||||
"_rawText": raw_text if local_type == 266287972401 else "",
|
||||
"_rawText": raw_text if local_type in (10000, 266287972401) else "",
|
||||
}
|
||||
)
|
||||
finally:
|
||||
@@ -5498,6 +5562,15 @@ def list_chat_messages(
|
||||
continue
|
||||
pat_usernames_in_page.update({mm.group(1) for mm in re.finditer(r"\$\{([^}]+)\}", template) if mm.group(1)})
|
||||
|
||||
system_usernames_in_page: set[str] = set()
|
||||
for m in messages_window:
|
||||
if int(m.get("type") or 0) != 10000:
|
||||
continue
|
||||
meta = _extract_chatroom_top_message_metadata(str(m.get("_rawText") or ""))
|
||||
operator_username = str(meta.get("operatorUsername") or "").strip()
|
||||
if operator_username:
|
||||
system_usernames_in_page.add(operator_username)
|
||||
|
||||
from_usernames = [str(m.get("fromUsername") or "").strip() for m in messages_window]
|
||||
sender_usernames_in_page = [str(m.get("senderUsername") or "").strip() for m in messages_window]
|
||||
quote_usernames_in_page = [str(m.get("quoteUsername") or "").strip() for m in messages_window]
|
||||
@@ -5510,6 +5583,7 @@ def list_chat_messages(
|
||||
+ list(pat_usernames_in_page)
|
||||
+ quote_usernames_in_page
|
||||
+ from_usernames
|
||||
+ list(system_usernames_in_page)
|
||||
)
|
||||
if u
|
||||
]
|
||||
@@ -5567,20 +5641,19 @@ def list_chat_messages(
|
||||
m["from"] = wd
|
||||
|
||||
su = str(m.get("senderUsername") or "")
|
||||
if not su:
|
||||
continue
|
||||
m["senderDisplayName"] = _resolve_sender_display_name(
|
||||
sender_username=su,
|
||||
sender_contact_rows=sender_contact_rows,
|
||||
wcdb_display_names=wcdb_display_names,
|
||||
group_nicknames=group_nicknames,
|
||||
)
|
||||
avatar_url = base_url + _avatar_url_unified(
|
||||
account_dir=account_dir,
|
||||
username=su,
|
||||
local_avatar_usernames=local_sender_avatars,
|
||||
)
|
||||
m["senderAvatar"] = avatar_url
|
||||
if su:
|
||||
m["senderDisplayName"] = _resolve_sender_display_name(
|
||||
sender_username=su,
|
||||
sender_contact_rows=sender_contact_rows,
|
||||
wcdb_display_names=wcdb_display_names,
|
||||
group_nicknames=group_nicknames,
|
||||
)
|
||||
avatar_url = base_url + _avatar_url_unified(
|
||||
account_dir=account_dir,
|
||||
username=su,
|
||||
local_avatar_usernames=local_sender_avatars,
|
||||
)
|
||||
m["senderAvatar"] = avatar_url
|
||||
|
||||
qu = str(m.get("quoteUsername") or "").strip()
|
||||
if qu:
|
||||
@@ -5706,13 +5779,11 @@ def list_chat_messages(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if int(m.get("type") or 0) == 266287972401:
|
||||
raw = str(m.get("_rawText") or "")
|
||||
if raw:
|
||||
m["content"] = _parse_pat_message(raw, sender_contact_rows)
|
||||
|
||||
if "_rawText" in m:
|
||||
m.pop("_rawText", None)
|
||||
_postprocess_special_message_content(
|
||||
message=m,
|
||||
sender_contact_rows=sender_contact_rows,
|
||||
wcdb_display_names=wcdb_display_names,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -6032,7 +6103,14 @@ async def _search_chat_messages_via_fts(
|
||||
scope = "conversation" if username else "global"
|
||||
|
||||
if username:
|
||||
uniq_usernames = list(dict.fromkeys([username] + [str(x.get("senderUsername") or "") for x in hits]))
|
||||
system_usernames = [
|
||||
str(_extract_chatroom_top_message_metadata(str(x.get("_rawText") or "")).get("operatorUsername") or "").strip()
|
||||
for x in hits
|
||||
if int(x.get("type") or 0) == 10000
|
||||
]
|
||||
uniq_usernames = list(
|
||||
dict.fromkeys([username] + [str(x.get("senderUsername") or "") for x in hits] + system_usernames)
|
||||
)
|
||||
contact_rows = _load_contact_rows(contact_db_path, uniq_usernames)
|
||||
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, uniq_usernames)
|
||||
|
||||
@@ -6099,10 +6177,22 @@ async def _search_chat_messages_via_fts(
|
||||
local_avatar_usernames=local_avatar_usernames,
|
||||
)
|
||||
h["senderAvatar"] = avatar_url
|
||||
_postprocess_special_message_content(
|
||||
message=h,
|
||||
sender_contact_rows=contact_rows,
|
||||
wcdb_display_names=wcdb_display_names,
|
||||
)
|
||||
else:
|
||||
system_usernames = [
|
||||
str(_extract_chatroom_top_message_metadata(str(x.get("_rawText") or "")).get("operatorUsername") or "").strip()
|
||||
for x in hits
|
||||
if int(x.get("type") or 0) == 10000
|
||||
]
|
||||
uniq_contacts = list(
|
||||
dict.fromkeys(
|
||||
[str(x.get("username") or "") for x in hits] + [str(x.get("senderUsername") or "") for x in hits]
|
||||
[str(x.get("username") or "") for x in hits]
|
||||
+ [str(x.get("senderUsername") or "") for x in hits]
|
||||
+ system_usernames
|
||||
)
|
||||
)
|
||||
contact_rows = _load_contact_rows(contact_db_path, uniq_contacts)
|
||||
@@ -6182,6 +6272,11 @@ async def _search_chat_messages_via_fts(
|
||||
local_avatar_usernames=local_avatar_usernames,
|
||||
)
|
||||
h["senderAvatar"] = avatar_url
|
||||
_postprocess_special_message_content(
|
||||
message=h,
|
||||
sender_contact_rows=contact_rows,
|
||||
wcdb_display_names=wcdb_display_names,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -6434,7 +6529,14 @@ async def search_chat_messages(
|
||||
total_in_scan = len(conv_hits)
|
||||
page = conv_hits[int(offset) : int(offset) + int(limit)]
|
||||
|
||||
uniq_usernames = list(dict.fromkeys([username] + [str(x.get("senderUsername") or "") for x in page]))
|
||||
system_usernames = [
|
||||
str(_extract_chatroom_top_message_metadata(str(x.get("_rawText") or "")).get("operatorUsername") or "").strip()
|
||||
for x in page
|
||||
if int(x.get("type") or 0) == 10000
|
||||
]
|
||||
uniq_usernames = list(
|
||||
dict.fromkeys([username] + [str(x.get("senderUsername") or "") for x in page] + system_usernames)
|
||||
)
|
||||
contact_rows = _load_contact_rows(contact_db_path, uniq_usernames)
|
||||
conv_row = contact_rows.get(username)
|
||||
conv_name = _pick_display_name(conv_row, username)
|
||||
@@ -6455,6 +6557,11 @@ async def search_chat_messages(
|
||||
wcdb_display_names={},
|
||||
group_nicknames=group_nicknames,
|
||||
)
|
||||
_postprocess_special_message_content(
|
||||
message=h,
|
||||
sender_contact_rows=contact_rows,
|
||||
wcdb_display_names={},
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -6531,7 +6638,13 @@ async def search_chat_messages(
|
||||
|
||||
uniq_contacts = list(
|
||||
dict.fromkeys(
|
||||
[str(x.get("username") or "") for x in page] + [str(x.get("senderUsername") or "") for x in page]
|
||||
[str(x.get("username") or "") for x in page]
|
||||
+ [str(x.get("senderUsername") or "") for x in page]
|
||||
+ [
|
||||
str(_extract_chatroom_top_message_metadata(str(x.get("_rawText") or "")).get("operatorUsername") or "").strip()
|
||||
for x in page
|
||||
if int(x.get("type") or 0) == 10000
|
||||
]
|
||||
)
|
||||
)
|
||||
contact_rows = _load_contact_rows(contact_db_path, uniq_contacts)
|
||||
@@ -6566,6 +6679,11 @@ async def search_chat_messages(
|
||||
wcdb_display_names={},
|
||||
group_nicknames=group_nickname_cache.get(cu, {}),
|
||||
)
|
||||
_postprocess_special_message_content(
|
||||
message=h,
|
||||
sender_contact_rows=contact_rows,
|
||||
wcdb_display_names={},
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
|
||||
@@ -14,7 +14,7 @@ from ..app_paths import get_output_databases_dir
|
||||
from ..logging_config import get_logger
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..key_store import upsert_account_keys_in_store
|
||||
from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases
|
||||
from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases, scan_account_databases_from_path
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -79,6 +79,8 @@ async def decrypt_databases(request: DecryptRequest):
|
||||
"account_results": results.get("account_results", {}),
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"解密API异常: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -126,44 +128,17 @@ async def decrypt_databases_stream(
|
||||
yield _sse({"type": "scanning", "message": "正在扫描数据库文件..."})
|
||||
await asyncio.sleep(0)
|
||||
|
||||
account_name = "unknown_account"
|
||||
path_parts = storage_path.parts
|
||||
account_patterns = ["wxid_"]
|
||||
for part in path_parts:
|
||||
for pattern in account_patterns:
|
||||
if part.startswith(pattern):
|
||||
parts = part.split("_")
|
||||
if len(parts) >= 3:
|
||||
account_name = "_".join(parts[:-1])
|
||||
else:
|
||||
account_name = part
|
||||
break
|
||||
if account_name != "unknown_account":
|
||||
break
|
||||
|
||||
if account_name == "unknown_account":
|
||||
for part in reversed(path_parts):
|
||||
if part != "db_storage" and len(part) > 3:
|
||||
account_name = part
|
||||
break
|
||||
|
||||
databases: list[dict] = []
|
||||
for root, _dirs, files in os.walk(storage_path):
|
||||
if "db_storage" not in str(root):
|
||||
continue
|
||||
for file_name in files:
|
||||
if not file_name.endswith(".db"):
|
||||
continue
|
||||
if file_name in ["key_info.db"]:
|
||||
continue
|
||||
db_path = os.path.join(root, file_name)
|
||||
databases.append({"path": db_path, "name": file_name, "account": account_name})
|
||||
|
||||
if not databases:
|
||||
yield _sse({"type": "error", "message": "未找到微信数据库文件!请检查 db_storage_path 是否正确"})
|
||||
scan_result = scan_account_databases_from_path(p)
|
||||
if scan_result["status"] == "error":
|
||||
payload = {"type": "error", "message": scan_result["message"]}
|
||||
detected_accounts = scan_result.get("detected_accounts") or []
|
||||
if detected_accounts:
|
||||
payload["detected_accounts"] = detected_accounts
|
||||
yield _sse(payload)
|
||||
return
|
||||
|
||||
account_databases = {account_name: databases}
|
||||
account_databases = scan_result.get("account_databases", {})
|
||||
account_sources = scan_result.get("account_sources", {})
|
||||
total_databases = sum(len(dbs) for dbs in account_databases.values())
|
||||
|
||||
yield _sse({"type": "start", "total": total_databases, "message": f"开始解密 {total_databases} 个数据库"})
|
||||
@@ -193,12 +168,9 @@ async def decrypt_databases_stream(
|
||||
|
||||
# Save a hint for later UI (same as non-stream endpoint).
|
||||
try:
|
||||
source_db_storage_path = p
|
||||
wxid_dir = ""
|
||||
if storage_path.name.lower() == "db_storage":
|
||||
wxid_dir = str(storage_path.parent)
|
||||
else:
|
||||
wxid_dir = str(storage_path)
|
||||
source_info = account_sources.get(account, {})
|
||||
source_db_storage_path = str(source_info.get("db_storage_path") or p)
|
||||
wxid_dir = str(source_info.get("wxid_dir") or "")
|
||||
(account_output_dir / "_source.json").write_text(
|
||||
json.dumps({"db_storage_path": source_db_storage_path, "wxid_dir": wxid_dir}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
|
||||
@@ -26,25 +26,41 @@ _DEFAULT_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
|
||||
_WCDB_API_DLL_SELECTED: Optional[Path] = None
|
||||
|
||||
|
||||
def _is_project_wcdb_api_dll_path(path: Path) -> bool:
|
||||
try:
|
||||
resolved = path.resolve(strict=False)
|
||||
except Exception:
|
||||
resolved = path
|
||||
|
||||
try:
|
||||
default_resolved = _DEFAULT_WCDB_API_DLL.resolve(strict=False)
|
||||
except Exception:
|
||||
default_resolved = _DEFAULT_WCDB_API_DLL
|
||||
|
||||
if resolved == default_resolved:
|
||||
return True
|
||||
|
||||
parts = tuple(str(part).lower() for part in resolved.parts)
|
||||
allowed_suffixes = (
|
||||
("backend", "native", "wcdb_api.dll"),
|
||||
("wechat_decrypt_tool", "native", "wcdb_api.dll"),
|
||||
)
|
||||
return any(parts[-len(suffix) :] == suffix for suffix in allowed_suffixes)
|
||||
|
||||
|
||||
def _candidate_wcdb_api_dll_paths() -> list[Path]:
|
||||
"""Return possible locations for wcdb_api.dll (prefer WeFlow's newer build when present)."""
|
||||
"""Return allowed locations for wcdb_api.dll."""
|
||||
cands: list[Path] = []
|
||||
|
||||
env = str(os.environ.get("WECHAT_TOOL_WCDB_API_DLL_PATH", "") or "").strip()
|
||||
if env:
|
||||
cands.append(Path(env))
|
||||
env_path = Path(env)
|
||||
if _is_project_wcdb_api_dll_path(env_path):
|
||||
cands.append(env_path)
|
||||
else:
|
||||
logger.warning("[wcdb] ignore external wcdb_api.dll override: %s", env_path)
|
||||
|
||||
# Repo checkout convenience: reuse bundled WeFlow / echotrace DLLs when available.
|
||||
try:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
except Exception:
|
||||
repo_root = Path.cwd()
|
||||
|
||||
for p in [
|
||||
repo_root / "WeFlow" / "resources" / "wcdb_api.dll",
|
||||
repo_root / "echotrace" / "assets" / "dll" / "wcdb_api.dll",
|
||||
_DEFAULT_WCDB_API_DLL,
|
||||
]:
|
||||
for p in (_DEFAULT_WCDB_API_DLL,):
|
||||
if p not in cands:
|
||||
cands.append(p)
|
||||
|
||||
|
||||
@@ -27,6 +27,169 @@ from .app_paths import get_output_databases_dir
|
||||
# SQLite文件头
|
||||
SQLITE_HEADER = b"SQLite format 3\x00"
|
||||
|
||||
|
||||
def _normalize_account_name(name: str) -> str:
|
||||
value = str(name or "").strip()
|
||||
if not value:
|
||||
return "unknown_account"
|
||||
|
||||
if value.startswith("wxid_"):
|
||||
parts = value.split("_")
|
||||
if len(parts) >= 3:
|
||||
trimmed = "_".join(parts[:-1]).strip()
|
||||
if trimmed:
|
||||
return trimmed
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _derive_account_name_from_path(path: Path) -> str:
|
||||
try:
|
||||
target = path.resolve()
|
||||
except Exception:
|
||||
target = path
|
||||
|
||||
for part in target.parts:
|
||||
part_str = str(part or "").strip()
|
||||
if part_str.startswith("wxid_"):
|
||||
return _normalize_account_name(part_str)
|
||||
|
||||
for part in reversed(target.parts):
|
||||
part_str = str(part or "").strip()
|
||||
if not part_str or part_str.lower() == "db_storage" or len(part_str) <= 3:
|
||||
continue
|
||||
return _normalize_account_name(part_str)
|
||||
|
||||
return "unknown_account"
|
||||
|
||||
|
||||
def _resolve_db_storage_roots(storage_path: Path) -> list[Path]:
|
||||
try:
|
||||
target = storage_path.resolve()
|
||||
except Exception:
|
||||
target = storage_path
|
||||
|
||||
if not target.exists():
|
||||
return []
|
||||
|
||||
current = target if target.is_dir() else target.parent
|
||||
probe = current
|
||||
while True:
|
||||
if probe.name.lower() == "db_storage":
|
||||
return [probe]
|
||||
parent = probe.parent
|
||||
if parent == probe:
|
||||
break
|
||||
probe = parent
|
||||
|
||||
roots: list[Path] = []
|
||||
try:
|
||||
for root, dirs, _files in os.walk(current):
|
||||
root_path = Path(root)
|
||||
if root_path.name.lower() != "db_storage":
|
||||
continue
|
||||
roots.append(root_path)
|
||||
dirs[:] = []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
uniq: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for root in roots:
|
||||
key = str(root)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
uniq.append(root)
|
||||
uniq.sort(key=lambda p: str(p).lower())
|
||||
return uniq
|
||||
|
||||
|
||||
def scan_account_databases_from_path(db_storage_path: str) -> dict:
|
||||
storage_path = Path(str(db_storage_path or "").strip())
|
||||
if not storage_path.exists():
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"指定的数据库路径不存在: {db_storage_path}",
|
||||
"account_databases": {},
|
||||
"account_sources": {},
|
||||
"detected_accounts": [],
|
||||
}
|
||||
|
||||
db_roots = _resolve_db_storage_roots(storage_path)
|
||||
if not db_roots:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "未找到微信数据库文件!请确保路径指向具体账号的 db_storage 目录。",
|
||||
"account_databases": {},
|
||||
"account_sources": {},
|
||||
"detected_accounts": [],
|
||||
}
|
||||
|
||||
detected_accounts = [
|
||||
{
|
||||
"account": _derive_account_name_from_path(root),
|
||||
"db_storage_path": str(root),
|
||||
"wxid_dir": str(root.parent),
|
||||
}
|
||||
for root in db_roots
|
||||
]
|
||||
|
||||
if len(db_roots) > 1:
|
||||
account_names = ", ".join(
|
||||
[str(item.get("account") or item.get("db_storage_path") or "").strip() for item in detected_accounts]
|
||||
)
|
||||
return {
|
||||
"status": "error",
|
||||
"message": (
|
||||
"检测到多个账号目录,请选择具体账号的 db_storage 目录后再解密,"
|
||||
f"不要直接选择上级目录。当前检测到: {account_names}"
|
||||
),
|
||||
"account_databases": {},
|
||||
"account_sources": {},
|
||||
"detected_accounts": detected_accounts,
|
||||
}
|
||||
|
||||
db_root = db_roots[0]
|
||||
account_name = _derive_account_name_from_path(db_root)
|
||||
databases: list[dict] = []
|
||||
for root, _dirs, files in os.walk(db_root):
|
||||
for file_name in files:
|
||||
if not file_name.endswith(".db"):
|
||||
continue
|
||||
if file_name in ["key_info.db"]:
|
||||
continue
|
||||
db_path = os.path.join(root, file_name)
|
||||
databases.append(
|
||||
{
|
||||
"path": db_path,
|
||||
"name": file_name,
|
||||
"account": account_name,
|
||||
}
|
||||
)
|
||||
|
||||
if not databases:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "未找到微信数据库文件!请检查 db_storage_path 是否正确",
|
||||
"account_databases": {},
|
||||
"account_sources": {},
|
||||
"detected_accounts": detected_accounts,
|
||||
}
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "",
|
||||
"account_databases": {account_name: databases},
|
||||
"account_sources": {
|
||||
account_name: {
|
||||
"db_storage_path": str(db_root),
|
||||
"wxid_dir": str(db_root.parent),
|
||||
}
|
||||
},
|
||||
"detected_accounts": detected_accounts,
|
||||
}
|
||||
|
||||
def setup_logging():
|
||||
"""设置日志配置 - 已弃用,使用统一的日志配置"""
|
||||
from .logging_config import setup_logging as unified_setup_logging
|
||||
@@ -259,75 +422,28 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
|
||||
# 查找数据库文件并按账号组织
|
||||
account_databases = {} # {account_name: [db_info, ...]}
|
||||
account_sources = {}
|
||||
detected_accounts = []
|
||||
|
||||
if db_storage_path:
|
||||
# 使用指定路径查找数据库
|
||||
storage_path = Path(db_storage_path)
|
||||
|
||||
if storage_path.exists():
|
||||
# 尝试从路径中提取账号名
|
||||
account_name = "unknown_account"
|
||||
path_parts = storage_path.parts
|
||||
|
||||
# 常见的微信账号格式模式
|
||||
account_patterns = ['wxid_']
|
||||
|
||||
for part in path_parts:
|
||||
# 检查是否匹配已知的账号格式
|
||||
for pattern in account_patterns:
|
||||
if part.startswith(pattern):
|
||||
# 提取主要部分,去掉后面的随机后缀
|
||||
# 例如:wxid_v4mbduwqtzpt22_1e7a -> wxid_v4mbduwqtzpt22
|
||||
parts = part.split('_')
|
||||
if len(parts) >= 3: # wxid_主要部分_随机后缀
|
||||
account_name = '_'.join(parts[:-1]) # 去掉最后一个随机部分
|
||||
else:
|
||||
account_name = part # 如果格式不符合预期,保留原名
|
||||
break
|
||||
if account_name != "unknown_account":
|
||||
break
|
||||
|
||||
# 如果没有匹配到已知格式,使用包含数据库的目录名
|
||||
if account_name == "unknown_account":
|
||||
# 查找包含db_storage的父目录作为账号名
|
||||
for part in reversed(path_parts):
|
||||
if part != "db_storage" and len(part) > 3:
|
||||
account_name = part
|
||||
break
|
||||
|
||||
databases = []
|
||||
# 使用递归查找,与自动检测逻辑一致
|
||||
for root, dirs, files in os.walk(storage_path):
|
||||
# 只处理db_storage目录下的数据库文件
|
||||
if "db_storage" not in str(root):
|
||||
continue
|
||||
for file_name in files:
|
||||
if not file_name.endswith(".db"):
|
||||
continue
|
||||
# 排除不需要解密的数据库
|
||||
if file_name in ["key_info.db"]:
|
||||
continue
|
||||
db_path = os.path.join(root, file_name)
|
||||
databases.append({
|
||||
'path': db_path,
|
||||
'name': file_name,
|
||||
'account': account_name
|
||||
})
|
||||
|
||||
if databases:
|
||||
account_databases[account_name] = databases
|
||||
logger.info(f"在指定路径找到账号 {account_name} 的 {len(databases)} 个数据库文件")
|
||||
else:
|
||||
scan_result = scan_account_databases_from_path(db_storage_path)
|
||||
detected_accounts = scan_result.get("detected_accounts", [])
|
||||
if scan_result["status"] == "error":
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"指定的数据库路径不存在: {db_storage_path}",
|
||||
"message": scan_result["message"],
|
||||
"total_databases": 0,
|
||||
"successful_count": 0,
|
||||
"failed_count": 0,
|
||||
"output_directory": str(base_output_dir.absolute()),
|
||||
"processed_files": [],
|
||||
"failed_files": []
|
||||
"failed_files": [],
|
||||
"detected_accounts": scan_result.get("detected_accounts", []),
|
||||
}
|
||||
account_databases = scan_result.get("account_databases", {})
|
||||
account_sources = scan_result.get("account_sources", {})
|
||||
for account_name, databases in account_databases.items():
|
||||
logger.info(f"在指定路径找到账号 {account_name} 的 {len(databases)} 个数据库文件")
|
||||
else:
|
||||
# 不再支持自动检测,要求用户提供具体的db_storage_path
|
||||
return {
|
||||
@@ -387,14 +503,9 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
logger.info(f"账号 {account_name} 输出目录: {account_output_dir}")
|
||||
|
||||
try:
|
||||
source_db_storage_path = str(db_storage_path or "")
|
||||
wxid_dir = ""
|
||||
if db_storage_path:
|
||||
sp = Path(db_storage_path)
|
||||
if sp.name.lower() == "db_storage":
|
||||
wxid_dir = str(sp.parent)
|
||||
else:
|
||||
wxid_dir = str(sp)
|
||||
source_info = account_sources.get(account_name, {})
|
||||
source_db_storage_path = str(source_info.get("db_storage_path") or db_storage_path or "")
|
||||
wxid_dir = str(source_info.get("wxid_dir") or "")
|
||||
(account_output_dir / "_source.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -473,7 +584,8 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
|
||||
"output_directory": str(base_output_dir.absolute()),
|
||||
"processed_files": processed_files,
|
||||
"failed_files": failed_files,
|
||||
"account_results": account_results # 新增:按账号的详细结果
|
||||
"account_results": account_results, # 新增:按账号的详细结果
|
||||
"detected_accounts": detected_accounts,
|
||||
}
|
||||
|
||||
logger.info("=" * 60)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
from wechat_decrypt_tool.routers import chat as chat_router
|
||||
|
||||
|
||||
class _DummyRequest:
|
||||
base_url = "http://testserver/"
|
||||
|
||||
|
||||
class _DummyConn:
|
||||
def __init__(self) -> None:
|
||||
self.handle = 1
|
||||
self.lock = threading.Lock()
|
||||
|
||||
|
||||
class TestChatRealtimeSystemMessageDisplayName(unittest.TestCase):
|
||||
def test_realtime_chatroom_top_message_prefers_remark_name(self):
|
||||
raw_text = (
|
||||
"17990148862@chatroom 2 3546361838777087323 0 "
|
||||
"wxid_k7zhjk9xvzsk22 21 A 69"
|
||||
)
|
||||
wcdb_rows = [
|
||||
{
|
||||
"localId": 1,
|
||||
"serverId": 123,
|
||||
"localType": 10000,
|
||||
"sortSeq": 1700000000000,
|
||||
"realSenderId": 0,
|
||||
"createTime": 1700000000,
|
||||
"messageContent": raw_text,
|
||||
"compressContent": None,
|
||||
"packedInfoData": None,
|
||||
"senderUsername": "",
|
||||
"isSent": False,
|
||||
}
|
||||
]
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
conn = _DummyConn()
|
||||
|
||||
with (
|
||||
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
|
||||
patch.object(chat_router.WCDB_REALTIME, "ensure_connected", return_value=conn),
|
||||
patch.object(chat_router, "_wcdb_get_messages", return_value=wcdb_rows),
|
||||
patch.object(
|
||||
chat_router,
|
||||
"_load_contact_rows",
|
||||
return_value={
|
||||
"wxid_k7zhjk9xvzsk22": {
|
||||
"remark": "周鑫",
|
||||
"nick_name": "A",
|
||||
"alias": "",
|
||||
}
|
||||
},
|
||||
),
|
||||
patch.object(chat_router, "_query_head_image_usernames", return_value=set()),
|
||||
patch.object(chat_router, "_wcdb_get_display_names", return_value={}),
|
||||
patch.object(chat_router, "_wcdb_get_avatar_urls", return_value={}),
|
||||
patch.object(chat_router, "_load_usernames_by_display_names", return_value={}),
|
||||
patch.object(chat_router, "_load_group_nickname_map", return_value={}),
|
||||
):
|
||||
resp = chat_router.list_chat_messages(
|
||||
_DummyRequest(),
|
||||
username="17990148862@chatroom",
|
||||
account="acc",
|
||||
limit=50,
|
||||
offset=0,
|
||||
order="asc",
|
||||
render_types=None,
|
||||
source="realtime",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
messages = resp.get("messages") or []
|
||||
self.assertEqual(len(messages), 1)
|
||||
msg = messages[0]
|
||||
self.assertEqual(msg.get("renderType"), "system")
|
||||
self.assertEqual(msg.get("content"), "周鑫移除了一条置顶消息")
|
||||
self.assertNotIn("_rawText", msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -37,6 +37,83 @@ class TestChatSystemMessageParsing(unittest.TestCase):
|
||||
raw_text = "<sysmsg><template><![CDATA[ 张三 加入了群聊 ]]></template></sysmsg>"
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "张三 加入了群聊")
|
||||
|
||||
def test_chatroom_top_message_uses_response_name_by_default(self):
|
||||
raw_text = (
|
||||
"<!-- ChatRoomTopMsgRequest --> 17990148862@chatroom 1 3546361838777087323 49 "
|
||||
"wxid_7iazcmpjn90k22 <!-- ChatRoomTopMsgResponse --> 21 新青年 68"
|
||||
)
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "新青年置顶了一条消息")
|
||||
|
||||
def test_chatroom_top_message_prefers_resolved_display_name(self):
|
||||
raw_text = (
|
||||
"<!-- ChatRoomTopMsgRequest --> 17990148862@chatroom 2 3546361838777087323 0 "
|
||||
"wxid_k7zhjk9xvzsk22 <!-- ChatRoomTopMsgResponse --> 21 A 69"
|
||||
)
|
||||
|
||||
def resolve_display_name(username: str, fallback: str) -> str:
|
||||
self.assertEqual(username, "wxid_k7zhjk9xvzsk22")
|
||||
self.assertEqual(fallback, "A")
|
||||
return "周鑫"
|
||||
|
||||
self.assertEqual(
|
||||
_parse_system_message_content(raw_text, resolve_display_name=resolve_display_name),
|
||||
"周鑫移除了一条置顶消息",
|
||||
)
|
||||
|
||||
def test_chatroom_top_message_without_comment_markers_still_parses(self):
|
||||
raw_text = "17990148862@chatroom 1 3546361838777087323 49 wxid_7iazcmpjn90k22 21 新青年 68"
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "新青年置顶了一条消息")
|
||||
|
||||
def test_chatroom_top_message_without_comment_markers_still_prefers_resolved_name(self):
|
||||
raw_text = "17990148862@chatroom 2 3546361838777087323 0 wxid_k7zhjk9xvzsk22 21 A 69"
|
||||
|
||||
def resolve_display_name(username: str, fallback: str) -> str:
|
||||
self.assertEqual(username, "wxid_k7zhjk9xvzsk22")
|
||||
self.assertEqual(fallback, "A")
|
||||
return "周鑫"
|
||||
|
||||
self.assertEqual(
|
||||
_parse_system_message_content(raw_text, resolve_display_name=resolve_display_name),
|
||||
"周鑫移除了一条置顶消息",
|
||||
)
|
||||
|
||||
def test_chatroom_top_message_xml_payload_still_parses(self):
|
||||
raw_text = (
|
||||
'<sysmsg type="mmchatroomtopmsg"><mmchatroomtopmsg>'
|
||||
'<chatroomname><![CDATA[17990148862@chatroom]]></chatroomname>'
|
||||
'<op><![CDATA[1]]></op>'
|
||||
'<newmsgid><![CDATA[3546361838777087323]]></newmsgid>'
|
||||
'<msgtype><![CDATA[49]]></msgtype>'
|
||||
'<username><![CDATA[wxid_7iazcmpjn90k22]]></username>'
|
||||
'<id><![CDATA[21]]></id>'
|
||||
'<nickname><![CDATA[新青年]]></nickname>'
|
||||
'</mmchatroomtopmsg><chatroominfoversion><![CDATA[68]]></chatroominfoversion></sysmsg>'
|
||||
)
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "新青年置顶了一条消息")
|
||||
|
||||
def test_chatroom_top_message_xml_payload_prefers_resolved_name(self):
|
||||
raw_text = (
|
||||
'<sysmsg type="mmchatroomtopmsg"><mmchatroomtopmsg>'
|
||||
'<chatroomname><![CDATA[17990148862@chatroom]]></chatroomname>'
|
||||
'<op><![CDATA[2]]></op>'
|
||||
'<newmsgid><![CDATA[3546361838777087323]]></newmsgid>'
|
||||
'<msgtype><![CDATA[0]]></msgtype>'
|
||||
'<username><![CDATA[wxid_k7zhjk9xvzsk22]]></username>'
|
||||
'<id><![CDATA[21]]></id>'
|
||||
'<nickname><![CDATA[A]]></nickname>'
|
||||
'</mmchatroomtopmsg><chatroominfoversion><![CDATA[69]]></chatroominfoversion></sysmsg>'
|
||||
)
|
||||
|
||||
def resolve_display_name(username: str, fallback: str) -> str:
|
||||
self.assertEqual(username, "wxid_k7zhjk9xvzsk22")
|
||||
self.assertEqual(fallback, "A")
|
||||
return "周鑫"
|
||||
|
||||
self.assertEqual(
|
||||
_parse_system_message_content(raw_text, resolve_display_name=resolve_display_name),
|
||||
"周鑫移除了一条置顶消息",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool import wcdb_realtime
|
||||
|
||||
|
||||
class TestWcdbRealtimeDllPathSelection(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
wcdb_realtime._WCDB_API_DLL_SELECTED = None
|
||||
|
||||
def tearDown(self) -> None:
|
||||
wcdb_realtime._WCDB_API_DLL_SELECTED = None
|
||||
|
||||
def test_resolve_prefers_project_dll_over_weflow(self) -> None:
|
||||
weflow_dll = ROOT / "WeFlow" / "resources" / "wcdb_api.dll"
|
||||
self.assertTrue(weflow_dll.exists())
|
||||
self.assertTrue(wcdb_realtime._DEFAULT_WCDB_API_DLL.exists())
|
||||
|
||||
with patch.dict(os.environ, {"WECHAT_TOOL_WCDB_API_DLL_PATH": str(weflow_dll)}, clear=False):
|
||||
resolved = wcdb_realtime._resolve_wcdb_api_dll_path()
|
||||
|
||||
self.assertEqual(
|
||||
resolved.resolve(),
|
||||
wcdb_realtime._DEFAULT_WCDB_API_DLL.resolve(),
|
||||
)
|
||||
|
||||
def test_resolve_accepts_project_packaged_override(self) -> None:
|
||||
packaged_dll = ROOT / "desktop" / "resources" / "backend" / "native" / "wcdb_api.dll"
|
||||
self.assertTrue(packaged_dll.exists())
|
||||
|
||||
with patch.dict(os.environ, {"WECHAT_TOOL_WCDB_API_DLL_PATH": str(packaged_dll)}, clear=False):
|
||||
resolved = wcdb_realtime._resolve_wcdb_api_dll_path()
|
||||
|
||||
self.assertEqual(resolved.resolve(), packaged_dll.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user