feat: 添加全站搜索功能和页面

This commit is contained in:
liulujian 2026-04-22 13:27:08 +08:00
parent 3019ff983d
commit cfb58db347
20 changed files with 1719 additions and 101 deletions

View File

@ -244,26 +244,33 @@ public class SearchServiceImpl implements SearchService {
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.getTitle(), reqVO.getKeyword()) ||
matchKeyword(zxxx.getContent(), reqVO.getKeyword())) {
if (matchKeyword(zxxx.getBt1(), reqVO.getKeyword()) ||
matchKeyword(zxxx.getZxNr(), reqVO.getKeyword())) {
SearchResultVO vo = new SearchResultVO();
vo.setId(zxxx.getId());
vo.setTitle(highlightKeyword(zxxx.getTitle(), reqVO.getKeyword()));
vo.setSummary(highlightKeyword(truncate(zxxx.getContent(), 200), reqVO.getKeyword()));
vo.setTitle(highlightKeyword(zxxx.getBt1(), reqVO.getKeyword()));
vo.setSummary(highlightKeyword(truncate(zxxx.getZxNr(), 200), reqVO.getKeyword()));
vo.setCategory(category);
vo.setCategoryType(getCategoryType(category));
vo.setCategoryType(currentCategoryType);
vo.setSource("碳信网");
vo.setSourceType("news");
vo.setPublishTime(zxxx.getPublishTime());
vo.setPublishTime(zxxx.getFbsj());
vo.setUrl("/mhzc/news/" + zxxx.getId());
resultList.add(vo);
@ -1191,7 +1198,76 @@ git commit -m "feat(search): 添加搜索结果项组件"
---
### Task 11: 创建搜索结果列表组件
### 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`
@ -1314,76 +1390,7 @@ git commit -m "feat(search): 添加搜索结果列表组件"
---
### Task 12: 创建空状态组件
**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 13: 创建搜索结果页
### Task 14: 创建搜索结果页
**Files:**
- Create: `txw-mhzc-web/src/pages/index/views/search/index.vue`
@ -1611,7 +1618,7 @@ git commit -m "feat(search): 添加搜索结果页"
---
### Task 14: 添加搜索路由
### Task 15: 添加搜索路由
**Files:**
- Modify: `txw-mhzc-web/src/pages/index/router/routes.js`
@ -1655,7 +1662,7 @@ git commit -m "feat(search): 添加搜索路由"
## 联调与测试
### Task 15: 前后端联调
### Task 16: 前后端联调
- [ ] **Step 1: 验证后端接口**

View File

@ -0,0 +1,319 @@
# gxzx 服务迁移至 mhzc 方案
## 一、迁移背景
`gxzx`(供需大厅/绿色金融/绿色交易/企业入驻)需要合并到 `mhzc` 服务中,以实现:
1. 统一服务治理,减少跨服务调用
2. 支撑服务中心的搜索聚合功能
3. 简化系统架构
---
## 二、迁移范围
### 2.1 数据库表9张
| 序号 | 表名 | 说明 |
|------|------|------|
| 1 | `txw_gxzx_gxxxb` | 供需信息表 |
| 2 | `txw_gxzx_shqkb` | 审核情况表 |
| 3 | `txw_gxzx_gxscb` | 供需收藏表 |
| 4 | `txw_gxzx_gxbqb` | 供需标签表 |
| 5 | `txw_gxzx_qybqb` | 企业标签表 |
| 6 | `txw_gxzx_rzsqjlb` | 入驻申请记录表 |
| 7 | `txw_gxzx_lsjrcpxx` | 绿色金融产品信息表 |
| 8 | `txw_gxzx_dkbxsqxx` | 贷款保险申请信息表 |
| 9 | `txw_gxzx_lsjy_zcxx` | 绿色交易资产信息表 |
### 2.2 Java代码
| 层次 | 文件数 | 说明 |
|------|--------|------|
| Controller | 4 | GxdtController, LsjrController, LsjyController, QyRzController |
| Service接口 | 8 | 含业务接口定义 |
| Service实现 | 7 | 业务逻辑实现 |
| Mapper | 9 | MyBatis Mapper接口 |
| Mapper XML | 9 | MyBatis XML映射文件 |
| Domain | 9 | 数据实体DO |
| VO/Req/Res | ~20 | 传输对象 |
### 2.3 核心业务功能
1. **供需大厅**GxdtController- 供需发布/审批/上下架/收藏
2. **绿色金融**LsjrController- 信贷/保险产品管理
3. **绿色交易**LsjyController- 资产信息管理
4. **企业入驻**QyRzController- 入驻申请审批
---
## 三、迁移步骤
### 阶段一:数据库迁移
1. **备份源库**:在迁移前备份 `txw-gxzx` 相关表数据
2. **创建目标表**:在 `mhzc` 数据库中创建9张表可保持原表名或重命名
3. **数据同步**:将数据从 `gxzx` 库同步到 `mhzc`
```sql
-- 示例在mhzc库执行假设两库在同一实例
INSERT INTO mhzc_db.txw_gxzx_gxxxb SELECT * FROM gxzx_db.txw_gxzx_gxxxb;
```
### 阶段二:代码迁移
#### 2.1 创建目录结构
`txw-mhzc` 项目中创建以下目录:
```
txw-mhzc-service-biz/src/main/java/com/css/txw/mhzc/
├── controller/gxzx/ # 原 gxzx/controller
├── service/gxzx/ # 原 gxzx/service
│ └── impl/ # 原 gxzx/service/impl
├── mapper/gxzx/ # 原 gxzx/mapper
├── pojo/domain/gxzx/ # 原 gxzx/pojo/domain
├── pojo/vo/gxzx/ # 原 gxzx/pojo/vo
├── pojo/req/gxzx/ # 新建请求对象
└── pojo/res/gxzx/ # 新建响应对象
```
#### 2.2 迁移文件清单
**Controller 层4个**
```
gxzx/controller/GxdtController.java → mhzc/controller/gxzx/GxdtController.java
gxzx/controller/LsjrController.java → mhzc/controller/gxzx/LsjrController.java
gxzx/controller/LsjyController.java → mhzc/controller/gxzx/LsjyController.java
gxzx/controller/QyRzController.java → mhzc/controller/gxzx/QyRzController.java
```
**Service 层8个接口 + 7个实现**
```
gxzx/service/TxwGxzxGxxxbService.java → mhzc/service/gxzx/TxwGxzxGxxxbService.java
gxzx/service/TxwGxzxShqkbService.java → mhzc/service/gxzx/TxwGxzxShqkbService.java
gxzx/service/TxwGxzxGxscbService.java → mhzc/service/gxzx/TxwGxzxGxscbService.java
gxzx/service/TxwGxzxGxbqbService.java → mhzc/service/gxzx/TxwGxzxGxbqbService.java
gxzx/service/TxwGxzxQybqbService.java → mhzc/service/gxzx/TxwGxzxQybqbService.java
gxzx/service/TxwGxzxRzsqjlbService.java → mhzc/service/gxzx/TxwGxzxRzsqjlbService.java
gxzx/service/GxzxLsjrService.java → mhzc/service/gxzx/GxzxLsjrService.java
gxzx/service/GxzxLsjyZcxxService.java → mhzc/service/gxzx/GxzxLsjyZcxxService.java
gxzx/service/impl/... → mhzc/service/gxzx/impl/...
```
**Mapper 层9个**
```
gxzx/mapper/TxwGxzxGxxxbMapper.java → mhzc/mapper/gxzx/TxwGxzxGxxxbMapper.java
gxzx/mapper/TxwGxzxShqkbMapper.java → mhzc/mapper/gxzx/TxwGxzxShqkbMapper.java
...其他7个同理
```
**Domain 层9个**
```
gxzx/pojo/domain/TxwGxzxGxxxbDO.java → mhzc/pojo/domain/gxzx/TxwGxzxGxxxbDO.java
...其他8个同理
```
**VO/Req/Res 层(~20个**
```
gxzx/pojo/vo/*.java → mhzc/pojo/vo/gxzx/
gxzx/pojo/req/*.java → mhzc/pojo/req/gxzx/
gxzx/pojo/lsjr/*.java → mhzc/pojo/lsjr/gxzx/
gxzx/pojo/lsjy/*.java → mhzc/pojo/lsjy/gxzx/
```
**Mapper XML9个**
```
gxzx/src/main/resources/mapper/*.xml → mhzc/src/main/resources/mapper/gxzx/
```
#### 2.3 代码修改要点
**1. 包名修改**
```java
// 原
package com.css.txw.gxzx.controller;
package com.css.txw.gxzx.service;
package com.css.txw.gxzx.mapper;
package com.css.txw.gxzx.pojo.domain;
// 改为
package com.css.txw.mhzc.controller.gxzx;
package com.css.txw.mhzc.service.gxzx;
package com.css.txw.mhzc.mapper.gxzx;
package com.css.txw.mhzc.pojo.domain.gxzx;
```
**2. import 语句修改**
```java
// 所有 import com.css.txw.gxzx.* 改为 com.css.txw.mhzc.gxzx.*
```
**3. Controller @RequestMapping 路径调整**
```java
// 原
@RequestMapping("/gxdt")
@RequestMapping("/lsjr")
@RequestMapping("/lsjy")
@RequestMapping("/qyrz")
// 保持不变(前端已对接)或调整
```
**4. ServiceImpl 类注解修改**
```java
// 原
@Service
public class TxwGxzxGxxxbServiceImpl extends ServiceImpl<TxwGxzxGxxxbMapper, TxwGxzxGxxxbDO>
// 改为
@Service
public class TxwGxzxGxxxbServiceImpl extends ServiceImpl<TxwGxzxGxxxbMapper, TxwGxzxGxxxbDO>
```
**5. MapperScan 配置**
`MhzcServiceConfiguration.java` 或新建 `GxzxMapperScanConfiguration.java`
```java
@MapperScan({"com.css.txw.mhzc.mapper", "com.css.txw.mhzc.mapper.gxzx"})
```
---
### 阶段三:依赖调整
#### 3.1 移除 gxzx 依赖
`txw-mhzc/pom.xml` 中移除对 `txw-gxzx-service-api` 的依赖(如果存在)。
#### 3.2 保留必要依赖
确保以下依赖存在:
```xml
<!-- 内部框架 -->
<dependency>ggzc-framework-starter-xxzx-api</dependency> <!-- 消息中心 -->
<!-- 数据库 -->
<dependency>DmJdbcDriver18</dependency>
<!-- 内部模块 -->
<dependency>txw-common</dependency>
```
#### 3.3 处理外部服务调用
**IMhzcApi 调用处理**(原 gxzx 调用 mhzc
- `TxwGxzxRzsqjlbServiceImpl` 中注入了 `IMhzcApi`
- 迁移后改为直接调用本地方法(企业入驻逻辑已在 mhzc 中)
**XxzxApi 调用处理**
- 确保 `txw-mhzc` 有消息中心依赖
- 如需迁移,保持调用方式不变
---
### 阶段四:配置调整
#### 4.1 网关路由(如有)
如果使用了网关,需要将 gxzx 相关路由从 gxzx 服务指向 mhzc
```yaml
# 网关配置
- id: gxzx-route
uri: http://mhzc-service
predicates:
- Path=/gxdt/**
- Path=/lsjr/**
- Path=/lsjy/**
- Path=/qyrz/**
```
#### 4.2 Nacos 注册
确保 `txw-mhzc` 注册到 Nacos且 gxzx 相关接口可访问。
---
### 阶段五:前端适配
前端 `txw-mhzc-web` 中已有 gxzx 相关接口调用(见 `fwsc/index.js`
```javascript
// 当前调用路径(需确认)
/gxzx/gxdt/gxxxList → /mhzc/gxdt/gxxxList 或保持不变
/gxzx/lsjr/queryJgList → /mhzc/lsjr/queryJgList 或保持不变
```
**方案**:迁移后保持接口路径不变,前端无需修改。
---
## 四、数据库表迁移脚本
```sql
-- 1. 备份原表(可选)
CREATE TABLE txw_gxzx_gxxxb_bak AS SELECT * FROM txw_gxzx_gxxxb;
-- 2. 创建新表(在 mhzc 库执行)
CREATE TABLE txw_gxzx_gxxxb (
gx_uuid VARCHAR(64) PRIMARY KEY,
bt_1 VARCHAR(200),
fwlx_dm VARCHAR(20),
sshy VARCHAR(50),
fwfw VARCHAR(500),
fwnr TEXT,
zt VARCHAR(10),
qyuuid VARCHAR(64),
sjzt VARCHAR(10),
gjjg DECIMAL(18,2),
-- 其他字段...
);
-- 3. 数据迁移
INSERT INTO mhzc_db.txw_gxzx_gxxxb SELECT * FROM gxzx_db.txw_gxzx_gxxxb;
-- 4. 验证
SELECT COUNT(*) FROM txw_gxzx_gxxxb;
```
---
## 五、风险点与注意事项
### 5.1 数据一致性
- 迁移期间禁止在 gxzx 写入数据
- 迁移后需验证数据完整性
### 5.2 接口兼容性
- 迁移前后接口路径尽量保持一致
- 如有变化需同步通知前端
### 5.3 事务处理
- 跨库迁移时可考虑使用分布式事务或分批迁移
- 确保关键业务(供需发布、审批)的事务完整性
### 5.4 依赖服务
- 消息中心XxzxApi需确保可用
- 确认 IMhzcApi 在 mhzc 中仍有调用需求
---
## 六、迁移验证清单
| 序号 | 验证项 | 方法 |
|------|--------|------|
| 1 | 数据库表迁移完整 | 对比记录数 |
| 2 | Controller 注入正常 | 启动应用无报错 |
| 3 | Service 层无异常 | 单元测试 |
| 4 | Mapper XML 路径正确 | 查询功能正常 |
| 5 | 接口路径可访问 | Postman 测试 |
| 6 | 搜索服务聚合供需数据 | 调用搜索接口验证 |
| 7 | 前端功能正常 | UI 测试 |
---
## 七、后续工作
1. **旧服务下线**gxzx 服务相关接口迁移完成后,可逐步停用 gxzx 服务
2. **搜索增强**:基于迁移后的供需数据,完善搜索服务聚合逻辑
3. **代码清理**:移除 gxzx 项目中已迁移的代码

View File

@ -43,17 +43,14 @@ request.interceptors.request.use(
if (newConf.loading) {
SingleLoading.startLoading();
}
// 设置随机数, 定位后端日志和解决浏览器缓存
const { url } = newConf;
if (url.indexOf('?') !== -1) {
newConf.url = `${url}&t=${new Date().getTime()}`; // 请求添加时间戳
} else {
newConf.url = `${url}?t=${new Date().getTime()}`;
}
// get请求映射params参数
if (newConf.method === 'get' && newConf.params) {
let url = `${newConf.url}?`;
// 先加时间戳(如果 URL 还没参数)
if (url.indexOf('?') === -1) {
newConf.url = `${newConf.url}?t=${new Date().getTime()}&`;
}
// 再追加 params
let paramsUrl = `${newConf.url}`;
for (const propName of Object.keys(newConf.params)) {
const value = newConf.params[propName];
const part = `${encodeURIComponent(propName)}=`;
@ -62,16 +59,23 @@ request.interceptors.request.use(
for (const key of Object.keys(value)) {
const params = `${propName}[${key}]`;
const subPart = `${encodeURIComponent(params)}=`;
url += `${subPart + encodeURIComponent(value[key])}&`;
paramsUrl += `${subPart + encodeURIComponent(value[key])}&`;
}
} else {
url += `${part + encodeURIComponent(value)}&`;
paramsUrl += `${part + encodeURIComponent(value)}&`;
}
}
}
url = url.slice(0, -1);
paramsUrl = paramsUrl.slice(0, -1);
newConf.params = {};
newConf.url = url;
newConf.url = paramsUrl;
} else {
// 无 params 时,直接加时间戳
if (url.indexOf('?') !== -1) {
newConf.url = `${url}&t=${new Date().getTime()}`;
} else {
newConf.url = `${url}?t=${new Date().getTime()}`;
}
}
return newConf;
},

View File

@ -0,0 +1,64 @@
import { fetchSso } from '@/core/request';
const basurl = '/mhzc';
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,
});
},
};

