chengc hai 1 hora
pai
achega
eed59e0f18
Modificáronse 29 ficheiros con 2957 adicións e 855 borrados
  1. 26 5
      assets/i18n/en.json
  2. 22 3
      assets/i18n/zh_CN.json
  3. 22 3
      assets/i18n/zh_TW.json
  4. 1 1
      docs/superpowers/specs/tboss-oa-prd.md
  5. 2 5
      lib/app.dart
  6. 15 0
      lib/core/data/mock_api_data.dart
  7. 34 0
      lib/core/navigation/host_app_channel.dart
  8. 1 1
      lib/core/network/api_client.dart
  9. 35 0
      lib/core/network/mock_data.dart
  10. 12 0
      lib/core/network/mock_interceptor.dart
  11. 5 1
      lib/core/router/app_router.dart
  12. 5 0
      lib/core/router/route_observer.dart
  13. 150 1
      lib/features/expense/expense_api.dart
  14. 553 0
      lib/features/expense/expense_apply_import_page.dart
  15. 35 0
      lib/features/expense/expense_create_controller.dart
  16. 624 247
      lib/features/expense/expense_create_page.dart
  17. 92 26
      lib/features/expense/expense_detail_page.dart
  18. 72 40
      lib/features/expense/expense_model.dart
  19. 437 147
      lib/features/expense/widgets/expense_detail_dialog.dart
  20. 66 0
      lib/features/expense_apply/expense_apply_api.dart
  21. 417 237
      lib/features/expense_apply/expense_apply_create_page.dart
  22. 51 35
      lib/features/expense_apply/expense_apply_model.dart
  23. 66 46
      lib/features/expense_apply/widgets/expense_apply_detail_dialog.dart
  24. 57 37
      lib/shared/widgets/action_bar.dart
  25. 6 0
      lib/shared/widgets/app_scaffold.dart
  26. 72 16
      lib/shared/widgets/attachment_picker.dart
  27. 11 1
      lib/shared/widgets/form_field_row.dart
  28. 1 3
      lib/shared/widgets/skeleton_list_card.dart
  29. 67 0
      lib/shared/widgets/submitting_dialog.dart

+ 26 - 5
assets/i18n/en.json

@@ -32,6 +32,7 @@
     "reset": "reset",
     "publish": "publish",
     "saveDraft": "saveDraft",
+    "submitting": "Submitting…",
     "submitApproval": "submitApproval",
     "saveDraftShort": "saveDraftShort",
     "delete": "delete",
@@ -40,6 +41,7 @@
     "close": "close",
     "retry": "retry",
     "confirmAdd": "confirmAdd",
+    "confirmEdit": "confirmEdit",
     "confirmSubmit": "confirmSubmit",
     "confirmPublish": "confirmPublish",
     "confirmAction": "confirmAction",
@@ -64,7 +66,8 @@
     "approved": "approved",
     "rejected": "rejected",
     "expired": "expired",
-    "paid": "paid",
+    "paid": "Paid",
+    "unpaid": "Unpaid",
     "returned": "returned",
     "completed": "completed",
     "statusPending": "statusPending",
@@ -96,9 +99,11 @@
     "unknownError": "unknownError",
     "networkTimeout": "networkTimeout",
     "draftSaved": "draftSaved",
-    "draftSavedToast": "draftSavedToast",
+    "draftSavedToast": "Draft saved",
+    "saveFailed": "Save failed",
     "submitSuccess": "submitSuccess",
     "submitFailedRetry": "submitFailedRetry",
+    "noCostTypeData": "No cost type data. Please add cost types in ERP first.",
     "submittedAwaitingApproval": "submittedAwaitingApproval",
     "published": "published",
     "withdrawn": "withdrawn",
@@ -110,6 +115,9 @@
     "enterField": "enterField",
     "applicant": "applicant",
     "department": "department",
+    "applyDept": "Apply Dept",
+    "expensePersonnel": "Expense Personnel",
+    "expenseDept": "Expense Dept",
     "dept": "dept",
     "date": "date",
     "startTime": "startTime",
@@ -118,6 +126,7 @@
     "hours": "hours",
     "total": "total",
     "send": "send",
+    "search": "Search",
     "searchByNameOrId": "searchByNameOrId",
     "searchEmployee": "searchEmployee",
     "searchEmployeeHint": "searchEmployeeHint",
@@ -302,6 +311,8 @@
     "expenseApplyRequest": "expenseApplyRequest",
     "expenseApplyImport": "expenseApplyImport",
     "importApprovedPreApp": "importApprovedPreApp",
+    "confirmImport": "confirmImport",
+    "importSuccess": "importSuccess",
     "searchExpenseApply": "searchExpenseApply",
     "noExpenseApplications": "noExpenseApplications",
     "reportExpenseApply": "reportExpenseApply",
@@ -310,6 +321,8 @@
     "feeType": "feeType",
     "feeReason": "feeReason",
     "enterFeeReason": "enterFeeReason",
+    "applyReason": "applyReason",
+    "enterApplyReason": "enterApplyReason",
     "validUntil": "validUntil",
     "selectExpiryDate": "selectExpiryDate",
     "relatedContractNo": "relatedContractNo",
@@ -430,6 +443,9 @@
       "reportExpenseApplyDetail": "Expense Apply Detail Report",
       "expenseApplyImport": "Import approved application",
       "importApprovedPreApp": "Import Approved Application",
+      "importExpenseApply": "Import Expense Application",
+      "confirmImport": "Confirm Import",
+      "importSuccess": "Import successful",
       "addExpenseDetailFirst": "Please add expense details first",
       "addAtLeastOneDetail": "Please add at least one expense detail",
       "detailRemark": "Detail Remark",
@@ -506,11 +522,16 @@
     "reportExpense": "reportExpense",
     "reportExpenseDetail": "reportExpenseDetail",
     "expenseDate": "expenseDate",
-    "reportNo": "reportNo",
+    "expenseNo": "Expense No.",
     "autoGenerated": "autoGenerated",
     "currency": "currency",
     "selectCurrency": "selectCurrency",
     "paymentMethod": "paymentMethod",
+    "bankTransfer": "Bank Transfer",
+    "cash": "Cash",
+    "alipay": "Alipay",
+    "wechat": "WeChat Pay",
+    "bankAccountName": "Account Name",
     "selectPaymentMethod": "selectPaymentMethod",
     "voucherNo": "voucherNo",
     "enterVoucherNo": "enterVoucherNo",
@@ -531,7 +552,7 @@
     "enterAmount": "enterAmount",
     "enterValidAmount": "enterValidAmount",
     "amountMustPositive": "amountMustPositive",
-    "amountExcludingTax": "amountExcludingTax",
+    "amountExcludingTax": "Excl. Tax Amount",
     "amountInclTax": "amountInclTax",
     "amountPositive": "amountPositive",
     "taxAmount": "taxAmount",
@@ -574,7 +595,7 @@
     "isInvoiceVerified": "isInvoiceVerified",
     "isTaxIdMatched": "isTaxIdMatched",
     "isCategoryCompliant": "isCategoryCompliant",
-    "paymentStatus": "paymentStatus",
+    "paymentStatus": "Payment Status",
     "confirmPaymentAndArchive": "confirmPaymentAndArchive",
     "confirmPaymentAndArchiveTip": "confirmPaymentAndArchiveTip",
     "nextPendingPayment": "nextPendingPayment",

+ 22 - 3
assets/i18n/zh_CN.json

@@ -40,6 +40,7 @@
     "close": "关闭",
     "retry": "重试",
     "confirmAdd": "确认添加",
+    "confirmEdit": "确认修改",
     "confirmSubmit": "确认提交",
     "confirmPublish": "确认发布",
     "confirmAction": "确定要{action}该申请吗?",
@@ -65,6 +66,7 @@
     "rejected": "已拒绝",
     "expired": "已过期",
     "paid": "已付款",
+    "unpaid": "待付款",
     "returned": "已还车",
     "completed": "已完成",
     "statusPending": "审批中",
@@ -97,6 +99,7 @@
     "networkTimeout": "网络连接超时",
     "draftSaved": "草稿已保存",
     "draftSavedToast": "已保存为草稿",
+    "saveFailed": "保存失败",
     "submitSuccess": "提交成功",
     "submitFailedRetry": "提交失败,请稍后重试",
     "submittedAwaitingApproval": "已提交,等待审批",
@@ -110,6 +113,11 @@
     "enterField": "请输入{field}",
     "applicant": "申请人",
     "department": "所属部门",
+    "applyDept": "申请部门",
+    "expensePersonnel": "报销人员",
+    "expenseDept": "报销部门",
+    "submitting": "提交中…",
+    "noCostTypeData": "暂无费用类型数据,请先前往ERP新增费用类型数据再尝试",
     "dept": "部门",
     "date": "日期",
     "startTime": "开始时间",
@@ -118,6 +126,7 @@
     "hours": "小时",
     "total": "合计",
     "send": "发送",
+    "search": "搜索",
     "searchByNameOrId": "输入姓名或工号进行检索...",
     "searchEmployee": "搜索员工",
     "searchEmployeeHint": "输入姓名或工号搜索",
@@ -248,6 +257,9 @@
     "expenseApplyRequest": "费用申请",
     "expenseApplyImport": "一键导入已通过的费用申请",
     "importApprovedPreApp": "一键导入已通过的费用申请",
+    "importExpenseApply": "导入费用申请",
+    "confirmImport": "确认导入",
+    "importSuccess": "导入成功",
     "searchExpenseApply": "搜索申请单号或申请人",
     "noExpenseApplications": "暂无费用申请",
     "reportExpenseApply": "费用申请报表",
@@ -256,6 +268,8 @@
     "feeType": "费用类型",
     "feeReason": "费用事由",
     "enterFeeReason": "请输入费用事由",
+    "applyReason": "申请事由",
+    "enterApplyReason": "请输入申请事由",
     "validUntil": "有效期至",
     "selectExpiryDate": "选择过期日期",
     "relatedContractNo": "关联合同号",
@@ -358,11 +372,16 @@
     "reportExpense": "费用报销报表",
     "reportExpenseDetail": "费用报销明细报表",
     "expenseDate": "报销日期",
-    "reportNo": "报销单号",
+    "expenseNo": "报销单号",
     "autoGenerated": "系统自动生成",
     "currency": "币别",
     "selectCurrency": "选择币别",
     "paymentMethod": "支付方式",
+    "bankTransfer": "银行转账",
+    "cash": "现金",
+    "alipay": "支付宝",
+    "wechat": "微信支付",
+    "bankAccountName": "开户户名",
     "selectPaymentMethod": "选择支付方式",
     "voucherNo": "凭证号",
     "enterVoucherNo": "请输入凭证号码",
@@ -383,7 +402,7 @@
     "enterAmount": "请输入金额",
     "enterValidAmount": "请输入有效金额",
     "amountMustPositive": "金额必须大于0",
-    "amountExcludingTax": "金额不含税",
+    "amountExcludingTax": "不含税金额",
     "amountInclTax": "含税金额",
     "amountPositive": "金额必须大于0",
     "taxAmount": "税金",
@@ -426,7 +445,7 @@
     "isInvoiceVerified": "isInvoiceVerified",
     "isTaxIdMatched": "isTaxIdMatched",
     "isCategoryCompliant": "isCategoryCompliant",
-    "paymentStatus": "paymentStatus",
+    "paymentStatus": "付款状态",
     "confirmPaymentAndArchive": "确认打款并归档",
     "confirmPaymentAndArchiveTip": "确认将本笔报销打款并归档?操作后不可撤销。",
     "nextPendingPayment": "下一笔待付款",

+ 22 - 3
assets/i18n/zh_TW.json

@@ -32,6 +32,7 @@
     "reset": "重置",
     "publish": "發佈",
     "saveDraft": "存為草稿",
+    "submitting": "提交中…",
     "submitApproval": "提交審批",
     "saveDraftShort": "存草稿",
     "delete": "刪除",
@@ -40,6 +41,7 @@
     "close": "關閉",
     "retry": "重試",
     "confirmAdd": "確認添加",
+    "confirmEdit": "確認修改",
     "confirmSubmit": "確認提交",
     "confirmPublish": "確認發佈",
     "confirmAction": "確定要{action}該申請吗?",
@@ -65,6 +67,7 @@
     "rejected": "已拒絕",
     "expired": "已過期",
     "paid": "已付款",
+    "unpaid": "待付款",
     "returned": "已還車",
     "completed": "已完成",
     "statusPending": "審批中",
@@ -97,8 +100,10 @@
     "networkTimeout": "網絡連接超時",
     "draftSaved": "草稿已保存",
     "draftSavedToast": "已保存為草稿",
+    "saveFailed": "保存失敗",
     "submitSuccess": "提交成功",
     "submitFailedRetry": "提交失敗,請稍后重試",
+    "noCostTypeData": "暫無費用類型數據,請先前往ERP新增費用類型數據再嘗試",
     "submittedAwaitingApproval": "已提交,等待審批",
     "published": "公告發佈成功",
     "withdrawn": "已撤回",
@@ -110,6 +115,9 @@
     "enterField": "請輸入{field}",
     "applicant": "申請人",
     "department": "所属部門",
+    "applyDept": "申請部門",
+    "expensePersonnel": "報銷人員",
+    "expenseDept": "報銷部門",
     "dept": "部門",
     "date": "日期",
     "startTime": "開始時間",
@@ -118,6 +126,7 @@
     "hours": "小時",
     "total": "合計",
     "send": "發送",
+    "search": "搜尋",
     "searchByNameOrId": "輸入姓名或工號進行检索...",
     "searchEmployee": "搜索員工",
     "searchEmployeeHint": "輸入姓名或工號搜索",
@@ -248,6 +257,9 @@
     "expenseApplyRequest": "費用申請",
     "expenseApplyImport": "一鍵導入已通過的費用申請",
     "importApprovedPreApp": "一鍵導入已通過的費用申請",
+    "importExpenseApply": "導入費用申請",
+    "confirmImport": "確認導入",
+    "importSuccess": "導入成功",
     "searchExpenseApply": "搜索申請單號或申請人",
     "noExpenseApplications": "暫無費用申請",
     "reportExpenseApply": "費用申請報表",
@@ -255,6 +267,8 @@
     "emergencyLevel": "紧急程度",
     "feeType": "費用類型",
     "feeReason": "費用事由",
+    "applyReason": "申請事由",
+    "enterApplyReason": "請輸入申請事由",
     "enterFeeReason": "請輸入費用事由",
     "validUntil": "有效期至",
     "selectExpiryDate": "選擇過期日期",
@@ -358,11 +372,16 @@
     "reportExpense": "費用報销報表",
     "reportExpenseDetail": "費用報销明細報表",
     "expenseDate": "報销日期",
-    "reportNo": "報销單號",
+    "expenseNo": "報銷單號",
     "autoGenerated": "係統自動生成",
     "currency": "幣別",
     "selectCurrency": "選擇幣別",
     "paymentMethod": "支付方式",
+    "bankTransfer": "銀行轉帳",
+    "cash": "現金",
+    "alipay": "支付寶",
+    "wechat": "微信支付",
+    "bankAccountName": "開戶戶名",
     "selectPaymentMethod": "選擇支付方式",
     "voucherNo": "憑證號",
     "enterVoucherNo": "請輸入憑證號碼",
@@ -383,7 +402,7 @@
     "enterAmount": "請輸入金額",
     "enterValidAmount": "請輸入有效金額",
     "amountMustPositive": "金額必须大于0",
-    "amountExcludingTax": "金額不含稅",
+    "amountExcludingTax": "不含稅金額",
     "amountInclTax": "含稅金額",
     "amountPositive": "金額必须大于0",
     "taxAmount": "稅金",
@@ -426,7 +445,7 @@
     "isInvoiceVerified": "isInvoiceVerified",
     "isTaxIdMatched": "isTaxIdMatched",
     "isCategoryCompliant": "isCategoryCompliant",
-    "paymentStatus": "paymentStatus",
+    "paymentStatus": "付款狀態",
     "confirmPaymentAndArchive": "確認打款并歸檔",
     "confirmPaymentAndArchiveTip": "確認将本笔報销打款并歸檔?操作后不可撤销。",
     "nextPendingPayment": "下一笔待付款",

+ 1 - 1
docs/superpowers/specs/tboss-oa-prd.md

@@ -197,7 +197,7 @@ TBOSS OA 的核心差异化:**不是独立产品,而是 ERP 的移动 OA 外
 
 | 功能 | 优先级 | 说明 |
 |------|--------|------|
-| 新建报销 | P0 | 两种方式:①导入费用申请(项目/科目自动带入,填每张导入金额);②直接新建(手动选项目→科目→查预算,与费用申请相同的链式操作)。均支持外币明细(自动汇率折算)、发票影像上传。支付方式(PaymentMethod)由员工在填单时选择 |
+| 新建报销 | P0 | 两种方式:①导入费用申请(项目/科目自动带入,填每张导入金额);②直接新建(手动选项目→科目→查预算,与费用申请相同的链式操作)。均支持外币明细(自动汇率折算)、附件上传(图片/PDF/Word/Excel,表头附件+明细行附件)。支付方式(PaymentMethod)由员工在填单时选择 |
 | 导入费用申请 | P0 | 从已通过且尚有额度的申请多选导入,每条填导入金额。导入后自动回填项目/科目,额度实时校验,超申请金额不可提交。严格按 BizScope 过滤,不可导入 expense_apply 专属类别 |
 | 列表筛选 | P0 | 全部/草稿/审批中/已通过/待付款/已付款/已拒绝/已撤回/已归档 |
 | 详情查看 | P0 | 状态横幅 + 费用明细 + 审批时间线 + 附件预览 |

+ 2 - 5
lib/app.dart

