From 2ecaa28091b41de707825db3628d380b62fa727f Mon Sep 17 00:00:00 2001 From: nanxun Date: Wed, 25 Feb 2026 20:54:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96electron=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/pc/IM/src/main/index.js | 8 +- frontend/pc/IM/src/main/ipcHandlers/window.js | 65 ++- frontend/pc/IM/src/preload/index.js | 5 +- frontend/pc/IM/src/renderer/src/App.vue | 9 - .../src/components/WindowControls.vue | 16 +- .../src/components/electron/ImagePreview.vue | 39 ++ .../pc/IM/src/renderer/src/router/index.js | 3 + .../pc/IM/src/renderer/src/stores/signalr.js | 2 - .../pc/IM/src/renderer/src/views/Main.vue | 11 +- .../pc/IM/src/renderer/src/views/Test.vue | 340 ++++++++------- .../messageContent/MessageContent.vue | 393 ++++++++++-------- 11 files changed, 505 insertions(+), 386 deletions(-) create mode 100644 frontend/pc/IM/src/renderer/src/components/electron/ImagePreview.vue diff --git a/frontend/pc/IM/src/main/index.js b/frontend/pc/IM/src/main/index.js index 00e3911..1471672 100644 --- a/frontend/pc/IM/src/main/index.js +++ b/frontend/pc/IM/src/main/index.js @@ -27,6 +27,11 @@ function createWindow() { mainWindow.show() }) + mainWindow.on('close', (event) => { + event.preventDefault(); + mainWindow.hide() + }) + mainWindow.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return { action: 'deny' } @@ -75,9 +80,10 @@ app.whenReady().then(() => { // explicitly with Cmd + Q. app.on('window-all-closed', () => { if (process.platform !== 'darwin') { - app.quit() + //app.quit() } }) + // In this file you can include the rest of your app's specific main process // code. You can also put them in separate files and require them here. diff --git a/frontend/pc/IM/src/main/ipcHandlers/window.js b/frontend/pc/IM/src/main/ipcHandlers/window.js index 828cb62..2b71798 100644 --- a/frontend/pc/IM/src/main/ipcHandlers/window.js +++ b/frontend/pc/IM/src/main/ipcHandlers/window.js @@ -1,15 +1,68 @@ -import { ipcMain, BrowserWindow } from "electron"; +import { ipcMain, BrowserWindow } from 'electron' +import icon from '../../../resources/icon.png?asset' +import { join } from 'path' +import { is } from '@electron-toolkit/utils' + +export function registerWindowHandler() { + const windowMapData = new Map() -export function registerWindowHandler(){ ipcMain.on('window-action', (event, action) => { - const win = BrowserWindow.fromWebContents(event.sender); - if (!win) return; + const win = BrowserWindow.fromWebContents(event.sender) + if (!win) return const actions = { minimize: () => win.minimize(), maximize: () => (win.isMaximized() ? win.unmaximize() : win.maximize()), close: () => win.hide(), + closeThis: () => { + const mainWin = BrowserWindow.fromId(1); // 假设 ID 1 是主窗口 + const win = BrowserWindow.fromWebContents(event.sender) + if(win.id != mainWin?.id){ + win.destroy() + } + }, isMaximized: () => win.isMaximized() - }; - actions[action]?.(); + } + actions[action]?.() + }) + ipcMain.on('window-new', (event, { route, data }) => { + const win = new BrowserWindow({ + width: 900, + height: 670, + show: true, + autoHideMenuBar: true, + frame: false, + ...(process.platform === 'linux' ? { icon } : {}), // Linux 必须在这里设 + icon: join(__dirname, '../../../resources/icon.png'), // Windows 开发环境预览 + webPreferences: { + preload: join(__dirname, '../preload/index.js'), + sandbox: false + } + }) + + const winId = win.id + windowMapData.set(winId, data) + + // 窗口关闭时,记得清理内存,防止内存泄漏 + win.on('closed', () => { + windowMapData.delete(winId) + }) + + // 构建 Query 参数 + const queryStr = `?winId=${winId}` + + if (is.dev && process.env['ELECTRON_RENDERER_URL']) { + // 开发环境:通常使用 Hash 路由 (如 http://localhost:5173/#/your-route?winId=1) + win.loadURL(`${process.env['ELECTRON_RENDERER_URL']}#/${route}${queryStr}`) + } else { + // 生产环境:loadFile 必须通过 hash 参数来传递路由和参数 + // 注意:join 会合并路径,hash 部分需要单独传给 options + win.loadFile(join(__dirname, '../../renderer/index.html'), { + hash: `${route}${queryStr}` + }) + } +}) + // 3. 增加数据索要接口 + ipcMain.handle('get-window-data', (event, winId) => { + return windowMapData.get(Number(winId)) }) } diff --git a/frontend/pc/IM/src/preload/index.js b/frontend/pc/IM/src/preload/index.js index 737bed4..923143a 100644 --- a/frontend/pc/IM/src/preload/index.js +++ b/frontend/pc/IM/src/preload/index.js @@ -7,7 +7,10 @@ const api = { minimize: () => ipcRenderer.send('window-action', 'minimize'), maximize: () => ipcRenderer.send('window-action', 'maximize'), close: () => ipcRenderer.send('window-action', 'close'), - isMaximized: () => ipcRenderer.send('window-action', 'isMaximized') + closeThis: () => ipcRenderer.send('window-action', 'closeThis'), + isMaximized: () => ipcRenderer.send('window-action', 'isMaximized'), + newWindow: (route, data) => ipcRenderer.send('window-new', { route, data }), + getWindowData: (winId) => ipcRenderer.invoke('get-window-data', winId) } } diff --git a/frontend/pc/IM/src/renderer/src/App.vue b/frontend/pc/IM/src/renderer/src/App.vue index 103066d..1290f45 100644 --- a/frontend/pc/IM/src/renderer/src/App.vue +++ b/frontend/pc/IM/src/renderer/src/App.vue @@ -10,15 +10,6 @@ import Alert from '@/components/messages/Alert.vue'; import { onMounted } from 'vue'; import { useAuthStore } from './stores/auth'; //import { useSignalRStore } from './stores/signalr'; - -onMounted(async () => { - const { useSignalRStore } = await import('./stores/signalr'); - const authStore = useAuthStore(); - const signalRStore = useSignalRStore(); - if(authStore.token){ - signalRStore.initSignalR(); - } -}) diff --git a/frontend/pc/IM/src/renderer/src/router/index.js b/frontend/pc/IM/src/renderer/src/router/index.js index 0810e24..7544bdb 100644 --- a/frontend/pc/IM/src/renderer/src/router/index.js +++ b/frontend/pc/IM/src/renderer/src/router/index.js @@ -70,6 +70,9 @@ const routes = [ meta: { requiresAuth: true } }, { path: '/test', component: TestView }, + { + path: '/imgpre', component: () => import('@/components/electron/ImagePreview.vue') + } ] const router = createRouter({ diff --git a/frontend/pc/IM/src/renderer/src/stores/signalr.js b/frontend/pc/IM/src/renderer/src/stores/signalr.js index 3b4f66d..d6badf1 100644 --- a/frontend/pc/IM/src/renderer/src/stores/signalr.js +++ b/frontend/pc/IM/src/renderer/src/stores/signalr.js @@ -6,8 +6,6 @@ import { useChatStore } from "./chat"; import { authService } from "@/services/auth"; import { generateSessionId } from "@/utils/sessionIdTools"; import { messageHandler } from "@/handler/messageHandler"; -import { useBrowserNotification } from "@/services/useBrowserNotification"; -import { useConversationStore } from "./conversation"; import { SignalRMessageHandler } from "@/utils/signalr/SignalMessageHandler"; import { signalRConnectionEventHandler } from "@/utils/signalr/signalRConnectionEventHandler"; diff --git a/frontend/pc/IM/src/renderer/src/views/Main.vue b/frontend/pc/IM/src/renderer/src/views/Main.vue index 8bfe2ea..cfc15a3 100644 --- a/frontend/pc/IM/src/renderer/src/views/Main.vue +++ b/frontend/pc/IM/src/renderer/src/views/Main.vue @@ -20,7 +20,7 @@ diff --git a/frontend/pc/IM/src/renderer/src/views/messages/messageContent/MessageContent.vue b/frontend/pc/IM/src/renderer/src/views/messages/messageContent/MessageContent.vue index 192f833..63d17fc 100644 --- a/frontend/pc/IM/src/renderer/src/views/messages/messageContent/MessageContent.vue +++ b/frontend/pc/IM/src/renderer/src/views/messages/messageContent/MessageContent.vue @@ -4,120 +4,108 @@
{{ conversationInfo?.targetName || '未选择会话' }}
- - - + + +
-
+
- - - - - -
+ + + + + +
-
-
- 正在播放视频 - -
+
+
+ 正在播放视频 + +
-
- -
-
+
+ +
+
-
-
-
- -
- - -
-
{{ m.senderName }}
-
-
{{ m.content }}
-
{{ m.content }}
-
- 图片消息 - -
-
-
- - - - - {{ m.progress || 0 }}% -
-
- - -
-
- - -
- -
+ + + +
+ + +
+
{{ + m.senderName }}
+
+
{{ m.content }}
+
{{ m.content }}
+
+ 图片消息 + +
+
+
+ + + + + {{ m.progress || 0 }}% +
+
+ + +
+
+ + +
+ + +
- {{ formatDate(m.timeStamp) }} + {{ formatDate(m.timeStamp) }} +
-
-
-
- - - -
- -
- -
-
- +
+
+ + + +
+ +
+ +
+
+
@@ -150,10 +138,10 @@ import { isElectron } from '../../../utils/electronHelper'; const props = defineProps({ - id:{ - type: String, - required:true - } + id: { + type: String, + required: true + } }) const infoSideBarShow = ref(false); @@ -162,7 +150,7 @@ const chatStore = useChatStore(); const signalRStore = useSignalRStore(); const conversationStore = useConversationStore(); const message = useMessage(); -const {sendMessage, sendFileMessage, sendTextMessage} = useSendMessageHandler(); +const { sendMessage, sendFileMessage, sendTextMessage } = useSendMessageHandler(); const input = ref(''); // 输入框内容 const historyRef = ref(null); // 绑定 DOM 用于滚动 @@ -186,10 +174,10 @@ const videoUrl = ref(null); const videoOpen = ref(false) const infoShowHandler = () => { - if(infoSideBarShow.value) - infoSideBarShow.value = false; -else -infoSideBarShow.value =true; + if (infoSideBarShow.value) + infoSideBarShow.value = false; + else + infoSideBarShow.value = true; } @@ -221,22 +209,31 @@ const getImageStyle = (content) => { const imagePreview = (m) => { const imageList = chatStore.messages - .filter(x => x.type == 'Image') - ; + .filter(x => x.type == 'Image') + ; const index = imageList.indexOf(m); - previewImages({ - imgList: imageList.map(m => m.content.url), - nowImgIndex: index - }); + if (isElectron()) { + const safeData = JSON.parse(JSON.stringify( { + imageList, + index + })); + window.api.window.newWindow('imgpre',safeData); + } else { + previewImages({ + imgList: imageList.map(m => m.content.url), + nowImgIndex: index + }); + } + } const startRecord = async () => { - try{ - const stream = await navigator.mediaDevices.getUserMedia({audio:true}); + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); mediaRecorder = new MediaRecorder(stream); audioChunks = []; mediaRecorder.ondataavailable = e => { - if(e.data.size > 0) audioChunks.push(e.data); + if (e.data.size > 0) audioChunks.push(e.data); } // 录音结束后的处理 mediaRecorder.onstop = () => { @@ -251,13 +248,13 @@ const startRecord = async () => { }); handleFile([audioFile]).finally(() => { // 停止所有轨道以关闭麦克风图标 - stream.getTracks().forEach(track => track.stop()); + stream.getTracks().forEach(track => track.stop()); }); }; mediaRecorder.start(); isRecord.value = true; - }catch(e){ + } catch (e) { console.log(e) message.error('无法获取麦克风权限!'); } @@ -330,15 +327,15 @@ const handleRightClick = (e, m) => { }, { label: '多选', - action: () => {} + action: () => { } }, { label: '翻译', - action: () => {} + action: () => { } }, { label: '引用', - action: () => {} + action: () => { } }, { label: '删除', @@ -356,7 +353,7 @@ watch( conversationStore.conversations.find(x => x.id == conversationInfo.value.id).unreadCount = 0; signalRStore.clearUnreadCount(conversationInfo.value.id); }, - {deep: true} + { deep: true } ); // 自动滚动到底部 @@ -387,30 +384,30 @@ async function handleFile(files) { let info = {}; let localUrl = null; let img = null; - switch(getMessageType(file.type)){ + switch (getMessageType(file.type)) { case FILE_TYPE.Image: localUrl = URL.createObjectURL(file); img = await loadImage(localUrl); info = new ImageInfo(file.type, '[图片]', img.width, img.height, await generateImageThumbnailBlob(await loadImage(localUrl), 200)); break; - case FILE_TYPE.Video: { - const imgBlob = await getVideoThumbnailBlob(file); - localUrl = URL.createObjectURL(imgBlob); - img = await loadImage(localUrl); + case FILE_TYPE.Video: { + const imgBlob = await getVideoThumbnailBlob(file); + localUrl = URL.createObjectURL(imgBlob); + img = await loadImage(localUrl); - info = new VideoInfo(file.type,'[视频]', img.width, img.height, imgBlob, await getVideoDuration(file)); - break; - } - case FILE_TYPE.Voice: { - localUrl = URL.createObjectURL(file); - info = new VoiceInfo(file.type,'[语音消息]', await getVideoDuration(file)); - break; - } + info = new VideoInfo(file.type, '[视频]', img.width, img.height, imgBlob, await getVideoDuration(file)); + break; + } + case FILE_TYPE.Voice: { + localUrl = URL.createObjectURL(file); + info = new VoiceInfo(file.type, '[语音消息]', await getVideoDuration(file)); + break; + } } - await sendFileMessage(file, conversationInfo,info,localUrl); + await sendFileMessage(file, conversationInfo, info, localUrl); } function toggleEmoji() { @@ -422,20 +419,20 @@ async function loadConversation(conversationId) { const res = await messageService.getConversationById(conversationId); conversationInfo.value = res.data; */ - if(conversationStore.conversations.length == 0){ - await conversationStore.loadUserConversations(); - } - conversationInfo.value = conversationStore.conversations.find(x => x.id == Number(conversationId)); + if (conversationStore.conversations.length == 0) { + await conversationStore.loadUserConversations(); + } + conversationInfo.value = conversationStore.conversations.find(x => x.id == Number(conversationId)); } const initChat = async (newId) => { await loadConversation(newId); - if(conversationInfo.value){ + if (conversationInfo.value) { const sessionid = generateSessionId( - conversationInfo.value.userId, conversationInfo.value.targetId, conversationInfo.value.chatType == MESSAGE_TYPE.GROUP) - await chatStore.swtichSession(sessionid,newId); - isFinished.value = false; - scrollToBottom(); + conversationInfo.value.userId, conversationInfo.value.targetId, conversationInfo.value.chatType == MESSAGE_TYPE.GROUP) + await chatStore.swtichSession(sessionid, newId); + isFinished.value = false; + scrollToBottom(); } } @@ -446,7 +443,7 @@ watch( () => { // 如果没有 ID,或者 Store 还没加载,返回一个安全的默认值 if (!conversationStore.conversations.length) { - return [props.id,null]; + return [props.id, null]; } // 尝试找到当前会话 @@ -465,9 +462,9 @@ watch( // 场景 A:路由切换 (ID 变了) // 这里的逻辑是:只要切了 ID,先加载本地的给用户看 //if (newId !== oldId) { - // 注意:这里只是加载本地缓存,不负责去服务器拉新 - // 真正的“拉新”逻辑交给下面的 isInited 判断 - await initChat(newId); + // 注意:这里只是加载本地缓存,不负责去服务器拉新 + // 真正的“拉新”逻辑交给下面的 isInited 判断 + await initChat(newId); //} // 场景 B:检测到需要补洞 (isInited 变为 false) @@ -482,23 +479,23 @@ watch( const msgList = await chatStore.fetchNewMsgFromServier(newId); const session = conversationStore.conversations.find(x => x.id == Number(newId)); - if(msgList && msgList.length > 0){ - const minSequenceId = Math.min(...msgList.map(m => m.sequenceId)); - const locaMaxSequenceId = chatStore.maxSequenceId; - if(locaMaxSequenceId < (minSequenceId - 1)){ - chatStore.messages = [];; - } - await chatStore.pushAndSortMessagesAsync(msgList, generateSessionId(session.userId, session.targetId, session.chatType == MESSAGE_TYPE.GROUP), true); + if (msgList && msgList.length > 0) { + const minSequenceId = Math.min(...msgList.map(m => m.sequenceId)); + const locaMaxSequenceId = chatStore.maxSequenceId; + if (locaMaxSequenceId < (minSequenceId - 1)) { + chatStore.messages = [];; } - // 3. 如果有新消息,存入 Store + await chatStore.pushAndSortMessagesAsync(msgList, generateSessionId(session.userId, session.targetId, session.chatType == MESSAGE_TYPE.GROUP), true); + } + // 3. 如果有新消息,存入 Store // 4. 关键步骤:手动把状态回正! // 必须重新 find 一次对象,确保引用是对的 if (session) { - session.isInitialized = true; - console.log(`[同步完成] 会话 ${newId} 状态已重置为 true`); + session.isInitialized = true; + console.log(`[同步完成] 会话 ${newId} 状态已重置为 true`); } } } catch (err) { @@ -509,14 +506,14 @@ watch( ); const initObs = async () => { -await nextTick(); + await nextTick(); observer = new IntersectionObserver((entries) => { const entry = entries[0]; // 只有当 Loading 组件进入视口,且满足触发条件 if (entry.isIntersecting) { - loadHistoryMsg(); - } + loadHistoryMsg(); + } }, { root: historyRef.value, // 指定监听容器 threshold: 0.1 // 露出 10% 就算触发 @@ -527,7 +524,7 @@ await nextTick(); // 如果 sentinel 是组件,需要拿它底下的 $el observer.observe(loadingRef.value.$el || loadingRef.value); } -// 3. 【主动侦测逻辑】 + // 3. 【主动侦测逻辑】 // 如果当前没有在加载,且还没有结束,且内容还没有撑开容器 const el = historyRef.value; if (el && !isLoading.value && !isFinished.value) { @@ -555,7 +552,8 @@ onUnmounted(() => { .chat-panel { display: flex; flex-direction: column; - height: 100%; /* 确保占满父容器 */ + height: 100%; + /* 确保占满父容器 */ background: #f5f5f5; } @@ -584,6 +582,7 @@ onUnmounted(() => { border-bottom: 1px solid #e0e0e0; -webkit-app-region: drag; } + /* 遮罩层:全屏、黑色半透明、固定定位 */ .video-overlay { position: fixed; @@ -595,7 +594,8 @@ onUnmounted(() => { display: flex; align-items: center; justify-content: center; - z-index: 9999; /* 确保在最顶层 */ + z-index: 9999; + /* 确保在最顶层 */ } /* 播放器弹窗主体 */ @@ -606,7 +606,7 @@ onUnmounted(() => { background: #000; border-radius: 8px; overflow: hidden; - box-shadow: 0 10px 30px rgba(0,0,0,0.5); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); } /* 顶部状态栏(包含关闭按钮) */ @@ -637,15 +637,19 @@ onUnmounted(() => { .player-wrapper { width: 100%; - aspect-ratio: 16 / 9; /* 锁定 16:9 比例 */ + aspect-ratio: 16 / 9; + /* 锁定 16:9 比例 */ background: #000; } /* 进场动画 */ -.fade-enter-active, .fade-leave-active { +.fade-enter-active, +.fade-leave-active { transition: opacity 0.3s ease; } -.fade-enter-from, .fade-leave-to { + +.fade-enter-from, +.fade-leave-to { opacity: 0; } @@ -679,15 +683,23 @@ onUnmounted(() => { } @keyframes pulse { - 0% { transform: scale(1); } - 50% { transform: scale(1.1); } - 100% { transform: scale(1); } + 0% { + transform: scale(1); + } + + 50% { + transform: scale(1.1); + } + + 100% { + transform: scale(1); + } } .image-msg { - max-width: 100px; - max-height: 200px; - object-fit: cover; + max-width: 100px; + max-height: 200px; + object-fit: cover; } /* 容器:由计算属性决定宽高 */ @@ -705,14 +717,16 @@ onUnmounted(() => { .image-msg-content { width: 100%; height: 100%; - object-fit: cover; /* 关键:裁剪而非拉伸 */ + object-fit: cover; + /* 关键:裁剪而非拉伸 */ display: block; } /* 覆盖层 */ .image-overlay { position: absolute; - inset: 0; /* 铺满父容器 */ + inset: 0; + /* 铺满父容器 */ background: rgba(0, 0, 0, 0.3); display: flex; flex-direction: column; @@ -727,21 +741,27 @@ onUnmounted(() => { width: 40px; height: 40px; } + .circular-progress svg { transform: rotate(-90deg); } + .circular-progress circle { fill: none; stroke-width: 3; } + .circular-progress .bg { stroke: rgba(255, 255, 255, 0.3); } + .circular-progress .bar { stroke: #ffffff; - stroke-dasharray: 100; /* 这里的 100 对应周长 */ + stroke-dasharray: 100; + /* 这里的 100 对应周长 */ transition: stroke-dashoffset 0.2s; } + .circular-progress .pct { position: absolute; inset: 0; @@ -750,6 +770,7 @@ onUnmounted(() => { justify-content: center; font-size: 10px; } + .error-icon { color: #ff4d4f; } @@ -789,15 +810,18 @@ onUnmounted(() => { from { transform: rotate(0deg); } + to { transform: rotate(360deg); } } .loaderIcon { - display: inline-block; /* 必须是 block 或 inline-block 才能旋转 */ + display: inline-block; + /* 必须是 block 或 inline-block 才能旋转 */ line-height: 0; - animation: spin 1s linear infinite; /* 1秒转一圈,线性速度,无限循环 */ + animation: spin 1s linear infinite; + /* 1秒转一圈,线性速度,无限循环 */ } .status { @@ -809,6 +833,7 @@ onUnmounted(() => { .msg.mine { flex-direction: row-reverse; } + .msg.mine .msg-content { display: flex; justify-content: flex-end;