mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
12 Commits
@@ -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);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
globalShortcut,
|
||||
dialog,
|
||||
shell,
|
||||
session,
|
||||
} = require("electron");
|
||||
let autoUpdater = null;
|
||||
let autoUpdaterLoadError = null;
|
||||
@@ -82,6 +83,11 @@ function getBackendAccessHost() {
|
||||
}
|
||||
|
||||
function getBackendPort() {
|
||||
const envPort = parsePort(process.env.WECHAT_TOOL_PORT);
|
||||
if (envPort != null) return envPort;
|
||||
// In dev we intentionally ignore persisted packaged-app settings so the
|
||||
// launcher can keep Electron, Nuxt devProxy and the backend child aligned.
|
||||
if (!app.isPackaged) return DEFAULT_BACKEND_PORT;
|
||||
const settingsPort = parsePort(loadDesktopSettings()?.backendPort);
|
||||
return settingsPort ?? DEFAULT_BACKEND_PORT;
|
||||
}
|
||||
@@ -215,6 +221,161 @@ function getUserDataDir() {
|
||||
return resolveDataDir();
|
||||
}
|
||||
|
||||
function sanitizeAccountName(account) {
|
||||
const name = String(account || "").trim();
|
||||
if (!name) throw new Error("缺少账号参数");
|
||||
if (name === "." || name === "..") throw new Error("账号参数非法");
|
||||
if (name.includes("/") || name.includes("\\")) throw new Error("账号参数非法");
|
||||
return name;
|
||||
}
|
||||
|
||||
function listDecryptedAccountsOnDisk(databasesDir) {
|
||||
try {
|
||||
if (!fs.existsSync(databasesDir)) return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(databasesDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const accounts = [];
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
if (!entry || !entry.isDirectory()) continue;
|
||||
const accountDir = path.join(databasesDir, entry.name);
|
||||
const hasSession = fs.existsSync(path.join(accountDir, "session.db"));
|
||||
const hasContact = fs.existsSync(path.join(accountDir, "contact.db"));
|
||||
if (hasSession && hasContact) accounts.push(String(entry.name || ""));
|
||||
} catch {}
|
||||
}
|
||||
accounts.sort((a, b) => a.localeCompare(b));
|
||||
return accounts;
|
||||
}
|
||||
|
||||
function resolveAccountDirInOutput(account) {
|
||||
const dataDir = resolveDataDir();
|
||||
if (!dataDir) throw new Error("无法定位数据目录");
|
||||
|
||||
const outputDir = path.join(dataDir, "output");
|
||||
const databasesDir = path.join(outputDir, "databases");
|
||||
const accountName = sanitizeAccountName(account);
|
||||
|
||||
const base = path.resolve(databasesDir);
|
||||
const accountDir = path.resolve(path.join(databasesDir, accountName));
|
||||
if (accountDir !== base && !accountDir.startsWith(base + path.sep)) {
|
||||
throw new Error("账号路径非法");
|
||||
}
|
||||
|
||||
return {
|
||||
dataDir,
|
||||
outputDir,
|
||||
databasesDir,
|
||||
accountName,
|
||||
accountDir,
|
||||
};
|
||||
}
|
||||
|
||||
function getAccountInfoFromDisk(account) {
|
||||
const { accountName, accountDir } = resolveAccountDirInOutput(account);
|
||||
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
|
||||
throw new Error("账号数据不存在");
|
||||
}
|
||||
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(accountDir, { withFileTypes: true });
|
||||
} catch {}
|
||||
const dbFiles = entries
|
||||
.filter((e) => !!e && e.isFile() && String(e.name || "").toLowerCase().endsWith(".db"))
|
||||
.map((e) => String(e.name || ""))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
let sessionUpdatedAt = 0;
|
||||
try {
|
||||
const st = fs.statSync(path.join(accountDir, "session.db"));
|
||||
sessionUpdatedAt = Math.floor(Number(st?.mtimeMs || 0) / 1000);
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
account: accountName,
|
||||
path: accountDir,
|
||||
database_count: dbFiles.length,
|
||||
databases: dbFiles,
|
||||
session_updated_at: sessionUpdatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function removeAccountFromKeyStore(dataDir, accountName) {
|
||||
const keyStorePath = path.join(dataDir, "output", "account_keys.json");
|
||||
try {
|
||||
if (!fs.existsSync(keyStorePath)) return false;
|
||||
const raw = fs.readFileSync(keyStorePath, { encoding: "utf8" });
|
||||
const parsed = JSON.parse(raw || "{}");
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
||||
if (!Object.prototype.hasOwnProperty.call(parsed, accountName)) return false;
|
||||
delete parsed[accountName];
|
||||
fs.writeFileSync(keyStorePath, JSON.stringify(parsed, null, 2), { encoding: "utf8" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAccountDataFromDisk(account) {
|
||||
const { dataDir, outputDir, databasesDir, accountName, accountDir } = resolveAccountDirInOutput(account);
|
||||
if (!fs.existsSync(accountDir) || !fs.statSync(accountDir).isDirectory()) {
|
||||
throw new Error("账号数据不存在");
|
||||
}
|
||||
|
||||
const wasBackendRunning = !!backendProc;
|
||||
let restartError = null;
|
||||
let result = null;
|
||||
|
||||
if (wasBackendRunning) {
|
||||
await stopBackendAndWait({ timeoutMs: 10_000 });
|
||||
}
|
||||
|
||||
try {
|
||||
const exportsDir = path.join(outputDir, "exports", accountName);
|
||||
try {
|
||||
fs.rmSync(exportsDir, { recursive: true, force: true });
|
||||
} catch {}
|
||||
|
||||
fs.rmSync(accountDir, { recursive: true, force: true });
|
||||
const removedKeyCache = removeAccountFromKeyStore(dataDir, accountName);
|
||||
const accounts = listDecryptedAccountsOnDisk(databasesDir);
|
||||
result = {
|
||||
status: "success",
|
||||
deleted_account: accountName,
|
||||
accounts,
|
||||
default_account: accounts.length ? accounts[0] : null,
|
||||
removed_key_cache: removedKeyCache,
|
||||
};
|
||||
} finally {
|
||||
if (wasBackendRunning) {
|
||||
try {
|
||||
startBackend();
|
||||
await waitForBackend({ timeoutMs: 30_000 });
|
||||
} catch (err) {
|
||||
restartError = err;
|
||||
logMain(`[main] failed to restart backend after deleteAccountData: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (restartError) {
|
||||
throw new Error(`删除完成,但后端重启失败:${restartError?.message || restartError}`);
|
||||
}
|
||||
if (!result) throw new Error("删除账号数据失败");
|
||||
return result;
|
||||
}
|
||||
|
||||
function getExeDir() {
|
||||
try {
|
||||
return path.dirname(process.execPath);
|
||||
@@ -310,6 +471,34 @@ function getDesktopSettingsPath() {
|
||||
return path.join(dir, "desktop-settings.json");
|
||||
}
|
||||
|
||||
function getPackagedUiDir() {
|
||||
if (!app.isPackaged) return null;
|
||||
try {
|
||||
return path.join(process.resourcesPath, "ui");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readPackagedUiBuildId() {
|
||||
const uiDir = getPackagedUiDir();
|
||||
if (!uiDir) return "";
|
||||
|
||||
try {
|
||||
const indexPath = path.join(uiDir, "index.html");
|
||||
if (!fs.existsSync(indexPath)) return "";
|
||||
const html = fs.readFileSync(indexPath, { encoding: "utf8" });
|
||||
const match =
|
||||
html.match(/buildId:"([^"]+)"/) ||
|
||||
html.match(/\/_payload\.json\?([^"'&<>\s]+)/) ||
|
||||
html.match(/data-src="\/_payload\.json\?([^"]+)"/);
|
||||
return String(match?.[1] || "").trim();
|
||||
} catch (err) {
|
||||
logMain(`[main] failed to read packaged UI build id: ${err?.message || err}`);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function loadDesktopSettings() {
|
||||
if (desktopSettings) return desktopSettings;
|
||||
|
||||
@@ -321,6 +510,9 @@ function loadDesktopSettings() {
|
||||
ignoredUpdateVersion: "",
|
||||
// Backend (FastAPI) listens on this port. Used in packaged builds.
|
||||
backendPort: DEFAULT_BACKEND_PORT,
|
||||
// Tracks the packaged UI build so we can invalidate Chromium's HTTP cache
|
||||
// after upgrades without wiping user data/localStorage.
|
||||
lastSeenUiBuildId: "",
|
||||
};
|
||||
|
||||
const p = getDesktopSettingsPath();
|
||||
@@ -384,6 +576,33 @@ function setIgnoredUpdateVersion(version) {
|
||||
return desktopSettings.ignoredUpdateVersion;
|
||||
}
|
||||
|
||||
async function refreshRendererCacheForPackagedUi() {
|
||||
if (!app.isPackaged) return;
|
||||
|
||||
const nextBuildId = readPackagedUiBuildId();
|
||||
if (!nextBuildId) return;
|
||||
|
||||
const prevBuildId = String(loadDesktopSettings()?.lastSeenUiBuildId || "").trim();
|
||||
if (prevBuildId === nextBuildId) return;
|
||||
|
||||
try {
|
||||
const ses = session?.defaultSession;
|
||||
if (ses) {
|
||||
await ses.clearCache();
|
||||
try {
|
||||
await ses.clearStorageData({ storages: ["serviceworkers"] });
|
||||
} catch {}
|
||||
}
|
||||
logMain(`[main] cleared renderer cache for UI build change: ${prevBuildId || "(none)"} -> ${nextBuildId}`);
|
||||
} catch (err) {
|
||||
logMain(`[main] failed to clear renderer cache for UI build change: ${err?.message || err}`);
|
||||
}
|
||||
|
||||
loadDesktopSettings();
|
||||
desktopSettings.lastSeenUiBuildId = nextBuildId;
|
||||
persistDesktopSettings();
|
||||
}
|
||||
|
||||
function parseEnvBool(value) {
|
||||
if (value == null) return null;
|
||||
const v = String(value).trim().toLowerCase();
|
||||
@@ -1384,6 +1603,22 @@ function registerWindowIpc() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:getAccountInfo", async (_event, account) => {
|
||||
try {
|
||||
return getAccountInfoFromDisk(account);
|
||||
} catch (e) {
|
||||
throw new Error(e?.message || String(e));
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:deleteAccountData", async (_event, account) => {
|
||||
try {
|
||||
return await deleteAccountDataFromDisk(account);
|
||||
} catch (e) {
|
||||
throw new Error(e?.message || String(e));
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("app:checkForUpdates", async () => {
|
||||
return await checkForUpdatesInternal();
|
||||
});
|
||||
@@ -1443,6 +1678,7 @@ function registerWindowIpc() {
|
||||
|
||||
async function main() {
|
||||
await app.whenReady();
|
||||
await refreshRendererCacheForPackagedUi();
|
||||
Menu.setApplicationMenu(null);
|
||||
registerWindowIpc();
|
||||
registerDebugShortcuts();
|
||||
|
||||
@@ -22,6 +22,8 @@ contextBridge.exposeInMainWorld("wechatDesktop", {
|
||||
// Data/output folder helpers
|
||||
getOutputDir: () => ipcRenderer.invoke("app:getOutputDir"),
|
||||
openOutputDir: () => ipcRenderer.invoke("app:openOutputDir"),
|
||||
getAccountInfo: (account) => ipcRenderer.invoke("app:getAccountInfo", String(account || "")),
|
||||
deleteAccountData: (account) => ipcRenderer.invoke("app:deleteAccountData", String(account || "")),
|
||||
|
||||
// Auto update
|
||||
getVersion: () => ipcRenderer.invoke("app:getVersion"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -63,186 +63,222 @@
|
||||
</div>
|
||||
|
||||
<section ref="desktopSectionRef">
|
||||
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">桌面行为</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">开机自启动</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">系统登录后自动启动桌面端应用</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="desktopAutoLaunch"
|
||||
class="settings-switch shrink-0"
|
||||
:class="switchTrackClass(desktopAutoLaunch, !isDesktopEnv || desktopAutoLaunchLoading)"
|
||||
:disabled="!isDesktopEnv || desktopAutoLaunchLoading"
|
||||
@click="toggleDesktopAutoLaunch"
|
||||
>
|
||||
<span class="settings-switch-thumb" :class="desktopAutoLaunch ? 'translate-x-[20px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="desktopAutoLaunchError" class="text-xs text-red-600 whitespace-pre-wrap -mt-2">
|
||||
{{ desktopAutoLaunchError }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">关闭窗口行为</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">点击关闭按钮时:默认最小化到托盘</div>
|
||||
</div>
|
||||
<select
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
|
||||
:disabled="!isDesktopEnv || desktopCloseBehaviorLoading"
|
||||
:value="desktopCloseBehavior"
|
||||
@change="onDesktopCloseBehaviorChange"
|
||||
>
|
||||
<option value="tray">最小化到托盘</option>
|
||||
<option value="exit">直接退出</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="desktopCloseBehaviorError" class="text-xs text-red-600 whitespace-pre-wrap -mt-2">
|
||||
{{ desktopCloseBehaviorError }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">后端端口</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">桌面端:重启内置后端并刷新;网页端:尝试切换端口</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<input
|
||||
v-model="desktopBackendPortInput"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="w-16 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-center text-[12px] tabular-nums text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
|
||||
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
|
||||
@keyup.enter="onDesktopBackendPortApply"
|
||||
/>
|
||||
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">桌面行为</div>
|
||||
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">开机自启动</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">系统登录后自动启动桌面端应用</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
|
||||
@click="onDesktopBackendPortApply"
|
||||
role="switch"
|
||||
:aria-checked="desktopAutoLaunch"
|
||||
class="settings-switch shrink-0"
|
||||
:class="switchTrackClass(desktopAutoLaunch, !isDesktopEnv || desktopAutoLaunchLoading)"
|
||||
:disabled="!isDesktopEnv || desktopAutoLaunchLoading"
|
||||
@click="toggleDesktopAutoLaunch"
|
||||
>
|
||||
{{ desktopBackendPortApplying ? '...' : '应用' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
|
||||
@click="onDesktopBackendPortReset"
|
||||
>
|
||||
恢复默认
|
||||
<span class="settings-switch-thumb" :class="desktopAutoLaunch ? 'translate-x-[20px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="desktopBackendPortError" class="text-xs text-red-600 whitespace-pre-wrap -mt-1.5">
|
||||
{{ desktopBackendPortError }}
|
||||
<div v-if="desktopAutoLaunchError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopAutoLaunchError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopOutputDirText }}</div>
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">关闭窗口行为</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">点击关闭按钮时:默认最小化到托盘</div>
|
||||
</div>
|
||||
<select
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
|
||||
:disabled="!isDesktopEnv || desktopCloseBehaviorLoading"
|
||||
:value="desktopCloseBehavior"
|
||||
@change="onDesktopCloseBehaviorChange"
|
||||
>
|
||||
<option value="tray">最小化到托盘</option>
|
||||
<option value="exit">直接退出</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="desktopCloseBehaviorError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopCloseBehaviorError }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isDesktopEnv || desktopOutputDirLoading"
|
||||
@click="onDesktopOpenOutputDir"
|
||||
>
|
||||
打开 output
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="desktopOutputDirError" class="text-xs text-red-600 whitespace-pre-wrap -mt-1.5">
|
||||
{{ desktopOutputDirError }}
|
||||
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">后端端口</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">桌面端:重启内置后端并刷新;网页端:尝试切换端口</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<input
|
||||
v-model="desktopBackendPortInput"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
class="w-16 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-center text-[12px] tabular-nums text-[#333] outline-none transition focus:border-[#07b75b] focus:ring-1 focus:ring-[#07b75b]/30"
|
||||
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
|
||||
@keyup.enter="onDesktopBackendPortApply"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
|
||||
@click="onDesktopBackendPortApply"
|
||||
>
|
||||
{{ desktopBackendPortApplying ? '...' : '应用' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="desktopBackendPortLoading || desktopBackendPortApplying"
|
||||
@click="onDesktopBackendPortReset"
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="desktopBackendPortError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopBackendPortError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">output 目录</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopOutputDirText }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isDesktopEnv || desktopOutputDirLoading"
|
||||
@click="onDesktopOpenOutputDir"
|
||||
>
|
||||
打开 output
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="desktopOutputDirError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopOutputDirError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">日志文件</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090] break-words">{{ desktopLogFileText }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-white px-2 py-1 text-[12px] text-[#222] transition hover:bg-[#f9f9f9] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="desktopLogFileLoading || desktopLogFileOpening"
|
||||
@click="onOpenBackendLogFile"
|
||||
>
|
||||
{{ desktopLogFileOpening ? '打开中...' : '打开日志' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="desktopLogFileError" class="mt-1.5 text-[11px] text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopLogFileError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section ref="startupSectionRef">
|
||||
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">启动偏好</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">启动后自动开启实时获取</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">进入聊天页后自动打开“实时开关”</div>
|
||||
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">启动偏好</div>
|
||||
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">启动后自动开启实时获取</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">进入聊天页后自动打开“实时开关”</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="desktopAutoRealtime"
|
||||
class="settings-switch shrink-0"
|
||||
:class="switchTrackClass(desktopAutoRealtime)"
|
||||
@click="toggleDesktopAutoRealtime"
|
||||
>
|
||||
<span class="settings-switch-thumb" :class="desktopAutoRealtime ? 'translate-x-[20px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="desktopAutoRealtime"
|
||||
class="settings-switch shrink-0"
|
||||
:class="switchTrackClass(desktopAutoRealtime)"
|
||||
@click="toggleDesktopAutoRealtime"
|
||||
>
|
||||
<span class="settings-switch-thumb" :class="desktopAutoRealtime ? 'translate-x-[20px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">有数据时默认进入聊天页</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">有已解密账号时,打开应用跳转到 /chat</div>
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">有数据时默认进入聊天页</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">有已解密账号时,打开应用跳转到 /chat</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="desktopDefaultToChatWhenData"
|
||||
class="settings-switch shrink-0"
|
||||
:class="switchTrackClass(desktopDefaultToChatWhenData)"
|
||||
@click="toggleDesktopDefaultToChat"
|
||||
>
|
||||
<span class="settings-switch-thumb" :class="desktopDefaultToChatWhenData ? 'translate-x-[20px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="desktopDefaultToChatWhenData"
|
||||
class="settings-switch shrink-0"
|
||||
:class="switchTrackClass(desktopDefaultToChatWhenData)"
|
||||
@click="toggleDesktopDefaultToChat"
|
||||
>
|
||||
<span class="settings-switch-thumb" :class="desktopDefaultToChatWhenData ? 'translate-x-[20px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section ref="updatesSectionRef">
|
||||
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">更新</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">当前版本</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">{{ desktopVersionText }}</div>
|
||||
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">更新</div>
|
||||
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">当前版本</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">{{ desktopVersionText }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-[#fafafa] px-2.5 py-1 text-[12px] text-[#222] transition hover:bg-[#f0f0f0] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isDesktopEnv || desktopUpdate.manualCheckLoading.value"
|
||||
@click="onDesktopCheckUpdates"
|
||||
>
|
||||
{{ desktopUpdate.manualCheckLoading.value ? '检查中...' : '检查桌面版更新' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="desktopUpdate.lastCheckMessage.value" class="mt-2 rounded-[6px] bg-[#f9f9f9] border border-[#eee] px-2.5 py-1.5 text-[11px] text-[#666] whitespace-pre-wrap break-words">
|
||||
{{ desktopUpdate.lastCheckMessage.value }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-[6px] border border-[#e2e2e2] bg-[#fafafa] px-2.5 py-1 text-[12px] text-[#222] transition hover:bg-[#f0f0f0] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!isDesktopEnv || desktopUpdate.manualCheckLoading.value"
|
||||
@click="onDesktopCheckUpdates"
|
||||
>
|
||||
{{ desktopUpdate.manualCheckLoading.value ? '检查中...' : '检查桌面版更新' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="desktopUpdate.lastCheckMessage.value" class="mt-2 rounded-[6px] bg-[#f9f9f9] border border-[#eee] px-2.5 py-1.5 text-[11px] text-[#666] whitespace-pre-wrap break-words">
|
||||
{{ desktopUpdate.lastCheckMessage.value }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section ref="snsSectionRef">
|
||||
<div class="mb-3 text-[12px] font-bold text-[#999] tracking-widest">朋友圈</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">朋友圈图片使用缓存</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">开启:下载解密失败时回退本地缓存(默认);关闭:始终重新下载</div>
|
||||
<div class="mb-2.5 text-[12px] font-bold text-[#999] tracking-widest">朋友圈</div>
|
||||
<div class="overflow-hidden rounded-[10px] border border-[#e7e7e7] bg-white divide-y divide-[#ececec]">
|
||||
<div class="px-3.5 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium text-[#222]">朋友圈图片使用缓存</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#909090]">开启:下载解密失败时回退本地缓存(默认);关闭:始终重新下载</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="snsUseCache"
|
||||
class="settings-switch shrink-0"
|
||||
:class="switchTrackClass(snsUseCache)"
|
||||
@click="toggleSnsUseCache"
|
||||
>
|
||||
<span class="settings-switch-thumb" :class="snsUseCache ? 'translate-x-[20px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="snsUseCache"
|
||||
class="settings-switch shrink-0"
|
||||
:class="switchTrackClass(snsUseCache)"
|
||||
@click="toggleSnsUseCache"
|
||||
>
|
||||
<span class="settings-switch-thumb" :class="snsUseCache ? 'translate-x-[20px]' : 'translate-x-0'" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -253,10 +289,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
import { readApiBaseOverride, writeApiBaseOverride } from '~/utils/api-settings'
|
||||
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/lib/desktop-settings'
|
||||
import { readApiBaseOverride, writeApiBaseOverride } from '~/lib/api-settings'
|
||||
import { invalidateApiBaseCache } from '~/composables/useApiBase'
|
||||
import { reportServerErrorFromError } from '~/lib/server-error-logging'
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -315,6 +353,15 @@ const desktopOutputDirText = computed(() => {
|
||||
return v || '—'
|
||||
})
|
||||
|
||||
const desktopLogFilePath = ref('')
|
||||
const desktopLogFileLoading = ref(false)
|
||||
const desktopLogFileOpening = ref(false)
|
||||
const desktopLogFileError = ref('')
|
||||
const desktopLogFileText = computed(() => {
|
||||
const v = String(desktopLogFilePath.value || '').trim()
|
||||
return v || '—'
|
||||
})
|
||||
|
||||
const switchTrackClass = (enabled, disabled = false) => {
|
||||
if (disabled) return enabled ? 'bg-[#07b75b] opacity-50 cursor-not-allowed' : 'bg-[#d0d0d0] opacity-50 cursor-not-allowed'
|
||||
return enabled ? 'bg-[#07b75b] hover:brightness-95' : 'bg-[#d0d0d0] hover:brightness-95'
|
||||
@@ -360,6 +407,24 @@ const onEscKeydown = (event) => {
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const fetchAdminEndpoint = async (url, options = {}) => {
|
||||
const apiBase = useApiBase()
|
||||
try {
|
||||
return await $fetch(url, {
|
||||
baseURL: apiBase,
|
||||
...options,
|
||||
})
|
||||
} catch (e) {
|
||||
await reportServerErrorFromError(e, {
|
||||
method: options?.method || 'GET',
|
||||
requestUrl: url,
|
||||
source: 'SettingsDialog',
|
||||
apiBase,
|
||||
})
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDesktopAutoLaunch = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getAutoLaunch) return
|
||||
@@ -436,8 +501,7 @@ const refreshDesktopBackendPort = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const apiBase = useApiBase()
|
||||
const resp = await $fetch('/admin/port', { baseURL: apiBase })
|
||||
const resp = await fetchAdminEndpoint('/admin/port')
|
||||
const n = Number(resp?.port)
|
||||
const d = Number(resp?.default_port)
|
||||
if (Number.isInteger(d) && d >= 1 && d <= 65535) desktopBackendPortDefault.value = d
|
||||
@@ -494,6 +558,34 @@ const onDesktopOpenOutputDir = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const refreshBackendLogFileInfo = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
desktopLogFileLoading.value = true
|
||||
desktopLogFileError.value = ''
|
||||
try {
|
||||
const resp = await fetchAdminEndpoint('/admin/log-file')
|
||||
desktopLogFilePath.value = String(resp?.path || '').trim()
|
||||
} catch (e) {
|
||||
desktopLogFileError.value = e?.message || '读取日志文件失败'
|
||||
} finally {
|
||||
desktopLogFileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenBackendLogFile = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
desktopLogFileOpening.value = true
|
||||
desktopLogFileError.value = ''
|
||||
try {
|
||||
const resp = await fetchAdminEndpoint('/admin/log-file/open', { method: 'POST' })
|
||||
if (resp?.path) desktopLogFilePath.value = String(resp.path || '').trim()
|
||||
} catch (e) {
|
||||
desktopLogFileError.value = e?.message || '打开日志文件失败'
|
||||
} finally {
|
||||
desktopLogFileOpening.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const applyDesktopBackendPort = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
const raw = String(desktopBackendPortInput.value || '').trim()
|
||||
@@ -510,10 +602,9 @@ const applyDesktopBackendPort = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const currentApiBase = useApiBase()
|
||||
let currentBackendPort = null
|
||||
try {
|
||||
const info = await $fetch('/admin/port', { baseURL: currentApiBase })
|
||||
const info = await fetchAdminEndpoint('/admin/port')
|
||||
const p = Number(info?.port)
|
||||
if (Number.isInteger(p) && p >= 1 && p <= 65535) currentBackendPort = p
|
||||
} catch {}
|
||||
@@ -524,8 +615,7 @@ const applyDesktopBackendPort = async () => {
|
||||
})()
|
||||
const isUiServedByBackend = !!(currentBackendPort && uiPort === currentBackendPort)
|
||||
|
||||
await $fetch('/admin/port', {
|
||||
baseURL: currentApiBase,
|
||||
await fetchAdminEndpoint('/admin/port', {
|
||||
method: 'POST',
|
||||
body: { port: n },
|
||||
})
|
||||
@@ -535,6 +625,7 @@ const applyDesktopBackendPort = async () => {
|
||||
const host = String(window.location?.hostname || '').trim() || '127.0.0.1'
|
||||
const nextOrigin = `${protocol}//${host}:${n}`
|
||||
writeApiBaseOverride(`${nextOrigin}/api`)
|
||||
invalidateApiBaseCache()
|
||||
|
||||
const waitForHealth = async (healthUrl, timeoutMs = 30_000) => {
|
||||
const startedAt = Date.now()
|
||||
@@ -608,6 +699,11 @@ const onDesktopCheckUpdates = async () => {
|
||||
await desktopUpdate.manualCheck()
|
||||
}
|
||||
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
await refreshBackendLogFileInfo()
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
const isElectron = /electron/i.test(String(navigator.userAgent || ''))
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
|
||||
<!-- Avatar -->
|
||||
<div class="w-full h-[60px] flex items-center justify-center">
|
||||
<div class="w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="group relative w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0 ring-1 ring-transparent transition hover:ring-[#07b75b]/40"
|
||||
title="账号信息"
|
||||
@click="openAccountDialog"
|
||||
>
|
||||
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
|
||||
<div
|
||||
v-else
|
||||
@@ -15,7 +20,7 @@
|
||||
>
|
||||
我
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chat -->
|
||||
@@ -164,22 +169,116 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="goSettings"
|
||||
title="设置"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="settingsDialogOpen ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<div class="mt-auto">
|
||||
<!-- Guide -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
title="引导页"
|
||||
@click="goGuide"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#5d5d5d]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 10.5L12 3l9 7.5" />
|
||||
<path d="M5 9.5V20h14V9.5" />
|
||||
<path d="M10 20v-6h4v6" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="goSettings"
|
||||
title="设置"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="settingsDialogOpen ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="accountDialogOpen"
|
||||
class="fixed inset-0 z-[130] flex items-center justify-center bg-black/35 px-4"
|
||||
@click.self="closeAccountDialog"
|
||||
>
|
||||
<div class="w-full max-w-[440px] overflow-hidden rounded-[12px] border border-[#e7e7e7] bg-white shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b border-[#efefef] px-4 py-3">
|
||||
<div class="text-[14px] font-semibold text-[#222]">当前账号信息</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 items-center justify-center rounded-md text-[#888] transition hover:bg-[#f2f2f2] hover:text-[#222]"
|
||||
title="关闭"
|
||||
:disabled="accountDeleteLoading"
|
||||
@click="closeAccountDialog"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 px-4 py-4">
|
||||
<div v-if="accountInfoLoading" class="text-[12px] text-[#7a7a7a]">正在加载账号信息...</div>
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-[42px] h-[42px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
|
||||
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
|
||||
:style="{ backgroundColor: '#4B5563' }"
|
||||
>
|
||||
我
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[14px] font-semibold text-[#222]">{{ selectedAccount || '未选择账号' }}</div>
|
||||
<div class="mt-0.5 text-[11px] text-[#8a8a8a]">账号标识(wxid)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[8px] border border-[#ededed] bg-[#fafafa] px-3 py-2 text-[12px] text-[#5f5f5f] space-y-1.5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="text-[#8a8a8a] shrink-0">数据库数量</span>
|
||||
<span class="font-medium text-[#333]">{{ accountInfo?.database_count ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="text-[#8a8a8a] shrink-0">数据目录</span>
|
||||
<span class="break-all text-right text-[#444]">{{ accountInfo?.path || (selectedAccount ? `output/databases/${selectedAccount}` : '—') }}</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="text-[#8a8a8a] shrink-0">最近会话库更新时间</span>
|
||||
<span class="text-[#444]">{{ sessionUpdatedAtText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="rounded-[8px] border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] leading-relaxed text-amber-900">
|
||||
仅删除本项目中的该账号解析数据/缓存/编辑记录,不会删除微信客户端中的任何聊天内容或账号数据。
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-[8px] border border-red-200 bg-red-50 px-3 py-2 text-[12px] font-medium text-red-700 transition hover:bg-red-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!selectedAccount || accountDeleteLoading"
|
||||
@click="deleteCurrentAccountData"
|
||||
>
|
||||
{{ accountDeleteLoading ? '删除中...' : '删除当前账号的项目数据' }}
|
||||
</button>
|
||||
<div class="text-[11px] text-[#8a8a8a]">删除成功后将自动返回引导页。</div>
|
||||
|
||||
<div v-if="accountInfoError" class="text-[11px] text-red-600 whitespace-pre-wrap">{{ accountInfoError }}</div>
|
||||
<div v-if="accountDeleteError" class="text-[11px] text-red-600 whitespace-pre-wrap">{{ accountDeleteError }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,9 +301,130 @@ const { privacyMode } = storeToRefs(privacyStore)
|
||||
const realtimeStore = useChatRealtimeStore()
|
||||
const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realtimeChecking, statusError: realtimeStatusError, toggling: realtimeToggling } = storeToRefs(realtimeStore)
|
||||
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
|
||||
const { getChatAccountInfo, deleteChatAccount } = useApi()
|
||||
|
||||
const accountDialogOpen = ref(false)
|
||||
const accountInfoLoading = ref(false)
|
||||
const accountInfoError = ref('')
|
||||
const accountInfo = ref(null)
|
||||
const accountDeleteLoading = ref(false)
|
||||
const accountDeleteError = ref('')
|
||||
const accountInfoApiUnsupported = ref(false)
|
||||
const deleteAccountApiUnsupported = ref(false)
|
||||
|
||||
const sessionUpdatedAtText = computed(() => {
|
||||
const ts = Number(accountInfo.value?.session_updated_at || 0)
|
||||
if (!Number.isFinite(ts) || ts <= 0) return '—'
|
||||
try {
|
||||
return new Date(ts * 1000).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
})
|
||||
|
||||
const isNotFoundError = (error) => {
|
||||
const status = Number(
|
||||
error?.statusCode
|
||||
?? error?.status
|
||||
?? error?.response?.status
|
||||
?? error?.data?.statusCode
|
||||
?? 0
|
||||
)
|
||||
return status === 404
|
||||
}
|
||||
|
||||
const loadAccountInfoByDesktopBridge = async (account) => {
|
||||
if (!process.client || typeof window === 'undefined') return null
|
||||
if (!window.wechatDesktop?.getAccountInfo) return null
|
||||
const res = await window.wechatDesktop.getAccountInfo(account)
|
||||
return res && typeof res === 'object' ? res : null
|
||||
}
|
||||
|
||||
const loadAccountInfo = async () => {
|
||||
accountInfoLoading.value = true
|
||||
accountInfoError.value = ''
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
if (!account) {
|
||||
accountInfo.value = null
|
||||
accountInfoLoading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
let lastError = null
|
||||
if (!accountInfoApiUnsupported.value) {
|
||||
try {
|
||||
const res = await getChatAccountInfo({ account })
|
||||
if (res?.status !== 'success') {
|
||||
throw new Error(res?.message || '读取账号信息失败')
|
||||
}
|
||||
accountInfo.value = res
|
||||
return
|
||||
} catch (e) {
|
||||
lastError = e
|
||||
if (isNotFoundError(e)) {
|
||||
accountInfoApiUnsupported.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fallback = await loadAccountInfoByDesktopBridge(account)
|
||||
if (fallback?.status === 'success') {
|
||||
accountInfo.value = fallback
|
||||
accountInfoError.value = ''
|
||||
return
|
||||
}
|
||||
if (fallback && fallback?.status && fallback.status !== 'success') {
|
||||
lastError = new Error(fallback?.message || '读取账号信息失败')
|
||||
} else if (!lastError) {
|
||||
lastError = new Error('读取账号信息失败')
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
if (!lastError) {
|
||||
lastError = fallbackErr
|
||||
}
|
||||
}
|
||||
|
||||
accountInfo.value = null
|
||||
accountInfoError.value = lastError?.message || '读取账号信息失败'
|
||||
} finally {
|
||||
accountInfoLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteAccountDataByDesktopBridge = async (account) => {
|
||||
if (!process.client || typeof window === 'undefined') return null
|
||||
if (!window.wechatDesktop?.deleteAccountData) return null
|
||||
const res = await window.wechatDesktop.deleteAccountData(account)
|
||||
return res && typeof res === 'object' ? res : { status: 'success' }
|
||||
}
|
||||
|
||||
const openAccountDialog = async () => {
|
||||
accountDialogOpen.value = true
|
||||
accountDeleteError.value = ''
|
||||
await loadAccountInfo()
|
||||
}
|
||||
|
||||
const closeAccountDialog = () => {
|
||||
if (accountDeleteLoading.value) return
|
||||
accountDialogOpen.value = false
|
||||
}
|
||||
|
||||
watch(selectedAccount, () => {
|
||||
if (!accountDialogOpen.value) return
|
||||
void loadAccountInfo()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await chatAccounts.ensureLoaded()
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
window.addEventListener('keydown', onWindowKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
window.removeEventListener('keydown', onWindowKeydown)
|
||||
})
|
||||
|
||||
const apiBase = useApiBase()
|
||||
@@ -240,10 +460,73 @@ const goWrapped = async () => {
|
||||
await navigateTo('/wrapped')
|
||||
}
|
||||
|
||||
const goGuide = async () => {
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
const goSettings = () => {
|
||||
openSettingsDialog()
|
||||
}
|
||||
|
||||
const onWindowKeydown = (event) => {
|
||||
if (event?.key !== 'Escape') return
|
||||
if (!accountDialogOpen.value) return
|
||||
event.preventDefault()
|
||||
closeAccountDialog()
|
||||
}
|
||||
|
||||
const deleteCurrentAccountData = async () => {
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
if (!account || accountDeleteLoading.value) return
|
||||
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
const confirmed = window.confirm(
|
||||
'将删除当前账号在本项目中的数据(解析缓存、编辑记录、导出缓存等),不会删除微信客户端内容。确认删除吗?'
|
||||
)
|
||||
if (!confirmed) return
|
||||
}
|
||||
|
||||
accountDeleteLoading.value = true
|
||||
accountDeleteError.value = ''
|
||||
try {
|
||||
let deleted = false
|
||||
let lastError = null
|
||||
|
||||
if (!deleteAccountApiUnsupported.value) {
|
||||
try {
|
||||
const apiRes = await deleteChatAccount({ account })
|
||||
if (apiRes?.status && apiRes.status !== 'success') {
|
||||
throw new Error(apiRes?.message || '删除账号数据失败')
|
||||
}
|
||||
deleted = true
|
||||
} catch (apiErr) {
|
||||
lastError = apiErr
|
||||
if (isNotFoundError(apiErr)) {
|
||||
deleteAccountApiUnsupported.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
const desktopRes = await deleteAccountDataByDesktopBridge(account)
|
||||
if (!desktopRes) {
|
||||
throw lastError || new Error('删除账号数据失败')
|
||||
}
|
||||
if (desktopRes?.status && desktopRes.status !== 'success') {
|
||||
throw new Error(desktopRes?.message || '删除账号数据失败')
|
||||
}
|
||||
}
|
||||
|
||||
accountDialogOpen.value = false
|
||||
await chatAccounts.ensureLoaded({ force: true })
|
||||
await navigateTo('/')
|
||||
} catch (e) {
|
||||
accountDeleteError.value = e?.message || '删除账号数据失败'
|
||||
} finally {
|
||||
accountDeleteLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const realtimeBusy = computed(() => !!realtimeChecking.value || !!realtimeToggling.value)
|
||||
|
||||
const realtimeTitle = computed(() => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<div v-if="selectedContact" class="flex-1 flex flex-col min-h-0 relative">
|
||||
<div class="chat-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-base font-medium text-gray-900" :class="{ 'privacy-blur': privacyMode }">
|
||||
{{ selectedContact ? selectedContact.name : '' }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button class="header-btn-icon" @click="refreshSelectedMessages" :disabled="isLoadingMessages" title="刷新消息">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-btn-icon" @click="openExportModal" :disabled="isExportCreating" title="导出聊天记录">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-btn-icon" :class="{ 'header-btn-icon-active': reverseMessageSides }" @click="toggleReverseMessageSides" :disabled="!selectedContact" :title="reverseMessageSides ? '取消反转消息位置' : '反转消息位置'">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 7h14" />
|
||||
<path d="M14 3l4 4-4 4" />
|
||||
<path d="M20 17H6" />
|
||||
<path d="M10 13l-4 4 4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-btn-icon" :class="{ 'header-btn-icon-active': messageSearchOpen }" @click="toggleMessageSearch" :title="messageSearchOpen ? '关闭搜索 (Esc)' : '搜索聊天记录 (Ctrl+F)'">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 16 16">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 14L11.1 11.1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-btn-icon" :class="{ 'header-btn-icon-active': timeSidebarOpen }" @click="toggleTimeSidebar" :disabled="!selectedContact || isLoadingMessages" title="按日期定位">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M8 7V3m8 4V3M3 11h18" />
|
||||
<rect x="4" y="5" width="16" height="16" rx="2" ry="2" stroke-width="1.8" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M7 14h2m3 0h2m3 0h2M7 18h2m3 0h2" />
|
||||
</svg>
|
||||
</button>
|
||||
<select
|
||||
v-model="messageTypeFilter"
|
||||
class="message-filter-select"
|
||||
:disabled="isLoadingMessages || searchContext.active"
|
||||
:title="searchContext.active ? '上下文模式下暂不可筛选' : '筛选消息类型'"
|
||||
>
|
||||
<option v-for="opt in messageTypeFilterOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="searchContext.active" class="px-6 py-2 border-b border-emerald-200 bg-emerald-50 flex items-center gap-3">
|
||||
<div class="text-sm text-emerald-900">
|
||||
{{ searchContextBannerText }}
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button type="button" class="text-xs px-3 py-1 rounded-md bg-white border border-emerald-200 hover:bg-emerald-100" @click="exitSearchContext">
|
||||
退出定位
|
||||
</button>
|
||||
<button type="button" class="text-xs px-3 py-1 rounded-md bg-white border border-gray-200 hover:bg-gray-50" @click="refreshSelectedMessages">
|
||||
返回最新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageList :state="state" />
|
||||
|
||||
<button
|
||||
v-if="showJumpToBottom"
|
||||
type="button"
|
||||
class="absolute bottom-6 right-6 z-20 w-10 h-10 rounded-full bg-white/90 border border-gray-200 shadow hover:bg-white flex items-center justify-center"
|
||||
title="回到最新"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-20 h-20 mx-auto mb-5 rounded-2xl bg-gradient-to-br from-[#03C160]/10 to-[#03C160]/5 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-[#03C160]/60" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-medium text-gray-700 mb-1.5">选择一个会话</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
从左侧列表选择联系人查看聊天记录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import MessageList from '~/components/chat/MessageList.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ConversationPane',
|
||||
components: { MessageList },
|
||||
props: {
|
||||
state: { type: Object, required: true }
|
||||
},
|
||||
setup(props) {
|
||||
return {
|
||||
...props.state
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -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,285 @@
|
||||
<script>
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'LinkCard',
|
||||
props: {
|
||||
href: { type: String, default: '' },
|
||||
heading: { type: String, default: '' },
|
||||
abstract: { type: String, default: '' },
|
||||
preview: { type: String, default: '' },
|
||||
fromAvatar: { type: String, default: '' },
|
||||
from: { type: String, default: '' },
|
||||
linkType: { type: String, default: '' },
|
||||
isSent: { type: Boolean, default: false },
|
||||
badge: { type: String, default: '' },
|
||||
variant: { type: String, default: 'default' }
|
||||
},
|
||||
setup(props) {
|
||||
const fromAvatarImgOk = ref(false)
|
||||
const fromAvatarImgError = ref(false)
|
||||
const lastFromAvatarUrl = ref('')
|
||||
|
||||
const getFromText = () => {
|
||||
const raw = String(props.from || '').trim()
|
||||
if (raw) return raw
|
||||
try {
|
||||
const href = String(props.href || '').trim()
|
||||
if (!/^https?:\/\//i.test(href)) return ''
|
||||
return String(new URL(href).hostname || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
const fromText = getFromText()
|
||||
const href = String(props.href || '').trim()
|
||||
const canNavigate = /^https?:\/\//i.test(href)
|
||||
const badgeText = String(props.badge || '').trim()
|
||||
const fromAvatarText = (() => {
|
||||
const text = String(fromText || '').trim()
|
||||
return text ? (Array.from(text)[0] || '') : ''
|
||||
})()
|
||||
const fromAvatarUrl = String(props.fromAvatar || '').trim()
|
||||
const isMiniProgram = String(props.linkType || '').trim() === 'mini_program'
|
||||
const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
|
||||
const Tag = canNavigate ? 'a' : 'div'
|
||||
|
||||
if (fromAvatarUrl !== lastFromAvatarUrl.value) {
|
||||
lastFromAvatarUrl.value = fromAvatarUrl
|
||||
fromAvatarImgOk.value = false
|
||||
fromAvatarImgError.value = false
|
||||
}
|
||||
|
||||
const showFromAvatarImg = Boolean(fromAvatarUrl) && !fromAvatarImgError.value
|
||||
const showFromAvatarText = (!fromAvatarUrl) || (!fromAvatarImgOk.value)
|
||||
const fromAvatarStyle = fromAvatarImgOk.value
|
||||
? {
|
||||
background: isCoverVariant ? 'rgba(255, 255, 255, 0.92)' : '#fff',
|
||||
color: 'transparent'
|
||||
}
|
||||
: null
|
||||
const miniProgramAvatarStyle = fromAvatarImgOk.value
|
||||
? {
|
||||
background: '#fff',
|
||||
color: 'transparent'
|
||||
}
|
||||
: null
|
||||
const onFromAvatarLoad = () => {
|
||||
fromAvatarImgOk.value = true
|
||||
fromAvatarImgError.value = false
|
||||
}
|
||||
const onFromAvatarError = () => {
|
||||
fromAvatarImgOk.value = false
|
||||
fromAvatarImgError.value = true
|
||||
}
|
||||
|
||||
if (isCoverVariant) {
|
||||
const fromRow = h('div', { class: 'wechat-link-cover-from' }, [
|
||||
h('div', { class: 'wechat-link-cover-from-avatar', style: fromAvatarStyle, 'aria-hidden': 'true' }, [
|
||||
showFromAvatarText ? (fromAvatarText || '\u200B') : null,
|
||||
showFromAvatarImg
|
||||
? h('img', {
|
||||
src: fromAvatarUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-cover-from-avatar-img',
|
||||
referrerpolicy: 'no-referrer',
|
||||
onLoad: onFromAvatarLoad,
|
||||
onError: onFromAvatarError
|
||||
})
|
||||
: null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-cover-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
|
||||
badgeText ? h('div', { class: 'wechat-link-cover-badge' }, badgeText) : null
|
||||
].filter(Boolean))
|
||||
|
||||
return h(
|
||||
Tag,
|
||||
{
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card-cover',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
].filter(Boolean).join(' '),
|
||||
style: {
|
||||
width: '137px',
|
||||
minWidth: '137px',
|
||||
maxWidth: '137px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
outline: 'none'
|
||||
}
|
||||
},
|
||||
[
|
||||
props.preview
|
||||
? h('div', { class: 'wechat-link-cover-image-wrap' }, [
|
||||
h('img', {
|
||||
src: props.preview,
|
||||
alt: props.heading || '链接封面',
|
||||
class: 'wechat-link-cover-image',
|
||||
referrerpolicy: 'no-referrer'
|
||||
}),
|
||||
fromRow
|
||||
])
|
||||
: fromRow,
|
||||
h('div', { class: 'wechat-link-cover-title' }, props.heading || href)
|
||||
].filter(Boolean)
|
||||
)
|
||||
}
|
||||
|
||||
const headingText = String(props.heading || href || '').trim()
|
||||
let abstractText = String(props.abstract || '').trim()
|
||||
if (abstractText && headingText && abstractText === headingText) abstractText = ''
|
||||
|
||||
if (isMiniProgram) {
|
||||
return h(
|
||||
Tag,
|
||||
{
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card',
|
||||
'wechat-link-card--mini-program',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
].filter(Boolean).join(' '),
|
||||
style: {
|
||||
width: '210px',
|
||||
minWidth: '210px',
|
||||
maxWidth: '210px',
|
||||
maxHeight: '270px',
|
||||
height: '270px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
outline: 'none'
|
||||
}
|
||||
},
|
||||
[
|
||||
h('div', { class: 'wechat-link-mini-body' }, [
|
||||
h('div', { class: 'wechat-link-mini-header' }, [
|
||||
h('div', { class: 'wechat-link-mini-header-avatar', style: miniProgramAvatarStyle, 'aria-hidden': 'true' }, [
|
||||
showFromAvatarText ? (fromAvatarText || '\u200B') : null,
|
||||
showFromAvatarImg
|
||||
? h('img', {
|
||||
src: fromAvatarUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-mini-header-avatar-img',
|
||||
referrerpolicy: 'no-referrer',
|
||||
onLoad: onFromAvatarLoad,
|
||||
onError: onFromAvatarError
|
||||
})
|
||||
: null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-mini-header-name' }, fromText || '\u200B')
|
||||
]),
|
||||
h('div', { class: 'wechat-link-mini-title' }, headingText || abstractText || href),
|
||||
h('div', { class: ['wechat-link-mini-preview', !props.preview ? 'wechat-link-mini-preview--empty' : ''].filter(Boolean).join(' ') }, [
|
||||
props.preview
|
||||
? h('img', {
|
||||
src: props.preview,
|
||||
alt: props.heading || '小程序预览',
|
||||
class: 'wechat-link-mini-preview-img',
|
||||
referrerpolicy: 'no-referrer'
|
||||
})
|
||||
: null
|
||||
].filter(Boolean))
|
||||
]),
|
||||
h('div', { class: 'wechat-link-mini-footer' }, [
|
||||
h('img', {
|
||||
src: miniProgramIconUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-mini-footer-icon',
|
||||
'aria-hidden': 'true'
|
||||
}),
|
||||
h('span', { class: 'wechat-link-mini-footer-text' }, '小程序')
|
||||
])
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
return h(
|
||||
Tag,
|
||||
{
|
||||
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
|
||||
class: [
|
||||
'wechat-link-card',
|
||||
!canNavigate ? 'wechat-link-card--disabled' : '',
|
||||
'wechat-special-card',
|
||||
'msg-radius',
|
||||
props.isSent ? 'wechat-special-sent-side' : ''
|
||||
].filter(Boolean).join(' '),
|
||||
style: {
|
||||
width: '210px',
|
||||
minWidth: '210px',
|
||||
maxWidth: '210px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxSizing: 'border-box',
|
||||
flex: '0 0 auto',
|
||||
background: '#fff',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
textDecoration: 'none',
|
||||
outline: 'none'
|
||||
}
|
||||
},
|
||||
[
|
||||
h('div', { class: 'wechat-link-content' }, [
|
||||
h('div', { class: 'wechat-link-title' }, headingText || href),
|
||||
(abstractText || props.preview)
|
||||
? h('div', { class: 'wechat-link-summary' }, [
|
||||
abstractText ? h('div', { class: 'wechat-link-desc' }, abstractText) : null,
|
||||
props.preview
|
||||
? h('div', { class: 'wechat-link-thumb' }, [
|
||||
h('img', {
|
||||
src: props.preview,
|
||||
alt: props.heading || '链接预览',
|
||||
class: 'wechat-link-thumb-img',
|
||||
referrerpolicy: 'no-referrer'
|
||||
})
|
||||
])
|
||||
: null
|
||||
].filter(Boolean))
|
||||
: null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-from' }, [
|
||||
h('div', { class: 'wechat-link-from-avatar', style: fromAvatarStyle, 'aria-hidden': 'true' }, [
|
||||
showFromAvatarText ? (fromAvatarText || '\u200B') : null,
|
||||
showFromAvatarImg
|
||||
? h('img', {
|
||||
src: fromAvatarUrl,
|
||||
alt: '',
|
||||
class: 'wechat-link-from-avatar-img',
|
||||
referrerpolicy: 'no-referrer',
|
||||
onLoad: onFromAvatarLoad,
|
||||
onError: onFromAvatarError
|
||||
})
|
||||
: null
|
||||
].filter(Boolean)),
|
||||
h('div', { class: 'wechat-link-from-name', style: { flex: '1 1 auto', minWidth: '0' } }, fromText || '\u200B'),
|
||||
badgeText ? h('div', { class: 'wechat-link-badge' }, badgeText) : null
|
||||
].filter(Boolean))
|
||||
].filter(Boolean)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -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,545 @@
|
||||
import { ref, toRaw } from 'vue'
|
||||
|
||||
const initialContextMenu = () => ({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
message: null,
|
||||
kind: '',
|
||||
disabled: false,
|
||||
editStatus: null,
|
||||
editStatusLoading: false
|
||||
})
|
||||
|
||||
const initialMessageEditModal = () => ({
|
||||
open: false,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: '',
|
||||
mode: 'content',
|
||||
sessionId: '',
|
||||
messageId: '',
|
||||
draft: '',
|
||||
rawRow: null
|
||||
})
|
||||
|
||||
const initialMessageFieldsModal = () => ({
|
||||
open: false,
|
||||
loading: false,
|
||||
saving: false,
|
||||
error: '',
|
||||
sessionId: '',
|
||||
messageId: '',
|
||||
unsafe: false,
|
||||
editsJson: '',
|
||||
rawRow: null
|
||||
})
|
||||
|
||||
export const useChatEditing = ({
|
||||
api,
|
||||
selectedAccount,
|
||||
selectedContact,
|
||||
refreshSelectedMessages,
|
||||
normalizeMessage,
|
||||
allMessages,
|
||||
locateMessageByServerId
|
||||
}) => {
|
||||
const contextMenu = ref(initialContextMenu())
|
||||
const messageEditModal = ref(initialMessageEditModal())
|
||||
const messageFieldsModal = ref(initialMessageFieldsModal())
|
||||
|
||||
const closeContextMenu = () => {
|
||||
contextMenu.value = initialContextMenu()
|
||||
}
|
||||
|
||||
const loadContextMenuEditStatus = async (params) => {
|
||||
if (!process.client) return
|
||||
const account = String(params?.account || '').trim()
|
||||
const username = String(params?.username || '').trim()
|
||||
const messageId = String(params?.message_id || '').trim()
|
||||
if (!account || !username || !messageId) {
|
||||
contextMenu.value.editStatusLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.getChatEditStatus({ account, username, message_id: messageId })
|
||||
const current = String(contextMenu.value?.message?.id || '').trim()
|
||||
if (contextMenu.value.visible && current === messageId) {
|
||||
contextMenu.value.editStatus = response || { modified: false }
|
||||
}
|
||||
} catch {
|
||||
const current = String(contextMenu.value?.message?.id || '').trim()
|
||||
if (contextMenu.value.visible && current === messageId) {
|
||||
contextMenu.value.editStatus = null
|
||||
}
|
||||
} finally {
|
||||
const current = String(contextMenu.value?.message?.id || '').trim()
|
||||
if (contextMenu.value.visible && current === messageId) {
|
||||
contextMenu.value.editStatusLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openMediaContextMenu = (event, message, kind) => {
|
||||
if (!process.client) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
let actualKind = kind
|
||||
let disabled = true
|
||||
if (kind === 'voice') {
|
||||
disabled = !(message?.serverIdStr || message?.serverId)
|
||||
} else if (kind === 'file') {
|
||||
disabled = !message?.fileMd5
|
||||
} else if (kind === 'image') {
|
||||
disabled = !(message?.imageMd5 || message?.imageFileId)
|
||||
} else if (kind === 'emoji') {
|
||||
disabled = !message?.emojiMd5
|
||||
} else if (kind === 'video') {
|
||||
if (message?.videoMd5 || message?.videoFileId) {
|
||||
disabled = false
|
||||
actualKind = 'video'
|
||||
} else if (message?.videoThumbMd5 || message?.videoThumbFileId) {
|
||||
disabled = false
|
||||
actualKind = 'video_thumb'
|
||||
}
|
||||
}
|
||||
|
||||
contextMenu.value = {
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
message,
|
||||
kind: actualKind,
|
||||
disabled,
|
||||
editStatus: null,
|
||||
editStatusLoading: false
|
||||
}
|
||||
|
||||
try {
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const username = String(selectedContact.value?.username || '').trim()
|
||||
const messageId = String(message?.id || '').trim()
|
||||
if (account && username && messageId) {
|
||||
contextMenu.value.editStatusLoading = true
|
||||
void loadContextMenuEditStatus({ account, username, message_id: messageId })
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const prettyJson = (value) => {
|
||||
try {
|
||||
return JSON.stringify(value ?? null, null, 2)
|
||||
} catch {
|
||||
return String(value ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
const isLikelyTextMessage = (message) => {
|
||||
if (!message) return false
|
||||
const renderType = String(message?.renderType || '').trim()
|
||||
if (renderType && renderType !== 'text') return false
|
||||
if (message?.imageUrl || message?.emojiUrl || message?.videoUrl || message?.voiceUrl) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const closeMessageEditModal = () => {
|
||||
messageEditModal.value = initialMessageEditModal()
|
||||
}
|
||||
|
||||
const openMessageEditModal = async ({ message, mode }) => {
|
||||
if (!process.client) return
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(selectedContact.value?.username || '').trim()
|
||||
const messageId = String(message?.id || '').trim()
|
||||
if (!account || !sessionId || !messageId) return
|
||||
|
||||
const resolvedMode = mode === 'raw' ? 'raw' : 'content'
|
||||
const initialDraft = resolvedMode === 'content'
|
||||
? (typeof message?.content === 'string' ? message.content : String(message?.content ?? ''))
|
||||
: ''
|
||||
|
||||
messageEditModal.value = {
|
||||
open: true,
|
||||
loading: true,
|
||||
saving: false,
|
||||
error: '',
|
||||
mode: resolvedMode,
|
||||
sessionId,
|
||||
messageId,
|
||||
draft: initialDraft,
|
||||
rawRow: null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId })
|
||||
const row = response?.row || null
|
||||
const rawContent = row?.message_content
|
||||
const rawDraft = typeof rawContent === 'string' ? rawContent : String(rawContent ?? '')
|
||||
const draft = resolvedMode === 'raw' ? rawDraft : messageEditModal.value.draft
|
||||
messageEditModal.value = { ...messageEditModal.value, loading: false, rawRow: row, draft }
|
||||
} catch (error) {
|
||||
messageEditModal.value = { ...messageEditModal.value, loading: false, error: error?.message || '加载失败' }
|
||||
}
|
||||
}
|
||||
|
||||
const saveMessageEditModal = async () => {
|
||||
if (!process.client) return
|
||||
if (messageEditModal.value.saving || messageEditModal.value.loading) return
|
||||
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(messageEditModal.value.sessionId || '').trim()
|
||||
const messageId = String(messageEditModal.value.messageId || '').trim()
|
||||
if (!account || !sessionId || !messageId) return
|
||||
|
||||
messageEditModal.value = { ...messageEditModal.value, saving: true, error: '' }
|
||||
try {
|
||||
const response = await api.editChatMessage({
|
||||
account,
|
||||
session_id: sessionId,
|
||||
message_id: messageId,
|
||||
edits: {
|
||||
message_content: String(messageEditModal.value.draft ?? '')
|
||||
},
|
||||
unsafe: false
|
||||
})
|
||||
|
||||
if (response?.updated_message) {
|
||||
try {
|
||||
const updated = normalizeMessage(response.updated_message)
|
||||
const username = String(selectedContact.value?.username || '').trim()
|
||||
const list = allMessages.value[username] || []
|
||||
const index = list.findIndex((message) => String(message?.id || '') === String(updated?.id || ''))
|
||||
if (index >= 0) {
|
||||
const next = [...list]
|
||||
next[index] = updated
|
||||
allMessages.value = { ...allMessages.value, [username]: next }
|
||||
} else {
|
||||
await refreshSelectedMessages()
|
||||
}
|
||||
} catch {
|
||||
await refreshSelectedMessages()
|
||||
}
|
||||
} else {
|
||||
await refreshSelectedMessages()
|
||||
}
|
||||
|
||||
closeMessageEditModal()
|
||||
} catch (error) {
|
||||
messageEditModal.value = { ...messageEditModal.value, saving: false, error: error?.message || '保存失败' }
|
||||
return
|
||||
} finally {
|
||||
messageEditModal.value = { ...messageEditModal.value, saving: false }
|
||||
}
|
||||
}
|
||||
|
||||
const closeMessageFieldsModal = () => {
|
||||
messageFieldsModal.value = initialMessageFieldsModal()
|
||||
}
|
||||
|
||||
const openMessageFieldsModal = async (message) => {
|
||||
if (!process.client) return
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(selectedContact.value?.username || '').trim()
|
||||
const messageId = String(message?.id || '').trim()
|
||||
if (!account || !sessionId || !messageId) return
|
||||
|
||||
messageFieldsModal.value = {
|
||||
open: true,
|
||||
loading: true,
|
||||
saving: false,
|
||||
error: '',
|
||||
sessionId,
|
||||
messageId,
|
||||
unsafe: false,
|
||||
editsJson: '',
|
||||
rawRow: null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId })
|
||||
const row = response?.row || null
|
||||
const seed = {}
|
||||
for (const key of ['message_content', 'local_type', 'create_time', 'server_id', 'origin_source', 'source']) {
|
||||
if (row && Object.prototype.hasOwnProperty.call(row, key)) seed[key] = row[key]
|
||||
}
|
||||
messageFieldsModal.value = {
|
||||
...messageFieldsModal.value,
|
||||
loading: false,
|
||||
rawRow: row,
|
||||
editsJson: JSON.stringify(seed, null, 2)
|
||||
}
|
||||
} catch (error) {
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, loading: false, error: error?.message || '加载失败' }
|
||||
}
|
||||
}
|
||||
|
||||
const saveMessageFieldsModal = async () => {
|
||||
if (!process.client) return
|
||||
if (messageFieldsModal.value.saving || messageFieldsModal.value.loading) return
|
||||
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(messageFieldsModal.value.sessionId || '').trim()
|
||||
const messageId = String(messageFieldsModal.value.messageId || '').trim()
|
||||
if (!account || !sessionId || !messageId) return
|
||||
|
||||
let edits = null
|
||||
try {
|
||||
edits = JSON.parse(String(messageFieldsModal.value.editsJson || '').trim() || 'null')
|
||||
} catch {
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, error: 'JSON 格式错误' }
|
||||
return
|
||||
}
|
||||
if (!edits || typeof edits !== 'object' || Array.isArray(edits)) {
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, error: 'edits 必须是 JSON 对象' }
|
||||
return
|
||||
}
|
||||
if (!Object.keys(edits).length) {
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, error: 'edits 不能为空' }
|
||||
return
|
||||
}
|
||||
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, saving: true, error: '' }
|
||||
try {
|
||||
await api.editChatMessage({
|
||||
account,
|
||||
session_id: sessionId,
|
||||
message_id: messageId,
|
||||
edits,
|
||||
unsafe: !!messageFieldsModal.value.unsafe
|
||||
})
|
||||
await refreshSelectedMessages()
|
||||
closeMessageFieldsModal()
|
||||
} catch (error) {
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, saving: false, error: error?.message || '保存失败' }
|
||||
return
|
||||
} finally {
|
||||
messageFieldsModal.value = { ...messageFieldsModal.value, saving: false }
|
||||
}
|
||||
}
|
||||
|
||||
const copyTextToClipboard = async (text) => {
|
||||
if (!process.client) return false
|
||||
|
||||
const value = String(text ?? '').trim()
|
||||
if (!value) return false
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
return true
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const element = document.createElement('textarea')
|
||||
element.value = value
|
||||
element.setAttribute('readonly', 'true')
|
||||
element.style.position = 'fixed'
|
||||
element.style.left = '-9999px'
|
||||
element.style.top = '-9999px'
|
||||
document.body.appendChild(element)
|
||||
element.select()
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(element)
|
||||
if (ok) return true
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
window.prompt('复制内容:', value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const onCopyMessageTextClick = async () => {
|
||||
if (!process.client) return
|
||||
const message = contextMenu.value.message
|
||||
if (!message) return
|
||||
try {
|
||||
const text = String(message?.content || '').trim()
|
||||
if (!text) {
|
||||
window.alert('该消息没有可复制的文本')
|
||||
return
|
||||
}
|
||||
const ok = await copyTextToClipboard(text)
|
||||
if (!ok) window.alert('复制失败:无法写入剪贴板')
|
||||
} catch {
|
||||
window.alert('复制失败')
|
||||
} finally {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const onCopyMessageJsonClick = async () => {
|
||||
if (!process.client) return
|
||||
const message = contextMenu.value.message
|
||||
if (!message) return
|
||||
try {
|
||||
const raw = toRaw(message) || message
|
||||
const json = JSON.stringify(raw, (_key, value) => (typeof value === 'bigint' ? value.toString() : value), 2)
|
||||
const ok = await copyTextToClipboard(json)
|
||||
if (!ok) window.alert('复制失败:无法写入剪贴板')
|
||||
} catch {
|
||||
window.alert('复制失败')
|
||||
} finally {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenFolderClick = async () => {
|
||||
if (contextMenu.value.disabled) return
|
||||
const message = contextMenu.value.message
|
||||
const kind = contextMenu.value.kind
|
||||
|
||||
try {
|
||||
if (!selectedAccount.value || !selectedContact.value?.username) return
|
||||
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
username: selectedContact.value.username,
|
||||
kind
|
||||
}
|
||||
|
||||
if (kind === 'voice') {
|
||||
params.server_id = message.serverIdStr || message.serverId
|
||||
} else if (kind === 'file') {
|
||||
params.md5 = message.fileMd5
|
||||
} else if (kind === 'image') {
|
||||
if (message.imageMd5) params.md5 = message.imageMd5
|
||||
else if (message.imageFileId) params.file_id = message.imageFileId
|
||||
} else if (kind === 'emoji') {
|
||||
params.md5 = message.emojiMd5
|
||||
} else if (kind === 'video') {
|
||||
params.md5 = message.videoMd5
|
||||
if (message.videoFileId) params.file_id = message.videoFileId
|
||||
} else if (kind === 'video_thumb') {
|
||||
params.md5 = message.videoThumbMd5
|
||||
if (message.videoThumbFileId) params.file_id = message.videoThumbFileId
|
||||
}
|
||||
|
||||
await api.openChatMediaFolder(params)
|
||||
} finally {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const onEditMessageClick = async () => {
|
||||
const message = contextMenu.value.message
|
||||
if (!message) return
|
||||
const mode = isLikelyTextMessage(message) ? 'content' : 'raw'
|
||||
closeContextMenu()
|
||||
await openMessageEditModal({ message, mode })
|
||||
}
|
||||
|
||||
const onEditMessageFieldsClick = async () => {
|
||||
const message = contextMenu.value.message
|
||||
if (!message) return
|
||||
closeContextMenu()
|
||||
await openMessageFieldsModal(message)
|
||||
}
|
||||
|
||||
const onResetEditedMessageClick = async () => {
|
||||
if (!process.client) return
|
||||
const message = contextMenu.value.message
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(selectedContact.value?.username || '').trim()
|
||||
const messageId = String(message?.id || '').trim()
|
||||
if (!message || !account || !sessionId || !messageId) return
|
||||
|
||||
const ok = window.confirm('确认恢复该条消息到首次快照吗?')
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
await api.resetChatEditedMessage({ account, session_id: sessionId, message_id: messageId })
|
||||
closeContextMenu()
|
||||
await refreshSelectedMessages()
|
||||
} catch (error) {
|
||||
window.alert(error?.message || '恢复失败')
|
||||
} finally {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const onRepairMessageSenderAsMeClick = async () => {
|
||||
if (!process.client) return
|
||||
const message = contextMenu.value.message
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(selectedContact.value?.username || '').trim()
|
||||
const messageId = String(message?.id || '').trim()
|
||||
if (!message || !account || !sessionId || !messageId) return
|
||||
|
||||
const ok = window.confirm('确认将该消息修复为“我发送”吗?这会修改 real_sender_id 字段。')
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
await api.repairChatMessageSender({ account, session_id: sessionId, message_id: messageId, mode: 'me' })
|
||||
closeContextMenu()
|
||||
await refreshSelectedMessages()
|
||||
} catch (error) {
|
||||
window.alert(error?.message || '修复失败')
|
||||
} finally {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const onFlipWechatMessageDirectionClick = async () => {
|
||||
if (!process.client) return
|
||||
const message = contextMenu.value.message
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const sessionId = String(selectedContact.value?.username || '').trim()
|
||||
const messageId = String(message?.id || '').trim()
|
||||
if (!message || !account || !sessionId || !messageId) return
|
||||
|
||||
const ok = window.confirm(
|
||||
'确认反转该消息在微信客户端的左右气泡位置吗?\n\n这会修改 packed_info_data 字段(有风险)。\n可通过“恢复原消息”撤销。'
|
||||
)
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
await api.flipChatMessageDirection({ account, session_id: sessionId, message_id: messageId })
|
||||
closeContextMenu()
|
||||
await refreshSelectedMessages()
|
||||
} catch (error) {
|
||||
window.alert(error?.message || '反转失败')
|
||||
} finally {
|
||||
closeContextMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const onLocateQuotedMessageClick = async () => {
|
||||
const message = contextMenu.value.message
|
||||
if (!message?.quoteServerId) return
|
||||
closeContextMenu()
|
||||
const ok = await locateMessageByServerId(message.quoteServerId)
|
||||
if (!ok && process.client) {
|
||||
window.alert('定位引用消息失败')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contextMenu,
|
||||
messageEditModal,
|
||||
messageFieldsModal,
|
||||
closeContextMenu,
|
||||
openMediaContextMenu,
|
||||
prettyJson,
|
||||
isLikelyTextMessage,
|
||||
closeMessageEditModal,
|
||||
openMessageEditModal,
|
||||
saveMessageEditModal,
|
||||
closeMessageFieldsModal,
|
||||
openMessageFieldsModal,
|
||||
saveMessageFieldsModal,
|
||||
copyTextToClipboard,
|
||||
onCopyMessageTextClick,
|
||||
onCopyMessageJsonClick,
|
||||
onOpenFolderClick,
|
||||
onEditMessageClick,
|
||||
onEditMessageFieldsClick,
|
||||
onResetEditedMessageClick,
|
||||
onRepairMessageSenderAsMeClick,
|
||||
onFlipWechatMessageDirectionClick,
|
||||
onLocateQuotedMessageClick
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { reportServerErrorFromResponse } from '~/lib/server-error-logging'
|
||||
import { toUnixSeconds } from '~/lib/chat/formatters'
|
||||
|
||||
export const useChatExport = ({ api, apiBase, contacts, selectedAccount, selectedContact, privacyMode }) => {
|
||||
const exportModalOpen = ref(false)
|
||||
const isExportCreating = ref(false)
|
||||
const exportError = ref('')
|
||||
|
||||
const exportScope = ref('current')
|
||||
const exportFormat = ref('json')
|
||||
const exportDownloadRemoteMedia = ref(true)
|
||||
const exportHtmlPageSize = ref(1000)
|
||||
const exportMessageTypeOptions = [
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'emoji', label: '表情' },
|
||||
{ value: 'video', label: '视频' },
|
||||
{ value: 'voice', label: '语音' },
|
||||
{ value: 'chatHistory', label: '聊天记录' },
|
||||
{ value: 'transfer', label: '转账' },
|
||||
{ value: 'redPacket', label: '红包' },
|
||||
{ value: 'file', label: '文件' },
|
||||
{ value: 'link', label: '链接' },
|
||||
{ value: 'quote', label: '引用' },
|
||||
{ value: 'system', label: '系统' },
|
||||
{ value: 'voip', label: '通话' }
|
||||
]
|
||||
const exportMessageTypes = ref(exportMessageTypeOptions.map((item) => item.value))
|
||||
|
||||
const exportStartLocal = ref('')
|
||||
const exportEndLocal = ref('')
|
||||
const exportFileName = ref('')
|
||||
const exportFolder = ref('')
|
||||
const exportFolderHandle = ref(null)
|
||||
const exportSaveBusy = ref(false)
|
||||
const exportSaveMsg = ref('')
|
||||
const exportAutoSavedFor = ref('')
|
||||
|
||||
const exportSearchQuery = ref('')
|
||||
const exportListTab = ref('all')
|
||||
const exportSelectedUsernames = ref([])
|
||||
|
||||
const exportJob = ref(null)
|
||||
let exportPollTimer = null
|
||||
let exportEventSource = null
|
||||
|
||||
const clamp01 = (value) => Math.min(1, Math.max(0, value))
|
||||
const asNumber = (value) => {
|
||||
const next = Number(value)
|
||||
return Number.isFinite(next) ? next : 0
|
||||
}
|
||||
|
||||
const exportOverallPercent = computed(() => {
|
||||
const job = exportJob.value
|
||||
const progress = job?.progress || {}
|
||||
const total = asNumber(progress.conversationsTotal)
|
||||
const done = asNumber(progress.conversationsDone)
|
||||
if (total <= 0) return 0
|
||||
|
||||
const currentTotal = asNumber(progress.currentConversationMessagesTotal)
|
||||
const currentDone = asNumber(progress.currentConversationMessagesExported)
|
||||
const currentFraction = currentTotal > 0 ? clamp01(currentDone / currentTotal) : 0
|
||||
const overall = clamp01((done + (job?.status === 'running' ? currentFraction : 0)) / total)
|
||||
return Math.round(overall * 100)
|
||||
})
|
||||
|
||||
const exportCurrentPercent = computed(() => {
|
||||
const progress = exportJob.value?.progress || {}
|
||||
const total = asNumber(progress.currentConversationMessagesTotal)
|
||||
const done = asNumber(progress.currentConversationMessagesExported)
|
||||
if (total <= 0) return null
|
||||
return Math.round(clamp01(done / total) * 100)
|
||||
})
|
||||
|
||||
const exportFilteredContacts = computed(() => {
|
||||
const query = String(exportSearchQuery.value || '').trim().toLowerCase()
|
||||
let list = Array.isArray(contacts.value) ? contacts.value : []
|
||||
|
||||
const tab = String(exportListTab.value || 'all')
|
||||
if (tab === 'groups') list = list.filter((contact) => !!contact?.isGroup)
|
||||
if (tab === 'singles') list = list.filter((contact) => !contact?.isGroup)
|
||||
|
||||
if (!query) return list
|
||||
return list.filter((contact) => {
|
||||
const name = String(contact?.name || '').toLowerCase()
|
||||
const username = String(contact?.username || '').toLowerCase()
|
||||
return name.includes(query) || username.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const exportContactCounts = computed(() => {
|
||||
const list = Array.isArray(contacts.value) ? contacts.value : []
|
||||
const total = list.length
|
||||
const groups = list.filter((contact) => !!contact?.isGroup).length
|
||||
return { total, groups, singles: total - groups }
|
||||
})
|
||||
|
||||
const isDesktopExportRuntime = () => {
|
||||
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
|
||||
}
|
||||
|
||||
const isWebDirectoryPickerSupported = () => {
|
||||
return !!(process.client && typeof window.showDirectoryPicker === 'function')
|
||||
}
|
||||
|
||||
const hasWebExportFolder = computed(() => {
|
||||
return !!(isWebDirectoryPickerSupported() && exportFolderHandle.value)
|
||||
})
|
||||
|
||||
const chooseExportFolder = async () => {
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
try {
|
||||
if (!process.client) {
|
||||
exportError.value = '当前环境不支持选择导出目录'
|
||||
return
|
||||
}
|
||||
|
||||
if (isDesktopExportRuntime()) {
|
||||
const result = await window.wechatDesktop.chooseDirectory({ title: '选择导出目录' })
|
||||
if (result && !result.canceled && Array.isArray(result.filePaths) && result.filePaths.length > 0) {
|
||||
exportFolder.value = String(result.filePaths[0] || '').trim()
|
||||
exportFolderHandle.value = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isWebDirectoryPickerSupported()) {
|
||||
const handle = await window.showDirectoryPicker()
|
||||
if (handle) {
|
||||
exportFolderHandle.value = handle
|
||||
exportFolder.value = `浏览器目录:${String(handle.name || '已选择')}`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
exportError.value = '当前浏览器不支持目录选择,请使用桌面端或 Chromium 新版浏览器'
|
||||
} catch (error) {
|
||||
exportError.value = error?.message || '选择导出目录失败'
|
||||
}
|
||||
}
|
||||
|
||||
const guessExportZipName = (job) => {
|
||||
const raw = String(job?.zipPath || '').trim()
|
||||
if (raw) {
|
||||
const name = raw.replace(/\\/g, '/').split('/').pop()
|
||||
if (name && name.toLowerCase().endsWith('.zip')) return name
|
||||
}
|
||||
const exportId = String(job?.exportId || '').trim() || 'export'
|
||||
return `wechat_chat_export_${exportId}.zip`
|
||||
}
|
||||
|
||||
const getExportDownloadUrl = (exportId) => {
|
||||
return `${apiBase}/chat/exports/${encodeURIComponent(String(exportId || ''))}/download`
|
||||
}
|
||||
|
||||
const saveExportToSelectedFolder = async (options = {}) => {
|
||||
const autoSave = !!options?.auto
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
if (!process.client || !isWebDirectoryPickerSupported()) {
|
||||
exportError.value = '当前环境不支持保存到浏览器目录'
|
||||
return
|
||||
}
|
||||
const handle = exportFolderHandle.value
|
||||
if (!handle || typeof handle.getFileHandle !== 'function') {
|
||||
exportError.value = '请先选择浏览器导出目录'
|
||||
return
|
||||
}
|
||||
|
||||
const exportId = exportJob.value?.exportId
|
||||
if (!exportId || String(exportJob.value?.status || '') !== 'done') {
|
||||
exportError.value = '导出任务尚未完成'
|
||||
return
|
||||
}
|
||||
|
||||
exportSaveBusy.value = true
|
||||
try {
|
||||
const response = await fetch(getExportDownloadUrl(exportId))
|
||||
if (!response.ok) {
|
||||
await reportServerErrorFromResponse(response, {
|
||||
method: 'GET',
|
||||
requestUrl: getExportDownloadUrl(exportId),
|
||||
message: `下载导出文件失败(${response.status})`,
|
||||
source: 'chat.exportDownload'
|
||||
})
|
||||
throw new Error(`下载导出文件失败(${response.status})`)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const fileName = guessExportZipName(exportJob.value)
|
||||
const fileHandle = await handle.getFileHandle(fileName, { create: true })
|
||||
const writable = await fileHandle.createWritable()
|
||||
await writable.write(blob)
|
||||
await writable.close()
|
||||
exportAutoSavedFor.value = String(exportId)
|
||||
exportSaveMsg.value = autoSave
|
||||
? `已自动保存到已选目录:${fileName}`
|
||||
: `已保存到已选目录:${fileName}`
|
||||
} catch (error) {
|
||||
exportError.value = error?.message || '保存到浏览器目录失败'
|
||||
} finally {
|
||||
exportSaveBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stopExportPolling = () => {
|
||||
if (exportEventSource) {
|
||||
try {
|
||||
exportEventSource.close()
|
||||
} catch {}
|
||||
exportEventSource = null
|
||||
}
|
||||
if (exportPollTimer) {
|
||||
clearInterval(exportPollTimer)
|
||||
exportPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const startExportHttpPolling = (exportId) => {
|
||||
if (!exportId) return
|
||||
exportPollTimer = setInterval(async () => {
|
||||
try {
|
||||
const response = await api.getChatExport(exportId)
|
||||
exportJob.value = response?.job || exportJob.value
|
||||
const status = String(exportJob.value?.status || '')
|
||||
if (status === 'done' || status === 'error' || status === 'cancelled') {
|
||||
stopExportPolling()
|
||||
}
|
||||
} catch {}
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
const startExportPolling = (exportId) => {
|
||||
stopExportPolling()
|
||||
if (!exportId) return
|
||||
|
||||
if (process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined') {
|
||||
const url = `${apiBase}/chat/exports/${encodeURIComponent(String(exportId))}/events`
|
||||
try {
|
||||
exportEventSource = new EventSource(url)
|
||||
exportEventSource.onmessage = (event) => {
|
||||
try {
|
||||
const next = JSON.parse(String(event.data || '{}'))
|
||||
exportJob.value = next || exportJob.value
|
||||
const status = String(exportJob.value?.status || '')
|
||||
if (status === 'done' || status === 'error' || status === 'cancelled') {
|
||||
stopExportPolling()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
exportEventSource.onerror = () => {
|
||||
try {
|
||||
exportEventSource?.close()
|
||||
} catch {}
|
||||
exportEventSource = null
|
||||
if (!exportPollTimer) startExportHttpPolling(exportId)
|
||||
}
|
||||
return
|
||||
} catch {
|
||||
exportEventSource = null
|
||||
}
|
||||
}
|
||||
|
||||
startExportHttpPolling(exportId)
|
||||
}
|
||||
|
||||
const openExportModal = () => {
|
||||
exportModalOpen.value = true
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
exportListTab.value = 'all'
|
||||
exportStartLocal.value = ''
|
||||
exportEndLocal.value = ''
|
||||
exportMessageTypes.value = exportMessageTypeOptions.map((item) => item.value)
|
||||
exportAutoSavedFor.value = ''
|
||||
exportScope.value = selectedContact.value?.username ? 'current' : 'all'
|
||||
}
|
||||
|
||||
const closeExportModal = () => {
|
||||
exportModalOpen.value = false
|
||||
exportError.value = ''
|
||||
}
|
||||
|
||||
watch(exportModalOpen, (open) => {
|
||||
if (!process.client) return
|
||||
if (!open) {
|
||||
stopExportPolling()
|
||||
return
|
||||
}
|
||||
|
||||
const exportId = exportJob.value?.exportId
|
||||
const status = String(exportJob.value?.status || '')
|
||||
if (exportId && (status === 'queued' || status === 'running')) {
|
||||
startExportPolling(exportId)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
exportId: String(exportJob.value?.exportId || ''),
|
||||
status: String(exportJob.value?.status || '')
|
||||
}),
|
||||
async ({ exportId, status }) => {
|
||||
if (!process.client || status !== 'done' || !exportId) return
|
||||
if (!hasWebExportFolder.value) return
|
||||
if (exportAutoSavedFor.value === exportId) return
|
||||
if (exportSaveBusy.value) return
|
||||
await saveExportToSelectedFolder({ auto: true })
|
||||
}
|
||||
)
|
||||
|
||||
const startChatExport = async () => {
|
||||
exportError.value = ''
|
||||
exportSaveMsg.value = ''
|
||||
if (!selectedAccount.value) {
|
||||
exportError.value = '未选择账号'
|
||||
return
|
||||
}
|
||||
|
||||
let scope = exportScope.value
|
||||
let usernames = []
|
||||
if (scope === 'current') {
|
||||
scope = 'selected'
|
||||
if (selectedContact.value?.username) {
|
||||
usernames = [selectedContact.value.username]
|
||||
}
|
||||
} else if (scope === 'selected') {
|
||||
usernames = Array.isArray(exportSelectedUsernames.value) ? exportSelectedUsernames.value.filter(Boolean) : []
|
||||
}
|
||||
|
||||
if (scope === 'selected' && (!usernames || usernames.length === 0)) {
|
||||
exportError.value = '请选择至少一个会话'
|
||||
return
|
||||
}
|
||||
|
||||
const hasDesktopFolder = isDesktopExportRuntime() && !!String(exportFolder.value || '').trim()
|
||||
const hasWebFolder = !isDesktopExportRuntime() && !!exportFolderHandle.value
|
||||
if (!hasDesktopFolder && !hasWebFolder) {
|
||||
exportError.value = '请先选择导出目录'
|
||||
return
|
||||
}
|
||||
|
||||
const startTime = toUnixSeconds(exportStartLocal.value)
|
||||
const endTime = toUnixSeconds(exportEndLocal.value)
|
||||
if (startTime && endTime && startTime > endTime) {
|
||||
exportError.value = '时间范围不合法:开始时间不能晚于结束时间'
|
||||
return
|
||||
}
|
||||
|
||||
const messageTypes = Array.isArray(exportMessageTypes.value) ? exportMessageTypes.value.filter(Boolean) : []
|
||||
if (messageTypes.length === 0) {
|
||||
exportError.value = '请至少勾选一个消息类型'
|
||||
return
|
||||
}
|
||||
|
||||
const selectedTypeSet = new Set(messageTypes.map((item) => String(item || '').trim()))
|
||||
const mediaKindSet = new Set()
|
||||
if (selectedTypeSet.has('chatHistory')) {
|
||||
mediaKindSet.add('image')
|
||||
mediaKindSet.add('emoji')
|
||||
mediaKindSet.add('video')
|
||||
mediaKindSet.add('video_thumb')
|
||||
mediaKindSet.add('voice')
|
||||
mediaKindSet.add('file')
|
||||
}
|
||||
if (selectedTypeSet.has('image')) mediaKindSet.add('image')
|
||||
if (selectedTypeSet.has('emoji')) mediaKindSet.add('emoji')
|
||||
if (selectedTypeSet.has('video')) {
|
||||
mediaKindSet.add('video')
|
||||
mediaKindSet.add('video_thumb')
|
||||
}
|
||||
if (selectedTypeSet.has('voice')) mediaKindSet.add('voice')
|
||||
if (selectedTypeSet.has('file')) mediaKindSet.add('file')
|
||||
|
||||
const mediaKinds = Array.from(mediaKindSet)
|
||||
const includeMedia = !privacyMode.value && mediaKinds.length > 0
|
||||
|
||||
isExportCreating.value = true
|
||||
exportAutoSavedFor.value = ''
|
||||
try {
|
||||
const response = await api.createChatExport({
|
||||
account: selectedAccount.value,
|
||||
scope,
|
||||
usernames,
|
||||
format: exportFormat.value,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
include_hidden: false,
|
||||
include_official: false,
|
||||
message_types: messageTypes,
|
||||
include_media: includeMedia,
|
||||
media_kinds: mediaKinds,
|
||||
download_remote_media: exportFormat.value === 'html' && !!exportDownloadRemoteMedia.value,
|
||||
html_page_size: Math.max(0, Math.floor(Number(exportHtmlPageSize.value || 1000))),
|
||||
output_dir: isDesktopExportRuntime() ? String(exportFolder.value || '').trim() : null,
|
||||
privacy_mode: !!privacyMode.value,
|
||||
file_name: exportFileName.value || null
|
||||
})
|
||||
|
||||
exportJob.value = response?.job || null
|
||||
const exportId = exportJob.value?.exportId
|
||||
if (exportId) startExportPolling(exportId)
|
||||
} catch (error) {
|
||||
exportError.value = error?.message || '创建导出任务失败'
|
||||
} finally {
|
||||
isExportCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelCurrentExport = async () => {
|
||||
const exportId = exportJob.value?.exportId
|
||||
if (!exportId) return
|
||||
|
||||
try {
|
||||
await api.cancelChatExport(exportId)
|
||||
const response = await api.getChatExport(exportId)
|
||||
exportJob.value = response?.job || exportJob.value
|
||||
} catch (error) {
|
||||
exportError.value = error?.message || '取消导出失败'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
exportModalOpen,
|
||||
isExportCreating,
|
||||
exportError,
|
||||
exportScope,
|
||||
exportFormat,
|
||||
exportDownloadRemoteMedia,
|
||||
exportHtmlPageSize,
|
||||
exportMessageTypeOptions,
|
||||
exportMessageTypes,
|
||||
exportStartLocal,
|
||||
exportEndLocal,
|
||||
exportFileName,
|
||||
exportFolder,
|
||||
exportFolderHandle,
|
||||
exportSaveBusy,
|
||||
exportSaveMsg,
|
||||
exportAutoSavedFor,
|
||||
exportSearchQuery,
|
||||
exportListTab,
|
||||
exportSelectedUsernames,
|
||||
exportJob,
|
||||
exportOverallPercent,
|
||||
exportCurrentPercent,
|
||||
exportFilteredContacts,
|
||||
exportContactCounts,
|
||||
hasWebExportFolder,
|
||||
chooseExportFolder,
|
||||
getExportDownloadUrl,
|
||||
saveExportToSelectedFolder,
|
||||
openExportModal,
|
||||
closeExportModal,
|
||||
startChatExport,
|
||||
cancelCurrentExport,
|
||||
stopExportPolling
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
buildChatHistoryWindowPayload,
|
||||
createChatHistoryRecordNormalizer,
|
||||
enhanceChatHistoryRecords,
|
||||
formatChatHistoryVideoDuration,
|
||||
getChatHistoryPreviewLines,
|
||||
isChatHistoryRecordItemIncomplete,
|
||||
normalizeChatHistoryUrl,
|
||||
parseChatHistoryRecord,
|
||||
pickFirstMd5,
|
||||
stripWeChatInvisible
|
||||
} from '~/lib/chat/chat-history'
|
||||
|
||||
export const useChatHistoryWindows = ({
|
||||
api,
|
||||
apiBase,
|
||||
selectedAccount,
|
||||
selectedContact,
|
||||
openImagePreview,
|
||||
openVideoPreview
|
||||
}) => {
|
||||
const floatingWindows = ref([])
|
||||
let floatingWindowSeq = 0
|
||||
let floatingWindowZ = 70
|
||||
const floatingDragState = { id: '', offsetX: 0, offsetY: 0 }
|
||||
|
||||
const clampNumber = (value, min, max) => Math.min(max, Math.max(min, value))
|
||||
const normalizeRecordItem = createChatHistoryRecordNormalizer({
|
||||
apiBase,
|
||||
getSelectedAccount: () => selectedAccount.value,
|
||||
getSelectedContact: () => selectedContact.value
|
||||
})
|
||||
|
||||
const getFloatingWindowById = (id) => {
|
||||
const list = Array.isArray(floatingWindows.value) ? floatingWindows.value : []
|
||||
return list.find((item) => String(item?.id || '') === String(id || '')) || null
|
||||
}
|
||||
|
||||
const focusFloatingWindow = (id) => {
|
||||
const windowItem = getFloatingWindowById(id)
|
||||
if (!windowItem) return
|
||||
floatingWindowZ += 1
|
||||
windowItem.zIndex = floatingWindowZ
|
||||
}
|
||||
|
||||
const closeFloatingWindow = (id) => {
|
||||
const key = String(id || '')
|
||||
floatingWindows.value = (Array.isArray(floatingWindows.value) ? floatingWindows.value : []).filter((item) => String(item?.id || '') !== key)
|
||||
if (floatingDragState.id && String(floatingDragState.id) === key) {
|
||||
floatingDragState.id = ''
|
||||
}
|
||||
}
|
||||
|
||||
const closeTopFloatingWindow = () => {
|
||||
const list = Array.isArray(floatingWindows.value) ? floatingWindows.value : []
|
||||
if (!list.length) return
|
||||
const top = [...list].sort((a, b) => Number(b?.zIndex || 0) - Number(a?.zIndex || 0))[0]
|
||||
if (top?.id) closeFloatingWindow(top.id)
|
||||
}
|
||||
|
||||
const openFloatingWindow = (payload) => {
|
||||
if (!process.client || typeof window === 'undefined') return null
|
||||
floatingWindowSeq += 1
|
||||
floatingWindowZ += 1
|
||||
const width = clampNumber(Number(payload?.width || 520), 360, Math.max(360, (window.innerWidth || 1200) - 48))
|
||||
const height = clampNumber(Number(payload?.height || 420), 320, Math.max(320, (window.innerHeight || 900) - 48))
|
||||
const x = clampNumber(Number(payload?.x || Math.round(((window.innerWidth || width) - width) / 2)), 16, Math.max(16, (window.innerWidth || width) - width - 16))
|
||||
const y = clampNumber(Number(payload?.y || Math.round(((window.innerHeight || height) - height) / 2)), 16, Math.max(16, (window.innerHeight || height) - height - 16))
|
||||
|
||||
const windowItem = {
|
||||
id: `chat-floating-${floatingWindowSeq}`,
|
||||
kind: String(payload?.kind || 'chatHistory'),
|
||||
title: String(payload?.title || ''),
|
||||
info: payload?.info || { isChatRoom: false },
|
||||
records: Array.isArray(payload?.records) ? payload.records : [],
|
||||
url: String(payload?.url || ''),
|
||||
content: String(payload?.content || ''),
|
||||
preview: String(payload?.preview || ''),
|
||||
from: String(payload?.from || ''),
|
||||
fromAvatar: String(payload?.fromAvatar || ''),
|
||||
loading: !!payload?.loading,
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
zIndex: floatingWindowZ
|
||||
}
|
||||
floatingWindows.value = [...floatingWindows.value, windowItem]
|
||||
return windowItem
|
||||
}
|
||||
|
||||
const startFloatingWindowDrag = (id, event) => {
|
||||
if (!process.client) return
|
||||
const windowItem = getFloatingWindowById(id)
|
||||
if (!windowItem) return
|
||||
focusFloatingWindow(id)
|
||||
const point = 'touches' in event ? event.touches?.[0] : event
|
||||
floatingDragState.id = id
|
||||
floatingDragState.offsetX = Number(point?.clientX || 0) - Number(windowItem.x || 0)
|
||||
floatingDragState.offsetY = Number(point?.clientY || 0) - Number(windowItem.y || 0)
|
||||
}
|
||||
|
||||
const onFloatingWindowMouseMove = (event) => {
|
||||
if (!process.client) return
|
||||
if (!floatingDragState.id) return
|
||||
const windowItem = getFloatingWindowById(floatingDragState.id)
|
||||
if (!windowItem) return
|
||||
const point = 'touches' in event ? event.touches?.[0] : event
|
||||
const nextX = Number(point?.clientX || 0) - floatingDragState.offsetX
|
||||
const nextY = Number(point?.clientY || 0) - floatingDragState.offsetY
|
||||
windowItem.x = clampNumber(nextX, 8, Math.max(8, (window.innerWidth || nextX) - windowItem.width - 8))
|
||||
windowItem.y = clampNumber(nextY, 8, Math.max(8, (window.innerHeight || nextY) - windowItem.height - 8))
|
||||
}
|
||||
|
||||
const onFloatingWindowMouseUp = () => {
|
||||
floatingDragState.id = ''
|
||||
}
|
||||
|
||||
const chatHistoryModalVisible = ref(false)
|
||||
const chatHistoryModalTitle = ref('')
|
||||
const chatHistoryModalRecords = ref([])
|
||||
const chatHistoryModalInfo = ref({ isChatRoom: false })
|
||||
const chatHistoryModalStack = ref([])
|
||||
const goBackChatHistoryModal = () => {}
|
||||
const closeChatHistoryModal = () => {
|
||||
chatHistoryModalVisible.value = false
|
||||
chatHistoryModalTitle.value = ''
|
||||
chatHistoryModalRecords.value = []
|
||||
chatHistoryModalInfo.value = { isChatRoom: false }
|
||||
chatHistoryModalStack.value = []
|
||||
}
|
||||
|
||||
const onChatHistoryVideoThumbError = (record) => {
|
||||
if (!record) return
|
||||
const candidates = record._videoThumbCandidates
|
||||
if (!Array.isArray(candidates) || candidates.length <= 1) {
|
||||
record._videoThumbError = true
|
||||
return
|
||||
}
|
||||
const current = Math.max(0, Number(record._videoThumbCandidateIndex || 0))
|
||||
const next = current + 1
|
||||
if (next < candidates.length) {
|
||||
record._videoThumbCandidateIndex = next
|
||||
record.videoThumbUrl = candidates[next]
|
||||
return
|
||||
}
|
||||
record._videoThumbError = true
|
||||
}
|
||||
|
||||
const onChatHistoryLinkPreviewError = (record) => {
|
||||
if (!record) return
|
||||
const candidates = record._linkPreviewCandidates
|
||||
if (!Array.isArray(candidates) || candidates.length <= 1) {
|
||||
record._linkPreviewError = true
|
||||
return
|
||||
}
|
||||
const current = Math.max(0, Number(record._linkPreviewCandidateIndex || 0))
|
||||
const next = current + 1
|
||||
if (next < candidates.length) {
|
||||
record._linkPreviewCandidateIndex = next
|
||||
record.preview = candidates[next]
|
||||
record._linkPreviewError = false
|
||||
return
|
||||
}
|
||||
record._linkPreviewError = true
|
||||
}
|
||||
|
||||
const onChatHistoryFromAvatarLoad = (record) => {
|
||||
try {
|
||||
if (record) {
|
||||
record._fromAvatarImgOk = true
|
||||
record._fromAvatarImgError = false
|
||||
record._fromAvatarLast = String(record.fromAvatar || '').trim()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const onChatHistoryFromAvatarError = (record) => {
|
||||
try {
|
||||
if (record) {
|
||||
record._fromAvatarImgOk = false
|
||||
record._fromAvatarImgError = true
|
||||
record._fromAvatarLast = String(record.fromAvatar || '').trim()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const onChatHistoryQuoteThumbError = (record) => {
|
||||
if (!record || !record.quote) return
|
||||
const candidates = record._quoteThumbCandidates
|
||||
if (!Array.isArray(candidates) || candidates.length <= 1) {
|
||||
record._quoteThumbError = true
|
||||
return
|
||||
}
|
||||
const current = Math.max(0, Number(record._quoteThumbCandidateIndex || 0))
|
||||
const next = current + 1
|
||||
if (next < candidates.length) {
|
||||
record._quoteThumbCandidateIndex = next
|
||||
record.quote.thumbUrl = candidates[next]
|
||||
return
|
||||
}
|
||||
record._quoteThumbError = true
|
||||
}
|
||||
|
||||
const openChatHistoryQuote = (record) => {
|
||||
if (!process.client) return
|
||||
const quote = record?.quote
|
||||
if (!quote) return
|
||||
const kind = String(quote.kind || '')
|
||||
const url = String(quote.url || '').trim()
|
||||
if (!url) return
|
||||
|
||||
if (kind === 'video') {
|
||||
openVideoPreview(url, quote?.thumbUrl)
|
||||
return
|
||||
}
|
||||
if (kind === 'image' || kind === 'emoji') {
|
||||
openImagePreview(url)
|
||||
}
|
||||
}
|
||||
|
||||
const getChatHistoryLinkFromText = (record) => {
|
||||
const from = String(record?.from || '').trim()
|
||||
if (from) return from
|
||||
const url = String(record?.url || '').trim()
|
||||
if (!url) return ''
|
||||
try { return new URL(url).hostname || '' } catch { return '' }
|
||||
}
|
||||
|
||||
const getChatHistoryLinkFromAvatarText = (record) => {
|
||||
const text = String(getChatHistoryLinkFromText(record) || '').trim()
|
||||
return text ? (Array.from(text)[0] || '') : ''
|
||||
}
|
||||
|
||||
const openUrlInBrowser = (url) => {
|
||||
const next = String(url || '').trim()
|
||||
if (!next) return
|
||||
try { window.open(next, '_blank', 'noopener,noreferrer') } catch {}
|
||||
}
|
||||
|
||||
const resolveChatHistoryLinkRecord = async (record) => {
|
||||
if (!process.client || !record || !selectedAccount.value) return null
|
||||
const serverId = String(record?.fromnewmsgid || '').trim()
|
||||
if (!serverId || record._linkResolving) return null
|
||||
|
||||
record._linkResolving = true
|
||||
try {
|
||||
const response = await api.resolveAppMsg({
|
||||
account: selectedAccount.value,
|
||||
server_id: serverId
|
||||
})
|
||||
if (response && typeof response === 'object') {
|
||||
const title = String(response.title || '').trim()
|
||||
const content = String(response.content || '').trim()
|
||||
const url = String(response.url || '').trim()
|
||||
const from = String(response.from || '').trim()
|
||||
|
||||
const normalizePreviewUrl = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
if (/^\/api\/chat\/media\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw
|
||||
if (!/^https?:\/\//i.test(raw)) return ''
|
||||
try {
|
||||
const host = new URL(raw).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
}
|
||||
} catch {}
|
||||
return raw
|
||||
}
|
||||
|
||||
if (title) record.title = title
|
||||
if (content && !stripWeChatInvisible(record.content)) record.content = content
|
||||
if (url) record.url = url
|
||||
if (from) record.from = from
|
||||
if (response.linkStyle) record.linkStyle = String(response.linkStyle || '').trim()
|
||||
if (response.linkType) record.linkType = String(response.linkType || '').trim()
|
||||
|
||||
const fromUsername = String(response.fromUsername || '').trim()
|
||||
if (fromUsername) record.fromUsername = fromUsername
|
||||
const fromAvatarUrl = fromUsername
|
||||
? `${apiBase}/chat/avatar?account=${encodeURIComponent(selectedAccount.value || '')}&username=${encodeURIComponent(fromUsername)}`
|
||||
: (url ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(url)}` : '')
|
||||
if (fromAvatarUrl) {
|
||||
const last = String(record._fromAvatarLast || '').trim()
|
||||
record.fromAvatar = fromAvatarUrl
|
||||
if (String(fromAvatarUrl).trim() !== last) {
|
||||
record._fromAvatarLast = String(fromAvatarUrl).trim()
|
||||
record._fromAvatarImgOk = false
|
||||
record._fromAvatarImgError = false
|
||||
}
|
||||
}
|
||||
|
||||
const style = String(response.linkStyle || '').trim()
|
||||
const thumb = String(response.thumbUrl || '').trim()
|
||||
const cover = String(response.coverUrl || '').trim()
|
||||
const picked = style === 'cover' ? (cover || thumb) : (thumb || cover)
|
||||
const previewResolved = normalizePreviewUrl(picked)
|
||||
if (previewResolved) {
|
||||
const currentPreview = String(record.preview || '').trim()
|
||||
const candidates = Array.isArray(record._linkPreviewCandidates) ? record._linkPreviewCandidates.slice() : []
|
||||
if (currentPreview && !candidates.includes(currentPreview)) candidates.push(currentPreview)
|
||||
if (!candidates.includes(previewResolved)) candidates.push(previewResolved)
|
||||
record._linkPreviewCandidates = candidates
|
||||
if (!currentPreview || record._linkPreviewError) {
|
||||
record.preview = previewResolved
|
||||
record._linkPreviewCandidateIndex = candidates.indexOf(previewResolved)
|
||||
record._linkPreviewError = false
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
} catch {}
|
||||
finally {
|
||||
try { record._linkResolving = false } catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const resolveChatHistoryLinkRecords = (windowItem) => {
|
||||
if (!process.client) return
|
||||
const records = Array.isArray(windowItem?.records) ? windowItem.records : []
|
||||
const targets = records.filter((record) => {
|
||||
if (!record) return false
|
||||
if (String(record.renderType || '') !== 'link') return false
|
||||
if (!String(record.fromnewmsgid || '').trim()) return false
|
||||
const fromMissing = String(record.from || '').trim() === ''
|
||||
const previewMissing = !String(record.preview || '').trim()
|
||||
const urlMissing = !String(record.url || '').trim()
|
||||
const fromAvatarMissing = !String(record.fromAvatar || '').trim()
|
||||
return fromMissing || previewMissing || urlMissing || fromAvatarMissing
|
||||
})
|
||||
if (!targets.length) return
|
||||
;(async () => {
|
||||
for (const target of targets.slice(0, 12)) {
|
||||
await resolveChatHistoryLinkRecord(target)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const openChatHistoryLinkWindow = (record) => {
|
||||
if (!process.client) return
|
||||
const title = String(record?.title || record?.content || '链接').trim()
|
||||
const url = String(record?.url || '').trim()
|
||||
const preview = String(record?.preview || '').trim()
|
||||
const from = String(record?.from || '').trim()
|
||||
const fromAvatar = String(record?.fromAvatar || '').trim()
|
||||
const needResolve = !!String(record?.fromnewmsgid || '').trim() && (!url || !from || !preview || !fromAvatar)
|
||||
const windowItem = openFloatingWindow({
|
||||
kind: 'link',
|
||||
title: title || '链接',
|
||||
url,
|
||||
content: String(record?.content || '').trim(),
|
||||
preview,
|
||||
from,
|
||||
fromAvatar,
|
||||
width: 520,
|
||||
height: 420,
|
||||
loading: needResolve
|
||||
})
|
||||
if (!windowItem) return
|
||||
focusFloatingWindow(windowItem.id)
|
||||
try {
|
||||
windowItem._linkPreviewCandidates = Array.isArray(record?._linkPreviewCandidates) ? record._linkPreviewCandidates.slice() : (preview ? [preview] : [])
|
||||
windowItem._linkPreviewCandidateIndex = Math.max(0, Number(record?._linkPreviewCandidateIndex || 0))
|
||||
windowItem._linkPreviewError = false
|
||||
windowItem._fromAvatarLast = fromAvatar
|
||||
windowItem._fromAvatarImgOk = !!record?._fromAvatarImgOk
|
||||
windowItem._fromAvatarImgError = !!record?._fromAvatarImgError
|
||||
windowItem.fromnewmsgid = String(record?.fromnewmsgid || '').trim()
|
||||
} catch {}
|
||||
if (needResolve) {
|
||||
;(async () => {
|
||||
await resolveChatHistoryLinkRecord(windowItem)
|
||||
windowItem.loading = false
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
const openChatHistoryModal = (message) => {
|
||||
if (!process.client) return
|
||||
const { title0, info0, records0 } = buildChatHistoryWindowPayload(message, normalizeRecordItem)
|
||||
const windowItem = openFloatingWindow({
|
||||
kind: 'chatHistory',
|
||||
title: title0 || '聊天记录',
|
||||
info: info0,
|
||||
records: records0,
|
||||
width: 560,
|
||||
height: Math.round(Math.max(420, (window.innerHeight || 700) * 0.78))
|
||||
})
|
||||
if (!windowItem) return
|
||||
try { resolveChatHistoryLinkRecords(windowItem) } catch {}
|
||||
}
|
||||
|
||||
const openNestedChatHistory = (record) => {
|
||||
if (!process.client) return
|
||||
const title = String(record?.title || '聊天记录')
|
||||
const content = String(record?.content || '')
|
||||
const recordItem = String(record?.recordItem || '').trim()
|
||||
const serverId = String(record?.fromnewmsgid || '').trim()
|
||||
|
||||
const { info0, records0 } = buildChatHistoryWindowPayload({ title, content, recordItem }, normalizeRecordItem)
|
||||
const windowItem = openFloatingWindow({
|
||||
kind: 'chatHistory',
|
||||
title: title || '聊天记录',
|
||||
info: info0,
|
||||
records: records0,
|
||||
width: 560,
|
||||
height: Math.round(Math.max(420, (window.innerHeight || 700) * 0.78)),
|
||||
loading: false
|
||||
})
|
||||
if (!windowItem) return
|
||||
try { resolveChatHistoryLinkRecords(windowItem) } catch {}
|
||||
|
||||
if (!serverId || !selectedAccount.value || record?._nestedResolving || !isChatHistoryRecordItemIncomplete(recordItem)) return
|
||||
record._nestedResolving = true
|
||||
windowItem.loading = true
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
const response = await api.resolveNestedChatHistory({
|
||||
account: selectedAccount.value,
|
||||
server_id: serverId
|
||||
})
|
||||
const resolved = String(response?.recordItem || '').trim()
|
||||
if (!resolved) return
|
||||
windowItem.title = String(response?.title || title || '聊天记录')
|
||||
const parsed = parseChatHistoryRecord(resolved)
|
||||
windowItem.info = parsed?.info || { isChatRoom: false, count: 0 }
|
||||
const items = Array.isArray(parsed?.items) ? parsed.items : []
|
||||
windowItem.records = items.length ? enhanceChatHistoryRecords(items.map(normalizeRecordItem)) : []
|
||||
if (!windowItem.records.length) {
|
||||
const lines = String(response?.content || content || '').trim().split(/\r?\n/).map((item) => item.trim()).filter(Boolean)
|
||||
windowItem.info = { isChatRoom: false, count: 0 }
|
||||
windowItem.records = lines.map((line, idx) => normalizeRecordItem({
|
||||
id: String(idx),
|
||||
datatype: '1',
|
||||
sourcename: '',
|
||||
sourcetime: '',
|
||||
content: line,
|
||||
renderType: 'text'
|
||||
}))
|
||||
}
|
||||
try { resolveChatHistoryLinkRecords(windowItem) } catch {}
|
||||
} catch {}
|
||||
finally {
|
||||
windowItem.loading = false
|
||||
try { record._nestedResolving = false } catch {}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
return {
|
||||
floatingWindows,
|
||||
chatHistoryModalVisible,
|
||||
chatHistoryModalTitle,
|
||||
chatHistoryModalRecords,
|
||||
chatHistoryModalInfo,
|
||||
chatHistoryModalStack,
|
||||
goBackChatHistoryModal,
|
||||
closeChatHistoryModal,
|
||||
getFloatingWindowById,
|
||||
focusFloatingWindow,
|
||||
closeFloatingWindow,
|
||||
closeTopFloatingWindow,
|
||||
openFloatingWindow,
|
||||
startFloatingWindowDrag,
|
||||
onFloatingWindowMouseMove,
|
||||
onFloatingWindowMouseUp,
|
||||
formatChatHistoryVideoDuration,
|
||||
getChatHistoryPreviewLines,
|
||||
onChatHistoryVideoThumbError,
|
||||
onChatHistoryLinkPreviewError,
|
||||
onChatHistoryFromAvatarLoad,
|
||||
onChatHistoryFromAvatarError,
|
||||
onChatHistoryQuoteThumbError,
|
||||
openChatHistoryQuote,
|
||||
getChatHistoryLinkFromText,
|
||||
getChatHistoryLinkFromAvatarText,
|
||||
openUrlInBrowser,
|
||||
resolveChatHistoryLinkRecord,
|
||||
resolveChatHistoryLinkRecords,
|
||||
openChatHistoryLinkWindow,
|
||||
openChatHistoryModal,
|
||||
openNestedChatHistory
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import {
|
||||
formatFileSize,
|
||||
formatTimeDivider,
|
||||
getVoiceDurationInSeconds,
|
||||
getVoiceWidth
|
||||
} from '~/lib/chat/formatters'
|
||||
import { createMessageNormalizer, dedupeMessagesById } from '~/lib/chat/message-normalizer'
|
||||
|
||||
export const useChatMessages = ({
|
||||
api,
|
||||
apiBase,
|
||||
selectedAccount,
|
||||
selectedContact,
|
||||
realtimeStore,
|
||||
realtimeEnabled,
|
||||
desktopAutoRealtime,
|
||||
privacyMode,
|
||||
searchContext
|
||||
}) => {
|
||||
const messagePageSize = 50
|
||||
|
||||
const allMessages = ref({})
|
||||
const messagesMeta = ref({})
|
||||
const isLoadingMessages = ref(false)
|
||||
const messagesError = ref('')
|
||||
const messageContainerRef = ref(null)
|
||||
const activeMessagesFor = ref('')
|
||||
const showJumpToBottom = ref(false)
|
||||
|
||||
const previewImageUrl = ref(null)
|
||||
const previewVideoUrl = ref(null)
|
||||
const previewVideoPosterUrl = ref('')
|
||||
const previewVideoError = ref('')
|
||||
|
||||
const voiceRefs = ref({})
|
||||
const currentPlayingVoice = ref(null)
|
||||
const playingVoiceId = ref(null)
|
||||
|
||||
const highlightServerIdStr = ref('')
|
||||
const highlightMessageId = ref('')
|
||||
let highlightTimer = null
|
||||
|
||||
const messageTypeFilter = ref('all')
|
||||
const messageTypeFilterOptions = [
|
||||
{ value: 'all', label: '全部' },
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'emoji', label: '表情' },
|
||||
{ value: 'video', label: '视频' },
|
||||
{ value: 'voice', label: '语音' },
|
||||
{ value: 'file', label: '文件' },
|
||||
{ value: 'link', label: '链接' },
|
||||
{ value: 'quote', label: '引用' },
|
||||
{ value: 'chatHistory', label: '聊天记录' },
|
||||
{ value: 'transfer', label: '转账' },
|
||||
{ value: 'redPacket', label: '红包' },
|
||||
{ value: 'location', label: '位置' },
|
||||
{ value: 'voip', label: '通话' },
|
||||
{ value: 'system', label: '系统' }
|
||||
]
|
||||
|
||||
const normalizeMessage = createMessageNormalizer({
|
||||
apiBase,
|
||||
getSelectedAccount: () => selectedAccount.value,
|
||||
getSelectedContact: () => selectedContact.value
|
||||
})
|
||||
|
||||
const messages = computed(() => {
|
||||
if (!selectedContact.value) return []
|
||||
return allMessages.value[selectedContact.value.username] || []
|
||||
})
|
||||
|
||||
const hasMoreMessages = computed(() => {
|
||||
if (!selectedContact.value) return false
|
||||
const key = selectedContact.value.username
|
||||
const meta = messagesMeta.value[key]
|
||||
if (!meta) return false
|
||||
if (meta.hasMore != null) return !!meta.hasMore
|
||||
const total = Number(meta.total || 0)
|
||||
const loaded = messages.value.length
|
||||
return total > loaded
|
||||
})
|
||||
|
||||
const reverseMessageSides = ref(false)
|
||||
const reverseSidesStorageKey = computed(() => {
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
const username = String(selectedContact.value?.username || '').trim()
|
||||
if (account && username) return `wechatda:reverse_message_sides:${account}:${username}`
|
||||
return 'wechatda:reverse_message_sides:global'
|
||||
})
|
||||
|
||||
const loadReverseMessageSides = () => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
const value = localStorage.getItem(reverseSidesStorageKey.value)
|
||||
reverseMessageSides.value = value === '1'
|
||||
} catch {}
|
||||
}
|
||||
|
||||
watch(reverseSidesStorageKey, () => loadReverseMessageSides(), { immediate: true })
|
||||
watch(reverseMessageSides, (value) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(reverseSidesStorageKey.value, value ? '1' : '0')
|
||||
} catch {}
|
||||
})
|
||||
|
||||
const toggleReverseMessageSides = () => {
|
||||
reverseMessageSides.value = !reverseMessageSides.value
|
||||
}
|
||||
|
||||
const renderMessages = computed(() => {
|
||||
const list = messages.value || []
|
||||
const reverseSides = !!reverseMessageSides.value
|
||||
let previousTs = 0
|
||||
return list.map((message) => {
|
||||
const ts = Number(message.createTime || 0)
|
||||
const show = !previousTs || (ts && Math.abs(ts - previousTs) >= 300)
|
||||
if (ts) previousTs = ts
|
||||
const originalIsSent = !!message?.isSent
|
||||
return {
|
||||
...message,
|
||||
_originalIsSent: originalIsSent,
|
||||
isSent: reverseSides ? !originalIsSent : originalIsSent,
|
||||
showTimeDivider: !!show,
|
||||
timeDivider: formatTimeDivider(ts)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const updateJumpToBottomState = () => {
|
||||
const container = messageContainerRef.value
|
||||
if (!container) {
|
||||
showJumpToBottom.value = false
|
||||
return
|
||||
}
|
||||
const distance = container.scrollHeight - container.scrollTop - container.clientHeight
|
||||
showJumpToBottom.value = distance > 160
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const container = messageContainerRef.value
|
||||
if (!container) return
|
||||
container.scrollTop = container.scrollHeight
|
||||
updateJumpToBottomState()
|
||||
}
|
||||
|
||||
const flashMessage = (id) => {
|
||||
highlightMessageId.value = String(id || '').trim()
|
||||
if (highlightTimer) clearTimeout(highlightTimer)
|
||||
highlightTimer = setTimeout(() => {
|
||||
highlightMessageId.value = ''
|
||||
highlightServerIdStr.value = ''
|
||||
highlightTimer = null
|
||||
}, 2200)
|
||||
}
|
||||
|
||||
const scrollToMessageId = async (id) => {
|
||||
const target = String(id || '').trim()
|
||||
if (!target) return false
|
||||
await nextTick()
|
||||
const container = messageContainerRef.value
|
||||
const element = container?.querySelector?.(`[data-msg-id="${CSS.escape(target)}"]`)
|
||||
if (!element || typeof element.scrollIntoView !== 'function') return false
|
||||
element.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
return true
|
||||
}
|
||||
|
||||
const openImagePreview = (url) => {
|
||||
previewImageUrl.value = String(url || '').trim() || null
|
||||
}
|
||||
|
||||
const closeImagePreview = () => {
|
||||
previewImageUrl.value = null
|
||||
}
|
||||
|
||||
const openVideoPreview = (url, poster) => {
|
||||
previewVideoUrl.value = String(url || '').trim() || null
|
||||
previewVideoPosterUrl.value = String(poster || '').trim()
|
||||
previewVideoError.value = ''
|
||||
}
|
||||
|
||||
const closeVideoPreview = () => {
|
||||
previewVideoUrl.value = null
|
||||
previewVideoPosterUrl.value = ''
|
||||
previewVideoError.value = ''
|
||||
}
|
||||
|
||||
const onPreviewVideoError = () => {
|
||||
previewVideoError.value = '视频加载失败,可能是资源不存在或无法访问。'
|
||||
}
|
||||
|
||||
const setVoiceRef = (id, element) => {
|
||||
const key = String(id || '').trim()
|
||||
if (!key) return
|
||||
if (element) {
|
||||
voiceRefs.value = { ...voiceRefs.value, [key]: element }
|
||||
} else if (voiceRefs.value[key]) {
|
||||
const next = { ...voiceRefs.value }
|
||||
delete next[key]
|
||||
voiceRefs.value = next
|
||||
}
|
||||
}
|
||||
|
||||
const playVoiceById = async (voiceId) => {
|
||||
const key = String(voiceId || '').trim()
|
||||
if (!key) return
|
||||
const audio = voiceRefs.value[key]
|
||||
if (!audio) return
|
||||
|
||||
try {
|
||||
if (currentPlayingVoice.value && currentPlayingVoice.value !== audio) {
|
||||
currentPlayingVoice.value.pause()
|
||||
currentPlayingVoice.value.currentTime = 0
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (currentPlayingVoice.value === audio && !audio.paused) {
|
||||
try {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
} catch {}
|
||||
currentPlayingVoice.value = null
|
||||
playingVoiceId.value = null
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await audio.play()
|
||||
currentPlayingVoice.value = audio
|
||||
playingVoiceId.value = key
|
||||
audio.onended = () => {
|
||||
if (playingVoiceId.value === key) {
|
||||
currentPlayingVoice.value = null
|
||||
playingVoiceId.value = null
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const playVoice = async (message) => {
|
||||
await playVoiceById(message?.id)
|
||||
}
|
||||
|
||||
const getQuoteVoiceId = (message) => `quote-${String(message?.quoteServerId || message?.id || '')}`
|
||||
|
||||
const playQuoteVoice = async (message) => {
|
||||
await playVoiceById(getQuoteVoiceId(message))
|
||||
}
|
||||
|
||||
const isQuotedVoice = (message) => String(message?.quoteType || '').trim() === '34'
|
||||
const isQuotedImage = (message) => {
|
||||
return !!String(message?.quoteImageUrl || '').trim() || String(message?.quoteContent || '').trim() === '[图片]'
|
||||
}
|
||||
const isQuotedLink = (message) => {
|
||||
return String(message?.quoteType || '').trim() === '5' || !!String(message?.quoteThumbUrl || '').trim()
|
||||
}
|
||||
const getQuotedLinkText = (message) => {
|
||||
const title = String(message?.quoteTitle || '').trim()
|
||||
const content = String(message?.quoteContent || '').trim()
|
||||
return content || title || ''
|
||||
}
|
||||
|
||||
const onQuoteImageError = (message) => {
|
||||
if (message) message._quoteImageError = true
|
||||
}
|
||||
|
||||
const onQuoteThumbError = (message) => {
|
||||
if (message) message._quoteThumbError = true
|
||||
}
|
||||
|
||||
const onAvatarError = (event, target) => {
|
||||
try { event?.target && (event.target.style.display = 'none') } catch {}
|
||||
try { if (target) target.avatar = null } catch {}
|
||||
}
|
||||
|
||||
const shouldShowEmojiDownload = (message) => {
|
||||
if (!message?.emojiMd5) return false
|
||||
const url = String(message?.emojiRemoteUrl || '').trim()
|
||||
if (!url) return false
|
||||
if (!/^https?:\/\//i.test(url)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const onEmojiDownloadClick = async (message) => {
|
||||
if (!process.client) return
|
||||
if (!message?.emojiMd5) return
|
||||
if (!selectedAccount.value) return
|
||||
|
||||
const emojiUrl = String(message?.emojiRemoteUrl || '').trim()
|
||||
if (!emojiUrl) {
|
||||
window.alert('该表情没有可用的下载地址')
|
||||
return
|
||||
}
|
||||
if (message._emojiDownloading) return
|
||||
|
||||
message._emojiDownloading = true
|
||||
try {
|
||||
await api.downloadChatEmoji({
|
||||
account: selectedAccount.value,
|
||||
md5: message.emojiMd5,
|
||||
emoji_url: emojiUrl,
|
||||
force: false
|
||||
})
|
||||
message._emojiDownloaded = true
|
||||
if (message.emojiLocalUrl) {
|
||||
message.emojiUrl = message.emojiLocalUrl
|
||||
}
|
||||
} catch (error) {
|
||||
window.alert(error?.message || '下载失败')
|
||||
} finally {
|
||||
message._emojiDownloading = false
|
||||
}
|
||||
}
|
||||
|
||||
const onFileClick = async (message) => {
|
||||
if (!message?.fileMd5) return
|
||||
try {
|
||||
if (!selectedAccount.value) return
|
||||
if (!selectedContact.value?.username) return
|
||||
await api.openChatMediaFolder({
|
||||
account: selectedAccount.value,
|
||||
username: selectedContact.value.username,
|
||||
kind: 'file',
|
||||
md5: message.fileMd5
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('打开文件夹失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMessages = async ({ username, reset }) => {
|
||||
if (!username || !selectedAccount.value) return
|
||||
|
||||
messagesError.value = ''
|
||||
isLoadingMessages.value = true
|
||||
activeMessagesFor.value = username
|
||||
|
||||
try {
|
||||
const existing = allMessages.value[username] || []
|
||||
const container = messageContainerRef.value
|
||||
const beforeScrollHeight = container ? container.scrollHeight : 0
|
||||
const beforeScrollTop = container ? container.scrollTop : 0
|
||||
const offset = reset ? 0 : existing.length
|
||||
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
username,
|
||||
limit: messagePageSize,
|
||||
offset,
|
||||
order: 'asc'
|
||||
}
|
||||
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
|
||||
params.render_types = messageTypeFilter.value
|
||||
}
|
||||
if (realtimeEnabled.value) {
|
||||
params.source = 'realtime'
|
||||
}
|
||||
const response = await api.listChatMessages(params)
|
||||
|
||||
const raw = response?.messages || []
|
||||
const mapped = dedupeMessagesById(raw.map(normalizeMessage))
|
||||
|
||||
if (activeMessagesFor.value !== username) return
|
||||
|
||||
if (reset) {
|
||||
allMessages.value = { ...allMessages.value, [username]: mapped }
|
||||
} else {
|
||||
const existingIds = new Set(existing.map((message) => String(message?.id || '')))
|
||||
const older = mapped.filter((message) => {
|
||||
const id = String(message?.id || '')
|
||||
if (!id) return true
|
||||
if (existingIds.has(id)) return false
|
||||
existingIds.add(id)
|
||||
return true
|
||||
})
|
||||
allMessages.value = {
|
||||
...allMessages.value,
|
||||
[username]: [...older, ...existing]
|
||||
}
|
||||
}
|
||||
|
||||
messagesMeta.value = {
|
||||
...messagesMeta.value,
|
||||
[username]: {
|
||||
total: Number(response?.total || 0),
|
||||
hasMore: response?.hasMore
|
||||
}
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
const nextContainer = messageContainerRef.value
|
||||
if (nextContainer) {
|
||||
if (reset) {
|
||||
nextContainer.scrollTop = nextContainer.scrollHeight
|
||||
} else {
|
||||
const afterScrollHeight = nextContainer.scrollHeight
|
||||
nextContainer.scrollTop = beforeScrollTop + (afterScrollHeight - beforeScrollHeight)
|
||||
}
|
||||
}
|
||||
updateJumpToBottomState()
|
||||
} catch (error) {
|
||||
messagesError.value = error?.message || '加载聊天记录失败'
|
||||
} finally {
|
||||
isLoadingMessages.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMoreMessages = async () => {
|
||||
if (!selectedContact.value) return
|
||||
if (searchContext.value?.active) return
|
||||
await loadMessages({ username: selectedContact.value.username, reset: false })
|
||||
}
|
||||
|
||||
const refreshSelectedMessages = async () => {
|
||||
if (!selectedContact.value) return
|
||||
await loadMessages({ username: selectedContact.value.username, reset: true })
|
||||
}
|
||||
|
||||
const refreshRealtimeIncremental = async () => {
|
||||
if (!realtimeEnabled.value || !selectedAccount.value || !selectedContact.value?.username) return
|
||||
if (searchContext.value?.active || isLoadingMessages.value) return
|
||||
|
||||
const username = selectedContact.value.username
|
||||
const existing = allMessages.value[username] || []
|
||||
if (!existing.length) return
|
||||
|
||||
const container = messageContainerRef.value
|
||||
const atBottom = !!container && (container.scrollHeight - container.scrollTop - container.clientHeight) < 80
|
||||
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
username,
|
||||
limit: 30,
|
||||
offset: 0,
|
||||
order: 'asc',
|
||||
source: 'realtime'
|
||||
}
|
||||
if (messageTypeFilter.value && messageTypeFilter.value !== 'all') {
|
||||
params.render_types = messageTypeFilter.value
|
||||
}
|
||||
|
||||
const response = await api.listChatMessages(params)
|
||||
if (selectedContact.value?.username !== username) return
|
||||
|
||||
const latest = (response?.messages || []).map(normalizeMessage)
|
||||
const seenIds = new Set(existing.map((message) => String(message?.id || '')))
|
||||
const newOnes = []
|
||||
for (const message of latest) {
|
||||
const id = String(message?.id || '')
|
||||
if (!id || seenIds.has(id)) continue
|
||||
seenIds.add(id)
|
||||
newOnes.push(message)
|
||||
}
|
||||
if (!newOnes.length) return
|
||||
|
||||
allMessages.value = { ...allMessages.value, [username]: [...existing, ...newOnes] }
|
||||
|
||||
await nextTick()
|
||||
const nextContainer = messageContainerRef.value
|
||||
if (nextContainer && atBottom) {
|
||||
nextContainer.scrollTop = nextContainer.scrollHeight
|
||||
}
|
||||
updateJumpToBottomState()
|
||||
}
|
||||
|
||||
let realtimeRefreshFuture = null
|
||||
let realtimeRefreshQueued = false
|
||||
|
||||
const queueRealtimeRefresh = () => {
|
||||
if (realtimeRefreshFuture) {
|
||||
realtimeRefreshQueued = true
|
||||
return
|
||||
}
|
||||
|
||||
realtimeRefreshFuture = refreshRealtimeIncremental().finally(() => {
|
||||
realtimeRefreshFuture = null
|
||||
if (realtimeRefreshQueued) {
|
||||
realtimeRefreshQueued = false
|
||||
queueRealtimeRefresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const tryEnableRealtimeAuto = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!desktopAutoRealtime.value || realtimeEnabled.value || !selectedAccount.value) return
|
||||
try {
|
||||
await realtimeStore.enable({ silent: true })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const resetMessageState = () => {
|
||||
allMessages.value = {}
|
||||
messagesMeta.value = {}
|
||||
messagesError.value = ''
|
||||
highlightMessageId.value = ''
|
||||
highlightServerIdStr.value = ''
|
||||
}
|
||||
|
||||
const contactProfileCardOpen = ref(false)
|
||||
const contactProfileCardMessageId = ref('')
|
||||
const contactProfileLoading = ref(false)
|
||||
const contactProfileError = ref('')
|
||||
const contactProfileData = ref(null)
|
||||
let contactProfileHoverHideTimer = null
|
||||
|
||||
const contactProfileResolvedName = computed(() => {
|
||||
const profile = contactProfileData.value || {}
|
||||
const displayName = String(profile?.displayName || '').trim()
|
||||
if (displayName) return displayName
|
||||
const contactName = String(selectedContact.value?.name || '').trim()
|
||||
if (contactName) return contactName
|
||||
return String(profile?.username || selectedContact.value?.username || '').trim()
|
||||
})
|
||||
|
||||
const contactProfileResolvedUsername = computed(() => {
|
||||
const profile = contactProfileData.value || {}
|
||||
return String(profile?.username || selectedContact.value?.username || '').trim()
|
||||
})
|
||||
|
||||
const contactProfileResolvedNickname = computed(() => String(contactProfileData.value?.nickname || '').trim())
|
||||
const contactProfileResolvedAlias = computed(() => String(contactProfileData.value?.alias || '').trim())
|
||||
const contactProfileResolvedRegion = computed(() => String(contactProfileData.value?.region || '').trim())
|
||||
const contactProfileResolvedRemark = computed(() => String(contactProfileData.value?.remark || '').trim())
|
||||
const contactProfileResolvedSignature = computed(() => String(contactProfileData.value?.signature || '').trim())
|
||||
const contactProfileResolvedSource = computed(() => String(contactProfileData.value?.source || '').trim())
|
||||
const contactProfileResolvedAvatar = computed(() => {
|
||||
const avatar = String(contactProfileData.value?.avatar || '').trim()
|
||||
if (avatar) return avatar
|
||||
return String(selectedContact.value?.avatar || '').trim()
|
||||
})
|
||||
|
||||
const contactProfileResolvedGender = computed(() => {
|
||||
const value = contactProfileData.value?.gender
|
||||
if (value == null || value === '') return ''
|
||||
const gender = Number(value)
|
||||
if (!Number.isFinite(gender)) return ''
|
||||
if (gender === 1) return '男'
|
||||
if (gender === 2) return '女'
|
||||
if (gender === 0) return '未知'
|
||||
return String(gender)
|
||||
})
|
||||
|
||||
const contactProfileResolvedSourceScene = computed(() => {
|
||||
const value = contactProfileData.value?.sourceScene
|
||||
if (value == null || value === '') return null
|
||||
const scene = Number(value)
|
||||
return Number.isFinite(scene) ? scene : null
|
||||
})
|
||||
|
||||
const fetchContactProfile = async (options = {}) => {
|
||||
const username = String(options?.username || contactProfileData.value?.username || selectedContact.value?.username || '').trim()
|
||||
const displayNameFallback = String(options?.displayName || '').trim()
|
||||
const avatarFallback = String(options?.avatar || '').trim()
|
||||
const account = String(selectedAccount.value || '').trim()
|
||||
if (!username || !account) {
|
||||
contactProfileData.value = null
|
||||
return
|
||||
}
|
||||
|
||||
contactProfileLoading.value = true
|
||||
contactProfileError.value = ''
|
||||
try {
|
||||
const response = await api.listChatContacts({
|
||||
account,
|
||||
include_friends: true,
|
||||
include_groups: true,
|
||||
include_officials: true
|
||||
})
|
||||
const list = Array.isArray(response?.contacts) ? response.contacts : []
|
||||
const matched = list.find((item) => String(item?.username || '').trim() === username)
|
||||
if (matched) {
|
||||
const normalized = { ...matched, username }
|
||||
if (!String(normalized.displayName || '').trim() && displayNameFallback) {
|
||||
normalized.displayName = displayNameFallback
|
||||
}
|
||||
if (!String(normalized.avatar || '').trim() && avatarFallback) {
|
||||
normalized.avatar = avatarFallback
|
||||
}
|
||||
contactProfileData.value = normalized
|
||||
} else {
|
||||
contactProfileData.value = {
|
||||
username,
|
||||
displayName: displayNameFallback || selectedContact.value?.name || username,
|
||||
avatar: avatarFallback || selectedContact.value?.avatar || '',
|
||||
nickname: '',
|
||||
alias: '',
|
||||
gender: null,
|
||||
region: '',
|
||||
remark: '',
|
||||
signature: '',
|
||||
source: '',
|
||||
sourceScene: null
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
contactProfileData.value = {
|
||||
username,
|
||||
displayName: displayNameFallback || selectedContact.value?.name || username,
|
||||
avatar: avatarFallback || selectedContact.value?.avatar || '',
|
||||
nickname: '',
|
||||
alias: '',
|
||||
gender: null,
|
||||
region: '',
|
||||
remark: '',
|
||||
signature: '',
|
||||
source: '',
|
||||
sourceScene: null
|
||||
}
|
||||
contactProfileError.value = error?.message || '加载联系人资料失败'
|
||||
} finally {
|
||||
contactProfileLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const clearContactProfileHoverHideTimer = () => {
|
||||
if (contactProfileHoverHideTimer) {
|
||||
clearTimeout(contactProfileHoverHideTimer)
|
||||
contactProfileHoverHideTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const closeContactProfileCard = () => {
|
||||
contactProfileCardOpen.value = false
|
||||
contactProfileCardMessageId.value = ''
|
||||
}
|
||||
|
||||
const onMessageAvatarMouseEnter = async (message) => {
|
||||
if (!!message?.isSent) return
|
||||
const messageId = String(message?.id ?? '').trim()
|
||||
if (!messageId) return
|
||||
const username = String(message?.senderUsername || '').trim()
|
||||
if (!username || username === 'self') return
|
||||
|
||||
const senderName = String(message?.senderDisplayName || message?.sender || '').trim()
|
||||
const senderAvatar = String(message?.avatar || '').trim()
|
||||
if (!contactProfileData.value || String(contactProfileData.value?.username || '').trim() !== username) {
|
||||
contactProfileData.value = {
|
||||
username,
|
||||
displayName: senderName || username,
|
||||
avatar: senderAvatar,
|
||||
nickname: '',
|
||||
alias: '',
|
||||
gender: null,
|
||||
region: '',
|
||||
remark: '',
|
||||
signature: '',
|
||||
source: '',
|
||||
sourceScene: null
|
||||
}
|
||||
} else {
|
||||
if (!String(contactProfileData.value?.displayName || '').trim() && senderName) {
|
||||
contactProfileData.value.displayName = senderName
|
||||
}
|
||||
if (!String(contactProfileData.value?.avatar || '').trim() && senderAvatar) {
|
||||
contactProfileData.value.avatar = senderAvatar
|
||||
}
|
||||
}
|
||||
|
||||
clearContactProfileHoverHideTimer()
|
||||
contactProfileCardMessageId.value = messageId
|
||||
contactProfileCardOpen.value = true
|
||||
await fetchContactProfile({ username, displayName: senderName, avatar: senderAvatar })
|
||||
}
|
||||
|
||||
const onMessageAvatarMouseLeave = () => {
|
||||
clearContactProfileHoverHideTimer()
|
||||
contactProfileHoverHideTimer = setTimeout(() => {
|
||||
closeContactProfileCard()
|
||||
}, 120)
|
||||
}
|
||||
|
||||
const onContactCardMouseEnter = () => {
|
||||
clearContactProfileHoverHideTimer()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedContact.value?.username,
|
||||
() => {
|
||||
clearContactProfileHoverHideTimer()
|
||||
closeContactProfileCard()
|
||||
contactProfileError.value = ''
|
||||
contactProfileData.value = null
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => selectedAccount.value,
|
||||
() => {
|
||||
clearContactProfileHoverHideTimer()
|
||||
closeContactProfileCard()
|
||||
contactProfileError.value = ''
|
||||
contactProfileData.value = null
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (highlightTimer) clearTimeout(highlightTimer)
|
||||
highlightTimer = null
|
||||
clearContactProfileHoverHideTimer()
|
||||
})
|
||||
|
||||
return {
|
||||
allMessages,
|
||||
messagesMeta,
|
||||
messages,
|
||||
renderMessages,
|
||||
hasMoreMessages,
|
||||
isLoadingMessages,
|
||||
messagesError,
|
||||
messageContainerRef,
|
||||
showJumpToBottom,
|
||||
messagePageSize,
|
||||
messageTypeFilter,
|
||||
messageTypeFilterOptions,
|
||||
reverseMessageSides,
|
||||
previewImageUrl,
|
||||
previewVideoUrl,
|
||||
previewVideoPosterUrl,
|
||||
previewVideoError,
|
||||
voiceRefs,
|
||||
currentPlayingVoice,
|
||||
playingVoiceId,
|
||||
highlightServerIdStr,
|
||||
highlightMessageId,
|
||||
contactProfileCardOpen,
|
||||
contactProfileCardMessageId,
|
||||
contactProfileLoading,
|
||||
contactProfileError,
|
||||
contactProfileData,
|
||||
contactProfileResolvedName,
|
||||
contactProfileResolvedUsername,
|
||||
contactProfileResolvedNickname,
|
||||
contactProfileResolvedAlias,
|
||||
contactProfileResolvedGender,
|
||||
contactProfileResolvedRegion,
|
||||
contactProfileResolvedRemark,
|
||||
contactProfileResolvedSignature,
|
||||
contactProfileResolvedSource,
|
||||
contactProfileResolvedSourceScene,
|
||||
contactProfileResolvedAvatar,
|
||||
normalizeMessage,
|
||||
updateJumpToBottomState,
|
||||
scrollToBottom,
|
||||
flashMessage,
|
||||
scrollToMessageId,
|
||||
openImagePreview,
|
||||
closeImagePreview,
|
||||
openVideoPreview,
|
||||
closeVideoPreview,
|
||||
onPreviewVideoError,
|
||||
setVoiceRef,
|
||||
playVoice,
|
||||
playQuoteVoice,
|
||||
getQuoteVoiceId,
|
||||
getVoiceDurationInSeconds,
|
||||
getVoiceWidth,
|
||||
isQuotedVoice,
|
||||
isQuotedImage,
|
||||
isQuotedLink,
|
||||
getQuotedLinkText,
|
||||
onQuoteImageError,
|
||||
onQuoteThumbError,
|
||||
onAvatarError,
|
||||
shouldShowEmojiDownload,
|
||||
onEmojiDownloadClick,
|
||||
onFileClick,
|
||||
toggleReverseMessageSides,
|
||||
loadMessages,
|
||||
loadMoreMessages,
|
||||
refreshSelectedMessages,
|
||||
refreshRealtimeIncremental,
|
||||
queueRealtimeRefresh,
|
||||
tryEnableRealtimeAuto,
|
||||
resetMessageState,
|
||||
fetchContactProfile,
|
||||
clearContactProfileHoverHideTimer,
|
||||
closeContactProfileCard,
|
||||
onMessageAvatarMouseEnter,
|
||||
onMessageAvatarMouseLeave,
|
||||
onContactCardMouseEnter,
|
||||
formatFileSize
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { normalizeSessionPreview } from '~/lib/chat/formatters'
|
||||
|
||||
const SESSION_LIST_WIDTH_KEY = 'ui.chat.session_list_width_physical'
|
||||
const SESSION_LIST_WIDTH_KEY_LEGACY = 'ui.chat.session_list_width'
|
||||
const SESSION_LIST_WIDTH_DEFAULT = 295
|
||||
const SESSION_LIST_WIDTH_MIN = 220
|
||||
const SESSION_LIST_WIDTH_MAX = 520
|
||||
|
||||
export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled, api }) => {
|
||||
const showSearchAccountSwitcher = false
|
||||
|
||||
const contacts = ref([])
|
||||
const selectedContact = ref(null)
|
||||
const searchQuery = ref('')
|
||||
const isLoadingContacts = ref(false)
|
||||
const contactsError = ref('')
|
||||
|
||||
const sessionListWidth = ref(SESSION_LIST_WIDTH_DEFAULT)
|
||||
const sessionListResizing = ref(false)
|
||||
|
||||
let sessionListResizeStartX = 0
|
||||
let sessionListResizeStartWidth = SESSION_LIST_WIDTH_DEFAULT
|
||||
let sessionListResizeStartDpr = 1
|
||||
let sessionListResizePrevCursor = ''
|
||||
let sessionListResizePrevUserSelect = ''
|
||||
|
||||
const availableAccounts = computed(() => {
|
||||
return Array.isArray(chatAccounts?.accounts) ? chatAccounts.accounts : []
|
||||
})
|
||||
|
||||
const clampSessionListWidth = (value) => {
|
||||
const next = Number.isFinite(value) ? value : SESSION_LIST_WIDTH_DEFAULT
|
||||
return Math.min(SESSION_LIST_WIDTH_MAX, Math.max(SESSION_LIST_WIDTH_MIN, Math.round(next)))
|
||||
}
|
||||
|
||||
const loadSessionListWidth = () => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_LIST_WIDTH_KEY)
|
||||
const value = parseInt(String(raw || ''), 10)
|
||||
if (!Number.isNaN(value)) {
|
||||
sessionListWidth.value = clampSessionListWidth(value)
|
||||
return
|
||||
}
|
||||
|
||||
const legacy = localStorage.getItem(SESSION_LIST_WIDTH_KEY_LEGACY)
|
||||
const legacyValue = parseInt(String(legacy || ''), 10)
|
||||
if (!Number.isNaN(legacyValue)) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const converted = clampSessionListWidth(legacyValue * dpr)
|
||||
sessionListWidth.value = converted
|
||||
try {
|
||||
localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(converted))
|
||||
localStorage.removeItem(SESSION_LIST_WIDTH_KEY_LEGACY)
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const saveSessionListWidth = () => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(SESSION_LIST_WIDTH_KEY, String(clampSessionListWidth(sessionListWidth.value)))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const setSessionListResizingActive = (active) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
const body = document.body
|
||||
if (!body) return
|
||||
if (active) {
|
||||
sessionListResizePrevCursor = body.style.cursor || ''
|
||||
sessionListResizePrevUserSelect = body.style.userSelect || ''
|
||||
body.style.cursor = 'col-resize'
|
||||
body.style.userSelect = 'none'
|
||||
} else {
|
||||
body.style.cursor = sessionListResizePrevCursor
|
||||
body.style.userSelect = sessionListResizePrevUserSelect
|
||||
sessionListResizePrevCursor = ''
|
||||
sessionListResizePrevUserSelect = ''
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const onSessionListResizerPointerMove = (event) => {
|
||||
if (!sessionListResizing.value) return
|
||||
const clientX = Number(event?.clientX || 0)
|
||||
sessionListWidth.value = clampSessionListWidth(
|
||||
sessionListResizeStartWidth + (clientX - sessionListResizeStartX) * (sessionListResizeStartDpr || 1)
|
||||
)
|
||||
}
|
||||
|
||||
const stopSessionListResize = () => {
|
||||
if (!process.client) return
|
||||
if (!sessionListResizing.value) return
|
||||
sessionListResizing.value = false
|
||||
setSessionListResizingActive(false)
|
||||
try {
|
||||
window.removeEventListener('pointermove', onSessionListResizerPointerMove)
|
||||
} catch {}
|
||||
saveSessionListWidth()
|
||||
}
|
||||
|
||||
const onSessionListResizerPointerUp = () => {
|
||||
stopSessionListResize()
|
||||
}
|
||||
|
||||
const onSessionListResizerPointerDown = (event) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
event?.preventDefault?.()
|
||||
} catch {}
|
||||
|
||||
sessionListResizing.value = true
|
||||
sessionListResizeStartX = Number(event?.clientX || 0)
|
||||
sessionListResizeStartWidth = Number(sessionListWidth.value || SESSION_LIST_WIDTH_DEFAULT)
|
||||
sessionListResizeStartDpr = window.devicePixelRatio || 1
|
||||
setSessionListResizingActive(true)
|
||||
|
||||
try {
|
||||
window.addEventListener('pointermove', onSessionListResizerPointerMove)
|
||||
window.addEventListener('pointerup', onSessionListResizerPointerUp, { once: true })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const resetSessionListWidth = () => {
|
||||
sessionListWidth.value = SESSION_LIST_WIDTH_DEFAULT
|
||||
saveSessionListWidth()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSessionListWidth()
|
||||
})
|
||||
|
||||
const filteredContacts = computed(() => {
|
||||
const query = String(searchQuery.value || '').trim().toLowerCase()
|
||||
if (!query) return contacts.value
|
||||
return contacts.value.filter((contact) => {
|
||||
const name = String(contact?.name || '').toLowerCase()
|
||||
const username = String(contact?.username || '').toLowerCase()
|
||||
return name.includes(query) || username.includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const mapSessions = (sessions) => {
|
||||
return sessions.map((session) => ({
|
||||
id: session.id,
|
||||
name: session.name || session.username || session.id,
|
||||
avatar: session.avatar || null,
|
||||
lastMessage: normalizeSessionPreview(session.lastMessage || ''),
|
||||
lastMessageTime: session.lastMessageTime || '',
|
||||
unreadCount: session.unreadCount || 0,
|
||||
isGroup: !!session.isGroup,
|
||||
isTop: !!session.isTop,
|
||||
username: session.username
|
||||
}))
|
||||
}
|
||||
|
||||
const clearContactsState = (errorMessage = '') => {
|
||||
contacts.value = []
|
||||
selectedContact.value = null
|
||||
contactsError.value = errorMessage
|
||||
}
|
||||
|
||||
const loadSessionsForSelectedAccount = async () => {
|
||||
if (!selectedAccount.value) {
|
||||
clearContactsState('')
|
||||
return []
|
||||
}
|
||||
|
||||
const fetchSessions = async (source) => {
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
limit: 400,
|
||||
include_hidden: false,
|
||||
include_official: false
|
||||
}
|
||||
if (source) params.source = source
|
||||
return api.listChatSessions(params)
|
||||
}
|
||||
|
||||
let sessionsResp = null
|
||||
if (realtimeEnabled?.value) {
|
||||
try {
|
||||
sessionsResp = await fetchSessions('realtime')
|
||||
} catch {
|
||||
sessionsResp = null
|
||||
}
|
||||
}
|
||||
if (!sessionsResp) {
|
||||
sessionsResp = await fetchSessions('')
|
||||
}
|
||||
|
||||
const sessions = Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions : []
|
||||
contacts.value = mapSessions(sessions)
|
||||
contactsError.value = ''
|
||||
return contacts.value
|
||||
}
|
||||
|
||||
const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!selectedAccount.value) return
|
||||
if (isLoadingContacts.value) return
|
||||
|
||||
const previousUsername = selectedContact.value?.username || ''
|
||||
const desiredSource = (sourceOverride != null)
|
||||
? String(sourceOverride || '').trim()
|
||||
: (realtimeEnabled?.value ? 'realtime' : '')
|
||||
|
||||
const params = {
|
||||
account: selectedAccount.value,
|
||||
limit: 400,
|
||||
include_hidden: false,
|
||||
include_official: false
|
||||
}
|
||||
|
||||
let sessionsResp = null
|
||||
if (desiredSource) {
|
||||
try {
|
||||
sessionsResp = await api.listChatSessions({ ...params, source: desiredSource })
|
||||
} catch {
|
||||
sessionsResp = null
|
||||
}
|
||||
}
|
||||
if (!sessionsResp) {
|
||||
try {
|
||||
sessionsResp = await api.listChatSessions(params)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = Array.isArray(sessionsResp?.sessions) ? sessionsResp.sessions : []
|
||||
const nextContacts = mapSessions(sessions)
|
||||
contacts.value = nextContacts
|
||||
|
||||
if (previousUsername) {
|
||||
const matched = nextContacts.find((contact) => contact.username === previousUsername)
|
||||
if (matched) selectedContact.value = matched
|
||||
}
|
||||
}
|
||||
|
||||
const loadContacts = async () => {
|
||||
if (contacts.value.length && !isLoadingContacts.value) {
|
||||
return { usedPrefetched: true }
|
||||
}
|
||||
|
||||
isLoadingContacts.value = true
|
||||
contactsError.value = ''
|
||||
try {
|
||||
await chatAccounts.ensureLoaded()
|
||||
|
||||
if (!selectedAccount.value) {
|
||||
clearContactsState(chatAccounts.error || '未检测到已解密账号,请先解密数据库。')
|
||||
return { usedPrefetched: false }
|
||||
}
|
||||
|
||||
await loadSessionsForSelectedAccount()
|
||||
return { usedPrefetched: false }
|
||||
} catch (error) {
|
||||
clearContactsState(error?.message || '加载联系人失败')
|
||||
return { usedPrefetched: false }
|
||||
} finally {
|
||||
isLoadingContacts.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showSearchAccountSwitcher,
|
||||
availableAccounts,
|
||||
contacts,
|
||||
selectedContact,
|
||||
searchQuery,
|
||||
filteredContacts,
|
||||
isLoadingContacts,
|
||||
contactsError,
|
||||
sessionListWidth,
|
||||
sessionListResizing,
|
||||
clearContactsState,
|
||||
loadContacts,
|
||||
loadSessionsForSelectedAccount,
|
||||
refreshSessionsForSelectedAccount,
|
||||
onSessionListResizerPointerDown,
|
||||
stopSessionListResize,
|
||||
resetSessionListWidth
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { reportServerError } from '~/lib/server-error-logging'
|
||||
|
||||
// API请求组合式函数
|
||||
export const useApi = () => {
|
||||
const baseURL = useApiBase()
|
||||
@@ -8,10 +10,19 @@ export const useApi = () => {
|
||||
const response = await $fetch(url, {
|
||||
baseURL,
|
||||
...options,
|
||||
onResponseError({ response }) {
|
||||
async onResponseError({ response }) {
|
||||
if (response.status === 400) {
|
||||
throw new Error(response._data?.detail || '请求参数错误')
|
||||
} else if (response.status === 500) {
|
||||
} else if (response.status >= 500) {
|
||||
await reportServerError({
|
||||
status: response.status,
|
||||
method: options?.method || 'GET',
|
||||
requestUrl: url,
|
||||
message: '服务器错误,请稍后重试',
|
||||
backendDetail: response._data?.detail || '',
|
||||
source: 'useApi',
|
||||
apiBase: baseURL,
|
||||
})
|
||||
throw new Error('服务器错误,请稍后重试')
|
||||
}
|
||||
}
|
||||
@@ -60,6 +71,22 @@ export const useApi = () => {
|
||||
return await request('/chat/accounts')
|
||||
}
|
||||
|
||||
const getChatAccountInfo = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
const url = '/chat/account_info' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url)
|
||||
}
|
||||
|
||||
const deleteChatAccount = async (params = {}) => {
|
||||
const account = String(params?.account || '').trim()
|
||||
if (!account) throw new Error('Missing account')
|
||||
const query = new URLSearchParams()
|
||||
query.set('account', account)
|
||||
const url = '/chat/account' + (query.toString() ? `?${query.toString()}` : '')
|
||||
return await request(url, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
const listChatSessions = async (params = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params && params.account) query.set('account', params.account)
|
||||
@@ -540,6 +567,8 @@ export const useApi = () => {
|
||||
decryptDatabase,
|
||||
healthCheck,
|
||||
listChatAccounts,
|
||||
getChatAccountInfo,
|
||||
deleteChatAccount,
|
||||
listChatSessions,
|
||||
listChatMessages,
|
||||
getChatMessageRaw,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useApiBase } from '~/composables/useApiBase'
|
||||
|
||||
const FRONTEND_SERVER_ERROR_ENDPOINT = '/admin/log-frontend-server-error'
|
||||
|
||||
const normalizeStatus = (value) => {
|
||||
const n = Number(value)
|
||||
if (!Number.isInteger(n)) return 0
|
||||
return n
|
||||
}
|
||||
|
||||
const stringifyDetail = (value) => {
|
||||
if (value == null) return ''
|
||||
if (typeof value === 'string') return value.trim()
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value).trim()
|
||||
}
|
||||
}
|
||||
|
||||
const currentOrigin = () => {
|
||||
if (!process.client || typeof window === 'undefined') return ''
|
||||
try {
|
||||
return String(window.location?.origin || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeBasePath = (apiBase) => {
|
||||
const raw = String(apiBase || '').trim()
|
||||
if (!raw) return '/api'
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
try {
|
||||
const u = new URL(raw)
|
||||
return u.pathname.replace(/\/+$/, '') || '/'
|
||||
} catch {
|
||||
return '/api'
|
||||
}
|
||||
}
|
||||
return raw.replace(/\/+$/, '') || '/'
|
||||
}
|
||||
|
||||
const normalizePathname = (value) => {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
try {
|
||||
return new URL(raw).pathname.replace(/\/+$/, '')
|
||||
} catch {
|
||||
return raw.split(/[?#]/, 1)[0].replace(/\/+$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
export const isServerErrorStatus = (status) => normalizeStatus(status) >= 500
|
||||
|
||||
export const resolveRequestUrl = (requestUrl, apiBase = '') => {
|
||||
const raw = String(requestUrl || '').trim()
|
||||
if (!raw) return ''
|
||||
if (/^https?:\/\//i.test(raw)) return raw
|
||||
|
||||
const origin = currentOrigin()
|
||||
if (!origin) return raw
|
||||
|
||||
if (raw.startsWith('/')) {
|
||||
const prefix = normalizeBasePath(apiBase)
|
||||
const combined = raw === prefix || raw.startsWith(`${prefix}/`) ? raw : `${prefix}${raw}`
|
||||
if (/^https?:\/\//i.test(String(apiBase || '').trim())) {
|
||||
try {
|
||||
const baseUrl = new URL(String(apiBase).trim())
|
||||
return new URL(combined, `${baseUrl.origin}/`).toString()
|
||||
} catch {
|
||||
return new URL(combined, origin).toString()
|
||||
}
|
||||
}
|
||||
return new URL(combined, origin).toString()
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(String(apiBase || '').trim())) {
|
||||
try {
|
||||
const base = String(apiBase).trim()
|
||||
return new URL(raw, base.endsWith('/') ? base : `${base}/`).toString()
|
||||
} catch {
|
||||
return new URL(raw, origin).toString()
|
||||
}
|
||||
}
|
||||
|
||||
return new URL(raw, origin).toString()
|
||||
}
|
||||
|
||||
const isFrontendServerLogUrl = (requestUrl) => {
|
||||
const path = normalizePathname(requestUrl)
|
||||
return path.endsWith('/api/admin/log-frontend-server-error') || path.endsWith('/admin/log-frontend-server-error')
|
||||
}
|
||||
|
||||
const extractBackendDetail = (data) => {
|
||||
if (data == null) return ''
|
||||
if (typeof data === 'string') return data.trim()
|
||||
if (typeof data === 'object' && !Array.isArray(data) && Object.prototype.hasOwnProperty.call(data, 'detail')) {
|
||||
return stringifyDetail(data.detail)
|
||||
}
|
||||
return stringifyDetail(data)
|
||||
}
|
||||
|
||||
const resolveApiBase = (apiBase) => {
|
||||
const raw = String(apiBase || '').trim()
|
||||
if (raw) return raw
|
||||
if (!process.client) return ''
|
||||
try {
|
||||
return String(useApiBase() || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const extractServerErrorFromError = (error) => {
|
||||
const response = error?.response
|
||||
return {
|
||||
status: normalizeStatus(error?.status ?? response?.status),
|
||||
backendDetail: extractBackendDetail(response?._data ?? response?.data ?? error?.data),
|
||||
message: String(error?.message || '').trim(),
|
||||
requestUrl: String(response?.url || error?.request || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export const extractServerErrorDetailFromResponse = async (response) => {
|
||||
if (!response || typeof response.clone !== 'function') return ''
|
||||
try {
|
||||
const clone = response.clone()
|
||||
const contentType = String(clone.headers?.get?.('content-type') || '').toLowerCase()
|
||||
if (contentType.includes('json')) {
|
||||
try {
|
||||
const payload = await clone.json()
|
||||
return extractBackendDetail(payload)
|
||||
} catch {}
|
||||
}
|
||||
const text = String(await clone.text()).trim()
|
||||
if (!text) return ''
|
||||
if (contentType.includes('json')) {
|
||||
try {
|
||||
return extractBackendDetail(JSON.parse(text))
|
||||
} catch {}
|
||||
}
|
||||
return text
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const reportServerError = async (context = {}) => {
|
||||
if (!process.client || typeof window === 'undefined') return false
|
||||
|
||||
const status = normalizeStatus(context.status)
|
||||
if (!isServerErrorStatus(status)) return false
|
||||
|
||||
const apiBase = resolveApiBase(context.apiBase)
|
||||
const requestUrl = resolveRequestUrl(context.requestUrl, apiBase)
|
||||
if (!requestUrl || isFrontendServerLogUrl(requestUrl)) return false
|
||||
|
||||
const endpointUrl = resolveRequestUrl(FRONTEND_SERVER_ERROR_ENDPOINT, apiBase)
|
||||
if (!endpointUrl) return false
|
||||
|
||||
const payload = {
|
||||
status,
|
||||
method: String(context.method || 'GET').trim().toUpperCase() || 'GET',
|
||||
request_url: requestUrl,
|
||||
message: String(context.message || '').trim(),
|
||||
backend_detail: String(context.backendDetail || '').trim(),
|
||||
source: String(context.source || '').trim(),
|
||||
page_url: String(window.location?.href || '').trim(),
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(endpointUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true,
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const reportServerErrorFromError = async (error, context = {}) => {
|
||||
const info = extractServerErrorFromError(error)
|
||||
return await reportServerError({
|
||||
...context,
|
||||
status: context.status ?? info.status,
|
||||
requestUrl: context.requestUrl || info.requestUrl,
|
||||
message: context.message || info.message,
|
||||
backendDetail: context.backendDetail || info.backendDetail,
|
||||
})
|
||||
}
|
||||
|
||||
export const reportServerErrorFromResponse = async (response, context = {}) => {
|
||||
const status = normalizeStatus(context.status ?? response?.status)
|
||||
if (!isServerErrorStatus(status)) return false
|
||||
const backendDetail = context.backendDetail || (await extractServerErrorDetailFromResponse(response))
|
||||
return await reportServerError({
|
||||
...context,
|
||||
status,
|
||||
requestUrl: context.requestUrl || response?.url || '',
|
||||
backendDetail,
|
||||
})
|
||||
}
|
||||
+15
-1
@@ -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
+314
-9151
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,8 +705,9 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
|
||||
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
|
||||
import { reportServerErrorFromError } from '~/lib/server-error-logging'
|
||||
|
||||
useHead({ title: '朋友圈 - 微信数据分析助手' })
|
||||
|
||||
@@ -1131,6 +1132,12 @@ const loadSelfInfo = async () => {
|
||||
selfInfo.value = resp
|
||||
}
|
||||
} catch (e) {
|
||||
await reportServerErrorFromError(e, {
|
||||
method: 'GET',
|
||||
requestUrl: `${apiBase}/sns/self_info?account=${encodeURIComponent(selectedAccount.value)}`,
|
||||
source: 'sns.loadSelfInfo',
|
||||
apiBase,
|
||||
})
|
||||
console.error('获取个人信息失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -15,6 +15,7 @@ from .logging_config import setup_logging, get_logger
|
||||
# 初始化日志系统
|
||||
setup_logging()
|
||||
logger = get_logger(__name__)
|
||||
request_logger = get_logger("wechat_decrypt_tool.request")
|
||||
|
||||
from . import __version__ as APP_VERSION
|
||||
from .path_fix import PathFixRoute
|
||||
@@ -32,6 +33,7 @@ from .routers.sns import router as _sns_router
|
||||
from .routers.sns_export import router as _sns_export_router
|
||||
from .routers.wechat_detection import router as _wechat_detection_router
|
||||
from .routers.wrapped import router as _wrapped_router
|
||||
from .request_logging import log_server_errors_middleware
|
||||
from .sns_stage_timing import add_sns_stage_timing_headers
|
||||
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
|
||||
|
||||
@@ -76,6 +78,11 @@ async def _add_sns_stage_timing_headers(request: Request, call_next):
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def _log_server_errors(request: Request, call_next):
|
||||
return await log_server_errors_middleware(request_logger, request, call_next)
|
||||
|
||||
|
||||
app.include_router(_health_router)
|
||||
app.include_router(_admin_router)
|
||||
app.include_router(_wechat_detection_router)
|
||||
@@ -99,9 +106,36 @@ class _SPAStaticFiles(StaticFiles):
|
||||
self._fallback_200 = Path(str(self.directory)) / "200.html"
|
||||
self._fallback_index = Path(str(self.directory)) / "index.html"
|
||||
|
||||
async def get_response(self, path: str, scope): # type: ignore[override]
|
||||
@staticmethod
|
||||
def _normalize_path(path: str) -> str:
|
||||
return str(path or "").strip().lstrip("/")
|
||||
|
||||
@classmethod
|
||||
def _is_shell_path(cls, path: str) -> bool:
|
||||
normalized = cls._normalize_path(path)
|
||||
return normalized in {"", "index.html", "200.html", "_payload.json"} or normalized.startswith(
|
||||
"_payload.json/"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _apply_cache_headers(cls, path: str, response):
|
||||
normalized = cls._normalize_path(path)
|
||||
try:
|
||||
return await super().get_response(path, scope)
|
||||
if cls._is_shell_path(normalized):
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||
response.headers["Pragma"] = "no-cache"
|
||||
response.headers["Expires"] = "0"
|
||||
elif normalized.startswith("_nuxt/"):
|
||||
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
||||
async def get_response(self, path: str, scope): # type: ignore[override]
|
||||
normalized = self._normalize_path(path)
|
||||
try:
|
||||
response = await super().get_response(path, scope)
|
||||
return self._apply_cache_headers(normalized, response)
|
||||
except StarletteHTTPException as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
@@ -112,8 +146,8 @@ class _SPAStaticFiles(StaticFiles):
|
||||
raise
|
||||
|
||||
if self._fallback_200.exists():
|
||||
return FileResponse(str(self._fallback_200))
|
||||
return FileResponse(str(self._fallback_index))
|
||||
return self._apply_cache_headers("200.html", FileResponse(str(self._fallback_200)))
|
||||
return self._apply_cache_headers("index.html", FileResponse(str(self._fallback_index)))
|
||||
|
||||
|
||||
def _maybe_mount_frontend() -> None:
|
||||
|
||||
@@ -485,3 +485,30 @@ def update_message_edit_local_id(
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def delete_account_edits(account: str) -> int:
|
||||
a = str(account or "").strip()
|
||||
if not a:
|
||||
return 0
|
||||
|
||||
conn: Optional[sqlite3.Connection] = None
|
||||
try:
|
||||
conn = _connect()
|
||||
cur = conn.execute(
|
||||
"""
|
||||
DELETE FROM message_edits
|
||||
WHERE account = ?
|
||||
""",
|
||||
(a,),
|
||||
)
|
||||
conn.commit()
|
||||
return int(getattr(cur, "rowcount", 0) or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
finally:
|
||||
try:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -67,3 +67,20 @@ def upsert_account_keys_in_store(
|
||||
pass
|
||||
|
||||
return item
|
||||
|
||||
|
||||
def remove_account_keys_from_store(account: str) -> bool:
|
||||
account = str(account or "").strip()
|
||||
if not account:
|
||||
return False
|
||||
|
||||
store = load_account_keys_store()
|
||||
if account not in store:
|
||||
return False
|
||||
|
||||
try:
|
||||
store.pop(account, None)
|
||||
_atomic_write_json(_KEY_STORE_PATH, store)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
def _stringify_detail(detail: Any) -> str:
|
||||
if detail is None:
|
||||
return ""
|
||||
if isinstance(detail, str):
|
||||
return detail.strip()
|
||||
try:
|
||||
return json.dumps(detail, ensure_ascii=False)
|
||||
except Exception:
|
||||
return str(detail).strip()
|
||||
|
||||
|
||||
def _extract_response_detail(response: Response) -> str:
|
||||
body = getattr(response, "body", None)
|
||||
if body is None:
|
||||
return ""
|
||||
|
||||
try:
|
||||
raw = body.tobytes() if isinstance(body, memoryview) else body
|
||||
except Exception:
|
||||
raw = body
|
||||
|
||||
if isinstance(raw, bytes):
|
||||
text = raw.decode("utf-8", errors="ignore").strip()
|
||||
else:
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
content_type = str(response.headers.get("content-type") or "").lower()
|
||||
if "json" not in content_type:
|
||||
return ""
|
||||
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
return _stringify_detail(payload.get("detail"))
|
||||
|
||||
|
||||
async def _buffer_response_body(response: Response) -> tuple[Response, bytes]:
|
||||
body = getattr(response, "body", None)
|
||||
if body is not None:
|
||||
try:
|
||||
raw = body.tobytes() if isinstance(body, memoryview) else body
|
||||
except Exception:
|
||||
raw = body
|
||||
if isinstance(raw, bytes):
|
||||
return response, raw
|
||||
if isinstance(raw, str):
|
||||
return response, raw.encode("utf-8")
|
||||
return response, bytes(raw)
|
||||
|
||||
chunks: list[bytes] = []
|
||||
body_iterator = getattr(response, "body_iterator", None)
|
||||
if body_iterator is not None:
|
||||
async for chunk in body_iterator:
|
||||
if isinstance(chunk, memoryview):
|
||||
chunks.append(chunk.tobytes())
|
||||
elif isinstance(chunk, bytes):
|
||||
chunks.append(chunk)
|
||||
else:
|
||||
chunks.append(str(chunk).encode("utf-8"))
|
||||
|
||||
body_bytes = b"".join(chunks)
|
||||
rebuilt = Response(
|
||||
content=body_bytes,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.media_type,
|
||||
background=response.background,
|
||||
)
|
||||
return rebuilt, body_bytes
|
||||
|
||||
|
||||
def _extract_response_detail_from_body(response: Response, body: bytes) -> str:
|
||||
if not body:
|
||||
return ""
|
||||
|
||||
try:
|
||||
text = body.decode("utf-8", errors="ignore").strip()
|
||||
except Exception:
|
||||
return ""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
content_type = str(response.headers.get("content-type") or "").lower()
|
||||
if "json" not in content_type:
|
||||
return ""
|
||||
|
||||
try:
|
||||
payload = json.loads(text)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
return _stringify_detail(payload.get("detail"))
|
||||
|
||||
|
||||
async def log_server_errors_middleware(logger, request: Request, call_next):
|
||||
method = str(request.method or "").upper() or "GET"
|
||||
path = str(request.url.path or "").strip() or "/"
|
||||
|
||||
try:
|
||||
response = await call_next(request)
|
||||
except Exception as exc:
|
||||
logger.exception("[server-exception] method=%s path=%s error=%s", method, path, exc)
|
||||
raise
|
||||
|
||||
status = int(getattr(response, "status_code", 0) or 0)
|
||||
if status >= 500:
|
||||
response, body = await _buffer_response_body(response)
|
||||
detail = _extract_response_detail_from_body(response, body) or _extract_response_detail(response)
|
||||
if detail:
|
||||
logger.error("[server-5xx] status=%s method=%s path=%s detail=%s", status, method, path, detail)
|
||||
else:
|
||||
logger.error("[server-5xx] status=%s method=%s path=%s", status, method, path)
|
||||
|
||||
return response
|
||||
@@ -13,11 +13,13 @@ import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
from ..logging_config import get_log_file_path, get_logger
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..runtime_settings import read_effective_backend_port, write_backend_port_env_file, write_backend_port_setting
|
||||
|
||||
|
||||
router = APIRouter(route_class=PathFixRoute)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
DEFAULT_BACKEND_PORT = 10392
|
||||
_PORT_CHANGE_IN_PROGRESS = False
|
||||
@@ -58,6 +60,36 @@ def _is_loopback_client(request: Request) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _get_current_log_file_path() -> Path:
|
||||
log_file = Path(get_log_file_path())
|
||||
try:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
if not log_file.exists():
|
||||
try:
|
||||
log_file.touch(exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return log_file
|
||||
|
||||
|
||||
def _open_path_with_default_app(path: Path) -> None:
|
||||
target = str(path)
|
||||
if os.name == "nt":
|
||||
opener = getattr(os, "startfile", None)
|
||||
if opener is None:
|
||||
raise RuntimeError("当前系统不支持默认打开文件")
|
||||
opener(target)
|
||||
return
|
||||
|
||||
if sys.platform == "darwin":
|
||||
subprocess.Popen(["open", target])
|
||||
return
|
||||
|
||||
subprocess.Popen(["xdg-open", target])
|
||||
|
||||
|
||||
def _is_port_available(port: int, host: str) -> bool:
|
||||
try:
|
||||
addr = (host, int(port))
|
||||
@@ -126,6 +158,54 @@ async def _exit_process_after(delay_s: float) -> None:
|
||||
os._exit(0) # noqa: S404
|
||||
|
||||
|
||||
@router.get("/api/admin/log-file", summary="获取当前后端日志文件路径")
|
||||
async def get_backend_log_file() -> dict:
|
||||
log_file = _get_current_log_file_path()
|
||||
return {"path": str(log_file), "exists": log_file.exists()}
|
||||
|
||||
|
||||
@router.post("/api/admin/log-file/open", summary="打开当前后端日志文件(仅允许本机访问)")
|
||||
async def open_backend_log_file(request: Request) -> dict:
|
||||
if not _is_loopback_client(request):
|
||||
raise HTTPException(status_code=403, detail="仅允许本机访问该接口")
|
||||
|
||||
log_file = _get_current_log_file_path()
|
||||
try:
|
||||
_open_path_with_default_app(log_file)
|
||||
except Exception as e:
|
||||
logger.error("open_backend_log_file failed path=%s err=%s", log_file, e)
|
||||
raise HTTPException(status_code=500, detail=f"打开日志文件失败:{e}")
|
||||
return {"success": True, "path": str(log_file)}
|
||||
|
||||
|
||||
@router.post("/api/admin/log-frontend-server-error", summary="记录前端感知到的服务器错误")
|
||||
async def log_frontend_server_error(payload: dict) -> dict:
|
||||
data = payload if isinstance(payload, dict) else {}
|
||||
try:
|
||||
status = int(data.get("status"))
|
||||
except Exception:
|
||||
status = 0
|
||||
|
||||
method = str(data.get("method") or "").strip().upper() or "GET"
|
||||
request_url = str(data.get("request_url") or "").strip()
|
||||
message = str(data.get("message") or "").strip()
|
||||
backend_detail = str(data.get("backend_detail") or "").strip()
|
||||
source = str(data.get("source") or "").strip()
|
||||
page_url = str(data.get("page_url") or "").strip()
|
||||
|
||||
logger.error(
|
||||
"[frontend-server-error] status=%s method=%s request_url=%s message=%s backend_detail=%s source=%s page_url=%s",
|
||||
status,
|
||||
method,
|
||||
request_url,
|
||||
message,
|
||||
backend_detail,
|
||||
source,
|
||||
page_url,
|
||||
)
|
||||
return {"success": True, "path": str(_get_current_log_file_path())}
|
||||
|
||||
|
||||
@router.get("/api/admin/port", summary="获取后端端口(用于前端设置页)")
|
||||
async def get_backend_port() -> dict:
|
||||
port, source = read_effective_backend_port(default=DEFAULT_BACKEND_PORT)
|
||||
|
||||
@@ -3,6 +3,7 @@ import re
|
||||
import sqlite3
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
@@ -67,6 +68,8 @@ from ..chat_helpers import (
|
||||
)
|
||||
from ..media_helpers import _resolve_account_db_storage_dir, _try_find_decrypted_resource
|
||||
from .. import chat_edit_store
|
||||
from ..app_paths import get_output_dir
|
||||
from ..key_store import remove_account_keys_from_store
|
||||
from ..path_fix import PathFixRoute
|
||||
from ..session_last_message import (
|
||||
build_session_last_message_table,
|
||||
@@ -3496,6 +3499,85 @@ async def list_chat_accounts():
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/chat/account_info", summary="获取当前账号信息")
|
||||
def get_chat_account_info(account: Optional[str] = None):
|
||||
account_dir = _resolve_account_dir(account)
|
||||
db_files = sorted([p.name for p in account_dir.glob("*.db") if p.is_file()])
|
||||
|
||||
session_db = account_dir / "session.db"
|
||||
session_updated_at = 0
|
||||
try:
|
||||
session_updated_at = int(session_db.stat().st_mtime)
|
||||
except Exception:
|
||||
session_updated_at = 0
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
"path": str(account_dir),
|
||||
"database_count": len(db_files),
|
||||
"databases": db_files,
|
||||
"session_updated_at": session_updated_at,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api/chat/account", summary="删除当前账号在本项目中的数据")
|
||||
def delete_chat_account(account: str):
|
||||
account_name = str(account or "").strip()
|
||||
if not account_name:
|
||||
raise HTTPException(status_code=400, detail="Missing account.")
|
||||
|
||||
account_dir = _resolve_account_dir(account_name)
|
||||
|
||||
# Best-effort: close realtime connections first, otherwise Windows may keep db files locked.
|
||||
try:
|
||||
WCDB_REALTIME.disconnect(account_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with _REALTIME_SYNC_MU:
|
||||
_REALTIME_SYNC_ALL_LOCKS.pop(account_name, None)
|
||||
stale_lock_keys = [k for k in _REALTIME_SYNC_LOCKS.keys() if k and k[0] == account_name]
|
||||
for k in stale_lock_keys:
|
||||
_REALTIME_SYNC_LOCKS.pop(k, None)
|
||||
|
||||
removed_edit_count = 0
|
||||
try:
|
||||
removed_edit_count = int(chat_edit_store.delete_account_edits(account_name) or 0)
|
||||
except Exception:
|
||||
removed_edit_count = 0
|
||||
|
||||
removed_key_cache = False
|
||||
try:
|
||||
removed_key_cache = bool(remove_account_keys_from_store(account_name))
|
||||
except Exception:
|
||||
removed_key_cache = False
|
||||
|
||||
output_dir = get_output_dir()
|
||||
exports_dir = output_dir / "exports" / account_name
|
||||
if exports_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(exports_dir)
|
||||
except Exception:
|
||||
# Ignore export cleanup failure; account dir removal is the core operation.
|
||||
pass
|
||||
|
||||
try:
|
||||
shutil.rmtree(account_dir)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"删除账号数据失败:{e}")
|
||||
|
||||
accounts = _list_decrypted_accounts()
|
||||
return {
|
||||
"status": "success",
|
||||
"deleted_account": account_name,
|
||||
"accounts": accounts,
|
||||
"default_account": accounts[0] if accounts else None,
|
||||
"removed_edit_count": removed_edit_count,
|
||||
"removed_key_cache": removed_key_cache,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/chat/sessions", summary="获取会话列表(聊天左侧列表)")
|
||||
def list_chat_sessions(
|
||||
request: Request,
|
||||
|
||||
@@ -787,10 +787,14 @@ class WCDBRealtimeConnection:
|
||||
|
||||
|
||||
class WCDBRealtimeManager:
|
||||
_FAILED_TTL = 60.0 # seconds before retrying a failed connection
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._mu = threading.Lock()
|
||||
self._conns: dict[str, WCDBRealtimeConnection] = {}
|
||||
self._connecting: dict[str, threading.Event] = {}
|
||||
# Negative cache: accounts that failed to connect recently (avoids repeated timeouts).
|
||||
self._failed: dict[str, float] = {} # account -> monotonic timestamp of failure
|
||||
|
||||
def get_status(self, account_dir: Path) -> dict[str, Any]:
|
||||
account = str(account_dir.name)
|
||||
@@ -830,9 +834,19 @@ class WCDBRealtimeManager:
|
||||
conn = self._conns.get(str(account))
|
||||
return bool(conn and conn.handle > 0)
|
||||
|
||||
def ensure_connected(self, account_dir: Path, *, key_hex: Optional[str] = None) -> WCDBRealtimeConnection:
|
||||
def ensure_connected(
|
||||
self, account_dir: Path, *, key_hex: Optional[str] = None, timeout: float = 5.0
|
||||
) -> WCDBRealtimeConnection:
|
||||
account = str(account_dir.name)
|
||||
|
||||
# Fast-reject if this account failed recently to avoid repeated timeouts.
|
||||
with self._mu:
|
||||
failed_at = self._failed.get(account)
|
||||
if failed_at is not None and (time.monotonic() - failed_at) < self._FAILED_TTL:
|
||||
raise WCDBRealtimeError("WCDB connection recently failed; retry after 60s.")
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
|
||||
while True:
|
||||
with self._mu:
|
||||
existing = self._conns.get(account)
|
||||
@@ -846,22 +860,59 @@ class WCDBRealtimeManager:
|
||||
break
|
||||
|
||||
# Another thread is connecting; wait a bit and retry.
|
||||
waiter.wait(timeout=10.0)
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise WCDBRealtimeError("Timed out waiting for WCDB connection.")
|
||||
waiter.wait(timeout=min(remaining, 10.0))
|
||||
if time.monotonic() >= deadline:
|
||||
raise WCDBRealtimeError("Timed out waiting for WCDB connection.")
|
||||
|
||||
key = str(key_hex or "").strip()
|
||||
if not key:
|
||||
key_item = get_account_keys_from_store(account)
|
||||
key = str((key_item or {}).get("db_key") or "").strip()
|
||||
if len(key) != 64:
|
||||
raise WCDBRealtimeError("Missing db key for this account (call /api/keys or decrypt first).")
|
||||
|
||||
try:
|
||||
if len(key) != 64:
|
||||
with self._mu:
|
||||
self._failed[account] = time.monotonic()
|
||||
raise WCDBRealtimeError("Missing db key for this account (call /api/keys or decrypt first).")
|
||||
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
|
||||
if db_storage_dir is None:
|
||||
raise WCDBRealtimeError("Cannot resolve db_storage directory for this account.")
|
||||
|
||||
session_db_path = _resolve_session_db_path(db_storage_dir)
|
||||
handle = open_account(session_db_path, key)
|
||||
|
||||
# Run open_account in a daemon thread with a timeout to avoid
|
||||
# blocking indefinitely when the native library hangs (locked DB).
|
||||
_handle_box: list[int] = []
|
||||
_open_err: list[Exception] = []
|
||||
|
||||
def _do_open() -> None:
|
||||
try:
|
||||
_handle_box.append(open_account(session_db_path, key))
|
||||
except Exception as exc:
|
||||
_open_err.append(exc)
|
||||
|
||||
remaining = max(0.1, deadline - time.monotonic())
|
||||
open_thread = threading.Thread(target=_do_open, daemon=True)
|
||||
open_thread.start()
|
||||
open_thread.join(timeout=remaining)
|
||||
|
||||
if open_thread.is_alive():
|
||||
with self._mu:
|
||||
self._failed[account] = time.monotonic()
|
||||
raise WCDBRealtimeError(
|
||||
f"open_account timed out after {timeout:.0f}s for {session_db_path}"
|
||||
)
|
||||
if _open_err:
|
||||
with self._mu:
|
||||
self._failed[account] = time.monotonic()
|
||||
raise _open_err[0]
|
||||
if not _handle_box:
|
||||
raise WCDBRealtimeError("open_account returned no handle.")
|
||||
|
||||
handle = _handle_box[0]
|
||||
# Some WCDB APIs (e.g. exec_query on non-session DBs) may require this context.
|
||||
try:
|
||||
set_my_wxid(handle, account)
|
||||
@@ -893,6 +944,7 @@ class WCDBRealtimeManager:
|
||||
return
|
||||
with self._mu:
|
||||
conn = self._conns.pop(a, None)
|
||||
self._failed.pop(a, None) # clear negative cache on explicit disconnect
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
|
||||
def _close_logging_handlers() -> None:
|
||||
import logging
|
||||
|
||||
for logger_name in ("", "uvicorn", "uvicorn.access", "uvicorn.error", "fastapi"):
|
||||
lg = logging.getLogger(logger_name)
|
||||
for h in lg.handlers[:]:
|
||||
try:
|
||||
h.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
lg.removeHandler(h)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class TestAdminServerErrorLogging(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
self._td = TemporaryDirectory()
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = self._td.name
|
||||
|
||||
import wechat_decrypt_tool.app_paths as app_paths
|
||||
import wechat_decrypt_tool.logging_config as logging_config
|
||||
import wechat_decrypt_tool.request_logging as request_logging
|
||||
import wechat_decrypt_tool.routers.admin as admin_router
|
||||
|
||||
importlib.reload(app_paths)
|
||||
importlib.reload(logging_config)
|
||||
importlib.reload(request_logging)
|
||||
importlib.reload(admin_router)
|
||||
|
||||
self.logging_config = logging_config
|
||||
self.request_logging = request_logging
|
||||
self.admin_router = admin_router
|
||||
self.log_file = self.logging_config.setup_logging()
|
||||
|
||||
def tearDown(self):
|
||||
_close_logging_handlers()
|
||||
|
||||
if self._prev_data_dir is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = self._prev_data_dir
|
||||
|
||||
self._td.cleanup()
|
||||
|
||||
def _read_log(self) -> str:
|
||||
return self.log_file.read_text(encoding="utf-8")
|
||||
|
||||
def _make_admin_app(self) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(self.admin_router.router)
|
||||
return app
|
||||
|
||||
def _make_logged_app(self) -> FastAPI:
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
async def _log_server_errors(request, call_next):
|
||||
return await self.request_logging.log_server_errors_middleware(
|
||||
self.logging_config.get_logger("tests.server_error_logging"),
|
||||
request,
|
||||
call_next,
|
||||
)
|
||||
|
||||
@app.get("/boom-http")
|
||||
async def _boom_http():
|
||||
raise HTTPException(status_code=500, detail="planned http failure")
|
||||
|
||||
@app.get("/boom-exception")
|
||||
async def _boom_exception():
|
||||
raise RuntimeError("planned unhandled failure")
|
||||
|
||||
return app
|
||||
|
||||
def test_get_log_file_returns_current_backend_log_path(self):
|
||||
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52000))
|
||||
|
||||
resp = client.get("/api/admin/log-file")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
payload = resp.json()
|
||||
self.assertEqual(Path(payload["path"]), self.log_file)
|
||||
self.assertTrue(payload["exists"])
|
||||
self.assertTrue(self.log_file.is_relative_to(Path(self._td.name) / "output" / "logs"))
|
||||
|
||||
def test_open_log_file_requires_loopback(self):
|
||||
client = TestClient(self._make_admin_app(), client=("203.0.113.8", 52001))
|
||||
|
||||
resp = client.post("/api/admin/log-file/open")
|
||||
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
def test_open_log_file_uses_default_opener_for_loopback(self):
|
||||
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52002))
|
||||
|
||||
with patch.object(self.admin_router, "_open_path_with_default_app") as mocked_open:
|
||||
resp = client.post("/api/admin/log-file/open")
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
mocked_open.assert_called_once_with(self.log_file)
|
||||
self.assertEqual(resp.json()["path"], str(self.log_file))
|
||||
|
||||
def test_frontend_server_error_endpoint_writes_log(self):
|
||||
client = TestClient(self._make_admin_app(), client=("127.0.0.1", 52003))
|
||||
|
||||
resp = client.post(
|
||||
"/api/admin/log-frontend-server-error",
|
||||
json={
|
||||
"status": 503,
|
||||
"method": "GET",
|
||||
"request_url": "http://127.0.0.1:10392/api/chat/accounts",
|
||||
"message": "fetch failed",
|
||||
"backend_detail": "upstream timeout",
|
||||
"source": "useApi",
|
||||
"page_url": "http://127.0.0.1:10392/chat",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
text = self._read_log()
|
||||
self.assertIn("[frontend-server-error]", text)
|
||||
self.assertIn("status=503", text)
|
||||
self.assertIn("source=useApi", text)
|
||||
self.assertIn("upstream timeout", text)
|
||||
|
||||
def test_http_500_response_is_logged(self):
|
||||
client = TestClient(self._make_logged_app(), client=("127.0.0.1", 52004))
|
||||
|
||||
resp = client.get("/boom-http")
|
||||
|
||||
self.assertEqual(resp.status_code, 500)
|
||||
text = self._read_log()
|
||||
self.assertIn("[server-5xx]", text)
|
||||
self.assertIn("status=500", text)
|
||||
self.assertIn("path=/boom-http", text)
|
||||
self.assertIn("planned http failure", text)
|
||||
|
||||
def test_unhandled_exception_is_logged_with_traceback(self):
|
||||
client = TestClient(
|
||||
self._make_logged_app(),
|
||||
client=("127.0.0.1", 52005),
|
||||
raise_server_exceptions=False,
|
||||
)
|
||||
|
||||
resp = client.get("/boom-exception")
|
||||
|
||||
self.assertEqual(resp.status_code, 500)
|
||||
text = self._read_log()
|
||||
self.assertIn("[server-exception]", text)
|
||||
self.assertIn("path=/boom-exception", text)
|
||||
self.assertIn("planned unhandled failure", text)
|
||||
self.assertIn("Traceback", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user