Files
2977094657 458a19a55d fix(chat-ui): 修复右键菜单越界和上下文模式状态丢失
- 为聊天右键菜单增加视口内重定位,避免菜单显示到屏幕外
- 在异步加载编辑状态后重新计算位置,并限制菜单高度支持滚动
- 补回 searchContext 状态传递,恢复上下文模式下的筛选禁用和提示横幅
2026-03-16 12:34:58 +08:00

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
}
}