Quellcode durchsuchen

expense apply adjust

chengc vor 6 Tagen
Ursprung
Commit
436ed1b142

+ 24 - 1
assets/i18n/en.json

@@ -120,6 +120,7 @@
   "confirmApprove": "Confirm Approve",
   "confirmReject": "Confirm Reject",
   "confirmAction": "Confirm {action}?",
+  "confirmAdd": "Confirm Add",
   "approvalComment": "Approval Comment (optional)",
   "applyFilter": "Apply Filter",
   "basicInfo": "Basic Info",
@@ -196,6 +197,7 @@
   "expenseProject": "Expense Item",
   "expenseReason": "Expense Reason",
   "enterExpenseReason": "Enter reason",
+  "enterExpenseName": "Enter expense item name",
   "selectProject": "Select Project",
   "selectProjectAndSubject": "Select Project and Budget Subject",
   "selectSubject": "Select subject",
@@ -674,5 +676,26 @@
   "unitPerson": "Person",
   "unitDay": "Day",
   "unitSet": "Set",
-  "entertainmentVip": "VIP"
+  "entertainmentVip": "VIP",
+  "expenseDate": "Expense Date",
+  "reportNo": "Report No.",
+  "autoGenerated": "Auto-generated",
+  "currency": "Currency",
+  "selectCurrency": "Select Currency",
+  "paymentMethod": "Payment Method",
+  "selectPaymentMethod": "Select Payment Method",
+  "enterVoucherNo": "Enter Voucher No.",
+  "remark": "Remark",
+  "enterRemark": "Enter Remark",
+  "amountExcludingTax": "Amount excl. tax",
+  "amountInclTax": "Amount (incl. tax)",
+  "amountPositive": "Amount must be positive",
+  "taxAmount": "Tax Amount",
+  "taxRate": "Tax Rate",
+  "customerVendor": "Customer/Vendor",
+  "offsetAmount": "Offset Amount",
+  "projectCode": "Project Code",
+  "subjectCode": "Subject Code",
+  "projectCategory": "Project Category",
+  "approvedTotal": "Approved Total"
 }

+ 24 - 1
assets/i18n/zh_CN.json

@@ -120,6 +120,7 @@
   "confirmApprove": "确认通过",
   "confirmReject": "确认拒绝",
   "confirmAction": "确定要{action}该申请吗?",
+  "confirmAdd": "确认添加",
   "approvalComment": "审批意见(选填)",
   "applyFilter": "应用筛选",
   "basicInfo": "基本信息",
@@ -196,6 +197,7 @@
   "expenseProject": "费用项目",
   "expenseReason": "报销事由",
   "enterExpenseReason": "请输入报销事由",
+  "enterExpenseName": "请输入费用项目名称",
   "selectProject": "选择关联项目",
   "selectProjectAndSubject": "选择项目及预算科目",
   "selectSubject": "请选择科目",
@@ -674,5 +676,26 @@
   "unitPerson": "人",
   "unitDay": "天",
   "unitSet": "套",
-  "entertainmentVip": "VIP"
+  "entertainmentVip": "VIP",
+  "expenseDate": "报销日期",
+  "reportNo": "报销单号",
+  "autoGenerated": "系统自动生成",
+  "currency": "币别",
+  "selectCurrency": "选择币别",
+  "paymentMethod": "支付方式",
+  "selectPaymentMethod": "选择支付方式",
+  "enterVoucherNo": "请输入凭证号码",
+  "remark": "备注",
+  "enterRemark": "请输入备注",
+  "amountExcludingTax": "金额不含税",
+  "amountInclTax": "含税金额",
+  "amountPositive": "金额必须大于0",
+  "taxAmount": "税金",
+  "taxRate": "税率",
+  "customerVendor": "客户/厂商",
+  "offsetAmount": "已充金额",
+  "projectCode": "项目代号",
+  "subjectCode": "科目代号",
+  "projectCategory": "项目类别",
+  "approvedTotal": "核准合计"
 }

+ 24 - 1
assets/i18n/zh_TW.json

@@ -120,6 +120,7 @@
   "confirmApprove": "確認通過",
   "confirmReject": "確認拒絕",
   "confirmAction": "確定要{action}該申請嗎?",
+  "confirmAdd": "確認添加",
   "approvalComment": "審批意見(選填)",
   "applyFilter": "應用篩選",
   "basicInfo": "基本資訊",
@@ -363,6 +364,7 @@
   "expenseProject": "費用項目",
   "expenseReason": "報销事由",
   "enterExpenseReason": "請輸入報销事由",
+  "enterExpenseName": "請輸入費用項目名稱",
   "selectSubject": "請選择科目",
   "selectCostCenter": "請選择成本中心",
   "selectBank": "請選择開户银行",
@@ -674,5 +676,26 @@
   "hintTravelFields": "請填寫差旅專用欄位(行程日期、交通方式等)",
   "hintEntertainmentFields": "請填寫招待專用欄位(招待對象、層級、人數等)",
   "hintMeetingFields": "請填寫會議專用欄位(會議日期、地點等)",
-  "entertainmentVip": "VIP"
+  "entertainmentVip": "VIP",
+  "expenseDate": "報銷日期",
+  "reportNo": "報銷單號",
+  "autoGenerated": "系統自動生成",
+  "currency": "幣別",
+  "selectCurrency": "選擇幣別",
+  "paymentMethod": "支付方式",
+  "selectPaymentMethod": "選擇支付方式",
+  "enterVoucherNo": "請輸入憑證號碼",
+  "remark": "備註",
+  "enterRemark": "請輸入備註",
+  "amountExcludingTax": "金額不含稅",
+  "amountInclTax": "含稅金額",
+  "amountPositive": "金額必須大於0",
+  "taxAmount": "稅金",
+  "taxRate": "稅率",
+  "customerVendor": "客戶/廠商",
+  "offsetAmount": "已充金額",
+  "projectCode": "項目代號",
+  "subjectCode": "科目代號",
+  "projectCategory": "項目類別",
+  "approvedTotal": "核准合計"
 }

+ 14 - 4
docs/superpowers/specs/tboss-oa-database.md

@@ -305,6 +305,8 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **CostCenterId** | BIGINT | | 成本中心 ERP ID,通过 .NET → ERP CostCenterService 下拉选择。可选,ERP 无数据时隐藏 |
 | **ProjectId** | BIGINT | | 关联项目 ERP ID(直接新建时手动选,导入事前申请时自动带入) |
 | **BudgetSubjectId** | BIGINT | | 关联预算科目 ERP ID |
