feat:增加博客搜索功能

This commit is contained in:
2025-11-16 21:56:26 +08:00
parent 47b6fb9937
commit 61922cbcd8
7 changed files with 298 additions and 34 deletions

View File

@@ -35,6 +35,15 @@ Future<List<Blog>> queryBlogByConditionApi(
);
}
Future<List<BlogSearch>> searchBlog(String keyword) {
return HttpUtil().get<List<BlogSearch>>(
"/blog/search",
queryParameters: {"keyword": keyword},
converter:
(data) => convertListResponse<BlogSearch>(data, BlogSearch.fromJson),
);
}
Future<Blog> queryBlogByIdApi(int id) {
return HttpUtil().get<Blog>(
"/blog/$id/content",

View File

@@ -107,3 +107,30 @@ class BlogStats {
Map<String, dynamic> toJson() => _$BlogStatsToJson(this);
}
@JsonSerializable(genericArgumentFactories: true)
class BlogSearch {
@JsonKey(name: 'id')
final int id;
@JsonKey(name: 'title')
final String title;
@JsonKey(name: 'content')
final String content;
@JsonKey(name: 'highlight')
final String highlight;
const BlogSearch({
required this.id,
required this.title,
required this.content,
required this.highlight,
});
factory BlogSearch.fromJson(Map<String, dynamic> json) =>
_$BlogSearchFromJson(json);
Map<String, dynamic> toJson() => _$BlogSearchToJson(this);
}

View File

@@ -59,3 +59,18 @@ Map<String, dynamic> _$BlogStatsToJson(BlogStats instance) => <String, dynamic>{
'visitCount': instance.visitCount,
'wordCount': instance.wordCount,
};
BlogSearch _$BlogSearchFromJson(Map<String, dynamic> json) => BlogSearch(
id: (json['id'] as num).toInt(),
title: json['title'] as String,
content: json['content'] as String,
highlight: json['highlight'] as String,
);
Map<String, dynamic> _$BlogSearchToJson(BlogSearch instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'content': instance.content,
'highlight': instance.highlight,
};

View File

@@ -1,5 +1,8 @@
import 'package:blog_app/apis/blog.dart';
import 'package:blog_app/layout/drawer.dart';
import 'package:blog_app/layout/menu.dart';
import 'package:blog_app/models/blog.dart';
import 'package:blog_app/widget/search.dart';
import 'package:flutter/material.dart';
class HomePage extends StatefulWidget {
@@ -11,6 +14,12 @@ class _HomePageState extends State<HomePage> {
int _currentPageIndex = 0;
late PageController _pageController;
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
List<BlogSearch> _searchResults = [];
String _searchQuery = '';
@override
void initState() {
super.initState();
@@ -20,53 +29,145 @@ class _HomePageState extends State<HomePage> {
@override
void dispose() {
_pageController.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
void _startSearch() {
setState(() {
_isSearching = true;
});
Future.delayed(Duration(milliseconds: 100), () {
_searchFocusNode.requestFocus();
});
}
void _exitSearch() {
setState(() {
_isSearching = false;
_searchController.clear();
_searchResults.clear();
_searchQuery = '';
});
FocusScope.of(context).unfocus();
}
void _clearSearch() {
_searchController.clear();
setState(() {
_searchResults.clear();
_searchQuery = '';
});
}
void _onSearchTextChanged(String query) async {
setState(() {
_searchQuery = query;
});
if (query.isEmpty) {
setState(() {
_searchResults.clear();
});
return;
}
final result = await searchBlog(query);
setState(() {
_searchResults = result;
});
}
Widget _buildLeading() {
if (_isSearching) {
return IconButton(
icon: Icon(Icons.arrow_back, color: Colors.white),
onPressed: _exitSearch,
);
}
return Builder(
builder:
(context) => IconButton(
icon: Icon(Icons.menu, color: Colors.white),
onPressed: () => Scaffold.of(context).openDrawer(),
),
);
}
Widget _buildAppBarTitle() {
if (_isSearching) {
return buildSearchField(
controller: _searchController,
focusNode: _searchFocusNode,
onChanged: _onSearchTextChanged,
);
}
return Text(
pages[_currentPageIndex].title,
style: TextStyle(color: Colors.white),
);
}
List<Widget> _buildAppBarActions() {
if (_isSearching) {
return [
if (_searchController.text.isNotEmpty)
IconButton(
icon: Icon(Icons.clear, color: Colors.white),
onPressed: _clearSearch,
),
];
} else {
return [
IconButton(
icon: Icon(Icons.search, color: Colors.white),
onPressed: _startSearch,
),
];
}
}
Widget _buildPageView() {
return _isSearching ? _buildSearchResults() : _buildNormalPageView();
}
Widget _buildNormalPageView() {
return PageView(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentPageIndex = index;
if (_isSearching) {
_exitSearch();
}
});
},
children: pageWidgets,
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
pages[_currentPageIndex].title,
style: TextStyle(color: Colors.white),
),
backgroundColor: Theme.of(context).colorScheme.primary,
elevation: 0,
leading: Builder(
builder:
(context) => IconButton(
icon: Icon(Icons.menu, color: Colors.white),
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
actions: [
IconButton(
icon: Icon(Icons.search, color: Colors.white),
onPressed: () {},
),
],
),
drawer: _buildDrawer(),
body: Padding(padding: EdgeInsets.all(10), child: _buildPageView()),
);
Widget _buildSearchResults() {
if (_searchQuery.isEmpty) {
return buildSearchQueryEmpty();
}
if (_searchResults.isEmpty) {
return buildSearchResultEmpty(_searchQuery);
}
return buildSearchResultList(context, _searchResults);
}
void onTapDrawerItem(int index) {
setState(() {
_currentPageIndex = index;
if (_isSearching) {
_exitSearch();
}
});
_pageController.animateToPage(
index,
@@ -99,13 +200,26 @@ class _HomePageState extends State<HomePage> {
color: Colors.white,
child: Column(
children: [
// 抽屉头部
buildDrawerHeader(context),
// 菜单项列表
Expanded(child: _buildDrawerBody()),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: _buildAppBarTitle(),
backgroundColor: Theme.of(context).colorScheme.primary,
elevation: 0,
leading: _buildLeading(),
actions: _buildAppBarActions(),
),
drawer: _buildDrawer(),
body: Padding(padding: EdgeInsets.all(10), child: _buildPageView()),
);
}
}

View File

@@ -38,10 +38,10 @@ class TimelinePageState extends State<TimelinePage> {
return Indicator.dot(size: 12.0, color: colors.primary);
},
contentsBuilder: (context, index) {
return buildBlogListItem(
context,
provider.blogList[index],
index + 1,
final blog = provider.blogList[index];
return InkWell(
onTap: () => navigatorToBlogDetail(context, blog.id),
child: buildBlogListItem(context, blog, index + 1),
);
},
),

99
lib/widget/search.dart Normal file
View File

@@ -0,0 +1,99 @@
import 'package:blog_app/models/blog.dart';
import 'package:blog_app/pages/blog_detail_page.dart';
import 'package:blog_app/widget/blog.dart';
import 'package:blog_app/widget/common.dart';
import 'package:flutter/material.dart';
Widget buildSearchField({
required TextEditingController controller,
required FocusNode focusNode,
required Function(String) onChanged,
}) {
return TextField(
controller: controller,
focusNode: focusNode,
autofocus: true,
style: TextStyle(color: Colors.white, fontSize: 18),
cursorColor: Colors.white,
decoration: InputDecoration(
hintText: '搜索文章内容...',
hintStyle: TextStyle(color: Colors.white70),
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
),
onChanged: onChanged,
textInputAction: TextInputAction.search,
);
}
Widget buildSearchResultItem(BuildContext context, BlogSearch blog) {
return buildCard(
context: context,
child: ListTile(
title: Text(
blog.title,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 4),
Text(
blog.content,
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
],
),
onTap: () => navigatorToBlogDetail(context, blog.id),
),
);
}
Widget buildSearchResultList(
BuildContext context,
List<BlogSearch> searchResultList,
) {
return ListView.builder(
itemCount: searchResultList.length,
itemBuilder: (context, index) {
return buildSearchResultItem(context, searchResultList[index]);
},
);
}
Widget buildSearchQueryEmpty() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search, size: 64, color: Colors.grey[400]),
SizedBox(height: 16),
Text('搜索文章内容', style: TextStyle(color: Colors.grey[600], fontSize: 16)),
SizedBox(height: 8),
Text('输入标题或内容进行搜索', style: TextStyle(color: Colors.grey[500])),
],
),
);
}
Widget buildSearchResultEmpty(String query) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
SizedBox(height: 16),
Text(
'没有找到"$query"相关文章',
style: TextStyle(color: Colors.grey[600], fontSize: 16),
),
SizedBox(height: 8),
Text('请尝试其他关键词', style: TextStyle(color: Colors.grey[500])),
],
),
);
}