chengc пре 1 дан
родитељ
комит
6482513067

+ 11 - 0
lib/features/expense/expense_api.dart

@@ -370,6 +370,17 @@ class ExpenseApi {
     );
     return ReportData.fromJson(response.data!);
   }
+
+  /// 费用报销报表明细(分页)
+  Future<Map<String, dynamic>> getExpenseReportDetails({
+    String? startDate, String? endDate, int page = 1, int size = 20,
+  }) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetExpenseReportDetails',
+      queryParameters: {'startDate': startDate, 'endDate': endDate, 'page': page, 'size': size},
+    );
+    return response.data!;
+  }
 }
 
 /// 费用报销审批列表项

+ 4 - 2
lib/features/expense/expense_apply_import_page.dart

@@ -93,6 +93,7 @@ class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
   List<ImportableItem> _items = [];
   final Map<String, bool> _expandedGroups = {};
   bool _loading = false;
+  bool _initialLoaded = false;
   bool _hasMore = true;
   int _page = 1;
   late final ScrollController _scrollCtrl;
@@ -128,7 +129,7 @@ class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
       final now = DateTime.now();
       _startDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
       _endDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${DateTime(now.year, now.month + 1, 0).day.toString().padLeft(2, '0')}';
-      _items = []; _page = 1; _hasMore = true; _generation++;
+      _items = []; _page = 1; _hasMore = true; _initialLoaded = false; _generation++;
       _load();
     }
   }
