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

38 KiB
Raw Blame History

全站搜索功能实现计划

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

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=碳排放

测试功能:

  1. 搜索框输入关键词,点击搜索
  2. 点击热门搜索词
  3. 分类 Tab 切换
  4. 分页功能
  5. 搜索历史展示和清除
  • Step 3: 提交联调代码
git add .
git commit -m "fix(search): 联调修复"

完成标准

  • 所有组件正常渲染
  • 搜索功能返回正确结果
  • 分类筛选正常工作
  • 分页功能正常
  • 搜索历史本地存储正常
  • 热门搜索词展示正常
  • 移动端适配正常(可选)