View File

@ -0,0 +1,183 @@
<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>

View File

@ -0,0 +1,117 @@
<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>

View File

@ -0,0 +1,50 @@
<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>

View File

@ -0,0 +1,68 @@
<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>

View File

@ -0,0 +1,120 @@
<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>

View File

@ -0,0 +1,104 @@
<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>

View File

@ -0,0 +1,61 @@
<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>

View File

@ -87,7 +87,7 @@ function tfwgj() {
function tfwxq() {
return import('@/pages/index/views/gxfb/tfwxq.vue');
}
//绿色交易
function lsjy() {
return import('@/pages/index/views/lsjy/lsjy.vue');
@ -98,6 +98,11 @@ function gzt() {
return import(/* webpackChunkName: "gzt" */ '@/pages/index/views/gzt/index.vue');
}
// 搜索结果页
function search() {
return import(/* webpackChunkName: "search" */ '@/pages/index/views/search/index.vue');
}
@ -298,4 +303,16 @@ export default [
disableBack: true,
},
},
{
name: 'search',
path: '/search',
component: search,
meta: {
title: '搜索结果',
isShowSideBar: false,
hasHome: true,
breadCrumbs: [{ title: '首页', to: '/home' }, { title: '搜索结果', to: '/search' }],
disableBack: true,
},
},
];

