流输出SSE

36 阅读10分钟

一、整体思路流程

二、组件解读

1.sendSSe(重要): 用于发送 Server-Sent Events (SSE) 请求,并处理相关的回调

1. 导出 controller 变量
- controller 是一个 AbortController 的实例,用于控制请求的中止。在函数外部声明 controller 变量允许它在函数之外也能访问到。
2. sendSSe 函数定义
- 参数:
  - bodyObj:请求体,可以是一个 JSON 字符串。
  - onopen:一个函数,当请求成功并建立连接时被调用。
  - onmessage:一个函数,用于处理接收到的每条消息。
  - onclose:一个函数,当连接关闭时被调用。
  - onerror:一个函数,用于处理请求错误。
- 功能:
  - 创建 AbortController 实例:controller 是一个 AbortController 对象,用于能够中止请求。如果不再需要请求,可以调用 controller.abort() 来中止它。
  - 解析请求体:尝试将 bodyObj 解析为 JSON 对象。如果解析失败(例如 bodyObj 不是有效的 JSON 字符串),则将 message 设置为空对象。
  - 调用 fetchSource 函数:使用 bodyObj 和回调函数(onopen、onmessage、onclose、onerror)调用 fetchSource 函数。fetchSource 函数的实现不在这段代码中,但它应该负责实际的 SSE 请求处理。
  注意事项
- 错误处理:sendSSe 函数尝试解析 bodyObj 为 JSON 对象,但如果失败,则不会引发异常,而是将 message 设为空对象。你可能需要根据实际需求来调整错误处理逻辑。
- fetchSource 函数:这段代码调用了 fetchSource 函数,但没有提供 fetchSource 的实现。fetchSource 应该是处理 SSE 连接的核心函数,包括请求的发送、消息的接收等逻辑。
let controller;
export function sendSSe(bodyObj, {
  onopen,
  onmessage,
  onclose,
  onerror
}) {
  controller = new AbortController()
  let message = {}
  try {
    message = JSON.parse(bodyObj)
  } catch (e) {
    message = {}
  }
  fetchSource(bodyObj, onopen, onmessage, onclose, onerror)
}
function fetchSource (bodyObj, onopen, onmessage, onclose, onerror) { 
  // const { rsid = '', rcuuid = '', rcver = '' } = message
    fetchEventSource('https://ai-standard.umetrip.com/umeai-talkdirector-service/LlmController/callModel', {
      headers: {
        'Content-type': 'application/json'
      },
      body: bodyObj,
      method: 'POST',
      signal: controller.signal,
      onopen,
      onmessage,
      onclose,
      onerror
    })
}

2. fetchEventSource: 处理服务器发送的事件(SSE),具备重试机制和资源管理功能

1. 设置请求头和重试逻辑:
    - 复制传入的请求头,并检查是否存在 Accept 头,如果不存在,则设置为 SSE 所需的值(EventStreamContentType)。
    - 初始化用于 fetch 操作和重试的变量。
2. 资源清理函数:
   - dispose 函数用于清理资源,它会中止当前的请求并清除重试定时器。
3. 处理中止信号:
  - 监听传入的中止信号 inputSignal。如果请求被中止,调用 dispose 函数并解析 promise,不再继续构造或记录错误。
4. 发起请求和处理响应:
    - 使用 fetch 函数发起请求,传入必要的参数和请求体。请求体通过 JSON.stringify 转换为 JSON 字符串。
    - 调用 onopen 回调函数处理响应。
    - 通过 getBytes 和 getMessages 处理响应体中的数据,并调用 onmessage 处理每条消息。
    - 如果收到的消息包含 ID,将其存储在请求头中,并在下次重试时发送。
    - 处理完成后,调用 onclose 回调函数,清理资源并解析 promise。
5. 错误处理和重试机制:
    - 如果请求过程中发生错误,记录错误信息并尝试重试。
    - 使用 setTimeout 设置重试延迟,并在每次重试时增加重试计数,最多重试 maxRetry 次。
    - 如果超过最大重试次数仍然失败,清理资源并拒绝 promise。
        关键参数说明:
        - input:请求的 URL 或配置对象。
        - inputSignal:可选的中止信号。
        - inputHeaders:请求头。
        - inputBody:请求体,通常是 JSON 格式。
        - inputOnOpen:请求成功后的回调函数。
        - onmessage:处理接收到的每条消息的回调函数。
        - onclose:请求完成后的回调函数。
        - onerror:处理请求错误的回调函数。
        - inputFetch:自定义的 fetch 实现(如果没有提供,则使用默认的 window.fetch)。
        - ...rest:其他额外的 fetch 配置参数。
  这个函数设计用于处理 SSE 连接,具备重试机制,能够在网络错误或服务器问题时自动重试请求,并确保在请求完成后正确清理资源。

3. getMessages: 处理从流式数据中接收到的消息。这种流式数据通常用于 Server-Sent Events (SSE) 的上下文

解析和处理服务器发送的事件流消息。它通过 onLine 函数将每一行数据分解为字段和值,并将这些数据传递给适当的回调函数。每当遇到空行时,它将当前消息传递给 onMessage 回调,并重置消息对象以接收新消息。这种模式在处理服务器推送的实时数据(如 SSE)时非常有效。

function getMessages(onId, onRetry, onMessage) {
   let message = newMessage();
   const decoder = new TextDecoder();
   return function onLine(line, fieldLength) {
    // Function body...
  }
}
  • getMessages: 这个函数接受三个回调函数作为参数:

    • onId: 当接收到 id 字段时调用。
    • onRetry: 当接收到 retry 字段时调用。
    • onMessage: 当消息结束时调用。
  • message: 用于存储当前消息的对象。newMessage 是一个函数,应该返回一个新的空消息对象。

  • decoder: TextDecoder 实例用于解码字节数组(Uint8Array)为字符串。通常用于处理二进制数据流中的文本。

onLine 函数

getMessages 函数返回的 onLine 函数用于处理每一行数据。它接受两个参数:

  • line: 当前行的字节数组。
  • fieldLength: 行中字段部分的长度。
处理逻辑
  1. 空行处理
javascriptCopy Code
if (line.length === 0) {
  onMessage && onMessage(message);
  message = newMessage();
}
  1. 处理字段和值

