38 KiB
全站搜索功能实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 为碳信网实现全站搜索功能,包括搜索页面、API接口、搜索建议、热门搜索、搜索历史
Architecture: 前端聚合在 txw-mhzc-web(搜索结果页),后端聚合在 txw-mhzc(SearchController + SearchService)。搜索聚合服务第一阶段只搜索资讯中心数据。
实现策略:
- 搜索历史:Phase 1 使用 localStorage(无用户认证),后续接入后端 API
- 热门搜索:Phase 1 返回硬编码数据,后续从数据库读取
Tech Stack: Vue 2 + TDesign + Vue Router (前端), Spring Boot + MyBatis Plus (后端)
文件结构
前端 (txw-mhzc-web)
txw-mhzc-web/src/pages/index/
├── views/
│ └── search/
│ └── index.vue # 搜索结果页 [新建]
├── components/
│ └── search/
│ ├── SearchArea.vue # 搜索区域 [新建]
│ ├── SearchSuggestion.vue # 搜索建议下拉 [新建]
│ ├── SearchHistoryBar.vue # 搜索历史横条 [新建]
│ ├── SearchCategoryTabs.vue # 分类Tab [新建]
│ ├── SearchResultList.vue # 结果列表 [新建]
│ └── SearchResultItem.vue # 结果项 [新建]
└── api/
└── search.js # 搜索接口 [新建]
后端 (txw-mhzc)
txw-mhzc/txw-mhzc-service-biz/src/main/java/com/css/txw/mhzc/
├── controller/
│ └── SearchController.java # 搜索接口 [新建]
├── service/
│ ├── SearchService.java # 搜索服务接口 [新建]
│ └── impl/
│ └── SearchServiceImpl.java # 搜索服务实现 [新建]
├── mapper/
│ └── SearchMapper.java # 搜索Mapper [新建]
└── pojo/
├── req/
│ └── SearchReqVO.java # 搜索请求 [新建]
└── vo/
├── SearchResultVO.java # 搜索结果 [新建]
└── HotSearchConfig.java # 热门搜索配置 [新建]
后端任务
Task 1: 创建搜索请求 VO
Files:
-
Create:
txw-mhzc/txw-mhzc-service-biz/src/main/java/com/css/txw/mhzc/pojo/req/SearchReqVO.java -
Step 1: 创建 SearchReqVO
package com.css.txw.mhzc.pojo.req;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
@Data
@Schema(description = "搜索请求")
public class SearchReqVO {
@Schema(description = "关键词", required = true)
@NotBlank(message = "关键词不能为空")
@Length(max = 50, message = "关键词不能超过50个字符")
private String keyword;
@Schema(description = "分类类型: all, carbon_cert, service, news")
private String categoryType = "all";
@Schema(description = "页码")
private Integer page = 1;
@Schema(description = "每页条数")
private Integer pageSize = 10;
}
- Step 2: 提交代码
git add txw-mhzc/txw-mhzc-service-biz/src/main/java/com/css/txw/mhzc/pojo/req/SearchReqVO.java
git commit -m "feat(search): 添加搜索请求VO"
Task 2: 创建搜索结果 VO
Files:
-
Create:
txw-mhzc/txw-mhzc-service-biz/src/main/java/com/css/txw/mhzc/pojo/vo/SearchResultVO.java -
Step 1: 创建 SearchResultVO
package com.css.txw.mhzc.pojo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@Schema(description = "搜索结果")
public class SearchResultVO {
@Schema(description = "内容ID")
private String id;
@Schema(description = "标题(关键词高亮)")
private String title;
@Schema(description = "摘要")
private String summary;
@Schema(description = "缩略图URL")
private String thumbnail;
@Schema(description = "分类标签(展示用)")
private String category;
@Schema(description = "分类类型(筛选用)")
private String categoryType;
@Schema(description = "来源名称")
private String source;
@Schema(description = "来源类型")
private String sourceType;
@Schema(description = "发布时间")
private String publishTime;
@Schema(description = "跳转链接")
private String url;
}
- Step 2: 提交代码
git add txw-mhzc/txw-mhzc-service-biz/src/main/java/com/css/txw/mhzc/pojo/vo/SearchResultVO.java
git commit -m "feat(search): 添加搜索结果VO"
Task 3: 创建搜索服务接口和实现
Files:
-
Create:
txw-mhzc/txw-mhzc-service-biz/src/main/java/com/css/txw/mhzc/service/SearchService.java -
Create:
txw-mhzc/txw-mhzc-service-biz/src/main/java/com/css/txw/mhzc/service/impl/SearchServiceImpl.java -
Step 1: 创建 SearchService 接口
package com.css.txw.mhzc.service;
import com.css.txw.mhzc.pojo.req.SearchReqVO;
import com.css.txw.mhzc.pojo.vo.SearchResultVO;
import java.util.List;
public interface SearchService {
/**
* 搜索
*/
List<SearchResultVO> search(SearchReqVO reqVO);
/**
* 获取热门搜索词
*/
List<String> getHotSearchKeywords();
/**
* 获取搜索建议
*/
List<String> getSearchSuggestions(String keyword);
/**
* 获取搜索历史
*/
List<String> getSearchHistory();
/**
* 清除搜索历史
*/
void clearSearchHistory();
}
- Step 2: 创建 SearchServiceImpl 实现
package com.css.txw.mhzc.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.css.ggzc.framework.common.pojo.CommonResult;
import com.css.txw.mhzc.pojo.req.SearchReqVO;
import com.css.txw.mhzc.pojo.vo.SearchResultVO;
import com.css.txw.mhzc.pojo.vo.SyzxxxVO;
import com.css.txw.mhzc.service.SearchService;
import com.css.txw.mhzc.service.TxwMhzcZxxxbService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Slf4j
public class SearchServiceImpl implements SearchService {
@Resource
private TxwMhzcZxxxbService zxxxbService;
private static final String HIGHLIGHT_START = "<em>";
private static final String HIGHLIGHT_END = "</em>";
@Override
public List<SearchResultVO> search(SearchReqVO reqVO) {
// 调用资讯接口获取数据
CommonResult<java.util.Map<String, List<SyzxxxVO>>> result = zxxxbService.zxxx();
List<SearchResultVO> resultList = new ArrayList<>();
String categoryType = reqVO.getCategoryType();
if (result != null && result.getData() != null) {
java.util.Map<String, List<SyzxxxVO>> data = result.getData();
// 从资讯数据中筛选
data.forEach((category, list) -> {
String currentCategoryType = getCategoryType(category);
// 如果选择了分类且不匹配,跳过
if (!"all".equals(categoryType) && !categoryType.equals(currentCategoryType)) {
return;
}
list.forEach(zxxx -> {
// 简单匹配逻辑,实际应更复杂
if (matchKeyword(zxxx.getBt1(), reqVO.getKeyword()) ||
matchKeyword(zxxx.getZxNr(), reqVO.getKeyword())) {
SearchResultVO vo = new SearchResultVO();
vo.setId(zxxx.getId());
vo.setTitle(highlightKeyword(zxxx.getBt1(), reqVO.getKeyword()));
vo.setSummary(highlightKeyword(truncate(zxxx.getZxNr(), 200), reqVO.getKeyword()));
vo.setCategory(category);
vo.setCategoryType(currentCategoryType);
vo.setSource("碳信网");
vo.setSourceType("news");
vo.setPublishTime(zxxx.getFbsj());
vo.setUrl("/mhzc/news/" + zxxx.getId());
resultList.add(vo);
}
});
});
}
// 分页
int start = (reqVO.getPage() - 1) * reqVO.getPageSize();
int end = Math.min(start + reqVO.getPageSize(), resultList.size());
if (start >= resultList.size()) {
return new ArrayList<>();
}
return resultList.subList(start, end);
}
@Override
public List<String> getHotSearchKeywords() {
// TODO: 从数据库读取运营配置的热门搜索词
return Arrays.asList("江苏电厂配额", "林业碳汇开发", "CBAM 报告", "零碳展会");
}
@Override
public List<String> getSearchSuggestions(String keyword) {
if (!StringUtils.hasText(keyword)) {
return new ArrayList<>();
}
List<String> suggestions = new ArrayList<>();
String lowerKeyword = keyword.toLowerCase();
// TODO: 实际应从数据库查询匹配的建议词
// 模拟返回
suggestions.add(keyword);
return suggestions.stream()
.filter(s -> s.toLowerCase().contains(lowerKeyword))
.limit(10)
.collect(Collectors.toList());
}
private boolean matchKeyword(String text, String keyword) {
if (!StringUtils.hasText(text) || !StringUtils.hasText(keyword)) {
return false;
}
return text.contains(keyword);
}
private String highlightKeyword(String text, String keyword) {
if (!StringUtils.hasText(text) || !StringUtils.hasText(keyword)) {
return text;
}
return text.replace(keyword, HIGHLIGHT_START + keyword + HIGHLIGHT_END);
}
private String truncate(String text, int maxLength) {
if (text == null || text.length() <= maxLength) {
return text;
}
return text.substring(0, maxLength) + "...";
}
private String getCategoryType(String category) {
if ("行业专题".equals(category)) {
return "news";
} else if ("碳证中心".equals(category)) {
return "carbon_cert";
} else if ("服务中心".equals(category)) {
return "service";
}
return "all";
}
@Override
public List<String> getSearchHistory() {
// Phase 1: 返回空列表(前端使用localStorage)
// TODO: 用户认证后从数据库或Redis获取
return new ArrayList<>();
}
@Override
public void clearSearchHistory() {
// Phase 1: 前端localStorage已清除
// TODO: 用户认证后清除数据库或Redis中的数据
}
}
- Step 3: 提交代码
git add txw-mhzc/txw-mhzc-service-biz/src/main/java/com/css/txw/mhzc/service/SearchService.java
git add txw-mhzc/txw-mhzc-service-biz/src/main/java/com/css/txw/mhzc/service/impl/SearchServiceImpl.java
git commit -m "feat(search): 添加搜索服务接口和实现"
Task 4: 创建搜索控制器
Files:
-
Create:
txw-mhzc/txw-mhzc-service-biz/src/main/java/com/css/txw/mhzc/controller/SearchController.java -
Step 1: 创建 SearchController
package com.css.txw.mhzc.controller;
import com.css.ggzc.framework.common.pojo.CommonResult;
import com.css.txw.mhzc.pojo.req.SearchReqVO;
import com.css.txw.mhzc.pojo.vo.SearchResultVO;
import com.css.txw.mhzc.service.SearchService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.validation.Valid;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/search")
@Tag(name = "搜索接口")
@Validated
@Slf4j
public class SearchController {
@Resource
private SearchService searchService;
@GetMapping
@Operation(summary = "搜索", description = "全站搜索")
public CommonResult<Map<String, Object>> search(@Valid SearchReqVO reqVO) {
List<SearchResultVO> list = searchService.search(reqVO);
// 构建返回结果
// 注意:实际应计算 categoryCount,这里简化处理
Map<String, Object> result = new java.util.HashMap<>();
result.put("total", list.size());
result.put("list", list);
Map<String, Integer> categoryCount = new java.util.HashMap<>();
categoryCount.put("all", list.size());
categoryCount.put("carbon_cert", 0);
categoryCount.put("service", 0);
categoryCount.put("news", list.size());
result.put("categoryCount", categoryCount);
return CommonResult.success(result);
}
@GetMapping("/hot")
@Operation(summary = "热门搜索", description = "获取热门搜索词")
public CommonResult<List<String>> getHotSearch() {
return CommonResult.success(searchService.getHotSearchKeywords());
}
@GetMapping("/suggest")
@Operation(summary = "搜索建议", description = "获取搜索建议")
public CommonResult<Map<String, List<String>>> getSuggest(@RequestParam String keyword) {
List<String> suggestions = searchService.getSearchSuggestions(keyword);
Map<String, List<String>> result = new java.util.HashMap<>();
result.put("suggestions", suggestions);
return CommonResult.success(result);
}
@GetMapping("/history")
@Operation(summary = "搜索历史", description = "获取用户搜索历史")
public CommonResult<List<String>> getSearchHistory() {
return CommonResult.success(searchService.getSearchHistory());
}
@DeleteMapping("/history")
@Operation(summary = "清除搜索历史", description = "清除用户搜索历史")
public CommonResult<Boolean> clearSearchHistory() {
searchService.clearSearchHistory();
return CommonResult.success(true);
}
}
- Step 2: 提交代码
git add txw-mhzc/txw-mhzc-service-biz/src/main/java/com/css/txw/mhzc/controller/SearchController.java
git commit -m "feat(search): 添加搜索控制器"
前端任务
Task 5: 创建搜索 API
Files:
-
Create:
txw-mhzc-web/src/pages/index/api/search.js -
Step 1: 创建 search.js API
import { fetchSso } from '@/core/request';
const basurl = '';
export default {
/**
* 搜索
* @param {Object} params - { keyword, categoryType, page, pageSize }
*/
search(params) {
return fetchSso({
url: `${basurl}/search`,
method: 'get',
loading: true,
params,
});
},
/**
* 获取热门搜索词
*/
getHotSearch() {
return fetchSso({
url: `${basurl}/search/hot`,
method: 'get',
loading: false,
});
},
/**
* 获取搜索建议
* @param {String} keyword - 关键词
*/
getSuggest(keyword) {
return fetchSso({
url: `${basurl}/search/suggest`,
method: 'get',
loading: false,
params: { keyword },
});
},
/**
* 获取搜索历史
*/
getSearchHistory() {
return fetchSso({
url: `${basurl}/search/history`,
method: 'get',
loading: false,
});
},
/**
* 清除搜索历史
*/
clearSearchHistory() {
return fetchSso({
url: `${basurl}/search/history`,
method: 'delete',
loading: false,
});
},
};
- Step 2: 提交代码
git add txw-mhzc-web/src/pages/index/api/search.js
git commit -m "feat(search): 添加搜索API"
Task 6: 创建搜索区域组件
Files:
-
Create:
txw-mhzc-web/src/pages/index/components/search/SearchArea.vue -
Step 1: 创建 SearchArea.vue
<template>
<div class="search-area">
<!-- 搜索区域背景 -->
<div class="search-bg">
<div class="search-box">
<!-- 分类下拉 -->
<t-select
v-model="selectedCategory"
:options="categoryOptions"
placeholder="请选择分类"
@change="handleCategoryChange"
style="width: 120px"
/>
<!-- 搜索输入框 -->
<t-input
v-model="keyword"
placeholder="搜索关键词"
@enter="handleSearch"
@input="handleInput"
style="flex: 1"
>
<template #suffix-icon>
<t-icon name="search" />
</template>
</t-input>
<!-- 搜索按钮 -->
<t-button theme="primary" @click="handleSearch">搜索</t-button>
</div>
<!-- 热门搜索 -->
<div class="hot-search">
<span class="hot-label">热门搜索:</span>
<span
v-for="(item, index) in hotSearchList"
:key="index"
class="hot-item"
@click="handleHotClick(item)"
>
{{ item }}
</span>
</div>
</div>
<!-- 搜索建议下拉 -->
<SearchSuggestion
v-if="showSuggestion"
:suggestions="suggestions"
@select="handleSuggestionSelect"
@close="showSuggestion = false"
/>
</div>
</template>
<script>
import SearchSuggestion from './SearchSuggestion.vue';
import searchApi from '@/pages/index/api/search.js';
export default {
name: 'SearchArea',
components: {
SearchSuggestion,
},
data() {
return {
keyword: '',
selectedCategory: 'all',
categoryOptions: [
{ label: '全部', value: 'all' },
{ label: '碳证中心', value: 'carbon_cert' },
{ label: '服务中心', value: 'service' },
{ label: '行业专题', value: 'news' },
],
hotSearchList: [],
suggestions: [],
showSuggestion: false,
inputTimer: null,
};
},
mounted() {
this.loadHotSearch();
},
methods: {
handleSearch() {
if (!this.keyword.trim()) {
this.$message.warning('请输入搜索关键词');
return;
}
this.showSuggestion = false;
this.$emit('search', {
keyword: this.keyword.trim(),
categoryType: this.selectedCategory,
page: 1,
});
},
handleInput(value) {
// 防抖处理
if (this.inputTimer) {
clearTimeout(this.inputTimer);
}
if (!value.trim()) {
this.showSuggestion = false;
return;
}
this.inputTimer = setTimeout(() => {
this.loadSuggestions(value);
}, 300);
},
handleCategoryChange(value) {
this.selectedCategory = value;
},
handleHotClick(keyword) {
this.keyword = keyword;
this.handleSearch();
},
handleSuggestionSelect(keyword) {
this.keyword = keyword;
this.showSuggestion = false;
this.handleSearch();
},
async loadHotSearch() {
try {
const res = await searchApi.getHotSearch();
if (res && res.data) {
this.hotSearchList = res.data;
}
} catch (error) {
console.error('加载热门搜索失败', error);
}
},
async loadSuggestions(keyword) {
try {
const res = await searchApi.getSuggest(keyword);
if (res && res.data && res.data.suggestions) {
this.suggestions = res.data.suggestions;
this.showSuggestion = this.suggestions.length > 0;
}
} catch (error) {
console.error('加载搜索建议失败', error);
}
},
},
};
</script>
<style scoped lang="less">
.search-area {
position: relative;
}
.search-bg {
background-color: #f5f5f5;
padding: 24px 0;
}
.search-box {
display: flex;
gap: 12px;
max-width: 800px;
margin: 0 auto;
}
.hot-search {
max-width: 800px;
margin: 12px auto 0;
font-size: 14px;
color: #999;
.hot-label {
margin-right: 8px;
}
.hot-item {
margin-right: 16px;
cursor: pointer;
color: #666;
&:hover {
color: #00b42a;
}
}
}
</style>
- Step 2: 提交代码
git add txw-mhzc-web/src/pages/index/components/search/SearchArea.vue
git commit -m "feat(search): 添加搜索区域组件"
Task 7: 创建搜索建议组件
Files:
-
Create:
txw-mhzc-web/src/pages/index/components/search/SearchSuggestion.vue -
Step 1: 创建 SearchSuggestion.vue
<template>
<div class="search-suggestion">
<div
v-for="(item, index) in suggestions"
:key="index"
class="suggestion-item"
@click="handleSelect(item)"
>
<span v-html="item"></span>
</div>
</div>
</template>
<script>
export default {
name: 'SearchSuggestion',
props: {
suggestions: {
type: Array,
default: () => [],
},
},
methods: {
handleSelect(keyword) {
// 去掉高亮标签
const plainKeyword = keyword.replace(/<em>|<\/em>/g, '');
this.$emit('select', plainKeyword);
},
},
};
</script>
<style scoped lang="less">
.search-suggestion {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-width: 800px;
margin: 0 auto;
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 100;
.suggestion-item {
padding: 10px 16px;
cursor: pointer;
font-size: 14px;
&:hover {
background-color: #f5f5f5;
}
:deep(em) {
color: #00b42a;
font-style: normal;
}
}
}
</style>
- Step 2: 提交代码
git add txw-mhzc-web/src/pages/index/components/search/SearchSuggestion.vue
git commit -m "feat(search): 添加搜索建议组件"
Task 8: 创建搜索历史横条组件
Files:
-
Create:
txw-mhzc-web/src/pages/index/components/search/SearchHistoryBar.vue -
Step 1: 创建 SearchHistoryBar.vue
<template>
<div class="search-history-bar" v-if="historyList.length > 0">
<span class="history-label">搜索历史</span>
<span
v-for="(item, index) in historyList"
:key="index"
class="history-item"
@click="handleClick(item)"
>
{{ item }}
</span>
<span class="clear-btn" @click="handleClear">清除全部</span>
</div>
</template>
<script>
export default {
name: 'SearchHistoryBar',
props: {
historyList: {
type: Array,
default: () => [],
},
},
methods: {
handleClick(keyword) {
this.$emit('select', keyword);
},
handleClear() {
this.$emit('clear');
},
},
};
</script>
<style scoped lang="less">
.search-history-bar {
max-width: 1200px;
margin: 16px auto;
font-size: 14px;
color: #666;
.history-label {
margin-right: 16px;
color: #999;
}
.history-item {
margin-right: 16px;
cursor: pointer;
color: #072ca6;
&:hover {
text-decoration: underline;
}
}
.clear-btn {
float: right;
cursor: pointer;
color: #999;
&:hover {
color: #666;
}
}
}
</style>
- Step 2: 提交代码
git add txw-mhzc-web/src/pages/index/components/search/SearchHistoryBar.vue
git commit -m "feat(search): 添加搜索历史横条组件"
Task 9: 创建分类 Tab 组件
Files:
-
Create:
txw-mhzc-web/src/pages/index/components/search/SearchCategoryTabs.vue -
Step 1: 创建 SearchCategoryTabs.vue
<template>
<div class="search-category-tabs">
<div class="tabs-container">
<span
v-for="tab in tabs"
:key="tab.value"
:class="['tab-item', { active: currentTab === tab.value }]"
@click="handleTabClick(tab.value)"
>
{{ tab.label }}
<span class="count" v-if="categoryCount">{{ categoryCount[tab.value] || 0 }}</span>
</span>
<div class="tab-indicator" :style="indicatorStyle"></div>
</div>
<div class="divider"></div>
</div>
</template>
<script>
export default {
name: 'SearchCategoryTabs',
props: {
currentTab: {
type: String,
default: 'all',
},
categoryCount: {
type: Object,
default: () => ({}),
},
},
data() {
return {
tabs: [
{ label: '全部', value: 'all' },
{ label: '碳证中心', value: 'carbon_cert' },
{ label: '服务中心', value: 'service' },
{ label: '行业专题', value: 'news' },
],
indicatorStyle: {},
};
},
watch: {
currentTab: {
immediate: true,
handler() {
this.updateIndicator();
},
},
},
mounted() {
this.updateIndicator();
},
methods: {
handleTabClick(value) {
this.$emit('change', value);
},
updateIndicator() {
const index = this.tabs.findIndex((t) => t.value === this.currentTab);
if (index !== -1) {
this.indicatorStyle = {
transform: `translateX(${index * 120}px)`,
width: '72px',
};
}
},
},
};
</script>
<style scoped lang="less">
.search-category-tabs {
max-width: 1200px;
margin: 0 auto;
}
.tabs-container {
display: flex;
position: relative;
padding-bottom: 8px;
}
.tab-item {
width: 120px;
padding: 8px 0;
text-align: center;
font-size: 16px;
color: #666;
cursor: pointer;
position: relative;
&.active {
color: #333;
font-weight: 500;
}
.count {
margin-left: 4px;
font-size: 14px;
color: #999;
}
}
.tab-indicator {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background-color: #00b42a;
transition: transform 0.3s ease;
}
.divider {
height: 1px;
background-color: #e5e5e5;
}
</style>
- Step 2: 提交代码
git add txw-mhzc-web/src/pages/index/components/search/SearchCategoryTabs.vue
git commit -m "feat(search): 添加分类Tab组件"
Task 10: 创建搜索结果项组件
Files:
-
Create:
txw-mhzc-web/src/pages/index/components/search/SearchResultItem.vue -
Step 1: 创建 SearchResultItem.vue
<template>
<div class="search-result-item" @click="handleClick">
<!-- 分类标签 -->
<span class="category-tag" :style="categoryStyle">
{{ result.category }}
</span>
<!-- 标题 -->
<h3 class="title" v-html="result.title"></h3>
<!-- 日期 -->
<div class="meta">
<span class="date">{{ result.publishTime }}</span>
<span class="source" v-if="result.source">{{ result.source }}</span>
</div>
<!-- 摘要 -->
<p class="summary" v-html="result.summary"></p>
</div>
</template>
<script>
export default {
name: 'SearchResultItem',
props: {
result: {
type: Object,
required: true,
},
},
computed: {
categoryStyle() {
if (this.result.categoryType === 'news') {
return { backgroundColor: '#e8f3ff', color: '#072ca6' };
} else if (this.result.categoryType === 'carbon_cert') {
return { backgroundColor: '#fff7e6', color: '#d25f00' };
} else if (this.result.categoryType === 'service') {
return { backgroundColor: '#e6fff0', color: '#00b42a' };
}
return { backgroundColor: '#f5f5f5', color: '#666' };
},
},
methods: {
handleClick() {
if (this.result.url) {
window.location.href = this.result.url;
}
},
},
};
</script>
<style scoped lang="less">
.search-result-item {
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
&:last-child {
border-bottom: none;
}
&:hover {
background-color: #fafafa;
}
.category-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 2px;
font-size: 14px;
margin-bottom: 8px;
}
.title {
font-size: 18px;
font-weight: 500;
color: #333;
margin: 0 0 8px 0;
line-height: 1.5;
:deep(em) {
color: #00b42a;
font-style: normal;
}
}
.meta {
font-size: 14px;
color: #999;
margin-bottom: 8px;
.date {
margin-right: 16px;
}
.source {
&::before {
content: '来源:';
}
}
}
.summary {
font-size: 14px;
color: #666;
line-height: 1.8;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
:deep(em) {
color: #00b42a;
font-style: normal;
}
}
}
</style>
- Step 2: 提交代码
git add txw-mhzc-web/src/pages/index/components/search/SearchResultItem.vue
git commit -m "feat(search): 添加搜索结果项组件"
Task 11: 创建空状态组件
Files:
-
Create:
txw-mhzc-web/src/pages/index/components/search/SearchEmpty.vue -
Step 1: 创建 SearchEmpty.vue
<template>
<div class="search-empty">
<div class="empty-icon">
<t-icon name="search" size="64px" />
</div>
<p class="empty-text">
未找到与 "<span class="keyword">{{ keyword }}</span>" 相关的搜索结果
</p>
<p class="empty-hint">请尝试其他关键词或调整筛选条件</p>
</div>
</template>
<script>
export default {
name: 'SearchEmpty',
props: {
keyword: {
type: String,
default: '',
},
},
};
</script>
<style scoped lang="less">
.search-empty {
padding: 80px 0;
text-align: center;
.empty-icon {
color: #ddd;
margin-bottom: 24px;
}
.empty-text {
font-size: 16px;
color: #666;
margin-bottom: 8px;
.keyword {
color: #00b42a;
}
}
.empty-hint {
font-size: 14px;
color: #999;
}
}
</style>
- Step 2: 提交代码
git add txw-mhzc-web/src/pages/index/components/search/SearchEmpty.vue
git commit -m "feat(search): 添加空状态组件"
Task 12: 创建搜索结果列表组件
Files:
-
Create:
txw-mhzc-web/src/pages/index/components/search/SearchResultList.vue -
Step 1: 创建 SearchResultList.vue
<template>
<div class="search-result-list">
<!-- 加载状态 -->
<div v-if="loading" class="loading">
<t-loading size="large" text="加载中..." />
</div>
<!-- 空状态 -->
<SearchEmpty v-else-if="list.length === 0" :keyword="keyword" />
<!-- 结果列表 -->
<div v-else class="result-container">
<SearchResultItem
v-for="item in list"
:key="item.id"
:result="item"
/>
</div>
<!-- 分页 -->
<div v-if="list.length > 0 && total > pageSize" class="pagination">
<t-pagination
v-model="currentPage"
:total="total"
:page-size="pageSize"
@change="handlePageChange"
/>
</div>
</div>
</template>
<script>
import SearchResultItem from './SearchResultItem.vue';
import SearchEmpty from './SearchEmpty.vue';
export default {
name: 'SearchResultList',
components: {
SearchResultItem,
SearchEmpty,
},
props: {
list: {
type: Array,
default: () => [],
},
total: {
type: Number,
default: 0,
},
pageSize: {
type: Number,
default: 10,
},
loading: {
type: Boolean,
default: false,
},
keyword: {
type: String,
default: '',
},
},
data() {
return {
currentPage: 1,
};
},
watch: {
currentPage: {
handler(val) {
this.currentPage = val;
},
},
},
methods: {
handlePageChange(pageInfo) {
this.$emit('page-change', pageInfo.current);
},
},
};
</script>
<style scoped lang="less">
.search-result-list {
max-width: 1200px;
margin: 0 auto;
padding: 0 16px;
.loading {
padding: 60px 0;
text-align: center;
}
.result-container {
padding: 16px 0;
}
.pagination {
padding: 24px 0;
text-align: center;
}
}
</style>
- Step 2: 提交代码
git add txw-mhzc-web/src/pages/index/components/search/SearchResultList.vue
git commit -m "feat(search): 添加搜索结果列表组件"
Task 14: 创建搜索结果页
Files:
-
Create:
txw-mhzc-web/src/pages/index/views/search/index.vue -
Step 1: 创建搜索结果页 index.vue
<template>
<div class="search-page">
<!-- 顶部导航 (复用现有组件) -->
<Nav :isyhxx="false" />
<!-- 搜索区域 -->
<SearchArea @search="handleSearch" />
<!-- 搜索历史 -->
<SearchHistoryBar
:historyList="searchHistory"
@select="handleHistorySelect"
@clear="handleClearHistory"
/>
<!-- 分类 Tab -->
<SearchCategoryTabs
:currentTab="currentCategory"
:categoryCount="categoryCount"
@change="handleCategoryChange"
/>
<!-- 搜索结果列表 -->
<SearchResultList
:list="resultList"
:total="total"
:pageSize="pageSize"
:loading="loading"
:keyword="keyword"
@page-change="handlePageChange"
/>
<!-- 底部版权 -->
<div class="footer">
<p>版权信息等</p>
</div>
</div>
</template>
<script>
import Nav from '@/pages/index/components/nav/index.vue';
import SearchArea from '@/pages/index/components/search/SearchArea.vue';
import SearchHistoryBar from '@/pages/index/components/search/SearchHistoryBar.vue';
import SearchCategoryTabs from '@/pages/index/components/search/SearchCategoryTabs.vue';
import SearchResultList from '@/pages/index/components/search/SearchResultList.vue';
import searchApi from '@/pages/index/api/search.js';
export default {
name: 'SearchPage',
components: {
Nav,
SearchArea,
SearchHistoryBar,
SearchCategoryTabs,
SearchResultList,
},
data() {
return {
keyword: '',
currentCategory: 'all',
currentPage: 1,
pageSize: 10,
resultList: [],
total: 0,
categoryCount: {},
loading: false,
searchHistory: [],
};
},
mounted() {
// 从 URL 获取搜索参数
this.keyword = this.$route.query.keyword || '';
this.currentCategory = this.$route.query.category || 'all';
// 加载搜索历史
this.loadSearchHistory();
// 如果有搜索关键词,执行搜索
if (this.keyword) {
this.doSearch();
}
},
methods: {
handleSearch(params) {
this.keyword = params.keyword;
this.currentCategory = params.categoryType;
this.currentPage = 1;
// 更新 URL
this.$router.push({
query: {
keyword: this.keyword,
category: this.currentCategory,
},
});
// 保存搜索历史
this.saveSearchHistory(this.keyword);
// 执行搜索
this.doSearch();
},
handleHistorySelect(keyword) {
this.keyword = keyword;
this.doSearch();
},
handleClearHistory() {
this.searchHistory = [];
searchApi.clearSearchHistory();
},
handleCategoryChange(category) {
this.currentCategory = category;
this.currentPage = 1;
// 更新 URL
this.$router.push({
query: {
keyword: this.keyword,
category: this.currentCategory,
},
});
// 重新搜索
this.doSearch();
},
handlePageChange(page) {
this.currentPage = page;
this.doSearch();
// 滚动到顶部
window.scrollTo(0, 0);
},
async doSearch() {
this.loading = true;
try {
const res = await searchApi.search({
keyword: this.keyword,
categoryType: this.currentCategory,
page: this.currentPage,
pageSize: this.pageSize,
});
if (res && res.data) {
this.resultList = res.data.list || [];
this.total = res.data.total || 0;
this.categoryCount = res.data.categoryCount || {};
}
} catch (error) {
console.error('搜索失败', error);
this.resultList = [];
this.total = 0;
} finally {
this.loading = false;
}
},
loadSearchHistory() {
// 从 localStorage 读取
try {
const history = localStorage.getItem('searchHistory');
if (history) {
this.searchHistory = JSON.parse(history);
}
} catch (e) {
console.error('读取搜索历史失败', e);
}
},
saveSearchHistory(keyword) {
if (!keyword) return;
// 去除重复
this.searchHistory = this.searchHistory.filter((k) => k !== keyword);
// 添加到开头
this.searchHistory.unshift(keyword);
// 只保留10条
if (this.searchHistory.length > 10) {
this.searchHistory = this.searchHistory.slice(0, 10);
}
// 保存到 localStorage
try {
localStorage.setItem('searchHistory', JSON.stringify(this.searchHistory));
} catch (e) {
console.error('保存搜索历史失败', e);
}
},
},
};
</script>
<style scoped lang="less">
.search-page {
min-height: 100vh;
background-color: #fff;
}
.footer {
background-color: #f5f5f5;
padding: 24px 0;
text-align: center;
margin-top: 40px;
p {
margin: 0;
font-size: 14px;
color: #999;
}
}
</style>
- Step 2: 提交代码
git add txw-mhzc-web/src/pages/index/views/search/index.vue
git commit -m "feat(search): 添加搜索结果页"
Task 15: 添加搜索路由
Files:
-
Modify:
txw-mhzc-web/src/pages/index/router/routes.js -
Step 1: 添加搜索路由
在 routes.js 中添加:
// 搜索
function search() {
return import(/* webpackChunkName: "search" */ '@/pages/index/views/search/index.vue');
}
在 export default 的路由数组中添加:
{
name: 'search',
path: '/search',
component: search,
meta: {
title: '搜索结果',
isShowSideBar: false,
hasHome: true,
breadCrumbs: [{ title: '首页', to: '/home' }, { title: '搜索结果', to: '/search' }],
disableBack: true,
},
},
- Step 2: 提交代码
git add txw-mhzc-web/src/pages/index/router/routes.js
git commit -m "feat(search): 添加搜索路由"
联调与测试
Task 16: 前后端联调
- Step 1: 验证后端接口
启动后端服务,访问 Swagger 文档:
http://localhost:xxxx/swagger-ui.html
测试接口:
-
GET /search?keyword=碳排放&categoryType=all&page=1&pageSize=10 -
GET /search/hot -
GET /search/suggest?keyword=碳 -
Step 2: 验证前端页面
启动前端项目:
cd txw-mhzc-web
yarn dev
访问搜索页面:
http://localhost:8080/#/search?keyword=碳排放
测试功能:
- 搜索框输入关键词,点击搜索
- 点击热门搜索词
- 分类 Tab 切换
- 分页功能
- 搜索历史展示和清除
- Step 3: 提交联调代码
git add .
git commit -m "fix(search): 联调修复"
完成标准
- 所有组件正常渲染
- 搜索功能返回正确结果
- 分类筛选正常工作
- 分页功能正常
- 搜索历史本地存储正常
- 热门搜索词展示正常
- 移动端适配正常(可选)