txw/docs/superpowers/plans/2026-04-10-search-plan.md

1717 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 全站搜索功能实现计划
> **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-mhzcSearchController + 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**
```java
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: 提交代码**
```bash
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**
```java
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: 提交代码**
```bash
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 接口**
```java
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 实现**
```java
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: 提交代码**
```bash
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**
```java
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: 提交代码**
```bash
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**
```javascript
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: 提交代码**
```bash
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**
```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: 提交代码**
```bash
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**
```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: 提交代码**
```bash
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**
```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: 提交代码**
```bash
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**
```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: 提交代码**
```bash
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**
```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: 提交代码**
```bash
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**
```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: 提交代码**
```bash
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**
```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: 提交代码**
```bash
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**
```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: 提交代码**
```bash
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 中添加:
```javascript
// 搜索
function search() {
return import(/* webpackChunkName: "search" */ '@/pages/index/views/search/index.vue');
}
```
在 export default 的路由数组中添加:
```javascript
{
name: 'search',
path: '/search',
component: search,
meta: {
title: '搜索结果',
isShowSideBar: false,
hasHome: true,
breadCrumbs: [{ title: '首页', to: '/home' }, { title: '搜索结果', to: '/search' }],
disableBack: true,
},
},
```
- [ ] **Step 2: 提交代码**
```bash
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: 验证前端页面**
启动前端项目:
```bash
cd txw-mhzc-web
yarn dev
```
访问搜索页面:
```
http://localhost:8080/#/search?keyword=碳排放
```
测试功能:
1. 搜索框输入关键词,点击搜索
2. 点击热门搜索词
3. 分类 Tab 切换
4. 分页功能
5. 搜索历史展示和清除
- [ ] **Step 3: 提交联调代码**
```bash
git add .
git commit -m "fix(search): 联调修复"
```
---
## 完成标准
- [ ] 所有组件正常渲染
- [ ] 搜索功能返回正确结果
- [ ] 分类筛选正常工作
- [ ] 分页功能正常
- [ ] 搜索历史本地存储正常
- [ ] 热门搜索词展示正常
- [ ] 移动端适配正常(可选)