mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
458a19a55d
- 为聊天右键菜单增加视口内重定位,避免菜单显示到屏幕外 - 在异步加载编辑状态后重新计算位置,并限制菜单高度支持滚动 - 补回 searchContext 状态传递,恢复上下文模式下的筛选禁用和提示横幅
593 lines
19 KiB
JavaScript
593 lines
19 KiB
JavaScript
import { nextTick, ref, toRaw } from 'vue'
|
|
|
|
const CONTEXT_MENU_MARGIN = 8
|
|
|
|
const initialContextMenu = () => ({
|
|
visible: false,
|
|
x: 0,
|
|
y: 0,
|
|
message: null,
|
|
kind: '',
|
|
disabled: false,
|
|
editStatus: null,
|
|
editStatusLoading: false
|
|
})
|
|
|
|
const initialMessageEditModal = () => ({
|
|
open: false,
|
|
loading: false,
|
|
saving: false,
|
|
error: '',
|
|
mode: 'content',
|
|
sessionId: '',
|
|
messageId: '',
|
|
draft: '',
|
|
rawRow: null
|
|
})
|
|
|
|
const initialMessageFieldsModal = () => ({
|
|
open: false,
|
|
loading: false,
|
|
saving: false,
|
|
error: '',
|
|
sessionId: '',
|
|
messageId: '',
|
|
unsafe: false,
|
|
editsJson: '',
|
|
rawRow: null
|
|
})
|
|
|
|
export const useChatEditing = ({
|
|
api,
|
|
selectedAccount,
|
|
selectedContact,
|
|
refreshSelectedMessages,
|
|
normalizeMessage,
|
|
allMessages,
|
|
locateMessageByServerId
|
|
}) => {
|
|
const contextMenu = ref(initialContextMenu())
|
|
const contextMenuElement = ref(null)
|
|
const messageEditModal = ref(initialMessageEditModal())
|
|
const messageFieldsModal = ref(initialMessageFieldsModal())
|
|
|
|
const closeContextMenu = () => {
|
|
contextMenu.value = initialContextMenu()
|
|
}
|
|
|
|
const repositionContextMenu = () => {
|
|
if (!process.client || !contextMenu.value.visible) return
|
|
const menuEl = contextMenuElement.value
|
|
if (!menuEl) return
|
|
|
|
const rect = menuEl.getBoundingClientRect()
|
|
const viewportWidth = Math.max(window.innerWidth || 0, document.documentElement?.clientWidth || 0)
|
|
const viewportHeight = Math.max(window.innerHeight || 0, document.documentElement?.clientHeight || 0)
|
|
if (!viewportWidth || !viewportHeight) return
|
|
|
|
const maxX = Math.max(CONTEXT_MENU_MARGIN, viewportWidth - rect.width - CONTEXT_MENU_MARGIN)
|
|
const maxY = Math.max(CONTEXT_MENU_MARGIN, viewportHeight - rect.height - CONTEXT_MENU_MARGIN)
|
|
const currentX = Number(contextMenu.value.x || 0)
|
|
const currentY = Number(contextMenu.value.y || 0)
|
|
const nextX = Math.min(Math.max(currentX, CONTEXT_MENU_MARGIN), maxX)
|
|
const nextY = Math.min(Math.max(currentY, CONTEXT_MENU_MARGIN), maxY)
|
|
|
|
if (nextX !== currentX || nextY !== currentY) {
|
|
contextMenu.value = {
|
|
...contextMenu.value,
|
|
x: nextX,
|
|
y: nextY
|
|
}
|
|
}
|
|
}
|
|
|
|
const scheduleContextMenuReposition = () => {
|
|
if (!process.client) return
|
|
void nextTick(() => {
|
|
const run = () => repositionContextMenu()
|
|
if (typeof window.requestAnimationFrame === 'function') {
|
|
window.requestAnimationFrame(run)
|
|
} else {
|
|
run()
|
|
}
|
|
})
|
|
}
|
|
|
|
const loadContextMenuEditStatus = async (params) => {
|
|
if (!process.client) return
|
|
const account = String(params?.account || '').trim()
|
|
const username = String(params?.username || '').trim()
|
|
const messageId = String(params?.message_id || '').trim()
|
|
if (!account || !username || !messageId) {
|
|
contextMenu.value.editStatusLoading = false
|
|
return
|
|
}
|
|
|
|
try {
|
|
const response = await api.getChatEditStatus({ account, username, message_id: messageId })
|
|
const current = String(contextMenu.value?.message?.id || '').trim()
|
|
if (contextMenu.value.visible && current === messageId) {
|
|
contextMenu.value.editStatus = response || { modified: false }
|
|
scheduleContextMenuReposition()
|
|
}
|
|
} catch {
|
|
const current = String(contextMenu.value?.message?.id || '').trim()
|
|
if (contextMenu.value.visible && current === messageId) {
|
|
contextMenu.value.editStatus = null
|
|
scheduleContextMenuReposition()
|
|
}
|
|
} finally {
|
|
const current = String(contextMenu.value?.message?.id || '').trim()
|
|
if (contextMenu.value.visible && current === messageId) {
|
|
contextMenu.value.editStatusLoading = false
|
|
scheduleContextMenuReposition()
|
|
}
|
|
}
|
|
}
|
|
|
|
const openMediaContextMenu = (event, message, kind) => {
|
|
if (!process.client) return
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
|
|
let actualKind = kind
|
|
let disabled = true
|
|
if (kind === 'voice') {
|
|
disabled = !(message?.serverIdStr || message?.serverId)
|
|
} else if (kind === 'file') {
|
|
disabled = !message?.fileMd5
|
|
} else if (kind === 'image') {
|
|
disabled = !(message?.imageMd5 || message?.imageFileId)
|
|
} else if (kind === 'emoji') {
|
|
disabled = !message?.emojiMd5
|
|
} else if (kind === 'video') {
|
|
if (message?.videoMd5 || message?.videoFileId) {
|
|
disabled = false
|
|
actualKind = 'video'
|
|
} else if (message?.videoThumbMd5 || message?.videoThumbFileId) {
|
|
disabled = false
|
|
actualKind = 'video_thumb'
|
|
}
|
|
}
|
|
|
|
contextMenu.value = {
|
|
visible: true,
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
message,
|
|
kind: actualKind,
|
|
disabled,
|
|
editStatus: null,
|
|
editStatusLoading: false
|
|
}
|
|
|
|
try {
|
|
const account = String(selectedAccount.value || '').trim()
|
|
const username = String(selectedContact.value?.username || '').trim()
|
|
const messageId = String(message?.id || '').trim()
|
|
if (account && username && messageId) {
|
|
contextMenu.value.editStatusLoading = true
|
|
void loadContextMenuEditStatus({ account, username, message_id: messageId })
|
|
}
|
|
} catch {}
|
|
|
|
scheduleContextMenuReposition()
|
|
}
|
|
|
|
const prettyJson = (value) => {
|
|
try {
|
|
return JSON.stringify(value ?? null, null, 2)
|
|
} catch {
|
|
return String(value ?? '')
|
|
}
|
|
}
|
|
|
|
const isLikelyTextMessage = (message) => {
|
|
if (!message) return false
|
|
const renderType = String(message?.renderType || '').trim()
|
|
if (renderType && renderType !== 'text') return false
|
|
if (message?.imageUrl || message?.emojiUrl || message?.videoUrl || message?.voiceUrl) return false
|
|
return true
|
|
}
|
|
|
|
const closeMessageEditModal = () => {
|
|
messageEditModal.value = initialMessageEditModal()
|
|
}
|
|
|
|
const openMessageEditModal = async ({ message, mode }) => {
|
|
if (!process.client) return
|
|
const account = String(selectedAccount.value || '').trim()
|
|
const sessionId = String(selectedContact.value?.username || '').trim()
|
|
const messageId = String(message?.id || '').trim()
|
|
if (!account || !sessionId || !messageId) return
|
|
|
|
const resolvedMode = mode === 'raw' ? 'raw' : 'content'
|
|
const initialDraft = resolvedMode === 'content'
|
|
? (typeof message?.content === 'string' ? message.content : String(message?.content ?? ''))
|
|
: ''
|
|
|
|
messageEditModal.value = {
|
|
open: true,
|
|
loading: true,
|
|
saving: false,
|
|
error: '',
|
|
mode: resolvedMode,
|
|
sessionId,
|
|
messageId,
|
|
draft: initialDraft,
|
|
rawRow: null
|
|
}
|
|
|
|
try {
|
|
const response = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId })
|
|
const row = response?.row || null
|
|
const rawContent = row?.message_content
|
|
const rawDraft = typeof rawContent === 'string' ? rawContent : String(rawContent ?? '')
|
|
const draft = resolvedMode === 'raw' ? rawDraft : messageEditModal.value.draft
|
|
messageEditModal.value = { ...messageEditModal.value, loading: false, rawRow: row, draft }
|
|
} catch (error) {
|
|
messageEditModal.value = { ...messageEditModal.value, loading: false, error: error?.message || '加载失败' }
|
|
}
|
|
}
|
|
|
|
const saveMessageEditModal = async () => {
|
|
if (!process.client) return
|
|
if (messageEditModal.value.saving || messageEditModal.value.loading) return
|
|
|
|
const account = String(selectedAccount.value || '').trim()
|
|
const sessionId = String(messageEditModal.value.sessionId || '').trim()
|
|
const messageId = String(messageEditModal.value.messageId || '').trim()
|
|
if (!account || !sessionId || !messageId) return
|
|
|
|
messageEditModal.value = { ...messageEditModal.value, saving: true, error: '' }
|
|
try {
|
|
const response = await api.editChatMessage({
|
|
account,
|
|
session_id: sessionId,
|
|
message_id: messageId,
|
|
edits: {
|
|
message_content: String(messageEditModal.value.draft ?? '')
|
|
},
|
|
unsafe: false
|
|
})
|
|
|
|
if (response?.updated_message) {
|
|
try {
|
|
const updated = normalizeMessage(response.updated_message)
|
|
const username = String(selectedContact.value?.username || '').trim()
|
|
const list = allMessages.value[username] || []
|
|
const index = list.findIndex((message) => String(message?.id || '') === String(updated?.id || ''))
|
|
if (index >= 0) {
|
|
const next = [...list]
|
|
next[index] = updated
|
|
allMessages.value = { ...allMessages.value, [username]: next }
|
|
} else {
|
|
await refreshSelectedMessages()
|
|
}
|
|
} catch {
|
|
await refreshSelectedMessages()
|
|
}
|
|
} else {
|
|
await refreshSelectedMessages()
|
|
}
|
|
|
|
closeMessageEditModal()
|
|
} catch (error) {
|
|
messageEditModal.value = { ...messageEditModal.value, saving: false, error: error?.message || '保存失败' }
|
|
return
|
|
} finally {
|
|
messageEditModal.value = { ...messageEditModal.value, saving: false }
|
|
}
|
|
}
|
|
|
|
const closeMessageFieldsModal = () => {
|
|
messageFieldsModal.value = initialMessageFieldsModal()
|
|
}
|
|
|
|
const openMessageFieldsModal = async (message) => {
|
|
if (!process.client) return
|
|
const account = String(selectedAccount.value || '').trim()
|
|
const sessionId = String(selectedContact.value?.username || '').trim()
|
|
const messageId = String(message?.id || '').trim()
|
|
if (!account || !sessionId || !messageId) return
|
|
|
|
messageFieldsModal.value = {
|
|
open: true,
|
|
loading: true,
|
|
saving: false,
|
|
error: '',
|
|
sessionId,
|
|
messageId,
|
|
unsafe: false,
|
|
editsJson: '',
|
|
rawRow: null
|
|
}
|
|
|
|
try {
|
|
const response = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId })
|
|
const row = response?.row || null
|
|
const seed = {}
|
|
for (const key of ['message_content', 'local_type', 'create_time', 'server_id', 'origin_source', 'source']) {
|
|
if (row && Object.prototype.hasOwnProperty.call(row, key)) seed[key] = row[key]
|
|
}
|
|
messageFieldsModal.value = {
|
|
...messageFieldsModal.value,
|
|
loading: false,
|
|
rawRow: row,
|
|
editsJson: JSON.stringify(seed, null, 2)
|
|
}
|
|
} catch (error) {
|
|
messageFieldsModal.value = { ...messageFieldsModal.value, loading: false, error: error?.message || '加载失败' }
|
|
}
|
|
}
|
|
|
|
const saveMessageFieldsModal = async () => {
|
|
if (!process.client) return
|
|
if (messageFieldsModal.value.saving || messageFieldsModal.value.loading) return
|
|
|
|
const account = String(selectedAccount.value || '').trim()
|
|
const sessionId = String(messageFieldsModal.value.sessionId || '').trim()
|
|
const messageId = String(messageFieldsModal.value.messageId || '').trim()
|
|
if (!account || !sessionId || !messageId) return
|
|
|
|
let edits = null
|
|
try {
|
|
edits = JSON.parse(String(messageFieldsModal.value.editsJson || '').trim() || 'null')
|
|
} catch {
|
|
messageFieldsModal.value = { ...messageFieldsModal.value, error: 'JSON 格式错误' }
|
|
return
|
|
}
|
|
if (!edits || typeof edits !== 'object' || Array.isArray(edits)) {
|
|
messageFieldsModal.value = { ...messageFieldsModal.value, error: 'edits 必须是 JSON 对象' }
|
|
return
|
|
}
|
|
if (!Object.keys(edits).length) {
|
|
messageFieldsModal.value = { ...messageFieldsModal.value, error: 'edits 不能为空' }
|
|
return
|
|
}
|
|
|
|
messageFieldsModal.value = { ...messageFieldsModal.value, saving: true, error: '' }
|
|
try {
|
|
await api.editChatMessage({
|
|
account,
|
|
session_id: sessionId,
|
|
message_id: messageId,
|
|
edits,
|
|
unsafe: !!messageFieldsModal.value.unsafe
|
|
})
|
|
await refreshSelectedMessages()
|
|
closeMessageFieldsModal()
|
|
} catch (error) {
|
|
messageFieldsModal.value = { ...messageFieldsModal.value, saving: false, error: error?.message || '保存失败' }
|
|
return
|
|
} finally {
|
|
messageFieldsModal.value = { ...messageFieldsModal.value, saving: false }
|
|
}
|
|
}
|
|
|
|
const copyTextToClipboard = async (text) => {
|
|
if (!process.client) return false
|
|
|
|
const value = String(text ?? '').trim()
|
|
if (!value) return false
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(value)
|
|
return true
|
|
} catch {}
|
|
|
|
try {
|
|
const element = document.createElement('textarea')
|
|
element.value = value
|
|
element.setAttribute('readonly', 'true')
|
|
element.style.position = 'fixed'
|
|
element.style.left = '-9999px'
|
|
element.style.top = '-9999px'
|
|
document.body.appendChild(element)
|
|
element.select()
|
|
const ok = document.execCommand('copy')
|
|
document.body.removeChild(element)
|
|
if (ok) return true
|
|
} catch {}
|
|
|
|
try {
|
|
window.prompt('复制内容:', value)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
const onCopyMessageTextClick = async () => {
|
|
if (!process.client) return
|
|
const message = contextMenu.value.message
|
|
if (!message) return
|
|
try {
|
|
const text = String(message?.content || '').trim()
|
|
if (!text) {
|
|
window.alert('该消息没有可复制的文本')
|
|
return
|
|
}
|
|
const ok = await copyTextToClipboard(text)
|
|
if (!ok) window.alert('复制失败:无法写入剪贴板')
|
|
} catch {
|
|
window.alert('复制失败')
|
|
} finally {
|
|
closeContextMenu()
|
|
}
|
|
}
|
|
|
|
const onCopyMessageJsonClick = async () => {
|
|
if (!process.client) return
|
|
const message = contextMenu.value.message
|
|
if (!message) return
|
|
try {
|
|
const raw = toRaw(message) || message
|
|
const json = JSON.stringify(raw, (_key, value) => (typeof value === 'bigint' ? value.toString() : value), 2)
|
|
const ok = await copyTextToClipboard(json)
|
|
if (!ok) window.alert('复制失败:无法写入剪贴板')
|
|
} catch {
|
|
window.alert('复制失败')
|
|
} finally {
|
|
closeContextMenu()
|
|
}
|
|
}
|
|
|
|
const onOpenFolderClick = async () => {
|
|
if (contextMenu.value.disabled) return
|
|
const message = contextMenu.value.message
|
|
const kind = contextMenu.value.kind
|
|
|
|
try {
|
|
if (!selectedAccount.value || !selectedContact.value?.username) return
|
|
|
|
const params = {
|
|
account: selectedAccount.value,
|
|
username: selectedContact.value.username,
|
|
kind
|
|
}
|
|
|
|
if (kind === 'voice') {
|
|
params.server_id = message.serverIdStr || message.serverId
|
|
} else if (kind === 'file') {
|
|
params.md5 = message.fileMd5
|
|
} else if (kind === 'image') {
|
|
if (message.imageMd5) params.md5 = message.imageMd5
|
|
else if (message.imageFileId) params.file_id = message.imageFileId
|
|
} else if (kind === 'emoji') {
|
|
params.md5 = message.emojiMd5
|
|
} else if (kind === 'video') {
|
|
params.md5 = message.videoMd5
|
|
if (message.videoFileId) params.file_id = message.videoFileId
|
|
} else if (kind === 'video_thumb') {
|
|
params.md5 = message.videoThumbMd5
|
|
if (message.videoThumbFileId) params.file_id = message.videoThumbFileId
|
|
}
|
|
|
|
await api.openChatMediaFolder(params)
|
|
} finally {
|
|
closeContextMenu()
|
|
}
|
|
}
|
|
|
|
const onEditMessageClick = async () => {
|
|
const message = contextMenu.value.message
|
|
if (!message) return
|
|
const mode = isLikelyTextMessage(message) ? 'content' : 'raw'
|
|
closeContextMenu()
|
|
await openMessageEditModal({ message, mode })
|
|
}
|
|
|
|
const onEditMessageFieldsClick = async () => {
|
|
const message = contextMenu.value.message
|
|
if (!message) return
|
|
closeContextMenu()
|
|
await openMessageFieldsModal(message)
|
|
}
|
|
|
|
const onResetEditedMessageClick = async () => {
|
|
if (!process.client) return
|
|
const message = contextMenu.value.message
|
|
const account = String(selectedAccount.value || '').trim()
|
|
const sessionId = String(selectedContact.value?.username || '').trim()
|
|
const messageId = String(message?.id || '').trim()
|
|
if (!message || !account || !sessionId || !messageId) return
|
|
|
|
const ok = window.confirm('确认恢复该条消息到首次快照吗?')
|
|
if (!ok) return
|
|
|
|
try {
|
|
await api.resetChatEditedMessage({ account, session_id: sessionId, message_id: messageId })
|
|
closeContextMenu()
|
|
await refreshSelectedMessages()
|
|
} catch (error) {
|
|
window.alert(error?.message || '恢复失败')
|
|
} finally {
|
|
closeContextMenu()
|
|
}
|
|
}
|
|
|
|
const onRepairMessageSenderAsMeClick = async () => {
|
|
if (!process.client) return
|
|
const message = contextMenu.value.message
|
|
const account = String(selectedAccount.value || '').trim()
|
|
const sessionId = String(selectedContact.value?.username || '').trim()
|
|
const messageId = String(message?.id || '').trim()
|
|
if (!message || !account || !sessionId || !messageId) return
|
|
|
|
const ok = window.confirm('确认将该消息修复为“我发送”吗?这会修改 real_sender_id 字段。')
|
|
if (!ok) return
|
|
|
|
try {
|
|
await api.repairChatMessageSender({ account, session_id: sessionId, message_id: messageId, mode: 'me' })
|
|
closeContextMenu()
|
|
await refreshSelectedMessages()
|
|
} catch (error) {
|
|
window.alert(error?.message || '修复失败')
|
|
} finally {
|
|
closeContextMenu()
|
|
}
|
|
}
|
|
|
|
const onFlipWechatMessageDirectionClick = async () => {
|
|
if (!process.client) return
|
|
const message = contextMenu.value.message
|
|
const account = String(selectedAccount.value || '').trim()
|
|
const sessionId = String(selectedContact.value?.username || '').trim()
|
|
const messageId = String(message?.id || '').trim()
|
|
if (!message || !account || !sessionId || !messageId) return
|
|
|
|
const ok = window.confirm(
|
|
'确认反转该消息在微信客户端的左右气泡位置吗?\n\n这会修改 packed_info_data 字段(有风险)。\n可通过“恢复原消息”撤销。'
|
|
)
|
|
if (!ok) return
|
|
|
|
try {
|
|
await api.flipChatMessageDirection({ account, session_id: sessionId, message_id: messageId })
|
|
closeContextMenu()
|
|
await refreshSelectedMessages()
|
|
} catch (error) {
|
|
window.alert(error?.message || '反转失败')
|
|
} finally {
|
|
closeContextMenu()
|
|
}
|
|
}
|
|
|
|
const onLocateQuotedMessageClick = async () => {
|
|
const message = contextMenu.value.message
|
|
if (!message?.quoteServerId) return
|
|
closeContextMenu()
|
|
const ok = await locateMessageByServerId(message.quoteServerId)
|
|
if (!ok && process.client) {
|
|
window.alert('定位引用消息失败')
|
|
}
|
|
}
|
|
|
|
return {
|
|
contextMenu,
|
|
contextMenuElement,
|
|
messageEditModal,
|
|
messageFieldsModal,
|
|
closeContextMenu,
|
|
openMediaContextMenu,
|
|
prettyJson,
|
|
isLikelyTextMessage,
|
|
closeMessageEditModal,
|
|
openMessageEditModal,
|
|
saveMessageEditModal,
|
|
closeMessageFieldsModal,
|
|
openMessageFieldsModal,
|
|
saveMessageFieldsModal,
|
|
copyTextToClipboard,
|
|
onCopyMessageTextClick,
|
|
onCopyMessageJsonClick,
|
|
onOpenFolderClick,
|
|
onEditMessageClick,
|
|
onEditMessageFieldsClick,
|
|
onResetEditedMessageClick,
|
|
onRepairMessageSenderAsMeClick,
|
|
onFlipWechatMessageDirectionClick,
|
|
onLocateQuotedMessageClick
|
|
}
|
|
}
|