feat(chat-edit-ui): 新增聊天编辑入口与修改记录页面

- 聊天右键菜单新增修改消息、字段编辑、恢复原消息、修复发送者、反转微信气泡位置

- 新增 /edits 页面,支持会话维度浏览修改记录与原/现对比

- 新增 EditedMessagePreview 组件复用聊天气泡样式

- 侧边栏新增修改记录入口

- useApi 增加 chat edits 相关接口,nuxt 配置支持可配置 API base

- 增加会话级反转消息位置前端显示开关(本地存储)
This commit is contained in:
2977094657
2026-02-22 11:56:28 +08:00
Unverified
parent d5927156f7
commit 949990d125
6 changed files with 1542 additions and 7 deletions
@@ -0,0 +1,134 @@
<template>
<div v-if="!message" class="flex items-center justify-center py-4">
<span class="text-sm text-gray-400">无数据</span>
</div>
<!-- 完全复用聊天页消息结构外层 flex + 头像 + 气泡 -->
<div v-else class="flex items-center" :class="message.isSent ? 'justify-end' : 'justify-start'">
<div class="flex items-start" :class="message.isSent ? 'flex-row-reverse' : ''">
<!-- 头像与聊天页完全一致 -->
<div class="relative">
<div
class="w-[calc(42px/var(--dpr,1))] h-[calc(42px/var(--dpr,1))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0"
:class="message.isSent ? 'ml-3' : 'mr-3'"
>
<div v-if="resolvedAvatar" class="w-full h-full">
<img
:src="resolvedAvatar"
alt="avatar"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
/>
</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') }"
>
{{ avatarLetter }}
</div>
</div>
</div>
<!-- 消息内容气泡与聊天页完全一致 -->
<div
class="flex flex-col relative group"
:class="message.isSent ? 'items-end' : 'items-start'"
>
<!-- 群聊发送者名可选 -->
<div
v-if="senderName && !message.isSent"
class="text-[11px] text-gray-500 mb-1"
:class="message.isSent ? 'text-right' : 'text-left'"
>
{{ senderName }}
</div>
<!-- 时间悬浮 tooltip -->
<div
v-if="message.fullTime"
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>
<!-- 表情 -->
<div v-if="renderType === 'emoji' && message.emojiUrl">
<img :src="normalizeMaybeUrl(message.emojiUrl)" alt="emoji" class="w-24 h-24 object-contain" />
</div>
<!-- 图片 -->
<div v-else-if="renderType === 'image' && message.imageUrl" class="max-w-sm">
<div class="msg-radius overflow-hidden">
<img :src="normalizeMaybeUrl(message.imageUrl)" alt="图片" class="max-w-[240px] max-h-[240px] object-cover" />
</div>
</div>
<!-- 视频 -->
<div
v-else-if="renderType === 'video'"
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'"
>
[视频]
</div>
<!-- 语音 -->
<div
v-else-if="renderType === 'voice'"
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'"
>
[语音]
</div>
<!-- 默认文本消息 -->
<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>
</template>
<script setup>
const props = defineProps({
message: { type: Object, default: null },
})
const mediaBase = process.client ? 'http://localhost:8000' : ''
const normalizeMaybeUrl = (u) => {
const raw = String(u || '').trim()
if (!raw) return ''
if (/^https?:\/\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw
if (/^\/api\//i.test(raw)) return `${mediaBase}${raw}`
return raw
}
const renderType = computed(() => String(props.message?.renderType || '').trim())
const resolvedAvatar = computed(() => {
const m = props.message
if (!m) return ''
return normalizeMaybeUrl(m.avatar || m.senderAvatar || '')
})
const avatarLetter = computed(() => {
const m = props.message
if (!m) return '?'
const name = m.senderDisplayName || m.senderUsername || m.sender || ''
return name.charAt(0) || '?'
})
const senderName = computed(() => {
const m = props.message
if (!m) return ''
return m.senderDisplayName || m.senderUsername || ''
})
</script>
+21 -1
View File
@@ -33,6 +33,22 @@
</div>
</div>
<!-- Edits -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="修改记录"
@click="goEdits"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isEditsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</div>
</div>
</div>
<!-- Moments -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@@ -199,6 +215,7 @@ const selfAvatarUrl = computed(() => {
})
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
const isEditsRoute = computed(() => route.path?.startsWith('/edits'))
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
@@ -208,6 +225,10 @@ const goChat = async () => {
await navigateTo('/chat')
}
const goEdits = async () => {
await navigateTo('/edits')
}
const goSns = async () => {
await navigateTo('/sns')
}
@@ -237,4 +258,3 @@ const toggleRealtime = async () => {
await realtimeStore.toggle({ silent: false })
}
</script>
+82 -2
View File
@@ -5,8 +5,10 @@ export const useApi = () => {
// 基础请求函数
const request = async (url, options = {}) => {
try {
// 在客户端使用完整的API路径
const baseURL = process.client ? 'http://localhost:8000/api' : '/api'
// Default to same-origin `/api` so Nuxt devProxy / backend-mounted UI both work.
// Override via `NUXT_PUBLIC_API_BASE`, e.g. `http://127.0.0.1:8000/api`.
const apiBase = String(config?.public?.apiBase || '').trim()
const baseURL = (apiBase ? apiBase : '/api').replace(/\/$/, '')
const response = await $fetch(url, {
baseURL,
@@ -87,6 +89,75 @@ export const useApi = () => {
return await request(url)
}
const getChatMessageRaw = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.username) query.set('username', params.username)
if (params && params.message_id) query.set('message_id', params.message_id)
const url = '/chat/messages/raw' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
const editChatMessage = async (payload = {}) => {
return await request('/chat/messages/edit', {
method: 'POST',
body: payload
})
}
const repairChatMessageSender = async (payload = {}) => {
return await request('/chat/messages/repair_sender', {
method: 'POST',
body: payload
})
}
// Flip message direction in the WeChat client by swapping packed_info_data (unsafe, but undoable via reset).
const flipChatMessageDirection = async (payload = {}) => {
return await request('/chat/messages/flip_direction', {
method: 'POST',
body: payload
})
}
const listChatEditedSessions = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
const url = '/chat/edits/sessions' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
const listChatEditedMessages = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.username) query.set('username', params.username)
const url = '/chat/edits/messages' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
const getChatEditStatus = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.username) query.set('username', params.username)
if (params && params.message_id) query.set('message_id', params.message_id)
const url = '/chat/edits/message_status' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
const resetChatEditedMessage = async (payload = {}) => {
return await request('/chat/edits/reset_message', {
method: 'POST',
body: payload
})
}
const resetChatEditedSession = async (payload = {}) => {
return await request('/chat/edits/reset_session', {
method: 'POST',
body: payload
})
}
const getChatRealtimeStatus = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
@@ -476,6 +547,15 @@ export const useApi = () => {
listChatAccounts,
listChatSessions,
listChatMessages,
getChatMessageRaw,
editChatMessage,
repairChatMessageSender,
flipChatMessageDirection,
listChatEditedSessions,
listChatEditedMessages,
getChatEditStatus,
resetChatEditedMessage,
resetChatEditedSession,
getChatRealtimeStatus,
syncChatRealtimeMessages,
syncChatRealtimeAll,
+11 -1
View File
@@ -2,6 +2,14 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: false },
runtimeConfig: {
public: {
// Full API base, including `/api` when needed.
// Example: `NUXT_PUBLIC_API_BASE=http://127.0.0.1:8000/api`
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api',
},
},
// 配置前端开发服务器端口
devServer: {
@@ -12,7 +20,9 @@ export default defineNuxtConfig({
nitro: {
devProxy: {
'/api': {
target: 'http://localhost:8000',
// `h3` strips the matched prefix (`/api`) before calling the middleware,
// so the proxy target must include `/api` to preserve backend routes.
target: 'http://127.0.0.1:8000/api',
changeOrigin: true
}
}
+568 -3
View File
@@ -326,6 +326,20 @@
<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 }"
@@ -1943,6 +1957,152 @@
>
打开文件夹
</button>
<div class="border-t border-gray-200"></div>
<button
v-if="contextMenu.message?.id"
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
type="button"
@click="onEditMessageClick"
>
{{ isLikelyTextMessage(contextMenu.message) ? '修改消息' : '编辑源码' }}
</button>
<button
v-if="contextMenu.message?.id"
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
type="button"
@click="onEditMessageFieldsClick"
>
字段编辑
</button>
<button
v-if="contextMenu.editStatus?.modified"
class="block w-full text-left px-3 py-2 hover:bg-gray-100 text-red-600"
type="button"
@click="onResetEditedMessageClick"
>
恢复原消息
</button>
<button
v-if="contextMenu.message?.id"
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
type="button"
@click="onRepairMessageSenderAsMeClick"
>
修复为我发送
</button>
<button
v-if="contextMenu.message?.id"
class="block w-full text-left px-3 py-2 hover:bg-gray-100 text-orange-600"
type="button"
@click="onFlipWechatMessageDirectionClick"
>
反转微信气泡位置
</button>
<div v-if="contextMenu.editStatusLoading" class="px-3 py-2 text-xs text-gray-400">检查修改状态</div>
</div>
<!-- 修改消息弹窗 -->
<div v-if="messageEditModal.open" class="fixed inset-0 z-[11000] flex items-center justify-center">
<div class="absolute inset-0 bg-black/40" @click="closeMessageEditModal"></div>
<div class="relative w-[860px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 flex items-center">
<div class="text-base font-medium text-gray-900">{{ messageEditModal.mode === 'content' ? '修改消息' : '编辑源码' }}</div>
<button class="ml-auto text-gray-400 hover:text-gray-600" type="button" @click="closeMessageEditModal">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="p-5 max-h-[75vh] overflow-y-auto space-y-3">
<div v-if="messageEditModal.error" class="text-sm text-red-600 whitespace-pre-wrap">{{ messageEditModal.error }}</div>
<div v-if="messageEditModal.loading" class="text-sm text-gray-500">加载中</div>
<textarea
v-model="messageEditModal.draft"
class="w-full min-h-[240px] rounded-md border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#03C160]/20"
:disabled="messageEditModal.loading || messageEditModal.saving"
:placeholder="messageEditModal.mode === 'content' ? '请输入新的消息内容' : '请输入新的 message_content(可输入 0x... 写入 BLOB'"
></textarea>
<details v-if="messageEditModal.rawRow" class="text-xs">
<summary class="cursor-pointer select-none text-gray-700 hover:text-gray-900">查看源消息raw</summary>
<div class="mt-2 rounded border border-gray-200 bg-gray-50 p-2 overflow-auto">
<pre class="text-[11px] leading-snug whitespace-pre-wrap break-words">{{ prettyJson(messageEditModal.rawRow) }}</pre>
</div>
</details>
</div>
<div class="px-5 py-3 border-t border-gray-200 flex items-center justify-end gap-2">
<button class="text-sm px-4 py-2 rounded border border-gray-200 hover:bg-gray-50" type="button" @click="closeMessageEditModal">取消</button>
<button
class="text-sm px-4 py-2 rounded bg-[#03C160] text-white hover:bg-[#02ad55]"
type="button"
:disabled="messageEditModal.loading || messageEditModal.saving"
:class="messageEditModal.loading || messageEditModal.saving ? 'opacity-60 cursor-not-allowed' : ''"
@click="saveMessageEditModal"
>
保存
</button>
</div>
</div>
</div>
<!-- 字段编辑弹窗 -->
<div v-if="messageFieldsModal.open" class="fixed inset-0 z-[11000] flex items-center justify-center">
<div class="absolute inset-0 bg-black/40" @click="closeMessageFieldsModal"></div>
<div class="relative w-[920px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 flex items-center">
<div class="text-base font-medium text-gray-900">字段编辑</div>
<button class="ml-auto text-gray-400 hover:text-gray-600" type="button" @click="closeMessageFieldsModal">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="p-5 max-h-[75vh] overflow-y-auto space-y-3">
<div v-if="messageFieldsModal.error" class="text-sm text-red-600 whitespace-pre-wrap">{{ messageFieldsModal.error }}</div>
<div v-if="messageFieldsModal.loading" class="text-sm text-gray-500">加载中</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-2 text-sm text-gray-700">
<input v-model="messageFieldsModal.unsafe" type="checkbox" class="rounded border-gray-300" />
<span>我已知风险允许修改 local_id / WCDB_CT / BLOB </span>
</label>
<div class="text-xs text-gray-500">修改时间/类型会自动同步 message_resource 关键字段</div>
</div>
<textarea
v-model="messageFieldsModal.editsJson"
class="w-full min-h-[320px] rounded-md border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#03C160]/20"
:disabled="messageFieldsModal.loading || messageFieldsModal.saving"
placeholder='{ "message_content": "...", "create_time": 123 }'
></textarea>
<details v-if="messageFieldsModal.rawRow" class="text-xs">
<summary class="cursor-pointer select-none text-gray-700 hover:text-gray-900">查看源消息raw</summary>
<div class="mt-2 rounded border border-gray-200 bg-gray-50 p-2 overflow-auto">
<pre class="text-[11px] leading-snug whitespace-pre-wrap break-words">{{ prettyJson(messageFieldsModal.rawRow) }}</pre>
</div>
</details>
</div>
<div class="px-5 py-3 border-t border-gray-200 flex items-center justify-end gap-2">
<button class="text-sm px-4 py-2 rounded border border-gray-200 hover:bg-gray-50" type="button" @click="closeMessageFieldsModal">取消</button>
<button
class="text-sm px-4 py-2 rounded bg-[#03C160] text-white hover:bg-[#02ad55]"
type="button"
:disabled="messageFieldsModal.loading || messageFieldsModal.saving"
:class="messageFieldsModal.loading || messageFieldsModal.saving ? 'opacity-60 cursor-not-allowed' : ''"
@click="saveMessageFieldsModal"
>
保存
</button>
</div>
</div>
</div>
<!-- 导出弹窗 -->
@@ -4019,10 +4179,10 @@ const playQuoteVoice = (message) => {
playVoice({ id: getQuoteVoiceId(message) })
}
const contextMenu = ref({ visible: false, x: 0, y: 0, message: null, kind: '', disabled: false })
const contextMenu = ref({ visible: false, x: 0, y: 0, message: null, kind: '', disabled: false, editStatus: null, editStatusLoading: false })
const closeContextMenu = () => {
contextMenu.value = { visible: false, x: 0, y: 0, message: null, kind: '', disabled: false }
contextMenu.value = { visible: false, x: 0, y: 0, message: null, kind: '', disabled: false, editStatus: null, editStatusLoading: false }
}
const openMediaContextMenu = (e, message, kind) => {
@@ -4059,7 +4219,382 @@ const openMediaContextMenu = (e, message, kind) => {
y: e.clientY,
message,
kind: actualKind,
disabled
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 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 api = useApi()
const resp = await api.getChatEditStatus({ account, username, message_id: messageId })
const cur = String(contextMenu.value?.message?.id || '').trim()
if (contextMenu.value.visible && cur === messageId) {
contextMenu.value.editStatus = resp || { modified: false }
}
} catch {
const cur = String(contextMenu.value?.message?.id || '').trim()
if (contextMenu.value.visible && cur === messageId) {
contextMenu.value.editStatus = null
}
} finally {
const cur = String(contextMenu.value?.message?.id || '').trim()
if (contextMenu.value.visible && cur === messageId) {
contextMenu.value.editStatusLoading = false
}
}
}
const prettyJson = (obj) => {
try {
return JSON.stringify(obj ?? null, null, 2)
} catch {
return String(obj ?? '')
}
}
const isLikelyTextMessage = (m) => {
if (!m) return false
const rt = String(m?.renderType || '').trim()
if (rt && rt !== 'text') return false
if (m?.imageUrl || m?.emojiUrl || m?.videoUrl || m?.voiceUrl) return false
return true
}
const messageEditModal = ref({
open: false,
loading: false,
saving: false,
error: '',
mode: 'content',
sessionId: '',
messageId: '',
draft: '',
rawRow: null,
})
const closeMessageEditModal = () => {
messageEditModal.value = {
open: false,
loading: false,
saving: false,
error: '',
mode: 'content',
sessionId: '',
messageId: '',
draft: '',
rawRow: null,
}
}
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 api = useApi()
const resp = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId })
const row = resp?.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 (e) {
messageEditModal.value = { ...messageEditModal.value, loading: false, error: e?.message || '加载失败' }
}
}
const saveMessageEditModal = async () => {
if (!process.client) return
if (messageEditModal.value.saving || messageEditModal.value.loading) return
const account = String(selectedAccount.value || '').trim()
if (!account) return
const sessionId = String(messageEditModal.value.sessionId || '').trim()
const messageId = String(messageEditModal.value.messageId || '').trim()
if (!sessionId || !messageId) return
messageEditModal.value = { ...messageEditModal.value, saving: true, error: '' }
try {
const api = useApi()
const resp = await api.editChatMessage({
account,
session_id: sessionId,
message_id: messageId,
edits: {
message_content: String(messageEditModal.value.draft ?? ''),
},
unsafe: false,
})
if (resp?.updated_message) {
try {
const u = normalizeMessage(resp.updated_message)
const uname = String(selectedContact.value?.username || '').trim()
const list = allMessages.value[uname] || []
const idx = list.findIndex((m) => String(m?.id || '') === String(u?.id || ''))
if (idx >= 0) {
const next = [...list]
next[idx] = u
allMessages.value = { ...allMessages.value, [uname]: next }
} else {
await refreshSelectedMessages()
}
} catch {
await refreshSelectedMessages()
}
} else {
await refreshSelectedMessages()
}
closeMessageEditModal()
} catch (e) {
messageEditModal.value = { ...messageEditModal.value, saving: false, error: e?.message || '保存失败' }
return
} finally {
messageEditModal.value = { ...messageEditModal.value, saving: false }
}
}
const messageFieldsModal = ref({
open: false,
loading: false,
saving: false,
error: '',
sessionId: '',
messageId: '',
unsafe: false,
editsJson: '',
rawRow: null,
})
const closeMessageFieldsModal = () => {
messageFieldsModal.value = {
open: false,
loading: false,
saving: false,
error: '',
sessionId: '',
messageId: '',
unsafe: false,
editsJson: '',
rawRow: null,
}
}
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 api = useApi()
const resp = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId })
const row = resp?.row || null
const seed = {}
for (const k of ['message_content', 'local_type', 'create_time', 'server_id', 'origin_source', 'source']) {
if (row && Object.prototype.hasOwnProperty.call(row, k)) seed[k] = row[k]
}
messageFieldsModal.value = {
...messageFieldsModal.value,
loading: false,
rawRow: row,
editsJson: JSON.stringify(seed, null, 2),
}
} catch (e) {
messageFieldsModal.value = { ...messageFieldsModal.value, loading: false, error: e?.message || '加载失败' }
}
}
const saveMessageFieldsModal = async () => {
if (!process.client) return
if (messageFieldsModal.value.saving || messageFieldsModal.value.loading) return
const account = String(selectedAccount.value || '').trim()
if (!account) return
const sessionId = String(messageFieldsModal.value.sessionId || '').trim()
const messageId = String(messageFieldsModal.value.messageId || '').trim()
if (!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 {
const api = useApi()
await api.editChatMessage({
account,
session_id: sessionId,
message_id: messageId,
edits,
unsafe: !!messageFieldsModal.value.unsafe,
})
await refreshSelectedMessages()
closeMessageFieldsModal()
} catch (e) {
messageFieldsModal.value = { ...messageFieldsModal.value, saving: false, error: e?.message || '保存失败' }
return
} finally {
messageFieldsModal.value = { ...messageFieldsModal.value, saving: false }
}
}
const onEditMessageClick = async () => {
if (!process.client) return
const m = contextMenu.value.message
if (!m) return
const mode = isLikelyTextMessage(m) ? 'content' : 'raw'
closeContextMenu()
await openMessageEditModal({ message: m, mode })
}
const onEditMessageFieldsClick = async () => {
if (!process.client) return
const m = contextMenu.value.message
if (!m) return
closeContextMenu()
await openMessageFieldsModal(m)
}
const onResetEditedMessageClick = async () => {
if (!process.client) return
const m = contextMenu.value.message
if (!m) return
const account = String(selectedAccount.value || '').trim()
const sessionId = String(selectedContact.value?.username || '').trim()
const messageId = String(m?.id || '').trim()
if (!account || !sessionId || !messageId) return
const ok = window.confirm('确认恢复该条消息到首次快照吗?')
if (!ok) return
try {
const api = useApi()
await api.resetChatEditedMessage({ account, session_id: sessionId, message_id: messageId })
closeContextMenu()
await refreshSelectedMessages()
} catch (e) {
window.alert(e?.message || '恢复失败')
} finally {
closeContextMenu()
}
}
const onRepairMessageSenderAsMeClick = async () => {
if (!process.client) return
const m = contextMenu.value.message
if (!m) return
const account = String(selectedAccount.value || '').trim()
const sessionId = String(selectedContact.value?.username || '').trim()
const messageId = String(m?.id || '').trim()
if (!account || !sessionId || !messageId) return
const ok = window.confirm('确认将该消息修复为“我发送”吗?这会修改 real_sender_id 字段。')
if (!ok) return
try {
const api = useApi()
await api.repairChatMessageSender({ account, session_id: sessionId, message_id: messageId, mode: 'me' })
closeContextMenu()
await refreshSelectedMessages()
} catch (e) {
window.alert(e?.message || '修复失败')
} finally {
closeContextMenu()
}
}
const onFlipWechatMessageDirectionClick = async () => {
if (!process.client) return
const m = contextMenu.value.message
if (!m) return
const account = String(selectedAccount.value || '').trim()
const sessionId = String(selectedContact.value?.username || '').trim()
const messageId = String(m?.id || '').trim()
if (!account || !sessionId || !messageId) return
const ok = window.confirm(
'确认反转该消息在微信客户端的左右气泡位置吗?\\n\\n这会修改 packed_info_data 字段(有风险)。\\n可通过“恢复原消息”撤销。'
)
if (!ok) return
try {
const api = useApi()
await api.flipChatMessageDirection({ account, session_id: sessionId, message_id: messageId })
closeContextMenu()
await refreshSelectedMessages()
} catch (e) {
window.alert(e?.message || '反转失败')
} finally {
closeContextMenu()
}
}
@@ -5237,15 +5772,45 @@ const getTransferTitle = (message) => {
return '转账'
}
// BLOB
const reverseMessageSides = ref(false)
const reverseSidesStorageKey = computed(() => {
const a = String(selectedAccount.value || '').trim()
const sid = String(selectedContact.value?.username || '').trim()
if (a && sid) return `wechatda:reverse_message_sides:${a}:${sid}`
return 'wechatda:reverse_message_sides:global'
})
const loadReverseMessageSides = () => {
if (!process.client) return
try {
const v = localStorage.getItem(reverseSidesStorageKey.value)
reverseMessageSides.value = v === '1'
} catch {}
}
watch(reverseSidesStorageKey, () => loadReverseMessageSides(), { immediate: true })
watch(reverseMessageSides, (v) => {
if (!process.client) return
try {
localStorage.setItem(reverseSidesStorageKey.value, v ? '1' : '0')
} catch {}
})
const toggleReverseMessageSides = () => {
reverseMessageSides.value = !reverseMessageSides.value
}
const renderMessages = computed(() => {
const list = messages.value || []
const reverseSides = !!reverseMessageSides.value
let prevTs = 0
return list.map((m) => {
const ts = Number(m.createTime || 0)
const show = !prevTs || (ts && Math.abs(ts - prevTs) >= 300)
if (ts) prevTs = ts
const origIsSent = !!m?.isSent
return {
...m,
_originalIsSent: origIsSent,
isSent: reverseSides ? !origIsSent : origIsSent,
showTimeDivider: !!show,
timeDivider: formatTimeDivider(ts)
}
+726
View File
@@ -0,0 +1,726 @@
<template>
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
<!-- 左侧会话列表与聊天页统一风格 -->
<div class="edits-sidebar border-r border-gray-200 flex flex-col">
<!-- 搜索栏区域 -->
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
<div class="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"
>
<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>
<button
class="w-8 h-8 flex items-center justify-center rounded-md text-gray-500 hover:text-gray-700 hover:bg-[#DEDEDE] transition-colors flex-shrink-0"
type="button"
:disabled="sessionsLoading || !selectedAccount"
:class="sessionsLoading || !selectedAccount ? 'opacity-40 cursor-not-allowed' : ''"
title="刷新"
@click="loadSessions"
>
<svg class="w-4 h-4" :class="sessionsLoading ? 'animate-spin' : ''" 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>
</div>
<div v-if="!selectedAccount" class="mt-1.5 text-xs text-gray-400">未选择账号</div>
<div v-if="sessionsError" class="mt-2 text-xs text-red-500 whitespace-pre-wrap">{{ sessionsError }}</div>
</div>
<!-- 会话列表区域 -->
<div class="flex-1 overflow-y-auto min-h-0">
<!-- 骨架屏加载 -->
<div v-if="sessionsLoading" class="px-3 py-4 h-full overflow-hidden">
<div v-for="i in 10" :key="i" class="flex items-center space-x-3 h-[calc(70px/var(--dpr,1))]">
<div class="w-[calc(45px/var(--dpr,1))] h-[calc(45px/var(--dpr,1))] 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: (55 + (i % 4) * 15) + 'px' }"></div>
<div class="h-3 bg-gray-200 rounded skeleton-pulse" :style="{ width: (70 + (i % 3) * 20) + 'px' }"></div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else-if="!sessions.length" class="px-3 py-2 text-sm text-gray-500">暂无修改记录</div>
<!-- 列表项复用聊天页样式 -->
<template v-else>
<div
v-for="s in filteredSessions"
:key="s.username"
class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(70px/var(--dpr,1))] flex items-center"
:class="s.username === activeUsername
? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]'
: 'hover:bg-[#eaeaea]'"
@click="selectSession(s.username)"
>
<div class="flex items-center space-x-3 w-full">
<!-- 头像 -->
<div class="relative flex-shrink-0">
<div class="w-[calc(45px/var(--dpr,1))] h-[calc(45px/var(--dpr,1))] rounded-md overflow-hidden bg-gray-300">
<div v-if="s.avatar" class="w-full h-full">
<img :src="normalizeMaybeUrl(s.avatar)" :alt="s.name || s.username" class="w-full h-full object-cover" referrerpolicy="no-referrer" />
</div>
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
:style="{ backgroundColor: '#4B5563' }">
{{ (s.name || s.username || '?').charAt(0) }}
</div>
</div>
<!-- 编辑数量红点 -->
<span
v-if="s.editedCount > 0"
class="absolute z-10 -top-[calc(4px/var(--dpr,1))] -right-[calc(4px/var(--dpr,1))] min-w-[calc(18px/var(--dpr,1))] h-[calc(18px/var(--dpr,1))] px-1 flex items-center justify-center bg-[#ed4d4d] rounded-full text-white text-[10px] font-medium leading-none"
>
{{ s.editedCount > 99 ? '99+' : s.editedCount }}
</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">{{ s.name || s.username }}</h3>
<div class="flex items-center flex-shrink-0 ml-2">
<span v-if="s.lastEditedAt" class="text-xs text-gray-500">{{ formatRelativeTime(s.lastEditedAt) }}</span>
</div>
</div>
<p class="text-xs text-gray-500 truncate mt-0.5 leading-tight">
{{ s.editedCount || 0 }} 条修改
</p>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 右侧diff 对比区 -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Header与聊天页统一 -->
<div class="chat-header">
<div class="flex items-center gap-3 min-w-0 flex-1">
<h2 class="text-base font-medium text-gray-900 truncate">
{{ activeSessionName }}
</h2>
</div>
<div class="ml-auto flex items-center gap-2">
<button
v-if="activeUsername"
class="header-btn-icon"
type="button"
:disabled="itemsLoading || resetting"
:class="itemsLoading || resetting ? 'opacity-50 cursor-not-allowed' : ''"
title="一键重置此会话"
@click="onResetSessionClick"
>
<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>
</div>
</div>
<!-- 内容区 -->
<div class="flex-1 overflow-y-auto" style="background-color: #EDEDED">
<!-- 错误提示 -->
<div v-if="itemsError" class="mx-5 mt-4 text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-4 py-3 whitespace-pre-wrap">{{ itemsError }}</div>
<!-- 加载态 -->
<div v-if="itemsLoading" class="px-5 py-6 space-y-5">
<div v-for="i in 3" :key="i" class="bg-white rounded-xl overflow-hidden shadow-sm">
<div class="h-10 bg-gray-50 border-b border-gray-100 skeleton-pulse"></div>
<div class="grid grid-cols-2 divide-x divide-gray-100">
<div class="p-4"><div class="h-16 bg-gray-100 rounded-lg skeleton-pulse"></div></div>
<div class="p-4"><div class="h-16 bg-gray-100 rounded-lg skeleton-pulse"></div></div>
</div>
</div>
</div>
<!-- 未选会话 -->
<div v-else-if="!activeUsername" class="flex flex-col items-center justify-center h-full">
<div class="w-20 h-20 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<svg class="w-10 h-10 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/>
</svg>
</div>
<div class="text-sm text-gray-400">请从左侧选择一个会话</div>
</div>
<!-- 无记录 -->
<div v-else-if="!items.length" class="flex flex-col items-center justify-center h-full">
<div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-3">
<svg class="w-8 h-8 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
</div>
<div class="text-sm text-gray-400">该会话暂无修改记录</div>
</div>
<!-- Diff 列表无卡片包装直接渲染在页面上 -->
<div v-else class="edits-list">
<div v-for="it in items" :key="it.messageId" class="edits-item">
<!-- 时间分割线聊天页风格居中 -->
<div class="flex justify-center mb-4">
<div class="px-3 py-1 text-xs text-[#9e9e9e]">
<span v-if="it.lastEditedAt">{{ formatTime(it.lastEditedAt) }}</span>
<span v-if="it.editCount"> · 编辑 {{ it.editCount }} </span>
</div>
</div>
<!-- Side-by-side diff -->
<div class="edits-diff-body">
<!-- 原消息 -->
<div class="edits-diff-pane">
<EditedMessagePreview :message="it.original" />
</div>
<!-- 中间分割线 -->
<div class="edits-diff-divider">
<button
class="edits-divider-arrow"
type="button"
:disabled="resetting"
:class="resetting ? 'opacity-50 cursor-not-allowed' : ''"
title="重置此条"
@click="onResetMessageClick(it)"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" />
<path d="M12 5l7 7-7 7" />
</svg>
</button>
</div>
<!-- 修改后 -->
<div class="edits-diff-pane">
<EditedMessagePreview :message="it.current" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 自定义确认/提示弹窗 -->
<Teleport to="body">
<Transition name="edits-dialog">
<div v-if="dialogVisible" class="edits-dialog-overlay" @click.self="onDialogCancel">
<div class="edits-dialog-card">
<div class="edits-dialog-title">{{ dialogTitle }}</div>
<div class="edits-dialog-msg">{{ dialogMessage }}</div>
<div class="edits-dialog-actions">
<button v-if="dialogShowCancel" class="edits-dialog-btn edits-dialog-cancel" @click="onDialogCancel">取消</button>
<button class="edits-dialog-btn edits-dialog-confirm" @click="onDialogConfirm">确定</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useChatAccountsStore } from '~/stores/chatAccounts'
import EditedMessagePreview from '~/components/EditedMessagePreview.vue'
const route = useRoute()
const chatAccounts = useChatAccountsStore()
const { selectedAccount } = storeToRefs(chatAccounts)
const searchQuery = ref('')
onMounted(async () => {
await chatAccounts.ensureLoaded()
await loadSessions()
})
watch(selectedAccount, async () => {
await loadSessions()
})
const mediaBase = process.client ? 'http://localhost:8000' : ''
const normalizeMaybeUrl = (u) => {
const raw = String(u || '').trim()
if (!raw) return ''
if (/^https?:\/\//i.test(raw) || /^blob:/i.test(raw) || /^data:/i.test(raw)) return raw
if (/^\/api\//i.test(raw)) return `${mediaBase}${raw}`
return raw
}
const formatTime = (ms) => {
const v = Number(ms || 0)
if (!v) return ''
try {
return new Date(v).toLocaleString()
} catch {
return String(v)
}
}
const formatRelativeTime = (ms) => {
const v = Number(ms || 0)
if (!v) return ''
try {
const d = new Date(v)
const now = new Date()
const diff = now - d
// 今天
if (d.toDateString() === now.toDateString()) {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
// 昨天
const yesterday = new Date(now)
yesterday.setDate(yesterday.getDate() - 1)
if (d.toDateString() === yesterday.toDateString()) {
return '昨天'
}
// 7 天内
if (diff < 7 * 24 * 3600 * 1000) {
const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
return days[d.getDay()]
}
// 更早
return `${d.getMonth() + 1}/${d.getDate()}`
} catch {
return String(v)
}
}
const prettyJson = (obj) => {
try {
return JSON.stringify(obj ?? null, null, 2)
} catch {
return String(obj ?? '')
}
}
const sessions = ref([])
const sessionsLoading = ref(false)
const sessionsError = ref('')
const items = ref([])
const itemsLoading = ref(false)
const itemsError = ref('')
const resetting = ref(false)
const activeUsername = computed(() => String(route.params?.username || '').trim())
const activeSessionName = computed(() => {
const uname = activeUsername.value
if (!uname) return '修改记录'
const s = sessions.value.find((x) => String(x?.username || '') === uname)
return s?.name || s?.username || uname
})
// 搜索过滤
const filteredSessions = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
if (!q) return sessions.value
return sessions.value.filter((s) => {
const name = String(s.name || '').toLowerCase()
const username = String(s.username || '').toLowerCase()
return name.includes(q) || username.includes(q)
})
})
const selectSession = async (username) => {
const u = String(username || '').trim()
if (!u) return
await navigateTo(`/edits/${encodeURIComponent(u)}`)
}
const loadSessions = async () => {
if (!process.client) return
sessionsError.value = ''
sessionsLoading.value = true
try {
if (!selectedAccount.value) {
sessions.value = []
return
}
const api = useApi()
const resp = await api.listChatEditedSessions({ account: selectedAccount.value })
sessions.value = Array.isArray(resp?.sessions) ? resp.sessions : []
const current = activeUsername.value
if (current) {
const exists = sessions.value.some((s) => String(s?.username || '') === current)
if (!exists && sessions.value.length) {
await selectSession(sessions.value[0].username)
}
if (!exists && !sessions.value.length) {
await navigateTo('/edits')
}
} else if (sessions.value.length) {
await selectSession(sessions.value[0].username)
}
} catch (e) {
sessions.value = []
sessionsError.value = e?.message || '加载会话失败'
} finally {
sessionsLoading.value = false
}
}
const loadItems = async () => {
if (!process.client) return
itemsError.value = ''
itemsLoading.value = true
try {
const uname = activeUsername.value
if (!selectedAccount.value || !uname) {
items.value = []
return
}
const api = useApi()
const resp = await api.listChatEditedMessages({ account: selectedAccount.value, username: uname })
items.value = Array.isArray(resp?.items) ? resp.items : []
} catch (e) {
items.value = []
itemsError.value = e?.message || '加载修改记录失败'
} finally {
itemsLoading.value = false
}
}
watch(activeUsername, async () => {
await loadItems()
}, { immediate: true })
// ===== 自定义弹窗 =====
const dialogVisible = ref(false)
const dialogTitle = ref('')
const dialogMessage = ref('')
const dialogShowCancel = ref(true)
let dialogResolve = null
const showConfirm = (title, message) => {
return new Promise((resolve) => {
dialogTitle.value = title
dialogMessage.value = message
dialogShowCancel.value = true
dialogResolve = resolve
dialogVisible.value = true
})
}
const showAlert = (title, message) => {
return new Promise((resolve) => {
dialogTitle.value = title
dialogMessage.value = message || ''
dialogShowCancel.value = false
dialogResolve = resolve
dialogVisible.value = true
})
}
const onDialogConfirm = () => {
dialogVisible.value = false
dialogResolve?.(true)
dialogResolve = null
}
const onDialogCancel = () => {
dialogVisible.value = false
dialogResolve?.(false)
dialogResolve = null
}
const onResetMessageClick = async (it) => {
if (!process.client) return
if (!selectedAccount.value) return
const uname = activeUsername.value
if (!uname) return
const mid = String(it?.messageId || '').trim()
if (!mid) return
const ok = await showConfirm('重置消息', '确认重置该条消息到首次快照吗?')
if (!ok) return
resetting.value = true
try {
const api = useApi()
await api.resetChatEditedMessage({
account: selectedAccount.value,
session_id: uname,
message_id: mid,
})
await loadSessions()
await loadItems()
} catch (e) {
await showAlert('重置失败', e?.message || '请稍后重试')
} finally {
resetting.value = false
}
}
const onResetSessionClick = async () => {
if (!process.client) return
if (!selectedAccount.value) return
const uname = activeUsername.value
if (!uname) return
const ok = await showConfirm('重置会话', '确认重置该会话下全部修改记录吗?')
if (!ok) return
resetting.value = true
try {
const api = useApi()
const resp = await api.resetChatEditedSession({
account: selectedAccount.value,
session_id: uname,
})
const restored = Number(resp?.restored || 0)
const failed = Number(resp?.failed || 0)
if (failed) {
await showAlert('部分失败', `已恢复 ${restored} 条,失败 ${failed} 条(详情请查看控制台)`)
// eslint-disable-next-line no-console
console.error('reset_session failures:', resp?.failures || [])
}
await loadSessions()
await loadItems()
} catch (e) {
await showAlert('重置失败', e?.message || '请稍后重试')
} finally {
resetting.value = false
}
}
</script>
<style scoped>
/* ===== 左侧会话面板 ===== */
.edits-sidebar {
width: var(--session-list-width, 280px);
min-width: 240px;
max-width: 360px;
background-color: #F7F7F7;
}
/* ===== 右侧 Diff 列表 ===== */
.edits-list {
padding: 8px 0;
}
.edits-item {
padding: 8px 16px 16px;
border-top: 1px solid #d6d6d6;
}
.edits-item:last-child {
border-bottom: 1px solid #d6d6d6;
}
/* Side-by-side diff */
.edits-diff-body {
display: flex;
min-height: 60px;
}
.edits-diff-pane {
flex: 1;
min-width: 0;
padding: 4px 8px;
max-height: 200px;
overflow-y: auto;
}
.edits-diff-pane::-webkit-scrollbar {
width: 4px;
}
.edits-diff-pane::-webkit-scrollbar-track {
background: transparent;
}
.edits-diff-pane::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 4px;
}
.edits-diff-pane::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.25);
}
.edits-diff-pane::-webkit-scrollbar-button {
display: none;
}
/* 中间分割线 */
.edits-diff-divider {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 24px;
flex-shrink: 0;
position: relative;
}
/* 虚线背景 */
.edits-diff-divider::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 2px;
border-left: none;
background: linear-gradient(to bottom, transparent 0%, #07c160 15%, #07c160 85%, transparent 100%);
}
/* 箭头按钮 */
.edits-divider-arrow {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 50%;
background: #EDEDED;
color: #07c160;
cursor: pointer;
transition: all 0.15s;
padding: 0;
}
.edits-divider-arrow:hover:not(:disabled) {
background: #e0f5e9;
color: #059341;
}
@media (max-width: 768px) {
.edits-diff-body {
flex-direction: column;
}
.edits-diff-divider {
flex-direction: row;
width: auto;
height: 24px;
}
.edits-diff-divider::before {
top: 50%;
bottom: auto;
left: 0;
right: 0;
width: auto;
height: 2px;
transform: none;
background: linear-gradient(to right, transparent 0%, #07c160 15%, #07c160 85%, transparent 100%);
}
}
</style>
<!-- 弹窗样式需要非 scoped因为 Teleport DOM 移到了 body -->
<style>
.edits-dialog-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
}
.edits-dialog-card {
background: #fff;
border-radius: 12px;
width: 320px;
max-width: 90vw;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
}
.edits-dialog-title {
font-size: 16px;
font-weight: 600;
color: #191919;
text-align: center;
padding: 24px 24px 8px;
}
.edits-dialog-msg {
font-size: 14px;
color: #666;
text-align: center;
padding: 0 24px 24px;
line-height: 1.5;
}
.edits-dialog-actions {
display: flex;
border-top: 1px solid #eee;
}
.edits-dialog-btn {
flex: 1;
padding: 14px 0;
font-size: 15px;
border: none;
background: none;
cursor: pointer;
transition: background 0.15s;
}
.edits-dialog-btn:active {
background: #f5f5f5;
}
.edits-dialog-cancel {
color: #666;
border-right: 1px solid #eee;
}
.edits-dialog-confirm {
color: #07c160;
font-weight: 500;
}
/* 弹窗过渡动画 */
.edits-dialog-enter-active,
.edits-dialog-leave-active {
transition: opacity 0.2s;
}
.edits-dialog-enter-active .edits-dialog-card,
.edits-dialog-leave-active .edits-dialog-card {
transition: transform 0.2s;
}
.edits-dialog-enter-from,
.edits-dialog-leave-to {
opacity: 0;
}
.edits-dialog-enter-from .edits-dialog-card {
transform: scale(0.95);
}
.edits-dialog-leave-to .edits-dialog-card {
transform: scale(0.95);
}
</style>