@@ -17,11 +17,8 @@ import 'core/navigation/navigation_channel.dart';
 import 'core/navigation/host_app_channel.dart';
 
 final apiClientProvider = Provider<ApiClient>((ref) {
-  const useMock = true;
-  final client = ApiClient(
-    baseUrl: HostAppChannel.baseUrl,
-    useMock: useMock,
-  );
+  const useMock = false;
+  final client = ApiClient(baseUrl: HostAppChannel.baseUrl, useMock: useMock);
   final authService = ref.read(authServiceProvider);
   client.setToken(authService.token);
   return client;

+ 15 - 0
lib/core/data/mock_api_data.dart

@@ -70,6 +70,21 @@ const mockCostCategories = [
   CostCategory(code: 'other', nameKey: 'costCategoryOther', acctSubjectId: '5599', acctSubjectName: '其他费用'),
 ];
 
+/// 客户/厂商(Mock)
+class CustomerVendor {
+  final String id;
+  final String name;
+  const CustomerVendor({required this.id, required this.name});
+}
+
+const mockCustomerVendors = [
+  CustomerVendor(id: 'CV001', name: '深圳市腾讯计算机系统有限公司'),
+  CustomerVendor(id: 'CV002', name: '阿里巴巴(中国)有限公司'),
+  CustomerVendor(id: 'CV003', name: '华为技术有限公司'),
+  CustomerVendor(id: 'CV004', name: '字节跳动有限公司'),
+  CustomerVendor(id: 'CV005', name: '京东集团股份有限公司'),
+];
+
 /// 费用承担部门(Mock)
 class CostDept {
   final String id;

+ 34 - 0
lib/core/navigation/host_app_channel.dart

@@ -22,10 +22,18 @@ class HostAppChannel {
   static String? _baseUrl;
   static String? _sn;
   static String? _loginId;
+  static String? _dep;
+  static String? _depName;
+  static String? _usr;
+  static String? _usrName;
 
   static String get baseUrl => _baseUrl ?? '';
   static String get sn => _sn ?? '';
   static String get loginId => _loginId ?? '';
+  static String get dep => _dep ?? '';
+  static String get depName => _depName ?? '';
+  static String get usr => _usr ?? '';
+  static String get usrName => _usrName ?? '';
 
   static bool _initialized = false;
   static bool get isInitialized => _initialized;
@@ -39,13 +47,39 @@ class HostAppChannel {
         _baseUrl = result['baseUrl'] as String?;
         _sn = result['sn'] as String?;
         _loginId = result['loginId'] as String?;
+        _dep = result['dep'] as String?;
+        _depName = result['depName'] as String?;
+        _usr = result['usr'] as String?;
+        _usrName = result['usrName'] as String?;
       }
     } catch (_) {
       // 调试 / Mock 模式回退
       _baseUrl = 'https://your-api-host.com/api';
       _sn = '';
       _loginId = '';
+      _dep = '';
+      _depName = '';
+      _usr = '';
+      _usrName = '';
     }
     _initialized = true;
   }
+
+  /// 重新从宿主 App 拉取配置(用于用户切换后刷新)。
+  static Future<void> refresh() async {
+    try {
+      final result = await _channel.invokeMethod<Map<dynamic, dynamic>>('getConfig');
+      if (result != null) {
+        _baseUrl = result['baseUrl'] as String? ?? _baseUrl;
+        _sn = result['sn'] as String? ?? _sn;
+        _loginId = result['loginId'] as String? ?? _loginId;
+        _dep = result['dep'] as String? ?? _dep;
+        _depName = result['depName'] as String? ?? _depName;
+        _usr = result['usr'] as String? ?? _usr;
+        _usrName = result['usrName'] as String? ?? _usrName;
+      }
+    } catch (_) {
+      // 刷新失败保留旧值
+    }
+  }
 }

+ 1 - 1
lib/core/network/api_client.dart

@@ -14,7 +14,7 @@ class ApiClient {
     _dio = Dio(BaseOptions(
       baseUrl: baseUrl,
       connectTimeout: const Duration(seconds: 15),
-      receiveTimeout: const Duration(seconds: 15),
+      receiveTimeout: const Duration(minutes: 30),
       headers: {'Content-Type': 'application/json'},
     ));
 

+ 35 - 0
lib/core/network/mock_data.dart

@@ -550,4 +550,39 @@ class MockData {
       ],
     },
   };
+
+  // ==================== 参考数据(费用申请新增页下拉) ====================
+
+  static List<Map<String, dynamic>> get costTypes => [
+    {'typeNo': 'transport', 'typeName': '交通费', 'accNo': '5501', 'accName': '差旅费'},
+    {'typeNo': 'hotel', 'typeName': '住宿费', 'accNo': '5501', 'accName': '差旅费'},
+    {'typeNo': 'office_supplies', 'typeName': '办公用品', 'accNo': '5502', 'accName': '办公费'},
+    {'typeNo': 'meals', 'typeName': '餐饮费', 'accNo': '5503', 'accName': '招待费'},
+    {'typeNo': 'materials', 'typeName': '材料费', 'accNo': '5504', 'accName': '材料费'},
+    {'typeNo': 'service', 'typeName': '服务费', 'accNo': '5505', 'accName': '服务费'},
+    {'typeNo': 'other', 'typeName': '其他', 'accNo': '5599', 'accName': '其他费用'},
+  ];
+
+  static List<Map<String, dynamic>> get projectCodes => [
+    {'objNo': '100', 'name': '华东市场拓展', 'objClass': 'SALES', 'objClassName': '销售类'},
+    {'objNo': '101', 'name': 'ERP系统升级', 'objClass': 'IT', 'objClassName': 'IT类'},
+    {'objNo': '102', 'name': '新产品研发', 'objClass': 'RD', 'objClassName': '研发类'},
+    {'objNo': '103', 'name': '华南渠道建设', 'objClass': 'SALES', 'objClassName': '销售类'},
+  ];
+
+  static List<Map<String, dynamic>> get departments => [
+    {'dep': 'D001', 'name': '技术部', 'engName': 'IT Dept', 'up': '', 'stopDd': null},
+    {'dep': 'D002', 'name': '销售部', 'engName': 'Sales Dept', 'up': '', 'stopDd': null},
+    {'dep': 'D003', 'name': '市场部', 'engName': 'Marketing Dept', 'up': '', 'stopDd': null},
+    {'dep': 'D004', 'name': '财务部', 'engName': 'Finance Dept', 'up': '', 'stopDd': null},
+    {'dep': 'D005', 'name': '人事部', 'engName': 'HR Dept', 'up': '', 'stopDd': null},
+  ];
+
+  static List<Map<String, dynamic>> get customers => [
+    {'cusNo': 'CV001', 'name': '深圳市腾讯计算机系统有限公司', 'snm': '腾讯', 'tel1': '', 'fax': '', 'adr1': '', 'cusAre': '', 'sal': ''},
+    {'cusNo': 'CV002', 'name': '阿里巴巴(中国)有限公司', 'snm': '阿里', 'tel1': '', 'fax': '', 'adr1': '', 'cusAre': '', 'sal': ''},
+    {'cusNo': 'CV003', 'name': '华为技术有限公司', 'snm': '华为', 'tel1': '', 'fax': '', 'adr1': '', 'cusAre': '', 'sal': ''},
+    {'cusNo': 'CV004', 'name': '字节跳动有限公司', 'snm': '字节', 'tel1': '', 'fax': '', 'adr1': '', 'cusAre': '', 'sal': ''},
+    {'cusNo': 'CV005', 'name': '京东集团股份有限公司', 'snm': '京东', 'tel1': '', 'fax': '', 'adr1': '', 'cusAre': '', 'sal': ''},
+  ];
 }

+ 12 - 0
lib/core/network/mock_interceptor.dart

