文档全局检索功能,匹配结果高亮显示

结果展示:

刚开始的页面类似树结构,但是使用的是a-menu+a-menu-item

输入搜索条件后从搜索框中悬浮显示结果,其中一级菜单和子菜单使用了分组;匹配的文本进行高亮显示

限定悬浮区域高度,超出高度显示滚动条

代码实现:

附上全部代码

<template>
  <div style="width: 100%;">
    <a-card>
      <div>
        <a-input placeholder="搜索" v-model:value="queryParam.searchText" @input="handleSearch">
          <template #prefix>
            <a-icon type="search" style="color: rgba(0, 0, 0, 0.25);" />
          </template>
        </a-input>
        <div>
          <div style="position: relative; z-index: 10;" class="menu-content">
            <a-menu v-if="Object.keys(searchResults).length > 0" mode="inline" style="position: absolute; top: 100%; left: 0;max-height: 270px; overflow-y: auto;" class="xcard">
              <div v-for="(group, categoryTitle, index) in searchResults" :key="categoryTitle + '-group'" class="category-group">
                <div :key="categoryTitle + '-category'" class="category-item">
                  <span>{{ categoryTitle }}</span>
                </div>
                <div class="results-group">
                  <div v-for="(result, resultIndex) in group" :key="result.id" class="result-item">
                    <div class="result-content" :style="{ maxHeight: calculateMaxHeight(result) + 'px' }">
                      <a-menu-item @click="handleSearchResultClick(result)" :class="{ 'align-top': resultIndex === 0 }">
                        <span v-html="highlightText(result.question)"></span>
                      </a-menu-item>
                    </div>
                    <div class="secondary-menu" v-if="result.secParam[0]">
                      <div v-for="(secParam, secParamIndex) in result.secParam">
                        <a-menu>
                          <a-menu-item @click.stop="handleSearchResultClick(secParam)" v-if="secParam">
                            <div class="sec-param-content">
                              <span v-html="highlightText(secParam.question)"></span>
                              <div class="vertical-line" />
                              <span class="answer" v-html="highlightText(replace5char(secParam.answer))"></span>
                            </div>
                          </a-menu-item>
                        </a-menu>
                      </div>
                    </div>
                    <div class="secondary-menu" v-else>
                      <a-menu>
                        <a-menu-item @click.stop="handleSearchResultClick(result)">
                          <span v-html="highlightText(result.answer)"></span>
                        </a-menu-item>
                      </a-menu>
                    </div>
                  </div>
                </div>
              </div>
            </a-menu>
          </div>
        </div>
      </div>
    </a-card>
  </div>
  <a-card>
    <a-row style="padding: 10px">
      <!-- 左侧类别列表 -->
      <a-col :span="4" style="margin-bottom: 10px;" class="a-col-container">
        <a-menu mode="inline">
          <a-menu-item-group v-for="category in categoryList" :key="category.key" :title="category.title">
            <a-menu-item v-for="child in category.children" :key="child.key" :data-category-id="child.key" @click="handleCategoryClick(child.key)">
              {{ child.title }}
            </a-menu-item>
          </a-menu-item-group>
        </a-menu>
      </a-col>
      <!-- 右侧问题展示 -->
      <a-col :span="20" v-if="childrenLevel === 1">
        <a-card style="margin-bottom: 10px;">
          <a-list :dataSource="selectedCategoryQuestions">
            <template #renderItem="{ item, index }">
              <a-list-item>
                <div style="display: flex; flex-direction: column;">
                  <br>
                  <p style="font-weight: bold">{{ item.title }}</p>
                  <br>
                  <div v-html="item.value"></div>
                  <span style="margin-right: 10px">{{ removeTemp(item.filePath) }}</span>
                  <a-button v-if="item.filePath" :ghost="true" type="primary" preIcon="ant-design:download-outlined" size="small" @click="downloadFile(item.filePath)" style="max-width: 200px">下载</a-button>
                  <a-button v-if="item.filePath && item.categoryId != '40'" :ghost="true" type="primary" preIcon="ant-design:eye-outlined" size="small" @click="handleViewFile(item.filePath)" style="max-width: 200px">查看</a-button>
                  <video v-if="item.categoryId == '40' && item.filePath" ref="videoPlayer" controls :src="getLastUrl(item.filePath)"></video>
                </div>
              </a-list-item>
            </template>
          </a-list>
        </a-card>
      </a-col>
      <a-col :span="20" v-if="childrenLevel === 2">
        <a-card style="margin-bottom: 10px;">
          <a-menu mode="inline">
            <a-menu-item-group v-for="chlidren in selectedCategoryQuestions" :key="chlidren.key">
              <a-menu-item @click="handleCategoryClick(chlidren.key)" style="color: #1890ff">
                {{ chlidren.title }}
              </a-menu-item>
            </a-menu-item-group>
          </a-menu>
        </a-card>
      </a-col>
    </a-row>
  </a-card>
