11 changed files with 1308 additions and 235 deletions
@ -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<>(); |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
Loading…
Reference in new issue