feat(phase2e-2): 会议内聊天面板落地(Task 12)
- 新增 ChatPanel 组件:右侧抽屉式聊天 UI,支持发送/加载更早消息/未读清零 - store/meeting.js:扩展 chatHasMore/chatUnreadCount/chatHistoryLoaded 等状态与 loadMoreChats/markChatsRead 动作;WS 消息到达时按 id 去重 - room.vue:替换临时 toast 占位为 ChatPanel,绑定未读徽标、成员展示名映射;watch unread 自动在面板打开时清零 - 后端:SendChat WS 广播与 REST 响应补充 user_name/user_avatar,聊天面板无需额外查询即可展示昵称 Made-with: Cursor
This commit is contained in:
@@ -127,17 +127,23 @@ func participantToDTO(p *model.MeetingParticipant) *dto.MeetingParticipantDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// chatToDTO 将 model.MeetingChat 转 DTO
|
// 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 {
|
if m == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &dto.MeetingChatDTO{
|
d := &dto.MeetingChatDTO{
|
||||||
ID: m.ID,
|
ID: m.ID,
|
||||||
RoomID: m.RoomID,
|
RoomID: m.RoomID,
|
||||||
UserID: m.UserID,
|
UserID: m.UserID,
|
||||||
Content: m.Content,
|
Content: m.Content,
|
||||||
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
|
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 ======
|
// ====== REST API ======
|
||||||
@@ -398,7 +404,8 @@ func (ctl *MeetingController) SendChat(c *gin.Context) {
|
|||||||
ctl.handleError(c, err, "发送会议聊天失败")
|
ctl.handleError(c, err, "发送会议聊天失败")
|
||||||
return
|
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=
|
// 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, "获取会议聊天失败")
|
ctl.handleError(c, err, "获取会议聊天失败")
|
||||||
return
|
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))
|
list := make([]dto.MeetingChatDTO, 0, len(msgs))
|
||||||
for i := range 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})
|
utils.ResponseOK(c, dto.ListMeetingChatsResponse{List: list, HasMore: hasMore})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,63 @@ func (s *MeetingService) broadcastToActiveParticipants(ctx context.Context, room
|
|||||||
s.broadcaster.BroadcastToMeeting(ctx, roomID, event, data, excludeUserIDs...)
|
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 创建即时会议
|
// CreateRoom 创建即时会议
|
||||||
@@ -773,12 +830,17 @@ func (s *MeetingService) SendChatMessage(ctx context.Context, userID int64, code
|
|||||||
return nil, err
|
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{}{
|
go s.broadcastToActiveParticipants(context.Background(), room.ID, constants.MeetingWSEventChatMessage, map[string]interface{}{
|
||||||
"room_code": code,
|
"room_code": code,
|
||||||
"message_id": chat.ID,
|
"message_id": chat.ID,
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"content": content,
|
"user_name": userName,
|
||||||
"created_at": chat.CreatedAt.Format("2006-01-02 15:04:05"),
|
"user_avatar": userAvatar,
|
||||||
|
"content": content,
|
||||||
|
"created_at": chat.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
}, userID)
|
}, userID)
|
||||||
|
|
||||||
logs.Debug(ctx, funcName, "会议聊天已发送",
|
logs.Debug(ctx, funcName, "会议聊天已发送",
|
||||||
|
|||||||
399
frontend/src/components/meeting/ChatPanel.vue
Normal file
399
frontend/src/components/meeting/ChatPanel.vue
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
<!--
|
||||||
|
会议聊天面板(Task 12)
|
||||||
|
|
||||||
|
职责:
|
||||||
|
- 右侧抽屉式消息流 + 底部输入框,抽屉风格与 MemberPanel 对齐
|
||||||
|
- 自己与他人的消息分别右 / 左对齐;同一人连续消息聚合头像
|
||||||
|
- 顶部"加载更多历史"按钮;首次打开时派发 @request-load 由父层触发一次懒加载
|
||||||
|
|
||||||
|
约束:
|
||||||
|
- 纯受控组件,不直接依赖 Pinia;由父组件把 messages/hasMore 等数据传入
|
||||||
|
- 发送通过 @send 事件回传,成功/失败由父层决定是否 toast
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<view v-if="visible" class="panel-root" @click.self="close">
|
||||||
|
<view class="panel" :class="{ show }">
|
||||||
|
<view class="header">
|
||||||
|
<view class="title-area">
|
||||||
|
<text class="title">会议聊天</text>
|
||||||
|
<text class="count">({{ messages.length }})</text>
|
||||||
|
</view>
|
||||||
|
<view class="btn-close" @click="close">
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view
|
||||||
|
class="msg-list"
|
||||||
|
:scroll-y="true"
|
||||||
|
:scroll-top="scrollTop"
|
||||||
|
:scroll-with-animation="scrollAnimate"
|
||||||
|
@scroll="onScroll"
|
||||||
|
>
|
||||||
|
<view v-if="hasMore" class="load-more" @click="$emit('load-more')">
|
||||||
|
<text v-if="!loadingMore">加载更早的消息</text>
|
||||||
|
<text v-else>加载中…</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="messages.length" class="load-end">
|
||||||
|
<text>没有更早的消息了</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-for="(m, idx) in messages"
|
||||||
|
:key="m.id || `tmp_${idx}`"
|
||||||
|
class="msg-row"
|
||||||
|
:class="{ self: m.user_id === currentUserId, 'merge-top': isMergedWithPrev(idx) }"
|
||||||
|
>
|
||||||
|
<view v-if="!isMergedWithPrev(idx)" class="avatar" :style="avatarStyle(m.user_id)">
|
||||||
|
{{ initialOf(m) }}
|
||||||
|
</view>
|
||||||
|
<view v-else class="avatar-placeholder"></view>
|
||||||
|
|
||||||
|
<view class="bubble-area">
|
||||||
|
<view v-if="!isMergedWithPrev(idx)" class="meta">
|
||||||
|
<text class="meta-name">{{ m.user_id === currentUserId ? '我' : displayName(m) }}</text>
|
||||||
|
<text class="meta-time">{{ formatTime(m.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="bubble" :class="{ self: m.user_id === currentUserId }">
|
||||||
|
<text>{{ m.content }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!messages.length" class="empty">
|
||||||
|
<text>发送第一条消息,开始会议内对话</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view :style="{ height: '4rpx' }" ref="bottomAnchor"></view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view class="input-area">
|
||||||
|
<textarea
|
||||||
|
class="input"
|
||||||
|
:value="draft"
|
||||||
|
placeholder="说点什么…"
|
||||||
|
:maxlength="500"
|
||||||
|
:adjust-position="false"
|
||||||
|
:cursor-spacing="10"
|
||||||
|
@input="onDraftInput"
|
||||||
|
@confirm="onSend"
|
||||||
|
/>
|
||||||
|
<button class="btn-send" :disabled="!canSend || sending" @click="onSend">
|
||||||
|
<text v-if="!sending">发送</text>
|
||||||
|
<text v-else>发送中</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
messages: { type: Array, default: () => [] },
|
||||||
|
hasMore: { type: Boolean, default: true },
|
||||||
|
loadingMore: { type: Boolean, default: false },
|
||||||
|
currentUserId: { type: [Number, String], default: 0 },
|
||||||
|
displayNameMap: { type: Object, default: () => ({}) }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'send', 'load-more', 'request-initial-load'])
|
||||||
|
|
||||||
|
const show = ref(false)
|
||||||
|
const draft = ref('')
|
||||||
|
const sending = ref(false)
|
||||||
|
const scrollTop = ref(0)
|
||||||
|
const scrollAnimate = ref(true)
|
||||||
|
const userScrolledUp = ref(false)
|
||||||
|
const lastScrollTop = ref(0)
|
||||||
|
|
||||||
|
watch(() => props.visible, async (v) => {
|
||||||
|
if (v) {
|
||||||
|
setTimeout(() => { show.value = true }, 10)
|
||||||
|
emit('request-initial-load')
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom(false)
|
||||||
|
} else {
|
||||||
|
show.value = false
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// 新消息到达时,若用户未向上翻阅,自动滚到底
|
||||||
|
watch(() => props.messages.length, async () => {
|
||||||
|
if (!props.visible) return
|
||||||
|
await nextTick()
|
||||||
|
if (!userScrolledUp.value) scrollToBottom(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSend = computed(() => draft.value.trim().length > 0)
|
||||||
|
|
||||||
|
const onDraftInput = (e) => {
|
||||||
|
draft.value = e?.detail?.value ?? e?.target?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSend = async () => {
|
||||||
|
const content = draft.value.trim()
|
||||||
|
if (!content || sending.value) return
|
||||||
|
sending.value = true
|
||||||
|
try {
|
||||||
|
await emit('send', content)
|
||||||
|
draft.value = ''
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom(true)
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = (animated) => {
|
||||||
|
scrollAnimate.value = animated
|
||||||
|
// 不可随便等于 last,不触发;每次加一个微量触发重置 scrollTop
|
||||||
|
const next = scrollTop.value + 1
|
||||||
|
scrollTop.value = 999999
|
||||||
|
setTimeout(() => { if (scrollTop.value === 999999) scrollTop.value = next }, 30)
|
||||||
|
userScrolledUp.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScroll = (e) => {
|
||||||
|
const top = e?.detail?.scrollTop ?? 0
|
||||||
|
// 若用户明显向上拉动(大于 60),则记为"手动上滚",暂停自动滚动
|
||||||
|
if (top < lastScrollTop.value - 40) userScrolledUp.value = true
|
||||||
|
// 拉到底时恢复自动滚动
|
||||||
|
const max = (e?.detail?.scrollHeight || 0) - (e?.detail?.viewportHeight || e?.detail?.wheelDeltaY || 0)
|
||||||
|
if (max > 0 && top >= max - 20) userScrolledUp.value = false
|
||||||
|
lastScrollTop.value = top
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相邻消息同一个发送者且间隔 < 5 分钟 → 合并头像 */
|
||||||
|
const isMergedWithPrev = (idx) => {
|
||||||
|
if (idx === 0) return false
|
||||||
|
const cur = props.messages[idx]
|
||||||
|
const prev = props.messages[idx - 1]
|
||||||
|
if (!cur || !prev) return false
|
||||||
|
if (cur.user_id !== prev.user_id) return false
|
||||||
|
const diff = timestampOf(cur) - timestampOf(prev)
|
||||||
|
return diff >= 0 && diff < 5 * 60 * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestampOf = (m) => {
|
||||||
|
if (!m?.created_at) return 0
|
||||||
|
const t = Date.parse(String(m.created_at).replace(' ', 'T'))
|
||||||
|
return Number.isFinite(t) ? t : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = (m) => {
|
||||||
|
if (m.user_name) return m.user_name
|
||||||
|
if (props.displayNameMap[m.user_id]) return props.displayNameMap[m.user_id]
|
||||||
|
return `用户 ${m.user_id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialOf = (m) => {
|
||||||
|
const n = (m.user_id === props.currentUserId ? '我' : displayName(m)).trim()
|
||||||
|
return n ? n.charAt(0).toUpperCase() : '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarStyle = (uid) => {
|
||||||
|
const seed = String(uid || '0')
|
||||||
|
let h = 0
|
||||||
|
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) % 360
|
||||||
|
return { background: `hsl(${h}, 60%, 48%)` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (iso) => {
|
||||||
|
if (!iso) return ''
|
||||||
|
const t = Date.parse(String(iso).replace(' ', 'T'))
|
||||||
|
if (!Number.isFinite(t)) return ''
|
||||||
|
const d = new Date(t)
|
||||||
|
const pad = (n) => n < 10 ? `0${n}` : String(n)
|
||||||
|
const now = new Date()
|
||||||
|
const sameDay = d.getFullYear() === now.getFullYear()
|
||||||
|
&& d.getMonth() === now.getMonth()
|
||||||
|
&& d.getDate() === now.getDate()
|
||||||
|
if (sameDay) return `${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => emit('close')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.panel-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 205;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
width: 640rpx;
|
||||||
|
max-width: 92vw;
|
||||||
|
height: 100%;
|
||||||
|
background: #1F2937;
|
||||||
|
color: #F3F4F6;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.24s ease;
|
||||||
|
box-shadow: -4rpx 0 24rpx rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.panel.show { transform: translateX(0); }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
border-bottom: 1rpx solid rgba(255, 255, 255, 0.08);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.title-area { display: flex; align-items: baseline; gap: 10rpx; }
|
||||||
|
.title { font-size: 32rpx; font-weight: 600; }
|
||||||
|
.count { color: rgba(255, 255, 255, 0.5); font-size: 24rpx; }
|
||||||
|
.btn-close {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-close:hover { background: rgba(255, 255, 255, 0.08); }
|
||||||
|
|
||||||
|
.msg-list {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16rpx 24rpx 24rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more, .load-end {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
padding: 14rpx 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.load-more:hover { color: rgba(255, 255, 255, 0.8); }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 100rpx 0 80rpx;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 8rpx 0;
|
||||||
|
}
|
||||||
|
.msg-row.merge-top { margin-top: -12rpx; padding-top: 0; }
|
||||||
|
.msg-row.self { flex-direction: row-reverse; }
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 64rpx;
|
||||||
|
height: 64rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
.avatar-placeholder {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 64rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8rpx;
|
||||||
|
max-width: 70%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.msg-row.self .bubble-area { align-items: flex-end; }
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
font-size: 20rpx;
|
||||||
|
}
|
||||||
|
.msg-row.self .meta { flex-direction: row-reverse; }
|
||||||
|
.meta-name { font-weight: 500; color: rgba(255, 255, 255, 0.85); font-size: 22rpx; }
|
||||||
|
.meta-time { font-family: 'SF Mono', 'Menlo', monospace; }
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
padding: 14rpx 20rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: rgba(55, 65, 81, 0.9);
|
||||||
|
color: #F3F4F6;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.bubble.self {
|
||||||
|
background: #3B82F6;
|
||||||
|
color: #FFFFFF;
|
||||||
|
border-bottom-right-radius: 4rpx;
|
||||||
|
}
|
||||||
|
.bubble:not(.self) { border-bottom-left-radius: 4rpx; }
|
||||||
|
|
||||||
|
.input-area {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
|
||||||
|
border-top: 1rpx solid rgba(255, 255, 255, 0.08);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
flex: 1;
|
||||||
|
background: rgba(55, 65, 81, 0.6);
|
||||||
|
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 14rpx;
|
||||||
|
padding: 14rpx 18rpx;
|
||||||
|
color: #F3F4F6;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 64rpx;
|
||||||
|
max-height: 220rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.input:focus {
|
||||||
|
border-color: rgba(59, 130, 246, 0.7);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send {
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 14rpx 28rpx;
|
||||||
|
background: #3B82F6;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 14rpx;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.btn-send:hover { background: #2563EB; }
|
||||||
|
.btn-send[disabled] {
|
||||||
|
background: rgba(59, 130, 246, 0.35);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -92,6 +92,20 @@
|
|||||||
@close="inviteVisible = false"
|
@close="inviteVisible = false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 聊天面板 -->
|
||||||
|
<ChatPanel
|
||||||
|
:visible="chatVisible"
|
||||||
|
:messages="chatMessages"
|
||||||
|
:current-user-id="myUserId"
|
||||||
|
:display-name-map="displayNameMap"
|
||||||
|
:has-more="chatHasMore"
|
||||||
|
:loading-more="chatLoadingMore"
|
||||||
|
@close="closeChat"
|
||||||
|
@send="onChatSend"
|
||||||
|
@load-more="onChatLoadMore"
|
||||||
|
@request-initial-load="onChatRequestInitialLoad"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 离会确认弹窗 -->
|
<!-- 离会确认弹窗 -->
|
||||||
<view v-if="leaveDialogVisible" class="leave-mask" @click.self="leaveDialogVisible = false">
|
<view v-if="leaveDialogVisible" class="leave-mask" @click.self="leaveDialogVisible = false">
|
||||||
<view class="leave-modal">
|
<view class="leave-modal">
|
||||||
@@ -132,17 +146,19 @@ import MeetingToolbar from '@/components/meeting/MeetingToolbar.vue'
|
|||||||
import MemberPanel from '@/components/meeting/MemberPanel.vue'
|
import MemberPanel from '@/components/meeting/MemberPanel.vue'
|
||||||
import InviteDialog from '@/components/meeting/InviteDialog.vue'
|
import InviteDialog from '@/components/meeting/InviteDialog.vue'
|
||||||
import NetworkBadge from '@/components/meeting/NetworkBadge.vue'
|
import NetworkBadge from '@/components/meeting/NetworkBadge.vue'
|
||||||
|
import ChatPanel from '@/components/meeting/ChatPanel.vue'
|
||||||
|
|
||||||
const meetingStore = useMeetingStore()
|
const meetingStore = useMeetingStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const memberVisible = ref(false)
|
const memberVisible = ref(false)
|
||||||
const inviteVisible = ref(false)
|
const inviteVisible = ref(false)
|
||||||
|
const chatVisible = ref(false)
|
||||||
const leaveDialogVisible = ref(false)
|
const leaveDialogVisible = ref(false)
|
||||||
const audioLoading = ref(false)
|
const audioLoading = ref(false)
|
||||||
const videoLoading = ref(false)
|
const videoLoading = ref(false)
|
||||||
|
const chatLoadingMore = ref(false)
|
||||||
const networkLevel = ref(4) // Task 15 接入真实 getStats 后动态更新
|
const networkLevel = ref(4) // Task 15 接入真实 getStats 后动态更新
|
||||||
const unreadChatCount = ref(0) // Task 14 接入 chat 后动态更新
|
|
||||||
const startedAt = ref(0)
|
const startedAt = ref(0)
|
||||||
const nowTs = ref(Date.now())
|
const nowTs = ref(Date.now())
|
||||||
let timerHandle = null
|
let timerHandle = null
|
||||||
@@ -181,6 +197,9 @@ const allowChat = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const invitePassword = computed(() => meetingStore.draftCreatePayload?.password || '')
|
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 formattedCode = computed(() => {
|
||||||
const d = String(roomCode.value || '').replace(/\D/g, '')
|
const d = String(roomCode.value || '').replace(/\D/g, '')
|
||||||
@@ -358,8 +377,41 @@ const onCamToggle = async () => {
|
|||||||
const openInvite = () => { inviteVisible.value = true }
|
const openInvite = () => { inviteVisible.value = true }
|
||||||
const openMembers = () => { memberVisible.value = true }
|
const openMembers = () => { memberVisible.value = true }
|
||||||
const openChat = () => {
|
const openChat = () => {
|
||||||
// Task 14 会议聊天入口;当前阶段仅提示
|
chatVisible.value = true
|
||||||
uni.showToast({ title: '聊天功能即将上线', icon: 'none' })
|
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) => {
|
const onKickMember = async (uid) => {
|
||||||
|
|||||||
@@ -79,9 +79,18 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
/** 活跃参与者列表(含当前用户,按加入时间正序) */
|
/** 活跃参与者列表(含当前用户,按加入时间正序) */
|
||||||
const participants = ref([])
|
const participants = ref([])
|
||||||
|
|
||||||
/** 会议聊天消息列表(最新在尾部) */
|
/** 会议聊天消息列表(按 ID 升序,最新在尾部) */
|
||||||
const chatMessages = ref([])
|
const chatMessages = ref([])
|
||||||
|
|
||||||
|
/** 会议聊天还有更早消息可加载(用于"加载更多"按钮) */
|
||||||
|
const chatHasMore = ref(true)
|
||||||
|
|
||||||
|
/** 会议聊天未读计数(来自他人且 ChatPanel 未打开期间累积) */
|
||||||
|
const chatUnreadCount = ref(0)
|
||||||
|
|
||||||
|
/** 会议聊天首次懒加载完成标记 */
|
||||||
|
const chatHistoryLoaded = ref(false)
|
||||||
|
|
||||||
/** 最近一次会议结束原因(room.ended_reason 或 room.ended.reason) */
|
/** 最近一次会议结束原因(room.ended_reason 或 room.ended.reason) */
|
||||||
const lastEndedReason = ref('')
|
const lastEndedReason = ref('')
|
||||||
|
|
||||||
@@ -180,6 +189,9 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
routerID.value = ''
|
routerID.value = ''
|
||||||
participants.value = []
|
participants.value = []
|
||||||
chatMessages.value = []
|
chatMessages.value = []
|
||||||
|
chatHasMore.value = true
|
||||||
|
chatUnreadCount.value = 0
|
||||||
|
chatHistoryLoaded.value = false
|
||||||
localAudioEnabled.value = false
|
localAudioEnabled.value = false
|
||||||
localVideoEnabled.value = false
|
localVideoEnabled.value = false
|
||||||
localProducers.audio = null
|
localProducers.audio = null
|
||||||
@@ -344,7 +356,22 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
const _onChatMessage = (msg) => {
|
const _onChatMessage = (msg) => {
|
||||||
const data = msg.data || {}
|
const data = msg.data || {}
|
||||||
if (!currentRoom.value || data.room_code !== currentRoom.value.room_code) return
|
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:聊天/管理 ====================
|
// ==================== Actions:聊天/管理 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送会议聊天
|
||||||
|
* 后端广播 meeting.chat.message 给房间内非发送者,发送方依赖 REST 响应里的 message 上屏;
|
||||||
|
* 若后端未把 message 字段回传,则退化为等待广播(不上屏)
|
||||||
|
*/
|
||||||
const sendChat = async (content) => {
|
const sendChat = async (content) => {
|
||||||
if (!currentRoom.value) throw new Error('未入会')
|
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 }
|
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) => {
|
const transferHost = async (targetUserID) => {
|
||||||
if (!currentRoom.value) throw new Error('未入会')
|
if (!currentRoom.value) throw new Error('未入会')
|
||||||
await meetingApi.transferHost(currentRoom.value.room_code, targetUserID)
|
await meetingApi.transferHost(currentRoom.value.room_code, targetUserID)
|
||||||
@@ -720,6 +778,9 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
routerID,
|
routerID,
|
||||||
participants,
|
participants,
|
||||||
chatMessages,
|
chatMessages,
|
||||||
|
chatHasMore,
|
||||||
|
chatUnreadCount,
|
||||||
|
chatHistoryLoaded,
|
||||||
lastEndedReason,
|
lastEndedReason,
|
||||||
lastAutoHostReason,
|
lastAutoHostReason,
|
||||||
localAudioEnabled,
|
localAudioEnabled,
|
||||||
@@ -751,7 +812,9 @@ export const useMeetingStore = defineStore('meeting', () => {
|
|||||||
|
|
||||||
// chat & admin
|
// chat & admin
|
||||||
sendChat,
|
sendChat,
|
||||||
|
loadMoreChats,
|
||||||
loadChatHistory,
|
loadChatHistory,
|
||||||
|
markChatsRead,
|
||||||
transferHost,
|
transferHost,
|
||||||
kickMember,
|
kickMember,
|
||||||
inviteUsers,
|
inviteUsers,
|
||||||
|
|||||||
Reference in New Issue
Block a user