feat:搜索结果展示

This commit is contained in:
小张 2025-09-12 03:11:18 +08:00
parent 7551571f0a
commit 0c638147f2
4 changed files with 801 additions and 2 deletions

BIN
public/serch/背景.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 MiB

View File

@ -286,8 +286,85 @@ export class CourseApi {
} }
// 搜索课程 // 搜索课程
static searchCourses(params: SearchRequest): Promise<ApiResponse<PaginationResponse<Course>>> { static async searchCourses(params: {
return ApiRequest.get('/courses/search', params) keyword?: string
limit?: string
page?: number
}): Promise<ApiResponse<Course[]> & { total?: number }> {
try {
console.log('🔍 搜索课程:', params)
const queryParams: any = {}
if (params.keyword) queryParams.keyword = params.keyword
if (params.limit) queryParams.limit = params.limit
const response = await ApiRequest.get<any>('/aiol/index/search', queryParams)
console.log('✅ 搜索课程成功:', response)
// 处理后端响应格式
if (response.data && response.data.success && response.data.result) {
// 转换后端数据格式为前端格式
const courses: Course[] = response.data.result.map((item: BackendCourseItem) => ({
id: item.id,
title: item.name || '',
description: item.description || '',
instructor: item.school || '未知讲师',
teacherList: item.teacherList || [],
duration: item.arrangement || '待定',
level: this.mapDifficultyToLevel(item.difficulty),
category: item.subject || '其他',
thumbnail: item.cover || '',
price: 0,
rating: 0,
studentsCount: item.enrollCount || 0,
lessonsCount: 0,
tags: [],
isEnrolled: item.isEnrolled || false,
progress: 0,
createdAt: this.formatTimestamp(item.createTime),
updatedAt: this.formatTimestamp(item.updateTime),
status: item.status === 1 ? 'published' : 'draft',
enrollmentCount: item.enrollCount || 0,
maxEnrollment: item.maxEnroll || 0,
startDate: item.startTime || '',
endDate: item.endTime || '',
outline: item.outline || '',
prerequisite: item.prerequisite || '',
reference: item.reference || '',
target: item.target || '',
question: item.question || '',
video: item.video || '',
izAi: item.izAi,
// 添加AI伴学相关字段
hasAiCompanion: item.izAi === 1,
aiEnabled: item.izAi === 1,
instructors: item.teacherList || []
}))
return {
code: 200,
message: '搜索成功',
data: courses,
total: courses.length
}
} else {
console.warn('⚠️ 搜索API返回格式异常:', response)
return {
code: 500,
message: response.data?.message || '搜索失败',
data: [],
total: 0
}
}
} catch (error: any) {
console.error('❌ 搜索课程失败:', error)
return {
code: 500,
message: error.message || '搜索失败',
data: [],
total: 0
}
}
} }
// 获取热门课程 // 获取热门课程

View File

