更新:增加“搜索”功能

This commit is contained in:
2025-11-13 10:31:08 +08:00
parent d2389b2ed1
commit 6ba3781375
7 changed files with 564 additions and 5 deletions

View File

@@ -6,6 +6,7 @@ import type {
IUserLateCourseListResponse, IUserLateCourseListResponse,
IMarketCourseListResponse IMarketCourseListResponse
} from '@/types/course' } from '@/types/course'
import type { ISearchRequest, ISearchResponse } from '@/types/search'
const client = createRequestClient({ baseURL: SERVICE_MAP.MAIN }) const client = createRequestClient({ baseURL: SERVICE_MAP.MAIN })
@@ -50,5 +51,18 @@ export const courseApi = {
method: 'POST', method: 'POST',
data: { id, limit, page } data: { id, limit, page }
}) })
},
/**
* 搜索课程和商品
* @param data 搜索参数 { keyWord, appName }
* @returns 搜索结果
*/
searchData(data: ISearchRequest) {
return client.request<ISearchResponse>({
url: 'bookAbroad/home/searchCourse',
method: 'POST',
data
})
} }
} }

View File

@@ -306,5 +306,21 @@
"moreTryListen": "More Trials", "moreTryListen": "More Trials",
"buy": "Buy", "buy": "Buy",
"searchPlaceholder": "Search courses..." "searchPlaceholder": "Search courses..."
},
"courseSearch": {
"title": "Search",
"placeholder": "Search courses and products...",
"historyTitle": "Search History",
"productTitle": "Products",
"courseTitle": "Courses",
"pleaseInputKeyword": "Please enter keyword",
"noData": "No data",
"vipDiscount": "VIP Discount",
"free": "Free",
"levelBeginner": "Beginner",
"levelAdvanced": "Advanced",
"required": "Required",
"elective": "Elective",
"each": "Each"
} }
} }

View File

@@ -307,5 +307,21 @@
"moreTryListen": "更多试听", "moreTryListen": "更多试听",
"buy": "购买", "buy": "购买",
"searchPlaceholder": "搜索课程..." "searchPlaceholder": "搜索课程..."
},
"courseSearch": {
"title": "搜索",
"placeholder": "搜索课程和商品...",
"historyTitle": "历史搜索",
"productTitle": "实物商品",
"courseTitle": "课程信息",
"pleaseInputKeyword": "请输入关键字",
"noData": "暂无数据",
"vipDiscount": "VIP优惠",
"free": "免费",
"levelBeginner": "初级",
"levelAdvanced": "高级",
"required": "必修",
"elective": "选修",
"each": "各"
} }
} }

View File