@@ -162,6 +163,7 @@ class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
       setState(() {
         if (append) { _items.addAll(list); _page++; } else { _items = list; _page = 2; }
         _loading = false;
+        _initialLoaded = true;
         _hasMore = list.length >= 20;
       });
     } catch (_) {
@@ -373,7 +375,7 @@ class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
   }
 
   Widget _buildListContent(AppLocalizations l10n, AppColorsExtension colors, Map<String, List<ImportableItem>> grouped) {
-    if (_loading && _items.isEmpty) {
+    if (_loading && !_initialLoaded) {
       return EasyRefresh(
         header: TDRefreshHeader(),
         controller: _refreshCtrl,

+ 124 - 100
lib/features/expense/expense_create_page.dart

@@ -215,13 +215,17 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
     final r = ResponsiveHelper.of(context);
     final l10n = AppLocalizations.of(context);
 
-    setNavBarTitle(context, ref, NavBarConfig(
-      title: widget.editId != null
-          ? l10n.get('editExpense')
-          : l10n.get('expenseApply'),
-      showBack: true,
-      onBack: () => _doPop(),
-    ));
+    setNavBarTitle(
+      context,
+      ref,
+      NavBarConfig(
+        title: widget.editId != null
+            ? l10n.get('editExpense')
+            : l10n.get('expenseApply'),
+        showBack: true,
+        onBack: () => _doPop(),
+      ),
+    );
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final bottomInset = MediaQuery.of(context).padding.bottom;
 
@@ -415,7 +419,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
         'DEP': _selectedDeptId,
         'USR_NO': HostAppChannel.usr,
         'PAY_ID': expense.paymentMethod,
-        'PRT_SW': 'N',
+        //'PRT_SW': 'N',
         'USR': HostAppChannel.usr,
         'REM': expense.remark,
         'CUR_ID': expense.currencyCode,
@@ -620,7 +624,13 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
         Row(
           mainAxisAlignment: MainAxisAlignment.spaceBetween,
           children: [
-            Text(l10n.get('generateVoucher'), style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
+            Text(
+              l10n.get('generateVoucher'),
+              style: TextStyle(
+                fontSize: AppFontSizes.subtitle,
+                color: colors.textSecondary,
+              ),
+            ),
             TDSwitch(
               isOn: expense.isGenerateVoucher,
               onChanged: (v) {
@@ -944,12 +954,24 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
     final l10n = AppLocalizations.of(context);
     final children = <Widget>[];
     if (!_attachAvailable) {
-      children.add(Text(l10n.get('attachServiceUnavailable'),
-          style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)));
+      children.add(
+        Text(
+          l10n.get('attachServiceUnavailable'),
+          style: TextStyle(
+            fontSize: AppFontSizes.caption,
+            color: colors.textPlaceholder,
+          ),
+        ),
+      );
     } else {
       children.addAll([
-        Text(l10n.get('maxAttachment'),
-            style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
+        Text(
+          l10n.get('maxAttachment'),
+          style: TextStyle(
+            fontSize: AppFontSizes.caption,
+            color: colors.textPlaceholder,
+          ),
+        ),
         const SizedBox(height: 8),
       ]);
     }
@@ -1021,7 +1043,8 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
           // 上传附件(billNo 提取失败则跳过,不影响主流程)
           if (billNo != null) {
             final now = DateTime.now();
-            final effDd = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} '
+            final effDd =
+                '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} '
                 '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}.'
                 '${now.millisecond.toString().padLeft(3, '0')}';
             final usr = HostAppChannel.usr;
@@ -1103,94 +1126,95 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
         TDToast.showText(l10n.get('noCostTypeData'), context: context);
         return;
       }
-    final state = controller.currentState;
-    ExpenseDetailInputData? initialData;
-    if (editIndex != null) {
-      final d = state.expense.details[editIndex];
-      initialData = ExpenseDetailInputData(
-        category: d.expenseCategory,
-        categoryName: d.categoryName,
-        acctSubjectId: d.acctSubjectId,
-        acctSubjectName: d.acctSubjectName,
-        purpose: d.purpose,
-        amount: d.amount,
-        taxRate: d.taxRate,
-        projectId: d.projectId,
-        projectName: d.projectName,
-        costDeptId: d.costDeptId,
-        costDeptName: d.costDeptName,
-        customerVendorId: d.customerVendorId,
-        customerVendorName: d.customerVendorName,
-        approvedAmount: d.approvedAmount,
-        bankName: d.bankName,
-        bankAccountName: d.bankAccountName,
-        bankAccount: d.bankAccount,
-        remark: d.remark,
-        attachmentPaths: d.attachments,
-        sqMan: d.sqMan,
-        sqManName: d.sqManName,
-        aeNo: d.aeNo,
-        aeDd: d.aeDd,
-      );
-    }
-    FocusManager.instance.primaryFocus?.unfocus();
-    final result = await ExpenseDetailDialog.show(
-      context,
-      categories: _dialogCategories,
-      projects: _dialogProjects,
-      costDepts: _dialogCostDepts,
-      customers: _dialogCustomers,
-      employees: _dialogEmployees,
-      l10n: l10n,
-      initialData: initialData,
-      checkAttachHealth: () => ref.read(expenseApiProvider).checkAttachHealth(),
-    );
-    if (result != null && mounted) {
-      final now = DateTime.now();
-      final detail = ExpenseDetailModel(
-        id: editIndex != null
-            ? state.expense.details[editIndex].id
-            : now.millisecondsSinceEpoch.toString(),
-        expenseId: '',
-        expenseCategory: result.category,
-        categoryName: result.categoryName,
-        purpose: result.purpose,
-        amount: result.taxRate > 0
-            ? result.amount / (1 + result.taxRate)
-            : result.amount,
-        taxRate: result.taxRate,
-        taxAmount: result.taxRate > 0
-            ? result.amount - result.amount / (1 + result.taxRate)
-            : 0,
-        totalAmount: result.amount,
-        projectId: result.projectId,
-        projectName: result.projectName,
-        costDeptId: result.costDeptId,
-        costDeptName: result.costDeptName,
-        acctSubjectId: result.acctSubjectId,
-        acctSubjectName: result.acctSubjectName,
-        customerVendorId: result.customerVendorId,
-        customerVendorName: result.customerVendorName,
-        approvedAmount: result.approvedAmount,
-        bankName: result.bankName,
-        bankAccountName: result.bankAccountName,
-        bankAccount: result.bankAccount,
-        sqMan: result.sqMan,
-        sqManName: result.sqManName,
-        aeNo: result.aeNo,
-        aeDd: result.aeDd,
-        remark: result.remark,
-        attachments: result.attachmentPaths,
-        createTime: now,
-        updateTime: now,
-      );
+      final state = controller.currentState;
+      ExpenseDetailInputData? initialData;
       if (editIndex != null) {
-        controller.updateDetail(editIndex, detail);
-      } else {
-        controller.addDetail(detail);
+        final d = state.expense.details[editIndex];
+        initialData = ExpenseDetailInputData(
+          category: d.expenseCategory,
+          categoryName: d.categoryName,
+          acctSubjectId: d.acctSubjectId,
+          acctSubjectName: d.acctSubjectName,
+          purpose: d.purpose,
+          amount: d.amount,
+          taxRate: d.taxRate,
+          projectId: d.projectId,
+          projectName: d.projectName,
+          costDeptId: d.costDeptId,
+          costDeptName: d.costDeptName,
+          customerVendorId: d.customerVendorId,
+          customerVendorName: d.customerVendorName,
+          approvedAmount: d.approvedAmount,
+          bankName: d.bankName,
+          bankAccountName: d.bankAccountName,
+          bankAccount: d.bankAccount,
+          remark: d.remark,
+          attachmentPaths: d.attachments,
+          sqMan: d.sqMan,
+          sqManName: d.sqManName,
+          aeNo: d.aeNo,
+          aeDd: d.aeDd,
+        );
+      }
+      FocusManager.instance.primaryFocus?.unfocus();
+      final result = await ExpenseDetailDialog.show(
+        context,
+        categories: _dialogCategories,
+        projects: _dialogProjects,
+        costDepts: _dialogCostDepts,
+        customers: _dialogCustomers,
+        employees: _dialogEmployees,
+        l10n: l10n,
+        initialData: initialData,
+        checkAttachHealth: () =>
+            ref.read(expenseApiProvider).checkAttachHealth(),
+      );
+      if (result != null && mounted) {
+        final now = DateTime.now();
+        final detail = ExpenseDetailModel(
+          id: editIndex != null
+              ? state.expense.details[editIndex].id
+              : now.millisecondsSinceEpoch.toString(),
+          expenseId: '',
+          expenseCategory: result.category,
+          categoryName: result.categoryName,
+          purpose: result.purpose,
+          amount: result.taxRate > 0
+              ? result.amount / (1 + result.taxRate)
+              : result.amount,
+          taxRate: result.taxRate,
+          taxAmount: result.taxRate > 0
+              ? result.amount - result.amount / (1 + result.taxRate)
+              : 0,
+          totalAmount: result.amount,
+          projectId: result.projectId,
+          projectName: result.projectName,
+          costDeptId: result.costDeptId,
+          costDeptName: result.costDeptName,
+          acctSubjectId: result.acctSubjectId,
+          acctSubjectName: result.acctSubjectName,
+          customerVendorId: result.customerVendorId,
+          customerVendorName: result.customerVendorName,
+          approvedAmount: result.approvedAmount,
+          bankName: result.bankName,
+          bankAccountName: result.bankAccountName,
+          bankAccount: result.bankAccount,
+          sqMan: result.sqMan,
+          sqManName: result.sqManName,
+          aeNo: result.aeNo,
+          aeDd: result.aeDd,
+          remark: result.remark,
+          attachments: result.attachmentPaths,
+          createTime: now,
+          updateTime: now,
+        );
+        if (editIndex != null) {
+          controller.updateDetail(editIndex, detail);
+        } else {
+          controller.addDetail(detail);
+        }
+        controller.recalculateAmount();
       }
-      controller.recalculateAmount();
-    }
     } finally {
       _addingDetail = false;
     }

+ 1 - 1
lib/features/expense/widgets/expense_detail_dialog.dart

@@ -702,7 +702,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     final currentLabel = '${(_taxRate * 100).toStringAsFixed(0)}%';
     return _pickerCard(
       label: _l10n.get('taxRate'),
-      required: true,
+      required: false,
       currentLabel: currentLabel,
       labels: _taxLabels.toList(),
       colors: colors,

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

@@ -119,6 +119,12 @@ class ExpenseApplyApi {
     return list.map((e) => DepartmentItem.fromJson(e as Map<String, dynamic>)).toList();
   }
 
+  /// 会计科目树(级联选择器数据源)
+  Future<dynamic> getAcctSubjects() async {
+    final response = await _client.get('/OA/GetAcctSubjects');
+    return response.data;
+  }
+
   /// 提交审批,返回申请单号(提取失败时返回 null,不影响主流程)
   /// BillSave 返回格式: { callok:true, resultData:{ BIL_NO:"AE20267020005", ... } }
   Future<String?> submit(Map<String, dynamic> data) async {
@@ -249,4 +255,15 @@ class ExpenseApplyApi {
     );
     return ReportData.fromJson(response.data!);
   }
+
+  /// 费用申请报表明细(分页)
+  Future<Map<String, dynamic>> getExpenseApplyReportDetails({
+    String? startDate, String? endDate, int page = 1, int size = 20,
+  }) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetExpenseApplyReportDetails',
+      queryParameters: {'startDate': startDate, 'endDate': endDate, 'page': page, 'size': size},
+    );
+    return response.data!;
+  }
 }

+ 310 - 228
lib/features/expense_apply/expense_apply_create_page.dart

@@ -30,8 +30,7 @@ class ExpenseApplyCreatePage extends ConsumerStatefulWidget {
       _ExpenseApplyCreatePageState();
 }
 
-class _ExpenseApplyCreatePageState
-    extends ConsumerState<ExpenseApplyCreatePage>
+class _ExpenseApplyCreatePageState extends ConsumerState<ExpenseApplyCreatePage>
     with WidgetsBindingObserver {
   static const _draftKey = 'expense_apply';
 
@@ -64,12 +63,12 @@ class _ExpenseApplyCreatePageState
   List<DepartmentItem> _departments = [];
   bool _refDataLoading = true;
   bool _addingDetail = false;
+  dynamic _acctTree;
 
   // ── 申请部门 ──
   String _selectedDeptId = '';
   String _selectedDeptName = '';
 
-
   @override
   void initState() {
     super.initState();
@@ -85,7 +84,10 @@ class _ExpenseApplyCreatePageState
     _checkAttachHealth();
     _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
-    _costTypes = []; _projects = []; _departments = [];
+    _costTypes = [];
+    _projects = [];
+    _departments = [];
+    _acctTree = null;
     _refDataLoading = true;
     _refDataFuture = null;
     _draftFuture = DraftStorage.has(_draftKey);
@@ -99,7 +101,10 @@ class _ExpenseApplyCreatePageState
     final completer = Completer<void>();
     _refDataFuture = completer.future;
     if (showLoading) {
-      LoadingDialog.show(context, text: AppLocalizations.of(context).get('dataLoading'));
+      LoadingDialog.show(
+        context,
+        text: AppLocalizations.of(context).get('dataLoading'),
+      );
     }
     try {
       final api = ref.read(expenseApplyApiProvider);
@@ -107,18 +112,24 @@ class _ExpenseApplyCreatePageState
         api.getCostTypes(),
         api.getProjectCodes(),
         api.getDepartments(),
+        api.getAcctSubjects(),
       ]);
       if (!mounted) return;
       setState(() {
         _costTypes = results[0] as List<CostTypeItem>;
         _projects = results[1] as List<ProjectCodeItem>;
         _departments = results[2] as List<DepartmentItem>;
+        _acctTree = _convertAcctTree(results[3]);
+        debugPrint('[_loadRefData] _acctTree isNull: ${_acctTree == null}');
         _refDataLoading = false;
         _autoSelectDept();
       });
       completer.complete();
     } catch (_) {
-      if (!mounted) { completer.complete(); return; }
+      if (!mounted) {
+        completer.complete();
+        return;
+      }
       setState(() => _refDataLoading = false);
       completer.complete();
     } finally {
@@ -188,7 +199,10 @@ class _ExpenseApplyCreatePageState
         _selectedDeptId = '';
         _selectedDeptName = '';
         // 重置参考数据
-        _costTypes = []; _projects = []; _departments = [];
+        _costTypes = [];
+        _projects = [];
+        _departments = [];
+        _acctTree = null;
         _refDataFuture = null;
         _refDataLoading = true;
         // 重置草稿状态
@@ -204,11 +218,15 @@ class _ExpenseApplyCreatePageState
   @override
   Widget build(BuildContext context) {
     final l10n = AppLocalizations.of(context);
-    setNavBarTitle(context, ref, NavBarConfig(
-      title: l10n.get('expenseApplyRequest'),
-      showBack: true,
-      onBack: () => _doPop(),
-    ));
+    setNavBarTitle(
+      context,
+      ref,
+      NavBarConfig(
+        title: l10n.get('expenseApplyRequest'),
+        showBack: true,
+        onBack: () => _doPop(),
+      ),
+    );
 
     return FutureBuilder<bool>(
       future: _draftFuture,
@@ -280,45 +298,53 @@ class _ExpenseApplyCreatePageState
       if (detailList != null) {
         for (final d in detailList) {
           final m = d as Map<String, dynamic>;
-          _details.add(_DetailItem(
-            id: m['id'] as int? ?? _detailIdCounter++,
-            category: m['category'] as String? ?? '',
-            categoryName: m['categoryName'] as String? ?? '',
-            acctSubjectId: m['acctSubjectId'] as String? ?? '',
-            acctSubjectName: m['acctSubjectName'] as String? ?? '',
-            purpose: m['purpose'] as String? ?? '',
-            projectId: m['projectId'] as int? ?? 0,
-            projectName: m['projectName'] as String? ?? '',
-            costDeptId: m['costDeptId'] as String? ?? '',
-            costDeptName: m['costDeptName'] as String? ?? '',
-            startDate: m['startDate'] as String? ?? '',
-            endDate: m['endDate'] as String? ?? '',
-            estimatedAmount: (m['estimatedAmount'] as num?)?.toDouble() ?? 0,
-            remark: m['remark'] as String? ?? '',
-          ));
+          _details.add(
+            _DetailItem(
+              id: m['id'] as int? ?? _detailIdCounter++,
+              category: m['category'] as String? ?? '',
+              categoryName: m['categoryName'] as String? ?? '',
+              acctSubjectId: m['acctSubjectId'] as String? ?? '',
+              acctSubjectName: m['acctSubjectName'] as String? ?? '',
+              purpose: m['purpose'] as String? ?? '',
+              projectId: m['projectId'] as int? ?? 0,
+              projectName: m['projectName'] as String? ?? '',
+              costDeptId: m['costDeptId'] as String? ?? '',
+              costDeptName: m['costDeptName'] as String? ?? '',
+              startDate: m['startDate'] as String? ?? '',
+              endDate: m['endDate'] as String? ?? '',
+              estimatedAmount: (m['estimatedAmount'] as num?)?.toDouble() ?? 0,
+              remark: m['remark'] as String? ?? '',
+            ),
+          );
         }
       }
-      _detailIdCounter = _details.isEmpty ? 1 : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1;
+      _detailIdCounter = _details.isEmpty
+          ? 1
+          : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1;
     });
   }
 
   Future<void> _saveDraftToStorage() async {
-    final detailList = _details.map((d) => {
-      'id': d.id,
-      '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,
-    }).toList();
+    final detailList = _details
+        .map(
+          (d) => {
+            'id': d.id,
+            '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,
+          },
+        )
+        .toList();
     await DraftStorage.save(_draftKey, {
       'urgency': _urgency,
       'purpose': _purposeController.text,
@@ -380,7 +406,8 @@ class _ExpenseApplyCreatePageState
         const SizedBox(height: 16),
         FormFieldRow(
           label: l10n.get('applicant'),
-          value: HostAppChannel.usr.isNotEmpty && HostAppChannel.usrName.isNotEmpty
+          value:
+              HostAppChannel.usr.isNotEmpty && HostAppChannel.usrName.isNotEmpty
               ? '${HostAppChannel.usr}/${HostAppChannel.usrName}'
               : '--',
           readOnly: true,
@@ -389,7 +416,9 @@ class _ExpenseApplyCreatePageState
         const SizedBox(height: 16),
         FormFieldRow(
           label: l10n.get('applyDept'),
-          value: _selectedDeptId.isNotEmpty ? '$_selectedDeptId/$_selectedDeptName' : '',
+          value: _selectedDeptId.isNotEmpty
+              ? '$_selectedDeptId/$_selectedDeptName'
+              : '',
           hint: l10n.get('pleaseSelect'),
           onTap: _refDataLoading ? null : () => _showDeptPicker(),
         ),
@@ -484,7 +513,11 @@ class _ExpenseApplyCreatePageState
             final sel = _urgency == e.value.value;
             final isCritical = e.value.value == Urgency.critical.value;
             final isUrgent = e.value.value == Urgency.urgent.value;
-            final activeColor = isCritical ? colors.danger : isUrgent ? colors.warning : colors.primary;
+            final activeColor = isCritical
+                ? colors.danger
+                : isUrgent
+                ? colors.warning
+                : colors.primary;
             return Padding(
               padding: EdgeInsets.only(left: e.key > 0 ? 18 : 0),
               child: GestureDetector(
@@ -561,117 +594,120 @@ class _ExpenseApplyCreatePageState
             return GestureDetector(
               onTap: () => _showDetailDialog(editIndex: e.key),
               child: Container(
-              margin: const EdgeInsets.symmetric(vertical: 8),
-              padding: const EdgeInsets.all(12),
-              decoration: BoxDecoration(
-                color: colors.bgPage,
-                borderRadius: BorderRadius.circular(8),
-              ),
-              child: Row(
-                crossAxisAlignment: CrossAxisAlignment.center,
-                children: [
-                  Expanded(
-                    child: Column(
-                      crossAxisAlignment: CrossAxisAlignment.start,
-                      children: [
-                        Row(
-                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
-                          children: [
-                            Expanded(
-                              child: Text(
-                                '${d.category}/${d.categoryName}',
-                                maxLines: 1,
-                                overflow: TextOverflow.ellipsis,
+                margin: const EdgeInsets.symmetric(vertical: 8),
+                padding: const EdgeInsets.all(12),
+                decoration: BoxDecoration(
+                  color: colors.bgPage,
+                  borderRadius: BorderRadius.circular(8),
+                ),
+                child: Row(
+                  crossAxisAlignment: CrossAxisAlignment.center,
+                  children: [
+                    Expanded(
+                      child: Column(
+                        crossAxisAlignment: CrossAxisAlignment.start,
+                        children: [
+                          Row(
+                            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                            children: [
+                              Expanded(
+                                child: Text(
+                                  '${d.category}/${d.categoryName}',
+                                  maxLines: 1,
+                                  overflow: TextOverflow.ellipsis,
+                                  style: TextStyle(
+                                    fontSize: AppFontSizes.subtitle,
+                                    color: colors.textPrimary,
+                                  ),
+                                ),
+                              ),
+                              const SizedBox(width: 12),
+                              Text(
+                                '¥${d.estimatedAmount.toStringAsFixed(2)}',
                                 style: TextStyle(
-                                  fontSize: AppFontSizes.subtitle,
-                                  color: colors.textPrimary,
+                                  fontSize: AppFontSizes.caption,
+                                  fontWeight: FontWeight.w600,
+                                  color: colors.amountPrimary,
                                 ),
                               ),
-                            ),
-                            const SizedBox(width: 12),
+                            ],
+                          ),
+                          if (d.acctSubjectId.isNotEmpty &&
+                              d.acctSubjectName.isNotEmpty) ...[
+                            const SizedBox(height: 4),
                             Text(
-                              '¥${d.estimatedAmount.toStringAsFixed(2)}',
+                              d.acctSubjectName,
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
                               style: TextStyle(
                                 fontSize: AppFontSizes.caption,
-                                fontWeight: FontWeight.w600,
-                                color: colors.amountPrimary,
+                                color: colors.textSecondary,
                               ),
                             ),
                           ],
-                        ),
-                        if (d.acctSubjectId.isNotEmpty && d.acctSubjectName.isNotEmpty) ...[
-                          const SizedBox(height: 4),
-                          Text(
-                            '${d.acctSubjectId}/${d.acctSubjectName}',
-                            maxLines: 1,
-                            overflow: TextOverflow.ellipsis,
-                            style: TextStyle(
-                              fontSize: AppFontSizes.caption,
-                              color: colors.textSecondary,
-                            ),
-                          ),
-                        ],
-                        if (d.projectId > 0 && d.projectName.isNotEmpty) ...[
-                          const SizedBox(height: 4),
-                          Text(
-                            '${d.projectId}/${d.projectName}',
-                            maxLines: 1,
-                            overflow: TextOverflow.ellipsis,
-                            style: TextStyle(
-                              fontSize: AppFontSizes.caption,
-                              color: colors.textSecondary,
+                          if (d.projectId > 0 && d.projectName.isNotEmpty) ...[
+                            const SizedBox(height: 4),
+                            Text(
+                              '${d.projectId}/${d.projectName}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
                             ),
-                          ),
-                        ],
-                        if (d.costDeptId.isNotEmpty && d.costDeptName.isNotEmpty) ...[
-                          const SizedBox(height: 4),
-                          Text(
-                            '${d.costDeptId}/${d.costDeptName}',
-                            maxLines: 1,
-                            overflow: TextOverflow.ellipsis,
-                            style: TextStyle(
-                              fontSize: AppFontSizes.caption,
-                              color: colors.textSecondary,
+                          ],
+                          if (d.costDeptId.isNotEmpty &&
+                              d.costDeptName.isNotEmpty) ...[
+                            const SizedBox(height: 4),
+                            Text(
+                              '${d.costDeptId}/${d.costDeptName}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
                             ),
-                          ),
-                        ],
-                        if (d.startDate.isNotEmpty && d.endDate.isNotEmpty) ...[
-                          const SizedBox(height: 4),
-                          Text(
-                            '${d.startDate} ~ ${d.endDate}',
-                            maxLines: 1,
-                            overflow: TextOverflow.ellipsis,
-                            style: TextStyle(
-                              fontSize: AppFontSizes.caption,
-                              color: colors.textSecondary,
+                          ],
+                          if (d.startDate.isNotEmpty &&
+                              d.endDate.isNotEmpty) ...[
+                            const SizedBox(height: 4),
+                            Text(
+                              '${d.startDate} ~ ${d.endDate}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
                             ),
-                          ),
-                        ],
-                        if (d.remark.isNotEmpty) ...[
-                          const SizedBox(height: 4),
-                          Text(
-                            d.remark,
-                            style: TextStyle(
-                              fontSize: AppFontSizes.caption,
-                              color: colors.textSecondary,
+                          ],
+                          if (d.remark.isNotEmpty) ...[
+                            const SizedBox(height: 4),
+                            Text(
+                              d.remark,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
                             ),
-                          ),
+                          ],
                         ],
-                      ],
+                      ),
                     ),
-                  ),
-                  const SizedBox(width: 8),
-                  GestureDetector(
-                    onTap: () => setState(() => _details.removeAt(e.key)),
-                    child: Icon(
-                      Icons.close,
-                      size: 18,
-                      color: colors.textSecondary,
+                    const SizedBox(width: 8),
+                    GestureDetector(
+                      onTap: () => setState(() => _details.removeAt(e.key)),
+                      child: Icon(
+                        Icons.close,
+                        size: 18,
+                        color: colors.textSecondary,
+                      ),
                     ),
-                  ),
-                ],
+                  ],
+                ),
               ),
-            ),
             );
           }),
         const SizedBox(height: 8),
@@ -704,8 +740,7 @@ class _ExpenseApplyCreatePageState
     );
   }
 
-  double _totalAmount() =>
-      _details.fold(0, (s, d) => s + d.estimatedAmount);
+  double _totalAmount() => _details.fold(0, (s, d) => s + d.estimatedAmount);
 
   Future<void> _showDetailDialog({int? editIndex}) async {
     if (_addingDetail) return;
@@ -720,60 +755,61 @@ class _ExpenseApplyCreatePageState
           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,
-      );
-    }
-    FocusManager.instance.primaryFocus?.unfocus();
-    final result = await ExpenseApplyDetailDialog.show(
-      // ignore: use_build_context_synchronously
-      context,
-      categories: _dialogCategories,
-      projects: _dialogProjects,
-      costDepts: _dialogCostDepts,
-      l10n: l10n,
-      initialData: initialData,
-    );
-    if (result != null && mounted) {
-      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,
+      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,
         );
-        if (editIndex != null) {
-          _details[editIndex] = item;
-        } else {
-          _details.add(item);
-        }
-      });
-    }
+      }
+      FocusManager.instance.primaryFocus?.unfocus();
+      final result = await ExpenseApplyDetailDialog.show(
+        // ignore: use_build_context_synchronously
+        context,
+        categories: _dialogCategories,
+        projects: _dialogProjects,
+        costDepts: _dialogCostDepts,
+        l10n: l10n,
+        acctTree: _acctTree,
+        initialData: initialData,
+      );
+      if (result != null && mounted) {
+        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);
+          }
+        });
+      }
     } finally {
       _addingDetail = false;
     }
@@ -781,6 +817,18 @@ class _ExpenseApplyCreatePageState
 
   // ═══ 3. 附件上传 ═══
 
+  /// 深度转换 JSON 解析的 List<dynamic> 为 List<Map>,确保 TDCascader 类型匹配
+  List<Map<String, dynamic>> _convertAcctTree(dynamic tree) {
+    if (tree is! List) return [];
+    return tree.map<Map<String, dynamic>>((e) {
+      final map = Map<String, dynamic>.from(e as Map);
+      if (map['children'] != null) {
+        map['children'] = _convertAcctTree(map['children']);
+      }
+      return map;
+    }).toList();
+  }
+
   Future<void> _checkAttachHealth() async {
     // 立即设为 false,等待 API 返回后再更新,避免缓存旧值
     if (mounted) setState(() => _attachAvailable = false);
@@ -797,12 +845,24 @@ class _ExpenseApplyCreatePageState
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final children = <Widget>[];
     if (!_attachAvailable) {
-      children.add(Text(l10n.get('attachServiceUnavailable'),
-          style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)));
+      children.add(
+        Text(
+          l10n.get('attachServiceUnavailable'),
+          style: TextStyle(
+            fontSize: AppFontSizes.caption,
+            color: colors.textPlaceholder,
+          ),
+        ),
+      );
     } else {
       children.addAll([
-        Text(l10n.get('maxAttachment'),
-            style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
+        Text(
+          l10n.get('maxAttachment'),
+          style: TextStyle(
+            fontSize: AppFontSizes.caption,
+            color: colors.textPlaceholder,
+          ),
+        ),
         const SizedBox(height: 8),
       ]);
     }
@@ -813,25 +873,35 @@ class _ExpenseApplyCreatePageState
         ...children,
         if (_attachAvailable)
           AttachmentPicker(
-          controller: _attachmentController,
-          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);
-            }
-          },
-        ),
+            controller: _attachmentController,
+            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);
+              }
+            },
+          ),
       ],
     );
   }
 
   void _showDeptPicker() {
     if (_departments.isEmpty) {
-      TDToast.showText(AppLocalizations.of(context).get('noData'), context: context);
+      TDToast.showText(
+        AppLocalizations.of(context).get('noData'),
+        context: context,
+      );
       return;
     }
     final l10n = AppLocalizations.of(context);
@@ -861,21 +931,22 @@ class _ExpenseApplyCreatePageState
   // ═══ API 数据 → 弹窗类型转换 ═══
 
   List<CostCategory> get _dialogCategories => _costTypes
-      .map((c) => CostCategory(
-            code: c.typeNo,
-            nameKey: c.typeName,
-            acctSubjectId: c.accNo,
-            acctSubjectName: c.accName,
-          ))
+      .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();
+  List<CostDept> get _dialogCostDepts =>
+      _departments.map((d) => CostDept(id: d.dep, name: d.name)).toList();
 
   // ═══ 4. 底部操作栏 ═══
   Widget _buildBottomBar(AppLocalizations l10n) {
@@ -910,7 +981,8 @@ class _ExpenseApplyCreatePageState
           // 上传表头附件(billNo 提取失败则跳过,不影响主流程)
           if (billNo != null && _attachmentController.files.isNotEmpty) {
             final now = DateTime.now();
-            final effDd = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} '
+            final effDd =
+                '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} '
                 '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}.'
                 '${now.millisecond.toString().padLeft(3, '0')}';
             final usr = HostAppChannel.usr;
@@ -936,7 +1008,10 @@ class _ExpenseApplyCreatePageState
           await DraftStorage.delete(_draftKey);
           if (mounted) {
             LoadingDialog.hide(context);
-            TDToast.showSuccess(l10n.get('submittedAwaitingApproval'), context: context);
+            TDToast.showSuccess(
+              l10n.get('submittedAwaitingApproval'),
+              context: context,
+            );
             GoRouter.of(context).go('/expense-apply/list');
           }
         } catch (_) {
@@ -982,7 +1057,7 @@ class _ExpenseApplyCreatePageState
           'TYPE_NO': d.category,
           'AMTN_YJ': d.estimatedAmount,
           'ACC_NO': d.acctSubjectId,
-          'ACC_NAME': d.acctSubjectName,
+          //'ACC_NAME': d.acctSubjectName,
           'DEP': d.costDeptId,
           'OBJ_NO': d.projectId > 0 ? d.projectId.toString() : '',
           'START_DD': d.startDate,
@@ -1140,11 +1215,18 @@ class _ExpenseApplyCreatePageState
         child: Row(
           mainAxisSize: MainAxisSize.min,
           children: [
-            Icon(Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder),
+            Icon(
+              Icons.rocket_launch_outlined,
+              size: 16,
+              color: colors.textPlaceholder,
+            ),
             const SizedBox(width: 6),
             Text(
               l10n.get('pageFooter'),
-              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder),
+              style: TextStyle(
+                fontSize: AppFontSizes.caption,
+                color: colors.textPlaceholder,
+              ),
             ),
           ],
         ),

+ 171 - 195
lib/features/expense_apply/widgets/expense_apply_detail_dialog.dart

@@ -1,4 +1,5 @@
 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';
@@ -47,6 +48,7 @@ class ExpenseApplyDetailDialog extends StatefulWidget {
   final List<CostDept> costDepts;
   final AppLocalizations l10n;
   final ExpenseDetailData? initialData;
+  final dynamic acctTree;
 
   const ExpenseApplyDetailDialog({
     super.key,
@@ -54,6 +56,7 @@ class ExpenseApplyDetailDialog extends StatefulWidget {
     required this.projects,
     required this.costDepts,
     required this.l10n,
+    required this.acctTree,
     this.initialData,
   });
 
@@ -64,6 +67,7 @@ class ExpenseApplyDetailDialog extends StatefulWidget {
     required List<Project> projects,
     required List<CostDept> costDepts,
     required AppLocalizations l10n,
+    required dynamic acctTree,
     ExpenseDetailData? initialData,
   }) {
     FocusScope.of(context).unfocus();
@@ -77,6 +81,7 @@ class ExpenseApplyDetailDialog extends StatefulWidget {
           projects: projects,
           costDepts: costDepts,
           l10n: l10n,
+          acctTree: acctTree,
           initialData: initialData,
         ),
       ),
@@ -143,6 +148,15 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
             acctSubjectId: '',
             acctSubjectName: '',
           );
+    // 编辑时恢复已选的会计科目(可能和费用类别默认科目不同)
+    if (d != null && d.acctSubjectId.isNotEmpty) {
+      _selCat = CostCategory(
+        code: _selCat.code,
+        nameKey: _selCat.nameKey,
+        acctSubjectId: d.acctSubjectId,
+        acctSubjectName: d.acctSubjectName,
+      );
+    }
     _selProject = (d != null && d.projectId != 0 && _projects.isNotEmpty)
         ? _projects.firstWhere(
             (p) => p.id == d.projectId,
@@ -167,6 +181,7 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
     _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
     _amountFocus.addListener(() => _ensureVisible(_amountFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
+
   }
 
   void _ensureVisible(FocusNode node) {
@@ -377,7 +392,6 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
     required String currentLabel,
     required List<String> labels,
     required ValueChanged<int> onSelected,
-    required AppColorsExtension colors,
     VoidCallback? onClear,
   }) {
     final tdTheme = TDTheme.of(context);
@@ -392,7 +406,6 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
         TDPicker.showMultiPicker(
           context,
           title: label,
-          backgroundColor: colors.bgCard,
           data: [labels],
           onConfirm: (selected) {
             if (selected.isNotEmpty && selected[0] is int) {
@@ -406,172 +419,129 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
         );
       },
       child: Container(
-        padding: const EdgeInsets.only(
-          left: 16,
-          right: 10,
-          top: 12,
-          bottom: 12,
-        ),
+        padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12),
         decoration: BoxDecoration(
           color: tdTheme.bgColorContainer,
           borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
           border: Border.all(color: tdTheme.componentStrokeColor),
         ),
-        child: Row(
-          children: [
-            TDText(
-              label,
-              maxLines: 1,
-              overflow: TextOverflow.visible,
-              font: tdTheme.fontBodyLarge,
-              fontWeight: FontWeight.w400,
-              style: const TextStyle(letterSpacing: 0),
-            ),
-            if (required)
-              Padding(
-                padding: const EdgeInsets.only(left: 4),
-                child: TDText(
-                  '*',
-                  font: tdTheme.fontBodyLarge,
-                  fontWeight: FontWeight.w400,
-                  style: TextStyle(color: tdTheme.errorColor6),
-                ),
-              ),
-            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),
-                  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,
-                          ),
-                  ),
-                ],
-              ),
+        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)),
             ),
