feat: 完善搜索建议

This commit is contained in:
imsyy 2023-07-28 18:27:11 +08:00
parent 382e0e9768
commit 83fcfa7a01
11 changed files with 280 additions and 59 deletions

View File

@ -22,7 +22,12 @@
- [x] 网站背景自定义 - [x] 网站背景自定义
- [x] 数据备份及恢复 - [x] 数据备份及恢复
- [x] 移动端适配 - [x] 移动端适配
* [ ] 还没想好呢 * [ ] 切换搜索引擎
* [ ] 设置
* [ ] 备份
* [ ] 一言
* [ ] 书签
* [ ] 备忘
### 部署 ### 部署

View File

@ -1,7 +1,7 @@
{ {
"name": "snavigation", "name": "snavigation",
"private": true, "private": true,
"version": "0.0.0", "version": "2.0.0 beta 1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@ -10,7 +10,7 @@
}, },
"dependencies": { "dependencies": {
"axios": "^1.4.0", "axios": "^1.4.0",
"axios-jsonp": "^1.0.4", "fetch-jsonp": "^1.3.0",
"pinia": "^2.1.4", "pinia": "^2.1.4",
"pinia-plugin-persistedstate": "^3.2.0", "pinia-plugin-persistedstate": "^3.2.0",
"sass": "^1.64.1", "sass": "^1.64.1",

View File

@ -8,9 +8,9 @@ dependencies:
axios: axios:
specifier: ^1.4.0 specifier: ^1.4.0
version: 1.4.0 version: 1.4.0
axios-jsonp: fetch-jsonp:
specifier: ^1.0.4 specifier: ^1.3.0
version: 1.0.4 version: 1.3.0
pinia: pinia:
specifier: ^2.1.4 specifier: ^2.1.4
version: 2.1.4(vue@3.3.4) version: 2.1.4(vue@3.3.4)
@ -403,10 +403,6 @@ packages:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: false dev: false
/axios-jsonp@1.0.4:
resolution: {integrity: sha512-KI5Fc4ery6DR+oneXG09hPZfGuNUW8Lblhe750h53Z0Eh5MRsrHn49YitDU4RsMk0HV+12zcvL2Q51QkOLGdIQ==}
dev: false
/axios@1.4.0: /axios@1.4.0:
resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==} resolution: {integrity: sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==}
dependencies: dependencies:
@ -497,6 +493,10 @@ packages:
/estree-walker@2.0.2: /estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
/fetch-jsonp@1.3.0:
resolution: {integrity: sha512-hxCYGvmANEmpkHpeWY8Kawfa5Z1t2csTpIClIDG/0S92eALWHRU1RnGaj86Tf5Cc0QF+afSa4SQ4pFB2rFM5QA==}
dev: false
/fill-range@7.0.1: /fill-range@7.0.1:
resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
engines: {node: '>=8'} engines: {node: '>=8'}

View File

@ -1,5 +1,7 @@
<template> <template>
<!-- 壁纸 -->
<Cover /> <Cover />
<!-- 主界面 -->
<Transition name="fade" mode="out-in"> <Transition name="fade" mode="out-in">
<main v-if="status.imgLoadStatus" id="main" @click="mainClick"> <main v-if="status.imgLoadStatus" id="main" @click="mainClick">
<WeatherTime /> <WeatherTime />

View File

@ -1,5 +1,5 @@
import axios from "@/utils/request"; import axios from "@/utils/request";
import jsonpAdapter from "axios-jsonp"; import fetchJsonp from "fetch-jsonp";
/** /**
* 获取天气 * 获取天气
@ -19,12 +19,16 @@ export const getWeather = () => {
*/ */
export const getSearchSuggestions = async (keyWord) => { export const getSearchSuggestions = async (keyWord) => {
try { try {
const response = await axios({ const encodedKeyword = encodeURIComponent(keyWord);
url: `https://suggestion.baidu.com/su?wd=${keyWord}&cb=json`, const response = await fetchJsonp(
adapter: jsonpAdapter, `https://suggestion.baidu.com/su?wd=${encodedKeyword}&cb=json`,
callbackParamName: "cb", {
}); // 回调参数
return response.s; jsonpCallback: "cb",
}
);
const data = await response.json();
return data.s;
} catch (error) { } catch (error) {
console.error("处理搜索建议发生错误:", error); console.error("处理搜索建议发生错误:", error);
return null; return null;

View File

@ -15,21 +15,27 @@
</div> </div>
<input <input
class="input" class="input"
id="main-input"
ref="searchInputRef" ref="searchInputRef"
type="text" type="text"
label="search" label="search"
title="请输入搜索内容" title="请输入搜索内容"
autocomplete="false"
:placeholder="inputTip" :placeholder="inputTip"
v-model="inputValue" v-model="inputValue"
@focus="status.setSiteStatus('focus')" @focus="status.setSiteStatus('focus')"
@keydown="pressKeyboard" @keydown.stop="pressKeyboard"
/> />
<div class="go" title="搜索" @click="toSearch"> <div class="go" title="搜索" @click="toSearch(inputValue)">
<SvgIcon iconName="icon-search" className="search" /> <SvgIcon iconName="icon-search" className="search" />
</div> </div>
</div> </div>
<!-- 搜索建议 --> <!-- 搜索建议 -->
<SearchSuggestions ref="searchSuggestionsRef" :keyWord="inputValue" /> <SearchSuggestions
ref="searchSuggestionsRef"
:keyWord="inputValue"
@toSearch="toSearch"
/>
</div> </div>
</template> </template>
@ -56,14 +62,48 @@ const searchSuggestionsRef = ref(null);
// //
const closeSearchInput = () => { const closeSearchInput = () => {
status.setSiteStatus("normal"); status.setSiteStatus("normal");
searchInputRef.value?.blur();
inputValue.value = ""; inputValue.value = "";
}; };
// //
const toSearch = () => { const toSearch = (val, type = 1) => {
const keywords = inputValue.value?.trim(); const searchValue = val?.trim();
if (keywords) { //
console.log("前往搜索:" + keywords); const jumpLink = (url) => {
if (set.urlJumpType === "open") {
window.location.href = url;
} else if (set.urlJumpType === "href") {
window.open(url, "_blank");
}
};
//
if (searchValue) {
console.log("前往搜索:" + searchValue, type);
// type
// 1 / 2 / 3 / 4 访
//
switch (type) {
case 1:
jumpLink(`https://www.bing.com/search?q=${searchValue}`);
break;
case 2:
jumpLink(`https://fanyi.baidu.com/#en/zh/${searchValue}`);
break;
case 3:
jumpLink(`mailto:${searchValue}`);
break;
case 4:
const urlRegex = /^(https?:\/\/)/i;
const url = urlRegex.test(searchValue)
? searchValue
: `//${searchValue}`;
jumpLink(url);
break;
default:
break;
}
closeSearchInput();
} else { } else {
status.setSiteStatus("focus"); status.setSiteStatus("focus");
searchInputRef.value?.focus(); searchInputRef.value?.focus();
@ -95,7 +135,7 @@ const pressKeyboard = (event) => {
// 13 // 13
if (keyCode === 13) toSearch(); if (keyCode === 13) toSearch();
// //
searchSuggestionsRef.value?.keyboardEvents(keyCode); searchSuggestionsRef.value?.keyboardEvents(keyCode, event);
}; };
onMounted(() => { onMounted(() => {

View File

@ -1,54 +1,113 @@
<template> <template>
<Transition name="fadeUp"> <Transition name="fadeUp" mode="out-in">
<div <div
v-show=" v-if="
set.showSuggestions && set.showSuggestions &&
status.siteStatus === 'focus' && status.siteStatus === 'focus' &&
searchKeyword !== null searchKeyword !== null
" "
class="search-suggestions" class="search-suggestions"
:style="{
height: suggestionsHeights !== 0 ? `${suggestionsHeights}px` : 'auto',
}"
> >
<!-- 搜索建议 -->
<Transition name="fade" mode="out-in"> <Transition name="fade" mode="out-in">
<div <div
v-if="searchKeyword !== null && searchSuggestionsData[0]" v-show="
searchKeyword !== null &&
searchKeywordType === 'text' &&
searchSuggestionsData[0]
"
class="all-result" class="all-result"
ref="allResultsRef"
> >
<!-- 快捷翻译 -->
<div class="translation" @click="toSearch(searchKeyword, 2)">
<SvgIcon iconName="icon-translation" />
<span class="text">快捷翻译{{ searchKeyword }}</span>
</div>
<!-- 建议 -->
<div <div
v-for="item in searchSuggestionsData" v-for="item in searchSuggestionsData"
:key="item"
class="s-result" class="s-result"
:key="item"
@click="toSearch(item, 1)"
> >
<SvgIcon iconName="icon-search" className="search" /> <SvgIcon iconName="icon-search" className="search" />
<span class="text">{{ item }}</span> <span class="text">{{ item }}</span>
</div> </div>
</div> </div>
<div class="no-result" v-else-if="!hasSuggestions"> </Transition>
<!-- 无搜索建议 -->
<Transition name="fade" mode="out-in">
<div
v-show="searchKeywordType === 'text' && !hasSuggestions"
class="no-result"
>
<SvgIcon iconName="icon-found" className="not-found" /> <SvgIcon iconName="icon-found" className="not-found" />
<div class="all-text"> <div class="all-text">
<span class="text">暂无搜索结果</span> <span class="text">暂无搜索建议</span>
<span class="tip">请尝试其他关键词</span> <span class="tip">请尝试其他关键词</span>
</div> </div>
</div> </div>
</Transition> </Transition>
<!-- 特殊类型 -->
<Transition name="fade" mode="out-in">
<div
v-show="searchKeywordType !== 'text'"
class="special-result"
ref="specialResultsRef"
>
<!-- 直接访问 -->
<div
class="s-result"
@click="
toSearch(searchKeyword, searchKeywordType === 'email' ? 3 : 4)
"
>
<SvgIcon iconName="icon-link" />
<span class="text">
{{
searchKeywordType === "email" ? "发送邮件至" : "直接访问"
}}{{ searchKeyword }}
</span>
</div>
<!-- 直接搜索 -->
<div class="s-result" @click="toSearch(searchKeyword, 1)">
<SvgIcon iconName="icon-search" />
<span class="text">搜索{{ searchKeyword }}</span>
</div>
</div>
</Transition>
</div> </div>
</Transition> </Transition>
</template> </template>
<script setup> <script setup>
import { ref, watch } from "vue"; import { nextTick, ref, watch } from "vue";
import { statusStore, setStore } from "@/stores"; import { statusStore, setStore } from "@/stores";
import { getSearchSuggestions } from "@/api"; import { getSearchSuggestions } from "@/api";
import debounce from "@/utils/debounce"; import debounce from "@/utils/debounce";
import identifyInput from "@/utils/identifyInput";
const set = setStore(); const set = setStore();
const status = statusStore(); const status = statusStore();
const emit = defineEmits(["toSearch"]);
// //
const searchKeyword = ref(null); const searchKeyword = ref(null);
//
const searchKeywordType = ref("text");
// //
const searchSuggestionsData = ref([]); const searchSuggestionsData = ref([]);
// //
const hasSuggestions = ref(true); const hasSuggestions = ref(true);
//
const allResultsRef = ref(null);
const specialResultsRef = ref(null);
//
const suggestionsHeights = ref(0);
// //
const props = defineProps({ const props = defineProps({
// //
@ -60,27 +119,98 @@ const props = defineProps({
// //
const keywordsSearch = debounce((val) => { const keywordsSearch = debounce((val) => {
if (val?.trim()) { const searchValue = val?.trim();
searchKeyword.value = searchValue;
//
searchKeywordType.value = identifyInput(searchValue);
//
if (searchKeywordType.value === "text") {
if (searchValue) {
console.log(val + "的搜索建议"); console.log(val + "的搜索建议");
searchKeyword.value = val;
// //
getSearchSuggestions(val).then((res) => { searchSuggestionsData.value = [];
getSearchSuggestions(searchValue)
.then((res) => {
console.log(res); console.log(res);
// //
hasSuggestions.value = res[0] ? true : false; hasSuggestions.value = res[0] ? true : false;
// //
searchSuggestionsData.value = Array.from(res); searchSuggestionsData.value = Array.from(res);
//
nextTick(() => {
const height = allResultsRef.value?.offsetHeight;
suggestionsHeights.value = res[0] ? height : 130;
});
})
.catch((error) => {
console.error("处理搜索建议发生错误:", error);
}); });
} else { } else {
searchKeyword.value = null; searchKeyword.value = null;
hasSuggestions.value = true; hasSuggestions.value = true;
suggestionsHeights.value = 0;
} }
}, 500); }
//
else {
hasSuggestions.value = true;
//
nextTick(() => {
const height = specialResultsRef.value?.offsetHeight;
suggestionsHeights.value = height ?? 62;
});
}
}, 300);
// //
const keyboardEvents = (keyCode) => { const keyboardEvents = (keyCode, event) => {
console.log("键盘按下:" + keyCode); try {
//
const mainInput = document.getElementById("main-input");
// 38 / 40 // 38 / 40
if (keyCode === 38 || keyCode === 40) {
//
event.preventDefault();
if (mainInput && allResultsRef.value && hasSuggestions.value) {
const suggestionItems =
allResultsRef.value.querySelectorAll(".s-result");
if (suggestionItems.length > 0) {
//
const focusedItem = document.querySelector(".s-result.focus");
//
const currentIndex = Array.from(suggestionItems).indexOf(focusedItem);
//
suggestionItems.forEach((item) =>
item.classList.toggle("focus", false)
);
//
let nextIndex = keyCode === 38 ? currentIndex - 1 : currentIndex + 1;
//
nextIndex = Math.max(
0,
Math.min(nextIndex, suggestionItems.length - 1)
);
//
if (nextIndex !== -1) {
suggestionItems[nextIndex].classList.toggle("focus", true);
mainInput.value =
suggestionItems[nextIndex].querySelector(".text").textContent;
}
}
}
}
// 13
if (keyCode === 13) {
toSearch(mainInput.value, 1);
}
} catch (error) {
console.error("键盘事件出现错误:" + error);
}
};
//
const toSearch = (val, type = 1) => {
emit("toSearch", val, type);
}; };
// //
@ -99,12 +229,17 @@ defineExpose({ keyboardEvents });
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
max-height: 338px;
overflow-y: hidden;
color: var(--main-text-color); color: var(--main-text-color);
background-color: var(--main-background-light-color); background-color: var(--main-background-light-color);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border-radius: 8px; border-radius: 16px;
.all-result { transition: height 0.25s ease, opacity 0.3s ease, transform 0.3s ease;
.s-result { .all-result,
.special-result {
.s-result,
.translation {
cursor: pointer; cursor: pointer;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
@ -113,7 +248,7 @@ defineExpose({ keyboardEvents });
padding: 6px 12px; padding: 6px 12px;
font-size: 14px; font-size: 14px;
transition: background-color 0.3s, padding-left 0.3s; transition: background-color 0.3s, padding-left 0.3s;
.search { .i-icon {
opacity: 0.8; opacity: 0.8;
margin-right: 8px; margin-right: 8px;
} }
@ -123,15 +258,16 @@ defineExpose({ keyboardEvents });
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
&:hover { &:hover,
&.focus {
background-color: var(--main-background-light-color); background-color: var(--main-background-light-color);
padding-left: 18px; padding-left: 18px;
} }
&:first-child { &:first-child {
border-radius: 8px 8px 0 0; border-radius: 16px 16px 0 0;
} }
&:last-child { &:last-child {
border-radius: 0 0 8px 8px; border-radius: 0 0 16px 16px;
} }
} }
} }

View File

@ -18,6 +18,9 @@ const useSetDataStore = defineStore("setData", {
showSeconds: false, showSeconds: false,
// 是否显示搜索建议 // 是否显示搜索建议
showSuggestions: true, showSuggestions: true,
// 跳转方式
// open 当前页面 / href 新标签页
urlJumpType: "href",
}; };
}, },
// 开启数据持久化 // 开启数据持久化

View File

@ -46,7 +46,7 @@ body {
.fadeUp-enter-active, .fadeUp-enter-active,
.fadeUp-leave-active { .fadeUp-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease-in-out; transition: opacity 0.3s ease, transform 0.3s ease;
} }
.fadeUp-enter-from, .fadeUp-enter-from,

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,31 @@
/**
* 判断输入的字符串是网址邮件地址还是普通文本
*
* @param {string} input - 输入的字符串
* @returns {(string | boolean)} - 返回 "url" 表示网址"email" 表示邮件地址true 表示普通文本
*/
const identifyInput = (input) => {
/**
* 网址正则
* @type {RegExp}
*/
const urlRegex =
/^(?:(?:(?:https?|ftp):\/\/)?(?:www\.)?)?([a-zA-Z0-9.-]+(?:\.[a-zA-Z]{2,})+)(?:\/[^\s]*)?(?:\?[^#\s]*)?(?:#[^\s]*)?$/;
/**
* 邮箱正则
* @type {RegExp}
*/
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// 判断是否为网址
if (urlRegex.test(input)) return "url";
// 判断是否为邮件地址
if (emailRegex.test(input)) return "email";
// 默认返回普通文本
return "text";
};
export default identifyInput;