@@ -72,6 +72,18 @@ class MockInterceptor extends Interceptor {
     if (path == '/OA/GetCurrencies') {
       return {'code': 0, 'message': 'success', 'data': _paginate(MockData.currencies, page)};
     }
+    if (path == '/OA/GetCostTypes') {
+      return {'code': 0, 'message': 'success', 'data': _paginate(MockData.costTypes, page)};
+    }
+    if (path == '/OA/GetProjectCodes') {
+      return {'code': 0, 'message': 'success', 'data': _paginate(MockData.projectCodes, page)};
+    }
+    if (path == '/OA/GetDepartments') {
+      return {'code': 0, 'message': 'success', 'data': _paginate(MockData.departments, page)};
+    }
+    if (path == '/OA/GetCustomers') {
+      return {'code': 0, 'message': 'success', 'data': _paginate(MockData.customers, page)};
+    }
     if (path == '/OA/GetApprovalTimeline') {
       return {'code': 0, 'message': 'success', 'data': MockData.approvalTimeline};
     }

+ 5 - 1
lib/core/router/app_router.dart

@@ -1,9 +1,11 @@
 import 'package:flutter/material.dart';
 import 'package:go_router/go_router.dart';
 import '../../shared/widgets/app_scaffold.dart';
+import 'route_observer.dart';
 import '../../features/home/home_page.dart';
 import '../../features/messages/message_list_page.dart';
 import '../../features/profile/profile_page.dart';
+import '../../features/expense/expense_apply_import_page.dart';
 import '../../features/expense/expense_list_page.dart';
 import '../../features/expense/expense_create_page.dart';
 import '../../features/expense/expense_detail_page.dart';
@@ -31,13 +33,15 @@ import '../../features/admin/admin_permissions_page.dart';
 
 GoRouter createAppRouter() {
   return GoRouter(
+    observers: [routeObserver],
     initialLocation: '/',
     routes: [
       GoRoute(path: '/', pageBuilder: (_, _) => const MaterialPage(child: AppScaffold(showTabBar: true, body: HomePage()))),
       GoRoute(path: '/messages', pageBuilder: (_, _) => const MaterialPage(child: AppScaffold(showTabBar: true, body: MessageListPage()))),
       GoRoute(path: '/profile', pageBuilder: (_, _) => const MaterialPage(child: AppScaffold(showTabBar: true, body: ProfilePage()))),
       GoRoute(path: '/expense/list', pageBuilder: (_, _) => const MaterialPage(child: AppScaffold(body: ExpenseListPage()))),
-      GoRoute(path: '/expense/create', pageBuilder: (_, state) => MaterialPage(child: AppScaffold(body: ExpenseApplyPage(editId: state.uri.queryParameters['id'])))),
+      GoRoute(path: '/expense/import-apply', pageBuilder: (_, _) => const MaterialPage(child: AppScaffold(body: ExpenseApplyImportPage(), resizeToAvoidBottomInset: false))),
+      GoRoute(path: '/expense/create', pageBuilder: (_, state) => MaterialPage(child: AppScaffold(body: ExpenseApplyPage(editId: state.uri.queryParameters['id']), resizeToAvoidBottomInset: false))),
       GoRoute(path: '/expense/detail/:id', pageBuilder: (_, state) => MaterialPage(child: AppScaffold(body: ExpenseDetailPage(id: state.pathParameters['id']!)))),
       GoRoute(path: '/overtime/list', pageBuilder: (_, _) => const MaterialPage(child: AppScaffold(body: OvertimeListPage()))),
       GoRoute(path: '/overtime/create', pageBuilder: (_, state) => MaterialPage(child: AppScaffold(body: OvertimeCreatePage(editId: state.uri.queryParameters['id'])))),

+ 5 - 0
lib/core/router/route_observer.dart

@@ -0,0 +1,5 @@
+import 'package:flutter/material.dart';
+
+/// 全局路由观察者,配合 RouteAware 实现页面生命周期监听。
+/// GoRouter 通过 observers 参数注册,页面通过 RouteAware.didPush 感知"页面已完全展示"。
+final routeObserver = RouteObserver<ModalRoute<dynamic>>();

+ 150 - 1
lib/features/expense/expense_api.dart

@@ -9,6 +9,84 @@ final expenseApiProvider = Provider<ExpenseApi>(
   (ref) => ExpenseApi(ref.read(apiClientProvider)),
 );
 
+// ═══ 参考数据模型(API 返回) ═══
+
+class CostTypeItem {
+  final String typeNo;
+  final String typeName;
+  final String accNo;
+  final String accName;
+  const CostTypeItem({required this.typeNo, required this.typeName, required this.accNo, required this.accName});
+  factory CostTypeItem.fromJson(Map<String, dynamic> json) => CostTypeItem(
+    typeNo: json['typeNo'] as String? ?? '',
+    typeName: json['typeName'] as String? ?? '',
+    accNo: json['accNo'] as String? ?? '',
+    accName: json['accName'] as String? ?? '',
+  );
+}
+
+class ProjectCodeItem {
+  final String objNo;
+  final String name;
+  const ProjectCodeItem({required this.objNo, required this.name});
+  factory ProjectCodeItem.fromJson(Map<String, dynamic> json) => ProjectCodeItem(
+    objNo: json['objNo'] as String? ?? '',
+    name: json['name'] as String? ?? '',
+  );
+}
+
+class DepartmentItem {
+  final String dep;
+  final String name;
+  const DepartmentItem({required this.dep, required this.name});
+  factory DepartmentItem.fromJson(Map<String, dynamic> json) => DepartmentItem(
+    dep: json['dep'] as String? ?? '',
+    name: json['name'] as String? ?? '',
+  );
+}
+
+class CustomerItem {
+  final String cusNo;
+  final String name;
+  const CustomerItem({required this.cusNo, required this.name});
+  factory CustomerItem.fromJson(Map<String, dynamic> json) => CustomerItem(
+    cusNo: json['cusNo'] as String? ?? '',
+    name: json['name'] as String? ?? '',
+  );
+}
+
+class CurrencyItem {
+  final String curId;
+  final String name;
+  const CurrencyItem({required this.curId, required this.name});
+  factory CurrencyItem.fromJson(Map<String, dynamic> json) => CurrencyItem(
+    curId: json['curId'] as String? ?? '',
+    name: json['name'] as String? ?? '',
+  );
+}
+
+class EmployeeItem {
+  final String salNo;
+  final String name;
+  final String dep;
+  final String tel;
+  final String email;
+  final String bnkNo;
+  final String bnkId;
+  final String accName;
+  const EmployeeItem({required this.salNo, required this.name, this.dep = '', this.tel = '', this.email = '', this.bnkNo = '', this.bnkId = '', this.accName = ''});
+  factory EmployeeItem.fromJson(Map<String, dynamic> json) => EmployeeItem(
+    salNo: json['salNo'] as String? ?? '',
+    name: json['name'] as String? ?? '',
+    dep: json['dep'] as String? ?? '',
+    tel: json['tel'] as String? ?? '',
+    email: json['email'] as String? ?? '',
+    bnkNo: json['bnkNo'] as String? ?? '',
+    bnkId: json['bnkId'] as String? ?? '',
+    accName: json['accName'] as String? ?? '',
+  );
+}
+
 class ExpenseApi {
   final ApiClient _client;
   ExpenseApi(this._client);
@@ -62,7 +140,78 @@ class ExpenseApi {
     await _client.post('/OA/ExpenseVerify', data: data);
   }
 
-  /// 币别查询
+  /// 费用类别字典
+  Future<List<CostTypeItem>> getCostTypes({String keyword = '', String accNo = ''}) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetCostTypes',
+      queryParameters: {'keyword': keyword, 'accNo': accNo, 'page': 1, 'size': 100},
+    );
+    final list = (response.data?['list'] as List<dynamic>?) ?? [];
+    return list.map((e) => CostTypeItem.fromJson(e as Map<String, dynamic>)).toList();
+  }
+
+  /// 项目代号
+  Future<List<ProjectCodeItem>> getProjectCodes({String keyword = '', String billDate = ''}) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetProjectCodes',
+      queryParameters: {'keyword': keyword, 'billDate': billDate, 'page': 1, 'size': 100},
+    );
+    final list = (response.data?['list'] as List<dynamic>?) ?? [];
+    return list.map((e) => ProjectCodeItem.fromJson(e as Map<String, dynamic>)).toList();
+  }
+
+  /// 部门
+  Future<List<DepartmentItem>> getDepartments({String keyword = '', bool onlyActive = true}) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetDepartments',
+      queryParameters: {'keyword': keyword, 'onlyActive': onlyActive, 'page': 1, 'size': 100},
+    );
+    final list = (response.data?['list'] as List<dynamic>?) ?? [];
+    return list.map((e) => DepartmentItem.fromJson(e as Map<String, dynamic>)).toList();
+  }
+
+  /// 客户/厂商
+  Future<List<CustomerItem>> getCustomers({String keyword = ''}) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetCustomers',
+      queryParameters: {'keyword': keyword, 'page': 1, 'size': 100},
+    );
+    final list = (response.data?['list'] as List<dynamic>?) ?? [];
+    return list.map((e) => CustomerItem.fromJson(e as Map<String, dynamic>)).toList();
+  }
+
+  /// 员工查询
+  Future<List<EmployeeItem>> getEmployees({String keyword = '', String salNo = ''}) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetEmployees',
+      queryParameters: {'keyword': keyword, 'salNo': salNo, 'page': 1, 'size': 100},
+    );
+    final list = (response.data?['list'] as List<dynamic>?) ?? [];
+    return list.map((e) => EmployeeItem.fromJson(e as Map<String, dynamic>)).toList();
+  }
+
+  /// 可转入报销单的费用申请明细
+  Future<Map<String, dynamic>> getImportableExpenseApplies({
+    String aeNo = '', String startDate = '', String endDate = '', int page = 1, int size = 20,
+  }) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetImportableExpenseApplies',
+      queryParameters: {'aeNo': aeNo, 'startDate': startDate, 'endDate': endDate, 'page': page, 'size': size},
+    );
+    return response.data!;
+  }
+
+  /// 币别
+  Future<List<CurrencyItem>> getCurrencies({String keyword = ''}) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetCurrencies',
+      queryParameters: {'keyword': keyword, 'page': 1, 'size': 50},
+    );
+    final list = (response.data?['list'] as List<dynamic>?) ?? [];
+    return list.map((e) => CurrencyItem.fromJson(e as Map<String, dynamic>)).toList();
+  }
+
+  /// 币别查询(旧版,保留兼容)
   Future<ApiResponse<Map<String, dynamic>>> fetchCurrencies({
     String keyword = '',
     int page = 1,

+ 553 - 0
lib/features/expense/expense_apply_import_page.dart

@@ -0,0 +1,553 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+import '../../shared/widgets/nav_bar_config.dart';
+import '../../core/i18n/app_localizations.dart';
+import '../../core/theme/app_colors_extension.dart';
+import 'expense_api.dart';
+
+/// 可导入的费用申请明细项
+class ImportableItem {
+  final String aeNo;
+  final String aeDd;
+  final String reason;
+  final double headAmtnYj;
+  final int itm;
+  final String sqMan;
+  final String sqName;
+  final String typeNo;
+  final String typeName;
+  final double amtnYj;
+  final String accNo;
+  final String accName;
+  final String dep;
+  final String depName;
+  final String objNo;
+  final String objName;
+  final String startDd;
+  final String endDd;
+  final String rem;
+  bool selected = false;
+
+  ImportableItem({
+    required this.aeNo, required this.aeDd, required this.reason,
+    required this.headAmtnYj, required this.itm, required this.sqMan, required this.sqName,
+    required this.typeNo, required this.typeName, required this.amtnYj,
+    required this.accNo, required this.accName, required this.dep,
+    required this.depName, required this.objNo, required this.objName,
+    required this.startDd, required this.endDd, required this.rem,
+  });
+
+  factory ImportableItem.fromJson(Map<String, dynamic> json) => ImportableItem(
+    aeNo: json['aeNo'] as String? ?? '',
+    aeDd: _fmtDate(json['aeDd'] as String?),
+    reason: json['reason'] as String? ?? '',
+    headAmtnYj: (json['headAmtnYj'] as num?)?.toDouble() ?? 0,
+    itm: json['itm'] as int? ?? 0,
+    sqMan: json['sqMan'] as String? ?? '',
+    sqName: json['sqName'] as String? ?? '',
+    typeNo: json['typeNo'] as String? ?? '',
+    typeName: json['typeName'] as String? ?? '',
+    amtnYj: (json['amtnYj'] as num?)?.toDouble() ?? 0,
+    accNo: json['accNo'] as String? ?? '',
+    accName: json['accName'] as String? ?? '',
+    dep: json['dep'] as String? ?? '',
+    depName: json['depName'] as String? ?? '',
+    objNo: json['objNo'] as String? ?? '',
+    objName: json['objName'] as String? ?? '',
+    startDd: _fmtDate(json['startDd'] as String?),
+    endDd: _fmtDate(json['endDd'] as String?),
+    rem: json['rem'] as String? ?? '',
+  );
+
+  static String _fmtDate(String? raw) {
+    if (raw == null || raw.isEmpty) return '';
+    try {
+      final d = DateTime.parse(raw);
+      return '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
+    } catch (_) {
+      return raw;
+    }
+  }
+}
+
+class ExpenseApplyImportPage extends ConsumerStatefulWidget {
+  const ExpenseApplyImportPage({super.key});
+
+  @override
+  ConsumerState<ExpenseApplyImportPage> createState() => _ExpenseApplyImportPageState();
+}
+
+class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage> {
+  final _aeNoCtrl = TextEditingController();
+  final _startDateCtrl = TextEditingController();
+  final _endDateCtrl = TextEditingController();
+  List<ImportableItem> _items = [];
+  bool _loading = false;
+  bool _hasMore = true;
+  int _page = 1;
+  late final ScrollController _scrollCtrl;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollCtrl = ScrollController()..addListener(_onScroll);
+    WidgetsBinding.instance.addPostFrameCallback((_) => _load());
+  }
+
+  @override
+  void dispose() {
+    _aeNoCtrl.dispose(); _startDateCtrl.dispose(); _endDateCtrl.dispose();
+    _scrollCtrl.dispose();
+    super.dispose();
+  }
+
+  void _onScroll() {
+    if (_scrollCtrl.position.pixels >= _scrollCtrl.position.maxScrollExtent - 200) {
+      _load(append: true);
+    }
+  }
+
+  Future<void> _load({bool append = false}) async {
+    if (_loading) return;
+    setState(() => _loading = true);
+    try {
+      final api = ref.read(expenseApiProvider);
+      final result = await api.getImportableExpenseApplies(
+        aeNo: _aeNoCtrl.text.trim(),
+        startDate: _startDateCtrl.text,
+        endDate: _endDateCtrl.text,
+        page: append ? _page : 1,
+      );
+      if (!mounted) return;
+      final list = (result['list'] as List<dynamic>?)
+          ?.map((e) => ImportableItem.fromJson(e as Map<String, dynamic>))
+          .toList() ?? [];
+      setState(() {
+        if (append) { _items.addAll(list); _page++; } else { _items = list; _page = 2; }
+        _loading = false;
+        _hasMore = list.length >= 20;
+      });
+    } catch (_) {
+      if (mounted) setState(() => _loading = false);
+    }
+  }
+
+  void _search() {
+    _validateDates();
+    if (_startDateCtrl.text.isNotEmpty && _endDateCtrl.text.isNotEmpty && _startDateCtrl.text.compareTo(_endDateCtrl.text) > 0) return;
+    _page = 1; _load();
+  }
+
+  Future<void> _refresh() async {
+    _page = 1;
+    await _load();
+  }
+
+  void _toggleItem(int idx) {
+    setState(() => _items[idx].selected = !_items[idx].selected);
+  }
+
+  void _toggleGroup(String aeNo) {
+    setState(() {
+      final items = _items.where((e) => e.aeNo == aeNo).toList();
+      final allSelected = items.every((e) => e.selected);
+      final newVal = !allSelected;
+      for (final e in items) { e.selected = newVal; }
+    });
+  }
+
+  bool _isGroupAllSelected(String aeNo) {
+    final items = _items.where((e) => e.aeNo == aeNo);
+    if (items.isEmpty) return false;
+    return items.every((e) => e.selected);
+  }
+
+  bool _isGroupAnySelected(String aeNo) {
+    return _items.any((e) => e.aeNo == aeNo && e.selected);
+  }
+
+  void _confirmImport() {
+    final l10n = AppLocalizations.of(context);
+    final selected = _items.where((e) => e.selected).toList();
+    if (selected.isEmpty) {
+      TDToast.showText(l10n.get('pleaseSelect'), context: context);
+      return;
+    }
+    Navigator.of(context).pop(selected);
+  }
+
+  void _pickDate(TextEditingController ctrl) {
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final now = DateTime.now();
+    TDPicker.showDatePicker(
+      context,
+      title: l10n.get('selectDate'),
+      backgroundColor: colors.bgCard,
+      useYear: true, useMonth: true, useDay: true,
+      useHour: false, useMinute: false, useSecond: false, useWeekDay: false,
+      dateStart: const [2020, 1, 1],
+      dateEnd: [now.year + 1, 12, 31],
+      initialDate: [now.year, now.month, now.day],
+      onConfirm: (selected) {
+        final d = '${selected['year']}-${(selected['month'] ?? '').toString().padLeft(2, '0')}-${(selected['day'] ?? '').toString().padLeft(2, '0')}';
+        ctrl.text = d;
+        setState(() {});
+        _validateDates();
+      },
+    );
+  }
+
+  void _validateDates() {
+    if (_startDateCtrl.text.isNotEmpty && _endDateCtrl.text.isNotEmpty) {
+      if (_startDateCtrl.text.compareTo(_endDateCtrl.text) > 0) {
+        TDToast.showText(AppLocalizations.of(context).get('filterDateStartAfterEnd'), context: context);
+      }
+    }
+  }
+
+  Widget _buildSearchBar(AppLocalizations l10n, AppColorsExtension colors) {
+    return Container(
+      color: colors.bgCard,
+      padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
+      child: Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          _inputRow(
+            label: l10n.get('expenseApplyNo'),
+            controller: _aeNoCtrl,
+            hint: l10n.get('expenseApplyNo'),
+            onSubmitted: (_) => _search(),
+          ),
+          const SizedBox(height: 8),
+          Row(children: [
+            Expanded(
+              child: _pickerRow(
+                label: l10n.get('filterStartDate'), value: _startDateCtrl.text,
+                hint: l10n.get('filterStartDate'), hasValue: _startDateCtrl.text.isNotEmpty,
+                onClear: () { _startDateCtrl.clear(); setState(() {}); },
+                onTap: () => _pickDate(_startDateCtrl),
+              ),
+            ),
+            const SizedBox(width: 8),
+            Expanded(
+              child: _pickerRow(
+                label: l10n.get('filterEndDate'), value: _endDateCtrl.text,
+                hint: l10n.get('filterEndDate'), hasValue: _endDateCtrl.text.isNotEmpty,
+                onClear: () { _endDateCtrl.clear(); setState(() {}); },
+                onTap: () => _pickDate(_endDateCtrl),
+              ),
+            ),
+            const SizedBox(width: 8),
+            GestureDetector(
+              onTap: _search,
+              child: Container(
+                width: 40, height: 40,
+                decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)),
+                child: const Icon(Icons.search, color: Colors.white, size: 20),
+              ),
+            ),
+          ]),
+        ],
+      ),
+    );
+  }
+
+  Widget _inputRow({
+    required String label, required TextEditingController controller,
+    required String hint, ValueChanged<String>? onSubmitted,
+  }) {
+    final tdTheme = TDTheme.of(context);
+    return Container(
+      padding: const EdgeInsets.only(left: 16, right: 10, top: 10, bottom: 10),
+      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)),
+        const SizedBox(width: 12),
+        Expanded(
+          child: TextField(
+            controller: controller,
+            textAlign: TextAlign.end,
+            style: TextStyle(fontSize: 16, color: Theme.of(context).extension<AppColorsExtension>()!.textPrimary),
+            decoration: InputDecoration(
+              hintText: hint,
+              hintStyle: TextStyle(fontSize: 16, color: Theme.of(context).extension<AppColorsExtension>()!.textPlaceholder),
+              border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero,
+            ),
+            onSubmitted: onSubmitted,
+            onChanged: (_) => setState(() {}),
+          ),
+        ),
+        if (controller.text.isNotEmpty) ...[
+          const SizedBox(width: 4),
+          GestureDetector(
+            onTap: () { controller.clear(); setState(() {}); },
+            child: Icon(Icons.close, size: 18, color: tdTheme.textColorPlaceholder),
+          ),
+        ],
+      ]),
+    );
+  }
+
+  Widget _pickerRow({
+    required String label,
+    required String value,
+    required String hint,
+    required bool hasValue,
+    VoidCallback? onClear,
+    VoidCallback? onTap,
+    Widget? child,
+  }) {
+    final tdTheme = TDTheme.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    return GestureDetector(
+      onTap: onTap,
+      child: Container(
+        padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12),
+        decoration: BoxDecoration(
+          color: colors.bgSecondaryContainer,
+          borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
+          border: Border.all(color: tdTheme.componentStrokeColor),
+        ),
+        child: 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(value.isNotEmpty ? value : hint, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400,
+              textColor: value.isNotEmpty ? tdTheme.textColorPrimary : tdTheme.textColorPlaceholder, textAlign: TextAlign.end)),
+            const SizedBox(width: 4),
+            if (hasValue)
+              GestureDetector(onTap: onClear, child: Icon(Icons.close, size: 18, color: tdTheme.textColorPlaceholder))
+            else
+              Icon(Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder),
+          ])),
+        ]),
+      ),
+    );
+  }
+
+  Widget _buildSkeleton(AppColorsExtension colors) {
+    return ListView.builder(
+      itemCount: 5,
+      itemBuilder: (ctx, i) => Container(
+        margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
+        padding: const EdgeInsets.all(16),
+        decoration: BoxDecoration(color: colors.bgCard, borderRadius: BorderRadius.circular(12)),
+        child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+          Row(children: [
+            _skeletonBox(22, 22, colors),
+            const SizedBox(width: 8),
+            _skeletonBox(160, 16, colors),
+            const Spacer(),
+            _skeletonBox(80, 14, colors),
+          ]),
+          const SizedBox(height: 8),
+          _skeletonBox(200, 12, colors),
+          const SizedBox(height: 12),
+          Divider(height: 1, color: colors.border),
+          const SizedBox(height: 8),
+          Row(children: [
+            _skeletonBox(18, 18, colors),
+            const SizedBox(width: 8),
+            _skeletonBox(30, 12, colors),
+            const SizedBox(width: 8),
+            Expanded(child: _skeletonBox(double.infinity, 40, colors)),
+          ]),
+        ]),
+      ),
+    );
+  }
+
+  Widget _skeletonBox(double w, double h, AppColorsExtension colors) {
+    return Container(
+      width: w, height: h,
+      decoration: BoxDecoration(
+        color: colors.bgPage,
+        borderRadius: BorderRadius.circular(6),
+      ),
+    );
+  }
+
+  Widget _buildHeaderCheckbox(String aeNo, AppColorsExtension colors) {
+    final allSel = _isGroupAllSelected(aeNo);
+    final anySel = _isGroupAnySelected(aeNo);
+    IconData icon;
+    Color iconColor;
+    if (allSel) {
+      icon = Icons.check_box;
+      iconColor = colors.primary;
+    } else if (anySel) {
+      icon = Icons.indeterminate_check_box;
+      iconColor = colors.primary;
+    } else {
+      icon = Icons.check_box_outline_blank;
+      iconColor = colors.textPlaceholder;
+    }
+    return GestureDetector(
+      onTap: () => _toggleGroup(aeNo),
+      child: Icon(icon, size: 22, color: iconColor),
+    );
+  }
+
+  Widget _buildItemCheckbox(int idx, AppColorsExtension colors) {
+    final item = _items[idx];
+    return GestureDetector(
+      onTap: () => _toggleItem(idx),
+      child: Icon(
+        item.selected ? Icons.check_box : Icons.check_box_outline_blank,
+        size: 18,
+        color: item.selected ? colors.primary : colors.textPlaceholder,
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    ref.read(navBarConfigProvider.notifier).update(
+      NavBarConfig(title: l10n.get('importExpenseApply'), showBack: true),
+    );
+
+    final grouped = <String, List<ImportableItem>>{};
+    for (final item in _items) {
+      grouped.putIfAbsent(item.aeNo, () => []).add(item);
+    }
+
+    return Scaffold(
+      backgroundColor: colors.bgPage,
+      body: Column(children: [
+        _buildSearchBar(l10n, colors),
+        Expanded(
+          child: RefreshIndicator(
+            onRefresh: _refresh,
+            child: _loading && _items.isEmpty
+              ? _buildSkeleton(colors)
+              : _items.isEmpty
+                ? ListView(children: [SizedBox(height: MediaQuery.of(context).size.height * 0.3, child: Center(child: Text(l10n.get('noData'), style: TextStyle(fontSize: 14, color: colors.textPlaceholder))))])
+                : ListView.builder(
+                  controller: _scrollCtrl,
+                  itemCount: grouped.length + (_hasMore ? 1 : 0) + (_items.isNotEmpty && !_hasMore ? 1 : 0),
+                  itemBuilder: (ctx, i) {
+                    if (i >= grouped.length && _hasMore) {
+                      if (!_loading) { WidgetsBinding.instance.addPostFrameCallback((_) => _load(append: true)); }
+                      return const Center(child: Padding(padding: EdgeInsets.all(16), child: TDLoading(size: TDLoadingSize.medium, icon: TDLoadingIcon.activity)));
+                    }
+                    if (i >= grouped.length) {
+                      return Center(child: Padding(padding: const EdgeInsets.all(16), child: Text(l10n.get('noMoreData'), style: TextStyle(fontSize: 13, color: colors.textPlaceholder))));
+                    }
+                    final aeNo = grouped.keys.elementAt(i);
+                    final items = grouped[aeNo]!;
+                    return Container(
+                      margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
+                      decoration: BoxDecoration(
+                        color: colors.bgCard,
+                        borderRadius: BorderRadius.circular(12),
+                      ),
+                      child: Column(
+                        crossAxisAlignment: CrossAxisAlignment.start,
+                        children: [
+                          // Header row: 点击整行切换全选
+                          GestureDetector(
+                            behavior: HitTestBehavior.opaque,
+                            onTap: () => _toggleGroup(aeNo),
+                            child: Padding(
+                              padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
+                              child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+                                Row(children: [
+                                  _buildHeaderCheckbox(aeNo, colors),
+                                  const SizedBox(width: 8),
+                                  Expanded(child: Text(aeNo, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.textPrimary))),
+                                  Text(items.first.aeDd, style: TextStyle(fontSize: 13, color: colors.textSecondary)),
+                                ]),
+                                if (items.first.reason.isNotEmpty)
+                                  Padding(
+                                    padding: const EdgeInsets.only(top: 4, left: 30),
+                                    child: Text(items.first.reason, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 13, color: colors.textSecondary)),
+                                  ),
+                              ]),
+                            ),
+                          ),
+                          Divider(height: 1, color: colors.border),
+                          // Detail rows: 点击整行切换勾选
+                          ...items.asMap().entries.map((entry) {
+                            final idx = _items.indexOf(entry.value);
+                            final d = entry.value;
+                            return GestureDetector(
+                              behavior: HitTestBehavior.opaque,
+                              onTap: () => _toggleItem(idx),
+                              child: Padding(
+                                padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
+                                child: Row(children: [
+                                  SizedBox(width: 30, child: _buildItemCheckbox(idx, colors)),
+                                  const SizedBox(width: 4),
+                                  Container(width: 24, alignment: Alignment.center, child: Text('#${d.itm}', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: colors.textSecondary))),
+                                  const SizedBox(width: 8),
+                                  Expanded(
+                                    child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+                                      Text('${d.typeName.isNotEmpty ? '${d.typeNo}/${d.typeName}' : d.typeNo}  ${d.accName}', style: TextStyle(fontSize: 14, color: colors.textPrimary)),
+                                      const SizedBox(height: 3),
+                                      if (d.sqMan.isNotEmpty)
+                                        Padding(
+                                          padding: const EdgeInsets.only(bottom: 2),
+                                          child: Text('${l10n.get('applicant')}: ${d.sqName.isNotEmpty ? '${d.sqMan}/${d.sqName}' : d.sqMan}', style: TextStyle(fontSize: 13, color: colors.textSecondary)),
+                                        ),
+                                      Padding(
+                                        padding: const EdgeInsets.only(bottom: 2),
+                                        child: Text('${l10n.get('acctSubject')}: ${d.accNo}/${d.accName}', style: TextStyle(fontSize: 13, color: colors.textSecondary)),
+                                      ),
+                                      if (d.depName.isNotEmpty)
+                                        Padding(
+                                          padding: const EdgeInsets.only(bottom: 2),
+                                          child: Text('${l10n.get('dept')}: ${d.dep}/${d.depName}', style: TextStyle(fontSize: 13, color: colors.textSecondary)),
+                                        ),
+                                      if (d.objName.isNotEmpty)
+                                        Padding(
+                                          padding: const EdgeInsets.only(bottom: 2),
+                                          child: Text('${l10n.get('project')}: ${d.objNo}/${d.objName}', style: TextStyle(fontSize: 13, color: colors.textSecondary)),
+                                        ),
+                                      if (d.startDd.isNotEmpty || d.endDd.isNotEmpty)
+                                        Text('${l10n.get('date')}: ${d.startDd} ~ ${d.endDd}', style: TextStyle(fontSize: 13, color: colors.textSecondary)),
+                                    ]),
+                                  ),
+                                  Text('¥${d.amtnYj.toStringAsFixed(2)}', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.amountPrimary)),
+                                ]),
+                              ),
+                            );
+                          }),
+                          if (items.length > 1)
+                            Padding(
+                              padding: const EdgeInsets.fromLTRB(0, 4, 16, 12),
+                              child: Row(mainAxisAlignment: MainAxisAlignment.end, children: [
+                                Text('${l10n.get('total')} ${items.length} ${l10n.get('unitItem')}', style: TextStyle(fontSize: 12, color: colors.textSecondary)),
+                              ]),
+                            )
+                          else
+                            const SizedBox(height: 8),
+                        ],
+                      ),
+                    );
+                  },
+                ),
+          ),
+        ),
+      ]),
+      bottomNavigationBar: SafeArea(
+        child: Padding(
+          padding: const EdgeInsets.all(12),
+          child: TDButton(
+            text: l10n.get('confirmImport'),
+            size: TDButtonSize.large,
+            theme: TDButtonTheme.primary,
+            onTap: _confirmImport,
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 35 - 0
lib/features/expense/expense_create_controller.dart

@@ -78,10 +78,39 @@ class ExpenseCreateController extends StateNotifier<ExpenseCreateState> {
     state = ExpenseCreateState(expense: draft);
   }
 
+  void reset() {
+    state = ExpenseCreateState(
+      expense: ExpenseModel(
+        id: '',
+        expenseNo: '',
+        createTime: DateTime.now(),
+        updateTime: DateTime.now(),
+      ),
+    );
+  }
+
   void updatePurpose(String purpose) {
     state = state.copyWith(expense: state.expense.copyWith(purpose: purpose));
   }
 
+  ExpenseCreateState get currentState => state;
+
+  void updateAttachments(List<String> paths) {
+    state = state.copyWith(expense: state.expense.copyWith(attachments: paths));
+  }
+
+  void updatePaymentMethod(String method) {
+    state = state.copyWith(expense: state.expense.copyWith(paymentMethod: method));
+  }
+
+  void updateRemark(String remark) {
+    state = state.copyWith(expense: state.expense.copyWith(remark: remark));
+  }
+
+  void updateDept(String deptId, String deptName) {
+    state = state.copyWith(expense: state.expense.copyWith(deptId: deptId, deptName: deptName));
+  }
+
   void addDetail(ExpenseDetailModel detail) {
     final details = [...state.expense.details, detail];
     state = state.copyWith(expense: state.expense.copyWith(details: details));
@@ -92,6 +121,12 @@ class ExpenseCreateController extends StateNotifier<ExpenseCreateState> {
     state = state.copyWith(expense: state.expense.copyWith(details: details));
   }
 
+  void updateDetail(int index, ExpenseDetailModel detail) {
+    final details = [...state.expense.details];
+    details[index] = detail;
+    state = state.copyWith(expense: state.expense.copyWith(details: details));
+  }
+
   void recalculateAmount() {
     var totalAmount = 0.0;
     var approvedAmount = 0.0;

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 624 - 247
lib/features/expense/expense_create_page.dart


+ 92 - 26
lib/features/expense/expense_detail_page.dart

@@ -54,7 +54,7 @@ class ExpenseDetailPage extends ConsumerWidget {
                 const SizedBox(height: 16),
                 _buildExpenseDetailSection(expense, l10n, colors),
                 const SizedBox(height: 16),
-                _buildInvoiceSection(expense, l10n, colors),
+                _buildAttachmentSection(expense, l10n, colors),
                 if (isFinance) ...[
                   const SizedBox(height: 16),
                   _buildComplianceSection(expense, l10n, colors),
@@ -101,10 +101,15 @@ class ExpenseDetailPage extends ConsumerWidget {
   // ═══ 基本信息 + 收款账户 — 对应 create 页 basicInfo + 数据库 Expense 字段 ═══
   Widget _buildBasicInfoSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
     var pms = expense.paymentMethod;
-    if (pms == 'bankTransfer') pms = l10n.get('bankTransfer');
-    else if (pms == 'cash') pms = l10n.get('cash');
-    else if (pms == 'alipay') pms = l10n.get('alipay');
-    else if (pms == 'wechat') pms = l10n.get('wechat');
+    if (pms == 'bankTransfer') {
+      pms = l10n.get('bankTransfer');
+    } else if (pms == 'cash') {
+      pms = l10n.get('cash');
+    } else if (pms == 'alipay') {
+      pms = l10n.get('alipay');
+    } else if (pms == 'wechat') {
+      pms = l10n.get('wechat');
+    }
     return FormSection(
       title: l10n.get('basicInfo'),
       leadingIcon: Icons.info_outline,
@@ -128,20 +133,6 @@ class ExpenseDetailPage extends ConsumerWidget {
         ],
         const SizedBox(height: 16),
         FormFieldRow(label: l10n.get('paymentMethod'), value: pms.isNotEmpty ? pms : '-', readOnly: true, showArrow: false),
-        if (expense.details.any((d) => d.bankName.isNotEmpty)) ...[
-          const SizedBox(height: 16),
-          FormSection(title: l10n.get('receiptAccount'), leadingIcon: Icons.account_balance_outlined, children: [
-            for (final d in expense.details.where((d) => d.bankName.isNotEmpty))
-              Padding(
-                padding: const EdgeInsets.only(bottom: 12),
-                child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
-                  Text('${d.bankName}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary)),
-                  const SizedBox(height: 4),
-                  Text('${d.bankAccountName}  ${d.bankAccount}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
-                ]),
-              ),
-          ]),
-        ],
       ],
     );
   }
@@ -202,6 +193,35 @@ class ExpenseDetailPage extends ConsumerWidget {
                   Text(d.remark, maxLines: 2, overflow: TextOverflow.ellipsis,
                       style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
                 ],
+                if (d.attachments.isNotEmpty) ...[
+                  const SizedBox(height: 8),
+                  Wrap(
+                    spacing: 6,
+                    runSpacing: 6,
+                    children: d.attachments.map((path) {
+                      final name = path.split('/').last.split('\\').last;
+                      return Container(
+                        width: 60, height: 60,
+                        decoration: BoxDecoration(
+                          color: colors.primaryLight,
+                          borderRadius: BorderRadius.circular(4),
+                        ),
+                        child: Column(
+                          mainAxisAlignment: MainAxisAlignment.center,
+                          children: [
+                            Icon(_fileTypeIcon(path), size: 24, color: colors.primary),
+                            const SizedBox(height: 2),
+                            Padding(
+                              padding: const EdgeInsets.symmetric(horizontal: 2),
+                              child: Text(name, maxLines: 1, overflow: TextOverflow.ellipsis,
+                                  style: TextStyle(fontSize: 9, color: colors.textSecondary)),
+                            ),
+                          ],
+                        ),
+                      );
+                    }).toList(),
+                  ),
+                ],
               ]),
             );
           }),
@@ -217,19 +237,46 @@ class ExpenseDetailPage extends ConsumerWidget {
     );
   }
 
-  // ═══ 发票附件 ═══
-  Widget _buildInvoiceSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
+  // ═══ 附件 ═══
+  Widget _buildAttachmentSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
     return FormSection(
-      title: l10n.get('invoiceAttachment'),
+      title: l10n.get('attachments'),
       leadingIcon: Icons.attach_file_outlined,
       children: [
         if (expense.attachments.isEmpty)
           Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))
         else
-          Wrap(spacing: 8, runSpacing: 8, children: expense.attachments.map((url) {
-            return Container(width: 80, height: 80,
-                decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border)),
-                child: Center(child: Icon(Icons.image_outlined, size: 24, color: colors.textPlaceholder)));
+          Wrap(spacing: 8, runSpacing: 8, children: expense.attachments.map((path) {
+            final name = path.split('/').last.split('\\').last;
+            return SizedBox(
+              width: 80,
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  Container(
+                    width: 80, height: 80,
+                    decoration: BoxDecoration(
+                      color: colors.bgPage, borderRadius: BorderRadius.circular(4),
+                      border: Border.all(color: colors.border),
+                    ),
+                    child: Center(
+                      child: Icon(
+                        _fileTypeIcon(path),
+                        size: 28,
+                        color: colors.primary,
+                      ),
+                    ),
+                  ),
+                  const SizedBox(height: 4),
+                  Text(
+                    name,
+                    maxLines: 1,
+                    overflow: TextOverflow.ellipsis,
+                    style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
+                  ),
+                ],
+              ),
+            );
           }).toList()),
       ],
     );