</template>

<script lang="ts" setup>
  import {reactive, ref, computed, onMounted, onBeforeUnmount , h} from 'vue';
  import { getFAQTree } from '../FrequentlyAskedQuestions.api';
  import { downloadFile , handleViewFile , getLastUrl} from '/@/utils/common/renderUtils';
  import eventBus from '/@/logics/mitt/event-bus.js';
  import { notification , NotificationPlacement } from 'ant-design-vue';
  import success from '../images/success.svg';
  import {style} from "@logicflow/extension/es/bpmn-elements/presets/icons";

  const queryParam = reactive<any>({});
  const categoryList = ref([]); // 类别列表
  const selectedCategoryQuestions = ref([]); // 选中类别下的问题列表
  const searchResults = ref([]); // 搜索结果
const url = ref('');
  // 获取类别列表
  async function fetchCategories(id) {
    categoryList.value = await getFAQTree({});
    if (id){
      handleCategoryClick(id);
    }
  }

  // 处理搜索
  function handleSearch() {
    const searchText = queryParam.searchText.trim();
    if (searchText === '') {
      searchResults.value = [];
      return;
    }
    const grouped = {};
    const regex = new RegExp(searchText.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'i'); // Create a case-insensitive regex pattern

    categoryList.value.forEach(category => {
      category.children.forEach(child => {
        if (
          child && (child.key || child.title || child.value || child.filePath || child.answer) &&
          (
            regex.test(child.key) ||
            regex.test(child.title) ||
            regex.test(child.value) ||
            regex.test(child.filePath) ||
            regex.test(child.answer)
          )
        ) {
          const categoryTitle = category.title;
          if (!grouped[categoryTitle]) {
            grouped[categoryTitle] = [];
          }
          grouped[categoryTitle].push({
            id: child.key,
            categoryTitle: category.title,
            question: child.title,
            answer:child.answer,
            secParam:[]
          });
        }
        if (child.children){
            child.children.forEach(c => {
              if (
                c && (c.key || c.title || c.value || c.filePath) &&
                (
                  regex.test(c.key) ||
                  regex.test(c.title) ||
                  regex.test(c.value) ||
                  regex.test(c.filePath)
                )
              ) {
                const categoryTitle = category.title;
                if (!grouped[categoryTitle]) {
                  grouped[categoryTitle] = [];
                }
                let existingItemIndex = -1;
                grouped[categoryTitle].forEach((item, index) => {
                  if (item.id === child.key) {
                    existingItemIndex = index;
                  }
                });
                if (existingItemIndex !== -1) {
                  grouped[categoryTitle][existingItemIndex].secParam.push({
                    id: c.key,
                    categoryTitle: child.title,
                    question: c.title,
                    answer:c.answer
                  });
                }else {
                  grouped[categoryTitle].push ({
                    id: child.key,
                    categoryTitle: category.title,
                    question: child.title,
                    secParam:[{
                      id: c.key,
                      categoryTitle: child.title,
                      question: c.title,
                      answer:c.answer
                    }]
                  });
                }
            }
          });
        }
      });
    });
    searchResults.value = grouped;
  }

  const childrenLevel = ref(1); // 初始化子节点层级为1

  // 处理点击类别事件
  function handleCategoryClick(id) {
    // 清除之前选中的状态
    const selectedItems = document.querySelectorAll('.ant-menu-item-selected');
    selectedItems.forEach(item => {
      item.classList.remove('ant-menu-item-selected');
    });
    let clickedCategory = null;
    // 遍历类别列表,查找点击的类别
    categoryList.value.forEach(category => {
      // 查找点击的类别是否在一级子节点中
      const foundChild = category.children.find(child => child.key == id);
      if (foundChild) {
          // 查找点击的类别是否在二级子节点中
        for (const child of category.children) {
          if (child.children != null && child.children.length > 0) {
            const foundGrandChild = child.children.find(grandChild => grandChild.parentId == id);
            if (foundGrandChild) {
              clickedCategory = foundChild;
              selectedCategoryQuestions.value = clickedCategory.children;
              childrenLevel.value = 2;
              break; // 当找到匹配项时,使用 break 语句退出循环
            }
          } else {
            clickedCategory = foundChild;
            selectedCategoryQuestions.value = [clickedCategory];
            childrenLevel.value = 1;
            break; // 当满足条件时,使用 break 语句退出循环
          }
        }
        const menuItem = document.querySelector(`[data-category-id="${id}"]`);
        if (menuItem) {
          //添加了 ant-menu-item-selected 类,以标记其为选中状态
          menuItem.classList.add('ant-menu-item-selected');
        }
      }else {
        for (const child of category.children) {
          if (child.children != null && child.children.length > 0) {
            const foundGrandChild = child.children.find(grandChild => grandChild.key == id);
            if (foundGrandChild) {
              if (foundGrandChild.level == 2){
                clickedCategory = foundGrandChild;
                selectedCategoryQuestions.value = [clickedCategory];
                childrenLevel.value = 1;
              }else if (foundGrandChild.level == 3){
                clickedCategory = foundGrandChild;
                selectedCategoryQuestions.value = [clickedCategory];
                childrenLevel.value = 1;
                const menuItem = document.querySelector(`[data-category-id="${child.key}"]`);
                if (menuItem) {
                  //添加了 ant-menu-item-selected 类,以标记其为选中状态
                  menuItem.classList.add('ant-menu-item-selected');
                }
              }
              break;
            }
          }
        }
      }
    });
  }

  // 处理搜索结果点击
  function handleSearchResultClick(result) {
    handleCategoryClick(result.id); // 传递对象而不是数组
    // 收回下面区域
    searchResults.value = [];
  }

  // 高亮显示
  function highlightText(text) {
    if (text != null && text != undefined){
      const regex = new RegExp(queryParam.searchText, 'gi');
      return text.replace(regex, '<span style="color: #1890FF; font-weight: bold;">$&</span>');
    }
  }