-          ],
-        ),
-      ),
-    );
-  }
-
-  // ── 只读信息卡片(会计科目) ──
-  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,
-            ),
+            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),
+              ),
+            ]),
           ),
-        ],
+        ]),
       ),
     );
   }
 
   // ── 1. 费用类别 ──
   Widget _buildCategoryPicker(AppColorsExtension colors) {
+    final labels = _cats.map((c) => '${c.code}/${_l10n.get(c.nameKey)}').toList();
     return _pickerCard(
       label: _l10n.get('expenseCategory'),
       required: true,
-      currentLabel: '${_selCat.code}/${_l10n.get(_selCat.nameKey)}',
-      labels: _cats.map((c) => '${c.code}/${_l10n.get(c.nameKey)}').toList(),
-      colors: colors,
-      onSelected: (idx) => setState(() => _selCat = _cats[idx]),
+      currentLabel: _selCat.code.isNotEmpty ? '${_selCat.code}/${_l10n.get(_selCat.nameKey)}' : _l10n.get('pleaseSelect'),
+      labels: labels,
+      onSelected: (i) => setState(() => _selCat = _cats[i]),
     );
   }
 
-  // ── 2. 会计科目(只读,选择类别后自动带出) ──
+  // ── 2. 会计科目(级联选择器) ──
   Widget _buildAcctSubjectCard(AppColorsExtension colors) {
-    return _readOnlyCard(
-      label: _l10n.get('acctSubject'),
-      value: '${_selCat.acctSubjectId}/${_selCat.acctSubjectName}',
-      colors: colors,
+    final tdTheme = TDTheme.of(context);
+    final hasValue = _selCat.acctSubjectId.isNotEmpty;
+    final displayValue = hasValue
+        ? (_selCat.acctSubjectName.startsWith(_selCat.acctSubjectId)
+            ? _selCat.acctSubjectName
+            : '${_selCat.acctSubjectId}/${_selCat.acctSubjectName.toString()}')
+        : _l10n.get('pleaseSelect');
+    return GestureDetector(
+      onTap: () {
+        debugPrint('[AcctSubject] onTap fired, acctTree isNull: ${widget.acctTree == null}');
+        if (widget.acctTree == null) {
+          TDToast.showText(_l10n.get('loading'), context: context);
+          return;
+        }
+        FocusManager.instance.primaryFocus?.unfocus();
+        TDCascader.showMultiCascader(
+          context,
+          title: _l10n.get('acctSubject'),
+          data: widget.acctTree as List<Map>,
+            onChange: (List<MultiCascaderListModel> selected) {
+              setState(() {
+                _selCat = CostCategory(
+                  code: _selCat.code,
+                  nameKey: _selCat.nameKey,
+                  acctSubjectId: (selected.last.value ?? '').replaceFirst('self:', ''),
+                  acctSubjectName: (selected.last.label ?? '').replaceFirst(RegExp(r'^> '), ''),
+                );
+              });
+            },
+          );
+      },
+      child: 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(_l10n.get('acctSubject'), maxLines: 1, overflow: TextOverflow.visible, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, style: const TextStyle(letterSpacing: 0)),
+          const SizedBox(width: 12),
+          Expanded(
+            child: Row(mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.max, children: [
+              Flexible(child: TDText(displayValue, maxLines: 1, overflow: TextOverflow.ellipsis, font: tdTheme.fontBodyLarge, fontWeight: FontWeight.w400, textColor: hasValue ? tdTheme.textColorPrimary : tdTheme.textColorPlaceholder, textAlign: TextAlign.end)),
+              const SizedBox(width: 4),
+              SizedBox(width: 18, height: 18, child: Icon(Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder)),
+            ]),
+          ),
+        ]),
+      ),
     );
   }
 