@@ -121,6 +121,12 @@
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTitleText": "%order.orderTitle%" "navigationBarTitleText": "%order.orderTitle%"
} }
}, {
"path": "pages/course/search",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "%courseSearch.title%"
}
} }
], ],
"tabBar": { "tabBar": {

456
pages/course/search.vue Normal file
View File

@@ -0,0 +1,456 @@
<template>
<view class="search-page">
<!-- 导航栏 -->
<nav-bar>
<template #title>
<view class="search-box">
<wd-search
v-model="keyword"
hide-cancel
clearable
:placeholder="$t('courseSearch.placeholder')"
@search="handleSearch"
@clear="handleClear"
/>
</view>
</template>
</nav-bar>
<!-- 历史搜索区域 -->
<view v-if="!showResults" class="history-section">
<view class="history-title">{{ $t('courseSearch.historyTitle') }}</view>
<view class="history-tags">
<wd-tag
v-for="(item, index) in historyList"
:key="index"
@click="handleHistoryClick(item)"
>
{{ item }}
</wd-tag>
</view>
</view>
<!-- 搜索结果区域 -->
<view v-if="showResults" class="search-results">
<!-- 加载状态 -->
<view v-if="loading" class="loading-wrapper">
<wd-loading />
</view>
<!-- 课程列表 -->
<view v-if="!loading && courseList.length > 0" class="course-section">
<view class="course-list">
<view
v-for="(item, index) in courseList"
:key="index"
class="course-item"
@click="goCourseDetail(item.id)"
>
<image :src="item.squareImage" class="course-image" mode="aspectFit" />
<view class="course-info">
<view class="course-title">{{ item.title }}</view>
<view class="course-content" v-html="item.content"></view>
<!-- 课程标签 -->
<view class="course-tags">
<wd-tag v-if="item.level && item.level !== 0" type="primary">
{{ item.level === 1 ? $t('courseSearch.levelBeginner') : $t('courseSearch.levelAdvanced') }}
</wd-tag>
<wd-tag v-if="item.selective === 1" type="warning">
{{ $t('courseSearch.required') }}
</wd-tag>
<wd-tag v-if="item.selective === 2" type="success">
{{ $t('courseSearch.elective') }}
</wd-tag>
</view>
</view>
<!-- 课程价格 -->
<view class="course-price">
<text v-if="item.courseCatalogueEntityList.length === 1">
{{
item.courseCatalogueEntityList[0].halfFee === 0
? $t('courseSearch.free')
: `¥${item.courseCatalogueEntityList[0].halfFee}/${item.courseCatalogueEntityList[0].fee}`
}}
</text>
<text v-if="item.courseCatalogueEntityList.length > 1">
<text v-for="(v, i) in item.courseCatalogueEntityList" :key="i">
{{ formatCatalogueTitle(v.title) }}
<text v-if="i !== item.courseCatalogueEntityList.length - 1">/</text>
</text>
{{
item.courseCatalogueEntityList[0].halfFee === 0
? $t('courseSearch.free')
: ` ${$t('courseSearch.each')}${item.courseCatalogueEntityList[0].halfFee}/${item.courseCatalogueEntityList[0].fee}`
}}
</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="!loading && isEmpty" class="empty-wrapper">
<wd-divider :text="$t('courseSearch.noData')" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import { courseApi } from '@/api/modules/course'
import type { ICourse } from '@/types/search'
const { t } = useI18n()
// 状态定义
const keyword = ref<string>('') // 搜索关键词
const historyList = ref<string[]>([]) // 历史搜索记录
const courseList = ref<ICourse[]>([]) // 课程列表
const showResults = ref<boolean>(false) // 是否显示搜索结果
const loading = ref<boolean>(false) // 加载状态
const isEmpty = ref<boolean>(false) // 是否为空结果
/**
* 获取历史搜索记录
*/
const getHistory = () => {
try {
let records = uni.getStorageSync('hisRecords') || []
records = [...new Set(records)]
if (records.length > 10) {
records = records.slice(0, 10)
}
historyList.value = records
uni.setStorageSync('hisRecords', records)
} catch (error) {
console.error('获取历史记录失败:', error)
historyList.value = []
}
}
/**
* 保存搜索记录
*/
const saveHistory = (kw: string) => {
try {
let records = uni.getStorageSync('hisRecords') || []
records.unshift(kw)
uni.setStorageSync('hisRecords', records)
} catch (error) {
console.error('保存历史记录失败:', error)
}
}
/**
* 执行搜索
*/
const handleSearch = async () => {
if (!keyword.value.trim()) {
uni.showToast({
icon: 'none',
title: t('courseSearch.pleaseInputKeyword')
})
return
}
loading.value = true
showResults.value = true
isEmpty.value = false
try {
const res = await courseApi.searchData({
title: keyword.value.trim()
})
if (res.code === 0) {
courseList.value = res.courseEntities || []
isEmpty.value = courseList.value.length === 0
// 保存搜索历史
saveHistory(keyword.value.trim())
} else {
courseList.value = []
isEmpty.value = true
}
} catch (error) {
console.error('搜索失败:', error)
uni.showToast({
icon: 'none',
title: t('global.networkConnectionError')
})
courseList.value = []
isEmpty.value = true
} finally {
loading.value = false
}
}
/**
* 点击历史标签搜索
*/
const handleHistoryClick = (item: string) => {
keyword.value = item
handleSearch()
}
/**
* 清空搜索
*/
const handleClear = () => {
keyword.value = ''
showResults.value = false
courseList.value = []
isEmpty.value = false
getHistory()
}
/**
* 跳转到课程详情
*/
const goCourseDetail = (courseId: number) => {
uni.navigateTo({
url: `/pages/course/courseDetail?id=${courseId}`
})
}
/**
* 格式化课程目录标题
* 提取"上/中/下"
*/
const formatCatalogueTitle = (title: string): string => {
const keywords = ['上部', '中部', '下部']
const result: string[] = []
keywords.forEach((keyword) => {
if (title.includes(keyword)) {
result.push(keyword.substring(0, 1))
}
})
return result.join('')
}
/**
* 页面挂载
*/
onMounted(() => {
getHistory()
})
/**
* 页面显示
*/
onShow(() => {
getHistory()
})
</script>
<style lang="scss" scoped>
.search-page {
min-height: 100vh;
background: #f7faf9;
}
.search-box {
display: flex;
height: 100%;
align-items: center;
width: 100%;
--wot-search-padding: 0;
--wot-search-side-padding: 0;
:deep(.wd-search) {
background: transparent;
width: 100%;
}
}
// 历史搜索样式
.history-section {
padding: 40rpx;
.history-title {
font-size: 30rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.history-tags {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
}
// 搜索结果样式
.search-results {
padding: 20rpx;
}
.loading-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 100rpx 0;
}
.empty-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 100rpx 0;
}
.section-title {
font-size: 40rpx;
color: #1e40af;
font-weight: bold;
letter-spacing: 2rpx;
margin-bottom: 30rpx;
text-shadow: 0 2rpx 4rpx rgba(0, 122, 255, 0.6);
}
// 商品网格样式
.product-section {
margin-bottom: 40rpx;
}
.product-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
}
.product-item {
background: #fff;
border-radius: 15rpx;
padding: 14rpx;
position: relative;
overflow: hidden;
.vip-badge {
position: absolute;
top: 10rpx;
left: 10rpx;
z-index: 10;
background: #f94f04;
color: #fff;
font-size: 22rpx;
font-weight: bold;
padding: 4rpx 8rpx;
border-radius: 4rpx;
}
.product-image {
width: 100%;
height: 220rpx;
border-radius: 10rpx;
background: #f0f0f0;
}
.product-name {
font-size: 24rpx;
font-weight: bold;
margin-top: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-price {
font-size: 26rpx;
margin-top: 10rpx;
.price-vip {
color: #e97512;
font-size: 26rpx;
font-weight: bold;
}
.price-original {
color: #8a8a8a;
font-size: 22rpx;
margin-left: 8rpx;
text-decoration: line-through;
}
.price-normal {
color: #e97512;
font-size: 26rpx;
font-weight: bold;
}
}
}
// 课程列表样式
.course-section {
margin-bottom: 40rpx;
}
.course-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.course-item {
display: flex;
background: #fff;
border-radius: 20rpx;
padding: 20rpx;
position: relative;
box-shadow: 0 0 10rpx rgba(0, 0, 0, 0.1);
.course-image {
width: 250rpx;
height: 250rpx;
margin-right: 20rpx;
border-radius: 10rpx;
background: #f0f0f0;
flex-shrink: 0;
}
.course-info {
flex: 1;
display: flex;
flex-direction: column;
.course-title {
font-size: 30rpx;
font-weight: bold;
color: #000;
margin-bottom: 10rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.course-content {
font-size: 24rpx;
color: #9c9c9c;
line-height: 30rpx;
height: 60rpx;
overflow: hidden;
margin-bottom: 10rpx;
}
.course-tags {
display: flex;
gap: 10rpx;
flex-wrap: wrap;
}
}
.course-price {
position: absolute;
bottom: 20rpx;
right: 20rpx;
font-size: 32rpx;
font-weight: 500;
color: red;
}
}
</style>

View File

@@ -292,6 +292,10 @@
.filter { .filter {
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
} }
.backdrop-filter {
-webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);
}
.transition { .transition {
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events;
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
@@ -488,6 +492,42 @@
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
} }
@property --tw-backdrop-blur {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-brightness {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-contrast {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-grayscale {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-hue-rotate {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-invert {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-opacity {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-saturate {
syntax: "*";
inherits: false;
}
@property --tw-backdrop-sepia {
syntax: "*";
inherits: false;
}
@property --tw-ease { @property --tw-ease {
syntax: "*"; syntax: "*";
inherits: false; inherits: false;
@@ -534,6 +574,15 @@
--tw-drop-shadow-color: initial; --tw-drop-shadow-color: initial;
--tw-drop-shadow-alpha: 100%; --tw-drop-shadow-alpha: 100%;
--tw-drop-shadow-size: initial; --tw-drop-shadow-size: initial;
--tw-backdrop-blur: initial;
--tw-backdrop-brightness: initial;
--tw-backdrop-contrast: initial;
--tw-backdrop-grayscale: initial;
--tw-backdrop-hue-rotate: initial;
--tw-backdrop-invert: initial;
--tw-backdrop-opacity: initial;
--tw-backdrop-saturate: initial;
--tw-backdrop-sepia: initial;
--tw-ease: initial; --tw-ease: initial;
} }
} }

View File

@@ -4,6 +4,7 @@ page {
--wot-fs-title: 18px; // 标题字号/重要正文字号 --wot-fs-title: 18px; // 标题字号/重要正文字号
--wot-fs-content: 16px; // 普通正文 --wot-fs-content: 16px; // 普通正文
--wot-fs-secondary: 14px; // 次要信息,注释/补充/正文 --wot-fs-secondary: 14px; // 次要信息,注释/补充/正文
--wot-fs-tertiary: 12px; // 次次要信息,注释/补充/正文
// 导航栏 // 导航栏
--wot-navbar-background: #fff; --wot-navbar-background: #fff;
@@ -130,8 +131,9 @@ uni-textarea {
// font-size: var(--wot-fs-content) !important; // font-size: var(--wot-fs-content) !important;
// } // }
// // tag // tag
// .wd-tag { .wd-tag {
// font-size: var(--wot-fs-secondary) !important; font-size: var(--wot-fs-tertiary) !important;
// border-radius: 4px !important; border-radius: 4px !important;
// } padding: 2px 6px !important;
}