//截取匹配到的字符前后5个字符
  function replace5char(text){
    const regex = new RegExp(queryParam.searchText, 'gi');
    const match = regex.exec(text);
    if (!match) return text; // 如果没有匹配到文字,则返回原始文本
    const startIndex = Math.max(match.index - 5, 0); // 计算开始索引
    const endIndex = Math.min(match.index + match[0].length + 5, text.length); // 计算结束索引
    const highlightedText = text.substring(startIndex, endIndex); // 截取匹配文本及左右各 5 个字符
    return highlightedText;
  }

//去除文本中的指定字符
  const removeTemp = (filePath: string) => {
    if (filePath && filePath != undefined){
      return filePath.replace('temp/', '');
    }
  };

  function getResultGroupHeight(group) {
    const resultItemHeight = 80;
    return group.length * resultItemHeight;
  }

  eventBus.on('reset-event', fetchCategories);
  eventBus.on('video-event', fetchCategories);

  onBeforeUnmount(() => {
    eventBus.off('video-event', fetchCategories);
  })

//设置动态高度
  function calculateMaxHeight(result) {
    // 获取所有 result.secParam 的总高度
    let secParamHeight = 0;
    if (result.secParam) {
      // 计算每个 result.secParam 的高度并相加
      secParamHeight = result.secParam.reduce((totalHeight, param) => {
        // 假设每个 result.secParam 的高度为 30px
        return totalHeight + 30; // 30 为 result.secParam 的预设高度
      }, 40);
    }
    // 返回 result 的高度,包括 result.secParam 的总高度和间距
    return secParamHeight + 10; // 10 为 result 与 result.secParam 的间距
  }

  function openNotification(){
    notification.open({
      message: '小提示',
      description:
        '搜索框输入检索内容可检索文档/视频等内容,' +
        '点击检索到的内容,可以跳转至对应位置.',
      duration: 0,
      icon: renderIcon(),
      style: {
        marginTop: `${200 - 0}px`,
      },
    });
  }

  function renderIcon() {
    return h('img', {
      src: success,
      alt: 'Success Icon',
      style: 'width: 24px; height: 24px;',
    });
  }

  // 初始化加载类别列表
  fetchCategories(null);
  openNotification();