+
   // ── 3. 关联项目 ──
   Widget _buildProjectPicker(AppColorsExtension colors) {
+    final labels = _projects.map((p) => '${p.id}/${p.name}').toList();
     return _pickerCard(
       label: _l10n.get('relatedProject'),
       required: false,
-      currentLabel: _selProject != null
-          ? '${_selProject!.id}/${_selProject!.name}'
-          : _l10n.get('pleaseSelect'),
-      labels: _projects.map((p) => '${p.id}/${p.name}').toList(),
-      colors: colors,
-      onSelected: (idx) => setState(() => _selProject = _projects[idx]),
-      onClear: _selProject != null
-          ? () => setState(() => _selProject = null)
-          : null,
+      currentLabel: _selProject != null ? '${_selProject!.id}/${_selProject!.name}' : _l10n.get('pleaseSelect'),
+      labels: labels,
+      onSelected: (i) => setState(() => _selProject = _projects[i]),
+      onClear: _selProject != null ? () => setState(() => _selProject = null) : null,
     );
   }
 
   // ── 5. 费用承担部门 ──
   Widget _buildCostDeptPicker(AppColorsExtension colors) {
+    final labels = _depts.map((d) => '${d.id}/${d.name}').toList();
     return _pickerCard(
       label: _l10n.get('costDept'),
       required: false,
-      currentLabel: _selDept != null
-          ? '${_selDept!.id}/${_selDept!.name}'
-          : _l10n.get('pleaseSelect'),
-      labels: _depts.map((d) => '${d.id}/${d.name}').toList(),
-      colors: colors,
-      onSelected: (idx) => setState(() => _selDept = _depts[idx]),
+      currentLabel: _selDept != null ? '${_selDept!.id}/${_selDept!.name}' : _l10n.get('pleaseSelect'),
+      labels: labels,
+      onSelected: (i) => setState(() => _selDept = _depts[i]),
       onClear: _selDept != null ? () => setState(() => _selDept = null) : null,
     );
   }
