tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/search/controller/AppSearchController.java
New file @@ -0,0 +1,36 @@ package com.tcm.training.admin.app.search.controller; import com.tcm.training.admin.app.search.service.AppSearchService; import com.tcm.training.admin.app.search.vo.AppSearchResultVO; import com.tcm.training.admin.common.ApiResponse; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** * APP 首页搜索接口。 */ @Validated @RestController @RequestMapping({"/api/app", "/api/v1/app"}) public class AppSearchController { private final AppSearchService appSearchService; /** * 注入 APP 首页搜索服务。 */ public AppSearchController(AppSearchService appSearchService) { this.appSearchService = appSearchService; } /** * 按关键字搜索养生课程与健康科普内容。 */ @GetMapping("/search") public ApiResponse<AppSearchResultVO> search(@RequestParam("keyword") String keyword) { return ApiResponse.ok(appSearchService.search(keyword)); } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/search/service/AppSearchService.java
New file @@ -0,0 +1,149 @@ package com.tcm.training.admin.app.search.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.tcm.training.admin.app.search.vo.AppSearchHealthCourseVO; import com.tcm.training.admin.app.search.vo.AppSearchHealthPopularizationVO; import com.tcm.training.admin.app.search.vo.AppSearchResultVO; import com.tcm.training.admin.live.entity.HealthPopularization; import com.tcm.training.admin.live.mapper.HealthCourseMapper; import com.tcm.training.admin.live.mapper.HealthPopularizationMapper; import com.tcm.training.admin.live.vo.HealthCourseListRow; import org.springframework.stereotype.Service; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * APP 首页搜索聚合服务。 */ @Service public class AppSearchService { private static final int STATUS_ENABLED = 1; private final HealthCourseMapper healthCourseMapper; private final HealthPopularizationMapper healthPopularizationMapper; /** * 注入 APP 搜索所需依赖。 */ public AppSearchService(HealthCourseMapper healthCourseMapper, HealthPopularizationMapper healthPopularizationMapper) { this.healthCourseMapper = healthCourseMapper; this.healthPopularizationMapper = healthPopularizationMapper; } /** * 按关键词聚合搜索养生课程与健康科普。 */ public AppSearchResultVO search(String keyword) { String normalizedKeyword = trimToNull(keyword); AppSearchResultVO resultVO = new AppSearchResultVO(); if (normalizedKeyword == null) { resultVO.setCourses(Collections.<AppSearchHealthCourseVO>emptyList()); resultVO.setPopularizations(Collections.<AppSearchHealthPopularizationVO>emptyList()); return resultVO; } resultVO.setCourses(toCourseList(healthCourseMapper.selectAppSearchCourses(normalizedKeyword))); resultVO.setPopularizations(toPopularizationList(listPopularizations(normalizedKeyword))); return resultVO; } /** * 查询命中的健康科普内容。 */ private List<HealthPopularization> listPopularizations(String keyword) { List<HealthPopularization> list = healthPopularizationMapper.selectList(new LambdaQueryWrapper<HealthPopularization>() .eq(HealthPopularization::getDeleted, 0) .eq(HealthPopularization::getStatus, STATUS_ENABLED) .and(wrapper -> wrapper .like(HealthPopularization::getTitle, keyword) .or() .like(HealthPopularization::getContent, keyword) .or() .like(HealthPopularization::getCreatedBy, keyword)) .orderByAsc(HealthPopularization::getSort) .orderByDesc(HealthPopularization::getId)); return list == null ? Collections.<HealthPopularization>emptyList() : list; } /** * 将课程搜索结果转换为 APP 搜索页卡片响应。 */ private List<AppSearchHealthCourseVO> toCourseList(List<HealthCourseListRow> rows) { if (rows == null || rows.isEmpty()) { return Collections.emptyList(); } List<AppSearchHealthCourseVO> result = new ArrayList<AppSearchHealthCourseVO>(rows.size()); for (HealthCourseListRow row : rows) { AppSearchHealthCourseVO courseVO = new AppSearchHealthCourseVO(); courseVO.setId(row.getId()); courseVO.setTitle(trimToEmpty(row.getTitle())); courseVO.setCoverImage(trimToEmpty(row.getCoverImg())); courseVO.setLevelName(trimToEmpty(row.getLevelName())); courseVO.setDifficultyName(trimToEmpty(row.getDifficultyName())); courseVO.setSalePrice(defaultPrice(row.getSalePrice())); courseVO.setLearnUserCount(defaultNumber(row.getLearnUserCount())); result.add(courseVO); } return result; } /** * 将健康科普搜索结果转换为 APP 搜索页卡片响应。 */ private List<AppSearchHealthPopularizationVO> toPopularizationList(List<HealthPopularization> items) { if (items == null || items.isEmpty()) { return Collections.emptyList(); } List<AppSearchHealthPopularizationVO> result = new ArrayList<AppSearchHealthPopularizationVO>(items.size()); for (HealthPopularization item : items) { AppSearchHealthPopularizationVO popularizationVO = new AppSearchHealthPopularizationVO(); popularizationVO.setId(item.getId()); popularizationVO.setTitle(trimToEmpty(item.getTitle())); popularizationVO.setCoverImage(trimToEmpty(item.getCoverImg())); popularizationVO.setContentType(item.getContentType()); result.add(popularizationVO); } return result; } /** * 兜底空价格字段。 */ private BigDecimal defaultPrice(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } /** * 兜底空数字字段。 */ private Integer defaultNumber(Integer value) { return value == null ? 0 : value; } /** * 将可空文本转换为空字符串。 */ private String trimToEmpty(String value) { if (value == null) { return ""; } String trimmed = value.trim(); return trimmed.isEmpty() ? "" : trimmed; } /** * 将可空关键字转换为可搜索文本。 */ private String trimToNull(String value) { if (value == null) { return null; } String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/search/vo/AppSearchHealthCourseVO.java
New file @@ -0,0 +1,28 @@ package com.tcm.training.admin.app.search.vo; import lombok.Getter; import lombok.Setter; import java.math.BigDecimal; /** * APP 搜索页养生课程卡片响应。 */ @Getter @Setter public class AppSearchHealthCourseVO { private Long id; private String title; private String coverImage; private String levelName; private String difficultyName; private BigDecimal salePrice; private Integer learnUserCount; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/search/vo/AppSearchHealthPopularizationVO.java
New file @@ -0,0 +1,20 @@ package com.tcm.training.admin.app.search.vo; import lombok.Getter; import lombok.Setter; /** * APP 搜索页健康科普卡片响应。 */ @Getter @Setter public class AppSearchHealthPopularizationVO { private Long id; private String title; private String coverImage; private Integer contentType; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/search/vo/AppSearchResultVO.java
New file @@ -0,0 +1,18 @@ package com.tcm.training.admin.app.search.vo; import lombok.Getter; import lombok.Setter; import java.util.List; /** * APP 首页搜索聚合响应。 */ @Getter @Setter public class AppSearchResultVO { private List<AppSearchHealthCourseVO> courses; private List<AppSearchHealthPopularizationVO> popularizations; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseMapper.java
@@ -24,5 +24,7 @@ @Param("difficultyCategoryId") Long difficultyCategoryId, @Param("status") Integer status); List<HealthCourseListRow> selectAppSearchCourses(@Param("keyword") String keyword); HealthCourseDetailRow selectCourseDetail(@Param("id") Long id); } tcm-training-admin-backend/src/main/resources/mapper/HealthCourseMapper.xml
@@ -77,4 +77,17 @@ AND c.id = #{id} </select> <select id="selectAppSearchCourses" resultType="com.tcm.training.admin.live.vo.HealthCourseListRow"> <include refid="HealthCourseSelect"/> AND c.status = 1 <if test="keyword != null and keyword.trim() != ''"> AND ( c.title LIKE CONCAT('%', #{keyword}, '%') OR c.summary LIKE CONCAT('%', #{keyword}, '%') OR e.name LIKE CONCAT('%', #{keyword}, '%') ) </if> ORDER BY c.sort ASC, c.id DESC </select> </mapper> traditionalApp/pages/search/search.vue
@@ -21,7 +21,7 @@ /> <icon v-if="searchKeyword" type="clear" size="16" color="#ccc" @tap="clearSearch" style="padding: 10rpx;"></icon> </view> <view class="search-btn" v-if="!isSearched" @tap="doSearch">搜索</view> <view class="search-btn" @tap="doSearch">{{ loading ? '搜索中' : '搜索' }}</view> </view> </view> @@ -29,68 +29,264 @@ <view class="search-results" v-if="isSearched"> <!-- 养生课程 --> <view class="section"> <view class="section-title">养生课程</view> <view class="course-list"> <view class="course-item" v-for="i in 2" :key="'course'+i"> <image class="course-img" src="/static/image/home/class.png" mode="aspectFill"></image> <view class="section-header"> <view class="section-title">养生课程</view> <view v-if="courseList.length > 3" class="section-action" @tap="toggleCourseExpanded"> {{ courseExpanded ? '收起' : '展开全部' }} </view> </view> <view v-if="visibleCourseList.length" class="course-list"> <view class="course-item" v-for="item in visibleCourseList" :key="item.id" @tap="goCourseDetail(item.id)"> <image class="course-img" :src="item.coverImage || defaultCourseCover" mode="aspectFill"></image> <view class="course-info"> <view class="course-title-box"> <text class="tag">养生</text> <text class="title">拍八虚新手入门版</text> <text v-if="resolveCourseTag(item)" class="tag">{{ resolveCourseTag(item) }}</text> <text class="title">{{ item.title }}</text> </view> <view class="course-bottom"> <view class="price-box"> <text class="price-symbol">¥</text> <text class="price-integer">8888</text> <text class="price-decimal">.25</text> <text class="price-integer">{{ resolvePriceParts(item.salePrice).integer }}</text> <text class="price-decimal">.{{ resolvePriceParts(item.salePrice).decimal }}</text> </view> <view class="people-box"> <image class="person-icon" src="/static/image/home/person.png" mode="aspectFit"></image> <text class="people-text">8888人正在学</text> <text class="people-text">{{ formatLearningCount(item.learnUserCount) }}人正在学</text> </view> </view> </view> </view> </view> <view v-else class="empty-text">暂无相关课程</view> </view> <!-- 健康科普 --> <view class="section article-section"> <view class="section-title">健康科普</view> <view class="article-list"> <view class="article-item" v-for="i in 2" :key="'article'+i"> <image class="article-img" src="/static/image/home/zixun.png" mode="aspectFill"></image> <view class="section-header"> <view class="section-title">健康科普</view> <view v-if="popularizationList.length > 3" class="section-action" @tap="togglePopularizationExpanded"> {{ popularizationExpanded ? '收起' : '展开全部' }} </view> </view> <view v-if="visiblePopularizationList.length" class="article-list"> <view class="article-item" v-for="item in visiblePopularizationList" :key="item.id" @tap="goPopularizationDetail(item.id)"> <image class="article-img" :src="item.coverImage || defaultPopularizationCover" mode="aspectFill"></image> <view class="article-shadow"> <text class="article-text">资讯标题资讯标题资讯标...</text> <text class="article-text">{{ item.title }}</text> </view> </view> </view> <view v-else class="empty-text">暂无相关科普</view> </view> </view> </view> </template> <script setup lang="ts"> import { ref } from 'vue'; import { computed, ref } from 'vue' import { getSearchResult } from './service' const statusBarHeight = ref(uni.getSystemInfoSync().statusBarHeight || 0); const searchKeyword = ref(''); const isSearched = ref(false); interface SearchHealthCourseItem { id: number | string title: string coverImage?: string levelName?: string difficultyName?: string salePrice?: number | string learnUserCount?: number } const goBack = () => { uni.navigateBack(); }; interface SearchPopularizationItem { id: number | string title: string coverImage?: string contentType?: number } const doSearch = () => { if (searchKeyword.value.trim()) { isSearched.value = true; interface SearchResponse { courses?: SearchHealthCourseItem[] popularizations?: SearchPopularizationItem[] } const DEFAULT_VISIBLE_COUNT = 3 const statusBarHeight = ref(uni.getSystemInfoSync().statusBarHeight || 0) const searchKeyword = ref('') const isSearched = ref(false) const loading = ref(false) const courseExpanded = ref(false) const popularizationExpanded = ref(false) const courseList = ref<SearchHealthCourseItem[]>([]) const popularizationList = ref<SearchPopularizationItem[]>([]) const defaultCourseCover = '/static/image/home/class.png' const defaultPopularizationCover = '/static/image/home/zixun.png' /** * 根据展开状态裁剪课程分区可见结果。 */ const visibleCourseList = computed(() => { if (courseExpanded.value) { return courseList.value } }; return courseList.value.slice(0, DEFAULT_VISIBLE_COUNT) }) const clearSearch = () => { searchKeyword.value = ''; isSearched.value = false; }; /** * 根据展开状态裁剪健康科普分区可见结果。 */ const visiblePopularizationList = computed(() => { if (popularizationExpanded.value) { return popularizationList.value } return popularizationList.value.slice(0, DEFAULT_VISIBLE_COUNT) }) /** * 返回上一页;若没有历史栈,则回首页避免卡在空页。 */ function goBack() { const currentPages = getCurrentPages() if (currentPages.length > 1) { uni.navigateBack() return } uni.switchTab({ url: '/pages/home/home' }) } /** * 发起搜索请求并刷新两个分区的结果列表。 */ async function doSearch() { const keyword = searchKeyword.value.trim() if (!keyword) { return } isSearched.value = true loading.value = true courseExpanded.value = false popularizationExpanded.value = false try { const response = await getSearchResult(keyword) if (response?.code !== 0 || !response.data) { resetSearchResult() return } const responseData = response.data as SearchResponse courseList.value = Array.isArray(responseData.courses) ? responseData.courses.filter((item: SearchHealthCourseItem) => !!item?.id) : [] popularizationList.value = Array.isArray(responseData.popularizations) ? responseData.popularizations.filter((item: SearchPopularizationItem) => !!item?.id) : [] } catch { resetSearchResult() } finally { loading.value = false } } /** * 清空搜索关键字并重置搜索结果状态。 */ function clearSearch() { searchKeyword.value = '' isSearched.value = false loading.value = false resetSearchResult() } /** * 重置搜索结果列表与展开状态。 */ function resetSearchResult() { courseExpanded.value = false popularizationExpanded.value = false courseList.value = [] popularizationList.value = [] } /** * 切换课程分区展开状态。 */ function toggleCourseExpanded() { courseExpanded.value = !courseExpanded.value } /** * 切换健康科普分区展开状态。 */ function togglePopularizationExpanded() { popularizationExpanded.value = !popularizationExpanded.value } /** * 解析课程卡片标签,优先展示等级,其次展示难度。 */ function resolveCourseTag(item: SearchHealthCourseItem) { if (item.levelName) { return item.levelName } if (item.difficultyName) { return item.difficultyName } return '' } /** * 将课程价格格式化为两位小数。 */ function formatPrice(price?: number | string) { const numericPrice = Number(price || 0) if (!Number.isFinite(numericPrice)) { return '0.00' } return numericPrice.toFixed(2) } /** * 解析价格区展示所需的整数和小数部分。 */ function resolvePriceParts(price?: number | string) { const [integer, decimal] = formatPrice(price).split('.') return { integer: integer || '0', decimal: decimal || '00', } } /** * 将学习人数格式化为页面展示文案。 */ function formatLearningCount(count?: number) { const safeCount = typeof count === 'number' && count > 0 ? count : 0 if (safeCount >= 10000) { return `${(Math.round(safeCount / 100) / 100).toFixed(2)}W` } return `${safeCount}` } /** * 跳转养生课程详情页。 */ function goCourseDetail(courseId?: number | string | null) { uni.navigateTo({ url: courseId ? `/pages/courseDetail/courseDetail?id=${courseId}` : '/pages/courseDetail/courseDetail' }) } /** * 跳转健康科普详情页。 */ function goPopularizationDetail(popularizationId?: number | string | null) { uni.navigateTo({ url: popularizationId ? `/pages/articleDetail/articleDetail?id=${popularizationId}` : '/pages/articleDetail/articleDetail' }) } </script> <style> @@ -174,11 +370,22 @@ padding-bottom: 40rpx; } .section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16rpx; } .section-title { font-size: 36rpx; font-weight: 600; color: #333; margin-bottom: 16rpx; } .section-action { font-size: 24rpx; color: #C63520; } .course-list { @@ -333,4 +540,10 @@ text-overflow: ellipsis; width: 100%; } .empty-text { font-size: 26rpx; line-height: 40rpx; color: #86909C; } </style> traditionalApp/pages/search/service.js
New file @@ -0,0 +1,8 @@ import request from '@/utils/request.js' /** * 获取 APP 首页搜索结果。 */ export const getSearchResult = (keyword) => { return request.get('/api/app/search', { keyword }, false, false) }