feature-nxdev #67

Merged
nanxun merged 7 commits from feature-nxdev into main 2026-03-07 13:43:02 +08:00
11 changed files with 505 additions and 386 deletions
Showing only changes of commit 2ecaa28091 - Show all commits
+7 -1
View File
@@ -27,6 +27,11 @@ function createWindow() {
mainWindow.show() mainWindow.show()
}) })
mainWindow.on('close', (event) => {
event.preventDefault();
mainWindow.hide()
})
mainWindow.webContents.setWindowOpenHandler((details) => { mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url) shell.openExternal(details.url)
return { action: 'deny' } return { action: 'deny' }
@@ -75,9 +80,10 @@ app.whenReady().then(() => {
// explicitly with Cmd + Q. // explicitly with Cmd + Q.
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
app.quit() //app.quit()
} }
}) })
// In this file you can include the rest of your app's specific main process // 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. // code. You can also put them in separate files and require them here.
+59 -6
View File
@@ -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) => { ipcMain.on('window-action', (event, action) => {
const win = BrowserWindow.fromWebContents(event.sender); const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return; if (!win) return
const actions = { const actions = {
minimize: () => win.minimize(), minimize: () => win.minimize(),
maximize: () => (win.isMaximized() ? win.unmaximize() : win.maximize()), maximize: () => (win.isMaximized() ? win.unmaximize() : win.maximize()),
close: () => win.hide(), 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() 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))
}) })
} }
+4 -1
View File
@@ -7,7 +7,10 @@ const api = {
minimize: () => ipcRenderer.send('window-action', 'minimize'), minimize: () => ipcRenderer.send('window-action', 'minimize'),
maximize: () => ipcRenderer.send('window-action', 'maximize'), maximize: () => ipcRenderer.send('window-action', 'maximize'),
close: () => ipcRenderer.send('window-action', 'close'), 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)
} }
} }
-9
View File
@@ -10,15 +10,6 @@ import Alert from '@/components/messages/Alert.vue';
import { onMounted } from 'vue'; import { onMounted } from 'vue';
import { useAuthStore } from './stores/auth'; import { useAuthStore } from './stores/auth';
//import { useSignalRStore } from './stores/signalr'; //import { useSignalRStore } from './stores/signalr';
onMounted(async () => {
const { useSignalRStore } = await import('./stores/signalr');
const authStore = useAuthStore();
const signalRStore = useSignalRStore();
if(authStore.token){
signalRStore.initSignalR();
}
})
</script> </script>
<style> <style>
#app { #app {
@@ -6,9 +6,12 @@
</svg> </svg>
</button> </button>
<button class="control-btn maximize" @click="toggleMaximize" title="最大化/还原"> <button class="control-btn maximize" @click="toggleMaximize" title="最大化/还原">
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2"> <svg v-if="!isMaximized" width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.2">
<rect x="1.5" y="1.5" width="7" height="7" v-if="!isMaximized" /> <rect x="1.5" y="1.5" width="7" height="7" />
<path d="M3 1h6v6H3z M1 3h6v6H1z" v-else fill="currentColor" fill-opacity="0.5" /> </svg>
<svg v-else viewBox="0 0 16 16" width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 3.5h6v6" stroke="currentColor" stroke-width="1.4" />
<rect x="4" y="5" width="6.5" height="6.5" stroke="currentColor" stroke-width="1.4" />
</svg> </svg>
</button> </button>
<button class="control-btn close" @click="close" title="关闭"> <button class="control-btn close" @click="close" title="关闭">
@@ -44,16 +47,19 @@ function close() {
<style scoped> <style scoped>
/* 窗口控制按钮 */ /* 窗口控制按钮 */
.window-controls { .window-controls {
z-index: 999;
/* 允许拖动整个窗口 */ /* 允许拖动整个窗口 */
-webkit-app-region: drag; -webkit-app-region: drag;
display: flex; display: flex;
height: 30px; height: 30px;
justify-content: flex-end; justify-content: flex-end;
} }
.control-btn { .control-btn {
/* 允许拖动整个窗口 */ /* 允许拖动整个窗口 */
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
width: 46px; /* 较宽的点击区域,类似 Win10/11 */ width: 46px;
/* 较宽的点击区域,类似 Win10/11 */
height: 100%; height: 100%;
border: none; border: none;
background: transparent; background: transparent;
@@ -64,10 +70,12 @@ function close() {
cursor: default; cursor: default;
transition: all 0.2s; transition: all 0.2s;
} }
.control-btn:hover { .control-btn:hover {
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
color: #1e293b; color: #1e293b;
} }
.control-btn.close:hover { .control-btn.close:hover {
background: #ef4444; background: #ef4444;
color: white; color: white;
@@ -0,0 +1,39 @@
<template>
<WindowControls/>
</template>
<script setup>
import { previewImages } from 'hevue-img-preview/v3'
import {onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import WindowControls from '../WindowControls.vue';
const route = useRoute();
const imageList = ref([]);
const index = ref(0);
onMounted(async () => {
const winId = route.query.winId;
const data = await window.api.window.getWindowData(winId);
imageList.value = data.imageList;
index.value = data.index;
previewImages({
imgList: imageList.value.map(m => m.content.url),
nowImgIndex: index,
clickMaskCLose: false,
disabledImgRightClick:true,
closeBtn: true,
zIndex: 998,
closeFn: () => {
window.api.window.closeThis();
}
});
});
</script>
<style>
</style>
@@ -70,6 +70,9 @@ const routes = [
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{ path: '/test', component: TestView }, { path: '/test', component: TestView },
{
path: '/imgpre', component: () => import('@/components/electron/ImagePreview.vue')
}
] ]
const router = createRouter({ const router = createRouter({
@@ -6,8 +6,6 @@ import { useChatStore } from "./chat";
import { authService } from "@/services/auth"; import { authService } from "@/services/auth";
import { generateSessionId } from "@/utils/sessionIdTools"; import { generateSessionId } from "@/utils/sessionIdTools";
import { messageHandler } from "@/handler/messageHandler"; import { messageHandler } from "@/handler/messageHandler";
import { useBrowserNotification } from "@/services/useBrowserNotification";
import { useConversationStore } from "./conversation";
import { SignalRMessageHandler } from "@/utils/signalr/SignalMessageHandler"; import { SignalRMessageHandler } from "@/utils/signalr/SignalMessageHandler";
import { signalRConnectionEventHandler } from "@/utils/signalr/signalRConnectionEventHandler"; import { signalRConnectionEventHandler } from "@/utils/signalr/signalRConnectionEventHandler";
+10 -1
View File
@@ -20,7 +20,7 @@
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import defaultAvatar from '@/assets/default_avatar.png' import defaultAvatar from '@/assets/default_avatar.png'
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -43,6 +43,15 @@ function handleStartChat(contact) {
console.error('Invalid contact object:', contact); console.error('Invalid contact object:', contact);
} }
} }
onMounted(async () => {
const { useSignalRStore } = await import('../stores/signalr');
const authStore = useAuthStore();
const signalRStore = useSignalRStore();
if(authStore.token){
signalRStore.initSignalR();
}
})
</script> </script>
<style scoped> <style scoped>
+161 -177
View File
@@ -1,222 +1,206 @@
<template> <template>
<div v-if="true" class="modal-mask" @click.self="close"> <Teleport to="body">
<div class="modal-container"> <div v-if="visible" class="mask" @click.self="close">
<div class="modal-header">
<h2>添加好友</h2> <!-- 工具栏 -->
<button class="icon-close" @click="close">×</button> <div class="toolbar" @click.stop>
<button @click="prev" title="上一张" v-html="feather.icons['arrow-left'].toSvg({width:15,height15})"></button>
<button @click="next" title="下一张"></button>
<button @click="zoomOut" title="缩小"></button>
<button @click="zoomIn" title="放大"></button>
<button @click="rotate" title="旋转"></button>
<button @click="download" title="下载"></button>
<button @click="close" title="关闭"></button>
</div> </div>
<div class="search-box"> <!-- 图片 -->
<input <img
type="file" :src="current"
placeholder="搜索 ID / 手机号" class="img"
@change="handleFileChange" :style="style"
@mousedown="onDown"
@wheel.prevent="onWheel"
@dblclick="toggleZoom"
draggable="false"
/> />
<!-- 索引 -->
<div class="indicator">{{ index + 1 }} / {{ list.length }}</div>
<!-- 左右翻页大按钮 -->
<div class="nav left" @click.stop="prev"></div>
<div class="nav right" @click.stop="next"></div>
</div> </div>
</div> </Teleport>
</div>
</template> </template>
<script setup> <script setup>
import { uploadFile } from '@/services/upload/uploader'; import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { ref } from 'vue'; import feather from 'feather-icons'
// 接收父组件的 v-model 用于控制显示隐藏 const props = defineProps({
const props = defineProps(['modelValue']); modelValue: Boolean,
const emit = defineEmits(['update:modelValue', 'add-friend']); list: { type: Array, default: ['http://localhost:5202/uploads/files/IM/2026/02/2/b92f0a4ba0f0.jpg', 'http://localhost:5202/uploads/files/IM/2026/02/2/b92f0a4ba0f0.jpg'] },
start: { type: Number, default: 0 }
})
const keyword = ref(''); const emit = defineEmits(['update:modelValue'])
const loading = ref(false);
const userResult = ref(null);
const hasSearched = ref(false);
const close = () => { const visible = ref(true)
emit('update:modelValue', false); const index = ref(0)
// 关闭时重置状态 const scale = ref(1)
userResult.value = null; const rotateDeg = ref(0)
hasSearched.value = false; const offset = ref({ x: 0, y: 0 })
keyword.value = '';
};
const onSearch = async () => { let dragging = false
if (!keyword.value) return; let startPos = { x: 0, y: 0 }
loading.value = true; watch(() => props.modelValue, v => {
hasSearched.value = false; visible.value = v
if (v) {
// 模拟 API 请求延时 index.value = props.start
setTimeout(() => { reset()
loading.value = false;
hasSearched.value = true;
// 模拟搜索结果
if (keyword.value === '12345') {
userResult.value = {
userId: '12345',
nickname: '极简猫',
avatar: 'https://api.dicebear.com/7.x/beta/svg?seed=Felix'
};
} else {
userResult.value = null;
} }
}, 600); })
};
const current = computed(() => props.list[index.value] || '')
const handleFileChange = async (event) => { const style = computed(() => ({
const file = event.target.files[0]; transform: `translate(${offset.value.x}px, ${offset.value.y}px) scale(${scale.value}) rotate(${rotateDeg.value}deg)`
uploadFile(file, { }))
onProgress: (p) => {
console.log(`当前进度:${p}%`); function reset() {
} scale.value = 1
}); rotateDeg.value = 0
offset.value = { x: 0, y: 0 }
} }
const handleAdd = () => { function close() { emit('update:modelValue', false) }
emit('add-friend', userResult.value); function prev() { if(index.value>0){ index.value--; reset() } }
// 通常这里会显示“已发送请求”提示 function next() { if(index.value<props.list.length-1){ index.value++; reset() } }
close(); function zoomIn() { scale.value = Math.min(scale.value + 0.2, 5) }
}; function zoomOut() { scale.value = Math.max(scale.value - 0.2, 0.3) }
function toggleZoom() { scale.value = scale.value === 1 ? 2 : 1 }
function rotate() { rotateDeg.value = (rotateDeg.value + 90) % 360 }
function onWheel(e){
if(e.ctrlKey){ e.deltaY>0?zoomOut():zoomIn() }
else { e.deltaY>0?next():prev() }
}
function onDown(e){
dragging = true
startPos = { x: e.clientX - offset.value.x, y: e.clientY - offset.value.y }
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
function onMove(e){ if(!dragging) return; offset.value={ x:e.clientX-startPos.x, y:e.clientY-startPos.y } }
function onUp(){ dragging=false; window.removeEventListener('mousemove',onMove); window.removeEventListener('mouseup',onUp) }
async function download(){
try{
const res = await fetch(current.value)
const blob = await res.blob()
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = current.value.split('/').pop()
a.click()
URL.revokeObjectURL(a.href)
}catch(e){ console.error(e) }
}
function onKey(e){
if(!visible.value) return
if(e.key==='Escape') close()
if(e.key==='ArrowLeft') prev()
if(e.key==='ArrowRight') next()
if(e.key==='ArrowUp') zoomIn()
if(e.key==='ArrowDown') zoomOut()
if(e.key==='r'||e.key==='R') rotate()
}
onMounted(()=> window.addEventListener('keydown',onKey))
onUnmounted(()=> window.removeEventListener('keydown',onKey))
</script> </script>
<style scoped> <style scoped>
/* 遮罩层 */ .mask {
.modal-mask {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.3); background: rgba(0,0,0,0.95);
backdrop-filter: blur(8px); z-index: 9999;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 999; overflow: hidden;
} }
/* 容器 */ /* 图片 */
.modal-container { .img {
background: #ffffff; max-width: 90vw;
width: 360px; max-height: 90vh;
border-radius: 24px; cursor: grab;
padding: 24px; user-select: none;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); transition: transform 0.15s ease;
} }
.modal-header { /* 工具栏 */
.toolbar {
position: absolute;
top: 24px;
display: flex; display: flex;
justify-content: space-between; gap: 14px;
margin-bottom: 20px; padding: 12px 16px;
} background: rgba(20,20,20,0.85);
.modal-header h2 {
font-size: 18px;
font-weight: 600;
color: #333;
}
.icon-close {
background: none;
border: none;
font-size: 24px;
color: #ccc;
cursor: pointer;
}
/* 搜索框 */
.search-box {
display: flex;
background: #f5f5f7;
border-radius: 12px; border-radius: 12px;
padding: 4px; box-shadow: 0 8px 24px rgba(0,0,0,0.4);
margin-bottom: 16px;
} }
.search-box input { .toolbar button {
flex: 1; width: 48px;
background: transparent; height: 48px;
border: none; font-size: 22px;
padding: 10px 12px;
outline: none;
font-size: 14px;
}
.search-btn {
background: #007aff; /* 经典的克莱因蓝/苹果蓝 */
color: white;
border: none;
padding: 8px 16px;
border-radius: 10px; border-radius: 10px;
background: rgba(255,255,255,0.08);
color: #fff;
border: none;
cursor: pointer; cursor: pointer;
font-weight: 500; transition: all .15s ease;
} }
.toolbar button:hover { background: rgba(255,255,255,.2); transform: scale(1.05);}
.toolbar button:active { transform: scale(0.95); }
/* 用户卡片 */ /* 左右翻页按钮 */
.user-card { .nav {
background: #fff; position: absolute;
border: 1px solid #f0f0f0; top: 50%;
border-radius: 16px; width: 64px;
padding: 16px; height: 64px;
margin-top: 10px; margin-top: -32px;
} border-radius: 50%;
background: rgba(0,0,0,.6);
.user-info { color: #fff;
font-size: 36px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; justify-content: center;
margin-bottom: 16px;
}
.avatar {
width: 52px;
height: 52px;
border-radius: 50%;
object-fit: cover;
}
.detail {
display: flex;
flex-direction: column;
}
.name {
font-weight: 600;
font-size: 16px;
}
.id {
font-size: 12px;
color: #999;
}
.add-action-btn {
width: 100%;
background: #f0f7ff;
color: #007aff;
border: none;
padding: 10px;
border-radius: 10px;
font-weight: 600;
cursor: pointer; cursor: pointer;
transition: all 0.2s; user-select: none;
} }
.nav.left { left: 32px; }
.nav.right { right: 32px; }
.nav:hover { background: rgba(255,255,255,.2); }
.add-action-btn:hover { /* 底部索引 */
background: #007aff; .indicator {
color: #fff; position: absolute;
} bottom: 28px;
font-size: 16px;
.empty-state { color: #ddd;
text-align: center; background: rgba(0,0,0,.5);
color: #999; padding: 6px 12px;
font-size: 13px; border-radius: 20px;
padding: 20px 0;
}
/* 动画效果 */
.fade-slide-enter-active, .fade-slide-leave-active {
transition: all 0.3s ease;
}
.fade-slide-enter-from, .fade-slide-leave-to {
opacity: 0;
transform: translateY(10px);
} }
</style> </style>
@@ -4,16 +4,20 @@
<header class="chat-header"> <header class="chat-header">
<span class="title">{{ conversationInfo?.targetName || '未选择会话' }}</span> <span class="title">{{ conversationInfo?.targetName || '未选择会话' }}</span>
<div class="actions"> <div class="actions">
<button class="tool-btn" @click="startCall('video')" v-html="feather.icons['video'].toSvg({width:20, height: 20})"></button> <button class="tool-btn" @click="startCall('video')"
<button class="tool-btn" @click="startCall('voice')" v-html="feather.icons['phone-call'].toSvg({width:20, height: 20})"></button> v-html="feather.icons['video'].toSvg({ width: 20, height: 20 })"></button>
<button class="tool-btn" @click="infoShowHandler" v-html="feather.icons['more-vertical'].toSvg({width:20, height: 20})"></button> <button class="tool-btn" @click="startCall('voice')"
v-html="feather.icons['phone-call'].toSvg({ width: 20, height: 20 })"></button>
<button class="tool-btn" @click="infoShowHandler"
v-html="feather.icons['more-vertical'].toSvg({ width: 20, height: 20 })"></button>
</div> </div>
</header> </header>
<div :class="{'main': !isElectron(), 'main-electron':isElectron()}"> <div :class="{ 'main': !isElectron(), 'main-electron': isElectron() }">
<div class="chat-history" ref="historyRef"> <div class="chat-history" ref="historyRef">
<HistoryLoading ref="loadingRef" :loading="isLoading" :finished="isFinished" :error="hasError" @retry="loadHistoryMsg"/> <HistoryLoading ref="loadingRef" :loading="isLoading" :finished="isFinished" :error="hasError"
<UserHoverCard ref="userHoverCardRef"/> @retry="loadHistoryMsg" />
<ContextMenu ref="menuRef"/> <UserHoverCard ref="userHoverCardRef" />
<ContextMenu ref="menuRef" />
<Teleport to="body"> <Teleport to="body">
<Transition name="fade"> <Transition name="fade">
<div v-if="videoOpen" class="video-overlay" @click.self="videoOpen = false"> <div v-if="videoOpen" class="video-overlay" @click.self="videoOpen = false">
@@ -25,12 +29,7 @@
</div> </div>
<div class="player-wrapper"> <div class="player-wrapper">
<vue3-video-player <vue3-video-player :src="videoUrl" poster="https://xxx.jpg" :controls="true" :autoplay="true" />
:src="videoUrl"
poster="https://xxx.jpg"
:controls="true"
:autoplay="true"
/>
</div> </div>
</div> </div>
@@ -39,56 +38,47 @@
</Teleport> </Teleport>
<div v-for="m in chatStore.messages" :key="m.id" :class="['msg', m.senderId == myInfo.id ? 'mine' : 'other']"> <div v-for="m in chatStore.messages" :key="m.id" :class="['msg', m.senderId == myInfo.id ? 'mine' : 'other']">
<img @mouseenter="(e) => handleHoverCard(e,m)" @mouseleave="closeHoverCard" :src="(m.senderId == myInfo.id ? (myInfo?.avatar || defaultAvatar) :m.chatType == MESSAGE_TYPE.GROUP ? m.senderAvatar : conversationInfo?.targetAvatar) ?? defaultAvatar" class="avatar-chat" /> <img @mouseenter="(e) => handleHoverCard(e, m)" @mouseleave="closeHoverCard"
:src="(m.senderId == myInfo.id ? (myInfo?.avatar || defaultAvatar) : m.chatType == MESSAGE_TYPE.GROUP ? m.senderAvatar : conversationInfo?.targetAvatar) ?? defaultAvatar"
class="avatar-chat" />
<div class="msg-content"> <div class="msg-content">
<div class="group-sendername" v-if="m.chatType == MESSAGE_TYPE.GROUP && m.senderId != myInfo.id">{{ m.senderName }}</div> <div class="group-sendername" v-if="m.chatType == MESSAGE_TYPE.GROUP && m.senderId != myInfo.id">{{
<div :class="['bubble', m.type == 'Text' ? 'text-bubble' : '']" @contextmenu.prevent="(e) => handleRightClick(e, m)"> m.senderName }}</div>
<div :class="['bubble', m.type == 'Text' ? 'text-bubble' : '']"
@contextmenu.prevent="(e) => handleRightClick(e, m)">
<div v-if="m.type === 'Text'">{{ m.content }}</div> <div v-if="m.type === 'Text'">{{ m.content }}</div>
<div v-else-if="m.type === 'emoji'" class="emoji-msg">{{ m.content }}</div> <div v-else-if="m.type === 'emoji'" class="emoji-msg">{{ m.content }}</div>
<div v-else-if="m.type === FILE_TYPE.Image" class="image-msg-container" :style="getImageStyle(m.content)"> <div v-else-if="m.type === FILE_TYPE.Image" class="image-msg-container" :style="getImageStyle(m.content)">
<img <img class="image-msg-content" :src="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb"
class="image-msg-content" alt="图片消息" @click="imagePreview(m)">
:src="m.isImgLoading || m.isLoading ? m.localUrl : m.content.thumb"
alt="图片消息" @click="imagePreview(m)"
>
<div v-if="m.isImgLoading || m.isError" class="image-overlay"> <div v-if="m.isImgLoading || m.isError" class="image-overlay">
<div v-if="m.isImgLoading" class="progress-box"> <div v-if="m.isImgLoading" class="progress-box">
<div class="circular-progress"> <div class="circular-progress">
<svg width="40" height="40" viewBox="0 0 40 40"> <svg width="40" height="40" viewBox="0 0 40 40">
<circle class="bg" cx="20" cy="20" r="16" /> <circle class="bg" cx="20" cy="20" r="16" />
<circle <circle class="bar" cx="20" cy="20" r="16"
class="bar" :style="{ strokeDashoffset: 100 - (m.progress || 0) }" />
cx="20" cy="20" r="16"
:style="{ strokeDashoffset: 100 - (m.progress || 0) }"
/>
</svg> </svg>
<span class="pct">{{ m.progress || 0 }}%</span> <span class="pct">{{ m.progress || 0 }}%</span>
</div> </div>
</div> </div>
<i v-if="m.isError" class="error-icon" v-html="feather.icons['alert-circle'].toSvg({width:24, height: 24})"></i> <i v-if="m.isError" class="error-icon"
v-html="feather.icons['alert-circle'].toSvg({ width: 24, height: 24 })"></i>
</div> </div>
</div> </div>
<VideoMsg v-else-if="m.type === FILE_TYPE.Video" <VideoMsg v-else-if="m.type === FILE_TYPE.Video" :thumbnailUrl="m.localUrl ?? m.content.thumb"
:thumbnailUrl="m.localUrl ?? m.content.thumb" :duration="m.content.duration" :w="m.content.w" :h="m.content.h" :uploading="m.isImgLoading"
:duration="m.content.duration" :progress="+m.progress" @play="playHandler(m)" />
:w="m.content.w" <VoiceMsg v-else-if="m.type === FILE_TYPE.Voice" :url="m.localUrl ?? m.content.url"
:h="m.content.h" :duration="m.content.duration" :isRead="true" :isSelf="m.senderId == myInfo.id" />
:uploading="m.isImgLoading"
:progress="+m.progress"
@play="playHandler(m)"
/>
<VoiceMsg v-else-if="m.type === FILE_TYPE.Voice"
:url="m.localUrl ?? m.content.url"
:duration="m.content.duration"
:isRead="true"
:isSelf="m.senderId == myInfo.id"
/>
<div class="status" v-if="m.senderId == myInfo.id"> <div class="status" v-if="m.senderId == myInfo.id">
<i v-if="m.isError" style="color: red;" v-html="feather.icons['alert-circle'].toSvg({width:18, height: 18})"></i> <i v-if="m.isError" style="color: red;"
<i v-if="m.isLoading" class="loaderIcon" v-html="feather.icons['loader'].toSvg({width:18, height: 18})"></i> v-html="feather.icons['alert-circle'].toSvg({ width: 18, height: 18 })"></i>
<i v-if="m.isLoading" class="loaderIcon"
v-html="feather.icons['loader'].toSvg({ width: 18, height: 18 })"></i>
</div> </div>
</div> </div>
@@ -99,25 +89,23 @@
<footer class="chat-footer"> <footer class="chat-footer">
<div class="toolbar"> <div class="toolbar">
<button class="tool-btn" @click="toggleEmoji" v-html="feather.icons['smile'].toSvg({width:25, height: 25})"> <button class="tool-btn" @click="toggleEmoji" v-html="feather.icons['smile'].toSvg({ width: 25, height: 25 })">
</button> </button>
<label class="tool-btn"> <label class="tool-btn">
<i v-html="feather.icons['file'].toSvg({width:25, height: 25})"></i> <i v-html="feather.icons['file'].toSvg({ width: 25, height: 25 })"></i>
<input type="file" hidden @change="handleFile($event.target.files)" /> <input type="file" hidden @change="handleFile($event.target.files)" />
</label> </label>
<button :class="['tool-btn', isRecord ? 'is-recording' : '']" @mousedown="startRecord" @mouseup="stopRecord" v-html="feather.icons[isRecord ? 'mic' : 'mic-off'].toSvg({width:25, height: 25})"> <button :class="['tool-btn', isRecord ? 'is-recording' : '']" @mousedown="startRecord" @mouseup="stopRecord"
v-html="feather.icons[isRecord ? 'mic' : 'mic-off'].toSvg({ width: 25, height: 25 })">
</button> </button>
</div> </div>
<textarea <textarea v-model="input" placeholder="请输入消息..." @keydown.enter.exact.prevent="sendText"></textarea>
v-model="input"
placeholder="请输入消息..."
@keydown.enter.exact.prevent="sendText"
></textarea>
<div class="send-row"> <div class="send-row">
<button class="send-btn" :disabled="!input.trim()" @click="sendText">发送(S)</button> <button class="send-btn" :disabled="!input.trim()" @click="sendText">发送(S)</button>
</div> </div>
</footer> </footer>
<InfoSidebar v-if="infoSideBarShow" class="infoSideBar" :chatType="conversationInfo.chatType ?? null" :groupData="conversationInfo"/> <InfoSidebar v-if="infoSideBarShow" class="infoSideBar" :chatType="conversationInfo.chatType ?? null"
:groupData="conversationInfo" />
</div> </div>
</section> </section>
</template> </template>
@@ -150,9 +138,9 @@ import { isElectron } from '../../../utils/electronHelper';
const props = defineProps({ const props = defineProps({
id:{ id: {
type: String, type: String,
required:true required: true
} }
}) })
@@ -162,7 +150,7 @@ const chatStore = useChatStore();
const signalRStore = useSignalRStore(); const signalRStore = useSignalRStore();
const conversationStore = useConversationStore(); const conversationStore = useConversationStore();
const message = useMessage(); const message = useMessage();
const {sendMessage, sendFileMessage, sendTextMessage} = useSendMessageHandler(); const { sendMessage, sendFileMessage, sendTextMessage } = useSendMessageHandler();
const input = ref(''); // 输入框内容 const input = ref(''); // 输入框内容
const historyRef = ref(null); // 绑定 DOM 用于滚动 const historyRef = ref(null); // 绑定 DOM 用于滚动
@@ -186,10 +174,10 @@ const videoUrl = ref(null);
const videoOpen = ref(false) const videoOpen = ref(false)
const infoShowHandler = () => { const infoShowHandler = () => {
if(infoSideBarShow.value) if (infoSideBarShow.value)
infoSideBarShow.value = false; infoSideBarShow.value = false;
else else
infoSideBarShow.value =true; infoSideBarShow.value = true;
} }
@@ -224,19 +212,28 @@ const imagePreview = (m) => {
.filter(x => x.type == 'Image') .filter(x => x.type == 'Image')
; ;
const index = imageList.indexOf(m); const index = imageList.indexOf(m);
if (isElectron()) {
const safeData = JSON.parse(JSON.stringify( {
imageList,
index
}));
window.api.window.newWindow('imgpre',safeData);
} else {
previewImages({ previewImages({
imgList: imageList.map(m => m.content.url), imgList: imageList.map(m => m.content.url),
nowImgIndex: index nowImgIndex: index
}); });
}
} }
const startRecord = async () => { const startRecord = async () => {
try{ try {
const stream = await navigator.mediaDevices.getUserMedia({audio:true}); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
mediaRecorder = new MediaRecorder(stream); mediaRecorder = new MediaRecorder(stream);
audioChunks = []; audioChunks = [];
mediaRecorder.ondataavailable = e => { mediaRecorder.ondataavailable = e => {
if(e.data.size > 0) audioChunks.push(e.data); if (e.data.size > 0) audioChunks.push(e.data);
} }
// 录音结束后的处理 // 录音结束后的处理
mediaRecorder.onstop = () => { mediaRecorder.onstop = () => {
@@ -257,7 +254,7 @@ const startRecord = async () => {
mediaRecorder.start(); mediaRecorder.start();
isRecord.value = true; isRecord.value = true;
}catch(e){ } catch (e) {
console.log(e) console.log(e)
message.error('无法获取麦克风权限!'); message.error('无法获取麦克风权限!');
} }
@@ -330,15 +327,15 @@ const handleRightClick = (e, m) => {
}, },
{ {
label: '多选', label: '多选',
action: () => {} action: () => { }
}, },
{ {
label: '翻译', label: '翻译',
action: () => {} action: () => { }
}, },
{ {
label: '引用', label: '引用',
action: () => {} action: () => { }
}, },
{ {
label: '删除', label: '删除',
@@ -356,7 +353,7 @@ watch(
conversationStore.conversations.find(x => x.id == conversationInfo.value.id).unreadCount = 0; conversationStore.conversations.find(x => x.id == conversationInfo.value.id).unreadCount = 0;
signalRStore.clearUnreadCount(conversationInfo.value.id); signalRStore.clearUnreadCount(conversationInfo.value.id);
}, },
{deep: true} { deep: true }
); );
// 自动滚动到底部 // 自动滚动到底部
@@ -387,7 +384,7 @@ async function handleFile(files) {
let info = {}; let info = {};
let localUrl = null; let localUrl = null;
let img = null; let img = null;
switch(getMessageType(file.type)){ switch (getMessageType(file.type)) {
case FILE_TYPE.Image: case FILE_TYPE.Image:
localUrl = URL.createObjectURL(file); localUrl = URL.createObjectURL(file);
img = await loadImage(localUrl); img = await loadImage(localUrl);
@@ -399,18 +396,18 @@ async function handleFile(files) {
localUrl = URL.createObjectURL(imgBlob); localUrl = URL.createObjectURL(imgBlob);
img = await loadImage(localUrl); img = await loadImage(localUrl);
info = new VideoInfo(file.type,'[视频]', img.width, img.height, imgBlob, await getVideoDuration(file)); info = new VideoInfo(file.type, '[视频]', img.width, img.height, imgBlob, await getVideoDuration(file));
break; break;
} }
case FILE_TYPE.Voice: { case FILE_TYPE.Voice: {
localUrl = URL.createObjectURL(file); localUrl = URL.createObjectURL(file);
info = new VoiceInfo(file.type,'[语音消息]', await getVideoDuration(file)); info = new VoiceInfo(file.type, '[语音消息]', await getVideoDuration(file));
break; break;
} }
} }
await sendFileMessage(file, conversationInfo,info,localUrl); await sendFileMessage(file, conversationInfo, info, localUrl);
} }
function toggleEmoji() { function toggleEmoji() {
@@ -422,7 +419,7 @@ async function loadConversation(conversationId) {
const res = await messageService.getConversationById(conversationId); const res = await messageService.getConversationById(conversationId);
conversationInfo.value = res.data; conversationInfo.value = res.data;
*/ */
if(conversationStore.conversations.length == 0){ if (conversationStore.conversations.length == 0) {
await conversationStore.loadUserConversations(); await conversationStore.loadUserConversations();
} }
conversationInfo.value = conversationStore.conversations.find(x => x.id == Number(conversationId)); conversationInfo.value = conversationStore.conversations.find(x => x.id == Number(conversationId));
@@ -430,10 +427,10 @@ async function loadConversation(conversationId) {
const initChat = async (newId) => { const initChat = async (newId) => {
await loadConversation(newId); await loadConversation(newId);
if(conversationInfo.value){ if (conversationInfo.value) {
const sessionid = generateSessionId( const sessionid = generateSessionId(
conversationInfo.value.userId, conversationInfo.value.targetId, conversationInfo.value.chatType == MESSAGE_TYPE.GROUP) conversationInfo.value.userId, conversationInfo.value.targetId, conversationInfo.value.chatType == MESSAGE_TYPE.GROUP)
await chatStore.swtichSession(sessionid,newId); await chatStore.swtichSession(sessionid, newId);
isFinished.value = false; isFinished.value = false;
scrollToBottom(); scrollToBottom();
} }
@@ -446,7 +443,7 @@ watch(
() => { () => {
// 如果没有 ID,或者 Store 还没加载,返回一个安全的默认值 // 如果没有 ID,或者 Store 还没加载,返回一个安全的默认值
if (!conversationStore.conversations.length) { if (!conversationStore.conversations.length) {
return [props.id,null]; return [props.id, null];
} }
// 尝试找到当前会话 // 尝试找到当前会话
@@ -482,10 +479,10 @@ watch(
const msgList = await chatStore.fetchNewMsgFromServier(newId); const msgList = await chatStore.fetchNewMsgFromServier(newId);
const session = conversationStore.conversations.find(x => x.id == Number(newId)); const session = conversationStore.conversations.find(x => x.id == Number(newId));
if(msgList && msgList.length > 0){ if (msgList && msgList.length > 0) {
const minSequenceId = Math.min(...msgList.map(m => m.sequenceId)); const minSequenceId = Math.min(...msgList.map(m => m.sequenceId));
const locaMaxSequenceId = chatStore.maxSequenceId; const locaMaxSequenceId = chatStore.maxSequenceId;
if(locaMaxSequenceId < (minSequenceId - 1)){ if (locaMaxSequenceId < (minSequenceId - 1)) {
chatStore.messages = [];; chatStore.messages = [];;
} }
await chatStore.pushAndSortMessagesAsync(msgList, generateSessionId(session.userId, session.targetId, session.chatType == MESSAGE_TYPE.GROUP), true); await chatStore.pushAndSortMessagesAsync(msgList, generateSessionId(session.userId, session.targetId, session.chatType == MESSAGE_TYPE.GROUP), true);
@@ -509,7 +506,7 @@ watch(
); );
const initObs = async () => { const initObs = async () => {
await nextTick(); await nextTick();
observer = new IntersectionObserver((entries) => { observer = new IntersectionObserver((entries) => {
const entry = entries[0]; const entry = entries[0];
@@ -527,7 +524,7 @@ await nextTick();
// 如果 sentinel 是组件,需要拿它底下的 $el // 如果 sentinel 是组件,需要拿它底下的 $el
observer.observe(loadingRef.value.$el || loadingRef.value); observer.observe(loadingRef.value.$el || loadingRef.value);
} }
// 3. 【主动侦测逻辑】 // 3. 【主动侦测逻辑】
// 如果当前没有在加载,且还没有结束,且内容还没有撑开容器 // 如果当前没有在加载,且还没有结束,且内容还没有撑开容器
const el = historyRef.value; const el = historyRef.value;
if (el && !isLoading.value && !isFinished.value) { if (el && !isLoading.value && !isFinished.value) {
@@ -555,7 +552,8 @@ onUnmounted(() => {
.chat-panel { .chat-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; /* 确保占满父容器 */ height: 100%;
/* 确保占满父容器 */
background: #f5f5f5; background: #f5f5f5;
} }
@@ -584,6 +582,7 @@ onUnmounted(() => {
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
-webkit-app-region: drag; -webkit-app-region: drag;
} }
/* 遮罩层:全屏、黑色半透明、固定定位 */ /* 遮罩层:全屏、黑色半透明、固定定位 */
.video-overlay { .video-overlay {
position: fixed; position: fixed;
@@ -595,7 +594,8 @@ onUnmounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 9999; /* 确保在最顶层 */ z-index: 9999;
/* 确保在最顶层 */
} }
/* 播放器弹窗主体 */ /* 播放器弹窗主体 */
@@ -606,7 +606,7 @@ onUnmounted(() => {
background: #000; background: #000;
border-radius: 8px; border-radius: 8px;
overflow: hidden; 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 { .player-wrapper {
width: 100%; width: 100%;
aspect-ratio: 16 / 9; /* 锁定 16:9 比例 */ aspect-ratio: 16 / 9;
/* 锁定 16:9 比例 */
background: #000; background: #000;
} }
/* 进场动画 */ /* 进场动画 */
.fade-enter-active, .fade-leave-active { .fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease; transition: opacity 0.3s ease;
} }
.fade-enter-from, .fade-leave-to {
.fade-enter-from,
.fade-leave-to {
opacity: 0; opacity: 0;
} }
@@ -679,9 +683,17 @@ onUnmounted(() => {
} }
@keyframes pulse { @keyframes pulse {
0% { transform: scale(1); } 0% {
50% { transform: scale(1.1); } transform: scale(1);
100% { transform: scale(1); } }
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
} }
.image-msg { .image-msg {
@@ -705,14 +717,16 @@ onUnmounted(() => {
.image-msg-content { .image-msg-content {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; /* 关键:裁剪而非拉伸 */ object-fit: cover;
/* 关键:裁剪而非拉伸 */
display: block; display: block;
} }
/* 覆盖层 */ /* 覆盖层 */
.image-overlay { .image-overlay {
position: absolute; position: absolute;
inset: 0; /* 铺满父容器 */ inset: 0;
/* 铺满父容器 */
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -727,21 +741,27 @@ onUnmounted(() => {
width: 40px; width: 40px;
height: 40px; height: 40px;
} }
.circular-progress svg { .circular-progress svg {
transform: rotate(-90deg); transform: rotate(-90deg);
} }
.circular-progress circle { .circular-progress circle {
fill: none; fill: none;
stroke-width: 3; stroke-width: 3;
} }
.circular-progress .bg { .circular-progress .bg {
stroke: rgba(255, 255, 255, 0.3); stroke: rgba(255, 255, 255, 0.3);
} }
.circular-progress .bar { .circular-progress .bar {
stroke: #ffffff; stroke: #ffffff;
stroke-dasharray: 100; /* 这里的 100 对应周长 */ stroke-dasharray: 100;
/* 这里的 100 对应周长 */
transition: stroke-dashoffset 0.2s; transition: stroke-dashoffset 0.2s;
} }
.circular-progress .pct { .circular-progress .pct {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -750,6 +770,7 @@ onUnmounted(() => {
justify-content: center; justify-content: center;
font-size: 10px; font-size: 10px;
} }
.error-icon { .error-icon {
color: #ff4d4f; color: #ff4d4f;
} }
@@ -789,15 +810,18 @@ onUnmounted(() => {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
} }
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
.loaderIcon { .loaderIcon {
display: inline-block; /* 必须是 block 或 inline-block 才能旋转 */ display: inline-block;
/* 必须是 block 或 inline-block 才能旋转 */
line-height: 0; line-height: 0;
animation: spin 1s linear infinite; /* 1秒转一圈,线性速度,无限循环 */ animation: spin 1s linear infinite;
/* 1秒转一圈,线性速度,无限循环 */
} }
.status { .status {
@@ -809,6 +833,7 @@ onUnmounted(() => {
.msg.mine { .msg.mine {
flex-direction: row-reverse; flex-direction: row-reverse;
} }
.msg.mine .msg-content { .msg.mine .msg-content {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;