+| **CurrencyCode** | VARCHAR(10) | | 币别代码,默认 CNY。选外币时从 .NET → ERP ExchangeRateService 获取汇率填入明细行 |
+| **ApprovedAmount** | DECIMAL(18,2) | ✅ | 核准金额合计 = 所有明细行 ApprovedAmount 汇总,只读不手动修改,默认 0 |
 | **TotalAmount** | DECIMAL(18,2) | ✅ | 报销总金额 = 所有明细行 TotalAmount 汇总,只读不手动修改,默认 0 |
 | **Purpose** | NVARCHAR(500) | ✅ | 报销事由说明 |
 | **BankName** | NVARCHAR(100) | ✅ | 收款银行全称,前端支持下拉联想(数据源 .NET 字典 API),也可自由输入 |
@@ -346,12 +348,18 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **InvoiceCode** | VARCHAR(50) | | 发票代码 |
 | **InvoiceType** | VARCHAR(20) | ✅ | 发票类型:special(专票)/general(普票)/none(无发票)。无发票场景(单笔 ≤200 元小额零星)时选 none |
 | **TaxRate** | DECIMAL(5,4) | | 税率,如 0.0600 / 0.0900 / 0.1300 |
+| **ApprovedAmount** | DECIMAL(18,2) | ✅ | 该行核准金额,默认=TotalAmount(审批直接通过时),审批人可修改。默认 0 |
+| **CustomerVendorName** | NVARCHAR(200) | | 客户或供应商名称,用于采购/招待类费用追溯 |
+| **ProjectCode** | VARCHAR(30) | | 项目代号(ERP 项目编码),与 ProjectId 配对冗余存储,方便查询 |
+| **SubjectCode** | VARCHAR(30) | | 科目代号(ERP 科目编码),与 BudgetSubjectId 配对冗余存储 |
+| **ProjectCategory** | VARCHAR(20) | | 项目类别(ERP 项目分类字段),冗余存储 |
+| **OffsetAmount** | DECIMAL(18,2) | ✅ | 已冲账金额(如预付/借款冲抵),默认 0 |
 | **SortOrder** | INT | ✅ | 排序号,默认 1 |
 | **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() |
 | **UpdateTime** | DATETIME | | |
 | **IsDeleted** | BIT | ✅ | DEFAULT 0 |
 
-**索引**:`IX_ExpenseDetail_ExpenseId` (ExpenseId, SortOrder) INCLUDE (ExpenseDate, ExpenseType, ExpenseDesc, Amount, TotalAmount, InvoiceType)
+**索引**:`IX_ExpenseDetail_ExpenseId` (ExpenseId, SortOrder) INCLUDE (ExpenseDate, ExpenseType, ExpenseDesc, Amount, TotalAmount, InvoiceType, ApprovedAmount, CustomerVendorName, ProjectCode, SubjectCode, ProjectCategory, OffsetAmount)
 
 ---
 
@@ -746,7 +754,7 @@ ON VehiclePassenger (ApplicationId, IsDeleted)
 INCLUDE (UserId, PassengerName);
 CREATE NONCLUSTERED INDEX IX_ExpenseDetail_ExpenseId 
 ON ExpenseDetail (ExpenseId, SortOrder) 
-INCLUDE (ExpenseDate, ExpenseType, ExpenseDesc, Amount, TotalAmount, InvoiceType);
+INCLUDE (ExpenseDate, ExpenseType, ExpenseDesc, Amount, TotalAmount, InvoiceType, ApprovedAmount, CustomerVendorName, ProjectCode, SubjectCode, ProjectCategory, OffsetAmount);
 CREATE NONCLUSTERED INDEX IX_ExpenseAppDetail_AppId 
 ON ExpenseAppDetail (ApplicationId, SortOrder) 
 INCLUDE (ExpenseCategory, EstimatedAmount, Remark);
@@ -954,7 +962,7 @@ INCLUDE (ExpenseCategory, EstimatedAmount, Remark);
 // 主表 + 关联的申请
 const string sql = @"
 SELECT 
-    e.Id, e.ReportNo, e.TotalAmount, e.Purpose,
+    e.Id, e.ReportNo, e.TotalAmount, e.ApprovedAmount, e.CurrencyCode, e.Purpose,
     e.BankName, e.AccountName, e.BankAccount,
     e.IsInvoiceVerified, e.IsTaxIdMatched, e.IsCategoryCompliant,
     e.BankTransferNo, e.VoucherNo,
@@ -974,7 +982,9 @@ WHERE eam.ExpenseId = @expenseId;
 SELECT ed.Id, ed.ExpenseDate, ed.ExpenseType, ed.ExpenseDesc,
     ed.Amount, ed.TaxAmount, ed.TotalAmount,
     ed.CurrencyCode, ed.ExchangeRate, ed.BaseAmount,
-    ed.InvoiceNo, ed.InvoiceCode, ed.InvoiceType, ed.TaxRate, ed.SortOrder,
+    ed.InvoiceNo, ed.InvoiceCode, ed.InvoiceType, ed.TaxRate,
+    ed.ApprovedAmount, ed.CustomerVendorName, ed.ProjectCode, ed.SubjectCode, ed.ProjectCategory, ed.OffsetAmount,
+    ed.SortOrder,
     cc.CategoryName AS ExpenseTypeName
 FROM ExpenseDetail ed
 LEFT JOIN SysCostCategory cc ON cc.CategoryCode = ed.ExpenseType AND cc.IsDeleted = 0

+ 34 - 21
docs/superpowers/specs/tboss-oa-design.md

@@ -387,10 +387,25 @@
 
 | 字段 | 组件 | 说明 |
 |------|------|------|
-| 报销事由 | TDTextarea | 必填,对应 DB `Purpose` |
-| 成本中心 | TDPicker | .NET → ERP CostCenterService,可选。ERP 无数据时隐藏 |
-| 报销总金额 | 只读 | 明细累加上浮动画显示,对应 DB `TotalAmount` |
-| 项目 / 科目 | 只读或手动 | 导入时自动带入;直接新建时手动选择(TDCascader,同页面 4) |
+| 报销日期 | TDInput(只读) | 提交时写入当日 |
+| 报销单号 | TDInput(只读) | 草稿时显示"保存后自动生成",提交后显示 BX-YYYYMMDD-XXX |
+| 币别 | TDPicker | 默认 CNY,可选 USD/EUR/JPY/HKD/GBP,选外币时明细行自动联动汇率 |
+| 报销人员 | TDInput(只读) | 当前登录用户姓名 |
+| 报销部门 | TDInput(只读) | 当前用户所属部门 |
+| 支付方式 | TDPicker | 银行转账 / 现金 / 支付宝 / 微信支付,默认银行转账 |
+| 凭证号码 | TDInput | 选填,财务核销时录入的金蝶记账凭证号 |
+| 备注 | TDInput | 选填,整单备注说明 |
+
+##### 关联管控区
+
+| 字段 | 组件 | 说明 |
+|------|------|------|
+| 报销事由 | TDTextarea | 必填,≤500 字 |
+| 关联项目 | TDCascader | 同页面 4 |
+| 预算科目 | TDPicker | 同页面 4 |
+| 成本中心 | TDPicker | .NET → ERP CostCenterService,可选 |
+| 报销金额合计 | 只读 | ∑Detail.TotalAmount 自动汇总 |
+| 核准金额合计 | 只读 | ∑Detail.ApprovedAmount 自动汇总,审批阶段由审批人修改 |
 
 ##### 收款账户区
 