else if (fieldLength > 0) {
      const field = decoder.decode(line.subarray(0, fieldLength));
      const valueOffset = fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1);
      const value = decoder.decode(line.subarray(valueOffset));
      console.log('data', field, value)
      switch (field) {
        case 'data':
        // if this message already has data, append the new value to the old.
        // otherwise, just set to the new value:
          message.data = message.data
            ? `${message.data}\n${value}`
            : value; // otherwise,
          break;
        case 'event':
          message.event = value;
          break;
        case 'id':
          onId(message.id = value);
          break;
        case 'retry':
          // eslint-disable-next-line no-case-declarations
          const retry = parseInt(value, 10);
          if (!isNaN(retry)) { // per spec, ignore non-integers
            onRetry(message.retry = retry);
          }
          break;
        default:
          break;
      }
    }
export const closeSSE = () => {
  if (controller) {
    // console.log('enter 断开链接::::')
    controller.abort()
    controller = undefined
  }
}

5,请求调用

try {
          sendSSe(JSON.stringify(item), {
            onopen: () => {
              console.log('enter on open');
            },
            onmessage: (event) => {
              const answer = toJsonParse(event.data, 'object');
              this.answer = answer;
              this.$set(this.messageData[this.robotIndex], 'resume', '...');
              this.$set(this.messageData[this.robotIndex], 'msg', '<p>...</p>');
              // 定时器清除
              this.handleAnswerTimerClear('中台返回');

              this.handleModelAnswerWithMsgType();
            },
            onclose: (event) => {
              console.log('enter onclose::::', event);
            },
            onerror: () => {
              console.log('enter on error::::');
            }
          });
        } catch (error) {
          console.log('流式异常')
          this.streamFlag = false
          this.$set(this.messageData[this.robotIndex], 'resume', '网络异常');
          this.$set(this.messageData[this.robotIndex], 'msg', '<p>网络异常</p>');
          console.log('异常报错', error);
        }

6.打字机效果

要实现打字机效果的流式输出,可以采用以下思路:

  1. 逐字符输出:模拟打字机效果,通过逐个字符的方式输出文本。
  2. 定时器控制:使用 setIntervalrequestAnimationFrame 定期更新显示的字符,实现逐字显示的效果。
  3. 缓存文本:将待显示的完整文本缓存到变量中,然后逐步取出并显示每个字符。
function typewriterEffect(text, element, interval = 100) {
  let index = 0;
  const timer = setInterval(() => {
    if (index < text.length) {
      element.textContent += text[index++];
    } else {
      clearInterval(timer);
    }
  }, interval);
}

// 使用示例const container = document.getElementById('output');
typewriterEffect('Hello, world!', container);
text:待显示的文本。
element:显示文本的 HTML 元素。
interval:每个字符之间的时间间隔(毫秒)。

7.代码

sseUtils.js
const moment = require('moment');
// 4.断开 FETCH-SSE 连接
export const closeSSE = () => {
  if (controller) {
    // console.log('enter 断开链接::::')
    controller.abort()
    controller = undefined
  }
}
function newMessage() {
// data, event, and id must be initialized to empty strings:
// https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
// retry should be initialized to undefined so we return a consistent shape
// to the js engine all the time: https://mathiasbynens.be/notes/shapes-ics#takeaways
  return {
    data: '',
    event: '',
    id: '',
    retry: undefined
  };
}

function concat(a, b) {
  const res = new Uint8Array(a.length + b.length);
  res.set(a);
  res.set(b, a.length);
  return res;
}

async function getBytes(stream, onChunk) {
  const reader = stream.getReader();
  let result;
  // eslint-disable-next-line no-cond-assign, no-await-in-loop
  while (!(result = await reader.read()).done) {
    onChunk(result.value);
  }
}

