Sfoglia il codice sorgente

fix #19407 资料选择控件优化

chengc 1 settimana fa
parent
commit
4d7cc3be19

+ 6 - 4
assets/i18n/en.json

@@ -29,6 +29,7 @@
     "submit": "Submit",
     "cancel": "Cancel",
     "confirm": "Confirm",
+    "selectedLabel": "Selected: ",
     "reset": "Reset",
     "publish": "Publish",
     "saveDraft": "Save Draft",
@@ -98,6 +99,7 @@
     "unknown": "Unknown",
     "other": "Other",
     "no": "None",
+    "select": "Select",
     "selectOrFill": "Select or fill in",
     "pleaseSelect": "Please select",
     "selectDateTime": "Select date & time",
@@ -301,8 +303,8 @@
     "noDetailHint": "No details, tap above to add",
     "detailRemark": "Detail Remark",
     "expenseProject": "Expense Item",
-    "expenseCategory": "Expense Category",
-    "selectExpenseCategory": "Select expense category",
+    "expenseCategory": "Expense Item",
+    "selectExpenseCategory": "Select expense item",
     "acctSubject": "GL Account",
     "selectAcctSubject": "Auto-filled from expense category",
     "costDept": "Cost Department",
@@ -748,7 +750,7 @@
     "notReturned": "Not Returned",
     "yearTotalApp": "Total Amount",
     "approvedCount": "Approved Count",
-    "approvedAmount": "Transferred Amount",
+    "approvedAmount": "Approved Amount",
     "transferredAmount": "Transferred Amount",
     "monthVisits": "This Month Visits",
     "visitCustomers": "Customers Visited",
@@ -827,4 +829,4 @@
     "headerAttachments": "Header Attachments",
     "detailLine": "Detail Line"
   }
-}
+}

+ 6 - 4
assets/i18n/zh_CN.json

@@ -28,7 +28,8 @@
     "save": "保存",
     "submit": "提交",
     "cancel": "取消",
-    "confirm": "确认",
+    "confirm": "确定",
+    "selectedLabel": "已选:",
     "reset": "重置",
     "publish": "发布",
     "saveDraft": "存草稿",
@@ -95,6 +96,7 @@
     "unknown": "未知",
     "other": "其他",
     "no": "无",
+    "select": "选择",
     "selectOrFill": "请选择或填写",
     "pleaseSelect": "请选择",
     "selectDateTime": "选择日期时间",
@@ -301,8 +303,8 @@
     "noDetailHint": "暂无明细,点击上方添加",
     "detailRemark": "明细说明",
     "expenseProject": "费用项目",
-    "expenseCategory": "费用类别",
-    "selectExpenseCategory": "选择费用类别",
+    "expenseCategory": "费用项目",
+    "selectExpenseCategory": "选择费用项目",
     "acctSubject": "会计科目",
     "selectAcctSubject": "选择费用类别后自动带出",
     "costDept": "费用承担部门",
@@ -748,7 +750,7 @@
     "notReturned": "未还车",
     "yearTotalApp": "总申请金额",
     "approvedCount": "已通过笔数",
-    "approvedAmount": "已转报销金额",
+    "approvedAmount": "核准金额",
     "transferredAmount": "已转报销金额",
     "monthVisits": "本月拜访",
     "visitCustomers": "拜访客户",

+ 6 - 4
assets/i18n/zh_TW.json

@@ -28,7 +28,8 @@
     "save": "保存",
     "submit": "提交",
     "cancel": "取消",
-    "confirm": "確認",
+    "confirm": "確定",
+    "selectedLabel": "已選:",
     "reset": "重置",
     "publish": "發佈",
     "saveDraft": "存草稿",
@@ -98,6 +99,7 @@
     "unknown": "未知",
     "other": "其他",
     "no": "無",
+    "select": "選擇",
     "selectOrFill": "請選擇或填寫",
     "pleaseSelect": "請選擇",
     "selectDateTime": "選擇日期時間",
@@ -301,8 +303,8 @@
     "noDetailHint": "暫無明細,点击上方添加",
     "detailRemark": "明細說明",
     "expenseProject": "費用項目",
-    "expenseCategory": "費用類別",
-    "selectExpenseCategory": "選擇費用類別",
+    "expenseCategory": "費用項目",
+    "selectExpenseCategory": "選擇費用項目",
     "acctSubject": "會計科目",
     "selectAcctSubject": "選擇費用類別後自動帶出",
     "costDept": "費用承擔部門",
@@ -827,4 +829,4 @@
     "headerAttachments": "表頭附件",
     "detailLine": "明細行"
   }
-}
+}

+ 6 - 3
lib/core/i18n/app_localizations.dart

