Prechádzať zdrojové kódy

fix #19407 做单资料接口做缓存优化

chengc 1 týždeň pred
rodič
commit
2ddaa6b638

+ 51 - 0
lib/core/data/api_cache.dart

@@ -0,0 +1,51 @@
+import 'package:flutter/foundation.dart';
+
+/// 简单内存缓存,用于基础资料分页接口。
+/// TTL 内相同 cacheKey 的请求直接返回缓存结果。
+class ApiCache {
+  final int ttlSeconds;
+  final String? _keyPrefix;
+  final Map<String, _CacheEntry> _store = {};
+
+  ApiCache({this.ttlSeconds = 120, String? keyPrefix}) : _keyPrefix = keyPrefix;
+
+  String _fullKey(String key) =>
+      _keyPrefix != null ? '${_keyPrefix}_$key' : key;
+
+  /// 缓存命中返回已有数据;未命中调用 [fetcher] 并存入缓存。
+  /// [skipCache] 为 true 时跳过缓存直接调用 [fetcher]。
+  Future<List<T>> getOrFetch<T>(
+    String key,
+    Future<List<T>> Function() fetcher, {
+    bool skipCache = false,
+  }) async {
+    final fullKey = _fullKey(key);
+    if (!skipCache) {
+      final entry = _store[fullKey];
+      if (entry != null && !entry.isExpired) {
+        if (kDebugMode) debugPrint('[ApiCache] HIT  $fullKey');
+        return entry.data as List<T>;
+      }
+    }
+    if (kDebugMode) debugPrint('[ApiCache] MISS $fullKey');
+    final data = await fetcher();
+    _store[fullKey] = _CacheEntry(
+      data,
+      DateTime.now().add(Duration(seconds: ttlSeconds)),
+    );
+    return data;
+  }
+
+  /// 移除指定 key 的缓存条目。
+  void invalidate(String key) => _store.remove(_fullKey(key));
+
+  /// 清除所有缓存(下拉刷新时调用)。
+  void clear() => _store.clear();
+}
+
+class _CacheEntry {
+  final dynamic data;
+  final DateTime expiresAt;
+  _CacheEntry(this.data, this.expiresAt);
+  bool get isExpired => DateTime.now().isAfter(expiresAt);
+}

+ 108 - 82
lib/features/expense/expense_api.dart

@@ -10,6 +10,7 @@ import '../../shared/models/bill_attachment.dart';
 import '../../shared/models/bill_file_rights.dart';
 import '../../core/navigation/host_app_channel.dart';
 import '../../core/utils/date_utils.dart' as du;
+import '../../core/data/api_cache.dart';
 import 'expense_model.dart';
 import 'expense_list_controller.dart';
 import '../expense_apply/report_model.dart';
