Sfoglia il codice sorgente

feat(expense_application): 重构费用明细弹窗 + 级联选择 + 日期校验

- 抽取 ExpenseDetailDialog 独立组件,使用 TDSlidePopupRoute + TDInput cardStyle
- NumberInputConfig 参数化数字输入控制(整数/小数位/键盘类型)
- 关联管控改用 TDCascader 级联选择项目→预算科目
- 差旅/会议日期增加起止日期互斥校验 + i18n
- 优化弹窗 UI:标题居中、卡片风格统一、按钮固定底部
chengc 3 giorni fa
parent
commit
171768a728

+ 3 - 0
assets/i18n/en.json

@@ -197,7 +197,9 @@
   "expenseReason": "Expense Reason",
   "enterExpenseReason": "Enter reason",
   "selectProject": "Select Project",
+  "selectProjectAndSubject": "Select Project and Budget Subject",
   "selectSubject": "Select subject",
+  "project": "Project",
   "selectCostCenter": "Select cost center",
   "selectBank": "Select bank",
   "enterBankAccount": "Enter account",
@@ -551,6 +553,7 @@
   "selectExpiryDate": "Select Expiry Date",
   "selectTransport": "Select Transport",
   "selectUnit": "Select Unit",
+  "startDateNotAfterEndDate": "Start date cannot be later than end date",
   "submitFailedRetry": "Submission failed, please try again later",
   "submittedAwaitingApproval": "Submitted, awaiting approval",
   "tapToViewNavigation": "Tap to view navigation",

+ 3 - 0
assets/i18n/zh_CN.json

@@ -197,7 +197,9 @@
   "expenseReason": "报销事由",
   "enterExpenseReason": "请输入报销事由",
   "selectProject": "选择关联项目",
+  "selectProjectAndSubject": "选择项目及预算科目",
   "selectSubject": "请选择科目",
+  "project": "项目",
   "selectCostCenter": "请选择成本中心",
   "selectBank": "请选择开户银行",
   "enterBankAccount": "请输入银行账号",
@@ -551,6 +553,7 @@
   "selectExpiryDate": "选择过期日期",
   "selectTransport": "选择交通工具",
   "selectUnit": "选择单位",
+  "startDateNotAfterEndDate": "开始日期不能大于结束日期",
   "submitFailedRetry": "提交失败,请稍后重试",
   "submittedAwaitingApproval": "已提交,等待审批",
   "tapToViewNavigation": "点击查看导航",

+ 3 - 0
assets/i18n/zh_TW.json

@@ -231,6 +231,7 @@
   "selectExpiryDate": "選擇過期日期",
   "selectTransport": "選擇交通工具",
   "selectUnit": "選擇單位",
+  "startDateNotAfterEndDate": "開始日期不能大於結束日期",
   "submitFailedRetry": "提交失敗,請稍後重試",
   "submittedAwaitingApproval": "已提交,等待審批",
   "tapToViewNavigation": "點擊檢視導航",
@@ -251,6 +252,8 @@
   "announcementExpired": "該公告已於 {date} 過期",
   "returnCarArchivedAt": "已還車歸檔於 {time}",
   "selectProject": "選擇關聯項目",
+  "selectProjectAndSubject": "選擇項目及預算科目",
+  "project": "項目",
   "selectedCount": "已選 {count} 人",
   "watermarkHintDynamic": "照片將自動加入浮水印:伺服器授時 + GPS經緯度({lat}°N, {lng}°E)",
   "tdOpen": "開",

+ 117 - 298
lib/features/expense_application/expense_application_apply_page.dart

