Browse Source

聊天室

mh
cuitw 1 month ago
parent
commit
40b9cbc1c8
  1. 101
      ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java
  2. 109
      ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomChatService.java
  3. 6
      ruoyi-ui/src/assets/styles/ruoyi.scss
  4. 13
      ruoyi-ui/src/lang/en.js
  5. 13
      ruoyi-ui/src/lang/zh.js
  6. 77
      ruoyi-ui/src/utils/websocket.js
  7. 115
      ruoyi-ui/src/views/childRoom/index.vue
  8. 209
      ruoyi-ui/src/views/dialogs/KTimeSetDialog.vue
  9. 483
      ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue
  10. 174
      ruoyi-ui/src/views/dialogs/PlatformEditDialog.vue
  11. 243
      ruoyi-ui/src/views/dialogs/RouteEditDialog.vue

101
ruoyi-admin/src/main/java/com/ruoyi/websocket/controller/RoomWebSocketController.java

@ -18,6 +18,7 @@ import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.domain.Rooms;
import com.ruoyi.system.service.IRoomsService;
import com.ruoyi.websocket.dto.RoomMemberDTO;
import com.ruoyi.websocket.service.RoomChatService;
import com.ruoyi.websocket.service.RoomWebSocketService;
/**
@ -35,18 +36,26 @@ public class RoomWebSocketController {
private RoomWebSocketService roomWebSocketService;
@Autowired
private RoomChatService roomChatService;
@Autowired
private IRoomsService roomsService;
private static final String TYPE_JOIN = "JOIN";
private static final String TYPE_LEAVE = "LEAVE";
private static final String TYPE_PING = "PING";
private static final String TYPE_CHAT = "CHAT";
private static final String TYPE_PRIVATE_CHAT = "PRIVATE_CHAT";
private static final String TYPE_CHAT_HISTORY = "CHAT_HISTORY";
private static final String TYPE_PRIVATE_CHAT_HISTORY = "PRIVATE_CHAT_HISTORY";
private static final String TYPE_PRIVATE_CHAT_HISTORY_REQUEST = "PRIVATE_CHAT_HISTORY_REQUEST";
private static final String TYPE_MEMBER_JOINED = "MEMBER_JOINED";
private static final String TYPE_MEMBER_LEFT = "MEMBER_LEFT";
private static final String TYPE_MEMBER_LIST = "MEMBER_LIST";
private static final String TYPE_PONG = "PONG";
/**
* 处理房间消息JOINLEAVEPING
* 处理房间消息JOINLEAVEPINGCHATPRIVATE_CHAT
*/
@MessageMapping("/room/{roomId}")
public void handleRoomMessage(@DestinationVariable Long roomId, @Payload String payload,
@ -70,6 +79,12 @@ public class RoomWebSocketController {
handleLeave(roomId, sessionId, loginUser);
} else if (TYPE_PING.equals(type)) {
handlePing(roomId, sessionId, loginUser);
} else if (TYPE_CHAT.equals(type)) {
handleChat(roomId, sessionId, loginUser, body);
} else if (TYPE_PRIVATE_CHAT.equals(type)) {
handlePrivateChat(roomId, sessionId, loginUser, body);
} else if (TYPE_PRIVATE_CHAT_HISTORY_REQUEST.equals(type)) {
handlePrivateChatHistoryRequest(roomId, loginUser, body);
}
}
@ -100,6 +115,12 @@ public class RoomWebSocketController {
String topic = "/topic/room/" + roomId;
messagingTemplate.convertAndSend(topic, memberListMsg);
List<Object> chatHistory = roomChatService.getGroupChatHistory(roomId);
Map<String, Object> chatHistoryMsg = new HashMap<>();
chatHistoryMsg.put("type", TYPE_CHAT_HISTORY);
chatHistoryMsg.put("messages", chatHistory);
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", chatHistoryMsg);
}
private void handleLeave(Long roomId, String sessionId, LoginUser loginUser) {
@ -123,6 +144,84 @@ public class RoomWebSocketController {
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", msg);
}
/** 群聊:广播给房间内所有人 */
private void handleChat(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) {
String content = body != null && body.containsKey("content") ? String.valueOf(body.get("content")) : "";
if (content.isEmpty()) return;
Map<String, Object> sender = new HashMap<>();
sender.put("userId", loginUser.getUserId());
sender.put("userName", loginUser.getUsername());
sender.put("nickName", loginUser.getUser().getNickName());
sender.put("avatar", loginUser.getUser().getAvatar());
sender.put("sessionId", sessionId);
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_CHAT);
msg.put("sender", sender);
msg.put("content", content);
msg.put("timestamp", System.currentTimeMillis());
roomChatService.saveGroupChat(roomId, loginUser.getUserId(), msg);
messagingTemplate.convertAndSend("/topic/room/" + roomId, msg);
}
/** 私聊:发送给指定用户(仅限同房间成员) */
private void handlePrivateChat(Long roomId, String sessionId, LoginUser loginUser, Map<String, Object> body) {
Object targetUserNameObj = body != null ? body.get("targetUserName") : null;
Object targetUserIdObj = body != null ? body.get("targetUserId") : null;
String content = body != null && body.containsKey("content") ? String.valueOf(body.get("content")) : "";
if (targetUserNameObj == null || content.isEmpty()) return;
String targetUserName = String.valueOf(targetUserNameObj);
if (targetUserName.equals(loginUser.getUsername())) return;
List<RoomMemberDTO> members = roomWebSocketService.getRoomMembers(roomId);
boolean targetInRoom = members.stream().anyMatch(m -> targetUserName.equals(m.getUserName()));
if (!targetInRoom) return;
Map<String, Object> sender = new HashMap<>();
sender.put("userId", loginUser.getUserId());
sender.put("userName", loginUser.getUsername());
sender.put("nickName", loginUser.getUser().getNickName());
sender.put("avatar", loginUser.getUser().getAvatar());
sender.put("sessionId", sessionId);
Map<String, Object> msg = new HashMap<>();
msg.put("type", TYPE_PRIVATE_CHAT);
msg.put("sender", sender);
msg.put("targetUserId", targetUserIdObj);
msg.put("targetUserName", targetUserName);
msg.put("content", content);
msg.put("timestamp", System.currentTimeMillis());
Long targetUserId = targetUserIdObj instanceof Number ? ((Number) targetUserIdObj).longValue() : null;
if (targetUserId != null) {
roomChatService.savePrivateChat(loginUser.getUserId(), targetUserId, msg);
}
messagingTemplate.convertAndSendToUser(targetUserName, "/queue/private", msg);
}
/** 私聊历史请求:返回与指定用户的聊天记录 */
private void handlePrivateChatHistoryRequest(Long roomId, LoginUser loginUser, Map<String, Object> body) {
Object targetUserIdObj = body != null ? body.get("targetUserId") : null;
if (targetUserIdObj == null) return;
Long targetUserId = targetUserIdObj instanceof Number ? ((Number) targetUserIdObj).longValue() : null;
if (targetUserId == null) return;
List<RoomMemberDTO> members = roomWebSocketService.getRoomMembers(roomId);
boolean targetInRoom = members.stream().anyMatch(m -> targetUserId.equals(m.getUserId()));
if (!targetInRoom) return;
List<Object> history = roomChatService.getPrivateChatHistory(loginUser.getUserId(), targetUserId);
Map<String, Object> resp = new HashMap<>();
resp.put("type", TYPE_PRIVATE_CHAT_HISTORY);
resp.put("targetUserId", targetUserId);
resp.put("messages", history);
messagingTemplate.convertAndSendToUser(loginUser.getUsername(), "/queue/private", resp);
}
private RoomMemberDTO buildMember(LoginUser loginUser, String sessionId, Long roomId, Map<String, Object> body) {
RoomMemberDTO dto = new RoomMemberDTO();
dto.setUserId(loginUser.getUserId());

109
ruoyi-admin/src/main/java/com/ruoyi/websocket/service/RoomChatService.java

@ -0,0 +1,109 @@
package com.ruoyi.websocket.service;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import com.alibaba.fastjson2.JSON;
/**
* 房间聊天 Redis 持久化服务
* 使用 Sorted Set 存储score 为时间戳超过 30 天自动清理
*
* @author ruoyi
*/
@Service
public class RoomChatService {
private static final String ROOM_CHAT_PREFIX = "room:";
private static final String ROOM_CHAT_USER_SUFFIX = ":user:";
private static final String ROOM_CHAT_SUFFIX = ":chat";
private static final String ROOM_CHAT_USERS_SUFFIX = ":chat:users";
private static final String PRIVATE_CHAT_PREFIX = "chat:private:";
private static final long EXPIRE_DAYS = 30;
private static final long EXPIRE_MS = EXPIRE_DAYS * 24 * 60 * 60 * 1000L;
@Autowired
@Qualifier("stringObjectRedisTemplate")
private RedisTemplate<String, Object> redisTemplate;
private String roomChatKey(Long roomId, Long userId) {
return ROOM_CHAT_PREFIX + roomId + ROOM_CHAT_USER_SUFFIX + userId + ROOM_CHAT_SUFFIX;
}
private String roomChatUsersKey(Long roomId) {
return ROOM_CHAT_PREFIX + roomId + ROOM_CHAT_USERS_SUFFIX;
}
private String privateChatKey(Long userId1, Long userId2) {
long a = userId1 != null ? userId1 : 0;
long b = userId2 != null ? userId2 : 0;
return PRIVATE_CHAT_PREFIX + Math.min(a, b) + ":" + Math.max(a, b);
}
/**
* 保存群聊消息 roomId + userId key 存储
*/
public void saveGroupChat(Long roomId, Long senderUserId, Object msg) {
if (senderUserId == null) return;
String key = roomChatKey(roomId, senderUserId);
long now = System.currentTimeMillis();
String member = msg instanceof String ? (String) msg : JSON.toJSONString(msg);
redisTemplate.opsForZSet().add(key, member, now);
redisTemplate.opsForZSet().removeRangeByScore(key, Double.NEGATIVE_INFINITY, now - EXPIRE_MS);
redisTemplate.opsForSet().add(roomChatUsersKey(roomId), String.valueOf(senderUserId));
}
/**
* 获取群聊历史最近 30 天内按时间升序合并所有用户
*/
public List<Object> getGroupChatHistory(Long roomId) {
String usersKey = roomChatUsersKey(roomId);
Set<Object> userIds = redisTemplate.opsForSet().members(usersKey);
if (userIds == null || userIds.isEmpty()) return new ArrayList<>();
long min = System.currentTimeMillis() - EXPIRE_MS;
List<Object[]> merged = new ArrayList<>();
for (Object uid : userIds) {
String key = roomChatKey(roomId, Long.valueOf(String.valueOf(uid)));
Set<ZSetOperations.TypedTuple<Object>> tuples =
redisTemplate.opsForZSet().rangeByScoreWithScores(key, min, Double.POSITIVE_INFINITY);
if (tuples != null) {
for (ZSetOperations.TypedTuple<Object> t : tuples) {
merged.add(new Object[] { t.getValue(), t.getScore() != null ? t.getScore() : 0.0 });
}
}
}
merged.sort((a, b) -> Double.compare((Double) a[1], (Double) b[1]));
List<Object> result = new ArrayList<>();
for (Object[] arr : merged) {
result.add(arr[0]);
}
return result;
}
/**
* 保存私聊消息
*/
public void savePrivateChat(Long userId1, Long userId2, Object msg) {
String key = privateChatKey(userId1, userId2);
long now = System.currentTimeMillis();
String member = msg instanceof String ? (String) msg : JSON.toJSONString(msg);
redisTemplate.opsForZSet().add(key, member, now);
redisTemplate.opsForZSet().removeRangeByScore(key, Double.NEGATIVE_INFINITY, now - EXPIRE_MS);
}
/**
* 获取私聊历史最近 30 天内按时间升序
*/
public List<Object> getPrivateChatHistory(Long userId1, Long userId2) {
String key = privateChatKey(userId1, userId2);
long min = System.currentTimeMillis() - EXPIRE_MS;
Set<Object> set = redisTemplate.opsForZSet().rangeByScore(key, min, Double.POSITIVE_INFINITY);
return set != null ? new ArrayList<>(set) : new ArrayList<>();
}
}

6
ruoyi-ui/src/assets/styles/ruoyi.scss

@ -81,6 +81,12 @@
margin-top: 6vh !important;
}
/* 全局弹窗:取消背景变暗,点击遮罩不影响地图操作(遮罩透明且不拦截鼠标事件) */
.v-modal {
background-color: transparent !important;
pointer-events: none !important;
}
.el-dialog__wrapper.scrollbar .el-dialog .el-dialog__body {
overflow: auto;
overflow-x: hidden;

13
ruoyi-ui/src/lang/en.js

@ -51,12 +51,7 @@ export default {
powerZone: 'Power Zone',
threatZone: 'Threat Zone'
},
tools: {
routeCalculation: 'Route Calculation',
conflictDisplay: 'Conflict Display',
dataMaterials: 'Data Materials',
coordinateConversion: 'Coordinate Conversion'
},
options: {
settings: 'Settings',
pageLayout: 'Page Layout',
@ -150,7 +145,11 @@ export default {
rollbackConfirmText: 'Are you sure you want to rollback to the selected operation?',
rollbackWarning: 'This operation will undo this operation and all subsequent changes, cannot be recovered!',
confirmRollback: 'Confirm Rollback',
groupChat: 'Group Chat - Online Members Communication',
groupChat: 'Group Chat',
privateChat: 'Private Chat',
selectMember: 'Select',
selectMemberToChat: 'Select a member to start private chat',
selectMemberFirst: 'Please select a member first',
onlineCount: ' people online',
inputMessage: 'Please enter message...',
send: 'Send',

13
ruoyi-ui/src/lang/zh.js

@ -51,12 +51,7 @@ export default {
powerZone: '威力区',
threatZone: '威胁区'
},
tools: {
routeCalculation: '航线计算',
conflictDisplay: '冲突显示',
dataMaterials: '数据资料',
coordinateConversion: '坐标换算'
},
options: {
settings: '设置',
pageLayout: '页面布局',
@ -150,7 +145,11 @@ export default {
rollbackConfirmText: '确定要回滚到所选操作吗?',
rollbackWarning: '此操作将撤销该操作及其后的所有更改,不可恢复!',
confirmRollback: '确定回滚',
groupChat: '群聊 - 在线成员交流',
groupChat: '群聊',
privateChat: '私聊',
selectMember: '选择对象',
selectMemberToChat: '选择成员开始私聊',
selectMemberFirst: '请先选择要私聊的成员',
onlineCount: '人在线',
inputMessage: '请输入消息...',
send: '发送',

77
ruoyi-ui/src/utils/websocket.js

@ -14,6 +14,10 @@ const WS_BASE = process.env.VUE_APP_BASE_API || '/dev-api'
* @param {Function} options.onMembers - 收到成员列表回调 (members) => {}
* @param {Function} options.onMemberJoined - 成员加入回调 (member) => {}
* @param {Function} options.onMemberLeft - 成员离开回调 (member, sessionId) => {}
* @param {Function} options.onChatMessage - 群聊消息回调 (msg) => {}
* @param {Function} options.onPrivateChat - 私聊消息回调 (msg) => {}
* @param {Function} options.onChatHistory - 群聊历史回调 (messages) => {}
* @param {Function} options.onPrivateChatHistory - 私聊历史回调 (targetUserId, messages) => {}
* @param {Function} options.onConnected - 连接成功回调
* @param {Function} options.onDisconnected - 断开回调
* @param {Function} options.onError - 错误回调
@ -25,6 +29,10 @@ export function createRoomWebSocket(options) {
onMembers,
onMemberJoined,
onMemberLeft,
onChatMessage,
onPrivateChat,
onChatHistory,
onPrivateChatHistory,
onConnected,
onDisconnected,
onError,
@ -32,7 +40,8 @@ export function createRoomWebSocket(options) {
} = options
let client = null
let subscription = null
let roomSubscription = null
let privateSubscription = null
let heartbeatTimer = null
let reconnectAttempts = 0
const maxReconnectAttempts = 10
@ -72,6 +81,33 @@ export function createRoomWebSocket(options) {
}
}
function sendChat(content) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'CHAT', content })
})
}
}
function sendPrivateChat(targetUserId, targetUserName, content) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'PRIVATE_CHAT', targetUserId, targetUserName, content })
})
}
}
function sendPrivateChatHistoryRequest(targetUserId) {
if (client && client.connected) {
client.publish({
destination: '/app/room/' + roomId,
body: JSON.stringify({ type: 'PRIVATE_CHAT_HISTORY_REQUEST', targetUserId })
})
}
}
function startHeartbeat() {
stopHeartbeat()
heartbeatTimer = setInterval(sendPing, 30000)
@ -84,7 +120,7 @@ export function createRoomWebSocket(options) {
}
}
function handleMessage(message) {
function handleRoomMessage(message) {
try {
const body = JSON.parse(message.body)
const type = body.type
@ -94,12 +130,30 @@ export function createRoomWebSocket(options) {
onMemberJoined && onMemberJoined(body.member)
} else if (type === 'MEMBER_LEFT' && body.member) {
onMemberLeft && onMemberLeft(body.member, body.sessionId)
} else if (type === 'CHAT' && body.sender) {
onChatMessage && onChatMessage(body)
}
} catch (e) {
console.warn('[WebSocket] parse message error:', e)
}
}
function handlePrivateMessage(message) {
try {
const body = JSON.parse(message.body)
const type = body.type
if (type === 'PRIVATE_CHAT' && body.sender) {
onPrivateChat && onPrivateChat(body)
} else if (type === 'CHAT_HISTORY' && Array.isArray(body.messages)) {
onChatHistory && onChatHistory(body.messages)
} else if (type === 'PRIVATE_CHAT_HISTORY' && body.targetUserId != null && Array.isArray(body.messages)) {
onPrivateChatHistory && onPrivateChatHistory(body.targetUserId, body.messages)
}
} catch (e) {
console.warn('[WebSocket] parse private message error:', e)
}
}
function connect() {
const token = getToken()
if (!token) {
@ -115,7 +169,8 @@ export function createRoomWebSocket(options) {
heartbeatOutgoing: 0,
onConnect: () => {
reconnectAttempts = 0
subscription = client.subscribe('/topic/room/' + roomId, handleMessage)
roomSubscription = client.subscribe('/topic/room/' + roomId, handleRoomMessage)
privateSubscription = client.subscribe('/user/queue/private', handlePrivateMessage)
sendJoin()
startHeartbeat()
onConnected && onConnected()
@ -126,7 +181,8 @@ export function createRoomWebSocket(options) {
},
onWebSocketClose: () => {
stopHeartbeat()
subscription = null
roomSubscription = null
privateSubscription = null
onDisconnected && onDisconnected()
}
})
@ -136,9 +192,13 @@ export function createRoomWebSocket(options) {
function disconnect() {
stopHeartbeat()
sendLeave()
if (subscription) {
subscription.unsubscribe()
subscription = null
if (roomSubscription) {
roomSubscription.unsubscribe()
roomSubscription = null
}
if (privateSubscription) {
privateSubscription.unsubscribe()
privateSubscription = null
}
if (client) {
client.deactivate()
@ -160,6 +220,9 @@ export function createRoomWebSocket(options) {
return {
disconnect,
reconnect: connect,
sendChat,
sendPrivateChat,
sendPrivateChatHistoryRequest,
get connected() {
return client && client.connected
}

115
ruoyi-ui/src/views/childRoom/index.vue

@ -75,25 +75,13 @@
</div>
</el-dialog>
<!-- 设定/修改 K 时弹窗房主或管理员可随时打开并修改 -->
<el-dialog title="设定 / 修改 K 时" :visible.sync="showKTimeSetDialog" width="420px" :append-to-body="true">
<el-form label-width="90px">
<el-form-item label="K 时(基准)">
<el-date-picker
v-model="kTimeForm.dateTime"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="选择日期和时间"
style="width: 100%"
/>
</el-form-item>
<p class="k-time-tip">航线的任务时间将以此 K 时为基准进行加减航点表时间为相对 K 的分钟数房主/管理员可随时再次点击作战时间修改 K </p>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="showKTimeSetDialog = false"> </el-button>
<el-button type="primary" @click="saveKTime"> </el-button>
</div>
</el-dialog>
<!-- 设定/修改 K 时弹窗 4T 一致可拖动记录位置不阻挡地图 -->
<k-time-set-dialog
v-model="showKTimeSetDialog"
:date-time.sync="kTimeForm.dateTime"
:room-id="currentRoomId"
@save="saveKTime"
/>
</div>
<!-- 顶部导航栏 -->
<top-header
@ -312,23 +300,32 @@
</el-dialog>
</div>
<!-- 在线成员弹窗 -->
<!-- 在线成员弹窗 4T 一致可拖动记录位置不阻挡地图 -->
<online-members-dialog
v-model="showOnlineMembers"
:online-members="wsOnlineMembers"
:room-id="currentRoomId"
:chat-messages="chatMessages"
:private-chat-messages="privateChatMessages"
:send-chat="sendChat"
:send-private-chat="sendPrivateChat"
:send-private-chat-history-request="sendPrivateChatHistoryRequest"
:current-user-id="currentUserId"
/>
<!-- 平台编辑弹窗 -->
<!-- 平台编辑弹窗 4T 一致可拖动记录位置不阻挡地图 -->
<platform-edit-dialog
v-model="showPlatformDialog"
:platform="selectedPlatform"
:room-id="currentRoomId"
@save="updatePlatform"
/>
<!-- 航线编辑弹窗 -->
<!-- 航线编辑弹窗 4T 一致可拖动记录位置不阻挡地图 -->
<route-edit-dialog
v-model="showRouteDialog"
:route="selectedRoute"
:room-id="currentRoomId"
@save="updateRoute"
/>
@ -431,6 +428,7 @@ import WaypointEditDialog from '@/views/dialogs/WaypointEditDialog'
import ScaleDialog from '@/views/dialogs/ScaleDialog'
import ExternalParamsDialog from '@/views/dialogs/ExternalParamsDialog'
import PageLayoutDialog from '@/views/dialogs/PageLayoutDialog'
import KTimeSetDialog from '@/views/dialogs/KTimeSetDialog'
import LeftMenu from './LeftMenu'
import RightPanel from './RightPanel'
import BottomLeftPanel from './BottomLeftPanel'
@ -457,6 +455,7 @@ export default {
ScaleDialog,
ExternalParamsDialog,
PageLayoutDialog,
KTimeSetDialog,
LeftMenu,
RightPanel,
BottomLeftPanel,
@ -516,6 +515,8 @@ export default {
onlineCount: 0,
wsOnlineMembers: [],
wsConnection: null,
chatMessages: [],
privateChatMessages: {},
combatTime: 'K+00:00:00', //
astroTime: '',
roomDetail: null,
@ -669,6 +670,10 @@ export default {
}
},
computed: {
currentUserId() {
const id = this.$store.getters.id;
return id != null ? Number(id) : null;
},
isRoomOwner() {
if (!this.roomDetail || this.roomDetail.ownerId == null) return false;
const myId = this.$store.getters.id;
@ -1199,6 +1204,42 @@ export default {
showOnlineMembersDialog() {
this.showOnlineMembers = true;
},
sendChat(content) {
if (this.wsConnection && this.wsConnection.sendChat) {
this.wsConnection.sendChat(content);
}
},
sendPrivateChatHistoryRequest(targetUserId) {
if (this.wsConnection && this.wsConnection.sendPrivateChatHistoryRequest) {
this.wsConnection.sendPrivateChatHistoryRequest(targetUserId);
}
},
normalizeChatMessages(messages) {
if (!messages || !Array.isArray(messages)) return [];
return messages.map(m => {
if (typeof m === 'string') {
try {
return JSON.parse(m);
} catch (e) {
return null;
}
}
return m;
}).filter(Boolean);
},
sendPrivateChat(targetUserId, targetUserName, content) {
if (!this.wsConnection || !this.wsConnection.sendPrivateChat) return;
this.wsConnection.sendPrivateChat(targetUserId, targetUserName, content);
const sender = {
userId: this.currentUserId,
userName: this.$store.getters.name,
nickName: this.$store.getters.nickName || this.$store.getters.name,
avatar: this.$store.getters.avatar || ''
};
const msg = { type: 'PRIVATE_CHAT', sender, targetUserId, targetUserName, content, timestamp: Date.now() };
const list = this.privateChatMessages[targetUserId] || [];
this.$set(this.privateChatMessages, targetUserId, [...list, msg]);
},
connectRoomWebSocket() {
if (!this.currentRoomId) return;
this.disconnectRoomWebSocket();
@ -1208,6 +1249,8 @@ export default {
onMembers: (members) => {
this.wsOnlineMembers = members.map(m => ({
id: m.sessionId || m.userId,
userId: m.userId,
userName: m.userName,
name: m.nickName || m.userName,
role: m.role === 'owner' ? '房主' : (m.role === 'admin' ? '管理员' : '成员'),
status: '在线',
@ -1220,6 +1263,8 @@ export default {
const baseUrl = (process.env.VUE_APP_BACKEND_URL || (window.location.origin + (process.env.VUE_APP_BASE_API || '')));
const m = {
id: member.sessionId || member.userId,
userId: member.userId,
userName: member.userName,
name: member.nickName || member.userName,
role: member.role === 'owner' ? '房主' : (member.role === 'admin' ? '管理员' : '成员'),
status: '在线',
@ -1235,10 +1280,34 @@ export default {
this.wsOnlineMembers = this.wsOnlineMembers.filter(m => m.id !== sessionId && m.id !== member.sessionId);
this.onlineCount = this.wsOnlineMembers.length;
},
onChatMessage: (msg) => {
this.chatMessages = [...this.chatMessages, msg];
},
onPrivateChat: (msg) => {
const senderId = msg.sender && msg.sender.userId;
if (!senderId) return;
const list = this.privateChatMessages[senderId] || [];
this.$set(this.privateChatMessages, senderId, [...list, msg]);
},
onChatHistory: (messages) => {
const history = this.normalizeChatMessages(messages);
const maxTs = history.length ? Math.max(...history.map(m => m.timestamp || 0)) : 0;
const newer = this.chatMessages.filter(m => (m.timestamp || 0) > maxTs);
this.chatMessages = [...history, ...newer];
},
onPrivateChatHistory: (targetUserId, messages) => {
const history = this.normalizeChatMessages(messages);
const existing = this.privateChatMessages[targetUserId] || [];
const maxTs = history.length ? Math.max(...history.map(m => m.timestamp || 0)) : 0;
const newer = existing.filter(m => (m.timestamp || 0) > maxTs);
this.$set(this.privateChatMessages, targetUserId, [...history, ...newer]);
},
onConnected: () => {},
onDisconnected: () => {
this.onlineCount = 0;
this.wsOnlineMembers = [];
this.chatMessages = [];
this.privateChatMessages = {};
},
onError: (err) => {
console.warn('[WebSocket]', err);
@ -1252,6 +1321,8 @@ export default {
}
this.wsOnlineMembers = [];
this.onlineCount = 0;
this.chatMessages = [];
this.privateChatMessages = {};
},
//
openPlatformDialog(platform) {

209
ruoyi-ui/src/views/dialogs/KTimeSetDialog.vue

@ -0,0 +1,209 @@
<template>
<!-- 功能与 4T 一致透明遮罩可拖动记录位置不阻挡地图风格保持原样 -->
<div v-if="value" class="k-time-set-dialog">
<div class="panel-container" :style="panelStyle">
<div class="dialog-header" @mousedown="onDragStart">
<h3>设定 / 修改 K </h3>
<div class="close-btn" @mousedown.stop @click="closeDialog">×</div>
</div>
<div class="dialog-body">
<el-form label-width="90px">
<el-form-item label="K 时(基准)">
<el-date-picker
:value="dateTime"
@input="$emit('update:dateTime', $event)"
type="datetime"
value-format="yyyy-MM-dd HH:mm:ss"
placeholder="选择日期和时间"
style="width: 100%"
/>
</el-form-item>
</el-form>
<div class="dialog-footer">
<el-button @click="closeDialog"> </el-button>
<el-button type="primary" @click="handleSave"> </el-button>
</div>
</div>
</div>
</div>
</template>
<script>
const STORAGE_KEY_PREFIX = 'kTimeSetPanel_'
export default {
name: 'KTimeSetDialog',
props: {
value: { type: Boolean, default: false },
dateTime: { type: String, default: null },
roomId: { type: [String, Number], default: null }
},
data() {
return {
panelLeft: null,
panelTop: null,
panelWidth: 420,
panelHeight: 250,
isDragging: false,
dragStartX: 0,
dragStartY: 0
}
},
computed: {
panelStyle() {
const left = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20
const top = this.panelTop != null ? this.panelTop : (window.innerHeight - this.panelHeight) / 2 - 40
return {
left: `${left}px`,
top: `${top}px`,
width: `${this.panelWidth}px`,
height: `${this.panelHeight}px`
}
}
},
watch: {
value(val) {
if (val) this.loadPosition()
}
},
methods: {
closeDialog() {
this.$emit('input', false)
},
handleSave() {
this.$emit('save')
},
getStorageKey() {
return STORAGE_KEY_PREFIX + (this.roomId || 'default')
},
loadPosition() {
try {
const raw = localStorage.getItem(this.getStorageKey())
if (raw) {
const d = JSON.parse(raw)
if (d.panelPosition) {
const left = Number(d.panelPosition.left)
const top = Number(d.panelPosition.top)
if (!isNaN(left) && left >= 0) this.panelLeft = Math.min(left, window.innerWidth - this.panelWidth)
if (!isNaN(top) && top >= 0) this.panelTop = Math.min(top, window.innerHeight - this.panelHeight)
}
}
} catch (e) {
console.warn('加载 K 时弹窗位置失败:', e)
}
},
savePosition() {
try {
const payload = {}
if (this.panelLeft != null && this.panelTop != null) {
payload.panelPosition = { left: this.panelLeft, top: this.panelTop }
}
localStorage.setItem(this.getStorageKey(), JSON.stringify(payload))
} catch (e) {
console.warn('保存 K 时弹窗位置失败:', e)
}
},
onDragStart(e) {
e.preventDefault()
this.isDragging = true
const currentLeft = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20
const currentTop = this.panelTop != null ? this.panelTop : (window.innerHeight - this.panelHeight) / 2 - 40
this.dragStartX = e.clientX - currentLeft
this.dragStartY = e.clientY - currentTop
document.addEventListener('mousemove', this.onDragMove)
document.addEventListener('mouseup', this.onDragEnd)
},
onDragMove(e) {
if (!this.isDragging) return
e.preventDefault()
let left = e.clientX - this.dragStartX
let top = e.clientY - this.dragStartY
left = Math.max(0, Math.min(window.innerWidth - this.panelWidth, left))
top = Math.max(0, Math.min(window.innerHeight - this.panelHeight, top))
this.panelLeft = left
this.panelTop = top
},
onDragEnd() {
this.isDragging = false
document.removeEventListener('mousemove', this.onDragMove)
document.removeEventListener('mouseup', this.onDragEnd)
this.savePosition()
}
}
}
</script>
<style scoped>
/* 透明遮罩、不阻挡地图 */
.k-time-set-dialog {
position: fixed;
inset: 0;
z-index: 1000;
background: transparent;
pointer-events: none;
}
/* 原 el-dialog 风格 */
.panel-container {
position: fixed;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
overflow: hidden;
z-index: 1001;
pointer-events: auto;
display: flex;
flex-direction: column;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background-color: #f5f7fa;
border-bottom: 1px solid #ebeef5;
cursor: move;
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.close-btn {
font-size: 20px;
color: #909399;
cursor: pointer;
transition: color 0.3s;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #606266;
background: #f5f5f5;
border-radius: 50%;
}
.dialog-body {
padding: 20px 25px;
flex: 1;
overflow: hidden;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 10px 0 0 0;
margin-top: 10px;
border-top: 1px solid #ebeef5;
}
</style>

483
ruoyi-ui/src/views/dialogs/OnlineMembersDialog.vue

@ -1,13 +1,10 @@
<template>
<!-- 4T 弹窗一致透明遮罩可拖动记录位置不阻挡地图 -->
<div v-if="value" class="online-members-dialog">
<!-- 遮罩层 -->
<div class="dialog-overlay" @click="closeDialog"></div>
<!-- 弹窗内容由全局 dialogDrag 插件支持拖动 -->
<div class="dialog-content">
<div class="dialog-header">
<div class="panel-container" :style="panelStyle">
<div class="dialog-header" @mousedown="onDragStart">
<h3>{{ $t('onlineMembersDialog.title') }}</h3>
<div class="close-btn" @click="closeDialog">×</div>
<div class="close-btn" @mousedown.stop @click="closeDialog">×</div>
</div>
<div class="dialog-body">
@ -119,25 +116,65 @@
<el-tab-pane :label="$t('onlineMembersDialog.chatRoom')" name="chat">
<div class="chat-room">
<div class="chat-header">
<h4>{{ $t('onlineMembersDialog.groupChat') }}</h4>
<el-radio-group v-model="chatMode" size="mini">
<el-radio-button label="group">{{ $t('onlineMembersDialog.groupChat') }}</el-radio-button>
<el-radio-button label="private">{{ $t('onlineMembersDialog.privateChat') }}</el-radio-button>
</el-radio-group>
<span class="online-count">{{ displayOnlineMembers.length }} {{ $t('onlineMembersDialog.onlineCount') }}</span>
</div>
<!-- 私聊选择对象 -->
<div v-if="chatMode === 'private'" class="private-target">
<span class="target-label">{{ $t('onlineMembersDialog.selectMember') }}</span>
<el-select v-model="privateChatTarget" :placeholder="$t('onlineMembersDialog.selectMemberToChat')" size="small" filterable class="target-select">
<el-option
v-for="m in chatableMembers"
:key="m.userId"
:label="m.name"
:value="m.userId"
>
<span>{{ m.name }}</span>
<span class="target-role">({{ m.role }})</span>
</el-option>
</el-select>
</div>
<!-- 聊天内容区域 -->
<div class="chat-content" ref="chatContent">
<div
v-for="(message, index) in chatMessages"
:key="index"
:class="['chat-message', message.sender === '我' ? 'self-message' : 'other-message']"
>
<div class="message-avatar">
<el-avatar :size="32" :src="message.avatar">{{ message.sender.charAt(0) }}</el-avatar>
<template v-if="chatMode === 'group'">
<div
v-for="(message, index) in displayChatMessages"
:key="'g-' + index"
:class="['chat-message', isMessageSelf(message) ? 'self-message' : 'other-message']"
>
<div class="message-avatar">
<el-avatar :size="32" :src="getMessageAvatar(message)">{{ getMessageSenderName(message).charAt(0) }}</el-avatar>
</div>
<div class="message-content">
<div class="message-sender">{{ getMessageSenderName(message) }}</div>
<div class="message-text">{{ message.content }}</div>
<div class="message-time">{{ formatMessageTime(message.timestamp) }}</div>
</div>
</div>
<div class="message-content">
<div class="message-sender">{{ message.sender }}</div>
<div class="message-text">{{ message.content }}</div>
<div class="message-time">{{ message.time }}</div>
</template>
<template v-else-if="chatMode === 'private' && privateChatTarget">
<div
v-for="(message, index) in displayPrivateMessages"
:key="'p-' + index"
:class="['chat-message', isPrivateMessageSelf(message) ? 'self-message' : 'other-message']"
>
<div class="message-avatar">
<el-avatar :size="32" :src="getPrivateMessageAvatar(message)">{{ getPrivateMessageSenderName(message).charAt(0) }}</el-avatar>
</div>
<div class="message-content">
<div class="message-sender">{{ getPrivateMessageSenderName(message) }}</div>
<div class="message-text">{{ message.content }}</div>
<div class="message-time">{{ formatMessageTime(message.timestamp) }}</div>
</div>
</div>
</template>
<div v-else-if="chatMode === 'private' && !privateChatTarget" class="chat-empty">
{{ $t('onlineMembersDialog.selectMemberToChat') }}
</div>
</div>
@ -145,22 +182,26 @@
<div class="chat-input">
<el-input
v-model="newMessage"
:placeholder="$t('onlineMembersDialog.inputMessage')"
:placeholder="chatMode === 'private' && !privateChatTarget ? $t('onlineMembersDialog.selectMemberFirst') : $t('onlineMembersDialog.inputMessage')"
:disabled="chatMode === 'private' && !privateChatTarget"
@keyup.enter="sendMessage"
clearable
></el-input>
<el-button type="primary" @click="sendMessage" class="send-btn">{{ $t('onlineMembersDialog.send') }}</el-button>
<el-button type="primary" @click="sendMessage" class="send-btn" :disabled="chatMode === 'private' && !privateChatTarget">{{ $t('onlineMembersDialog.send') }}</el-button>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 移除了底部的dialog-footer关闭按钮 -->
<!-- 右下角拖拽调整大小 -->
<div class="resize-handle" @mousedown="onResizeStart" title="拖动调整大小"></div>
</div>
</div>
</template>
<script>
const STORAGE_KEY_PREFIX = 'onlineMembersPanel_'
export default {
name: 'OnlineMembersDialog',
props: {
@ -171,11 +212,51 @@ export default {
onlineMembers: {
type: Array,
default: () => []
},
roomId: {
type: [String, Number],
default: null
},
chatMessages: {
type: Array,
default: () => []
},
privateChatMessages: {
type: Object,
default: () => ({})
},
sendChat: {
type: Function,
default: null
},
sendPrivateChat: {
type: Function,
default: null
},
sendPrivateChatHistoryRequest: {
type: Function,
default: null
},
currentUserId: {
type: [Number, String],
default: null
}
},
data() {
return {
activeTab: 'members',
panelLeft: null,
panelTop: null,
panelWidth: 700,
panelHeight: 520,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
isResizing: false,
resizeStartX: 0,
resizeStartY: 0,
resizeStartW: 0,
resizeStartH: 0,
showRollbackDialog: false,
// 线 props.onlineMembers 使 mock
@ -243,39 +324,133 @@ export default {
}
],
//
newMessage: '',
chatMessages: [
{
sender: '张三',
content: '各位注意,J-20参数已更新,速度调整为850km/h',
time: 'K+00:45:30',
avatar: 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'
},
{
sender: '李四',
content: '收到,Alpha进场航线已选中,等待进一步指令',
time: 'K+00:42:20',
avatar: 'https://cube.elemecdn.com/1/88/03b0d39583f48206768a7534e55bcpng.png'
},
{
sender: '王五',
content: 'Beta巡逻航线已添加WP5航点,坐标确认完毕',
time: 'K+00:38:50',
avatar: 'https://cube.elemecdn.com/2/88/03b0d39583f48206768a7534e55bcpng.png'
}
]
chatMode: 'group',
privateChatTarget: null
};
},
computed: {
displayOnlineMembers() {
return (this.onlineMembers && this.onlineMembers.length > 0) ? this.onlineMembers : this._mockOnlineMembers;
},
chatableMembers() {
const cur = this.currentUserId != null ? Number(this.currentUserId) : null;
return this.displayOnlineMembers.filter(m => m.userId != null && Number(m.userId) !== cur);
},
displayChatMessages() {
return this.chatMessages || [];
},
displayPrivateMessages() {
if (!this.privateChatTarget) return [];
return this.privateChatMessages[this.privateChatTarget] || [];
},
selectedPrivateMember() {
if (!this.privateChatTarget) return null;
return this.chatableMembers.find(m => Number(m.userId) === Number(this.privateChatTarget));
},
panelStyle() {
const left = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20;
const top = this.panelTop != null ? this.panelTop : (window.innerHeight - this.panelHeight) / 2 - 40;
return {
left: `${left}px`,
top: `${top}px`,
width: `${this.panelWidth}px`,
height: `${this.panelHeight}px`
};
}
},
methods: {
closeDialog() {
this.$emit('input', false);
},
getStorageKey() {
return STORAGE_KEY_PREFIX + (this.roomId || 'default');
},
loadPosition() {
try {
const key = this.getStorageKey();
const raw = localStorage.getItem(key);
if (raw) {
const d = JSON.parse(raw);
if (d.panelPosition) {
const left = Number(d.panelPosition.left);
const top = Number(d.panelPosition.top);
if (!isNaN(left) && left >= 0) this.panelLeft = Math.min(left, window.innerWidth - this.panelWidth);
if (!isNaN(top) && top >= 0) this.panelTop = Math.min(top, window.innerHeight - this.panelHeight);
}
if (d.panelSize) {
const w = Number(d.panelSize.width);
const h = Number(d.panelSize.height);
if (!isNaN(w) && w >= 400 && w <= 1000) this.panelWidth = w;
if (!isNaN(h) && h >= 300 && h <= window.innerHeight - 60) this.panelHeight = h;
}
}
} catch (e) {
console.warn('加载在线成员弹窗位置失败:', e);
}
},
savePosition() {
try {
const payload = { panelSize: { width: this.panelWidth, height: this.panelHeight } };
if (this.panelLeft != null && this.panelTop != null) {
payload.panelPosition = { left: this.panelLeft, top: this.panelTop };
}
localStorage.setItem(this.getStorageKey(), JSON.stringify(payload));
} catch (e) {
console.warn('保存在线成员弹窗位置失败:', e);
}
},
onDragStart(e) {
e.preventDefault();
this.isDragging = true;
const currentLeft = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20;
const currentTop = this.panelTop != null ? this.panelTop : (window.innerHeight - this.panelHeight) / 2 - 40;
this.dragStartX = e.clientX - currentLeft;
this.dragStartY = e.clientY - currentTop;
document.addEventListener('mousemove', this.onDragMove);
document.addEventListener('mouseup', this.onDragEnd);
},
onDragMove(e) {
if (!this.isDragging) return;
e.preventDefault();
let left = e.clientX - this.dragStartX;
let top = e.clientY - this.dragStartY;
left = Math.max(0, Math.min(window.innerWidth - this.panelWidth, left));
top = Math.max(0, Math.min(window.innerHeight - this.panelHeight, top));
this.panelLeft = left;
this.panelTop = top;
},
onDragEnd() {
this.isDragging = false;
document.removeEventListener('mousemove', this.onDragMove);
document.removeEventListener('mouseup', this.onDragEnd);
this.savePosition();
},
onResizeStart(e) {
e.preventDefault();
e.stopPropagation();
this.isResizing = true;
this.resizeStartX = e.clientX;
this.resizeStartY = e.clientY;
this.resizeStartW = this.panelWidth;
this.resizeStartH = this.panelHeight;
document.addEventListener('mousemove', this.onResizeMove);
document.addEventListener('mouseup', this.onResizeEnd);
},
onResizeMove(e) {
if (!this.isResizing) return;
e.preventDefault();
const dx = e.clientX - this.resizeStartX;
const dy = e.clientY - this.resizeStartY;
this.panelWidth = Math.max(400, Math.min(1000, this.resizeStartW + dx));
this.panelHeight = Math.max(300, Math.min(window.innerHeight - 60, this.resizeStartH + dy));
},
onResizeEnd() {
this.isResizing = false;
document.removeEventListener('mousemove', this.onResizeMove);
document.removeEventListener('mouseup', this.onResizeEnd);
this.savePosition();
},
showRollbackConfirm() {
this.showRollbackDialog = true;
@ -289,76 +464,90 @@ export default {
//
sendMessage() {
if (!this.newMessage.trim()) {
const text = this.newMessage.trim();
if (!text) {
this.$message.warning(this.$t('onlineMembersDialog.pleaseInputMessage'));
return;
}
// K+
const now = new Date();
const hours = now.getHours().toString().padStart(2, '0');
const minutes = now.getMinutes().toString().padStart(2, '0');
const seconds = now.getSeconds().toString().padStart(2, '0');
const currentTime = `K+${hours}:${minutes}:${seconds}`;
//
this.chatMessages.push({
sender: '我',
content: this.newMessage.trim(),
time: currentTime,
avatar: 'https://cube.elemecdn.com/5/88/03b0d39583f48206768a7534e55bcpng.png'
});
//
if (this.chatMode === 'group') {
if (this.sendChat) this.sendChat(text);
} else {
const target = this.selectedPrivateMember;
if (!target || !this.sendPrivateChat) return;
this.sendPrivateChat(target.userId, target.userName, text);
}
this.newMessage = '';
//
this.$nextTick(() => {
const chatContent = this.$refs.chatContent;
chatContent.scrollTop = chatContent.scrollHeight;
});
//
setTimeout(() => {
const randomMember = this.onlineMembers[Math.floor(Math.random() * this.onlineMembers.length)];
const replies = [
`收到你的消息,${randomMember.role}已确认`,
`已处理:${randomMember.name}正在执行相关操作`,
`明白,${randomMember.role}这边已准备就绪`
];
const randomReply = replies[Math.floor(Math.random() * replies.length)];
const now2 = new Date();
const hours2 = now2.getHours().toString().padStart(2, '0');
const minutes2 = now2.getMinutes().toString().padStart(2, '0');
const seconds2 = now2.getSeconds().toString().padStart(2, '0');
const replyTime = `K+${hours2}:${minutes2}:${seconds2}`;
this.chatMessages.push({
sender: randomMember.name,
content: randomReply,
time: replyTime,
avatar: randomMember.avatar
});
//
this.$nextTick(() => {
const chatContent = this.$refs.chatContent;
chatContent.scrollTop = chatContent.scrollHeight;
});
}, 1000);
this.$nextTick(() => this.scrollChatToBottom());
},
formatMessageTime(ts) {
if (!ts) return '';
const d = new Date(typeof ts === 'number' ? ts : parseInt(ts, 10));
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0') + ':' + d.getSeconds().toString().padStart(2, '0');
},
isMessageSelf(msg) {
return msg.sender && Number(msg.sender.userId) === Number(this.currentUserId);
},
getMessageAvatar(msg) {
const av = (msg.sender && msg.sender.avatar) || '';
return this.resolveAvatarUrl(av);
},
getMessageSenderName(msg) {
if (!msg.sender) return '';
return msg.sender.nickName || msg.sender.userName || '';
},
isPrivateMessageSelf(msg) {
return msg.sender && Number(msg.sender.userId) === Number(this.currentUserId);
},
getPrivateMessageAvatar(msg) {
const av = (msg.sender && msg.sender.avatar) || '';
return this.resolveAvatarUrl(av);
},
getPrivateMessageSenderName(msg) {
if (!msg.sender) return '';
return msg.sender.nickName || msg.sender.userName || '';
},
scrollChatToBottom() {
const el = this.$refs.chatContent;
if (el) el.scrollTop = el.scrollHeight;
},
resolveAvatarUrl(av) {
if (!av) return '';
if (av.startsWith('http')) return av;
const base = process.env.VUE_APP_BACKEND_URL || (window.location.origin + (process.env.VUE_APP_BASE_API || '/dev-api'));
return base + av;
}
},
//
watch: {
value(val) {
if (val) this.loadPosition();
},
activeTab(newVal) {
if (newVal === 'chat') {
this.$nextTick(() => {
const chatContent = this.$refs.chatContent;
if (chatContent) {
chatContent.scrollTop = chatContent.scrollHeight;
}
});
this.$nextTick(() => this.scrollChatToBottom());
}
},
chatMessages: {
handler() {
this.$nextTick(() => this.scrollChatToBottom());
},
deep: true
},
displayPrivateMessages: {
handler() {
this.$nextTick(() => this.scrollChatToBottom());
},
deep: true
},
chatableMembers: {
handler(members) {
if (this.privateChatTarget && !members.some(m => Number(m.userId) === Number(this.privateChatTarget))) {
this.privateChatTarget = null;
}
}
},
privateChatTarget(val) {
if (val && this.sendPrivateChatHistoryRequest) {
this.sendPrivateChatHistoryRequest(val);
}
}
}
@ -366,38 +555,25 @@ export default {
</script>
<style scoped>
/* 与 4T 弹窗一致:透明遮罩、不阻挡地图点击 */
.online-members-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
pointer-events: none;
}
.dialog-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(2px);
cursor: pointer;
}
.dialog-content {
position: relative;
.panel-container {
position: fixed;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
width: 90%;
max-width: 700px;
max-height: 90vh;
overflow-y: auto;
overflow: hidden;
z-index: 1001;
pointer-events: auto;
display: flex;
flex-direction: column;
animation: dialog-fade-in 0.3s ease;
}
@ -448,7 +624,25 @@ export default {
.dialog-body {
padding: 20px;
/* 调整内边距 让内容和顶部更协调 */
flex: 1;
overflow-y: auto;
min-height: 0;
}
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
user-select: none;
z-index: 10;
background: linear-gradient(to top left, transparent 50%, rgba(0, 138, 255, 0.2) 50%);
}
.resize-handle:hover {
background: linear-gradient(to top left, transparent 50%, rgba(0, 138, 255, 0.4) 50%);
}
/* 在线成员样式 */
@ -651,6 +845,37 @@ export default {
color: #333;
}
.private-target {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.target-label {
font-size: 13px;
color: #666;
margin-right: 8px;
white-space: nowrap;
}
.target-select {
flex: 1;
min-width: 120px;
}
.target-role {
font-size: 12px;
color: #999;
margin-left: 4px;
}
.chat-empty {
color: #999;
font-size: 14px;
text-align: center;
padding: 40px 0;
}
.online-count {
font-size: 12px;
color: #008aff;

174
ruoyi-ui/src/views/dialogs/PlatformEditDialog.vue

@ -1,10 +1,10 @@
<template>
<!-- 4T 一致透明遮罩可拖动记录位置不阻挡地图 -->
<div v-if="value" class="platform-edit-dialog">
<div class="dialog-overlay" @click="closeDialog"></div>
<div class="dialog-content">
<div class="dialog-header">
<div class="panel-container" :style="panelStyle">
<div class="dialog-header" @mousedown="onDragStart">
<h3>平台配置编辑</h3>
<div class="close-btn" @click="closeDialog">×</div>
<div class="close-btn" @mousedown.stop @click="closeDialog">×</div>
</div>
<div class="dialog-body">
@ -104,6 +104,7 @@
<el-button size="medium" @click="closeDialog" style="width: 120px; border-radius: 20px;"> </el-button>
<el-button size="medium" type="primary" @click="savePlatform" style="width: 120px; border-radius: 20px;"> </el-button>
</div>
<div class="resize-handle" @mousedown="onResizeStart" title="拖动调整大小"></div>
</div>
</div>
</template>
@ -112,14 +113,29 @@
import { getToken } from "@/utils/auth";
import { updateLib } from "@/api/system/lib";
const STORAGE_KEY_PREFIX = 'platformEditPanel_'
export default {
name: 'PlatformEditDialog',
props: {
value: { type: Boolean, default: false },
platform: { type: Object, default: () => ({}) }
platform: { type: Object, default: () => ({}) },
roomId: { type: [String, Number], default: null }
},
data() {
return {
panelLeft: null,
panelTop: null,
panelWidth: 550,
panelHeight: 520,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
isResizing: false,
resizeStartX: 0,
resizeStartY: 0,
resizeStartW: 0,
resizeStartH: 0,
//
uploadImgUrl: process.env.VUE_APP_BASE_API + "/common/upload",
headers: { Authorization: "Bearer " + getToken() },
@ -145,9 +161,22 @@ export default {
}
};
},
computed: {
panelStyle() {
const left = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20;
const top = this.panelTop != null ? this.panelTop : (window.innerHeight - this.panelHeight) / 2 - 40;
return {
left: `${left}px`,
top: `${top}px`,
width: `${this.panelWidth}px`,
height: `${this.panelHeight}px`
};
}
},
watch: {
value(newVal) {
if (newVal) {
this.loadPosition();
console.log("子组件接收到的原始数据:", this.platform);
this.$nextTick(() => {
this.initFormData();
@ -156,6 +185,93 @@ export default {
}
},
methods: {
getStorageKey() {
return STORAGE_KEY_PREFIX + (this.roomId || 'default');
},
loadPosition() {
try {
const raw = localStorage.getItem(this.getStorageKey());
if (raw) {
const d = JSON.parse(raw);
if (d.panelPosition) {
const left = Number(d.panelPosition.left);
const top = Number(d.panelPosition.top);
if (!isNaN(left) && left >= 0) this.panelLeft = Math.min(left, window.innerWidth - this.panelWidth);
if (!isNaN(top) && top >= 0) this.panelTop = Math.min(top, window.innerHeight - this.panelHeight);
}
if (d.panelSize) {
const w = Number(d.panelSize.width);
const h = Number(d.panelSize.height);
if (!isNaN(w) && w >= 400 && w <= 900) this.panelWidth = w;
if (!isNaN(h) && h >= 400 && h <= window.innerHeight - 60) this.panelHeight = h;
}
}
} catch (e) {
console.warn('加载平台编辑弹窗位置失败:', e);
}
},
savePosition() {
try {
const payload = { panelSize: { width: this.panelWidth, height: this.panelHeight } };
if (this.panelLeft != null && this.panelTop != null) {
payload.panelPosition = { left: this.panelLeft, top: this.panelTop };
}
localStorage.setItem(this.getStorageKey(), JSON.stringify(payload));
} catch (e) {
console.warn('保存平台编辑弹窗位置失败:', e);
}
},
onDragStart(e) {
e.preventDefault();
this.isDragging = true;
const currentLeft = this.panelLeft != null ? this.panelLeft : (window.innerWidth - this.panelWidth) / 2 - 20;
const currentTop = this.panelTop != null ? this.panelTop : (window.innerHeight - this.panelHeight) / 2 - 40;
this.dragStartX = e.clientX - currentLeft;
this.dragStartY = e.clientY - currentTop;
document.addEventListener('mousemove', this.onDragMove);
document.addEventListener('mouseup', this.onDragEnd);
},
onDragMove(e) {
if (!this.isDragging) return;
e.preventDefault();
let left = e.clientX - this.dragStartX;
let top = e.clientY - this.dragStartY;
left = Math.max(0, Math.min(window.innerWidth - this.panelWidth, left));
top = Math.max(0, Math.min(window.innerHeight - this.panelHeight, top));
this.panelLeft = left;
this.panelTop = top;
},
onDragEnd() {
this.isDragging = false;
document.removeEventListener('mousemove', this.onDragMove);
document.removeEventListener('mouseup', this.onDragEnd);
this.savePosition();
},
onResizeStart(e) {
e.preventDefault();
e.stopPropagation();
this.isResizing = true;
this.resizeStartX = e.clientX;
this.resizeStartY = e.clientY;
this.resizeStartW = this.panelWidth;
this.resizeStartH = this.panelHeight;
document.addEventListener('mousemove', this.onResizeMove);
document.addEventListener('mouseup', this.onResizeEnd);
},
onResizeMove(e) {
if (!this.isResizing) return;
e.preventDefault();
const dx = e.clientX - this.resizeStartX;
const dy = e.clientY - this.resizeStartY;
this.panelWidth = Math.max(400, Math.min(900, this.resizeStartW + dx));
this.panelHeight = Math.max(400, Math.min(window.innerHeight - 60, this.resizeStartH + dy));
},
onResizeEnd() {
this.isResizing = false;
document.removeEventListener('mousemove', this.onResizeMove);
document.removeEventListener('mouseup', this.onResizeEnd);
this.savePosition();
},
// JSON
initFormData() {
//
@ -265,39 +381,25 @@ export default {
</script>
<style scoped>
/* 1. 弹窗核心容器:增加圆角,优化阴影 */
/* 与 4T 一致:透明遮罩、不阻挡地图 */
.platform-edit-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
background: transparent;
pointer-events: none;
}
.dialog-content {
position: relative;
.panel-container {
position: fixed;
background: white;
border-radius: 16px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
width: 550px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 2001;
pointer-events: auto;
animation: dialog-fade-in 0.3s ease;
}
@ -306,13 +408,13 @@ export default {
to { opacity: 1; transform: translateY(0); }
}
/* 页眉页脚:彻底删掉分割线 */
.dialog-header {
padding: 20px 25px 5px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f2f2f2;
cursor: move;
}
.dialog-header h3 {
@ -459,4 +561,20 @@ export default {
padding: 10px 0 25px;
border-top: none;
}
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
user-select: none;
z-index: 10;
background: linear-gradient(to top left, transparent 50%, rgba(24, 144, 255, 0.2) 50%);
}
.resize-handle:hover {
background: linear-gradient(to top left, transparent 50%, rgba(24, 144, 255, 0.4) 50%);
}
</style>

243
ruoyi-ui/src/views/dialogs/RouteEditDialog.vue

@ -1,12 +1,12 @@
<template>
<el-dialog
title="编辑航线"
:visible.sync="visible"
:width="activeTab === 'waypoints' ? '920px' : '560px'"
:close-on-click-modal="false"
append-to-body
custom-class="blue-dialog route-edit-dialog"
>
<!-- 4T 一致透明遮罩可拖动记录位置不阻挡地图 -->
<div v-if="visible" class="route-edit-dialog-wrap">
<div class="panel-container" :style="panelStyle">
<div class="dialog-header" @mousedown="onDragStart">
<h3>编辑航线</h3>
<div class="close-btn" @mousedown.stop @click="visible = false">×</div>
</div>
<div class="dialog-body-inner">
<div class="route-edit-tab-bar">
<button type="button" class="tab-bar-item" :class="{ active: activeTab === 'basic' }" @click="activeTab = 'basic'">基础</button>
<button type="button" class="tab-bar-item" :class="{ active: activeTab === 'platform' }" @click="activeTab = 'platform'">平台</button>
@ -277,24 +277,42 @@
</div>
</div>
<span slot="footer" class="dialog-footer">
<div class="dialog-footer">
<el-button size="mini" @click="visible = false"> </el-button>
<el-button type="primary" size="mini" class="blue-btn" @click="handleSave"> </el-button>
</span>
</el-dialog>
</div>
</div>
<div class="resize-handle" @mousedown="onResizeStart" title="拖动调整大小"></div>
</div>
</div>
</template>
<script>
import { listLib } from '@/api/system/lib'
const STORAGE_KEY_PREFIX = 'routeEditPanel_'
export default {
name: 'RouteEditDialog',
props: {
value: { type: Boolean, default: false },
route: { type: Object, default: () => ({}) }
route: { type: Object, default: () => ({}) },
roomId: { type: [String, Number], default: null }
},
data() {
return {
panelLeft: null,
panelTop: null,
panelWidth: 560,
panelHeight: 540,
isDragging: false,
dragStartX: 0,
dragStartY: 0,
isResizing: false,
resizeStartX: 0,
resizeStartY: 0,
resizeStartW: 0,
resizeStartH: 0,
activeTab: 'basic',
platformCategory: 'Air',
platformLoading: false,
@ -328,6 +346,18 @@ export default {
visible: {
get() { return this.value },
set(val) { this.$emit('input', val) }
},
panelStyle() {
const w = this.panelWidth
const h = this.panelHeight
const left = this.panelLeft != null ? this.panelLeft : (window.innerWidth - w) / 2 - 20
const top = this.panelTop != null ? this.panelTop : (window.innerHeight - h) / 2 - 40
return {
left: `${left}px`,
top: `${top}px`,
width: `${w}px`,
height: `${h}px`
}
}
},
watch: {
@ -349,17 +379,106 @@ export default {
deep: true
},
visible(val) {
if (val && this.activeTab === 'platform') {
this.loadPlatforms()
if (val) {
this.loadPosition()
if (this.activeTab === 'platform') this.loadPlatforms()
}
},
activeTab(val) {
if (val === 'platform' && this.visible) {
this.loadPlatforms()
}
if (val === 'platform' && this.visible) this.loadPlatforms()
if (val === 'waypoints' && this.panelWidth < 920) this.panelWidth = 920
}
},
methods: {
getStorageKey() {
return STORAGE_KEY_PREFIX + (this.roomId || 'default')
},
loadPosition() {
try {
const raw = localStorage.getItem(this.getStorageKey())
if (raw) {
const d = JSON.parse(raw)
if (d.panelPosition) {
const left = Number(d.panelPosition.left)
const top = Number(d.panelPosition.top)
if (!isNaN(left) && left >= 0) this.panelLeft = Math.min(left, window.innerWidth - this.panelWidth)
if (!isNaN(top) && top >= 0) this.panelTop = Math.min(top, window.innerHeight - this.panelHeight)
}
if (d.panelSize) {
const w = Number(d.panelSize.width)
const h = Number(d.panelSize.height)
if (!isNaN(w) && w >= 400 && w <= 1200) this.panelWidth = w
if (!isNaN(h) && h >= 400 && h <= window.innerHeight - 60) this.panelHeight = h
}
}
} catch (e) {
console.warn('加载航线编辑弹窗位置失败:', e)
}
},
savePosition() {
try {
const payload = { panelSize: { width: this.panelWidth, height: this.panelHeight } }
if (this.panelLeft != null && this.panelTop != null) {
payload.panelPosition = { left: this.panelLeft, top: this.panelTop }
}
localStorage.setItem(this.getStorageKey(), JSON.stringify(payload))
} catch (e) {
console.warn('保存航线编辑弹窗位置失败:', e)
}
},
onDragStart(e) {
e.preventDefault()
this.isDragging = true
const w = this.panelWidth
const h = this.panelHeight
const currentLeft = this.panelLeft != null ? this.panelLeft : (window.innerWidth - w) / 2 - 20
const currentTop = this.panelTop != null ? this.panelTop : (window.innerHeight - h) / 2 - 40
this.dragStartX = e.clientX - currentLeft
this.dragStartY = e.clientY - currentTop
document.addEventListener('mousemove', this.onDragMove)
document.addEventListener('mouseup', this.onDragEnd)
},
onDragMove(e) {
if (!this.isDragging) return
e.preventDefault()
let left = e.clientX - this.dragStartX
let top = e.clientY - this.dragStartY
left = Math.max(0, Math.min(window.innerWidth - this.panelWidth, left))
top = Math.max(0, Math.min(window.innerHeight - this.panelHeight, top))
this.panelLeft = left
this.panelTop = top
},
onDragEnd() {
this.isDragging = false
document.removeEventListener('mousemove', this.onDragMove)
document.removeEventListener('mouseup', this.onDragEnd)
this.savePosition()
},
onResizeStart(e) {
e.preventDefault()
e.stopPropagation()
this.isResizing = true
this.resizeStartX = e.clientX
this.resizeStartY = e.clientY
this.resizeStartW = this.panelWidth
this.resizeStartH = this.panelHeight
document.addEventListener('mousemove', this.onResizeMove)
document.addEventListener('mouseup', this.onResizeEnd)
},
onResizeMove(e) {
if (!this.isResizing) return
e.preventDefault()
const dx = e.clientX - this.resizeStartX
const dy = e.clientY - this.resizeStartY
this.panelWidth = Math.max(560, Math.min(1200, this.resizeStartW + dx))
this.panelHeight = Math.max(400, Math.min(window.innerHeight - 60, this.resizeStartH + dy))
},
onResizeEnd() {
this.isResizing = false
document.removeEventListener('mousemove', this.onResizeMove)
document.removeEventListener('mouseup', this.onResizeEnd)
this.savePosition()
},
loadPlatforms() {
this.platformLoading = true
listLib().then(res => {
@ -532,24 +651,80 @@ export default {
</script>
<style>
/* 航线编辑标题:加粗加黑 */
.route-edit-dialog .el-dialog__title { font-weight: 700; color: #303133; }
.route-edit-dialog .el-dialog__header { padding: 14px 20px 12px; }
/* 弹窗固定于视口正中间,不随页面滚动 */
.route-edit-dialog.el-dialog {
position: fixed !important;
top: 50% !important;
left: 50% !important;
margin: 0 !important;
transform: translate(-50%, -50%);
}
/* 弹窗内容区固定高度,切换基础/平台时大小不变 */
.route-edit-dialog .el-dialog__body {
padding-top: 12px;
height: 420px;
min-height: 420px;
/* 与 4T 一致:透明遮罩、不阻挡地图 */
.route-edit-dialog-wrap {
position: fixed;
inset: 0;
z-index: 2000;
background: transparent;
pointer-events: none;
}
.route-edit-dialog-wrap .panel-container {
position: fixed;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
overflow: hidden;
box-sizing: border-box;
z-index: 2001;
pointer-events: auto;
display: flex;
flex-direction: column;
}
.route-edit-dialog-wrap .dialog-header {
padding: 14px 20px 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #ebeef5;
cursor: move;
}
.route-edit-dialog-wrap .dialog-header h3 {
margin: 0;
font-weight: 700;
color: #303133;
font-size: 16px;
}
.route-edit-dialog-wrap .close-btn {
font-size: 20px;
color: #909399;
cursor: pointer;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.route-edit-dialog-wrap .close-btn:hover {
color: #606266;
background: #f5f5f5;
border-radius: 50%;
}
.route-edit-dialog-wrap .dialog-body-inner {
padding: 12px 20px 16px;
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.route-edit-dialog-wrap .dialog-footer {
padding: 10px 0 0;
margin-top: 8px;
border-top: 1px solid #ebeef5;
}
.route-edit-dialog-wrap .resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
user-select: none;
z-index: 10;
background: linear-gradient(to top left, transparent 50%, rgba(22, 93, 255, 0.2) 50%);
}
.route-edit-dialog-wrap .resize-handle:hover {
background: linear-gradient(to top left, transparent 50%, rgba(22, 93, 255, 0.4) 50%);
}
</style>
<style scoped>

Loading…
Cancel
Save