Bladeren bron

feat: 实现聊天窗智能滚动

zhouyuhao 7 maanden geleden
bovenliggende
commit
82028193c3

+ 1 - 0
package.json

@@ -30,6 +30,7 @@
     "echarts": "^5.5.1",
     "element-plus": "^2.8.1",
     "exceljs": "^4.4.0",
+    "github-markdown-css": "^5.8.1",
     "leaflet": "^1.9.4",
     "lodash": "^4.17.21",
     "markdown-it": "^14.1.0",

+ 0 - 1
src/components/NotificationMessageCard/src/NotificationMessageCard.vue

@@ -191,7 +191,6 @@ const parentHeight = computed(() => {
   return (window.innerHeight || document.documentElement.clientHeight) - props.topOffset
 })
 const scrollParentBoxStyle = computed(() => {
-  console.log(props.topOffset, 'value')
   return props.topOffset ? { height: `${parentHeight.value}px` } : {}
 })
 </script>

+ 65 - 0
src/styles/reset.scss

@@ -134,3 +134,68 @@ div {
     margin: 0;
   }
 }
+
+.markdown-body {
+  line-height: 1.6;
+  font-size: 16px;
+  color: #333;
+}
+
+.markdown-body h1,
+.markdown-body h2,
+.markdown-body h3,
+.markdown-body h4,
+.markdown-body h5,
+.markdown-body h6 {
+  margin: 1em 0 0.5em;
+  font-weight: bold;
+}
+
+.markdown-body p {
+  margin: 1em 0;
+}
+
+.markdown-body ul,
+.markdown-body ol {
+  margin-left: 1.5em;
+  padding-left: 1em;
+}
+
+.markdown-body ul {
+  list-style-type: disc;
+}
+
+.markdown-body ol {
+  list-style-type: decimal;
+}
+
+.markdown-body li {
+  margin: 0.5em 0;
+}
+
+.markdown-body blockquote {
+  padding-left: 1em;
+  border-left: 4px solid #ddd;
+  color: #555;
+  margin: 1em 0;
+}
+
+.markdown-body a {
+  color: #0366d6;
+  text-decoration: underline;
+}
+
+.markdown-body code {
+  background: #f5f5f5;
+  padding: 0.2em 0.4em;
+  border-radius: 4px;
+  font-family: monospace;
+  font-size: 0.9em;
+}
+
+.markdown-body pre code {
+  display: block;
+  padding: 1em;
+  background: #f6f8fa;
+  overflow-x: auto;
+}

+ 15 - 5
src/styles/theme.scss

@@ -285,17 +285,26 @@
   --color-dialogue-bg: #fff;
   --color-dialogue_container-border: #fff;
   --color-carousel-card-bg: #fff;
-  --color-dialogue-icon-bg: radial-gradient(50% 43% at 50% 54.79%, #D5B4F3 0%, #FFF9FC 100%);
-  --color-dialogue-text-bg: linear-gradient(92deg, #EAECFF 1.33%, #F1E3FB 99.63%);
-  --color-dialogue_title: linear-gradient(90deg, #A71549 1.77%, #06256E 46.77%);
-  --color-dialogue_container-bg:rgba(255, 255, 255, 0.50);
-  --color-dialogue_content-bg:linear-gradient(117deg, var(--1-gradient-ai-robot-0, #B5FEF3) 4.31%, var(--1-gradient-ai-robot-15, #F9EEEC) 14.24%, var(--1-gradient-ai-robot-38, #FBD3EE) 29.71%, var(--1-gradient-ai-robot-59, #DDD5F7) 43.72%, var(--1-gradient-ai-robot-83, #C8F4F3) 59.35%, var(--1-gradient-ai-robot-100, #CADFF8) 70.56%);
+  --color-dialogue-icon-bg: radial-gradient(50% 43% at 50% 54.79%, #d5b4f3 0%, #fff9fc 100%);
+  --color-dialogue-text-bg: linear-gradient(92deg, #eaecff 1.33%, #f1e3fb 99.63%);
+  --color-dialogue_title: linear-gradient(90deg, #a71549 1.77%, #06256e 46.77%);
+  --color-dialogue_container-bg: rgba(255, 255, 255, 0.5);
+  --color-dialogue_content-bg: linear-gradient(
+    117deg,
+    var(--1-gradient-ai-robot-0, #b5fef3) 4.31%,
+    var(--1-gradient-ai-robot-15, #f9eeec) 14.24%,
+    var(--1-gradient-ai-robot-38, #fbd3ee) 29.71%,
+    var(--1-gradient-ai-robot-59, #ddd5f7) 43.72%,
+    var(--1-gradient-ai-robot-83, #c8f4f3) 59.35%,
+    var(--1-gradient-ai-robot-100, #cadff8) 70.56%
+  );
   --color-ai-chat-header-bg-gradient-begin: #eaecff;
   --color-ai-chat-header-bg-gradient-end: #fefdff;
   --color-ai-user-bubble-bg-gradient-begin: #ffede6;
   --color-ai-user-bubble-bg-gradient-end: #f2f4f7;
   --input-border: #eaebed;
   --color-pause-btn-bg: #fff1e6;
+  --color-loading-text: #b5b9bf;
 }
 
 :root.dark {
@@ -477,4 +486,5 @@
   --color-ai-user-bubble-bg-gradient-end: #5c6a7d;
   --input-border: #656f7d;
   --color-pause-btn-bg: #453b36;
+  --color-loading-text: #818892;
 }

+ 113 - 16
src/views/AIRobotChat/src/AIRobotChat.vue

@@ -8,6 +8,7 @@ import robotBubbleLight from './image/robotBubbleLight.png'
 import robotBubbleDark from './image/robotBubbleDark.png'
 import { useThemeStore } from '@/stores/modules/theme'
 import MarkdownIt from 'markdown-it'
+import 'github-markdown-css/github-markdown.css'
 
 const md = new MarkdownIt({
   html: true,
@@ -15,15 +16,10 @@ const md = new MarkdownIt({
   typographer: true,
   breaks: true
 })
-const mdContent = `
-## 测试标题
 
-这是一个 **粗体** 和 *斜体* 的例子。
-
-点击这个 [链接](https://example.com) 查看详情。
-
-`
-const renderedMessage = computed(() => md.render(mdContent))
+const renderedMessage = (content) => {
+  return md.render(content)
+}
 
 const themeStore = useThemeStore()
 const modalSize = ref('large')
@@ -38,6 +34,7 @@ const robotBubbleImg = computed(() => {
   return themeStore.theme === 'light' ? robotBubbleLight : robotBubbleDark
 })
 
+const loadingAnswer = ref(false) // 是否正在加载答案
 interface MessageItem {
   type: 'robot' | 'user'
   content: string
@@ -68,6 +65,15 @@ const messages = ref<MessageItem[]>([
   }
 ])
 
+watch(
+  () => messages.value,
+  (newVal) => {
+    // 将消息存储到 sessionStorage 中
+    sessionStorage.setItem('AIChat', JSON.stringify(newVal))
+  },
+  { immediate: true, deep: true }
+)
+
 // 用户问题请求时间
 const queryTime = ref(-1)
 // 当前用户问题回复进度
@@ -99,6 +105,67 @@ const handleSend = (question, isPresetQuestion = true) => {
     queryTime.value++
   }, 1000)
 }
+const showScrollButton = ref(false) // 控制按钮显示
+const messagesRef = ref()
+const autoScroll = ref(true)
+
+function handleScroll() {
+  const el = messagesRef.value
+  const threshold = 50 // 到底部的距离阈值
+  const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < threshold
+  autoScroll.value = atBottom
+
+  showScrollButton.value = el.scrollHeight > el.clientHeight && !autoScroll.value
+}
+function scrollToBottom() {
+  nextTick(() => {
+    if (messagesRef.value) {
+      messagesRef.value.scrollTop = messagesRef.value.scrollHeight
+    }
+  })
+}
+
+const simulateStreamingMarkdown = () => {
+  const chunks = [
+    '# 欢迎使用 Markdown\n\n',
+    '这是一个用于测试的 **Markdown** 文本。\n\n',
+    '你可以使用 *斜体* 来强调重点。\n\n',
+    '也可以使用 [链接](https://example.com) 引导用户跳转。\n\n',
+    '- 支持无序列表\n',
+    '- 非常适合逐行流式显示\n\n',
+    '这是一段测试!\n'
+  ]
+  const clientHeight = messagesRef.value.getBoundingClientRect()
+  console.log(messagesRef.value.offsetHeight, 'messagesRef.value', clientHeight)
+
+  loadingAnswer
+  messages.value.push({
+    type: 'robot',
+    content: '',
+    isAnswer: true,
+    feedback: '',
+    isShowFeedback: false
+  })
+  loadingAnswer.value = true
+  let index = 0
+
+  if (autoScroll.value) {
+    scrollToBottom()
+  }
+  const interval = setInterval(() => {
+    index === chunks.length - 1 ? (loadingAnswer.value = false) : ''
+    if (index < chunks.length) {
+      messages.value[messages.value.length - 1].content += chunks[index]
+      index++
+
+      if (autoScroll.value) {
+        scrollToBottom()
+      }
+    } else {
+      clearInterval(interval)
+    }
+  }, 500) // 每500ms追加一段
+}
 
 // 根据时间更新消息内容
 const updateMessageContent = (time) => {
@@ -169,6 +236,7 @@ const handleClose = () => {
     <div
       class="chat-messages"
       ref="messagesRef"
+      @scroll="handleScroll"
       :style="{ marginTop: modalSize === 'large' ? '81px' : '151px' }"
     >
       <div
@@ -185,10 +253,10 @@ const handleClose = () => {
         @mouseleave="msg.isShowFeedback = false"
       >
         <!-- 请求失败后的提示icon  -->
-        <div
+        <span
           v-if="queryTime === -3 && index === messages.length - 1"
           class="pause-icon font_family icon-icon_warning_fill_b"
-        ></div>
+        ></span>
         <!-- loading icon -->
         <img
           class="loading-img"
@@ -198,8 +266,10 @@ const handleClose = () => {
         />
         <span v-if="!msg.isAnswer">{{ msg.content }}</span>
         <div v-else>
-          <div v-html="renderedMessage" class="markdown-body"></div>
-          <LoadingDots></LoadingDots>
+          <div v-html="renderedMessage(msg.content)" class="markdown-body"></div>
+          <LoadingDots
+            v-if="index === messages.length - 1 && msg.isAnswer && loadingAnswer"
+          ></LoadingDots>
           <div></div>
         </div>
         <!-- 评价  -->
@@ -243,6 +313,10 @@ const handleClose = () => {
           <div class="dot"></div>
         </div>
       </div>
+      <!-- 滚动到底部icon -->
+      <div v-if="showScrollButton" class="scroll-to-bottom-btn" @click="scrollToBottom">
+        <span class="font_family icon-icon_movedown_b"></span>
+      </div>
     </div>
 
     <div class="footer-input" :class="[isFooterInputFocus ? 'focus-style' : '']">
@@ -252,10 +326,11 @@ const handleClose = () => {
         @focus="isFooterInputFocus = true"
         @blur="isFooterInputFocus = false"
       />
+      <el-button @click="simulateStreamingMarkdown">测试</el-button>
       <div
         class="input-icon"
         :class="[userQuestion ? 'input-style' : 'disable']"
-        @click="handleSend(userQuestion.value, false)"
+        @click="handleSend(userQuestion, false)"
       >
         <span class="font_family icon-icon_send_b"></span>
       </div>
@@ -314,7 +389,7 @@ const handleClose = () => {
     display: flex;
     flex-direction: column;
     gap: 16px;
-    padding: 0 16px;
+    padding: 0 16px 16px;
     overflow: auto;
     .message-item {
       position: relative;
@@ -372,7 +447,9 @@ const handleClose = () => {
         margin-right: 4px;
         animation: loading-rotate 2s linear infinite;
       }
-
+      .markdown-body {
+        background: transparent;
+      }
       .pause-btn {
         position: absolute;
         right: -22px;
@@ -393,7 +470,9 @@ const handleClose = () => {
       }
     }
     .query-style {
-      color: #b5b9bf;
+      span {
+        color: #b5b9bf;
+      }
     }
     .robot-bubble {
       background: var(--scoring-bg-color);
@@ -417,6 +496,24 @@ const handleClose = () => {
         bottom: -7px;
       }
     }
+    .scroll-to-bottom-btn {
+      position: absolute;
+      right: 50%;
+      transform: translateX(50%);
+      bottom: 52px;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      height: 32px;
+      width: 32px;
+      border-radius: 50%;
+      background-color: var(--color-customize-column-right-section-bg);
+      cursor: pointer;
+      &:hover {
+        background-color: var(--color-theme);
+        color: #fff;
+      }
+    }
   }
   .footer-input {
     display: flex;

+ 0 - 1
src/views/Layout/src/LayoutView.vue

@@ -45,7 +45,6 @@ const AvatarClick = () => {
 
     <!-- 右侧整体布局 -->
     <el-container style="min-width: 900px">
-      <el-button @click="onClick">测试</el-button>
       <!-- 顶部Header -->
       <el-header class="layout-header">
         <Header></Header>