mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
7 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);
|
||||
});
|
||||
@@ -83,6 +83,11 @@ function getBackendAccessHost() {
|
||||
}
|
||||
|
||||
function getBackendPort() {
|
||||
const envPort = parsePort(process.env.WECHAT_TOOL_PORT);
|
||||
if (envPort != null) return envPort;
|
||||
// In dev we intentionally ignore persisted packaged-app settings so the
|
||||
// launcher can keep Electron, Nuxt devProxy and the backend child aligned.
|
||||
if (!app.isPackaged) return DEFAULT_BACKEND_PORT;
|
||||
const settingsPort = parsePort(loadDesktopSettings()?.backendPort);
|
||||
return settingsPort ?? DEFAULT_BACKEND_PORT;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -289,9 +289,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
import { readApiBaseOverride, writeApiBaseOverride } from '~/utils/api-settings'
|
||||
import { reportServerErrorFromError } from '~/utils/server-error-logging'
|
||||
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/lib/desktop-settings'
|
||||
import { readApiBaseOverride, writeApiBaseOverride } from '~/lib/api-settings'
|
||||
import { invalidateApiBaseCache } from '~/composables/useApiBase'
|
||||
import { reportServerErrorFromError } from '~/lib/server-error-logging'
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
@@ -624,6 +625,7 @@ const applyDesktopBackendPort = async () => {
|
||||
const host = String(window.location?.hostname || '').trim() || '127.0.0.1'
|
||||
const nextOrigin = `${protocol}//${host}:${n}`
|
||||
writeApiBaseOverride(`${nextOrigin}/api`)
|
||||
invalidateApiBaseCache()
|
||||
|
||||
const waitForHealth = async (healthUrl, timeoutMs = 30_000) => {
|
||||
const startedAt = Date.now()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<div v-if="selectedContact" class="flex-1 flex flex-col min-h-0 relative">
|
||||
<div class="chat-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="text-base font-medium text-gray-900" :class="{ 'privacy-blur': privacyMode }">
|
||||
{{ selectedContact ? selectedContact.name : '' }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button class="header-btn-icon" @click="refreshSelectedMessages" :disabled="isLoadingMessages" title="刷新消息">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-btn-icon" @click="openExportModal" :disabled="isExportCreating" title="导出聊天记录">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-btn-icon" :class="{ 'header-btn-icon-active': reverseMessageSides }" @click="toggleReverseMessageSides" :disabled="!selectedContact" :title="reverseMessageSides ? '取消反转消息位置' : '反转消息位置'">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 7h14" />
|
||||
<path d="M14 3l4 4-4 4" />
|
||||
<path d="M20 17H6" />
|
||||
<path d="M10 13l-4 4 4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-btn-icon" :class="{ 'header-btn-icon-active': messageSearchOpen }" @click="toggleMessageSearch" :title="messageSearchOpen ? '关闭搜索 (Esc)' : '搜索聊天记录 (Ctrl+F)'">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 16 16">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.33333 12.6667C10.2789 12.6667 12.6667 10.2789 12.6667 7.33333C12.6667 4.38781 10.2789 2 7.33333 2C4.38781 2 2 4.38781 2 7.33333C2 10.2789 4.38781 12.6667 7.33333 12.6667Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M14 14L11.1 11.1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="header-btn-icon" :class="{ 'header-btn-icon-active': timeSidebarOpen }" @click="toggleTimeSidebar" :disabled="!selectedContact || isLoadingMessages" title="按日期定位">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M8 7V3m8 4V3M3 11h18" />
|
||||
<rect x="4" y="5" width="16" height="16" rx="2" ry="2" stroke-width="1.8" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M7 14h2m3 0h2m3 0h2M7 18h2m3 0h2" />
|
||||
</svg>
|
||||
</button>
|
||||
<select
|
||||
v-model="messageTypeFilter"
|
||||
class="message-filter-select"
|
||||
:disabled="isLoadingMessages || searchContext.active"
|
||||
:title="searchContext.active ? '上下文模式下暂不可筛选' : '筛选消息类型'"
|
||||
>
|
||||
<option v-for="opt in messageTypeFilterOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="searchContext.active" class="px-6 py-2 border-b border-emerald-200 bg-emerald-50 flex items-center gap-3">
|
||||
<div class="text-sm text-emerald-900">
|
||||
{{ searchContextBannerText }}
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button type="button" class="text-xs px-3 py-1 rounded-md bg-white border border-emerald-200 hover:bg-emerald-100" @click="exitSearchContext">
|
||||
退出定位
|
||||
</button>
|
||||
<button type="button" class="text-xs px-3 py-1 rounded-md bg-white border border-gray-200 hover:bg-gray-50" @click="refreshSelectedMessages">
|
||||
返回最新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageList :state="state" />
|
||||
|
||||
<button
|
||||
v-if="showJumpToBottom"
|
||||
type="button"
|
||||
class="absolute bottom-6 right-6 z-20 w-10 h-10 rounded-full bg-white/90 border border-gray-200 shadow hover:bg-white flex items-center justify-center"
|
||||
title="回到最新"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="w-20 h-20 mx-auto mb-5 rounded-2xl bg-gradient-to-br from-[#03C160]/10 to-[#03C160]/5 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-[#03C160]/60" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-medium text-gray-700 mb-1.5">选择一个会话</h3>
|
||||
<p class="text-sm text-gray-400">
|
||||
从左侧列表选择联系人查看聊天记录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import MessageList from '~/components/chat/MessageList.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ConversationPane',
|
||||
components: { MessageList },
|
||||
props: {
|
||||
state: { type: Object, required: true }
|
||||
},
|
||||
setup(props) {
|
||||
return {
|
||||
...props.state
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<img
|
||||
v-if="iconUrl"
|
||||
:src="iconUrl"
|
||||
alt=""
|
||||
class="wechat-file-icon"
|
||||
/>
|
||||
<svg v-else-if="kind === 'ppt'" viewBox="0 0 24 24" fill="none" class="wechat-file-icon text-orange-500">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
<path d="M14 2v6h6" stroke="currentColor" stroke-width="1.5" />
|
||||
<text x="6" y="17" font-size="5" fill="currentColor" font-weight="bold">PPT</text>
|
||||
</svg>
|
||||
<svg v-else-if="kind === 'txt'" viewBox="0 0 24 24" fill="none" class="wechat-file-icon text-gray-500">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
<path d="M14 2v6h6" stroke="currentColor" stroke-width="1.5" />
|
||||
<text x="6" y="17" font-size="5" fill="currentColor" font-weight="bold">TXT</text>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" class="wechat-file-icon text-gray-400">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zm-1 2l5 5h-5V4zM6 20V4h6v6h6v10H6z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { getFileIconKind, getFileIconUrl } from '~/lib/chat/file-icons'
|
||||
|
||||
const props = defineProps({
|
||||
fileName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const kind = computed(() => getFileIconKind(props.fileName))
|
||||
const iconUrl = computed(() => getFileIconUrl(props.fileName))
|
||||
</script>
|
||||
@@ -0,0 +1,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,4 +1,4 @@
|
||||
import { reportServerError } from '~/utils/server-error-logging'
|
||||
import { reportServerError } from '~/lib/server-error-logging'
|
||||
|
||||
// API请求组合式函数
|
||||
export const useApi = () => {
|
||||
|
||||
@@ -1,14 +1,45 @@
|
||||
import { normalizeApiBase, readApiBaseOverride } from '~/utils/api-settings'
|
||||
import { normalizeApiBase, readApiBaseOverride } from '~/lib/api-settings'
|
||||
|
||||
// Client-side cache so that useApiBase() can be called safely outside
|
||||
// the Nuxt composable context (e.g. inside async callbacks / onMounted chains).
|
||||
let _clientCache = ''
|
||||
|
||||
const shouldIgnoreStoredOverride = () => {
|
||||
if (!process.client || !import.meta.dev) return false
|
||||
return typeof window !== 'undefined' && !!window.wechatDesktop?.__brand
|
||||
}
|
||||
|
||||
export const useApiBase = () => {
|
||||
const config = useRuntimeConfig()
|
||||
if (process.client && _clientCache) return _clientCache
|
||||
|
||||
// useRuntimeConfig() requires the Nuxt app context, which is only
|
||||
// guaranteed during synchronous setup. On the client we cache the
|
||||
// result so later (context-less) calls still work.
|
||||
let config
|
||||
try {
|
||||
config = useRuntimeConfig()
|
||||
} catch {
|
||||
// Context unavailable – fall back to cached value or default.
|
||||
return _clientCache || '/api'
|
||||
}
|
||||
|
||||
// Default to same-origin `/api` so Nuxt devProxy / backend-mounted UI both work.
|
||||
// Override priority:
|
||||
// 1) Local UI setting (web + desktop)
|
||||
// 2) NUXT_PUBLIC_API_BASE env/runtime config
|
||||
// 3) `/api`
|
||||
const override = process.client ? readApiBaseOverride() : ''
|
||||
const override = process.client && !shouldIgnoreStoredOverride() ? readApiBaseOverride() : ''
|
||||
const runtime = String(config?.public?.apiBase || '').trim()
|
||||
return normalizeApiBase(override || runtime || '/api')
|
||||
const result = normalizeApiBase(override || runtime || '/api')
|
||||
|
||||
if (process.client) _clientCache = result
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this when the user changes the API base override in settings
|
||||
* so the cached value is refreshed.
|
||||
*/
|
||||
export const invalidateApiBaseCache = () => {
|
||||
_clientCache = ''
|
||||
}
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
import { getChatHistoryPreviewLines } from '~/lib/chat/formatters'
|
||||
|
||||
export const isMaybeMd5 = (value) => /^[0-9a-f]{32}$/i.test(String(value || '').trim())
|
||||
|
||||
export const pickFirstMd5 = (...values) => {
|
||||
for (const value of values) {
|
||||
const text = String(value || '').trim()
|
||||
if (isMaybeMd5(text)) return text.toLowerCase()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const normalizeChatHistoryUrl = (value) => String(value || '').trim().replace(/\s+/g, '')
|
||||
|
||||
export const stripWeChatInvisible = (value) => {
|
||||
return String(value || '').replace(/[\u3164\u2800]/g, '').trim()
|
||||
}
|
||||
|
||||
export const parseChatHistoryRecord = (recordItemXml) => {
|
||||
if (!process.client) return { info: null, items: [] }
|
||||
const xml = String(recordItemXml || '').trim()
|
||||
if (!xml) return { info: null, items: [] }
|
||||
|
||||
const normalized = xml
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
|
||||
.replace(/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[\da-fA-F]+;)/g, '&')
|
||||
|
||||
let doc
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(normalized, 'text/xml')
|
||||
} catch {
|
||||
return { info: null, items: [] }
|
||||
}
|
||||
|
||||
const parserErrors = doc.getElementsByTagName('parsererror')
|
||||
if (parserErrors && parserErrors.length) return { info: null, items: [] }
|
||||
|
||||
const getText = (node, tag) => {
|
||||
try {
|
||||
if (!node) return ''
|
||||
const elements = Array.from(node.getElementsByTagName(tag) || [])
|
||||
const direct = elements.find((el) => el && el.parentNode === node)
|
||||
const target = direct || elements[0]
|
||||
return String(target?.textContent || '').trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const getDirectChildXml = (node, tag) => {
|
||||
try {
|
||||
if (!node) return ''
|
||||
const children = Array.from(node.children || [])
|
||||
const target = children.find((child) => String(child?.tagName || '').toLowerCase() === String(tag || '').toLowerCase())
|
||||
if (!target) return ''
|
||||
const raw = String(target.textContent || '').trim()
|
||||
if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw
|
||||
if (typeof XMLSerializer !== 'undefined') {
|
||||
return new XMLSerializer().serializeToString(target)
|
||||
}
|
||||
} catch {}
|
||||
return ''
|
||||
}
|
||||
|
||||
const getAnyXml = (node, tag) => {
|
||||
try {
|
||||
if (!node) return ''
|
||||
const elements = Array.from(node.getElementsByTagName(tag) || [])
|
||||
const direct = elements.find((el) => el && el.parentNode === node)
|
||||
const target = direct || elements[0]
|
||||
if (!target) return ''
|
||||
const raw = String(target.textContent || '').trim()
|
||||
if (raw && raw.startsWith('<') && raw.endsWith('>')) return raw
|
||||
if (typeof XMLSerializer !== 'undefined') return new XMLSerializer().serializeToString(target)
|
||||
} catch {}
|
||||
return ''
|
||||
}
|
||||
|
||||
const sameTag = (element, tag) => String(element?.tagName || '').toLowerCase() === String(tag || '').toLowerCase()
|
||||
|
||||
const closestAncestorByTag = (node, tag) => {
|
||||
const lower = String(tag || '').toLowerCase()
|
||||
let current = node
|
||||
while (current) {
|
||||
if (current.nodeType === 1 && String(current.tagName || '').toLowerCase() === lower) return current
|
||||
current = current.parentNode
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const root = doc?.documentElement
|
||||
const isChatRoom = String(getText(root, 'isChatRoom') || '').trim() === '1'
|
||||
const title = getText(root, 'title')
|
||||
const desc = getText(root, 'desc') || getText(root, 'info')
|
||||
|
||||
const datalist = (() => {
|
||||
try {
|
||||
const all = Array.from(doc.getElementsByTagName('datalist') || [])
|
||||
const top = root ? all.find((el) => closestAncestorByTag(el, 'recorditem') === root) : null
|
||||
return top || all[0] || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
const datalistCount = (() => {
|
||||
try {
|
||||
if (!datalist) return 0
|
||||
const value = String(datalist.getAttribute('count') || '').trim()
|
||||
return Math.max(0, parseInt(value, 10) || 0)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})()
|
||||
|
||||
const itemNodes = (() => {
|
||||
if (datalist) return Array.from(datalist.children || []).filter((el) => sameTag(el, 'dataitem'))
|
||||
return Array.from(root?.children || []).filter((el) => sameTag(el, 'dataitem'))
|
||||
})()
|
||||
|
||||
const parsed = itemNodes.map((node, idx) => {
|
||||
const datatype = String(node.getAttribute('datatype') || getText(node, 'datatype') || '').trim()
|
||||
const dataid = String(node.getAttribute('dataid') || getText(node, 'dataid') || '').trim() || String(idx)
|
||||
|
||||
const sourcename = getText(node, 'sourcename')
|
||||
const sourcetime = getText(node, 'sourcetime')
|
||||
const sourceheadurl = normalizeChatHistoryUrl(getText(node, 'sourceheadurl'))
|
||||
const datatitle = getText(node, 'datatitle')
|
||||
const datadesc = getText(node, 'datadesc')
|
||||
const link = normalizeChatHistoryUrl(getText(node, 'link') || getText(node, 'dataurl') || getText(node, 'url'))
|
||||
const datafmt = getText(node, 'datafmt')
|
||||
const duration = getText(node, 'duration')
|
||||
|
||||
const fullmd5 = getText(node, 'fullmd5')
|
||||
const thumbfullmd5 = getText(node, 'thumbfullmd5')
|
||||
const md5 = getText(node, 'md5') || getText(node, 'emoticonmd5') || getText(node, 'emojiMd5')
|
||||
const fromnewmsgid = getText(node, 'fromnewmsgid')
|
||||
const srcMsgLocalid = getText(node, 'srcMsgLocalid') || getText(node, 'srcMsgLocalId')
|
||||
const srcMsgCreateTime = getText(node, 'srcMsgCreateTime')
|
||||
const cdnurlstring = normalizeChatHistoryUrl(getText(node, 'cdnurlstring'))
|
||||
const encrypturlstring = normalizeChatHistoryUrl(getText(node, 'encrypturlstring'))
|
||||
const externurl = normalizeChatHistoryUrl(getText(node, 'externurl'))
|
||||
const aeskey = getText(node, 'aeskey')
|
||||
const nestedRecordItem = getAnyXml(node, 'recorditem') || getDirectChildXml(node, 'recorditem') || getText(node, 'recorditem')
|
||||
|
||||
let content = datatitle || datadesc
|
||||
if (!content) {
|
||||
if (datatype === '4') content = '[视频]'
|
||||
else if (datatype === '2' || datatype === '3') content = '[图片]'
|
||||
else if (datatype === '47' || datatype === '37') content = '[表情]'
|
||||
else if (datatype) content = `[消息 ${datatype}]`
|
||||
else content = '[消息]'
|
||||
}
|
||||
|
||||
const fmt = String(datafmt || '').trim().toLowerCase().replace(/^\./, '')
|
||||
const imageFormats = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif'])
|
||||
|
||||
let renderType = 'text'
|
||||
if (datatype === '17') {
|
||||
renderType = 'chatHistory'
|
||||
} else if (datatype === '5' || link) {
|
||||
renderType = 'link'
|
||||
} else if (datatype === '4' || String(duration || '').trim() || fmt === 'mp4') {
|
||||
renderType = 'video'
|
||||
} else if (datatype === '47' || datatype === '37') {
|
||||
renderType = 'emoji'
|
||||
} else if (
|
||||
datatype === '2'
|
||||
|| datatype === '3'
|
||||
|| imageFormats.has(fmt)
|
||||
|| (datatype !== '1' && isMaybeMd5(fullmd5))
|
||||
) {
|
||||
renderType = 'image'
|
||||
} else if (isMaybeMd5(md5) && /表情/.test(String(content || ''))) {
|
||||
renderType = 'emoji'
|
||||
}
|
||||
|
||||
let outTitle = ''
|
||||
let outUrl = ''
|
||||
let recordItem = ''
|
||||
if (renderType === 'chatHistory') {
|
||||
outTitle = datatitle || content || '聊天记录'
|
||||
content = datadesc || ''
|
||||
recordItem = nestedRecordItem
|
||||
} else if (renderType === 'link') {
|
||||
outTitle = datatitle || content || ''
|
||||
outUrl = link || externurl || ''
|
||||
const cleanDesc = stripWeChatInvisible(datadesc)
|
||||
const cleanTitle = stripWeChatInvisible(outTitle)
|
||||
if (!cleanDesc || (cleanTitle && cleanDesc === cleanTitle)) {
|
||||
content = ''
|
||||
} else {
|
||||
content = String(datadesc || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: dataid,
|
||||
datatype,
|
||||
sourcename,
|
||||
sourcetime,
|
||||
sourceheadurl,
|
||||
datafmt,
|
||||
duration,
|
||||
fullmd5,
|
||||
thumbfullmd5,
|
||||
md5,
|
||||
fromnewmsgid,
|
||||
srcMsgLocalid,
|
||||
srcMsgCreateTime,
|
||||
cdnurlstring,
|
||||
encrypturlstring,
|
||||
externurl,
|
||||
aeskey,
|
||||
renderType,
|
||||
title: outTitle,
|
||||
recordItem,
|
||||
url: outUrl,
|
||||
content
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
info: { isChatRoom, title, desc, count: datalistCount },
|
||||
items: parsed
|
||||
}
|
||||
}
|
||||
|
||||
export const formatChatHistoryVideoDuration = (value) => {
|
||||
const total = Math.max(0, parseInt(String(value || '').trim(), 10) || 0)
|
||||
const minutes = Math.floor(total / 60)
|
||||
const seconds = total % 60
|
||||
if (minutes <= 0) return `0:${String(seconds).padStart(2, '0')}`
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export const createChatHistoryRecordNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => {
|
||||
return (record) => {
|
||||
const account = encodeURIComponent(String(getSelectedAccount?.() || '').trim())
|
||||
const username = encodeURIComponent(String(getSelectedContact?.()?.username || '').trim())
|
||||
const output = { ...(record || {}) }
|
||||
|
||||
output.senderDisplayName = String(output.sourcename || '').trim()
|
||||
output.senderAvatar = normalizeChatHistoryUrl(output.sourceheadurl)
|
||||
output.fullTime = String(output.sourcetime || '').trim()
|
||||
|
||||
if (output.renderType === 'link') {
|
||||
const linkUrl = String(output.url || output.externurl || '').trim()
|
||||
output.url = linkUrl
|
||||
output.from = String(output.from || '').trim()
|
||||
const previewCandidates = []
|
||||
const fileId = (() => {
|
||||
const localId = parseInt(String(output.srcMsgLocalid || '').trim(), 10) || 0
|
||||
const createTime = parseInt(String(output.srcMsgCreateTime || '').trim(), 10) || 0
|
||||
if (localId > 0 && createTime > 0) return `${localId}_${createTime}`
|
||||
return ''
|
||||
})()
|
||||
if (fileId) {
|
||||
previewCandidates.push(
|
||||
`${apiBase}/chat/media/image?account=${account}&file_id=${encodeURIComponent(fileId)}&username=${username}`
|
||||
)
|
||||
}
|
||||
|
||||
output.previewMd5 = pickFirstMd5(output.fullmd5, output.thumbfullmd5, output.md5)
|
||||
const srcServerId = String(output.fromnewmsgid || '').trim()
|
||||
if (output.previewMd5) {
|
||||
const previewParts = [
|
||||
`account=${account}`,
|
||||
`md5=${encodeURIComponent(output.previewMd5)}`,
|
||||
srcServerId ? `server_id=${encodeURIComponent(srcServerId)}` : '',
|
||||
`username=${username}`
|
||||
].filter(Boolean)
|
||||
previewCandidates.push(`${apiBase}/chat/media/image?${previewParts.join('&')}`)
|
||||
}
|
||||
|
||||
output._linkPreviewCandidates = previewCandidates
|
||||
output._linkPreviewCandidateIndex = 0
|
||||
output._linkPreviewError = false
|
||||
output.preview = previewCandidates[0] || ''
|
||||
|
||||
const fromUsername = String(output.fromUsername || '').trim()
|
||||
output.fromUsername = fromUsername
|
||||
output.fromAvatar = fromUsername
|
||||
? `${apiBase}/chat/avatar?account=${account}&username=${encodeURIComponent(fromUsername)}`
|
||||
: (linkUrl ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(linkUrl)}` : '')
|
||||
output._fromAvatarLast = output.fromAvatar
|
||||
output._fromAvatarImgOk = false
|
||||
output._fromAvatarImgError = false
|
||||
} else if (output.renderType === 'video') {
|
||||
output.videoMd5 = pickFirstMd5(output.fullmd5, output.md5)
|
||||
output.videoThumbMd5 = pickFirstMd5(output.thumbfullmd5)
|
||||
output.videoDuration = String(output.duration || '').trim()
|
||||
const thumbCandidates = []
|
||||
if (output.videoMd5) {
|
||||
thumbCandidates.push(`${apiBase}/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(output.videoMd5)}&username=${username}`)
|
||||
}
|
||||
if (output.videoThumbMd5 && output.videoThumbMd5 !== output.videoMd5) {
|
||||
thumbCandidates.push(`${apiBase}/chat/media/video_thumb?account=${account}&md5=${encodeURIComponent(output.videoThumbMd5)}&username=${username}`)
|
||||
}
|
||||
output._videoThumbCandidates = thumbCandidates
|
||||
output._videoThumbCandidateIndex = 0
|
||||
output._videoThumbError = false
|
||||
output.videoThumbUrl = thumbCandidates[0] || ''
|
||||
output.videoUrl = output.videoMd5
|
||||
? `${apiBase}/chat/media/video?account=${account}&md5=${encodeURIComponent(output.videoMd5)}&username=${username}`
|
||||
: ''
|
||||
if (!output.content || /^\[.+\]$/.test(String(output.content || '').trim())) output.content = '[视频]'
|
||||
} else if (output.renderType === 'emoji') {
|
||||
output.emojiMd5 = pickFirstMd5(output.md5, output.fullmd5, output.thumbfullmd5)
|
||||
const remoteEmojiUrl = String(output.cdnurlstring || output.externurl || output.encrypturlstring || '').trim()
|
||||
const remoteAesKey = String(output.aeskey || '').trim()
|
||||
output.emojiRemoteUrl = remoteEmojiUrl
|
||||
output.emojiUrl = output.emojiMd5
|
||||
? `${apiBase}/chat/media/emoji?account=${account}&md5=${encodeURIComponent(output.emojiMd5)}&username=${username}${remoteEmojiUrl ? `&emoji_url=${encodeURIComponent(remoteEmojiUrl)}` : ''}${remoteAesKey ? `&aes_key=${encodeURIComponent(remoteAesKey)}` : ''}`
|
||||
: ''
|
||||
if (!output.content || /^\[.+\]$/.test(String(output.content || '').trim())) output.content = '[表情]'
|
||||
} else if (output.renderType === 'image') {
|
||||
output.imageMd5 = pickFirstMd5(output.fullmd5, output.thumbfullmd5, output.md5)
|
||||
const srcServerId = String(output.fromnewmsgid || '').trim()
|
||||
const imageParts = [
|
||||
`account=${account}`,
|
||||
output.imageMd5 ? `md5=${encodeURIComponent(output.imageMd5)}` : '',
|
||||
srcServerId ? `server_id=${encodeURIComponent(srcServerId)}` : '',
|
||||
`username=${username}`
|
||||
].filter(Boolean)
|
||||
output.imageUrl = imageParts.length ? `${apiBase}/chat/media/image?${imageParts.join('&')}` : ''
|
||||
if (!output.content || /^\[.+\]$/.test(String(output.content || '').trim())) output.content = '[图片]'
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
export const enhanceChatHistoryRecords = (records) => {
|
||||
const list = Array.isArray(records) ? records : []
|
||||
const videoByThumbMd5 = new Map()
|
||||
const videoByMd5 = new Map()
|
||||
const imageByMd5 = new Map()
|
||||
const emojiByMd5 = new Map()
|
||||
|
||||
for (const record of list) {
|
||||
if (!record) continue
|
||||
if (record.renderType === 'video' && record.videoThumbMd5) {
|
||||
videoByThumbMd5.set(String(record.videoThumbMd5).toLowerCase(), record)
|
||||
}
|
||||
if (record.renderType === 'video' && record.videoMd5) {
|
||||
videoByMd5.set(String(record.videoMd5).toLowerCase(), record)
|
||||
}
|
||||
if (record.renderType === 'image') {
|
||||
const keys = [
|
||||
pickFirstMd5(record.imageMd5),
|
||||
pickFirstMd5(record.fullmd5),
|
||||
pickFirstMd5(record.thumbfullmd5)
|
||||
].filter(Boolean)
|
||||
for (const key of keys) imageByMd5.set(key, record)
|
||||
}
|
||||
if (record.renderType === 'emoji') {
|
||||
const keys = [
|
||||
pickFirstMd5(record.emojiMd5),
|
||||
pickFirstMd5(record.md5),
|
||||
pickFirstMd5(record.fullmd5),
|
||||
pickFirstMd5(record.thumbfullmd5)
|
||||
].filter(Boolean)
|
||||
for (const key of keys) emojiByMd5.set(key, record)
|
||||
}
|
||||
}
|
||||
|
||||
for (const record of list) {
|
||||
if (!record || String(record.renderType || '') !== 'text') continue
|
||||
|
||||
const refKey = pickFirstMd5(record.thumbfullmd5) || pickFirstMd5(record.fullmd5)
|
||||
if (!refKey) continue
|
||||
|
||||
const video = videoByThumbMd5.get(refKey) || videoByMd5.get(refKey)
|
||||
if (video) {
|
||||
const quoteThumbCandidates = Array.isArray(video._videoThumbCandidates) ? video._videoThumbCandidates.slice() : []
|
||||
record._quoteThumbCandidates = quoteThumbCandidates
|
||||
record._quoteThumbCandidateIndex = 0
|
||||
record._quoteThumbError = false
|
||||
const quoteThumbUrl = quoteThumbCandidates[0] || video.videoThumbUrl || ''
|
||||
record.renderType = 'quote'
|
||||
record.quote = {
|
||||
kind: 'video',
|
||||
thumbUrl: quoteThumbUrl,
|
||||
url: video.videoUrl || '',
|
||||
duration: video.videoDuration || '',
|
||||
label: video.content || '[视频]',
|
||||
targetId: video.id || ''
|
||||
}
|
||||
record.quoteMedia = {
|
||||
videoMd5: video.videoMd5,
|
||||
videoThumbMd5: video.videoThumbMd5,
|
||||
videoUrl: video.videoUrl,
|
||||
videoThumbUrl: quoteThumbUrl
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const image = imageByMd5.get(refKey)
|
||||
if (image) {
|
||||
record.renderType = 'quote'
|
||||
record.quote = {
|
||||
kind: 'image',
|
||||
thumbUrl: image.imageUrl || '',
|
||||
url: image.imageUrl || '',
|
||||
label: image.content || '[图片]',
|
||||
targetId: image.id || ''
|
||||
}
|
||||
record.quoteMedia = {
|
||||
imageMd5: image.imageMd5,
|
||||
imageUrl: image.imageUrl
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const emoji = emojiByMd5.get(refKey)
|
||||
if (emoji) {
|
||||
record.renderType = 'quote'
|
||||
record.quote = {
|
||||
kind: 'emoji',
|
||||
thumbUrl: emoji.emojiUrl || '',
|
||||
url: emoji.emojiUrl || '',
|
||||
label: emoji.content || '[表情]',
|
||||
targetId: emoji.id || ''
|
||||
}
|
||||
record.quoteMedia = {
|
||||
emojiMd5: emoji.emojiMd5,
|
||||
emojiUrl: emoji.emojiUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
export const isChatHistoryRecordItemIncomplete = (recordItemXml) => {
|
||||
const recordItem = String(recordItemXml || '').trim()
|
||||
if (!recordItem) return true
|
||||
try {
|
||||
const parsed = parseChatHistoryRecord(recordItem)
|
||||
const got = Array.isArray(parsed?.items) ? parsed.items.length : 0
|
||||
const expected = Math.max(0, parseInt(String(parsed?.info?.count || '0'), 10) || 0)
|
||||
if (expected > 0 && got < expected) return true
|
||||
if (got <= 0) return true
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const buildChatHistoryWindowPayload = (payload, normalizeRecordItem) => {
|
||||
const title0 = String(payload?.title || '聊天记录')
|
||||
const content0 = String(payload?.content || '')
|
||||
const recordItem0 = String(payload?.recordItem || '').trim()
|
||||
const parsed = parseChatHistoryRecord(recordItem0)
|
||||
const info0 = parsed?.info || { isChatRoom: false, count: 0 }
|
||||
const items = Array.isArray(parsed?.items) ? parsed.items : []
|
||||
let records0 = items.length ? enhanceChatHistoryRecords(items.map(normalizeRecordItem)) : []
|
||||
if (!records0.length) {
|
||||
const lines = content0.trim().split(/\r?\n/).map((item) => item.trim()).filter(Boolean)
|
||||
records0 = lines.map((line, idx) => normalizeRecordItem({
|
||||
id: String(idx),
|
||||
datatype: '1',
|
||||
sourcename: '',
|
||||
sourcetime: '',
|
||||
content: line,
|
||||
renderType: 'text'
|
||||
}))
|
||||
}
|
||||
return { title0, content0, recordItem0, info0, records0 }
|
||||
}
|
||||
|
||||
export { getChatHistoryPreviewLines }
|
||||
@@ -0,0 +1,50 @@
|
||||
import zipIconUrl from '~/assets/images/wechat/zip.png'
|
||||
import pdfIconUrl from '~/assets/images/wechat/pdf.png'
|
||||
import wordIconUrl from '~/assets/images/wechat/word.png'
|
||||
import excelIconUrl from '~/assets/images/wechat/excel.png'
|
||||
|
||||
export const getFileIconKind = (fileName) => {
|
||||
if (!fileName) return 'default'
|
||||
const ext = String(fileName).split('.').pop()?.toLowerCase() || ''
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
return 'pdf'
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z':
|
||||
case 'tar':
|
||||
case 'gz':
|
||||
return 'zip'
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'doc'
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
case 'csv':
|
||||
return 'xls'
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return 'ppt'
|
||||
case 'txt':
|
||||
case 'md':
|
||||
case 'log':
|
||||
return 'txt'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
export const getFileIconUrl = (fileName) => {
|
||||
switch (getFileIconKind(fileName)) {
|
||||
case 'pdf':
|
||||
return pdfIconUrl
|
||||
case 'doc':
|
||||
return wordIconUrl
|
||||
case 'xls':
|
||||
return excelIconUrl
|
||||
case 'zip':
|
||||
return zipIconUrl
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
export const normalizeSessionPreview = (value) => {
|
||||
const text = String(value || '').trim()
|
||||
if (/^\[location\]/i.test(text)) return text.replace(/^\[location\]/i, '[位置]')
|
||||
if (/:\s*\[location\]$/i.test(text)) return text.replace(/\[location\]$/i, '[位置]')
|
||||
return text
|
||||
}
|
||||
|
||||
export const formatSmartTime = (ts) => {
|
||||
if (!ts) return ''
|
||||
try {
|
||||
const date = new Date(Number(ts) * 1000)
|
||||
const now = new Date()
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const mm = String(date.getMinutes()).padStart(2, '0')
|
||||
const timeStr = `${hh}:${mm}`
|
||||
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const targetStart = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const dayDiff = Math.floor((todayStart - targetStart) / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (dayDiff === 0) return timeStr
|
||||
if (dayDiff === 1) return `昨天 ${timeStr}`
|
||||
if (dayDiff >= 2 && dayDiff <= 6) {
|
||||
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||
return `${weekdays[date.getDay()]} ${timeStr}`
|
||||
}
|
||||
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${month}月${day}日 ${timeStr}`
|
||||
}
|
||||
|
||||
return `${date.getFullYear()}年${month}月${day}日 ${timeStr}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const formatTimeDivider = (ts) => formatSmartTime(ts)
|
||||
|
||||
export const formatMessageTime = (ts) => {
|
||||
if (!ts) return ''
|
||||
try {
|
||||
const date = new Date(Number(ts) * 1000)
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const mm = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${hh}:${mm}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const formatMessageFullTime = (ts) => {
|
||||
if (!ts) return ''
|
||||
try {
|
||||
const date = new Date(Number(ts) * 1000)
|
||||
const yyyy = String(date.getFullYear())
|
||||
const MM = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const mm = String(date.getMinutes()).padStart(2, '0')
|
||||
const ss = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${yyyy}-${MM}-${dd} ${hh}:${mm}:${ss}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export const formatFileSize = (size) => {
|
||||
if (!size) return ''
|
||||
const text = String(size).trim()
|
||||
const value = parseFloat(text)
|
||||
if (Number.isNaN(value)) return text
|
||||
if (value < 1024) return `${value} B`
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(2)} KB`
|
||||
return `${(value / 1024 / 1024).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
export const formatTransferAmount = (amount) => {
|
||||
const text = String(amount ?? '').trim()
|
||||
if (!text) return ''
|
||||
return text.replace(/[¥¥]/g, '').trim()
|
||||
}
|
||||
|
||||
export const getRedPacketText = (message) => {
|
||||
const text = String(message?.content ?? '').trim()
|
||||
if (!text || text === '[Red Packet]') return '恭喜发财,大吉大利'
|
||||
return text
|
||||
}
|
||||
|
||||
export const isTransferReturned = (message) => {
|
||||
const paySubType = String(message?.paySubType || '').trim()
|
||||
if (paySubType === '4' || paySubType === '9') return true
|
||||
const status = String(message?.transferStatus || '').trim()
|
||||
const content = String(message?.content || '').trim()
|
||||
const text = `${status} ${content}`.trim()
|
||||
if (!text) return false
|
||||
return text.includes('退回') || text.includes('退还')
|
||||
}
|
||||
|
||||
export const isTransferOverdue = (message) => {
|
||||
const paySubType = String(message?.paySubType || '').trim()
|
||||
if (paySubType === '10') return true
|
||||
const status = String(message?.transferStatus || '').trim()
|
||||
const content = String(message?.content || '').trim()
|
||||
const text = `${status} ${content}`.trim()
|
||||
if (!text) return false
|
||||
return text.includes('过期')
|
||||
}
|
||||
|
||||
export const getTransferTitle = (message) => {
|
||||
const paySubType = String(message?.paySubType || '').trim()
|
||||
if (message?.transferStatus) return message.transferStatus
|
||||
switch (paySubType) {
|
||||
case '1':
|
||||
return '转账'
|
||||
case '3':
|
||||
return message?.isSent ? '已被接收' : '已收款'
|
||||
case '8':
|
||||
return '发起转账'
|
||||
case '4':
|
||||
return '已退还'
|
||||
case '9':
|
||||
return '已被退还'
|
||||
case '10':
|
||||
return '已过期'
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (message?.content && message.content !== '转账' && message.content !== '[转账]') {
|
||||
return message.content
|
||||
}
|
||||
return '转账'
|
||||
}
|
||||
|
||||
export const formatCount = (count) => {
|
||||
const value = Number(count || 0)
|
||||
if (!Number.isFinite(value) || value <= 0) return ''
|
||||
try {
|
||||
return value.toLocaleString()
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
export const escapeHtml = (value) => {
|
||||
if (!value) return ''
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
export const highlightKeyword = (text, keyword) => {
|
||||
if (!text || !keyword) return escapeHtml(text || '')
|
||||
const escaped = escapeHtml(text)
|
||||
const kw = String(keyword || '').trim()
|
||||
if (!kw) return escaped
|
||||
try {
|
||||
const escapedKw = kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const regex = new RegExp(`(${escapedKw})`, 'gi')
|
||||
return escaped.replace(regex, '<mark class="search-highlight">$1</mark>')
|
||||
} catch {
|
||||
return escaped
|
||||
}
|
||||
}
|
||||
|
||||
export const getVoiceDurationInSeconds = (durationMs) => {
|
||||
const value = Number(durationMs || 0)
|
||||
if (!Number.isFinite(value) || value <= 0) return 0
|
||||
return Math.max(1, Math.round(value / 1000))
|
||||
}
|
||||
|
||||
export const getVoiceWidth = (durationMs) => {
|
||||
const seconds = getVoiceDurationInSeconds(durationMs)
|
||||
const clamped = Math.min(60, Math.max(1, seconds))
|
||||
return `${80 + clamped * 4}px`
|
||||
}
|
||||
|
||||
export const toUnixSeconds = (datetimeLocal) => {
|
||||
const value = String(datetimeLocal || '').trim()
|
||||
if (!value) return null
|
||||
const date = new Date(value)
|
||||
const ms = date.getTime()
|
||||
if (Number.isNaN(ms)) return null
|
||||
return Math.floor(ms / 1000)
|
||||
}
|
||||
|
||||
export const dateToUnixSeconds = (dateStr, endOfDay = false) => {
|
||||
const value = String(dateStr || '').trim()
|
||||
if (!value) return null
|
||||
const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})$/)
|
||||
if (!matched) return null
|
||||
const year = Number(matched[1])
|
||||
const month = Number(matched[2])
|
||||
const day = Number(matched[3])
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null
|
||||
const date = new Date(year, month - 1, day, endOfDay ? 23 : 0, endOfDay ? 59 : 0, endOfDay ? 59 : 0)
|
||||
const ms = date.getTime()
|
||||
if (Number.isNaN(ms)) return null
|
||||
return Math.floor(ms / 1000)
|
||||
}
|
||||
|
||||
export const getChatHistoryPreviewLines = (message) => {
|
||||
const raw = String(message?.content || '').trim()
|
||||
if (!raw) return []
|
||||
return raw.split(/\r?\n/).map((item) => item.trim()).filter(Boolean).slice(0, 4)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { formatMessageFullTime, formatMessageTime } from '~/lib/chat/formatters'
|
||||
|
||||
const normalizeMaybeUrl = (value) => (typeof value === 'string' ? value.trim() : '')
|
||||
|
||||
const isUsableMediaUrl = (value) => {
|
||||
const text = normalizeMaybeUrl(value)
|
||||
if (!text) return false
|
||||
return (
|
||||
/^https?:\/\//i.test(text)
|
||||
|| /^blob:/i.test(text)
|
||||
|| /^data:/i.test(text)
|
||||
|| /^\/api\/chat\/media\//i.test(text)
|
||||
)
|
||||
}
|
||||
|
||||
const buildAccountMediaUrl = (apiBase, path, parts) => {
|
||||
return `${apiBase}${path}?${parts.filter(Boolean).join('&')}`
|
||||
}
|
||||
|
||||
export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelectedContact }) => {
|
||||
return (msg) => {
|
||||
const account = String(getSelectedAccount?.() || '').trim()
|
||||
const contact = getSelectedContact?.() || null
|
||||
const username = String(contact?.username || '').trim()
|
||||
const isSent = !!msg.isSent
|
||||
const sender = isSent ? '我' : (msg.senderDisplayName || msg.senderUsername || contact?.name || '')
|
||||
const fallbackAvatar = (!isSent && !contact?.isGroup) ? (contact?.avatar || null) : null
|
||||
|
||||
const normalizedThumbUrl = (() => {
|
||||
const candidates = [msg.thumbUrl, msg.preview]
|
||||
for (const candidate of candidates) {
|
||||
if (isUsableMediaUrl(candidate)) return normalizeMaybeUrl(candidate)
|
||||
}
|
||||
return ''
|
||||
})()
|
||||
|
||||
const normalizedLinkPreviewUrl = (() => {
|
||||
const url = normalizedThumbUrl
|
||||
if (!url) return ''
|
||||
if (/^\/api\/chat\/media\//i.test(url) || /^blob:/i.test(url) || /^data:/i.test(url)) return url
|
||||
if (!/^https?:\/\//i.test(url)) return url
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
} catch {}
|
||||
return url
|
||||
})()
|
||||
|
||||
const fromUsername = String(msg.fromUsername || '').trim()
|
||||
const fromAvatar = fromUsername
|
||||
? `${apiBase}/chat/avatar?account=${encodeURIComponent(account)}&username=${encodeURIComponent(fromUsername)}`
|
||||
: (() => {
|
||||
const href = String(msg.url || '').trim()
|
||||
return href ? `${apiBase}/chat/media/favicon?url=${encodeURIComponent(href)}` : ''
|
||||
})()
|
||||
|
||||
const localEmojiUrl = msg.emojiMd5
|
||||
? `${apiBase}/chat/media/emoji?account=${encodeURIComponent(account)}&md5=${encodeURIComponent(msg.emojiMd5)}&username=${encodeURIComponent(username)}`
|
||||
: ''
|
||||
|
||||
const localImageUrl = (() => {
|
||||
if (!msg.imageMd5 && !msg.imageFileId) return ''
|
||||
return buildAccountMediaUrl(apiBase, '/chat/media/image', [
|
||||
`account=${encodeURIComponent(account)}`,
|
||||
msg.imageMd5 ? `md5=${encodeURIComponent(msg.imageMd5)}` : '',
|
||||
msg.imageFileId ? `file_id=${encodeURIComponent(msg.imageFileId)}` : '',
|
||||
`username=${encodeURIComponent(username)}`
|
||||
])
|
||||
})()
|
||||
|
||||
const normalizedImageUrl = (() => {
|
||||
const current = isUsableMediaUrl(msg.imageUrl) ? normalizeMaybeUrl(msg.imageUrl) : ''
|
||||
if (current && /\/api\/chat\/media\/image\b/i.test(current) && localImageUrl) {
|
||||
return localImageUrl
|
||||
}
|
||||
return current || localImageUrl || ''
|
||||
})()
|
||||
|
||||
const normalizedEmojiUrl = msg.emojiUrl || localEmojiUrl
|
||||
|
||||
const localVideoThumbUrl = (() => {
|
||||
if (!msg.videoThumbMd5 && !msg.videoThumbFileId) return ''
|
||||
return buildAccountMediaUrl(apiBase, '/chat/media/video_thumb', [
|
||||
`account=${encodeURIComponent(account)}`,
|
||||
msg.videoThumbMd5 ? `md5=${encodeURIComponent(msg.videoThumbMd5)}` : '',
|
||||
msg.videoThumbFileId ? `file_id=${encodeURIComponent(msg.videoThumbFileId)}` : '',
|
||||
`username=${encodeURIComponent(username)}`
|
||||
])
|
||||
})()
|
||||
|
||||
const localVideoUrl = (() => {
|
||||
if (!msg.videoMd5 && !msg.videoFileId) return ''
|
||||
return buildAccountMediaUrl(apiBase, '/chat/media/video', [
|
||||
`account=${encodeURIComponent(account)}`,
|
||||
msg.videoMd5 ? `md5=${encodeURIComponent(msg.videoMd5)}` : '',
|
||||
msg.videoFileId ? `file_id=${encodeURIComponent(msg.videoFileId)}` : '',
|
||||
`username=${encodeURIComponent(username)}`
|
||||
])
|
||||
})()
|
||||
|
||||
const normalizedVideoThumbUrl = (isUsableMediaUrl(msg.videoThumbUrl) ? normalizeMaybeUrl(msg.videoThumbUrl) : '') || localVideoThumbUrl
|
||||
const normalizedVideoUrl = (isUsableMediaUrl(msg.videoUrl) ? normalizeMaybeUrl(msg.videoUrl) : '') || localVideoUrl
|
||||
const serverIdStr = String(msg.serverIdStr || (msg.serverId != null ? String(msg.serverId) : '')).trim()
|
||||
const normalizedVoiceUrl = (() => {
|
||||
if (msg.voiceUrl) return msg.voiceUrl
|
||||
if (!serverIdStr) return ''
|
||||
if (String(msg.renderType || '') !== 'voice') return ''
|
||||
return `${apiBase}/chat/media/voice?account=${encodeURIComponent(account)}&server_id=${encodeURIComponent(serverIdStr)}`
|
||||
})()
|
||||
|
||||
const remoteFromServer = (
|
||||
typeof msg.emojiRemoteUrl === 'string'
|
||||
&& /^https?:\/\//i.test(msg.emojiRemoteUrl)
|
||||
&& !/\/api\/chat\/media\/emoji\b/i.test(msg.emojiRemoteUrl)
|
||||
&& !/\blocalhost\b/i.test(msg.emojiRemoteUrl)
|
||||
&& !/\b127\.0\.0\.1\b/i.test(msg.emojiRemoteUrl)
|
||||
) ? msg.emojiRemoteUrl : ''
|
||||
|
||||
const remoteFromEmojiUrl = (
|
||||
typeof msg.emojiUrl === 'string'
|
||||
&& /^https?:\/\//i.test(msg.emojiUrl)
|
||||
&& !/\/api\/chat\/media\/emoji\b/i.test(msg.emojiUrl)
|
||||
&& !/\blocalhost\b/i.test(msg.emojiUrl)
|
||||
&& !/\b127\.0\.0\.1\b/i.test(msg.emojiUrl)
|
||||
) ? msg.emojiUrl : ''
|
||||
|
||||
const emojiRemoteUrl = remoteFromServer || remoteFromEmojiUrl
|
||||
const emojiIsLocal = typeof normalizedEmojiUrl === 'string' && /\/api\/chat\/media\/emoji\b/i.test(normalizedEmojiUrl)
|
||||
const emojiDownloaded = !!emojiRemoteUrl && !!emojiIsLocal
|
||||
|
||||
const replyText = String(msg.content || '').trim()
|
||||
let quoteContent = String(msg.quoteContent || '')
|
||||
const trimmedQuoteContent = quoteContent.trim()
|
||||
if (replyText && trimmedQuoteContent) {
|
||||
if (trimmedQuoteContent === replyText) {
|
||||
quoteContent = ''
|
||||
} else {
|
||||
const lines = trimmedQuoteContent.split(/\r?\n/).map((item) => item.trim())
|
||||
if (lines.length && (lines[0] === replyText || lines[0] === replyText.split(/\r?\n/)[0]?.trim())) {
|
||||
quoteContent = trimmedQuoteContent.split(/\r?\n/).slice(1).join('\n').trim()
|
||||
} else if (trimmedQuoteContent.startsWith(replyText)) {
|
||||
quoteContent = trimmedQuoteContent.slice(replyText.length).trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const quoteServerIdStr = String(msg.quoteServerId || '').trim()
|
||||
const quoteTypeStr = String(msg.quoteType || '').trim()
|
||||
const quoteVoiceUrl = quoteServerIdStr
|
||||
? `${apiBase}/chat/media/voice?account=${encodeURIComponent(account)}&server_id=${encodeURIComponent(quoteServerIdStr)}`
|
||||
: ''
|
||||
|
||||
const quoteImageUrl = (() => {
|
||||
if (!quoteServerIdStr) return ''
|
||||
if (quoteTypeStr !== '3' && String(msg.quoteContent || '').trim() !== '[图片]') return ''
|
||||
return buildAccountMediaUrl(apiBase, '/chat/media/image', [
|
||||
`account=${encodeURIComponent(account)}`,
|
||||
`server_id=${encodeURIComponent(quoteServerIdStr)}`,
|
||||
username ? `username=${encodeURIComponent(username)}` : ''
|
||||
])
|
||||
})()
|
||||
|
||||
const quoteThumbUrl = (() => {
|
||||
const raw = isUsableMediaUrl(msg.quoteThumbUrl) ? normalizeMaybeUrl(msg.quoteThumbUrl) : ''
|
||||
if (!raw) return ''
|
||||
if (/^\/api\/chat\/media\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw
|
||||
if (!/^https?:\/\//i.test(raw)) return raw
|
||||
try {
|
||||
const host = new URL(raw).hostname.toLowerCase()
|
||||
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||
return `${apiBase}/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||
}
|
||||
} catch {}
|
||||
return raw
|
||||
})()
|
||||
|
||||
return {
|
||||
id: msg.id,
|
||||
serverId: msg.serverId || 0,
|
||||
serverIdStr,
|
||||
sender,
|
||||
senderUsername: msg.senderUsername || '',
|
||||
senderDisplayName: msg.senderDisplayName || '',
|
||||
content: msg.content || '',
|
||||
time: formatMessageTime(msg.createTime),
|
||||
fullTime: formatMessageFullTime(msg.createTime),
|
||||
createTime: Number(msg.createTime || 0),
|
||||
isSent,
|
||||
type: 'text',
|
||||
renderType: msg.renderType || 'text',
|
||||
voipType: msg.voipType || '',
|
||||
title: msg.title || '',
|
||||
url: msg.url || '',
|
||||
recordItem: msg.recordItem || '',
|
||||
imageMd5: msg.imageMd5 || '',
|
||||
imageFileId: msg.imageFileId || '',
|
||||
emojiMd5: msg.emojiMd5 || '',
|
||||
emojiUrl: normalizedEmojiUrl || '',
|
||||
emojiLocalUrl: localEmojiUrl || '',
|
||||
emojiRemoteUrl,
|
||||
_emojiDownloaded: !!emojiDownloaded,
|
||||
thumbUrl: msg.thumbUrl || '',
|
||||
imageUrl: normalizedImageUrl || '',
|
||||
videoMd5: msg.videoMd5 || '',
|
||||
videoThumbMd5: msg.videoThumbMd5 || '',
|
||||
videoFileId: msg.videoFileId || '',
|
||||
videoThumbFileId: msg.videoThumbFileId || '',
|
||||
videoThumbUrl: normalizedVideoThumbUrl || '',
|
||||
videoUrl: normalizedVideoUrl || '',
|
||||
quoteTitle: msg.quoteTitle || '',
|
||||
quoteContent,
|
||||
quoteUsername: msg.quoteUsername || '',
|
||||
quoteServerId: quoteServerIdStr,
|
||||
quoteType: quoteTypeStr,
|
||||
quoteVoiceLength: msg.quoteVoiceLength || '',
|
||||
quoteVoiceUrl,
|
||||
quoteImageUrl: quoteImageUrl || '',
|
||||
quoteThumbUrl: quoteThumbUrl || '',
|
||||
_quoteImageError: false,
|
||||
_quoteThumbError: false,
|
||||
amount: msg.amount || '',
|
||||
coverUrl: msg.coverUrl || '',
|
||||
fileSize: msg.fileSize || '',
|
||||
fileMd5: msg.fileMd5 || '',
|
||||
paySubType: msg.paySubType || '',
|
||||
transferStatus: msg.transferStatus || '',
|
||||
transferReceived: msg.paySubType === '3' || msg.transferStatus === '已收款' || msg.transferStatus === '已被接收',
|
||||
voiceUrl: normalizedVoiceUrl || '',
|
||||
voiceDuration: msg.voiceLength || msg.voiceDuration || '',
|
||||
locationLat: msg.locationLat ?? null,
|
||||
locationLng: msg.locationLng ?? null,
|
||||
locationPoiname: String(msg.locationPoiname || '').trim(),
|
||||
locationLabel: String(msg.locationLabel || '').trim(),
|
||||
preview: normalizedLinkPreviewUrl || '',
|
||||
linkType: String(msg.linkType || '').trim(),
|
||||
linkStyle: String(msg.linkStyle || '').trim(),
|
||||
linkCardVariant: String(msg.linkStyle || '').trim() === 'cover' ? 'cover' : 'default',
|
||||
from: String(msg.from || '').trim(),
|
||||
fromUsername,
|
||||
fromAvatar,
|
||||
isGroup: !!contact?.isGroup,
|
||||
avatar: msg.senderAvatar || msg.avatar || fallbackAvatar || null,
|
||||
avatarColor: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dedupeMessagesById = (list) => {
|
||||
const input = Array.isArray(list) ? list : []
|
||||
const seen = new Set()
|
||||
const output = []
|
||||
for (const item of input) {
|
||||
const id = String(item?.id || '')
|
||||
if (!id) {
|
||||
output.push(item)
|
||||
continue
|
||||
}
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
output.push(item)
|
||||
}
|
||||
return output
|
||||
}
|
||||
+15
-1
@@ -1,10 +1,18 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
const frontendHost = String(process.env.NUXT_HOST || '').trim()
|
||||
const frontendPort = Number.parseInt(String(process.env.NUXT_PORT || process.env.PORT || '3000').trim(), 10)
|
||||
const backendPort = String(process.env.WECHAT_TOOL_PORT || '10392').trim() || '10392'
|
||||
const devProxyTarget = `http://127.0.0.1:${backendPort}/api`
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: false },
|
||||
experimental: {
|
||||
// This app does not use Nuxt route rules on the client, so disabling
|
||||
// the app manifest avoids an unnecessary `/_nuxt/builds/meta/dev.json`
|
||||
// preload request and the related Chrome warning in dev mode.
|
||||
appManifest: false,
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
@@ -16,7 +24,8 @@ export default defineNuxtConfig({
|
||||
|
||||
// 配置前端开发服务器端口
|
||||
devServer: {
|
||||
port: 3000
|
||||
...(frontendHost ? { host: frontendHost } : {}),
|
||||
port: Number.isInteger(frontendPort) && frontendPort >= 1 && frontendPort <= 65535 ? frontendPort : 3000
|
||||
},
|
||||
|
||||
// 配置API代理,解决跨域问题
|
||||
@@ -31,6 +40,11 @@ export default defineNuxtConfig({
|
||||
}
|
||||
},
|
||||
|
||||
// 应用配置
|
||||
css: [
|
||||
'~/assets/css/chat.css'
|
||||
],
|
||||
|
||||
// 应用配置
|
||||
app: {
|
||||
head: {
|
||||
|
||||
Generated
+2754
-4856
File diff suppressed because it is too large
Load Diff
+314
-9158
File diff suppressed because it is too large
Load Diff
@@ -68,7 +68,7 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
|
||||
|
||||
onMounted(async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
|
||||
@@ -705,9 +705,9 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
import { reportServerErrorFromError } from '~/utils/server-error-logging'
|
||||
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
|
||||
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
|
||||
import { reportServerErrorFromError } from '~/lib/server-error-logging'
|
||||
|
||||
useHead({ title: '朋友圈 - 微信数据分析助手' })
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 客户端插件:检查API连接状态
|
||||
export default defineNuxtPlugin(async (nuxtApp) => {
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
const { healthCheck } = useApi()
|
||||
const appStore = useAppStore()
|
||||
let intervalId = 0
|
||||
|
||||
// 检查API连接
|
||||
const checkApiConnection = async () => {
|
||||
@@ -17,10 +18,14 @@ export default defineNuxtPlugin(async (nuxtApp) => {
|
||||
console.error('API连接失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始检查
|
||||
await checkApiConnection()
|
||||
|
||||
// 定期检查(每30秒)
|
||||
setInterval(checkApiConnection, 30000)
|
||||
})
|
||||
|
||||
nuxtApp.hook('app:mounted', () => {
|
||||
void checkApiConnection()
|
||||
|
||||
if (!intervalId) {
|
||||
intervalId = window.setInterval(() => {
|
||||
void checkApiConnection()
|
||||
}, 30000)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user