u
This commit is contained in:
1 parent
35951777b0
commit
63f1d4d1dc
39 files changed
+880
-133
No files matched your search
+5
-5
@@ -21,15 +21,15 @@
|
||||
<option name="name" value="JBoss Community repository" />
|
||||
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="aliyun" />
|
||||
<option name="name" value="aliyun" />
|
||||
<option name="url" value="http://maven.aliyun.com/nexus/content/groups/public" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="central" />
|
||||
<option name="name" value="Central Repository" />
|
||||
<option name="url" value="https://maven.aliyun.com/repository/public" />
|
||||
</remote-repository>
|
||||
<remote-repository>
|
||||
<option name="id" value="aliyun" />
|
||||
<option name="name" value="aliyun" />
|
||||
<option name="url" value="http://maven.aliyun.com/nexus/content/groups/public" />
|
||||
</remote-repository>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+1
-1
@@ -8,5 +8,5 @@
|
||||
</list>
|
||||
</option>
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="temurin-1.8" project-jdk-type="JavaSDK" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK" />
|
||||
</project>
|
||||
@@ -58,11 +58,11 @@
|
||||
<artifactId>spring-boot-starter-logging</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-devtools</artifactId>
|
||||
<optional>true</optional> <!-- 可选依赖,不打包到生产 -->
|
||||
</dependency>
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>org.springframework.boot</groupId>-->
|
||||
<!-- <artifactId>spring-boot-devtools</artifactId>-->
|
||||
<!-- <optional>true</optional> <!– 可选依赖,不打包到生产 –>-->
|
||||
<!-- </dependency>-->
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
|
||||
@@ -24,6 +24,7 @@ public class AuthController {
|
||||
response.put("message", "Missing parameters");
|
||||
return response;
|
||||
}
|
||||
|
||||
UserInfo userInfo = UserRepository.getUserPasswordById(orgId, userId);
|
||||
if (userInfo != null && userInfo.getPassword().equals(password)) {
|
||||
String token = JwtUtil.generateToken(userId, orgId);
|
||||
@@ -33,6 +34,7 @@ public class AuthController {
|
||||
} else {
|
||||
response.put("message", "Invalid credentials");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
-5
@@ -19,10 +19,7 @@ public class ColumnConfigController {
|
||||
*/
|
||||
@GetMapping("/api/columns")
|
||||
public Result<?> getColumnConfig(@RequestParam String tableName, HttpServletRequest request) {
|
||||
|
||||
|
||||
try {
|
||||
|
||||
String orgId = UserContext.getOrgId();
|
||||
System.out.println("api/columns:"+orgId);
|
||||
List<Map<String, Object>> data = JsonUtils.queryColumnConfig(orgId, tableName);
|
||||
@@ -32,10 +29,8 @@ public class ColumnConfigController {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@GetMapping("/api/columnsEdit")
|
||||
public Result<?> getColumnEditConfig(@RequestParam String tableName, HttpServletRequest request) {
|
||||
|
||||
try {
|
||||
String orgId = UserContext.getOrgId();
|
||||
List<Map<String, Object>> data = JsonUtils.queryColumnEditConfig(orgId, tableName);
|
||||
|
||||
@@ -8,7 +8,7 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("http://localhost:5173", "http://localhost:8089","http://localhost:5174") // 前端地址
|
||||
.allowedOriginPatterns("*") // 前端地址
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
|
||||
@@ -3,11 +3,15 @@ package com.link.api.util;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.link.im.manager.UserChannelManager;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.sql.*;
|
||||
import java.sql.Date;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class PaginationUtils {
|
||||
|
||||
@@ -470,5 +474,5 @@ public class PaginationUtils {
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.link.im.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.link.api.util.DruidDataSourceManager;
|
||||
import com.link.api.util.PaginationUtils;
|
||||
import com.link.api.util.Result;
|
||||
import com.link.api.util.UserContext;
|
||||
import com.link.im.manager.UserChannelManager;
|
||||
import com.link.im.utils.DbUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RestController
|
||||
@CrossOrigin
|
||||
public class CompanyController {
|
||||
|
||||
@GetMapping("/api/loadCompanyList")
|
||||
public Result<?> getCompanyList(){
|
||||
try {
|
||||
String orgId = UserContext.getOrgId();
|
||||
String sql = "select b_id,b_name from b_user where b_canuse = '1' ";
|
||||
|
||||
List<Map<String, Object>> maps = DbUtils.queryForList(orgId, sql);
|
||||
|
||||
for (Map<String, Object> map : maps) {
|
||||
String bId = map.get("b_id").toString();
|
||||
|
||||
map.put("_online",UserChannelManager.isOnline(bId));
|
||||
}
|
||||
|
||||
return Result.ok("成功",maps);
|
||||
} catch (Exception e) {
|
||||
return Result.fail("公司列表查询异常:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.link.im.handler;
|
||||
|
||||
import com.link.api.util.UserContext;
|
||||
import com.link.im.manager.UserChannelManager;
|
||||
import com.link.im.manager.WriteManager;
|
||||
import com.link.im.model.ImRequest;
|
||||
import com.link.im.model.ImResponse;
|
||||
import com.link.im.utils.CommonUtils;
|
||||
import com.link.im.utils.DbUtils;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Component
|
||||
public class ImMessageProcessor {
|
||||
public void process(ChannelHandlerContext ctx, ImRequest request) {
|
||||
String type = request.getType();
|
||||
if (type == null) {
|
||||
WriteManager.writeToChannel(ctx.channel(), "消息类型不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "ping":
|
||||
handlePing(ctx);
|
||||
break;
|
||||
case "login":
|
||||
handleLogin(ctx, request);
|
||||
break;
|
||||
default:
|
||||
WriteManager.writeToChannel(ctx.channel(), "未知的消息类型: " + type);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void handlePing(ChannelHandlerContext ctx) {
|
||||
WriteManager.writeToChannel(ctx.channel(), ImResponse.success("pong",null));
|
||||
}
|
||||
|
||||
private void handleLogin(ChannelHandlerContext ctx,ImRequest request){
|
||||
String fromUserid = request.getFrom();
|
||||
UserChannelManager.addUser(fromUserid,ctx.channel());
|
||||
String orgId = request.getOrgId();
|
||||
|
||||
System.out.println(orgId);
|
||||
|
||||
// 1.通知给好友
|
||||
String sql1 = "select b_frienduser_Id from IM_Friends where b_user_id = '" + fromUserid +"'";
|
||||
try {
|
||||
List<Map<String, Object>> maps = DbUtils.queryForList(orgId, sql1);
|
||||
for (Map<String, Object> map : maps) {
|
||||
String bFrienduserId = map.get("b_frienduser_Id").toString();
|
||||
if (CommonUtils.isNotEmpty(bFrienduserId)) {
|
||||
boolean friendOnline = UserChannelManager.isOnline(bFrienduserId);
|
||||
if(friendOnline){
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
// 2.通知给群组
|
||||
|
||||
// 3.通知给全部(测试用,先留,后面会删)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import com.link.im.manager.GroupManager;
|
||||
import com.link.im.manager.UserChannelManager;
|
||||
import com.link.im.manager.WriteManager;
|
||||
import com.link.im.model.ImMessage;
|
||||
import com.link.im.model.ImRequest;
|
||||
import com.link.im.model.ImResponse;
|
||||
import io.netty.channel.ChannelHandler;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.SimpleChannelInboundHandler;
|
||||
@@ -17,6 +19,7 @@ import org.slf4j.LoggerFactory;
|
||||
public class ImWebSocketHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ImWebSocketHandler.class);
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
private final ImMessageProcessor imMessageProcessor = new ImMessageProcessor();
|
||||
|
||||
@Override
|
||||
public void handlerAdded(ChannelHandlerContext ctx) {
|
||||
@@ -35,14 +38,15 @@ public class ImWebSocketHandler extends SimpleChannelInboundHandler<TextWebSocke
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception {
|
||||
String text = msg.text();
|
||||
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) {
|
||||
try {
|
||||
ImMessage imMessage = MAPPER.readValue(text, ImMessage.class);
|
||||
|
||||
System.out.println(imMessage);
|
||||
System.out.println(msg.text());
|
||||
ImRequest request = MAPPER.readValue(msg.text(), ImRequest.class);
|
||||
imMessageProcessor.process(ctx,request);
|
||||
} catch (Exception e) {
|
||||
WriteManager.writeToChannel(ctx.channel(),"JSON格式错误");
|
||||
logger.error("channelRead0数据处理失败", e);
|
||||
|
||||
WriteManager.writeToChannel(ctx.channel(),ImResponse.error("error",e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package com.link.im.manager;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -14,20 +12,45 @@ public class UserChannelManager {
|
||||
private static final ConcurrentHashMap<String, Channel> userChannelMap = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 添加用户与Channel绑定
|
||||
* 添加用户与Channel绑定,如果已有绑定,先移除旧的
|
||||
*/
|
||||
public static void addUser(String userId, Channel channel) {
|
||||
if (userId != null && channel != null) {
|
||||
userChannelMap.put(userId, channel);
|
||||
if (userId == null || channel == null) return;
|
||||
|
||||
// 如果已有旧的 channel,先移除
|
||||
Channel oldChannel = userChannelMap.put(userId, channel);
|
||||
|
||||
if (oldChannel != null && oldChannel != channel) {
|
||||
try {
|
||||
if (oldChannel.isActive()) {
|
||||
oldChannel.close();
|
||||
}
|
||||
logger.info("用户 {} 已有旧 channel,被替换: oldChannelId={}, newChannelId={}",
|
||||
userId, oldChannel.id().asShortText(), channel.id().asShortText());
|
||||
} catch (Exception e) {
|
||||
logger.error("关闭旧 channel 异常: userId={}", userId, e);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("用户上线: userId={}, channelId={}", userId, channel.id().asShortText());
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除用户绑定
|
||||
*/
|
||||
public static void removeUserById(String userId) {
|
||||
if (userId != null) {
|
||||
userChannelMap.remove(userId);
|
||||
if (userId == null) return;
|
||||
|
||||
Channel removed = userChannelMap.remove(userId);
|
||||
if (removed != null) {
|
||||
try {
|
||||
if (removed.isActive()) {
|
||||
removed.close();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.error("关闭 channel 异常: userId={}", userId, e);
|
||||
}
|
||||
logger.info("用户下线: userId={}, channelId={}", userId, removed.id().asShortText());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,5 +83,4 @@ public class UserChannelManager {
|
||||
Channel ch = userChannelMap.get(userId);
|
||||
return ch != null && ch.isActive();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -24,6 +24,18 @@ public class ImMessage {
|
||||
return type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ImMessage{" +
|
||||
"type='" + type + '\'' +
|
||||
", msg='" + msg + '\'' +
|
||||
", from='" + from + '\'' +
|
||||
", to='" + to + '\'' +
|
||||
", content='" + content + '\'' +
|
||||
", timestamp=" + timestamp +
|
||||
'}';
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.link.im.model;
|
||||
|
||||
public class ImRequest {
|
||||
private String type;
|
||||
private String from;
|
||||
private String to;
|
||||
private String content;
|
||||
private String orgId;
|
||||
private long timestamp;
|
||||
|
||||
// 无参构造
|
||||
public ImRequest() {}
|
||||
|
||||
// 全参构造
|
||||
public ImRequest(String type, String from, String to, String content, long timestamp,String orgId) {
|
||||
this.type = type;
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.content = content;
|
||||
this.timestamp = timestamp;
|
||||
this.orgId = orgId;
|
||||
}
|
||||
|
||||
public String getOrgId() {
|
||||
return orgId;
|
||||
}
|
||||
|
||||
public void setOrgId(String orgId) {
|
||||
this.orgId = orgId;
|
||||
}
|
||||
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getFrom() {
|
||||
return from;
|
||||
}
|
||||
|
||||
public void setFrom(String from) {
|
||||
this.from = from;
|
||||
}
|
||||
|
||||
public String getTo() {
|
||||
return to;
|
||||
}
|
||||
|
||||
public void setTo(String to) {
|
||||
this.to = to;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public void setTimestamp(long timestamp) {
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ImRequest{" +
|
||||
"type='" + type + '\'' +
|
||||
", from='" + from + '\'' +
|
||||
", to='" + to + '\'' +
|
||||
", content='" + content + '\'' +
|
||||
", timestamp=" + timestamp +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.link.im.model;
|
||||
|
||||
public class ImResponse {
|
||||
private String type;
|
||||
private int code;
|
||||
private String message;
|
||||
private long timestamp;
|
||||
private Object data;
|
||||
|
||||
// 无参构造
|
||||
public ImResponse() {}
|
||||
|
||||
// 全参构造
|
||||
public ImResponse(String type, int code, String message, long timestamp, Object data) {
|
||||
this.type = type;
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.timestamp = timestamp;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public static ImResponse success(String type, Object data) {
|
||||
return new ImResponse(type, 200, "success", System.currentTimeMillis(), data);
|
||||
}
|
||||
|
||||
public static ImResponse success(String type, String message, Object data) {
|
||||
return new ImResponse(type, 200, message, System.currentTimeMillis(), data);
|
||||
}
|
||||
|
||||
public static ImResponse error(String type, String message) {
|
||||
return new ImResponse(type, 500, message, System.currentTimeMillis(), null);
|
||||
}
|
||||
|
||||
public static ImResponse error(String type, int code, String message) {
|
||||
return new ImResponse(type, code, message, System.currentTimeMillis(), null);
|
||||
}
|
||||
|
||||
// Getter & Setter
|
||||
public String getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public void setType(String type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public long getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public void setTimestamp(long timestamp) {
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
public Object getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public void setData(Object data) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ImResponse{" +
|
||||
"type='" + type + '\'' +
|
||||
", code=" + code +
|
||||
", message='" + message + '\'' +
|
||||
", timestamp=" + timestamp +
|
||||
", data=" + data +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.link.im.utils;
|
||||
|
||||
public class CommonUtils {
|
||||
/**
|
||||
* 判断字符串是否为空(null 或 "")
|
||||
* @param str 待判断的字符串
|
||||
* @return true 如果字符串为 null 或 空串
|
||||
*/
|
||||
public static boolean isEmpty(String str) {
|
||||
return str == null || str.trim().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否非空(不是 null 且不为空串)
|
||||
* @param str 待判断的字符串
|
||||
* @return true 如果字符串非空
|
||||
*/
|
||||
public static boolean isNotEmpty(String str) {
|
||||
return !isEmpty(str);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.link.im.utils;
|
||||
|
||||
|
||||
import com.link.api.util.DruidDataSourceManager;
|
||||
|
||||
import java.sql.*;
|
||||
import java.util.*;
|
||||
|
||||
public class DbUtils {
|
||||
|
||||
/**
|
||||
* 根据 SQL 查询数据
|
||||
*
|
||||
* @param orgCode 数据源组织编码
|
||||
* @param sql 查询 SQL
|
||||
* @return List<Map<String,Object>>,每一行作为 Map
|
||||
*/
|
||||
public static List<Map<String, Object>> queryForList(String orgCode, String sql) throws SQLException {
|
||||
List<Map<String, Object>> resultList = new ArrayList<>();
|
||||
|
||||
try (Connection conn = DruidDataSourceManager.getConnection(orgCode);
|
||||
PreparedStatement stmt = conn.prepareStatement(sql);
|
||||
ResultSet rs = stmt.executeQuery()) {
|
||||
|
||||
ResultSetMetaData metaData = rs.getMetaData();
|
||||
int columnCount = metaData.getColumnCount();
|
||||
|
||||
while (rs.next()) {
|
||||
Map<String, Object> rowMap = new LinkedHashMap<>();
|
||||
for (int i = 1; i <= columnCount; i++) {
|
||||
String columnName = metaData.getColumnLabel(i);
|
||||
Object value = rs.getObject(i);
|
||||
rowMap.put(columnName, value);
|
||||
}
|
||||
resultList.add(rowMap);
|
||||
}
|
||||
}
|
||||
|
||||
return resultList;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
url=jdbc:sqlserver://127.0.0.1:1433;databaseName=G3HY2025
|
||||
url=jdbc:sqlserver://192.168.1.189:1433;databaseName=G3-Link;encrypt=false
|
||||
username=sa
|
||||
password=g3soft
|
||||
driver=com.microsoft.sqlserver.jdbc.SQLServerDriver
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,4 +1,4 @@
|
||||
url=jdbc:sqlserver://127.0.0.1:1433;databaseName=G3HY2025
|
||||
url=jdbc:sqlserver://192.168.1.189:1433;databaseName=G3-Link;encrypt=false
|
||||
username=sa
|
||||
password=g3soft
|
||||
driver=com.microsoft.sqlserver.jdbc.SQLServerDriver
|
||||
@@ -1,18 +1,15 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import Layout from '@/layout/index.vue'
|
||||
import { useWebSocket } from './stores/websocketStore'
|
||||
|
||||
const websocket = useWebSocket()
|
||||
|
||||
onMounted(() => {
|
||||
websocket.connect('ws://127.0.0.1:8090/ws')
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
websocket.disconnect()
|
||||
// websocket.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<Layout />
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '../stores/userStore'
|
||||
import { useWebSocket } from '../stores/websocketStore'
|
||||
|
||||
const websocket = useWebSocket()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const activeIndex = ref('/home')
|
||||
|
||||
onMounted(() => {
|
||||
router.afterEach((to) => {
|
||||
activeIndex.value = to.path
|
||||
})
|
||||
})
|
||||
|
||||
function toPath(url) {
|
||||
if (url === '/login') {
|
||||
console.log(userStore.isLogined())
|
||||
|
||||
if (userStore.isLogined()) {
|
||||
websocket.disconnect()
|
||||
userStore.resetUserInfo()
|
||||
}
|
||||
router.push(url)
|
||||
}
|
||||
else {
|
||||
router.push(url)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<el-menu
|
||||
v-model="activeIndex"
|
||||
:default-active="activeIndex"
|
||||
mode="horizontal"
|
||||
active-text-color="#1553f1"
|
||||
>
|
||||
<el-menu-item index="/home" @click="toPath('/home')">
|
||||
首页
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/im" @click="toPath('/im')">
|
||||
IM即时通信
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/login" @click="toPath('/login')">
|
||||
登录
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
<div class="layout-main">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
& *::-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);
|
||||
}
|
||||
|
||||
.el-menu--horizontal {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,30 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import Login from '@/views/auth/index.vue'
|
||||
import Home from '@/views/home/index.vue'
|
||||
import Im from '@/views/im/index.vue'
|
||||
|
||||
import { useWebSocket } from '../stores/websocketStore'
|
||||
// 新增登录页面
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/im',
|
||||
redirect: '/home',
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
component: Home,
|
||||
meta: { title: '首页' },
|
||||
},
|
||||
{
|
||||
path: '/im',
|
||||
component: Im,
|
||||
meta: { title: 'IM聊天' },
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
component: Login,
|
||||
meta: { title: '登录' },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -17,16 +33,36 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
// 全局前置守卫
|
||||
router.beforeEach((to, from, next) => {
|
||||
// 设置页面标题
|
||||
document.title = to.meta.title ? `${to.meta.title} - 系统名称` : '系统名称'
|
||||
|
||||
// 通过检查,继续跳转
|
||||
const userStore = useUserStore()
|
||||
const websocket = useWebSocket()
|
||||
|
||||
// 登录页直接放行,不主动断开
|
||||
if (to.path === '/login') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
if (!userStore.isLogined()) {
|
||||
ElMessage.warning('请先登录')
|
||||
|
||||
userStore.resetUserInfo()
|
||||
next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
|
||||
// 登录状态下,确保连接
|
||||
if (!websocket.isConnected.value) {
|
||||
websocket.connect()
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
router.afterEach((to, from) => {
|
||||
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -4,18 +4,19 @@ import { ref } from 'vue'
|
||||
export const useUserStore = defineStore(
|
||||
'userInfo',
|
||||
() => {
|
||||
|
||||
const userInfo = ref({
|
||||
orgid: '',
|
||||
orgid: 'g3hd',
|
||||
userid: '',
|
||||
token: '',
|
||||
username: '',
|
||||
})
|
||||
|
||||
// 设置用户信息
|
||||
function setUserInfo(orgid, userid, token) {
|
||||
function setUserInfo(orgid, userid, username, token) {
|
||||
userInfo.value.orgid = orgid
|
||||
userInfo.value.userid = userid
|
||||
userInfo.value.token = token
|
||||
userInfo.value.username = username
|
||||
}
|
||||
|
||||
// 重置用户信息
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// stores/websocket.js
|
||||
import { ElNotification } from 'element-plus'
|
||||
import { onScopeDispose, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useUserStore } from './userStore'
|
||||
|
||||
let instance = null // 单例缓存
|
||||
|
||||
export function useWebSocket() {
|
||||
if (instance)
|
||||
return instance // 已存在则直接返回
|
||||
|
||||
const userStore = useUserStore()
|
||||
const socket = ref(null)
|
||||
const isConnected = ref(false)
|
||||
const listeners = ref(new Set())
|
||||
@@ -9,25 +17,19 @@ export function useWebSocket() {
|
||||
let reconnectAttempts = 0
|
||||
let pingTimer = null
|
||||
let pongTimeout = null
|
||||
const PING_INTERVAL = 10000 // 收到PONG后间隔再发送下一次PING
|
||||
const PONG_TIMEOUT = 15000 // PING发送后15秒未收到PONG就重连
|
||||
|
||||
const PING_INTERVAL = 10000
|
||||
const PONG_TIMEOUT = 15000
|
||||
const currentUrl = 'ws://127.0.0.1:8090/ws'
|
||||
let reconnectNotification = null
|
||||
let currentUrl = null
|
||||
let isReconnecting = false
|
||||
let manualClose = false // ✅ 标记是否为手动关闭
|
||||
|
||||
// --------------------
|
||||
// 私有方法
|
||||
// --------------------
|
||||
// ---------------- 清理 ----------------
|
||||
function cleanup() {
|
||||
if (pingTimer) {
|
||||
clearTimeout(pingTimer)
|
||||
pingTimer = null
|
||||
}
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout)
|
||||
pongTimeout = null
|
||||
}
|
||||
clearTimeout(pingTimer)
|
||||
clearTimeout(pongTimeout)
|
||||
pingTimer = null
|
||||
pongTimeout = null
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
@@ -39,23 +41,14 @@ export function useWebSocket() {
|
||||
listeners.value.forEach(cb => cb(message))
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// 心跳逻辑(链式)
|
||||
// --------------------
|
||||
// ---------------- 心跳 ----------------
|
||||
function sendPing() {
|
||||
if (!socket.value || socket.value.readyState !== WebSocket.OPEN)
|
||||
return
|
||||
|
||||
try {
|
||||
sendMessage('PING')
|
||||
}
|
||||
catch (err) {
|
||||
console.error('发送 PING 失败', err)
|
||||
}
|
||||
sendMessage({ type: 'ping' })
|
||||
|
||||
// 设置 PONG 超时
|
||||
if (pongTimeout)
|
||||
clearTimeout(pongTimeout)
|
||||
clearTimeout(pongTimeout)
|
||||
pongTimeout = setTimeout(() => {
|
||||
console.warn('未收到PONG,触发重连')
|
||||
handleReconnect()
|
||||
@@ -63,15 +56,8 @@ export function useWebSocket() {
|
||||
}
|
||||
|
||||
function handlePong() {
|
||||
// 收到 PONG,清理超时
|
||||
if (pongTimeout) {
|
||||
clearTimeout(pongTimeout)
|
||||
pongTimeout = null
|
||||
}
|
||||
|
||||
// 等 PING_INTERVAL 后再发送下一次 PING
|
||||
if (pingTimer)
|
||||
clearTimeout(pingTimer)
|
||||
clearTimeout(pongTimeout)
|
||||
clearTimeout(pingTimer)
|
||||
pingTimer = setTimeout(sendPing, PING_INTERVAL)
|
||||
}
|
||||
|
||||
@@ -79,62 +65,57 @@ export function useWebSocket() {
|
||||
sendPing()
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// 重连逻辑
|
||||
// --------------------
|
||||
// ---------------- 重连 ----------------
|
||||
function handleReconnect() {
|
||||
if (isReconnecting)
|
||||
return
|
||||
if (isReconnecting || manualClose)
|
||||
return // ✅ 如果是手动断开则不重连
|
||||
|
||||
isReconnecting = true
|
||||
cleanup()
|
||||
isConnected.value = false
|
||||
socket.value?.close()
|
||||
|
||||
reconnectAttempts++
|
||||
if (reconnectNotification)
|
||||
reconnectNotification.close()
|
||||
reconnectNotification?.close()
|
||||
reconnectNotification = ElNotification({
|
||||
title: '提示',
|
||||
message: `WebSocket 连接失败,正在第 ${reconnectAttempts} 次重连...`,
|
||||
type: 'warning',
|
||||
duration: 0,
|
||||
key: 'websocket-reconnect',
|
||||
})
|
||||
|
||||
const delay = Math.min(3000 * 2 ** (reconnectAttempts - 1), 30000)
|
||||
setTimeout(() => {
|
||||
isReconnecting = false
|
||||
connect(currentUrl)
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
function clearReconnectNotification() {
|
||||
if (reconnectNotification) {
|
||||
reconnectNotification.close()
|
||||
reconnectNotification = null
|
||||
}
|
||||
reconnectNotification?.close()
|
||||
reconnectNotification = null
|
||||
}
|
||||
|
||||
// --------------------
|
||||
// 公共方法
|
||||
// --------------------
|
||||
function connect(url) {
|
||||
currentUrl = url
|
||||
// ---------------- 公共方法 ----------------
|
||||
function connect() {
|
||||
if (isConnected.value)
|
||||
return
|
||||
if (socket.value)
|
||||
disconnect()
|
||||
|
||||
const newSocket = new WebSocket(url)
|
||||
manualClose = false // ✅ 每次连接前重置为 false
|
||||
|
||||
const newSocket = new WebSocket(currentUrl)
|
||||
|
||||
newSocket.onopen = () => {
|
||||
isConnected.value = true
|
||||
isReconnecting = false
|
||||
clearReconnectNotification()
|
||||
|
||||
if (userStore.isLogined)
|
||||
sendLogin(userStore.userInfo.userid)
|
||||
|
||||
ElNotification({
|
||||
title: '提示',
|
||||
message: 'WebSocket 连接成功',
|
||||
message: '上线成功',
|
||||
type: 'success',
|
||||
duration: 2000,
|
||||
})
|
||||
@@ -144,26 +125,42 @@ export function useWebSocket() {
|
||||
}
|
||||
|
||||
newSocket.onmessage = (event) => {
|
||||
let message
|
||||
let resData
|
||||
try {
|
||||
message = JSON.parse(event.data)
|
||||
console.log(event.data)
|
||||
|
||||
if (message.type === 'PONG') {
|
||||
handlePong()
|
||||
return
|
||||
}
|
||||
|
||||
notifyListeners(message)
|
||||
resData = JSON.parse(event.data)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('消息解析失败:', err)
|
||||
notifyListeners({ type: 'ERROR', data: '消息解析失败' })
|
||||
return
|
||||
}
|
||||
|
||||
if (resData.code !== 200) {
|
||||
ElNotification({
|
||||
title: '错误',
|
||||
message: resData.message || '发生未知错误',
|
||||
type: 'error',
|
||||
showClose: true,
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (resData.type === 'pong') {
|
||||
handlePong()
|
||||
}
|
||||
else {
|
||||
notifyListeners(resData)
|
||||
}
|
||||
}
|
||||
|
||||
newSocket.onclose = () => {
|
||||
handleDisconnect()
|
||||
if (!isReconnecting)
|
||||
|
||||
// ✅ 非手动断开才重连
|
||||
if (!manualClose && !isReconnecting)
|
||||
handleReconnect()
|
||||
}
|
||||
|
||||
@@ -175,14 +172,25 @@ export function useWebSocket() {
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (!socket.value)
|
||||
return
|
||||
|
||||
manualClose = true
|
||||
socket.value?.close()
|
||||
cleanup()
|
||||
clearReconnectNotification()
|
||||
|
||||
ElNotification({
|
||||
type: 'warning',
|
||||
title: '提示',
|
||||
message: '已下线',
|
||||
})
|
||||
}
|
||||
|
||||
function sendMessage(type, data) {
|
||||
function sendMessage(data) {
|
||||
if (isConnected.value && socket.value) {
|
||||
const message = { type, data }
|
||||
socket.value.send(JSON.stringify(message))
|
||||
data.orgId = userStore.userInfo.orgid || ''
|
||||
socket.value.send(JSON.stringify(data))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,13 +199,23 @@ export function useWebSocket() {
|
||||
return () => listeners.value.delete(callback)
|
||||
}
|
||||
|
||||
onScopeDispose(() => disconnect())
|
||||
function sendLogin(userId) {
|
||||
if (!isConnected.value)
|
||||
return
|
||||
sendMessage({
|
||||
type: 'login',
|
||||
from: userId,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
instance = {
|
||||
isConnected,
|
||||
connect,
|
||||
disconnect,
|
||||
sendMessage,
|
||||
onMessage,
|
||||
sendLogin,
|
||||
}
|
||||
|
||||
return instance
|
||||
}
|
||||
@@ -1,34 +1,51 @@
|
||||
// src/utils/axios.js
|
||||
import axios from 'axios'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: 'http://localhost:8089/api', // 后端接口地址
|
||||
timeout: 5000
|
||||
})
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
// 请求拦截器:自动添加 token
|
||||
instance.interceptors.request.use(
|
||||
config => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
(config) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
if (userStore.userInfo.token) {
|
||||
config.headers.Authorization = `Bearer ${userStore.userInfo.token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
error => Promise.reject(error),
|
||||
)
|
||||
|
||||
// 响应拦截器:统一处理错误或过期 token
|
||||
instance.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
(error) => {
|
||||
const userStore = useUserStore()
|
||||
if (error.response && error.response.status === 401) {
|
||||
alert('登录失效,请重新登录')
|
||||
window.localStorage.removeItem('token')
|
||||
userStore.resetUserInfo()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export function axiosPost(url, data = {}, config = {}) {
|
||||
return instance.post(url, data, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...config,
|
||||
}).then(res => res.data)
|
||||
}
|
||||
|
||||
export function axiosGet(url, params = {}, config = {}) {
|
||||
return instance.get(url, {
|
||||
params,
|
||||
...config,
|
||||
}).then(res => res.data)
|
||||
}
|
||||
|
||||
export default instance
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup>
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { axiosPost } from '@/utils/axios'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const router = useRouter()
|
||||
const form = ref({
|
||||
orgid: userStore.userInfo.orgid,
|
||||
userid: userStore.userInfo.userid,
|
||||
password: '',
|
||||
})
|
||||
const loginLoading = ref(false)
|
||||
|
||||
async function onLogin() {
|
||||
loginLoading.value = true
|
||||
try {
|
||||
const resData = await axiosPost(
|
||||
'/login',
|
||||
{
|
||||
orgid: form.value.orgid,
|
||||
userid: form.value.userid,
|
||||
password: form.value.password,
|
||||
},
|
||||
)
|
||||
if (resData.token) {
|
||||
userStore.setUserInfo(form.value.orgid, form.value.userid, resData.userInfo.username, resData.token)
|
||||
ElMessage.success(resData.message || '登录成功')
|
||||
router.push('/home')
|
||||
}
|
||||
else {
|
||||
ElMessage.error(resData.message || '登录失败')
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
ElMessage.error(`请求错误:${err.response?.data?.message || err.message}`)
|
||||
}
|
||||
finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<div class="login">
|
||||
<div class="login-left">
|
||||
<img src="/vite.svg" alt="Logo">
|
||||
</div>
|
||||
<div class="login-right">
|
||||
<div class="login-right-title">
|
||||
Online
|
||||
</div>
|
||||
<el-form :model="form" label-width="auto" label-position="top" size="large">
|
||||
<el-form-item label="用户名" required>
|
||||
<el-input v-model="form.userid" style="height: 36px !important" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="form.password" type="password" show-password style="height: 36px !important"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary" :loading="loginLoading" style="width: 100%; margin-top: 16px"
|
||||
@click="onLogin"
|
||||
>
|
||||
登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="css">
|
||||
.app {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.login {
|
||||
display: flex;
|
||||
width: 1000px;
|
||||
height: 500px;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0px 6px 24px -8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60%;
|
||||
padding: 32px;
|
||||
border-right: 1px solid #e9e9e9;
|
||||
}
|
||||
|
||||
.login-left img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.login-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
width: 50%;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.login-right-title {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
import { axiosGet, axiosPost } from '@/utils/axios'
|
||||
import { useWebSocket } from '../../stores/websocketStore'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const websocket = useWebSocket()
|
||||
|
||||
const tableData = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await axiosGet('/loadCompanyList')
|
||||
tableData.value = res.data
|
||||
}
|
||||
catch (err) {
|
||||
ElMessage.error(`请求错误:${err.response?.data?.message || err.message}`)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-container">
|
||||
<div class="welcome">
|
||||
{{ userStore.userInfo.username }},欢迎登录
|
||||
</div>
|
||||
<div class="home-header">
|
||||
IM 聊天列表(支持多公司,单公司聊天)
|
||||
</div>
|
||||
<el-table :data="tableData" border stripe style="width: 50%;">
|
||||
<el-table-column prop="b_name" label="用户名称" />
|
||||
<el-table-column prop="_online" label="在线状态" />
|
||||
|
||||
<el-table-column fixed="right" label="操作">
|
||||
<template #default="scope">
|
||||
<el-button v-if="userStore.userInfo.userid !== scope.row.b_id" link type="primary" size="small">
|
||||
聊天
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 40px 20px;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
font-size: 16px;
|
||||
color: #555;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.home-header {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user