@@ -11,6 +11,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 'widgets/expense_detail_dialog.dart';
 
 class ExpenseApplicationApplyPage extends ConsumerStatefulWidget {
   final String? id;
@@ -325,7 +326,17 @@ class _ExpenseApplicationApplyPageState
           value: _estimatedEndDate,
           hint: l10n.get('pleaseSelect'),
           required: true,
-          onTap: () => _pickDate((d) => setState(() => _estimatedEndDate = d)),
+          onTap: () => _pickDate((d) {
+            if (_estimatedStartDate.isNotEmpty &&
+                _estimatedStartDate.compareTo(d) > 0) {
+              TDToast.showText(
+                l10n.get('startDateNotAfterEndDate'),
+                context: context,
+              );
+              return;
+            }
+            setState(() => _estimatedEndDate = d);
+          }),
         ),
         const SizedBox(height: 16),
         Row(
@@ -448,7 +459,17 @@ class _ExpenseApplicationApplyPageState
           value: _meetingEndDate,
           hint: l10n.get('pleaseSelect'),
           required: true,
-          onTap: () => _pickDate((d) => setState(() => _meetingEndDate = d)),
+          onTap: () => _pickDate((d) {
+            if (_meetingStartDate.isNotEmpty &&
+                _meetingStartDate.compareTo(d) > 0) {
+              TDToast.showText(
+                l10n.get('startDateNotAfterEndDate'),
+                context: context,
+              );
+              return;
+            }
+            setState(() => _meetingEndDate = d);
+          }),
         ),
         const SizedBox(height: 16),
         FormFieldRow(
@@ -466,60 +487,72 @@ class _ExpenseApplicationApplyPageState
   }
 
   // ═══ 3. 关联管控 ═══
