增加搜索功能

This commit is contained in:
李志强 2025-12-26 17:03:29 +08:00
parent b86941fc9c
commit cd00fadea3
11 changed files with 624 additions and 61 deletions

View File

@ -22,7 +22,9 @@ declare module 'vue' {
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElTag: typeof import('element-plus/es')['ElTag']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']

BIN
frontend/public/default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@ -0,0 +1,16 @@
//进行接口API的统一管理
import { request } from "./axios";
export class search {
/**
* @description
* @param {string} keyword -
* @param {string} type -
* @param {number} page -
* @param {number} limit -
* @return {Promise}
*/
static async getArticleDetail(keyword: string, type: string, page: number = 1, limit: number = 10) {
return request("/index/search/apiSearch", { keyword, type, page, limit }, "get");
}
}

View File

@ -5,6 +5,8 @@
:root {
--white:#fff;
--primary-color: #409eff;
--primary-hover-color: #3c4ccf;
--primary-light-bg: #e7f3ff;
--success-color: #67c23a;
--warning-color: #e6a23c;
--danger-color: #f56c6c;
@ -21,6 +23,7 @@
--background-color: #f5f5f5;
--background-color-page: #f0f2f5;
--background-color-hover: #f7f8fd;
}
// 全局重置样式

View File

@ -47,6 +47,11 @@ const router = createRouter({
name: "downloadGames",
component: () => import("@/views/downloadGames/index.vue"),
},
{
path: "/search",
name: "search",
component: () => import("@/views/components/search.vue"),
},
],
});

View File