@@ -404,22 +419,20 @@
 
 | 字段 | 组件 | 说明 |
 |------|------|------|
-| 发生日期 | TDDatePicker | 年月日,对应 DB `ExpenseDate` |
-| 费用类别 | TDPicker | SysCostCategory 叶子节点,对应 DB `ExpenseType` |
-| 费用描述 | TDInput | 摘要说明,对应 DB `ExpenseDesc` |
-| 币种 | TDPicker | 默认 CNY。选外币时自动从 .NET→ERP ExchangeRateService 填汇率 |
-| 原币金额 | TDInput(数字) | 不含税金额,对应 DB `Amount` |
-| 税额 | TDInput(数字) | 进项税额,对应 DB `TaxAmount` |
-| 价税合计 | 只读 | 自动计算 = 金额+税额,对应 DB `TotalAmount` |
-| 本币金额 | 只读 | 自动计算 = 价税合计×汇率,对应 DB `BaseAmount` |
-| 发票类型 | TDPicker | 增值税专票(special) / 普票(general) / 无发票(none) |
-| 发票号码 | TDInput | 专票/普票时必填,无发票时可空 |
-| 发票代码 | TDInput | 同上 |
-| 税率 | TDPicker | 6% / 9% / 13%,对应 DB `TaxRate` |
-| [+ 添加] | 按钮 | 平滑展开新明细卡片 |
-| ✕ 删除 | 图标按钮 | 气泡确认→折叠消失 |
-
-> 无发票场景:单笔≤200 元小额零星可选 `none`,此时发票号和代码可空。明细金额自动汇总至主表 `TotalAmount`。
+| 费用项目 | TDInput | 费用摘要描述,必填 |
+| 金额(含税) | TDInput(数字) | 价税合计,必填。输入后自动计算不含税金额 |
+| 金额(不含税) | 只读 | = 含税金额 / (1+税率) |
+| 核准金额 | TDInput(数字) | 默认等于含税金额,审批阶段审批人可修改 |
+| 摘要 | TDInput | 选填,费用补充说明 |
+| 客户/厂商 | TDInput | 选填,采购/招待类费用关联的企业名称 |
+| 费用科目 | TDPicker | SysCostCategory 叶子节点 |
+| 项目代号 | 只读 | 由关联项目自动带入 |
+| 科目代号 | 只读 | 由关联科目自动带入 |
+| 项目类别 | 只读 | 由关联项目自动带入 |
+| 已充金额 | TDInput(数字) | 预付/借款冲抵金额,默认 0 |
+| 税金 | 只读 | = 含税金额 - 不含税金额 |
+| 税率 | TDTag 选择 | 6% / 9% / 13%,必选 |
+| [+ 添加] / ✕ 删除 | 按钮 | 同页面 4 |
 
 ##### 附件上传区
 
@@ -448,7 +461,7 @@
 | 项目 / 科目 | 直接新建时必选(ERP 有数据时) |
 | 导入金额 | 每条>0,∑≤对应申请剩余可报额度 |
 | 收款银行/户名/账号 | 必填,账号 16-19 位数字 |
-| 报销明细 | 至少一行(金额>0,费用类别必选) |
+| 明细行 | 至少一行(含税金额>0,费用项目必填) |
 | 发票信息 | 发票类型必选,专票/普票时发票号必填 |
 
 > 不通过时 ScrollTo 报错字段+红闪+TDMessage。

+ 5 - 0
lib/app.dart

@@ -11,6 +11,7 @@ import 'core/network/api_client.dart';
 import 'core/auth/auth_service.dart';
 import 'core/i18n/app_localizations.dart';
 import 'core/i18n/locale_provider.dart';
