u
This commit is contained in:
1 parent
e98ab295c4
commit
92fb1b74a7
22 files changed
+1204
-45
No files matched your search
@@ -24,6 +24,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"/company/recommend",
|
||||
"/company/recommend/hot",
|
||||
|
||||
"/post/tag",
|
||||
|
||||
"/forum/post/page",
|
||||
"/forum/post/detail",
|
||||
"/forum/post/getRecommendList",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package cn.link56.im;
|
||||
|
||||
import cn.link56.im.config.NettyConfig;
|
||||
import cn.link56.im.handler.AuthHandler;
|
||||
import cn.link56.im.handler.DispatchHandler;
|
||||
import cn.link56.im.handler.MessageHandler;
|
||||
import cn.link56.im.handler.ServerHandler;
|
||||
import io.netty.handler.timeout.IdleStateHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
import io.netty.bootstrap.ServerBootstrap;
|
||||
import io.netty.channel.*;
|
||||
@@ -18,6 +22,7 @@ import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
|
||||
import javax.annotation.PostConstruct;
|
||||
import javax.annotation.PreDestroy;
|
||||
import javax.annotation.Resource;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Component
|
||||
public class NettyServer {
|
||||
@@ -27,6 +32,13 @@ public class NettyServer {
|
||||
private EventLoopGroup bossGroup;
|
||||
private EventLoopGroup workerGroup;
|
||||
|
||||
@Resource
|
||||
private AuthHandler authHandler;
|
||||
@Resource
|
||||
private ServerHandler serverHandler;
|
||||
@Resource
|
||||
private DispatchHandler dispatchHandler;
|
||||
|
||||
@PostConstruct
|
||||
public void start() {
|
||||
new Thread(this::runServer, "Netty-ImServer-Thread").start();
|
||||
@@ -52,7 +64,17 @@ public class NettyServer {
|
||||
pipeline.addLast(new ChunkedWriteHandler());
|
||||
pipeline.addLast(new CorsHandler(CorsConfig.withAnyOrigin().build()));
|
||||
pipeline.addLast(new WebSocketServerProtocolHandler(nettyConfig.getPath(), null, true, 65536));
|
||||
pipeline.addLast(new ServerHandler());
|
||||
// 4. 空闲检测(60 秒没收到客户端任何消息就断)
|
||||
pipeline.addLast("idle-handler",
|
||||
new IdleStateHandler(
|
||||
60,
|
||||
0,
|
||||
0,
|
||||
TimeUnit.SECONDS
|
||||
));
|
||||
pipeline.addLast(serverHandler);
|
||||
pipeline.addLast(authHandler);
|
||||
pipeline.addLast(dispatchHandler);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,29 @@ package cn.link56.im.enums;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public enum MessageTypeEnums {
|
||||
PING,PONG,ONLINE,OFFLINE,PRIVATE,GROUP
|
||||
PING(0),
|
||||
PONG(1),
|
||||
LOGIN(2),
|
||||
LOGOUT(3),
|
||||
ONLINE(4),
|
||||
OFFLINE(5),
|
||||
PRIVATE(6),
|
||||
GROUP(7);
|
||||
|
||||
private final int code;
|
||||
|
||||
MessageTypeEnums(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public static MessageTypeEnums valueOf(int code) {
|
||||
for (MessageTypeEnums type : values()) {
|
||||
if (type.code == code) {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("未知消息类型: " + code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package cn.link56.im.handler;
|
||||
|
||||
import cn.link56.auth.token.model.TokenPayload;
|
||||
import cn.link56.auth.token.service.AuthTokenService;
|
||||
import cn.link56.im.enums.MessageTypeEnums;
|
||||
import cn.link56.im.message.MessageRequest;
|
||||
import cn.link56.im.model.dto.OnlineDTO;
|
||||
import cn.link56.im.storage.SessionManager;
|
||||
import com.alibaba.druid.util.StringUtils;
|
||||
import io.netty.channel.ChannelHandler;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.ChannelInboundHandlerAdapter;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
|
||||
@ChannelHandler.Sharable
|
||||
@Component
|
||||
public class AuthHandler extends ChannelInboundHandlerAdapter {
|
||||
@Resource
|
||||
private AuthTokenService authTokenService;
|
||||
|
||||
@Override
|
||||
public void channelRead(ChannelHandlerContext ctx, Object msg)
|
||||
throws Exception {
|
||||
|
||||
if (!(msg instanceof MessageRequest)) {
|
||||
ctx.fireChannelRead(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
MessageRequest req = (MessageRequest) msg;
|
||||
Integer type = req.getType();
|
||||
|
||||
if (type == MessageTypeEnums.PING.getCode() || type == MessageTypeEnums.ONLINE.getCode()){
|
||||
ctx.fireChannelRead(req);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果该通道未验证
|
||||
if (!SessionManager.isAuthenticated(ctx.channel())) {
|
||||
ctx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fireChannelRead(req);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.link56.im.handler;
|
||||
|
||||
import cn.link56.im.message.MessageRequest;
|
||||
import io.netty.channel.ChannelHandler;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.SimpleChannelInboundHandler;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
|
||||
/**
|
||||
* 转发消息
|
||||
*/
|
||||
@ChannelHandler.Sharable
|
||||
@Component
|
||||
public class DispatchHandler extends SimpleChannelInboundHandler<MessageRequest> {
|
||||
@Resource
|
||||
private MessageHandler messageHandler;
|
||||
|
||||
@Override
|
||||
protected void channelRead0(ChannelHandlerContext ctx,
|
||||
MessageRequest request) {
|
||||
|
||||
messageHandler.handleMessage(ctx.channel(), request);
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
package cn.link56.im.handler;
|
||||
|
||||
import cn.link56.auth.token.model.TokenPayload;
|
||||
import cn.link56.auth.token.service.AuthTokenService;
|
||||
import cn.link56.im.enums.MessageTypeEnums;
|
||||
import cn.link56.im.message.MessageRequest;
|
||||
import cn.link56.im.message.MessageResponse;
|
||||
import cn.link56.im.model.dto.OnlineDTO;
|
||||
import cn.link56.im.storage.SessionManager;
|
||||
import cn.link56.im.utils.MessageDataConverter;
|
||||
import cn.link56.im.utils.MessageWriter;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
|
||||
@Component
|
||||
public class MessageHandler {
|
||||
@Resource
|
||||
private AuthTokenService authTokenService;
|
||||
|
||||
// 处理消息
|
||||
public void handleMessage(Channel channel, MessageRequest request) {
|
||||
System.out.println(request);
|
||||
switch (MessageTypeEnums.valueOf(request.getType())) {
|
||||
case PING:
|
||||
handlePing(channel, request);
|
||||
@@ -41,22 +51,32 @@ public class MessageHandler {
|
||||
|
||||
// 上线
|
||||
private void handleOnline(Channel channel, MessageRequest request) {
|
||||
// 验证
|
||||
Long from = request.getFrom();
|
||||
Object data = request.getData(); // companyId userId
|
||||
OnlineDTO dto = MessageDataConverter.convert(request.getData(), OnlineDTO.class);
|
||||
TokenPayload tokenPayload = authTokenService.parsePayload(dto.getToken());
|
||||
|
||||
// 账号 Token
|
||||
if (tokenPayload == null) {
|
||||
MessageWriter.writeToChannel(channel,MessageResponse.error("Token无效"));
|
||||
return;
|
||||
}
|
||||
|
||||
Long companyId = tokenPayload.getCompanyId();
|
||||
Long userId = tokenPayload.getUserId();
|
||||
|
||||
// 添加 Session 并自动标记已认证
|
||||
SessionManager.addSession(companyId, userId, channel);
|
||||
|
||||
MessageWriter.writeToChannel(channel, MessageResponse.success(MessageTypeEnums.ONLINE));
|
||||
}
|
||||
|
||||
// 下线逻辑
|
||||
private void handleOffline(Channel channel, MessageRequest request) {
|
||||
// 下线逻辑
|
||||
}
|
||||
|
||||
// 私聊逻辑
|
||||
private void handlePrivate(Channel channel, MessageRequest request) {
|
||||
// 私聊逻辑
|
||||
}
|
||||
|
||||
// 群聊逻辑
|
||||
private void handleGroup(Channel channel, MessageRequest request) {
|
||||
// 群聊逻辑
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
package cn.link56.im.handler;
|
||||
|
||||
import cn.link56.im.message.MessageRequest;
|
||||
import cn.link56.im.storage.SessionManager;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.netty.channel.ChannelHandler;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.SimpleChannelInboundHandler;
|
||||
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
|
||||
import io.netty.handler.timeout.IdleStateEvent;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@ChannelHandler.Sharable
|
||||
public class ServerHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private final MessageHandler messageHandler = new MessageHandler();
|
||||
|
||||
@Override
|
||||
public void handlerAdded(ChannelHandlerContext ctx) {
|
||||
System.out.println("客户端连接: " + ctx.channel().id().asShortText());
|
||||
@@ -22,13 +23,15 @@ public class ServerHandler extends SimpleChannelInboundHandler<TextWebSocketFram
|
||||
@Override
|
||||
public void handlerRemoved(ChannelHandlerContext ctx) {
|
||||
System.out.println("客户端断开: " + ctx.channel().id().asShortText());
|
||||
|
||||
SessionManager.removeChannel(ctx.channel());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void channelRead0(ChannelHandlerContext channelHandlerContext, TextWebSocketFrame textWebSocketFrame) throws Exception {
|
||||
MessageRequest request = MAPPER.readValue(textWebSocketFrame.text(), MessageRequest.class);
|
||||
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) throws Exception {
|
||||
MessageRequest request = MAPPER.readValue(frame.text(), MessageRequest.class);
|
||||
|
||||
messageHandler.handleMessage(channelHandlerContext.channel(),request);
|
||||
ctx.fireChannelRead(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -3,12 +3,14 @@ package cn.link56.im.message;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
@ToString
|
||||
public class MessageRequest {
|
||||
private String type;
|
||||
private Integer type;
|
||||
private Long from;
|
||||
private Object to;
|
||||
private Long timestamp;
|
||||
|
||||
@@ -13,7 +13,7 @@ import lombok.NoArgsConstructor;
|
||||
@NoArgsConstructor
|
||||
@Builder
|
||||
public class MessageResponse {
|
||||
private String type;
|
||||
private Integer type;
|
||||
private Long from;
|
||||
private Object data;
|
||||
private Long timestamp;
|
||||
@@ -27,7 +27,7 @@ public class MessageResponse {
|
||||
}
|
||||
|
||||
public static MessageResponse success(MessageTypeEnums messageTypeEnums) {
|
||||
return buildResponse(messageTypeEnums.name(), MessageResponseStatusConstants.SUCCESS, null, null);
|
||||
return buildResponse(messageTypeEnums.getCode(), MessageResponseStatusConstants.SUCCESS, null, null);
|
||||
}
|
||||
|
||||
public static MessageResponse error() {
|
||||
@@ -38,7 +38,7 @@ public class MessageResponse {
|
||||
return buildResponse(null, MessageResponseStatusConstants.ERROR, null, msg);
|
||||
}
|
||||
|
||||
private static MessageResponse buildResponse(String type, Integer code, Object data, String message) {
|
||||
private static MessageResponse buildResponse(Integer type, Integer code, Object data, String message) {
|
||||
MessageResponse response = new MessageResponse();
|
||||
response.setType(type);
|
||||
response.setCode(code);
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package cn.link56.im.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
@NoArgsConstructor
|
||||
public class OnlineDTO {
|
||||
private String token;
|
||||
}
|
||||
@@ -1,41 +1,87 @@
|
||||
package cn.link56.im.storage;
|
||||
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.util.AttributeKey;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public class SessionManager {
|
||||
|
||||
/** userId -> Session */
|
||||
private static final ConcurrentHashMap<Long, Session> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
/** Channel 是否已认证 */
|
||||
private static final AttributeKey<Boolean> AUTH_KEY =
|
||||
AttributeKey.valueOf("AUTHENTICATED");
|
||||
|
||||
/** Channel 绑定的 userId */
|
||||
private static final AttributeKey<Long> USER_ID_KEY =
|
||||
AttributeKey.valueOf("USER_ID");
|
||||
|
||||
/** Channel 绑定的 companyId */
|
||||
private static final AttributeKey<Long> COMPANY_ID_KEY =
|
||||
AttributeKey.valueOf("COMPANY_ID");
|
||||
|
||||
/* ================= 认证相关 ================= */
|
||||
|
||||
public static void markAuthenticated(Channel channel, Long companyId, Long userId) {
|
||||
if (channel == null || companyId == null || userId == null) return;
|
||||
|
||||
channel.attr(AUTH_KEY).set(true);
|
||||
channel.attr(USER_ID_KEY).set(userId);
|
||||
channel.attr(COMPANY_ID_KEY).set(companyId);
|
||||
}
|
||||
|
||||
public static boolean isAuthenticated(Channel channel) {
|
||||
if (channel == null) return false;
|
||||
return Boolean.TRUE.equals(channel.attr(AUTH_KEY).get());
|
||||
}
|
||||
|
||||
public static Long getUserId(Channel channel) {
|
||||
return channel.attr(USER_ID_KEY).get();
|
||||
}
|
||||
|
||||
public static Long getCompanyId(Channel channel) {
|
||||
return channel.attr(COMPANY_ID_KEY).get();
|
||||
}
|
||||
|
||||
/* ================= Session 管理 ================= */
|
||||
|
||||
/** 添加会话,支持同一个用户多端登录 */
|
||||
public static void addSession(Long companyId, Long userId, Channel channel) {
|
||||
if (companyId == null || userId == null || channel == null) return;
|
||||
|
||||
// 通过 compute 方法保证线程安全
|
||||
// 线程安全地添加 Channel 到 Session
|
||||
sessions.compute(userId, (key, session) -> {
|
||||
if (session == null) {
|
||||
// 新建 Session
|
||||
session = new Session(companyId, userId);
|
||||
}
|
||||
// 添加新的 Channel 到 Session
|
||||
session.addChannel(channel);
|
||||
|
||||
markAuthenticated(channel, companyId, userId);
|
||||
|
||||
return session;
|
||||
});
|
||||
}
|
||||
|
||||
/** 移除某个 Channel */
|
||||
public static void removeChannel(Long userId, Channel channel) {
|
||||
if (userId == null || channel == null) return;
|
||||
|
||||
public static void removeChannel(Channel channel) {
|
||||
if (channel == null) return;
|
||||
|
||||
Long userId = getUserId(channel);
|
||||
if (userId == null) return;
|
||||
|
||||
Session session = sessions.get(userId);
|
||||
if (session != null) {
|
||||
session.removeChannel(channel);
|
||||
// 如果用户所有连接都断开,删除 Session
|
||||
if (!session.isOnline()) {
|
||||
sessions.remove(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理 Channel 上的状态
|
||||
channel.attr(AUTH_KEY).set(null);
|
||||
channel.attr(USER_ID_KEY).set(null);
|
||||
channel.attr(COMPANY_ID_KEY).set(null);
|
||||
}
|
||||
|
||||
/** 获取用户 Session */
|
||||
@@ -43,7 +89,6 @@ public class SessionManager {
|
||||
return sessions.get(userId);
|
||||
}
|
||||
|
||||
/** 检查用户是否在线 */
|
||||
public static boolean isOnline(Long userId) {
|
||||
Session session = sessions.get(userId);
|
||||
return session != null && session.isOnline();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package cn.link56.im.utils;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
public class MessageDataConverter {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
public static <T> T convert(Object data, Class<T> clazz) {
|
||||
return MAPPER.convertValue(data, clazz);
|
||||
}
|
||||
}
|
||||
@@ -14,18 +14,20 @@ import java.util.Set;
|
||||
public class MessageWriter {
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
|
||||
public static void writeToUser(Long userId,Object message){
|
||||
if (ObjectUtils.isEmpty(userId) || ObjectUtils.isEmpty(message)){
|
||||
public static void writeToUser(Long userId, Object message) {
|
||||
if (userId == null || message == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Session session = SessionManager.getSession(userId);
|
||||
Set<Channel> channels = session.getChannels();
|
||||
if (channels.isEmpty()) return;
|
||||
if (session == null || session.getChannels().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (Channel ch : channels) {
|
||||
writeToChannel(ch, message);
|
||||
for (Channel ch : session.getChannels()) {
|
||||
if (ch != null && ch.isActive()) {
|
||||
writeToChannel(ch, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +42,8 @@ public class MessageWriter {
|
||||
String payload = MAPPER.writeValueAsString(message);
|
||||
// 发送给客户端
|
||||
channel.writeAndFlush(new TextWebSocketFrame(payload));
|
||||
System.out.println("===发送消息===");
|
||||
System.out.println(payload);
|
||||
} catch (Exception e) {
|
||||
System.out.println("===发送消息失败===");
|
||||
e.printStackTrace();
|
||||
|
||||
@@ -35,7 +35,7 @@ mybatis-plus:
|
||||
cache-enabled: true
|
||||
use-generated-keys: true
|
||||
default-executor-type: simple
|
||||
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||
map-underscore-to-camel-case: true
|
||||
global-config:
|
||||
db-config:
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
VITE_SERVER_BASEURL = 'http://127.0.0.1:8084'
|
||||
VITE_WEBSOCKET_BASEURL = 'ws://127.0.0.1:8083/websocket'
|
||||
VITE_WEBSOCKET_BASEURL = 'ws://127.0.0.1:8090/ws'
|
||||
@@ -1,20 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PostTag } from './types/base'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted } from 'vue'
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { initBaseApi } from '@/request/api/base'
|
||||
import { useCommonStore } from '@/store/common'
|
||||
import { getPostTagApi } from './request/api/post'
|
||||
import { useUserStore } from './store/user'
|
||||
import { useWebSocket } from './store/websocket'
|
||||
|
||||
const websocket = useWebSocket()
|
||||
const commonOptions = useCommonStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
onMounted(() => {
|
||||
websocket.connect()
|
||||
|
||||
if (userStore.isLogined) {
|
||||
websocket.setAuthToken(userStore.token)
|
||||
}
|
||||
|
||||
handleInitData()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
websocket.close()
|
||||
})
|
||||
|
||||
function handleInitData() {
|
||||
initBaseApi().then((res) => {
|
||||
console.log(res)
|
||||
if (res.isSuccess) {
|
||||
commonOptions.areaOptions.splice(0, commonOptions.areaOptions.length, ...res.data.areas || [])
|
||||
commonOptions.industryOptions.splice(0, commonOptions.industryOptions.length, ...res.data.industries || [])
|
||||
@@ -29,7 +41,6 @@ function handleInitData() {
|
||||
|
||||
getPostTagApi()
|
||||
.then((res) => {
|
||||
console.log(res)
|
||||
if (res.isSuccess) {
|
||||
commonOptions.postTagOptions.splice(0, commonOptions.postTagOptions.length, ...res.data || [])
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import SafeImage from '@/components/SafeImage.vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useWebSocket } from '@/store/websocket'
|
||||
|
||||
const websocket = useWebSocket()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
@@ -25,6 +27,7 @@ function navigateTo(path: string) {
|
||||
|
||||
function exitLogin() {
|
||||
userStore.clear()
|
||||
websocket.logout()
|
||||
|
||||
router.push('/auth/login')
|
||||
}
|
||||
@@ -57,14 +60,16 @@ function isActive(path: string) {
|
||||
<div class="action">
|
||||
<i-tdesign:search class="action-item" />
|
||||
|
||||
<i-tdesign:notification v-if="userStore.isLogined" class="action-item" />
|
||||
<i-tdesign:notification v-if="userStore.isLogined" class="action-item" @click="navigateTo('/im')" />
|
||||
|
||||
<template v-if="userStore.isLogined">
|
||||
<el-divider direction="vertical" />
|
||||
|
||||
<el-dropdown>
|
||||
<div class="user">
|
||||
<SafeImage :url="userStore.user.avatar || userStore.company.logo" :size="36" round />
|
||||
<el-badge v-if="websocket.connected.value" :value="12" is-dot>
|
||||
<SafeImage :url="userStore.user.avatar || userStore.company.logo" :size="36" round />
|
||||
</el-badge>
|
||||
<div class="info">
|
||||
<span class="name">{{ userStore.user.name }}</span>
|
||||
<span class="company">{{ userStore.company.name }}</span>
|
||||
@@ -183,6 +188,10 @@ function isActive(path: string) {
|
||||
cursor: pointer;
|
||||
outline: 0;
|
||||
|
||||
:deep(.el-badge__content.is-dot) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -39,6 +39,10 @@ const router = createRouter({
|
||||
path: '/company/detail',
|
||||
component: () => import('@/views/company/detail.vue'),
|
||||
},
|
||||
{
|
||||
path: '/im',
|
||||
component: () => import('@/views/im/index.vue'),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: () => import('@/views/settings/index.vue'),
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import type { MessageRequest, MessageResponse } from '@/types/im'
|
||||
import { readonly, ref } from 'vue'
|
||||
import { MessageType } from '@/types/im'
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
url: string
|
||||
heartbeatInterval?: number
|
||||
pongTimeout?: number
|
||||
reconnectInterval?: number
|
||||
maxReconnectTimes?: number
|
||||
}
|
||||
|
||||
/* ================== 单例 ================== */
|
||||
let singleton: ReturnType<typeof createWebSocket> | null = null
|
||||
|
||||
function createWebSocket(options?: Partial<UseWebSocketOptions>) {
|
||||
/* ================== 配置 ================== */
|
||||
const config: Required<UseWebSocketOptions> = {
|
||||
url:
|
||||
options?.url
|
||||
|| import.meta.env.VITE_WEBSOCKET_BASEURL
|
||||
|| 'ws://127.0.0.1:8090/ws',
|
||||
|
||||
heartbeatInterval: options?.heartbeatInterval ?? 15_000,
|
||||
pongTimeout: options?.pongTimeout ?? 5_000,
|
||||
reconnectInterval: options?.reconnectInterval ?? 3_000,
|
||||
maxReconnectTimes: options?.maxReconnectTimes ?? 5,
|
||||
}
|
||||
|
||||
/* ================== 状态 ================== */
|
||||
let ws: WebSocket | null = null
|
||||
|
||||
const connected = ref(false)
|
||||
const authenticated = ref(false)
|
||||
|
||||
let cachedToken: string | null = null
|
||||
|
||||
let heartbeatTimer: number | null = null
|
||||
let pongTimeoutTimer: number | null = null
|
||||
let reconnectTimer: number | null = null
|
||||
|
||||
let reconnectTimes = 0
|
||||
let manualClose = false
|
||||
|
||||
/* ================== 外部消息回调 ================== */
|
||||
const messageHandlers: Array<(msg: MessageResponse) => void> = []
|
||||
|
||||
/* ================== 连接管理 ================== */
|
||||
|
||||
function connect() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
||||
console.log('[WebSocket] 已存在连接或正在连接,跳过')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[WebSocket] 开始建立连接')
|
||||
manualClose = false
|
||||
|
||||
ws = new WebSocket(config.url)
|
||||
|
||||
ws.onopen = () => {
|
||||
connected.value = true
|
||||
reconnectTimes = 0
|
||||
|
||||
console.log('[WebSocket] 连接成功')
|
||||
|
||||
startHeartbeat()
|
||||
|
||||
// 自动重新登录
|
||||
if (cachedToken) {
|
||||
console.log('[WebSocket] 自动重新发送 LOGIN')
|
||||
login(cachedToken)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onmessage = (evt) => {
|
||||
const msg: MessageResponse = JSON.parse(evt.data)
|
||||
handleMessage(msg)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.warn('[WebSocket] 连接关闭')
|
||||
|
||||
connected.value = false
|
||||
authenticated.value = false
|
||||
|
||||
stopHeartbeat()
|
||||
stopPongTimeout()
|
||||
|
||||
ws = null
|
||||
|
||||
if (!manualClose) {
|
||||
tryReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('[WebSocket] 连接错误', err)
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
console.log('[WebSocket] 主动关闭连接')
|
||||
|
||||
manualClose = true
|
||||
stopHeartbeat()
|
||||
stopPongTimeout()
|
||||
clearReconnectTimer()
|
||||
|
||||
ws?.close()
|
||||
ws = null
|
||||
}
|
||||
|
||||
/* ================== 重连 ================== */
|
||||
|
||||
function tryReconnect() {
|
||||
if (reconnectTimer || reconnectTimes >= config.maxReconnectTimes)
|
||||
return
|
||||
|
||||
reconnectTimes++
|
||||
console.warn(`[WebSocket] 第 ${reconnectTimes} 次重连中...`)
|
||||
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
connect()
|
||||
}, config.reconnectInterval)
|
||||
}
|
||||
|
||||
function clearReconnectTimer() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ================== 心跳 ================== */
|
||||
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat()
|
||||
|
||||
heartbeatTimer = window.setTimeout(() => {
|
||||
sendRaw(MessageType.PING)
|
||||
startPongTimeout()
|
||||
startHeartbeat()
|
||||
}, config.heartbeatInterval)
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatTimer) {
|
||||
clearTimeout(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startPongTimeout() {
|
||||
stopPongTimeout()
|
||||
|
||||
pongTimeoutTimer = window.setTimeout(() => {
|
||||
console.error('[WebSocket] 心跳超时,断开连接')
|
||||
ws?.close()
|
||||
}, config.pongTimeout)
|
||||
}
|
||||
|
||||
function stopPongTimeout() {
|
||||
if (pongTimeoutTimer) {
|
||||
clearTimeout(pongTimeoutTimer)
|
||||
pongTimeoutTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/* ================== 发送 ================== */
|
||||
|
||||
function sendRaw(type: MessageType, data?: any, to?: any) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('[WebSocket] 发送失败,未连接')
|
||||
return
|
||||
}
|
||||
|
||||
const msg: MessageRequest = {
|
||||
type,
|
||||
from: 0,
|
||||
to: to ?? null,
|
||||
timestamp: Date.now(),
|
||||
data: data ?? null,
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify(msg))
|
||||
}
|
||||
|
||||
function send(type: MessageType, data?: any, to?: any) {
|
||||
if (!authenticated.value && type !== MessageType.LOGIN) {
|
||||
console.warn('[WebSocket] 未 LOGIN,禁止发送业务消息')
|
||||
return
|
||||
}
|
||||
sendRaw(type, data, to)
|
||||
}
|
||||
|
||||
/* ================== 登录相关 ================== */
|
||||
|
||||
function setAuthToken(token: string | null) {
|
||||
cachedToken = token
|
||||
if (token && connected.value) {
|
||||
login(token)
|
||||
}
|
||||
}
|
||||
|
||||
function login(token: string) {
|
||||
if (!connected.value || authenticated.value)
|
||||
return
|
||||
cachedToken = token
|
||||
sendRaw(MessageType.LOGIN, { token })
|
||||
}
|
||||
|
||||
function logout() {
|
||||
cachedToken = null
|
||||
authenticated.value = false
|
||||
sendRaw(MessageType.LOGOUT)
|
||||
}
|
||||
|
||||
/* ================== 消息处理(核心修改点) ================== */
|
||||
|
||||
function handleMessage(msg: MessageResponse) {
|
||||
switch (msg.type) {
|
||||
case MessageType.PONG:
|
||||
stopPongTimeout()
|
||||
return
|
||||
|
||||
case MessageType.LOGIN:
|
||||
authenticated.value = true
|
||||
console.log('[WebSocket] LOGIN 成功')
|
||||
return
|
||||
|
||||
case MessageType.LOGOUT:
|
||||
authenticated.value = false
|
||||
console.log('[WebSocket] 已 LOGOUT')
|
||||
return
|
||||
|
||||
default:
|
||||
// 所有业务消息交给外部
|
||||
messageHandlers.forEach(cb => cb(msg))
|
||||
}
|
||||
}
|
||||
|
||||
/* ================== 对外订阅 ================== */
|
||||
|
||||
function onMessage(handler: (msg: MessageResponse) => void) {
|
||||
messageHandlers.push(handler)
|
||||
}
|
||||
|
||||
/* ================== 业务 API ================== */
|
||||
|
||||
function sendPrivate(toUserId: number, content: string) {
|
||||
send(MessageType.PRIVATE, { content }, toUserId)
|
||||
}
|
||||
|
||||
function sendGroup(groupId: number, content: string) {
|
||||
send(MessageType.GROUP, { groupId, content })
|
||||
}
|
||||
|
||||
/* ================== 初始化 ================== */
|
||||
|
||||
connect()
|
||||
|
||||
return {
|
||||
connect,
|
||||
close,
|
||||
setAuthToken,
|
||||
|
||||
login,
|
||||
logout,
|
||||
|
||||
sendPrivate,
|
||||
sendGroup,
|
||||
|
||||
onMessage,
|
||||
|
||||
connected: readonly(connected),
|
||||
authenticated: readonly(authenticated),
|
||||
}
|
||||
}
|
||||
|
||||
/* ================== 对外 ================== */
|
||||
|
||||
export function useWebSocket(options?: Partial<UseWebSocketOptions>) {
|
||||
if (!singleton) {
|
||||
singleton = createWebSocket(options)
|
||||
}
|
||||
return singleton
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export enum MessageType {
|
||||
PING = 0,
|
||||
PONG = 1,
|
||||
|
||||
LOGIN = 2,
|
||||
LOGOUT = 3,
|
||||
|
||||
ONLINE = 4,
|
||||
OFFLINE = 5,
|
||||
|
||||
PRIVATE = 6,
|
||||
GROUP = 7,
|
||||
}
|
||||
|
||||
export interface MessageRequest {
|
||||
type: number
|
||||
from: number
|
||||
to: any
|
||||
timestamp: number
|
||||
data: any
|
||||
}
|
||||
|
||||
export interface MessageResponse {
|
||||
type: number
|
||||
from: number
|
||||
data: any
|
||||
timestamp: number
|
||||
|
||||
code: number
|
||||
message: string
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import EmailCodeInput from '@/components/EmailCodeInput.vue'
|
||||
import { loginByEmailCodeApi, loginByPasswordApi } from '@/request/api/auth'
|
||||
import { authCodeSendApi } from '@/request/api/auth/code'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useWebSocket } from '@/store/websocket'
|
||||
import { AuthCodeChannel, AuthCodePurpose } from '@/types/auth/code'
|
||||
import { validateEmail } from '@/utils/validate'
|
||||
|
||||
const websocket = useWebSocket()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -134,7 +134,7 @@ async function submitForm(formEl: FormInstance | undefined) {
|
||||
ElMessage.success('登录成功')
|
||||
|
||||
userStore.setLoginInfo(res.data)
|
||||
|
||||
websocket.login(res.data.token)
|
||||
router.push('/')
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
<script lang="ts" setup>
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
|
||||
import { useWebSocket } from '@/store/websocket'
|
||||
|
||||
const websocket = useWebSocket()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="im">
|
||||
<div class="im__container">
|
||||
<div class="im__header">
|
||||
<div class="im__desc">
|
||||
<div class="im__logo">
|
||||
<SvgIcon icon="solar:buildings-2-linear" :size="28" color="#fff" />
|
||||
</div>
|
||||
<div class="im__desc-extra">
|
||||
<div class="im__title">
|
||||
Link56 在线聊天
|
||||
</div>
|
||||
<div class="im__subtitle">
|
||||
实时沟通 · 私聊 · 群聊
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="im__user">
|
||||
<div class="im__user-info">
|
||||
<div
|
||||
class="im__user-status"
|
||||
:class="websocket.connected.value ? 'im__user-status--online' : 'im__user-status--offline'"
|
||||
>
|
||||
<span>{{ websocket.connected.value ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="im__body">
|
||||
<div class="im__list">
|
||||
<div class="im__list-search">
|
||||
<el-input placeholder="请根据名称搜索" :prefix-icon="Search" />
|
||||
</div>
|
||||
|
||||
<div class="im__list-item">
|
||||
<div class="im__list-header">
|
||||
<SvgIcon icon="tabler:message-circle" :size="16" />
|
||||
<span class="im__list-header-title">
|
||||
单聊
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.im {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background-color: #f5f6fa;
|
||||
|
||||
& *::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
& *::-webkit-scrollbar-track {
|
||||
background-color: #fff;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
& *::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
& *::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.im__container {
|
||||
width: 1300px;
|
||||
height: 80%;
|
||||
min-height: 700px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.im__header {
|
||||
padding: 0 16px;
|
||||
height: 64px;
|
||||
border-bottom: 1px solid #e6e9ef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
.im__desc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.im__logo {
|
||||
background-color: #1553f1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||
0 2px 4px -2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.im__desc-extra {
|
||||
margin-left: 12px;
|
||||
|
||||
.im__title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.im__subtitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.im__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0px 8px;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.im__user-info {
|
||||
margin-left: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.im__user-name {
|
||||
font-size: 14px;
|
||||
}
|
||||
.im__user-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
.im__user-status--online {
|
||||
color: #4caf50;
|
||||
}
|
||||
.im__user-status--offline {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.im__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.im__list {
|
||||
width: 300px;
|
||||
border-right: 1px solid #ddd;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
.im__list-search {
|
||||
height: 56px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #e6e9ef;
|
||||
}
|
||||
|
||||
.im__list-item {
|
||||
display: flex;
|
||||
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-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
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:has(.im__chat-item-last-message:not(:empty)) {
|
||||
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;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.im__window {
|
||||
flex: 1;
|
||||
background-color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.im__window-header {
|
||||
height: 56px;
|
||||
border-bottom: 1px solid #e6e9ef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
.im__window-peer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0px 8px;
|
||||
|
||||
.im__window-peer-info {
|
||||
margin-left: 8px;
|
||||
.im__window-peer-name {
|
||||
font-size: 16px;
|
||||
}
|
||||
.im__window-peer-desc {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
|
||||
.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-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.im__chat-message-wrapper {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.im__chat-message-time {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.im__chat-message {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
width: 100%;
|
||||
.im__chat-message-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.im__chat-message-body {
|
||||
width: 100%;
|
||||
.im__chat-message-name {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
.im__chat-message-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.im__chat-message-content--text {
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
word-break: break-all;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
.im__chat-message--from {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
.im__chat-message-body {
|
||||
margin-right: 8px;
|
||||
text-align: right;
|
||||
.im__chat-message-content--text {
|
||||
background-color: #3c73ff;
|
||||
color: #fff;
|
||||
}
|
||||
.im__chat-message-name {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.im__chat-message-status {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.im__chat-message--to {
|
||||
text-align: left;
|
||||
align-items: flex-start;
|
||||
.im__chat-message-body {
|
||||
margin-left: 8px;
|
||||
.im__chat-message-status {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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: 64px;
|
||||
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: 8px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.im__window-center {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user