结果展示:
刚开始的页面类似树结构,但是使用的是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);
})
后端返回的是一个树结构。