@@ -376,4 +423,23 @@ class ExpenseDetailPage extends ConsumerWidget {
       onRightTap: null,
     );
   }
+
+  IconData _fileTypeIcon(String path) {
+    final ext = path.split('.').last.toLowerCase();
+    switch (ext) {
+      case 'pdf':
+        return Icons.picture_as_pdf;
+      case 'doc':
+      case 'docx':
+        return Icons.description;
+      case 'xls':
+      case 'xlsx':
+        return Icons.table_chart;
+      case 'ppt':
+      case 'pptx':
+        return Icons.slideshow;
+      default:
+        return Icons.insert_drive_file;
+    }
+  }
 }

+ 72 - 40
lib/features/expense/expense_model.dart

@@ -49,7 +49,7 @@ class ExpenseModel {
     this.applicantName = '',
     this.deptId = '',
     this.deptName = '',
-    this.currencyCode = 'CNY',
+    this.currencyCode = '',
     this.isGenerateVoucher = false,
     this.voucherNo = '',
     this.approvedAmount = 0.0,
@@ -80,38 +80,42 @@ class ExpenseModel {
 
   factory ExpenseModel.fromJson(Map<String, dynamic> json) {
     return ExpenseModel(
-      id: json['id'] as String,
-      expenseNo: json['expenseNo'] as String? ?? '',
-      expenseDate: json['expenseDate'] != null
-          ? DateTime.parse(json['expenseDate'] as String)
+      id: json['id'] as String? ?? '',
+      expenseNo: json['bxNo'] as String? ?? '',
+      expenseDate: json['bxDate'] != null
+          ? DateTime.parse(json['bxDate'] as String)
           : null,
-      applicantId: json['applicantId'] as String? ?? '',
-      applicantName: json['applicantName'] as String? ?? '',
-      deptId: json['deptId'] as String? ?? '',
-      deptName: json['deptName'] as String? ?? '',
-      currencyCode: json['currencyCode'] as String? ?? 'CNY',
+      applicantId: json['usr'] as String? ?? '',
+      applicantName: json['applicantName'] as String? ?? json['usr'] as String? ?? '',
+      deptId: json['dep'] as String? ?? '',
+      deptName: json['deptName'] as String? ?? json['dep'] as String? ?? '',
+      currencyCode: json['currencyCode'] as String? ?? '',
       isGenerateVoucher: json['isGenerateVoucher'] as bool? ?? false,
       voucherNo: json['voucherNo'] as String? ?? '',
       approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0.0,
       totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0.0,
-      purpose: json['purpose'] as String? ?? '',
-      remark: json['remark'] as String? ?? '',
+      purpose: json['reason'] as String? ?? '',
+      remark: json['rem'] as String? ?? '',
       paymentMethod: json['paymentMethod'] as String? ?? '',
       isInvoiceVerified: json['isInvoiceVerified'] as bool? ?? false,
       isTaxIdMatched: json['isTaxIdMatched'] as bool? ?? false,
       isCategoryCompliant: json['isCategoryCompliant'] as bool? ?? false,
       bankTransferNo: json['bankTransferNo'] as String? ?? '',
       paymentStatus: json['paymentStatus'] as String? ?? 'unpaid',
-      status: json['status'] as String? ?? 'draft',
+      status: json['clsDate'] != null ? 'closed' : (json['status'] as String? ?? 'draft'),
       approvalInstanceId: json['approvalInstanceId'] as String? ?? '',
       previousInstanceIds: json['previousInstanceIds'] as String? ?? '',
-      effectiveDate: json['effectiveDate'] != null
-          ? DateTime.parse(json['effectiveDate'] as String)
+      effectiveDate: json['effDd'] != null
+          ? DateTime.parse(json['effDd'] as String)
           : null,
-      auditorId: json['auditorId'] as String? ?? '',
+      auditorId: json['chkMan'] as String? ?? '',
       version: json['version'] as int? ?? 1,
-      createTime: DateTime.parse(json['createTime'] as String),
-      updateTime: DateTime.parse(json['updateTime'] as String),
+      createTime: json['createTime'] != null
+          ? DateTime.parse(json['createTime'] as String)
+          : DateTime.now(),
+      updateTime: json['updateTime'] != null
+          ? DateTime.parse(json['updateTime'] as String)
+          : DateTime.now(),
       isDeleted: json['isDeleted'] as bool? ?? false,
       currentApproverId: json['currentApproverId'] as String? ?? '',
       approvalChain:
@@ -279,6 +283,10 @@ class ExpenseDetailModel {
   final String bankName;
   final String bankAccountName;
   final String bankAccount;
+  final String sqMan;
+  final String sqManName;
+  final String aeNo;
+  final String aeDd;
   final String remark;
   final int sortOrder;
   final List<String> attachments;
@@ -304,7 +312,7 @@ class ExpenseDetailModel {
     this.taxRate = 0.0,
     this.taxAmount = 0.0,
     required this.totalAmount,
-    this.currencyCode = 'CNY',
+    this.currencyCode = '',
     this.exchangeRate = 1.0,
     this.baseAmount = 0.0,
     this.approvedAmount = 0.0,
@@ -314,6 +322,10 @@ class ExpenseDetailModel {
     this.bankName = '',
     this.bankAccountName = '',
     this.bankAccount = '',
+    this.sqMan = '',
+    this.sqManName = '',
+    this.aeNo = '',
+    this.aeDd = '',
     this.remark = '',
     this.sortOrder = 1,
     this.attachments = const [],
@@ -324,44 +336,52 @@ class ExpenseDetailModel {
 
   factory ExpenseDetailModel.fromJson(Map<String, dynamic> json) {
     return ExpenseDetailModel(
-      id: json['id'] as String,
+      id: json['id'] as String? ?? '',
       expenseId: json['expenseId'] as String? ?? '',
-      expenseApplyId: json['expenseApplyId'] as String? ?? '',
-      expenseApplyNo: json['expenseApplyNo'] as String? ?? '',
-      expenseApplyDate: json['expenseApplyDate'] != null
-          ? DateTime.parse(json['expenseApplyDate'] as String)
+      expenseApplyId: json['aeNo'] as String? ?? '',
+      expenseApplyNo: json['aeNo'] as String? ?? '',
+      expenseApplyDate: json['aeDd'] != null
+          ? DateTime.parse(json['aeDd'] as String)
           : null,
       expenseCategory: json['expenseCategory'] as String? ?? '',
       purpose: json['purpose'] as String? ?? '',
-      projectId: json['projectId'] as String? ?? '',
-      projectName: json['projectName'] as String? ?? '',
-      costDeptId: json['costDeptId'] as String? ?? '',
-      costDeptName: json['costDeptName'] as String? ?? '',
-      acctSubjectId: json['acctSubjectId'] as String? ?? '',
-      acctSubjectName: json['acctSubjectName'] as String? ?? '',
-      amount: (json['amount'] as num?)?.toDouble() ?? 0.0,
-      taxRate: (json['taxRate'] as num?)?.toDouble() ?? 0.0,
-      taxAmount: (json['taxAmount'] as num?)?.toDouble() ?? 0.0,
-      totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0.0,
-      currencyCode: json['currencyCode'] as String? ?? 'CNY',
+      projectId: json['objNo'] as String? ?? '',
+      projectName: json['objName'] as String? ?? json['projectName'] as String? ?? '',
+      costDeptId: json['dep'] as String? ?? '',
+      costDeptName: json['depName'] as String? ?? json['dep'] as String? ?? '',
+      acctSubjectId: json['accNo'] as String? ?? '',
+      acctSubjectName: json['accName'] as String? ?? '',
+      amount: (json['amt'] as num?)?.toDouble() ?? 0.0,
+      taxRate: (json['taxRto'] as num?)?.toDouble() ?? 0.0,
+      taxAmount: (json['tax'] as num?)?.toDouble() ?? 0.0,
+      totalAmount: (json['amtn'] as num?)?.toDouble() ?? 0.0,
+      currencyCode: json['currencyCode'] as String? ?? '',
       exchangeRate: (json['exchangeRate'] as num?)?.toDouble() ?? 1.0,
       baseAmount: (json['baseAmount'] as num?)?.toDouble() ?? 0.0,
       approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0.0,
       customerVendorId: json['customerVendorId'] as String? ?? '',
-      customerVendorName: json['customerVendorName'] as String? ?? '',
+      customerVendorName: json['cust'] as String? ?? '',
       offsetAmount: (json['offsetAmount'] as num?)?.toDouble() ?? 0.0,
       bankName: json['bankName'] as String? ?? '',
       bankAccountName: json['bankAccountName'] as String? ?? '',
       bankAccount: json['bankAccount'] as String? ?? '',
-      remark: json['remark'] as String? ?? '',
-      sortOrder: json['sortOrder'] as int? ?? 1,
+      sqMan: json['sqMan'] as String? ?? '',
+      sqManName: json['sqName'] as String? ?? json['sqManName'] as String? ?? json['sqMan'] as String? ?? '',
+      aeNo: json['aeNo'] as String? ?? '',
+      aeDd: json['aeDd'] as String? ?? '',
+      remark: json['rem'] as String? ?? '',
+      sortOrder: json['itm'] as int? ?? 1,
       attachments:
           (json['attachments'] as List<dynamic>?)
               ?.map((e) => e as String)
               .toList() ??
           [],
-      createTime: DateTime.parse(json['createTime'] as String),
-      updateTime: DateTime.parse(json['updateTime'] as String),
+      createTime: json['createTime'] != null
+          ? DateTime.parse(json['createTime'] as String)
+          : DateTime.now(),
+      updateTime: json['updateTime'] != null
+          ? DateTime.parse(json['updateTime'] as String)
+          : DateTime.now(),
       isDeleted: json['isDeleted'] as bool? ?? false,
     );
   }
@@ -394,6 +414,10 @@ class ExpenseDetailModel {
     'bankName': bankName,
     'bankAccountName': bankAccountName,
     'bankAccount': bankAccount,
+    'sqMan': sqMan,
+    'sqManName': sqManName,
+    'aeNo': aeNo,
+    'aeDd': aeDd,
     'remark': remark,
     'sortOrder': sortOrder,
     'attachments': attachments,
@@ -430,6 +454,10 @@ class ExpenseDetailModel {
     String? bankName,
     String? bankAccountName,
     String? bankAccount,
+    String? sqMan,
+    String? sqManName,
+    String? aeNo,
+    String? aeDd,
     String? remark,
     int? sortOrder,
     List<String>? attachments,
@@ -465,6 +493,10 @@ class ExpenseDetailModel {
       bankName: bankName ?? this.bankName,
       bankAccountName: bankAccountName ?? this.bankAccountName,
       bankAccount: bankAccount ?? this.bankAccount,
+      sqMan: sqMan ?? this.sqMan,
+      sqManName: sqManName ?? this.sqManName,
+      aeNo: aeNo ?? this.aeNo,
+      aeDd: aeDd ?? this.aeDd,
       remark: remark ?? this.remark,
       sortOrder: sortOrder ?? this.sortOrder,
       attachments: attachments ?? this.attachments,

+ 437 - 147
lib/features/expense/widgets/expense_detail_dialog.dart

@@ -5,27 +5,59 @@ 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 '../expense_api.dart';
+import '../../../shared/widgets/attachment_picker.dart';
 
 /// 报销明细输入数据。
 class ExpenseDetailInputData {
   final String category;
   final String categoryName;
+  final String acctSubjectId;
+  final String acctSubjectName;
   final String purpose;
   final double amount; // 含税金额
   final double taxRate;
+  final String projectId;
+  final String projectName;
+  final String costDeptId;
+  final String costDeptName;
+  final String customerVendorId;
   final String customerVendorName;
   final double offsetAmount;
+  final String bankName;
+  final String bankAccountName;
+  final String bankAccount;
   final String remark;
+  final List<String> attachmentPaths;
+  final String sqMan;
+  final String sqManName;
+  final String aeNo;
+  final String aeDd;
 
   const ExpenseDetailInputData({
     required this.category,
     required this.categoryName,
+    required this.acctSubjectId,
+    required this.acctSubjectName,
     required this.purpose,
     required this.amount,
     required this.taxRate,
+    this.projectId = '',
+    this.projectName = '',
+    this.costDeptId = '',
+    this.costDeptName = '',
+    this.customerVendorId = '',
     this.customerVendorName = '',
     this.offsetAmount = 0.0,
+    this.bankName = '',
+    this.bankAccountName = '',
+    this.bankAccount = '',
     this.remark = '',
+    this.attachmentPaths = const [],
+    this.sqMan = '',
+    this.sqManName = '',
+    this.aeNo = '',
+    this.aeDd = '',
   });
 }
 
@@ -35,19 +67,34 @@ class ExpenseDetailInputData {
 /// 参照 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 AppLocalizations l10n;
+  final ExpenseDetailInputData? initialData;
 
   const ExpenseDetailDialog({
     super.key,
     required this.categories,
+    required this.projects,
+    required this.costDepts,
+    required this.customers,
+    required this.employees,
     required this.l10n,
+    this.initialData,
   });
 
   /// 显示弹窗,返回 [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 AppLocalizations l10n,
+    ExpenseDetailInputData? initialData,
   }) {
     FocusScope.of(context).unfocus();
     return Navigator.push<ExpenseDetailInputData>(
@@ -57,7 +104,12 @@ class ExpenseDetailDialog extends StatefulWidget {
         isDismissible: false,
         builder: (_) => ExpenseDetailDialog(
           categories: categories,
+          projects: projects,
+          costDepts: costDepts,
+          customers: customers,
+          employees: employees,
           l10n: l10n,
+          initialData: initialData,
         ),
       ),
     );
@@ -73,10 +125,18 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
   late String _catLabel;
   late TextEditingController _descCtrl;
   late TextEditingController _amountCtrl;
-  late TextEditingController _customerCtrl;
+  CustomerVendor? _selCustomer;
   late TextEditingController _offsetCtrl;
   late TextEditingController _remarkCtrl;
+  late TextEditingController _bankNameCtrl;
+  late TextEditingController _bankAccountNameCtrl;
+  late TextEditingController _bankAccountCtrl;
   double _taxRate = 0.06;
+  Project? _selProject;
+  CostDept? _selDept;
+  EmployeeItem? _selEmployee;
+  late final AttachmentPickerController _attachmentCtrl;
+  final ScrollController _scrollCtrl = ScrollController();
 
   static const _taxOptions = [0.06, 0.09, 0.13];
   static const _taxLabels = ['6%', '9%', '13%'];
@@ -84,25 +144,62 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
   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();
-    _cat = _cats.isNotEmpty ? _cats.first.code : 'other';
+    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';
     _catLabel = _l10n.get(_cats.firstWhere((c) => c.code == _cat).nameKey);
-    _descCtrl = TextEditingController();
-    _amountCtrl = TextEditingController();
-    _customerCtrl = TextEditingController();
-    _offsetCtrl = TextEditingController();
-    _remarkCtrl = TextEditingController();
+    _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: '', name: d.customerVendorName);
+    }
+    _offsetCtrl = TextEditingController(text: d != null && d.offsetAmount > 0 ? d.offsetAmount.toStringAsFixed(2) : '');
+    _remarkCtrl = TextEditingController(text: d?.remark ?? '');
+    _bankNameCtrl = TextEditingController(text: d?.bankName ?? '');
+    _bankAccountNameCtrl = TextEditingController(text: d?.bankAccountName ?? '');
+    _bankAccountCtrl = TextEditingController(text: d?.bankAccount ?? '');
+    _taxRate = d?.taxRate ?? 0.13;
+    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);
+        });
+      }
+    }
+    _attachmentCtrl = AttachmentPickerController(maxCount: 9)
+      ..addListener(() => setState(() {}));
   }
 
   @override
   void dispose() {
     _descCtrl.dispose();
     _amountCtrl.dispose();
-    _customerCtrl.dispose();
     _offsetCtrl.dispose();
     _remarkCtrl.dispose();
+    _bankNameCtrl.dispose();
+    _bankAccountNameCtrl.dispose();
+    _bankAccountCtrl.dispose();
+    _attachmentCtrl.dispose();
+    _scrollCtrl.dispose();
     super.dispose();
   }
 
@@ -121,15 +218,28 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
       context,
       ExpenseDetailInputData(
         category: _cat,
-        categoryName: _l10n.get(
-          _cats.firstWhere((c) => c.code == _cat).nameKey,
-        ),
+        categoryName: _l10n.get(_selCat.nameKey),
+        acctSubjectId: _selCat.acctSubjectId,
+        acctSubjectName: _selCat.acctSubjectName,
         purpose: desc,
         amount: amount,
         taxRate: _taxRate,
-        customerVendorName: _customerCtrl.text.trim(),
+        projectId: _selProject?.id.toString() ?? '',
+        projectName: _selProject?.name ?? '',
+        costDeptId: _selDept?.id ?? '',
+        costDeptName: _selDept?.name ?? '',
+        customerVendorId: _selCustomer?.id ?? '',
+        customerVendorName: _selCustomer?.name ?? '',
         offsetAmount: double.tryParse(_offsetCtrl.text) ?? 0,
+        bankName: _bankNameCtrl.text.trim(),
+        bankAccountName: _bankAccountNameCtrl.text.trim(),
+        bankAccount: _bankAccountCtrl.text.trim(),
         remark: _remarkCtrl.text.trim(),
+        attachmentPaths: _attachmentCtrl.toPathList(),
+        sqMan: _selEmployee?.salNo ?? '',
+        sqManName: _selEmployee?.name ?? '',
+        aeNo: widget.initialData?.aeNo ?? '',
+        aeDd: widget.initialData?.aeDd ?? '',
       ),
     );
   }
@@ -146,9 +256,13 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     final bottomInset = MediaQuery.of(context).viewInsets.bottom;
 
     return SafeArea(
-      child: Padding(
-        padding: EdgeInsets.only(bottom: bottomInset),
-        child: Container(
+      child: ConstrainedBox(
+        constraints: BoxConstraints(
+          maxHeight: MediaQuery.of(context).size.height * 0.8,
+        ),
+        child: Padding(
+          padding: EdgeInsets.only(bottom: bottomInset),
+          child: Container(
           decoration: BoxDecoration(
             color: colors.bgPage,
             borderRadius:
@@ -160,32 +274,55 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
             children: [
               _buildHeader(colors),
               Flexible(
+                child: GestureDetector(
+                onTap: () => FocusScope.of(context).unfocus(),
+                behavior: HitTestBehavior.translucent,
                 child: SingleChildScrollView(
+                  controller: _scrollCtrl,
+                  keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
                   padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
                   child: Column(
                     mainAxisSize: MainAxisSize.min,
                     crossAxisAlignment: CrossAxisAlignment.stretch,
                     children: [
+                      if (_isEdit && widget.initialData!.aeNo.isNotEmpty) ...[
+                        _buildAeInfoCard(colors),
+                        const SizedBox(height: 12),
+                      ],
                       _buildCategoryCard(colors),
                       const SizedBox(height: 12),
-                      _buildDescCard(),
+                      _buildAcctSubjectCard(colors),
+                      const SizedBox(height: 12),
+                      _buildPurposeInput(colors),
                       const SizedBox(height: 12),
                       _buildAmountCard(),
                       const SizedBox(height: 12),
                       _buildTaxRateCard(colors),
+                      if ((double.tryParse(_amountCtrl.text) ?? 0) > 0) ...[
+                        const SizedBox(height: 12),
+                        _buildCalcInfo(colors),
+                      ],
+                      const SizedBox(height: 12),
+                      _buildProjectCard(colors),
+                      const SizedBox(height: 12),
+                      _buildCostDeptCard(colors),
+                      const SizedBox(height: 12),
+                      _buildEmployeeCard(colors),
                       const SizedBox(height: 12),
-                      // 自动计算展示
-                      _buildCalcInfo(colors),
+                      _buildBankInfoCard(colors),
                       const SizedBox(height: 12),
-                      _buildCustomerCard(),
+                      _buildCustomerCard(colors),
                       const SizedBox(height: 12),
                       _buildOffsetCard(),
                       const SizedBox(height: 12),
-                      _buildRemarkCard(colors),
+                      _buildRemarkInput(colors),
+                      const SizedBox(height: 12),
+                      _buildAttachmentCard(colors),
                     ],
                   ),
                 ),
               ),
+              ),
               Container(
                 padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
                 decoration: BoxDecoration(
@@ -200,6 +337,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
           ),
         ),
       ),
+      ),
     );
   }
 
@@ -262,8 +400,10 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     required List<String> labels,
     required ValueChanged<int> onSelected,
     required AppColorsExtension colors,
+    VoidCallback? onClear,
   }) {
     final tdTheme = TDTheme.of(context);
+    final hasValue = onClear != null;
     return GestureDetector(
       onTap: () {
         TDPicker.showMultiPicker(
@@ -283,9 +423,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
         );
       },
       child: Container(
-        padding: const EdgeInsets.only(
-          left: 16, right: 16, 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),
@@ -293,49 +431,21 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
         ),
         child: Row(
           children: [
-            TDText(
-              label,
-              maxLines: 1,
-              overflow: TextOverflow.visible,
-              font: tdTheme.fontBodyLarge,
-              fontWeight: FontWeight.w400,
-              style: const TextStyle(letterSpacing: 0),
-            ),
+            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),
-                ),
-              ),
+              Padding(padding: const EdgeInsets.only(left: 4), child: TDText('*', font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: TextStyle(color: tdTheme.errorColor6))),
             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,
-                    ),
-                  ),
-                  const SizedBox(width: 4),
-                  Icon(
-                    Icons.chevron_right,
-                    size: 18,
-                    color: tdTheme.textColorPlaceholder,
-                  ),
-                ],
-              ),
+              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)),
+                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),
+                ),
+              ]),
             ),
           ],
         ),
