Преглед изворни кода

feat:会话存档展示内容

caoliqin пре 20 часа
родитељ
комит
5fbc719430
3 измењених фајлова са 967 додато и 1 уклоњено
  1. 63 0
      src/api/qw/qwMsgAuditMessage.js
  2. 8 1
      src/api/qw/qwUser.js
  3. 896 0
      src/views/qw/qwMsgAuditMessage/index.vue

+ 63 - 0
src/api/qw/qwMsgAuditMessage.js

@@ -0,0 +1,63 @@
+import request from '@/utils/request'
+
+
+// 查询会话列表
+export function listQwMsgAuditConversation(query) {
+  return request({
+    url: '/qw/qwMsgAuditMessage/conversationList',
+    method: 'get',
+    params: query
+  })
+}
+
+// 会话下消息分页
+export function listQwMsgAuditMessage(query) {
+  return request({
+    url: '/qw/qwMsgAuditMessage/list',
+    method: 'get',
+    params: query
+  })
+}
+
+// 查询企微会话存档结构化消息详细
+export function getQwMsgAuditMessage(id) {
+  return request({
+    url: '/qw/qwMsgAuditMessage/' + id,
+    method: 'get'
+  })
+}
+
+// 新增企微会话存档结构化消息
+export function addQwMsgAuditMessage(data) {
+  return request({
+    url: '/qw/qwMsgAuditMessage',
+    method: 'post',
+    data: data
+  })
+}
+
+// 修改企微会话存档结构化消息
+export function updateQwMsgAuditMessage(data) {
+  return request({
+    url: '/qw/qwMsgAuditMessage',
+    method: 'put',
+    data: data
+  })
+}
+
+// 删除企微会话存档结构化消息
+export function delQwMsgAuditMessage(id) {
+  return request({
+    url: '/qw/qwMsgAuditMessage/' + id,
+    method: 'delete'
+  })
+}
+
+// 导出企微会话存档结构化消息
+export function exportQwMsgAuditMessage(query) {
+  return request({
+    url: '/qw/qwMsgAuditMessage/export',
+    method: 'get',
+    params: query
+  })
+}

+ 8 - 1
src/api/qw/qwUser.js

@@ -24,4 +24,11 @@ export function getUserList(params) {
     params: params
   })
 }
-
+// 分页查询企微员工列表
+export function qwList(params) {
+    return request({
+        url: '/qw/user/qwList',
+        method: 'get',
+        params: params
+    })
+}

+ 896 - 0
src/views/qw/qwMsgAuditMessage/index.vue

