19 changed files with 1196 additions and 41 deletions
@ -0,0 +1,115 @@ |
|||
package com.ruoyi.websocket.service; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.HashMap; |
|||
import java.util.HashSet; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.messaging.simp.SimpMessagingTemplate; |
|||
import org.springframework.stereotype.Service; |
|||
import com.ruoyi.system.domain.Rooms; |
|||
import com.ruoyi.system.service.IRoomsService; |
|||
import com.ruoyi.websocket.dto.RoomMemberDTO; |
|||
|
|||
/** |
|||
* 大房间与子房间在线成员联动:子房间展示本房+父房间;大房间展示本房+所有子房间。 |
|||
* 成员增删后向所有相关房间的订阅 topic 广播聚合后的 MEMBER_LIST。 |
|||
*/ |
|||
@Service |
|||
public class RoomOnlineMemberBroadcastService { |
|||
|
|||
private static final String TYPE_MEMBER_LIST = "MEMBER_LIST"; |
|||
|
|||
@Autowired |
|||
private RoomWebSocketService roomWebSocketService; |
|||
|
|||
@Autowired |
|||
private IRoomsService roomsService; |
|||
|
|||
@Autowired |
|||
private SimpMessagingTemplate messagingTemplate; |
|||
|
|||
/** |
|||
* 某「视角房间」下应展示的在线成员(已按 sessionId 去重)。 |
|||
*/ |
|||
public List<RoomMemberDTO> aggregateMembersForView(Long viewRoomId) { |
|||
if (viewRoomId == null) { |
|||
return new ArrayList<>(); |
|||
} |
|||
Rooms room = roomsService.selectRoomsById(viewRoomId); |
|||
if (room == null) { |
|||
return new ArrayList<>(); |
|||
} |
|||
LinkedHashMap<String, RoomMemberDTO> bySession = new LinkedHashMap<>(); |
|||
if (room.getParentId() != null) { |
|||
putAllMembers(bySession, viewRoomId, roomWebSocketService.getRoomMembers(viewRoomId)); |
|||
putAllMembers(bySession, room.getParentId(), roomWebSocketService.getRoomMembers(room.getParentId())); |
|||
} else { |
|||
putAllMembers(bySession, viewRoomId, roomWebSocketService.getRoomMembers(viewRoomId)); |
|||
Rooms query = new Rooms(); |
|||
query.setParentId(viewRoomId); |
|||
List<Rooms> children = roomsService.selectRoomsList(query); |
|||
if (children != null) { |
|||
for (Rooms child : children) { |
|||
if (child != null && child.getId() != null) { |
|||
putAllMembers(bySession, child.getId(), roomWebSocketService.getRoomMembers(child.getId())); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
return new ArrayList<>(bySession.values()); |
|||
} |
|||
|
|||
private void putAllMembers(Map<String, RoomMemberDTO> bySession, Long sourceRoomId, List<RoomMemberDTO> list) { |
|||
if (list == null) { |
|||
return; |
|||
} |
|||
for (RoomMemberDTO m : list) { |
|||
if (m != null && m.getSessionId() != null) { |
|||
// 兼容历史数据:老成员可能没有 roomId 字段
|
|||
if (m.getRoomId() == null) { |
|||
m.setRoomId(sourceRoomId); |
|||
} |
|||
bySession.put(m.getSessionId(), m); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 在指定房间发生加入/离开后,刷新该房间、其父房间(若有)或所有子房间(若为大房间)的在线列表推送。 |
|||
*/ |
|||
public void broadcastAfterMembershipChange(Long changedRoomId) { |
|||
if (changedRoomId == null) { |
|||
return; |
|||
} |
|||
Rooms changed = roomsService.selectRoomsById(changedRoomId); |
|||
if (changed == null) { |
|||
return; |
|||
} |
|||
Set<Long> topicRoomIds = new HashSet<>(); |
|||
topicRoomIds.add(changedRoomId); |
|||
if (changed.getParentId() != null) { |
|||
topicRoomIds.add(changed.getParentId()); |
|||
} else { |
|||
Rooms query = new Rooms(); |
|||
query.setParentId(changedRoomId); |
|||
List<Rooms> children = roomsService.selectRoomsList(query); |
|||
if (children != null) { |
|||
for (Rooms child : children) { |
|||
if (child != null && child.getId() != null) { |
|||
topicRoomIds.add(child.getId()); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
for (Long tid : topicRoomIds) { |
|||
Map<String, Object> msg = new HashMap<>(); |
|||
msg.put("type", TYPE_MEMBER_LIST); |
|||
msg.put("members", aggregateMembersForView(tid)); |
|||
messagingTemplate.convertAndSend("/topic/room/" + tid, msg); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
package com.ruoyi.system.domain; |
|||
|
|||
import com.ruoyi.common.core.domain.BaseEntity; |
|||
|
|||
/** |
|||
* 协同房间用户资料(显示名、头像) |
|||
*/ |
|||
public class RoomUserProfile extends BaseEntity { |
|||
private static final long serialVersionUID = 1L; |
|||
|
|||
private Long id; |
|||
private Long userId; |
|||
private String displayName; |
|||
private String avatar; |
|||
|
|||
public Long getId() { |
|||
return id; |
|||
} |
|||
|
|||
public void setId(Long id) { |
|||
this.id = id; |
|||
} |
|||
|
|||
public Long getUserId() { |
|||
return userId; |
|||
} |
|||
|
|||
public void setUserId(Long userId) { |
|||
this.userId = userId; |
|||
} |
|||
|
|||
public String getDisplayName() { |
|||
return displayName; |
|||
} |
|||
|
|||
public void setDisplayName(String displayName) { |
|||
this.displayName = displayName; |
|||
} |
|||
|
|||
public String getAvatar() { |
|||
return avatar; |
|||
} |
|||
|
|||
public void setAvatar(String avatar) { |
|||
this.avatar = avatar; |
|||
} |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.ruoyi.system.mapper; |
|||
|
|||
import com.ruoyi.system.domain.RoomUserProfile; |
|||
|
|||
public interface RoomUserProfileMapper { |
|||
|
|||
RoomUserProfile selectByUserId(Long userId); |
|||
|
|||
int insertRoomUserProfile(RoomUserProfile profile); |
|||
|
|||
int updateRoomUserProfile(RoomUserProfile profile); |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
package com.ruoyi.system.service; |
|||
|
|||
import com.ruoyi.system.domain.RoomUserProfile; |
|||
|
|||
public interface IRoomUserProfileService { |
|||
|
|||
RoomUserProfile getOrInitByUser(Long userId, String defaultUserName); |
|||
|
|||
RoomUserProfile updateDisplayName(Long userId, String displayName, String defaultUserName); |
|||
|
|||
RoomUserProfile updateAvatar(Long userId, String avatar, String defaultUserName); |
|||
} |
|||
@ -0,0 +1,63 @@ |
|||
package com.ruoyi.system.service.impl; |
|||
|
|||
import java.util.Date; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.stereotype.Service; |
|||
import com.ruoyi.common.utils.StringUtils; |
|||
import com.ruoyi.system.domain.RoomUserProfile; |
|||
import com.ruoyi.system.mapper.RoomUserProfileMapper; |
|||
import com.ruoyi.system.service.IRoomUserProfileService; |
|||
|
|||
@Service |
|||
public class RoomUserProfileServiceImpl implements IRoomUserProfileService { |
|||
|
|||
public static final String DEFAULT_AVATAR = |
|||
"https://cube.elemecdn.com/0/88dd03f9bf287d08f58fbcf58fddbf4a8c6/avatar.png"; |
|||
|
|||
@Autowired |
|||
private RoomUserProfileMapper roomUserProfileMapper; |
|||
|
|||
@Override |
|||
public RoomUserProfile getOrInitByUser(Long userId, String defaultUserName) { |
|||
if (userId == null) { |
|||
return null; |
|||
} |
|||
RoomUserProfile profile = roomUserProfileMapper.selectByUserId(userId); |
|||
if (profile != null) { |
|||
return profile; |
|||
} |
|||
RoomUserProfile created = new RoomUserProfile(); |
|||
created.setUserId(userId); |
|||
created.setDisplayName(StringUtils.isNotEmpty(defaultUserName) ? defaultUserName : "用户" + userId); |
|||
created.setAvatar(DEFAULT_AVATAR); |
|||
Date now = new Date(); |
|||
created.setCreateTime(now); |
|||
created.setUpdateTime(now); |
|||
roomUserProfileMapper.insertRoomUserProfile(created); |
|||
return roomUserProfileMapper.selectByUserId(userId); |
|||
} |
|||
|
|||
@Override |
|||
public RoomUserProfile updateDisplayName(Long userId, String displayName, String defaultUserName) { |
|||
RoomUserProfile current = getOrInitByUser(userId, defaultUserName); |
|||
if (current == null) { |
|||
return null; |
|||
} |
|||
current.setDisplayName(StringUtils.isNotEmpty(displayName) ? displayName : current.getDisplayName()); |
|||
current.setUpdateTime(new Date()); |
|||
roomUserProfileMapper.updateRoomUserProfile(current); |
|||
return roomUserProfileMapper.selectByUserId(userId); |
|||
} |
|||
|
|||
@Override |
|||
public RoomUserProfile updateAvatar(Long userId, String avatar, String defaultUserName) { |
|||
RoomUserProfile current = getOrInitByUser(userId, defaultUserName); |
|||
if (current == null) { |
|||
return null; |
|||
} |
|||
current.setAvatar(StringUtils.isNotEmpty(avatar) ? avatar : current.getAvatar()); |
|||
current.setUpdateTime(new Date()); |
|||
roomUserProfileMapper.updateRoomUserProfile(current); |
|||
return roomUserProfileMapper.selectByUserId(userId); |
|||
} |
|||
} |
|||
@ -0,0 +1,54 @@ |
|||
<?xml version="1.0" encoding="UTF-8" ?> |
|||
<!DOCTYPE mapper |
|||
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" |
|||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> |
|||
<mapper namespace="com.ruoyi.system.mapper.RoomUserProfileMapper"> |
|||
|
|||
<resultMap type="com.ruoyi.system.domain.RoomUserProfile" id="RoomUserProfileResult"> |
|||
<result property="id" column="id"/> |
|||
<result property="userId" column="user_id"/> |
|||
<result property="displayName" column="display_name"/> |
|||
<result property="avatar" column="avatar"/> |
|||
<result property="createTime" column="create_time"/> |
|||
<result property="updateTime" column="update_time"/> |
|||
</resultMap> |
|||
|
|||
<sql id="selectRoomUserProfileVo"> |
|||
select id, user_id, display_name, avatar, create_time, update_time |
|||
from room_user_profile |
|||
</sql> |
|||
|
|||
<select id="selectByUserId" parameterType="Long" resultMap="RoomUserProfileResult"> |
|||
<include refid="selectRoomUserProfileVo"/> |
|||
where user_id = #{userId} |
|||
limit 1 |
|||
</select> |
|||
|
|||
<insert id="insertRoomUserProfile" parameterType="com.ruoyi.system.domain.RoomUserProfile" useGeneratedKeys="true" keyProperty="id"> |
|||
insert into room_user_profile |
|||
<trim prefix="(" suffix=")" suffixOverrides=","> |
|||
<if test="userId != null">user_id,</if> |
|||
<if test="displayName != null">display_name,</if> |
|||
<if test="avatar != null">avatar,</if> |
|||
<if test="createTime != null">create_time,</if> |
|||
<if test="updateTime != null">update_time,</if> |
|||
</trim> |
|||
<trim prefix="values (" suffix=")" suffixOverrides=","> |
|||
<if test="userId != null">#{userId},</if> |
|||
<if test="displayName != null">#{displayName},</if> |
|||
<if test="avatar != null">#{avatar},</if> |
|||
<if test="createTime != null">#{createTime},</if> |
|||
<if test="updateTime != null">#{updateTime},</if> |
|||
</trim> |
|||
</insert> |
|||
|
|||
<update id="updateRoomUserProfile" parameterType="com.ruoyi.system.domain.RoomUserProfile"> |
|||
update room_user_profile |
|||
<trim prefix="SET" suffixOverrides=","> |
|||
<if test="displayName != null">display_name = #{displayName},</if> |
|||
<if test="avatar != null">avatar = #{avatar},</if> |
|||
<if test="updateTime != null">update_time = #{updateTime},</if> |
|||
</trim> |
|||
where user_id = #{userId} |
|||
</update> |
|||
</mapper> |
|||
@ -0,0 +1,286 @@ |
|||
<template> |
|||
<div> |
|||
<el-dialog |
|||
title="编辑用户信息" |
|||
:visible.sync="innerVisible" |
|||
width="420px" |
|||
append-to-body |
|||
@close="handleClose" |
|||
> |
|||
<div class="profile-dialog-body"> |
|||
<div class="avatar-row"> |
|||
<el-avatar :size="72" :src="previewAvatar" /> |
|||
<el-upload |
|||
class="avatar-uploader" |
|||
action="#" |
|||
:auto-upload="false" |
|||
:show-file-list="false" |
|||
:before-upload="beforeAvatarUpload" |
|||
:on-change="handleChooseAvatar" |
|||
accept=".png,.jpg,.jpeg,.gif,.webp" |
|||
> |
|||
<el-button size="mini">上传头像</el-button> |
|||
</el-upload> |
|||
</div> |
|||
<el-form label-width="80px" size="small"> |
|||
<el-form-item label="用户名"> |
|||
<el-input v-model.trim="form.displayName" maxlength="32" show-word-limit /> |
|||
</el-form-item> |
|||
<el-form-item label="账号"> |
|||
<el-input :value="form.userName" disabled /> |
|||
</el-form-item> |
|||
</el-form> |
|||
</div> |
|||
<span slot="footer" class="dialog-footer"> |
|||
<el-button @click="innerVisible = false">取 消</el-button> |
|||
<el-button type="primary" :loading="saving" @click="saveProfile">保 存</el-button> |
|||
</span> |
|||
</el-dialog> |
|||
|
|||
<el-dialog |
|||
title="调整头像" |
|||
:visible.sync="cropperVisible" |
|||
width="760px" |
|||
append-to-body |
|||
@close="onCropperClose" |
|||
> |
|||
<el-row> |
|||
<el-col :span="12" style="height: 360px;"> |
|||
<vue-cropper |
|||
v-if="cropperVisible" |
|||
ref="cropper" |
|||
:img="cropperOptions.img" |
|||
:info="true" |
|||
:autoCrop="true" |
|||
:autoCropWidth="220" |
|||
:autoCropHeight="220" |
|||
:fixedBox="true" |
|||
:outputType="'png'" |
|||
@realTime="realTimePreview" |
|||
/> |
|||
</el-col> |
|||
<el-col :span="12" style="height: 360px;"> |
|||
<div class="avatar-upload-preview"> |
|||
<img :src="cropperPreview.url" :style="cropperPreview.img" /> |
|||
</div> |
|||
</el-col> |
|||
</el-row> |
|||
<div class="cropper-toolbar-wrap"> |
|||
<el-button class="zoom-btn" icon="el-icon-plus" size="small" @click="changeScale(1)">放大</el-button> |
|||
<el-button class="zoom-btn" icon="el-icon-minus" size="small" @click="changeScale(-1)">缩小</el-button> |
|||
</div> |
|||
<span slot="footer" class="dialog-footer"> |
|||
<el-button @click="cropperVisible = false">取 消</el-button> |
|||
<el-button type="primary" :loading="uploading" @click="confirmCropAndUpload">确 定</el-button> |
|||
</span> |
|||
</el-dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { VueCropper } from 'vue-cropper' |
|||
import { updateRoomUserProfile, uploadRoomUserAvatar } from '@/api/system/whiteboard' |
|||
|
|||
export default { |
|||
name: 'UserProfileDialog', |
|||
components: { |
|||
VueCropper |
|||
}, |
|||
props: { |
|||
value: { |
|||
type: Boolean, |
|||
default: false |
|||
}, |
|||
profile: { |
|||
type: Object, |
|||
default: () => ({}) |
|||
} |
|||
}, |
|||
data() { |
|||
return { |
|||
innerVisible: false, |
|||
saving: false, |
|||
uploading: false, |
|||
cropperVisible: false, |
|||
cropperOptions: { |
|||
img: '', |
|||
filename: 'avatar.png' |
|||
}, |
|||
cropperPreview: {}, |
|||
localAvatarPreview: '', |
|||
form: { |
|||
userName: '', |
|||
displayName: '', |
|||
avatar: '' |
|||
} |
|||
} |
|||
}, |
|||
computed: { |
|||
previewAvatar() { |
|||
if (this.localAvatarPreview) return this.localAvatarPreview |
|||
if (!this.form.avatar) return '' |
|||
if (this.form.avatar.startsWith('data:')) return this.form.avatar |
|||
if (this.form.avatar.startsWith('http')) return this.form.avatar |
|||
const base = process.env.VUE_APP_BACKEND_URL || (window.location.origin + (process.env.VUE_APP_BASE_API || '/dev-api')) |
|||
return base + this.form.avatar |
|||
} |
|||
}, |
|||
watch: { |
|||
value: { |
|||
immediate: true, |
|||
handler(v) { |
|||
this.innerVisible = v |
|||
if (v) this.syncForm() |
|||
} |
|||
}, |
|||
innerVisible(v) { |
|||
this.$emit('input', v) |
|||
if (v) this.syncForm() |
|||
}, |
|||
profile: { |
|||
deep: true, |
|||
handler() { |
|||
if (this.innerVisible) this.syncForm() |
|||
} |
|||
} |
|||
}, |
|||
methods: { |
|||
syncForm() { |
|||
const p = this.profile || {} |
|||
this.form.userName = p.userName || '' |
|||
this.form.displayName = p.displayName || p.userName || '' |
|||
this.form.avatar = p.avatar || '' |
|||
}, |
|||
beforeAvatarUpload(file) { |
|||
const okType = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.type) |
|||
if (!okType) { |
|||
this.$message.error('仅支持 JPG/PNG/GIF/WEBP 图片') |
|||
return false |
|||
} |
|||
const okSize = file.size / 1024 / 1024 < 2 |
|||
if (!okSize) { |
|||
this.$message.error('头像大小不能超过 2MB') |
|||
return false |
|||
} |
|||
return true |
|||
}, |
|||
handleChooseAvatar(file) { |
|||
const rawFile = file && file.raw |
|||
if (!rawFile) return |
|||
const reader = new FileReader() |
|||
reader.readAsDataURL(rawFile) |
|||
reader.onload = () => { |
|||
this.localAvatarPreview = reader.result || '' |
|||
this.cropperOptions.img = reader.result |
|||
this.cropperOptions.filename = rawFile.name || 'avatar.png' |
|||
this.cropperVisible = true |
|||
} |
|||
}, |
|||
changeScale(num) { |
|||
if (this.$refs.cropper) this.$refs.cropper.changeScale(num) |
|||
}, |
|||
realTimePreview(data) { |
|||
this.cropperPreview = data || {} |
|||
}, |
|||
confirmCropAndUpload() { |
|||
if (!this.$refs.cropper || this.uploading) return |
|||
this.uploading = true |
|||
this.$refs.cropper.getCropBlob(async (blob) => { |
|||
try { |
|||
this.localAvatarPreview = URL.createObjectURL(blob) |
|||
const file = new File([blob], this.cropperOptions.filename, { type: 'image/png' }) |
|||
const res = await uploadRoomUserAvatar(file) |
|||
const data = res.data || {} |
|||
const nested = data.data || {} |
|||
const avatar = data.avatar || nested.avatar || res.imgUrl || res.fullImgUrl || '' |
|||
const merged = { |
|||
...data, |
|||
...nested, |
|||
avatar |
|||
} |
|||
this.form.avatar = avatar || this.form.avatar |
|||
this.$emit('profile-updated', merged) |
|||
this.localAvatarPreview = '' |
|||
this.cropperVisible = false |
|||
this.$message.success('头像上传成功') |
|||
} finally { |
|||
this.uploading = false |
|||
} |
|||
}) |
|||
}, |
|||
async saveProfile() { |
|||
if (!this.form.displayName) { |
|||
this.$message.warning('用户名不能为空') |
|||
return |
|||
} |
|||
this.saving = true |
|||
try { |
|||
const res = await updateRoomUserProfile({ displayName: this.form.displayName }) |
|||
this.$emit('profile-updated', res.data || {}) |
|||
this.$message.success('保存成功') |
|||
this.innerVisible = false |
|||
} finally { |
|||
this.saving = false |
|||
} |
|||
}, |
|||
handleClose() { |
|||
this.$emit('input', false) |
|||
}, |
|||
onCropperClose() { |
|||
this.cropperPreview = {} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.profile-dialog-body { |
|||
padding: 4px 10px; |
|||
} |
|||
|
|||
.avatar-row { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 12px; |
|||
margin-bottom: 16px; |
|||
} |
|||
|
|||
.avatar-upload-preview { |
|||
width: 220px; |
|||
height: 220px; |
|||
border-radius: 50%; |
|||
overflow: hidden; |
|||
margin: 0 auto; |
|||
border: 1px solid #dcdfe6; |
|||
} |
|||
|
|||
.cropper-toolbar-wrap { |
|||
margin-top: 14px; |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
gap: 12px; |
|||
} |
|||
|
|||
.zoom-btn { |
|||
min-width: 88px; |
|||
height: 34px; |
|||
border-radius: 18px; |
|||
border: 1px solid #d0def5; |
|||
background: #f4f8ff; |
|||
color: #2f6fd6; |
|||
font-weight: 600; |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
.zoom-btn:hover, |
|||
.zoom-btn:focus { |
|||
border-color: #6ea0ef; |
|||
background: #e9f2ff; |
|||
color: #1f5fcb; |
|||
} |
|||
|
|||
.zoom-btn:active { |
|||
transform: translateY(1px); |
|||
} |
|||
</style> |
|||
@ -0,0 +1,11 @@ |
|||
-- 协同房间用户资料表(显示名 + 头像) |
|||
CREATE TABLE IF NOT EXISTS room_user_profile ( |
|||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', |
|||
user_id BIGINT NOT NULL COMMENT 'sys_user.user_id', |
|||
display_name VARCHAR(64) NOT NULL COMMENT '协同显示名', |
|||
avatar VARCHAR(255) DEFAULT NULL COMMENT '协同头像URL/资源路径', |
|||
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
|||
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', |
|||
PRIMARY KEY (id), |
|||
UNIQUE KEY uk_user_id (user_id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='协同房间用户资料'; |
|||
Loading…
Reference in new issue