</script>

<style lang="less" scoped>
  /* 样式可以根据实际情况进行调整 */
  a-menu {
    position: relative;
    top: 100%;
    left: 0;
    transform: translateY(5px);
    z-index: 10;
  }
  a-list {
    padding: 16px;
  }

  .top-text {
    font-size: 20px;
    font-weight: bold;
  }

  .a-col-container {
    max-height: calc(100vh - 10px); /* 10px用来适应padding或margin等可能存在的额外高度 */
    overflow-y: auto; /* 添加垂直滚动条 */
  }

  .category-group {
    display: flex;
    align-items: flex-start;
    margin-top: 1%;
    margin-left: 1%;
  }

  .category-item {
    display: flex; /* 将标题项设置为flex布局 */
    align-items: center; /* 垂直居中对齐 */
  }

  .results-group {
    display: flex;
    flex-direction: column;
  }

  .result-item {
    display: flex;
    flex-direction: row;
    align-items: flex-start;
    margin-bottom: 10px; /* 垂直间距 */
  }

  .result-content {
    flex-grow: 1;
  }

  /* 添加垂直对齐样式 */
  .align-top {
    align-self: flex-start; /* 将结果的第一条内容垂直对齐到顶部 */
  }

  .sec-param-item {
    display: flex;
    align-items: center; /* 垂直居中 */
  }

  .sec-param-content {
    display: flex;
    align-items: baseline; /* 底部对齐 */
  }

  .answer {
    margin-left: 10px; /* 适当调整左右间距 */
  }

  .vertical-line {
    display: inline-block; /* 设置为内联块级元素 */
    border-left: 0.1px solid #1890ff; /* 设置垂直竖线样式 */
    margin: 0 5px; /* 设置竖线与文字之间的间距 */
    height: 30px; /* 设置竖线高度与父元素高度相同 */
  }

  .menu-content {
    position: absolute;
    top: 100%;
    left: 0;
    margin-top: 1%;
    border: 1px solid #ccc; /* 添加 1 像素宽度的实线边框,颜色为灰色 */
    border-radius: 5px; /* 可选:添加边框圆角 */
  }

  .xcard{
    box-shadow: 5px 5px 10px rgba(2, 2, 2, 0.2);
  }
</style>

其中eventBus为自定义的事件总线,主要是从其他地方路由至此页面时,对应的调用事件,执行自定义方法,我是在其他页面调用后,跳转页面达到选中对应菜单操作;代码如下:

// event-bus.js
import mitt from '/@/utils/mitt';

const emitter = mitt();

export default emitter;

调用方式

//在需要跳转的地方使用,假设有个按钮点击后跳转页面,且传递参数;
//eventBus.emit('video-event', secondaryCategoryId);  secondaryCategoryId为传递的参数
import eventBus from '/@/logics/mitt/event-bus.js';
import { useRouter } from 'vue-router';

const router = useRouter();
function navigateToVideo(videoPath, secondaryCategoryId) {
    if (videoPath) {
      // 第一次路由跳转
      router.push(videoPath).then(() => {
        // 触发事件总线上的 video-event 事件,并传递参数
        eventBus.emit('video-event', secondaryCategoryId);
/*该操作是为了避免第一次路由页面所需内容未加载完成,导致页面不跳转,可以使用两次路由或者加setTimeout延迟
        // 在第一次路由跳转之后,再次执行一次路由跳转
        router.push(videoPath).then(() => {
          // 触发事件总线上的 video-event 事件,并传递参数
          eventBus.emit('video-event', secondaryCategoryId);
        });*/
      });
    }
  }


//然后在被跳转的页面中,fetchCategories为跳转后需要执行的方法,会将传递的secondaryCategoryId作为fetchCategories的参数
import eventBus from '/@/logics/mitt/event-bus.js';
eventBus.on('video-event', fetchCategories);

  onBeforeUnmount(() => {
    eventBus.off('video-event', fetchCategories);
  })

后端返回的是一个树结构。

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值