@@ -358,49 +468,98 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     );
   }
 
-  // ── 费用项目 ──
-  Widget _buildDescCard() {
-    final screenWidth = MediaQuery.of(context).size.width;
-    return TDInput(
-      type: TDInputType.cardStyle,
-      cardStyle: TDCardStyle.topText,
-      width: screenWidth - 32,
-      leftLabel: _l10n.get('expenseName'),
-      required: true,
+  // ── 输入卡片(对齐 pickerCard 样式) ──
+  Widget _inputCard({
+    required String label,
+    required bool required,
+    required TextEditingController controller,
+    required String hintText,
+    required AppColorsExtension colors,
+    TextInputType? keyboardType,
+    List<TextInputFormatter>? inputFormatters,
+  }) {
+    final tdTheme = TDTheme.of(context);
+    final hasValue = controller.text.isNotEmpty;
+    return Container(
+      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))),
+          const SizedBox(width: 12),
+          Expanded(
+            child: Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [
+              Flexible(
+                child: TextField(
+                  controller: controller,
+                  textAlign: TextAlign.end,
+                  keyboardType: keyboardType,
+                  inputFormatters: inputFormatters,
+                  style: TextStyle(fontSize: 16, color: colors.textPrimary),
+                  decoration: InputDecoration(
+                    hintText: hintText,
+                    hintStyle: TextStyle(fontSize: 16, color: colors.textPlaceholder),
+                    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,
+              ),
+            ]),
+          ),
+        ],
+      ),
+    );
+  }
+
+  // ── 费用事由 ──
+  Widget _buildPurposeInput(AppColorsExtension colors) {
+    final tdTheme = TDTheme.of(context);
+    return TDTextarea(
       controller: _descCtrl,
-      hintText: _l10n.get('enterExpenseName'),
-      contentAlignment: TextAlign.center,
-      showBottomDivider: false,
+      label: _l10n.get('feeReason'),
+      required: true,
+      hintText: _l10n.get('enterFeeReason'),
+      maxLines: 3,
+      minLines: 1,
+      maxLength: 500,
+      indicator: true,
+      decoration: BoxDecoration(
+        color: tdTheme.bgColorContainer,
+        borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
+        border: Border.all(color: tdTheme.componentStrokeColor),
+      ),
       onChanged: (_) => setState(() {}),
-      onClearTap: () {
-        _descCtrl.clear();
-        setState(() {});
-      },
     );
   }
 
-  // ── 金额(含税) ──
+  // ── 含税金额 ──
   Widget _buildAmountCard() {
-    final screenWidth = MediaQuery.of(context).size.width;
-    return TDInput(
-      type: TDInputType.cardStyle,
-      cardStyle: TDCardStyle.topText,
-      width: screenWidth - 32,
-      leftLabel: _l10n.get('amountInclTax'),
+    return _inputCard(
+      label: _l10n.get('amountInclTax'),
       required: true,
       controller: _amountCtrl,
       hintText: '>0',
-      contentAlignment: TextAlign.center,
-      inputType: const TextInputType.numberWithOptions(decimal: true),
-      inputFormatters: [
-        FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
-      ],
-      showBottomDivider: false,
-      onChanged: (_) => setState(() {}),
-      onClearTap: () {
-        _amountCtrl.clear();
-        setState(() {});
-      },
+      colors: Theme.of(context).extension<AppColorsExtension>()!,
+      keyboardType: const TextInputType.numberWithOptions(decimal: true),
+      inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$'))],
     );
   }
 
@@ -427,7 +586,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     return Container(
       padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
       decoration: BoxDecoration(
-        color: colors.primary50,
+        color: colors.primaryLight,
         borderRadius: BorderRadius.circular(8),
       ),
       child: Row(
@@ -453,52 +612,110 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     );
   }
 
+  // ── 导入单据信息 ──
+  Widget _buildAeInfoCard(AppColorsExtension colors) {
+    final d = widget.initialData!;
+    final tdTheme = TDTheme.of(context);
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+      decoration: _cardDecoration(tdTheme),
+      child: Column(
+        children: [
+          _readOnlyRow(tdTheme, _l10n.get('expenseApplyNo'), d.aeNo),
+          const SizedBox(height: 8),
+          _readOnlyRow(tdTheme, _l10n.get('applyDate'), d.aeDd),
+        ],
+      ),
+    );
+  }
+
+  Widget _readOnlyRow(TDThemeData tdTheme, String label, String value) {
+    return Row(children: [
+      TDText(label, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
+      const SizedBox(width: 12),
+      Expanded(child: TDText(value, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end)),
+    ]);
+  }
+
+  BoxDecoration _cardDecoration(TDThemeData tdTheme) => BoxDecoration(
+    color: tdTheme.bgColorContainer,
+    borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
+    border: Border.all(color: tdTheme.componentStrokeColor),
+  );
+
+  // ── 申请人 ──
+  Widget _buildEmployeeCard(AppColorsExtension colors) {
+    final employees = widget.employees;
+    return _pickerCard(
+      label: _l10n.get('applicant'),
+      required: false,
+      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;
+      }),
+      onClear: _selEmployee != null ? () => setState(() {
+        _selEmployee = null;
+        _bankNameCtrl.clear();
+        _bankAccountNameCtrl.clear();
+        _bankAccountCtrl.clear();
+      }) : null,
+    );
+  }
+
   // ── 客户/厂商 ──
