1、搜索页、搜索结果页

This commit is contained in:
cuiyouliang 2025-08-27 18:14:45 +08:00
parent 1a959e7c3b
commit 0e63d49814
5 changed files with 1543 additions and 306 deletions

215
lib/pages/search/index.dart Normal file
View File

@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:loopin/components/my_toast.dart';
import '../../behavior/custom_scroll_behavior.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final TextEditingController _searchController = TextEditingController();
final GetStorage _storage = GetStorage();
List<dynamic> _searchHistory = [];
@override
void initState() {
super.initState();
_loadSearchHistory();
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
//
void _loadSearchHistory() {
setState(() {
_searchHistory = _storage.read('searchHistory') ?? [];
});
}
//
void _saveSearchHistory() {
_storage.write('searchHistory', _searchHistory);
}
//
void _addSearchItem(String query) {
if (query.trim().isEmpty) return;
setState(() {
//
_searchHistory.remove(query);
//
_searchHistory.insert(0, query);
// 10
if (_searchHistory.length > 10) {
_searchHistory = _searchHistory.sublist(0, 10);
}
});
_saveSearchHistory();
}
//
void _removeSearchItem(int index) {
setState(() {
_searchHistory.removeAt(index);
});
_saveSearchHistory();
}
//
void _clearAllHistory() {
setState(() {
_searchHistory.clear();
});
_saveSearchHistory();
MyToast().tip(
title: '搜索历史已清除',
position: 'center',
type: 'success',
);
}
//
void _performSearch() {
final searchWords = _searchController.text.trim();
if (searchWords.isNotEmpty) {
_addSearchItem(searchWords);
_searchController.clear();
FocusScope.of(context).unfocus();
// tab索引
Get.toNamed('/search-result', arguments: searchWords);
} else {
MyToast().tip(
title: '请输入搜索内容',
position: 'center',
type: 'error',
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
centerTitle: true,
backgroundColor: Colors.black,
foregroundColor: Colors.white,
elevation: 0,
title: Container(
height: 40,
decoration: BoxDecoration(
color: Colors.grey[900],
borderRadius: BorderRadius.circular(20),
),
child: TextField(
controller: _searchController,
style: const TextStyle(color: Colors.white, fontSize: 16),
decoration: InputDecoration(
hintText: '请输入内容~',
hintStyle: TextStyle(color: Colors.grey[500], fontSize: 16),
border: InputBorder.none,
prefixIcon: Icon(Icons.search, color: Colors.grey[500], size: 20),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(Icons.close, color: Colors.grey[500], size: 20),
onPressed: () {
_searchController.clear();
setState(() {});
},
)
: null,
contentPadding: const EdgeInsets.symmetric(vertical: 10),
),
onSubmitted: (_) => _performSearch(),
onChanged: (_) => setState(() {}),
),
),
actions: [
TextButton(
onPressed: _performSearch,
child: const Text('搜索', style: TextStyle(color: Colors.white, fontSize: 16)),
),
],
),
body: ScrollConfiguration(
behavior: CustomScrollBehavior().copyWith(scrollbars: false),
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
if (_searchHistory.isNotEmpty) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'搜索历史',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold
),
),
TextButton(
onPressed: _clearAllHistory,
child: const Text(
'清除所有',
style: TextStyle(color: Colors.grey, fontSize: 14),
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(_searchHistory.length, (index) {
return GestureDetector(
onTap: () {
_searchController.text = _searchHistory[index];
_performSearch();
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_searchHistory[index],
style: const TextStyle(color: Colors.white),
),
const SizedBox(width: 6),
GestureDetector(
onTap: () => _removeSearchItem(index),
child: Icon(Icons.close, size: 16, color: Colors.grey[500]),
),
],
),
),
);
}),
),
const SizedBox(height: 24),
],
],
),
),
),
);
}
}

View File