@@ -133,8 +134,12 @@ class EmployeeItem {
 
 class ExpenseApi {
   final ApiClient _client;
+  final _cache = ApiCache(keyPrefix: HostAppChannel.sn);
   ExpenseApi(this._client);
 
+  /// 清除所有基础资料缓存(picker 下拉刷新时调用)。
+  void clearRefCache() => _cache.clear();
+
   /// 费用报销列表(分页)
   Future<PaginatedData<ExpenseModel>> fetchList({
     String status = '',
@@ -236,19 +241,22 @@ class ExpenseApi {
     int page = 1,
     int size = 100,
   }) async {
-    final response = await _client.get<Map<String, dynamic>>(
-      '/OA/GetCostTypes',
-      queryParameters: {
-        'keyword': keyword,
-        'accNo': accNo,
-        'page': page,
-        'size': size,
-      },
-    );
-    final list = (response.data?['list'] as List<dynamic>?) ?? [];
-    return list
-        .map((e) => CostTypeItem.fromJson(e as Map<String, dynamic>))
-        .toList();
+    final cacheKey = 'getCostTypes_${keyword}_${accNo}_$page';
+    return _cache.getOrFetch(cacheKey, () async {
+      final response = await _client.get<Map<String, dynamic>>(
+        '/OA/GetCostTypes',
+        queryParameters: {
+          'keyword': keyword,
+          'accNo': accNo,
+          'page': page,
+          'size': size,
+        },
+      );
+      final list = (response.data?['list'] as List<dynamic>?) ?? [];
+      return list
+          .map((e) => CostTypeItem.fromJson(e as Map<String, dynamic>))
+          .toList();
+    });
   }
 
   /// 费用项目字典(分页)
@@ -259,20 +267,23 @@ class ExpenseApi {
     int page = 1,
     int size = 20,
   }) async {
-    final response = await _client.get<Map<String, dynamic>>(
-      '/OA/GetCostProjects',
-      queryParameters: {
-        'keyword': keyword,
-        'accNo': accNo,
-        'onlyActive': onlyActive,
-        'page': page,
-        'size': size,
-      },
-    );
-    final list = (response.data?['list'] as List<dynamic>?) ?? [];
-    return list
-        .map((e) => CostProjectItem.fromJson(e as Map<String, dynamic>))
-        .toList();
+    final cacheKey = 'getCostProjects_${keyword}_${accNo}_${onlyActive}_$page';
+    return _cache.getOrFetch(cacheKey, () async {
+      final response = await _client.get<Map<String, dynamic>>(
+        '/OA/GetCostProjects',
+        queryParameters: {
+          'keyword': keyword,
+          'accNo': accNo,
+          'onlyActive': onlyActive,
+          'page': page,
+          'size': size,
+        },
+      );
+      final list = (response.data?['list'] as List<dynamic>?) ?? [];
+      return list
+          .map((e) => CostProjectItem.fromJson(e as Map<String, dynamic>))
+          .toList();
+    });
   }
 
   /// 项目代号
@@ -282,19 +293,22 @@ class ExpenseApi {
     int page = 1,
     int size = 100,
   }) async {
-    final response = await _client.get<Map<String, dynamic>>(
-      '/OA/GetProjectCodes',
-      queryParameters: {
-        'keyword': keyword,
-        'billDate': billDate,
-        'page': page,
-        'size': size,
-      },
-    );
-    final list = (response.data?['list'] as List<dynamic>?) ?? [];
-    return list
-        .map((e) => ProjectCodeItem.fromJson(e as Map<String, dynamic>))
-        .toList();
+    final cacheKey = 'getProjectCodes_${keyword}_${billDate}_$page';
+    return _cache.getOrFetch(cacheKey, () async {
+      final response = await _client.get<Map<String, dynamic>>(
+        '/OA/GetProjectCodes',
+        queryParameters: {
+          'keyword': keyword,
+          'billDate': billDate,
+          'page': page,
+          'size': size,
+        },
+      );
+      final list = (response.data?['list'] as List<dynamic>?) ?? [];
+      return list
+          .map((e) => ProjectCodeItem.fromJson(e as Map<String, dynamic>))
+          .toList();
+    });
   }
 
   /// 部门
@@ -304,19 +318,22 @@ class ExpenseApi {
     int page = 1,
     int size = 100,
   }) async {
-    final response = await _client.get<Map<String, dynamic>>(
-      '/OA/GetDepartments',
-      queryParameters: {
-        'keyword': keyword,
-        'onlyActive': onlyActive,
-        'page': page,
-        'size': size,
-      },
-    );
-    final list = (response.data?['list'] as List<dynamic>?) ?? [];
-    return list
-        .map((e) => DepartmentItem.fromJson(e as Map<String, dynamic>))
-        .toList();
+    final cacheKey = 'getDepartments_${keyword}_${onlyActive}_$page';
+    return _cache.getOrFetch(cacheKey, () async {
+      final response = await _client.get<Map<String, dynamic>>(
+        '/OA/GetDepartments',
+        queryParameters: {
+          'keyword': keyword,
+          'onlyActive': onlyActive,
+          'page': page,
+          'size': size,
+        },
+      );
+      final list = (response.data?['list'] as List<dynamic>?) ?? [];
+      return list
+          .map((e) => DepartmentItem.fromJson(e as Map<String, dynamic>))
+          .toList();
+    });
   }
 
   /// 客户/厂商
