“mitao”
2026-04-10 41628d05fda686c6a2c53d9f21367d20031b4188
feat:App首页-搜索
6个文件已添加
3个文件已修改
549 ■■■■■ 已修改文件
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/search/controller/AppSearchController.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/search/service/AppSearchService.java 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/search/vo/AppSearchHealthCourseVO.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/search/vo/AppSearchHealthPopularizationVO.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/search/vo/AppSearchResultVO.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/HealthCourseMapper.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/resources/mapper/HealthCourseMapper.xml 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/search/search.vue 275 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
traditionalApp/pages/search/service.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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)
}