mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
feat(chat-edit-ui): 新增聊天编辑入口与修改记录页面
- 聊天右键菜单新增修改消息、字段编辑、恢复原消息、修复发送者、反转微信气泡位置 - 新增 /edits 页面,支持会话维度浏览修改记录与原/现对比 - 新增 EditedMessagePreview 组件复用聊天气泡样式 - 侧边栏新增修改记录入口 - useApi 增加 chat edits 相关接口,nuxt 配置支持可配置 API base - 增加会话级反转消息位置前端显示开关(本地存储)
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user