vb_search_select.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  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(
  180. _selectedOption!.label,
  181. style: TextStyle(color: Colors.black),
  182. ),
  183. ),
  184. );
  185. }
  186. // 添加其他已加载的项到下拉列表
  187. if (_items.isNotEmpty) {
  188. for (var item in _items) {
  189. final option = widget.converter(item);
  190. // 避免重复添加选中项
  191. if (_selectedOption == null || option.value != _selectedOption!.value) {
  192. dropdownItems.add(
  193. DropdownMenuItem<String>(
  194. value: option.value,
  195. child: Text(option.label),
  196. ),
  197. );
  198. }
  199. }
  200. }
  201. // 创建一个不会真正展开下拉列表的DropdownButton
  202. Widget dropdownWidget = DropdownButton<String>(
  203. value: _selectedValue,
  204. hint: Text('请点击查询${widget.hint ?? ""}...'),
  205. items: dropdownItems.isEmpty ? null : dropdownItems,
  206. onChanged: null,
  207. isExpanded: true,
  208. icon: Icon(Icons.search), // 使用搜索图标表明这是搜索选择器
  209. );
  210. Widget selectWidget = widget.hideUnderline
  211. ? DropdownButtonHideUnderline(child: dropdownWidget)
  212. : dropdownWidget;
  213. // 使用 GestureDetector 包裹整个组件以确保点击事件能被正确捕获
  214. Widget wrappedWidget = GestureDetector(
  215. onTap: widget.enabled ? _showSearchDialog : null,
  216. child: selectWidget,
  217. );
  218. // 处理清空按钮显示
  219. if (widget.showClearButton && _selectedValue != null) {
  220. return Row(
  221. mainAxisSize: MainAxisSize.min,
  222. children: [
  223. Expanded(child: wrappedWidget),
  224. IconButton(
  225. icon: const Icon(Icons.clear, size: 20),
  226. onPressed: widget.enabled ? _clearSelection : null,
  227. ),
  228. ],
  229. );
  230. }
  231. return wrappedWidget;
  232. }
  233. }
  234. /// 搜索对话框
  235. class _SearchDialog<T> extends StatefulWidget {
  236. final SearchApiFunction<T> searchApi;
  237. final SelectOptionConverter<T> converter;
  238. final String? hint;
  239. final int pageSize;
  240. final Map<String, dynamic>? extraParams;
  241. final TextEditingController searchController;
  242. final FocusNode searchFocusNode;
  243. const _SearchDialog({
  244. required this.searchApi,
  245. required this.converter,
  246. required this.hint,
  247. required this.pageSize,
  248. required this.extraParams,
  249. required this.searchController,
  250. required this.searchFocusNode,
  251. });
  252. @override
  253. _SearchDialogState<T> createState() => _SearchDialogState<T>();
  254. }
  255. class _SearchDialogState<T> extends State<_SearchDialog<T>> {
  256. List<T> _items = [];
  257. bool _isLoading = false;
  258. int _currentPage = 1;
  259. bool _hasMore = true;
  260. String _lastSearchText = '';
  261. final ScrollController _scrollController = ScrollController();
  262. @override
  263. void initState() {
  264. super.initState();
  265. // 清空搜索框
  266. widget.searchController.clear();
  267. _items.clear();
  268. _currentPage = 1;
  269. _hasMore = true;
  270. _lastSearchText = '';
  271. _performSearch('');
  272. // 移除自动获取焦点的代码,让用户手动点击输入框获取焦点
  273. // WidgetsBinding.instance.addPostFrameCallback((_) {
  274. // widget.searchFocusNode.requestFocus();
  275. // });
  276. _scrollController.addListener(_scrollListener);
  277. }
  278. @override
  279. void dispose() {
  280. _scrollController.removeListener(_scrollListener);
  281. _scrollController.dispose();
  282. super.dispose();
  283. }
  284. void _scrollListener() {
  285. if (_scrollController.position.pixels ==
  286. _scrollController.position.maxScrollExtent &&
  287. _hasMore &&
  288. !_isLoading) {
  289. _loadMore();
  290. }
  291. }
  292. void _loadMore() async {
  293. setState(() {
  294. _isLoading = true;
  295. });
  296. try {
  297. _currentPage++;
  298. final result = await _fetchData(_currentPage);
  299. final List<dynamic> rows = result['rows'] is List ? result['rows'] : [];
  300. final int total = result['total'] is int ? result['total'] : 0;
  301. final newItems = rows.cast<T>().toList();
  302. if (mounted) {
  303. setState(() {
  304. _items.addAll(newItems);
  305. _hasMore = _items.length < total;
  306. _isLoading = false;
  307. });
  308. }
  309. } catch (e) {
  310. logger.e('加载更多失败:$e');
  311. if (mounted) {
  312. setState(() {
  313. _currentPage--; // 回退页码
  314. _isLoading = false;
  315. });
  316. }
  317. }
  318. }
  319. void _performSearch(String keyword) async {
  320. setState(() {
  321. _isLoading = true;
  322. });
  323. try {
  324. _currentPage = 1;
  325. _hasMore = true;
  326. final result = await _fetchData(_currentPage, keyword: keyword);
  327. final List<dynamic> rows = result['rows'] is List ? result['rows'] : [];
  328. final int total = result['total'] is int ? result['total'] : 0;
  329. final newItems = rows.cast<T>().toList();
  330. if (mounted) {
  331. setState(() {
  332. _items = newItems;
  333. _hasMore = _items.length < total;
  334. _isLoading = false;
  335. });
  336. }
  337. } catch (e) {
  338. logger.e('搜索失败:$e');
  339. if (mounted) {
  340. setState(() {
  341. _items = [];
  342. _hasMore = false;
  343. _isLoading = false;
  344. });
  345. }
  346. }
  347. }
  348. Future<Map<String, dynamic>> _fetchData(
  349. int pageNum, {
  350. String? keyword,
  351. }) async {
  352. final Map<String, dynamic> queryParams = {
  353. 'pageSize': widget.pageSize,
  354. 'pageNum': pageNum,
  355. };
  356. queryParams['keyword'] = keyword ?? "";
  357. if (widget.extraParams != null) {
  358. queryParams.addAll(widget.extraParams!);
  359. }
  360. final r = await widget.searchApi(queryParams: queryParams);
  361. return r;
  362. }
  363. void _onSearchChanged(String value) {
  364. Future.delayed(const Duration(milliseconds: 300), () {
  365. if (_lastSearchText != widget.searchController.text) {
  366. _lastSearchText = widget.searchController.text;
  367. _items.clear();
  368. _currentPage = 1;
  369. _hasMore = true;
  370. _performSearch(widget.searchController.text);
  371. }
  372. });
  373. }
  374. void _selectItem(SelectOption option, T item) {
  375. Navigator.of(context).pop(option);
  376. }
  377. @override
  378. Widget build(BuildContext context) {
  379. return AlertDialog(
  380. title: Text('查询${widget.hint ?? ""}'),
  381. content: SizedBox(
  382. width: MediaQuery.of(context).size.width * 0.9,
  383. height: MediaQuery.of(context).size.height * 0.6,
  384. child: Column(
  385. children: [
  386. TextField(
  387. controller: widget.searchController,
  388. focusNode: widget.searchFocusNode,
  389. decoration: InputDecoration(
  390. hintText: '请输入${widget.hint}搜索关键词',
  391. contentPadding: EdgeInsets.symmetric(
  392. horizontal: 12,
  393. vertical: 8,
  394. ),
  395. border: OutlineInputBorder(
  396. borderRadius: BorderRadius.circular(4),
  397. ),
  398. isDense: true,
  399. ),
  400. onChanged: _onSearchChanged,
  401. ),
  402. SizedBox(height: 16),
  403. if (_isLoading && _items.isEmpty)
  404. Expanded(child: Center(child: CircularProgressIndicator()))
  405. else if (!_isLoading && _items.isEmpty)
  406. Expanded(
  407. child: Center(
  408. child: Text(
  409. '没有查询到结果',
  410. style: TextStyle(color: Colors.grey, fontSize: 14),
  411. ),
  412. ),
  413. )
  414. else
  415. Expanded(
  416. child: NotificationListener<ScrollNotification>(
  417. onNotification: (notification) {
  418. if (notification.metrics.pixels ==
  419. notification.metrics.maxScrollExtent &&
  420. _hasMore &&
  421. !_isLoading) {
  422. _loadMore();
  423. return true;
  424. }
  425. return false;
  426. },
  427. child: ListView.builder(
  428. itemCount: _items.length + (_hasMore ? 1 : 0),
  429. itemBuilder: (context, index) {
  430. if (index == _items.length) {
  431. // 显示加载更多指示器
  432. return Padding(
  433. padding: const EdgeInsets.fromLTRB(
  434. 5.0,
  435. 5.0,
  436. 5.0,
  437. 5.0,
  438. ),
  439. child: Center(
  440. child: _isLoading
  441. ? CircularProgressIndicator()
  442. : Text('上拉加载更多'),
  443. ),
  444. );
  445. }
  446. final item = _items[index];
  447. final option = widget.converter(item);
  448. return ListTile(
  449. title: Text(option.label),
  450. onTap: () => _selectItem(option, item),
  451. dense: true,
  452. contentPadding: EdgeInsets.symmetric(
  453. horizontal: 16,
  454. vertical: 4,
  455. ),
  456. visualDensity: VisualDensity.compact,
  457. );
  458. },
  459. ),
  460. ),
  461. ),
  462. ],
  463. ),
  464. ),
  465. actions: [
  466. TextButton(
  467. onPressed: () => Navigator.of(context).pop(),
  468. child: Text('取消'),
  469. ),
  470. ],
  471. );
  472. }
  473. }