mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
18 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" />
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"version": "1.3.0",
|
||||
"main": "src/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -s first \"cd ..\\\\frontend && npm run dev\" \"cross-env ELECTRON_START_URL=http://localhost:3000 electron .\"",
|
||||
"dev": "node scripts/dev.cjs",
|
||||
"dev:static": "pushd ..\\\\frontend && npm run generate && popd && cross-env ELECTRON_START_URL=http://127.0.0.1:10392 electron .",
|
||||
"build:ui": "pushd ..\\\\frontend && npm run generate && popd && node scripts\\\\copy-ui.cjs",
|
||||
"build:backend": "uv sync --extra build && node scripts/build-backend.cjs",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
const { spawn, spawnSync } = require("child_process");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..", "..");
|
||||
const frontendDir = path.join(repoRoot, "frontend");
|
||||
const desktopDir = path.join(repoRoot, "desktop");
|
||||
|
||||
function parsePort(value) {
|
||||
const n = Number.parseInt(String(value || "").trim(), 10);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null;
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
process.stdout.write(`[dev] ${message}\n`);
|
||||
}
|
||||
|
||||
function prefixPipe(stream, prefix) {
|
||||
if (!stream) return;
|
||||
let pending = "";
|
||||
stream.setEncoding("utf8");
|
||||
stream.on("data", (chunk) => {
|
||||
pending += chunk;
|
||||
const lines = pending.split(/\r?\n/);
|
||||
pending = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
process.stdout.write(`${prefix} ${line}\n`);
|
||||
}
|
||||
});
|
||||
stream.on("end", () => {
|
||||
const tail = pending.trim();
|
||||
if (tail) process.stdout.write(`${prefix} ${tail}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
function isPortAvailable(port, host) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
const done = (ok) => {
|
||||
try {
|
||||
server.close();
|
||||
} catch {}
|
||||
resolve(ok);
|
||||
};
|
||||
server.once("error", () => done(false));
|
||||
server.once("listening", () => done(true));
|
||||
server.listen(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
async function choosePort({ label, envName, preferredPort, host, searchLimit = 20 }) {
|
||||
if (preferredPort != null) {
|
||||
const ok = await isPortAvailable(preferredPort, host);
|
||||
if (!ok) throw new Error(`${label}端口 ${preferredPort} 已被占用,请修改环境变量 ${envName}`);
|
||||
return preferredPort;
|
||||
}
|
||||
|
||||
const startPort = envName === "NUXT_PORT" ? 3000 : 10392;
|
||||
for (let port = startPort; port <= startPort + searchLimit; port += 1) {
|
||||
if (await isPortAvailable(port, host)) return port;
|
||||
}
|
||||
throw new Error(`未找到可用的${label}端口(起始 ${startPort})`);
|
||||
}
|
||||
|
||||
function httpReady(url) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(url, (res) => {
|
||||
res.resume();
|
||||
resolve(true);
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
req.setTimeout(1000, () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForUrl(url, child, timeoutMs) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (child.exitCode != null) {
|
||||
throw new Error(`前端进程提前退出,exitCode=${child.exitCode}`);
|
||||
}
|
||||
if (await httpReady(url)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
throw new Error(`等待前端启动超时:${url}`);
|
||||
}
|
||||
|
||||
function killChild(child) {
|
||||
if (!child || child.killed || child.exitCode != null) return;
|
||||
if (process.platform === "win32") {
|
||||
spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function spawnLogged(command, args, options, prefix) {
|
||||
const child = spawn(command, args, {
|
||||
...options,
|
||||
shell: process.platform === "win32",
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
});
|
||||
prefixPipe(child.stdout, `${prefix}`);
|
||||
prefixPipe(child.stderr, `${prefix}`);
|
||||
return child;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const frontendHost = String(process.env.NUXT_HOST || "127.0.0.1").trim() || "127.0.0.1";
|
||||
const requestedFrontendPort = parsePort(process.env.NUXT_PORT);
|
||||
const requestedBackendPort = parsePort(process.env.WECHAT_TOOL_PORT);
|
||||
const frontendPort = await choosePort({
|
||||
label: "前端",
|
||||
envName: "NUXT_PORT",
|
||||
preferredPort: requestedFrontendPort,
|
||||
host: frontendHost,
|
||||
});
|
||||
const backendPort = await choosePort({
|
||||
label: "后端",
|
||||
envName: "WECHAT_TOOL_PORT",
|
||||
preferredPort: requestedBackendPort,
|
||||
host: "127.0.0.1",
|
||||
});
|
||||
const startUrl = `http://${frontendHost}:${frontendPort}`;
|
||||
|
||||
log(`frontend=${startUrl}`);
|
||||
log(`backend=http://127.0.0.1:${backendPort}/api`);
|
||||
|
||||
const sharedEnv = {
|
||||
...process.env,
|
||||
NUXT_HOST: frontendHost,
|
||||
NUXT_PORT: String(frontendPort),
|
||||
WECHAT_TOOL_PORT: String(backendPort),
|
||||
ELECTRON_START_URL: startUrl,
|
||||
};
|
||||
|
||||
const npmCommand = "npm";
|
||||
const electronCommand = "electron";
|
||||
const children = new Set();
|
||||
let shuttingDown = false;
|
||||
|
||||
const shutdown = (exitCode) => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
for (const child of children) killChild(child);
|
||||
process.exitCode = exitCode;
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => shutdown(130));
|
||||
process.on("SIGTERM", () => shutdown(143));
|
||||
|
||||
const frontend = spawnLogged(npmCommand, ["run", "dev"], { cwd: frontendDir, env: sharedEnv }, "[frontend]");
|
||||
children.add(frontend);
|
||||
frontend.once("exit", (code, signal) => {
|
||||
log(`frontend exited code=${code} signal=${signal}`);
|
||||
shutdown(code == null ? 1 : code);
|
||||
});
|
||||
|
||||
await waitForUrl(startUrl, frontend, 60_000);
|
||||
log("frontend is ready, starting Electron");
|
||||
|
||||
const electron = spawnLogged(electronCommand, ["."], { cwd: desktopDir, env: sharedEnv }, "[electron]");
|
||||
children.add(electron);
|
||||
electron.once("exit", (code, signal) => {
|
||||
log(`electron exited code=${code} signal=${signal}`);
|
||||
shutdown(code == null ? 0 : code);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`[dev] ${err?.stack || err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
+117
-1
@@ -83,6 +83,11 @@ function getBackendAccessHost() {
|
||||
}
|
||||
|
||||
function getBackendPort() {
|
||||
const envPort = parsePort(process.env.WECHAT_TOOL_PORT);
|
||||
if (envPort != null) return envPort;
|
||||
// In dev we intentionally ignore persisted packaged-app settings so the
|
||||
// launcher can keep Electron, Nuxt devProxy and the backend child aligned.
|
||||
if (!app.isPackaged) return DEFAULT_BACKEND_PORT;
|
||||
const settingsPort = parsePort(loadDesktopSettings()?.backendPort);
|
||||
return settingsPort ?? DEFAULT_BACKEND_PORT;
|
||||
}
|
||||
@@ -1354,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;
|
||||
|
||||
@@ -1375,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,
|
||||
@@ -1418,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));
|
||||
}
|
||||
@@ -1497,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);
|
||||
@@ -1722,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),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -289,9 +289,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
import { readApiBaseOverride, writeApiBaseOverride } from '~/utils/api-settings'
|
||||
import { reportServerErrorFromError } from '~/utils/server-error-logging'
|
||||
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/lib/desktop-settings'
|
||||
import { readApiBaseOverride, writeApiBaseOverride } from '~/lib/api-settings'
|
||||
import { invalidateApiBaseCache } from '~/composables/useApiBase'
|
||||
import { reportServerErrorFromError } from '~/lib/server-error-logging'
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
@@ -624,6 +625,7 @@ const applyDesktopBackendPort = async () => {
|
||||
const host = String(window.location?.hostname || '').trim() || '127.0.0.1'
|
||||
const nextOrigin = `${protocol}//${host}:${n}`
|
||||
writeApiBaseOverride(`${nextOrigin}/api`)
|
||||
invalidateApiBaseCache()
|
||||
|
||||
const waitForHealth = async (healthUrl, timeoutMs = 30_000) => {
|
||||
const startedAt = Date.now()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<div v-if="selectedContact" class="flex-1 flex flex-col min-h-0 relative">
|
||||
<div class="chat-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-base font-medium text-gray-900" :class="{ 'privacy-blur': privacyMode }">
|
||||
{{ selectedContact ? selectedContact.name : '' }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button class="header-btn-icon" @click="refreshSelectedMessages" :disabled="isLoadingMessages" title="刷新消息">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-btn-icon" @click="openExportModal" :disabled="isExportCreating" title="导出聊天记录">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-btn-icon" :class="{ 'header-btn-icon-active': reverseMessageSides }" @click="toggleReverseMessageSides" :disabled="!selectedContact" :title="reverseMessageSides ? '取消反转消息位置' : '反转消息位置'">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 7h14" />
|
||||
<path d="M14 3l4 4-4 4" />
|
||||
<path d="M20 17H6" />
|
||||
<path d="M10 13l-4 4 4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-btn-icon" :class="{ 'header-btn-icon-active': messageSearchOpen }" @click="toggleMessageSearch" :title="messageSearchOpen ? '关闭搜索 (Esc)' : '搜索聊天记录 (Ctrl+F)'">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 16 16">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 14L11.1 11.1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-btn-icon" :class="{ 'header-btn-icon-active': timeSidebarOpen }" @click="toggleTimeSidebar" :disabled="!selectedContact || isLoadingMessages" title="按日期定位">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M8 7V3m8 4V3M3 11h18" />
|
||||
<rect x="4" y="5" width="16" height="16" rx="2" ry="2" stroke-width="1.8" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M7 14h2m3 0h2m3 0h2M7 18h2m3 0h2" />
|
||||
</svg>
|
||||
</button>
|
||||
<select
|
||||
v-model="messageTypeFilter"
|
||||
class="message-filter-select"
|
||||
:disabled="isLoadingMessages || searchContext.active"
|
||||
:title="searchContext.active ? '上下文模式下暂不可筛选' : '筛选消息类型'"
|
||||
>
|
||||
<option v-for="opt in messageTypeFilterOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="searchContext.active" class="px-6 py-2 border-b border-emerald-200 bg-emerald-50 flex items-center gap-3">
|
||||
<div class="text-sm text-emerald-900">
|
||||
{{ searchContextBannerText }}
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button type="button" class="text-xs px-3 py-1 rounded-md bg-white border border-emerald-200 hover:bg-emerald-100" @click="exitSearchContext">
|
||||
退出定位
|
||||
</button>
|
||||
<button type="button" class="text-xs px-3 py-1 rounded-md bg-white border border-gray-200 hover:bg-gray-50" @click="refreshSelectedMessages">
|
||||
返回最新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageList :state="state" />
|
||||
|
||||
<button
|
||||
v-if="showJumpToBottom"
|
||||
type="button"
|
||||
class="absolute bottom-6 right-6 z-20 w-10 h-10 rounded-full bg-white/90 border border-gray-200 shadow hover:bg-white flex items-center justify-center"
|
||||
title="回到最新"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-20 h-20 mx-auto mb-5 rounded-2xl bg-gradient-to-br from-[#03C160]/10 to-[#03C160]/5 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-[#03C160]/60" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-medium text-gray-700 mb-1.5">选择一个会话</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
从左侧列表选择联系人查看聊天记录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import MessageList from '~/components/chat/MessageList.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ConversationPane',
|
||||
components: { MessageList },
|
||||
props: {
|
||||
state: { type: Object, required: true }
|
||||
},
|
||||
setup(props) {
|
||||
return {
|
||||
...props.state
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<img
|
||||
v-if="iconUrl"
|
||||
:src="iconUrl"
|
||||
alt=""
|
||||
class="wechat-file-icon"
|
||||
/>
|
||||
<svg v-else-if="kind === 'ppt'" viewBox="0 0 24 24" fill="none" class="wechat-file-icon text-orange-500">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
<path d="M14 2v6h6" stroke="currentColor" stroke-width="1.5" />
|
||||
<text x="6" y="17" font-size="5" fill="currentColor" font-weight="bold">PPT</text>
|
||||
</svg>
|
||||
<svg v-else-if="kind === 'txt'" viewBox="0 0 24 24" fill="none" class="wechat-file-icon text-gray-500">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
<path d="M14 2v6h6" stroke="currentColor" stroke-width="1.5" />
|
||||
<text x="6" y="17" font-size="5" fill="currentColor" font-weight="bold">TXT</text>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" class="wechat-file-icon text-gray-400">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h6v6h6v10H6z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { getFileIconKind, getFileIconUrl } from '~/lib/chat/file-icons'
|
||||
|
||||
const props = defineProps({
|
||||
fileName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const kind = computed(() => getFileIconKind(props.fileName))
|
||||
const iconUrl = computed(() => getFileIconUrl(props.fileName))
|
||||
</script>
|
||||
@@ -0,0 +1,287 @@
|
||||
<script>
|
||||
import { defineComponent, h, ref, watch } from 'vue'
|
||||
import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'LinkCard',
|
||||
props: {
|
||||
href: { type: String, default: '' },
|
||||
heading: { type: String, default: '' },
|
||||
abstract: { type: String, default: '' },
|
||||
preview: { type: String, default: '' },
|
||||
fromAvatar: { type: String, default: '' },
|
||||
from: { type: String, default: '' },
|
||||
linkType: { type: String, default: '' },
|
||||
isSent: { type: Boolean, default: false },
|
||||
badge: { type: String, default: '' },
|
||||
variant: { type: String, default: 'default' }
|
||||
},
|
||||
setup(props) {
|
||||
const fromAvatarImgOk = ref(false)
|
||||
const fromAvatarImgError = ref(false)
|
||||
|
||||
watch(
|
||||
() => String(props.fromAvatar || '').trim(),
|
||||
() => {
|
||||
fromAvatarImgOk.value = false
|
||||
fromAvatarImgError.value = false
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const getFromText = () => {
|
||||
const raw = String(props.from || '').trim()
|
||||
if (raw) return raw
|
||||
try {
|
||||
const href = String(props.href || '').trim()
|
||||
if (!/^https?:\/\//i.test(href)) return ''
|
||||
return String(new URL(href).hostname || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
const fromText = getFromText()
|
||||
const href = String(props.href || '').trim()
|
||||
const canNavigate = /^https?:\/\//i.test(href)
|
||||
const badgeText = String(props.badge || '').trim()
|
||||
const fromAvatarText = (() => {
|
||||
const text = String(fromText || '').trim()
|
||||
return text ? (Array.from(text)[0] || '') : ''
|
||||
})()
|
||||
const fromAvatarUrl = String(props.fromAvatar || '').trim()
|
||||
const isMiniProgram = String(props.linkType || '').trim() === 'mini_program'
|
||||
const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
|
||||
const Tag = canNavigate ? 'a' : 'div'
|
||||
|
||||
const showFromAvatarImg = Boolean(fromAvatarUrl) && !fromAvatarImgError.value
|
||||
const showFromAvatarText = (!fromAvatarUrl) || (!fromAvatarImgOk.value)
|
||||
const fromAvatarStyle = fromAvatarImgOk.value
|
||||
? {
|
||||
background: isCoverVariant ? 'rgba(255, 255, 255, 0.92)' : '#fff',
|
||||
color: 'transparent'
|
||||
}
|
||||
: null
|
||||
const miniProgramAvatarStyle = fromAvatarImgOk.value
|
||||
? {
|
||||
background: '#fff',
|
||||
color: 'transparent'
|
||||
}
|
||||
: null
|
||||
const onFromAvatarLoad = () => {
|
||||
fromAvatarImgOk.value = true
|
||||
fromAvatarImgError.value = false
|
||||
}
|
||||
const onFromAvatarError = () => {
|
||||
fromAvatarImgOk.value = false
|
||||
fromAvatarImgError.value = true
|
||||
}
|
||||
|
||||
if (isCoverVariant) {
|
||||
const fromRow = h('div', { class: 'wechat-link-cover-from' }, [
|
||||
h('div', { class: 'wechat-link-cover-from-avatar', style: fromAvatarStyle, 'aria-hidden': 'true' }, [
|
||||
showFromAvatarText ? (fromAvatarText || '\u200B') : null,
|
||||
showFromAvatarImg
|
||||
? h('img', {
|
||||
src: fromAvatarUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-cover-from-avatar-img',
|
||||
referrerpolicy: 'no-referrer',
|
||||
onLoad: onFromAvatarLoad,
|
||||
onError: onFromAvatarError
|
||||
})
|
||||
: null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-cover-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
|
||||
badgeText ? h('div', { class: 'wechat-link-cover-badge' }, badgeText) : null
|
||||
].filter(Boolean))
|
||||
|
||||
return h(
|
||||
Tag,
|
||||
{
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card-cover',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
].filter(Boolean).join(' '),
|
||||
style: {
|
||||
width: '137px',
|
||||
minWidth: '137px',
|
||||
maxWidth: '137px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
outline: 'none'
|
||||
}
|
||||
},
|
||||
[
|
||||
props.preview
|
||||
? h('div', { class: 'wechat-link-cover-image-wrap' }, [
|
||||
h('img', {
|
||||
src: props.preview,
|
||||
alt: props.heading || '链接封面',
|
||||
class: 'wechat-link-cover-image',
|
||||
referrerpolicy: 'no-referrer'
|
||||
}),
|
||||
fromRow
|
||||
])
|
||||
: fromRow,
|
||||
h('div', { class: 'wechat-link-cover-title' }, props.heading || href)
|
||||
].filter(Boolean)
|
||||
)
|
||||
}
|
||||
|
||||
const headingText = String(props.heading || href || '').trim()
|
||||
let abstractText = String(props.abstract || '').trim()
|
||||
if (abstractText && headingText && abstractText === headingText) abstractText = ''
|
||||
|
||||
if (isMiniProgram) {
|
||||
return h(
|
||||
Tag,
|
||||
{
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card',
|
||||
'wechat-link-card--mini-program',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
].filter(Boolean).join(' '),
|
||||
style: {
|
||||
width: '210px',
|
||||
minWidth: '210px',
|
||||
maxWidth: '210px',
|
||||
maxHeight: '270px',
|
||||
height: '270px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
outline: 'none'
|
||||
}
|
||||
},
|
||||
[
|
||||
h('div', { class: 'wechat-link-mini-body' }, [
|
||||
h('div', { class: 'wechat-link-mini-header' }, [
|
||||
h('div', { class: 'wechat-link-mini-header-avatar', style: miniProgramAvatarStyle, 'aria-hidden': 'true' }, [
|
||||
showFromAvatarText ? (fromAvatarText || '\u200B') : null,
|
||||
showFromAvatarImg
|
||||
? h('img', {
|
||||
src: fromAvatarUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-mini-header-avatar-img',
|
||||
referrerpolicy: 'no-referrer',
|
||||
onLoad: onFromAvatarLoad,
|
||||
onError: onFromAvatarError
|
||||
})
|
||||
: null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-mini-header-name' }, fromText || '\u200B')
|
||||
]),
|
||||
h('div', { class: 'wechat-link-mini-title' }, headingText || abstractText || href),
|
||||
h('div', { class: ['wechat-link-mini-preview', !props.preview ? 'wechat-link-mini-preview--empty' : ''].filter(Boolean).join(' ') }, [
|
||||
props.preview
|
||||
? h('img', {
|
||||
src: props.preview,
|
||||
alt: props.heading || '小程序预览',
|
||||
class: 'wechat-link-mini-preview-img',
|
||||
referrerpolicy: 'no-referrer'
|
||||
})
|
||||
: null
|
||||
].filter(Boolean))
|
||||
]),
|
||||
h('div', { class: 'wechat-link-mini-footer' }, [
|
||||
h('img', {
|
||||
src: miniProgramIconUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-mini-footer-icon',
|
||||
'aria-hidden': 'true'
|
||||
}),
|
||||
h('span', { class: 'wechat-link-mini-footer-text' }, '小程序')
|
||||
])
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
return h(
|
||||
Tag,
|
||||
{
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
].filter(Boolean).join(' '),
|
||||
style: {
|
||||
width: '210px',
|
||||
minWidth: '210px',
|
||||
maxWidth: '210px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
outline: 'none'
|
||||
}
|
||||
},
|
||||
[
|
||||
h('div', { class: 'wechat-link-content' }, [
|
||||
h('div', { class: 'wechat-link-title' }, headingText || href),
|
||||
(abstractText || props.preview)
|
||||
? h('div', { class: 'wechat-link-summary' }, [
|
||||
abstractText ? h('div', { class: 'wechat-link-desc' }, abstractText) : null,
|
||||
props.preview
|
||||
? h('div', { class: 'wechat-link-thumb' }, [
|
||||
h('img', {
|
||||
src: props.preview,
|
||||
alt: props.heading || '链接预览',
|
||||
class: 'wechat-link-thumb-img',
|
||||
referrerpolicy: 'no-referrer'
|
||||
})
|
||||
])
|
||||
: null
|
||||
].filter(Boolean))
|
||||
: null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-from' }, [
|
||||
h('div', { class: 'wechat-link-from-avatar', style: fromAvatarStyle, 'aria-hidden': 'true' }, [
|
||||
showFromAvatarText ? (fromAvatarText || '\u200B') : null,
|
||||
showFromAvatarImg
|
||||
? h('img', {
|
||||
src: fromAvatarUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-from-avatar-img',
|
||||
referrerpolicy: 'no-referrer',
|
||||
onLoad: onFromAvatarLoad,
|
||||
onError: onFromAvatarError
|
||||
})
|
||||
: null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
|
||||
badgeText ? h('div', { class: 'wechat-link-badge' }, badgeText) : null
|
||||
].filter(Boolean))
|
||||
].filter(Boolean)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<LinkCard
|
||||
v-if="message.renderType === 'link'"
|
||||
:href="message.url"
|
||||
:heading="message.title || message.content"
|
||||
:abstract="message.content"
|
||||
:preview="message.preview"
|
||||
:fromAvatar="message.fromAvatar"
|
||||
:from="message.from"
|
||||
:linkType="message.linkType"
|
||||
:isSent="message.isSent"
|
||||
:variant="message.linkCardVariant || 'default'"
|
||||
/>
|
||||
<div v-else-if="message.renderType === 'file'"
|
||||
class="wechat-redpacket-card wechat-special-card wechat-file-card msg-radius"
|
||||
:class="message.isSent ? 'wechat-special-sent-side' : ''"
|
||||
@click="onFileClick(message)"
|
||||
@contextmenu="openMediaContextMenu($event, message, 'file')">
|
||||
<div class="wechat-redpacket-content">
|
||||
<div class="wechat-redpacket-info wechat-file-info">
|
||||
<span class="wechat-file-name">{{ message.title || message.content || '文件' }}</span>
|
||||
<span class="wechat-file-size" v-if="message.fileSize">{{ formatFileSize(message.fileSize) }}</span>
|
||||
</div>
|
||||
<FileTypeIcon :file-name="message.title" />
|
||||
</div>
|
||||
<div class="wechat-redpacket-bottom wechat-file-bottom">
|
||||
<img :src="wechatPcLogoUrl" alt="" class="wechat-file-logo" />
|
||||
<span>微信电脑版</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'image'"
|
||||
class="max-w-sm">
|
||||
<div class="msg-radius overflow-hidden cursor-pointer" :class="message.isSent ? '' : ''" @click="message.imageUrl && openImagePreview(message.imageUrl)" @contextmenu="openMediaContextMenu($event, message, 'image')">
|
||||
<img v-if="message.imageUrl" :src="message.imageUrl" alt="图片" class="max-w-[240px] max-h-[240px] object-cover hover:opacity-90 transition-opacity">
|
||||
<div v-else class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'video'" class="max-w-sm">
|
||||
<div class="msg-radius overflow-hidden relative bg-black/5" @contextmenu="openMediaContextMenu($event, message, 'video')">
|
||||
<img v-if="message.videoThumbUrl" :src="message.videoThumbUrl" alt="视频" class="block w-[220px] max-w-[260px] h-auto max-h-[260px] object-cover">
|
||||
<div v-else class="px-3 py-2 text-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
<button
|
||||
v-if="message.videoThumbUrl && message.videoUrl"
|
||||
type="button"
|
||||
class="absolute inset-0 flex items-center justify-center"
|
||||
@click.stop="openVideoPreview(message.videoUrl, message.videoThumbUrl)"
|
||||
>
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
</button>
|
||||
<div class="absolute inset-0 flex items-center justify-center" v-else-if="message.videoThumbUrl">
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'voice'"
|
||||
class="wechat-voice-wrapper"
|
||||
@contextmenu="openMediaContextMenu($event, message, 'voice')">
|
||||
<div
|
||||
class="wechat-voice-bubble msg-radius"
|
||||
:class="message.isSent ? 'wechat-voice-sent' : 'wechat-voice-received'"
|
||||
:style="{ width: getVoiceWidth(message.voiceDuration) }"
|
||||
@click="message.voiceUrl && playVoice(message)"
|
||||
>
|
||||
<div class="wechat-voice-content" :class="message.isSent ? 'flex-row-reverse' : ''">
|
||||
<svg class="wechat-voice-icon" :class="[message.isSent ? 'voice-icon-sent' : 'voice-icon-received', { 'voice-playing': playingVoiceId === message.id }]" viewBox="0 0 32 32" fill="currentColor">
|
||||
<path d="M10.24 11.616l-4.224 4.192 4.224 4.192c1.088-1.056 1.76-2.56 1.76-4.192s-0.672-3.136-1.76-4.192z"></path>
|
||||
<path class="voice-wave-2" d="M15.199 6.721l-1.791 1.76c1.856 1.888 3.008 4.48 3.008 7.328s-1.152 5.44-3.008 7.328l1.791 1.76c2.336-2.304 3.809-5.536 3.809-9.088s-1.473-6.784-3.809-9.088z"></path>
|
||||
<path class="voice-wave-3" d="M20.129 1.793l-1.762 1.76c3.104 3.168 5.025 7.488 5.025 12.256s-1.921 9.088-5.025 12.256l1.762 1.76c3.648-3.616 5.887-8.544 5.887-14.016s-2.239-10.432-5.887-14.016z"></path>
|
||||
</svg>
|
||||
<span class="wechat-voice-duration">{{ getVoiceDurationInSeconds(message.voiceDuration) }}"</span>
|
||||
</div>
|
||||
<span v-if="!message.voiceRead && !message.isSent" class="wechat-voice-unread"></span>
|
||||
</div>
|
||||
<audio
|
||||
v-if="message.voiceUrl"
|
||||
:ref="el => setVoiceRef(message.id, el)"
|
||||
:src="message.voiceUrl"
|
||||
preload="none"
|
||||
class="hidden"
|
||||
></audio>
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'voip'"
|
||||
class="wechat-voip-bubble msg-radius"
|
||||
:class="message.isSent ? 'wechat-voip-sent' : 'wechat-voip-received'">
|
||||
<div class="wechat-voip-content" :class="message.isSent ? 'flex-row-reverse' : ''">
|
||||
<img v-if="message.voipType === 'video'" src="/assets/images/wechat/wechat-video-light.png" class="wechat-voip-icon" alt="">
|
||||
<img v-else src="/assets/images/wechat/wechat-audio-light.png" class="wechat-voip-icon" alt="">
|
||||
<span class="wechat-voip-text">{{ message.content || '通话' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'emoji'" class="max-w-sm flex items-center group" :class="message.isSent ? 'flex-row-reverse' : ''">
|
||||
<template v-if="message.emojiUrl">
|
||||
<img :src="message.emojiUrl" alt="表情" class="w-24 h-24 object-contain" @contextmenu="openMediaContextMenu($event, message, 'emoji')">
|
||||
<button
|
||||
v-if="shouldShowEmojiDownload(message)"
|
||||
class="text-xs px-2 py-1 rounded bg-white border border-gray-200 text-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:class="message.isSent ? 'mr-2' : 'ml-2'"
|
||||
:disabled="!!message._emojiDownloading"
|
||||
@click.stop="onEmojiDownloadClick(message)"
|
||||
>
|
||||
{{ message._emojiDownloading ? '下载中...' : (message._emojiDownloaded ? '已下载' : '下载') }}
|
||||
</button>
|
||||
</template>
|
||||
<div v-else class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
<template v-else-if="message.renderType === 'quote'">
|
||||
<div
|
||||
class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
<span v-for="(seg, idx) in parseTextWithEmoji(message.content)" :key="idx">
|
||||
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
|
||||
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px">
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.quoteTitle || message.quoteContent"
|
||||
class="mt-[5px] px-2 text-xs text-neutral-600 rounded max-w-[404px] max-h-[65px] overflow-hidden flex items-start bg-[#e1e1e1]">
|
||||
<div class="py-2 min-w-0 flex-1">
|
||||
<div v-if="isQuotedVoice(message)" class="flex items-center gap-1 min-w-0">
|
||||
<span v-if="message.quoteTitle" class="truncate flex-shrink-0">{{ message.quoteTitle }}:</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 min-w-0 hover:opacity-80"
|
||||
:disabled="!message.quoteVoiceUrl"
|
||||
:class="!message.quoteVoiceUrl ? 'opacity-60 cursor-not-allowed' : ''"
|
||||
@click.stop="message.quoteVoiceUrl && playQuoteVoice(message)"
|
||||
>
|
||||
<svg
|
||||
class="wechat-voice-icon wechat-quote-voice-icon"
|
||||
:class="{ 'voice-playing': playingVoiceId === getQuoteVoiceId(message) }"
|
||||
viewBox="0 0 32 32"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M10.24 11.616l-4.224 4.192 4.224 4.192c1.088-1.056 1.76-2.56 1.76-4.192s-0.672-3.136-1.76-4.192z"></path>
|
||||
<path class="voice-wave-2" d="M15.199 6.721l-1.791 1.76c1.856 1.888 3.008 4.48 3.008 7.328s-1.152 5.44-3.008 7.328l1.791 1.76c2.336-2.304 3.809-5.536 3.809-9.088s-1.473-6.784-3.809-9.088z"></path>
|
||||
<path class="voice-wave-3" d="M20.129 1.793l-1.762 1.76c3.104 3.168 5.025 7.488 5.025 12.256s-1.921 9.088-5.025 12.256l1.762 1.76c3.648-3.616 5.887-8.544 5.887-14.016s-2.239-10.432-5.887-14.016z"></path>
|
||||
</svg>
|
||||
<span v-if="getVoiceDurationInSeconds(message.quoteVoiceLength) > 0" class="flex-shrink-0">{{ getVoiceDurationInSeconds(message.quoteVoiceLength) }}"</span>
|
||||
<span v-else class="flex-shrink-0">语音</span>
|
||||
</button>
|
||||
<audio
|
||||
v-if="message.quoteVoiceUrl"
|
||||
:ref="el => setVoiceRef(getQuoteVoiceId(message), el)"
|
||||
:src="message.quoteVoiceUrl"
|
||||
preload="none"
|
||||
class="hidden"
|
||||
></audio>
|
||||
</div>
|
||||
<div v-else class="min-w-0 flex items-start">
|
||||
<template v-if="isQuotedLink(message)">
|
||||
<div class="line-clamp-2 min-w-0 flex-1">
|
||||
<span v-if="message.quoteTitle">{{ message.quoteTitle }}:</span>
|
||||
<span
|
||||
v-if="getQuotedLinkText(message)"
|
||||
:class="message.quoteTitle ? 'ml-1' : ''"
|
||||
>
|
||||
🔗 {{ getQuotedLinkText(message) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="line-clamp-2 min-w-0 flex-1">
|
||||
<span v-if="message.quoteTitle">{{ message.quoteTitle }}:</span>
|
||||
<span
|
||||
v-if="message.quoteContent && !(isQuotedImage(message) && message.quoteTitle && message.quoteImageUrl && !message._quoteImageError)"
|
||||
:class="message.quoteTitle ? 'ml-1' : ''"
|
||||
>
|
||||
{{ message.quoteContent }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isQuotedLink(message) && message.quoteThumbUrl && !message._quoteThumbError"
|
||||
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
|
||||
@click.stop="openImagePreview(message.quoteThumbUrl)"
|
||||
>
|
||||
<img
|
||||
:src="message.quoteThumbUrl"
|
||||
alt="引用链接缩略图"
|
||||
class="max-h-[49px] w-auto max-w-[98px] object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="onQuoteThumbError(message)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isQuotedLink(message) && isQuotedImage(message) && message.quoteImageUrl && !message._quoteImageError"
|
||||
class="ml-2 my-2 flex-shrink-0 max-w-[98px] max-h-[49px] overflow-hidden flex items-center justify-center cursor-pointer"
|
||||
@click.stop="openImagePreview(message.quoteImageUrl)"
|
||||
>
|
||||
<img
|
||||
:src="message.quoteImageUrl"
|
||||
alt="引用图片"
|
||||
class="max-h-[49px] w-auto max-w-[98px] object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="onQuoteImageError(message)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 合并转发聊天记录(Chat History) -->
|
||||
<div
|
||||
v-else-if="message.renderType === 'chatHistory'"
|
||||
class="wechat-chat-history-card wechat-special-card msg-radius"
|
||||
:class="message.isSent ? 'wechat-special-sent-side' : ''"
|
||||
@click.stop="openChatHistoryModal(message)"
|
||||
>
|
||||
<div class="wechat-chat-history-body">
|
||||
<div class="wechat-chat-history-title">{{ message.title || '聊天记录' }}</div>
|
||||
<div class="wechat-chat-history-preview" v-if="getChatHistoryPreviewLines(message).length">
|
||||
<div
|
||||
v-for="(line, idx) in getChatHistoryPreviewLines(message)"
|
||||
:key="idx"
|
||||
class="wechat-chat-history-line"
|
||||
>
|
||||
{{ line }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-chat-history-bottom">
|
||||
<span>聊天记录</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'transfer'"
|
||||
class="wechat-transfer-card msg-radius"
|
||||
:class="[{ 'wechat-transfer-received': message.transferReceived, 'wechat-transfer-returned': isTransferReturned(message), 'wechat-transfer-overdue': isTransferOverdue(message) }, message.isSent ? 'wechat-transfer-sent-side' : 'wechat-transfer-received-side']">
|
||||
<div class="wechat-transfer-content">
|
||||
<img src="/assets/images/wechat/wechat-returned.png" v-if="isTransferReturned(message)" class="wechat-transfer-icon" alt="">
|
||||
<img src="/assets/images/wechat/overdue.png" v-else-if="isTransferOverdue(message)" class="wechat-transfer-icon" alt="">
|
||||
<img src="/assets/images/wechat/wechat-trans-icon2.png" v-else-if="message.transferReceived" class="wechat-transfer-icon" alt="">
|
||||
<img src="/assets/images/wechat/wechat-trans-icon1.png" v-else class="wechat-transfer-icon" alt="">
|
||||
<div class="wechat-transfer-info">
|
||||
<span class="wechat-transfer-amount" v-if="message.amount">¥{{ formatTransferAmount(message.amount) }}</span>
|
||||
<span class="wechat-transfer-status">{{ getTransferTitle(message) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-transfer-bottom">
|
||||
<span>微信转账</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 红包消息 - 微信风格橙色卡片 -->
|
||||
<div v-else-if="message.renderType === 'redPacket'" class="wechat-redpacket-card wechat-special-card msg-radius"
|
||||
:class="[{ 'wechat-redpacket-received': message.redPacketReceived }, message.isSent ? 'wechat-special-sent-side' : '']">
|
||||
<div class="wechat-redpacket-content">
|
||||
<img src="/assets/images/wechat/wechat-trans-icon3.png" v-if="!message.redPacketReceived" class="wechat-redpacket-icon" alt="">
|
||||
<img src="/assets/images/wechat/wechat-trans-icon4.png" v-else class="wechat-redpacket-icon" alt="">
|
||||
<div class="wechat-redpacket-info">
|
||||
<span class="wechat-redpacket-text">{{ getRedPacketText(message) }}</span>
|
||||
<span class="wechat-redpacket-status" v-if="message.redPacketReceived">已领取</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wechat-redpacket-bottom">
|
||||
<span>微信红包</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.renderType === 'location'" class="max-w-sm">
|
||||
<ChatLocationCard :message="message" />
|
||||
</div>
|
||||
<!-- 文本消息 -->
|
||||
<div v-else-if="message.renderType === 'text'"
|
||||
class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed"
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
<span v-for="(seg, idx) in parseTextWithEmoji(message.content)" :key="idx">
|
||||
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
|
||||
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px">
|
||||
</span>
|
||||
</div>
|
||||
<!-- 表情消息 -->
|
||||
<!-- 其他类型统一降级为普通文本展示 -->
|
||||
<div v-else
|
||||
class="px-3 py-2 text-xs max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed text-gray-700"
|
||||
:class="message.isSent ? 'bg-[#95EC69] text-black bubble-tail-r' : 'bg-white text-gray-800 bubble-tail-l'">
|
||||
{{ message.content || ('[' + (message.type || 'unknown') + '] 消息组件已移除') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import wechatPcLogoUrl from '~/assets/images/wechat/WeChat-Icon-Logo.wine.svg'
|
||||
import ChatLocationCard from '~/components/ChatLocationCard.vue'
|
||||
import FileTypeIcon from '~/components/chat/FileTypeIcon.vue'
|
||||
import LinkCard from '~/components/chat/LinkCard.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MessageContent',
|
||||
components: { ChatLocationCard, FileTypeIcon, LinkCard },
|
||||
props: {
|
||||
state: { type: Object, required: true },
|
||||
message: { type: Object, required: true }
|
||||
},
|
||||
setup(props) {
|
||||
return {
|
||||
...props.state,
|
||||
message: props.message,
|
||||
wechatPcLogoUrl
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div
|
||||
class="mb-6"
|
||||
:class="[
|
||||
(highlightServerIdStr && message.serverIdStr && highlightServerIdStr === message.serverIdStr) ? 'message-locate-highlight' : '',
|
||||
(highlightMessageId === message.id) ? 'bg-emerald-100/50 rounded-md px-2 py-1 -mx-2' : ''
|
||||
]"
|
||||
:data-server-id="message.serverIdStr || ''"
|
||||
:data-msg-id="message.id"
|
||||
:data-create-time="message.createTime"
|
||||
>
|
||||
<div v-if="message.showTimeDivider" class="flex justify-center mb-4">
|
||||
<div class="px-3 py-1 text-xs text-[#9e9e9e]">
|
||||
{{ message.timeDivider }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="message.renderType === 'system'" class="flex justify-center">
|
||||
<div class="px-3 py-1 text-xs text-[#9e9e9e]">
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex items-center" :class="message.isSent ? 'justify-end' : 'justify-start'">
|
||||
<div class="flex items-start max-w-md" :class="message.isSent ? 'flex-row-reverse' : ''">
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="onMessageAvatarMouseEnter(message)"
|
||||
@mouseleave="onMessageAvatarMouseLeave"
|
||||
>
|
||||
<div class="w-[calc(42px/var(--dpr))] h-[calc(42px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="[message.isSent ? 'ml-3' : 'mr-3', { 'privacy-blur': privacyMode }]">
|
||||
<div v-if="message.avatar" class="w-full h-full">
|
||||
<img
|
||||
:src="message.avatar"
|
||||
:alt="message.sender + '的头像'"
|
||||
class="w-full h-full object-cover"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="onAvatarError($event, message)"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
|
||||
:style="{ backgroundColor: message.avatarColor || (message.isSent ? '#4B5563' : '#6B7280') }"
|
||||
>
|
||||
{{ message.sender.charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="contactProfileCardOpen && contactProfileCardMessageId === String(message.id ?? '')"
|
||||
class="absolute z-40 w-[360px] max-w-[88vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden"
|
||||
:class="message.isSent ? 'right-0 top-[calc(100%+8px)]' : 'left-0 top-[calc(100%+8px)]'"
|
||||
@mouseenter="onContactCardMouseEnter"
|
||||
@mouseleave="onMessageAvatarMouseLeave"
|
||||
>
|
||||
<div class="px-3 py-2 border-b border-gray-200 text-sm font-medium text-gray-900">联系人资料</div>
|
||||
<div class="p-3 space-y-3 bg-[#F6F6F6]">
|
||||
<div v-if="contactProfileLoading" class="text-sm text-gray-500 text-center py-4">资料加载中...</div>
|
||||
<div v-else-if="contactProfileError" class="text-sm text-red-500 whitespace-pre-wrap">{{ contactProfileError }}</div>
|
||||
<div v-else class="bg-white rounded-md border border-gray-100 overflow-hidden">
|
||||
<div class="p-3 flex items-center gap-3 border-b border-gray-100">
|
||||
<div class="w-12 h-12 rounded-md overflow-hidden bg-gray-200 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<img v-if="contactProfileResolvedAvatar" :src="contactProfileResolvedAvatar" alt="头像" class="w-full h-full object-cover" referrerpolicy="no-referrer" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-white text-sm font-bold" style="background-color:#4B5563">{{ contactProfileResolvedName.charAt(0) || '?' }}</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div class="text-sm text-gray-900 truncate">{{ contactProfileResolvedName || '未知联系人' }}</div>
|
||||
<div class="text-xs text-gray-500 truncate">{{ contactProfileResolvedUsername }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm">
|
||||
<div class="px-3 py-2.5 flex items-start gap-3 border-b border-gray-100">
|
||||
<div class="w-12 text-gray-500 shrink-0">昵称</div>
|
||||
<div class="text-gray-900 break-all" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedNickname || '-' }}</div>
|
||||
</div>
|
||||
<div class="px-3 py-2.5 flex items-start gap-3 border-b border-gray-100">
|
||||
<div class="w-12 text-gray-500 shrink-0">微信号</div>
|
||||
<div class="text-gray-900 break-all" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedAlias || '-' }}</div>
|
||||
</div>
|
||||
<div class="px-3 py-2.5 flex items-start gap-3 border-b border-gray-100">
|
||||
<div class="w-12 text-gray-500 shrink-0">性别</div>
|
||||
<div class="text-gray-900 break-all" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedGender || '-' }}</div>
|
||||
</div>
|
||||
<div class="px-3 py-2.5 flex items-start gap-3 border-b border-gray-100">
|
||||
<div class="w-12 text-gray-500 shrink-0">地区</div>
|
||||
<div class="text-gray-900 break-all" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedRegion || '-' }}</div>
|
||||
</div>
|
||||
<div class="px-3 py-2.5 flex items-start gap-3 border-b border-gray-100">
|
||||
<div class="w-12 text-gray-500 shrink-0">备注</div>
|
||||
<div class="text-gray-900 break-all" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedRemark || '-' }}</div>
|
||||
</div>
|
||||
<div class="px-3 py-2.5 flex items-start gap-3 border-b border-gray-100">
|
||||
<div class="w-12 text-gray-500 shrink-0">签名</div>
|
||||
<div class="text-gray-900 whitespace-pre-wrap break-words" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedSignature || '-' }}</div>
|
||||
</div>
|
||||
<div class="px-3 py-2.5 flex items-start gap-3" :title="contactProfileResolvedSourceScene != null ? `来源场景码:${contactProfileResolvedSourceScene}` : ''">
|
||||
<div class="w-12 text-gray-500 shrink-0">来源</div>
|
||||
<div class="text-gray-900 break-all" :class="{ 'privacy-blur': privacyMode }">{{ contactProfileResolvedSource || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col relative group"
|
||||
:class="[message.isSent ? 'items-end' : 'items-start', { 'privacy-blur': privacyMode }]"
|
||||
@contextmenu="openMediaContextMenu($event, message, 'message')"
|
||||
>
|
||||
<div v-if="message.isGroup && !message.isSent && message.senderDisplayName" class="text-[11px] text-gray-500 mb-1" :class="message.isSent ? 'text-right' : 'text-left'">
|
||||
{{ message.senderDisplayName }}
|
||||
</div>
|
||||
<div
|
||||
class="absolute -top-6 z-10 rounded bg-black/70 text-white text-[10px] px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap"
|
||||
:class="message.isSent ? 'right-0' : 'left-0'"
|
||||
>
|
||||
{{ message.fullTime }}
|
||||
</div>
|
||||
|
||||
<MessageContent :message="message" :state="state" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import MessageContent from '~/components/chat/MessageContent.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MessageItem',
|
||||
components: { MessageContent },
|
||||
props: {
|
||||
state: { type: Object, required: true },
|
||||
message: { type: Object, required: true }
|
||||
},
|
||||
setup(props) {
|
||||
return {
|
||||
...props.state,
|
||||
message: props.message
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div ref="messageContainerRef" class="flex-1 overflow-y-auto p-4 min-h-0" @scroll="onMessageScroll">
|
||||
<div v-if="selectedContact && hasMoreMessages" class="flex justify-center mb-4">
|
||||
<div
|
||||
class="text-xs px-3 py-1 rounded-md bg-white border border-gray-200 text-gray-700 select-none"
|
||||
:class="isLoadingMessages ? 'opacity-60' : 'hover:bg-gray-50 cursor-pointer'"
|
||||
@click="!isLoadingMessages && loadMoreMessages()"
|
||||
>
|
||||
{{ isLoadingMessages ? '加载中...' : '继续上滑加载更多' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingMessages && messages.length === 0" class="text-center text-sm text-gray-500 py-6">
|
||||
加载中...
|
||||
</div>
|
||||
<div v-else-if="messagesError" class="text-center text-sm text-red-500 py-6 whitespace-pre-wrap">
|
||||
{{ messagesError }}
|
||||
</div>
|
||||
<div v-else-if="messages.length === 0" class="text-center text-sm text-gray-500 py-6">
|
||||
暂无聊天记录
|
||||
</div>
|
||||
|
||||
<MessageItem
|
||||
v-for="message in renderMessages"
|
||||
:key="message.id"
|
||||
:message="message"
|
||||
:state="state"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import MessageItem from '~/components/chat/MessageItem.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MessageList',
|
||||
components: { MessageItem },
|
||||
props: {
|
||||
state: { type: Object, required: true }
|
||||
},
|
||||
setup(props) {
|
||||
return {
|
||||
...props.state
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div
|
||||
class="session-list-panel border-r border-gray-200 flex flex-col min-h-0 shrink-0 relative"
|
||||
:style="{ backgroundColor: '#F7F7F7', '--session-list-width': sessionListWidth + 'px' }"
|
||||
>
|
||||
<!-- 拖动调整会话列表宽度 -->
|
||||
<div
|
||||
class="session-list-resizer"
|
||||
:class="{ 'session-list-resizer-active': sessionListResizing }"
|
||||
title="拖动调整会话列表宽度"
|
||||
@pointerdown="onSessionListResizerPointerDown"
|
||||
@dblclick="resetSessionListWidth"
|
||||
/>
|
||||
<!-- 聊天列表 -->
|
||||
<div class="h-full flex flex-col min-h-0">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="contact-search-wrapper flex-1">
|
||||
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 14L11.1 11.1" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索联系人"
|
||||
v-model="searchQuery"
|
||||
class="contact-search-input"
|
||||
:class="{ 'privacy-blur': privacyMode }"
|
||||
>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
type="button"
|
||||
class="contact-search-clear"
|
||||
@click="searchQuery = ''"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
v-if="showSearchAccountSwitcher"
|
||||
v-model="selectedAccount"
|
||||
@change="onAccountChange"
|
||||
class="account-select"
|
||||
>
|
||||
<option v-if="!availableAccounts.length" disabled value="">{{ chatAccounts.loading ? '加载中...' : (chatAccounts.error || '无数据库') }}</option>
|
||||
<option v-for="acc in availableAccounts" :key="acc" :value="acc">{{ acc }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 联系人列表 -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<div v-if="isLoadingContacts" class="px-3 py-4 h-full overflow-hidden">
|
||||
<div v-for="i in 15" :key="i" class="flex items-center space-x-3 h-[calc(80px/var(--dpr))]">
|
||||
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md bg-gray-200 skeleton-pulse"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-3.5 bg-gray-200 rounded skeleton-pulse" :style="{ width: (60 + (i % 4) * 15) + 'px' }"></div>
|
||||
<div class="h-3 bg-gray-200 rounded skeleton-pulse" :style="{ width: (80 + (i % 3) * 20) + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="contactsError" class="px-3 py-2 text-sm text-red-500 whitespace-pre-wrap">
|
||||
{{ contactsError }}
|
||||
</div>
|
||||
<div v-else-if="contacts.length === 0" class="px-3 py-2 text-sm text-gray-500">
|
||||
暂无会话
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-for="contact in filteredContacts" :key="contact.id"
|
||||
class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(80px/var(--dpr))] flex items-center"
|
||||
:class="contact.isTop
|
||||
? (selectedContact?.id === contact.id
|
||||
? 'bg-[#D2D2D2] hover:bg-[#C7C7C7]'
|
||||
: 'bg-[#EAEAEA] hover:bg-[#DEDEDE]')
|
||||
: (selectedContact?.id === contact.id
|
||||
? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]'
|
||||
: 'hover:bg-[#eaeaea]')"
|
||||
@click="selectContact(contact)">
|
||||
<div class="flex items-center space-x-3 w-full">
|
||||
<!-- 联系人头像 -->
|
||||
<div class="relative flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md overflow-hidden bg-gray-300">
|
||||
<div v-if="contact.avatar" class="w-full h-full">
|
||||
<img :src="contact.avatar" :alt="contact.name" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer" @error="onAvatarError($event, contact)">
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
|
||||
:style="{ backgroundColor: contact.avatarColor || '#4B5563' }">
|
||||
{{ contact.name.charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="contact.unreadCount > 0"
|
||||
class="absolute z-10 -top-[calc(4px/var(--dpr))] -right-[calc(4px/var(--dpr))] w-[calc(10px/var(--dpr))] h-[calc(10px/var(--dpr))] bg-[#ed4d4d] rounded-full"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- 联系人信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-900 truncate" :class="{ 'privacy-blur': privacyMode }">{{ contact.name }}</h3>
|
||||
<div class="flex items-center flex-shrink-0 ml-2">
|
||||
<span class="text-xs text-gray-500">{{ contact.lastMessageTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 truncate mt-0.5 leading-tight" :class="{ 'privacy-blur': privacyMode }">
|
||||
<span
|
||||
v-for="(seg, idx) in parseTextWithEmoji(
|
||||
(contact.unreadCount > 0 ? `[${contact.unreadCount > 99 ? '99+' : contact.unreadCount}条] ` : '') +
|
||||
String(contact.lastMessage || '')
|
||||
)"
|
||||
:key="idx"
|
||||
>
|
||||
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
|
||||
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" />
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 样式展示列表已移除 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'SessionListPanel',
|
||||
props: {
|
||||
state: { type: Object, required: true }
|
||||
},
|
||||
setup(props) {
|
||||
return { ...props.state }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -260,7 +260,7 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import Stack from '~/components/wrapped/shared/VueBitsStack.vue'
|
||||
import WechatEmojiTable, { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||
import WechatEmojiTable, { parseTextWithEmoji } from '~/lib/wechat-emojis'
|
||||
|
||||
const props = defineProps({
|
||||
card: { type: Object, required: true },
|
||||
|
||||
@@ -99,7 +99,7 @@ import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { gsap } from 'gsap'
|
||||
import KeywordWordCloud from '~/components/wrapped/visualizations/KeywordWordCloud.vue'
|
||||
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { heatColor } from '~/utils/wrapped/heatmap'
|
||||
import { heatColor } from '~/lib/wrapped/heatmap'
|
||||
|
||||
const props = defineProps({
|
||||
year: { type: Number, default: new Date().getFullYear() },
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { heatColor, maxInMatrix, formatHourRange } from '~/utils/wrapped/heatmap'
|
||||
import { heatColor, maxInMatrix, formatHourRange } from '~/lib/wrapped/heatmap'
|
||||
|
||||
const props = defineProps({
|
||||
weekdayLabels: { type: Array, default: () => ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] },
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
import { nextTick, ref, toRaw } from 'vue'
|
||||
|
||||
const CONTEXT_MENU_MARGIN = 8
|
||||
|
||||
const initialContextMenu = () => ({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
message: null,
|
||||
kind: '',
|
||||
disabled: false,
|
||||
editStatus: null,
|
||||
editStatusLoading: false
|
||||
})
|
||||
|
||||
const initialMessageEditModal = () => ({
|
||||
open: false,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: '',
|
||||
mode: 'content',
|
||||
sessionId: '',
|
||||
messageId: '',
|
||||
draft: '',
|
||||
rawRow: null
|
||||
})
|
||||
|
||||
const initialMessageFieldsModal = () => ({
|
||||
open: false,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: '',
|
||||
sessionId: '',
|
||||
messageId: '',
|
||||
unsafe: false,
|
||||
editsJson: '',
|
||||
rawRow: null
|
||||
})
|
||||
|
||||
export const useChatEditing = ({
|
||||
api,
|
||||
selectedAccount,
|
||||
selectedContact,
|
||||
refreshSelectedMessages,
|
||||
normalizeMessage,
|
||||
allMessages,
|
||||
locateMessageByServerId
|
||||
}) => {
|
||||
const contextMenu = ref(initialContextMenu())
|
||||
const contextMenuElement = ref(null)
|
||||
const messageEditModal = ref(initialMessageEditModal())
|
||||
const messageFieldsModal = ref(initialMessageFieldsModal())
|
||||
|
||||
const closeContextMenu = () => {
|
||||
contextMenu.value = initialContextMenu()
|
||||
}
|
||||
|
||||
const repositionContextMenu = () => {
|
||||
if (!process.client || !contextMenu.value.visible) return
|
||||
const menuEl = contextMenuElement.value
|
||||
if (!menuEl) return
|
||||
|
||||
const rect = menuEl.getBoundingClientRect()
|
||||
const viewportWidth = Math.max(window.innerWidth || 0, document.documentElement?.clientWidth || 0)
|
||||
const viewportHeight = Math.max(window.innerHeight || 0, document.documentElement?.clientHeight || 0)
|
||||
if (!viewportWidth || !viewportHeight) return
|
||||
|
||||
const maxX = Math.max(CONTEXT_MENU_MARGIN, viewportWidth - rect.width - CONTEXT_MENU_MARGIN)
|
||||
const maxY = Math.max(CONTEXT_MENU_MARGIN, viewportHeight - rect.height - CONTEXT_MENU_MARGIN)
|
||||
const currentX = Number(contextMenu.value.x || 0)
|
||||
const currentY = Number(contextMenu.value.y || 0)
|
||||
const nextX = Math.min(Math.max(currentX, CONTEXT_MENU_MARGIN), maxX)
|
||||
const nextY = Math.min(Math.max(currentY, CONTEXT_MENU_MARGIN), maxY)
|
||||
|
||||
if (nextX !== currentX || nextY !== currentY) {
|
||||
contextMenu.value = {
|
||||
...contextMenu.value,
|
||||
x: nextX,
|
||||
y: nextY
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleContextMenuReposition = () => {
|
||||
if (!process.client) return
|
||||
void nextTick(() => {
|
||||
const run = () => repositionContextMenu()
|
||||
if (typeof window.requestAnimationFrame === 'function') {
|
||||
window.requestAnimationFrame(run)
|
||||
} else {
|
||||
run()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const loadContextMenuEditStatus = async (params) => {
|
||||
if (!process.client) return
|
||||
const account = String(params?.account || '').trim()
|
||||
const username = String(params?.username || '').trim()
|
||||
const messageId = String(params?.message_id || '').trim()
|
||||
if (!account || !username || !messageId) {
|
||||
contextMenu.value.editStatusLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.getChatEditStatus({ account, username, message_id: messageId })
|
||||
const current = String(contextMenu.value?.message?.id || '').trim()
|
||||
if (contextMenu.value.visible && current === messageId) {
|
||||
contextMenu.value.editStatus = response || { modified: false }
|
||||
scheduleContextMenuReposition()
|
||||
}
|
||||
} catch {
|
||||
const current = String(contextMenu.value?.message?.id || '').trim()
|
||||
if (contextMenu.value.visible && current === messageId) {
|
||||
contextMenu.value.editStatus = null
|
||||
scheduleContextMenuReposition()
|
||||
}
|
||||
} finally {
|
||||
const current = String(contextMenu.value?.message?.id || '').trim()
|
||||
if (contextMenu.value.visible && current === messageId) {
|
||||
contextMenu.value.editStatusLoading = false
|
||||
scheduleContextMenuReposition()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openMediaContextMenu = (event, message, kind) => {
|
||||
if (!process.client) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
let actualKind = kind
|
||||
let disabled = true
|
||||
if (kind === 'voice') {
|
||||
disabled = !(message?.serverIdStr || message?.serverId)
|
||||
} else if (kind === 'file') {
|
||||
disabled = !message?.fileMd5
|
||||
} else if (kind === 'image') {
|
||||
disabled = !(message?.imageMd5 || message?.imageFileId)
|
||||
} else if (kind === 'emoji') {
|
||||
disabled = !message?.emojiMd5
|
||||
} else if (kind === 'video') {
|
||||
if (message?.videoMd5 || message?.videoFileId) {
|
||||
disabled = false
|
||||
actualKind = 'video'
|
||||
} else if (message?.videoThumbMd5 || message?.videoThumbFileId) {
|
||||
disabled = false
|
||||
actualKind = 'video_thumb'
|
||||
}
|
||||
}
|
||||
|
||||
contextMenu.value = {
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
message,
|
||||
kind: actualKind,
|
||||
disabled,
|
||||
editStatus: null,
|
||||
editStatusLoading: false
|
||||
}
|
||||
|
||||
try {
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const username = String(selectedContact.value?.username || '').trim()
|
||||
const messageId = String(message?.id || '').trim()
|
||||
if (account && username && messageId) {
|
||||
contextMenu.value.editStatusLoading = true
|
||||
void loadContextMenuEditStatus({ account, username, message_id: messageId })
|
||||
}
|
||||
} catch {}
|
||||
|
||||
scheduleContextMenuReposition()
|
||||
}
|
||||
|
||||
const prettyJson = (value) => {
|
||||
try {
|
||||
return JSON.stringify(value ?? null, null, 2)
|
||||
} catch {
|
||||
return String(value ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
const isLikelyTextMessage = (message) => {
|
||||
if (!message) return false
|
||||
const renderType = String(message?.renderType || '').trim()
|
||||
if (renderType && renderType !== 'text') return false
|
||||
if (message?.imageUrl || message?.emojiUrl || message?.videoUrl || message?.voiceUrl) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const closeMessageEditModal = () => {
|
||||
messageEditModal.value = initialMessageEditModal()
|
||||
}
|
||||
|
||||
const openMessageEditModal = async ({ message, mode }) => {
|
||||
if (!process.client) return
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(selectedContact.value?.username || '').trim()
|
||||
const messageId = String(message?.id || '').trim()
|
||||
if (!account || !sessionId || !messageId) return
|
||||
|
||||
const resolvedMode = mode === 'raw' ? 'raw' : 'content'
|
||||
const initialDraft = resolvedMode === 'content'
|
||||
? (typeof message?.content === 'string' ? message.content : String(message?.content ?? ''))
|
||||
: ''
|
||||
|
||||
messageEditModal.value = {
|
||||
open: true,
|
||||
loading: true,
|
||||
saving: false,
|
||||
error: '',
|
||||
mode: resolvedMode,
|
||||
sessionId,
|
||||
messageId,
|
||||
draft: initialDraft,
|
||||
rawRow: null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId })
|
||||
const row = response?.row || null
|
||||
const rawContent = row?.message_content
|
||||
const rawDraft = typeof rawContent === 'string' ? rawContent : String(rawContent ?? '')
|
||||
const draft = resolvedMode === 'raw' ? rawDraft : messageEditModal.value.draft
|
||||
messageEditModal.value = { ...messageEditModal.value, loading: false, rawRow: row, draft }
|
||||
} catch (error) {
|
||||
messageEditModal.value = { ...messageEditModal.value, loading: false, error: error?.message || '加载失败' }
|
||||
}
|
||||
}
|
||||
|
||||
const saveMessageEditModal = async () => {
|
||||
if (!process.client) return
|
||||
if (messageEditModal.value.saving || messageEditModal.value.loading) return
|
||||
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(messageEditModal.value.sessionId || '').trim()
|
||||
const messageId = String(messageEditModal.value.messageId || '').trim()
|
||||
if (!account || !sessionId || !messageId) return
|
||||
|
||||
messageEditModal.value = { ...messageEditModal.value, saving: true, error: '' }
|
||||
try {
|
||||
const response = await api.editChatMessage({
|
||||
account,
|
||||
session_id: sessionId,
|
||||
message_id: messageId,
|
||||
edits: {
|
||||
message_content: String(messageEditModal.value.draft ?? '')
|
||||
},
|
||||
unsafe: false
|
||||
})
|
||||
|
||||
if (response?.updated_message) {
|
||||
try {
|
||||
const updated = normalizeMessage(response.updated_message)
|
||||
const username = String(selectedContact.value?.username || '').trim()
|
||||
const list = allMessages.value[username] || []
|
||||
const index = list.findIndex((message) => String(message?.id || '') === String(updated?.id || ''))
|
||||
if (index >= 0) {
|
||||
const next = [...list]
|
||||
next[index] = updated
|
||||
allMessages.value = { ...allMessages.value, [username]: next }
|
||||
} else {
|
||||
await refreshSelectedMessages()
|
||||
}
|
||||
} catch {
|
||||
await refreshSelectedMessages()
|
||||
}
|
||||
} else {
|
||||
await refreshSelectedMessages()
|
||||
}
|
||||
|
||||
closeMessageEditModal()
|
||||
} catch (error) {
|
||||
messageEditModal.value = { ...messageEditModal.value, saving: false, error: error?.message || '保存失败' }
|
||||
return
|
||||
} finally {
|
||||
messageEditModal.value = { ...messageEditModal.value, saving: false }
|
||||
}
|
||||
}
|
||||
|
||||
const closeMessageFieldsModal = () => {
|
||||
messageFieldsModal.value = initialMessageFieldsModal()
|
||||
}
|
||||
|
||||
const openMessageFieldsModal = async (message) => {
|
||||
if (!process.client) return
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(selectedContact.value?.username || '').trim()
|
||||
const messageId = String(message?.id || '').trim()
|
||||
if (!account || !sessionId || !messageId) return
|
||||
|
||||
messageFieldsModal.value = {
|
||||
open: true,
|
||||
loading: true,
|
||||
saving: false,
|
||||
error: '',
|
||||
sessionId,
|
||||
messageId,
|
||||
unsafe: false,
|
||||
editsJson: '',
|
||||
rawRow: null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId })
|
||||
const row = response?.row || null
|
||||
const seed = {}
|
||||
for (const key of ['message_content', 'local_type', 'create_time', 'server_id', 'origin_source', 'source']) {
|
||||
if (row && Object.prototype.hasOwnProperty.call(row, key)) seed[key] = row[key]
|
||||
}
|
||||
messageFieldsModal.value = {
|
||||
...messageFieldsModal.value,
|
||||
loading: false,
|
||||
rawRow: row,
|
||||
editsJson: JSON.stringify(seed, null, 2)
|
||||
}
|
||||
} catch (error) {
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, loading: false, error: error?.message || '加载失败' }
|
||||
}
|
||||
}
|
||||
|
||||
const saveMessageFieldsModal = async () => {
|
||||
if (!process.client) return
|
||||
if (messageFieldsModal.value.saving || messageFieldsModal.value.loading) return
|
||||
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(messageFieldsModal.value.sessionId || '').trim()
|
||||
const messageId = String(messageFieldsModal.value.messageId || '').trim()
|
||||
if (!account || !sessionId || !messageId) return
|
||||
|
||||
let edits = null
|
||||
try {
|
||||
edits = JSON.parse(String(messageFieldsModal.value.editsJson || '').trim() || 'null')
|
||||
} catch {
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, error: 'JSON 格式错误' }
|
||||
return
|
||||
}
|
||||
if (!edits || typeof edits !== 'object' || Array.isArray(edits)) {
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, error: 'edits 必须是 JSON 对象' }
|
||||
return
|
||||
}
|
||||
if (!Object.keys(edits).length) {
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, error: 'edits 不能为空' }
|
||||
return
|
||||
}
|
||||
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, saving: true, error: '' }
|
||||
try {
|
||||
await api.editChatMessage({
|
||||
account,
|
||||
session_id: sessionId,
|
||||
message_id: messageId,
|
||||
edits,
|
||||
unsafe: !!messageFieldsModal.value.unsafe
|
||||
})
|
||||
await refreshSelectedMessages()
|
||||
closeMessageFieldsModal()
|
||||
} catch (error) {
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, saving: false, error: error?.message || '保存失败' }
|
||||
return
|
||||
} finally {
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, saving: false }
|
||||
}
|
||||
}
|
||||
|
||||
const copyTextToClipboard = async (text) => {
|
||||
if (!process.client) return false
|
||||
|
||||
const value = String(text ?? '').trim()
|
||||
if (!value) return false
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
return true
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const element = document.createElement('textarea')
|
||||
element.value = value
|
||||
element.setAttribute('readonly', 'true')
|
||||
element.style.position = 'fixed'
|
||||
element.style.left = '-9999px'
|
||||
element.style.top = '-9999px'
|
||||
document.body.appendChild(element)
|
||||
element.select()
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(element)
|
||||
if (ok) return true
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
window.prompt('复制内容:', value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const onCopyMessageTextClick = async () => {
|
||||
if (!process.client) return
|
||||
const message = contextMenu.value.message
|
||||
if (!message) return
|
||||
try {
|
||||
const text = String(message?.content || '').trim()
|
||||
if (!text) {
|
||||
window.alert('该消息没有可复制的文本')
|
||||
return
|
||||
}
|
||||
const ok = await copyTextToClipboard(text)
|
||||
if (!ok) window.alert('复制失败:无法写入剪贴板')
|
||||
} catch {
|
||||
window.alert('复制失败')
|
||||
} finally {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const onCopyMessageJsonClick = async () => {
|
||||
if (!process.client) return
|
||||
const message = contextMenu.value.message
|
||||
if (!message) return
|
||||
try {
|
||||
const raw = toRaw(message) || message
|
||||
const json = JSON.stringify(raw, (_key, value) => (typeof value === 'bigint' ? value.toString() : value), 2)
|
||||
const ok = await copyTextToClipboard(json)
|
||||
if (!ok) window.alert('复制失败:无法写入剪贴板')
|
||||
} catch {
|
||||
window.alert('复制失败')
|
||||
} finally {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenFolderClick = async () => {
|
||||
if (contextMenu.value.disabled) return
|
||||
const message = contextMenu.value.message
|
||||
const kind = contextMenu.value.kind
|
||||
|
||||
try {
|
||||
if (!selectedAccount.value || !selectedContact.value?.username) return
|
||||
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
username: selectedContact.value.username,
|
||||
kind
|
||||
}
|
||||
|
||||
if (kind === 'voice') {
|
||||
params.server_id = message.serverIdStr || message.serverId
|
||||
} else if (kind === 'file') {
|
||||
params.md5 = message.fileMd5
|
||||
} else if (kind === 'image') {
|
||||
if (message.imageMd5) params.md5 = message.imageMd5
|
||||
else if (message.imageFileId) params.file_id = message.imageFileId
|
||||
} else if (kind === 'emoji') {
|
||||
params.md5 = message.emojiMd5
|
||||
} else if (kind === 'video') {
|
||||
params.md5 = message.videoMd5
|
||||
if (message.videoFileId) params.file_id = message.videoFileId
|
||||
} else if (kind === 'video_thumb') {
|
||||
params.md5 = message.videoThumbMd5
|
||||
if (message.videoThumbFileId) params.file_id = message.videoThumbFileId
|
||||
}
|
||||
|
||||
await api.openChatMediaFolder(params)
|
||||
} finally {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const onEditMessageClick = async () => {
|
||||
const message = contextMenu.value.message
|
||||
if (!message) return
|
||||
const mode = isLikelyTextMessage(message) ? 'content' : 'raw'
|
||||
closeContextMenu()
|
||||
await openMessageEditModal({ message, mode })
|
||||
}
|
||||
|
||||
const onEditMessageFieldsClick = async () => {
|
||||
const message = contextMenu.value.message
|
||||
if (!message) return
|
||||
closeContextMenu()
|
||||
await openMessageFieldsModal(message)
|
||||
}
|
||||
|
||||
const onResetEditedMessageClick = async () => {
|
||||
if (!process.client) return
|
||||
const message = contextMenu.value.message
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(selectedContact.value?.username || '').trim()
|
||||
const messageId = String(message?.id || '').trim()
|
||||
if (!message || !account || !sessionId || !messageId) return
|
||||
|
||||
const ok = window.confirm('确认恢复该条消息到首次快照吗?')
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
await api.resetChatEditedMessage({ account, session_id: sessionId, message_id: messageId })
|
||||
closeContextMenu()
|
||||
await refreshSelectedMessages()
|
||||
} catch (error) {
|
||||
window.alert(error?.message || '恢复失败')
|
||||
} finally {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const onRepairMessageSenderAsMeClick = async () => {
|
||||
if (!process.client) return
|
||||
const message = contextMenu.value.message
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(selectedContact.value?.username || '').trim()
|
||||
const messageId = String(message?.id || '').trim()
|
||||
if (!message || !account || !sessionId || !messageId) return
|
||||
|
||||
const ok = window.confirm('确认将该消息修复为“我发送”吗?这会修改 real_sender_id 字段。')
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
await api.repairChatMessageSender({ account, session_id: sessionId, message_id: messageId, mode: 'me' })
|
||||
closeContextMenu()
|
||||
await refreshSelectedMessages()
|
||||
} catch (error) {
|
||||
window.alert(error?.message || '修复失败')
|
||||
} finally {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const onFlipWechatMessageDirectionClick = async () => {
|
||||
if (!process.client) return
|
||||
const message = contextMenu.value.message
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(selectedContact.value?.username || '').trim()
|
||||
const messageId = String(message?.id || '').trim()
|
||||
if (!message || !account || !sessionId || !messageId) return
|
||||
|
||||
const ok = window.confirm(
|
||||
'确认反转该消息在微信客户端的左右气泡位置吗?\n\n这会修改 packed_info_data 字段(有风险)。\n可通过“恢复原消息”撤销。'
|
||||
)
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
await api.flipChatMessageDirection({ account, session_id: sessionId, message_id: messageId })
|
||||
closeContextMenu()
|
||||
await refreshSelectedMessages()
|
||||
} catch (error) {
|
||||
window.alert(error?.message || '反转失败')
|
||||
} finally {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const onLocateQuotedMessageClick = async () => {
|
||||
const message = contextMenu.value.message
|
||||
if (!message?.quoteServerId) return
|
||||
closeContextMenu()
|
||||
const ok = await locateMessageByServerId(message.quoteServerId)
|
||||
if (!ok && process.client) {
|
||||
window.alert('定位引用消息失败')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextMenu,
|
||||
contextMenuElement,
|
||||
messageEditModal,
|
||||
messageFieldsModal,
|
||||
closeContextMenu,
|
||||
openMediaContextMenu,
|
||||
prettyJson,
|
||||
isLikelyTextMessage,
|
||||
closeMessageEditModal,
|
||||
openMessageEditModal,
|
||||
saveMessageEditModal,
|
||||
closeMessageFieldsModal,
|
||||
openMessageFieldsModal,
|
||||
saveMessageFieldsModal,
|
||||
copyTextToClipboard,
|
||||
onCopyMessageTextClick,
|
||||
onCopyMessageJsonClick,
|
||||
onOpenFolderClick,
|
||||
onEditMessageClick,
|
||||
onEditMessageFieldsClick,
|
||||
onResetEditedMessageClick,
|
||||
onRepairMessageSenderAsMeClick,
|
||||
onFlipWechatMessageDirectionClick,
|
||||
onLocateQuotedMessageClick
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { reportServerErrorFromResponse } from '~/lib/server-error-logging'
|
||||
import { toUnixSeconds } from '~/lib/chat/formatters'
|
||||
|
||||
export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selectedContact, privacyMode }) => {
|
||||
const exportModalOpen = ref(false)
|
||||
const isExportCreating = ref(false)
|
||||
const exportError = ref('')
|
||||
|
||||
const exportScope = ref('current')
|
||||
const exportFormat = ref('json')
|
||||
const exportDownloadRemoteMedia = ref(true)
|
||||
const exportHtmlPageSize = ref(1000)
|
||||
const exportMessageTypeOptions = [
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'emoji', label: '表情' },
|
||||
{ value: 'video', label: '视频' },
|
||||
{ value: 'voice', label: '语音' },
|
||||
{ value: 'chatHistory', label: '聊天记录' },
|
||||
{ value: 'transfer', label: '转账' },
|
||||
{ value: 'redPacket', label: '红包' },
|
||||
{ value: 'file', label: '文件' },
|
||||
{ value: 'link', label: '链接' },
|
||||
{ value: 'quote', label: '引用' },
|
||||
{ value: 'system', label: '系统' },
|
||||
{ value: 'voip', label: '通话' }
|
||||
]
|
||||
const exportMessageTypes = ref(exportMessageTypeOptions.map((item) => item.value))
|
||||
|
||||
const exportStartLocal = ref('')
|
||||
const exportEndLocal = ref('')
|
||||
const exportFileName = ref('')
|
||||
const exportFolder = ref('')
|
||||
const exportFolderHandle = ref(null)
|
||||
const exportSaveBusy = ref(false)
|
||||
const exportSaveMsg = ref('')
|
||||
const exportAutoSavedFor = ref('')
|
||||
|
||||
const exportSearchQuery = ref('')
|
||||
const exportListTab = ref('all')
|
||||
const exportSelectedUsernames = ref([])
|
||||
|
||||
const exportJob = ref(null)
|
||||
let exportPollTimer = null
|
||||
let exportEventSource = null
|
||||
|
||||
const clamp01 = (value) => Math.min(1, Math.max(0, value))
|
||||
const asNumber = (value) => {
|
||||
const next = Number(value)
|
||||
return Number.isFinite(next) ? next : 0
|
||||
}
|
||||
|
||||
const exportOverallPercent = computed(() => {
|
||||
const job = exportJob.value
|
||||
const progress = job?.progress || {}
|
||||
const total = asNumber(progress.conversationsTotal)
|
||||
const done = asNumber(progress.conversationsDone)
|
||||
if (total <= 0) return 0
|
||||
|
||||
const currentTotal = asNumber(progress.currentConversationMessagesTotal)
|
||||
const currentDone = asNumber(progress.currentConversationMessagesExported)
|
||||
const currentFraction = currentTotal > 0 ? clamp01(currentDone / currentTotal) : 0
|
||||
const overall = clamp01((done + (job?.status === 'running' ? currentFraction : 0)) / total)
|
||||
return Math.round(overall * 100)
|
||||
})
|
||||
|
||||
const exportCurrentPercent = computed(() => {
|
||||
const progress = exportJob.value?.progress || {}
|
||||
const total = asNumber(progress.currentConversationMessagesTotal)
|
||||
const done = asNumber(progress.currentConversationMessagesExported)
|
||||
if (total <= 0) return null
|
||||
return Math.round(clamp01(done / total) * 100)
|
||||
})
|
||||
|
||||
const 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 normalizedTab = String(tab || 'all')
|
||||
if (normalizedTab === 'groups') list = list.filter((contact) => !!contact?.isGroup)
|
||||
if (normalizedTab === 'singles') list = list.filter((contact) => !contact?.isGroup)
|
||||
|
||||
if (!normalizedQuery) return list
|
||||
return list.filter((contact) => {
|
||||
const name = String(contact?.name || '').toLowerCase()
|
||||
const username = String(contact?.username || '').toLowerCase()
|
||||
return name.includes(normalizedQuery) || username.includes(normalizedQuery)
|
||||
})
|
||||
}
|
||||
|
||||
const exportFilteredContacts = computed(() => {
|
||||
return getExportFilteredContacts()
|
||||
})
|
||||
|
||||
const exportContactCounts = computed(() => {
|
||||
const list = Array.isArray(contacts.value) ? contacts.value : []
|
||||
const total = list.length
|
||||
const groups = list.filter((contact) => !!contact?.isGroup).length
|
||||
return { total, groups, singles: total - groups }
|
||||
})
|
||||
|
||||
const 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)
|
||||
}
|
||||
|
||||
const isWebDirectoryPickerSupported = () => {
|
||||
return !!(process.client && typeof window.showDirectoryPicker === 'function')
|
||||
}
|
||||
|
||||
const hasWebExportFolder = computed(() => {
|
||||
return !!(isWebDirectoryPickerSupported() && exportFolderHandle.value)
|
||||
})
|
||||
|
||||
const chooseExportFolder = async () => {
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
try {
|
||||
if (!process.client) {
|
||||
exportError.value = '当前环境不支持选择导出目录'
|
||||
return
|
||||
}
|
||||
|
||||
if (isDesktopExportRuntime()) {
|
||||
const result = await window.wechatDesktop.chooseDirectory({ title: '选择导出目录' })
|
||||
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
|
||||
exportFolder.value = String(result.filePaths[0] || '').trim()
|
||||
exportFolderHandle.value = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isWebDirectoryPickerSupported()) {
|
||||
const handle = await window.showDirectoryPicker()
|
||||
if (handle) {
|
||||
exportFolderHandle.value = handle
|
||||
exportFolder.value = `浏览器目录:${String(handle.name || '已选择')}`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
exportError.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器'
|
||||
} catch (error) {
|
||||
exportError.value = error?.message || '选择导出目录失败'
|
||||
}
|
||||
}
|
||||
|
||||
const guessExportZipName = (job) => {
|
||||
const raw = String(job?.zipPath || '').trim()
|
||||
if (raw) {
|
||||
const name = raw.replace(/\\/g, '/').split('/').pop()
|
||||
if (name && name.toLowerCase().endsWith('.zip')) return name
|
||||
}
|
||||
const exportId = String(job?.exportId || '').trim() || 'export'
|
||||
return `wechat_chat_export_${exportId}.zip`
|
||||
}
|
||||
|
||||
const getExportDownloadUrl = (exportId) => {
|
||||
return `${apiBase}/chat/exports/${encodeURIComponent(String(exportId || ''))}/download`
|
||||
}
|
||||
|
||||
const saveExportToSelectedFolder = async (options = {}) => {
|
||||
const autoSave = !!options?.auto
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
if (!process.client || !isWebDirectoryPickerSupported()) {
|
||||
exportError.value = '当前环境不支持保存到浏览器目录'
|
||||
return
|
||||
}
|
||||
const handle = exportFolderHandle.value
|
||||
if (!handle || typeof handle.getFileHandle !== 'function') {
|
||||
exportError.value = '请先选择浏览器导出目录'
|
||||
return
|
||||
}
|
||||
|
||||
const exportId = exportJob.value?.exportId
|
||||
if (!exportId || String(exportJob.value?.status || '') !== 'done') {
|
||||
exportError.value = '导出任务尚未完成'
|
||||
return
|
||||
}
|
||||
|
||||
exportSaveBusy.value = true
|
||||
try {
|
||||
const response = await fetch(getExportDownloadUrl(exportId))
|
||||
if (!response.ok) {
|
||||
await reportServerErrorFromResponse(response, {
|
||||
method: 'GET',
|
||||
requestUrl: getExportDownloadUrl(exportId),
|
||||
message: `下载导出文件失败(${response.status})`,
|
||||
source: 'chat.exportDownload'
|
||||
})
|
||||
throw new Error(`下载导出文件失败(${response.status})`)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const fileName = guessExportZipName(exportJob.value)
|
||||
const fileHandle = await handle.getFileHandle(fileName, { create: true })
|
||||
const writable = await fileHandle.createWritable()
|
||||
await writable.write(blob)
|
||||
await writable.close()
|
||||
exportAutoSavedFor.value = String(exportId)
|
||||
exportSaveMsg.value = autoSave
|
||||
? `已自动保存到已选目录:${fileName}`
|
||||
: `已保存到已选目录:${fileName}`
|
||||
} catch (error) {
|
||||
exportError.value = error?.message || '保存到浏览器目录失败'
|
||||
} finally {
|
||||
exportSaveBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stopExportPolling = () => {
|
||||
if (exportEventSource) {
|
||||
try {
|
||||
exportEventSource.close()
|
||||
} catch {}
|
||||
exportEventSource = null
|
||||
}
|
||||
if (exportPollTimer) {
|
||||
clearInterval(exportPollTimer)
|
||||
exportPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const startExportHttpPolling = (exportId) => {
|
||||
if (!exportId) return
|
||||
exportPollTimer = setInterval(async () => {
|
||||
try {
|
||||
const response = await api.getChatExport(exportId)
|
||||
exportJob.value = response?.job || exportJob.value
|
||||
const status = String(exportJob.value?.status || '')
|
||||
if (status === 'done' || status === 'error' || status === 'cancelled') {
|
||||
stopExportPolling()
|
||||
}
|
||||
} catch {}
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
const startExportPolling = (exportId) => {
|
||||
stopExportPolling()
|
||||
if (!exportId) return
|
||||
|
||||
if (process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined') {
|
||||
const url = `${apiBase}/chat/exports/${encodeURIComponent(String(exportId))}/events`
|
||||
try {
|
||||
exportEventSource = new EventSource(url)
|
||||
exportEventSource.onmessage = (event) => {
|
||||
try {
|
||||
const next = JSON.parse(String(event.data || '{}'))
|
||||
exportJob.value = next || exportJob.value
|
||||
const status = String(exportJob.value?.status || '')
|
||||
if (status === 'done' || status === 'error' || status === 'cancelled') {
|
||||
stopExportPolling()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
exportEventSource.onerror = () => {
|
||||
try {
|
||||
exportEventSource?.close()
|
||||
} catch {}
|
||||
exportEventSource = null
|
||||
if (!exportPollTimer) startExportHttpPolling(exportId)
|
||||
}
|
||||
return
|
||||
} catch {
|
||||
exportEventSource = null
|
||||
}
|
||||
}
|
||||
|
||||
startExportHttpPolling(exportId)
|
||||
}
|
||||
|
||||
const openExportModal = () => {
|
||||
exportModalOpen.value = true
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
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' : 'selected'
|
||||
if (!selectedContact.value?.username) {
|
||||
selectExportFilteredContacts('all')
|
||||
}
|
||||
}
|
||||
|
||||
const closeExportModal = () => {
|
||||
exportModalOpen.value = false
|
||||
exportError.value = ''
|
||||
}
|
||||
|
||||
watch(exportModalOpen, (open) => {
|
||||
if (!process.client) return
|
||||
if (!open) {
|
||||
stopExportPolling()
|
||||
return
|
||||
}
|
||||
|
||||
const exportId = exportJob.value?.exportId
|
||||
const status = String(exportJob.value?.status || '')
|
||||
if (exportId && (status === 'queued' || status === 'running')) {
|
||||
startExportPolling(exportId)
|
||||
}
|
||||
})
|
||||
|
||||
watch(exportScope, (scope, previousScope) => {
|
||||
if (scope !== 'selected' || previousScope === 'selected') return
|
||||
if (exportSelectedUsernames.value.length > 0) return
|
||||
selectExportFilteredContacts(exportListTab.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
exportId: String(exportJob.value?.exportId || ''),
|
||||
status: String(exportJob.value?.status || '')
|
||||
}),
|
||||
async ({ exportId, status }) => {
|
||||
if (!process.client || status !== 'done' || !exportId) return
|
||||
if (!hasWebExportFolder.value) return
|
||||
if (exportAutoSavedFor.value === exportId) return
|
||||
if (exportSaveBusy.value) return
|
||||
await saveExportToSelectedFolder({ auto: true })
|
||||
}
|
||||
)
|
||||
|
||||
const startChatExport = async () => {
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
if (!selectedAccount.value) {
|
||||
exportError.value = '未选择账号'
|
||||
return
|
||||
}
|
||||
|
||||
let scope = exportScope.value
|
||||
let usernames = []
|
||||
if (scope === 'current') {
|
||||
scope = 'selected'
|
||||
if (selectedContact.value?.username) {
|
||||
usernames = [selectedContact.value.username]
|
||||
}
|
||||
} else if (scope === 'selected') {
|
||||
usernames = Array.isArray(exportSelectedUsernames.value) ? exportSelectedUsernames.value.filter(Boolean) : []
|
||||
}
|
||||
|
||||
if (scope === 'selected' && (!usernames || usernames.length === 0)) {
|
||||
exportError.value = '请选择至少一个会话'
|
||||
return
|
||||
}
|
||||
|
||||
const hasDesktopFolder = isDesktopExportRuntime() && !!String(exportFolder.value || '').trim()
|
||||
const hasWebFolder = !isDesktopExportRuntime() && !!exportFolderHandle.value
|
||||
if (!hasDesktopFolder && !hasWebFolder) {
|
||||
exportError.value = '请先选择导出目录'
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = toUnixSeconds(exportStartLocal.value)
|
||||
const endTime = toUnixSeconds(exportEndLocal.value)
|
||||
if (startTime && endTime && startTime > endTime) {
|
||||
exportError.value = '时间范围不合法:开始时间不能晚于结束时间'
|
||||
return
|
||||
}
|
||||
|
||||
const messageTypes = Array.isArray(exportMessageTypes.value) ? exportMessageTypes.value.filter(Boolean) : []
|
||||
if (messageTypes.length === 0) {
|
||||
exportError.value = '请至少勾选一个消息类型'
|
||||
return
|
||||
}
|
||||
|
||||
const selectedTypeSet = new Set(messageTypes.map((item) => String(item || '').trim()))
|
||||
const mediaKindSet = new Set()
|
||||
if (selectedTypeSet.has('chatHistory')) {
|
||||
mediaKindSet.add('image')
|
||||
mediaKindSet.add('emoji')
|
||||
mediaKindSet.add('video')
|
||||
mediaKindSet.add('video_thumb')
|
||||
mediaKindSet.add('voice')
|
||||
mediaKindSet.add('file')
|
||||
}
|
||||
if (selectedTypeSet.has('image')) mediaKindSet.add('image')
|
||||
if (selectedTypeSet.has('emoji')) mediaKindSet.add('emoji')
|
||||
if (selectedTypeSet.has('video')) {
|
||||
mediaKindSet.add('video')
|
||||
mediaKindSet.add('video_thumb')
|
||||
}
|
||||
if (selectedTypeSet.has('voice')) mediaKindSet.add('voice')
|
||||
if (selectedTypeSet.has('file')) mediaKindSet.add('file')
|
||||
|
||||
const mediaKinds = Array.from(mediaKindSet)
|
||||
const includeMedia = !privacyMode.value && mediaKinds.length > 0
|
||||
|
||||
isExportCreating.value = true
|
||||
exportAutoSavedFor.value = ''
|
||||
try {
|
||||
const response = await api.createChatExport({
|
||||
account: selectedAccount.value,
|
||||
scope,
|
||||
usernames,
|
||||
format: exportFormat.value,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
include_hidden: false,
|
||||
include_official: false,
|
||||
message_types: messageTypes,
|
||||
include_media: includeMedia,
|
||||
media_kinds: mediaKinds,
|
||||
download_remote_media: exportFormat.value === 'html' && !!exportDownloadRemoteMedia.value,
|
||||
html_page_size: Math.max(0, Math.floor(Number(exportHtmlPageSize.value || 1000))),
|
||||
output_dir: isDesktopExportRuntime() ? String(exportFolder.value || '').trim() : null,
|
||||
privacy_mode: !!privacyMode.value,
|
||||
file_name: exportFileName.value || null
|
||||
})
|
||||
|
||||
exportJob.value = response?.job || null
|
||||
const exportId = exportJob.value?.exportId
|
||||
if (exportId) startExportPolling(exportId)
|
||||
} catch (error) {
|
||||
exportError.value = error?.message || '创建导出任务失败'
|
||||
} finally {
|
||||
isExportCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelCurrentExport = async () => {
|
||||
const exportId = exportJob.value?.exportId
|
||||
if (!exportId) return
|
||||
|
||||
try {
|
||||
await api.cancelChatExport(exportId)
|
||||
const response = await api.getChatExport(exportId)
|
||||
exportJob.value = response?.job || exportJob.value
|
||||
} catch (error) {
|
||||
exportError.value = error?.message || '取消导出失败'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exportModalOpen,
|
||||
isExportCreating,
|
||||
exportError,
|
||||
exportScope,
|
||||
exportFormat,
|
||||
exportDownloadRemoteMedia,
|
||||
exportHtmlPageSize,
|
||||
exportMessageTypeOptions,
|
||||
exportMessageTypes,
|
||||
exportStartLocal,
|
||||
exportEndLocal,
|
||||
exportFileName,
|
||||
exportFolder,
|
||||
exportFolderHandle,
|
||||
exportSaveBusy,
|
||||
exportSaveMsg,
|
||||
exportAutoSavedFor,
|
||||
exportSearchQuery,
|
||||
exportListTab,
|
||||
exportSelectedUsernames,
|
||||
exportJob,
|
||||
exportOverallPercent,
|
||||
exportCurrentPercent,
|
||||
exportFilteredContacts,
|
||||
exportContactCounts,
|
||||
onExportBatchScopeClick,
|
||||
onExportListTabClick,
|
||||
isExportContactSelected,
|
||||
hasWebExportFolder,
|
||||
chooseExportFolder,
|
||||
getExportDownloadUrl,
|
||||
saveExportToSelectedFolder,
|
||||
openExportModal,
|
||||
closeExportModal,
|
||||
startChatExport,
|
||||
cancelCurrentExport,
|
||||
stopExportPolling
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
buildChatHistoryWindowPayload,
|
||||
createChatHistoryRecordNormalizer,
|
||||
enhanceChatHistoryRecords,
|
||||
formatChatHistoryVideoDuration,
|
||||
getChatHistoryPreviewLines,
|
||||
isChatHistoryRecordItemIncomplete,
|
||||
normalizeChatHistoryUrl,
|
||||
parseChatHistoryRecord,
|
||||
pickFirstMd5,
|
||||
stripWeChatInvisible
|
||||
} from '~/lib/chat/chat-history'
|
||||
|
||||
export const useChatHistoryWindows = ({
|
||||
api,
|
||||
apiBase,
|
||||
selectedAccount,
|
||||
selectedContact,
|
||||
openImagePreview,
|
||||
openVideoPreview
|
||||
}) => {
|
||||
const floatingWindows = ref([])
|
||||
let floatingWindowSeq = 0
|
||||
let floatingWindowZ = 70
|
||||
const floatingDragState = { id: '', offsetX: 0, offsetY: 0 }
|
||||
|
||||
const clampNumber = (value, min, max) => Math.min(max, Math.max(min, value))
|
||||
const normalizeRecordItem = createChatHistoryRecordNormalizer({
|
||||
apiBase,
|
||||
getSelectedAccount: () => selectedAccount.value,
|
||||
getSelectedContact: () => selectedContact.value
|
||||
})
|
||||
|
||||
const getFloatingWindowById = (id) => {
|
||||
const list = Array.isArray(floatingWindows.value) ? floatingWindows.value : []
|
||||
return list.find((item) => String(item?.id || '') === String(id || '')) || null
|
||||
}
|
||||
|
||||
const focusFloatingWindow = (id) => {
|
||||
const windowItem = getFloatingWindowById(id)
|
||||
if (!windowItem) return
|
||||
floatingWindowZ += 1
|
||||
windowItem.zIndex = floatingWindowZ
|
||||
}
|
||||
|
||||
const closeFloatingWindow = (id) => {
|
||||
const key = String(id || '')
|
||||
floatingWindows.value = (Array.isArray(floatingWindows.value) ? floatingWindows.value : []).filter((item) => String(item?.id || '') !== key)
|
||||
if (floatingDragState.id && String(floatingDragState.id) === key) {
|
||||
floatingDragState.id = ''
|
||||
}
|
||||
}
|
||||
|
||||
const closeTopFloatingWindow = () => {
|
||||
const list = Array.isArray(floatingWindows.value) ? floatingWindows.value : []
|
||||
if (!list.length) return
|
||||
const top = [...list].sort((a, b) => Number(b?.zIndex || 0) - Number(a?.zIndex || 0))[0]
|
||||
if (top?.id) closeFloatingWindow(top.id)
|
||||
}
|
||||
|
||||
const openFloatingWindow = (payload) => {
|
||||
if (!process.client || typeof window === 'undefined') return null
|
||||
floatingWindowSeq += 1
|
||||
floatingWindowZ += 1
|
||||
const width = clampNumber(Number(payload?.width || 520), 360, Math.max(360, (window.innerWidth || 1200) - 48))
|
||||
const height = clampNumber(Number(payload?.height || 420), 320, Math.max(320, (window.innerHeight || 900) - 48))
|
||||
const x = clampNumber(Number(payload?.x || Math.round(((window.innerWidth || width) - width) / 2)), 16, Math.max(16, (window.innerWidth || width) - width - 16))
|
||||
const y = clampNumber(Number(payload?.y || Math.round(((window.innerHeight || height) - height) / 2)), 16, Math.max(16, (window.innerHeight || height) - height - 16))
|
||||
|
||||
const windowItem = {
|
||||
id: `chat-floating-${floatingWindowSeq}`,
|
||||
kind: String(payload?.kind || 'chatHistory'),
|
||||
title: String(payload?.title || ''),
|
||||
info: payload?.info || { isChatRoom: false },
|
||||
records: Array.isArray(payload?.records) ? payload.records : [],
|
||||
url: String(payload?.url || ''),
|
||||
content: String(payload?.content || ''),
|
||||
preview: String(payload?.preview || ''),
|
||||
from: String(payload?.from || ''),
|
||||
fromAvatar: String(payload?.fromAvatar || ''),
|
||||
loading: !!payload?.loading,
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
zIndex: floatingWindowZ
|
||||
}
|
||||
floatingWindows.value = [...floatingWindows.value, windowItem]
|
||||
return windowItem
|
||||
}
|
||||
|
||||
const startFloatingWindowDrag = (id, event) => {
|
||||
if (!process.client) return
|
||||
const windowItem = getFloatingWindowById(id)
|
||||
if (!windowItem) return
|
||||
focusFloatingWindow(id)
|
||||
const point = 'touches' in event ? event.touches?.[0] : event
|
||||
floatingDragState.id = id
|
||||
floatingDragState.offsetX = Number(point?.clientX || 0) - Number(windowItem.x || 0)
|
||||
floatingDragState.offsetY = Number(point?.clientY || 0) - Number(windowItem.y || 0)
|
||||
}
|
||||
|
||||
const onFloatingWindowMouseMove = (event) => {
|
||||
if (!process.client) return
|
||||
if (!floatingDragState.id) return
|
||||
const windowItem = getFloatingWindowById(floatingDragState.id)
|
||||
if (!windowItem) return
|
||||
const point = 'touches' in event ? event.touches?.[0] : event
|
||||
const nextX = Number(point?.clientX || 0) - floatingDragState.offsetX
|
||||
const nextY = Number(point?.clientY || 0) - floatingDragState.offsetY
|
||||
windowItem.x = clampNumber(nextX, 8, Math.max(8, (window.innerWidth || nextX) - windowItem.width - 8))
|
||||
windowItem.y = clampNumber(nextY, 8, Math.max(8, (window.innerHeight || nextY) - windowItem.height - 8))
|
||||
}
|
||||
|
||||
const onFloatingWindowMouseUp = () => {
|
||||
floatingDragState.id = ''
|
||||
}
|
||||
|
||||
const chatHistoryModalVisible = ref(false)
|
||||
const chatHistoryModalTitle = ref('')
|
||||
const chatHistoryModalRecords = ref([])
|
||||
const chatHistoryModalInfo = ref({ isChatRoom: false })
|
||||
const chatHistoryModalStack = ref([])
|
||||
const goBackChatHistoryModal = () => {}
|
||||
const closeChatHistoryModal = () => {
|
||||
chatHistoryModalVisible.value = false
|
||||
chatHistoryModalTitle.value = ''
|
||||
chatHistoryModalRecords.value = []
|
||||
chatHistoryModalInfo.value = { isChatRoom: false }
|
||||
chatHistoryModalStack.value = []
|
||||
}
|
||||
|
||||
const onChatHistoryVideoThumbError = (record) => {
|
||||
if (!record) return
|
||||
const candidates = record._videoThumbCandidates
|
||||
if (!Array.isArray(candidates) || candidates.length <= 1) {
|
||||
record._videoThumbError = true
|
||||
return
|
||||
}
|
||||
const current = Math.max(0, Number(record._videoThumbCandidateIndex || 0))
|
||||
const next = current + 1
|
||||
if (next < candidates.length) {
|
||||
record._videoThumbCandidateIndex = next
|
||||
record.videoThumbUrl = candidates[next]
|
||||
return
|
||||
}
|
||||
record._videoThumbError = true
|
||||
}
|
||||
|
||||
const onChatHistoryLinkPreviewError = (record) => {
|
||||
if (!record) return
|
||||
const candidates = record._linkPreviewCandidates
|
||||
if (!Array.isArray(candidates) || candidates.length <= 1) {
|
||||
record._linkPreviewError = true
|
||||
return
|
||||
}
|
||||
const current = Math.max(0, Number(record._linkPreviewCandidateIndex || 0))
|
||||
const next = current + 1
|
||||
if (next < candidates.length) {
|
||||
record._linkPreviewCandidateIndex = next
|
||||
record.preview = candidates[next]
|
||||
record._linkPreviewError = false
|
||||
return
|
||||
}
|
||||
record._linkPreviewError = true
|
||||
}
|
||||
|
||||
const onChatHistoryFromAvatarLoad = (record) => {
|
||||
try {
|
||||
if (record) {
|
||||
record._fromAvatarImgOk = true
|
||||
record._fromAvatarImgError = false
|
||||
record._fromAvatarLast = String(record.fromAvatar || '').trim()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const onChatHistoryFromAvatarError = (record) => {
|
||||
try {
|
||||
if (record) {
|
||||
record._fromAvatarImgOk = false
|
||||
record._fromAvatarImgError = true
|
||||
record._fromAvatarLast = String(record.fromAvatar || '').trim()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const onChatHistoryQuoteThumbError = (record) => {
|
||||
if (!record || !record.quote) return
|
||||
const candidates = record._quoteThumbCandidates
|
||||
if (!Array.isArray(candidates) || candidates.length <= 1) {
|
||||
record._quoteThumbError = true
|
||||
return
|
||||
}
|
||||
const current = Math.max(0, Number(record._quoteThumbCandidateIndex || 0))
|
||||
const next = current + 1
|
||||
if (next < candidates.length) {
|
||||
record._quoteThumbCandidateIndex = next
|
||||
record.quote.thumbUrl = candidates[next]
|
||||
return
|
||||
}
|
||||
record._quoteThumbError = true
|
||||
}
|
||||
|
||||
const openChatHistoryQuote = (record) => {
|
||||
if (!process.client) return
|
||||
const quote = record?.quote
|
||||
if (!quote) return
|
||||
const kind = String(quote.kind || '')
|
||||
const url = String(quote.url || '').trim()
|
||||
if (!url) return
|
||||
|
||||
if (kind === 'video') {
|
||||
openVideoPreview(url, quote?.thumbUrl)
|
||||
return
|
||||
}
|
||||
if (kind === 'image' || kind === 'emoji') {
|
||||
openImagePreview(url)
|
||||
}
|
||||
}
|
||||
|
||||
const getChatHistoryLinkFromText = (record) => {
|
||||
const from = String(record?.from || '').trim()
|
||||
if (from) return from
|
||||
const url = String(record?.url || '').trim()
|
||||
if (!url) return ''
|
||||
try { return new URL(url).hostname || '' } catch { return '' }
|
||||
}
|
||||
|
||||
const getChatHistoryLinkFromAvatarText = (record) => {
|
||||
const text = String(getChatHistoryLinkFromText(record) || '').trim()
|
||||
return text ? (Array.from(text)[0] || '') : ''
|
||||
}
|
||||
|
||||
const openUrlInBrowser = (url) => {
|
||||
const next = String(url || '').trim()
|
||||
if (!next) return
|
||||
try { window.open(next, '_blank', 'noopener,noreferrer') } catch {}
|
||||
}
|
||||
|
||||
const resolveChatHistoryLinkRecord = async (record) => {
|
||||
if (!process.client || !record || !selectedAccount.value) return null
|
||||
const serverId = String(record?.fromnewmsgid || '').trim()
|
||||
if (!serverId || record._linkResolving) return null
|
||||
|
||||
record._linkResolving = true
|
||||
try {
|
||||
const response = await api.resolveAppMsg({
|
||||
account: selectedAccount.value,
|
||||
server_id: serverId
|
||||
})
|
||||
if (response && typeof response === 'object') {
|
||||
const title = String(response.title || '').trim()
|
||||
const content = String(response.content || '').trim()
|
||||
const url = String(response.url || '').trim()
|
||||
const from = String(response.from || '').trim()
|
||||
|
||||
const normalizePreviewUrl = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
if (/^\/api\/chat\/media\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw
|
||||
if (!/^https?:\/\//i.test(raw)) return ''
|
||||
try {
|
||||
const host = new URL(raw).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
}
|
||||
} catch {}
|
||||
return raw
|
||||
}
|
||||
|
||||
if (title) record.title = title
|
||||
if (content && !stripWeChatInvisible(record.content)) record.content = content
|
||||
if (url) record.url = url
|
||||
if (from) record.from = from
|
||||
if (response.linkStyle) record.linkStyle = String(response.linkStyle || '').trim()
|
||||
if (response.linkType) record.linkType = String(response.linkType || '').trim()
|
||||
|
||||
const fromUsername = String(response.fromUsername || '').trim()
|
||||
if (fromUsername) record.fromUsername = fromUsername
|
||||
const fromAvatarUrl = fromUsername
|
||||
? `${apiBase}/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}`
|
||||
: (url ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(url)}` : '')
|
||||
if (fromAvatarUrl) {
|
||||
const last = String(record._fromAvatarLast || '').trim()
|
||||
record.fromAvatar = fromAvatarUrl
|
||||
if (String(fromAvatarUrl).trim() !== last) {
|
||||
record._fromAvatarLast = String(fromAvatarUrl).trim()
|
||||
record._fromAvatarImgOk = false
|
||||
record._fromAvatarImgError = false
|
||||
}
|
||||
}
|
||||
|
||||
const style = String(response.linkStyle || '').trim()
|
||||
const thumb = String(response.thumbUrl || '').trim()
|
||||
const cover = String(response.coverUrl || '').trim()
|
||||
const picked = style === 'cover' ? (cover || thumb) : (thumb || cover)
|
||||
const previewResolved = normalizePreviewUrl(picked)
|
||||
if (previewResolved) {
|
||||
const currentPreview = String(record.preview || '').trim()
|
||||
const candidates = Array.isArray(record._linkPreviewCandidates) ? record._linkPreviewCandidates.slice() : []
|
||||
if (currentPreview && !candidates.includes(currentPreview)) candidates.push(currentPreview)
|
||||
if (!candidates.includes(previewResolved)) candidates.push(previewResolved)
|
||||
record._linkPreviewCandidates = candidates
|
||||
if (!currentPreview || record._linkPreviewError) {
|
||||
record.preview = previewResolved
|
||||
record._linkPreviewCandidateIndex = candidates.indexOf(previewResolved)
|
||||
record._linkPreviewError = false
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
} catch {}
|
||||
finally {
|
||||
try { record._linkResolving = false } catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const resolveChatHistoryLinkRecords = (windowItem) => {
|
||||
if (!process.client) return
|
||||
const records = Array.isArray(windowItem?.records) ? windowItem.records : []
|
||||
const targets = records.filter((record) => {
|
||||
if (!record) return false
|
||||
if (String(record.renderType || '') !== 'link') return false
|
||||
if (!String(record.fromnewmsgid || '').trim()) return false
|
||||
const fromMissing = String(record.from || '').trim() === ''
|
||||
const previewMissing = !String(record.preview || '').trim()
|
||||
const urlMissing = !String(record.url || '').trim()
|
||||
const fromAvatarMissing = !String(record.fromAvatar || '').trim()
|
||||
return fromMissing || previewMissing || urlMissing || fromAvatarMissing
|
||||
})
|
||||
if (!targets.length) return
|
||||
;(async () => {
|
||||
for (const target of targets.slice(0, 12)) {
|
||||
await resolveChatHistoryLinkRecord(target)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const openChatHistoryLinkWindow = (record) => {
|
||||
if (!process.client) return
|
||||
const title = String(record?.title || record?.content || '链接').trim()
|
||||
const url = String(record?.url || '').trim()
|
||||
const preview = String(record?.preview || '').trim()
|
||||
const from = String(record?.from || '').trim()
|
||||
const fromAvatar = String(record?.fromAvatar || '').trim()
|
||||
const needResolve = !!String(record?.fromnewmsgid || '').trim() && (!url || !from || !preview || !fromAvatar)
|
||||
const windowItem = openFloatingWindow({
|
||||
kind: 'link',
|
||||
title: title || '链接',
|
||||
url,
|
||||
content: String(record?.content || '').trim(),
|
||||
preview,
|
||||
from,
|
||||
fromAvatar,
|
||||
width: 520,
|
||||
height: 420,
|
||||
loading: needResolve
|
||||
})
|
||||
if (!windowItem) return
|
||||
focusFloatingWindow(windowItem.id)
|
||||
try {
|
||||
windowItem._linkPreviewCandidates = Array.isArray(record?._linkPreviewCandidates) ? record._linkPreviewCandidates.slice() : (preview ? [preview] : [])
|
||||
windowItem._linkPreviewCandidateIndex = Math.max(0, Number(record?._linkPreviewCandidateIndex || 0))
|
||||
windowItem._linkPreviewError = false
|
||||
windowItem._fromAvatarLast = fromAvatar
|
||||
windowItem._fromAvatarImgOk = !!record?._fromAvatarImgOk
|
||||
windowItem._fromAvatarImgError = !!record?._fromAvatarImgError
|
||||
windowItem.fromnewmsgid = String(record?.fromnewmsgid || '').trim()
|
||||
} catch {}
|
||||
if (needResolve) {
|
||||
;(async () => {
|
||||
await resolveChatHistoryLinkRecord(windowItem)
|
||||
windowItem.loading = false
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
const openChatHistoryModal = (message) => {
|
||||
if (!process.client) return
|
||||
const { title0, info0, records0 } = buildChatHistoryWindowPayload(message, normalizeRecordItem)
|
||||
const windowItem = openFloatingWindow({
|
||||
kind: 'chatHistory',
|
||||
title: title0 || '聊天记录',
|
||||
info: info0,
|
||||
records: records0,
|
||||
width: 560,
|
||||
height: Math.round(Math.max(420, (window.innerHeight || 700) * 0.78))
|
||||
})
|
||||
if (!windowItem) return
|
||||
try { resolveChatHistoryLinkRecords(windowItem) } catch {}
|
||||
}
|
||||
|
||||
const openNestedChatHistory = (record) => {
|
||||
if (!process.client) return
|
||||
const title = String(record?.title || '聊天记录')
|
||||
const content = String(record?.content || '')
|
||||
const recordItem = String(record?.recordItem || '').trim()
|
||||
const serverId = String(record?.fromnewmsgid || '').trim()
|
||||
|
||||
const { info0, records0 } = buildChatHistoryWindowPayload({ title, content, recordItem }, normalizeRecordItem)
|
||||
const windowItem = openFloatingWindow({
|
||||
kind: 'chatHistory',
|
||||
title: title || '聊天记录',
|
||||
info: info0,
|
||||
records: records0,
|
||||
width: 560,
|
||||
height: Math.round(Math.max(420, (window.innerHeight || 700) * 0.78)),
|
||||
loading: false
|
||||
})
|
||||
if (!windowItem) return
|
||||
try { resolveChatHistoryLinkRecords(windowItem) } catch {}
|
||||
|
||||
if (!serverId || !selectedAccount.value || record?._nestedResolving || !isChatHistoryRecordItemIncomplete(recordItem)) return
|
||||
record._nestedResolving = true
|
||||
windowItem.loading = true
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const response = await api.resolveNestedChatHistory({
|
||||
account: selectedAccount.value,
|
||||
server_id: serverId
|
||||
})
|
||||
const resolved = String(response?.recordItem || '').trim()
|
||||
if (!resolved) return
|
||||
windowItem.title = String(response?.title || title || '聊天记录')
|
||||
const parsed = parseChatHistoryRecord(resolved)
|
||||
windowItem.info = parsed?.info || { isChatRoom: false, count: 0 }
|
||||
const items = Array.isArray(parsed?.items) ? parsed.items : []
|
||||
windowItem.records = items.length ? enhanceChatHistoryRecords(items.map(normalizeRecordItem)) : []
|
||||
if (!windowItem.records.length) {
|
||||
const lines = String(response?.content || content || '').trim().split(/\r?\n/).map((item) => item.trim()).filter(Boolean)
|
||||
windowItem.info = { isChatRoom: false, count: 0 }
|
||||
windowItem.records = lines.map((line, idx) => normalizeRecordItem({
|
||||
id: String(idx),
|
||||
datatype: '1',
|
||||
sourcename: '',
|
||||
sourcetime: '',
|
||||
content: line,
|
||||
renderType: 'text'
|
||||
}))
|
||||
}
|
||||
try { resolveChatHistoryLinkRecords(windowItem) } catch {}
|
||||
} catch {}
|
||||
finally {
|
||||
windowItem.loading = false
|
||||
try { record._nestedResolving = false } catch {}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
return {
|
||||
floatingWindows,
|
||||
chatHistoryModalVisible,
|
||||
chatHistoryModalTitle,
|
||||
chatHistoryModalRecords,
|
||||
chatHistoryModalInfo,
|
||||
chatHistoryModalStack,
|
||||
goBackChatHistoryModal,
|
||||
closeChatHistoryModal,
|
||||
getFloatingWindowById,
|
||||
focusFloatingWindow,
|
||||
closeFloatingWindow,
|
||||
closeTopFloatingWindow,
|
||||
openFloatingWindow,
|
||||
startFloatingWindowDrag,
|
||||
onFloatingWindowMouseMove,
|
||||
onFloatingWindowMouseUp,
|
||||
formatChatHistoryVideoDuration,
|
||||
getChatHistoryPreviewLines,
|
||||
onChatHistoryVideoThumbError,
|
||||
onChatHistoryLinkPreviewError,
|
||||
onChatHistoryFromAvatarLoad,
|
||||
onChatHistoryFromAvatarError,
|
||||
onChatHistoryQuoteThumbError,
|
||||
openChatHistoryQuote,
|
||||
getChatHistoryLinkFromText,
|
||||
getChatHistoryLinkFromAvatarText,
|
||||
openUrlInBrowser,
|
||||
resolveChatHistoryLinkRecord,
|
||||
resolveChatHistoryLinkRecords,
|
||||
openChatHistoryLinkWindow,
|
||||
openChatHistoryModal,
|
||||
openNestedChatHistory
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
formatFileSize,
|
||||
formatTimeDivider,
|
||||
getVoiceDurationInSeconds,
|
||||
getVoiceWidth
|
||||
} from '~/lib/chat/formatters'
|
||||
import { createMessageNormalizer, dedupeMessagesById } from '~/lib/chat/message-normalizer'
|
||||
|
||||
export const useChatMessages = ({
|
||||
api,
|
||||
apiBase,
|
||||
selectedAccount,
|
||||
selectedContact,
|
||||
realtimeStore,
|
||||
realtimeEnabled,
|
||||
desktopAutoRealtime,
|
||||
privacyMode,
|
||||
searchContext
|
||||
}) => {
|
||||
const messagePageSize = 50
|
||||
|
||||
const allMessages = ref({})
|
||||
const messagesMeta = ref({})
|
||||
const isLoadingMessages = ref(false)
|
||||
const messagesError = ref('')
|
||||
const messageContainerRef = ref(null)
|
||||
const activeMessagesFor = ref('')
|
||||
const showJumpToBottom = ref(false)
|
||||
let lastRenderMessagesFingerprint = ''
|
||||
|
||||
const isDesktopRenderer = () => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
return !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
const logMessagePhase = (phase, details = {}) => {
|
||||
if (!isDesktopRenderer()) return
|
||||
try {
|
||||
window.wechatDesktop?.logDebug?.('chat-messages', phase, details)
|
||||
} catch {}
|
||||
console.info(`[chat-messages] ${phase}`, {
|
||||
account: String(selectedAccount.value || '').trim(),
|
||||
selectedUsername: String(selectedContact.value?.username || '').trim(),
|
||||
activeMessagesFor: String(activeMessagesFor.value || '').trim(),
|
||||
...details
|
||||
})
|
||||
}
|
||||
|
||||
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 = new Map()
|
||||
const currentPlayingVoice = ref(null)
|
||||
const playingVoiceId = ref(null)
|
||||
|
||||
const highlightServerIdStr = ref('')
|
||||
const highlightMessageId = ref('')
|
||||
let highlightTimer = null
|
||||
|
||||
const messageTypeFilter = ref('all')
|
||||
const messageTypeFilterOptions = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'emoji', label: '表情' },
|
||||
{ value: 'video', label: '视频' },
|
||||
{ value: 'voice', label: '语音' },
|
||||
{ value: 'file', label: '文件' },
|
||||
{ value: 'link', label: '链接' },
|
||||
{ value: 'quote', label: '引用' },
|
||||
{ value: 'chatHistory', label: '聊天记录' },
|
||||
{ value: 'transfer', label: '转账' },
|
||||
{ value: 'redPacket', label: '红包' },
|
||||
{ value: 'location', label: '位置' },
|
||||
{ value: 'voip', label: '通话' },
|
||||
{ value: 'system', label: '系统' }
|
||||
]
|
||||
|
||||
const normalizeMessage = createMessageNormalizer({
|
||||
apiBase,
|
||||
getSelectedAccount: () => selectedAccount.value,
|
||||
getSelectedContact: () => selectedContact.value
|
||||
})
|
||||
|
||||
const messages = computed(() => {
|
||||
if (!selectedContact.value) return []
|
||||
return allMessages.value[selectedContact.value.username] || []
|
||||
})
|
||||
|
||||
const hasMoreMessages = computed(() => {
|
||||
if (!selectedContact.value) return false
|
||||
const key = selectedContact.value.username
|
||||
const meta = messagesMeta.value[key]
|
||||
if (!meta) return false
|
||||
if (meta.hasMore != null) return !!meta.hasMore
|
||||
const total = Number(meta.total || 0)
|
||||
const loaded = messages.value.length
|
||||
return total > loaded
|
||||
})
|
||||
|
||||
const reverseMessageSides = ref(false)
|
||||
const reverseSidesStorageKey = computed(() => {
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const username = String(selectedContact.value?.username || '').trim()
|
||||
if (account && username) return `wechatda:reverse_message_sides:${account}:${username}`
|
||||
return 'wechatda:reverse_message_sides:global'
|
||||
})
|
||||
|
||||
const loadReverseMessageSides = () => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
const value = localStorage.getItem(reverseSidesStorageKey.value)
|
||||
reverseMessageSides.value = value === '1'
|
||||
} catch {}
|
||||
}
|
||||
|
||||
watch(reverseSidesStorageKey, () => loadReverseMessageSides(), { immediate: true })
|
||||
watch(reverseMessageSides, (value) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(reverseSidesStorageKey.value, value ? '1' : '0')
|
||||
} catch {}
|
||||
})
|
||||
|
||||
const toggleReverseMessageSides = () => {
|
||||
reverseMessageSides.value = !reverseMessageSides.value
|
||||
}
|
||||
|
||||
const renderMessages = computed(() => {
|
||||
const list = messages.value || []
|
||||
const reverseSides = !!reverseMessageSides.value
|
||||
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
|
||||
const rendered = list.map((message) => {
|
||||
const ts = Number(message.createTime || 0)
|
||||
const show = !previousTs || (ts && Math.abs(ts - previousTs) >= 300)
|
||||
if (ts) previousTs = ts
|
||||
const originalIsSent = !!message?.isSent
|
||||
return {
|
||||
...message,
|
||||
_originalIsSent: originalIsSent,
|
||||
isSent: reverseSides ? !originalIsSent : originalIsSent,
|
||||
showTimeDivider: !!show,
|
||||
timeDivider: formatTimeDivider(ts)
|
||||
}
|
||||
})
|
||||
if (shouldLogRender) {
|
||||
lastRenderMessagesFingerprint = fingerprint
|
||||
logMessagePhase('renderMessages:end', {
|
||||
count: rendered.length,
|
||||
reverseSides
|
||||
})
|
||||
}
|
||||
return rendered
|
||||
})
|
||||
|
||||
const updateJumpToBottomState = () => {
|
||||
const container = messageContainerRef.value
|
||||
if (!container) {
|
||||
showJumpToBottom.value = false
|
||||
return
|
||||
}
|
||||
const distance = container.scrollHeight - container.scrollTop - container.clientHeight
|
||||
showJumpToBottom.value = distance > 160
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const container = messageContainerRef.value
|
||||
if (!container) return
|
||||
container.scrollTop = container.scrollHeight
|
||||
updateJumpToBottomState()
|
||||
}
|
||||
|
||||
const flashMessage = (id) => {
|
||||
highlightMessageId.value = String(id || '').trim()
|
||||
if (highlightTimer) clearTimeout(highlightTimer)
|
||||
highlightTimer = setTimeout(() => {
|
||||
highlightMessageId.value = ''
|
||||
highlightServerIdStr.value = ''
|
||||
highlightTimer = null
|
||||
}, 2200)
|
||||
}
|
||||
|
||||
const scrollToMessageId = async (id) => {
|
||||
const target = String(id || '').trim()
|
||||
if (!target) return false
|
||||
await nextTick()
|
||||
const container = messageContainerRef.value
|
||||
const element = container?.querySelector?.(`[data-msg-id="${CSS.escape(target)}"]`)
|
||||
if (!element || typeof element.scrollIntoView !== 'function') return false
|
||||
element.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
return true
|
||||
}
|
||||
|
||||
const openImagePreview = (url) => {
|
||||
previewImageUrl.value = String(url || '').trim() || null
|
||||
}
|
||||
|
||||
const closeImagePreview = () => {
|
||||
previewImageUrl.value = null
|
||||
}
|
||||
|
||||
const openVideoPreview = (url, poster) => {
|
||||
previewVideoUrl.value = String(url || '').trim() || null
|
||||
previewVideoPosterUrl.value = String(poster || '').trim()
|
||||
previewVideoError.value = ''
|
||||
}
|
||||
|
||||
const closeVideoPreview = () => {
|
||||
previewVideoUrl.value = null
|
||||
previewVideoPosterUrl.value = ''
|
||||
previewVideoError.value = ''
|
||||
}
|
||||
|
||||
const onPreviewVideoError = () => {
|
||||
previewVideoError.value = '视频加载失败,可能是资源不存在或无法访问。'
|
||||
}
|
||||
|
||||
const setVoiceRef = (id, element) => {
|
||||
const key = String(id || '').trim()
|
||||
if (!key) return
|
||||
if (element) {
|
||||
voiceRefs.set(key, element)
|
||||
} else {
|
||||
voiceRefs.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const playVoiceById = async (voiceId) => {
|
||||
const key = String(voiceId || '').trim()
|
||||
if (!key) return
|
||||
const audio = voiceRefs.get(key)
|
||||
if (!audio) return
|
||||
|
||||
try {
|
||||
if (currentPlayingVoice.value && currentPlayingVoice.value !== audio) {
|
||||
currentPlayingVoice.value.pause()
|
||||
currentPlayingVoice.value.currentTime = 0
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (currentPlayingVoice.value === audio && !audio.paused) {
|
||||
try {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
} catch {}
|
||||
currentPlayingVoice.value = null
|
||||
playingVoiceId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await audio.play()
|
||||
currentPlayingVoice.value = audio
|
||||
playingVoiceId.value = key
|
||||
audio.onended = () => {
|
||||
if (playingVoiceId.value === key) {
|
||||
currentPlayingVoice.value = null
|
||||
playingVoiceId.value = null
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const playVoice = async (message) => {
|
||||
await playVoiceById(message?.id)
|
||||
}
|
||||
|
||||
const getQuoteVoiceId = (message) => `quote-${String(message?.quoteServerId || message?.id || '')}`
|
||||
|
||||
const playQuoteVoice = async (message) => {
|
||||
await playVoiceById(getQuoteVoiceId(message))
|
||||
}
|
||||
|
||||
const isQuotedVoice = (message) => String(message?.quoteType || '').trim() === '34'
|
||||
const isQuotedImage = (message) => {
|
||||
return !!String(message?.quoteImageUrl || '').trim() || String(message?.quoteContent || '').trim() === '[图片]'
|
||||
}
|
||||
const isQuotedLink = (message) => {
|
||||
return String(message?.quoteType || '').trim() === '5' || !!String(message?.quoteThumbUrl || '').trim()
|
||||
}
|
||||
const getQuotedLinkText = (message) => {
|
||||
const title = String(message?.quoteTitle || '').trim()
|
||||
const content = String(message?.quoteContent || '').trim()
|
||||
return content || title || ''
|
||||
}
|
||||
|
||||
const onQuoteImageError = (message) => {
|
||||
if (message) message._quoteImageError = true
|
||||
}
|
||||
|
||||
const onQuoteThumbError = (message) => {
|
||||
if (message) message._quoteThumbError = true
|
||||
}
|
||||
|
||||
const onAvatarError = (event, target) => {
|
||||
try { event?.target && (event.target.style.display = 'none') } catch {}
|
||||
try { if (target) target.avatar = null } catch {}
|
||||
}
|
||||
|
||||
const shouldShowEmojiDownload = (message) => {
|
||||
if (!message?.emojiMd5) return false
|
||||
const url = String(message?.emojiRemoteUrl || '').trim()
|
||||
if (!url) return false
|
||||
if (!/^https?:\/\//i.test(url)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const onEmojiDownloadClick = async (message) => {
|
||||
if (!process.client) return
|
||||
if (!message?.emojiMd5) return
|
||||
if (!selectedAccount.value) return
|
||||
|
||||
const emojiUrl = String(message?.emojiRemoteUrl || '').trim()
|
||||
if (!emojiUrl) {
|
||||
window.alert('该表情没有可用的下载地址')
|
||||
return
|
||||
}
|
||||
if (message._emojiDownloading) return
|
||||
|
||||
message._emojiDownloading = true
|
||||
try {
|
||||
await api.downloadChatEmoji({
|
||||
account: selectedAccount.value,
|
||||
md5: message.emojiMd5,
|
||||
emoji_url: emojiUrl,
|
||||
force: false
|
||||
})
|
||||
message._emojiDownloaded = true
|
||||
if (message.emojiLocalUrl) {
|
||||
message.emojiUrl = message.emojiLocalUrl
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(error?.message || '下载失败')
|
||||
} finally {
|
||||
message._emojiDownloading = false
|
||||
}
|
||||
}
|
||||
|
||||
const onFileClick = async (message) => {
|
||||
if (!message?.fileMd5) return
|
||||
try {
|
||||
if (!selectedAccount.value) return
|
||||
if (!selectedContact.value?.username) return
|
||||
await api.openChatMediaFolder({
|
||||
account: selectedAccount.value,
|
||||
username: selectedContact.value.username,
|
||||
kind: 'file',
|
||||
md5: message.fileMd5
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('打开文件夹失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMessages = async ({ username, reset }) => {
|
||||
if (!username || !selectedAccount.value) return
|
||||
|
||||
logMessagePhase('loadMessages:enter', {
|
||||
username,
|
||||
reset
|
||||
})
|
||||
messagesError.value = ''
|
||||
isLoadingMessages.value = true
|
||||
activeMessagesFor.value = username
|
||||
|
||||
try {
|
||||
const existing = allMessages.value[username] || []
|
||||
const container = messageContainerRef.value
|
||||
const beforeScrollHeight = container ? container.scrollHeight : 0
|
||||
const beforeScrollTop = container ? container.scrollTop : 0
|
||||
const offset = reset ? 0 : existing.length
|
||||
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
username,
|
||||
limit: messagePageSize,
|
||||
offset,
|
||||
order: 'asc'
|
||||
}
|
||||
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
|
||||
params.render_types = messageTypeFilter.value
|
||||
}
|
||||
if (realtimeEnabled.value) {
|
||||
params.source = 'realtime'
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
const existingIds = new Set(existing.map((message) => String(message?.id || '')))
|
||||
const older = mapped.filter((message) => {
|
||||
const id = String(message?.id || '')
|
||||
if (!id) return true
|
||||
if (existingIds.has(id)) return false
|
||||
existingIds.add(id)
|
||||
return true
|
||||
})
|
||||
allMessages.value = {
|
||||
...allMessages.value,
|
||||
[username]: [...older, ...existing]
|
||||
}
|
||||
}
|
||||
logMessagePhase('loadMessages:state-commit:end', {
|
||||
username,
|
||||
storedCount: (allMessages.value[username] || []).length
|
||||
})
|
||||
|
||||
messagesMeta.value = {
|
||||
...messagesMeta.value,
|
||||
[username]: {
|
||||
total: Number(response?.total || 0),
|
||||
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) {
|
||||
nextContainer.scrollTop = nextContainer.scrollHeight
|
||||
} else {
|
||||
const afterScrollHeight = nextContainer.scrollHeight
|
||||
nextContainer.scrollTop = beforeScrollTop + (afterScrollHeight - beforeScrollHeight)
|
||||
}
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const loadMoreMessages = async () => {
|
||||
if (!selectedContact.value) return
|
||||
if (searchContext.value?.active) return
|
||||
await loadMessages({ username: selectedContact.value.username, reset: false })
|
||||
}
|
||||
|
||||
const refreshSelectedMessages = async () => {
|
||||
if (!selectedContact.value) return
|
||||
await loadMessages({ username: selectedContact.value.username, reset: true })
|
||||
}
|
||||
|
||||
const refreshRealtimeIncremental = async () => {
|
||||
if (!realtimeEnabled.value || !selectedAccount.value || !selectedContact.value?.username) return
|
||||
if (searchContext.value?.active || isLoadingMessages.value) return
|
||||
|
||||
const username = selectedContact.value.username
|
||||
const existing = allMessages.value[username] || []
|
||||
if (!existing.length) return
|
||||
|
||||
const container = messageContainerRef.value
|
||||
const atBottom = !!container && (container.scrollHeight - container.scrollTop - container.clientHeight) < 80
|
||||
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
username,
|
||||
limit: 30,
|
||||
offset: 0,
|
||||
order: 'asc',
|
||||
source: 'realtime'
|
||||
}
|
||||
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
|
||||
params.render_types = messageTypeFilter.value
|
||||
}
|
||||
|
||||
const response = await api.listChatMessages(params)
|
||||
if (selectedContact.value?.username !== username) return
|
||||
|
||||
const latest = (response?.messages || []).map(normalizeMessage)
|
||||
const seenIds = new Set(existing.map((message) => String(message?.id || '')))
|
||||
const newOnes = []
|
||||
for (const message of latest) {
|
||||
const id = String(message?.id || '')
|
||||
if (!id || seenIds.has(id)) continue
|
||||
seenIds.add(id)
|
||||
newOnes.push(message)
|
||||
}
|
||||
if (!newOnes.length) return
|
||||
|
||||
allMessages.value = { ...allMessages.value, [username]: [...existing, ...newOnes] }
|
||||
|
||||
await nextTick()
|
||||
const nextContainer = messageContainerRef.value
|
||||
if (nextContainer && atBottom) {
|
||||
nextContainer.scrollTop = nextContainer.scrollHeight
|
||||
}
|
||||
updateJumpToBottomState()
|
||||
}
|
||||
|
||||
let realtimeRefreshFuture = null
|
||||
let realtimeRefreshQueued = false
|
||||
|
||||
const queueRealtimeRefresh = () => {
|
||||
if (realtimeRefreshFuture) {
|
||||
realtimeRefreshQueued = true
|
||||
return
|
||||
}
|
||||
|
||||
realtimeRefreshFuture = refreshRealtimeIncremental().finally(() => {
|
||||
realtimeRefreshFuture = null
|
||||
if (realtimeRefreshQueued) {
|
||||
realtimeRefreshQueued = false
|
||||
queueRealtimeRefresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const tryEnableRealtimeAuto = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!desktopAutoRealtime.value || realtimeEnabled.value || !selectedAccount.value) return
|
||||
try {
|
||||
await realtimeStore.enable({ silent: true })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const 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 = ''
|
||||
highlightMessageId.value = ''
|
||||
highlightServerIdStr.value = ''
|
||||
}
|
||||
|
||||
const contactProfileCardOpen = ref(false)
|
||||
const contactProfileCardMessageId = ref('')
|
||||
const contactProfileLoading = ref(false)
|
||||
const contactProfileError = ref('')
|
||||
const contactProfileData = ref(null)
|
||||
let contactProfileHoverHideTimer = null
|
||||
|
||||
const contactProfileResolvedName = computed(() => {
|
||||
const profile = contactProfileData.value || {}
|
||||
const displayName = String(profile?.displayName || '').trim()
|
||||
if (displayName) return displayName
|
||||
const contactName = String(selectedContact.value?.name || '').trim()
|
||||
if (contactName) return contactName
|
||||
return String(profile?.username || selectedContact.value?.username || '').trim()
|
||||
})
|
||||
|
||||
const contactProfileResolvedUsername = computed(() => {
|
||||
const profile = contactProfileData.value || {}
|
||||
return String(profile?.username || selectedContact.value?.username || '').trim()
|
||||
})
|
||||
|
||||
const contactProfileResolvedNickname = computed(() => String(contactProfileData.value?.nickname || '').trim())
|
||||
const contactProfileResolvedAlias = computed(() => String(contactProfileData.value?.alias || '').trim())
|
||||
const contactProfileResolvedRegion = computed(() => String(contactProfileData.value?.region || '').trim())
|
||||
const contactProfileResolvedRemark = computed(() => String(contactProfileData.value?.remark || '').trim())
|
||||
const contactProfileResolvedSignature = computed(() => String(contactProfileData.value?.signature || '').trim())
|
||||
const contactProfileResolvedSource = computed(() => String(contactProfileData.value?.source || '').trim())
|
||||
const contactProfileResolvedAvatar = computed(() => {
|
||||
const avatar = String(contactProfileData.value?.avatar || '').trim()
|
||||
if (avatar) return avatar
|
||||
return String(selectedContact.value?.avatar || '').trim()
|
||||
})
|
||||
|
||||
const contactProfileResolvedGender = computed(() => {
|
||||
const value = contactProfileData.value?.gender
|
||||
if (value == null || value === '') return ''
|
||||
const gender = Number(value)
|
||||
if (!Number.isFinite(gender)) return ''
|
||||
if (gender === 1) return '男'
|
||||
if (gender === 2) return '女'
|
||||
if (gender === 0) return '未知'
|
||||
return String(gender)
|
||||
})
|
||||
|
||||
const contactProfileResolvedSourceScene = computed(() => {
|
||||
const value = contactProfileData.value?.sourceScene
|
||||
if (value == null || value === '') return null
|
||||
const scene = Number(value)
|
||||
return Number.isFinite(scene) ? scene : null
|
||||
})
|
||||
|
||||
const fetchContactProfile = async (options = {}) => {
|
||||
const username = String(options?.username || contactProfileData.value?.username || selectedContact.value?.username || '').trim()
|
||||
const displayNameFallback = String(options?.displayName || '').trim()
|
||||
const avatarFallback = String(options?.avatar || '').trim()
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
if (!username || !account) {
|
||||
contactProfileData.value = null
|
||||
return
|
||||
}
|
||||
|
||||
contactProfileLoading.value = true
|
||||
contactProfileError.value = ''
|
||||
try {
|
||||
const response = await api.listChatContacts({
|
||||
account,
|
||||
include_friends: true,
|
||||
include_groups: true,
|
||||
include_officials: true
|
||||
})
|
||||
const list = Array.isArray(response?.contacts) ? response.contacts : []
|
||||
const matched = list.find((item) => String(item?.username || '').trim() === username)
|
||||
if (matched) {
|
||||
const normalized = { ...matched, username }
|
||||
if (!String(normalized.displayName || '').trim() && displayNameFallback) {
|
||||
normalized.displayName = displayNameFallback
|
||||
}
|
||||
if (!String(normalized.avatar || '').trim() && avatarFallback) {
|
||||
normalized.avatar = avatarFallback
|
||||
}
|
||||
contactProfileData.value = normalized
|
||||
} else {
|
||||
contactProfileData.value = {
|
||||
username,
|
||||
displayName: displayNameFallback || selectedContact.value?.name || username,
|
||||
avatar: avatarFallback || selectedContact.value?.avatar || '',
|
||||
nickname: '',
|
||||
alias: '',
|
||||
gender: null,
|
||||
region: '',
|
||||
remark: '',
|
||||
signature: '',
|
||||
source: '',
|
||||
sourceScene: null
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
contactProfileData.value = {
|
||||
username,
|
||||
displayName: displayNameFallback || selectedContact.value?.name || username,
|
||||
avatar: avatarFallback || selectedContact.value?.avatar || '',
|
||||
nickname: '',
|
||||
alias: '',
|
||||
gender: null,
|
||||
region: '',
|
||||
remark: '',
|
||||
signature: '',
|
||||
source: '',
|
||||
sourceScene: null
|
||||
}
|
||||
contactProfileError.value = error?.message || '加载联系人资料失败'
|
||||
} finally {
|
||||
contactProfileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearContactProfileHoverHideTimer = () => {
|
||||
if (contactProfileHoverHideTimer) {
|
||||
clearTimeout(contactProfileHoverHideTimer)
|
||||
contactProfileHoverHideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const closeContactProfileCard = () => {
|
||||
contactProfileCardOpen.value = false
|
||||
contactProfileCardMessageId.value = ''
|
||||
}
|
||||
|
||||
const onMessageAvatarMouseEnter = async (message) => {
|
||||
if (!!message?.isSent) return
|
||||
const messageId = String(message?.id ?? '').trim()
|
||||
if (!messageId) return
|
||||
const username = String(message?.senderUsername || '').trim()
|
||||
if (!username || username === 'self') return
|
||||
|
||||
const senderName = String(message?.senderDisplayName || message?.sender || '').trim()
|
||||
const senderAvatar = String(message?.avatar || '').trim()
|
||||
if (!contactProfileData.value || String(contactProfileData.value?.username || '').trim() !== username) {
|
||||
contactProfileData.value = {
|
||||
username,
|
||||
displayName: senderName || username,
|
||||
avatar: senderAvatar,
|
||||
nickname: '',
|
||||
alias: '',
|
||||
gender: null,
|
||||
region: '',
|
||||
remark: '',
|
||||
signature: '',
|
||||
source: '',
|
||||
sourceScene: null
|
||||
}
|
||||
} else {
|
||||
if (!String(contactProfileData.value?.displayName || '').trim() && senderName) {
|
||||
contactProfileData.value.displayName = senderName
|
||||
}
|
||||
if (!String(contactProfileData.value?.avatar || '').trim() && senderAvatar) {
|
||||
contactProfileData.value.avatar = senderAvatar
|
||||
}
|
||||
}
|
||||
|
||||
clearContactProfileHoverHideTimer()
|
||||
contactProfileCardMessageId.value = messageId
|
||||
contactProfileCardOpen.value = true
|
||||
await fetchContactProfile({ username, displayName: senderName, avatar: senderAvatar })
|
||||
}
|
||||
|
||||
const onMessageAvatarMouseLeave = () => {
|
||||
clearContactProfileHoverHideTimer()
|
||||
contactProfileHoverHideTimer = setTimeout(() => {
|
||||
closeContactProfileCard()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const onContactCardMouseEnter = () => {
|
||||
clearContactProfileHoverHideTimer()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedContact.value?.username,
|
||||
() => {
|
||||
clearContactProfileHoverHideTimer()
|
||||
closeContactProfileCard()
|
||||
contactProfileError.value = ''
|
||||
contactProfileData.value = null
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => selectedAccount.value,
|
||||
() => {
|
||||
clearContactProfileHoverHideTimer()
|
||||
closeContactProfileCard()
|
||||
contactProfileError.value = ''
|
||||
contactProfileData.value = null
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (highlightTimer) clearTimeout(highlightTimer)
|
||||
highlightTimer = null
|
||||
clearContactProfileHoverHideTimer()
|
||||
clearVoicePlaybackState()
|
||||
})
|
||||
|
||||
return {
|
||||
allMessages,
|
||||
messagesMeta,
|
||||
messages,
|
||||
renderMessages,
|
||||
hasMoreMessages,
|
||||
isLoadingMessages,
|
||||
messagesError,
|
||||
messageContainerRef,
|
||||
showJumpToBottom,
|
||||
messagePageSize,
|
||||
messageTypeFilter,
|
||||
messageTypeFilterOptions,
|
||||
reverseMessageSides,
|
||||
previewImageUrl,
|
||||
previewVideoUrl,
|
||||
previewVideoPosterUrl,
|
||||
previewVideoError,
|
||||
voiceRefs,
|
||||
currentPlayingVoice,
|
||||
playingVoiceId,
|
||||
highlightServerIdStr,
|
||||
highlightMessageId,
|
||||
contactProfileCardOpen,
|
||||
contactProfileCardMessageId,
|
||||
contactProfileLoading,
|
||||
contactProfileError,
|
||||
contactProfileData,
|
||||
contactProfileResolvedName,
|
||||
contactProfileResolvedUsername,
|
||||
contactProfileResolvedNickname,
|
||||
contactProfileResolvedAlias,
|
||||
contactProfileResolvedGender,
|
||||
contactProfileResolvedRegion,
|
||||
contactProfileResolvedRemark,
|
||||
contactProfileResolvedSignature,
|
||||
contactProfileResolvedSource,
|
||||
contactProfileResolvedSourceScene,
|
||||
contactProfileResolvedAvatar,
|
||||
normalizeMessage,
|
||||
updateJumpToBottomState,
|
||||
scrollToBottom,
|
||||
flashMessage,
|
||||
scrollToMessageId,
|
||||
openImagePreview,
|
||||
closeImagePreview,
|
||||
openVideoPreview,
|
||||
closeVideoPreview,
|
||||
onPreviewVideoError,
|
||||
setVoiceRef,
|
||||
playVoice,
|
||||
playQuoteVoice,
|
||||
getQuoteVoiceId,
|
||||
getVoiceDurationInSeconds,
|
||||
getVoiceWidth,
|
||||
isQuotedVoice,
|
||||
isQuotedImage,
|
||||
isQuotedLink,
|
||||
getQuotedLinkText,
|
||||
onQuoteImageError,
|
||||
onQuoteThumbError,
|
||||
onAvatarError,
|
||||
shouldShowEmojiDownload,
|
||||
onEmojiDownloadClick,
|
||||
onFileClick,
|
||||
toggleReverseMessageSides,
|
||||
loadMessages,
|
||||
loadMoreMessages,
|
||||
refreshSelectedMessages,
|
||||
refreshRealtimeIncremental,
|
||||
queueRealtimeRefresh,
|
||||
tryEnableRealtimeAuto,
|
||||
resetMessageState,
|
||||
fetchContactProfile,
|
||||
clearContactProfileHoverHideTimer,
|
||||
closeContactProfileCard,
|
||||
onMessageAvatarMouseEnter,
|
||||
onMessageAvatarMouseLeave,
|
||||
onContactCardMouseEnter,
|
||||
formatFileSize
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { normalizeSessionPreview } from '~/lib/chat/formatters'
|
||||
|
||||
const SESSION_LIST_WIDTH_KEY = 'ui.chat.session_list_width_physical'
|
||||
const SESSION_LIST_WIDTH_KEY_LEGACY = 'ui.chat.session_list_width'
|
||||
const SESSION_LIST_WIDTH_DEFAULT = 295
|
||||
const SESSION_LIST_WIDTH_MIN = 220
|
||||
const SESSION_LIST_WIDTH_MAX = 520
|
||||
|
||||
export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled, api }) => {
|
||||
const showSearchAccountSwitcher = false
|
||||
|
||||
const contacts = ref([])
|
||||
const selectedContact = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const isLoadingContacts = ref(false)
|
||||
const contactsError = ref('')
|
||||
|
||||
const sessionListWidth = ref(SESSION_LIST_WIDTH_DEFAULT)
|
||||
const sessionListResizing = ref(false)
|
||||
|
||||
let sessionListResizeStartX = 0
|
||||
let sessionListResizeStartWidth = SESSION_LIST_WIDTH_DEFAULT
|
||||
let sessionListResizeStartDpr = 1
|
||||
let sessionListResizePrevCursor = ''
|
||||
let sessionListResizePrevUserSelect = ''
|
||||
|
||||
const availableAccounts = computed(() => {
|
||||
return Array.isArray(chatAccounts?.accounts) ? chatAccounts.accounts : []
|
||||
})
|
||||
|
||||
const clampSessionListWidth = (value) => {
|
||||
const next = Number.isFinite(value) ? value : SESSION_LIST_WIDTH_DEFAULT
|
||||
return Math.min(SESSION_LIST_WIDTH_MAX, Math.max(SESSION_LIST_WIDTH_MIN, Math.round(next)))
|
||||
}
|
||||
|
||||
const loadSessionListWidth = () => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_LIST_WIDTH_KEY)
|
||||
const value = parseInt(String(raw || ''), 10)
|
||||
if (!Number.isNaN(value)) {
|
||||
sessionListWidth.value = clampSessionListWidth(value)
|
||||
return
|
||||
}
|
||||
|
||||
const legacy = localStorage.getItem(SESSION_LIST_WIDTH_KEY_LEGACY)
|
||||
const legacyValue = parseInt(String(legacy || ''), 10)
|
||||
if (!Number.isNaN(legacyValue)) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const converted = clampSessionListWidth(legacyValue * dpr)
|
||||
sessionListWidth.value = converted
|
||||
try {
|
||||
localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(converted))
|
||||
localStorage.removeItem(SESSION_LIST_WIDTH_KEY_LEGACY)
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const saveSessionListWidth = () => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(clampSessionListWidth(sessionListWidth.value)))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const setSessionListResizingActive = (active) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
const body = document.body
|
||||
if (!body) return
|
||||
if (active) {
|
||||
sessionListResizePrevCursor = body.style.cursor || ''
|
||||
sessionListResizePrevUserSelect = body.style.userSelect || ''
|
||||
body.style.cursor = 'col-resize'
|
||||
body.style.userSelect = 'none'
|
||||
} else {
|
||||
body.style.cursor = sessionListResizePrevCursor
|
||||
body.style.userSelect = sessionListResizePrevUserSelect
|
||||
sessionListResizePrevCursor = ''
|
||||
sessionListResizePrevUserSelect = ''
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const onSessionListResizerPointerMove = (event) => {
|
||||
if (!sessionListResizing.value) return
|
||||
const clientX = Number(event?.clientX || 0)
|
||||
sessionListWidth.value = clampSessionListWidth(
|
||||
sessionListResizeStartWidth + (clientX - sessionListResizeStartX) * (sessionListResizeStartDpr || 1)
|
||||
)
|
||||
}
|
||||
|
||||
const stopSessionListResize = () => {
|
||||
if (!process.client) return
|
||||
if (!sessionListResizing.value) return
|
||||
sessionListResizing.value = false
|
||||
setSessionListResizingActive(false)
|
||||
try {
|
||||
window.removeEventListener('pointermove', onSessionListResizerPointerMove)
|
||||
} catch {}
|
||||
saveSessionListWidth()
|
||||
}
|
||||
|
||||
const onSessionListResizerPointerUp = () => {
|
||||
stopSessionListResize()
|
||||
}
|
||||
|
||||
const onSessionListResizerPointerDown = (event) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
event?.preventDefault?.()
|
||||
} catch {}
|
||||
|
||||
sessionListResizing.value = true
|
||||
sessionListResizeStartX = Number(event?.clientX || 0)
|
||||
sessionListResizeStartWidth = Number(sessionListWidth.value || SESSION_LIST_WIDTH_DEFAULT)
|
||||
sessionListResizeStartDpr = window.devicePixelRatio || 1
|
||||
setSessionListResizingActive(true)
|
||||
|
||||
try {
|
||||
window.addEventListener('pointermove', onSessionListResizerPointerMove)
|
||||
window.addEventListener('pointerup', onSessionListResizerPointerUp, { once: true })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const resetSessionListWidth = () => {
|
||||
sessionListWidth.value = SESSION_LIST_WIDTH_DEFAULT
|
||||
saveSessionListWidth()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSessionListWidth()
|
||||
})
|
||||
|
||||
const filteredContacts = computed(() => {
|
||||
const query = String(searchQuery.value || '').trim().toLowerCase()
|
||||
if (!query) return contacts.value
|
||||
return contacts.value.filter((contact) => {
|
||||
const name = String(contact?.name || '').toLowerCase()
|
||||
const username = String(contact?.username || '').toLowerCase()
|
||||
return name.includes(query) || username.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const mapSessions = (sessions) => {
|
||||
return sessions.map((session) => ({
|
||||
id: session.id,
|
||||
name: session.name || session.username || session.id,
|
||||
avatar: session.avatar || null,
|
||||
lastMessage: normalizeSessionPreview(session.lastMessage || ''),
|
||||
lastMessageTime: session.lastMessageTime || '',
|
||||
unreadCount: session.unreadCount || 0,
|
||||
isGroup: !!session.isGroup,
|
||||
isTop: !!session.isTop,
|
||||
username: session.username
|
||||
}))
|
||||
}
|
||||
|
||||
const clearContactsState = (errorMessage = '') => {
|
||||
contacts.value = []
|
||||
selectedContact.value = null
|
||||
contactsError.value = errorMessage
|
||||
}
|
||||
|
||||
const loadSessionsForSelectedAccount = async () => {
|
||||
if (!selectedAccount.value) {
|
||||
clearContactsState('')
|
||||
return []
|
||||
}
|
||||
|
||||
const fetchSessions = async (source) => {
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
limit: 400,
|
||||
include_hidden: false,
|
||||
include_official: false
|
||||
}
|
||||
if (source) params.source = source
|
||||
return api.listChatSessions(params)
|
||||
}
|
||||
|
||||
let sessionsResp = null
|
||||
if (realtimeEnabled?.value) {
|
||||
try {
|
||||
sessionsResp = await fetchSessions('realtime')
|
||||
} catch {
|
||||
sessionsResp = null
|
||||
}
|
||||
}
|
||||
if (!sessionsResp) {
|
||||
sessionsResp = await fetchSessions('')
|
||||
}
|
||||
|
||||
const sessions = Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions : []
|
||||
contacts.value = mapSessions(sessions)
|
||||
contactsError.value = ''
|
||||
return contacts.value
|
||||
}
|
||||
|
||||
const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!selectedAccount.value) return
|
||||
if (isLoadingContacts.value) return
|
||||
|
||||
const previousUsername = selectedContact.value?.username || ''
|
||||
const desiredSource = (sourceOverride != null)
|
||||
? String(sourceOverride || '').trim()
|
||||
: (realtimeEnabled?.value ? 'realtime' : '')
|
||||
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
limit: 400,
|
||||
include_hidden: false,
|
||||
include_official: false
|
||||
}
|
||||
|
||||
let sessionsResp = null
|
||||
if (desiredSource) {
|
||||
try {
|
||||
sessionsResp = await api.listChatSessions({ ...params, source: desiredSource })
|
||||
} catch {
|
||||
sessionsResp = null
|
||||
}
|
||||
}
|
||||
if (!sessionsResp) {
|
||||
try {
|
||||
sessionsResp = await api.listChatSessions(params)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions : []
|
||||
const nextContacts = mapSessions(sessions)
|
||||
contacts.value = nextContacts
|
||||
|
||||
if (previousUsername) {
|
||||
const matched = nextContacts.find((contact) => contact.username === previousUsername)
|
||||
if (matched) selectedContact.value = matched
|
||||
}
|
||||
}
|
||||
|
||||
const loadContacts = async () => {
|
||||
if (contacts.value.length && !isLoadingContacts.value) {
|
||||
return { usedPrefetched: true }
|
||||
}
|
||||
|
||||
isLoadingContacts.value = true
|
||||
contactsError.value = ''
|
||||
try {
|
||||
await chatAccounts.ensureLoaded()
|
||||
|
||||
if (!selectedAccount.value) {
|
||||
clearContactsState(chatAccounts.error || '未检测到已解密账号,请先解密数据库。')
|
||||
return { usedPrefetched: false }
|
||||
}
|
||||
|
||||
await loadSessionsForSelectedAccount()
|
||||
return { usedPrefetched: false }
|
||||
} catch (error) {
|
||||
clearContactsState(error?.message || '加载联系人失败')
|
||||
return { usedPrefetched: false }
|
||||
} finally {
|
||||
isLoadingContacts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showSearchAccountSwitcher,
|
||||
availableAccounts,
|
||||
contacts,
|
||||
selectedContact,
|
||||
searchQuery,
|
||||
filteredContacts,
|
||||
isLoadingContacts,
|
||||
contactsError,
|
||||
sessionListWidth,
|
||||
sessionListResizing,
|
||||
clearContactsState,
|
||||
loadContacts,
|
||||
loadSessionsForSelectedAccount,
|
||||
refreshSessionsForSelectedAccount,
|
||||
onSessionListResizerPointerDown,
|
||||
stopSessionListResize,
|
||||
resetSessionListWidth
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { reportServerError } from '~/utils/server-error-logging'
|
||||
import { reportServerError } from '~/lib/server-error-logging'
|
||||
|
||||
// API请求组合式函数
|
||||
export const useApi = () => {
|
||||
|
||||
@@ -1,14 +1,45 @@
|
||||
import { normalizeApiBase, readApiBaseOverride } from '~/utils/api-settings'
|
||||
import { normalizeApiBase, readApiBaseOverride } from '~/lib/api-settings'
|
||||
|
||||
// Client-side cache so that useApiBase() can be called safely outside
|
||||
// the Nuxt composable context (e.g. inside async callbacks / onMounted chains).
|
||||
let _clientCache = ''
|
||||
|
||||
const shouldIgnoreStoredOverride = () => {
|
||||
if (!process.client || !import.meta.dev) return false
|
||||
return typeof window !== 'undefined' && !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
export const useApiBase = () => {
|
||||
const config = useRuntimeConfig()
|
||||
if (process.client && _clientCache) return _clientCache
|
||||
|
||||
// useRuntimeConfig() requires the Nuxt app context, which is only
|
||||
// guaranteed during synchronous setup. On the client we cache the
|
||||
// result so later (context-less) calls still work.
|
||||
let config
|
||||
try {
|
||||
config = useRuntimeConfig()
|
||||
} catch {
|
||||
// Context unavailable – fall back to cached value or default.
|
||||
return _clientCache || '/api'
|
||||
}
|
||||
|
||||
// Default to same-origin `/api` so Nuxt devProxy / backend-mounted UI both work.
|
||||
// Override priority:
|
||||
// 1) Local UI setting (web + desktop)
|
||||
// 2) NUXT_PUBLIC_API_BASE env/runtime config
|
||||
// 3) `/api`
|
||||
const override = process.client ? readApiBaseOverride() : ''
|
||||
const override = process.client && !shouldIgnoreStoredOverride() ? readApiBaseOverride() : ''
|
||||
const runtime = String(config?.public?.apiBase || '').trim()
|
||||
return normalizeApiBase(override || runtime || '/api')
|
||||
const result = normalizeApiBase(override || runtime || '/api')
|
||||
|
||||
if (process.client) _clientCache = result
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this when the user changes the API base override in settings
|
||||
* so the cached value is refreshed.
|
||||
*/
|
||||
export const invalidateApiBaseCache = () => {
|
||||
_clientCache = ''
|
||||
}
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import { getChatHistoryPreviewLines } from '~/lib/chat/formatters'
|
||||
|
||||
export const isMaybeMd5 = (value) => /^[0-9a-f]{32}$/i.test(String(value || '').trim())
|
||||
|
||||
export const pickFirstMd5 = (...values) => {
|
||||
for (const value of values) {
|
||||
const text = String(value || '').trim()
|
||||
if (isMaybeMd5(text)) return text.toLowerCase()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const normalizeChatHistoryUrl = (value) => String(value || '').trim().replace(/\s+/g, '')
|
||||
|
||||
export const stripWeChatInvisible = (value) => {
|
||||
return String(value || '').replace(/[\u3164\u2800]/g, '').trim()
|
||||
}
|
||||
|
||||
export const parseChatHistoryRecord = (recordItemXml) => {
|
||||
if (!process.client) return { info: null, items: [] }
|
||||
const xml = String(recordItemXml || '').trim()
|
||||
if (!xml) return { info: null, items: [] }
|
||||
|
||||
const normalized = xml
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
|
||||
.replace(/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[\da-fA-F]+;)/g, '&')
|
||||
|
||||
let doc
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(normalized, 'text/xml')
|
||||
} catch {
|
||||
return { info: null, items: [] }
|
||||
}
|
||||
|
||||
const parserErrors = doc.getElementsByTagName('parsererror')
|
||||
if (parserErrors && parserErrors.length) return { info: null, items: [] }
|
||||
|
||||
const getText = (node, tag) => {
|
||||
try {
|
||||
if (!node) return ''
|
||||
const elements = Array.from(node.getElementsByTagName(tag) || [])
|
||||
const direct = elements.find((el) => el && el.parentNode === node)
|
||||
const target = direct || elements[0]
|
||||
return String(target?.textContent || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const getDirectChildXml = (node, tag) => {
|
||||
try {
|
||||
if (!node) return ''
|
||||
const children = Array.from(node.children || [])
|
||||
const target = children.find((child) => String(child?.tagName || '').toLowerCase() === String(tag || '').toLowerCase())
|
||||
if (!target) return ''
|
||||
const raw = String(target.textContent || '').trim()
|
||||
if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw
|
||||
if (typeof XMLSerializer !== 'undefined') {
|
||||
return new XMLSerializer().serializeToString(target)
|
||||
}
|
||||
} catch {}
|
||||
return ''
|
||||
}
|
||||
|
||||
const getAnyXml = (node, tag) => {
|
||||
try {
|
||||
if (!node) return ''
|
||||
const elements = Array.from(node.getElementsByTagName(tag) || [])
|
||||
const direct = elements.find((el) => el && el.parentNode === node)
|
||||
const target = direct || elements[0]
|
||||
if (!target) return ''
|
||||
const raw = String(target.textContent || '').trim()
|
||||
if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw
|
||||
if (typeof XMLSerializer !== 'undefined') return new XMLSerializer().serializeToString(target)
|
||||
} catch {}
|
||||
return ''
|
||||
}
|
||||
|
||||
const sameTag = (element, tag) => String(element?.tagName || '').toLowerCase() === String(tag || '').toLowerCase()
|
||||
|
||||
const closestAncestorByTag = (node, tag) => {
|
||||
const lower = String(tag || '').toLowerCase()
|
||||
let current = node
|
||||
while (current) {
|
||||
if (current.nodeType === 1 && String(current.tagName || '').toLowerCase() === lower) return current
|
||||
current = current.parentNode
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const root = doc?.documentElement
|
||||
const isChatRoom = String(getText(root, 'isChatRoom') || '').trim() === '1'
|
||||
const title = getText(root, 'title')
|
||||
const desc = getText(root, 'desc') || getText(root, 'info')
|
||||
|
||||
const datalist = (() => {
|
||||
try {
|
||||
const all = Array.from(doc.getElementsByTagName('datalist') || [])
|
||||
const top = root ? all.find((el) => closestAncestorByTag(el, 'recorditem') === root) : null
|
||||
return top || all[0] || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
const datalistCount = (() => {
|
||||
try {
|
||||
if (!datalist) return 0
|
||||
const value = String(datalist.getAttribute('count') || '').trim()
|
||||
return Math.max(0, parseInt(value, 10) || 0)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})()
|
||||
|
||||
const itemNodes = (() => {
|
||||
if (datalist) return Array.from(datalist.children || []).filter((el) => sameTag(el, 'dataitem'))
|
||||
return Array.from(root?.children || []).filter((el) => sameTag(el, 'dataitem'))
|
||||
})()
|
||||
|
||||
const parsed = itemNodes.map((node, idx) => {
|
||||
const datatype = String(node.getAttribute('datatype') || getText(node, 'datatype') || '').trim()
|
||||
const dataid = String(node.getAttribute('dataid') || getText(node, 'dataid') || '').trim() || String(idx)
|
||||
|
||||
const sourcename = getText(node, 'sourcename')
|
||||
const sourcetime = getText(node, 'sourcetime')
|
||||
const sourceheadurl = normalizeChatHistoryUrl(getText(node, 'sourceheadurl'))
|
||||
const datatitle = getText(node, 'datatitle')
|
||||
const datadesc = getText(node, 'datadesc')
|
||||
const link = normalizeChatHistoryUrl(getText(node, 'link') || getText(node, 'dataurl') || getText(node, 'url'))
|
||||
const datafmt = getText(node, 'datafmt')
|
||||
const duration = getText(node, 'duration')
|
||||
|
||||
const fullmd5 = getText(node, 'fullmd5')
|
||||
const thumbfullmd5 = getText(node, 'thumbfullmd5')
|
||||
const md5 = getText(node, 'md5') || getText(node, 'emoticonmd5') || getText(node, 'emojiMd5')
|
||||
const fromnewmsgid = getText(node, 'fromnewmsgid')
|
||||
const srcMsgLocalid = getText(node, 'srcMsgLocalid') || getText(node, 'srcMsgLocalId')
|
||||
const srcMsgCreateTime = getText(node, 'srcMsgCreateTime')
|
||||
const cdnurlstring = normalizeChatHistoryUrl(getText(node, 'cdnurlstring'))
|
||||
const encrypturlstring = normalizeChatHistoryUrl(getText(node, 'encrypturlstring'))
|
||||
const externurl = normalizeChatHistoryUrl(getText(node, 'externurl'))
|
||||
const aeskey = getText(node, 'aeskey')
|
||||
const nestedRecordItem = getAnyXml(node, 'recorditem') || getDirectChildXml(node, 'recorditem') || getText(node, 'recorditem')
|
||||
|
||||
let content = datatitle || datadesc
|
||||
if (!content) {
|
||||
if (datatype === '4') content = '[视频]'
|
||||
else if (datatype === '2' || datatype === '3') content = '[图片]'
|
||||
else if (datatype === '47' || datatype === '37') content = '[表情]'
|
||||
else if (datatype) content = `[消息 ${datatype}]`
|
||||
else content = '[消息]'
|
||||
}
|
||||
|
||||
const fmt = String(datafmt || '').trim().toLowerCase().replace(/^\./, '')
|
||||
const imageFormats = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'])
|
||||
|
||||
let renderType = 'text'
|
||||
if (datatype === '17') {
|
||||
renderType = 'chatHistory'
|
||||
} else if (datatype === '5' || link) {
|
||||
renderType = 'link'
|
||||
} else if (datatype === '4' || String(duration || '').trim() || fmt === 'mp4') {
|
||||
renderType = 'video'
|
||||
} else if (datatype === '47' || datatype === '37') {
|
||||
renderType = 'emoji'
|
||||
} else if (
|
||||
datatype === '2'
|
||||
|| datatype === '3'
|
||||
|| imageFormats.has(fmt)
|
||||
|| (datatype !== '1' && isMaybeMd5(fullmd5))
|
||||
) {
|
||||
renderType = 'image'
|
||||
} else if (isMaybeMd5(md5) && /表情/.test(String(content || ''))) {
|
||||
renderType = 'emoji'
|
||||
}
|
||||
|
||||
let outTitle = ''
|
||||
let outUrl = ''
|
||||
let recordItem = ''
|
||||
if (renderType === 'chatHistory') {
|
||||
outTitle = datatitle || content || '聊天记录'
|
||||
content = datadesc || ''
|
||||
recordItem = nestedRecordItem
|
||||
} else if (renderType === 'link') {
|
||||
outTitle = datatitle || content || ''
|
||||
outUrl = link || externurl || ''
|
||||
const cleanDesc = stripWeChatInvisible(datadesc)
|
||||
const cleanTitle = stripWeChatInvisible(outTitle)
|
||||
if (!cleanDesc || (cleanTitle && cleanDesc === cleanTitle)) {
|
||||
content = ''
|
||||
} else {
|
||||
content = String(datadesc || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: dataid,
|
||||
datatype,
|
||||
sourcename,
|
||||
sourcetime,
|
||||
sourceheadurl,
|
||||
datafmt,
|
||||
duration,
|
||||
fullmd5,
|
||||
thumbfullmd5,
|
||||
md5,
|
||||
fromnewmsgid,
|
||||
srcMsgLocalid,
|
||||
srcMsgCreateTime,
|
||||
cdnurlstring,
|
||||
encrypturlstring,
|
||||
externurl,
|
||||
aeskey,
|
||||
renderType,
|
||||
title: outTitle,
|
||||
recordItem,
|
||||
url: outUrl,
|
||||
content
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
info: { isChatRoom, title, desc, count: datalistCount },
|
||||
items: parsed
|
||||
}
|
||||
}
|
||||
|
||||
export const formatChatHistoryVideoDuration = (value) => {
|
||||
const total = Math.max(0, parseInt(String(value || '').trim(), 10) || 0)
|
||||
const minutes = Math.floor(total / 60)
|
||||
const seconds = total % 60
|
||||
if (minutes <= 0) return `0:${String(seconds).padStart(2, '0')}`
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export const createChatHistoryRecordNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => {
|
||||
return (record) => {
|
||||
const account = encodeURIComponent(String(getSelectedAccount?.() || '').trim())
|
||||
const username = encodeURIComponent(String(getSelectedContact?.()?.username || '').trim())
|
||||
const output = { ...(record || {}) }
|
||||
|
||||
output.senderDisplayName = String(output.sourcename || '').trim()
|
||||
output.senderAvatar = normalizeChatHistoryUrl(output.sourceheadurl)
|
||||
output.fullTime = String(output.sourcetime || '').trim()
|
||||
|
||||
if (output.renderType === 'link') {
|
||||
const linkUrl = String(output.url || output.externurl || '').trim()
|
||||
output.url = linkUrl
|
||||
output.from = String(output.from || '').trim()
|
||||
const previewCandidates = []
|
||||
const fileId = (() => {
|
||||
const localId = parseInt(String(output.srcMsgLocalid || '').trim(), 10) || 0
|
||||
const createTime = parseInt(String(output.srcMsgCreateTime || '').trim(), 10) || 0
|
||||
if (localId > 0 && createTime > 0) return `${localId}_${createTime}`
|
||||
return ''
|
||||
})()
|
||||
if (fileId) {
|
||||
previewCandidates.push(
|
||||
`${apiBase}/chat/media/image?account=${account}&file_id=${encodeURIComponent(fileId)}&username=${username}`
|
||||
)
|
||||
}
|
||||
|
||||
output.previewMd5 = pickFirstMd5(output.fullmd5, output.thumbfullmd5, output.md5)
|
||||
const srcServerId = String(output.fromnewmsgid || '').trim()
|
||||
if (output.previewMd5) {
|
||||
const previewParts = [
|
||||
`account=${account}`,
|
||||
`md5=${encodeURIComponent(output.previewMd5)}`,
|
||||
srcServerId ? `server_id=${encodeURIComponent(srcServerId)}` : '',
|
||||
`username=${username}`
|
||||
].filter(Boolean)
|
||||
previewCandidates.push(`${apiBase}/chat/media/image?${previewParts.join('&')}`)
|
||||
}
|
||||
|
||||
output._linkPreviewCandidates = previewCandidates
|
||||
output._linkPreviewCandidateIndex = 0
|
||||
output._linkPreviewError = false
|
||||
output.preview = previewCandidates[0] || ''
|
||||
|
||||
const fromUsername = String(output.fromUsername || '').trim()
|
||||
output.fromUsername = fromUsername
|
||||
output.fromAvatar = fromUsername
|
||||
? `${apiBase}/chat/avatar?account=${account}&username=${encodeURIComponent(fromUsername)}`
|
||||
: (linkUrl ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(linkUrl)}` : '')
|
||||
output._fromAvatarLast = output.fromAvatar
|
||||
output._fromAvatarImgOk = false
|
||||
output._fromAvatarImgError = false
|
||||
} else if (output.renderType === 'video') {
|
||||
output.videoMd5 = pickFirstMd5(output.fullmd5, output.md5)
|
||||
output.videoThumbMd5 = pickFirstMd5(output.thumbfullmd5)
|
||||
output.videoDuration = String(output.duration || '').trim()
|
||||
const thumbCandidates = []
|
||||
if (output.videoMd5) {
|
||||
thumbCandidates.push(`${apiBase}/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(output.videoMd5)}&username=${username}`)
|
||||
}
|
||||
if (output.videoThumbMd5 && output.videoThumbMd5 !== output.videoMd5) {
|
||||
thumbCandidates.push(`${apiBase}/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(output.videoThumbMd5)}&username=${username}`)
|
||||
}
|
||||
output._videoThumbCandidates = thumbCandidates
|
||||
output._videoThumbCandidateIndex = 0
|
||||
output._videoThumbError = false
|
||||
output.videoThumbUrl = thumbCandidates[0] || ''
|
||||
output.videoUrl = output.videoMd5
|
||||
? `${apiBase}/chat/media/video?account=${account}&md5=${encodeURIComponent(output.videoMd5)}&username=${username}`
|
||||
: ''
|
||||
if (!output.content || /^\[.+\]$/.test(String(output.content || '').trim())) output.content = '[视频]'
|
||||
} else if (output.renderType === 'emoji') {
|
||||
output.emojiMd5 = pickFirstMd5(output.md5, output.fullmd5, output.thumbfullmd5)
|
||||
const remoteEmojiUrl = String(output.cdnurlstring || output.externurl || output.encrypturlstring || '').trim()
|
||||
const remoteAesKey = String(output.aeskey || '').trim()
|
||||
output.emojiRemoteUrl = remoteEmojiUrl
|
||||
output.emojiUrl = output.emojiMd5
|
||||
? `${apiBase}/chat/media/emoji?account=${account}&md5=${encodeURIComponent(output.emojiMd5)}&username=${username}${remoteEmojiUrl ? `&emoji_url=${encodeURIComponent(remoteEmojiUrl)}` : ''}${remoteAesKey ? `&aes_key=${encodeURIComponent(remoteAesKey)}` : ''}`
|
||||
: ''
|
||||
if (!output.content || /^\[.+\]$/.test(String(output.content || '').trim())) output.content = '[表情]'
|
||||
} else if (output.renderType === 'image') {
|
||||
output.imageMd5 = pickFirstMd5(output.fullmd5, output.thumbfullmd5, output.md5)
|
||||
const srcServerId = String(output.fromnewmsgid || '').trim()
|
||||
const imageParts = [
|
||||
`account=${account}`,
|
||||
output.imageMd5 ? `md5=${encodeURIComponent(output.imageMd5)}` : '',
|
||||
srcServerId ? `server_id=${encodeURIComponent(srcServerId)}` : '',
|
||||
`username=${username}`
|
||||
].filter(Boolean)
|
||||
output.imageUrl = imageParts.length ? `${apiBase}/chat/media/image?${imageParts.join('&')}` : ''
|
||||
if (!output.content || /^\[.+\]$/.test(String(output.content || '').trim())) output.content = '[图片]'
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
export const enhanceChatHistoryRecords = (records) => {
|
||||
const list = Array.isArray(records) ? records : []
|
||||
const videoByThumbMd5 = new Map()
|
||||
const videoByMd5 = new Map()
|
||||
const imageByMd5 = new Map()
|
||||
const emojiByMd5 = new Map()
|
||||
|
||||
for (const record of list) {
|
||||
if (!record) continue
|
||||
if (record.renderType === 'video' && record.videoThumbMd5) {
|
||||
videoByThumbMd5.set(String(record.videoThumbMd5).toLowerCase(), record)
|
||||
}
|
||||
if (record.renderType === 'video' && record.videoMd5) {
|
||||
videoByMd5.set(String(record.videoMd5).toLowerCase(), record)
|
||||
}
|
||||
if (record.renderType === 'image') {
|
||||
const keys = [
|
||||
pickFirstMd5(record.imageMd5),
|
||||
pickFirstMd5(record.fullmd5),
|
||||
pickFirstMd5(record.thumbfullmd5)
|
||||
].filter(Boolean)
|
||||
for (const key of keys) imageByMd5.set(key, record)
|
||||
}
|
||||
if (record.renderType === 'emoji') {
|
||||
const keys = [
|
||||
pickFirstMd5(record.emojiMd5),
|
||||
pickFirstMd5(record.md5),
|
||||
pickFirstMd5(record.fullmd5),
|
||||
pickFirstMd5(record.thumbfullmd5)
|
||||
].filter(Boolean)
|
||||
for (const key of keys) emojiByMd5.set(key, record)
|
||||
}
|
||||
}
|
||||
|
||||
for (const record of list) {
|
||||
if (!record || String(record.renderType || '') !== 'text') continue
|
||||
|
||||
const refKey = pickFirstMd5(record.thumbfullmd5) || pickFirstMd5(record.fullmd5)
|
||||
if (!refKey) continue
|
||||
|
||||
const video = videoByThumbMd5.get(refKey) || videoByMd5.get(refKey)
|
||||
if (video) {
|
||||
const quoteThumbCandidates = Array.isArray(video._videoThumbCandidates) ? video._videoThumbCandidates.slice() : []
|
||||
record._quoteThumbCandidates = quoteThumbCandidates
|
||||
record._quoteThumbCandidateIndex = 0
|
||||
record._quoteThumbError = false
|
||||
const quoteThumbUrl = quoteThumbCandidates[0] || video.videoThumbUrl || ''
|
||||
record.renderType = 'quote'
|
||||
record.quote = {
|
||||
kind: 'video',
|
||||
thumbUrl: quoteThumbUrl,
|
||||
url: video.videoUrl || '',
|
||||
duration: video.videoDuration || '',
|
||||
label: video.content || '[视频]',
|
||||
targetId: video.id || ''
|
||||
}
|
||||
record.quoteMedia = {
|
||||
videoMd5: video.videoMd5,
|
||||
videoThumbMd5: video.videoThumbMd5,
|
||||
videoUrl: video.videoUrl,
|
||||
videoThumbUrl: quoteThumbUrl
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const image = imageByMd5.get(refKey)
|
||||
if (image) {
|
||||
record.renderType = 'quote'
|
||||
record.quote = {
|
||||
kind: 'image',
|
||||
thumbUrl: image.imageUrl || '',
|
||||
url: image.imageUrl || '',
|
||||
label: image.content || '[图片]',
|
||||
targetId: image.id || ''
|
||||
}
|
||||
record.quoteMedia = {
|
||||
imageMd5: image.imageMd5,
|
||||
imageUrl: image.imageUrl
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const emoji = emojiByMd5.get(refKey)
|
||||
if (emoji) {
|
||||
record.renderType = 'quote'
|
||||
record.quote = {
|
||||
kind: 'emoji',
|
||||
thumbUrl: emoji.emojiUrl || '',
|
||||
url: emoji.emojiUrl || '',
|
||||
label: emoji.content || '[表情]',
|
||||
targetId: emoji.id || ''
|
||||
}
|
||||
record.quoteMedia = {
|
||||
emojiMd5: emoji.emojiMd5,
|
||||
emojiUrl: emoji.emojiUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
export const isChatHistoryRecordItemIncomplete = (recordItemXml) => {
|
||||
const recordItem = String(recordItemXml || '').trim()
|
||||
if (!recordItem) return true
|
||||
try {
|
||||
const parsed = parseChatHistoryRecord(recordItem)
|
||||
const got = Array.isArray(parsed?.items) ? parsed.items.length : 0
|
||||
const expected = Math.max(0, parseInt(String(parsed?.info?.count || '0'), 10) || 0)
|
||||
if (expected > 0 && got < expected) return true
|
||||
if (got <= 0) return true
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const buildChatHistoryWindowPayload = (payload, normalizeRecordItem) => {
|
||||
const title0 = String(payload?.title || '聊天记录')
|
||||
const content0 = String(payload?.content || '')
|
||||
const recordItem0 = String(payload?.recordItem || '').trim()
|
||||
const parsed = parseChatHistoryRecord(recordItem0)
|
||||
const info0 = parsed?.info || { isChatRoom: false, count: 0 }
|
||||
const items = Array.isArray(parsed?.items) ? parsed.items : []
|
||||
let records0 = items.length ? enhanceChatHistoryRecords(items.map(normalizeRecordItem)) : []
|
||||
if (!records0.length) {
|
||||
const lines = content0.trim().split(/\r?\n/).map((item) => item.trim()).filter(Boolean)
|
||||
records0 = lines.map((line, idx) => normalizeRecordItem({
|
||||
id: String(idx),
|
||||
datatype: '1',
|
||||
sourcename: '',
|
||||
sourcetime: '',
|
||||
content: line,
|
||||
renderType: 'text'
|
||||
}))
|
||||
}
|
||||
return { title0, content0, recordItem0, info0, records0 }
|
||||
}
|
||||
|
||||
export { getChatHistoryPreviewLines }
|
||||
@@ -0,0 +1,50 @@
|
||||
import zipIconUrl from '~/assets/images/wechat/zip.png'
|
||||
import pdfIconUrl from '~/assets/images/wechat/pdf.png'
|
||||
import wordIconUrl from '~/assets/images/wechat/word.png'
|
||||
import excelIconUrl from '~/assets/images/wechat/excel.png'
|
||||
|
||||
export const getFileIconKind = (fileName) => {
|
||||
if (!fileName) return 'default'
|
||||
const ext = String(fileName).split('.').pop()?.toLowerCase() || ''
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
return 'pdf'
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z':
|
||||
case 'tar':
|
||||
case 'gz':
|
||||
return 'zip'
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'doc'
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
case 'csv':
|
||||
return 'xls'
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return 'ppt'
|
||||
case 'txt':
|
||||
case 'md':
|
||||
case 'log':
|
||||
return 'txt'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
export const getFileIconUrl = (fileName) => {
|
||||
switch (getFileIconKind(fileName)) {
|
||||
case 'pdf':
|
||||
return pdfIconUrl
|
||||
case 'doc':
|
||||
return wordIconUrl
|
||||
case 'xls':
|
||||
return excelIconUrl
|
||||
case 'zip':
|
||||
return zipIconUrl
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
export const normalizeSessionPreview = (value) => {
|
||||
const text = String(value || '').trim()
|
||||
if (/^\[location\]/i.test(text)) return text.replace(/^\[location\]/i, '[位置]')
|
||||
if (/:\s*\[location\]$/i.test(text)) return text.replace(/\[location\]$/i, '[位置]')
|
||||
return text
|
||||
}
|
||||
|
||||
export const formatSmartTime = (ts) => {
|
||||
if (!ts) return ''
|
||||
try {
|
||||
const date = new Date(Number(ts) * 1000)
|
||||
const now = new Date()
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const mm = String(date.getMinutes()).padStart(2, '0')
|
||||
const timeStr = `${hh}:${mm}`
|
||||
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const targetStart = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const dayDiff = Math.floor((todayStart - targetStart) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (dayDiff === 0) return timeStr
|
||||
if (dayDiff === 1) return `昨天 ${timeStr}`
|
||||
if (dayDiff >= 2 && dayDiff <= 6) {
|
||||
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||
return `${weekdays[date.getDay()]} ${timeStr}`
|
||||
}
|
||||
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${month}月${day}日 ${timeStr}`
|
||||
}
|
||||
|
||||
return `${date.getFullYear()}年${month}月${day}日 ${timeStr}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const formatTimeDivider = (ts) => formatSmartTime(ts)
|
||||
|
||||
export const formatMessageTime = (ts) => {
|
||||
if (!ts) return ''
|
||||
try {
|
||||
const date = new Date(Number(ts) * 1000)
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const mm = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${hh}:${mm}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const formatMessageFullTime = (ts) => {
|
||||
if (!ts) return ''
|
||||
try {
|
||||
const date = new Date(Number(ts) * 1000)
|
||||
const yyyy = String(date.getFullYear())
|
||||
const MM = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const mm = String(date.getMinutes()).padStart(2, '0')
|
||||
const ss = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${yyyy}-${MM}-${dd} ${hh}:${mm}:${ss}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const formatFileSize = (size) => {
|
||||
if (!size) return ''
|
||||
const text = String(size).trim()
|
||||
const value = parseFloat(text)
|
||||
if (Number.isNaN(value)) return text
|
||||
if (value < 1024) return `${value} B`
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`
|
||||
return `${(value / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
export const formatTransferAmount = (amount) => {
|
||||
const text = String(amount ?? '').trim()
|
||||
if (!text) return ''
|
||||
return text.replace(/[¥¥]/g, '').trim()
|
||||
}
|
||||
|
||||
export const getRedPacketText = (message) => {
|
||||
const text = String(message?.content ?? '').trim()
|
||||
if (!text || text === '[Red Packet]') return '恭喜发财,大吉大利'
|
||||
return text
|
||||
}
|
||||
|
||||
export const isTransferReturned = (message) => {
|
||||
const paySubType = String(message?.paySubType || '').trim()
|
||||
if (paySubType === '4' || paySubType === '9') return true
|
||||
const status = String(message?.transferStatus || '').trim()
|
||||
const content = String(message?.content || '').trim()
|
||||
const text = `${status} ${content}`.trim()
|
||||
if (!text) return false
|
||||
return text.includes('退回') || text.includes('退还')
|
||||
}
|
||||
|
||||
export const isTransferOverdue = (message) => {
|
||||
const paySubType = String(message?.paySubType || '').trim()
|
||||
if (paySubType === '10') return true
|
||||
const status = String(message?.transferStatus || '').trim()
|
||||
const content = String(message?.content || '').trim()
|
||||
const text = `${status} ${content}`.trim()
|
||||
if (!text) return false
|
||||
return text.includes('过期')
|
||||
}
|
||||
|
||||
export const getTransferTitle = (message) => {
|
||||
const paySubType = String(message?.paySubType || '').trim()
|
||||
if (message?.transferStatus) return message.transferStatus
|
||||
switch (paySubType) {
|
||||
case '1':
|
||||
return '转账'
|
||||
case '3':
|
||||
return message?.isSent ? '已被接收' : '已收款'
|
||||
case '8':
|
||||
return '发起转账'
|
||||
case '4':
|
||||
return '已退还'
|
||||
case '9':
|
||||
return '已被退还'
|
||||
case '10':
|
||||
return '已过期'
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (message?.content && message.content !== '转账' && message.content !== '[转账]') {
|
||||
return message.content
|
||||
}
|
||||
return '转账'
|
||||
}
|
||||
|
||||
export const formatCount = (count) => {
|
||||
const value = Number(count || 0)
|
||||
if (!Number.isFinite(value) || value <= 0) return ''
|
||||
try {
|
||||
return value.toLocaleString()
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
export const escapeHtml = (value) => {
|
||||
if (!value) return ''
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export const highlightKeyword = (text, keyword) => {
|
||||
if (!text || !keyword) return escapeHtml(text || '')
|
||||
const escaped = escapeHtml(text)
|
||||
const kw = String(keyword || '').trim()
|
||||
if (!kw) return escaped
|
||||
try {
|
||||
const escapedKw = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(`(${escapedKw})`, 'gi')
|
||||
return escaped.replace(regex, '<mark class="search-highlight">$1</mark>')
|
||||
} catch {
|
||||
return escaped
|
||||
}
|
||||
}
|
||||
|
||||
export const getVoiceDurationInSeconds = (durationMs) => {
|
||||
const value = Number(durationMs || 0)
|
||||
if (!Number.isFinite(value) || value <= 0) return 0
|
||||
return Math.max(1, Math.round(value / 1000))
|
||||
}
|
||||
|
||||
export const getVoiceWidth = (durationMs) => {
|
||||
const seconds = getVoiceDurationInSeconds(durationMs)
|
||||
const clamped = Math.min(60, Math.max(1, seconds))
|
||||
return `${80 + clamped * 4}px`
|
||||
}
|
||||
|
||||
export const toUnixSeconds = (datetimeLocal) => {
|
||||
const value = String(datetimeLocal || '').trim()
|
||||
if (!value) return null
|
||||
const date = new Date(value)
|
||||
const ms = date.getTime()
|
||||
if (Number.isNaN(ms)) return null
|
||||
return Math.floor(ms / 1000)
|
||||
}
|
||||
|
||||
export const dateToUnixSeconds = (dateStr, endOfDay = false) => {
|
||||
const value = String(dateStr || '').trim()
|
||||
if (!value) return null
|
||||
const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (!matched) return null
|
||||
const year = Number(matched[1])
|
||||
const month = Number(matched[2])
|
||||
const day = Number(matched[3])
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
|
||||
const date = new Date(year, month - 1, day, endOfDay ? 23 : 0, endOfDay ? 59 : 0, endOfDay ? 59 : 0)
|
||||
const ms = date.getTime()
|
||||
if (Number.isNaN(ms)) return null
|
||||
return Math.floor(ms / 1000)
|
||||
}
|
||||
|
||||
export const getChatHistoryPreviewLines = (message) => {
|
||||
const raw = String(message?.content || '').trim()
|
||||
if (!raw) return []
|
||||
return raw.split(/\r?\n/).map((item) => item.trim()).filter(Boolean).slice(0, 4)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { formatMessageFullTime, formatMessageTime } from '~/lib/chat/formatters'
|
||||
|
||||
const normalizeMaybeUrl = (value) => (typeof value === 'string' ? value.trim() : '')
|
||||
|
||||
const isUsableMediaUrl = (value) => {
|
||||
const text = normalizeMaybeUrl(value)
|
||||
if (!text) return false
|
||||
return (
|
||||
/^https?:\/\//i.test(text)
|
||||
|| /^blob:/i.test(text)
|
||||
|| /^data:/i.test(text)
|
||||
|| /^\/api\/chat\/media\//i.test(text)
|
||||
)
|
||||
}
|
||||
|
||||
const buildAccountMediaUrl = (apiBase, path, parts) => {
|
||||
return `${apiBase}${path}?${parts.filter(Boolean).join('&')}`
|
||||
}
|
||||
|
||||
export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => {
|
||||
return (msg) => {
|
||||
const account = String(getSelectedAccount?.() || '').trim()
|
||||
const contact = getSelectedContact?.() || null
|
||||
const username = String(contact?.username || '').trim()
|
||||
const isSent = !!msg.isSent
|
||||
const sender = isSent ? '我' : (msg.senderDisplayName || msg.senderUsername || contact?.name || '')
|
||||
const fallbackAvatar = (!isSent && !contact?.isGroup) ? (contact?.avatar || null) : null
|
||||
|
||||
const normalizedThumbUrl = (() => {
|
||||
const candidates = [msg.thumbUrl, msg.preview]
|
||||
for (const candidate of candidates) {
|
||||
if (isUsableMediaUrl(candidate)) return normalizeMaybeUrl(candidate)
|
||||
}
|
||||
return ''
|
||||
})()
|
||||
|
||||
const normalizedLinkPreviewUrl = (() => {
|
||||
const url = normalizedThumbUrl
|
||||
if (!url) return ''
|
||||
if (/^\/api\/chat\/media\//i.test(url) || /^blob:/i.test(url) || /^data:/i.test(url)) return url
|
||||
if (!/^https?:\/\//i.test(url)) return url
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
} catch {}
|
||||
return url
|
||||
})()
|
||||
|
||||
const fromUsername = String(msg.fromUsername || '').trim()
|
||||
const fromAvatar = fromUsername
|
||||
? `${apiBase}/chat/avatar?account=${encodeURIComponent(account)}&username=${encodeURIComponent(fromUsername)}`
|
||||
: (() => {
|
||||
const href = String(msg.url || '').trim()
|
||||
return href ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(href)}` : ''
|
||||
})()
|
||||
|
||||
const localEmojiUrl = msg.emojiMd5
|
||||
? `${apiBase}/chat/media/emoji?account=${encodeURIComponent(account)}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(username)}`
|
||||
: ''
|
||||
|
||||
const localImageUrl = (() => {
|
||||
if (!msg.imageMd5 && !msg.imageFileId) return ''
|
||||
return buildAccountMediaUrl(apiBase, '/chat/media/image', [
|
||||
`account=${encodeURIComponent(account)}`,
|
||||
msg.imageMd5 ? `md5=${encodeURIComponent(msg.imageMd5)}` : '',
|
||||
msg.imageFileId ? `file_id=${encodeURIComponent(msg.imageFileId)}` : '',
|
||||
`username=${encodeURIComponent(username)}`
|
||||
])
|
||||
})()
|
||||
|
||||
const normalizedImageUrl = (() => {
|
||||
const current = isUsableMediaUrl(msg.imageUrl) ? normalizeMaybeUrl(msg.imageUrl) : ''
|
||||
if (current && /\/api\/chat\/media\/image\b/i.test(current) && localImageUrl) {
|
||||
return localImageUrl
|
||||
}
|
||||
return current || localImageUrl || ''
|
||||
})()
|
||||
|
||||
const normalizedEmojiUrl = msg.emojiUrl || localEmojiUrl
|
||||
|
||||
const localVideoThumbUrl = (() => {
|
||||
if (!msg.videoThumbMd5 && !msg.videoThumbFileId) return ''
|
||||
return buildAccountMediaUrl(apiBase, '/chat/media/video_thumb', [
|
||||
`account=${encodeURIComponent(account)}`,
|
||||
msg.videoThumbMd5 ? `md5=${encodeURIComponent(msg.videoThumbMd5)}` : '',
|
||||
msg.videoThumbFileId ? `file_id=${encodeURIComponent(msg.videoThumbFileId)}` : '',
|
||||
`username=${encodeURIComponent(username)}`
|
||||
])
|
||||
})()
|
||||
|
||||
const localVideoUrl = (() => {
|
||||
if (!msg.videoMd5 && !msg.videoFileId) return ''
|
||||
return buildAccountMediaUrl(apiBase, '/chat/media/video', [
|
||||
`account=${encodeURIComponent(account)}`,
|
||||
msg.videoMd5 ? `md5=${encodeURIComponent(msg.videoMd5)}` : '',
|
||||
msg.videoFileId ? `file_id=${encodeURIComponent(msg.videoFileId)}` : '',
|
||||
`username=${encodeURIComponent(username)}`
|
||||
])
|
||||
})()
|
||||
|
||||
const normalizedVideoThumbUrl = (isUsableMediaUrl(msg.videoThumbUrl) ? normalizeMaybeUrl(msg.videoThumbUrl) : '') || localVideoThumbUrl
|
||||
const normalizedVideoUrl = (isUsableMediaUrl(msg.videoUrl) ? normalizeMaybeUrl(msg.videoUrl) : '') || localVideoUrl
|
||||
const serverIdStr = String(msg.serverIdStr || (msg.serverId != null ? String(msg.serverId) : '')).trim()
|
||||
const normalizedVoiceUrl = (() => {
|
||||
if (msg.voiceUrl) return msg.voiceUrl
|
||||
if (!serverIdStr) return ''
|
||||
if (String(msg.renderType || '') !== 'voice') return ''
|
||||
return `${apiBase}/chat/media/voice?account=${encodeURIComponent(account)}&server_id=${encodeURIComponent(serverIdStr)}`
|
||||
})()
|
||||
|
||||
const remoteFromServer = (
|
||||
typeof msg.emojiRemoteUrl === 'string'
|
||||
&& /^https?:\/\//i.test(msg.emojiRemoteUrl)
|
||||
&& !/\/api\/chat\/media\/emoji\b/i.test(msg.emojiRemoteUrl)
|
||||
&& !/\blocalhost\b/i.test(msg.emojiRemoteUrl)
|
||||
&& !/\b127\.0\.0\.1\b/i.test(msg.emojiRemoteUrl)
|
||||
) ? msg.emojiRemoteUrl : ''
|
||||
|
||||
const remoteFromEmojiUrl = (
|
||||
typeof msg.emojiUrl === 'string'
|
||||
&& /^https?:\/\//i.test(msg.emojiUrl)
|
||||
&& !/\/api\/chat\/media\/emoji\b/i.test(msg.emojiUrl)
|
||||
&& !/\blocalhost\b/i.test(msg.emojiUrl)
|
||||
&& !/\b127\.0\.0\.1\b/i.test(msg.emojiUrl)
|
||||
) ? msg.emojiUrl : ''
|
||||
|
||||
const emojiRemoteUrl = remoteFromServer || remoteFromEmojiUrl
|
||||
const emojiIsLocal = typeof normalizedEmojiUrl === 'string' && /\/api\/chat\/media\/emoji\b/i.test(normalizedEmojiUrl)
|
||||
const emojiDownloaded = !!emojiRemoteUrl && !!emojiIsLocal
|
||||
|
||||
const replyText = String(msg.content || '').trim()
|
||||
let quoteContent = String(msg.quoteContent || '')
|
||||
const trimmedQuoteContent = quoteContent.trim()
|
||||
if (replyText && trimmedQuoteContent) {
|
||||
if (trimmedQuoteContent === replyText) {
|
||||
quoteContent = ''
|
||||
} else {
|
||||
const lines = trimmedQuoteContent.split(/\r?\n/).map((item) => item.trim())
|
||||
if (lines.length && (lines[0] === replyText || lines[0] === replyText.split(/\r?\n/)[0]?.trim())) {
|
||||
quoteContent = trimmedQuoteContent.split(/\r?\n/).slice(1).join('\n').trim()
|
||||
} else if (trimmedQuoteContent.startsWith(replyText)) {
|
||||
quoteContent = trimmedQuoteContent.slice(replyText.length).trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const quoteServerIdStr = String(msg.quoteServerId || '').trim()
|
||||
const quoteTypeStr = String(msg.quoteType || '').trim()
|
||||
const quoteVoiceUrl = quoteServerIdStr
|
||||
? `${apiBase}/chat/media/voice?account=${encodeURIComponent(account)}&server_id=${encodeURIComponent(quoteServerIdStr)}`
|
||||
: ''
|
||||
|
||||
const quoteImageUrl = (() => {
|
||||
if (!quoteServerIdStr) return ''
|
||||
if (quoteTypeStr !== '3' && String(msg.quoteContent || '').trim() !== '[图片]') return ''
|
||||
return buildAccountMediaUrl(apiBase, '/chat/media/image', [
|
||||
`account=${encodeURIComponent(account)}`,
|
||||
`server_id=${encodeURIComponent(quoteServerIdStr)}`,
|
||||
username ? `username=${encodeURIComponent(username)}` : ''
|
||||
])
|
||||
})()
|
||||
|
||||
const quoteThumbUrl = (() => {
|
||||
const raw = isUsableMediaUrl(msg.quoteThumbUrl) ? normalizeMaybeUrl(msg.quoteThumbUrl) : ''
|
||||
if (!raw) return ''
|
||||
if (/^\/api\/chat\/media\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw
|
||||
if (!/^https?:\/\//i.test(raw)) return raw
|
||||
try {
|
||||
const host = new URL(raw).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
}
|
||||
} catch {}
|
||||
return raw
|
||||
})()
|
||||
|
||||
return {
|
||||
id: msg.id,
|
||||
serverId: msg.serverId || 0,
|
||||
serverIdStr,
|
||||
sender,
|
||||
senderUsername: msg.senderUsername || '',
|
||||
senderDisplayName: msg.senderDisplayName || '',
|
||||
content: msg.content || '',
|
||||
time: formatMessageTime(msg.createTime),
|
||||
fullTime: formatMessageFullTime(msg.createTime),
|
||||
createTime: Number(msg.createTime || 0),
|
||||
isSent,
|
||||
type: 'text',
|
||||
renderType: msg.renderType || 'text',
|
||||
voipType: msg.voipType || '',
|
||||
title: msg.title || '',
|
||||
url: msg.url || '',
|
||||
recordItem: msg.recordItem || '',
|
||||
imageMd5: msg.imageMd5 || '',
|
||||
imageFileId: msg.imageFileId || '',
|
||||
emojiMd5: msg.emojiMd5 || '',
|
||||
emojiUrl: normalizedEmojiUrl || '',
|
||||
emojiLocalUrl: localEmojiUrl || '',
|
||||
emojiRemoteUrl,
|
||||
_emojiDownloaded: !!emojiDownloaded,
|
||||
thumbUrl: msg.thumbUrl || '',
|
||||
imageUrl: normalizedImageUrl || '',
|
||||
videoMd5: msg.videoMd5 || '',
|
||||
videoThumbMd5: msg.videoThumbMd5 || '',
|
||||
videoFileId: msg.videoFileId || '',
|
||||
videoThumbFileId: msg.videoThumbFileId || '',
|
||||
videoThumbUrl: normalizedVideoThumbUrl || '',
|
||||
videoUrl: normalizedVideoUrl || '',
|
||||
quoteTitle: msg.quoteTitle || '',
|
||||
quoteContent,
|
||||
quoteUsername: msg.quoteUsername || '',
|
||||
quoteServerId: quoteServerIdStr,
|
||||
quoteType: quoteTypeStr,
|
||||
quoteVoiceLength: msg.quoteVoiceLength || '',
|
||||
quoteVoiceUrl,
|
||||
quoteImageUrl: quoteImageUrl || '',
|
||||
quoteThumbUrl: quoteThumbUrl || '',
|
||||
_quoteImageError: false,
|
||||
_quoteThumbError: false,
|
||||
amount: msg.amount || '',
|
||||
coverUrl: msg.coverUrl || '',
|
||||
fileSize: msg.fileSize || '',
|
||||
fileMd5: msg.fileMd5 || '',
|
||||
paySubType: msg.paySubType || '',
|
||||
transferStatus: msg.transferStatus || '',
|
||||
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款' || msg.transferStatus === '已被接收',
|
||||
voiceUrl: normalizedVoiceUrl || '',
|
||||
voiceDuration: msg.voiceLength || msg.voiceDuration || '',
|
||||
locationLat: msg.locationLat ?? null,
|
||||
locationLng: msg.locationLng ?? null,
|
||||
locationPoiname: String(msg.locationPoiname || '').trim(),
|
||||
locationLabel: String(msg.locationLabel || '').trim(),
|
||||
preview: normalizedLinkPreviewUrl || '',
|
||||
linkType: String(msg.linkType || '').trim(),
|
||||
linkStyle: String(msg.linkStyle || '').trim(),
|
||||
linkCardVariant: String(msg.linkStyle || '').trim() === 'cover' ? 'cover' : 'default',
|
||||
from: String(msg.from || '').trim(),
|
||||
fromUsername,
|
||||
fromAvatar,
|
||||
isGroup: !!contact?.isGroup,
|
||||
avatar: msg.senderAvatar || msg.avatar || fallbackAvatar || null,
|
||||
avatarColor: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dedupeMessagesById = (list) => {
|
||||
const input = Array.isArray(list) ? list : []
|
||||
const seen = new Set()
|
||||
const output = []
|
||||
for (const item of input) {
|
||||
const id = String(item?.id || '')
|
||||
if (!id) {
|
||||
output.push(item)
|
||||
continue
|
||||
}
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
output.push(item)
|
||||
}
|
||||
return output
|
||||
}
|
||||
+15
-1
@@ -1,10 +1,18 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
const frontendHost = String(process.env.NUXT_HOST || '').trim()
|
||||
const frontendPort = Number.parseInt(String(process.env.NUXT_PORT || process.env.PORT || '3000').trim(), 10)
|
||||
const backendPort = String(process.env.WECHAT_TOOL_PORT || '10392').trim() || '10392'
|
||||
const devProxyTarget = `http://127.0.0.1:${backendPort}/api`
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: false },
|
||||
experimental: {
|
||||
// This app does not use Nuxt route rules on the client, so disabling
|
||||
// the app manifest avoids an unnecessary `/_nuxt/builds/meta/dev.json`
|
||||
// preload request and the related Chrome warning in dev mode.
|
||||
appManifest: false,
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
@@ -16,7 +24,8 @@ export default defineNuxtConfig({
|
||||
|
||||
// 配置前端开发服务器端口
|
||||
devServer: {
|
||||
port: 3000
|
||||
...(frontendHost ? { host: frontendHost } : {}),
|
||||
port: Number.isInteger(frontendPort) && frontendPort >= 1 && frontendPort <= 65535 ? frontendPort : 3000
|
||||
},
|
||||
|
||||
// 配置API代理,解决跨域问题
|
||||
@@ -31,6 +40,11 @@ export default defineNuxtConfig({
|
||||
}
|
||||
},
|
||||
|
||||
// 应用配置
|
||||
css: [
|
||||
'~/assets/css/chat.css'
|
||||
],
|
||||
|
||||
// 应用配置
|
||||
app: {
|
||||
head: {
|
||||
|
||||
Generated
+2754
-4856
File diff suppressed because it is too large
Load Diff
+493
-9138
File diff suppressed because it is too large
Load Diff
@@ -68,7 +68,7 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
|
||||
|
||||
onMounted(async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
|
||||
@@ -705,9 +705,9 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
import { reportServerErrorFromError } from '~/utils/server-error-logging'
|
||||
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
|
||||
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
|
||||
import { reportServerErrorFromError } from '~/lib/server-error-logging'
|
||||
|
||||
useHead({ title: '朋友圈 - 微信数据分析助手' })
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 客户端插件:检查API连接状态
|
||||
export default defineNuxtPlugin(async (nuxtApp) => {
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const { healthCheck } = useApi()
|
||||
const appStore = useAppStore()
|
||||
let intervalId = 0
|
||||
|
||||
// 检查API连接
|
||||
const checkApiConnection = async () => {
|
||||
@@ -17,10 +18,14 @@ export default defineNuxtPlugin(async (nuxtApp) => {
|
||||
console.error('API连接失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始检查
|
||||
await checkApiConnection()
|
||||
|
||||
// 定期检查(每30秒)
|
||||
setInterval(checkApiConnection, 30000)
|
||||
})
|
||||
|
||||
nuxtApp.hook('app:mounted', () => {
|
||||
void checkApiConnection()
|
||||
|
||||
if (!intervalId) {
|
||||
intervalId = window.setInterval(() => {
|
||||
void checkApiConnection()
|
||||
}, 30000)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,11 @@ export const useChatAccountsStore = defineStore('chatAccounts', () => {
|
||||
const error = ref('')
|
||||
const loaded = ref(false)
|
||||
|
||||
// Capture apiBase during synchronous store setup when Nuxt context is available.
|
||||
// useApiBase() calls useRuntimeConfig() which requires the Nuxt app context;
|
||||
// that context can be lost inside deferred async functions (e.g. onMounted callbacks).
|
||||
const _apiBase = useApiBase()
|
||||
|
||||
let loadPromise = null
|
||||
|
||||
const readSelectedAccount = () => {
|
||||
@@ -64,8 +69,7 @@ export const useChatAccountsStore = defineStore('chatAccounts', () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const api = useApi()
|
||||
const resp = await api.listChatAccounts()
|
||||
const resp = await $fetch('/chat/accounts', { baseURL: _apiBase })
|
||||
const nextAccounts = Array.isArray(resp?.accounts) ? resp.accounts : []
|
||||
accounts.value = nextAccounts
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { readPrivacyMode, writePrivacyMode } from '~/utils/privacy-mode'
|
||||
import { readPrivacyMode, writePrivacyMode } from '~/lib/privacy-mode'
|
||||
|
||||
export const usePrivacyStore = defineStore('privacy', () => {
|
||||
const privacyMode = ref(false)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -787,10 +803,14 @@ class WCDBRealtimeConnection:
|
||||
|
||||
|
||||
class WCDBRealtimeManager:
|
||||
_FAILED_TTL = 60.0 # seconds before retrying a failed connection
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._mu = threading.Lock()
|
||||
self._conns: dict[str, WCDBRealtimeConnection] = {}
|
||||
self._connecting: dict[str, threading.Event] = {}
|
||||
# Negative cache: accounts that failed to connect recently (avoids repeated timeouts).
|
||||
self._failed: dict[str, float] = {} # account -> monotonic timestamp of failure
|
||||
|
||||
def get_status(self, account_dir: Path) -> dict[str, Any]:
|
||||
account = str(account_dir.name)
|
||||
@@ -830,9 +850,19 @@ class WCDBRealtimeManager:
|
||||
conn = self._conns.get(str(account))
|
||||
return bool(conn and conn.handle > 0)
|
||||
|
||||
def ensure_connected(self, account_dir: Path, *, key_hex: Optional[str] = None) -> WCDBRealtimeConnection:
|
||||
def ensure_connected(
|
||||
self, account_dir: Path, *, key_hex: Optional[str] = None, timeout: float = 5.0
|
||||
) -> WCDBRealtimeConnection:
|
||||
account = str(account_dir.name)
|
||||
|
||||
# Fast-reject if this account failed recently to avoid repeated timeouts.
|
||||
with self._mu:
|
||||
failed_at = self._failed.get(account)
|
||||
if failed_at is not None and (time.monotonic() - failed_at) < self._FAILED_TTL:
|
||||
raise WCDBRealtimeError("WCDB connection recently failed; retry after 60s.")
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
|
||||
while True:
|
||||
with self._mu:
|
||||
existing = self._conns.get(account)
|
||||
@@ -846,22 +876,59 @@ class WCDBRealtimeManager:
|
||||
break
|
||||
|
||||
# Another thread is connecting; wait a bit and retry.
|
||||
waiter.wait(timeout=10.0)
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise WCDBRealtimeError("Timed out waiting for WCDB connection.")
|
||||
waiter.wait(timeout=min(remaining, 10.0))
|
||||
if time.monotonic() >= deadline:
|
||||
raise WCDBRealtimeError("Timed out waiting for WCDB connection.")
|
||||
|
||||
key = str(key_hex or "").strip()
|
||||
if not key:
|
||||
key_item = get_account_keys_from_store(account)
|
||||
key = str((key_item or {}).get("db_key") or "").strip()
|
||||
if len(key) != 64:
|
||||
raise WCDBRealtimeError("Missing db key for this account (call /api/keys or decrypt first).")
|
||||
|
||||
try:
|
||||
if len(key) != 64:
|
||||
with self._mu:
|
||||
self._failed[account] = time.monotonic()
|
||||
raise WCDBRealtimeError("Missing db key for this account (call /api/keys or decrypt first).")
|
||||
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
|
||||
if db_storage_dir is None:
|
||||
raise WCDBRealtimeError("Cannot resolve db_storage directory for this account.")
|
||||
|
||||
session_db_path = _resolve_session_db_path(db_storage_dir)
|
||||
handle = open_account(session_db_path, key)
|
||||
|
||||
# Run open_account in a daemon thread with a timeout to avoid
|
||||
# blocking indefinitely when the native library hangs (locked DB).
|
||||
_handle_box: list[int] = []
|
||||
_open_err: list[Exception] = []
|
||||
|
||||
def _do_open() -> None:
|
||||
try:
|
||||
_handle_box.append(open_account(session_db_path, key))
|
||||
except Exception as exc:
|
||||
_open_err.append(exc)
|
||||
|
||||
remaining = max(0.1, deadline - time.monotonic())
|
||||
open_thread = threading.Thread(target=_do_open, daemon=True)
|
||||
open_thread.start()
|
||||
open_thread.join(timeout=remaining)
|
||||
|
||||
if open_thread.is_alive():
|
||||
with self._mu:
|
||||
self._failed[account] = time.monotonic()
|
||||
raise WCDBRealtimeError(
|
||||
f"open_account timed out after {timeout:.0f}s for {session_db_path}"
|
||||
)
|
||||
if _open_err:
|
||||
with self._mu:
|
||||
self._failed[account] = time.monotonic()
|
||||
raise _open_err[0]
|
||||
if not _handle_box:
|
||||
raise WCDBRealtimeError("open_account returned no handle.")
|
||||
|
||||
handle = _handle_box[0]
|
||||
# Some WCDB APIs (e.g. exec_query on non-session DBs) may require this context.
|
||||
try:
|
||||
set_my_wxid(handle, account)
|
||||
@@ -893,6 +960,7 @@ class WCDBRealtimeManager:
|
||||
return
|
||||
with self._mu:
|
||||
conn = self._conns.pop(a, None)
|
||||
self._failed.pop(a, None) # clear negative cache on explicit disconnect
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -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,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