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