@@ -0,0 +1,896 @@
+<template>
+  <div class="app-container qw-msg-audit-page">
+    <div class="audit-shell">
+      <!-- 左:主体 + 员工 -->
+      <aside class="audit-left">
+        <div class="aside-block aside-corp">
+          <div class="block-label">企微主体</div>
+          <el-select
+            v-model="corpId"
+            filterable
+            clearable
+            placeholder="请选择企微主体"
+            size="small"
+            class="corp-select"
+            :loading="corpLoading"
+            @change="handleCorpChange"
+          >
+            <el-option
+              v-for="item in corpList"
+              :key="item.dictValue"
+              :label="item.dictLabel"
+              :value="item.dictValue"
+            />
+          </el-select>
+        </div>
+        <div v-loading="userLoading" class="aside-block aside-users">
+          <div class="block-label">员工</div>
+          <el-input
+            v-model="userFilter"
+            size="small"
+            clearable
+            placeholder="筛选当前页"
+            prefix-icon="el-icon-search"
+            class="user-search"
+          />
+          <el-scrollbar class="user-scroll">
+            <div
+              v-for="u in filteredUsers"
+              :key="userRowKey(u)"
+              class="user-item"
+              :class="{ active: selectedUser && selectedUser.qwUserId === u.qwUserId }"
+              @click="selectUser(u)"
+            >
+              <span class="user-name">{{ displayUserName(u) }}</span>
+              <span class="user-id text-muted">{{ u.qwUserId }}</span>
+            </div>
+            <el-empty
+              v-if="!userLoading && filteredUsers.length === 0"
+              :image-size="72"
+              description="暂无员工"
+            />
+          </el-scrollbar>
+          <pagination
+            v-show="userTotal > 0"
+            small
+            class="user-pagination"
+            layout="prev, pager, next"
+            :total="userTotal"
+            :page.sync="userQuery.pageNum"
+            :limit.sync="userQuery.pageSize"
+            @pagination="loadUserList"
+          />
+        </div>
+      </aside>
+
+      <!-- 中:会话列表 -->
+      <aside class="audit-conv">
+        <div class="conv-head">
+          <span class="conv-head-title">会话</span>
+          <el-radio-group
+            v-model="chatScope"
+            size="mini"
+            :disabled="!selectedUser"
+            @change="handleChatScopeChange"
+          >
+            <el-radio-button label="single">单聊</el-radio-button>
+            <el-radio-button label="group">群聊</el-radio-button>
+          </el-radio-group>
+        </div>
+
+        <div v-loading="convLoading" class="conv-body">
+          <el-scrollbar class="conv-scroll">
+            <div
+              v-for="(c, idx) in conversationList"
+              :key="convKey(c, idx)"
+              class="conv-item"
+              :class="{ active: isSelectedConv(c) }"
+              @click="selectConversation(c)"
+            >
+              <div class="conv-item-main">
+                <el-avatar :src="convAvatar(c)" :size="40" shape="square" class="conv-avatar" />
+                <div class="conv-text">
+                  <div class="conv-title">{{ convTitle(c) }}</div>
+                </div>
+              </div>
+            </div>
+            <el-empty
+              v-if="!convLoading && selectedUser && conversationList.length === 0"
+              :image-size="56"
+              description="暂无会话"
+            />
+            <div v-if="!selectedUser" class="conv-placeholder text-muted">请先选择左边的员工</div>
+          </el-scrollbar>
+          <pagination
+            v-show="conversationTotal > 0"
+            small
+            class="conv-pagination"
+            layout="prev, pager, next"
+            :total="conversationTotal"
+            :page.sync="conversationQuery.pageNum"
+            :limit.sync="conversationQuery.pageSize"
+            @pagination="loadConversationList"
+          />
+        </div>
+      </aside>
+
+      <!-- 右:聊天记录 -->
+      <section class="audit-chat">
+        <div class="chat-toolbar">
+          <div class="toolbar-title">
+            <template v-if="selectedUser && selectedConversation">
+              <el-avatar :src="convAvatar(selectedConversation)" :size="36" shape="square" class="toolbar-avatar" />
+              <span class="toolbar-name">{{ convTitle(selectedConversation) }}</span>
+              <span class="text-muted toolbar-sub">· {{ displayUserName(selectedUser) }}</span>
+            </template>
+            <span v-else-if="selectedUser" class="text-muted">请选择会话</span>
+            <span v-else class="text-muted">请先选择员工和会话</span>
+          </div>
+        </div>
+
+        <div v-loading="msgLoading" class="chat-panel">
+          <template v-if="selectedUser && selectedConversation">
+            <el-scrollbar ref="msgScroll" class="msg-scroll">
+              <div class="msg-list">
+                <div v-if="msgLoadingMore" class="msg-load-tip">加载更早的消息…</div>
+                <div
+                  v-for="msg in messages"
+                  :key="msg.id || msg.msgId"
+                  class="msg-row"
+                  :class="{ 'is-self': isSelfMessage(msg) }"
+                >
+                  <div class="msg-meta">
+                    <span class="from">{{ msg.fromUser }}</span>
+                    <span class="time">{{ formatMsgTime(msg.msgTime) }}</span>
+                    <span v-if="chatScope === 'group' && msg.roomId" class="room-tag">群 {{ shortRoom(msg.roomId) }}</span>
+                  </div>
+                  <div class="bubble">
+                    <div v-if="msg.textContent" class="text">{{ msg.textContent }}</div>
+                    <div v-else-if="msg.mediaOssUrl" class="media">
+                      <a :href="msg.mediaOssUrl" target="_blank" rel="noopener">查看媒体</a>
+                      <span v-if="msg.mediaFileName" class="file-name">{{ msg.mediaFileName }}</span>
+                    </div>
+                    <div v-else class="fallback text-muted">
+                      [{{ msgTypeLabel(msg.msgType) }}]
+                      <template v-if="msg.mediaFileName">{{ msg.mediaFileName }}</template>
+                    </div>
+                  </div>
+<!--                  <div class="msg-extra text-muted">-->
+<!--                    <span v-if="msg.toList">to: {{ formatToList(msg.toList) }}</span>-->
+<!--                    <span v-if="msg.fromUserRole != null && msg.fromUserRole !== ''"> · 角色 {{ msg.fromUserRole }}</span>-->
+<!--                  </div>-->
+                </div>
+              </div>
+              <el-empty v-if="!msgLoading && messages.length === 0" description="暂无消息" />
+            </el-scrollbar>
+          </template>
+          <el-empty v-else-if="!selectedUser" description="在左侧选择员工和会话" />
+          <el-empty v-else description="选择会话后查看消息记录" />
+        </div>
+      </section>
+    </div>
+  </div>
+</template>
+
+<script>
+import { getMyQwCompanyList } from '@/api/qw/user'
+import { qwList } from '@/api/qw/qwUser'
+import { listQwMsgAuditConversation, listQwMsgAuditMessage } from '@/api/qw/qwMsgAuditMessage'
+
+export default {
+  name: 'QwMsgAuditMessage',
+  data() {
+    return {
+      corpId: null,
+      corpList: [],
+      corpLoading: false,
+      userList: [],
+      userTotal: 0,
+      userQuery: {
+        pageNum: 1,
+        pageSize: 20
+      },
+      userLoading: false,
+      userFilter: '',
+      selectedUser: null,
+      chatScope: 'single',
+      conversationList: [],
+      conversationTotal: 0,
+      conversationQuery: {
+        pageNum: 1,
+        pageSize: 15
+      },
+      convLoading: false,
+      selectedConversation: null,
+      messages: [],
+      msgPageSize: 15,
+      msgPageNum: 1,
+      msgTotal: 0,
+      msgHasMore: false,
+      msgLoadingMore: false,
+      msgLoading: false,
+      _msgScrollWrap: null,
+      _onMsgScroll: null
+    }
+  },
+  computed: {
+    filteredUsers() {
+      const q = (this.userFilter || '').toLowerCase()
+      if (!q) return this.userList
+      return this.userList.filter((u) => {
+        const name = (u.qwUserName || u.nickName || '').toLowerCase()
+        const id = u.qwUserId != null ? String(u.qwUserId).toLowerCase() : ''
+        return name.includes(q) || id.includes(q)
+      })
+    }
+  },
+  created() {
+    this.loadCorpList()
+  },
+  // 离开页面时去掉滚动监听,避免内存泄漏
+  beforeDestroy() {
+    this.unbindMsgScroll()
+  },
+  methods: {
+    // 气泡里显示时间
+    formatMsgTime(t) {
+      if (!t) return ''
+      return this.parseTime(t)
+    },
+    // 群聊里 roomId 太长时缩略展示
+    shortRoom(roomId) {
+      if (!roomId) return ''
+      const s = String(roomId)
+      return s.length > 12 ? s.slice(0, 10) + '…' : s
+    },
+
+    // toList 可能是对象,转成可读字符串
+    formatToList(toList) {
+      if (toList == null) return ''
+      if (typeof toList === 'string') return toList
+      try {
+        return JSON.stringify(toList)
+      } catch (e) {
+        return String(toList)
+      }
+    },
+    msgTypeLabel(type) {
+      if (type == null || type === '') return '未知类型'
+      return String(type)
+    },
+    // 是否当前员工自己发的(用于绿气泡靠右)
+    isSelfMessage(msg) {
+      if (!this.selectedUser || !msg.fromUser) return false
+      return String(msg.fromUser) === String(this.selectedUser.qwUserId)
+    },
+    displayUserName(u) {
+      if (!u) return ''
+      return u.qwUserName || ''
+    },
+    userRowKey(u) {
+      return u.qwUserId
+    },
+    convKey(c, idx) {
+      if (c && c.conversationKey != null) return String(c.conversationKey)
+      return 'conv-' + idx
+    },
+    convTitle(c) {
+      if (!c) return ''
+      if (c.name) return c.name
+      if (this.chatScope === 'group') {
+        return c.roomName || '群聊'
+      }
+      return c.title || '—'
+    },
+    // 接口头像链接字段 Avatar(兼容 avatar)
+    convAvatar(c) {
+      if (!c) return ''
+      return c.Avatar || c.avatar || ''
+    },
+    isSelectedConv(c) {
+      if (!c || !this.selectedConversation) return false
+      const a = c.conversationKey
+      const b = this.selectedConversation.conversationKey
+      if (a == null || b == null) return false
+      return String(a) === String(b)
+    },
+
+    // full=true:换主体、员工被踢出当前页等,清空中间会话列表 + 右侧消息;full=false:只清空右侧消息与分页状态
+    resetSession(full) {
+      if (full) {
+        this.selectedConversation = null
+        this.conversationList = []
+        this.conversationTotal = 0
+        this.conversationQuery.pageNum = 1
+      }
+      this.messages = []
+      this.msgPageNum = 1
+      this.msgTotal = 0
+      this.msgHasMore = false
+    },
+
+    // 拉主体下拉,默认选中第一项并顺带请求员工列表
+    async loadCorpList() {
+      this.corpLoading = true
+      try {
+        const res = await getMyQwCompanyList()
+        this.corpList = res.data || []
+        if (this.corpList.length > 0) {
+          this.corpId = this.corpList[0].dictValue
+          await this.handleCorpChange()
+        } else {
+          this.corpId = null
+        }
+      } catch (e) {
+        this.corpList = []
+        this.corpId = null
+        this.$message.warning('获取企微主体失败')
+      } finally {
+        this.corpLoading = false
+      }
+    },
+
+    // 左侧员工分页(依赖当前 corpId)
+    async loadUserList() {
+      if (!this.corpId) {
+        this.userList = []
+        this.userTotal = 0
+        return
+      }
+      this.userLoading = true
+      try {
+        const res = await qwList({
+          corpId: this.corpId,
+          pageNum: this.userQuery.pageNum,
+          pageSize: this.userQuery.pageSize
+        })
+        this.userList = res.rows || []
+        this.userTotal = res.total || 0
+        if (
+          this.selectedUser &&
+          !this.userList.some((u) => String(u.qwUserId) === String(this.selectedUser.qwUserId))
+        ) {
+          this.selectedUser = null
+          this.resetSession(true)
+        }
+      } catch (e) {
+        this.userList = []
+        this.userTotal = 0
+        this.$message.warning('获取员工列表失败')
+      } finally {
+        this.userLoading = false
+      }
+    },
+
+    // 换主体:清空选人、会话、消息,再拉员工
+    async handleCorpChange() {
+      this.selectedUser = null
+      this.resetSession(true)
+      this.unbindMsgScroll()
+      this.userQuery.pageNum = 1
+      if (!this.corpId) {
+        this.userList = []
+        this.userTotal = 0
+        return
+      }
+      await this.loadUserList()
+    },
+
+    // 点某个员工:拉中间会话列表
+    async selectUser(u) {
+      this.selectedUser = u
+      this.selectedConversation = null
+      this.resetSession(false)
+      this.conversationQuery.pageNum = 1
+      this.unbindMsgScroll()
+      await this.loadConversationList()
+    },
+
+    // 单聊 / 群聊切换:重新拉会话列表,右侧清空
+    async handleChatScopeChange() {
+      if (!this.selectedUser) return
+      this.selectedConversation = null
+      this.resetSession(false)
+      this.conversationQuery.pageNum = 1
+      this.unbindMsgScroll()
+      await this.loadConversationList()
+    },
+
+    // 中间栏会话分页
+    async loadConversationList() {
+      if (!this.selectedUser || !this.corpId) {
+        this.conversationList = []
+        this.conversationTotal = 0
+        return
+      }
+      this.convLoading = true
+      try {
+        const res = await listQwMsgAuditConversation({
+          corpId: this.corpId,
+          qwUserId: this.selectedUser.qwUserId,
+          chatScope: this.chatScope,
+          pageNum: this.conversationQuery.pageNum,
+          pageSize: this.conversationQuery.pageSize
+        })
+        const rows = res.rows || res.data
+        this.conversationList = Array.isArray(rows) ? rows : []
+        this.conversationTotal = res.total != null ? res.total : 0
+        if (this.selectedConversation && !this.conversationList.some((c) => this.isSelectedConv(c))) {
+          this.selectedConversation = null
+          this.resetSession(false)
+        }
+      } catch (e) {
+        this.conversationList = []
+        this.conversationTotal = 0
+        this.$message.warning('获取会话列表失败')
+      } finally {
+        this.convLoading = false
+      }
+    },
+
+    async selectConversation(c) {
+      if (!c || c.conversationKey == null || c.conversationKey === '') {
+        this.$message.warning('会话参数有误,无法加载消息')
+        return
+      }
+      this.selectedConversation = c
+      this.resetSession(false)
+      this.unbindMsgScroll()
+      this.msgLoading = true
+      try {
+        await this.fetchMessagePage(1, true)
+        this.$nextTick(() => {
+          this.scrollMsgBottom()
+          this.bindMsgScroll()
+        })
+      } finally {
+        this.msgLoading = false
+      }
+    },
+
+    // 消息列表按时间降序时,reverse 后展示为「上旧下新」
+    async fetchMessagePage(pageNum, replace) {
+      const key = this.selectedConversation && this.selectedConversation.conversationKey
+      if (key == null || key === '') {
+        return
+      }
+      const res = await listQwMsgAuditMessage({
+        conversationKey: key,
+        pageNum,
+        pageSize: this.msgPageSize
+      })
+      const raw = res.rows || res.data
+      const rows = Array.isArray(raw) ? raw : []
+      const asc = rows.slice().reverse()
+      const total = res.total != null ? Number(res.total) : null
+
+      if (replace) {
+        this.messages = asc
+        this.msgPageNum = pageNum
+        this.msgTotal = total != null ? total : 0
+      } else {
+        this.messages = [...asc, ...this.messages]
+        this.msgPageNum = pageNum
+      }
+      if (total != null) {
+        this.msgHasMore = this.messages.length < total
+      } else {
+        this.msgHasMore = rows.length >= this.msgPageSize
+      }
+    },
+
+    async loadOlderMessages() {
+      if (!this.selectedConversation || !this.msgHasMore) return
+      const key = this.selectedConversation.conversationKey
+      if (key == null || key === '') return
+      if (this.msgLoading || this.msgLoadingMore) return
+
+      const nextPage = this.msgPageNum + 1
+      const scrollbar = this.$refs.msgScroll
+      const wrap = scrollbar && scrollbar.wrap
+      const oldScrollHeight = wrap ? wrap.scrollHeight : 0
+      const oldScrollTop = wrap ? wrap.scrollTop : 0
+
+      this.msgLoadingMore = true
+      try {
+        await this.fetchMessagePage(nextPage, false)
+      } finally {
+        this.msgLoadingMore = false
+        this.$nextTick(() => {
+          if (wrap) {
+            const delta = wrap.scrollHeight - oldScrollHeight
+            wrap.scrollTop = oldScrollTop + delta
+          }
+        })
+      }
+    },
+
+    bindMsgScroll() {
+      this.unbindMsgScroll()
+      this.$nextTick(() => {
+        const sb = this.$refs.msgScroll
+        if (!sb || !sb.wrap) return
+        this._msgScrollWrap = sb.wrap
+        // 距顶部很近时尝试加载更早消息
+        this._onMsgScroll = () => {
+          const wrap = this._msgScrollWrap
+          if (!wrap || wrap.scrollTop > 100) return
+          this.loadOlderMessages()
+        }
+        sb.wrap.addEventListener('scroll', this._onMsgScroll, { passive: true })
+      })
+    },
+
+      // 移除消息滚动容器上的scroll事件监听
+    unbindMsgScroll() {
+      if (this._msgScrollWrap && this._onMsgScroll) {
+        this._msgScrollWrap.removeEventListener('scroll', this._onMsgScroll)
+      }
+      this._msgScrollWrap = null
+      this._onMsgScroll = null
+    },
+
+    scrollMsgBottom() {
+      const ref = this.$refs.msgScroll
+      if (ref && ref.wrap) {
+        ref.wrap.scrollTop = ref.wrap.scrollHeight
+      }
+    }
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.qw-msg-audit-page {
+  // 整页占满主内容区高度,避免外层再出一条纵向滚动条;只让内部列表滚动
+  height: calc(100vh - 120px);
+  max-height: calc(100vh - 120px);
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  box-sizing: border-box;
+
+  .audit-shell {
+    flex: 1;
+    min-height: 0;
+    display: flex;
+    border: 1px solid #ebeef5;
+    border-radius: 4px;
+    background: #fff;
+    overflow: hidden;
+  }
+
+  .audit-left {
+    width: 260px;
+    flex-shrink: 0;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+    border-right: 1px solid #ebeef5;
+    background: #fafafa;
+  }
+
+  .audit-conv {
+    width: 220px;
+    flex-shrink: 0;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+    border-right: 1px solid #ebeef5;
+    background: #fff;
+  }
+
+  .conv-head {
+    flex-shrink: 0;
+    padding: 10px 8px;
+    border-bottom: 1px solid #ebeef5;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+
+    .conv-head-title {
+      font-size: 12px;
+      color: #909399;
+    }
+
+    ::v-deep .el-radio-group {
+      display: flex;
+      width: 100%;
+    }
+
+    ::v-deep .el-radio-button {
+      flex: 1;
+
+      .el-radio-button__inner {
+        width: 100%;
+        padding: 6px 8px;
+      }
+    }
+  }
+
+  .conv-body {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+  }
+
+  .conv-scroll {
+    flex: 1;
+    min-height: 160px;
+  }
+
+  .conv-placeholder {
+    padding: 24px 12px;
+    font-size: 12px;
+    text-align: center;
+  }
+
+  .conv-item {
+    padding: 8px 10px;
+    margin: 0 6px 6px;
+    border-radius: 6px;
+    cursor: pointer;
+    border: 1px solid transparent;
+    transition: background 0.15s;
+
+    &:hover {
+      background: #f5f7fa;
+    }
+
+    &.active {
+      border-color: #c2e7b0;
+      background: #f0f9eb;
+    }
+
+    .conv-item-main {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+    }
+
+    .conv-avatar {
+      flex-shrink: 0;
+    }
+
+    .conv-text {
+      flex: 1;
+      min-width: 0;
+    }
+
+    .conv-title {
+      font-size: 13px;
+      color: #303133;
+      line-height: 1.4;
+      word-break: break-all;
+    }
+  }
+
+  .conv-pagination {
+    flex-shrink: 0;
+    padding: 4px;
+
+    ::v-deep .pagination-container {
+      margin: 0;
+      padding: 0;
+    }
+  }
+
+  .aside-block {
+    padding: 12px;
+  }
+
+  .aside-corp {
+    flex-shrink: 0;
+    border-bottom: 1px solid #ebeef5;
+  }
+
+  .aside-users {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+  }
+
+  .block-label {
+    font-size: 12px;
+    color: #909399;
+    margin-bottom: 8px;
+  }
+
+  .corp-select {
+    width: 100%;
+  }
+
+  .user-search {
+    margin-bottom: 8px;
+  }
+
+  .user-pagination {
+    margin-top: 8px;
+    padding: 0 4px;
+
+    ::v-deep .pagination-container {
+      margin: 0;
+      padding: 0;
+    }
+  }
+
+  .user-scroll {
+    flex: 1;
+    min-height: 200px;
+  }
+
+  .user-item {
+    padding: 8px 10px;
+    margin-bottom: 4px;
+    border-radius: 4px;
+    cursor: pointer;
+    background: #fff;
+    border: 1px solid transparent;
+    transition: background 0.15s;
+
+    &:hover {
+      background: #f0f9eb;
+    }
+
+    &.active {
+      border-color: #67c23a;
+      background: #f0f9eb;
+    }
+
+    .user-name {
+      display: block;
+      font-size: 14px;
+      color: #303133;
+    }
+
+    .user-id {
+      font-size: 12px;
+    }
+  }
+
+  .text-muted {
+    color: #909399;
+    font-size: 12px;
+  }
+
+  .audit-chat {
+    flex: 1;
+    min-width: 0;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+  }
+
+  .chat-toolbar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 10px 16px;
+    border-bottom: 1px solid #ebeef5;
+    flex-shrink: 0;
+    gap: 12px;
+    flex-wrap: wrap;
+  }
+
+  .toolbar-title {
+    font-size: 14px;
+    font-weight: 500;
+    min-width: 0;
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    flex-wrap: wrap;
+  }
+
+  .toolbar-avatar {
+    flex-shrink: 0;
+  }
+
+  .toolbar-name {
+    font-weight: 500;
+  }
+
+  .toolbar-sub {
+    font-weight: normal;
+    font-size: 13px;
+  }
+
+  .chat-panel {
+    flex: 1;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+    overflow: hidden;
+    background: #ededed;
+    position: relative;
+  }
+
+  // flex 子项里 el-scrollbar 用 height:0 + flex:1 才能拿到确定高度,只在此处滚动
+  .msg-scroll {
+    flex: 1;
+    min-height: 0;
+    height: 0;
+
+    ::v-deep .el-scrollbar__wrap {
+      overflow-x: hidden;
+    }
+  }
+
+  .msg-load-tip {
+    text-align: center;
+    font-size: 12px;
+    color: #909399;
+    padding: 8px 0 4px;
+  }
+
+  .msg-list {
+    padding: 16px;
+    max-width: 880px;
+    margin: 0 auto;
+  }
+
+  .msg-row {
+    margin-bottom: 14px;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-start;
+
+    &.is-self {
+      align-items: flex-end;
+
+      .bubble {
+        background: #95ec69;
+        color: #000;
+      }
+    }
+  }
+
+  .msg-meta {
+    font-size: 12px;
+    color: #909399;
+    margin-bottom: 4px;
+
+    .from {
+      margin-right: 8px;
+    }
+
+    .room-tag {
+      margin-left: 8px;
+      color: #67c23a;
+    }
+  }
+
+  .bubble {
+    max-width: 75%;
+    padding: 8px 12px;
+    border-radius: 6px;
+    background: #fff;
+    word-break: break-word;
+    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
+
+    .text {
+      line-height: 1.5;
+      font-size: 14px;
+    }
+
+    .media {
+      font-size: 13px;
+
+      .file-name {
+        display: block;
+        margin-top: 4px;
+        color: #606266;
+      }
+    }
+  }
+
+  .msg-extra {
+    margin-top: 4px;
+    font-size: 11px;
+    max-width: 75%;
+  }
+}
+</style>