@ -558,6 +558,12 @@ const routes: RouteRecordRaw[] = [
component: Courses, component: Courses,
meta: { title: '课程列表' } meta: { title: '课程列表' }
}, },
{
path: '/search',
name: 'SearchResults',
component: () => import('@/views/SearchResults.vue'),
meta: { title: '搜索结果' }
},
{ {
path: '/course/:id', path: '/course/:id',
name: 'CourseDetail', name: 'CourseDetail',

716
src/views/SearchResults.vue Normal file
View File

@ -0,0 +1,716 @@
<template>
<div class="search-results-page">
<!-- 背景图片 -->
<div class="background-container">
<img src="/serch/背景.png" alt="背景" class="background-image" />
</div>
<!-- 主要内容 -->
<div class="main-content">
<div class="container">
<!-- 页面标题 -->
<div class="page-header">
<h1 class="page-title">资源库·搜索</h1>
</div>
<!-- 搜索框 -->
<div class="search-container">
<div class="search-box">
<input
v-model="searchKeyword"
type="text"
placeholder="搜索课程..."
class="search-input"
@keyup.enter="handleSearch"
/>
<button class="search-btn" @click="handleSearch">
<img
src="https://lanhu-oss-proxy.lanhuapp.com/SketchPng4617c5d0e102114051f8321ef18af957e78bb797ad196789befab13c980617fc"
alt="搜索"
class="search-icon"
/>
</button>
</div>
</div>
<!-- 搜索结果统计 -->
<div class="search-stats" v-if="searchResults.length > 0 || hasSearched">
<span class="stats-text">显示结果{{ searchResults.length }}</span>
<div class="sort-dropdown">
<span>筛选</span>
<span class="dropdown-icon"></span>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<n-spin size="large" />
<p>正在搜索...</p>
</div>
<!-- 搜索结果 -->
<div class="search-results" v-else-if="searchResults.length > 0">
<!-- 课程网格 -->
<div class="courses-grid">
<div class="course-card" v-for="course in searchResults" :key="course.id">
<div class="course-image">
<img :src="course.thumbnail" :alt="course.title" />
<!-- AI伴学标签 -->
<div v-if="shouldShowAiTag(course)" class="ai-companion-tag">
<img src="/images/aiCompanion/AI伴学标签@2x.png" alt="AI伴学" class="ai-tag-image">
</div>
</div>
<div class="course-info">
<h3 class="course-title">{{ getCourseTitle(course) }}</h3>
<div class="course-meta">
<div class="course-chapters">
<img src="/images/courses/课程总章数.png" alt="课程章节" class="meta-icon">
<span>共9章54节</span>
</div>
<div class="course-duration">
<img src="/images/courses/课程总时长.png" alt="课程时长" class="meta-icon">
<span>12小时43分钟</span>
</div>
</div>
<div class="course-footer">
<div class="course-stats">
<span class="course-students">讲师: {{ getCourseInstructors(course) }}</span>
</div>
<button
:class="getButtonClass(course)"
@click="goToCourseDetail(course)"
>
<img
v-if="shouldShowButtonIcon(course)"
:src="getButtonIcon(course)"
alt="AI图标"
class="button-icon"
/>
{{ getButtonText(course) }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 无搜索结果 -->
<div v-else-if="hasSearched && !loading" class="no-results">
<div class="no-results-content">
<div class="no-results-icon">🔍</div>
<h3>未找到相关课程</h3>
<p>请尝试其他关键词或检查拼写</p>
</div>
</div>
<!-- 初始状态 -->
<div v-else class="initial-state">
<div class="initial-content">
<div class="placeholder-icon">🔍</div>
<h3>输入关键词搜索课程</h3>
<p>发现更多优质学习资源</p>
</div>
</div>
<!-- 分页 -->
<div v-if="searchResults.length > 0" class="pagination-container">
<n-pagination
v-model:page="currentPage"
:page-count="totalPages"
:page-size="pageSize"
show-size-picker
:page-sizes="[10, 20, 30, 50]"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { CourseApi } from '@/api'
import type { Course } from '@/api/types'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const message = useMessage()
const userStore = useUserStore()
//
const searchKeyword = ref('')
const searchResults = ref<Course[]>([])
const loading = ref(false)
const hasSearched = ref(false)
const currentPage = ref(1)
const pageSize = ref(20)
const totalResults = ref(0)
//
const totalPages = computed(() => Math.ceil(totalResults.value / pageSize.value))
//
const handleSearch = async () => {
if (!searchKeyword.value.trim()) {
message.warning('请输入搜索关键词')
return
}
loading.value = true
hasSearched.value = true
try {
console.log('🔍 开始搜索:', searchKeyword.value)
const response = await CourseApi.searchCourses({
keyword: searchKeyword.value.trim(),
limit: pageSize.value.toString(),
page: currentPage.value
})
console.log('📊 搜索结果:', response)
if (response.code === 200 || response.code === 0) {
searchResults.value = response.data || []
totalResults.value = response.total || searchResults.value.length
// URL
router.replace({
query: {
...route.query,
keyword: searchKeyword.value,
page: currentPage.value.toString()
}
})
} else {
message.error(response.message || '搜索失败')
searchResults.value = []
}
} catch (error: any) {
console.error('❌ 搜索失败:', error)
message.error('搜索失败,请稍后重试')
searchResults.value = []
} finally {
loading.value = false
}
}
//
const handlePageChange = (page: number) => {
currentPage.value = page
handleSearch()
}
const handlePageSizeChange = (size: number) => {
pageSize.value = size
currentPage.value = 1
handleSearch()
}
// courses
const shouldShowAiTag = (course: any) => {
return course.hasAiCompanion || course.aiEnabled || course.izAi === 1
}
const getCourseTitle = (course: any) => {
return course.title || course.name || '课程标题'
}
const getCourseInstructors = (course: any) => {
if (course.instructors && course.instructors.length > 0) {
return course.instructors.map((instructor: any) => instructor.name).join(', ')
}
if (course.teacherList && course.teacherList.length > 0) {
return course.teacherList.map((teacher: any) => teacher.name).join(', ')
}
return course.instructor || course.school || '未知讲师'
}
// courses
const getButtonText = (course: any) => {
const isAi = course?.izAi === 1
const isEnrolled = course?.isEnrolled === true
console.log('🔍 按钮文本逻辑:', {
courseId: course?.id,
courseName: course?.title || course?.name,
isAi,
isEnrolled,
izAi: course?.izAi
})
if (isAi) {
// AIisEnrolled=true""isEnrolled=false""
return isEnrolled ? '去学习' : '去报名'
} else {
// isEnrolled=true""isEnrolled=false""
return isEnrolled ? '去学习' : '去报名'
}
}
// courses
const getButtonClass = (course: any) => {
const isAi = course?.izAi === 1
return isAi ? 'enroll-btn ai-btn' : 'enroll-btn'
}
// courses
const shouldShowButtonIcon = (course: any) => {
return course?.izAi === 1
}
// courses
const getButtonIcon = (course: any) => {
const isEnrolled = course?.isEnrolled === true
// isEnrolled=truecourseAi.pngisEnrolled=falsecourseAii.png
return isEnrolled ? '/images/course/courseAi.png' : '/images/course/courseAii.png'
}
// courses
const goToCourseDetail = async (course: any) => {
try {
//
if (!userStore.isLoggedIn) {
console.log('用户未登录跳转到AI伴学页面')
router.push(`/ai-companion?courseId=${course.id}`)
return
}
console.log('检查课程报名状态课程ID:', course.id)
//
const response = await CourseApi.checkEnrollmentStatus(String(course.id))
if ((response.code === 0 || response.code === 200) && response.data) {
const isEnrolled = response.data.result
if (isEnrolled) {
//
console.log('用户已报名,跳转到已兑换页面')
router.push(`/course/${course.id}/exchanged`)
} else {
// AI
console.log('用户未报名跳转到AI伴学页面')
router.push(`/ai-companion?courseId=${course.id}`)
}
} else {
// AI
console.warn('查询报名状态失败跳转到AI伴学页面')
router.push(`/ai-companion?courseId=${course.id}`)
}
} catch (error) {
console.error('检查报名状态时发生错误:', error)
// AI
router.push(`/ai-companion?courseId=${course.id}`)
}
}
// URL
onMounted(() => {
const keyword = (route.query.keyword || route.query.q) as string
const page = parseInt(route.query.page as string) || 1
if (keyword) {
searchKeyword.value = keyword
currentPage.value = page
handleSearch()
}
})
</script>
<style scoped>
.search-results-page {
min-height: 100vh;
position: relative;
background: #F5F8FB;
}
.background-container {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 0;
}
.background-image {
width: 100%;
height: auto;
display: block;
}
.main-content {
position: relative;
z-index: 1;
padding: 40px 0;
}
.container {
width: 1420px;
margin: 0 auto;
padding: 0 0;
box-sizing: border-box;
}
.page-header {
text-align: center;
margin-bottom: 40px;
}
.page-title {
font-size: 32px;
font-weight: 600;
color: #333;
margin: 0;
}
.search-container {
display: flex;
justify-content: center;
margin-bottom: 40px;
}
.search-box {
position: relative;
width: 100%;
max-width: 600px;
}
.search-input {
width: 100%;
height: 48px;
padding: 0 60px 0 20px;
border: 1px solid #E6E6E6;
border-radius: 24px;
font-size: 16px;
background: white;
outline: none;
transition: border-color 0.3s;
}
.search-input:focus {
border-color: #0288D1;
}
.search-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
border: none;
background: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.search-icon {
width: 20px;
height: 20px;
}
.search-stats {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.stats-text {
font-size: 16px;
color: #666;
}
.sort-dropdown {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #666;
}
.dropdown-icon {
font-size: 12px;
color: #666;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 0;
gap: 20px;
}
.courses-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
column-gap: 20px;
row-gap: 24px;
margin-bottom: 40px;
width: 100%;
box-sizing: border-box;
justify-content: center;
}
.course-card {
background: white;
border-radius: 3px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
width: 268px;
height: 350px;
display: flex;
flex-direction: column;
}
.course-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.course-image {
width: 100%;
height: 180px;
overflow: hidden;
border-radius: 8px 8px 0 0;
position: relative;
flex-shrink: 0;
}
.course-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* AI伴学标签样式 */
.ai-companion-tag {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
}
.ai-tag-image {
width: auto;
height: auto;
max-width: 60px;
max-height: 30px;
display: block;
}
.course-info {
padding: 16px;
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.course-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin: 0 0 12px 0;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.course-meta {
display: flex;
justify-content: left;
gap: 10px;
font-size: 12px;
color: #999;
margin-bottom: 16px;
}
.course-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.course-chapters,
.course-duration {
display: flex;
align-items: center;
font-size: 12px;
color: #666;
}
.meta-icon {
width: 16px;
height: 16px;
margin-right: 4px;
vertical-align: middle;
}
.course-students {
color: #999;
font-size: 12px;
}
.enroll-btn {
background: #0286D5;
color: white;
border: none;
padding: 6px 10px;
border-radius: 20px;
cursor: pointer;
font-size: 12px;
transition: background 0.2s;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.enroll-btn:hover {
background: #40a9ff;
}
/* AI伴学按钮样式 */
.enroll-btn.ai-btn {
background: linear-gradient(135deg, #33C4FF 0%, #0088D1 100%);
}
.enroll-btn.ai-btn:hover {
background: linear-gradient(135deg, #2bb3ff 0%, #0077b8 100%);
}
/* 按钮图标样式 */
.button-icon {
width: 15px;
height: 15px;
object-fit: contain;
flex-shrink: 0;
margin-top: 2px; /* 向下调整图标位置 */
}
.no-results,
.initial-state {
display: flex;
justify-content: center;
align-items: center;
padding: 80px 0;
}
.no-results-content,
.initial-content {
text-align: center;
}
.no-results-icon,
.placeholder-icon {
font-size: 80px;
margin-bottom: 24px;
opacity: 0.6;
display: flex;
justify-content: center;
align-items: center;
}
.no-results-content h3,
.initial-content h3 {
font-size: 24px;
color: #333;
margin: 0 0 12px 0;
}
.no-results-content p,
.initial-content p {
font-size: 16px;
color: #666;
margin: 0;
}
.pagination-container {
display: flex;
justify-content: center;
padding: 40px 0;
}
/* 响应式设计 */
@media (min-width: 1600px) {
.courses-grid {
grid-template-columns: repeat(5, 1fr);
}
}
@media (min-width: 1400px) {
.container {
max-width: 1420px;
}
}
@media (max-width: 1400px) {
.courses-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1420px) {
.container {
max-width: calc(100vw - 160px);
padding: 0 20px;
}
}
@media (max-width: 992px) {
.courses-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.container {
max-width: calc(100vw - 40px);
padding: 0 20px;
}
.courses-grid {
grid-template-columns: repeat(2, 1fr);
column-gap: 16px;
row-gap: 20px;
}
.search-stats {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
}
@media (max-width: 768px) {
.container {
margin: 0 20px;
}
}
@media (max-width: 480px) {
.courses-grid {
grid-template-columns: 1fr;
}
.container {
margin: 0 16px;
}
}
</style>