@ -0,0 +1,622 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:loopin/service/http.dart';
import 'package:loopin/components/my_toast.dart';
import '../../behavior/custom_scroll_behavior.dart';
class SearchResultPage extends StatefulWidget {
const SearchResultPage({super.key});
@override
State<SearchResultPage> createState() => _SearchResultPageState();
}
class _SearchResultPageState extends State<SearchResultPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
String _searchQuery = Get.parameters?['searchWords'] ?? '';
int _initialTabIndex = 0;
final TextEditingController _searchController = TextEditingController();
final FocusNode _searchFocusNode = FocusNode();
// tab的数据
List<dynamic> _videoResults = [];
List<dynamic> _productResults = [];
List<dynamic> _userResults = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
//
_searchController.text = _searchQuery;
// tab参数
final tabParam = Get.parameters?['tab'];
if (tabParam != null && tabParam.isNotEmpty) {
_initialTabIndex = int.tryParse(tabParam) ?? 0;
}
_tabController = TabController(
length: 4,
vsync: this,
initialIndex: _initialTabIndex,
);
// Tab切换
_tabController.addListener(_onTabChanged);
//
_loadInitialData();
}
@override
void dispose() {
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
void _onTabChanged() {
if (_tabController.indexIsChanging) {
// Tab的数据
_loadTabData(_tabController.index);
}
}
//
void _performSearch() {
final newQuery = _searchController.text.trim();
if (newQuery.isEmpty) {
MyToast().tip(
title: '请输入搜索关键词',
position: 'center',
type: 'warning',
);
return;
}
if (newQuery == _searchQuery) {
return; //
}
setState(() {
_searchQuery = newQuery;
_isLoading = true;
});
//
_searchFocusNode.unfocus();
//
_loadInitialData();
}
//
Future<void> _loadInitialData() async {
try {
setState(() {
_isLoading = true;
});
// Tab的数据
await _loadTabData(_initialTabIndex);
} catch (e) {
print('加载数据失败: $e');
MyToast().tip(
title: '加载失败',
position: 'center',
type: 'error',
);
} finally {
setState(() {
_isLoading = false;
});
}
}
// Tab的数据
Future<void> _loadTabData(int tabIndex) async {
try {
setState(() {
_isLoading = true;
});
switch (tabIndex) {
case 0: //
final res = await Http.get('/api/search/videos', params: {
'keyword': _searchQuery,
'page': 1,
'current': 20,
});
if (res['code'] == 200) {
setState(() {
_videoResults = res['data']['list'] ?? [];
});
}
break;
case 1: //
final res = await Http.get('/api/search/products', params: {
'keyword': _searchQuery,
'page': 1,
'current': 20,
});
if (res['code'] == 200) {
setState(() {
_productResults = res['data']['list'] ?? [];
});
}
break;
case 2: //
final res = await Http.get('/api/search/users', params: {
'keyword': _searchQuery,
'page': 1,
'current': 20,
});
if (res['code'] == 200) {
setState(() {
_userResults = res['data']['list'] ?? [];
});
}
break;
}
} catch (e) {
print('加载Tab数据失败: $e');
MyToast().tip(
title: '加载失败',
position: 'center',
type: 'error',
);
} finally {
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Container(
height: 36,
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
const SizedBox(width: 12),
const Icon(Icons.search, color: Colors.grey, size: 20),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.2,
),
decoration: const InputDecoration(
hintText: '搜索视频、商品、用户、团购...',
hintStyle: TextStyle(
color: Colors.grey,
fontSize: 14,
height: 1.2,
),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(vertical: 8),
isDense: true,
),
onSubmitted: (_) => _performSearch(),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.grey, size: 18),
onPressed: () {
_searchController.clear();
},
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(),
),
const SizedBox(width: 4),
],
),
),
backgroundColor: Colors.black,
foregroundColor: Colors.white,
elevation: 0.5,
actions: [
TextButton(
onPressed: _performSearch,
child: const Text(
'搜索',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
],
),
body: Column(
children: [
Container(
color: Colors.white,
padding: const EdgeInsets.only(top: 8),
child: TabBar(
controller: _tabController,
// isScrollable: true,
indicatorColor: Colors.pink,
labelColor: Colors.pink,
unselectedLabelColor: Colors.grey,
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.normal),
padding: const EdgeInsets.only(left: 16), //
indicatorPadding: EdgeInsets.zero,
labelPadding: const EdgeInsets.symmetric(horizontal: 16), //
indicatorSize: TabBarIndicatorSize.label, // label宽度
dividerColor: Colors.transparent,
tabs: const [
Tab(text: '视频'),
Tab(text: '商品'),
Tab(text: '用户'),
],
),
),
// Tab内容区域
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: TabBarView(
controller: _tabController,
children: [
// Tab
_buildVideoTab(),
// Tab
_buildProductTab(),
// Tab
_buildUserTab(),
],
),
),
],
),
);
}
// Tab
Widget _buildVideoTab() {
if (_videoResults.isEmpty) {
return _buildEmptyView('暂无视频结果');
}
return ScrollConfiguration(
behavior: CustomScrollBehavior().copyWith(scrollbars: false),
child: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _videoResults.length,
itemBuilder: (context, index) {
final video = _videoResults[index] as Map<String, dynamic>;
return _buildVideoItem(video);
},
),
);
}
Widget _buildVideoItem(Map<String, dynamic> video) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//
Container(
width: 120,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: Colors.grey[200],
image: video['cover'] != null
? DecorationImage(
image: NetworkImage(video['cover'].toString()),
fit: BoxFit.cover,
)
: null,
),
child: video['cover'] == null
? const Icon(Icons.videocam, color: Colors.grey)
: null,
),
const SizedBox(width: 12),
//
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
video['title']?.toString() ?? '无标题',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
video['author']?.toString() ?? '未知作者',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(height: 4),
Row(
children: [
Text(
'#${video['tag']?.toString() ?? '无标签'}',
style: const TextStyle(
fontSize: 12,
color: Colors.blue,
),
),
const SizedBox(width: 8),
Text(
video['location']?.toString() ?? '',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
],
),
),
],
),
);
}
// Tab
Widget _buildProductTab() {
if (_productResults.isEmpty) {
return _buildEmptyView('暂无商品结果');
}
return ScrollConfiguration(
behavior: CustomScrollBehavior().copyWith(scrollbars: false),
child: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _productResults.length,
itemBuilder: (context, index) {
final product = _productResults[index] as Map<String, dynamic>;
return _buildProductItem(product);
},
),
);
}
Widget _buildProductItem(Map<String, dynamic> product) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
//
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: Colors.grey[200],
image: product['image'] != null
? DecorationImage(
image: NetworkImage(product['image'].toString()),
fit: BoxFit.cover,
)
: null,
),
child: product['image'] == null
? const Icon(Icons.shopping_bag, color: Colors.grey)
: null,
),
const SizedBox(width: 12),
//
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product['name']?.toString() ?? '未知商品',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
'¥${product['price']?.toString() ?? '0.00'}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.pink,
),
),
const SizedBox(height: 6),
Row(
children: [
Text(
'已售 ${product['sold']?.toString() ?? '0'}',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
const SizedBox(width: 12),
Text(
'${product['comments']?.toString() ?? '0'}条评论',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
],
),
),
//
IconButton(
onPressed: () {
//
},
icon: const Icon(Icons.shopping_cart, size: 20),
),
],
),
);
}
// Tab
Widget _buildUserTab() {
if (_userResults.isEmpty) {
return _buildEmptyView('暂无用户结果');
}
return ScrollConfiguration(
behavior: CustomScrollBehavior().copyWith(scrollbars: false),
child: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _userResults.length,
itemBuilder: (context, index) {
final user = _userResults[index] as Map<String, dynamic>;
return _buildUserItem(user);
},
),
);
}
Widget _buildUserItem(Map<String, dynamic> user) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
//
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey[200],
image: user['avatar'] != null
? DecorationImage(
image: NetworkImage(user['avatar'].toString()),
fit: BoxFit.cover,
)
: null,
),
child: user['avatar'] == null
? const Icon(Icons.person, color: Colors.grey)
: null,
),
const SizedBox(width: 12),
//
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user['name']?.toString() ?? '未知用户',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'粉丝: ${user['fans']?.toString() ?? '0'}',
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
),
//
ElevatedButton(
onPressed: () {
//
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.pink,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: const Text(
'关注',
style: TextStyle(fontSize: 12),
),
),
],
)
);
}
//
Widget _buildEmptyView(String message) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.search_off, size: 60, color: Colors.grey),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
);
}
}

View File

@ -128,7 +128,9 @@ class _VideoPageState extends State<VideoPage> with SingleTickerProviderStateMix
Icons.search_rounded,
color: tabColor(),
),
onPressed: () {},
onPressed: () {
Get.toNamed('/search');
},
),
),
],

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,8 @@ import 'package:loopin/pages/my/setting.dart';
import 'package:loopin/pages/my/user_info.dart';
import 'package:loopin/pages/my/vloger.dart';
import 'package:loopin/pages/video/report.dart';
import 'package:loopin/pages/search/index.dart';
import 'package:loopin/pages/search/search-result.dart';
import '../layouts/index.dart';
/* 引入路由页面 */
@ -38,6 +40,8 @@ final Map<String, Widget> routes = {
'/order/detail': const OrderDetail(),
'/vloger': const Vloger(),
'/report': const ReportPage(),
'/search': const SearchPage(),
'/search-result': const SearchResultPage(),
//settins
'/setting': const Setting(),
'/userInfo': const UserInfo(),