diff --git a/backend/go-service/app/meeting/controller/meeting_controller.go b/backend/go-service/app/meeting/controller/meeting_controller.go
index 7bd8dee..7abf18d 100644
--- a/backend/go-service/app/meeting/controller/meeting_controller.go
+++ b/backend/go-service/app/meeting/controller/meeting_controller.go
@@ -127,17 +127,23 @@ func participantToDTO(p *model.MeetingParticipant) *dto.MeetingParticipantDTO {
}
// chatToDTO 将 model.MeetingChat 转 DTO
-func chatToDTO(m *model.MeetingChat) *dto.MeetingChatDTO {
+// userMap 可选:若提供则附带 user_name / user_avatar,否则为空
+func chatToDTO(m *model.MeetingChat, userMap map[int64]service.UserDisplayInfo) *dto.MeetingChatDTO {
if m == nil {
return nil
}
- return &dto.MeetingChatDTO{
+ d := &dto.MeetingChatDTO{
ID: m.ID,
RoomID: m.RoomID,
UserID: m.UserID,
Content: m.Content,
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
}
+ if info, ok := userMap[m.UserID]; ok {
+ d.UserName = info.Name
+ d.UserAvatar = info.Avatar
+ }
+ return d
}
// ====== REST API ======
@@ -398,7 +404,8 @@ func (ctl *MeetingController) SendChat(c *gin.Context) {
ctl.handleError(c, err, "发送会议聊天失败")
return
}
- utils.ResponseCreated(c, dto.SendMeetingChatResponse{Message: *chatToDTO(msg)})
+ userMap := ctl.meetingService.ResolveUsersDisplay(c.Request.Context(), []int64{msg.UserID})
+ utils.ResponseCreated(c, dto.SendMeetingChatResponse{Message: *chatToDTO(msg, userMap)})
}
// ListChats GET /api/v1/meeting/rooms/:code/chats?before_id=&limit=
@@ -419,9 +426,14 @@ func (ctl *MeetingController) ListChats(c *gin.Context) {
ctl.handleError(c, err, "获取会议聊天失败")
return
}
+ userIDs := make([]int64, 0, len(msgs))
+ for i := range msgs {
+ userIDs = append(userIDs, msgs[i].UserID)
+ }
+ userMap := ctl.meetingService.ResolveUsersDisplay(c.Request.Context(), userIDs)
list := make([]dto.MeetingChatDTO, 0, len(msgs))
for i := range msgs {
- list = append(list, *chatToDTO(&msgs[i]))
+ list = append(list, *chatToDTO(&msgs[i], userMap))
}
utils.ResponseOK(c, dto.ListMeetingChatsResponse{List: list, HasMore: hasMore})
}
diff --git a/backend/go-service/app/meeting/service/meeting_service.go b/backend/go-service/app/meeting/service/meeting_service.go
index 9b8bd8f..d6b041f 100644
--- a/backend/go-service/app/meeting/service/meeting_service.go
+++ b/backend/go-service/app/meeting/service/meeting_service.go
@@ -150,6 +150,63 @@ func (s *MeetingService) broadcastToActiveParticipants(ctx context.Context, room
s.broadcaster.BroadcastToMeeting(ctx, roomID, event, data, excludeUserIDs...)
}
+// UserDisplayInfo 用户展示信息(昵称优先,降级为用户名;头像可空)
+type UserDisplayInfo struct {
+ Name string
+ Avatar string
+}
+
+// resolveUserDisplay 查单个用户的展示信息;查询失败/未命中时返回空字符串
+// 仅服务层内部使用,调用方失败时降级为 user_name="" 即可
+func (s *MeetingService) resolveUserDisplay(ctx context.Context, userID int64) (name string, avatar string) {
+ if s.userResolver == nil || userID <= 0 {
+ return "", ""
+ }
+ users, err := s.userResolver.GetUsersByIDs(ctx, []int64{userID})
+ if err != nil || len(users) == 0 {
+ return "", ""
+ }
+ u := users[0]
+ if u.Nickname != "" {
+ return u.Nickname, u.Avatar
+ }
+ return u.Username, u.Avatar
+}
+
+// ResolveUsersDisplay 批量查询用户展示信息,controller 端用于填充 DTO
+// 返回 map[userID]UserDisplayInfo,查询失败时返回空 map(调用方按降级处理)
+func (s *MeetingService) ResolveUsersDisplay(ctx context.Context, userIDs []int64) map[int64]UserDisplayInfo {
+ out := make(map[int64]UserDisplayInfo, len(userIDs))
+ if s.userResolver == nil || len(userIDs) == 0 {
+ return out
+ }
+ // 去重
+ seen := make(map[int64]struct{}, len(userIDs))
+ unique := make([]int64, 0, len(userIDs))
+ for _, id := range userIDs {
+ if id <= 0 {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ unique = append(unique, id)
+ }
+ users, err := s.userResolver.GetUsersByIDs(ctx, unique)
+ if err != nil {
+ return out
+ }
+ for _, u := range users {
+ name := u.Nickname
+ if name == "" {
+ name = u.Username
+ }
+ out[u.ID] = UserDisplayInfo{Name: name, Avatar: u.Avatar}
+ }
+ return out
+}
+
// ====== 会议生命周期 ======
// CreateRoom 创建即时会议
@@ -773,12 +830,17 @@ func (s *MeetingService) SendChatMessage(ctx context.Context, userID int64, code
return nil, err
}
+ // 附带 user_name / user_avatar,前端聊天面板无需额外拉取
+ userName, userAvatar := s.resolveUserDisplay(ctx, userID)
+
go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventChatMessage, map[string]interface{}{
- "room_code": code,
- "message_id": chat.ID,
- "user_id": userID,
- "content": content,
- "created_at": chat.CreatedAt.Format("2006-01-02 15:04:05"),
+ "room_code": code,
+ "message_id": chat.ID,
+ "user_id": userID,
+ "user_name": userName,
+ "user_avatar": userAvatar,
+ "content": content,
+ "created_at": chat.CreatedAt.Format("2006-01-02 15:04:05"),
}, userID)
logs.Debug(ctx, funcName, "会议聊天已发送",
diff --git a/frontend/src/components/meeting/ChatPanel.vue b/frontend/src/components/meeting/ChatPanel.vue
new file mode 100644
index 0000000..7d10a6f
--- /dev/null
+++ b/frontend/src/components/meeting/ChatPanel.vue
@@ -0,0 +1,399 @@
+
+
+
+
+
+
+
+
+ 加载更早的消息
+ 加载中…
+
+
+ 没有更早的消息了
+
+
+
+
+ {{ initialOf(m) }}
+
+
+
+
+
+ {{ m.user_id === currentUserId ? '我' : displayName(m) }}
+ {{ formatTime(m.created_at) }}
+
+
+ {{ m.content }}
+
+
+
+
+
+ 发送第一条消息,开始会议内对话
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/pages/meeting/room.vue b/frontend/src/pages/meeting/room.vue
index c62f412..c59b3fe 100644
--- a/frontend/src/pages/meeting/room.vue
+++ b/frontend/src/pages/meeting/room.vue
@@ -92,6 +92,20 @@
@close="inviteVisible = false"
/>
+
+
+
@@ -132,17 +146,19 @@ import MeetingToolbar from '@/components/meeting/MeetingToolbar.vue'
import MemberPanel from '@/components/meeting/MemberPanel.vue'
import InviteDialog from '@/components/meeting/InviteDialog.vue'
import NetworkBadge from '@/components/meeting/NetworkBadge.vue'
+import ChatPanel from '@/components/meeting/ChatPanel.vue'
const meetingStore = useMeetingStore()
const userStore = useUserStore()
const memberVisible = ref(false)
const inviteVisible = ref(false)
+const chatVisible = ref(false)
const leaveDialogVisible = ref(false)
const audioLoading = ref(false)
const videoLoading = ref(false)
+const chatLoadingMore = ref(false)
const networkLevel = ref(4) // Task 15 接入真实 getStats 后动态更新
-const unreadChatCount = ref(0) // Task 14 接入 chat 后动态更新
const startedAt = ref(0)
const nowTs = ref(Date.now())
let timerHandle = null
@@ -181,6 +197,9 @@ const allowChat = computed(() => {
}
})
const invitePassword = computed(() => meetingStore.draftCreatePayload?.password || '')
+const chatMessages = computed(() => meetingStore.chatMessages)
+const chatHasMore = computed(() => meetingStore.chatHasMore)
+const unreadChatCount = computed(() => meetingStore.chatUnreadCount)
const formattedCode = computed(() => {
const d = String(roomCode.value || '').replace(/\D/g, '')
@@ -358,8 +377,41 @@ const onCamToggle = async () => {
const openInvite = () => { inviteVisible.value = true }
const openMembers = () => { memberVisible.value = true }
const openChat = () => {
- // Task 14 会议聊天入口;当前阶段仅提示
- uni.showToast({ title: '聊天功能即将上线', icon: 'none' })
+ chatVisible.value = true
+ meetingStore.markChatsRead()
+}
+const closeChat = () => { chatVisible.value = false }
+
+watch(unreadChatCount, (count) => {
+ if (count > 0 && chatVisible.value) {
+ meetingStore.markChatsRead()
+ }
+})
+
+const onChatSend = async (content) => {
+ try {
+ await meetingStore.sendChat(content)
+ } catch (err) {
+ uni.showToast({ title: err?.message || '发送失败', icon: 'none' })
+ throw err
+ }
+}
+
+const onChatLoadMore = async () => {
+ if (chatLoadingMore.value) return
+ chatLoadingMore.value = true
+ try {
+ await meetingStore.loadMoreChats(30)
+ } catch (err) {
+ uni.showToast({ title: err?.message || '加载失败', icon: 'none' })
+ } finally {
+ chatLoadingMore.value = false
+ }
+}
+
+const onChatRequestInitialLoad = async () => {
+ if (meetingStore.chatHistoryLoaded) return
+ await onChatLoadMore()
}
const onKickMember = async (uid) => {
diff --git a/frontend/src/store/meeting.js b/frontend/src/store/meeting.js
index efe07de..b256b0a 100644
--- a/frontend/src/store/meeting.js
+++ b/frontend/src/store/meeting.js
@@ -79,9 +79,18 @@ export const useMeetingStore = defineStore('meeting', () => {
/** 活跃参与者列表(含当前用户,按加入时间正序) */
const participants = ref([])
- /** 会议聊天消息列表(最新在尾部) */
+ /** 会议聊天消息列表(按 ID 升序,最新在尾部) */
const chatMessages = ref([])
+ /** 会议聊天还有更早消息可加载(用于"加载更多"按钮) */
+ const chatHasMore = ref(true)
+
+ /** 会议聊天未读计数(来自他人且 ChatPanel 未打开期间累积) */
+ const chatUnreadCount = ref(0)
+
+ /** 会议聊天首次懒加载完成标记 */
+ const chatHistoryLoaded = ref(false)
+
/** 最近一次会议结束原因(room.ended_reason 或 room.ended.reason) */
const lastEndedReason = ref('')
@@ -180,6 +189,9 @@ export const useMeetingStore = defineStore('meeting', () => {
routerID.value = ''
participants.value = []
chatMessages.value = []
+ chatHasMore.value = true
+ chatUnreadCount.value = 0
+ chatHistoryLoaded.value = false
localAudioEnabled.value = false
localVideoEnabled.value = false
localProducers.audio = null
@@ -344,7 +356,22 @@ export const useMeetingStore = defineStore('meeting', () => {
const _onChatMessage = (msg) => {
const data = msg.data || {}
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
- chatMessages.value.push(data)
+ // WS 广播字段名与 REST 响应不同:id vs message_id;统一映射为 id/user_id/content/created_at
+ const item = {
+ id: data.message_id ?? data.id,
+ user_id: data.user_id,
+ user_name: data.user_name || '',
+ user_avatar: data.user_avatar || '',
+ content: data.content,
+ created_at: data.created_at
+ }
+ // 防止重复:相同 id 已存在则忽略(sendChat 响应 + 广播同时到达的兜底)
+ if (item.id && chatMessages.value.some(m => m.id === item.id)) return
+ chatMessages.value.push(item)
+ const userStore = useUserStore()
+ if (item.user_id !== userStore.userInfo?.id) {
+ chatUnreadCount.value += 1
+ }
}
/**
@@ -656,16 +683,47 @@ export const useMeetingStore = defineStore('meeting', () => {
// ==================== Actions:聊天/管理 ====================
+ /**
+ * 发送会议聊天
+ * 后端广播 meeting.chat.message 给房间内非发送者,发送方依赖 REST 响应里的 message 上屏;
+ * 若后端未把 message 字段回传,则退化为等待广播(不上屏)
+ */
const sendChat = async (content) => {
if (!currentRoom.value) throw new Error('未入会')
- await meetingApi.sendChat(currentRoom.value.room_code, content)
+ const resp = await meetingApi.sendChat(currentRoom.value.room_code, content)
+ const msg = resp?.message
+ if (msg && !chatMessages.value.some(m => m.id === msg.id)) {
+ chatMessages.value.push(msg)
+ }
+ return msg
}
- const loadChatHistory = async (beforeId = 0, limit = 30) => {
+ /** 加载更早的聊天消息,按 ID 游标向前翻页;首次进入时 beforeId=0 表示拿最新一页 */
+ const loadMoreChats = async (limit = 30) => {
if (!currentRoom.value) return { list: [], has_more: false }
- return meetingApi.listChats(currentRoom.value.room_code, { before_id: beforeId, limit })
+ const oldest = chatMessages.value[0]
+ const beforeId = oldest?.id || 0
+ const resp = await meetingApi.listChats(currentRoom.value.room_code, { before_id: beforeId, limit })
+ const list = (resp?.list || []).slice().sort((a, b) => (a.id || 0) - (b.id || 0))
+ if (list.length) {
+ // 去重后按时间序插入到头部
+ const existing = new Set(chatMessages.value.map(m => m.id))
+ const fresh = list.filter(m => !existing.has(m.id))
+ if (fresh.length) chatMessages.value.unshift(...fresh)
+ }
+ chatHasMore.value = !!resp?.has_more
+ chatHistoryLoaded.value = true
+ return resp
}
+ /** 读掉所有会议聊天未读(ChatPanel 打开时调用) */
+ const markChatsRead = () => {
+ chatUnreadCount.value = 0
+ }
+
+ /** 兼容旧调用:保留 loadChatHistory 名称,内部调 loadMoreChats */
+ const loadChatHistory = loadMoreChats
+
const transferHost = async (targetUserID) => {
if (!currentRoom.value) throw new Error('未入会')
await meetingApi.transferHost(currentRoom.value.room_code, targetUserID)
@@ -720,6 +778,9 @@ export const useMeetingStore = defineStore('meeting', () => {
routerID,
participants,
chatMessages,
+ chatHasMore,
+ chatUnreadCount,
+ chatHistoryLoaded,
lastEndedReason,
lastAutoHostReason,
localAudioEnabled,
@@ -751,7 +812,9 @@ export const useMeetingStore = defineStore('meeting', () => {
// chat & admin
sendChat,
+ loadMoreChats,
loadChatHistory,
+ markChatsRead,
transferHost,
kickMember,
inviteUsers,