+import 'core/navigation/navigation_channel.dart';
 
 final apiClientProvider = Provider<ApiClient>((ref) {
   const useMock = true;
@@ -35,6 +36,10 @@ class App extends ConsumerWidget {
     final router = ref.watch(_routerProvider);
     final locale = ref.watch(localeProvider);
     final themeMode = ref.watch(themeModeProvider);
+
+    // 监听平台(iOS)下发的导航指令
+    NavigationChannel.startListening(router);
+
     TDTheme.needMultiTheme();
     TDTheme.setResourceBuilder(
       (context) => TDResourceI18nDelegate(context),

+ 45 - 0
lib/core/navigation/navigation_channel.dart

@@ -0,0 +1,45 @@
+import 'package:flutter/services.dart';
+import 'package:go_router/go_router.dart';
+
+/// 平台(iOS/Android)主动下发路由时,通过此 Channel 传递给 Flutter。
+///
+/// iOS 侧调用方式:
+/// ```objc
+/// [engine.navigationChannel invokeMethod:@"pushRouteInformation"
+///     arguments:@{@"location": @"/expense/apply"}];
+/// ```
+///
+/// 或者通过自定义 channel:
+/// ```objc
+/// FlutterMethodChannel *ch = [FlutterMethodChannel
+///     methodChannelWithName:@"com.tboss.oa/navigation"
+///            binaryMessenger:engine.binaryMessenger];
+/// [ch invokeMethod:@"navigateTo" arguments:@"/expense/apply"];
+/// ```
+class NavigationChannel {
+  static const _channel = MethodChannel('com.tboss.oa/navigation');
+
+  /// 开始监听平台下发的导航指令。
+  ///
+  /// [router] 必须是已初始化的 GoRouter 实例。
+  static void startListening(GoRouter router) {
+    _channel.setMethodCallHandler((call) async {
+      switch (call.method) {
+        case 'navigateTo':
+          final route = call.arguments as String?;
+          if (route != null && route.isNotEmpty) {
+            router.go(route);
+          }
+          break;
+        case 'pushRoute':
+          final route = call.arguments as String?;
+          if (route != null && route.isNotEmpty) {
+            router.push(route);
+          }
+          break;
+        default:
+          break;
+      }
+    });
+  }
+}

+ 55 - 4
lib/features/expense/expense_apply_controller.dart

@@ -40,6 +40,50 @@ class ExpenseApplyController extends StateNotifier<ExpenseApplyState> {
         ),
       );
 
+  void updateCurrencyCode(String code) {
+    state = state.copyWith(expense: state.expense.copyWith(currencyCode: code));
+  }
+
+  void updateApprovedAmount(double amount) {
+    state = state.copyWith(expense: state.expense.copyWith(approvedAmount: amount));
+  }
+
+  void updateDetailApprovedAmount(int index, double amount) {
+    final details = [...state.expense.details];
+    details[index] = details[index].copyWith(approvedAmount: amount);
+    state = state.copyWith(expense: state.expense.copyWith(details: details));
+  }
+
+  void updateDetailCustomerVendor(int index, String name) {
+    final details = [...state.expense.details];
+    details[index] = details[index].copyWith(customerVendorName: name);
+    state = state.copyWith(expense: state.expense.copyWith(details: details));
+  }
+
+  void updateDetailProjectCode(int index, String code) {
+    final details = [...state.expense.details];
+    details[index] = details[index].copyWith(projectCode: code);
+    state = state.copyWith(expense: state.expense.copyWith(details: details));
+  }
+
+  void updateDetailSubjectCode(int index, String code) {
+    final details = [...state.expense.details];
+    details[index] = details[index].copyWith(subjectCode: code);
+    state = state.copyWith(expense: state.expense.copyWith(details: details));
+  }
+
+  void updateDetailProjectCategory(int index, String category) {
+    final details = [...state.expense.details];
+    details[index] = details[index].copyWith(projectCategory: category);
+    state = state.copyWith(expense: state.expense.copyWith(details: details));
+  }
+
+  void updateDetailOffsetAmount(int index, double amount) {
+    final details = [...state.expense.details];
+    details[index] = details[index].copyWith(offsetAmount: amount);
+    state = state.copyWith(expense: state.expense.copyWith(details: details));
+  }
+
   void updatePurpose(String purpose) {
     state = state.copyWith(expense: state.expense.copyWith(purpose: purpose));
   }
@@ -55,11 +99,18 @@ class ExpenseApplyController extends StateNotifier<ExpenseApplyState> {
   }
 
   void recalculateAmount() {
-    final total = state.expense.details.fold<double>(
-      0,
-      (sum, d) => sum + d.totalAmount,
+    var totalAmount = 0.0;
+    var approvedAmount = 0.0;
+    for (final d in state.expense.details) {
+      totalAmount += d.totalAmount;
+      approvedAmount += d.approvedAmount;
+    }
+    state = state.copyWith(
+      expense: state.expense.copyWith(
+        totalAmount: totalAmount,
+        approvedAmount: approvedAmount,
+      ),
     );
-    state = state.copyWith(expense: state.expense.copyWith(totalAmount: total));
   }
 
   Future<bool> submit() async {

Datei-Diff unterdrückt, da er zu groß ist
+ 640 - 187
lib/features/expense/expense_apply_page.dart


+ 3 - 3
lib/features/expense/expense_detail_page.dart

@@ -214,7 +214,7 @@ class ExpenseDetailPage extends ConsumerWidget {
         ),
         FormFieldRow(
           label: l10n.get('bankAccount'),
-          value: expense.accountId.isNotEmpty ? expense.accountId : null,
+          value: expense.bankAccount.isNotEmpty ? expense.bankAccount : null,
           hint: '-',
           readOnly: true,
           showArrow: false,
@@ -347,7 +347,7 @@ class ExpenseDetailPage extends ConsumerWidget {
     AppLocalizations l10n,
     AppColorsExtension colors,
   ) {
-    final hasInvoices = expense.invoiceImages.isNotEmpty;
+    final hasInvoices = expense.attachments.isNotEmpty;
 
     return FormSection(
       title: l10n.get('invoiceAttachment'),
@@ -367,7 +367,7 @@ class ExpenseDetailPage extends ConsumerWidget {
           Wrap(
             spacing: 8,
             runSpacing: 8,
-            children: expense.invoiceImages.map((url) {
+            children: expense.attachments.map((url) {
               return Container(
                 width: 80,
                 height: 80,

+ 7 - 23
lib/features/expense/expense_list_controller.dart

@@ -19,15 +19,11 @@ final mockExpenses = <ExpenseModel>[
     invoiceCount: 3,
     status: 'pending',
     purpose: '北京出差拜访客户',
-    accountBankName: '中国银行',
-    accountHolderName: '张三',
+    bankName: '中国银行',
     accountName: '张三',
-    accountId: '6217001234567890123',
     paymentStatus: 'unpaid',
     projectName: '华北区客户拓展',
     budgetSubjectName: '差旅费预算',
-    sourceApplicationId: 'EA001',
-    sourceImportAmount: 2000.00,
     attachments: ['http://example.com/invoice1.jpg'],
     createTime: DateTime(2026, 5, 20),
     updateTime: DateTime(2026, 5, 20),
@@ -110,10 +106,8 @@ final mockExpenses = <ExpenseModel>[
     invoiceCount: 2,
     status: 'approved',
     purpose: '部门办公用品采购',
-    accountBankName: '中国银行',
-    accountHolderName: '张三',
+    bankName: '中国银行',
     accountName: '张三',
-    accountId: '6217001234567890123',
     paymentStatus: 'paid',
     voucherNo: 'V202606001',
     projectName: '办公运营',
@@ -165,10 +159,8 @@ final mockExpenses = <ExpenseModel>[
     invoiceCount: 1,
     status: 'rejected',
     purpose: '客户答谢晚宴',
-    accountBankName: '工商银行',
-    accountHolderName: '赵六',
+    bankName: '工商银行',
     accountName: '赵六',
-    accountId: '6222009876543210987',
     paymentStatus: 'unpaid',
     createTime: DateTime(2026, 5, 10),
     updateTime: DateTime(2026, 5, 12),
@@ -217,10 +209,8 @@ final mockExpenses = <ExpenseModel>[
     invoiceCount: 1,
     status: 'pending',
     purpose: '市内出行',
-    accountBankName: '建设银行',
-    accountHolderName: '钱七',
+    bankName: '建设银行',
     accountName: '钱七',
-    accountId: '6217001111222233334',
     paymentStatus: 'unpaid',
     createTime: DateTime(2026, 5, 22),
     updateTime: DateTime(2026, 5, 22),
@@ -256,10 +246,8 @@ final mockExpenses = <ExpenseModel>[
     invoiceCount: 4,
     status: 'approved',
     purpose: '深圳出差',
-    accountBankName: '中国银行',
-    accountHolderName: '张三',
+    bankName: '中国银行',
     accountName: '张三',
-    accountId: '6217001234567890123',
     paymentStatus: 'unpaid',
     voucherNo: '',
     projectName: '华南区客户拓展',
@@ -328,10 +316,8 @@ final mockExpenses = <ExpenseModel>[
     invoiceCount: 1,
     status: 'draft',
     purpose: '日常办公用车',
-    accountBankName: '中国银行',
-    accountHolderName: '张三',
+    bankName: '中国银行',
     accountName: '张三',
-    accountId: '6217001234567890123',
     paymentStatus: 'unpaid',
     createTime: DateTime(2026, 6, 1),
     updateTime: DateTime(2026, 6, 1),
@@ -367,10 +353,8 @@ final mockExpenses = <ExpenseModel>[
     invoiceCount: 2,
     status: 'withdrawn',
     purpose: '上海出差(已撤回)',
-    accountBankName: '中国银行',
-    accountHolderName: '张三',
+    bankName: '中国银行',
     accountName: '张三',
-    accountId: '6217001234567890123',
     paymentStatus: 'unpaid',
     createTime: DateTime(2026, 6, 2),
     updateTime: DateTime(2026, 6, 3),

+ 92 - 50
lib/features/expense/expense_model.dart

@@ -16,11 +16,7 @@ class ExpenseModel {
   final String projectName;
   final String budgetSubjectId;
   final String budgetSubjectName;
-  final double loanWriteoffAmount;
   final String paymentMethod;
-  final String accountBankName;
-  final String accountHolderName;
-  final String accountId;
   final String accountName;
   final String remark;
   final String purpose;
@@ -38,17 +34,14 @@ class ExpenseModel {
   final bool isInvoiceVerified;
   final bool isTaxIdMatched;
   final bool isCategoryCompliant;
-  // 申请来源
-  final String sourceApplicationId;
-  final double sourceImportAmount;
-  // 发票附件图片
-  final List<String> invoiceImages;
   // 数据库字段
   final String bankName;
   final String bankAccount;
   final String bankTransferNo;
   final String approvalInstanceId;
   final DateTime? applicationDate;
+  final String currencyCode;
+  final double approvedAmount;
 
   const ExpenseModel({
     required this.id,
@@ -66,15 +59,11 @@ class ExpenseModel {
     this.projectName = '',
     this.budgetSubjectId = '',
     this.budgetSubjectName = '',
-    this.loanWriteoffAmount = 0.0,
     this.attachments = const [],
     this.paymentStatus = 'unpaid',
     this.voucherNo = '',
     this.paymentMethod = '',
-    this.accountId = '',
     this.accountName = '',
-    this.accountBankName = '',
-    this.accountHolderName = '',
     this.remark = '',
     this.purpose = '',
     this.status = 'draft',
@@ -87,14 +76,13 @@ class ExpenseModel {
     this.isInvoiceVerified = false,
     this.isTaxIdMatched = false,
     this.isCategoryCompliant = false,
-    this.sourceApplicationId = '',
-    this.sourceImportAmount = 0.0,
     this.bankName = '',
     this.bankAccount = '',
     this.bankTransferNo = '',
     this.approvalInstanceId = '',
     this.applicationDate,
-    this.invoiceImages = const [],
+    this.currencyCode = 'CNY',
+    this.approvedAmount = 0.0,
   });
 
   factory ExpenseModel.fromJson(Map<String, dynamic> json) {
@@ -114,8 +102,6 @@ class ExpenseModel {
       projectName: json['projectName'] as String? ?? '',
       budgetSubjectId: json['budgetSubjectId'] as String? ?? '',
       budgetSubjectName: json['budgetSubjectName'] as String? ?? '',
-      loanWriteoffAmount:
-          (json['loanWriteoffAmount'] as num?)?.toDouble() ?? 0.0,
       attachments:
           (json['attachments'] as List<dynamic>?)
               ?.map((e) => e as String)
@@ -124,10 +110,7 @@ class ExpenseModel {
       paymentStatus: json['paymentStatus'] as String? ?? 'unpaid',
       voucherNo: json['voucherNo'] as String? ?? '',
       paymentMethod: json['paymentMethod'] as String? ?? '',
-      accountId: json['accountId'] as String? ?? '',
       accountName: json['accountName'] as String? ?? '',
-      accountBankName: json['accountBankName'] as String? ?? '',
-      accountHolderName: json['accountHolderName'] as String? ?? '',
       remark: json['remark'] as String? ?? '',
       purpose: json['purpose'] as String? ?? '',
       status: json['status'] as String? ?? 'draft',
@@ -154,9 +137,6 @@ class ExpenseModel {
       isInvoiceVerified: json['isInvoiceVerified'] as bool? ?? false,
       isTaxIdMatched: json['isTaxIdMatched'] as bool? ?? false,
       isCategoryCompliant: json['isCategoryCompliant'] as bool? ?? false,
-      sourceApplicationId: json['sourceApplicationId'] as String? ?? '',
-      sourceImportAmount:
-          (json['sourceImportAmount'] as num?)?.toDouble() ?? 0.0,
       bankName: json['bankName'] as String? ?? '',
       bankAccount: json['bankAccount'] as String? ?? '',
       bankTransferNo: json['bankTransferNo'] as String? ?? '',
@@ -164,11 +144,8 @@ class ExpenseModel {
       applicationDate: json['applicationDate'] != null
           ? DateTime.parse(json['applicationDate'] as String)
           : null,
-      invoiceImages:
-          (json['invoiceImages'] as List<dynamic>?)
-              ?.map((e) => e as String)
-              .toList() ??
-          [],
+      currencyCode: json['currencyCode'] as String? ?? 'CNY',
+      approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0.0,
     );
   }
 
@@ -188,15 +165,11 @@ class ExpenseModel {
     'projectName': projectName,
     'budgetSubjectId': budgetSubjectId,
     'budgetSubjectName': budgetSubjectName,
-    'loanWriteoffAmount': loanWriteoffAmount,
     'attachments': attachments,
     'paymentStatus': paymentStatus,
     'voucherNo': voucherNo,
     'paymentMethod': paymentMethod,
-    'accountId': accountId,
     'accountName': accountName,
-    'accountBankName': accountBankName,
-    'accountHolderName': accountHolderName,
     'remark': remark,
     'purpose': purpose,
     'status': status,
@@ -209,14 +182,13 @@ class ExpenseModel {
     'isInvoiceVerified': isInvoiceVerified,
     'isTaxIdMatched': isTaxIdMatched,
     'isCategoryCompliant': isCategoryCompliant,
-    'sourceApplicationId': sourceApplicationId,
-    'sourceImportAmount': sourceImportAmount,
     'bankName': bankName,
     'bankAccount': bankAccount,
     'bankTransferNo': bankTransferNo,
     'approvalInstanceId': approvalInstanceId,
     'applicationDate': applicationDate?.toIso8601String(),
-    'invoiceImages': invoiceImages,
+    'currencyCode': currencyCode,
+    'approvedAmount': approvedAmount,
   };
 
   ExpenseModel copyWith({
@@ -235,15 +207,11 @@ class ExpenseModel {
     String? projectName,
     String? budgetSubjectId,
     String? budgetSubjectName,
-    double? loanWriteoffAmount,
     List<String>? attachments,
     String? paymentStatus,
     String? voucherNo,
     String? paymentMethod,
-    String? accountId,
     String? accountName,
-    String? accountBankName,
-    String? accountHolderName,
     String? remark,
     String? purpose,
     String? status,
@@ -256,14 +224,13 @@ class ExpenseModel {
     bool? isInvoiceVerified,
     bool? isTaxIdMatched,
     bool? isCategoryCompliant,
-    String? sourceApplicationId,
-    double? sourceImportAmount,
     String? bankName,
     String? bankAccount,
     String? bankTransferNo,
     String? approvalInstanceId,
     DateTime? applicationDate,
-    List<String>? invoiceImages,
+    String? currencyCode,
+    double? approvedAmount,
   }) {
     return ExpenseModel(
       id: id ?? this.id,
@@ -281,15 +248,11 @@ class ExpenseModel {
       projectName: projectName ?? this.projectName,
       budgetSubjectId: budgetSubjectId ?? this.budgetSubjectId,
       budgetSubjectName: budgetSubjectName ?? this.budgetSubjectName,
-      loanWriteoffAmount: loanWriteoffAmount ?? this.loanWriteoffAmount,
       attachments: attachments ?? this.attachments,
       paymentStatus: paymentStatus ?? this.paymentStatus,
       voucherNo: voucherNo ?? this.voucherNo,
       paymentMethod: paymentMethod ?? this.paymentMethod,
-      accountId: accountId ?? this.accountId,
       accountName: accountName ?? this.accountName,
-      accountBankName: accountBankName ?? this.accountBankName,
-      accountHolderName: accountHolderName ?? this.accountHolderName,
       remark: remark ?? this.remark,
       purpose: purpose ?? this.purpose,
       status: status ?? this.status,
@@ -302,14 +265,13 @@ class ExpenseModel {
       isInvoiceVerified: isInvoiceVerified ?? this.isInvoiceVerified,
       isTaxIdMatched: isTaxIdMatched ?? this.isTaxIdMatched,
       isCategoryCompliant: isCategoryCompliant ?? this.isCategoryCompliant,
-      sourceApplicationId: sourceApplicationId ?? this.sourceApplicationId,
-      sourceImportAmount: sourceImportAmount ?? this.sourceImportAmount,
       bankName: bankName ?? this.bankName,
       bankAccount: bankAccount ?? this.bankAccount,
       bankTransferNo: bankTransferNo ?? this.bankTransferNo,
       approvalInstanceId: approvalInstanceId ?? this.approvalInstanceId,
       applicationDate: applicationDate ?? this.applicationDate,
-      invoiceImages: invoiceImages ?? this.invoiceImages,
+      currencyCode: currencyCode ?? this.currencyCode,
+      approvedAmount: approvedAmount ?? this.approvedAmount,
     );
   }
 }
@@ -333,6 +295,12 @@ class ExpenseDetailModel {
   final double taxRate;
   final String remark;
   final List<String> attachments;
+  final double approvedAmount;
+  final String customerVendorName;
+  final String projectCode;
+  final String subjectCode;
+  final String projectCategory;
+  final double offsetAmount;
   final int sortOrder;
 
   const ExpenseDetailModel({
@@ -354,6 +322,12 @@ class ExpenseDetailModel {
     this.taxRate = 0.0,
     this.remark = '',
     this.attachments = const [],
+    this.approvedAmount = 0.0,
+    this.customerVendorName = '',
+    this.projectCode = '',
+    this.subjectCode = '',
+    this.projectCategory = '',
+    this.offsetAmount = 0.0,
     this.sortOrder = 1,
   });
 
@@ -381,6 +355,12 @@ class ExpenseDetailModel {
               ?.map((e) => e as String)
               .toList() ??
           [],
+      approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0.0,
+      customerVendorName: json['customerVendorName'] as String? ?? '',
+      projectCode: json['projectCode'] as String? ?? '',
+      subjectCode: json['subjectCode'] as String? ?? '',
+      projectCategory: json['projectCategory'] as String? ?? '',
+      offsetAmount: (json['offsetAmount'] as num?)?.toDouble() ?? 0.0,
       sortOrder: json['sortOrder'] as int? ?? 1,
     );
   }
@@ -404,6 +384,68 @@ class ExpenseDetailModel {
     'taxRate': taxRate,
     'remark': remark,
     'attachments': attachments,
+    'approvedAmount': approvedAmount,
+    'customerVendorName': customerVendorName,
+    'projectCode': projectCode,
+    'subjectCode': subjectCode,
+    'projectCategory': projectCategory,
+    'offsetAmount': offsetAmount,
     'sortOrder': sortOrder,
   };
+
+  ExpenseDetailModel copyWith({
+    String? id,
+    String? expenseId,
+    DateTime? expenseDate,
+    String? expenseType,
+    String? expenseDesc,
+    double? amount,
+    double? taxAmount,
+    double? totalAmount,
+    double? baseAmount,
+    String? currency,
+    double? exchangeRate,
+    String? invoiceNo,
+    String? invoiceCode,
+    String? invoiceType,
+    bool? isDeductible,
+    double? taxRate,
+    String? remark,
+    List<String>? attachments,
+    double? approvedAmount,
+    String? customerVendorName,
+    String? projectCode,
+    String? subjectCode,
+    String? projectCategory,
+    double? offsetAmount,
+    int? sortOrder,
+  }) {
+    return ExpenseDetailModel(
+      id: id ?? this.id,
+      expenseId: expenseId ?? this.expenseId,
+      expenseDate: expenseDate ?? this.expenseDate,
+      expenseType: expenseType ?? this.expenseType,
+      expenseDesc: expenseDesc ?? this.expenseDesc,
+      amount: amount ?? this.amount,
+      taxAmount: taxAmount ?? this.taxAmount,
+      totalAmount: totalAmount ?? this.totalAmount,
+      baseAmount: baseAmount ?? this.baseAmount,
+      currency: currency ?? this.currency,
+      exchangeRate: exchangeRate ?? this.exchangeRate,
+      invoiceNo: invoiceNo ?? this.invoiceNo,
+      invoiceCode: invoiceCode ?? this.invoiceCode,
+      invoiceType: invoiceType ?? this.invoiceType,
+      isDeductible: isDeductible ?? this.isDeductible,
+      taxRate: taxRate ?? this.taxRate,
+      remark: remark ?? this.remark,
+      attachments: attachments ?? this.attachments,
+      approvedAmount: approvedAmount ?? this.approvedAmount,
+      customerVendorName: customerVendorName ?? this.customerVendorName,
+      projectCode: projectCode ?? this.projectCode,
+      subjectCode: subjectCode ?? this.subjectCode,
+      projectCategory: projectCategory ?? this.projectCategory,
+      offsetAmount: offsetAmount ?? this.offsetAmount,
+      sortOrder: sortOrder ?? this.sortOrder,
+    );
+  }
 }

+ 560 - 0
lib/features/expense/widgets/expense_detail_edit_dialog.dart

@@ -0,0 +1,560 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+import '../../../core/i18n/app_localizations.dart';
+import '../../../core/theme/app_colors.dart';
+import '../../../core/theme/app_colors_extension.dart';
+import '../../../core/data/mock_api_data.dart';
+
+/// 报销明细输入数据。
+class ExpenseDetailInputData {
+  final String category;
+  final String categoryName;
+  final String expenseDesc;
+  final double amount; // 含税金额
+  final double taxRate;
+  final String customerVendorName;
+  final double offsetAmount;
+  final String remark;
+
+  const ExpenseDetailInputData({
+    required this.category,
+    required this.categoryName,
+    required this.expenseDesc,
+    required this.amount,
+    required this.taxRate,
+    this.customerVendorName = '',
+    this.offsetAmount = 0.0,
+    this.remark = '',
+  });
+}
+
+/// 报销明细编辑弹窗。
+///
+/// 使用 [TDSlidePopupRoute] 从底部滑出,卡片化展示表单字段。
+/// 参照 ExpenseApplicationApplyPage 的 ExpenseDetailDialog 样式。
+class ExpenseDetailEditDialog extends StatefulWidget {
+  final List<CostCategory> categories;
+  final AppLocalizations l10n;
+
+  const ExpenseDetailEditDialog({
+    super.key,
+    required this.categories,
+    required this.l10n,
+  });
+
+  /// 显示弹窗,返回 [ExpenseDetailInputData] 或 `null`(取消时)。
+  static Future<ExpenseDetailInputData?> show(
+    BuildContext context, {
+    required List<CostCategory> categories,
+    required AppLocalizations l10n,
+  }) {
+    FocusScope.of(context).unfocus();
+    return Navigator.push<ExpenseDetailInputData>(
+      context,
+      TDSlidePopupRoute<ExpenseDetailInputData>(
+        slideTransitionFrom: SlideTransitionFrom.bottom,
+        isDismissible: false,
+        builder: (_) => ExpenseDetailEditDialog(
+          categories: categories,
+          l10n: l10n,
+        ),
+      ),
+    );
+  }
+
+  @override
+  State<ExpenseDetailEditDialog> createState() =>
+      _ExpenseDetailEditDialogState();
+}
+
+class _ExpenseDetailEditDialogState extends State<ExpenseDetailEditDialog> {
+  late String _cat;
+  late String _catLabel;
+  late TextEditingController _descCtrl;
+  late TextEditingController _amountCtrl;
+  late TextEditingController _customerCtrl;
+  late TextEditingController _offsetCtrl;
+  late TextEditingController _remarkCtrl;
+  double _taxRate = 0.06;
+
+  static const _taxOptions = [0.06, 0.09, 0.13];
+  static const _taxLabels = ['6%', '9%', '13%'];
+
+  List<CostCategory> get _cats => widget.categories;
+  AppLocalizations get _l10n => widget.l10n;
+
+  @override
+  void initState() {
+    super.initState();
+    _cat = _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();
+  }
+
+  @override
+  void dispose() {
+    _descCtrl.dispose();
+    _amountCtrl.dispose();
+    _customerCtrl.dispose();
+    _offsetCtrl.dispose();
+    _remarkCtrl.dispose();
+    super.dispose();
+  }
+
+  void _confirm() {
+    final amount = double.tryParse(_amountCtrl.text) ?? 0;
+    final desc = _descCtrl.text.trim();
+    if (desc.isEmpty) {
+      TDToast.showText(_l10n.get('enterExpenseName'), context: context);
+      return;
+    }
+    if (amount <= 0) {
+      TDToast.showText(_l10n.get('amountPositive'), context: context);
+      return;
+    }
+    Navigator.pop(
+      context,
+      ExpenseDetailInputData(
+        category: _cat,
+        categoryName: _l10n.get(
+          _cats.firstWhere((c) => c.code == _cat).nameKey,
+        ),
+        expenseDesc: desc,
+        amount: amount,
+        taxRate: _taxRate,
+        customerVendorName: _customerCtrl.text.trim(),
+        offsetAmount: double.tryParse(_offsetCtrl.text) ?? 0,
+        remark: _remarkCtrl.text.trim(),
+      ),
+    );
+  }
+
+  double get _amountExclTax => _taxRate > 0
+      ? (double.tryParse(_amountCtrl.text) ?? 0) / (1 + _taxRate)
+      : (double.tryParse(_amountCtrl.text) ?? 0);
+  double get _taxAmount =>
+      (double.tryParse(_amountCtrl.text) ?? 0) - _amountExclTax;
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final bottomInset = MediaQuery.of(context).viewInsets.bottom;
+
+    return SafeArea(
+      child: Padding(
+        padding: EdgeInsets.only(bottom: bottomInset),
+        child: Container(
+          decoration: BoxDecoration(
+            color: colors.bgPage,
+            borderRadius:
+                const BorderRadius.vertical(top: Radius.circular(16)),
+          ),
+          child: Column(
+            mainAxisSize: MainAxisSize.min,
+            crossAxisAlignment: CrossAxisAlignment.stretch,
+            children: [
+              _buildHeader(colors),
+              Flexible(
+                child: SingleChildScrollView(
+                  padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
+                  child: Column(
+                    mainAxisSize: MainAxisSize.min,
+                    crossAxisAlignment: CrossAxisAlignment.stretch,
+                    children: [
+                      _buildCategoryCard(colors),
+                      const SizedBox(height: 12),
+                      _buildDescCard(),
+                      const SizedBox(height: 12),
+                      _buildAmountCard(),
+                      const SizedBox(height: 12),
+                      _buildTaxRateCard(colors),
+                      const SizedBox(height: 12),
+                      // 自动计算展示
+                      _buildCalcInfo(colors),
+                      const SizedBox(height: 12),
+                      _buildCustomerCard(),
+                      const SizedBox(height: 12),
+                      _buildOffsetCard(),
+                      const SizedBox(height: 12),
+                      _buildRemarkCard(colors),
+                    ],
+                  ),
+                ),
+              ),
+              Container(
+                padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
+                decoration: BoxDecoration(
+                  color: colors.bgCard,
+                  border: Border(
+                    top: BorderSide(color: colors.border, width: 0.5),
+                  ),
+                ),
+                child: _buildActions(),
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+
+  // ── 标题栏 ──
+  Widget _buildHeader(AppColorsExtension colors) {
+    return Column(
+      mainAxisSize: MainAxisSize.min,
+      children: [
+        Center(
+          child: Container(
+            margin: const EdgeInsets.only(top: 8, bottom: 4),
+            width: 36,
+            height: 4,
+            decoration: BoxDecoration(
+              color: colors.border,
+              borderRadius: BorderRadius.circular(2),
+            ),
+          ),
+        ),
+        Padding(
+          padding: const EdgeInsets.fromLTRB(20, 8, 12, 16),
+          child: Row(
+            children: [
+              const SizedBox(width: 28),
+              Expanded(
+                child: Center(
+                  child: Text(
+                    _l10n.get('addExpenseDetail'),
+                    style: TextStyle(
+                      fontSize: AppFontSizes.title,
+                      fontWeight: FontWeight.w600,
+                      color: colors.textPrimary,
+                    ),
+                  ),
+                ),
+              ),
+              GestureDetector(
+                onTap: () => Navigator.pop(context),
+                child: Padding(
+                  padding: const EdgeInsets.all(4),
+                  child: Icon(
+                    Icons.close,
+                    size: 20,
+                    color: colors.textSecondary,
+                  ),
+                ),
+              ),
+            ],
+          ),
+        ),
+      ],
+    );
+  }
+
+  // ── picker 卡片 ──
+  Widget _pickerCard({
+    required String label,
+    required bool required,
+    required String currentLabel,
+    required List<String> labels,
+    required ValueChanged<int> onSelected,
+    required AppColorsExtension colors,
+  }) {
+    final tdTheme = TDTheme.of(context);
+    return GestureDetector(
+      onTap: () {
+        TDPicker.showMultiPicker(
+          context,
+          title: label,
+          backgroundColor: colors.bgCard,
+          data: [labels],
+          onConfirm: (selected) {
+            if (selected.isNotEmpty && selected[0] is int) {
+              final idx = selected[0] as int;
+              if (idx >= 0 && idx < labels.length) {
+                Navigator.of(context).pop();
+                onSelected(idx);
+              }
+            }
+          },
+        );
+      },
+      child: 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),
+            ),
+            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: 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,
+                  ),
+                ],
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  // ── 费用类别 ──
+  Widget _buildCategoryCard(AppColorsExtension colors) {
+    return _pickerCard(
+      label: _l10n.get('expenseCategory'),
+      required: true,
+      currentLabel: _catLabel,
+      labels: _cats.map((c) => _l10n.get(c.nameKey)).toList(),
+      colors: colors,
+      onSelected: (idx) => setState(() {
+        _cat = _cats[idx].code;
+        _catLabel = _l10n.get(_cats[idx].nameKey);
+      }),
+    );
+  }
+
+  // ── 费用项目 ──
+  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,
+      controller: _descCtrl,
+      hintText: _l10n.get('enterExpenseName'),
+      contentAlignment: TextAlign.center,
+      showBottomDivider: false,
+      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'),
+      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(() {});
+      },
+    );
+  }
+
+  // ── 税率 ──
+  Widget _buildTaxRateCard(AppColorsExtension colors) {
+    final currentLabel =
+        '${(_taxRate * 100).toStringAsFixed(0)}%';
+    return _pickerCard(
+      label: _l10n.get('taxRate'),
+      required: true,
+      currentLabel: currentLabel,
+      labels: _taxLabels.toList(),
+      colors: colors,
+      onSelected: (idx) => setState(() {
+        _taxRate = _taxOptions[idx];
+      }),
+    );
+  }
+
+  // ── 计算信息 ──
+  Widget _buildCalcInfo(AppColorsExtension colors) {
+    final amount = double.tryParse(_amountCtrl.text) ?? 0;
+    if (amount <= 0) return const SizedBox.shrink();
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
+      decoration: BoxDecoration(
+        color: colors.primary50,
+        borderRadius: BorderRadius.circular(8),
+      ),
+      child: Row(
+        children: [
+          Expanded(
+            child: Text(
+              '${_l10n.get('amountExcludingTax')}: ¥${_amountExclTax.toStringAsFixed(2)}',
+              style: TextStyle(
+                fontSize: AppFontSizes.body,
+                color: colors.textSecondary,
+              ),
+            ),
+          ),
+          Text(
+            '${_l10n.get('taxAmount')}: ¥${_taxAmount.toStringAsFixed(2)}',
+            style: TextStyle(
+              fontSize: AppFontSizes.body,
+              color: colors.textSecondary,
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  // ── 客户/厂商 ──
+  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 _buildOffsetCard() {
+    final screenWidth = MediaQuery.of(context).size.width;
+    return TDInput(
+      type: TDInputType.cardStyle,
+      cardStyle: TDCardStyle.topText,
+      width: screenWidth - 32,
+      leftLabel: _l10n.get('offsetAmount'),
+      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(() {});
+      },
+    );
+  }
+
+  // ── 备注 ──
+  Widget _buildRemarkCard(AppColorsExtension colors) {
+    final tdTheme = TDTheme.of(context);
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Padding(
+          padding: const EdgeInsets.only(left: 4),
+          child: TDText(
+            _l10n.get('detailRemark'),
+            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,
+        ),
+      ],
+    );
+  }
+
+  // ── 操作按钮 ──
+  Widget _buildActions() {
+    return Row(
+      children: [
+        Expanded(
+          child: TDButton(
+            text: _l10n.get('cancel'),
+            size: TDButtonSize.large,
+            type: TDButtonType.outline,
+            shape: TDButtonShape.rectangle,
+            theme: TDButtonTheme.defaultTheme,
+            onTap: () => Navigator.pop(context),
+          ),
+        ),
+        const SizedBox(width: 12),
+        Expanded(
+          child: TDButton(
+            text: _l10n.get('confirmAdd'),
+            size: TDButtonSize.large,
+            type: TDButtonType.fill,
+            shape: TDButtonShape.rectangle,
+            theme: TDButtonTheme.primary,
+            onTap: _confirm,
+          ),
+        ),
+      ],
+    );
+  }
+}