@@ -325,14 +342,17 @@ class ExpenseApi {
     int page = 1,
     int size = 100,
   }) async {
-    final response = await _client.get<Map<String, dynamic>>(
-      '/OA/GetCustomers',
-      queryParameters: {'keyword': keyword, 'page': page, 'size': size},
-    );
-    final list = (response.data?['list'] as List<dynamic>?) ?? [];
-    return list
-        .map((e) => CustomerItem.fromJson(e as Map<String, dynamic>))
-        .toList();
+    final cacheKey = 'getCustomers_${keyword}_$page';
+    return _cache.getOrFetch(cacheKey, () async {
+      final response = await _client.get<Map<String, dynamic>>(
+        '/OA/GetCustomers',
+        queryParameters: {'keyword': keyword, 'page': page, 'size': size},
+      );
+      final list = (response.data?['list'] as List<dynamic>?) ?? [];
+      return list
+          .map((e) => CustomerItem.fromJson(e as Map<String, dynamic>))
+          .toList();
+    });
   }
 
   /// 员工查询
@@ -342,19 +362,22 @@ class ExpenseApi {
     int page = 1,
     int size = 100,
   }) async {
-    final response = await _client.get<Map<String, dynamic>>(
-      '/OA/GetEmployees',
-      queryParameters: {
-        'keyword': keyword,
-        'salNo': salNo,
-        'page': page,
-        'size': size,
-      },
-    );
-    final list = (response.data?['list'] as List<dynamic>?) ?? [];
-    return list
-        .map((e) => EmployeeItem.fromJson(e as Map<String, dynamic>))
-        .toList();
+    final cacheKey = 'getEmployees_${keyword}_${salNo}_$page';
+    return _cache.getOrFetch(cacheKey, () async {
+      final response = await _client.get<Map<String, dynamic>>(
+        '/OA/GetEmployees',
+        queryParameters: {
+          'keyword': keyword,
+          'salNo': salNo,
+          'page': page,
+          'size': size,
+        },
+      );
+      final list = (response.data?['list'] as List<dynamic>?) ?? [];
+      return list
+          .map((e) => EmployeeItem.fromJson(e as Map<String, dynamic>))
+          .toList();
+    });
   }
 
   /// 可转入报销单的费用申请明细(keyword 模糊匹配单号/事由/部门/申请人/费用类型)
@@ -386,14 +409,17 @@ class ExpenseApi {
     int page = 1,
     int size = 50,
   }) async {
-    final response = await _client.get<Map<String, dynamic>>(
-      '/OA/GetCurrencies',
-      queryParameters: {'keyword': keyword, 'page': page, 'size': size},
-    );
-    final list = (response.data?['list'] as List<dynamic>?) ?? [];
-    return list
-        .map((e) => CurrencyItem.fromJson(e as Map<String, dynamic>))
-        .toList();
+    final cacheKey = 'getCurrencies_${keyword}_$page';
+    return _cache.getOrFetch(cacheKey, () async {
+      final response = await _client.get<Map<String, dynamic>>(
+        '/OA/GetCurrencies',
+        queryParameters: {'keyword': keyword, 'page': page, 'size': size},
+      );
+      final list = (response.data?['list'] as List<dynamic>?) ?? [];
+      return list
+          .map((e) => CurrencyItem.fromJson(e as Map<String, dynamic>))
+          .toList();
+    });
   }
 
   /// 币别查询(旧版,保留兼容)

+ 2 - 0
lib/features/expense/expense_create_page.dart