@@ -35,12 +35,15 @@ class AppLocalizations {
     final jsonStr = await rootBundle.loadString(path);
     final raw = jsonDecode(jsonStr) as Map<String, dynamic>;
     final flat = <String, String>{};
-    void flatten(Map<String, dynamic> map) {
+    void flatten(Map<String, dynamic> map, [String prefix = '']) {
       for (final entry in map.entries) {
+        final key = prefix.isEmpty ? entry.key : '$prefix.${entry.key}';
         if (entry.value is Map<String, dynamic>) {
-          flatten(entry.value as Map<String, dynamic>);
+          flatten(entry.value as Map<String, dynamic>, key);
         } else {
-          flat[entry.key] = entry.value.toString();
+          flat[key] = entry.value.toString();
+          // 向下兼容:无冲突时保留裸 key,首次写入优先
+          flat.putIfAbsent(entry.key, () => entry.value.toString());
         }
       }
     }

+ 72 - 12
lib/features/expense/expense_api.dart

@@ -39,6 +39,26 @@ class CostTypeItem {
   );
 }
 
+class CostProjectItem {
+  final String idx1;
+  final String name;
+  final String accNo;
+  final String accName;
+  const CostProjectItem({
+    required this.idx1,
+    required this.name,
+    required this.accNo,
+    required this.accName,
+  });
+  factory CostProjectItem.fromJson(Map<String, dynamic> json) =>
+      CostProjectItem(
+        idx1: json['idx1'] as String? ?? '',
+        name: json['name'] as String? ?? '',
+        accNo: json['accNo'] as String? ?? '',
+        accName: json['accName'] as String? ?? '',
+      );
+}
+
 class ProjectCodeItem {
   final String objNo;
   final String name;
@@ -213,14 +233,16 @@ class ExpenseApi {
   Future<List<CostTypeItem>> getCostTypes({
     String keyword = '',
     String accNo = '',
+    int page = 1,
+    int size = 100,
   }) async {
     final response = await _client.get<Map<String, dynamic>>(
       '/OA/GetCostTypes',
       queryParameters: {
         'keyword': keyword,
         'accNo': accNo,
-        'page': 1,
-        'size': 100,
+        'page': page,
+        'size': size,
       },
     );
     final list = (response.data?['list'] as List<dynamic>?) ?? [];
@@ -229,18 +251,44 @@ class ExpenseApi {
         .toList();
   }
 
+  /// 费用项目字典(分页)
+  Future<List<CostProjectItem>> getCostProjects({
+    String keyword = '',
+    String accNo = '',
+    bool onlyActive = true,
+    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();
+  }
+
   /// 项目代号
   Future<List<ProjectCodeItem>> getProjectCodes({
     String keyword = '',
     String billDate = '',
+    int page = 1,
+    int size = 100,
   }) async {
     final response = await _client.get<Map<String, dynamic>>(
       '/OA/GetProjectCodes',
       queryParameters: {
         'keyword': keyword,
         'billDate': billDate,
-        'page': 1,
-        'size': 100,
+        'page': page,
+        'size': size,
       },
     );
     final list = (response.data?['list'] as List<dynamic>?) ?? [];
@@ -253,14 +301,16 @@ class ExpenseApi {
   Future<List<DepartmentItem>> getDepartments({
     String keyword = '',
     bool onlyActive = true,
+    int page = 1,
+    int size = 100,
   }) async {
     final response = await _client.get<Map<String, dynamic>>(
       '/OA/GetDepartments',
       queryParameters: {
         'keyword': keyword,
         'onlyActive': onlyActive,
-        'page': 1,
-        'size': 100,
+        'page': page,
+        'size': size,
       },
     );
     final list = (response.data?['list'] as List<dynamic>?) ?? [];
@@ -270,10 +320,14 @@ class ExpenseApi {
   }
 
   /// 客户/厂商
-  Future<List<CustomerItem>> getCustomers({String keyword = ''}) async {
+  Future<List<CustomerItem>> getCustomers({
+    String keyword = '',
+    int page = 1,
+    int size = 100,
+  }) async {
     final response = await _client.get<Map<String, dynamic>>(
       '/OA/GetCustomers',
-      queryParameters: {'keyword': keyword, 'page': 1, 'size': 100},
+      queryParameters: {'keyword': keyword, 'page': page, 'size': size},
     );
     final list = (response.data?['list'] as List<dynamic>?) ?? [];
     return list
@@ -285,14 +339,16 @@ class ExpenseApi {
   Future<List<EmployeeItem>> getEmployees({
     String keyword = '',
     String salNo = '',
+    int page = 1,
+    int size = 100,
   }) async {
     final response = await _client.get<Map<String, dynamic>>(
       '/OA/GetEmployees',
       queryParameters: {
         'keyword': keyword,
         'salNo': salNo,
-        'page': 1,
-        'size': 100,
+        'page': page,
+        'size': size,
       },
     );
     final list = (response.data?['list'] as List<dynamic>?) ?? [];
@@ -325,10 +381,14 @@ class ExpenseApi {
   }
 
   /// 币别
-  Future<List<CurrencyItem>> getCurrencies({String keyword = ''}) async {
+  Future<List<CurrencyItem>> getCurrencies({
+    String keyword = '',
+    int page = 1,
+    int size = 50,
+  }) async {
     final response = await _client.get<Map<String, dynamic>>(
       '/OA/GetCurrencies',
-      queryParameters: {'keyword': keyword, 'page': 1, 'size': 50},
+      queryParameters: {'keyword': keyword, 'page': page, 'size': size},
     );
     final list = (response.data?['list'] as List<dynamic>?) ?? [];
     return list

+ 34 - 94
lib/features/expense/expense_create_page.dart

@@ -1,4 +1,4 @@
-import 'dart:async';
+import 'dart:async';
 import 'dart:io';
 
 import 'package:flutter/material.dart';
@@ -13,6 +13,7 @@ import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
 import '../../shared/widgets/app_skeletons.dart';
 import '../../shared/widgets/nav_bar_config.dart';
+import '../../shared/widgets/searchable_picker_sheet.dart';
 import 'expense_api.dart';
 import 'expense_create_controller.dart';
 import '../../core/i18n/app_localizations.dart';
@@ -20,7 +21,6 @@ import 'expense_model.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors_extension.dart';
 import '../../core/navigation/host_app_channel.dart';
-import '../../core/data/mock_api_data.dart';
 import 'widgets/expense_detail_dialog.dart';
 import '../../shared/models/bill_file_rights.dart';
 import '../../shared/widgets/action_bar.dart';
@@ -49,10 +49,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
   late Future<bool> _draftFuture;
   bool _draftHandled = false;
   // ── 参考数据(从 API 加载) ──
-  List<CostTypeItem> _costTypes = [];
-  List<ProjectCodeItem> _projects = [];
   List<DepartmentItem> _departments = [];
-  List<CustomerItem> _customers = [];
   List<CurrencyItem> _currencies = [];
   List<EmployeeItem> _employees = [];
   EmployeeItem? _selEmployee;
@@ -84,10 +81,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
     _checkBillFileRights();
     _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
-    _costTypes = [];
-    _projects = [];
     _departments = [];
-    _customers = [];
     _employees = [];
     _refDataLoading = true;
     _refDataFuture = null;
@@ -118,10 +112,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
     try {
       final api = ref.read(expenseApiProvider);
       final results = await Future.wait([
-        api.getCostTypes(),
-        api.getProjectCodes(),
         api.getDepartments(),
-        api.getCustomers(),
         api.getCurrencies(),
         api.getEmployees(),
       ]);
@@ -130,12 +121,9 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
         return;
       }
       setState(() {
-        _costTypes = results[0] as List<CostTypeItem>;
-        _projects = results[1] as List<ProjectCodeItem>;
-        _departments = results[2] as List<DepartmentItem>;
-        _customers = results[3] as List<CustomerItem>;
-        _currencies = results[4] as List<CurrencyItem>;
-        _employees = results[5] as List<EmployeeItem>;
+        _departments = results[0] as List<DepartmentItem>;
+        _currencies = results[1] as List<CurrencyItem>;
+        _employees = results[2] as List<EmployeeItem>;
         _refDataLoading = false;
         _autoSelectDept();
         _autoSelectEmployee();
@@ -314,36 +302,11 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
     );
   }
 
-  // ═══ API 数据 → 弹窗类型转换 ═══
-
-  List<CostCategory> get _dialogCategories => _costTypes
-      .map(
-        (c) => CostCategory(
-          code: c.typeNo,
-          nameKey: c.typeName,
-          acctSubjectId: c.accNo,
-          acctSubjectName: c.accName,
-        ),
-      )
-      .toList();
-
-  List<Project> get _dialogProjects => _projects
-      .map((p) => Project(id: int.tryParse(p.objNo) ?? 0, name: p.name))
-      .toList();
-
-  List<CostDept> get _dialogCostDepts =>
-      _departments.map((d) => CostDept(id: d.dep, name: d.name)).toList();
-
   String _currencyLabel(String code) {
     final match = _currencies.where((c) => c.curId == code);
     return match.isNotEmpty ? '${match.first.curId}/${match.first.name}' : code;
   }
 
-  List<CustomerVendor> get _dialogCustomers =>
-      _customers.map((c) => CustomerVendor(id: c.cusNo, name: c.name)).toList();
-
-  List<EmployeeItem> get _dialogEmployees => _employees;
-
   void _autoSelectDept() {
     if (_selectedDeptId.isNotEmpty) return;
     final dep = HostAppChannel.dep;
@@ -365,56 +328,41 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
     }
   }
 
-  void _showEmployeePicker() {
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final labels = _employees.map((e) => '${e.salNo}/${e.name}').toList();
-    TDPicker.showMultiPicker(
+  Future<void> _showEmployeePicker() async {
+    FocusManager.instance.primaryFocus?.unfocus();
+    final l10n = AppLocalizations.of(context);
+    final api = ref.read(expenseApiProvider);
+    final result = await showSearchablePicker<EmployeeItem>(
       context,
-      title: AppLocalizations.of(context).get('expensePersonnel'),
-      backgroundColor: colors.bgCard,
-      data: [labels],
-      onConfirm: (selected) {
-        if (selected.isNotEmpty && selected[0] is int) {
-          final idx = selected[0] as int;
-          if (idx >= 0 && idx < labels.length) {
-            Navigator.of(context).pop();
-            setState(() => _selEmployee = _employees[idx]);
-          }
-        }
-      },
+      title: '${l10n.get('select')}${l10n.get('expensePersonnel')}',
+      searchHint: l10n.get('search'),
+      loader: (keyword, page) =>
+          api.getEmployees(keyword: keyword, page: page, size: 20),
+      labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
     );
+    if (result != null && mounted) {
+      setState(() => _selEmployee = result);
+    }
   }
 
-  void _showDeptPicker() {
-    if (_departments.isEmpty) {
-      TDToast.showText(
-        AppLocalizations.of(context).get('noData'),
-        context: context,
-      );
-      return;
-    }
-    final l10n = AppLocalizations.of(context);
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final labels = _departments.map((d) => '${d.dep}/${d.name}').toList();
+  Future<void> _showDeptPicker() async {
     FocusManager.instance.primaryFocus?.unfocus();
-    TDPicker.showMultiPicker(
+    final l10n = AppLocalizations.of(context);
+    final api = ref.read(expenseApiProvider);
+    final result = await showSearchablePicker<DepartmentItem>(
       context,
-      title: l10n.get('expenseDept'),
-      backgroundColor: colors.bgCard,
-      data: [labels],
-      onConfirm: (selected) {
-        if (selected.isNotEmpty && selected[0] is int) {
-          final idx = selected[0] as int;
-          if (idx >= 0 && idx < labels.length) {
-            Navigator.of(context).pop();
-            setState(() {
-              _selectedDeptId = _departments[idx].dep;
-              _selectedDeptName = _departments[idx].name;
-            });
-          }
-        }
-      },
+      title: '${l10n.get('select')}${l10n.get('expenseDept')}',
+      searchHint: l10n.get('search'),
+      loader: (keyword, page) =>
+          api.getDepartments(keyword: keyword, page: page, size: 20),
+      labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
     );
+    if (result != null && mounted) {
+      setState(() {
+        _selectedDeptId = result.dep;
+        _selectedDeptName = result.name;
+      });
+    }
   }
 
   Map<String, dynamic> _buildSubmitData(ExpenseCreateState state) {
@@ -1208,10 +1156,6 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
     _addingDetail = true;
     try {
       final l10n = AppLocalizations.of(context);
-      if (_costTypes.isEmpty) {
-        TDToast.showText(l10n.get('noCostTypeData'), context: context);
-        return;
-      }
       final state = controller.currentState;
       ExpenseDetailInputData? initialData;
       if (editIndex != null) {
@@ -1245,11 +1189,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
       FocusManager.instance.primaryFocus?.unfocus();
       final result = await ExpenseDetailDialog.show(
         context,
-        categories: _dialogCategories,
-        projects: _dialogProjects,
-        costDepts: _dialogCostDepts,
-        customers: _dialogCustomers,
-        employees: _dialogEmployees,
+        api: ref.read(expenseApiProvider),
         l10n: l10n,
         initialData: initialData,
         checkAttachHealth: () =>

+ 37 - 120
lib/features/expense/expense_edit_page.dart

@@ -1,4 +1,4 @@
-import 'dart:async';
+import 'dart:async';
 
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
@@ -13,8 +13,8 @@ import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
 import '../../shared/widgets/app_skeletons.dart';
 import '../../shared/widgets/nav_bar_config.dart';
-import '../../core/data/mock_api_data.dart';
 import 'widgets/expense_detail_dialog.dart';
+import '../../shared/widgets/searchable_picker_sheet.dart';
 import '../../shared/widgets/loading_dialog.dart';
 import '../../shared/widgets/action_bar.dart';
 import 'expense_api.dart';
@@ -51,12 +51,7 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
   final _detailsSectionKey = GlobalKey();
 
   // ── 参考数据(从 API 加载) ──
-  List<CostTypeItem> _costTypes = [];
-  List<ProjectCodeItem> _projects = [];
-  List<DepartmentItem> _departments = [];
-  List<CustomerItem> _customers = [];
   List<CurrencyItem> _currencies = [];
-  List<EmployeeItem> _employees = [];
   EmployeeItem? _selEmployee;
   bool _firstBuild = true;
   bool _refDataLoading = true;
@@ -103,36 +98,27 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
     try {
       final api = ref.read(expenseApiProvider);
       final results = await Future.wait([
-        api.getCostTypes(),
-        api.getProjectCodes(),
-        api.getDepartments(),
-        api.getCustomers(),
         api.getCurrencies(),
-        api.getEmployees(),
         api.fetchDetail(widget.billNo),
       ]);
       if (!mounted) return;
-      final expense = results[6] as ExpenseModel;
+      final expense = results[1] as ExpenseModel;
       setState(() {
-        _costTypes = results[0] as List<CostTypeItem>;
-        _projects = results[1] as List<ProjectCodeItem>;
-        _departments = results[2] as List<DepartmentItem>;
-        _customers = results[3] as List<CustomerItem>;
-        _currencies = results[4] as List<CurrencyItem>;
-        _employees = results[5] as List<EmployeeItem>;
+        _currencies = results[0] as List<CurrencyItem>;
         _expense = expense;
         _expenseDate = expense.expenseDate != null
             ? du.DateUtils.formatDate(expense.expenseDate!)
             : _today();
         _selectedDeptId = expense.deptId;
         _selectedDeptName = expense.deptName;
+        _selEmployee = expense.applicantId.isNotEmpty
+            ? EmployeeItem(salNo: expense.applicantId, name: expense.applicantName)
+            : null;
         _purposeController.text = expense.purpose;
         _remarkController.text = expense.remark;
         _refDataLoading = false;
         _firstBuild = false;
         _recalculateAmount();
-        _autoSelectDept();
-        _autoSelectEmployee();
       });
     } catch (e) {
       if (!mounted) return;
@@ -144,45 +130,21 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
     }
   }
 
-  void _autoSelectDept() {
-    if (_selectedDeptId.isNotEmpty) return;
-    final dep = HostAppChannel.dep;
-    if (dep.isEmpty) return;
-    final match = _departments.where((d) => d.dep == dep);
-    if (match.isNotEmpty) {
-      _selectedDeptId = match.first.dep;
-      _selectedDeptName = match.first.name;
-    }
-  }
-
-  void _autoSelectEmployee() {
-    if (_selEmployee != null) return;
-    final usr = HostAppChannel.usr;
-    if (usr.isEmpty || _employees.isEmpty) return;
-    final match = _employees.where((e) => e.salNo == usr);
-    if (match.isNotEmpty) {
-      setState(() => _selEmployee = match.first);
-    }
-  }
-
-  void _showEmployeePicker() {
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final labels = _employees.map((e) => '${e.salNo}/${e.name}').toList();
-    TDPicker.showMultiPicker(
+  Future<void> _showEmployeePicker() async {
+    FocusManager.instance.primaryFocus?.unfocus();
+    final l10n = AppLocalizations.of(context);
+    final api = ref.read(expenseApiProvider);
+    final result = await showSearchablePicker<EmployeeItem>(
       context,
-      title: AppLocalizations.of(context).get('expensePersonnel'),
-      backgroundColor: colors.bgCard,
-      data: [labels],
-      onConfirm: (selected) {
-        if (selected.isNotEmpty && selected[0] is int) {
-          final idx = selected[0] as int;
-          if (idx >= 0 && idx < labels.length) {
-            Navigator.of(context).pop();
-            setState(() => _selEmployee = _employees[idx]);
-          }
-        }
-      },
+      title: '${l10n.get('select')}${l10n.get('expensePersonnel')}',
+      searchHint: l10n.get('search'),
+      loader: (keyword, page) =>
+          api.getEmployees(keyword: keyword, page: page, size: 20),
+      labelBuilder: (e) => e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
     );
+    if (result != null && mounted) {
+      setState(() => _selEmployee = result);
+    }
   }
 
   void _ensureVisible(FocusNode node) {
@@ -291,66 +253,29 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
     });
   }
 
-  // ═══ API 数据 → 弹窗类型转换 ═══
-
-  List<CostCategory> get _dialogCategories => _costTypes
-      .map(
-        (c) => CostCategory(
-          code: c.typeNo,
-          nameKey: c.typeName,
-          acctSubjectId: c.accNo,
-          acctSubjectName: c.accName,
-        ),
-      )
-      .toList();
-
-  List<Project> get _dialogProjects => _projects
-      .map((p) => Project(id: int.tryParse(p.objNo) ?? 0, name: p.name))
-      .toList();
-
-  List<CostDept> get _dialogCostDepts =>
-      _departments.map((d) => CostDept(id: d.dep, name: d.name)).toList();
-
   String _currencyLabel(String code) {
     final match = _currencies.where((c) => c.curId == code);
     return match.isNotEmpty ? '${match.first.curId}/${match.first.name}' : code;
   }
 
-  List<CustomerVendor> get _dialogCustomers =>
-      _customers.map((c) => CustomerVendor(id: c.cusNo, name: c.name)).toList();
-
-  List<EmployeeItem> get _dialogEmployees => _employees;
-
-  void _showDeptPicker() {
-    if (_departments.isEmpty) {
-      TDToast.showText(
-        AppLocalizations.of(context).get('noData'),
-        context: context,
-      );
-      return;
-    }
-    final l10n = AppLocalizations.of(context);
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final labels = _departments.map((d) => '${d.dep}/${d.name}').toList();
+  Future<void> _showDeptPicker() async {
     FocusManager.instance.primaryFocus?.unfocus();
-    TDPicker.showMultiPicker(
+    final l10n = AppLocalizations.of(context);
+    final api = ref.read(expenseApiProvider);
+    final result = await showSearchablePicker<DepartmentItem>(
       context,
-      title: l10n.get('expenseDept'),
-      backgroundColor: colors.bgCard,
-      data: [labels],
-      onConfirm: (selected) {
-        if (selected.isNotEmpty && selected[0] is int) {
-          final idx = selected[0] as int;
-          if (idx >= 0 && idx < labels.length) {
-            Navigator.of(context).pop();
-            setState(() {
-              _selectedDeptId = _departments[idx].dep;
-              _selectedDeptName = _departments[idx].name;
-            });
-          }
-        }
-      },
+      title: '${l10n.get('select')}${l10n.get('expenseDept')}',
+      searchHint: l10n.get('search'),
+      loader: (keyword, page) =>
+          api.getDepartments(keyword: keyword, page: page, size: 20),
+      labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
     );
+    if (result != null && mounted) {
+      setState(() {
+        _selectedDeptId = result.dep;
+        _selectedDeptName = result.name;
+      });
+    }
   }
 
   Map<String, dynamic> _buildSubmitData() {
@@ -533,7 +458,7 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
               ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
               : '',
           hint: l10n.get('pleaseSelect'),
-          onTap: _employees.isNotEmpty ? _showEmployeePicker : null,
+          onTap: _showEmployeePicker,
         ),
         const SizedBox(height: 16),
         FormFieldRow(
@@ -906,10 +831,6 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
     _addingDetail = true;
     try {
       final l10n = AppLocalizations.of(context);
-      if (_costTypes.isEmpty) {
-        TDToast.showText(l10n.get('noCostTypeData'), context: context);
-        return;
-      }
       final expense = _expense!;
       ExpenseDetailInputData? initialData;
       if (editIndex != null) {
@@ -943,11 +864,7 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
       FocusManager.instance.primaryFocus?.unfocus();
       final result = await ExpenseDetailDialog.show(
         context,
-        categories: _dialogCategories,
-        projects: _dialogProjects,
-        costDepts: _dialogCostDepts,
-        customers: _dialogCustomers,
-        employees: _dialogEmployees,
+        api: ref.read(expenseApiProvider),
         l10n: l10n,
         initialData: initialData,
         checkAttachHealth: () =>

+ 1 - 1
lib/features/expense/expense_model.dart

@@ -97,7 +97,7 @@ class ExpenseModel {
       deptId: json['dept'] as String? ?? '',
       deptName: json['deptName'] as String? ?? json['dept'] as String? ?? '',
       currencyCode: json['curId'] as String? ?? '',
-      isGenerateVoucher: json['isGenerateVoucher'] as bool? ?? false,
+      isGenerateVoucher: (json['vohId'] as String?) == 'T',
       voucherNo: json['vohNo'] as String? ?? '',
       approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0.0,
       totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0.0,

+ 157 - 137
lib/features/expense/widgets/expense_detail_dialog.dart

@@ -1,4 +1,4 @@
-// ignore_for_file: use_build_context_synchronously
+// ignore_for_file: use_build_context_synchronously
 
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
@@ -6,10 +6,10 @@ import 'package:tdesign_flutter/tdesign_flutter.dart';
 import '../../../core/i18n/app_localizations.dart';
 import '../../../core/theme/app_colors.dart';
 import '../../../core/theme/app_colors_extension.dart';
-import '../../../core/data/mock_api_data.dart';
 import '../../../shared/models/bill_file_rights.dart';
 import '../expense_api.dart';
 import '../../../shared/widgets/attachment_picker.dart';
+import '../../../shared/widgets/searchable_picker_sheet.dart';
 
 /// 报销明细输入数据。
 class ExpenseDetailInputData {
@@ -18,7 +18,7 @@ class ExpenseDetailInputData {
   final String acctSubjectId;
   final String acctSubjectName;
   final String purpose;
-  final double amount; // 含税金额
+  final double amount;
   final double taxRate;
   final String projectId;
   final String projectName;
@@ -71,11 +71,7 @@ class ExpenseDetailInputData {
 /// 使用 [TDSlidePopupRoute] 从底部滑出,卡片化展示表单字段。
 /// 参照 ExpenseApplyCreatePage 的 ExpenseDetailDialog 样式。
 class ExpenseDetailDialog extends StatefulWidget {
-  final List<CostCategory> categories;
-  final List<Project> projects;
-  final List<CostDept> costDepts;
-  final List<CustomerVendor> customers;
-  final List<EmployeeItem> employees;
+  final ExpenseApi api;
   final AppLocalizations l10n;
   final ExpenseDetailInputData? initialData;
   final Future<bool> Function()? checkAttachHealth;
@@ -85,11 +81,7 @@ class ExpenseDetailDialog extends StatefulWidget {
 
   const ExpenseDetailDialog({
     super.key,
-    required this.categories,
-    required this.projects,
-    required this.costDepts,
-    required this.customers,
-    required this.employees,
+    required this.api,
     required this.l10n,
     this.initialData,
     this.checkAttachHealth,
@@ -101,11 +93,7 @@ class ExpenseDetailDialog extends StatefulWidget {
   /// 显示弹窗,返回 [ExpenseDetailInputData] 或 `null`(取消时)。
   static Future<ExpenseDetailInputData?> show(
     BuildContext context, {
-    required List<CostCategory> categories,
-    required List<Project> projects,
-    required List<CostDept> costDepts,
-    required List<CustomerVendor> customers,
-    required List<EmployeeItem> employees,
+    required ExpenseApi api,
     required AppLocalizations l10n,
     ExpenseDetailInputData? initialData,
     Future<bool> Function()? checkAttachHealth,
@@ -120,11 +108,7 @@ class ExpenseDetailDialog extends StatefulWidget {
         slideTransitionFrom: SlideTransitionFrom.bottom,
         isDismissible: true,
         builder: (_) => ExpenseDetailDialog(
-          categories: categories,
-          projects: projects,
-          costDepts: costDepts,
-          customers: customers,
-          employees: employees,
+          api: api,
           l10n: l10n,
           initialData: initialData,
           checkAttachHealth: checkAttachHealth,
@@ -141,18 +125,18 @@ class ExpenseDetailDialog extends StatefulWidget {
 }
 
 class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
-  late String _cat;
+  late CostProjectItem _selCat;
   late TextEditingController _descCtrl;
   late TextEditingController _amountCtrl;
-  CustomerVendor? _selCustomer;
+  CustomerItem? _selCustomer;
   late TextEditingController _approvedAmountCtrl;
   late TextEditingController _remarkCtrl;
   late TextEditingController _bankNameCtrl;
   late TextEditingController _bankAccountNameCtrl;
   late TextEditingController _bankAccountCtrl;
   double _taxRate = 0;
-  Project? _selProject;
-  CostDept? _selDept;
+  ProjectCodeItem? _selProject;
+  DepartmentItem? _selDept;
   EmployeeItem? _selEmployee;
   late final AttachmentPickerController _attachmentCtrl;
   final ScrollController _scrollCtrl = ScrollController();
@@ -192,31 +176,27 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
   static const _taxOptions = [0, 6, 9, 13];
   static const _taxLabels = ['0%', '6%', '9%', '13%'];
 
-  List<CostCategory> get _cats => widget.categories;
   AppLocalizations get _l10n => widget.l10n;
 
-  CostCategory get _selCat => _cats.firstWhere((c) => c.code == _cat);
-
   bool get _isEdit => widget.initialData != null;
 
   @override
   void initState() {
     super.initState();
     final d = widget.initialData;
-    _cat = d != null
-        ? (_cats.any((c) => c.code == d.category)
-              ? d.category
-              : _cats.first.code)
-        : _cats.isNotEmpty
-        ? _cats.first.code
-        : 'other';
+    _selCat = CostProjectItem(
+      idx1: d?.category ?? '',
+      name: d?.categoryName ?? '',
+      accNo: d?.acctSubjectId ?? '',
+      accName: d?.acctSubjectName ?? '',
+    );
     _descCtrl = TextEditingController(text: d?.purpose ?? '');
     _amountCtrl = TextEditingController(
       text: d != null && d.amount > 0 ? d.amount.toStringAsFixed(2) : '',
     );
-    if (d != null && d.customerVendorName.isNotEmpty) {
-      _selCustomer = CustomerVendor(id: d.customerVendorId, name: d.customerVendorName);
-    }
+    _selCustomer = (d != null && d.customerVendorId.isNotEmpty)
+        ? CustomerItem(cusNo: '', name: '')
+        : null;
     _approvedAmountCtrl = TextEditingController(
       text: d != null && d.approvedAmount > 0
           ? d.approvedAmount.toStringAsFixed(2)
@@ -229,29 +209,19 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     );
     _bankAccountCtrl = TextEditingController(text: d?.bankAccount ?? '');
     _taxRate = d?.taxRate ?? 0;
-    if (d != null && d.sqMan.isNotEmpty && widget.employees.isNotEmpty) {
-      final idx = widget.employees.indexWhere((e) => e.salNo == d.sqMan);
-      if (idx >= 0) _selEmployee = widget.employees[idx];
-    }
-    if (d != null) {
-      if (d.projectId.isNotEmpty && widget.projects.isNotEmpty) {
-        _selProject = widget.projects.firstWhere(
-          (p) => p.id.toString() == d.projectId,
-          orElse: () => widget.projects.first,
-        );
-      }
-      if (d.costDeptId.isNotEmpty && widget.costDepts.isNotEmpty) {
-        _selDept = widget.costDepts.firstWhere(
-          (dept) => dept.id == d.costDeptId,
-          orElse: () => widget.costDepts.first,
-        );
-      }
-      if (d.attachmentPaths.isNotEmpty) {
-        // Restore attachments will be handled after build
-        WidgetsBinding.instance.addPostFrameCallback((_) {
-          _attachmentCtrl.restoreFromPaths(d.attachmentPaths);
-        });
-      }
+    _selEmployee = (d != null && d.sqMan.isNotEmpty)
+        ? EmployeeItem(salNo: d.sqMan, name: d.sqManName)
+        : null;
+    _selProject = (d != null && d.projectId.isNotEmpty)
+        ? ProjectCodeItem(objNo: d.projectId, name: d.projectName)
+        : null;
+    _selDept = (d != null && d.costDeptId.isNotEmpty)
+        ? DepartmentItem(dep: d.costDeptId, name: d.costDeptName)
+        : null;
+    if (d != null && d.attachmentPaths.isNotEmpty) {
+      WidgetsBinding.instance.addPostFrameCallback((_) {
+        _attachmentCtrl.restoreFromPaths(d.attachmentPaths);
+      });
     }
     _attachmentCtrl = AttachmentPickerController(maxCount: 9)
       ..addListener(() => setState(() {}));
@@ -302,18 +272,18 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     Navigator.pop(
       context,
       ExpenseDetailInputData(
-        category: _cat,
-        categoryName: _l10n.get(_selCat.nameKey),
-        acctSubjectId: _selCat.acctSubjectId,
-        acctSubjectName: _selCat.acctSubjectName,
+        category: _selCat.idx1,
+        categoryName: _selCat.name,
+        acctSubjectId: _selCat.accNo,
+        acctSubjectName: _selCat.accName,
         purpose: '',
         amount: amount,
         taxRate: _taxRate,
-        projectId: _selProject?.id.toString() ?? '',
+        projectId: _selProject?.objNo ?? '',
         projectName: _selProject?.name ?? '',
-        costDeptId: _selDept?.id ?? '',
+        costDeptId: _selDept?.dep ?? '',
         costDeptName: _selDept?.name ?? '',
-        customerVendorId: _selCustomer?.id ?? '',
+        customerVendorId: _selCustomer?.cusNo ?? '',
         customerVendorName: _selCustomer?.name ?? '',
         approvedAmount: double.tryParse(_approvedAmountCtrl.text) ?? 0,
         bankName: _bankNameCtrl.text.trim(),
@@ -451,7 +421,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
           padding: const EdgeInsets.fromLTRB(20, 8, 12, 16),
           child: Row(
             children: [
-              const SizedBox(width: 28),
+              const SizedBox(width: 24),
               Expanded(
                 child: Center(
                   child: Text(
@@ -487,35 +457,15 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     required String label,
     required bool required,
     required String currentLabel,
-    required List<String> labels,
-    required ValueChanged<int> onSelected,
-    required AppColorsExtension colors,
+    required bool hasValue,
+    required VoidCallback onTap,
     VoidCallback? onClear,
   }) {
     final tdTheme = TDTheme.of(context);
-    final hasValue = onClear != null;
     return GestureDetector(
       onTap: () {
-        if (labels.isEmpty) {
-          TDToast.showText(_l10n.get('noData'), context: context);
-          return;
-        }
         FocusManager.instance.primaryFocus?.unfocus();
-        TDPicker.showMultiPicker(
-          context,
-          title: label,
-          backgroundColor: colors.bgCard,
-          data: [labels],
-          onConfirm: (selected) {
-            if (selected.isNotEmpty && selected[0] is int) {
-              final idx = selected[0] as int;
-              if (idx >= 0 && idx < labels.length) {
-                Navigator.of(context).pop();
-                onSelected(idx);
-              }
-            }
-          },
-        );
+        onTap();
       },
       child: Container(
         padding: const EdgeInsets.only(
@@ -562,7 +512,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
                       overflow: TextOverflow.ellipsis,
                       font: tdTheme.fontBodyLarge,
                       fontWeight: FontWeight.w400,
-                      textColor: tdTheme.textColorPrimary,
+                      textColor: hasValue ? tdTheme.textColorPrimary : tdTheme.textColorPlaceholder,
                       textAlign: TextAlign.end,
                     ),
                   ),
@@ -570,7 +520,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
                   SizedBox(
                     width: 18,
                     height: 18,
-                    child: hasValue
+                    child: onClear != null
                         ? GestureDetector(
                             onTap: onClear,
                             child: Icon(
@@ -594,15 +544,29 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     );
   }
 
-  // ── 费用类别 ──
+  // ── 费用项目 ──
   Widget _buildCategoryCard(AppColorsExtension colors) {
     return _pickerCard(
       label: _l10n.get('expenseCategory'),
       required: true,
-      currentLabel: '${_selCat.code}/${_l10n.get(_selCat.nameKey)}',
-      labels: _cats.map((c) => '${c.code}/${_l10n.get(c.nameKey)}').toList(),
-      colors: colors,
-      onSelected: (idx) => setState(() => _cat = _cats[idx].code),
+      hasValue: _selCat.idx1.isNotEmpty,
+      currentLabel: _selCat.idx1.isNotEmpty
+          ? '${_selCat.idx1}/${_selCat.name}'
+          : _l10n.get('pleaseSelect'),
+      onTap: () async {
+        final result = await showSearchablePicker<CostProjectItem>(
+          context,
+          title: '${_l10n.get('select')}${_l10n.get('expenseCategory')}',
+          searchHint: _l10n.get('search'),
+          loader: (keyword, page) => widget.api
+              .getCostProjects(keyword: keyword, page: page, size: 20)
+              .then((list) => list.cast<CostProjectItem>()),
+          labelBuilder: (c) => '${c.idx1}/${c.name}',
+        );
+        if (result != null && mounted) {
+          setState(() => _selCat = result);
+        }
+      },
     );
   }
 
@@ -717,15 +681,30 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
   // ── 税率 ──
   Widget _buildTaxRateCard(AppColorsExtension colors) {
     final currentLabel = '${_taxRate.toStringAsFixed(0)}%';
+    final labels = _taxLabels.toList();
     return _pickerCard(
       label: _l10n.get('taxRate'),
       required: false,
+      hasValue: true,
       currentLabel: currentLabel,
-      labels: _taxLabels.toList(),
-      colors: colors,
-      onSelected: (idx) => setState(() {
-        _taxRate = _taxOptions[idx].toDouble();
-      }),
+      onTap: () {
+        FocusManager.instance.primaryFocus?.unfocus();
+        TDPicker.showMultiPicker(
+          context,
+          title: _l10n.get('taxRate'),
+          backgroundColor: colors.bgCard,
+          data: [labels],
+          onConfirm: (selected) {
+            if (selected.isNotEmpty && selected[0] is int) {
+              final idx = selected[0] as int;
+              if (idx >= 0 && idx < labels.length) {
+                Navigator.of(context).pop();
+                setState(() => _taxRate = _taxOptions[idx].toDouble());
+              }
+            }
+          },
+        );
+      },
     );
   }
 
@@ -766,9 +745,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
   Widget _buildAeInfoCard(AppColorsExtension colors) {
     final d = widget.initialData!;
     final tdTheme = TDTheme.of(context);
-    final dateStr = d.aeDd.isNotEmpty
-        ? _formatDate(d.aeDd)
-        : d.aeDd;
+    final dateStr = d.aeDd.isNotEmpty ? _formatDate(d.aeDd) : d.aeDd;
     return Container(
       padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
       decoration: _cardDecoration(tdTheme),
@@ -821,21 +798,32 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
 
   // ── 申请人 ──
   Widget _buildEmployeeCard(AppColorsExtension colors) {
-    final employees = widget.employees;
     return _pickerCard(
       label: _l10n.get('applicant'),
       required: false,
+      hasValue: _selEmployee != null,
       currentLabel: _selEmployee != null
           ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
           : _l10n.get('pleaseSelect'),
-      labels: employees.map((e) => '${e.salNo}/${e.name}').toList(),
-      colors: colors,
-      onSelected: (idx) => setState(() {
-        _selEmployee = employees[idx];
-        _bankNameCtrl.text = _selEmployee!.bnkNo;
-        _bankAccountNameCtrl.text = _selEmployee!.accName;
-        _bankAccountCtrl.text = _selEmployee!.bnkId;
-      }),
+      onTap: () async {
+        final result = await showSearchablePicker<EmployeeItem>(
+          context,
+          title: '${_l10n.get('select')}${_l10n.get('applicant')}',
+          searchHint: _l10n.get('search'),
+          loader: (keyword, page) =>
+              widget.api.getEmployees(keyword: keyword, page: page, size: 20),
+          labelBuilder: (e) =>
+              e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
+        );
+        if (result != null && mounted) {
+          setState(() {
+            _selEmployee = result;
+            _bankNameCtrl.text = result.bnkNo;
+            _bankAccountNameCtrl.text = result.accName;
+            _bankAccountCtrl.text = result.bnkId;
+          });
+        }
+      },
       onClear: _selEmployee != null
           ? () => setState(() {
               _selEmployee = null;
@@ -849,16 +837,27 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
 
   // ── 客户/厂商 ──
   Widget _buildCustomerCard(AppColorsExtension colors) {
-    final vendors = widget.customers;
     return _pickerCard(
       label: _l10n.get('customerVendor'),
       required: false,
+      hasValue: _selCustomer != null,
       currentLabel: _selCustomer != null
-          ? '${_selCustomer!.id}/${_selCustomer!.name}'
+          ? '${_selCustomer!.cusNo}/${_selCustomer!.name}'
           : _l10n.get('pleaseSelect'),
-      labels: vendors.map((v) => '${v.id}/${v.name}').toList(),
-      colors: colors,
-      onSelected: (idx) => setState(() => _selCustomer = vendors[idx]),
+      onTap: () async {
+        final result = await showSearchablePicker<CustomerItem>(
+          context,
+          title: '${_l10n.get('select')}${_l10n.get('customerVendor')}',
+          searchHint: _l10n.get('search'),
+          loader: (keyword, page) =>
+              widget.api.getCustomers(keyword: keyword, page: page, size: 20),
+          labelBuilder: (v) =>
+              v.name.isEmpty ? v.cusNo : '${v.cusNo} ${v.name}',
+        );
+        if (result != null && mounted) {
+          setState(() => _selCustomer = result);
+        }
+      },
       onClear: _selCustomer != null
           ? () => setState(() => _selCustomer = null)
           : null,
@@ -983,11 +982,11 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
 
   // ── 会计科目(只读,选择类别后自动带出) ──
   Widget _buildAcctSubjectCard(AppColorsExtension colors) {
-    final id = _selCat.acctSubjectId.isNotEmpty
-        ? _selCat.acctSubjectId
+    final id = _selCat.accNo.isNotEmpty
+        ? _selCat.accNo
         : widget.initialData?.acctSubjectId ?? '';
-    final name = _selCat.acctSubjectName.isNotEmpty
-        ? _selCat.acctSubjectName
+    final name = _selCat.accName.isNotEmpty
+        ? _selCat.accName
         : widget.initialData?.acctSubjectName ?? '';
     return _readOnlyCard(
       label: _l10n.get('acctSubject'),
@@ -1038,16 +1037,27 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
 
   // ── 关联项目 ──
   Widget _buildProjectCard(AppColorsExtension colors) {
-    final projects = widget.projects;
     return _pickerCard(
       label: _l10n.get('relatedProject'),
       required: false,
+      hasValue: _selProject != null,
       currentLabel: _selProject != null
-          ? '${_selProject!.id}/${_selProject!.name}'
+          ? '${_selProject!.objNo}/${_selProject!.name}'
           : _l10n.get('pleaseSelect'),
-      labels: projects.map((p) => '${p.id}/${p.name}').toList(),
-      colors: colors,
-      onSelected: (idx) => setState(() => _selProject = projects[idx]),
+      onTap: () async {
+        final result = await showSearchablePicker<ProjectCodeItem>(
+          context,
+          title: '${_l10n.get('select')}${_l10n.get('relatedProject')}',
+          searchHint: _l10n.get('search'),
+          loader: (keyword, page) => widget.api
+              .getProjectCodes(keyword: keyword, page: page, size: 20)
+              .then((list) => list.cast<ProjectCodeItem>()),
+          labelBuilder: (p) => '${p.objNo}/${p.name}',
+        );
+        if (result != null && mounted) {
+          setState(() => _selProject = result);
+        }
+      },
       onClear: _selProject != null
           ? () => setState(() => _selProject = null)
           : null,
@@ -1056,16 +1066,26 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
 
   // ── 费用承担部门 ──
   Widget _buildCostDeptCard(AppColorsExtension colors) {
-    final depts = widget.costDepts;
     return _pickerCard(
       label: _l10n.get('costDept'),
       required: false,
+      hasValue: _selDept != null,
       currentLabel: _selDept != null
-          ? '${_selDept!.id}/${_selDept!.name}'
+          ? '${_selDept!.dep}/${_selDept!.name}'
           : _l10n.get('pleaseSelect'),
-      labels: depts.map((d) => '${d.id}/${d.name}').toList(),
-      colors: colors,
-      onSelected: (idx) => setState(() => _selDept = depts[idx]),
+      onTap: () async {
+        final result = await showSearchablePicker<DepartmentItem>(
+          context,
+          title: '${_l10n.get('select')}${_l10n.get('costDept')}',
+          searchHint: _l10n.get('search'),
+          loader: (keyword, page) =>
+              widget.api.getDepartments(keyword: keyword, page: page, size: 20),
+          labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
+        );
+        if (result != null && mounted) {
+          setState(() => _selDept = result);
+        }
+      },
       onClear: _selDept != null ? () => setState(() => _selDept = null) : null,
     );
   }

+ 1 - 0
lib/features/expense/widgets/expense_detail_view_dialog.dart

@@ -57,6 +57,7 @@ class ExpenseDetailViewDialog extends StatelessWidget {
               padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
               child: Row(
                 children: [
+                  const SizedBox(width: 24),
                   Expanded(child: Center(child: Text(l10n.get('expenseDetails'), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)))),
                   GestureDetector(
                     onTap: () => Navigator.pop(context),

+ 60 - 8
lib/features/expense_apply/expense_apply_api.dart

@@ -35,6 +35,26 @@ class CostTypeItem {
   );
 }
 
+class CostProjectItem {
+  final String idx1;
+  final String name;
+  final String accNo;
+  final String accName;
+  const CostProjectItem({
+    required this.idx1,
+    required this.name,
+    required this.accNo,
+    required this.accName,
+  });
+  factory CostProjectItem.fromJson(Map<String, dynamic> json) =>
+      CostProjectItem(
+        idx1: json['idx1'] as String? ?? '',
+        name: json['name'] as String? ?? '',
+        accNo: json['accNo'] as String? ?? '',
+        accName: json['accName'] as String? ?? '',
+      );
+}
+
 class ProjectCodeItem {
   final String objNo;
   final String name;
@@ -100,14 +120,16 @@ class ExpenseApplyApi {
   Future<List<CostTypeItem>> getCostTypes({
     String keyword = '',
     String accNo = '',
+    int page = 1,
+    int size = 100,
   }) async {
     final response = await _client.get<Map<String, dynamic>>(
       '/OA/GetCostTypes',
       queryParameters: {
         'keyword': keyword,
         'accNo': accNo,
-        'page': 1,
-        'size': 100,
+        'page': page,
+        'size': size,
       },
     );
     final list = (response.data?['list'] as List<dynamic>?) ?? [];
@@ -116,18 +138,44 @@ class ExpenseApplyApi {
         .toList();
   }
 
+  /// 费用项目字典(分页)
+  Future<List<CostProjectItem>> getCostProjects({
+    String keyword = '',
+    String accNo = '',
+    bool onlyActive = true,
+    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();
+  }
+
   /// 项目代号
   Future<List<ProjectCodeItem>> getProjectCodes({
     String keyword = '',
     String billDate = '',
+    int page = 1,
+    int size = 100,
   }) async {
     final response = await _client.get<Map<String, dynamic>>(
       '/OA/GetProjectCodes',
       queryParameters: {
         'keyword': keyword,
         'billDate': billDate,
-        'page': 1,
-        'size': 100,
+        'page': page,
+        'size': size,
       },
     );
     final list = (response.data?['list'] as List<dynamic>?) ?? [];
@@ -140,14 +188,16 @@ class ExpenseApplyApi {
   Future<List<DepartmentItem>> getDepartments({
     String keyword = '',
     bool onlyActive = true,
+    int page = 1,
+    int size = 100,
   }) async {
     final response = await _client.get<Map<String, dynamic>>(
       '/OA/GetDepartments',
       queryParameters: {
         'keyword': keyword,
         'onlyActive': onlyActive,
-        'page': 1,
-        'size': 100,
+        'page': page,
+        'size': size,
       },
     );
     final list = (response.data?['list'] as List<dynamic>?) ?? [];
@@ -365,14 +415,16 @@ class ExpenseApplyApi {
   Future<List<EmployeeItem>> getEmployees({
     String keyword = '',
     String salNo = '',
+    int page = 1,
+    int size = 100,
   }) async {
     final response = await _client.get<Map<String, dynamic>>(
       '/OA/GetEmployees',
       queryParameters: {
         'keyword': keyword,
         'salNo': salNo,
-        'page': 1,
-        'size': 100,
+        'page': page,
+        'size': size,
       },
     );
     final list = (response.data?['list'] as List<dynamic>?) ?? [];

+ 50 - 118
lib/features/expense_apply/expense_apply_create_page.dart

@@ -12,6 +12,7 @@ import 'package:dio/dio.dart';
 import '../../core/network/api_exception.dart';
 import '../../shared/widgets/action_bar.dart';
 import '../../shared/widgets/loading_dialog.dart';
+import '../../shared/widgets/searchable_picker_sheet.dart';
 import '../../shared/models/bill_file_rights.dart';
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
@@ -21,7 +22,6 @@ import '../../shared/widgets/attachment_picker.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors_extension.dart';
 import '../../core/constants/enums.dart';
-import '../../core/data/mock_api_data.dart';
 import 'expense_apply_api.dart';
 import 'widgets/expense_apply_detail_dialog.dart';
 
@@ -56,17 +56,14 @@ class _ExpenseApplyCreatePageState
   late final AttachmentPickerController _attachmentController;
   bool _attachAvailable = false;
   BillFileRights _billFileRights = BillFileRights.none;
+  EmployeeItem? _currentUserAsEmployee;
 
   // ── 草稿 ──
   late Future<bool> _draftFuture;
   bool _draftHandled = false;
 
   // ── 参考数据(从 API 加载) ──
-  List<CostTypeItem> _costTypes = [];
-  List<ProjectCodeItem> _projects = [];
   List<DepartmentItem> _departments = [];
-  List<EmployeeItem> _employees = [];
-  EmployeeItem? _selEmployee;
   bool _firstBuild = true;
   bool _refDataLoading = true;
   bool _addingDetail = false;
@@ -91,8 +88,6 @@ class _ExpenseApplyCreatePageState
     _checkBillFileRights();
     _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
-    _costTypes = [];
-    _projects = [];
     _departments = [];
     _acctTree = null;
     _refDataLoading = true;
@@ -129,22 +124,19 @@ class _ExpenseApplyCreatePageState
     try {
       final api = ref.read(expenseApplyApiProvider);
       final results = await Future.wait([
-        api.getCostTypes(),
-        api.getProjectCodes(),
         api.getDepartments(),
         api.getAcctSubjects(),
-        api.getEmployees(),
+        api.getEmployees(salNo: HostAppChannel.usr, size: 1),
       ]);
       if (!mounted) return;
+      final employeeCheck = results[2] as List<EmployeeItem>;
       setState(() {
-        _costTypes = results[0] as List<CostTypeItem>;
-        _projects = results[1] as List<ProjectCodeItem>;
-        _departments = results[2] as List<DepartmentItem>;
-        _acctTree = _convertAcctTree(results[3]);
-        _employees = results[4] as List<EmployeeItem>;
+        _departments = results[0] as List<DepartmentItem>;
+        _acctTree = _convertAcctTree(results[1]);
+        _currentUserAsEmployee =
+            employeeCheck.isNotEmpty ? employeeCheck.first : null;
         _refDataLoading = false;
         _autoSelectDept();
-        _autoSelectEmployee();
       });
       completer.complete();
     } catch (_) {
@@ -171,36 +163,6 @@ class _ExpenseApplyCreatePageState
     }
   }
 
-  void _autoSelectEmployee() {
-    if (_selEmployee != null) return;
-    final usr = HostAppChannel.usr;
-    if (usr.isEmpty || _employees.isEmpty) return;
-    final match = _employees.where((e) => e.salNo == usr);
-    if (match.isNotEmpty) {
-      setState(() => _selEmployee = match.first);
-    }
-  }
-
-  void _showEmployeePicker() {
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final labels = _employees.map((e) => '${e.salNo}/${e.name}').toList();
-    TDPicker.showMultiPicker(
-      context,
-      title: AppLocalizations.of(context).get('applicant'),
-      backgroundColor: colors.bgCard,
-      data: [labels],
-      onConfirm: (selected) {
-        if (selected.isNotEmpty && selected[0] is int) {
-          final idx = selected[0] as int;
-          if (idx >= 0 && idx < labels.length) {
-            Navigator.of(context).pop();
-            setState(() => _selEmployee = _employees[idx]);
-          }
-        }
-      },
-    );
-  }
-
   void _ensureVisible(FocusNode node) {
     if (!node.hasFocus) return;
     WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -330,6 +292,8 @@ class _ExpenseApplyCreatePageState
               endDate: m['endDate'] as String? ?? '',
               estimatedAmount: (m['estimatedAmount'] as num?)?.toDouble() ?? 0,
               remark: m['remark'] as String? ?? '',
+              sqMan: m['sqMan'] as String? ?? '',
+              sqManName: m['sqManName'] as String? ?? '',
             ),
           );
         }
@@ -421,15 +385,6 @@ class _ExpenseApplyCreatePageState
         ),
         const SizedBox(height: 16),
         FormFieldRow(
-          label: l10n.get('applicant'),
-          value: _selEmployee != null
-              ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
-              : '',
-          hint: l10n.get('pleaseSelect'),
-          onTap: _employees.isNotEmpty ? _showEmployeePicker : null,
-        ),
-        const SizedBox(height: 16),
-        FormFieldRow(
           label: l10n.get('applyDept'),
           value: _selectedDeptId.isNotEmpty
               ? '$_selectedDeptId/$_selectedDeptName'
@@ -660,6 +615,18 @@ class _ExpenseApplyCreatePageState
                               ),
                             ),
                           ],
+                          if (d.sqMan.isNotEmpty) ...[
+                            const SizedBox(height: 4),
+                            Text(
+                              '${l10n.get('applicant')}: ${d.sqMan}${d.sqManName.isNotEmpty ? '/${d.sqManName}' : ''}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          ],
                           if (d.projectId.isNotEmpty &&
                               d.projectName.isNotEmpty) ...[
                             const SizedBox(height: 4),
@@ -763,14 +730,6 @@ class _ExpenseApplyCreatePageState
     _addingDetail = true;
     try {
       final l10n = AppLocalizations.of(context);
-      if (_costTypes.isEmpty) {
-        await _loadRefData(showLoading: true);
-        if (!mounted) return;
-        if (_costTypes.isEmpty) {
-          TDToast.showText(l10n.get('noCostTypeData'), context: context);
-          return;
-        }
-      }
       ExpenseDetailData? initialData;
       if (editIndex != null) {
         final d = _details[editIndex];
@@ -788,18 +747,20 @@ class _ExpenseApplyCreatePageState
           endDate: d.endDate,
           estimatedAmount: d.estimatedAmount,
           remark: d.remark,
+          sqMan: d.sqMan,
+          sqManName: d.sqManName,
         );
       }
       FocusManager.instance.primaryFocus?.unfocus();
       final result = await ExpenseApplyDetailDialog.show(
         // ignore: use_build_context_synchronously
         context,
-        categories: _dialogCategories,
-        projects: _dialogProjects,
-        costDepts: _dialogCostDepts,
+        api: ref.read(expenseApplyApiProvider),
         l10n: l10n,
         acctTree: _acctTree,
         initialData: initialData,
+        defaultSqMan: _currentUserAsEmployee?.salNo,
+        defaultSqManName: _currentUserAsEmployee?.name,
       );
       if (result != null && mounted) {
         setState(() {
@@ -818,6 +779,8 @@ class _ExpenseApplyCreatePageState
             endDate: result.endDate,
             estimatedAmount: result.estimatedAmount,
             remark: result.remark,
+            sqMan: result.sqMan,
+            sqManName: result.sqManName,
           );
           if (editIndex != null) {
             _details[editIndex] = item;
@@ -933,58 +896,26 @@ class _ExpenseApplyCreatePageState
     );
   }
 
-  void _showDeptPicker() {
-    if (_departments.isEmpty) {
-      TDToast.showText(
-        AppLocalizations.of(context).get('noData'),
-        context: context,
-      );
-      return;
-    }
-    final l10n = AppLocalizations.of(context);
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final labels = _departments.map((d) => '${d.dep}/${d.name}').toList();
+  Future<void> _showDeptPicker() async {
     FocusManager.instance.primaryFocus?.unfocus();
-    TDPicker.showMultiPicker(
+    final l10n = AppLocalizations.of(context);
+    final api = ref.read(expenseApplyApiProvider);
+    final result = await showSearchablePicker<DepartmentItem>(
       context,
-      title: l10n.get('applyDept'),
-      backgroundColor: colors.bgCard,
-      data: [labels],
-      onConfirm: (selected) {
-        if (selected.isNotEmpty && selected[0] is int) {
-          final idx = selected[0] as int;
-          if (idx >= 0 && idx < labels.length) {
-            Navigator.of(context).pop();
-            setState(() {
-              _selectedDeptId = _departments[idx].dep;
-              _selectedDeptName = _departments[idx].name;
-            });
-          }
-        }
-      },
+      title: '${l10n.get('select')}${l10n.get('applyDept')}',
+      searchHint: l10n.get('search'),
+      loader: (keyword, page) =>
+          api.getDepartments(keyword: keyword, page: page, size: 20),
+      labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
     );
+    if (result != null && mounted) {
+      setState(() {
+        _selectedDeptId = result.dep;
+        _selectedDeptName = result.name;
+      });
+    }
   }
 
-  // ═══ API 数据 → 弹窗类型转换 ═══
-
-  List<CostCategory> get _dialogCategories => _costTypes
-      .map(
-        (c) => CostCategory(
-          code: c.typeNo,
-          nameKey: c.typeName,
-          acctSubjectId: c.accNo,
-          acctSubjectName: c.accName,
-        ),
-      )
-      .toList();
-
-  List<Project> get _dialogProjects => _projects
-      .map((p) => Project(id: int.tryParse(p.objNo) ?? 0, name: p.name))
-      .toList();
-
-  List<CostDept> get _dialogCostDepts =>
-      _departments.map((d) => CostDept(id: d.dep, name: d.name)).toList();
-
   // ═══ 4. 底部操作栏 ═══
   Widget _buildBottomBar(AppLocalizations l10n) {
     return ActionBar(
@@ -1045,10 +976,7 @@ class _ExpenseApplyCreatePageState
           await DraftStorage.delete(_draftKey);
           if (mounted) {
             LoadingDialog.hide(context);
-            TDToast.showSuccess(
-              l10n.get('submitSuccess'),
-              context: context,
-            );
+            TDToast.showSuccess(l10n.get('submitSuccess'), context: context);
             GoRouter.of(context).go('/expense-apply/list');
           }
         } catch (e) {
@@ -1112,7 +1040,7 @@ class _ExpenseApplyCreatePageState
       'BodyData1': _details.asMap().entries.map((e) {
         final d = e.value;
         return {
-          'SQ_MAN': _selEmployee?.salNo ?? '',
+          'SQ_MAN': d.sqMan,
           'TYPE_NO': d.category,
           'AMTN_YJ': d.estimatedAmount,
           'ACC_NO': d.acctSubjectId,
@@ -1312,6 +1240,8 @@ class _DetailItem {
   final String endDate;
   final double estimatedAmount;
   final String remark;
+  final String sqMan;
+  final String sqManName;
   const _DetailItem({
     required this.id,
     required this.category,
@@ -1327,5 +1257,7 @@ class _DetailItem {
     required this.endDate,
     required this.estimatedAmount,
     required this.remark,
+    this.sqMan = '',
+    this.sqManName = '',
   });
 }

+ 13 - 10
lib/features/expense_apply/expense_apply_detail_page.dart

@@ -300,16 +300,7 @@ class _ExpenseApplyDetailPageState
         ),
         const SizedBox(height: 16),
         FormFieldRow(
-          label: l10n.get('applicant'),
-          value: app.applicantId.isNotEmpty
-              ? '${app.applicantId}${app.applicantName.isNotEmpty ? '/${app.applicantName}' : ''}'
-              : '-',
-          readOnly: true,
-          showArrow: false,
-        ),
-        const SizedBox(height: 16),
-        FormFieldRow(
-          label: l10n.get('department'),
+          label: l10n.get('applyDept'),
           value: app.deptId.isNotEmpty
               ? '${app.deptId}${app.deptName.isNotEmpty ? '/${app.deptName}' : ''}'
               : '-',
@@ -433,6 +424,18 @@ class _ExpenseApplyDetailPageState
                               ),
                             ),
                           ],
+                          if (d.sqMan.isNotEmpty) ...[
+                            const SizedBox(height: 4),
+                            Text(
+                              '${l10n.get('applicant')}: ${d.sqMan}${d.sqName.isNotEmpty ? '/${d.sqName}' : ''}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          ],
                           if (d.projectId.isNotEmpty) ...[
                             const SizedBox(height: 4),
                             Text(

+ 43 - 114
lib/features/expense_apply/expense_apply_edit_page.dart

@@ -1,4 +1,4 @@
-import 'dart:async';
+import 'dart:async';
 
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
@@ -18,9 +18,9 @@ import '../../shared/widgets/nav_bar_config.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors_extension.dart';
 import '../../core/constants/enums.dart';
-import '../../core/data/mock_api_data.dart';
 import 'expense_apply_api.dart';
 import 'widgets/expense_apply_detail_dialog.dart';
+import '../../shared/widgets/searchable_picker_sheet.dart';
 
 class ExpenseApplyEditPage extends ConsumerStatefulWidget {
   final String billNo;
@@ -49,11 +49,6 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
   int _detailIdCounter = 1;
 
   // ── 参考数据(从 API 加载) ──
-  List<CostTypeItem> _costTypes = [];
-  List<ProjectCodeItem> _projects = [];
-  List<DepartmentItem> _departments = [];
-  List<EmployeeItem> _employees = [];
-  EmployeeItem? _selEmployee;
   bool _firstBuild = true;
   bool _refDataLoading = true;
   bool _loadingBill = true;
@@ -76,9 +71,6 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
     );
     _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
-    _costTypes = [];
-    _projects = [];
-    _departments = [];
     _acctTree = null;
     _refDataLoading = true;
     _loadingBill = true;
@@ -113,21 +105,12 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
     try {
       final api = ref.read(expenseApplyApiProvider);
       final results = await Future.wait([
-        api.getCostTypes(),
-        api.getProjectCodes(),
-        api.getDepartments(),
         api.getAcctSubjects(),
-        api.getEmployees(),
       ]);
       if (!mounted) return;
       setState(() {
-        _costTypes = results[0] as List<CostTypeItem>;
-        _projects = results[1] as List<ProjectCodeItem>;
-        _departments = results[2] as List<DepartmentItem>;
-        _acctTree = _convertAcctTree(results[3]);
-        _employees = results[4] as List<EmployeeItem>;
+        _acctTree = _convertAcctTree(results[0]);
         _refDataLoading = false;
-        _autoSelectEmployee();
       });
       completer.complete();
     } catch (_) {
@@ -143,36 +126,6 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
     }
   }
 
-  void _autoSelectEmployee() {
-    if (_selEmployee != null) return;
-    final usr = HostAppChannel.usr;
-    if (usr.isEmpty || _employees.isEmpty) return;
-    final match = _employees.where((e) => e.salNo == usr);
-    if (match.isNotEmpty) {
-      setState(() => _selEmployee = match.first);
-    }
-  }
-
-  void _showEmployeePicker() {
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final labels = _employees.map((e) => '${e.salNo}/${e.name}').toList();
-    TDPicker.showMultiPicker(
-      context,
-      title: AppLocalizations.of(context).get('applicant'),
-      backgroundColor: colors.bgCard,
-      data: [labels],
-      onConfirm: (selected) {
-        if (selected.isNotEmpty && selected[0] is int) {
-          final idx = selected[0] as int;
-          if (idx >= 0 && idx < labels.length) {
-            Navigator.of(context).pop();
-            setState(() => _selEmployee = _employees[idx]);
-          }
-        }
-      },
-    );
-  }
-
   Future<void> _loadBillData() async {
     try {
       final api = ref.read(expenseApplyApiProvider);
@@ -235,6 +188,8 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
             estimatedAmount: d.estimatedAmount,
             remark: d.remark,
             preItm: d.preItm,
+            sqMan: d.sqMan,
+            sqManName: d.sqName,
           ));
         }
 
@@ -377,15 +332,6 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
         ),
         const SizedBox(height: 16),
         FormFieldRow(
-          label: l10n.get('applicant'),
-          value: _selEmployee != null
-              ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
-              : '',
-          hint: l10n.get('pleaseSelect'),
-          onTap: _employees.isNotEmpty ? _showEmployeePicker : null,
-        ),
-        const SizedBox(height: 16),
-        FormFieldRow(
           label: l10n.get('applyDept'),
           value: _selectedDeptId.isNotEmpty
               ? '$_selectedDeptId/$_selectedDeptName'
@@ -591,6 +537,18 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
                               ),
                             ),
                           ],
+                          if (d.sqMan.isNotEmpty) ...[
+                            const SizedBox(height: 4),
+                            Text(
+                              '${l10n.get('applicant')}: ${d.sqMan}${d.sqManName.isNotEmpty ? '/${d.sqManName}' : ''}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          ],
                           if (d.projectId.isNotEmpty) ...[
                             const SizedBox(height: 4),
                             Text(
@@ -692,14 +650,7 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
     _addingDetail = true;
     try {
       final l10n = AppLocalizations.of(context);
-      if (_costTypes.isEmpty) {
-        await _loadRefData(showLoading: true);
-        if (!mounted) return;
-        if (_costTypes.isEmpty) {
-          TDToast.showText(l10n.get('noCostTypeData'), context: context);
-          return;
-        }
-      }
+
       ExpenseDetailData? initialData;
       if (editIndex != null) {
         final d = _details[editIndex];
@@ -717,15 +668,15 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
           endDate: d.endDate,
           estimatedAmount: d.estimatedAmount,
           remark: d.remark,
+          sqMan: d.sqMan,
+          sqManName: d.sqManName,
         );
       }
       FocusManager.instance.primaryFocus?.unfocus();
       final result = await ExpenseApplyDetailDialog.show(
         // ignore: use_build_context_synchronously
         context,
-        categories: _dialogCategories,
-        projects: _dialogProjects,
-        costDepts: _dialogCostDepts,
+        api: ref.read(expenseApplyApiProvider),
         l10n: l10n,
         acctTree: _acctTree,
         initialData: initialData,
@@ -748,6 +699,8 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
             estimatedAmount: result.estimatedAmount,
             remark: result.remark,
             preItm: editIndex != null ? _details[editIndex].preItm : null,
+            sqMan: result.sqMan,
+            sqManName: result.sqManName,
           );
           if (editIndex != null) {
             _details[editIndex] = item;
@@ -774,23 +727,7 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
     }).toList();
   }
 
-  List<CostCategory> get _dialogCategories => _costTypes
-      .map(
-        (c) => CostCategory(
-          code: c.typeNo,
-          nameKey: c.typeName,
-          acctSubjectId: c.accNo,
-          acctSubjectName: c.accName,
-        ),
-      )
-      .toList();
 
-  List<Project> get _dialogProjects => _projects
-      .map((p) => Project(id: int.tryParse(p.objNo) ?? 0, name: p.name))
-      .toList();
-
-  List<CostDept> get _dialogCostDepts =>
-      _departments.map((d) => CostDept(id: d.dep, name: d.name)).toList();
 
   // ═══ 3. 底部操作栏 ═══
   Widget _buildBottomBar(AppLocalizations l10n) {
@@ -882,7 +819,7 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
         final d = e.value;
         final item = <String, dynamic>{
           'AE_NO': _billNo,
-          'SQ_MAN': _selEmployee?.salNo ?? '',
+          'SQ_MAN': d.sqMan,
           'TYPE_NO': d.category,
           'AMTN_YJ': d.estimatedAmount,
           'ACC_NO': d.acctSubjectId,
@@ -923,36 +860,24 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
     }
   }
 
-  void _showDeptPicker() {
-    if (_departments.isEmpty) {
-      TDToast.showText(
-        AppLocalizations.of(context).get('noData'),
-        context: context,
-      );
-      return;
-    }
-    final l10n = AppLocalizations.of(context);
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final labels = _departments.map((d) => '${d.dep}/${d.name}').toList();
+  Future<void> _showDeptPicker() async {
     FocusManager.instance.primaryFocus?.unfocus();
-    TDPicker.showMultiPicker(
+    final l10n = AppLocalizations.of(context);
+    final api = ref.read(expenseApplyApiProvider);
+    final result = await showSearchablePicker<DepartmentItem>(
       context,
-      title: l10n.get('applyDept'),
-      backgroundColor: colors.bgCard,
-      data: [labels],
-      onConfirm: (selected) {
-        if (selected.isNotEmpty && selected[0] is int) {
-          final idx = selected[0] as int;
-          if (idx >= 0 && idx < labels.length) {
-            Navigator.of(context).pop();
-            setState(() {
-              _selectedDeptId = _departments[idx].dep;
-              _selectedDeptName = _departments[idx].name;
-            });
-          }
-        }
-      },
+      title: '${l10n.get('select')}${l10n.get('applyDept')}',
+      searchHint: l10n.get('search'),
+      loader: (keyword, page) =>
+          api.getDepartments(keyword: keyword, page: page, size: 20),
+      labelBuilder: (d) => d.name.isEmpty ? d.dep : '${d.dep} ${d.name}',
     );
+    if (result != null && mounted) {
+      setState(() {
+        _selectedDeptId = result.dep;
+        _selectedDeptName = result.name;
+      });
+    }
   }
 
   Widget _buildPageFooter() {
@@ -1030,6 +955,8 @@ class _DetailItem {
   final double estimatedAmount;
   final String remark;
   final int? preItm;
+  final String sqMan;
+  final String sqManName;
   const _DetailItem({
     required this.id,
     required this.category,
@@ -1046,5 +973,7 @@ class _DetailItem {
     required this.estimatedAmount,
     required this.remark,
     this.preItm,
+    this.sqMan = '',
+    this.sqManName = '',
   });
 }

+ 366 - 196
lib/features/expense_apply/widgets/expense_apply_detail_dialog.dart

@@ -1,4 +1,4 @@
-// ignore_for_file: use_build_context_synchronously
+// ignore_for_file: use_build_context_synchronously
 
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
@@ -6,7 +6,8 @@ import 'package:tdesign_flutter/tdesign_flutter.dart';
 import '../../../core/i18n/app_localizations.dart';
 import '../../../core/theme/app_colors.dart';
 import '../../../core/theme/app_colors_extension.dart';
-import '../../../core/data/mock_api_data.dart';
+import '../../../shared/widgets/searchable_picker_sheet.dart';
+import '../expense_apply_api.dart';
 
 /// 费用明细输入数据。
 class ExpenseDetailData {
@@ -23,6 +24,8 @@ class ExpenseDetailData {
   final String endDate;
   final double estimatedAmount;
   final String remark;
+  final String sqMan;
+  final String sqManName;
 
   const ExpenseDetailData({
     required this.category,
@@ -38,39 +41,42 @@ class ExpenseDetailData {
     required this.endDate,
     required this.estimatedAmount,
     required this.remark,
+    this.sqMan = '',
+    this.sqManName = '',
   });
 }
 
 /// 添加费用明细弹窗。
 ///
 /// 使用 [TDSlidePopupRoute] 从底部滑出,卡片化展示表单字段。
+/// 费用项目、关联项目、费用承担部门均从接口实时加载,无需预传数据。
 class ExpenseApplyDetailDialog extends StatefulWidget {
-  final List<CostCategory> categories;
-  final List<Project> projects;
-  final List<CostDept> costDepts;
+  final ExpenseApplyApi api;
   final AppLocalizations l10n;
   final ExpenseDetailData? initialData;
   final dynamic acctTree;
+  final String? defaultSqMan;
+  final String? defaultSqManName;
 
   const ExpenseApplyDetailDialog({
     super.key,
-    required this.categories,
-    required this.projects,
-    required this.costDepts,
+    required this.api,
     required this.l10n,
     required this.acctTree,
     this.initialData,
+    this.defaultSqMan,
+    this.defaultSqManName,
   });
 
   /// 显示弹窗,返回 [ExpenseDetailData] 或 `null`(取消时)。
   static Future<ExpenseDetailData?> show(
     BuildContext context, {
-    required List<CostCategory> categories,
-    required List<Project> projects,
-    required List<CostDept> costDepts,
+    required ExpenseApplyApi api,
     required AppLocalizations l10n,
     required dynamic acctTree,
     ExpenseDetailData? initialData,
+    String? defaultSqMan,
+    String? defaultSqManName,
   }) {
     FocusScope.of(context).unfocus();
     return Navigator.push<ExpenseDetailData>(
@@ -79,12 +85,12 @@ class ExpenseApplyDetailDialog extends StatefulWidget {
         slideTransitionFrom: SlideTransitionFrom.bottom,
         isDismissible: true,
         builder: (_) => ExpenseApplyDetailDialog(
-          categories: categories,
-          projects: projects,
-          costDepts: costDepts,
+          api: api,
           l10n: l10n,
           acctTree: acctTree,
           initialData: initialData,
+          defaultSqMan: defaultSqMan,
+          defaultSqManName: defaultSqManName,
         ),
       ),
     );
@@ -96,12 +102,14 @@ class ExpenseApplyDetailDialog extends StatefulWidget {
 }
 
 class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
-  // 费用类别
-  late CostCategory _selCat;
+  // 费用项目
+  late CostProjectItem _selCat;
   // 关联项目
-  Project? _selProject;
+  ProjectCodeItem? _selProject;
   // 费用承担部门
-  CostDept? _selDept;
+  DepartmentItem? _selDept;
+  // 申请人
+  EmployeeItem? _selEmployee;
   // 预计开始/结束日期
   String _startDate = '';
   String _endDate = '';
@@ -117,9 +125,6 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
   // 滚动控制
   final _scrollCtrl = ScrollController();
 
-  List<CostCategory> get _cats => widget.categories;
-  List<Project> get _projects => widget.projects;
-  List<CostDept> get _depts => widget.costDepts;
   AppLocalizations get _l10n => widget.l10n;
 
   bool get _isEdit => widget.initialData != null;
@@ -128,49 +133,23 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
   void initState() {
     super.initState();
     final d = widget.initialData;
-    if (_cats.isEmpty) {
-      // 费用类别无数据,弹窗无法使用
-      WidgetsBinding.instance.addPostFrameCallback((_) {
-        if (mounted) {
-          TDToast.showText(_l10n.get('noData'), context: context);
-          Navigator.pop(context);
-        }
-      });
-    }
-    _selCat = _cats.isNotEmpty
-        ? (d != null
-              ? _cats.firstWhere(
-                  (c) => c.code == d.category,
-                  orElse: () => _cats.first,
-                )
-              : _cats.first)
-        : const CostCategory(
-            code: '',
-            nameKey: '',
-            acctSubjectId: '',
-            acctSubjectName: '',
-          );
-    // 编辑时恢复已选的会计科目(可能和费用类别默认科目不同)
-    if (d != null && d.acctSubjectId.isNotEmpty) {
-      _selCat = CostCategory(
-        code: _selCat.code,
-        nameKey: _selCat.nameKey,
-        acctSubjectId: d.acctSubjectId,
-        acctSubjectName: d.acctSubjectName,
-      );
-    }
-    _selProject = (d != null && d.projectId.isNotEmpty && _projects.isNotEmpty)
-        ? _projects.firstWhere(
-            (p) => p.id.toString() == d.projectId,
-            orElse: () => _projects.first,
-          )
+    _selCat = CostProjectItem(
+      idx1: d?.category ?? '',
+      name: d?.categoryName ?? '',
+      accNo: d?.acctSubjectId ?? '',
+      accName: d?.acctSubjectName ?? '',
+    );
+    _selProject = (d != null && d.projectId.isNotEmpty)
+        ? ProjectCodeItem(objNo: d.projectId, name: d.projectName)
         : null;
-    _selDept = (d != null && d.costDeptId.isNotEmpty && _depts.isNotEmpty)
-        ? _depts.firstWhere(
-            (dept) => dept.id == d.costDeptId,
-            orElse: () => _depts.first,
-          )
+    _selDept = (d != null && d.costDeptId.isNotEmpty)
+        ? DepartmentItem(dep: d.costDeptId, name: d.costDeptName)
         : null;
+    _selEmployee = (d != null && d.sqMan.isNotEmpty)
+        ? EmployeeItem(salNo: d.sqMan, name: d.sqManName)
+        : (widget.defaultSqMan != null
+            ? EmployeeItem(salNo: widget.defaultSqMan!, name: widget.defaultSqManName ?? '')
+            : null);
     _startDate = d?.startDate ?? '';
     _endDate = d?.endDate ?? '';
     if (d != null) {
@@ -183,7 +162,6 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
     _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
     _amountFocus.addListener(() => _ensureVisible(_amountFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
-
   }
 
   void _ensureVisible(FocusNode node) {
@@ -228,6 +206,20 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
   }
 
   void _confirm() {
+    if (_selCat.idx1.isEmpty) {
+      TDToast.showText(
+        '${_l10n.get('pleaseSelect')}${_l10n.get('expenseCategory')}',
+        context: context,
+      );
+      return;
+    }
+    if (_selEmployee == null) {
+      TDToast.showText(
+        '${_l10n.get('pleaseSelect')}${_l10n.get('applicant')}',
+        context: context,
+      );
+      return;
+    }
     final amt = double.tryParse(_amountCtrl.text) ?? 0;
     if (amt <= 0) {
       TDToast.showText(_l10n.get('estimatedAmountPositive'), context: context);
@@ -242,15 +234,17 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
     Navigator.pop(
       context,
       ExpenseDetailData(
-        category: _selCat.code,
-        categoryName: _l10n.get(_selCat.nameKey),
-        acctSubjectId: _selCat.acctSubjectId,
-        acctSubjectName: _selCat.acctSubjectName,
+        category: _selCat.idx1,
+        categoryName: _selCat.name,
+        acctSubjectId: _selCat.accNo,
+        acctSubjectName: _selCat.accName,
         purpose: _purposeCtrl.text.trim(),
-        projectId: _selProject != null ? _selProject!.id.toString() : '',
+        projectId: _selProject != null ? _selProject!.objNo.toString() : '',
         projectName: _selProject?.name ?? '',
-        costDeptId: _selDept?.id ?? '',
+        costDeptId: _selDept?.dep ?? '',
         costDeptName: _selDept?.name ?? '',
+        sqMan: _selEmployee?.salNo ?? '',
+        sqManName: _selEmployee?.name ?? '',
         startDate: _startDate,
         endDate: _endDate,
         estimatedAmount: amt,
@@ -302,6 +296,8 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
                           const SizedBox(height: 12),
                           _buildAcctSubjectCard(colors),
                           const SizedBox(height: 12),
+                          _buildEmployeePicker(colors),
+                          const SizedBox(height: 12),
                           _buildAmountInput(colors),
                           const SizedBox(height: 12),
                           _buildProjectPicker(colors),
@@ -356,7 +352,7 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
           padding: const EdgeInsets.fromLTRB(20, 8, 12, 16),
           child: Row(
             children: [
-              const SizedBox(width: 28),
+              const SizedBox(width: 24),
               Expanded(
                 child: Center(
                   child: Text(
@@ -392,94 +388,137 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
     required String label,
     required bool required,
     required String currentLabel,
-    required List<String> labels,
-    required ValueChanged<int> onSelected,
+    required bool hasValue,
+    required VoidCallback onTap,
     VoidCallback? onClear,
   }) {
     final tdTheme = TDTheme.of(context);
-    final hasValue = onClear != null;
     return GestureDetector(
       onTap: () {
-        if (labels.isEmpty) {
-          TDToast.showText(_l10n.get('noData'), context: context);
-          return;
-        }
         FocusManager.instance.primaryFocus?.unfocus();
-        TDPicker.showMultiPicker(
-          context,
-          title: label,
-          data: [labels],
-          onConfirm: (selected) {
-            if (selected.isNotEmpty && selected[0] is int) {
-              final idx = selected[0] as int;
-              if (idx >= 0 && idx < labels.length) {
-                Navigator.of(context).pop();
-                onSelected(idx);
-              }
-            }
-          },
-        );
+        onTap();
       },
       child: Container(
-        padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12),
+        padding: const EdgeInsets.only(
+          left: 16,
+          right: 10,
+          top: 12,
+          bottom: 12,
+        ),
         decoration: BoxDecoration(
           color: tdTheme.bgColorContainer,
           borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
           border: Border.all(color: tdTheme.componentStrokeColor),
         ),
-        child: Row(children: [
-          TDText(label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
-          if (required)
-            Padding(
-              padding: const EdgeInsets.only(left: 4),
-              child: TDText('*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6)),
+        child: Row(
+          children: [
+            TDText(
+              label,
+              maxLines: 1,
+              overflow: TextOverflow.visible,
+              font: tdTheme.fontBodyLarge,
+              fontWeight: FontWeight.w400,
+              style: const TextStyle(letterSpacing: 0),
             ),
-          const SizedBox(width: 12),
-          Expanded(
-            child: Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [
-              Flexible(
-                child: TDText(currentLabel, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end),
+            if (required)
+              Padding(
+                padding: const EdgeInsets.only(left: 4),
+                child: TDText(
+                  '*',
+                  font: tdTheme.fontBodyLarge,
+                  fontWeight: FontWeight.w400,
+                  style: TextStyle(color: tdTheme.errorColor6),
+                ),
               ),
-              const SizedBox(width: 4),
-              SizedBox(
-                width: 18, height: 18,
-                child: hasValue
-                    ? GestureDetector(onTap: onClear, child: Icon(Icons.close, size: 18, color: tdTheme.textColorPlaceholder))
-                    : Icon(Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder),
+            const SizedBox(width: 12),
+            Expanded(
+              child: Row(
+                mainAxisAlignment: MainAxisAlignment.end,
+                mainAxisSize: MainAxisSize.max,
+                children: [
+                  Flexible(
+                    child: TDText(
+                      currentLabel,
+                      maxLines: 1,
+                      overflow: TextOverflow.ellipsis,
+                      font: tdTheme.fontBodyLarge,
+                      fontWeight: FontWeight.w400,
+                      textColor: hasValue
+                          ? tdTheme.textColorPrimary
+                          : tdTheme.textColorPlaceholder,
+                      textAlign: TextAlign.end,
+                    ),
+                  ),
+                  const SizedBox(width: 4),
+                  SizedBox(
+                    width: 18,
+                    height: 18,
+                    child: onClear != null
+                        ? GestureDetector(
+                            onTap: onClear,
+                            child: Icon(
+                              Icons.close,
+                              size: 18,
+                              color: tdTheme.textColorPlaceholder,
+                            ),
+                          )
+                        : Icon(
+                            Icons.chevron_right,
+                            size: 18,
+                            color: tdTheme.textColorPlaceholder,
+                          ),
+                  ),
+                ],
               ),
-            ]),
-          ),
-        ]),
+            ),
+          ],
+        ),
       ),
     );
   }
 
-  // ── 1. 费用类别 ──
+  // ── 1. 费用项目 ──
   Widget _buildCategoryPicker(AppColorsExtension colors) {
-    final labels = _cats.map((c) => '${c.code}/${_l10n.get(c.nameKey)}').toList();
     return _pickerCard(
       label: _l10n.get('expenseCategory'),
       required: true,
-      currentLabel: _selCat.code.isNotEmpty ? '${_selCat.code}/${_l10n.get(_selCat.nameKey)}' : _l10n.get('pleaseSelect'),
-      labels: labels,
-      onSelected: (i) => setState(() => _selCat = _cats[i]),
+      hasValue: _selCat.idx1.isNotEmpty,
+      currentLabel: _selCat.idx1.isNotEmpty
+          ? '${_selCat.idx1}/${_selCat.name}'
+          : _l10n.get('pleaseSelect'),
+      onTap: () async {
+        final result = await showSearchablePicker<CostProjectItem>(
+          context,
+          title: '${_l10n.get('select')}${_l10n.get('expenseCategory')}',
+          searchHint: _l10n.get('search'),
+          loader: (keyword, page) => widget.api
+              .getCostProjects(keyword: keyword, page: page, size: 20)
+              .then((list) => list.cast<CostProjectItem>()),
+          labelBuilder: (c) => '${c.idx1}/${c.name}',
+        );
+        if (result != null && mounted) {
+          setState(() => _selCat = result);
+        }
+      },
     );
   }
 
   // ── 2. 会计科目(级联选择器) ──
   Widget _buildAcctSubjectCard(AppColorsExtension colors) {
     final tdTheme = TDTheme.of(context);
-    final hasValue = _selCat.acctSubjectId.isNotEmpty;
+    final hasValue = _selCat.accNo.isNotEmpty;
     final displayValue = hasValue
-        ? (_selCat.acctSubjectName.startsWith(_selCat.acctSubjectId)
-            ? _selCat.acctSubjectName
-            : (_selCat.acctSubjectName.toString().isNotEmpty
-                ? '${_selCat.acctSubjectId}/${_selCat.acctSubjectName}'
-                : _selCat.acctSubjectId))
+        ? (_selCat.accName.startsWith(_selCat.accNo)
+              ? _selCat.accName
+              : (_selCat.accName.toString().isNotEmpty
+                    ? '${_selCat.accNo}/${_selCat.accName}'
+                    : _selCat.accNo))
         : _l10n.get('pleaseSelect');
     return GestureDetector(
       onTap: () {
-        debugPrint('[AcctSubject] onTap fired, acctTree isNull: ${widget.acctTree == null}');
+        debugPrint(
+          '[AcctSubject] onTap fired, acctTree isNull: ${widget.acctTree == null}',
+        );
         if (widget.acctTree == null) {
           TDToast.showText(_l10n.get('loading'), context: context);
           return;
@@ -490,67 +529,165 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
           title: _l10n.get('acctSubject'),
           data: widget.acctTree as List<Map>,
           theme: 'step',
-            onChange: (List<MultiCascaderListModel> selected) {
-              setState(() {
-                _selCat = CostCategory(
-                  code: _selCat.code,
-                  nameKey: _selCat.nameKey,
-                  acctSubjectId: (selected.last.value ?? '').replaceFirst('self:', ''),
-                  acctSubjectName: (selected.last.label ?? '').replaceFirst(RegExp(r'^> '), '').replaceFirst('${selected.last.value ?? ''}/', ''),
-                );
-              });
-            },
-          );
+          onChange: (List<MultiCascaderListModel> selected) {
+            setState(() {
+              _selCat = CostProjectItem(
+                idx1: _selCat.idx1,
+                name: _selCat.name,
+                accNo: (selected.last.value ?? '').replaceFirst('self:', ''),
+                accName: (selected.last.label ?? '')
+                    .replaceFirst(RegExp(r'^> '), '')
+                    .replaceFirst('${selected.last.value ?? ''}/', ''),
+              );
+            });
+          },
+        );
       },
       child: Container(
-        padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12),
+        padding: const EdgeInsets.only(
+          left: 16,
+          right: 10,
+          top: 12,
+          bottom: 12,
+        ),
         decoration: BoxDecoration(
           color: tdTheme.bgColorContainer,
           borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
           border: Border.all(color: tdTheme.componentStrokeColor),
         ),
-        child: Row(children: [
-          TDText(_l10n.get('acctSubject'), maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
-          const SizedBox(width: 12),
-          Expanded(
-            child: Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [
-              Flexible(child: TDText(displayValue, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: hasValue ? tdTheme.textColorPrimary : tdTheme.textColorPlaceholder, textAlign: TextAlign.end)),
-              const SizedBox(width: 4),
-              SizedBox(width: 18, height: 18, child: Icon(Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder)),
-            ]),
-          ),
-        ]),
+        child: Row(
+          children: [
+            TDText(
+              _l10n.get('acctSubject'),
+              maxLines: 1,
+              overflow: TextOverflow.visible,
+              font: tdTheme.fontBodyLarge,
+              fontWeight: FontWeight.w400,
+              style: const TextStyle(letterSpacing: 0),
+            ),
+            const SizedBox(width: 12),
+            Expanded(
+              child: Row(
+                mainAxisAlignment: MainAxisAlignment.end,
+                mainAxisSize: MainAxisSize.max,
+                children: [
+                  Flexible(
+                    child: TDText(
+                      displayValue,
+                      maxLines: 1,
+                      overflow: TextOverflow.ellipsis,
+                      font: tdTheme.fontBodyLarge,
+                      fontWeight: FontWeight.w400,
+                      textColor: hasValue
+                          ? tdTheme.textColorPrimary
+                          : tdTheme.textColorPlaceholder,
+                      textAlign: TextAlign.end,
+                    ),
+                  ),
+                  const SizedBox(width: 4),
+                  SizedBox(
+                    width: 18,
+                    height: 18,
+                    child: Icon(
+                      Icons.chevron_right,
+                      size: 18,
+                      color: tdTheme.textColorPlaceholder,
+                    ),
+                  ),
+                ],
+              ),
+            ),
+          ],
+        ),
       ),
     );
   }
 
-
   // ── 3. 关联项目 ──
   Widget _buildProjectPicker(AppColorsExtension colors) {
-    final labels = _projects.map((p) => '${p.id}/${p.name}').toList();
     return _pickerCard(
       label: _l10n.get('relatedProject'),
       required: false,
-      currentLabel: _selProject != null ? '${_selProject!.id}/${_selProject!.name}' : _l10n.get('pleaseSelect'),
-      labels: labels,
-      onSelected: (i) => setState(() => _selProject = _projects[i]),
-      onClear: _selProject != null ? () => setState(() => _selProject = null) : null,
+      hasValue: _selProject != null,
+      currentLabel: _selProject != null
+          ? '${_selProject!.objNo}/${_selProject!.name}'
+          : _l10n.get('pleaseSelect'),
+      onTap: () async {
+        final result = await showSearchablePicker<ProjectCodeItem>(
+          context,
+          title: '${_l10n.get('select')}${_l10n.get('relatedProject')}',
+          searchHint: _l10n.get('search'),
+          loader: (keyword, page) => widget.api
+              .getProjectCodes(keyword: keyword, page: page, size: 20)
+              .then((list) => list.cast<ProjectCodeItem>()),
+          labelBuilder: (p) => '${p.objNo}/${p.name}',
+        );
+        if (result != null && mounted) {
+          setState(() => _selProject = result);
+        }
+      },
+      onClear: _selProject != null
+          ? () => setState(() => _selProject = null)
+          : null,
     );
   }
 
   // ── 5. 费用承担部门 ──
   Widget _buildCostDeptPicker(AppColorsExtension colors) {
-    final labels = _depts.map((d) => '${d.id}/${d.name}').toList();
     return _pickerCard(
       label: _l10n.get('costDept'),
       required: false,
-      currentLabel: _selDept != null ? '${_selDept!.id}/${_selDept!.name}' : _l10n.get('pleaseSelect'),
-      labels: labels,
-      onSelected: (i) => setState(() => _selDept = _depts[i]),
+      hasValue: _selDept != null,
+      currentLabel: _selDept != null
+          ? '${_selDept!.dep}/${_selDept!.name}'
+          : _l10n.get('pleaseSelect'),
+      onTap: () async {
+        final result = await showSearchablePicker<DepartmentItem>(
+          context,
+          title: '${_l10n.get('select')}${_l10n.get('costDept')}',
+          searchHint: _l10n.get('search'),
+          loader: (keyword, page) => widget.api
+              .getDepartments(keyword: keyword, page: page, size: 20)
+              .then((list) => list.cast<DepartmentItem>()),
+          labelBuilder: (d) => '${d.dep}/${d.name}',
+        );
+        if (result != null && mounted) {
+          setState(() => _selDept = result);
+        }
+      },
       onClear: _selDept != null ? () => setState(() => _selDept = null) : null,
     );
   }
 
+  // ── 申请人 ──
+  Widget _buildEmployeePicker(AppColorsExtension colors) {
+    return _pickerCard(
+      label: _l10n.get('applicant'),
+      required: true,
+      hasValue: _selEmployee != null,
+      currentLabel: _selEmployee != null
+          ? '${_selEmployee!.salNo}/${_selEmployee!.name}'
+          : _l10n.get('pleaseSelect'),
+      onTap: () async {
+        final result = await showSearchablePicker<EmployeeItem>(
+          context,
+          title: '${_l10n.get('select')}${_l10n.get('applicant')}',
+          searchHint: _l10n.get('search'),
+          loader: (keyword, page) =>
+              widget.api.getEmployees(keyword: keyword, page: page, size: 20),
+          labelBuilder: (e) =>
+              e.name.isEmpty ? e.salNo : '${e.salNo} ${e.name}',
+        );
+        if (result != null && mounted) {
+          setState(() => _selEmployee = result);
+        }
+      },
+      onClear: _selEmployee != null
+          ? () => setState(() => _selEmployee = null)
+          : null,
+    );
+  }
+
   // ── 6. 预计开始日期 ──
   Widget _buildStartDatePicker(AppColorsExtension colors) {
     return _datePickerCard(
@@ -694,7 +831,9 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
       controller: _amountCtrl,
       hintText: '>0',
       keyboardType: const TextInputType.numberWithOptions(decimal: true),
-      inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$'))],
+      inputFormatters: [
+        FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
+      ],
       focusNode: _amountFocus,
     );
   }
@@ -767,48 +906,79 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
         borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
         border: Border.all(color: tdTheme.componentStrokeColor),
       ),
-      child: Row(children: [
-        TDText(label, maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
-        if (required)
-          Padding(
-            padding: const EdgeInsets.only(left: 4),
-            child: TDText('*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6)),
+      child: Row(
+        children: [
+          TDText(
+            label,
+            maxLines: 1,
+            overflow: TextOverflow.visible,
+            font: tdTheme.fontBodyLarge,
+            fontWeight: FontWeight.w400,
+            style: const TextStyle(letterSpacing: 0),
           ),
-        const SizedBox(width: 12),
-        Expanded(
-          child: Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [
-            Flexible(
-              child: TextField(
-                controller: controller,
-                focusNode: focusNode,
-                textAlign: TextAlign.end,
-                keyboardType: keyboardType,
-                inputFormatters: inputFormatters,
-                style: TextStyle(fontSize: 16, color: tdTheme.textColorPrimary),
-                decoration: InputDecoration(
-                  hintText: hintText,
-                  hintStyle: TextStyle(fontSize: 16, color: tdTheme.textColorPlaceholder),
-                  border: InputBorder.none,
-                  isDense: true,
-                  contentPadding: EdgeInsets.zero,
-                ),
-                onChanged: (_) => setState(() {}),
+          if (required)
+            Padding(
+              padding: const EdgeInsets.only(left: 4),
+              child: TDText(
+                '*',
+                font: tdTheme.fontBodyLarge,
+                fontWeight: FontWeight.w400,
+                style: TextStyle(color: tdTheme.errorColor6),
               ),
             ),
-            const SizedBox(width: 4),
-            SizedBox(
-              width: 18, height: 18,
-              child: hasValue
-                  ? GestureDetector(
-                      onTap: () { controller.clear(); setState(() {}); },
-                      child: Icon(Icons.close, size: 18, color: tdTheme.textColorPlaceholder),
-                    )
-                  : null,
+          const SizedBox(width: 12),
+          Expanded(
+            child: Row(
+              mainAxisAlignment: MainAxisAlignment.end,
+              mainAxisSize: MainAxisSize.max,
+              children: [
+                Flexible(
+                  child: TextField(
+                    controller: controller,
+                    focusNode: focusNode,
+                    textAlign: TextAlign.end,
+                    keyboardType: keyboardType,
+                    inputFormatters: inputFormatters,
+                    style: TextStyle(
+                      fontSize: 16,
+                      color: tdTheme.textColorPrimary,
+                    ),
+                    decoration: InputDecoration(
+                      hintText: hintText,
+                      hintStyle: TextStyle(
+                        fontSize: 16,
+                        color: tdTheme.textColorPlaceholder,
+                      ),
+                      border: InputBorder.none,
+                      isDense: true,
+                      contentPadding: EdgeInsets.zero,
+                    ),
+                    onChanged: (_) => setState(() {}),
+                  ),
+                ),
+                const SizedBox(width: 4),
+                SizedBox(
+                  width: 18,
+                  height: 18,
+                  child: hasValue
+                      ? GestureDetector(
+                          onTap: () {
+                            controller.clear();
+                            setState(() {});
+                          },
+                          child: Icon(
+                            Icons.close,
+                            size: 18,
+                            color: tdTheme.textColorPlaceholder,
+                          ),
+                        )
+                      : null,
+                ),
+              ],
             ),
-          ]),
-        ),
-      ]),
+          ),
+        ],
+      ),
     );
   }
-
 }

+ 92 - 32
lib/features/expense_apply/widgets/expense_apply_detail_view_dialog.dart

@@ -27,7 +27,8 @@ class ExpenseApplyDetailViewDialog extends StatelessWidget {
       TDSlidePopupRoute<void>(
         slideTransitionFrom: SlideTransitionFrom.bottom,
         isDismissible: true,
-        builder: (_) => ExpenseApplyDetailViewDialog(detail: detail, l10n: l10n),
+        builder: (_) =>
+            ExpenseApplyDetailViewDialog(detail: detail, l10n: l10n),
       ),
     );
   }
@@ -42,7 +43,9 @@ class ExpenseApplyDetailViewDialog extends StatelessWidget {
         : d.expenseCategory;
 
     return ConstrainedBox(
-      constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.8),
+      constraints: BoxConstraints(
+        maxHeight: MediaQuery.of(context).size.height * 0.8,
+      ),
       child: Container(
         decoration: BoxDecoration(
           color: colors.bgCard,
@@ -54,41 +57,95 @@ class ExpenseApplyDetailViewDialog extends StatelessWidget {
             mainAxisSize: MainAxisSize.min,
             children: [
               // 标题栏
-            Padding(
-              padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
-              child: Row(
-                children: [
-                  Expanded(child: Center(child: Text(l10n.get('expenseDetails'), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)))),
-                  GestureDetector(
-                    onTap: () => Navigator.pop(context),
-                    child: Icon(Icons.close, color: colors.textSecondary),
-                  ),
-                ],
-              ),
-            ),
-            // 内容
-            Flexible(
-              child: SingleChildScrollView(
-                padding: const EdgeInsets.all(20),
-                child: Column(
-                  crossAxisAlignment: CrossAxisAlignment.start,
+              Padding(
+                padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
+                child: Row(
                   children: [
-                    _row(l10n.get('expenseCategory'), catLabel, colors),
-                    _row(l10n.get('estimatedAmount'), '¥${d.estimatedAmount.toStringAsFixed(2)}', colors),
-                    _row(l10n.get('acctSubject'), d.acctSubjectId.isNotEmpty ? '${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/${d.acctSubjectName}' : ''}' : '-', colors),
-                    _row(l10n.get('project'), d.projectId.isNotEmpty ? '${d.projectId}${d.projectName.isNotEmpty ? '/${d.projectName}' : ''}' : '-', colors),
-                    _row(l10n.get('costDept'), d.costDeptId.isNotEmpty ? '${d.costDeptId}${d.costDeptName.isNotEmpty ? '/${d.costDeptName}' : ''}' : '-', colors),
-                    _row(l10n.get('estimatedDate'), d.estimatedStartDate != null ? '${du.DateUtils.formatDate(d.estimatedStartDate!)}${d.estimatedEndDate != null ? ' ~ ${du.DateUtils.formatDate(d.estimatedEndDate!)}' : ''}' : '-', colors),
-                    _row(l10n.get('expenseNo'), d.bxNo.isNotEmpty ? d.bxNo : '-', colors),
-                    _row(l10n.get('remark'), d.remark.isNotEmpty ? d.remark : '-', colors),
+                    const SizedBox(width: 24),
+                    Expanded(
+                      child: Center(
+                        child: Text(
+                          l10n.get('expenseDetails'),
+                          style: const TextStyle(
+                            fontSize: 18,
+                            fontWeight: FontWeight.w600,
+                          ),
+                        ),
+                      ),
+                    ),
+                    GestureDetector(
+                      onTap: () => Navigator.pop(context),
+                      child: Icon(Icons.close, color: colors.textSecondary),
+                    ),
                   ],
                 ),
               ),
-            ),
-          ],
+              // 内容
+              Flexible(
+                child: SingleChildScrollView(
+                  padding: const EdgeInsets.all(20),
+                  child: Column(
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: [
+                      _row(l10n.get('expenseCategory'), catLabel, colors),
+                      _row(
+                        l10n.get('estimatedAmount'),
+                        '¥${d.estimatedAmount.toStringAsFixed(2)}',
+                        colors,
+                      ),
+                      _row(
+                        l10n.get('acctSubject'),
+                        d.acctSubjectId.isNotEmpty
+                            ? '${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/${d.acctSubjectName}' : ''}'
+                            : '-',
+                        colors,
+                      ),
+                      _row(
+                        l10n.get('applicant'),
+                        d.sqMan.isNotEmpty
+                            ? '${d.sqMan}${d.sqName.isNotEmpty ? '/${d.sqName}' : ''}'
+                            : '-',
+                        colors,
+                      ),
+                      _row(
+                        l10n.get('project'),
+                        d.projectId.isNotEmpty
+                            ? '${d.projectId}${d.projectName.isNotEmpty ? '/${d.projectName}' : ''}'
+                            : '-',
+                        colors,
+                      ),
+                      _row(
+                        l10n.get('costDept'),
+                        d.costDeptId.isNotEmpty
+                            ? '${d.costDeptId}${d.costDeptName.isNotEmpty ? '/${d.costDeptName}' : ''}'
+                            : '-',
+                        colors,
+                      ),
+                      _row(
+                        l10n.get('estimatedDate'),
+                        d.estimatedStartDate != null
+                            ? '${du.DateUtils.formatDate(d.estimatedStartDate!)}${d.estimatedEndDate != null ? ' ~ ${du.DateUtils.formatDate(d.estimatedEndDate!)}' : ''}'
+                            : '-',
+                        colors,
+                      ),
+                      _row(
+                        l10n.get('expenseNo'),
+                        d.bxNo.isNotEmpty ? d.bxNo : '-',
+                        colors,
+                      ),
+                      _row(
+                        l10n.get('remark'),
+                        d.remark.isNotEmpty ? d.remark : '-',
+                        colors,
+                      ),
+                    ],
+                  ),
+                ),
+              ),
+            ],
+          ),
         ),
       ),
-      ),
     );
   }
 
@@ -100,7 +157,10 @@ class ExpenseApplyDetailViewDialog extends StatelessWidget {
         children: [
           SizedBox(
             width: 100,
-            child: Text(label, style: TextStyle(fontSize: 14, color: colors.textSecondary)),
+            child: Text(
+              label,
+              style: TextStyle(fontSize: 14, color: colors.textSecondary),
+            ),
           ),
           const SizedBox(width: 12),
           Expanded(child: Text(value, style: const TextStyle(fontSize: 14))),

+ 506 - 0
lib/shared/widgets/searchable_picker_sheet.dart

@@ -0,0 +1,506 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+import 'package:easy_refresh/easy_refresh.dart';
+import '../../core/i18n/app_localizations.dart';
+import '../../core/theme/app_colors_extension.dart';
+import 'list_footer.dart';
+
+/// 分页数据加载函数签名。
+typedef PickerLoader<T> = Future<List<T>> Function(String keyword, int page);
+
+/// 单选弹窗,返回选中项或 null。
+Future<T?> showSearchablePicker<T>(
+  BuildContext context, {
+  required String title,
+  required PickerLoader<T> loader,
+  required String Function(T item) labelBuilder,
+  String Function(T item)? subtitleBuilder,
+  T? initialSelected,
+  String? searchHint,
+}) {
+  return Navigator.push<T>(
+    context,
+    TDSlidePopupRoute<T>(
+      slideTransitionFrom: SlideTransitionFrom.bottom,
+      isDismissible: true,
+      builder: (_) => _SearchablePickerSheet<T>(
+        title: title,
+        multiSelect: false,
+        loader: loader,
+        labelBuilder: labelBuilder,
+        subtitleBuilder: subtitleBuilder,
+        initialSelected: initialSelected != null ? [initialSelected] : null,
+        searchHint: searchHint,
+      ),
+    ),
+  );
+}
+
+/// 多选弹窗,返回选中列表。
+Future<List<T>> showSearchableMultiPicker<T>(
+  BuildContext context, {
+  required String title,
+  required PickerLoader<T> loader,
+  required String Function(T item) labelBuilder,
+  String Function(T item)? subtitleBuilder,
+  List<T>? initialSelected,
+  String? searchHint,
+}) async {
+  final result = await Navigator.push<List<T>>(
+    context,
+    TDSlidePopupRoute<List<T>>(
+      slideTransitionFrom: SlideTransitionFrom.bottom,
+      isDismissible: false,
+      builder: (_) => _SearchablePickerSheet<T>(
+        title: title,
+        multiSelect: true,
+        loader: loader,
+        labelBuilder: labelBuilder,
+        subtitleBuilder: subtitleBuilder,
+        initialSelected: initialSelected,
+        searchHint: searchHint,
+      ),
+    ),
+  );
+  return result ?? [];
+}
+
+// ═══ 内部实现 ═══
+
+class _SearchablePickerSheet<T> extends StatefulWidget {
+  final String title;
+  final bool multiSelect;
+  final PickerLoader<T> loader;
+  final String Function(T item) labelBuilder;
+  final String Function(T item)? subtitleBuilder;
+  final List<T>? initialSelected;
+  final String? searchHint;
+
+  const _SearchablePickerSheet({
+    required this.title,
+    required this.multiSelect,
+    required this.loader,
+    required this.labelBuilder,
+    this.subtitleBuilder,
+    this.initialSelected,
+    this.searchHint,
+  });
+
+  @override
+  State<_SearchablePickerSheet<T>> createState() =>
+      _SearchablePickerSheetState<T>();
+}
+
+class _SearchablePickerSheetState<T> extends State<_SearchablePickerSheet<T>> {
+  final _scrollCtrl = ScrollController();
+
+  List<T> _items = [];
+  int _page = 1;
+  bool _loading = true;
+  bool _loadingMore = false;
+  bool _hasMore = true;
+  String _keyword = '';
+
+  final Set<T> _selected = {};
+
+  Timer? _debounce;
+
+  @override
+  void initState() {
+    super.initState();
+    if (widget.initialSelected != null) {
+      _selected.addAll(widget.initialSelected!);
+    }
+    _scrollCtrl.addListener(_onScroll);
+    _load();
+  }
+
+  @override
+  void dispose() {
+    _debounce?.cancel();
+    _scrollCtrl.dispose();
+    super.dispose();
+  }
+
+  void _onScroll() {
+    if (_scrollCtrl.position.pixels >=
+            _scrollCtrl.position.maxScrollExtent - 100 &&
+        !_loadingMore &&
+        _hasMore) {
+      _loadMore();
+    }
+  }
+
+  Future<void> _load({bool reset = false}) async {
+    if (reset) {
+      _page = 1;
+      _hasMore = true;
+    }
+    if (_page == 1) setState(() => _loading = true);
+    try {
+      final items = await widget.loader(_keyword, _page);
+      if (!mounted) return;
+      setState(() {
+        if (_page == 1) {
+          _items = items;
+        } else {
+          _items.addAll(items);
+        }
+        _hasMore = items.length >= 20;
+        _loading = false;
+        _loadingMore = false;
+      });
+    } catch (_) {
+      if (!mounted) return;
+      setState(() {
+        _loading = false;
+        _loadingMore = false;
+      });
+    }
+  }
+
+  Future<void> _loadMore() async {
+    if (_loadingMore || !_hasMore) return;
+    _page++;
+    setState(() => _loadingMore = true);
+    await _load();
+  }
+
+  void _onSearch(String value) {
+    _debounce?.cancel();
+    _debounce = Timer(const Duration(milliseconds: 300), () {
+      _keyword = value;
+      _page = 1;
+      _items = [];
+      _load(reset: true);
+    });
+  }
+
+  void _onItemTap(T item) {
+    setState(() {
+      if (widget.multiSelect) {
+        if (_selected.contains(item)) {
+          _selected.remove(item);
+        } else {
+          _selected.add(item);
+        }
+      } else {
+        // 单选:仅记录选中项,点击「确定」才提交
+        _selected
+          ..clear()
+          ..add(item);
+      }
+    });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final screenHeight = MediaQuery.of(context).size.height;
+
+    return SafeArea(
+      child: Padding(
+        padding: EdgeInsets.only(
+          bottom: MediaQuery.of(context).viewInsets.bottom,
+        ),
+        child: Material(
+          color: colors.bgCard,
+          clipBehavior: Clip.antiAlias,
+          borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
+          child: ConstrainedBox(
+            constraints: BoxConstraints(maxHeight: screenHeight * 0.8),
+            child: Container(
+              color: colors.bgPage,
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  _buildHeader(colors),
+                  _buildSearchBar(colors),
+                  Expanded(
+                    child: ColoredBox(
+                      color: colors.bgCard,
+                      child: _buildList(colors),
+                    ),
+                  ),
+                  _buildFooter(colors),
+                ],
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  Widget _buildHeader(AppColorsExtension colors) {
+    return Container(
+      padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
+      decoration: BoxDecoration(color: colors.bgCard),
+      child: Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Center(
+            child: Container(
+              width: 36,
+              height: 4,
+              margin: const EdgeInsets.only(bottom: 10),
+              decoration: BoxDecoration(
+                color: colors.border,
+                borderRadius: BorderRadius.circular(2),
+              ),
+            ),
+          ),
+          Row(
+            children: [
+              const SizedBox(width: 22),
+              Expanded(
+                child: Text(
+                  widget.title,
+                  textAlign: TextAlign.center,
+                  style: TextStyle(
+                    fontSize: 16,
+                    fontWeight: FontWeight.w600,
+                    color: colors.textPrimary,
+                  ),
+                ),
+              ),
+              GestureDetector(
+                onTap: () => Navigator.of(context).pop(),
+                child: Icon(Icons.close, size: 22, color: colors.textSecondary),
+              ),
+            ],
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildSearchBar(AppColorsExtension colors) {
+    return Container(
+      padding: const EdgeInsets.only(bottom: 8),
+      decoration: BoxDecoration(
+        color: colors.bgCard,
+        border: Border(
+          bottom: BorderSide(color: colors.border, width: 0.5),
+        ),
+      ),
+      child: TDSearchBar(
+        placeHolder:
+            widget.searchHint ?? AppLocalizations.of(context).get('search'),
+        style: TDSearchStyle.round,
+        onTextChanged: _onSearch,
+      ),
+    );
+  }
+
+  Widget _buildList(AppColorsExtension colors) {
+    if (_loading && _items.isEmpty) {
+      return const Center(
+        child: TDLoading(
+          size: TDLoadingSize.medium,
+          icon: TDLoadingIcon.activity,
+        ),
+      );
+    }
+    return EasyRefresh(
+      header: TDRefreshHeader(),
+      onRefresh: () => _load(reset: true),
+      child: _items.isEmpty
+          ? ListView(
+              children: [
+                const SizedBox(height: 80),
+                Center(
+                  child: Text(
+                    AppLocalizations.of(context).get('noData'),
+                    style:
+                        TextStyle(fontSize: 14, color: colors.textSecondary),
+                  ),
+                ),
+              ],
+            )
+          : ListView.separated(
+              controller: _scrollCtrl,
+              padding: EdgeInsets.zero,
+              itemCount: _items.length + 1,
+              separatorBuilder: (_, _) =>
+                  Divider(height: 1, thickness: 0.5, color: colors.border),
+              itemBuilder: (context, index) {
+                if (index == _items.length) {
+                  if (_loadingMore) {
+                    return const Padding(
+                      padding: EdgeInsets.all(16),
+                      child: Center(
+                        child: TDLoading(
+                          size: TDLoadingSize.small,
+                          icon: TDLoadingIcon.activity,
+                        ),
+                      ),
+                    );
+                  }
+                  return ListFooter(
+                    itemCount: _items.length,
+                    hasMore: _hasMore,
+                  );
+                }
+                final item = _items[index];
+                final label = widget.labelBuilder(item);
+                final isSelected = _selected.contains(item);
+
+                return _PickerItemTile(
+                  label: label,
+                  isSelected: isSelected,
+                  isMultiSelect: widget.multiSelect,
+                  onTap: () => _onItemTap(item),
+                );
+              },
+            ),
+    );
+  }
+
+  Widget _buildFooter(AppColorsExtension colors) {
+    final l10n = AppLocalizations.of(context);
+    final selectedText = _selected.map(widget.labelBuilder).join(';');
+    return Container(
+      padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
+      decoration: BoxDecoration(
+        color: colors.bgCard,
+        border: Border(top: BorderSide(color: colors.border, width: 0.5)),
+      ),
+      child: Column(
+        mainAxisSize: MainAxisSize.min,
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          // 已选内容预览:单选/多选统一展示,多选以 ; 分隔,最多两行超出省略
+          if (selectedText.isNotEmpty) ...[
+            Text.rich(
+              TextSpan(
+                children: [
+                  TextSpan(
+                    text: l10n.get('selectedLabel'),
+                    style: TextStyle(color: colors.textSecondary),
+                  ),
+                  TextSpan(
+                    text: selectedText,
+                    style: TextStyle(color: colors.primary),
+                  ),
+                ],
+              ),
+              maxLines: 2,
+              overflow: TextOverflow.ellipsis,
+              style: const TextStyle(fontSize: 13, height: 1.4),
+            ),
+            const SizedBox(height: 10),
+          ],
+          Row(
+            children: [
+              Expanded(
+                child: GestureDetector(
+                  onTap: () => Navigator.of(context).pop(),
+                  child: Container(
+                    height: 40,
+                    decoration: BoxDecoration(
+                      color: colors.bgSecondaryContainer,
+                      borderRadius: BorderRadius.circular(20),
+                    ),
+                    child: Center(
+                      child: Text(
+                        l10n.get('cancel'),
+                        style: TextStyle(
+                          fontSize: 14,
+                          color: colors.textSecondary,
+                        ),
+                      ),
+                    ),
+                  ),
+                ),
+              ),
+              const SizedBox(width: 12),
+              Expanded(
+                child: GestureDetector(
+                  onTap: () {
+                    if (widget.multiSelect) {
+                      Navigator.of(context).pop(_selected.toList());
+                    } else {
+                      Navigator.of(context)
+                          .pop(_selected.isEmpty ? null : _selected.first);
+                    }
+                  },
+                  child: Container(
+                    height: 40,
+                    decoration: BoxDecoration(
+                      color: colors.primary,
+                      borderRadius: BorderRadius.circular(20),
+                    ),
+                    child: Center(
+                      child: Text(
+                        widget.multiSelect
+                            ? '${l10n.get('confirm')}${_selected.isNotEmpty ? "(${_selected.length})" : ""}'
+                            : l10n.get('confirm'),
+                        style:
+                            const TextStyle(fontSize: 14, color: Colors.white),
+                      ),
+                    ),
+                  ),
+                ),
+              ),
+            ],
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+/// 选择器列表项。
+class _PickerItemTile extends StatelessWidget {
+  final String label;
+  final bool isSelected;
+  final bool isMultiSelect;
+  final VoidCallback onTap;
+
+  const _PickerItemTile({
+    required this.label,
+    required this.isSelected,
+    required this.isMultiSelect,
+    required this.onTap,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+
+    return GestureDetector(
+      onTap: onTap,
+      behavior: HitTestBehavior.opaque,
+      child: Padding(
+        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
+        child: Row(
+          children: [
+            Icon(
+              isMultiSelect
+                  ? (isSelected
+                      ? TDIcons.check_rectangle_filled
+                      : TDIcons.rectangle)
+                  : (isSelected ? TDIcons.check_circle_filled : TDIcons.circle),
+              size: 22,
+              color: isSelected ? colors.primary : colors.textPlaceholder,
+            ),
+            const SizedBox(width: 12),
+            Expanded(
+              child: Text(
+                label,
+                style: TextStyle(
+                  fontSize: 15,
+                  color: isSelected ? colors.primary : colors.textPrimary,
+                  fontWeight:
+                      isSelected ? FontWeight.w500 : FontWeight.normal,
+                ),
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}