@@ -713,71 +683,14 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
 
   // ── 8. 预估金额 ──
   Widget _buildAmountInput(AppColorsExtension colors) {
-    final tdTheme = TDTheme.of(context);
-    final hasValue = _amountCtrl.text.isNotEmpty;
-    return Container(
-      padding: const EdgeInsets.only(left: 16, right: 10, top: 0, bottom: 0),
-      decoration: BoxDecoration(
-        color: tdTheme.bgColorContainer,
-        borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
-        border: Border.all(color: tdTheme.componentStrokeColor),
-      ),
-      child: Row(
-        children: [
-          TDText(
-            _l10n.get('estimatedAmount'),
-            maxLines: 1,
-            overflow: TextOverflow.visible,
-            font: tdTheme.fontBodyLarge,
-            fontWeight: FontWeight.w400,
-            style: const TextStyle(letterSpacing: 0),
-          ),
-          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: TextField(
-              controller: _amountCtrl,
-              focusNode: _amountFocus,
-              textAlign: TextAlign.end,
-              keyboardType: const TextInputType.numberWithOptions(
-                decimal: true,
-              ),
-              style: TextStyle(fontSize: 16, color: tdTheme.textColorPrimary),
-              decoration: InputDecoration(
-                hintText: '>0',
-                hintStyle: TextStyle(color: tdTheme.textColorPlaceholder),
-                border: InputBorder.none,
-                isDense: true,
-                contentPadding: const EdgeInsets.symmetric(vertical: 12),
-              ),
-              onChanged: (_) => setState(() {}),
-            ),
-          ),
-          if (hasValue)
-            GestureDetector(
-              onTap: () {
-                _amountCtrl.clear();
-                setState(() {});
-              },
-              child: Padding(
-                padding: const EdgeInsets.all(4),
-                child: Icon(
-                  Icons.close,
-                  size: 18,
-                  color: tdTheme.textColorPlaceholder,
-                ),
-              ),
-            ),
-        ],
-      ),
+    return _inputCard(
+      label: _l10n.get('estimatedAmount'),
+      required: true,
+      controller: _amountCtrl,
+      hintText: '>0',
+      keyboardType: const TextInputType.numberWithOptions(decimal: true),
+      inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$'))],
+      focusNode: _amountFocus,
     );
   }
 
@@ -830,4 +743,67 @@ class _ExpenseApplyDetailDialogState extends State<ExpenseApplyDetailDialog> {
       ],
     );
   }
+
+  Widget _inputCard({
+    required String label,
+    required bool required,
+    required TextEditingController controller,
+    required String hintText,
+    TextInputType? keyboardType,
+    List<TextInputFormatter>? inputFormatters,
+    FocusNode? focusNode,
+  }) {
+    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,
+                focusNode: focusNode,
+                textAlign: TextAlign.end,
+                keyboardType: keyboardType,
+                inputFormatters: inputFormatters,
+                style: TextStyle(fontSize: 16, color: tdTheme.textColorPrimary),
+                decoration: InputDecoration(
+                  hintText: hintText,
+                  hintStyle: TextStyle(fontSize: 16, color: tdTheme.textColorPlaceholder),
+                  border: InputBorder.none,
+                  isDense: true,
+                  contentPadding: EdgeInsets.zero,
+                ),
+                onChanged: (_) => setState(() {}),
+              ),
+            ),
+            const SizedBox(width: 4),
+            SizedBox(
+              width: 18, height: 18,
+              child: hasValue
+                  ? GestureDetector(
+                      onTap: () { controller.clear(); setState(() {}); },
+                      child: Icon(Icons.close, size: 18, color: tdTheme.textColorPlaceholder),
+                    )
+                  : null,
+            ),
+          ]),
+        ),
+      ]),
+    );
+  }
+
 }

+ 77 - 15
lib/features/report/expense_apply_detail_report_page.dart

@@ -25,6 +25,10 @@ class _ExpenseApplyDetailReportPageState
   bool _loading = true;
   String? _error;
   ReportData? _data;
+  List<ReportDetailItem> _details = [];
+  int _detailPage = 1;
+  int _detailTotal = 0;
+  bool _detailLoading = false;
 
   @override
   void initState() {
@@ -53,12 +57,37 @@ class _ExpenseApplyDetailReportPageState
         startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
         endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
       );
-      if (mounted) setState(() { _data = data; _loading = false; });
+      if (mounted) { setState(() { _data = data; _loading = false; }); _loadDetails(); }
     } catch (e) {
       if (mounted) setState(() { _error = e.toString(); _loading = false; });
     }
   }
 
+  Future<void> _loadDetails({bool append = false}) async {
+    if (_detailLoading) return;
+    setState(() => _detailLoading = true);
+    try {
+      final api = ref.read(expenseApplyApiProvider);
+      final result = await api.getExpenseApplyReportDetails(
+        startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
+        endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
+        page: append ? _detailPage : 1,
+      );
+      if (!mounted) return;
+      final list = (result['list'] as List<dynamic>?)
+          ?.map((e) => ReportDetailItem.fromJson(e as Map<String, dynamic>))
+          .toList() ?? [];
+      setState(() {
+        if (append) { _details.addAll(list); _detailPage++; }
+        else { _details = list; _detailPage = 2; }
+        _detailTotal = result['total'] as int? ?? 0;
+        _detailLoading = false;
+      });
+    } catch (_) {
+      if (mounted) setState(() => _detailLoading = false);
+    }
+  }
+
   void _pickDate(TextEditingController ctrl) {
     final l10n = AppLocalizations.of(context);
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
@@ -87,7 +116,9 @@ class _ExpenseApplyDetailReportPageState
   }
 
   void _applyFilter() {
+    _details = []; _detailPage = 1; _detailTotal = 0;
     _loadData();
+    _loadDetails();
   }
 
   @override
@@ -104,19 +135,17 @@ class _ExpenseApplyDetailReportPageState
       ),
     );
     if (_loading) {
-      return Scaffold(
-        body: Center(child: CircularProgressIndicator(color: colors.primary)),
+      return const Center(
+        child: TDLoading(size: TDLoadingSize.large, icon: TDLoadingIcon.activity),
       );
     }
     if (_error != null) {
-      return Scaffold(
-        body: Center(
-          child: Column(mainAxisSize: MainAxisSize.min, children: [
-            Text(_error!, style: TextStyle(color: colors.textSecondary)),
-            const SizedBox(height: 12),
-            TDButton(text: l10n.get('retry'), theme: TDButtonTheme.primary, onTap: _loadData),
-          ]),
-        ),
+      return Center(
+        child: Column(mainAxisSize: MainAxisSize.min, children: [
+          Text(_error!, style: TextStyle(color: colors.textSecondary)),
+          const SizedBox(height: 12),
+          TDButton(text: l10n.get('retry'), theme: TDButtonTheme.primary, onTap: _loadData),
+        ]),
       );
     }
     return Scaffold(
@@ -473,8 +502,13 @@ class _ExpenseApplyDetailReportPageState
 
   // ── 明细列表 ──
   Widget _buildDetailList() {
-    final details = _data?.details ?? [];
-    if (details.isEmpty) return const SizedBox.shrink();
+    if (_detailLoading && _details.isEmpty) {
+      return const Padding(
+        padding: EdgeInsets.only(top: 32),
+        child: Center(child: TDLoading(size: TDLoadingSize.medium, icon: TDLoadingIcon.activity)),
+      );
+    }
+    if (_details.isEmpty) return const SizedBox.shrink();
     final l10n = AppLocalizations.of(context);
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final headerStyle = TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.textSecondary);
@@ -489,7 +523,7 @@ class _ExpenseApplyDetailReportPageState
           Padding(padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row(children: [
             Text(l10n.get('detailList'), style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.textPrimary)),
             const Spacer(),
-            Text('${details.length} ${l10n.get('items')}', style: TextStyle(fontSize: 12, color: colors.textSecondary)),
+            Text('${_details.length} ${l10n.get('items')}', style: TextStyle(fontSize: 12, color: colors.textSecondary)),
           ])),
           Container(
             padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -504,7 +538,7 @@ class _ExpenseApplyDetailReportPageState
               SizedBox(width: 60, child: Text(l10n.get('filterStatus'), textAlign: TextAlign.center, style: headerStyle)),
             ]),
           ),
-          ...details.map((d) {
+          ..._details.map((d) {
             final approved = d.isApproved;
             final chipColor = approved ? colors.success : colors.warning;
             return Padding(
@@ -532,6 +566,34 @@ class _ExpenseApplyDetailReportPageState
               ]),
             );
           }),
