diff --git a/frontend/components/EditedMessagePreview.vue b/frontend/components/EditedMessagePreview.vue new file mode 100644 index 0000000..e698a38 --- /dev/null +++ b/frontend/components/EditedMessagePreview.vue @@ -0,0 +1,134 @@ + + + diff --git a/frontend/components/SidebarRail.vue b/frontend/components/SidebarRail.vue index 0d3d84d..e7273b0 100644 --- a/frontend/components/SidebarRail.vue +++ b/frontend/components/SidebarRail.vue @@ -33,6 +33,22 @@ + +
+
+
+ +
+
+
+
{ }) 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 }) } - diff --git a/frontend/composables/useApi.js b/frontend/composables/useApi.js index fe84ac3..ffe5601 100644 --- a/frontend/composables/useApi.js +++ b/frontend/composables/useApi.js @@ -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, diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index bbe6e30..963fda3 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -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 } } diff --git a/frontend/pages/chat/[[username]].vue b/frontend/pages/chat/[[username]].vue index 44ff0af..23c7f6d 100644 --- a/frontend/pages/chat/[[username]].vue +++ b/frontend/pages/chat/[[username]].vue @@ -326,6 +326,20 @@ + + +
+ + + + + + +
检查修改状态…
+
+ + +
+
+
+
+
{{ messageEditModal.mode === 'content' ? '修改消息' : '编辑源码' }}
+ +
+ +
+
{{ messageEditModal.error }}
+
加载中…
+ + + +
+ 查看源消息(raw) +
+
{{ prettyJson(messageEditModal.rawRow) }}
+
+
+
+ +
+ + +
+
+
+ + +
+
+
+
+
字段编辑
+ +
+ +
+
{{ messageFieldsModal.error }}
+
加载中…
+ +
+ +
修改时间/类型会自动同步 message_resource 关键字段
+
+ + + +
+ 查看源消息(raw) +
+
{{ prettyJson(messageFieldsModal.rawRow) }}
+
+
+
+ +
+ + +
+
@@ -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) } diff --git a/frontend/pages/edits/[[username]].vue b/frontend/pages/edits/[[username]].vue new file mode 100644 index 0000000..2561172 --- /dev/null +++ b/frontend/pages/edits/[[username]].vue @@ -0,0 +1,726 @@ + + + + + + + +