const ControlChars = {
  NewLine: 10,
  CarriageReturn: 13,
  Space: 32,
  Colon: 58
}
function getLines(onLine) {
  let buffer;
  let position; // current read position
  let fieldLength; // length of the `field` portion of the line
  let discardTrailingNewline = false;

  // return a function that can process each incoming byte chunk:
  return function onChunk (arr) {
    if (buffer === undefined) {
      buffer = arr;
      position = 0;
      fieldLength = -1;
    } else {
    // we're still parsing the old line. Append the new bytes into buffer:
      buffer = concat(buffer, arr);
    }

    const bufLength = buffer.length;
    let lineStart = 0; // index where the current line starts
    while (position < bufLength) {
      if (discardTrailingNewline) {
        if (buffer[position] === ControlChars.NewLine) {
          lineStart = ++position; // skip to next char
        }
        discardTrailingNewline = false;
      }
      // start looking forward till the end of line:
      let lineEnd = -1; // index of the \r or \n char
      for (; position < bufLength && lineEnd === -1; ++position) {
        switch (buffer[position]) {
          case ControlChars.Colon:
            if (fieldLength === -1) { // first colon in line
              fieldLength = position - lineStart;
            }
            break;
            //  \r case below should fallthrough to \n:
          case ControlChars.CarriageReturn:
            discardTrailingNewline = true;
            break;
          case ControlChars.NewLine:
            lineEnd = position;
            break;
          default:
            break;
        }
      }

      if (lineEnd === -1) {
      // We reached the end of the buffer but the line hasn't ended.
      // Wait for the next arr and then continue parsing:
        break;
      }

      // we've reached the line end, send it out:
      onLine(buffer.subarray(lineStart, lineEnd), fieldLength);
      lineStart = position; // we're now on the next line
      fieldLength = -1;
    }

    if (lineStart === bufLength) {
      buffer = undefined; // we've finished reading it
    } else if (lineStart !== 0) {
    // Create a new view into buffer beginning at lineStart so we don't
    // need to copy over the previous lines when we get the new arr:
      buffer = buffer.subarray(lineStart);
      position -= lineStart;
    }
  }
}
// 3.处理从流式数据中接收到的消息
// onId: 当接收到 id 字段时调用。
// onRetry: 当接收到 retry 字段时调用。
// onMessage: 当消息结束时调用。
function getMessages(
  onId,
  onRetry,
  onMessage
) {
  let message = newMessage(); // newMessage 是一个函数,应该返回一个新的空消息对象。
  const decoder = new TextDecoder();

  // getMessages 函数返回的 onLine 函数用于处理每一行数据
  return function onLine (line, fieldLength) {
    // 空行处理
    if (line.length === 0) {
      onMessage && onMessage(message);
      message = newMessage();
    } else if (fieldLength > 0) { // exclude comments and lines with no values
    // line is of format "<field>:<value>" or "<field>: <value>"
    // https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
      const field = decoder.decode(line.subarray(0, fieldLength));
      const valueOffset = fieldLength + (line[fieldLength + 1] === ControlChars.Space ? 2 : 1);
      const value = decoder.decode(line.subarray(valueOffset));
      console.log('data', field, value)
      switch (field) {
        case 'data':
        // if this message already has data, append the new value to the old.
        // otherwise, just set to the new value:
          message.data = message.data
            ? `${message.data}\n${value}`
            : value; // otherwise,
          break;
        case 'event':
          message.event = value;
          break;
        case 'id':
          onId(message.id = value);
          break;
        case 'retry':
          // eslint-disable-next-line no-case-declarations
          const retry = parseInt(value, 10);
          if (!isNaN(retry)) { // per spec, ignore non-integers
            onRetry(message.retry = retry);
          }
          break;
        default:
          break;
      }
    }
  }
}
// 2.处理服务器发送的事件(SSE),具备重试机制和资源管理功能
const EventStreamContentType = 'text/event-stream';
const LastEventId = 'last-event-id';
function fetchEventSource(input, {
  signal: inputSignal,
  headers: inputHeaders,
  body: inputBody,
  onopen: inputOnOpen,
  onmessage,
  onclose,
  onerror,
  fetch: inputFetch,
  ...rest
}) {
  return new Promise((resolve, reject) => {
  // make a copy of the input headers since we may modify it below:
    const headers = { ...inputHeaders };
    if (!headers.accept) {
      headers.accept = EventStreamContentType;
    }

    let curRequestController;

    // let retryInterval = DefaultRetryInterval;
    const retryTimer = 0;
    function dispose() {
    // document.removeEventListener('visibilitychange', onVisibilityChange);
      window.clearTimeout(retryTimer);
      curRequestController.abort();
    }

    // if the incoming signal aborts, dispose resources and resolve:
    inputSignal && inputSignal.addEventListener('abort', () => {
      // console.log('enter abort::::::')
      dispose();
      resolve(); // don't waste time constructing/logging errors
    });

    const fetch = inputFetch || window.fetch;
    const onopen = inputOnOpen
    let retryCount = 0;
    const maxRetry = 3; // 设置重试次数
    async function create() {
      curRequestController = new AbortController();
      try {
        let params = JSON.parse(inputBody)
        params.dateTime = moment(new Date()).format('YYYY-MM-DD HH:mm:ss');
        const response = await fetch(input, {
          ...rest,
          headers,
          body: JSON.stringify(params),
          signal: curRequestController.signal
        });
        await onopen(response);
        await getBytes(response.body, getLines(getMessages((id) => {
          if (id) {
          // store the id and send it back on the next retry:
            headers[LastEventId] = id;
          } else {
          // don't send the last-event-id header anymore:
            delete headers[LastEventId];
          }
        }, () => {
          // retryInterval = retry;
        }, onmessage)));
        onclose && onclose();
        dispose();
        resolve();
      } catch (err) {
        console.log('里层抛出异常了', err)
        // 防止重刷太快 时分秒是一样的问题
        setTimeout(() => {
          if (retryCount < maxRetry) {
            retryCount++
            console.log(`重刷第${retryCount} 次`)
            create()
          } else {
            console.error('多次重刷后仍失败', err)
            dispose();
            reject(err);
          }
        }, 1000)
        // if (!curRequestController.signal.aborted) {
        // // if we haven't aborted the request ourselves:
        //   try {
        //   // check if we need to retry:
        //     const interval = onerror?.(err) ?? retryInterval;
        //     // console.log('interval::::::', interval)
        //     window.clearTimeout(retryTimer);
        //     retryTimer = window.setTimeout(create, interval);
        //   } catch (innerErr) {
        //   // we should not retry anymore:
        //     dispose();
        //     reject(innerErr);
        //   }
        // }
      }
    }

    create();
  });
}

// 1.用于发送 Server-Sent Events (SSE) 请求,并处理相关的回调
let controller;
export function sendSSe(bodyObj, {
  onopen,
  onmessage,
  onclose,
  onerror
}) {
  controller = new AbortController()
  let message = {}
  try {
    message = JSON.parse(bodyObj)
  } catch (e) {
    message = {}
  }
  fetchSource(bodyObj, onopen, onmessage, onclose, onerror)
}
function fetchSource (bodyObj, onopen, onmessage, onclose, onerror) { 
  // const { rsid = '', rcuuid = '', rcver = '' } = message
    fetchEventSource('https://ai-standard.umetrip.com/umeai-talkdirector-service/LlmController/callModel', {
      headers: {
        'Content-type': 'application/json'
      },
      body: bodyObj,
      method: 'POST',
      signal: controller.signal,
      onopen,
      onmessage,
      onclose,
      onerror
    })
}
<style lang="less">
@import "../../assets/css/base.less";
</style>
<style scoped>
.main_wrapper {
  background: #f5f6f7;
  width: 7.5rem;
  height: 100%;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
}
.mod2-text1 {
  color: #383838;
}
.swipe-ul1 {
  /* padding-left: 1rem; */
  white-space: nowrap;
  overflow-x: auto;
  margin-bottom: 0.3rem;
}
.swipe-ul1 li {
  display: inline-block;
  padding-top: 0.16rem;
  width: 1.57rem;
  height: 1.32rem;
  font-size: 0.24rem;
  margin-right: 0.2rem;
  border-radius: 0.07rem;
}
.swipe-ul1 .icon {
  width: 0.59rem;
  height: 0.59rem;
}
.mod1-text2 {
  font-size: 0.26rem;
}
.tag {
  font-size: 0.26rem;
}
.tag span {
  padding: 0.1rem 0.17rem;
}
.mod2-icon1,
.mod2-icon2,
.mod2-icon3,
.mod2-dls dt {
  width: 0.8rem;
  height: 0.8rem;
}
.mod2-dls dd {
  font-size: 0.26rem;
}
.mod2 textarea {
  font-size: 0.24rem;
  background: #fff;
}
.mod2 button {
  font-size: 0.24rem;
  width: 2rem;
  height: 0.76rem;
}
.word-feed {
  word-break: break-all;
}
.tipMod5 {
  font-size: 20px;
}
.tipAP{
  width: 6.90rem;
  background: #ffffff;
  border-radius: 0.08rem;
  box-shadow: 0px 0px 0.12rem 0px rgba(0,0,0,0.07); 
  bottom: 1.46rem;
  left: 0.3rem;
  /* padding: 0.1rem 0.3rem; */
  position: fixed;
}
.tipAP li{
  padding: 0.1rem 0.15rem;
  line-height: 0.45rem;
  border-bottom: 1px solid rgba(237,237,237,1);
  font-size: 16px;
}
.tipAP li:last-child{
  border: none;
}
.mod1-text1 {
  white-space: pre-line !important;
}
</style>

<template>
  <div class="main_wrapper">
    <div class="mod1" id="scroll">
      <template v-for="item in data">
        <!-- <div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div> -->
        <template v-if="item.event == 'autoServiceMessage'">
          <div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
          <ul class="swipe-ul1">
            <li @click="asyncSendMessage('首乘关怀', '首乘关怀', $event)" class="li1">
              <i class="icon icon1"></i>首乘关怀
            </li>
            <li
              @click="asyncSendMessage('行程单查询', '行程单查询', $event)"
              class="li2"
            >
              <i class="icon icon2"></i>行程单查询
            </li>
            <li @click="asyncSendMessage('值机服务', '值机服务', $event)" class="li3">
              <i class="icon icon3"></i>值机服务
            </li>
            <li
              @click="asyncSendMessage('非自愿改期', '非自愿改期', $event)"
              class="li4"
            >
              <i class="icon icon4"></i>非自愿改期
            </li>
          </ul>
          <div class="mod5" v-if="item.msg.labelList.length">
            <ul class="mod5-ul1">
              <li
                @click="currentIndex = index2"
                v-for="(item2, index2) in item.msg.labelList"
                :key="index2"
                :class="{ active: currentIndex == index2 }"
              >
                {{ item2.labelTitle }}
              </li>
            </ul>
            <ul
              v-for="(item2, index2) in item.msg.labelList"
              :key="index2"
              class="mod5-ul2"
              v-if="currentIndex == index2"
            >
              <li
                v-for="(item3, index3) in item2.questionAnswerList"
                :key="index3"
                @click="sendMessage(item3)"
                v-if="item3.isUsed == '1'"
              >
                <template>
                  <div class="text1">{{ item3.question }}</div>
                  <van-icon name="arrow" />
                </template>
              </li>
            </ul>
          </div>
        </template>
        <template
          v-else-if="
            item.event == 'sendMessage' || item.event == 'sendNotReplyMessage'
          "
        >
          <div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
          <div class="mod1-r2" v-if="item.from.connType == connType">
            <div class="mod1-r2c1">
              <div class="mod2-text1 word-feed" v-html="item.msg"></div>
            </div>
            <div class="mod1-r2c2">
              <img :src="item.from.connImg" alt="" />
            </div>
          </div>
          <div class="mod1-r1" v-else>
            <div class="mod1-r1c1">
              <img :src="item.from.connImg" alt="" />
            </div>
            <div class="mod1-r1c2">
              <div class="mod1-text1 word-feed" v-html="item.msg"></div>
            </div>
          </div>
        </template>
        <template v-else-if="item.event == 'evaluateMessage'">
          <div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
          <div class="tag">
            <span>感谢您使用我们的服务,请为本次服务做出评价!</span>
          </div>
          <div class="mod2">
            <div class="mod2-dls active">
              <dl @click="comment(item, 5)">
                <dt :class="{ active: item.evaluate == 5 }">
                  <div class="mod2-icon1"></div>
                </dt>
                <dd>满意</dd>
              </dl>
              <dl @click="comment(item, 3)">
                <dt :class="{ active: item.evaluate == 3 }">
                  <div class="mod2-icon2"></div>
                </dt>
                <dd>一般</dd>
              </dl>
              <dl @click="comment(item, 1)">
                <dt :class="{ active: item.evaluate == 1 }">
                  <div class="mod2-icon3"></div>
                </dt>
                <dd>不满意</dd>
              </dl>
            </div>
            <template v-if="item.evaluate">
              <div class="mod2-textarea">
                <textarea
                  :disabled="item.disabled"
                  v-model="item.evaluateText"
                  placeholder="请输入评价信息"
                ></textarea>
              </div>
              <div class="mod2-button">
                <button
                  :disabled="item.disabled"
                  @click="evaluateResponse(item)"
                >
                  提交评价
                </button>
              </div>
            </template>
          </div>
        </template>
        <template v-else-if="item.event == 'lineUpMessage'">
          <div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
          <div class="mod1-text2" v-html="item.msg.lineUpTemple"></div>
        </template>
        <template v-else-if="item.event == 'robotMessage'">
          <div>
            <div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
            <div class="mod1-r1">
              <div class="mod1-r1c1">
                <img :src="item.from.connImg" alt="" />
              </div>
              <div class="mod1-r1c2">
                <div class="mod1-text1">
                  <div v-html="item.resume.replace(/无查询值也没航班号/g, '')"></div>
                </div>
                <div
                  class="mod5-content"
                  v-if="
                    item.candidateTemplates && item.candidateTemplates.length
                  "
                >
                  <div
                    class="tipMod5"
                    style="
                      padding: 0.15rem 0;
                      border-bottom: 1px solid rgba(237, 237, 237, 1);
                      margin-right: -0.2rem;
                    "
                  >
                    或者您想了解以下内容:
                  </div>
                  <ul class="mod5-ul2">
                    <li
                      v-for="item2 in item.candidateTemplates"
                      @click="asyncSendMessage(item2, item2, $event)"
                    >
                      <div class="text1">{{ item2 }}</div>
                      <van-icon name="arrow" />
                    </li>
                  </ul>
                </div>
              </div>
            </div>
          </div>
        </template>

        <template v-else-if="item.event == 'autoServiceAnswerMessage'">
          <div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
          <div class="mod1-r1">
            <div class="mod1-r1c1">
              <img :src="item.from.connImg" alt="" />
            </div>
            <div class="mod1-r1c2">
              <div class="mod1-text1" v-html="item.msg.text"></div>
              <a target="_blank" :href="item.msg.url" class="mod1-button">{{
                item.msg.button
              }}</a>
            </div>
          </div>
        </template>
        <template v-else>
          <div class="mod1-time">{{ item.dateTime.slice(11, 16) }}</div>
          <div class="mod1-r1">
            <div class="mod1-r1c1">
              <img :src="item.from.connImg" alt="" />
            </div>
            <div class="mod1-r1c2">
              <div class="mod1-text1" v-html="item.resume"></div>
            </div>
          </div>
        </template>
      </template>
      <div @click="closeTalkInfo()" v-if="talkStatus == 3" class="mod1-close">
        <i></i>结束服务
      </div>
    </div>
    <div>
    <div class="tipAP">
        <ul v-if="showTip && !isEnd">
            <li @click="asyncSendMessage(item, item, $event)" v-for="(item, index) in tips" :key="index">
            {{ item }}
            </li>
        </ul>
    </div>
    <div ref="bottomRef" id="scrollDiv"></div>
    <div class="footer">
      <div class="footer-inner">
        <div class="footer-r1">
          <div id="text-container" class="text"></div>
          <div class="footer-r1c2">
            <a
              href="javascript:;"
              class="footer-send"
              @click="asyncSendMessage()"
            ></a>
            <div @click="handleToolBar" class="icon"></div>
          </div>
        </div>
        <div v-show="showToolbar" class="toolbar" id="toolbar-container"></div>
      </div>
    </div>
  </div>
</div>
</template>
<script>
import echarts from 'echarts';
import { fetchPost, reqUpload } from '../../utils/web';
import { urlBase, uploadApi, getImgUrl } from '../../../config/config.js';
import {
  getUrlParams,
  toJsonParse,
  splitString,
  sleep,
  debounce,
  throttle
} from '../../utils/tools';
import E from 'wangeditor';
import moment from 'moment';
import { Toast } from 'vant';
import { sendSSe, closeSSE } from '../../utils/sseUtils';
import { setTimeout } from 'timers';
let answerTimer = null;
let scrollTimer = null;
export default {
  data() {
    return {
      showToolbar: false,
      chatWindow: '',
      isRead: false,
      data: [],
      talkStatus: 0,
      evaluate: '',
      evaluateText: '',
      currentIndex: 0,
      tips: [],
      enable: false,
      showTip: false,
      appId: '',
      agentId: '',
      answerText: '', // sse返回的所有文字答案
      answer: {}, // 当前sse返回的答案
      renderText: '', // 打字机效果渲染所用的答案
      isRender: false, // 当前是否在渲染答案
      isRenderAll: true, // 当前答案是否全部渲染完成
      isLoading: false,
      sessionId: '',
      currentAnswerIndex: 0, // 答案字符串取值当前下标
      isScrollEnd: false, // 是否已经滚动到页面最后
      robotIndex: 0, // 机器人当前索引
      messageData: [], // message的值
      isEnd: false, // 是否结束
      streamFlag: false, // 流文件全局 防止重复调用
      timerId: ''
    };
  },
  mounted() {
    if (localStorage.getItem('connUid')) {
      this.connUid = window.localStorage.getItem('connUid');
    } else {
      this.connUid = this.generateUUID();
      window.localStorage.setItem('connUid', this.connUid);
    }
    this.connType = this.$route.query.connType; // 身份类型:0——游客,1——普通旅客,2——VIP旅客,3——客服,4——管理员
    this.connName = this.$route.query.connName;
    this.connImg = this.$route.query.connImg;
    this.editor = this.initEditor();
    this.getParamsUser();
    this.memberOnline();
    this.enable = false;
  },
  created() {},
  methods: {
    // 初始进入调用,会获取用户的设备信息等
    memberOnline() {
      fetchPost('umeai-chatonline/chatMember/memberOnline', {
        event: 'memberOnline', // 事件类型:上线接口固定memberOnline
        from: {
          connUid: this.connUid, // 用户字符uid
          connType: this.connType
        }, // 发言人信息
        msg: {
          equipmentCode: this.connUid, // 设备号:前端生成随机字符串标识
          ua: this.getBrowser(), // 浏览器信息
          // ip: returnCitySN["cip"], //客户端IP地址
          // ipCity: returnCitySN['cname'], //客户端IP所在城市
          sysInfo: this.getOS(), // 操作系统
          referren: location.href, // 入口URL
          source: this.$route.query.source // 接入渠道,一般只有旅客才会使用: LOONGAIR_APP_GJ--APP、LOONGAIR_VX_GJ--微信公众号、LOONGAIR_WEB_GJ--网站、LOONGAIR_SINA_GJ--新浪、LOONGAIR_MINI_GJ--小程序
        }, // 触发上线的客户端信息:业务中只有对旅客才会有记录对需求
        appId: this.appId,
        agentId: this.agentId
      }).then((res) => {
        if (res) {
          setInterval(() => {
            this.polling();
          }, 500);
        } else {
          Toast.fail('无数据');
        }
      });
    },
    // 短轮询的方式一直发起网络请求;
    polling() {
      fetchPost('umeai-chatonline/chatMember/polling', {
        from: {
          connUid: this.connUid, // 用户字符uid
          connType: this.connType
        }, // 发言人信息
        appId: this.appId,
        agentId: this.agentId
      }).then((res) => {
        if (res.data) {
          res.data.unreadMessage.forEach((item) => {
            if (item.chatWindowType == 2) {
              this.chatWindow = item.chatWindow;
              this.talkStatus = item.talkStatus;
              if (!this.isRead) {
                this.readMessage(item, 'all');
              } else {
                if (item.unreadMessageCount > 0) {
                  this.readMessage(item, 'new');
                } else {
                    this.isEnd = false;
                    // this.enable = false;
                }
              }
            }
          });
        } else {
          location.reload();
        }
      });
    },
    // 获取某条具体的消息
    readMessage(item, type) {
      fetchPost('umeai-chatonline/chatMember/readMessage', {
        dateTime: moment(new Date()).format('YYYY-MM-DD hh:mm:ss'),
        event: 'readMessage',
        from: {
          connUid: this.connUid, // 用户字符uid
          connType: this.connType
        }, // 发言人信息
        to: item.chatWindow,
        msg: {
          firstSeq: type == 'new' ? this.data[this.data.length - 1].seq + 1 : 0,
          lastSeq: type == 'new' ? '' : item.lastSeq,
          firstTimeStamp: '',
          lastTimeStamp: '',
          queryText: ''
        },
        appId: this.appId,
        agentId: this.agentId
      }).then((res) => {
        if (res) {
          this.isRead = true;
          res.data.forEach((item, index) => {
            if (item.event === 'evaluateMessage') {
              this.checkEvaluation(item);
            }
          });
          if (type === 'all') {
            this.data = res.data;
          } else {
            if (
              res.data.length > 0 &&
              res.data[0].seq > this.data[this.data.length - 1].seq
            ) {
              this.data = this.data.concat(res.data);
            }
          }

          let mData = this.data;
          for (let i = 0; i < mData.length; i++) {
            if (mData[i].msg && mData[i].msg.lineUpTemple) {
              let lineUpTemple = mData[i].msg.lineUpTemple;
              if (lineUpTemple.indexOf('#') >= 0) {
                let valueArr = lineUpTemple.split('#');
                let value = mData[i].msg.value || 0;
                mData[i].msg.lineUpTemple =
                  valueArr[0] +
                  `<strong>${value}</strong> ` +
                  valueArr[valueArr.length - 1];
              }
            }
          }
          this.data = mData;

          // 流式数据
          let robotMsg = this.data.some((item) => item.event == 'robotMessage');
          if (robotMsg) {
            this.messageData = this.data || [];
          } else {
            this.isEnd = false;
            this.enable = false;
          }
          this.data.forEach((item, index) => {
            if (item.event === 'robotMessage' && !this.handleIsString(item)) {
              if (this.streamFlag) {
                return;
              }
              if (!this.streamFlag) {
                this.handleStream(item, index, type);
                this.$set(this.messageData[index], 'candidateTemplates', []);
                this.streamFlag = true
              }
            }
          })
          setTimeout(() => {
            var div = document.querySelector('#scroll');
            div.scrollTop = div.scrollHeight;
          }, 0);
        } else {
          Toast.fail('无数据');
        }
      }).catch((e) => {
      })
    },
    asyncSendMessage(msg, resume, event) {
        this.showTip = false
        if (this.enable) {
            Toast.fail('请不要频繁点击!');
            return 
        }
        this.enable = true
        if (this.isEnd) {
            this.showTip = false
            Toast.fail('请等待流文件输出完...');
            return;
        }
        this.isEnd = true;
        if (this.editor.txt.text() === '' && !resume && !msg) {
            Toast.fail('请输入内容');
            return;
        }
        throttle(this.debounceSendMessage(msg, resume), 10000)
        // this.debounceSendMessage(msg, resume)
    },
    // 发送消息调用方法
    debounceSendMessage(msg, resume) {
      if (this.enable) {
        this.isLoading = false;
        this.isRenderAll = false;
        this.streamFlag = false

        // 将相关变量初始化
        this.isRenderAll = true;
        this.isLoading = true;
        this.currentAnswerIndex = 0;
        this.renderText = '';
        this.answerText = '';
        this.isRenderAll = false;
        this.isRender = false;
        let msgContent = resume || this.editor.txt.text();
        if (msgContent === '&nbsp' || msgContent === '&nbsp;') {
          Toast.fail('请输入内容');
          return;
        }
        fetchPost('umeai-chatonline/chatMember/asyncSendMessage', {
          dateTime: moment(new Date()).format('YYYY-MM-DD HH:mm:ss'),
          event: 'sendMessage',
          from: {
            connUid: this.connUid, // 用户字符uid
            connType: this.connType,
            connName: this.connName ? this.connName : undefined,
            connImg: this.connImg ? this.connImg : undefined
          }, // 发言人信息
          to: this.chatWindow,
          resume: resume || this.editor.txt.text(),
          msg: msg || this.editor.txt.html(),
          appId: this.appId,
          agentId: this.agentId
        }).then((res) => {
          if (res.data) {
            this.editor.txt.html('');
            // this.enable = false;
          } else {
            Toast.fail('无数据');
            // this.enable = false;
          }
        }).catch(() => {
            // this.enable = false;
        })
      }
    },
    // 发送消息方法
    sendMessage(item, type) {
      fetchPost('umeai-chatonline/chatMember/asyncSendMessage', {
        dateTime: moment(new Date()).format('YYYY-MM-DD HH:mm:ss'),
        event: 'sendNotReplyMessage',
        from: {
          connUid: type === 'robot' ? 'Robot' : this.connUid, // 用户字符uid
          connType: type === 'robot' ? 3 : this.connType,
          connName:
            type !== 'robot' && this.connName ? this.connName : undefined,
          connImg: type !== 'robot' && this.connImg ? this.connImg : undefined
        }, // 发言人信息
        to: this.chatWindow,
        resume: type === 'robot' ? item.answer : item.question,
        // msg: msg ? window.btoa(window.encodeURIComponent(msg)) : window.btoa(window.encodeURIComponent(this.editor.txt.html()))
        msg: type === 'robot' ? item.answer : item.question,
        appId: this.appId,
        agentId: this.agentId
      }).then((res) => {
        if (res.data) {
          if (type !== 'robot') {
            this.sendMessage(item, 'robot');
          }
          this.editor.txt.html('');
        } else {
          Toast.fail('无数据');
        }
      });
    },
    evaluateResponse(item) {
      item.disabled = true;
      fetchPost('umeai-talkdirector-service/talkInfo/evaluateResponse', {
        id: item.msg.id,
        evaluate: item.evaluate,
        evaluateText: item.evaluateText,
        appId: this.appId,
        agentId: this.agentId
      }).then((res) => {
        if (res) {
          Toast.success('评价成功');
        } else {
          Toast.fail('无数据');
        }
      });
    },
    checkEvaluation(item) {
      fetchPost('umeai-talkdirector-service/talkInfo/checkEvaluation', {
        id: item.msg.id,
        appId: this.appId,
        agentId: this.agentId
      }).then((res) => {
        if (res) {
          this.$set(item, 'evaluate', res.data.evaluate);
          this.$set(item, 'evaluateText', res.data.evaluateText);
          if (res.data.evaluate != null) {
            this.$set(item, 'disabled', true);
          } else {
            this.$set(item, 'disabled', false);
          }
        } else {
          Toast.fail('无数据');
        }
      });
    },
    closeTalkInfo() {
      fetchPost('umeai-talkdirector-service/talkInfo/closeTalkInfo', {
        chatWindowIdUid: this.chatWindow,
        closeType: 5,
        appId: this.appId,
        agentId: this.agentId
      }).then((res) => {
        if (res) {
          Toast.success('关闭成功');
        }
      });
    },
    getquestion() {
      fetchPost('umeai-knowledge-service/public/getquestion', {
        question: this.editor.txt.text().replace('&nbsp;', ''),
        appId: this.appId,
        agentId: this.agentId
      }).then((res) => {
        if (res) {
          this.tips = res.data;
          if (this.tips.length > 0 && this.editor.txt.text() != '') {
            this.showTip = true;
          } else {
            this.showTip = false;
          }
        }
      });
    },
    comment(item, evaluate) {
      if (!item.disabled) {
        item.evaluate = evaluate;
      }
    },
    getOS() {
      var sUserAgent = navigator.userAgent;
      var isWin =
        navigator.platform == 'Win32' || navigator.platform == 'Windows';
      var isMac =
        navigator.platform == 'Mac68K' ||
        navigator.platform == 'MacPPC' ||
        navigator.platform == 'Macintosh' ||
        navigator.platform == 'MacIntel';
      if (isMac) return 'Mac';
      var isUnix = navigator.platform == 'X11' && !isWin && !isMac;
      if (isUnix) return 'Unix';
      var isLinux = String(navigator.platform).indexOf('Linux') > -1;
      if (isLinux) return 'Linux';
      if (isWin) {
        var isWin2K =
          sUserAgent.indexOf('Windows NT 5.0') > -1 ||
          sUserAgent.indexOf('Windows 2000') > -1;
        if (isWin2K) return 'Win2000';
        var isWinXP =
          sUserAgent.indexOf('Windows NT 5.1') > -1 ||
          sUserAgent.indexOf('Windows XP') > -1;
        if (isWinXP) return 'WinXP';
        var isWin2003 =
          sUserAgent.indexOf('Windows NT 5.2') > -1 ||
          sUserAgent.indexOf('Windows 2003') > -1;
        if (isWin2003) return 'Win2003';
        var isWinVista =
          sUserAgent.indexOf('Windows NT 6.0') > -1 ||
          sUserAgent.indexOf('Windows Vista') > -1;
        if (isWinVista) return 'WinVista';
        var isWin7 =
          sUserAgent.indexOf('Windows NT 6.1') > -1 ||
          sUserAgent.indexOf('Windows 7') > -1;
        if (isWin7) return 'Win7';
        var isWin10 =
          sUserAgent.indexOf('Windows NT 10') > -1 ||
          sUserAgent.indexOf('Windows 10') > -1;
        if (isWin10) return 'Win10';
      }
      return 'other';
    },
    getBrowser() {
      var browser = {};
      var userAgent = navigator.userAgent.toLowerCase();
      var s;
      (s = userAgent.match(/msie ([\d.]+)/))
        ? (browser.ie = s[1])
        : (s = userAgent.match(/firefox/([\d.]+)/))
        ? (browser.firefox = s[1])
        : (s = userAgent.match(/chrome/([\d.]+)/))
        ? (browser.chrome = s[1])
        : (s = userAgent.match(/opera.([\d.]+)/))
        ? (browser.opera = s[1])
        : (s = userAgent.match(/version/([\d.]+).*safari/))
        ? (browser.safari = s[1])
        : 0;
      var version = '';
      if (browser.ie) {
        version = 'IE ' + browser.ie;
      } else {
        if (browser.firefox) {
          version = 'firefox ' + browser.firefox;
        } else {
          if (browser.chrome) {
            version = 'chrome ' + browser.chrome;
          } else {
            if (browser.opera) {
              version = 'opera ' + browser.opera;
            } else {
              if (browser.safari) {
                version = 'safari ' + browser.safari;
              } else {
                version = '未知浏览器';
              }
            }
          }
        }
      }
      return version;
    },
    generateUUID() {
      let d = new Date().getTime();
      if (window.performance && typeof window.performance.now === 'function') {
        d += performance.now(); // use high-precision timer if available
      }
      let uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(
        /[xy]/g,
        function (c) {
          let r = (d + Math.random() * 16) % 16 | 0;
          d = Math.floor(d / 16);
          return (c == 'x' ? r : (r & 0x3) | 0x8).toString(16);
        }
      );
      uuid = uuid.replace(/-/g, '');
      return uuid;
    },
    initEditor() {
      let that = this;
      const editor = new E('#toolbar-container', '#text-container');
      // 配置菜单栏,删减菜单,调整顺序
      editor.config.menus = ['emoticon', 'image'];
      editor.config.height = 100;
      editor.config.zIndex = 99999;
      editor.config.uploadImgServer = '/upload-img';
      editor.config.placeholder = '我要提问';
      editor.config.showFullScreen = false;
      editor.config.showLinkImg = false;
      editor.config.customUploadImg = function (resultFiles, insertImgFn) {
        // resultFiles 是 input 中选中的文件列表
        // insertImgFn 是获取图片 url 后,插入到编辑器的方法

        // 上传图片,返回结果,将图片插入到编辑器中
        resultFiles.forEach((item) => {
          const formData = new FormData();
          formData.append('file', item);
          reqUpload(uploadApi, formData).then((res) => {
            let url = `${getImgUrl}${res.fileInfo.fid}`;
            that.asyncSendMessage(`<img src="${url}" />`, '[图片]');
          });
        });
      };
      editor.config.onchangeTimeout = 500;
      let used = 1;
      editor.config.onchange = async (newHtml) => {
        that.getFooterHeight()
        if (used === 1) {
          let dom = editor.$textContainerElem.elems[0];
          dom.style.wordBreak = 'break-all';
          used = 0;
        }
        if (editor.txt.html() === '') {
          editor.txt.html('&nbsp');
        }
        
       const menuAp = document.getElementsByClassName('w-e-menu')
       if (menuAp[0].getAttribute('data-title') === '表情') {
         const tooltipClass = document.getElementsByClassName('w-e-menu-tooltip')
         tooltipClass[0].style.visibility = 'hidden'
       }
        setTimeout(() => {
            that.getquestion();
        }, 1000)
      };
      editor.txt.eventHooks.keyupEvents.push(this.submit);
      editor.create();
      editor.txt.html('&nbsp');
      return editor;
    },
    submit(e) {
      if (event.keyCode == 13) {
        this.asyncSendMessage();
      }
    },
    prev() {
      this.$refs.swipe.prev();
    },
    next() {
      this.$refs.swipe.next();
    },
    prev2() {
      this.$refs.swipe2.prev();
    },
    next2() {
      this.$refs.swipe2.next();
    },
    getParamsUser() {
      let params = getUrlParams(window.location.href);
      this.appId = params.appId || '';
      this.agentId = params.agentId || '';
    },
    handleIsString(item) {
      return typeof item.msg === 'string';
    },
    // 流式输出
    handleStream(item, index, type) {
        this.robotIndex = index;
        try {
          sendSSe(JSON.stringify(item), {
            onopen: () => {
              console.log('enter on open');
            },
            onmessage: (event) => {
              const answer = toJsonParse(event.data, 'object');
              this.answer = answer;
              this.$set(this.messageData[this.robotIndex], 'resume', '...');
              this.$set(this.messageData[this.robotIndex], 'msg', '<p>...</p>');
              // 定时器清除
              this.handleAnswerTimerClear('中台返回');

              this.handleModelAnswerWithMsgType();
            },
            onclose: (event) => {
              console.log('enter onclose::::', event);
            },
            onerror: () => {
              console.log('enter on error::::');
            }
          });
        } catch (error) {
          console.log('流式异常')
          this.streamFlag = false
          this.$set(this.messageData[this.robotIndex], 'resume', '网络异常');
          this.$set(this.messageData[this.robotIndex], 'msg', '<p>网络异常</p>');
          console.log('异常报错', error);
        }
    },
    // 建立前端获取答案计时器
    handleAnswerTimerStart(info) {
      answerTimer = setTimeout(() => {
        this.isLoading = false;
        this.isRenderAll = true;
        if (this.messageData[this.robotIndex]) {
          this.$set(
            this.messageData[this.robotIndex],
            'resume',
            this.answerText
          );
          this.$set(
            this.messageData[this.robotIndex],
            'msg',
            `<p>${this.renderText}</p>`
          );
          //   this.$set(this.messageData[this.robotIndex], 'isLoading', false);
          //   this.$set(this.messageData[this.robotIndex], 'isTimeout', true);
        }
        closeSSE();
        this.handleAnswerTimerClear();
      }, 60000);
    },
    // 清除前端获取答案计时器
    handleAnswerTimerClear() {
      if (answerTimer) {
        clearTimeout(answerTimer);
        answerTimer = null;
      }
      return false;
    },
    getcandidateTemplates() {
      const { candidateTemplates } = this.answer;
      this.$set(
        this.messageData[this.robotIndex],
        'candidateTemplates',
        candidateTemplates
      );
    },
    // 普通文本
    handleModelAnswerWithMsgType() {
      const { content, end } = this.answer;
      if (end) {
        if (!this.answerText.length) {
          //   this.$set(this.messageData[this.robotIndex], 'isLoading', false);
          //   this.$set(this.messageData[this.robotIndex], 'isTimeout', true);
          this.$set(this.messageData[this.robotIndex], 'resume', '...');
          this.$set(this.messageData[this.robotIndex], 'msg', '<p>...</p>');
        }
        this.isRenderAll = true;
        this.isLoading = false;
      }
      if (!content) {
        return;
      }
      this.answerText += content;
      this.renderAnswer();
      this.handleAnswerTimerStart('接收到机器人数据');
    },
    clearHandle() {
        this.isRender = false;
        this.isRenderAll = true;
        this.isLoading = false;
        this.isEnd = false;
        this.enable = false;
        this.streamFlag = false;
    },
    // 前端控制答案展示实现打字机效果
    async renderAnswer() {
      if (this.isRender === true) return;
      if (this.currentAnswerIndex >= this.answerText.length) {
        // 说明中台答案传输完成并且前端渲染文本答案完成
        console.log('看下拼接数据', this.isLoading, this.renderText);
        let renderText = this.renderText ? this.renderText.replace(/无查询值也没航班号/g, '') : ''
        this.scrollToEnd();
        let that = this;
        that.$set(
            that.messageData[that.robotIndex],
            'resume',
            renderText
          );
          that.$set(
            that.messageData[that.robotIndex],
            'msg',
            `<p>${renderText}</p>`
          );
          this.getcandidateTemplates();
          this.clearHandle()
        return;
      }
      this.isRender = true;
      const { str: subText, index: textIndex } = splitString(
        this.answerText.substring(this.currentAnswerIndex),
        20
      );
      this.currentAnswerIndex =
        this.answerText.length >= this.currentAnswerIndex + textIndex
          ? this.currentAnswerIndex + textIndex
          : this.answerText.length;
      this.$set(this.messageData[this.robotIndex], 'isLoading', false);
      for (let i = 0; i < subText.length;) {
        const { str, index } = splitString(subText.substring(i), 2);
        this.renderText += str;
        i += index;
        if (this.messageData[this.robotIndex]) {
          this.$set(
            this.messageData[this.robotIndex],
            'resume',
            this.renderText
          );
          this.$set(
            this.messageData[this.robotIndex],
            'msg',
            `<p>${this.renderText}</p>`
          );
        }
        await sleep(50);
      }
      await sleep(100);
      this.isRender = false;
    //   this.enable = false;
      this.scrollToEnd();
      await sleep(100);
      this.renderAnswer();
    },
    // 滚动到页面底部
    scrollToEnd() {
      scrollTimer = setTimeout(() => {
        var div = document.querySelector('#scroll');
        div.scrollTop = div.scrollHeight;
        // 清除定时器
        if (scrollTimer) {
          clearTimeout(scrollTimer);
          scrollTimer = null;
        }
      }, 150);
    },
    getFooterHeight() {
        const footer = document.querySelector('.footer');
        // 检查是否成功选择到元素
        let footerHeight = 0
        if (footer) {
            // 获取 footer 元素的高度
            footerHeight = footer.offsetHeight;
        } else {
            console.log('Footer element not found.');
        }
        let currentTip = document.querySelector('.tipAP')
        if (currentTip) {
            currentTip.style.bottom = footerHeight + 'px'
            // currentTip.style.padding = '0.1rem 0.3rem'
        }
    },
    async handleToolBar() {
        this.showToolbar = !this.showToolbar
        this.showTip = false
        let that = this;
        setTimeout(() => {
            that.getFooterHeight()
            this.showTip = true
        }, 300)
    }
  }
};
</script>