+          // 分页栏
+          if (_detailTotal > 0)
+            Container(
+              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
+              decoration: BoxDecoration(
+                border: Border(top: BorderSide(color: colors.border, width: 0.5)),
+              ),
+              child: Row(
+                mainAxisAlignment: MainAxisAlignment.center,
+                children: [
+                  GestureDetector(
+                    onTap: _detailPage > 2 ? () { _detailPage -= 2; _loadDetails(append: false); } : null,
+                    child: Icon(Icons.chevron_left, size: 20, color: _detailPage > 2 ? colors.textPrimary : colors.textPlaceholder),
+                  ),
+                  const SizedBox(width: 16),
+                  Text(_detailPage > 1 ? '${_detailPage - 1}' : '1', style: TextStyle(fontSize: 13, color: colors.textPrimary)),
+                  const SizedBox(width: 4),
+                  Text('/ ${(_detailTotal / 20).ceil()}', style: TextStyle(fontSize: 13, color: colors.textSecondary)),
+                  const SizedBox(width: 16),
+                  GestureDetector(
+                    onTap: (_detailPage - 1) * 20 < _detailTotal ? () { _loadDetails(append: true); } : null,
+                    child: Icon(Icons.chevron_right, size: 20, color: (_detailPage - 1) * 20 < _detailTotal ? colors.textPrimary : colors.textPlaceholder),
+                  ),
+                  const SizedBox(width: 16),
+                  Text('${l10n.get('total')} $_detailTotal ${l10n.get('items')}', style: TextStyle(fontSize: 12, color: colors.textSecondary)),
+                ],
+              ),
+            ),
           const SizedBox(height: 8),
         ]),
       ),

+ 98 - 38
lib/features/report/expense_detail_report_page.dart

@@ -25,6 +25,10 @@ class _ExpenseDetailReportPageState
   bool _loading = true;
   String? _error;
   ReportData? _data;
+  List<ReportDetailItem> _details = [];
+  int _detailPage = 1;
+  int _detailTotal = 0;
+  bool _detailLoading = false;
 
   @override
   void initState() {
@@ -58,6 +62,7 @@ class _ExpenseDetailReportPageState
           _data = data;
           _loading = false;
         });
+        _loadDetails();
       }
     } catch (e) {
       if (mounted) {
@@ -69,6 +74,31 @@ class _ExpenseDetailReportPageState
     }
   }
 
+  Future<void> _loadDetails({bool append = false}) async {
+    if (_detailLoading) return;
+    setState(() => _detailLoading = true);
+    try {
+      final api = ref.read(expenseApiProvider);
+      final result = await api.getExpenseReportDetails(
+        startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
+        endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
+        page: append ? _detailPage : 1,
+      );
+      if (!mounted) return;
+      final list = (result['list'] as List<dynamic>?)
+          ?.map((e) => ReportDetailItem.fromJson(e as Map<String, dynamic>))
+          .toList() ?? [];
+      setState(() {
+        if (append) { _details.addAll(list); _detailPage++; }
+        else { _details = list; _detailPage = 2; }
+        _detailTotal = result['total'] as int? ?? 0;
+        _detailLoading = false;
+      });
+    } catch (_) {
+      if (mounted) setState(() => _detailLoading = false);
+    }
+  }
+
   // ── 日期选择 ──
   void _pickDate(TextEditingController ctrl) {
     final l10n = AppLocalizations.of(context);
@@ -122,7 +152,11 @@ class _ExpenseDetailReportPageState
           Expanded(child: GestureDetector(onTap: () => _pickDate(_endCtrl), child: _dateChip(_endCtrl, l10n.get('filterEndDate'), tdTheme, colors))),
           const SizedBox(width: 8),
           GestureDetector(
-            onTap: _loadData,
+            onTap: () {
+              _details = []; _detailPage = 1; _detailTotal = 0;
+              _loadData();
+              _loadDetails();
+            },
             child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: const Icon(Icons.search, color: Colors.white, size: 22)),
           ),
         ]),
@@ -435,43 +469,37 @@ class _ExpenseDetailReportPageState
       showBack: true,
       onBack: () => context.pop(),
     ));
+    if (_loading) {
+      return const Center(
+        child: TDLoading(size: TDLoadingSize.large, icon: TDLoadingIcon.activity),
+      );
+    }
+    if (_error != null) {
+      return Center(
+        child: Column(mainAxisSize: MainAxisSize.min, children: [
+          Icon(Icons.error_outline, size: 48, color: colors.textPlaceholder),
+          const SizedBox(height: 12),
+          Text(_error!, style: TextStyle(color: colors.textSecondary), textAlign: TextAlign.center),
+          const SizedBox(height: 16),
+          GestureDetector(
+            onTap: _loadData,
+            child: Container(
+              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+              decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(8)),
+              child: Text(l10n.get('retry'), style: TextStyle(color: Colors.white, fontSize: 14)),
+            ),
+          ),
+        ]),
+      );
+    }
     return Scaffold(
       body: SingleChildScrollView(
         child: Column(
           children: [
             _buildDateFilter(),
-            if (_loading)
-              Padding(
-                padding: const EdgeInsets.only(top: 60),
-                child: Center(child: CircularProgressIndicator(color: colors.primary)),
-              )
-            else if (_error != null)
-              Padding(
-                padding: const EdgeInsets.all(40),
-                child: Center(
-                  child: Column(
-                    children: [
-                      Icon(Icons.error_outline, size: 48, color: colors.textPlaceholder),
-                      const SizedBox(height: 12),
-                      Text(_error!, style: TextStyle(color: colors.textSecondary), textAlign: TextAlign.center),
-                      const SizedBox(height: 16),
-                      GestureDetector(
-                        onTap: _loadData,
-                        child: Container(
-                          padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
-                          decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)),
-                          child: const Text('重试', style: TextStyle(color: Colors.white)),
-                        ),
-                      ),
-                    ],
-                  ),
-                ),
-              )
-            else ...[
-              _buildStatCards(),
-              _buildChartSection(),
-              _buildDetailList(),
-            ],
+            _buildStatCards(),
+            _buildChartSection(),
+            _buildDetailList(),
             const SizedBox(height: 80),
           ],
         ),
@@ -479,10 +507,14 @@ class _ExpenseDetailReportPageState
     );
   }
 
-  // ── 明细列表 ──
   Widget _buildDetailList() {
-    final details = _data?.details ?? [];
-    if (details.isEmpty) return const SizedBox.shrink();
+    if (_detailLoading && _details.isEmpty) {
+      return const Padding(
+        padding: EdgeInsets.only(top: 32),
+        child: Center(child: TDLoading(size: TDLoadingSize.medium, icon: TDLoadingIcon.activity)),
+      );
+    }
+    if (_details.isEmpty) return const SizedBox.shrink();
     final l10n = AppLocalizations.of(context);
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final headerStyle = TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.textSecondary);
@@ -497,7 +529,7 @@ class _ExpenseDetailReportPageState
           Padding(padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Row(children: [
             Text(l10n.get('detailList'), style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.textPrimary)),
             const Spacer(),
-            Text('${details.length} ${l10n.get('items')}', style: TextStyle(fontSize: 12, color: colors.textSecondary)),
+            Text('${_details.length} ${l10n.get('items')}', style: TextStyle(fontSize: 12, color: colors.textSecondary)),
           ])),
           Container(
             padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -512,7 +544,7 @@ class _ExpenseDetailReportPageState
               SizedBox(width: 60, child: Text(l10n.get('filterStatus'), textAlign: TextAlign.center, style: headerStyle)),
             ]),
           ),
-          ...details.map((d) {
+          ..._details.map((d) {
             final approved = d.isApproved;
             final chipColor = approved ? colors.success : colors.warning;
             return Padding(
@@ -540,6 +572,34 @@ class _ExpenseDetailReportPageState
               ]),
             );
           }),
+          // 分页栏
+          if (_detailTotal > 0)
+            Container(
+              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
+              decoration: BoxDecoration(
+                border: Border(top: BorderSide(color: colors.border, width: 0.5)),
+              ),
+              child: Row(
+                mainAxisAlignment: MainAxisAlignment.center,
+                children: [
+                  GestureDetector(
+                    onTap: _detailPage > 2 ? () { _detailPage -= 2; _loadDetails(append: false); } : null,
+                    child: Icon(Icons.chevron_left, size: 20, color: _detailPage > 2 ? colors.textPrimary : colors.textPlaceholder),
+                  ),
+                  const SizedBox(width: 16),
+                  Text(_detailPage > 1 ? '${_detailPage - 1}' : '1', style: TextStyle(fontSize: 13, color: colors.textPrimary)),
+                  const SizedBox(width: 4),
+                  Text('/ ${(_detailTotal / 20).ceil()}', style: TextStyle(fontSize: 13, color: colors.textSecondary)),
+                  const SizedBox(width: 16),
+                  GestureDetector(
+                    onTap: (_detailPage - 1) * 20 < _detailTotal ? () { _loadDetails(append: true); } : null,
+                    child: Icon(Icons.chevron_right, size: 20, color: (_detailPage - 1) * 20 < _detailTotal ? colors.textPrimary : colors.textPlaceholder),
+                  ),
+                  const SizedBox(width: 16),
+                  Text('${l10n.get('total')} $_detailTotal ${l10n.get('items')}', style: TextStyle(fontSize: 12, color: colors.textSecondary)),
+                ],
+              ),
+            ),
           const SizedBox(height: 8),
         ]),
       ),

+ 3 - 2
lib/shared/widgets/form_section.dart