@ -81,7 +81,11 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import defaultImage from "@/assets/imgs/default.png";
const route = useRoute();
// Props
interface ResourceConfig {
@ -115,7 +119,7 @@ const currentCategoryName = computed(() => {
//
const getImageUrl = (imagePath: string) => {
if (!imagePath) return "/src/assets/imgs/default.png";
if (!imagePath) return defaultImage;
return import.meta.env.VITE_API_DOMAIN + imagePath;
};
@ -130,9 +134,14 @@ const fetchCategories = async () => {
Array.isArray(response.data.data)
) {
categories.value = response.data.data;
//
// category ID
if (categories.value.length > 0) {
const categoryIdFromRoute = route.query.category as string;
if (categoryIdFromRoute && categories.value.some(cat => cat.id === categoryIdFromRoute)) {
selectedCategory.value = categoryIdFromRoute;
} else {
selectedCategory.value = categories.value[0].id;
}
fetchResources();
}
} else {

View File

@ -5,6 +5,7 @@ import { ElMessage } from "element-plus";
import Header from "@/views/components/header.vue";
import Footer from "@/views/components/footer.vue";
import { article } from "@/api/article";
import defaultImage from "@/assets/imgs/default.png";
const route = useRoute();
const router = useRouter();
@ -14,7 +15,9 @@ const loading = ref(true);
//
const getImageUrl = (imagePath: string) => {
if (!imagePath) return "/src/assets/imgs/default.png";
if (!imagePath) {
return defaultImage;
}
return import.meta.env.VITE_API_DOMAIN + imagePath;
};
@ -56,6 +59,10 @@ const fetchArticleDetail = async () => {
};
onMounted(() => {
// defaultImage使tree-shaking
if (defaultImage) {
console.log('Default image loaded:', defaultImage);
}
fetchArticleDetail();
});
</script>
@ -537,6 +544,11 @@ onMounted(() => {
justify-content: space-between;
margin: 30px 0;
.prev,
.next {
width: 50%;
}
a {
color: #333;
}

View File

@ -5,6 +5,7 @@ import { ElMessage } from "element-plus";
import Header from "@/views/components/header.vue";
import Footer from "@/views/components/footer.vue";
import { resource as resourceApi } from "@/api/resource";
import defaultImage from "@/assets/imgs/default.png";
const route = useRoute();
const router = useRouter(); // useRouter
@ -27,7 +28,9 @@ const pageInfo = computed(() => ({
//
const getImageUrl = (imagePath: string) => {
if (!imagePath) return "/src/assets/imgs/default.png";
if (!imagePath) {
return defaultImage;
}
return import.meta.env.VITE_API_DOMAIN + imagePath;
};
@ -158,14 +161,12 @@ const fetchResourceDetail = async () => {
try {
const response: any = await resourceApi.getResourceDetail(resourceId.value);
console.log("获取资源详情响应:", response);
if (response?.data?.code === 0 && response?.data?.data) {
resourceData.value = response.data.data.resource;
if (response.data.code === 0) {
resourceData.value = response.data.data;
console.log(resourceData.value);
prevResource.value = response.data.data.prev_resource;
nextResource.value = response.data.data.next_resource;
relatedResources.value = response.data.data.related_resources || [];
console.log("资源数据加载完成:", resourceData.value?.title);
} else {
console.error("API响应格式错误:", response);
ElMessage.error("获取资源详情失败:数据格式错误");
@ -196,8 +197,6 @@ watch(
);
onMounted(() => {
// fetchResourceDetailwatch
//
window.scrollTo(0, 0);
});
</script>
@ -217,9 +216,7 @@ onMounted(() => {
<el-breadcrumb-item :to="{ path: '/' }"
>首页</el-breadcrumb-item
>
<el-breadcrumb-item :to="{ path: pageInfo.path }">{{
pageInfo.name
}}</el-breadcrumb-item>
<el-breadcrumb-item>{{ resourceData.cate }}</el-breadcrumb-item>
<el-breadcrumb-item>详情</el-breadcrumb-item>
</el-breadcrumb>
</div>
@ -332,6 +329,11 @@ onMounted(() => {
@click="handleContentClick"
></div>
<div class="py-5" style="color: #ccc; text-align: center">
-------- THE END --------
</div>
<el-divider></el-divider>
<div class="disclaimers">
<div class="disclaimer-item">
<div class="disclaimer-title">免责声明:</div>
@ -519,7 +521,7 @@ onMounted(() => {
.top-content {
width: 100%;
height: 400px;
background-color: #0081ff;
background-color: var(--primary-color);
position: relative;
.top-content-main {
@ -611,10 +613,10 @@ onMounted(() => {
}
.value {
border: 1px #0d6efd dashed;
border: 1px var(--primary-color) dashed;
padding: 3px 6px;
font-size: 13px;
background-color: #0081ff12;
background-color: var(--primary-light-bg);
border-radius: 5px;
}
}
@ -626,11 +628,11 @@ onMounted(() => {
align-items: center;
.codebtn {
color: #0d6efd;
color: var(--primary-color);
margin-left: 20px;
padding: 6px 20px;
border-radius: 8px;
border: 1px solid #0d6efd;
border: 1px solid var(--primary-color);
cursor: pointer;
transition: all 0.3s ease;
background-color: #fff;

View File

@ -1,4 +1,13 @@
<script setup lang="ts">
import { ArrowUp } from "@element-plus/icons-vue";
//
function goToTop() {
window.scrollTo({
top: 0,
behavior: "smooth",
});
}
</script>
<template>
<div class="footer">
@ -9,7 +18,7 @@
<div class="mr-20">
<img src="@/assets/imgs/logo-light.png" alt="Logo" height="70" />
<p class="text-white-50 my-4 f18" style="width: 400px">
美天智能科技这里是介绍
这里是介绍
</p>
</div>
@ -85,6 +94,13 @@
</div>
<div class="tongji"></div>
</section>
<!-- 回到顶部按钮 -->
<div class="go-to-top" @click="goToTop">
<el-icon class="go-to-top-icon">
<ArrowUp />
</el-icon>
</div>
</template>
<style lang="less" scoped>
.footer {
@ -279,4 +295,47 @@
}
}
}
//
.go-to-top {
position: fixed;
bottom: 30px;
right: 30px;
width: 50px;
height: 50px;
background: var(--primary-color, #409eff);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
z-index: 1000;
&:hover {
background: var(--primary-hover-color, #337ecc);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2);
}
.go-to-top-icon {
font-size: 24px;
color: #fff;
}
}
// -
@media (max-width: 768px) {
.go-to-top {
bottom: 20px;
right: 20px;
width: 45px;
height: 45px;
.go-to-top-icon {
font-size: 20px;
}
}
}
</style>

View File

@ -1,12 +1,22 @@
<script lang="ts" setup>
import { ref } from "vue";
import { ref, nextTick } from "vue";
import { useRouter } from "vue-router";
import { Search, User, Setting, SwitchButton } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { ElMessage, ElDialog } from "element-plus";
//
const showSearch = ref(false);
const showSearchDialog = ref(false);
const searchText = ref("");
const searchType = ref("articles");
const searchInput = ref();
const username = ref("管理员"); // store
const router = useRouter();
//
const searchTypeOptions = [
{ value: "articles", label: "文章" },
{ value: "resources", label: "资源" },
];
//
interface MenuItem {
@ -26,14 +36,34 @@ const staticMenuItems: MenuItem[] = [
//
const handleSearch = () => {
if (searchText.value.trim()) {
console.log("搜索:", searchText.value);
// TODO:
//
showSearch.value = false;
//
router.push({
path: "/search",
query: {
keyword: searchText.value.trim(),
type: searchType.value,
},
replace: true, //
});
//
showSearchDialog.value = false;
searchText.value = "";
searchType.value = "articles"; //
}
};
const openSearchDialog = async () => {
showSearchDialog.value = true;
await nextTick();
searchInput.value?.focus();
};
const closeSearchDialog = () => {
showSearchDialog.value = false;
searchText.value = "";
searchType.value = "articles";
};
const handleCommand = (command: string) => {
switch (command) {
case "profile":
@ -85,24 +115,10 @@ const handleCommand = (command: string) => {
<!-- 右侧工具栏 -->
<div class="header-right">
<!-- 搜索按钮 -->
<el-button link class="search-btn" @click="showSearch = !showSearch">
<el-button link class="search-btn" @click="openSearchDialog">
<el-icon><Search /></el-icon>
</el-button>
<!-- 搜索框可展开 -->
<el-input
v-if="showSearch"
v-model="searchText"
placeholder="搜索..."
size="small"
class="search-input"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<!-- 用户信息 -->
<div class="user-info">
<span class="username">{{ username }}</span>
@ -130,6 +146,57 @@ const handleCommand = (command: string) => {
</div>
</div>
</div>
<!-- 搜索弹窗 -->
<el-dialog
v-model="showSearchDialog"
title="搜索"
width="600px"
:before-close="closeSearchDialog"
center
>
<div class="search-dialog-content">
<div class="search-form-row">
<div class="search-form-item">
<el-select
v-model="searchType"
placeholder="选择类型"
size="large"
class="search-type-select"
>
<el-option
v-for="option in searchTypeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</div>
<div class="search-form-item">
<el-input
v-model="searchText"
placeholder="请输入搜索关键词..."
size="large"
clearable
@keyup.enter="handleSearch"
ref="searchInput"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeSearchDialog">取消</el-button>
<el-button type="primary" @click="handleSearch"> 搜索 </el-button>
</div>
</template>
</el-dialog>
</div>
</template>
@ -181,13 +248,13 @@ const handleCommand = (command: string) => {
transition: all 0.3s ease;
&:hover {
color: #409eff;
background: rgba(64, 158, 255, 0.1);
color: var(--primary-color);
background: var(--primary-light-bg);
}
&.router-link-active {
color: #409eff;
background: rgba(64, 158, 255, 0.1);
color: var(--primary-color);
background: var(--primary-light-bg);
}
}
}
@ -198,30 +265,28 @@ const handleCommand = (command: string) => {
.header-right {
display: flex;
align-items: center;
gap: 16px;
gap: 12px;
.search-btn {
color: #666;
flex-shrink: 0;
&:hover {
color: #409eff;
color: var(--primary-color);
}
}
.search-input {
width: 200px;
margin-right: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
flex-shrink: 0;
.username {
color: #333;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
}
.user-dropdown {
@ -233,7 +298,7 @@ const handleCommand = (command: string) => {
&:hover {
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
}
}
}
@ -266,11 +331,6 @@ const handleCommand = (command: string) => {
.header-right {
gap: 8px;
.search-input {
width: 150px;
margin-right: 8px;
}
.username {
display: none; //
}
@ -288,4 +348,67 @@ const handleCommand = (command: string) => {
}
}
}
//
.search-dialog-content {
padding: 20px 15px;
.search-form-row {
display: flex;
gap: 15px;
align-items: flex-start;
padding: 0 10px;
box-sizing: border-box;
.search-form-item {
&:first-child {
flex: 0 0 20%; // 4/10 = 40%
}
&:last-child {
flex: 0 0 80%; // 6/10 = 60%
}
.search-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #303133;
font-size: 14px;
}
.search-type-select {
width: 100%;
}
.el-input {
.el-input__inner {
border-radius: 8px;
font-size: 16px;
}
}
}
}
}
.dialog-footer {
text-align: right;
}
//
@media (max-width: 768px) {
.search-dialog-content {
.search-form-row {
flex-direction: column;
gap: 16px;
.search-form-item {
&:first-child,
&:last-child {
flex: none;
}
}
}
}
}
</style>

View File

@ -0,0 +1,332 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import Header from "@/views/components/header.vue";
import Footer from "@/views/components/footer.vue";
import { ElMessage } from "element-plus";
import { search } from "@/api/search";
//
const searchText = ref("");
const searchType = ref("articles");
const loading = ref(false);
const results = ref<any[]>([]);
const router = useRouter();
const route = useRoute();
const hasSearched = ref(false);
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);
const totalPages = ref(0);
//
const searchTypeOptions = [
{ value: "articles", label: "文章" },
{ value: "resources", label: "资源" },
];
const handleSearch = async (page: number = 1) => {
if (!searchText.value.trim()) {
ElMessage.warning("请输入搜索关键词");
return;
}
results.value = [];
loading.value = true;
try {
const res = await search.getArticleDetail(
searchText.value.trim(),
searchType.value,
page,
pageSize.value
);
results.value = res.data.data?.items || [];
total.value = res.data.data?.total || 0;
totalPages.value = res.data.data?.total_pages || 0;
currentPage.value = page;
hasSearched.value = true;
//
if (total.value > 0 && totalPages.value <= 1) {
totalPages.value = Math.ceil(total.value / pageSize.value);
}
} catch (error) {
console.error("搜索失败:", error);
ElMessage.error("搜索失败,请稍后重试");
results.value = [];
total.value = 0;
totalPages.value = 0;
} finally {
loading.value = false;
}
};
const handleResultClick = (item: any) => {
//
if (searchType.value === "articles") {
router.push({ path: "/article", query: { id: item.id } });
} else if (searchType.value === "resources") {
router.push({ path: "/resource", query: { id: item.id } });
}
};
//
const handleCurrentChange = (page: number) => {
handleSearch(page);
};
const handleSizeChange = (size: number) => {
pageSize.value = size;
handleSearch(1);
};
//
if (route.query.keyword) {
searchText.value = String(route.query.keyword);
if (route.query.type) {
searchType.value = String(route.query.type);
}
handleSearch();
}
//
watch(
() => route.query,
(newQuery) => {
if (newQuery.keyword) {
const keyword = String(newQuery.keyword);
const type = newQuery.type ? String(newQuery.type) : "articles";
//
if (keyword !== searchText.value || type !== searchType.value) {
searchText.value = keyword;
searchType.value = type;
handleSearch();
}
}
},
{ deep: true }
);
</script>
<template>
<Header />
<div class="search-page-container">
<div class="search-bar">
<el-select
v-model="searchType"
placeholder="选择类型"
class="search-type-select"
size="large"
>
<el-option
v-for="option in searchTypeOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<input
v-model="searchText"
@keyup.enter="handleSearch"
placeholder="请输入搜索关键词"
class="search-input"
/>
<button @click="handleSearch" class="search-btn">搜索</button>
</div>
<el-divider></el-divider>
<div class="search-results-area">
<template v-if="loading">
<div class="search-loading">正在搜索请稍候</div>
</template>
<template v-else>
<template v-if="hasSearched">
<template v-if="results.length === 0">
<div class="search-no-result">没有找到相关内容</div>
</template>
<ul v-else class="search-result-list">
<li
class="search-result-item"
v-for="(item, idx) in results"
:key="item.id"
@click="handleResultClick(item)"
tabindex="0"
>
<div class="result-title">
<span class="tag" v-if="item.category?.name">{{
item.category.name
}}</span>
<div class="title">{{ item.title }}</div>
</div>
<div class="result-meta">
<span class="result-author" v-if="item.author">
<i class="el-icon-user"></i> {{ item.author }}
</span>
<span class="result-date" v-if="item.publishdate">
<i class="el-icon-time"></i> {{ item.publishdate }}
</span>
</div>
</li>
</ul>
</template>
</template>
<!-- 分页 -->
<div v-if="hasSearched && total > 0" class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
<Footer />
</template>
<style scoped>
.search-page-container {
max-width: 800px;
margin: 120px auto 20px;
padding: 30px 20px 60px;
background: #fff;
min-height: 70vh;
border-radius: 10px;
box-shadow: 0 4px 24px rgba(76, 80, 130, 0.08);
}
.search-bar {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-bottom: 30px;
}
.search-type-select {
width: 100px;
height: 38px;
}
.search-input {
width: 320px;
height: 38px;
font-size: 16px;
border: 1px solid #cccccc;
border-radius: 5px;
padding: 0 15px;
transition: border-color 0.2s;
outline: none;
}
.search-input:focus {
border-color: var(--primary-color);
}
.search-btn {
height: 38px;
padding: 0 28px;
border-radius: 5px;
background: var(--primary-color);
color: #fff;
border: none;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.search-btn:hover {
background: var(--primary-hover-color);
}
.search-results-area {
margin-top: 22px;
min-height: 200px;
}
.search-loading {
text-align: center;
color: #777;
font-size: 18px;
}
.search-no-result {
text-align: center;
color: #bbb;
font-size: 17px;
padding: 60px 0 80px;
}
.search-result-list {
list-style: none;
padding: 0;
margin: 0;
}
.search-result-item {
padding: 18px 20px 12px 20px;
border-bottom: 1px dashed #f0f0f0;
margin-bottom: 8px;
cursor: pointer;
user-select: none;
}
.search-result-item:focus,
.search-result-item:hover {
background: var(--background-color-hover);
}
.result-title {
font-weight: bold;
font-size: 18px;
color: #262f46;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 16px;
}
.result-title .tag {
background: var(--primary-light-bg);
color: var(--primary-color);
font-size: 13px;
border-radius: 3px;
padding: 4px 8px;
margin-left: 8px;
width: 100px;
text-align: center;
}
.result-title .title{
width: 100%;
}
.result-meta {
display: flex;
align-items: center;
gap: 20px;
font-size: 14px;
color: #909399;
.result-author,
.result-date {
display: flex;
align-items: center;
gap: 4px;
}
.el-icon-user,
.el-icon-time {
font-size: 12px;
}
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 40px;
padding: 20px 0;
}
</style>