View File

@ -0,0 +1,195 @@
<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() {
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;
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;
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() {
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);
if (this.searchHistory.length > 10) {
this.searchHistory = this.searchHistory.slice(0, 10);
}
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>

View File

@ -286,26 +286,26 @@ module.exports = {
// 会误伤 SPA 路由 /view/mhzc/...,刷新时整页请求被转发到后端导致 Proxy error。必须用 ^ 限定为路径前缀。
proxy: {
'^/sso': {
// target: 'http://localhost:9301',
target: 'http://carbon.liantu.tech',
target: 'http://localhost:9301',
// target: 'http://carbon.liantu.tech',
// target: 'http://10.23.20.13:94/',
changeOrigin: true,
},
'^/mhzc': {
// target: 'http://localhost:9302',
target: 'http://carbon.liantu.tech',
target: 'http://localhost:9302',
// target: 'http://carbon.liantu.tech',
// target: 'http://10.23.20.13:94/',
changeOrigin: true,
},
'^/gxzx': {
// target: 'http://localhost:9303',
target: 'http://carbon.liantu.tech',
target: 'http://localhost:9303',
// target: 'http://carbon.liantu.tech',
// target: 'http://10.23.20.13:94/',
changeOrigin: true,
},
'^/yygl': {
// target: 'http://localhost:20010',
target: 'http://carbon.liantu.tech',
target: 'http://localhost:20010',
// target: 'http://carbon.liantu.tech',
// target: 'http://10.23.20.13:94/',
changeOrigin: true,
},

View File

@ -0,0 +1,75 @@
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.HashMap;
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);
Map<String, Object> result = new HashMap<>();
result.put("total", list.size());
result.put("list", list);
Map<String, Integer> categoryCount = new 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 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);
}
}

View File

@ -0,0 +1,25 @@
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;
}

View File

@ -0,0 +1,39 @@
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;
}

View File

@ -15,4 +15,6 @@ public class SyzxxxVO implements Serializable {
private String zxLx;
private String uuid;
}

View File

@ -0,0 +1,33 @@
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();
}

View File

@ -0,0 +1,135 @@
package com.css.txw.mhzc.service.impl;
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.Map;
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) {
Map<String, List<SyzxxxVO>> data = zxxxbService.zxxx();
List<SearchResultVO> resultList = new ArrayList<>();
String categoryType = reqVO.getCategoryType();
if (data != null) {
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.getUuid());
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() != null ? zxxx.getFbsj().toString() : null);
vo.setUrl("/mhzc/news/" + zxxx.getUuid());
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() {
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();
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() {
return new ArrayList<>();
}
@Override
public void clearSearchHistory() {
}
}