@@ -60,8 +60,9 @@ class FormSection extends StatelessWidget {
               if (showAction)
                 GestureDetector(
                   onTap: onActionTap,
+                  behavior: HitTestBehavior.opaque,
                   child: Padding(
-                    padding: const EdgeInsetsDirectional.only(start: 12, top: 4, bottom: 4),
+                    padding: const EdgeInsetsDirectional.only(start: 16, top: 6, bottom: 6),
                     child: Row(
                       mainAxisSize: MainAxisSize.min,
                       children: [
@@ -75,7 +76,7 @@ class FormSection extends StatelessWidget {
                         const SizedBox(width: 4),
                         Icon(
                           actionIcon ?? Icons.add,
-                          size: 18,
+                          size: 20,
                           color: colors.primary,
                         ),
                       ],

+ 228 - 294
lib/shared/widgets/skeleton_list_card.dart

@@ -1,375 +1,309 @@
 import 'package:flutter/material.dart';
-import 'package:tdesign_flutter/tdesign_flutter.dart';
+import 'package:skeletonizer/skeletonizer.dart';
 import '../../core/theme/app_colors_extension.dart';
 
-/// Skeleton 占位卡片,匹配 [ListCard] 布局(基于 TDSkeleton.fromRowCol):
-///
-/// 真实 ListCard 结构:
-/// ┌────────────────────────────┐
-/// │ cardNo ← spaceBetween → amount │  R1: Flexible(14/600) + Text(16/700)
-/// │ applicant(short)               │  R2: Text(13)
-/// │ description                    │  R3: Text(14)
-/// │ date ← spaceBetween → tag      │  R4: Flexible(12) + statusTag
-/// └────────────────────────────┘
+/// 骨架占位卡片容器(列表加载态)
+class SkeletonLoadingList extends StatelessWidget {
+  final int cardCount;
+  final Widget Function() cardBuilder;
+  final EdgeInsetsGeometry padding;
+
+  const SkeletonLoadingList({
+    super.key,
+    this.cardCount = 5,
+    this.cardBuilder = _defaultBuilder,
+    this.padding = const EdgeInsets.fromLTRB(16, 16, 16, 24),
+  });
+
+  static Widget _defaultBuilder() => const SkeletonListCard();
+
+  @override
+  Widget build(BuildContext context) {
+    return SingleChildScrollView(
+      padding: padding,
+      child: Skeletonizer(
+        enabled: true,
+        child: Column(
+          children: List.generate(
+            cardCount,
+            (_) => Padding(
+              padding: const EdgeInsets.only(bottom: 16),
+              child: cardBuilder(),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+/// 匹配 ListCard 的骨架占位
 class SkeletonListCard extends StatelessWidget {
   const SkeletonListCard({super.key});
 
   @override
   Widget build(BuildContext context) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    return Container(
-      padding: const EdgeInsets.all(12),
-      decoration: BoxDecoration(
-        color: colors.bgCard,
-        borderRadius: BorderRadius.circular(8),
-      ),
-      child: Column(children: [
-        TDSkeleton.fromRowCol(
-          animation: TDSkeletonAnimation.flashed,
-          rowCol: TDSkeletonRowCol(
-            objects: const [
-              [
-                TDSkeletonRowColObj.text(width: 120, height: 17, flex: 0),
-                TDSkeletonRowColObj.spacer(flex: 1),
-                TDSkeletonRowColObj.text(width: 80, height: 20, flex: 0),
-              ],
-              [
-                TDSkeletonRowColObj.text(width: 140, height: 14, flex: 0),
-              ],
-              [
-                TDSkeletonRowColObj.text(height: 17),
+    return Skeletonizer(
+      enabled: true,
+      child: Container(
+        padding: const EdgeInsets.all(12),
+        decoration: BoxDecoration(
+          color: colors.bgCard,
+          borderRadius: BorderRadius.circular(8),
+        ),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                Bone.text(width: 120),
+                Bone.text(width: 80),
               ],
-              [
-                TDSkeletonRowColObj.text(width: 100, height: 14, flex: 0),
-                TDSkeletonRowColObj.spacer(flex: 1),
-                TDSkeletonRowColObj.rect(width: 48, height: 20, flex: 0),
+            ),
+            SizedBox(height: 6),
+            Bone.text(width: 140),
+            SizedBox(height: 6),
+            Bone.text(),
+            SizedBox(height: 8),
+            Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                Bone.text(width: 100),
+                Bone(width: 48, height: 20, borderRadius: BorderRadius.circular(4)),
               ],
-            ],
-            style: TDSkeletonRowColStyle(rowSpacing: (_) => 6),
-          ),
+            ),
+          ],
         ),
-      ]),
+      ),
     );
   }
 }
 
-/// Skeleton 占位卡片,匹配车辆列表卡片布局(基于 TDSkeleton.fromRowCol):
-///
-/// 真实卡片结构:
-/// ┌────────────────────────────┐
-/// │ 车牌号 ← spaceBetween → 徽章│  R1: Text(16/700) + statusTag
-/// │             6px             │
-/// │ 申请单号 ← spaceBetween → 标签│ R2: Text(12) + purposeTag
-/// │             6px             │
-/// │ 路线(ellipsis) ← → 时间     │  R3: Flexible(13) + Text(12)
-/// └────────────────────────────┘
+/// 匹配车辆列表卡片的骨架占位
 class SkeletonVehicleCard extends StatelessWidget {
   const SkeletonVehicleCard({super.key});
 
   @override
   Widget build(BuildContext context) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    return Container(
-      padding: const EdgeInsets.all(12),
-      decoration: BoxDecoration(
-        color: colors.bgCard,
-        borderRadius: BorderRadius.circular(8),
-      ),
-      child: Column(children: [
-        TDSkeleton.fromRowCol(
-          animation: TDSkeletonAnimation.flashed,
-          rowCol: TDSkeletonRowCol(
-            objects: const [
-              // R1: licensePlate + status badge
-              [
-                TDSkeletonRowColObj.text(width: 100, height: 20, flex: 0),
-                TDSkeletonRowColObj.spacer(flex: 1),
-                TDSkeletonRowColObj.rect(width: 48, height: 20, flex: 0),
+    return Skeletonizer(
+      enabled: true,
+      child: Container(
+        padding: const EdgeInsets.all(12),
+        decoration: BoxDecoration(
+          color: colors.bgCard,
+          borderRadius: BorderRadius.circular(8),
+        ),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                Bone.text(width: 100),
+                Bone(width: 48, height: 20, borderRadius: BorderRadius.circular(4)),
               ],
-              // R2: applicationNo + purpose tag
-              [
-                TDSkeletonRowColObj.text(width: 140, height: 14, flex: 0),
-                TDSkeletonRowColObj.spacer(flex: 1),
-                TDSkeletonRowColObj.rect(width: 40, height: 16, flex: 0),
+            ),
+            SizedBox(height: 6),
+            Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                Bone.text(width: 140),
+                Bone(width: 40, height: 16, borderRadius: BorderRadius.circular(4)),
               ],
-              // R3: route + date
-              [
-                TDSkeletonRowColObj.text(height: 15, flex: 2),
-                TDSkeletonRowColObj.spacer(flex: 1),
-                TDSkeletonRowColObj.text(width: 120, height: 14, flex: 0),
+            ),
+            SizedBox(height: 6),
+            Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                Expanded(child: Bone.text()),
+                const SizedBox(width: 8),
+                Bone.text(width: 120),
               ],
-            ],
-            style: TDSkeletonRowColStyle(
-              rowSpacing: (_) => 6,
             ),
-          ),
+          ],
         ),
-      ]),
+      ),
     );
   }
 }
 
-/// Skeleton 占位卡片,匹配外勤日志卡片布局(基于 TDSkeleton.fromRowCol):
-///
-/// 真实卡片结构:
-/// ┌────────────────────────────┐
-/// │ visitNo                     │  R1: Text(11)
-/// │             4px             │
-/// │ customerName ← → statusTag │  R2: Flexible(15/700) + statusTag
-/// │             4px             │
-/// │ checkInAddress              │  R3: Text(12)
-/// │             4px             │
-/// │ summary(ellipsis) ← → date │  R4: Flexible(12) + Text(11)
-/// └────────────────────────────┘
+/// 匹配外勤日志卡片的骨架占位
 class SkeletonOutingLogCard extends StatelessWidget {
   const SkeletonOutingLogCard({super.key});
 
   @override
   Widget build(BuildContext context) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    return Container(
-      padding: const EdgeInsets.all(12),
-      decoration: BoxDecoration(
-        color: colors.bgCard,
-        borderRadius: BorderRadius.circular(8),
-      ),
-      child: Column(children: [
-        TDSkeleton.fromRowCol(
-          animation: TDSkeletonAnimation.flashed,
-          rowCol: TDSkeletonRowCol(
-            objects: const [
-              // R1: visitNo
-              [TDSkeletonRowColObj.text(width: 100, height: 13)],
-              // R2: customerName + statusTag
-              [
-                TDSkeletonRowColObj.text(height: 18, flex: 3),
-                TDSkeletonRowColObj.spacer(flex: 1),
-                TDSkeletonRowColObj.rect(width: 48, height: 20, flex: 0),
+    return Skeletonizer(
+      enabled: true,
+      child: Container(
+        padding: const EdgeInsets.all(12),
+        decoration: BoxDecoration(
+          color: colors.bgCard,
+          borderRadius: BorderRadius.circular(8),
+        ),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Bone.text(width: 100),
+            SizedBox(height: 4),
+            Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                Expanded(flex: 3, child: Bone.text()),
+                const SizedBox(width: 8),
+                Bone(width: 48, height: 20, borderRadius: BorderRadius.circular(4)),
               ],
-              // R3: checkInAddress
-              [TDSkeletonRowColObj.text(height: 14)],
-              // R4: summary + date
-              [
-                TDSkeletonRowColObj.text(height: 14, flex: 2),
-                TDSkeletonRowColObj.spacer(flex: 1),
-                TDSkeletonRowColObj.text(width: 80, height: 13, flex: 0),
+            ),
+            SizedBox(height: 4),
+            Bone.text(),
+            SizedBox(height: 4),
+            Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                Expanded(flex: 2, child: Bone.text()),
+                const SizedBox(width: 8),
+                Bone.text(width: 80),
               ],
-            ],
-            style: TDSkeletonRowColStyle(rowSpacing: (_) => 4),
-          ),
+            ),
+          ],
         ),
-      ]),
+      ),
     );
   }
 }
 
-/// Skeleton 占位卡片,匹配公告卡片布局(基于 TDSkeleton.fromRowCol):
-///
-/// 真实卡片结构:
-/// ┌────────────────────────────┐
-/// │ title                       │  R1: Text(15/600)
-/// │             8px             │
-/// │ typeTag  publisher ← → date│  R2: tag(56) + SizedBox(8) + Text(12) + spaceBetween + Text(12)
-/// └────────────────────────────┘
+/// 匹配公告卡片的骨架占位
 class SkeletonAnnouncementCard extends StatelessWidget {
   const SkeletonAnnouncementCard({super.key});
 
   @override
   Widget build(BuildContext context) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    return Container(
-      padding: const EdgeInsets.all(12),
-      decoration: BoxDecoration(
-        color: colors.bgCard,
-        borderRadius: BorderRadius.circular(8),
-      ),
-      child: Column(children: [
-        TDSkeleton.fromRowCol(
-          animation: TDSkeletonAnimation.flashed,
-          rowCol: TDSkeletonRowCol(
-            objects: const [
-              // R1: title (full width)
-              [TDSkeletonRowColObj.text(height: 18)],
-              // R2: typeTag + publisher + spacer(flex:1) + date
-              [
-                TDSkeletonRowColObj.rect(width: 56, height: 20, flex: 0),
-                TDSkeletonRowColObj.spacer(width: 8),
-                TDSkeletonRowColObj.text(width: 60, height: 14, flex: 0),
-                TDSkeletonRowColObj.spacer(flex: 1),
-                TDSkeletonRowColObj.text(width: 120, height: 14, flex: 0),
+    return Skeletonizer(
+      enabled: true,
+      child: Container(
+        padding: const EdgeInsets.all(12),
+        decoration: BoxDecoration(
+          color: colors.bgCard,
+          borderRadius: BorderRadius.circular(8),
+        ),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Bone.text(),
+            SizedBox(height: 8),
+            Row(
+              children: [
+                Bone(width: 56, height: 20, borderRadius: BorderRadius.circular(4)),
+                const SizedBox(width: 8),
+                Bone.text(width: 60),
+                const Spacer(),
+                Bone.text(width: 120),
               ],
-            ],
-            style: TDSkeletonRowColStyle(rowSpacing: (_) => 8),
-          ),
+            ),
+          ],
         ),
-      ]),
+      ),
     );
   }
 }
 
-/// Skeleton 占位卡片,匹配费用申请导入列表卡片布局:
-///
-/// 真实卡片结构:
-/// ┌──────────────────────────────────────┐
-/// │ [□ 22] AE202606001      06-15   │  R1: rect(22) + text(wide) + text(narrow)
-/// │        申请事由文字...            │  R2: text(medium)
-/// │ ─────────────────────────────── │  Divider
-/// │ [□ 18] #1  类型/科目  ¥1,500  │  R3: rect(18) + text + text(wide) + amount
-/// │         申请人/会计科目...      │  R4: text(wide)
-/// └──────────────────────────────────────┘
+/// 匹配费用申请导入列表卡片的骨架占位
 class SkeletonImportCard extends StatelessWidget {
   const SkeletonImportCard({super.key});
 
   @override
   Widget build(BuildContext context) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    return Container(
-      padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
-      decoration: BoxDecoration(
-        color: colors.bgCard,
-        borderRadius: BorderRadius.circular(12),
-      ),
-      child: Column(children: [
-        TDSkeleton.fromRowCol(
-          animation: TDSkeletonAnimation.flashed,
-          rowCol: TDSkeletonRowCol(
-            objects: const [
-              // R1: checkbox(22) + aeNo(wide text, 15) + date(narrow)
-              [
-                TDSkeletonRowColObj.rect(width: 22, height: 22, flex: 0),
-                TDSkeletonRowColObj.spacer(width: 8),
-                TDSkeletonRowColObj.text(height: 17, flex: 3),
-                TDSkeletonRowColObj.spacer(flex: 1),
-                TDSkeletonRowColObj.text(width: 80, height: 14, flex: 0),
-              ],
-              // R2: reason text (left-aligned with checkbox)
-              [
-                TDSkeletonRowColObj.spacer(width: 30),
-                TDSkeletonRowColObj.text(width: 140, height: 14, flex: 0),
-              ],
-              // R3: detail row checkbox(18) + #itm + type text + amount
-              [
-                TDSkeletonRowColObj.rect(width: 18, height: 18, flex: 0),
-                TDSkeletonRowColObj.spacer(width: 4),
-                TDSkeletonRowColObj.text(width: 24, height: 14, flex: 0),
-                TDSkeletonRowColObj.spacer(width: 8),
-                TDSkeletonRowColObj.text(height: 15, flex: 3),
-                TDSkeletonRowColObj.spacer(flex: 1),
-                TDSkeletonRowColObj.text(width: 64, height: 17, flex: 0),
+    return Skeletonizer(
+      enabled: true,
+      child: Container(
+        padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
+        decoration: BoxDecoration(
+          color: colors.bgCard,
+          borderRadius: BorderRadius.circular(12),
+        ),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Row(
+              children: [
+                Bone.square(size: 22, borderRadius: BorderRadius.circular(4)),
+                const SizedBox(width: 8),
+                Expanded(flex: 3, child: Bone.text()),
+                const Spacer(),
+                Bone.text(width: 80),
               ],
-              // R6: detail sub-text
-              [
-                TDSkeletonRowColObj.spacer(width: 54),
-                TDSkeletonRowColObj.text(height: 14, flex: 2),
+            ),
+            SizedBox(height: 8),
+            Row(children: [const SizedBox(width: 30), Bone.text(width: 140)]),
+            SizedBox(height: 8),
+            Row(
+              children: [
+                Bone.square(size: 18, borderRadius: BorderRadius.circular(4)),
+                const SizedBox(width: 4),
+                Bone.text(width: 24),
+                const SizedBox(width: 8),
+                Expanded(flex: 3, child: Bone.text()),
+                const Spacer(),
+                Bone.text(width: 64),
               ],
-            ],
-            style: TDSkeletonRowColStyle(rowSpacing: (_) => 8),
-          ),
+            ),
+            SizedBox(height: 8),
+            Row(children: [const SizedBox(width: 54), Expanded(flex: 2, child: Bone.text())]),
+          ],
         ),
-      ]),
+      ),
     );
   }
 }
 
-/// Skeleton 占位卡片,匹配 [MessageItem] 布局(基于 TDSkeleton.fromRowCol):
-///
-/// 真实 MessageItem 结构:
-/// ┌──────────────────────────────────────────┐
-/// │ [● 40×40]  标题文字...        MM-DD HH:mm │ [●] │
-/// │            发送人                         │     │
-/// │            摘要内容...                    │     │
-/// └──────────────────────────────────────────┘
+/// 匹配消息卡片的骨架占位
 class SkeletonMessageCard extends StatelessWidget {
   const SkeletonMessageCard({super.key});
 
   @override
   Widget build(BuildContext context) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    return Container(
-      height: 88,
-      padding: const EdgeInsets.all(12),
-      decoration: BoxDecoration(
-        color: colors.bgCard,
-        borderRadius: BorderRadius.circular(8),
-      ),
-      child: Row(
-        children: [
-          // 左侧圆形图标占位 40×40
-          TDSkeleton.fromRowCol(
-            animation: TDSkeletonAnimation.flashed,
-            rowCol: TDSkeletonRowCol(
-              objects: const [
-                [TDSkeletonRowColObj.rect(width: 40, height: 40, flex: 0)],
-              ],
-            ),
-          ),
-          const SizedBox(width: 12),
-          // 中间三行文字骨架
-          Expanded(
-            child: TDSkeleton.fromRowCol(
-              animation: TDSkeletonAnimation.flashed,
-              rowCol: TDSkeletonRowCol(
-                objects: const [
-                  // R1: 标题(宽) + 时间(窄)
-                  [
-                    TDSkeletonRowColObj.text(height: 17, flex: 3),
-                    TDSkeletonRowColObj.spacer(flex: 1),
-                    TDSkeletonRowColObj.text(width: 80, height: 13, flex: 0),
-                  ],
-                  // R2: 发送人
-                  [TDSkeletonRowColObj.text(width: 80, height: 13, flex: 0)],
-                  // R3: 摘要
-                  [TDSkeletonRowColObj.text(height: 13, flex: 2)],
+    return Skeletonizer(
+      enabled: true,
+      child: Container(
+        height: 88,
+        padding: const EdgeInsets.all(12),
+        decoration: BoxDecoration(
+          color: colors.bgCard,
+          borderRadius: BorderRadius.circular(8),
+        ),
+        child: Row(
+          children: [
+            Bone.circle(size: 40),
+            SizedBox(width: 12),
+            Expanded(
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Row(
+                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                    children: [
+                      Expanded(flex: 3, child: Bone.text()),
+                      const Spacer(),
+                      Bone.text(width: 80),
+                    ],
+                  ),
+                  SizedBox(height: 2),
+                  Bone.text(width: 80),
+                  SizedBox(height: 2),
+                  Expanded(flex: 2, child: Bone.text()),
                 ],
-                style: TDSkeletonRowColStyle(rowSpacing: (_) => 2),
               ),
             ),
-          ),
-          const SizedBox(width: 12),
-          // 右侧未读红点占位 8×8
-          TDSkeleton.fromRowCol(
-            animation: TDSkeletonAnimation.flashed,
-            rowCol: TDSkeletonRowCol(
-              objects: const [
-                [TDSkeletonRowColObj.rect(width: 8, height: 8, flex: 0)],
-              ],
-            ),
-          ),
-        ],
-      ),
-    );
-  }
-}
-
-/// 列表加载态:骨架卡片占位
-///
-/// [cardCount] 骨架卡片数量,默认 5
-/// [cardBuilder] 骨架卡片构建器,默认 [SkeletonListCard]
-class SkeletonLoadingList extends StatelessWidget {
-  final int cardCount;
-  final Widget Function() cardBuilder;
-  final EdgeInsetsGeometry padding;
-
-  const SkeletonLoadingList({
-    super.key,
-    this.cardCount = 5,
-    this.cardBuilder = _defaultBuilder,
-    this.padding = const EdgeInsets.fromLTRB(16, 16, 16, 24),
-  });
-
-  static Widget _defaultBuilder() => const SkeletonListCard();
-
-  @override
-  Widget build(BuildContext context) {
-    return SingleChildScrollView(
-      padding: padding,
-      child: Column(
-        children: List.generate(
-          cardCount,
-          (_) => Padding(
-            padding: const EdgeInsets.only(bottom: 16),
-            child: cardBuilder(),
-          ),
+            SizedBox(width: 12),
+            Bone(width: 8, height: 8, borderRadius: BorderRadius.circular(4)),
+          ],
         ),
       ),
     );

+ 8 - 0
pubspec.lock

@@ -869,6 +869,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "3.0.0"
+  skeletonizer:
+    dependency: "direct main"
+    description:
+      name: skeletonizer
+      sha256: "9f38f9b47ec3cf2235a6a4f154a88a95432bc55ba98b3e2eb6ced5c1974bc122"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.1.3"
   sky_engine:
     dependency: transitive
     description: flutter

+ 1 - 0
pubspec.yaml

@@ -25,6 +25,7 @@ dependencies:
   marquee: ^2.3.0
   open_filex: ^4.5.0
   path_provider: ^2.1.0
+  skeletonizer: ^2.1.0+1
 
 dev_dependencies:
   flutter_test: