搜索功能
CarefreeCMS 提供强大的全文搜索功能,支持多种搜索方式,帮助用户快速找到所需内容。
搜索概述
搜索类型
基础搜索
- 标题搜索
- 内容搜索
- 标签搜索
- 作者搜索
高级搜索
- 分类筛选
- 时间范围
- 标签组合
- 自定义字段
全文搜索
- 中文分词
- 关键词高亮
- 相关度排序
- 搜索建议
搜索引擎
内置搜索
- MySQL LIKE 查询
- MySQL 全文索引
- 简单快速
Elasticsearch
- 分布式搜索
- 强大的分词
- 高性能
- 适合大数据量
Meilisearch
- 轻量级搜索引擎
- 易于部署
- 搜索速度快
- 中文支持好
基础搜索
前台搜索框
头部搜索
<div class="header-search">
<form action="/search" method="get">
<input type="text"
name="keyword"
placeholder="搜索文章..."
autocomplete="off">
<button type="submit">
<i class="icon-search"></i>
</button>
</form>
</div>
搜索页面
<div class="search-page">
<div class="search-box">
<form action="/search" method="get">
<input type="text"
name="keyword"
value="{{ keyword }}"
placeholder="输入关键词搜索">
<button type="submit">搜索</button>
</form>
</div>
<div class="search-results">
<p class="search-info">
找到 <strong>{{ total }}</strong> 个结果
</p>
{% for article in articles %}
<div class="result-item">
<h3>
<a href="/article/{{ article.id }}">
{{ article.title|highlight(keyword)|raw }}
</a>
</h3>
<p class="summary">
{{ article.summary|highlight(keyword)|raw }}
</p>
<div class="meta">
<span>{{ article.create_time }}</span>
<span>{{ article.category.name }}</span>
</div>
</div>
{% endfor %}
</div>
<!-- 分页 -->
{{ pagination|raw }}
</div>
后端实现
搜索控制器
<?php
namespace app\controller\index;
use app\model\Article;
class Search
{
public function index()
{
$keyword = input('keyword', '');
$page = input('page', 1);
$pageSize = 20;
if (empty($keyword)) {
return view('search/index', [
'keyword' => '',
'articles' => [],
'total' => 0
]);
}
// 搜索文章
$query = Article::where('status', 1);
// 标题或内容包含关键词
$query->where(function($q) use ($keyword) {
$q->whereOr('title', 'like', "%{$keyword}%")
->whereOr('content', 'like', "%{$keyword}%");
});
// 获取总数
$total = $query->count();
// 分页查询
$articles = $query->page($page, $pageSize)
->order('create_time', 'desc')
->select();
return view('search/index', [
'keyword' => $keyword,
'articles' => $articles,
'total' => $total,
'page' => $page
]);
}
}
高级搜索
搜索表单
<form action="/search" method="get" class="advanced-search">
<!-- 关键词 -->
<div class="form-group">
<label>关键词</label>
<input type="text" name="keyword" value="{{ keyword }}">
</div>
<!-- 分类 -->
<div class="form-group">
<label>分类</label>
<select name="category_id">
<option value="">全部分类</option>
{% for category in categories %}
<option value="{{ category.id }}"
{{ category_id == category.id ? 'selected' : '' }}>
{{ category.name }}
</option>
{% endfor %}
</select>
</div>
<!-- 标签 -->
<div class="form-group">
<label>标签</label>
<select name="tag_id">
<option value="">全部标签</option>
{% for tag in tags %}
<option value="{{ tag.id }}"
{{ tag_id == tag.id ? 'selected' : '' }}>
{{ tag.name }}
</option>
{% endfor %}
</select>
</div>
<!-- 时间范围 -->
<div class="form-group">
<label>发布时间</label>
<input type="date" name="start_date" value="{{ start_date }}">
<span>至</span>
<input type="date" name="end_date" value="{{ end_date }}">
</div>
<!-- 排序 -->
<div class="form-group">
<label>排序方式</label>
<select name="sort">
<option value="create_time">发布时间</option>
<option value="view_count">浏览量</option>
<option value="like_count">点赞数</option>
</select>
<select name="order">
<option value="desc">降序</option>
<option value="asc">升序</option>
</select>
</div>
<button type="submit">搜索</button>
<button type="reset">重置</button>
</form>
后端处理
public function advanced()
{
$params = [
'keyword' => input('keyword', ''),
'category_id' => input('category_id', 0),
'tag_id' => input('tag_id', 0),
'start_date' => input('start_date', ''),
'end_date' => input('end_date', ''),
'sort' => input('sort', 'create_time'),
'order' => input('order', 'desc'),
'page' => input('page', 1),
];
$query = Article::where('status', 1);
// 关键词搜索
if (!empty($params['keyword'])) {
$query->where(function($q) use ($params) {
$q->whereOr('title', 'like', "%{$params['keyword']}%")
->whereOr('content', 'like', "%{$params['keyword']}%");
});
}
// 分类筛选
if ($params['category_id'] > 0) {
$query->where('category_id', $params['category_id']);
}
// 标签筛选
if ($params['tag_id'] > 0) {
$query->whereExists(function($q) use ($params) {
$q->table('article_tag')
->whereRaw('article_tag.article_id = article.id')
->where('article_tag.tag_id', $params['tag_id']);
});
}
// 时间范围
if (!empty($params['start_date'])) {
$query->where('create_time', '>=', $params['start_date']);
}
if (!empty($params['end_date'])) {
$query->where('create_time', '<=', $params['end_date'] . ' 23:59:59');
}
// 排序
$query->order($params['sort'], $params['order']);
// 分页
$total = $query->count();
$articles = $query->page($params['page'], 20)->select();
return view('search/advanced', [
'params' => $params,
'articles' => $articles,
'total' => $total,
]);
}
MySQL 全文索引
创建全文索引
-- 为标题和内容创建全文索引
ALTER TABLE article
ADD FULLTEXT INDEX ft_title_content (title, content)
WITH PARSER ngram;
使用全文搜索
public function fulltext()
{
$keyword = input('keyword', '');
if (empty($keyword)) {
return json(['code' => 400, 'message' => '请输入关键词']);
}
// 使用全文搜索
$articles = Article::where('status', 1)
->whereRaw("MATCH(title, content) AGAINST(? IN NATURAL LANGUAGE MODE)", [$keyword])
->order('create_time', 'desc')
->select();
return json([
'code' => 200,
'data' => $articles
]);
}
布尔模式搜索
// 必须包含 "Vue" 和 "教程"
$keyword = '+Vue +教程';
// 必须包含 "Vue",但不能包含 "React"
$keyword = '+Vue -React';
// 包含 "Vue" 或 "React"
$keyword = 'Vue React';
$articles = Article::whereRaw(
"MATCH(title, content) AGAINST(? IN BOOLEAN MODE)",
[$keyword]
)->select();
Elasticsearch 集成
安装配置
安装 Elasticsearch
# Docker 安装
docker run -d \
--name elasticsearch \
-p 9200:9200 \
-p 9300:9300 \
-e "discovery.type=single-node" \
elasticsearch:8.11.0
安装中文分词插件
# 进入容器
docker exec -it elasticsearch bash
# 安装 ik 分词器
elasticsearch-plugin install https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v8.11.0/elasticsearch-analysis-ik-8.11.0.zip
# 重启
docker restart elasticsearch
安装 PHP 客户端
composer require elasticsearch/elasticsearch
配置索引
<?php
namespace app\service;
use Elasticsearch\ClientBuilder;
class ElasticsearchService
{
private $client;
public function __construct()
{
$this->client = ClientBuilder::create()
->setHosts(['localhost:9200'])
->build();
}
// 创建索引
public function createIndex()
{
$params = [
'index' => 'articles',
'body' => [
'settings' => [
'number_of_shards' => 1,
'number_of_replicas' => 0,
'analysis' => [
'analyzer' => [
'ik_smart' => [
'type' => 'custom',
'tokenizer' => 'ik_smart'
]
]
]
],
'mappings' => [
'properties' => [
'id' => ['type' => 'integer'],
'title' => [
'type' => 'text',
'analyzer' => 'ik_smart',
'search_analyzer' => 'ik_smart'
],
'content' => [
'type' => 'text',
'analyzer' => 'ik_smart',
'search_analyzer' => 'ik_smart'
],
'category_id' => ['type' => 'integer'],
'tags' => ['type' => 'keyword'],
'create_time' => ['type' => 'date']
]
]
]
];
return $this->client->indices()->create($params);
}
// 添加文档
public function addDocument($article)
{
$params = [
'index' => 'articles',
'id' => $article->id,
'body' => [
'id' => $article->id,
'title' => $article->title,
'content' => strip_tags($article->content),
'category_id' => $article->category_id,
'tags' => $article->tags->column('name'),
'create_time' => $article->create_time
]
];
return $this->client->index($params);
}
// 搜索
public function search($keyword, $page = 1, $pageSize = 20)
{
$params = [
'index' => 'articles',
'body' => [
'from' => ($page - 1) * $pageSize,
'size' => $pageSize,
'query' => [
'multi_match' => [
'query' => $keyword,
'fields' => ['title^3', 'content'],
'type' => 'best_fields'
]
],
'highlight' => [
'pre_tags' => ['<em class="highlight">'],
'post_tags' => ['</em>'],
'fields' => [
'title' => new \stdClass(),
'content' => [
'fragment_size' => 150,
'number_of_fragments' => 3
]
]
]
]
];
$response = $this->client->search($params);
return [
'total' => $response['hits']['total']['value'],
'hits' => $response['hits']['hits']
];
}
}
使用示例
use app\service\ElasticsearchService;
class SearchController
{
public function elasticsearch()
{
$keyword = input('keyword', '');
$page = input('page', 1);
$es = new ElasticsearchService();
$result = $es->search($keyword, $page);
$articles = [];
foreach ($result['hits'] as $hit) {
$articles[] = [
'id' => $hit['_source']['id'],
'title' => $hit['highlight']['title'][0] ?? $hit['_source']['title'],
'content' => $hit['highlight']['content'][0] ?? '',
'score' => $hit['_score']
];
}
return json([
'code' => 200,
'data' => [
'articles' => $articles,
'total' => $result['total']
]
]);
}
}
搜索优化
关键词高亮
PHP 实现
function highlight($text, $keyword)
{
if (empty($keyword)) {
return $text;
}
$keywords = explode(' ', $keyword);
foreach ($keywords as $word) {
$text = preg_replace(
'/(' . preg_quote($word, '/') . ')/ui',
'<em class="highlight">$1</em>',
$text
);
}
return $text;
}
Twig 过滤器
use Twig\TwigFilter;
$twig->addFilter(new TwigFilter('highlight', function($text, $keyword) {
return highlight($text, $keyword);
}));
使用
<h3>___TWIG0___</h3>
<p>___TWIG1___</p>
搜索建议
自动完成
// 前端实现
const searchInput = document.querySelector('#search-input');
const suggestionBox = document.querySelector('#suggestions');
let timeout;
searchInput.addEventListener('input', function(e) {
clearTimeout(timeout);
const keyword = e.target.value;
if (keyword.length < 2) {
suggestionBox.style.display = 'none';
return;
}
timeout = setTimeout(() => {
fetch(`/api/search/suggest?keyword=${keyword}`)
.then(res => res.json())
.then(data => {
if (data.code === 200) {
showSuggestions(data.data);
}
});
}, 300);
});
function showSuggestions(suggestions) {
suggestionBox.innerHTML = '';
suggestions.forEach(item => {
const div = document.createElement('div');
div.className = 'suggestion-item';
div.textContent = item;
div.onclick = () => {
searchInput.value = item;
suggestionBox.style.display = 'none';
};
suggestionBox.appendChild(div);
});
suggestionBox.style.display = 'block';
}
后端接口
public function suggest()
{
$keyword = input('keyword', '');
// 从文章标题中匹配
$articles = Article::where('title', 'like', "%{$keyword}%")
->field('title')
->limit(10)
->select();
$suggestions = $articles->column('title');
return json([
'code' => 200,
'data' => $suggestions
]);
}
热门搜索
记录搜索关键词
use app\model\SearchLog;
public function index()
{
$keyword = input('keyword', '');
if (!empty($keyword)) {
// 记录搜索
SearchLog::create([
'keyword' => $keyword,
'result_count' => $total,
'user_id' => session('user_id'),
'ip' => request()->ip(),
'create_time' => date('Y-m-d H:i:s')
]);
}
// 执行搜索...
}
获取热门搜索
public function hot()
{
// 最近7天的热门搜索
$keywords = SearchLog::where('create_time', '>=', date('Y-m-d', strtotime('-7 days')))
->field('keyword, COUNT(*) as count')
->group('keyword')
->order('count', 'desc')
->limit(10)
->select();
return json([
'code' => 200,
'data' => $keywords
]);
}
前台显示
<div class="hot-search">
<h4>热门搜索</h4>
<div class="tags">
{% for item in hot_keywords %}
<a href="/search?keyword={{ item.keyword }}" class="tag">
{{ item.keyword }}
</a>
{% endfor %}
</div>
</div>
搜索统计
统计面板
public function statistics()
{
// 总搜索次数
$total = SearchLog::count();
// 今日搜索次数
$today = SearchLog::whereDay('create_time', 'today')->count();
// 搜索关键词排行
$topKeywords = SearchLog::field('keyword, COUNT(*) as count')
->group('keyword')
->order('count', 'desc')
->limit(20)
->select();
// 无结果搜索
$noResults = SearchLog::where('result_count', 0)
->field('keyword, COUNT(*) as count')
->group('keyword')
->order('count', 'desc')
->limit(20)
->select();
return view('admin/search/statistics', [
'total' => $total,
'today' => $today,
'topKeywords' => $topKeywords,
'noResults' => $noResults
]);
}
API 接口
搜索接口
请求
GET /api/search?keyword=Vue&page=1&page_size=20
响应
{
"code": 200,
"message": "搜索成功",
"data": {
"list": [
{
"id": 1,
"title": "Vue 3 教程",
"summary": "Vue 3 入门教程...",
"highlight": {
"title": "<em>Vue</em> 3 教程",
"content": "...详细的<em>Vue</em>教程..."
}
}
],
"total": 15,
"page": 1,
"page_size": 20
}
}
搜索建议接口
请求
GET /api/search/suggest?keyword=Vue
响应
{
"code": 200,
"data": [
"Vue 3 教程",
"Vue Router",
"Vue Composition API"
]
}
最佳实践
搜索性能
使用索引
-- 为常用搜索字段添加索引
CREATE INDEX idx_title ON article(title);
CREATE INDEX idx_status_time ON article(status, create_time);
缓存结果
use think\facade\Cache;
public function search($keyword)
{
$cacheKey = 'search:' . md5($keyword);
// 尝试从缓存获取
$result = Cache::get($cacheKey);
if ($result) {
return $result;
}
// 执行搜索
$result = $this->doSearch($keyword);
// 缓存5分钟
Cache::set($cacheKey, $result, 300);
return $result;
}
搜索体验
输入防抖
// 防止频繁请求
let searchTimeout;
input.addEventListener('input', function(e) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
performSearch(e.target.value);
}, 300);
});
搜索历史
// 保存搜索历史
function saveSearchHistory(keyword) {
let history = JSON.parse(localStorage.getItem('searchHistory') || '[]');
// 去重并添加到开头
history = history.filter(item => item !== keyword);
history.unshift(keyword);
// 只保留最近10条
history = history.slice(0, 10);
localStorage.setItem('searchHistory', JSON.stringify(history));
}
常见问题
中文搜索不准确?
使用 ngram 分词器
ALTER TABLE article
ADD FULLTEXT INDEX ft_content (content)
WITH PARSER ngram;
或使用 Elasticsearch + IK 分词
搜索速度慢?
优化方案:
- 添加数据库索引
- 限制搜索范围(只搜索标题)
- 使用全文索引
- 使用 Elasticsearch
- 缓存搜索结果
如何过滤敏感词?
public function filterSensitiveWords($keyword)
{
$sensitiveWords = ['敏感词1', '敏感词2'];
foreach ($sensitiveWords as $word) {
$keyword = str_replace($word, '***', $keyword);
}
return $keyword;
}
