vb_search_select.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. import 'package:chicken_farm/components/vb_select.dart';
  2. import 'package:chicken_farm/core/utils/logger.dart';
  3. import 'package:flutter/material.dart';
  4. /// 搜索API函数签名
  5. /// T 为数据类型
  6. typedef SearchApiFunction<T> =
  7. Future<Map<String, dynamic>> Function({
  8. required Map<String, dynamic> queryParams,
  9. });
  10. /// 选项转换器,将数据类型T转换为SelectOption
  11. typedef SelectOptionConverter<T> = SelectOption Function(T data);
  12. /// 搜索选择组件
  13. class VberSearchSelect<T> extends StatefulWidget {
  14. /// 搜索API函数
  15. final SearchApiFunction<T> searchApi;
  16. /// 数据转换方法,将原始数据转换为SelectOption
  17. final SelectOptionConverter<T> converter;
  18. /// 根据value获取对应label的函数
  19. final Future<String?> Function(String?)? getValueLabel;
  20. /// 当前选中的值
  21. final String? value;
  22. /// 值改变回调
  23. final ValueChanged<String?>? onChanged;
  24. /// 提示文字
  25. final String? hint;
  26. /// 是否启用
  27. final bool enabled;
  28. /// 每页数据条数
  29. final int pageSize;
  30. /// 额外参数,用于传递给搜索API
  31. final Map<String, dynamic>? extraParams;
  32. /// 是否隐藏下划线
  33. final bool hideUnderline;
  34. /// 是否显示清空按钮
  35. final bool showClearButton;
  36. const VberSearchSelect({
  37. super.key,
  38. required this.searchApi,
  39. required this.converter,
  40. this.getValueLabel,
  41. this.value,
  42. this.onChanged,
  43. this.hint,
  44. this.enabled = true,
  45. this.pageSize = 10,
  46. this.extraParams,
  47. this.hideUnderline = false,
  48. this.showClearButton = true,
  49. });
  50. @override
  51. State<VberSearchSelect<T>> createState() => _VberSearchSelectState<T>();
  52. }
  53. class _VberSearchSelectState<T> extends State<VberSearchSelect<T>> {
  54. final TextEditingController _searchController = TextEditingController();
  55. final FocusNode _searchFocusNode = FocusNode();
  56. String? _selectedValue;
  57. SelectOption? _selectedOption;
  58. final List<T> _items = [];
  59. @override
  60. void initState() {
  61. super.initState();
  62. _selectedValue = widget.value;
  63. _initSelectedOption();
  64. }
  65. /// 初始化选中选项
  66. void _initSelectedOption() async {
  67. if (widget.value != null && widget.getValueLabel != null) {
  68. try {
  69. final label = await widget.getValueLabel!(widget.value);
  70. if (mounted) {
  71. setState(() {
  72. _selectedOption = SelectOption(
  73. label: label ?? widget.value ?? '',
  74. value: widget.value!,
  75. );
  76. });
  77. }
  78. } catch (e) {
  79. logger.e('Failed to get label for value: ${widget.value}', e);
  80. }
  81. }
  82. }
  83. @override
  84. void didUpdateWidget(covariant VberSearchSelect<T> oldWidget) {
  85. super.didUpdateWidget(oldWidget);
  86. // 当外部传入的value发生变化时,同步更新内部状态
  87. if (oldWidget.value != widget.value) {
  88. _syncValueAndText(widget.value);
  89. }
  90. }
  91. /// 同步外部value和内部_selectedValue以及_selectedOption
  92. void _syncValueAndText(String? newValue) async {
  93. setState(() {
  94. _selectedValue = newValue;
  95. });
  96. // 优先使用存储的选项
  97. String? label;
  98. if (_selectedOption != null && _selectedOption!.value == newValue) {
  99. label = _selectedOption!.label;
  100. }
  101. // 尝试在现有选项中查找匹配的标签
  102. if (label == null && newValue != null) {
  103. for (var item in _items) {
  104. final option = widget.converter(item);
  105. if (option.value == newValue) {
  106. label = option.label;
  107. break;
  108. }
  109. }
  110. }
  111. // 如果在现有选项中没找到,且提供了getValueLabel函数,则尝试通过它获取
  112. if (label == null && newValue != null && widget.getValueLabel != null) {
  113. try {
  114. label = await widget.getValueLabel!(newValue);
  115. } catch (e) {
  116. logger.e('Failed to get label for value: $newValue', e);
  117. }
  118. }
  119. // 更新选中选项
  120. if (mounted) {
  121. setState(() {
  122. // 优先级: 找到的label > value本身 > 空字符串
  123. if (newValue != null) {
  124. _selectedOption = SelectOption(
  125. label: label ?? newValue,
  126. value: newValue,
  127. );
  128. } else {
  129. _selectedOption = null;
  130. }
  131. });
  132. }
  133. }
  134. @override
  135. void dispose() {
  136. _searchController.dispose();
  137. _searchFocusNode.dispose();
  138. super.dispose();
  139. }
  140. void _showSearchDialog() async {
  141. final result = await showDialog<SelectOption>(
  142. context: context,
  143. barrierDismissible: true,
  144. builder: (BuildContext context) {
  145. return _SearchDialog<T>(
  146. searchApi: widget.searchApi,
  147. converter: widget.converter,
  148. hint: widget.hint,
  149. pageSize: widget.pageSize,
  150. extraParams: widget.extraParams,
  151. searchController: _searchController,
  152. searchFocusNode: _searchFocusNode,
  153. );
  154. },
  155. );
  156. if (result != null) {
  157. setState(() {
  158. _selectedValue = result.value;
  159. _selectedOption = result;
  160. });
  161. widget.onChanged?.call(result.value);
  162. }
  163. }
  164. void _clearSelection() {
  165. setState(() {
  166. _selectedValue = null;
  167. _selectedOption = null;
  168. });
  169. widget.onChanged?.call(null);
  170. }
  171. @override
  172. Widget build(BuildContext context) {
  173. List<DropdownMenuItem<String>> dropdownItems = [];
  174. // 添加当前选中项到下拉列表(如果存在)
  175. if (_selectedOption != null) {
  176. dropdownItems.add(
  177. DropdownMenuItem<String>(
  178. value: _selectedOption!.value,
  179. child: Text(_selectedOption!.label),
  180. ),
  181. );
  182. }
  183. // 添加其他已加载的项到下拉列表
  184. if (_items.isNotEmpty) {
  185. for (var item in _items) {
  186. final option = widget.converter(item);
  187. // 避免重复添加选中项
  188. if (_selectedOption == null || option.value != _selectedOption!.value) {
  189. dropdownItems.add(
  190. DropdownMenuItem<String>(
  191. value: option.value,
  192. child: Text(option.label),
  193. ),
  194. );
  195. }
  196. }
  197. }
  198. // 创建一个不会真正展开下拉列表的DropdownButton
  199. Widget dropdownWidget = DropdownButton<String>(
  200. value: _selectedValue,
  201. hint: Text('请点击查询${widget.hint ?? ""}...'),
  202. items: dropdownItems.isEmpty ? null : dropdownItems,
  203. onChanged: null,
  204. isExpanded: true,
  205. icon: Icon(Icons.search), // 使用搜索图标表明这是搜索选择器
  206. );
  207. Widget selectWidget = widget.hideUnderline
  208. ? DropdownButtonHideUnderline(child: dropdownWidget)
  209. : dropdownWidget;
  210. // 使用 GestureDetector 包裹整个组件以确保点击事件能被正确捕获
  211. Widget wrappedWidget = GestureDetector(
  212. onTap: widget.enabled ? _showSearchDialog : null,
  213. child: selectWidget,
  214. );
  215. // 处理清空按钮显示
  216. if (widget.showClearButton && _selectedValue != null) {
  217. return Row(
  218. mainAxisSize: MainAxisSize.min,
  219. children: [
  220. Expanded(child: wrappedWidget),
  221. IconButton(
  222. icon: const Icon(Icons.clear, size: 20),
  223. onPressed: widget.enabled ? _clearSelection : null,
  224. ),
  225. ],
  226. );
  227. }
  228. return wrappedWidget;
  229. }
  230. }
  231. /// 搜索对话框
  232. class _SearchDialog<T> extends StatefulWidget {
  233. final SearchApiFunction<T> searchApi;
  234. final SelectOptionConverter<T> converter;
  235. final String? hint;
  236. final int pageSize;
  237. final Map<String, dynamic>? extraParams;
  238. final TextEditingController searchController;
  239. final FocusNode searchFocusNode;
  240. const _SearchDialog({
  241. required this.searchApi,
  242. required this.converter,
  243. required this.hint,
  244. required this.pageSize,
  245. required this.extraParams,
  246. required this.searchController,
  247. required this.searchFocusNode,
  248. });
  249. @override
  250. _SearchDialogState<T> createState() => _SearchDialogState<T>();
  251. }
  252. class _SearchDialogState<T> extends State<_SearchDialog<T>> {
  253. List<T> _items = [];
  254. bool _isLoading = false;
  255. int _currentPage = 1;
  256. bool _hasMore = true;
  257. String _lastSearchText = '';
  258. final ScrollController _scrollController = ScrollController();
  259. @override
  260. void initState() {
  261. super.initState();
  262. // 清空搜索框
  263. widget.searchController.clear();
  264. _items.clear();
  265. _currentPage = 1;
  266. _hasMore = true;
  267. _lastSearchText = '';
  268. _performSearch('');
  269. // 移除自动获取焦点的代码,让用户手动点击输入框获取焦点
  270. // WidgetsBinding.instance.addPostFrameCallback((_) {
  271. // widget.searchFocusNode.requestFocus();
  272. // });
  273. _scrollController.addListener(_scrollListener);
  274. }
  275. @override
  276. void dispose() {
  277. _scrollController.removeListener(_scrollListener);
  278. _scrollController.dispose();
  279. super.dispose();
  280. }
  281. void _scrollListener() {
  282. if (_scrollController.position.pixels ==
  283. _scrollController.position.maxScrollExtent &&
  284. _hasMore &&
  285. !_isLoading) {
  286. _loadMore();
  287. }
  288. }
  289. void _loadMore() async {
  290. setState(() {
  291. _isLoading = true;
  292. });
  293. try {
  294. _currentPage++;
  295. final result = await _fetchData(_currentPage);
  296. final List<dynamic> rows = result['rows'] is List ? result['rows'] : [];
  297. final int total = result['total'] is int ? result['total'] : 0;
  298. final newItems = rows.cast<T>().toList();
  299. if (mounted) {
  300. setState(() {
  301. _items.addAll(newItems);
  302. _hasMore = _items.length < total;
  303. _isLoading = false;
  304. });
  305. }
  306. } catch (e) {
  307. logger.e('加载更多失败:$e');
  308. if (mounted) {
  309. setState(() {
  310. _currentPage--; // 回退页码
  311. _isLoading = false;
  312. });
  313. }
  314. }
  315. }
  316. void _performSearch(String keyword) async {
  317. setState(() {
  318. _isLoading = true;
  319. });
  320. try {
  321. _currentPage = 1;
  322. _hasMore = true;
  323. final result = await _fetchData(_currentPage, keyword: keyword);
  324. final List<dynamic> rows = result['rows'] is List ? result['rows'] : [];
  325. final int total = result['total'] is int ? result['total'] : 0;
  326. final newItems = rows.cast<T>().toList();
  327. if (mounted) {
  328. setState(() {
  329. _items = newItems;
  330. _hasMore = _items.length < total;
  331. _isLoading = false;
  332. });
  333. }
  334. } catch (e) {
  335. logger.e('搜索失败:$e');
  336. if (mounted) {
  337. setState(() {
  338. _items = [];
  339. _hasMore = false;
  340. _isLoading = false;
  341. });
  342. }
  343. }
  344. }
  345. Future<Map<String, dynamic>> _fetchData(
  346. int pageNum, {
  347. String? keyword,
  348. }) async {
  349. final Map<String, dynamic> queryParams = {
  350. 'pageSize': widget.pageSize,
  351. 'pageNum': pageNum,
  352. };
  353. if (keyword != null && keyword.isNotEmpty) {
  354. queryParams['keyword'] = keyword;
  355. }
  356. if (widget.extraParams != null) {
  357. queryParams.addAll(widget.extraParams!);
  358. }
  359. final r = await widget.searchApi(queryParams: queryParams);
  360. return r;
  361. }
  362. void _onSearchChanged(String value) {
  363. Future.delayed(const Duration(milliseconds: 300), () {
  364. if (_lastSearchText != widget.searchController.text) {
  365. _lastSearchText = widget.searchController.text;
  366. _items.clear();
  367. _currentPage = 1;
  368. _hasMore = true;
  369. _performSearch(widget.searchController.text);
  370. }
  371. });
  372. }
  373. void _selectItem(SelectOption option, T item) {
  374. Navigator.of(context).pop(option);
  375. }
  376. @override
  377. Widget build(BuildContext context) {
  378. return AlertDialog(
  379. title: Text('查询${widget.hint ?? ""}'),
  380. content: SizedBox(
  381. width: MediaQuery.of(context).size.width * 0.8,
  382. height: MediaQuery.of(context).size.height * 0.6,
  383. child: Column(
  384. children: [
  385. TextField(
  386. controller: widget.searchController,
  387. focusNode: widget.searchFocusNode,
  388. decoration: InputDecoration(
  389. hintText: '请输入${widget.hint}搜索关键词',
  390. contentPadding: EdgeInsets.symmetric(
  391. horizontal: 12,
  392. vertical: 8,
  393. ),
  394. border: OutlineInputBorder(
  395. borderRadius: BorderRadius.circular(4),
  396. ),
  397. isDense: true,
  398. ),
  399. onChanged: _onSearchChanged,
  400. ),
  401. SizedBox(height: 16),
  402. if (_isLoading && _items.isEmpty)
  403. Expanded(child: Center(child: CircularProgressIndicator()))
  404. else if (!_isLoading && _items.isEmpty)
  405. Expanded(
  406. child: Center(
  407. child: Text(
  408. '没有查询到结果',
  409. style: TextStyle(color: Colors.grey, fontSize: 14),
  410. ),
  411. ),
  412. )
  413. else
  414. Expanded(
  415. child: NotificationListener<ScrollNotification>(
  416. onNotification: (notification) {
  417. if (notification.metrics.pixels ==
  418. notification.metrics.maxScrollExtent &&
  419. _hasMore &&
  420. !_isLoading) {
  421. _loadMore();
  422. return true;
  423. }
  424. return false;
  425. },
  426. child: ListView.builder(
  427. itemCount: _items.length + (_hasMore ? 1 : 0),
  428. itemBuilder: (context, index) {
  429. if (index == _items.length) {
  430. // 显示加载更多指示器
  431. return Padding(
  432. padding: const EdgeInsets.all(12.0),
  433. child: Center(
  434. child: _isLoading
  435. ? CircularProgressIndicator()
  436. : Text('上拉加载更多'),
  437. ),
  438. );
  439. }
  440. final item = _items[index];
  441. final option = widget.converter(item);
  442. return ListTile(
  443. title: Text(option.label),
  444. onTap: () => _selectItem(option, item),
  445. dense: true,
  446. contentPadding: EdgeInsets.symmetric(
  447. horizontal: 16,
  448. vertical: 4,
  449. ),
  450. visualDensity: VisualDensity.compact,
  451. );
  452. },
  453. ),
  454. ),
  455. ),
  456. ],
  457. ),
  458. ),
  459. actions: [
  460. TextButton(
  461. onPressed: () => Navigator.of(context).pop(),
  462. child: Text('取消'),
  463. ),
  464. ],
  465. );
  466. }
  467. }