-  Widget _buildCustomerCard() {
-    final screenWidth = MediaQuery.of(context).size.width;
-    return TDInput(
-      type: TDInputType.cardStyle,
-      cardStyle: TDCardStyle.topText,
-      width: screenWidth - 32,
-      leftLabel: _l10n.get('customerVendor'),
-      controller: _customerCtrl,
-      hintText: _l10n.get('optional'),
-      contentAlignment: TextAlign.center,
-      showBottomDivider: false,
-      onChanged: (_) => setState(() {}),
-      onClearTap: () {
-        _customerCtrl.clear();
-        setState(() {});
-      },
+  Widget _buildCustomerCard(AppColorsExtension colors) {
+    final vendors = widget.customers;
+    return _pickerCard(
+      label: _l10n.get('customerVendor'),
+      required: false,
+      currentLabel: _selCustomer?.name ?? _l10n.get('pleaseSelect'),
+      labels: vendors.map((v) => v.name).toList(),
+      colors: colors,
+      onSelected: (idx) => setState(() => _selCustomer = vendors[idx]),
+      onClear: _selCustomer != null ? () => setState(() => _selCustomer = null) : null,
     );
   }
 
   // ── 已充金额 ──
   Widget _buildOffsetCard() {
-    final screenWidth = MediaQuery.of(context).size.width;
-    return TDInput(
-      type: TDInputType.cardStyle,
-      cardStyle: TDCardStyle.topText,
-      width: screenWidth - 32,
-      leftLabel: _l10n.get('offsetAmount'),
+    return _inputCard(
+      label: _l10n.get('offsetAmount'),
+      required: false,
       controller: _offsetCtrl,
       hintText: '0',
-      contentAlignment: TextAlign.center,
-      inputType: const TextInputType.numberWithOptions(decimal: true),
-      inputFormatters: [
-        FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
-      ],
-      showBottomDivider: false,
-      onChanged: (_) => setState(() {}),
-      onClearTap: () {
-        _offsetCtrl.clear();
-        setState(() {});
-      },
+      colors: Theme.of(context).extension<AppColorsExtension>()!,
+      keyboardType: const TextInputType.numberWithOptions(decimal: true),
+      inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$'))],
     );
   }
 
   // ── 备注 ──
-  Widget _buildRemarkCard(AppColorsExtension colors) {
+  Widget _buildRemarkInput(AppColorsExtension colors) {
+    final tdTheme = TDTheme.of(context);
+    return TDTextarea(
+      controller: _remarkCtrl,
+      label: _l10n.get('remark'),
+      hintText: _l10n.get('enterRemark'),
+      maxLines: 3,
+      minLines: 1,
+      maxLength: 500,
+      indicator: true,
+      decoration: BoxDecoration(
+        color: tdTheme.bgColorContainer,
+        borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
+        border: Border.all(color: tdTheme.componentStrokeColor),
+      ),
+      onChanged: (_) => setState(() {}),
+    );
+  }
+
+  // ── 操作按钮 ──
+  Widget _buildAttachmentCard(AppColorsExtension colors) {
     final tdTheme = TDTheme.of(context);
     return Column(
       crossAxisAlignment: CrossAxisAlignment.start,
@@ -506,30 +723,103 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
         Padding(
           padding: const EdgeInsets.only(left: 4),
           child: TDText(
-            _l10n.get('detailRemark'),
+            _l10n.get('attachmentUpload'),
             font: tdTheme.fontBodyLarge,
             fontWeight: FontWeight.w400,
             style: const TextStyle(letterSpacing: 0),
           ),
         ),
-        const SizedBox(height: 8),
-        TDTextarea(
-          controller: _remarkCtrl,
-          hintText: _l10n.get('optional'),
-          maxLines: 3,
-          minLines: 1,
-          maxLength: 200,
-          indicator: true,
-          padding: EdgeInsets.zero,
-          bordered: true,
-          inputType: TextInputType.multiline,
-          backgroundColor: tdTheme.bgColorContainer,
+        const SizedBox(height: 4),
+        AttachmentPicker(
+          controller: _attachmentCtrl,
+          maxImageSizeMB: 10,
+          maxFileSizeMB: 20,
+          allowedExtensions: const ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'],
+          onFileRejected: (file, reason) {
+            if (context.mounted) TDToast.showText(reason, context: context);
+          },
         ),
       ],
     );
   }
 
-  // ── 操作按钮 ──
+  // ── 会计科目(只读,选择类别后自动带出) ──
+  Widget _buildAcctSubjectCard(AppColorsExtension colors) {
+    return _readOnlyCard(
+      label: _l10n.get('acctSubject'),
+      value: '${_selCat.acctSubjectId} ${_selCat.acctSubjectName}',
+      colors: colors,
+    );
+  }
+
+  Widget _readOnlyCard({
+    required String label,
+    required String value,
+    required AppColorsExtension colors,
+  }) {
+    final tdTheme = TDTheme.of(context);
+    return Container(
+      padding: const EdgeInsets.only(left: 16, right: 16, 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)),
+          const SizedBox(width: 12),
+          Expanded(
+            child: TDText(value, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400,
+              textColor: tdTheme.textColorPrimary, textAlign: TextAlign.end),
+          ),
+        ],
+      ),
+    );
+  }
+
+  // ── 关联项目 ──
+  Widget _buildProjectCard(AppColorsExtension colors) {
+    final projects = widget.projects;
+    return _pickerCard(
+      label: _l10n.get('relatedProject'),
+      required: false,
+      currentLabel: _selProject?.name ?? _l10n.get('pleaseSelect'),
+      labels: projects.map((p) => p.name).toList(),
+      colors: colors,
+      onSelected: (idx) => setState(() => _selProject = projects[idx]),
+      onClear: _selProject != null ? () => setState(() => _selProject = null) : null,
+    );
+  }
+
+  // ── 费用承担部门 ──
+  Widget _buildCostDeptCard(AppColorsExtension colors) {
+    final depts = widget.costDepts;
+    return _pickerCard(
+      label: _l10n.get('costDept'),
+      required: false,
+      currentLabel: _selDept?.name ?? _l10n.get('pleaseSelect'),
+      labels: depts.map((d) => d.name).toList(),
+      colors: colors,
+      onSelected: (idx) => setState(() => _selDept = depts[idx]),
+      onClear: _selDept != null ? () => setState(() => _selDept = null) : null,
+    );
+  }
+
+  // ── 收款银行信息 ──
+  Widget _buildBankInfoCard(AppColorsExtension colors) {
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        _inputCard(label: _l10n.get('bankName'), required: false, controller: _bankNameCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors),
+        const SizedBox(height: 12),
+        _inputCard(label: _l10n.get('bankAccountName'), required: false, controller: _bankAccountNameCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors),
+        const SizedBox(height: 12),
+        _inputCard(label: _l10n.get('bankAccount'), required: false, controller: _bankAccountCtrl, hintText: _l10n.get('pleaseEnter'), colors: colors),
+      ],
+    );
+  }
+
   Widget _buildActions() {
     return Row(
       children: [
@@ -546,7 +836,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
         const SizedBox(width: 12),
         Expanded(
           child: TDButton(
-            text: _l10n.get('confirmAdd'),
+            text: _isEdit ? _l10n.get('confirmEdit') : _l10n.get('add'),
             size: TDButtonSize.large,
             type: TDButtonType.fill,
             shape: TDButtonShape.rectangle,

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

@@ -8,6 +8,42 @@ final expenseApplyApiProvider = Provider<ExpenseApplyApi>(
   (ref) => ExpenseApplyApi(ref.read(apiClientProvider)),
 );
 
+// ═══ 参考数据模型(API 返回) ═══
+
+class CostTypeItem {
+  final String typeNo;
+  final String typeName;
+  final String accNo;
+  final String accName;
+  const CostTypeItem({required this.typeNo, required this.typeName, required this.accNo, required this.accName});
+  factory CostTypeItem.fromJson(Map<String, dynamic> json) => CostTypeItem(
+    typeNo: json['typeNo'] as String? ?? '',
+    typeName: json['typeName'] as String? ?? '',
+    accNo: json['accNo'] as String? ?? '',
+    accName: json['accName'] as String? ?? '',
+  );
+}
+
+class ProjectCodeItem {
+  final String objNo;
+  final String name;
+  const ProjectCodeItem({required this.objNo, required this.name});
+  factory ProjectCodeItem.fromJson(Map<String, dynamic> json) => ProjectCodeItem(
+    objNo: json['objNo'] as String? ?? '',
+    name: json['name'] as String? ?? '',
+  );
+}
+
+class DepartmentItem {
+  final String dep;
+  final String name;
+  const DepartmentItem({required this.dep, required this.name});
+  factory DepartmentItem.fromJson(Map<String, dynamic> json) => DepartmentItem(
+    dep: json['dep'] as String? ?? '',
+    name: json['name'] as String? ?? '',
+  );
+}
+
 class ExpenseApplyApi {
   final ApiClient _client;
   ExpenseApplyApi(this._client);
@@ -46,6 +82,36 @@ class ExpenseApplyApi {
     return ExpenseApplyModel.fromJson(response.data!);
   }
 
+  /// 费用类别字典
+  Future<List<CostTypeItem>> getCostTypes({String keyword = '', String accNo = ''}) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetCostTypes',
+      queryParameters: {'keyword': keyword, 'accNo': accNo, 'page': 1, 'size': 100},
+    );
+    final list = (response.data?['list'] as List<dynamic>?) ?? [];
+    return list.map((e) => CostTypeItem.fromJson(e as Map<String, dynamic>)).toList();
+  }
+
+  /// 项目代号
+  Future<List<ProjectCodeItem>> getProjectCodes({String keyword = '', String billDate = ''}) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetProjectCodes',
+      queryParameters: {'keyword': keyword, 'billDate': billDate, 'page': 1, 'size': 100},
+    );
+    final list = (response.data?['list'] as List<dynamic>?) ?? [];
+    return list.map((e) => ProjectCodeItem.fromJson(e as Map<String, dynamic>)).toList();
+  }
+
+  /// 部门
+  Future<List<DepartmentItem>> getDepartments({String keyword = '', bool onlyActive = true}) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetDepartments',
+      queryParameters: {'keyword': keyword, 'onlyActive': onlyActive, 'page': 1, 'size': 100},
+    );
+    final list = (response.data?['list'] as List<dynamic>?) ?? [];
+    return list.map((e) => DepartmentItem.fromJson(e as Map<String, dynamic>)).toList();
+  }
+
   /// 提交审批
   Future<void> submit(Map<String, dynamic> data) async {
     await _client.post('/OA/BillSave', data: {

+ 417 - 237
lib/features/expense_apply/expense_apply_create_page.dart

@@ -4,8 +4,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:go_router/go_router.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
 import '../../core/i18n/app_localizations.dart';
+import '../../core/navigation/host_app_channel.dart';
 import '../../core/storage/draft_storage.dart';
 import '../../shared/widgets/action_bar.dart';
+import '../../shared/widgets/submitting_dialog.dart';
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
 import '../../shared/widgets/nav_bar_config.dart';
@@ -14,6 +16,7 @@ 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';
 
 class ExpenseApplyCreatePage extends ConsumerStatefulWidget {
@@ -26,7 +29,8 @@ class ExpenseApplyCreatePage extends ConsumerStatefulWidget {
 }
 
 class _ExpenseApplyCreatePageState
-    extends ConsumerState<ExpenseApplyCreatePage> {
+    extends ConsumerState<ExpenseApplyCreatePage>
+    with WidgetsBindingObserver {
   static const _draftKey = 'expense_apply';
 
   // ── 基本信息 ──
@@ -46,14 +50,72 @@ class _ExpenseApplyCreatePageState
   // ── 附件 ──
   late final AttachmentPickerController _attachmentController;
 
+  // ── 草稿 ──
+  late Future<bool> _draftFuture;
+  bool _draftHandled = false;
+  bool _isPoppingToNative = false;
+
+  // ── 参考数据(从 API 加载) ──
+  List<CostTypeItem> _costTypes = [];
+  List<ProjectCodeItem> _projects = [];
+  List<DepartmentItem> _departments = [];
+  bool _refDataLoading = true;
+
+  // ── 申请部门 ──
+  String _selectedDeptId = '';
+  String _selectedDeptName = '';
+
+
   @override
   void initState() {
     super.initState();
+    WidgetsBinding.instance.addObserver(this);
+    SystemChrome.setSystemUIOverlayStyle(
+      const SystemUiOverlayStyle(
+        statusBarColor: Colors.transparent,
+        statusBarIconBrightness: Brightness.dark,
+      ),
+    );
     _attachmentController = AttachmentPickerController(maxCount: 9)
       ..addListener(() => setState(() {}));
     _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
-    WidgetsBinding.instance.addPostFrameCallback((_) => _checkDraft());
+    _draftFuture = DraftStorage.has(_draftKey);
+    _loadRefData();
+  }
+
+  Future<void> _loadRefData() async {
+    try {
+      final api = ref.read(expenseApplyApiProvider);
+      final results = await Future.wait([
+        api.getCostTypes(),
+        api.getProjectCodes(),
+        api.getDepartments(),
+      ]);
+      if (!mounted) return;
+      setState(() {
+        _costTypes = results[0] as List<CostTypeItem>;
+        _projects = results[1] as List<ProjectCodeItem>;
+        _departments = results[2] as List<DepartmentItem>;
+        _refDataLoading = false;
+        // 自动匹配当前用户的部门
+        _autoSelectDept();
+      });
+    } catch (_) {
+      if (!mounted) return;
+      setState(() => _refDataLoading = false);
+    }
+  }
+
+  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 _ensureVisible(FocusNode node) {
@@ -74,6 +136,7 @@ class _ExpenseApplyCreatePageState
 
   @override
   void dispose() {
+    WidgetsBinding.instance.removeObserver(this);
     _purposeController.dispose();
     _purposeFocus.dispose();
     _referenceNoController.dispose();
@@ -85,6 +148,20 @@ class _ExpenseApplyCreatePageState
   }
 
   @override
+  void didChangeAppLifecycleState(AppLifecycleState state) {
+    if (state == AppLifecycleState.resumed && _isPoppingToNative) {
+      _isPoppingToNative = false;
+      HostAppChannel.refresh();
+      setState(() {
+        _draftHandled = false;
+        _draftFuture = DraftStorage.has(_draftKey);
+        _refDataLoading = true;
+      });
+      _loadRefData();
+    }
+  }
+
+  @override
   Widget build(BuildContext context) {
     final l10n = AppLocalizations.of(context);
     ref
@@ -97,94 +174,70 @@ class _ExpenseApplyCreatePageState
           ),
         );
 
-    return PopScope(
-      canPop: false,
-      onPopInvokedWithResult: (didPop, _) {
-        if (didPop) return;
-        if (_hasUnsaved()) {
-          _showConfirmDialog(
-            l10n.get('confirmExit'),
-            l10n.get('unsavedContentWarning'),
-            l10n.get('continueEditing'),
-            l10n.get('discardAndExit'),
-            () => _doPop(),
-          );
-        } else {
-          _doPop();
+    return FutureBuilder<bool>(
+      future: _draftFuture,
+      builder: (ctx, snapshot) {
+        final hasDraft = snapshot.hasData && snapshot.data == true;
+
+        if (hasDraft && !_draftHandled) {
+          _draftHandled = true;
+          WidgetsBinding.instance.addPostFrameCallback((_) {
+            if (mounted) _showDraftDialog();
+          });
         }
-      },
-      child: Column(
-        children: [
-          Expanded(
-            child: GestureDetector(
-              onTap: () => FocusScope.of(context).unfocus(),
-              child: SingleChildScrollView(
-                controller: _scrollCtrl,
-                padding: const EdgeInsets.all(16),
-                child: Column(
-                  children: [
-                    _buildBasicInfo(l10n),
-                    const SizedBox(height: 16),
-                    _buildDetailsSection(l10n),
-                    const SizedBox(height: 16),
-                    _buildAttachmentSection(l10n),
-                    const SizedBox(height: 80),
-                  ],
+
+        return PopScope(
+          canPop: false,
+          onPopInvokedWithResult: (didPop, _) {
+            if (didPop) return;
+            _doPop();
+          },
+          child: Column(
+            children: [
+              Expanded(
+                child: GestureDetector(
+                  onTap: () => FocusScope.of(context).unfocus(),
+                  child: SingleChildScrollView(
+                    controller: _scrollCtrl,
+                    padding: const EdgeInsets.all(16),
+                    child: Column(
+                      children: [
+                        _buildBasicInfo(l10n),
+                        const SizedBox(height: 16),
+                        _buildDetailsSection(l10n),
+                        const SizedBox(height: 16),
+                        _buildAttachmentSection(l10n),
+                        const SizedBox(height: 80),
+                      ],
+                    ),
+                  ),
                 ),
               ),
-            ),
+              _buildBottomBar(l10n),
+            ],
           ),
-          _buildBottomBar(l10n),
-        ],
-      ),
+        );
+      },
     );
   }
 
   // ═══ 草稿持久化 ═══
 
-  Future<void> _checkDraft() async {
-    if (!mounted) return;
-    final has = await DraftStorage.has(_draftKey);
-    if (!has || !mounted) return;
-    final l10n = AppLocalizations.of(context);
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final yes = await showDialog<bool>(
-      context: context,
-      builder: (ctx) => TDAlertDialog(
-        title: l10n.get('draftFound'),
-        content: l10n.get('draftRestorePrompt'),
-        leftBtn: TDDialogButtonOptions(
-          title: l10n.get('discard'),
-          titleColor: colors.textSecondary,
-          action: () => Navigator.pop(ctx, false),
-        ),
-        rightBtn: TDDialogButtonOptions(
-          title: l10n.get('restore'),
-          titleColor: colors.primary,
-          action: () => Navigator.pop(ctx, true),
-        ),
-      ),
-    );
-    if (yes == true && mounted) {
-      await _restoreDraft();
-    } else {
-      await DraftStorage.delete(_draftKey);
-    }
-  }
-
   Future<void> _restoreDraft() async {
     final data = await DraftStorage.load(_draftKey);
     if (data == null) return;
+    final attData = data['attachments'] as List<dynamic>?;
+    if (attData != null) {
+      await _attachmentController.restoreFromPaths(attData.cast<String>());
+    }
     setState(() {
       _urgency = data['urgency'] as String? ?? Urgency.normal.value;
       _purposeController.text = data['purpose'] as String? ?? '';
       _validUntil = data['validUntil'] as String? ?? '';
       _referenceNoController.text = data['referenceNo'] as String? ?? '';
       _remarkController.text = data['remark'] as String? ?? '';
-      final attData = data['attachments'] as List<dynamic>?;
-      if (attData != null) {
-        _attachmentController.restoreFromPaths(attData.cast<String>());
-      }
+      _selectedDeptId = data['deptId'] as String? ?? '';
+      _selectedDeptName = data['deptName'] as String? ?? '';
       _details.clear();
       final detailList = data['details'] as List<dynamic>?;
       if (detailList != null) {
@@ -232,6 +285,8 @@ class _ExpenseApplyCreatePageState
     await DraftStorage.save(_draftKey, {
       'urgency': _urgency,
       'purpose': _purposeController.text,
+      'deptId': _selectedDeptId,
+      'deptName': _selectedDeptName,
       'validUntil': _validUntil,
       'referenceNo': _referenceNoController.text,
       'remark': _remarkController.text,
@@ -240,6 +295,37 @@ class _ExpenseApplyCreatePageState
     });
   }
 
+  // ═══ 草稿弹窗 ═══
+  // 使用 showDialog 而非内联渲染,确保 TDAlertDialog 获取正确的主题上下文
+  void _showDraftDialog() {
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    showDialog(
+      context: context,
+      barrierDismissible: false,
+      builder: (ctx) => TDAlertDialog(
+        title: l10n.get('draftFound'),
+        content: l10n.get('draftRestorePrompt'),
+        leftBtn: TDDialogButtonOptions(
+          title: l10n.get('discard'),
+          titleColor: colors.textSecondary,
+          action: () {
+            Navigator.pop(ctx);
+            DraftStorage.delete(_draftKey);
+          },
+        ),
+        rightBtn: TDDialogButtonOptions(
+          title: l10n.get('restore'),
+          titleColor: colors.primary,
+          action: () {
+            Navigator.pop(ctx);
+            _restoreDraft();
+          },
+        ),
+      ),
+    );
+  }
+
   // ═══ 1. 基本信息 ═══
   Widget _buildBasicInfo(AppLocalizations l10n) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
@@ -248,36 +334,38 @@ class _ExpenseApplyCreatePageState
       leadingIcon: Icons.info_outline,
       children: [
         FormFieldRow(
-          label: l10n.get('applicant'),
-          value: '张三',
+          label: l10n.get('date'),
+          value: _today(),
           readOnly: true,
           showArrow: false,
         ),
         const SizedBox(height: 16),
         FormFieldRow(
-          label: l10n.get('department'),
-          value: '技术部',
+          label: l10n.get('applicant'),
+          value: HostAppChannel.usr.isNotEmpty && HostAppChannel.usrName.isNotEmpty
+              ? '${HostAppChannel.usr}/${HostAppChannel.usrName}'
+              : '--',
           readOnly: true,
           showArrow: false,
         ),
         const SizedBox(height: 16),
         FormFieldRow(
-          label: l10n.get('date'),
-          value: _today(),
-          readOnly: true,
-          showArrow: false,
+          label: l10n.get('applyDept'),
+          value: _selectedDeptName,
+          hint: l10n.get('pleaseSelect'),
+          onTap: _refDataLoading ? null : () => _showDeptPicker(),
         ),
         const SizedBox(height: 16),
         _label(l10n.get('emergencyLevel'), required: true),
         const SizedBox(height: 8),
         _buildUrgencyRadio(l10n),
         const SizedBox(height: 16),
-        _label(l10n.get('feeReason'), required: true),
+        _label(l10n.get('applyReason'), required: true),
         const SizedBox(height: 8),
         TDTextarea(
           controller: _purposeController,
           focusNode: _purposeFocus,
-          hintText: l10n.get('enterFeeReason'),
+          hintText: l10n.get('enterApplyReason'),
           maxLines: 4,
           minLines: 1,
           maxLength: 500,
@@ -286,29 +374,30 @@ class _ExpenseApplyCreatePageState
           bordered: true,
           backgroundColor: colors.bgPage,
         ),
-        const SizedBox(height: 16),
-        FormFieldRow(
-          label: l10n.get('validUntil'),
-          value: _validUntil,
-          hint: l10n.get('pleaseSelect'),
-          onTap: () => _pickDate((d) => setState(() => _validUntil = d)),
-        ),
-        const SizedBox(height: 16),
-        FormFieldRow(
-          label: l10n.get('relatedContractNo'),
-          value: _referenceNoController.text,
-          hint: l10n.get('optional'),
-          onTap: () => _showTextInput(
-            l10n.get('relatedContractNo'),
-            (v) => setState(() {
-              _referenceNoController.text = v;
-              _referenceNoController.selection = TextSelection.fromPosition(
-                TextPosition(offset: v.length),
-              );
-            }),
-            initialText: _referenceNoController.text,
-          ),
-        ),
+        // TODO: 暂不支持录入,后续开放
+        // const SizedBox(height: 16),
+        // FormFieldRow(
+        //   label: l10n.get('validUntil'),
+        //   value: _validUntil,
+        //   hint: l10n.get('pleaseSelect'),
+        //   onTap: () => _pickDate((d) => setState(() => _validUntil = d)),
+        // ),
+        // const SizedBox(height: 16),
+        // FormFieldRow(
+        //   label: l10n.get('relatedContractNo'),
+        //   value: _referenceNoController.text,
+        //   hint: l10n.get('optional'),
+        //   onTap: () => _showTextInput(
+        //     l10n.get('relatedContractNo'),
+        //     (v) => setState(() {
+        //       _referenceNoController.text = v;
+        //       _referenceNoController.selection = TextSelection.fromPosition(
+        //         TextPosition(offset: v.length),
+        //       );
+        //     }),
+        //     initialText: _referenceNoController.text,
+        //   ),
+        // ),
         const SizedBox(height: 16),
         _label(l10n.get('remark')),
         const SizedBox(height: 8),
@@ -388,7 +477,7 @@ class _ExpenseApplyCreatePageState
     return FormSection(
       title: l10n.get('expenseDetails'),
       leadingIcon: Icons.receipt_long_outlined,
-      showAction: true,
+      showAction: !_refDataLoading,
       actionText: l10n.get('add'),
       onActionTap: _showDetailDialog,
       children: [
@@ -406,7 +495,9 @@ class _ExpenseApplyCreatePageState
         else
           ..._details.asMap().entries.map((e) {
             final d = e.value;
-            return Container(
+            return GestureDetector(
+              onTap: () => _showDetailDialog(editIndex: e.key),
+              child: Container(
               margin: const EdgeInsets.symmetric(vertical: 8),
               padding: const EdgeInsets.all(12),
               decoration: BoxDecoration(
@@ -485,6 +576,7 @@ class _ExpenseApplyCreatePageState
                   ),
                 ],
               ),
+            ),
             );
           }),
         const SizedBox(height: 8),
@@ -520,36 +612,63 @@ class _ExpenseApplyCreatePageState
   double _totalAmount() =>
       _details.fold(0, (s, d) => s + d.estimatedAmount);
 
-  Future<void> _showDetailDialog() async {
+  Future<void> _showDetailDialog({int? editIndex}) async {
     final l10n = AppLocalizations.of(context);
+    if (_costTypes.isEmpty) {
+      TDToast.showText(l10n.get('noCostTypeData'), context: context);
+      return;
+    }
+    ExpenseDetailData? initialData;
+    if (editIndex != null) {
+      final d = _details[editIndex];
+      initialData = ExpenseDetailData(
+        category: d.category,
+        categoryName: d.categoryName,
+        acctSubjectId: d.acctSubjectId,
+        acctSubjectName: d.acctSubjectName,
+        purpose: d.purpose,
+        projectId: d.projectId,
+        projectName: d.projectName,
+        costDeptId: d.costDeptId,
+        costDeptName: d.costDeptName,
+        startDate: d.startDate,
+        endDate: d.endDate,
+        estimatedAmount: d.estimatedAmount,
+        remark: d.remark,
+      );
+    }
     final result = await ExpenseApplyDetailDialog.show(
       context,
-      categories: mockCostCategories,
-      projects: mockProjects,
-      costDepts: mockCostDepts,
+      categories: _dialogCategories,
+      projects: _dialogProjects,
+      costDepts: _dialogCostDepts,
       l10n: l10n,
+      initialData: initialData,
     );
     if (result != null && mounted) {
-      setState(
-        () => _details.add(
-          _DetailItem(
-            id: _detailIdCounter++,
-            category: result.category,
-            categoryName: result.categoryName,
-            acctSubjectId: result.acctSubjectId,
-            acctSubjectName: result.acctSubjectName,
-            purpose: result.purpose,
-            projectId: result.projectId,
-            projectName: result.projectName,
-            costDeptId: result.costDeptId,
-            costDeptName: result.costDeptName,
-            startDate: result.startDate,
-            endDate: result.endDate,
-            estimatedAmount: result.estimatedAmount,
-            remark: result.remark,
-          ),
-        ),
-      );
+      setState(() {
+        final item = _DetailItem(
+          id: editIndex != null ? _details[editIndex].id : _detailIdCounter++,
+          category: result.category,
+          categoryName: result.categoryName,
+          acctSubjectId: result.acctSubjectId,
+          acctSubjectName: result.acctSubjectName,
+          purpose: result.purpose,
+          projectId: result.projectId,
+          projectName: result.projectName,
+          costDeptId: result.costDeptId,
+          costDeptName: result.costDeptName,
+          startDate: result.startDate,
+          endDate: result.endDate,
+          estimatedAmount: result.estimatedAmount,
+          remark: result.remark,
+        );
+        if (editIndex != null) {
+          _details[editIndex] = item;
+        } else {
+          _details.add(item);
+        }
+      });
     }
   }
 
@@ -586,28 +705,68 @@ 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.name).toList();
+    TDPicker.showMultiPicker(
+      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;
+            });
+          }
+        }
+      },
+    );
+  }
+
+  // ═══ 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) {
-    final isDraft = widget.id != null;
     return ActionBar(
-      leftLabel: isDraft ? l10n.get('reset') : null,
+      showLeft: false,
       centerLabel: l10n.get('saveDraft'),
-      rightLabel: l10n.get('submitApproval'),
-      showLeft: isDraft,
-      onLeftTap: isDraft
-          ? () => _showConfirmDialog(
-              l10n.get('confirmReset'),
-              l10n.get('resetWarning'),
-              l10n.get('cancel'),
-              l10n.get('confirmReset'),
-              _resetAll,
-            )
-          : null,
+      rightLabel: l10n.get('submit'),
+      centerTextOnly: true,
       onCenterTap: () async {
-        await _saveDraftToStorage();
-        if (mounted) {
-          TDToast.showSuccess(l10n.get('draftSavedToast'), context: context);
-          context.pop();
+        try {
+          await _saveDraftToStorage();
+          if (mounted) _forcePop();
+        } catch (_) {
+          if (mounted) {
+            TDToast.showFail(l10n.get('saveFailed'), context: context);
+          }
         }
       },
       onRightTap: () async {
@@ -616,52 +775,126 @@ class _ExpenseApplyCreatePageState
           TDToast.showText(err.first, context: context);
           return;
         }
-        await DraftStorage.delete(_draftKey);
-        if (mounted) {
-          TDToast.showSuccess(
-            l10n.get('submittedAwaitingApproval'),
-            context: context,
-          );
-          context.pop();
+        SubmittingDialog.show(context);
+        try {
+          final data = _buildSubmitData();
+          await ref.read(expenseApplyApiProvider).submit(data);
+          await DraftStorage.delete(_draftKey);
+          if (mounted) {
+            SubmittingDialog.hide(context);
+            TDToast.showSuccess(l10n.get('submittedAwaitingApproval'), context: context);
+            GoRouter.of(context).go('/expense-apply/list');
+          }
+        } catch (_) {
+          if (mounted) {
+            SubmittingDialog.hide(context);
+            TDToast.showFail(l10n.get('submitFailedRetry'), context: context);
+          }
         }
       },
     );
   }
 
+  Map<String, dynamic> _buildSubmitData() {
+    // 紧急程度映射:normal→1, urgent→2, critical→3
+    String priority;
+    switch (_urgency) {
+      case 'urgent':
+        priority = '2';
+        break;
+      case 'critical':
+        priority = '3';
+        break;
+      default:
+        priority = '1';
+    }
+
+    return {
+      'HeadData': {
+        'AE_DD': _today(),
+        'PRIORITY': priority,
+        'AMTN_YJ': _totalAmount(),
+        'REASON': _purposeController.text.trim(),
+        'REM': _remarkController.text,
+        'DEP': _selectedDeptId,
+        'USR': HostAppChannel.usr,
+      },
+      'BodyData1': _details.asMap().entries.map((e) {
+        final i = e.key;
+        final d = e.value;
+        return {
+          'ITM': i + 1,
+          'SQ_MAN': HostAppChannel.usr,
+          'TYPE_NO': d.category,
+          'AMTN_YJ': d.estimatedAmount,
+          'ACC_NO': d.acctSubjectId,
+          'ACC_NAME': d.acctSubjectName,
+          'DEP': d.costDeptId,
+          'OBJ_NO': d.projectId > 0 ? d.projectId.toString() : '',
+          'START_DD': d.startDate,
+          'END_DD': d.endDate,
+          'REM': d.remark.isNotEmpty ? d.remark : d.purpose,
+        };
+      }).toList(),
+    };
+  }
+
   List<String> _validate(AppLocalizations l10n) {
     final e = <String>[];
     if (_purposeController.text.trim().isEmpty) {
-      e.add(l10n.get('enterFeeReason'));
+      e.add(l10n.get('enterApplyReason'));
     }
     if (_details.isEmpty) e.add(l10n.get('addAtLeastOneDetail'));
     return e;
   }
 
-  void _resetAll() => setState(() {
-    _purposeController.clear();
-    _urgency = Urgency.normal.value;
-    _validUntil = '';
-    _referenceNoController.clear();
-    _remarkController.clear();
-    _details.clear();
-    _attachmentController.clear();
-  });
-
   void _doPop() {
-    final router = GoRouter.of(context);
-    if (router.canPop()) {
-      router.pop();
+    if (_hasUnsaved()) {
+      final l10n = AppLocalizations.of(context);
+      _showConfirmDialog(
+        l10n.get('confirmExit'),
+        l10n.get('unsavedContentWarning'),
+        l10n.get('continueEditing'),
+        l10n.get('discardAndExit'),
+        () async {
+          await DraftStorage.delete(_draftKey);
+          if (!mounted) return;
+          setState(() => _clearLocalState());
+          _forcePop();
+        },
+      );
     } else {
-      SystemNavigator.pop();
+      _forcePop();
     }
   }
 
+  void _forcePop() {
+    _isPoppingToNative = true;
+    SystemNavigator.pop();
+  }
+
   bool _hasUnsaved() =>
       _purposeController.text.isNotEmpty ||
       _details.isNotEmpty ||
       _attachmentController.files.isNotEmpty ||
       _referenceNoController.text.isNotEmpty ||
-      _remarkController.text.isNotEmpty;
+      _remarkController.text.isNotEmpty ||
+      _urgency != Urgency.normal.value ||
+      _validUntil.isNotEmpty ||
+      _selectedDeptId.isNotEmpty;
+
+  void _clearLocalState() {
+    _urgency = Urgency.normal.value;
+    _purposeController.clear();
+    _validUntil = '';
+    _referenceNoController.clear();
+    _remarkController.clear();
+    _details.clear();
+    _detailIdCounter = 1;
+    _attachmentController.clear();
+    _selectedDeptId = '';
+    _selectedDeptName = '';
+  }
 
   void _unfocus() => FocusScope.of(context).unfocus();
 
@@ -677,6 +910,7 @@ class _ExpenseApplyCreatePageState
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     showDialog(
       context: context,
+      useRootNavigator: true,
       builder: (ctx) => TDAlertDialog(
         title: title,
         content: content,
@@ -698,72 +932,18 @@ class _ExpenseApplyCreatePageState
     );
   }
 
-  void _showTextInput(
-    String title,
-    Function(String) onConfirm, {
-    String initialText = '',
-  }) {
-    _unfocus();
-    final l10n = AppLocalizations.of(context);
-    final c = TextEditingController(text: initialText);
-    showGeneralDialog(
-      context: context,
-      pageBuilder: (ctx, animation, secondaryAnimation) => TDInputDialog(
-        textEditingController: c,
-        title: title,
-        hintText: l10n.get('pleaseEnter'),
-        leftBtn: TDDialogButtonOptions(
-          title: l10n.get('cancel'),
-          action: () => Navigator.pop(ctx),
-        ),
-        rightBtn: TDDialogButtonOptions(
-          title: l10n.get('confirm'),
-          action: () {
-            onConfirm(c.text);
-            Navigator.pop(ctx);
-          },
-        ),
-      ),
-    );
-  }
-
-  void _pickDate(Function(String) onPick) {
-    _unfocus();
-    final l10n = AppLocalizations.of(context);
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final theme = Theme.of(context);
-    final now = DateTime.now();
-    showModalBottomSheet(
-      context: context,
-      backgroundColor: Colors.transparent,
-      builder: (ctx) => Theme(
-        data: theme,
-        child: TDDatePicker(
-          title: l10n.get('selectDate'),
-          backgroundColor: colors.bgCard,
-          model: DatePickerModel(
-            useYear: true,
-            useMonth: true,
-            useDay: true,
-            useHour: false,
-            useMinute: false,
-            useSecond: false,
-            useWeekDay: false,
-            dateStart: [2020, 1, 1],
-            dateEnd: [now.year + 1, 12, 31],
-            dateInitial: [now.year, now.month, now.day],
-          ),
-          onConfirm: (selected) {
-            onPick(
-              '${selected['year']}-${selected['month']!.toString().padLeft(2, '0')}-${selected['day']!.toString().padLeft(2, '0')}',
-            );
-            Navigator.of(ctx).pop();
-          },
-          onCancel: (_) => Navigator.of(ctx).pop(),
-        ),
-      ),
-    );
-  }
+  // TODO: 有效期至 / 关联合同号 暂不支持,方法暂时注释
+  // void _showTextInput(
+  //   String title,
+  //   Function(String) onConfirm, {
+  //   String initialText = '',
+  // }) {
+  //   ...
+  // }
+
+  // void _pickDate(Function(String) onPick) {
+  //   ...
+  // }
 
   Widget _label(String t, {bool required = false}) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;

+ 51 - 35
lib/features/expense_apply/expense_apply_model.dart

@@ -68,24 +68,24 @@ class ExpenseApplyModel {
 
   factory ExpenseApplyModel.fromJson(Map<String, dynamic> json) {
     return ExpenseApplyModel(
-      id: json['id'] as String,
-      expenseApplyNo: json['expenseApplyNo'] as String? ?? '',
-      expenseApplyDate: json['expenseApplyDate'] != null
-          ? DateTime.parse(json['expenseApplyDate'] as String)
+      id: json['id'] as String? ?? '',
+      expenseApplyNo: json['applyNo'] as String? ?? '',
+      expenseApplyDate: json['applyDate'] != null
+          ? DateTime.parse(json['applyDate'] as String)
           : null,
-      applicantId: json['applicantId'] as String? ?? '',
-      applicantName: json['applicantName'] as String? ?? '',
-      deptId: json['deptId'] as String? ?? '',
-      deptName: json['deptName'] as String? ?? '',
+      applicantId: json['usr'] as String? ?? '',
+      applicantName: json['applicantName'] as String? ?? json['usr'] as String? ?? '',
+      deptId: json['dept'] as String? ?? '',
+      deptName: json['deptName'] as String? ?? json['dep'] as String? ?? '',
       estimatedAmount: (json['estimatedAmount'] as num?)?.toDouble() ?? 0.0,
-      urgency: json['urgency'] as String? ?? 'normal',
-      purpose: json['purpose'] as String? ?? '',
-      remark: json['remark'] as String? ?? '',
-      effectiveDate: json['effectiveDate'] != null
-          ? DateTime.parse(json['effectiveDate'] as String)
+      urgency: json['priority'] as String? ?? 'normal',
+      purpose: json['reason'] as String? ?? '',
+      remark: json['rem'] as String? ?? '',
+      effectiveDate: json['effDd'] != null
+          ? DateTime.parse(json['effDd'] as String)
           : null,
-      auditorId: json['auditorId'] as String? ?? '',
-      status: json['status'] as String? ?? 'draft',
+      auditorId: json['chkMan'] as String? ?? '',
+      status: json['clsDate'] != null ? 'closed' : (json['status'] as String? ?? 'draft'),
       usageStatus: json['usageStatus'] as String? ?? 'unused',
       validUntil: json['validUntil'] != null
           ? DateTime.parse(json['validUntil'] as String)
@@ -94,8 +94,12 @@ class ExpenseApplyModel {
       approvalInstanceId: json['approvalInstanceId'] as String? ?? '',
       previousInstanceIds: json['previousInstanceIds'] as String? ?? '',
       version: json['version'] as int? ?? 1,
-      createTime: DateTime.parse(json['createTime'] as String),
-      updateTime: DateTime.parse(json['updateTime'] as String),
+      createTime: json['createTime'] != null
+          ? DateTime.parse(json['createTime'] as String)
+          : DateTime.now(),
+      updateTime: json['updateTime'] != null
+          ? DateTime.parse(json['updateTime'] as String)
+          : DateTime.now(),
       isDeleted: json['isDeleted'] as bool? ?? false,
       currentApproverId: json['currentApproverId'] as String? ?? '',
       approvalChain:
@@ -230,6 +234,8 @@ class ExpenseApplyDetailModel {
   final String costDeptName;
   final String acctSubjectId;
   final String acctSubjectName;
+  final String sqMan;
+  final String sqName;
   final DateTime? estimatedStartDate;
   final DateTime? estimatedEndDate;
   final double estimatedAmount;
@@ -250,6 +256,8 @@ class ExpenseApplyDetailModel {
     this.costDeptName = '',
     this.acctSubjectId = '',
     this.acctSubjectName = '',
+    this.sqMan = '',
+    this.sqName = '',
     this.estimatedStartDate,
     this.estimatedEndDate,
     this.estimatedAmount = 0.0,
@@ -262,27 +270,33 @@ class ExpenseApplyDetailModel {
 
   factory ExpenseApplyDetailModel.fromJson(Map<String, dynamic> json) {
     return ExpenseApplyDetailModel(
-      id: json['id'] as String,
-      expenseApplyId: json['expenseApplyId'] as String? ?? '',
-      expenseCategory: json['expenseCategory'] as String? ?? '',
+      id: json['id'] as String? ?? '',
+      expenseApplyId: json['aeNo'] as String? ?? '',
+      expenseCategory: json['typeNo'] as String? ?? '',
       purpose: json['purpose'] as String? ?? '',
-      projectId: json['projectId'] as String? ?? '',
-      projectName: json['projectName'] as String? ?? '',
-      costDeptId: json['costDeptId'] as String? ?? '',
-      costDeptName: json['costDeptName'] as String? ?? '',
-      acctSubjectId: json['acctSubjectId'] as String? ?? '',
-      acctSubjectName: json['acctSubjectName'] as String? ?? '',
-      estimatedStartDate: json['estimatedStartDate'] != null
-          ? DateTime.parse(json['estimatedStartDate'] as String)
+      projectId: json['objNo'] as String? ?? '',
+      projectName: json['objName'] as String? ?? json['projectName'] as String? ?? '',
+      costDeptId: json['dep'] as String? ?? '',
+      costDeptName: json['depName'] as String? ?? json['dep'] as String? ?? '',
+      acctSubjectId: json['accNo'] as String? ?? '',
+      acctSubjectName: json['accName'] as String? ?? '',
+      estimatedStartDate: json['startDd'] != null
+          ? DateTime.parse(json['startDd'] as String)
           : null,
-      estimatedEndDate: json['estimatedEndDate'] != null
-          ? DateTime.parse(json['estimatedEndDate'] as String)
+      estimatedEndDate: json['endDd'] != null
+          ? DateTime.parse(json['endDd'] as String)
           : null,
-      estimatedAmount: (json['estimatedAmount'] as num?)?.toDouble() ?? 0.0,
-      remark: json['remark'] as String? ?? '',
-      sortOrder: json['sortOrder'] as int? ?? 1,
-      createTime: DateTime.parse(json['createTime'] as String),
-      updateTime: DateTime.parse(json['updateTime'] as String),
+      estimatedAmount: (json['amtnYj'] as num?)?.toDouble() ?? 0.0,
+      remark: json['rem'] as String? ?? '',
+      sqMan: json['sqMan'] as String? ?? '',
+      sqName: json['sqName'] as String? ?? '',
+      sortOrder: json['itm'] as int? ?? 1,
+      createTime: json['createTime'] != null
+          ? DateTime.parse(json['createTime'] as String)
+          : DateTime.now(),
+      updateTime: json['updateTime'] != null
+          ? DateTime.parse(json['updateTime'] as String)
+          : DateTime.now(),
       isDeleted: json['isDeleted'] as bool? ?? false,
     );
   }
@@ -298,6 +312,8 @@ class ExpenseApplyDetailModel {
     'costDeptName': costDeptName,
     'acctSubjectId': acctSubjectId,
     'acctSubjectName': acctSubjectName,
+    'sqMan': sqMan,
+    'sqName': sqName,
     'estimatedStartDate': estimatedStartDate?.toIso8601String(),
     'estimatedEndDate': estimatedEndDate?.toIso8601String(),
     'estimatedAmount': estimatedAmount,

+ 66 - 46
lib/features/expense_apply/widgets/expense_apply_detail_dialog.dart

@@ -46,6 +46,7 @@ class ExpenseApplyDetailDialog extends StatefulWidget {
   final List<Project> projects;
   final List<CostDept> costDepts;
   final AppLocalizations l10n;
+  final ExpenseDetailData? initialData;
 
   const ExpenseApplyDetailDialog({
     super.key,
@@ -53,6 +54,7 @@ class ExpenseApplyDetailDialog extends StatefulWidget {
     required this.projects,
     required this.costDepts,
     required this.l10n,
+    this.initialData,
   });
 
   /// 显示弹窗,返回 [ExpenseDetailData] 或 `null`(取消时)。
@@ -62,6 +64,7 @@ class ExpenseApplyDetailDialog extends StatefulWidget {
     required List<Project> projects,
     required List<CostDept> costDepts,
     required AppLocalizations l10n,
+    ExpenseDetailData? initialData,
   }) {
     FocusScope.of(context).unfocus();
     return Navigator.push<ExpenseDetailData>(
@@ -74,6 +77,7 @@ class ExpenseApplyDetailDialog extends StatefulWidget {
           projects: projects,
           costDepts: costDepts,
           l10n: l10n,
+          initialData: initialData,
         ),
       ),
     );
@@ -112,12 +116,39 @@ class _ExpenseApplyDetailDialogState
   List<CostDept> get _depts => widget.costDepts;
   AppLocalizations get _l10n => widget.l10n;
 
+  bool get _isEdit => widget.initialData != null;
+
   @override
   void initState() {
     super.initState();
-    _selCat = _cats.first;
-    _selProject = null;
-    _selDept = null;
+    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: '');
+    _selProject = (d != null && d.projectId != 0 && _projects.isNotEmpty)
+        ? _projects.firstWhere((p) => p.id == d.projectId, orElse: () => _projects.first)
+        : null;
+    _selDept = (d != null && d.costDeptId.isNotEmpty && _depts.isNotEmpty)
+        ? _depts.firstWhere((dept) => dept.id == d.costDeptId, orElse: () => _depts.first)
+        : null;
+    _startDate = d?.startDate ?? '';
+    _endDate = d?.endDate ?? '';
+    if (d != null) {
+      _purposeCtrl.text = d.purpose;
+      _amountCtrl.text = d.estimatedAmount > 0 ? d.estimatedAmount.toStringAsFixed(2) : '';
+      _remarkCtrl.text = d.remark;
+    }
     _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
     _amountFocus.addListener(() => _ensureVisible(_amountFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
@@ -212,8 +243,12 @@ class _ExpenseApplyDetailDialogState
               children: [
                 _buildHeader(colors),
                 Flexible(
+                  child: GestureDetector(
+                  onTap: () => FocusScope.of(context).unfocus(),
+                  behavior: HitTestBehavior.translucent,
                   child: SingleChildScrollView(
                   controller: _scrollCtrl,
+                  keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
                   padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
                   child: Column(
                     mainAxisSize: MainAxisSize.min,
@@ -240,6 +275,7 @@ class _ExpenseApplyDetailDialogState
                   ),
                 ),
               ),
+              ),
               Container(
                 padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
                 decoration: BoxDecoration(
@@ -322,6 +358,10 @@ class _ExpenseApplyDetailDialogState
     final hasValue = onClear != null;
     return GestureDetector(
       onTap: () {
+        if (labels.isEmpty) {
+          TDToast.showText(_l10n.get('noData'), context: context);
+          return;
+        }
         TDPicker.showMultiPicker(
           context,
           title: label,
@@ -384,26 +424,16 @@ class _ExpenseApplyDetailDialogState
                       textAlign: TextAlign.end,
                     ),
                   ),
-                  if (hasValue)
-                    GestureDetector(
-                      onTap: onClear,
-                      child: Padding(
-                        padding: const EdgeInsets.only(left: 8),
-                        child: Icon(
-                          Icons.cancel,
-                          size: 18,
-                          color: tdTheme.textColorPlaceholder,
-                        ),
-                      ),
-                    )
-                  else ...[
-                    const SizedBox(width: 4),
-                    Icon(
-                      Icons.chevron_right,
-                      size: 18,
-                      color: tdTheme.textColorPlaceholder,
-                    ),
-                  ],
+                  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),
+                  ),
                 ],
               ),
             ),
@@ -599,26 +629,16 @@ class _ExpenseApplyDetailDialogState
                       textAlign: TextAlign.end,
                     ),
                   ),
-                  if (hasValue)
-                    GestureDetector(
-                      onTap: onClear,
-                      child: Padding(
-                        padding: const EdgeInsets.only(left: 8),
-                        child: Icon(
-                          Icons.cancel,
-                          size: 18,
-                          color: tdTheme.textColorPlaceholder,
-                        ),
-                      ),
-                    )
-                  else ...[
-                    const SizedBox(width: 4),
-                    Icon(
-                      Icons.chevron_right,
-                      size: 18,
-                      color: tdTheme.textColorPlaceholder,
-                    ),
-                  ],
+                  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),
+                  ),
                 ],
               ),
             ),
@@ -725,9 +745,9 @@ class _ExpenseApplyDetailDialogState
                 setState(() {});
               },
               child: Padding(
-                padding: const EdgeInsets.only(left: 8),
+                padding: const EdgeInsets.all(4),
                 child: Icon(
-                  Icons.cancel,
+                  Icons.close,
                   size: 18,
                   color: tdTheme.textColorPlaceholder,
                 ),
@@ -776,7 +796,7 @@ class _ExpenseApplyDetailDialogState
         const SizedBox(width: 12),
         Expanded(
           child: TDButton(
-            text: _l10n.get('add'),
+            text: _isEdit ? _l10n.get('confirmEdit') : _l10n.get('add'),
             size: TDButtonSize.large,
             type: TDButtonType.fill,
             shape: TDButtonShape.rectangle,

+ 57 - 37
lib/shared/widgets/action_bar.dart

@@ -8,22 +8,28 @@ import '../../core/theme/app_colors_extension.dart';
 /// 所有按钮圆角22,总高度72,padding 16,gap 12,背景bgCard。
 class ActionBar extends StatelessWidget {
   final String? leftLabel;
-  final String centerLabel;
-  final String rightLabel;
+  final String? centerLabel;
+  final String? rightLabel;
   final VoidCallback? onLeftTap;
   final VoidCallback? onCenterTap;
   final VoidCallback? onRightTap;
   final bool showLeft;
+  final bool showCenter;
+  final bool showRight;
+  final bool centerTextOnly;
 
   const ActionBar({
     super.key,
     this.leftLabel,
-    required this.centerLabel,
-    required this.rightLabel,
+    this.centerLabel,
+    this.rightLabel,
     this.onLeftTap,
     this.onCenterTap,
     this.onRightTap,
     this.showLeft = true,
+    this.showCenter = true,
+    this.showRight = true,
+    this.centerTextOnly = false,
   });
 
   @override
@@ -34,44 +40,58 @@ class ActionBar extends StatelessWidget {
       padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
       decoration: BoxDecoration(
         color: colors.bgCard,
+        border: Border(top: BorderSide(color: colors.border, width: 0.5)),
       ),
       child: Row(
-        children: [
-          // 左侧重置按钮(可选显示)
-          if (showLeft && leftLabel != null) ...[
-            Expanded(
-              child: _ActionButton(
-                label: leftLabel!,
-                backgroundColor: colors.bgCard,
-                textColor: colors.textSecondary,
-                onTap: onLeftTap,
-              ),
-            ),
-            const SizedBox(width: 12),
-          ],
-          // 中间存草稿按钮
-          Expanded(
-            child: _ActionButton(
-              label: centerLabel,
-              backgroundColor: colors.primaryLight,
-              textColor: colors.primary,
-              onTap: onCenterTap,
-            ),
-          ),
-          const SizedBox(width: 12),
-          // 右侧提交按钮
-          Expanded(
-            child: _ActionButton(
-              label: rightLabel,
-              backgroundColor: colors.primary,
-              textColor: colors.bgCard,
-              onTap: onRightTap,
-            ),
-          ),
-        ],
+        children: _buildButtons(colors),
       ),
     );
   }
+
+  List<Widget> _buildButtons(AppColorsExtension colors) {
+    final buttons = <Widget>[];
+
+    void addGap() {
+      if (buttons.isNotEmpty) buttons.add(const SizedBox(width: 12));
+    }
+
+    if (showLeft && leftLabel != null) {
+      buttons.add(Expanded(
+        child: _ActionButton(
+          label: leftLabel!,
+          backgroundColor: colors.bgCard,
+          textColor: colors.textSecondary,
+          onTap: onLeftTap,
+        ),
+      ));
+    }
+
+    if (showCenter && centerLabel != null) {
+      addGap();
+      buttons.add(Expanded(
+        child: _ActionButton(
+          label: centerLabel!,
+          backgroundColor: centerTextOnly ? colors.bgCard : colors.primaryLight,
+          textColor: centerTextOnly ? colors.textSecondary : colors.primary,
+          onTap: onCenterTap,
+        ),
+      ));
+    }
+
+    if (showRight && rightLabel != null) {
+      addGap();
+      buttons.add(Expanded(
+        child: _ActionButton(
+          label: rightLabel!,
+          backgroundColor: colors.primary,
+          textColor: colors.bgCard,
+          onTap: onRightTap,
+        ),
+      ));
+    }
+
+    return buttons;
+  }
 }
 
 class _ActionButton extends StatelessWidget {

+ 6 - 0
lib/shared/widgets/app_scaffold.dart

@@ -119,6 +119,12 @@ class AppScaffold extends ConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
+    SystemChrome.setSystemUIOverlayStyle(
+      const SystemUiOverlayStyle(
+        statusBarColor: Colors.transparent,
+        statusBarIconBrightness: Brightness.dark,
+      ),
+    );
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
     final location = GoRouterState.of(context).uri.toString();

+ 72 - 16
lib/shared/widgets/attachment_picker.dart

@@ -135,23 +135,79 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
       context: context,
       backgroundColor: colors.bgCard,
       shape: const RoundedRectangleBorder(
-        borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
+        borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
       ),
       builder: (ctx) => SafeArea(
-        child: Column(
-          mainAxisSize: MainAxisSize.min,
-          children: [
-            ListTile(
-              leading: const Icon(Icons.image_outlined),
-              title: Text(l10n.get('pickImage')),
-              onTap: () => Navigator.pop(ctx, 'image'),
-            ),
-            ListTile(
-              leading: const Icon(Icons.description_outlined),
-              title: Text(l10n.get('pickFile')),
-              onTap: () => Navigator.pop(ctx, 'file'),
-            ),
-          ],
+        child: Padding(
+          padding: const EdgeInsets.fromLTRB(0, 8, 0, 20),
+          child: Column(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              // 拖拽手柄
+              Center(
+                child: Container(
+                  width: 36,
+                  height: 4,
+                  margin: const EdgeInsets.only(bottom: 12),
+                  decoration: BoxDecoration(
+                    color: colors.border,
+                    borderRadius: BorderRadius.circular(2),
+                  ),
+                ),
+              ),
+              // 选择图片
+              InkWell(
+                onTap: () => Navigator.pop(ctx, 'image'),
+                child: Padding(
+                  padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
+                  child: Row(
+                    children: [
+                      Container(
+                        width: 44,
+                        height: 44,
+                        decoration: BoxDecoration(
+                          color: colors.primaryLight,
+                          borderRadius: BorderRadius.circular(12),
+                        ),
+                        child: Icon(Icons.image_outlined, color: colors.primary, size: 24),
+                      ),
+                      const SizedBox(width: 16),
+                      Text(
+                        l10n.get('pickImage'),
+                        style: TextStyle(fontSize: 16, color: colors.textPrimary),
+                      ),
+                    ],
+                  ),
+                ),
+              ),
+              const Divider(height: 1, indent: 76),
+              // 选择文件
+              InkWell(
+                onTap: () => Navigator.pop(ctx, 'file'),
+                child: Padding(
+                  padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
+                  child: Row(
+                    children: [
+                      Container(
+                        width: 44,
+                        height: 44,
+                        decoration: BoxDecoration(
+                          color: colors.primaryLight,
+                          borderRadius: BorderRadius.circular(12),
+                        ),
+                        child: Icon(Icons.description_outlined, color: colors.primary, size: 24),
+                      ),
+                      const SizedBox(width: 16),
+                      Text(
+                        l10n.get('pickFile'),
+                        style: TextStyle(fontSize: 16, color: colors.textPrimary),
+                      ),
+                    ],
+                  ),
+                ),
+              ),
+            ],
+          ),
         ),
       ),
     );
@@ -267,7 +323,7 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
                   width: widget.thumbnailSize,
                   height: widget.thumbnailSize,
                   decoration: BoxDecoration(
-                    color: colors.bgPage,
+                    color: colors.bgCard,
                     borderRadius: BorderRadius.circular(4),
                     border: Border.all(color: colors.border, width: 1),
                   ),

+ 11 - 1
lib/shared/widgets/form_field_row.dart

@@ -13,6 +13,7 @@ class FormFieldRow extends StatelessWidget {
   final bool readOnly;
   final bool required;
   final VoidCallback? onTap;
+  final VoidCallback? onClear;
 
   const FormFieldRow({
     super.key,
@@ -23,6 +24,7 @@ class FormFieldRow extends StatelessWidget {
     this.readOnly = false,
     this.required = false,
     this.onTap,
+    this.onClear,
   });
 
   @override
@@ -70,7 +72,15 @@ class FormFieldRow extends StatelessWidget {
                         : colors.textPlaceholder,
                   ),
                 ),
-                if (showArrow && !readOnly) ...[
+                if (hasValue && onClear != null)
+                  GestureDetector(
+                    onTap: onClear,
+                    child: const Padding(
+                      padding: EdgeInsets.only(left: 8),
+                      child: Icon(Icons.close, size: 16, color: Color(0xFFBBBBBB)),
+                    ),
+                  )
+                else if (showArrow && !readOnly) ...[
                   const SizedBox(width: 4),
                   Icon(
                     Icons.chevron_right,

+ 1 - 3
lib/shared/widgets/skeleton_list_card.dart

@@ -290,9 +290,7 @@ class SkeletonLoadingList extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return ListView(
-      padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
-      physics: const NeverScrollableScrollPhysics(),
+    return Column(
       children: List.generate(
         cardCount,
         (_) => Padding(

+ 67 - 0
lib/shared/widgets/submitting_dialog.dart

@@ -0,0 +1,67 @@
+import 'package:flutter/material.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+import '../../core/theme/app_colors_extension.dart';
+import '../../core/i18n/app_localizations.dart';
+
+/// 提交中 loading 弹窗。
+///
+/// 方形卡片,上方菊花动画,下方文字。
+/// 使用方式:
+/// ```dart
+/// SubmittingDialog.show(context);
+/// // ... 异步操作 ...
+/// SubmittingDialog.hide(context);
+/// ```
+class SubmittingDialog {
+  SubmittingDialog._();
+
+  /// 显示提交中弹窗。返回按钮可关闭(关闭后 API 请求仍在进行)。
+  static void show(BuildContext context) {
+    showDialog(
+      context: context,
+      barrierDismissible: false,
+      builder: (_) => _SubmittingContent(),
+    );
+  }
+
+  /// 关闭提交中弹窗。即使弹窗已被返回键关闭也不会抛异常。
+  static void hide(BuildContext context) {
+    Navigator.of(context).maybePop();
+  }
+}
+
+class _SubmittingContent extends StatelessWidget {
+  @override
+  Widget build(BuildContext context) {
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    return Center(
+      child: Container(
+        width: 120,
+        padding: const EdgeInsets.fromLTRB(24, 28, 24, 24),
+        decoration: BoxDecoration(
+          color: colors.bgCard,
+          borderRadius: BorderRadius.circular(12),
+        ),
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            const TDLoading(
+              size: TDLoadingSize.large,
+              icon: TDLoadingIcon.activity,
+            ),
+            const SizedBox(height: 16),
+            Text(
+              l10n.get('submitting'),
+              style: TextStyle(
+                fontSize: 14,
+                fontWeight: FontWeight.w500,
+                color: colors.textSecondary,
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}