@@ -339,6 +339,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
       loader: (keyword, page) =>
           api.getEmployees(keyword: keyword, page: page, size: 20),
       labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
+      onRefresh: () => api.clearRefCache(),
     );
     if (result != null && mounted) {
       setState(() => _selEmployee = result);
@@ -356,6 +357,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
       loader: (keyword, page) =>
           api.getDepartments(keyword: keyword, page: page, size: 20),
       labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
+      onRefresh: () => api.clearRefCache(),
     );
     if (result != null && mounted) {
       setState(() {

+ 2 - 0
lib/features/expense/expense_edit_page.dart

@@ -141,6 +141,7 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
       loader: (keyword, page) =>
           api.getEmployees(keyword: keyword, page: page, size: 20),
       labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
+      onRefresh: () => api.clearRefCache(),
     );
     if (result != null && mounted) {
       setState(() => _selEmployee = result);
@@ -269,6 +270,7 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
       loader: (keyword, page) =>
           api.getDepartments(keyword: keyword, page: page, size: 20),
       labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
+      onRefresh: () => api.clearRefCache(),
     );
     if (result != null && mounted) {
       setState(() {

+ 5 - 0
lib/features/expense/widgets/expense_detail_dialog.dart

@@ -562,6 +562,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
               .getCostProjects(keyword: keyword, page: page, size: 20)
               .then((list) => list.cast<CostProjectItem>()),
           labelBuilder: (c) => '${c.idx1}/${c.name}',
+          onRefresh: () => widget.api.clearRefCache(),
         );
         if (result != null && mounted) {
           setState(() => _selCat = result);
@@ -814,6 +815,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
               widget.api.getEmployees(keyword: keyword, page: page, size: 20),
           labelBuilder: (e) =>
               e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
+          onRefresh: () => widget.api.clearRefCache(),
         );
         if (result != null && mounted) {
           setState(() {
@@ -853,6 +855,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
               widget.api.getCustomers(keyword: keyword, page: page, size: 20),
           labelBuilder: (v) =>
               v.name.isEmpty ? v.cusNo : '${v.cusNo} ${v.name}',
+          onRefresh: () => widget.api.clearRefCache(),
         );
         if (result != null && mounted) {
           setState(() => _selCustomer = result);
@@ -1053,6 +1056,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
               .getProjectCodes(keyword: keyword, page: page, size: 20)
               .then((list) => list.cast<ProjectCodeItem>()),
           labelBuilder: (p) => '${p.objNo}/${p.name}',
+          onRefresh: () => widget.api.clearRefCache(),
         );
         if (result != null && mounted) {
           setState(() => _selProject = result);
@@ -1081,6 +1085,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
           loader: (keyword, page) =>
               widget.api.getDepartments(keyword: keyword, page: page, size: 20),
           labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
+          onRefresh: () => widget.api.clearRefCache(),
         );
         if (result != null && mounted) {
           setState(() => _selDept = result);

+ 87 - 66
lib/features/expense_apply/expense_apply_api.dart

@@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
 import '../../core/network/api_client.dart';
 import '../../app.dart';
 import '../../shared/models/pagination_model.dart';
+import '../../core/data/api_cache.dart';
+import '../../core/navigation/host_app_channel.dart';
 import '../../shared/models/bill_attachment.dart';
 import '../../shared/models/bill_file_rights.dart';
 import 'expense_apply_model.dart';
@@ -78,8 +80,12 @@ class DepartmentItem {
 
 class ExpenseApplyApi {
   final ApiClient _client;
+  final _cache = ApiCache(keyPrefix: HostAppChannel.sn);
   ExpenseApplyApi(this._client);
 
+  /// 清除所有基础资料缓存(picker 下拉刷新时调用)。
+  void clearRefCache() => _cache.clear();
+
   /// 费用申请列表(分页)
   Future<PaginatedData<ExpenseApplyModel>> fetchList({
     String status = '',
@@ -123,19 +129,22 @@ class ExpenseApplyApi {
     int page = 1,
     int size = 100,
   }) async {
-    final response = await _client.get<Map<String, dynamic>>(
-      '/OA/GetCostTypes',
-      queryParameters: {
-        'keyword': keyword,
-        'accNo': accNo,
-        'page': page,
-        'size': size,
-      },
-    );
-    final list = (response.data?['list'] as List<dynamic>?) ?? [];
-    return list
-        .map((e) => CostTypeItem.fromJson(e as Map<String, dynamic>))
-        .toList();
+    final cacheKey = 'getCostTypes_${keyword}_${accNo}_$page';
+    return _cache.getOrFetch(cacheKey, () async {
+      final response = await _client.get<Map<String, dynamic>>(
+        '/OA/GetCostTypes',
+        queryParameters: {
+          'keyword': keyword,
+          'accNo': accNo,
+          'page': page,
+          'size': size,
+        },
+      );
+      final list = (response.data?['list'] as List<dynamic>?) ?? [];
+      return list
+          .map((e) => CostTypeItem.fromJson(e as Map<String, dynamic>))
+          .toList();
+    });
   }
 
   /// 费用项目字典(分页)
@@ -146,20 +155,23 @@ class ExpenseApplyApi {
     int page = 1,
     int size = 20,
   }) async {
-    final response = await _client.get<Map<String, dynamic>>(
-      '/OA/GetCostProjects',
-      queryParameters: {
-        'keyword': keyword,
-        'accNo': accNo,
-        'onlyActive': onlyActive,
-        'page': page,
-        'size': size,
-      },
-    );
-    final list = (response.data?['list'] as List<dynamic>?) ?? [];
-    return list
-        .map((e) => CostProjectItem.fromJson(e as Map<String, dynamic>))
-        .toList();
+    final cacheKey = 'getCostProjects_${keyword}_${accNo}_${onlyActive}_$page';
+    return _cache.getOrFetch(cacheKey, () async {
+      final response = await _client.get<Map<String, dynamic>>(
+        '/OA/GetCostProjects',
+        queryParameters: {
+          'keyword': keyword,
+          'accNo': accNo,
+          'onlyActive': onlyActive,
+          'page': page,
+          'size': size,
+        },
+      );
+      final list = (response.data?['list'] as List<dynamic>?) ?? [];
+      return list
+          .map((e) => CostProjectItem.fromJson(e as Map<String, dynamic>))
+          .toList();
+    });
   }
 
   /// 项目代号
@@ -169,19 +181,22 @@ class ExpenseApplyApi {
     int page = 1,
     int size = 100,
   }) async {
-    final response = await _client.get<Map<String, dynamic>>(
-      '/OA/GetProjectCodes',
-      queryParameters: {
-        'keyword': keyword,
-        'billDate': billDate,
-        'page': page,
-        'size': size,
-      },
-    );
-    final list = (response.data?['list'] as List<dynamic>?) ?? [];
-    return list
-        .map((e) => ProjectCodeItem.fromJson(e as Map<String, dynamic>))
-        .toList();
+    final cacheKey = 'getProjectCodes_${keyword}_${billDate}_$page';
+    return _cache.getOrFetch(cacheKey, () async {
+      final response = await _client.get<Map<String, dynamic>>(
+        '/OA/GetProjectCodes',
+        queryParameters: {
+          'keyword': keyword,
+          'billDate': billDate,
+          'page': page,
+          'size': size,
+        },
+      );
+      final list = (response.data?['list'] as List<dynamic>?) ?? [];
+      return list
+          .map((e) => ProjectCodeItem.fromJson(e as Map<String, dynamic>))
+          .toList();
+    });
   }
 
   /// 部门
@@ -191,19 +206,22 @@ class ExpenseApplyApi {
     int page = 1,
     int size = 100,
   }) async {
-    final response = await _client.get<Map<String, dynamic>>(
-      '/OA/GetDepartments',
-      queryParameters: {
-        'keyword': keyword,
-        'onlyActive': onlyActive,
-        'page': page,
-        'size': size,
-      },
-    );
-    final list = (response.data?['list'] as List<dynamic>?) ?? [];
-    return list
-        .map((e) => DepartmentItem.fromJson(e as Map<String, dynamic>))
-        .toList();
+    final cacheKey = 'getDepartments_${keyword}_${onlyActive}_$page';
+    return _cache.getOrFetch(cacheKey, () async {
+      final response = await _client.get<Map<String, dynamic>>(
+        '/OA/GetDepartments',
+        queryParameters: {
+          'keyword': keyword,
+          'onlyActive': onlyActive,
+          'page': page,
+          'size': size,
+        },
+      );
+      final list = (response.data?['list'] as List<dynamic>?) ?? [];
+      return list
+          .map((e) => DepartmentItem.fromJson(e as Map<String, dynamic>))
+          .toList();
+    });
   }
 
   /// 会计科目树(级联选择器数据源)
@@ -418,19 +436,22 @@ class ExpenseApplyApi {
     int page = 1,
     int size = 100,
   }) async {
-    final response = await _client.get<Map<String, dynamic>>(
-      '/OA/GetEmployees',
-      queryParameters: {
-        'keyword': keyword,
-        'salNo': salNo,
-        'page': page,
-        'size': size,
-      },
-    );
-    final list = (response.data?['list'] as List<dynamic>?) ?? [];
-    return list
-        .map((e) => EmployeeItem.fromJson(e as Map<String, dynamic>))
-        .toList();
+    final cacheKey = 'getEmployees_${keyword}_${salNo}_$page';
+    return _cache.getOrFetch(cacheKey, () async {
+      final response = await _client.get<Map<String, dynamic>>(
+        '/OA/GetEmployees',
+        queryParameters: {
+          'keyword': keyword,
+          'salNo': salNo,
+          'page': page,
+          'size': size,
+        },
+      );
+      final list = (response.data?['list'] as List<dynamic>?) ?? [];
+      return list
+          .map((e) => EmployeeItem.fromJson(e as Map<String, dynamic>))
+          .toList();
+    });
   }
 }
 

+ 1 - 0
lib/features/expense_apply/expense_apply_create_page.dart

@@ -907,6 +907,7 @@ class _ExpenseApplyCreatePageState
       loader: (keyword, page) =>
           api.getDepartments(keyword: keyword, page: page, size: 20),
       labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
+      onRefresh: () => api.clearRefCache(),
     );
     if (result != null && mounted) {
       setState(() {

+ 1 - 0
lib/features/expense_apply/expense_apply_edit_page.dart

@@ -871,6 +871,7 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
       loader: (keyword, page) =>
           api.getDepartments(keyword: keyword, page: page, size: 20),
       labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
+      onRefresh: () => api.clearRefCache(),
     );
     if (result != null && mounted) {
       setState(() {

+ 4 - 0
lib/features/expense_apply/widgets/expense_apply_detail_dialog.dart

@@ -495,6 +495,7 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
               .getCostProjects(keyword: keyword, page: page, size: 20)
               .then((list) => list.cast<CostProjectItem>()),
           labelBuilder: (c) => '${c.idx1}/${c.name}',
+          onRefresh: () => widget.api.clearRefCache(),
         );
         if (result != null && mounted) {
           setState(() => _selCat = result);
@@ -621,6 +622,7 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
               .getProjectCodes(keyword: keyword, page: page, size: 20)
               .then((list) => list.cast<ProjectCodeItem>()),
           labelBuilder: (p) => '${p.objNo}/${p.name}',
+          onRefresh: () => widget.api.clearRefCache(),
         );
         if (result != null && mounted) {
           setState(() => _selProject = result);
@@ -650,6 +652,7 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
               .getDepartments(keyword: keyword, page: page, size: 20)
               .then((list) => list.cast<DepartmentItem>()),
           labelBuilder: (d) => '${d.dep}/${d.name}',
+          onRefresh: () => widget.api.clearRefCache(),
         );
         if (result != null && mounted) {
           setState(() => _selDept = result);
@@ -677,6 +680,7 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
               widget.api.getEmployees(keyword: keyword, page: page, size: 20),
           labelBuilder: (e) =>
               e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
+          onRefresh: () => widget.api.clearRefCache(),
         );
         if (result != null && mounted) {
           setState(() => _selEmployee = result);

+ 13 - 3
lib/shared/widgets/searchable_picker_sheet.dart

@@ -18,6 +18,7 @@ Future<T?> showSearchablePicker<T>(
   String Function(T item)? subtitleBuilder,
   T? initialSelected,
   String? searchHint,
+  VoidCallback? onRefresh,
 }) {
   return Navigator.push<T>(
     context,
@@ -32,6 +33,7 @@ Future<T?> showSearchablePicker<T>(
         subtitleBuilder: subtitleBuilder,
         initialSelected: initialSelected != null ? [initialSelected] : null,
         searchHint: searchHint,
+        onRefresh: onRefresh,
       ),
     ),
   );
@@ -46,6 +48,7 @@ Future<List<T>> showSearchableMultiPicker<T>(
   String Function(T item)? subtitleBuilder,
   List<T>? initialSelected,
   String? searchHint,
+  VoidCallback? onRefresh,
 }) async {
   final result = await Navigator.push<List<T>>(
     context,
@@ -60,6 +63,7 @@ Future<List<T>> showSearchableMultiPicker<T>(
         subtitleBuilder: subtitleBuilder,
         initialSelected: initialSelected,
         searchHint: searchHint,
+        onRefresh: onRefresh,
       ),
     ),
   );
@@ -76,6 +80,7 @@ class _SearchablePickerSheet<T> extends StatefulWidget {
   final String Function(T item)? subtitleBuilder;
   final List<T>? initialSelected;
   final String? searchHint;
+  final VoidCallback? onRefresh;
 
   const _SearchablePickerSheet({
     required this.title,
@@ -85,6 +90,7 @@ class _SearchablePickerSheet<T> extends StatefulWidget {
     this.subtitleBuilder,
     this.initialSelected,
     this.searchHint,
+    this.onRefresh,
   });
 
   @override
@@ -136,6 +142,7 @@ class _SearchablePickerSheetState<T> extends State<_SearchablePickerSheet<T>> {
     if (reset) {
       _page = 1;
       _hasMore = true;
+      setState(() => _items = []);
     }
     if (_page == 1) setState(() => _loading = true);
     try {
@@ -161,7 +168,7 @@ class _SearchablePickerSheetState<T> extends State<_SearchablePickerSheet<T>> {
   }
 
   Future<void> _loadMore() async {
-    if (_loadingMore || !_hasMore) return;
+    if (_loadingMore || _loading || !_hasMore) return;
     _page++;
     setState(() => _loadingMore = true);
     await _load();
@@ -172,7 +179,7 @@ class _SearchablePickerSheetState<T> extends State<_SearchablePickerSheet<T>> {
     _debounce = Timer(const Duration(milliseconds: 300), () {
       _keyword = value;
       _page = 1;
-      _items = [];
+      setState(() => _items = []);
       _load(reset: true);
     });
   }
@@ -305,7 +312,10 @@ class _SearchablePickerSheetState<T> extends State<_SearchablePickerSheet<T>> {
     }
     return EasyRefresh(
       header: TDRefreshHeader(),
-      onRefresh: () => _load(reset: true),
+      onRefresh: () async {
+        widget.onRefresh?.call();
+        await _load(reset: true);
+      },
       child: _items.isEmpty
           ? ListView(
               children: [