Compare commits

...

7 Commits

  • fix(chat-build): 避免预渲染阶段请求本地接口
    - 移除聊天页 setup 阶段的服务端会话预取\n- 改为客户端挂载后再加载账号和会话数据\n- 修复 nuxt generate 在发布机构建时访问本地聊天接口导致的失败
  • refactor(chat-ui): 拆分聊天页组件并抽离组合式逻辑
    - 将聊天页从超大单文件拆分为会话列表、会话面板、消息列表、消息项和覆盖层组件\n- 抽离会话、消息、搜索、导出、编辑、历史窗口等组合式逻辑\n- 下沉消息格式化、消息归一化、聊天记录解析与文件图标等工具模块\n- 新增 chat.css 并在 Nuxt 中注册,统一聊天页样式
  • fix(dev-runtime): 统一开发端口并优化页面初始化
    - 新增 dev 启动脚本,自动分配前后端端口并等待 Nuxt 就绪后再启动 Electron
    - 开发模式忽略持久化 API 覆盖,统一 Nuxt 与桌面端端口配置
    - API 健康检查改为挂载后执行,聊天页预取改为 lazy,并隐藏搜索区账号切换
  • Merge pull request #34 from alfredt999/main
    修復1月更新後聊天預覽無法顯示
  • fix: resolve chat messages not loading due to Nuxt auto-import bug and WCDB timeout
    - Move frontend/utils/ to frontend/lib/ to avoid Nuxt unimport scanner
      incorrectly extracting function parameter names (value, fallback) as
      module-level exports, which injected phantom imports that broke all
      client-side JavaScript execution
    - Update all import paths across 13 files from ~/utils/ to ~/lib/
    - Add timeout (5s) and negative cache (60s TTL) to WCDB realtime
      ensure_connected() to prevent open_account() from hanging indefinitely
      when the WeChat database is locked
    - Reorder selectContact() to fire loadMessages() before navigateTo() so
      the message fetch starts before route navigation triggers Suspense
    - Add watch: false to SSR useAsyncData calls to prevent unnecessary
      re-fetching on client-side route changes
46 changed files with 12533 additions and 14049 deletions
+1 -1
View File
@@ -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",
+179
View File
@@ -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);
});
+5
View File
@@ -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
+5 -3
View File
@@ -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>
+33
View File
@@ -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>
+285
View File
@@ -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>
+316
View File
@@ -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>
+148
View File
@@ -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>
+48
View File
@@ -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: () => ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] },
+545
View File
@@ -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
}
}
+460
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { reportServerError } from '~/utils/server-error-logging'
import { reportServerError } from '~/lib/server-error-logging'
// API请求组合式函数
export const useApi = () => {
+35 -4
View File
@@ -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 = ''
}
+474
View File
@@ -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(/&#x20;/g, ' ')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '')
.replace(/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[\da-fA-F]+;)/g, '&amp;')
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 }
+50
View File
@@ -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 ''
}
}
+211
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
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)
}
+265
View File
@@ -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
View File
@@ -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: {
+2754 -4856
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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: '朋友圈 - 微信数据分析助手' })
+13 -8
View File
@@ -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)
}
})
})
+6 -2
View File
@@ -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 -1
View File
@@ -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)
+57 -5
View File
@@ -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: