This commit is contained in:
oneao committed 2025-10-13 17:32:02 +08:00
1 parent a6c5e70f85
commit 2598098876
8 files changed
+646 -45

No files matched your search

@@ -8,6 +8,8 @@ import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.cors.CorsConfig;
import io.netty.handler.codec.http.cors.CorsHandler;
import io.netty.handler.stream.ChunkedWriteHandler;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.timeout.IdleStateHandler;
@@ -53,18 +55,11 @@ public class ImServer {
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// ===== HTTP 协议支持 =====
pipeline.addLast(new HttpServerCodec());
pipeline.addLast(new HttpObjectAggregator(65536));
pipeline.addLast(new ChunkedWriteHandler());
// ===== 心跳检测 =====
pipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));
// ===== WebSocket 协议支持 =====
pipeline.addLast(new CorsHandler(CorsConfig.withAnyOrigin().build()));
pipeline.addLast(new WebSocketServerProtocolHandler(websocketPath, null, true, 65536));
// ===== 自定义消息处理 =====
pipeline.addLast(new ImWebSocketHandler());
}
});
+12
View File
@@ -1,4 +1,16 @@
<script setup>
import { onBeforeUnmount, onMounted } from 'vue'
import { useWebSocket } from './stores/websocketStore'
const websocket = useWebSocket()
onMounted(() => {
websocket.connect('ws://192.168.1.136:8090/ws')
})
onBeforeUnmount(() => {
websocket.disconnect()
})
</script>
<template>
@@ -41,7 +41,12 @@ const iconSize = computed(() => (props.size ? `${props.size}px` : '24px'))
</i>
</template>
<style>
<style scoped>
.icon {
display: flex;
align-items: center;
justify-content: center;
}
.icon-spin {
display: inline-block;
animation: icon-spin 1s linear infinite;
@@ -0,0 +1,100 @@
import { ElNotification } from 'element-plus'
import { onScopeDispose, ref } from 'vue'
export function useWebSocket() {
const socket = ref(null)
const isConnected = ref(false)
const listeners = ref(new Set())
let reconnecting = false
let reconnectNotification = null
function connect(url) {
if (isConnected.value)
return
if (socket.value)
disconnect()
const ws = new WebSocket(url)
socket.value = ws
ws.onopen = () => {
isConnected.value = true
reconnecting = false
// 关闭重连提示
if (reconnectNotification) {
reconnectNotification.close()
reconnectNotification = null
}
console.log('WebSocket 已连接')
ElNotification({
title: '成功',
message: 'WebSocket 已连接',
type: 'success',
showClose: false,
duration: 2000,
})
}
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data)
notifyListeners(message)
}
catch (err) {
console.error('消息解析失败:', err)
notifyListeners({ type: 'ERROR', data: '消息解析失败' })
}
}
ws.onclose = () => scheduleReconnect(url)
ws.onerror = () => scheduleReconnect(url)
}
function disconnect() {
socket.value?.close()
isConnected.value = false
}
function sendMessage(type, data) {
if (isConnected.value && socket.value) {
socket.value.send(JSON.stringify({ type, data }))
}
}
function onMessage(callback) {
listeners.value.add(callback)
return () => listeners.value.delete(callback)
}
function notifyListeners(message) {
listeners.value.forEach(cb => cb(message))
}
function scheduleReconnect(url) {
if (!reconnecting) {
reconnecting = true
reconnectNotification = ElNotification({
title: '提示',
message: 'WebSocket 连接断开,正在重连...',
type: 'warning',
showClose: false,
duration: 0,
})
}
console.log('WebSocket 即将重连...')
setTimeout(() => {
connect(url)
}, 3000) // 固定延迟重连,可按需改
}
onScopeDispose(() => disconnect())
return {
isConnected,
connect,
disconnect,
sendMessage,
onMessage,
}
}
+525 -34
View File
@@ -36,26 +36,48 @@ import Icon from '@/components/icon.vue'
</div>
<div class="im__body">
<div class="im__list">
<div class="im__list-input">
<div class="im__list-search">
<el-input placeholder="请根据名称搜索" :prefix-icon="Search" />
</div>
<div class="im__list-item">
<div class="im__list-header">
单聊
<Icon icon="tabler:message-circle" :size="16" />
<span class="im__list-header-title">
单聊
</span>
</div>
<div class="im__list-body">
<div class="im__chat-user">
<Avatar name="A" />
<div class="im__chat-user-info">
<div class="im__chat-user-main">
<div class="im__chat-user-name">张三</div>
<div class="im__chat-user-last-time">聊天时间</div>
<div
v-for="i in 10" :key="i" class="im__chat-item im__chat-item"
:class="[{ 'im__chat-item--selected': i === 1 }]"
>
<el-badge
is-dot
color="#24e68a"
:badge-style="{
width: '12px',
height: '12px',
}"
:offset="[-4, 34]"
>
<!-- 未登录:b3b7c2 -->
<Avatar name="A" class="im__chat-item-avatar" />
</el-badge>
<div class="im__chat-item-info">
<div class="im__chat-item-main">
<div class="im__chat-item-name">
张三张三张三张三张三张三张三张三张三张三
</div>
<div class="im__chat-item-last-time">
10:20
</div>
</div>
<div class="im__chat-user-message">
<div class="im__chat-user-last-message">下周一起吃个饭</div>
<div class="im__chat-user-unread-count">2</div>
<div class="im__chat-item-message">
<div class="im__chat-item-last-message">
下周下周下周下周下周下周下周下周下周下周
</div>
<el-badge :value="100" :max="10" class="im__chat-item-unread-count" />
</div>
</div>
</div>
@@ -63,23 +85,160 @@ import Icon from '@/components/icon.vue'
</div>
<div class="im__list-item">
<div class="im__list-header">
群聊
<Icon icon="bi:people" :size="16" />
<span class="im__list-header-title">
群聊
</span>
</div>
<div class="im__list-body">
<div>
Operation feedback: enable the users to clearly perceive their
operations by style updates and interactive effects;
</div>
<div>
Visual feedback: reflect current state by updating or rearranging
elements of the page.
<div v-for="i in 10" :key="i" class="im__chat-item">
<Avatar name="#" class="im__chat-item-avatar" bg-color="#574b90" />
<div class="im__chat-item-info">
<div class="im__chat-item-main">
<div class="im__chat-item-name">
张三张三张三张三张三张三张三张三张三张三
</div>
<div class="im__chat-item-last-time">
10:20
</div>
</div>
<div class="im__chat-item-message">
<div class="im__chat-item-last-message">
下周下周下周下周下周下周下周下周下周下周
</div>
<el-badge :value="100" :max="10" class="im__chat-item-unread-count" />
</div>
</div>
</div>
</div>
</div>
</div>
<div class="im__window">
chat
<div class="im__window-header">
<div class="im__window-peer">
<div class="im__window-peer-avatar">
<Avatar name="A" />
</div>
<div class="im__window-peer-info">
<div class="im__window-peer-name">
张三
</div>
<div class="im__window-peer-desc">
<div class="im__window-peer-status im__window-peer-status--online">
<span>[ </span>
<div class="im__window-peer-status-dot" />
<span>在线</span>
<span> ]</span>
</div>
<span>
公司描述等等等...
</span>
</div>
</div>
</div>
<div class="im__window-actions" />
</div>
<div class="im__window-body">
<el-splitter layout="vertical">
<el-splitter-panel>
<div class="im__chat-main">
<div class="im__chat-message im__chat-message--from">
<div class="im__chat-message-avatar">
<Avatar name="A" />
</div>
<div class="im__chat-message-body">
<div class="im__chat-message-name">
张三
</div>
<div class="im__chat-message-content">
<div class="im__chat-message-content--text">
这是一条文本消息这是一条文本消息这是一条文本消息这是一条文本消息这是一条文本消息这是一条文本消息这是一条文本消息这是一条文本消息
</div>
</div>
</div>
</div>
<div class="im__chat-message im__chat-message--to">
<Avatar name="B" />
<div class="im__chat-message-body">
<div class="im__chat-message-name">
张三
</div>
<div class="im__chat-message-content">
<div class="im__chat-message-content--text">
这是一条文本消息
</div>
</div>
</div>
</div>
</div>
</el-splitter-panel>
<el-splitter-panel :min="100" :max="300" :size="150">
<div class="im__chat-footer">
<div class="im__chat-toolbar">
<Icon icon="proicons:emoji" />
<div class="im__chat-toolbar-right">
<Icon
icon="solar:history-broken"
/>
</div>
</div>
<div class="im__chat-editor">
<div
class="im__chat-editor-input"
contenteditable="true"
/>
</div>
<div class="im__chat-sendarea">
<el-button type="primary" size="small" class="im__chat-send">
发送(回车)
</el-button>
</div>
</div>
</el-splitter-panel>
</el-splitter>
<div class="im__chat-group">
<div class="im__group-section-header">
<!-- <el-input placeholder="请根据名称搜索" :prefix-icon="Search" /> -->
<div class="im__group-section-header-title">
<Icon icon="bi:people" :size="18" color="#666666" />
<span class="im__group-section-header-text">群成员(222)</span>
</div>
<div class="im__group-section-header-extra">
<div class="im__group-section-header--search">
<Icon icon="lucide:search" :size="16" />
</div>
</div>
</div>
<div class="im__group-section-body">
<div v-for="i in 20" :key="i" class="im__group-item">
<el-badge
is-dot
color="#24e68a"
:badge-style="{
width: '12px',
height: '12px',
}"
:offset="[-4, 34]"
>
<!-- 未登录:b3b7c2 -->
<Avatar name="A" />
</el-badge>
<div class="im__group-item-info">
<div class="im__group-item-name">
name
</div>
<div class="im__group-item-desc">
desc
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -96,23 +255,23 @@ import Icon from '@/components/icon.vue'
background-color: #f5f6fa;
& *::-webkit-scrollbar {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
}
& *::-webkit-scrollbar-track {
background-color: #f1f1f1;
border-radius: 10px;
background-color: #fff;
border-radius: 3px;
}
& *::-webkit-scrollbar-thumb {
background-color: #888;
border-radius: 10px;
border: 2px solid #f1f1f1;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
transition: background-color 0.2s ease;
}
& *::-webkit-scrollbar-thumb:hover {
background-color: #555;
background-color: rgba(0, 0, 0, 0.4);
}
.im__container {
@@ -208,9 +367,9 @@ import Icon from '@/components/icon.vue'
flex-direction: column;
min-height: 0;
.im__list-input {
.im__list-search {
padding: 10px;
background-color: #1553f1;
border-bottom: 1px solid #e6e9ef;
}
.im__list-item {
@@ -218,26 +377,104 @@ import Icon from '@/components/icon.vue'
flex-direction: column;
flex: 1;
min-height: 0;
transition: background-color 0.25s ease;
.im__list-header {
font-size: 14px;
padding: 8px 10px;
background-color: #f8f9fc;
flex-shrink: 0;
display: flex;
align-items: center;
.im__list-header-title {
margin-left: 4px;
}
}
.im__list-body {
overflow-y: auto;
flex: 1;
min-height: 0;
.im__chat-user {
.im__chat-item {
display: flex;
align-items: center;
height: 56px;
width: 100%;
padding: 0 10px;
overflow-x: hidden;
&:hover {
background-color: #f5f7fa;
cursor: pointer;
}
.im__chat-item-avatar {
flex-shrink: 0;
}
.im__chat-item-info {
display: flex;
flex-direction: column;
flex: 1;
margin-left: 10px;
min-width: 0;
.im__chat-item-main,
.im__chat-item-message {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.im__chat-item-message {
margin-top: 4px;
}
.im__chat-item-name {
font-size: 14px;
font-weight: 500;
}
.im__chat-item-last-time,
.im__chat-item-last-message {
flex-shrink: 0;
color: #666;
font-size: 12px;
}
.im__chat-item-last-message,
.im__chat-item-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.im__chat-item-last-time {
margin-left: 8px;
}
.im__chat-item-unread-count {
display: flex;
margin-left: 8px;
}
}
}
.im__chat-item--selected {
position: relative;
background-color: #f0f6ff;
transition: background-color 0.25s ease;
&::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 3px;
background-color: #3b82f6;
border-radius: 2px;
box-shadow: 0 0 4px rgba(59, 130, 246, 0.4);
}
}
}
}
@@ -246,8 +483,262 @@ import Icon from '@/components/icon.vue'
.im__window {
flex: 1;
background-color: #fff;
padding: 10px;
overflow-y: auto;
display: flex;
flex-direction: column;
.im__window-header {
height: 64px;
border-bottom: 1px solid #e6e9ef;
display: flex;
align-items: center;
flex-shrink: 0;
.im__window-peer {
display: flex;
align-items: center;
padding: 0px 12px;
.im__window-peer-info {
margin-left: 8px;
.im__window-peer-name {
font-size: 16px;
line-height: 1;
}
.im__window-peer-desc {
display: flex;
align-items: center;
font-size: 12px;
line-height: 1;
margin-top: 6px;
color: #666;
height: 12px;
.im__window-peer-status {
display: flex;
align-items: center;
margin-right: 4px;
.im__window-peer-status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
margin: 0px 4px;
}
}
.im__window-peer-status--online {
.im__window-peer-status-dot {
background-color: #24e68a;
}
}
.im__window-peer-status--offline {
.im__window-peer-status-dot {
background-color: #b3b7c2;
}
}
}
}
}
}
.im__window-body {
flex: 1;
display: flex;
min-height: 0;
:deep(.el-splitter-panel) {
scrollbar-width: auto;
}
.im__chat-main {
padding: 16px 8px;
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
scrollbar-gutter: stable both-edges;
overflow-y: auto;
min-width: 0;
background-color: #f8fafc;
height: 100%;
.im__chat-message {
display: flex;
width: fit-content;
max-width: 70%;
.im__chat-message-avatar {
flex-shrink: 0;
}
.im__chat-message-body {
.im__chat-message-name {
font-size: 14px;
line-height: 1;
}
.im__chat-message-content {
display: inline-flex;
flex-direction: column;
margin-top: 8px;
}
.im__chat-message-content--text {
background-color: #ffffff;
color: #000000;
word-break: break-all;
padding: 6px 12px;
border-radius: 4px;
}
}
}
.im__chat-message--from {
align-items: flex-start;
.im__chat-message-body {
margin-left: 8px;
}
}
.im__chat-message--to {
align-self: flex-end;
flex-direction: row-reverse;
.im__chat-message-body {
margin-right: 8px;
.im__chat-message-content--text {
background-color: #3c73ff;
color: #fff;
}
.im__chat-message-name {
text-align: right;
}
}
}
}
.im__chat-group {
width: 300px;
border-left: 1px solid #e6e9ef;
min-height: 0;
display: flex;
flex-direction: column;
flex-shrink: 0;
.im__group-section-header {
height: 46px;
border-bottom: 1px solid #e6e9ef;
display: flex;
align-items: center;
padding: 0px 8px;
justify-content: space-between;
.im__group-section-header-title {
display: flex;
align-items: center;
}
.im__group-section-header-text {
margin-left: 4px;
line-height: 1;
font-size: 14px;
}
.im__group-section-header--search {
cursor: pointer;
color: #666666;
&:hover {
color: #1553f1 !important;
}
}
}
.im__group-section-body {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
min-height: 0;
.im__group-item {
height: 56px;
padding: 0 10px;
display: flex;
align-items: center;
cursor: pointer;
flex-shrink: 0;
&:hover {
background-color: #f5f7fa;
}
.im__group-item-info {
margin-left: 8px;
.im__group-item-name {
line-height: 1;
font-size: 14px;
}
.im__group-item-desc {
margin-top: 6px;
font-size: 12px;
color: #666;
}
}
}
}
}
.im__chat-footer {
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
height: 100%;
.im__chat-toolbar {
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
padding: 0px 8px;
gap: 8px;
.im__chat-toolbar-right {
margin-left: auto;
}
}
.im__chat-editor {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
flex: 1;
min-height: 0;
width: 100%;
scrollbar-gutter: stable both-edges;
.im__chat-editor-input {
padding: 4px;
flex: 1;
// overflow-y: auto;
box-sizing: border-box;
border: none;
resize: none;
background-color: transparent;
outline: none;
word-wrap: break-word;
word-break: break-all;
white-space: pre-wrap;
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
cursor: text;
}
}
.im__chat-sendarea {
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: auto;
padding: 0px 16px;
.im__chat-send {
background-color: #1553f1;
}
}
}
}
}
}
</style>
@@ -490,7 +490,6 @@ function getRollbackContent(data: GetHistoryResult) {
}
.chat-center-item-left {
display: flex;
align-items: flex-start;
width: fit-content;
max-width: 70%;
flex-direction: row;
@@ -498,7 +497,6 @@ function getRollbackContent(data: GetHistoryResult) {
}
.chat-center-item-right {
display: flex;
align-items: flex-start;
width: fit-content;
max-width: 70%;
flex-direction: row-reverse;