+  List<Map<String, dynamic>> _buildCascadeData() {
+    return mockProjects
+        .map(
+          (p) => <String, dynamic>{
+            'label': p.name,
+            'value': p.id.toString(),
+            'children': mockBudgetSubjects
+                .map(
+                  (s) => <String, dynamic>{
+                    'label': s.name,
+                    'value': s.id.toString(),
+                  },
+                )
+                .toList(),
+          },
+        )
+        .toList();
+  }
+
   Widget _buildControlSection(AppLocalizations l10n) {
+    final cascadeLabel = _selectedProjectName != null &&
+            _selectedSubjectName != null
+        ? '$_selectedProjectName / $_selectedSubjectName'
+        : _selectedProjectName;
     return FormSection(
       title: l10n.get('relatedControl'),
       leadingIcon: Icons.link_outlined,
       children: [
         FormFieldRow(
           label: l10n.get('relatedProject'),
-          value: _selectedProjectName,
-          hint: l10n.get('selectProject'),
+          value: cascadeLabel,
+          hint: l10n.get('selectProjectAndSubject'),
           required: true,
           onTap: () {
-            _showListPicker(
-              l10n.get('selectProject'),
-              mockProjects.map((p) => p.name).toList(),
-              (v) {
-                final p = mockProjects.firstWhere((x) => x.name == v);
-                setState(() {
-                  _selectedProjectId = p.id;
-                  _selectedProjectName = p.name;
-                  _selectedSubjectName = null;
-                  _selectedSubjectId = null;
-                  _availableBudget = 0;
-                });
-              },
-            );
-          },
-        ),
-        const SizedBox(height: 16),
-        FormFieldRow(
-          label: l10n.get('budgetSubject'),
-          value: _selectedSubjectName,
-          hint: l10n.get('selectSubject'),
-          required: true,
-          onTap: _selectedProjectId != null
-              ? () {
-                  _showListPicker(
-                    l10n.get('selectSubject'),
-                    mockBudgetSubjects.map((s) => s.name).toList(),
-                    (v) {
-                      final s = mockBudgetSubjects.firstWhere(
-                        (x) => x.name == v,
-                      );
+            _unfocus();
+            TDCascader.showMultiCascader(
+              context,
+              title: l10n.get('selectProjectAndSubject'),
+              data: _buildCascadeData(),
+              subTitles: [
+                l10n.get('project'),
+                l10n.get('budgetSubject'),
+              ],
+              onClose: () => Navigator.of(context).pop(),
+              onChange: (selected) {
+                  if (selected.length >= 2) {
+                    final pId =
+                        int.tryParse(selected[0].value ?? '');
+                    final sId =
+                        int.tryParse(selected[1].value ?? '');
+                    if (pId != null && sId != null) {
                       setState(() {
-                        _selectedSubjectId = s.id;
-                        _selectedSubjectName = s.name;
-                        _availableBudget = getMockBudget(
-                          _selectedProjectId!,
-                          s.id,
-                        );
+                        _selectedProjectId = pId;
+                        _selectedProjectName =
+                            selected[0].label;
+                        _selectedSubjectId = sId;
+                        _selectedSubjectName =
+                            selected[1].label;
+                        _availableBudget =
+                            getMockBudget(pId, sId);
                       });
-                    },
-                  );
-                }
-              : null,
+                    }
+                  }
+                },
+            );
+          },
         ),
         const SizedBox(height: 16),
         _buildBudgetRow(l10n),
@@ -738,259 +771,30 @@ class _ExpenseApplicationApplyPageState
     return mockCostCategories.where((c) => codes.contains(c.code)).toList();
   }
 
-  void _showDetailDialog() {
-    _unfocus();
+  Future<void> _showDetailDialog() async {
     final l10n = AppLocalizations.of(context);
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final cats = _availableDetailCategories;
-    String cat = cats.isNotEmpty ? cats.first.code : 'other';
-    String unit = l10n.get('unitPiece');
-    String catLabel = l10n.get(cats.firstWhere((c) => c.code == cat).nameKey);
-    String unitLabel = l10n.get('unitPiece');
-    final qtyCtrl = TextEditingController(text: '1');
-    final priceCtrl = TextEditingController();
-    final remarkCtrl = TextEditingController();
-
-    showModalBottomSheet(
-      context: context,
-      isScrollControlled: true,
-      backgroundColor: colors.bgCard,
-      shape: const RoundedRectangleBorder(
-        borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
-      ),
-      builder: (ctx) => StatefulBuilder(
-        builder: (ctx, setDlg) => Padding(
-          padding: EdgeInsets.only(
-            left: 16,
-            right: 16,
-            top: 16,
-            bottom: 16 + MediaQuery.of(ctx).viewInsets.bottom,
-          ),
-          child: Column(
-            mainAxisSize: MainAxisSize.min,
-            crossAxisAlignment: CrossAxisAlignment.stretch,
-            children: [
-              Center(
-                child: Text(
-                  l10n.get('addExpenseDetail'),
-                  style: TextStyle(
-                    fontSize: 18,
-                    fontWeight: FontWeight.w600,
-                    color: colors.textPrimary,
-                  ),
-                ),
-              ),
-              const SizedBox(height: 16),
-              // 费用类别 — 左右布局,必填
-              Row(
-                crossAxisAlignment: CrossAxisAlignment.center,
-                children: [
-                  SizedBox(
-                    width: 80,
-                    child: _label(l10n.get('expenseCategory'), required: true),
-                  ),
-                  const SizedBox(width: 8),
-                  Expanded(
-                    child: Container(
-                      clipBehavior: Clip.antiAlias,
-                      padding: const EdgeInsets.symmetric(horizontal: 12),
-                      decoration: BoxDecoration(
-                        borderRadius: BorderRadius.circular(8),
-                        border: Border.all(color: colors.border, width: 1),
-                      ),
-                      child: TDDropdownMenu(
-                        items: [
-                          TDDropdownItem(
-                            label: catLabel,
-                            options: cats
-                                .map(
-                                  (c) => TDDropdownItemOption(
-                                    value: c.code,
-                                    label: l10n.get(c.nameKey),
-                                  ),
-                                )
-                                .toList(),
-                          ),
-                        ],
-                        closeOnClickOverlay: true,
-                        onMenuClosed: (index) {
-                          if (index >= 0 && index < cats.length) {
-                            cat = cats[index].code;
-                            setDlg(() {});
-                          }
-                        },
-                      ),
-                    ),
-                  ),
-                ],
-              ),
-              const SizedBox(height: 12),
-              // 数量 — 左右布局,必填
-              Row(
-                crossAxisAlignment: CrossAxisAlignment.center,
-                children: [
-                  SizedBox(
-                    width: 80,
-                    child: _label(l10n.get('quantity'), required: true),
-                  ),
-                  const SizedBox(width: 8),
-                  Expanded(
-                    child: Container(
-                      clipBehavior: Clip.antiAlias,
-                      padding: const EdgeInsets.symmetric(horizontal: 12),
-                      decoration: BoxDecoration(
-                        borderRadius: BorderRadius.circular(8),
-                        border: Border.all(color: colors.border, width: 1),
-                      ),
-                      child: TDInput(
-                        controller: qtyCtrl,
-                        hintText: '>0',
-                        contentAlignment: TextAlign.center,
-                      ),
-                    ),
-                  ),
-                ],
-              ),
-              const SizedBox(height: 12),
-              // 单位 — 左右布局
-              Row(
-                crossAxisAlignment: CrossAxisAlignment.center,
-                children: [
-                  SizedBox(width: 80, child: _label(l10n.get('unit'))),
-                  const SizedBox(width: 8),
-                  Expanded(
-                    child: Container(
-                      clipBehavior: Clip.antiAlias,
-                      padding: const EdgeInsets.symmetric(horizontal: 12),
-                      decoration: BoxDecoration(
-                        borderRadius: BorderRadius.circular(8),
-                        border: Border.all(color: colors.border, width: 1),
-                      ),
-                      child: TDDropdownMenu(
-                        items: [
-                          TDDropdownItem(
-                            label: unitLabel,
-                            options: unitOptions
-                                .map(
-                                  (u) => TDDropdownItemOption(
-                                    value: u,
-                                    label: l10n.get(u),
-                                  ),
-                                )
-                                .toList(),
-                          ),
-                        ],
-                        closeOnClickOverlay: true,
-                        onMenuClosed: (index) {
-                          if (index >= 0 && index < unitOptions.length) {
-                            unit = l10n.get(unitOptions[index]);
-                            setDlg(() {});
-                          }
-                        },
-                      ),
-                    ),
-                  ),
-                ],
-              ),
-              const SizedBox(height: 12),
-              // 单价 — 左右布局,必填
-              Row(
-                crossAxisAlignment: CrossAxisAlignment.center,
-                children: [
-                  SizedBox(
-                    width: 80,
-                    child: _label(l10n.get('unitPrice'), required: true),
-                  ),
-                  const SizedBox(width: 8),
-                  Expanded(
-                    child: Container(
-                      clipBehavior: Clip.antiAlias,
-                      padding: const EdgeInsets.symmetric(horizontal: 12),
-                      decoration: BoxDecoration(
-                        borderRadius: BorderRadius.circular(8),
-                        border: Border.all(color: colors.border, width: 1),
-                      ),
-                      child: TDInput(
-                        controller: priceCtrl,
-                        hintText: '>0',
-                        contentAlignment: TextAlign.center,
-                      ),
-                    ),
-                  ),
-                ],
-              ),
-              const SizedBox(height: 12),
-              _label(l10n.get('detailRemark')),
-              const SizedBox(height: 8),
-              TDTextarea(
-                controller: remarkCtrl,
-                hintText: l10n.get('optional'),
-                maxLines: 3,
-                minLines: 1,
-                maxLength: 200,
-                indicator: true,
-                padding: EdgeInsets.zero,
-                bordered: true,
-                backgroundColor: colors.bgPage,
-              ),
-              const SizedBox(height: 16),
-              Row(
-                children: [
-                  Expanded(
-                    child: TDButton(
-                      text: l10n.get('cancel'),
-                      size: TDButtonSize.large,
-                      type: TDButtonType.outline,
-                      shape: TDButtonShape.rectangle,
-                      theme: TDButtonTheme.defaultTheme,
-                      onTap: () => Navigator.pop(ctx),
-                    ),
-                  ),
-                  const SizedBox(width: 12),
-                  Expanded(
-                    child: TDButton(
-                      text: l10n.get('confirm'),
-                      size: TDButtonSize.large,
-                      type: TDButtonType.fill,
-                      shape: TDButtonShape.rectangle,
-                      theme: TDButtonTheme.primary,
-                      onTap: () {
-                        final q = int.tryParse(qtyCtrl.text) ?? 0;
-                        final p = double.tryParse(priceCtrl.text) ?? 0;
-                        if (q <= 0 || p <= 0) {
-                          TDToast.showText(
-                            l10n.get('quantityPricePositive'),
-                            context: context,
-                          );
-                          return;
-                        }
-                        setState(
-                          () => _details.add(
-                            _DetailItem(
-                              id: _detailIdCounter++,
-                              category: cat,
-                              categoryName: l10n.get(
-                                cats.firstWhere((c) => c.code == cat).nameKey,
-                              ),
-                              quantity: q,
-                              unit: unit,
-                              unitPrice: p,
-                              amount: q * p.toDouble(),
-                              remark: remarkCtrl.text,
-                            ),
-                          ),
-                        );
-                        Navigator.pop(ctx);
-                      },
-                    ),
-                  ),
-                ],
-              ),
-            ],
+    final result = await ExpenseDetailDialog.show(
+      context,
+      categories: _availableDetailCategories,
+      unitKeys: unitOptions,
+      l10n: l10n,
+    );
+    if (result != null && mounted) {
+      setState(
+        () => _details.add(
+          _DetailItem(
+            id: _detailIdCounter++,
+            category: result.category,
+            categoryName: result.categoryName,
+            quantity: result.quantity,
+            unit: result.unit,
+            unitPrice: result.unitPrice,
+            amount: result.quantity * result.unitPrice,
+            remark: result.remark,
           ),
         ),
-      ),
-    );
+      );
+    }
   }
 
   // ═══ 5. 附件上传 ═══
@@ -1137,7 +941,14 @@ class _ExpenseApplicationApplyPageState
       if (_estimatedStartDate.isEmpty) {
         e.add(l10n.get('selectEstimatedStartDate'));
       }
-      if (_estimatedEndDate.isEmpty) e.add(l10n.get('selectEstimatedEndDate'));
+      if (_estimatedEndDate.isEmpty) {
+        e.add(l10n.get('selectEstimatedEndDate'));
+      }
+      if (_estimatedStartDate.isNotEmpty &&
+          _estimatedEndDate.isNotEmpty &&
+          _estimatedStartDate.compareTo(_estimatedEndDate) > 0) {
+        e.add(l10n.get('startDateNotAfterEndDate'));
+      }
     }
     if (_expenseTypes.contains('entertainment')) {
       if (_entertainmentTarget == null || _entertainmentTarget!.isEmpty) {
@@ -1151,7 +962,14 @@ class _ExpenseApplicationApplyPageState
       if (_meetingStartDate.isEmpty) {
         e.add(l10n.get('selectEstimatedStartDate'));
       }
-      if (_meetingEndDate.isEmpty) e.add(l10n.get('selectEstimatedEndDate'));
+      if (_meetingEndDate.isEmpty) {
+        e.add(l10n.get('selectEstimatedEndDate'));
+      }
+      if (_meetingStartDate.isNotEmpty &&
+          _meetingEndDate.isNotEmpty &&
+          _meetingStartDate.compareTo(_meetingEndDate) > 0) {
+        e.add(l10n.get('startDateNotAfterEndDate'));
+      }
     }
     return e;
   }
@@ -1212,6 +1030,7 @@ class _ExpenseApplicationApplyPageState
       builder: (ctx) => TDAlertDialog(
         title: title,
         content: content,
+        buttonStyle: TDDialogButtonStyle.text,
         leftBtn: TDDialogButtonOptions(
           title: leftText,
           titleColor: colors.primary,
@@ -1405,7 +1224,7 @@ class _DetailItem {
   final int id;
   final String category;
   final String categoryName;
-  final int quantity;
+  final double quantity;
   final String unit;
   final double unitPrice;
   final double amount;

+ 501 - 0
lib/features/expense_application/widgets/expense_detail_dialog.dart

@@ -0,0 +1,501 @@
+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 NumberInputConfig {
+  final bool allowDecimal;
+  final int maxDecimalPlaces;
+
+  const NumberInputConfig({
+    this.allowDecimal = false,
+    this.maxDecimalPlaces = 2,
+  });
+
+  /// 键盘类型:小数用带小数点的数字键盘,整数用纯数字键盘。
+  TextInputType get keyboardType => allowDecimal
+      ? const TextInputType.numberWithOptions(decimal: true)
+      : TextInputType.number;
+
+  /// 输入过滤器。
+  List<TextInputFormatter> get inputFormatters => [
+    FilteringTextInputFormatter.allow(
+      allowDecimal
+          ? RegExp(r'^\d*\.?\d{0,' + maxDecimalPlaces.toString() + r'}$')
+          : RegExp(r'^\d*$'),
+    ),
+  ];
+
+  /// 仅整数。
+  static const integer = NumberInputConfig();
+
+  /// 最多 2 位小数(如数量)。
+  static const qty = NumberInputConfig(allowDecimal: true);
+
+  /// 最多 2 位小数(如金额/单价)。
+  static const money = NumberInputConfig(allowDecimal: true);
+}
+
+/// 费用明细输入数据。
+class ExpenseDetailData {
+  final String category;
+  final String categoryName;
+  final double quantity;
+  final String unit;
+  final double unitPrice;
+  final String remark;
+
+  const ExpenseDetailData({
+    required this.category,
+    required this.categoryName,
+    required this.quantity,
+    required this.unit,
+    required this.unitPrice,
+    required this.remark,
+  });
+}
+
+/// 添加费用明细弹窗。
+///
+/// 使用 [TDSlidePopupRoute] 从底部滑出,卡片化展示表单字段。
+/// [quantityConfig] 与 [priceConfig] 控制各自输入的数字格式。
+/// const NumberInputConfig(allowDecimal: true, maxDecimalPlaces: 3) // 允许小数,最多3位小数
+class ExpenseDetailDialog extends StatefulWidget {
+  final List<CostCategory> categories;
+  final List<String> unitKeys;
+  final AppLocalizations l10n;
+  final NumberInputConfig quantityConfig;
+  final NumberInputConfig priceConfig;
+
+  const ExpenseDetailDialog({
+    super.key,
+    required this.categories,
+    required this.unitKeys,
+    required this.l10n,
+    this.quantityConfig = NumberInputConfig.qty,
+    this.priceConfig = NumberInputConfig.money,
+  });
+
+  /// 显示弹窗,返回 [ExpenseDetailData] 或 `null`(取消时)。
+  static Future<ExpenseDetailData?> show(
+    BuildContext context, {
+    required List<CostCategory> categories,
+    required List<String> unitKeys,
+    required AppLocalizations l10n,
+    NumberInputConfig quantityConfig = NumberInputConfig.money,
+    NumberInputConfig priceConfig = NumberInputConfig.money,
+  }) {
+    FocusScope.of(context).unfocus();
+    return Navigator.push<ExpenseDetailData>(
+      context,
+      TDSlidePopupRoute<ExpenseDetailData>(
+        slideTransitionFrom: SlideTransitionFrom.bottom,
+        isDismissible: false,
+        builder: (_) => ExpenseDetailDialog(
+          categories: categories,
+          unitKeys: unitKeys,
+          l10n: l10n,
+          quantityConfig: quantityConfig,
+          priceConfig: priceConfig,
+        ),
+      ),
+    );
+  }
+
+  @override
+  State<ExpenseDetailDialog> createState() => _ExpenseDetailDialogState();
+}
+
+class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
+  late String _cat;
+  late String _unit;
+  late String _catLabel;
+  late String _unitLabel;
+  late TextEditingController _qtyCtrl;
+  late TextEditingController _priceCtrl;
+  late TextEditingController _remarkCtrl;
+
+  List<CostCategory> get _cats => widget.categories;
+  AppLocalizations get _l10n => widget.l10n;
+
+  @override
+  void initState() {
+    super.initState();
+    _cat = _cats.isNotEmpty ? _cats.first.code : 'other';
+    _unit = widget.unitKeys.first;
+    _catLabel = _l10n.get(_cats.firstWhere((c) => c.code == _cat).nameKey);
+    _unitLabel = _l10n.get(_unit);
+    _qtyCtrl = TextEditingController(text: '1');
+    _priceCtrl = TextEditingController();
+    _remarkCtrl = TextEditingController();
+  }
+
+  @override
+  void dispose() {
+    _qtyCtrl.dispose();
+    _priceCtrl.dispose();
+    _remarkCtrl.dispose();
+    super.dispose();
+  }
+
+  void _confirm() {
+    final q = double.tryParse(_qtyCtrl.text) ?? 0;
+    final p = double.tryParse(_priceCtrl.text) ?? 0;
+    if (q <= 0 || p <= 0) {
+      TDToast.showText(_l10n.get('quantityPricePositive'), context: context);
+      return;
+    }
+    Navigator.pop(
+      context,
+      ExpenseDetailData(
+        category: _cat,
+        categoryName: _l10n.get(
+          _cats.firstWhere((c) => c.code == _cat).nameKey,
+        ),
+        quantity: q,
+        unit: _unit,
+        unitPrice: p,
+        remark: _remarkCtrl.text,
+      ),
+    );
+  }
+
+  @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),
+                      _buildQuantityCard(),
+                      const SizedBox(height: 12),
+                      _buildUnitCard(colors),
+                      const SizedBox(height: 12),
+                      _buildPriceCard(),
+                      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),
+                ),
+              ),
+            ],
+          ),
+        ),
+      ],
+    );
+  }
+
+  // ── 选择卡片(点击唤起 TDPicker.showMultiPicker,右侧箭头) ──
+  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.map((e) => e).toList()],
+          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.0),
+                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);
+      }),
+    );
+  }
+
+  // ── 数量(cardStyle,独占一行) ──
+  Widget _buildQuantityCard() {
+    final screenWidth = MediaQuery.of(context).size.width;
+
+    return TDInput(
+      type: TDInputType.cardStyle,
+      cardStyle: TDCardStyle.topText,
+      width: screenWidth - 32,
+      leftLabel: _l10n.get('quantity'),
+      required: true,
+      controller: _qtyCtrl,
+      hintText: '>0',
+      contentAlignment: TextAlign.center,
+      inputType: widget.quantityConfig.keyboardType,
+      inputFormatters: widget.quantityConfig.inputFormatters,
+      showBottomDivider: false,
+      onChanged: (_) => setState(() {}),
+      onClearTap: () {
+        _qtyCtrl.clear();
+        setState(() {});
+      },
+    );
+  }
+
+  // ── 单位 ──
+  Widget _buildUnitCard(AppColorsExtension colors) {
+    return _pickerCard(
+      label: _l10n.get('unit'),
+      required: false,
+      currentLabel: _unitLabel,
+      labels: widget.unitKeys.map((u) => _l10n.get(u)).toList(),
+      colors: colors,
+      onSelected: (idx) => setState(() {
+        _unit = widget.unitKeys[idx];
+        _unitLabel = _l10n.get(_unit);
+      }),
+    );
+  }
+
+  // ── 单价(cardStyle) ──
+  Widget _buildPriceCard() {
+    final screenWidth = MediaQuery.of(context).size.width;
+
+    return TDInput(
+      type: TDInputType.cardStyle,
+      cardStyle: TDCardStyle.topText,
+      width: screenWidth - 32,
+      leftLabel: _l10n.get('unitPrice'),
+      required: true,
+      controller: _priceCtrl,
+      hintText: '>0',
+      contentAlignment: TextAlign.center,
+      inputType: widget.priceConfig.keyboardType,
+      inputFormatters: widget.priceConfig.inputFormatters,
+      showBottomDivider: false,
+      onChanged: (_) => setState(() {}),
+      onClearTap: () {
+        _priceCtrl.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('confirm'),
+            size: TDButtonSize.large,
+            type: TDButtonType.fill,
+            shape: TDButtonShape.rectangle,
+            theme: TDButtonTheme.primary,
+            onTap: _confirm,
+          ),
+        ),
+      ],
+    );
+  }
+}