chengc 1 day ago
parent
commit
6abbc24390

+ 6 - 3
assets/i18n/en.json

@@ -147,7 +147,7 @@
     "noMoreData": "noMoreData",
     "expandRemaining": "Expand ${count} more",
     "collapseRemaining": "Collapse ${count} items",
-    "searchImportHint": "Search No./Reason/Dept/Applicant/Type",
+    "searchImportHint": "Search No./Reason/Applicant",
     "businessShort": "businessShort",
     "custom": "custom",
     "markedAsRead": "markedAsRead",
@@ -356,8 +356,11 @@
     "unit": "unit",
     "unitPrice": "unitPrice",
     "selectUnit": "selectUnit",
-    "estimatedStartDate": "estimatedStartDate",
-    "estimatedEndDate": "estimatedEndDate",
+    "estimatedStartDate": "Estimated Start Date",
+    "estimatedEndDate": "Estimated End Date",
+    "estimatedDate": "Estimated Date",
+    "detailList": "Detail List",
+    "items": "items",
     "selectEstimatedStartDate": "selectEstimatedStartDate",
     "selectEstimatedEndDate": "selectEstimatedEndDate",
     "startDateNotAfterEndDate": "startDateNotAfterEndDate",

+ 4 - 1
assets/i18n/zh_CN.json

@@ -147,7 +147,7 @@
     "noMoreData": "没有更多了",
     "expandRemaining": "展开剩余 ${count} 项",
     "collapseRemaining": "收起剩余 ${count} 项",
-    "searchImportHint": "搜索单号/事由/部门/申请人/费用类型",
+    "searchImportHint": "搜索单号/事由/申请人",
     "businessShort": "商务",
     "custom": "自定义",
     "markedAsRead": "已标记为已读",
@@ -305,6 +305,9 @@
     "selectUnit": "选择单位",
     "estimatedStartDate": "预计开始日期",
     "estimatedEndDate": "预计结束日期",
+    "estimatedDate": "预计日期",
+    "detailList": "明细列表",
+    "items": "项",
     "selectEstimatedStartDate": "请选择预计开始日期",
     "selectEstimatedEndDate": "请选择预计结束日期",
     "startDateNotAfterEndDate": "开始日期不能大于结束日期",

+ 4 - 1
assets/i18n/zh_TW.json

@@ -147,7 +147,7 @@
     "noMoreData": "没有更多了",
     "expandRemaining": "展開剩餘 ${count} 項",
     "collapseRemaining": "收起剩餘 ${count} 項",
-    "searchImportHint": "搜尋單號/事由/部門/申請人/費用類型",
+    "searchImportHint": "搜尋單號/事由/申請人",
     "businessShort": "商務",
     "custom": "自定义",
     "markedAsRead": "已標記為已讀",
@@ -305,6 +305,9 @@
     "selectUnit": "選擇單位",
     "estimatedStartDate": "預計開始日期",
     "estimatedEndDate": "預計結束日期",
+    "estimatedDate": "預計日期",
+    "detailList": "明細列表",
+    "items": "項",
     "selectEstimatedStartDate": "請選擇預計開始日期",
     "selectEstimatedEndDate": "請選擇預計結束日期",
     "startDateNotAfterEndDate": "開始日期不能大于結束日期",

+ 24 - 19
lib/core/navigation/host_app_channel.dart

@@ -7,15 +7,8 @@ import 'package:flutter/services.dart';
 /// - sn: SharedPreferences "MemberNo"
 /// - loginId: SharedPreferences "LoginId"
 ///
-/// iOS 调用示例:
-/// ```objc
-/// FlutterMethodChannel *ch = [FlutterMethodChannel
-///     methodChannelWithName:@"com.tboss.oa/config"
-///            binaryMessenger:engine.binaryMessenger];
-/// [ch setMethodCallHandler:^(FlutterMethodCall *call, FlutterResult result) {
-///     result(@"ok"); // 或返回具体值
-/// }];
-/// ```
+/// 首次安装 / 清数据后,原生端登录功能尚未完成,此时 getConfig
+/// 返回的字段可能为空,因此不标记 initialized,允许后续重试。
 class HostAppChannel {
   static const _channel = MethodChannel('com.tboss.oa/config');
 
@@ -41,6 +34,17 @@ class HostAppChannel {
   /// 从宿主 App 加载配置,需在 runApp 之前调用。
   static Future<void> initialize() async {
     if (_initialized) return;
+    // 监听原生端主动下发的刷新通知(登录完成后触发)
+    _channel.setMethodCallHandler((call) async {
+      if (call.method == 'onLoginComplete') {
+        await refresh();
+      }
+    });
+    await _tryInit();
+  }
+
+  /// 重试获取配置(原生端可能尚未就绪,失败不抛异常)
+  static Future<void> _tryInit() async {
     try {
       final result = await _channel.invokeMethod<Map<dynamic, dynamic>>('getConfig');
       if (result != null) {
@@ -53,19 +57,19 @@ class HostAppChannel {
         _usrName = result['usrName'] as String?;
       }
     } catch (_) {
-      // 调试 / Mock 模式回退
-      _baseUrl = 'https://your-api-host.com/api';
-      _sn = '';
-      _loginId = '';
-      _dep = '';
-      _depName = '';
-      _usr = '';
-      _usrName = '';
+      // 原生端 MethodChannel handler 尚未注册,稍后重试
     }
-    _initialized = true;
+    // 只有 baseUrl 有效时才认为初始化成功
+    _initialized = (_baseUrl != null && _baseUrl!.isNotEmpty);
+  }
+
+  /// 页面打开时调,确保配置已就绪;未就绪则重新拉取。
+  static Future<void> ensureConfig() async {
+    if (_initialized) return;
+    await _tryInit();
   }
 
-  /// 重新从宿主 App 拉取配置(用于用户切换后刷新)。
+  /// 重新从宿主 App 拉取配置(用于用户切换 / 登录完成后刷新)。
   static Future<void> refresh() async {
     try {
       final result = await _channel.invokeMethod<Map<dynamic, dynamic>>('getConfig');
@@ -77,6 +81,7 @@ class HostAppChannel {
         _depName = result['depName'] as String? ?? _depName;
         _usr = result['usr'] as String? ?? _usr;
         _usrName = result['usrName'] as String? ?? _usrName;
+        _initialized = (_baseUrl != null && _baseUrl!.isNotEmpty);
       }
     } catch (_) {
       // 刷新失败保留旧值

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

@@ -32,7 +32,8 @@ class ApiClient {
 
     _dio.interceptors.add(InterceptorsWrapper(
       onRequest: (options, handler) {
-        // ═══ 模拟 Android HeaderInterceptor ═══
+        // ═══ 动态读取 HostAppChannel 配置(支持登录后刷新) ═══
+        options.baseUrl = HostAppChannel.baseUrl;
         if (HostAppChannel.sn.isNotEmpty) {
           options.headers['sn'] = HostAppChannel.sn;
         }

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

@@ -11,6 +11,7 @@ import '../../core/navigation/host_app_channel.dart';
 import '../../core/utils/date_utils.dart' as du;
 import 'expense_model.dart';
 import 'expense_list_controller.dart';
+import '../expense_apply/report_model.dart';
 
 final expenseApiProvider = Provider<ExpenseApi>(
   (ref) => ExpenseApi(ref.read(apiClientProvider)),
@@ -357,6 +358,18 @@ class ExpenseApi {
     );
     return response.data!;
   }
+
+  /// 费用报销报表
+  Future<ReportData> getExpenseReport({String? startDate, String? endDate}) async {
+    final params = <String, dynamic>{};
+    if (startDate != null) params['startDate'] = startDate;
+    if (endDate != null) params['endDate'] = endDate;
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetExpenseReport',
+      queryParameters: params,
+    );
+    return ReportData.fromJson(response.data!);
+  }
 }
 
 /// 费用报销审批列表项

+ 6 - 3
lib/features/expense/expense_apply_import_page.dart

@@ -255,7 +255,10 @@ class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
   Widget _buildSearchBar(AppLocalizations l10n, AppColorsExtension colors) {
     final tdTheme = TDTheme.of(context);
     return Container(
-      color: colors.bgCard,
+      decoration: BoxDecoration(
+        color: colors.bgCard,
+        border: Border(bottom: BorderSide(color: tdTheme.componentStrokeColor)),
+      ),
       child: Column(
         mainAxisSize: MainAxisSize.min,
         children: [
@@ -343,7 +346,7 @@ class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
     final text = ctrl.text;
     return Container(
       height: 40,
-      padding: const EdgeInsets.symmetric(horizontal: 16),
+      padding: const EdgeInsets.symmetric(horizontal: 12),
       decoration: BoxDecoration(
         color: colors.bgSecondaryContainer,
         borderRadius: BorderRadius.circular(20),
@@ -351,7 +354,7 @@ class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
       ),
       child: Row(children: [
         Icon(Icons.calendar_today, size: 16, color: colors.textSecondary),
-        const SizedBox(width: 8),
+        const SizedBox(width: 6),
         Expanded(
           child: Text(
             text.isNotEmpty ? text : hint,

+ 2 - 0
lib/features/expense/expense_create_page.dart

@@ -988,6 +988,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
       centerTextOnly: true,
       onCenterTap: () async {
         if (state.isSubmitting) return;
+        FocusScope.of(context).unfocus();
         controller.updateAttachments(_attachmentController.toPathList());
         controller.updateDept(_selectedDeptId, _selectedDeptName);
         final ok = await controller.saveDraft();
@@ -1006,6 +1007,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
           TDToast.showText(err.first, context: context);
           return;
         }
+        FocusScope.of(context).unfocus();
         LoadingDialog.show(context, text: l10n.get('submitting'));
         try {
           final data = _buildSubmitData(state);

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

@@ -173,8 +173,8 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
             showArrow: false),
         const SizedBox(height: 16),
         FormFieldRow(
-            label: l10n.get('voucherNo'),
-            value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-',
+            label: l10n.get('date'),
+            value: du.DateUtils.formatDate(expense.createTime),
             readOnly: true,
             showArrow: false),
         const SizedBox(height: 16),
@@ -194,22 +194,10 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
             readOnly: true,
             showArrow: false),
         const SizedBox(height: 16),
-        FormFieldRow(
-            label: l10n.get('date'),
-            value: du.DateUtils.formatDateTime(expense.createTime),
-            readOnly: true,
-            showArrow: false),
-        const SizedBox(height: 16),
-        FormFieldRow(
-            label: l10n.get('currency'),
-            value: expense.currencyCode.isNotEmpty ? expense.currencyCode : '-',
-            readOnly: true,
-            showArrow: false),
-        const SizedBox(height: 16),
         SizedBox(
           height: 24,
           child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
-            Text(l10n.get('feeReason'),
+            Text(l10n.get('expenseReason'),
                 style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
             const SizedBox(width: 8),
             Expanded(
@@ -223,6 +211,18 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
         ),
         const SizedBox(height: 16),
         FormFieldRow(
+            label: l10n.get('voucherNo'),
+            value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-',
+            readOnly: true,
+            showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(
+            label: l10n.get('currency'),
+            value: expense.currencyCode.isNotEmpty ? expense.currencyCode : '-',
+            readOnly: true,
+            showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(
             label: l10n.get('paymentMethod'),
             value: expense.paymentMethod.isNotEmpty ? expense.paymentMethod : '-',
             readOnly: true,

+ 58 - 38
lib/features/expense/expense_list_page.dart

@@ -99,10 +99,10 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
   Widget _dateChip(TextEditingController ctrl, String hint, TDThemeData tdTheme, AppColorsExtension colors) {
     final text = ctrl.text;
     return Container(
-      height: 40, padding: const EdgeInsets.symmetric(horizontal: 16),
+      height: 40, padding: const EdgeInsets.symmetric(horizontal: 12),
       decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
       child: Row(children: [
-        Icon(Icons.calendar_today, size: 16, color: colors.textSecondary), const SizedBox(width: 8),
+        Icon(Icons.calendar_today, size: 16, color: colors.textSecondary), const SizedBox(width: 6),
         Expanded(child: Text(text.isNotEmpty ? text : hint, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14, color: text.isNotEmpty ? colors.textPrimary : colors.textSecondary))),
         if (text.isNotEmpty) GestureDetector(onTap: () { ctrl.clear(); setState(() {}); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
       ]),
@@ -115,44 +115,64 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final tdTheme = TDTheme.of(context);
 
-    setNavBarTitle(context, ref, NavBarConfig(title: l10n.get('expenseList'), showBack: true, onBack: () => SystemNavigator.pop()));
-    return Column(children: [
-      Container(
-        color: colors.bgCard,
-        child: Column(mainAxisSize: MainAxisSize.min, children: [
-          Padding(padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row(children: [
-            Expanded(child: GestureDetector(onTap: () => _pickDate(_startDateCtrl), child: _dateChip(_startDateCtrl, l10n.get('filterStartDate'), tdTheme, colors))),
-            const SizedBox(width: 8), Text('—', style: TextStyle(fontSize: 14, color: colors.textSecondary)), const SizedBox(width: 8),
-            Expanded(child: GestureDetector(onTap: () => _pickDate(_endDateCtrl), child: _dateChip(_endDateCtrl, l10n.get('filterEndDate'), tdTheme, colors))),
-          ])),
-          const SizedBox(height: 8),
-          Padding(padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Row(children: [
-            Expanded(
-              child: Container(
-                height: 40, padding: const EdgeInsets.symmetric(horizontal: 16),
-                decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
-                child: Row(children: [
-                  Expanded(child: TextField(controller: _keywordCtrl, style: TextStyle(fontSize: 14, color: colors.textPrimary), decoration: InputDecoration(hintText: l10n.get('searchExpense'), hintStyle: TextStyle(fontSize: 14, color: colors.textSecondary), border: InputBorder.none, isCollapsed: true), onChanged: (_) => setState(() {}))),
-                  if (_keywordCtrl.text.isNotEmpty) GestureDetector(onTap: () { _keywordCtrl.clear(); setState(() {}); _applyFilter(); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
-                ]),
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('expenseList'),
+      showBack: true,
+      onBack: () => SystemNavigator.pop(),
+      showRight: true,
+      rightWidget: GestureDetector(
+        onTap: () => context.push('/expense/create'),
+        child: Icon(Icons.add, color: colors.textPrimary, size: 22),
+      ),
+    ));
+    return Scaffold(
+      body: Column(children: [
+        Container(
+          decoration: BoxDecoration(
+            color: colors.bgCard,
+            border: Border(bottom: BorderSide(color: tdTheme.componentStrokeColor)),
+          ),
+          child: Column(mainAxisSize: MainAxisSize.min, children: [
+            Padding(padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row(children: [
+              Expanded(child: GestureDetector(onTap: () => _pickDate(_startDateCtrl), child: _dateChip(_startDateCtrl, l10n.get('filterStartDate'), tdTheme, colors))),
+              const SizedBox(width: 8), Text('—', style: TextStyle(fontSize: 14, color: colors.textSecondary)), const SizedBox(width: 8),
+              Expanded(child: GestureDetector(onTap: () => _pickDate(_endDateCtrl), child: _dateChip(_endDateCtrl, l10n.get('filterEndDate'), tdTheme, colors))),
+            ])),
+            const SizedBox(height: 8),
+            Padding(padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Row(children: [
+              Expanded(
+                child: Container(
+                  height: 40, padding: const EdgeInsets.symmetric(horizontal: 16),
+                  decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
+                  child: Row(children: [
+                    Expanded(child: TextField(controller: _keywordCtrl, style: TextStyle(fontSize: 14, color: colors.textPrimary), decoration: InputDecoration(hintText: l10n.get('searchExpense'), hintStyle: TextStyle(fontSize: 14, color: colors.textSecondary), border: InputBorder.none, isCollapsed: true), onChanged: (_) => setState(() {}))),
+                    if (_keywordCtrl.text.isNotEmpty) GestureDetector(onTap: () { _keywordCtrl.clear(); setState(() {}); _applyFilter(); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
+                  ]),
+                ),
+              ),
+              const SizedBox(width: 8),
+              GestureDetector(
+                onTap: () {
+                  final dir = ref.read(expenseSortDirProvider);
+                  ref.read(expenseSortDirProvider.notifier).state = dir == 'ASC' ? 'DESC' : 'ASC';
+                  _applyFilter();
+                },
+                child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: Center(child: Icon(ref.watch(expenseSortDirProvider) == 'ASC' ? Icons.arrow_upward : Icons.arrow_downward, color: Colors.white, size: 20))),
               ),
-            ),
-            const SizedBox(width: 8),
-            GestureDetector(
-              onTap: () {
-                final dir = ref.read(expenseSortDirProvider);
-                ref.read(expenseSortDirProvider.notifier).state = dir == 'ASC' ? 'DESC' : 'ASC';
-                _applyFilter();
-              },
-              child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: Center(child: Icon(ref.watch(expenseSortDirProvider) == 'ASC' ? Icons.arrow_upward : Icons.arrow_downward, color: Colors.white, size: 20))),
-            ),
-            const SizedBox(width: 8),
-            GestureDetector(onTap: _applyFilter, 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))),
-          ])),
-        ]),
+              const SizedBox(width: 8),
+              GestureDetector(onTap: _applyFilter, 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))),
+            ])),
+          ]),
+        ),
+        Expanded(child: Container(color: colors.bgPage, child: _ExpenseListContent(refreshCtrl: _refreshCtrl))),
+      ]),
+      floatingActionButton: FloatingActionButton(
+        onPressed: () => context.push('/report/expense-detail'),
+        backgroundColor: colors.primary,
+        shape: const CircleBorder(),
+        child: const Icon(Icons.bar_chart, color: Colors.white),
       ),
-      Expanded(child: Container(color: colors.bgPage, child: _ExpenseListContent(refreshCtrl: _refreshCtrl))),
-    ]);
+    );
   }
 }
 

+ 9 - 0
lib/features/expense/widgets/expense_detail_dialog.dart

@@ -715,6 +715,15 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
             style: const TextStyle(letterSpacing: 0),
           ),
         ),
+        Padding(
+          padding: const EdgeInsets.only(left: 4, top: 4),
+          child: TDText(
+            _l10n.get('maxAttachment'),
+            font: tdTheme.fontBodySmall,
+            fontWeight: FontWeight.w400,
+            textColor: tdTheme.textColorPlaceholder,
+          ),
+        ),
         const SizedBox(height: 4),
         if (!_attachAvailable)
           TDText(_l10n.get('attachServiceUnavailable'),

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

@@ -7,6 +7,7 @@ import '../../app.dart';
 import '../../shared/models/pagination_model.dart';
 import '../../shared/models/bill_attachment.dart';
 import 'expense_apply_model.dart';
+import 'report_model.dart';
 
 final expenseApplyApiProvider = Provider<ExpenseApplyApi>(
   (ref) => ExpenseApplyApi(ref.read(apiClientProvider)),
@@ -236,4 +237,16 @@ class ExpenseApplyApi {
     );
     return response.data!;
   }
+
+  /// 费用申请报表
+  Future<ReportData> getExpenseApplyReport({String? startDate, String? endDate}) async {
+    final params = <String, dynamic>{};
+    if (startDate != null) params['startDate'] = startDate;
+    if (endDate != null) params['endDate'] = endDate;
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetExpenseApplyReport',
+      queryParameters: params,
+    );
+    return ReportData.fromJson(response.data!);
+  }
 }

+ 2 - 0
lib/features/expense_apply/expense_apply_create_page.dart

@@ -879,6 +879,7 @@ class _ExpenseApplyCreatePageState
       rightLabel: l10n.get('submit'),
       centerTextOnly: true,
       onCenterTap: () async {
+        FocusScope.of(context).unfocus();
         try {
           await _saveDraftToStorage();
           if (mounted) _forcePop();
@@ -894,6 +895,7 @@ class _ExpenseApplyCreatePageState
           TDToast.showText(err.first, context: context);
           return;
         }
+        FocusScope.of(context).unfocus();
         LoadingDialog.show(context, text: l10n.get('submitting'));
         try {
           final data = _buildSubmitData();

+ 16 - 7
lib/features/expense_apply/expense_apply_detail_page.dart

@@ -183,12 +183,12 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
       children: [
         FormFieldRow(label: l10n.get('expenseApplyNo'), value: app.expenseApplyNo, readOnly: true, showArrow: false),
         const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDate(app.createTime), readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
         FormFieldRow(label: l10n.get('applicant'), value: app.applicantName, readOnly: true, showArrow: false),
         const SizedBox(height: 16),
         FormFieldRow(label: l10n.get('department'), value: app.deptName, readOnly: true, showArrow: false),
         const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDate(app.createTime), readOnly: true, showArrow: false),
-        const SizedBox(height: 16),
         SizedBox(
           height: 24,
           child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
@@ -197,7 +197,7 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
           ]),
         ),
         const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('feeReason'), value: app.purpose, readOnly: true, showArrow: false),
+        FormFieldRow(label: l10n.get('applyReason'), value: app.purpose, readOnly: true, showArrow: false),
         const SizedBox(height: 16),
         FormFieldRow(label: l10n.get('remark'), value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false),
       ],
@@ -254,10 +254,10 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
                             ),
                           ],
                         ),
-                        if (d.acctSubjectId.isNotEmpty && d.acctSubjectName.isNotEmpty) ...[
+                        if (d.acctSubjectId.isNotEmpty) ...[
                           const SizedBox(height: 4),
                           Text(
-                            '${d.acctSubjectId}/${d.acctSubjectName}',
+                            '${l10n.get('acctSubject')}: ${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/$d.acctSubjectName' : ''}',
                             maxLines: 1,
                             overflow: TextOverflow.ellipsis,
                             style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
@@ -275,7 +275,7 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
                         if (d.costDeptId.isNotEmpty && d.costDeptName.isNotEmpty) ...[
                           const SizedBox(height: 4),
                           Text(
-                            '${d.costDeptId}/${d.costDeptName}',
+                            '${l10n.get('costDept')}: ${d.costDeptId}/${d.costDeptName}',
                             maxLines: 1,
                             overflow: TextOverflow.ellipsis,
                             style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
@@ -284,7 +284,16 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
                         if (d.estimatedStartDate != null) ...[
                           const SizedBox(height: 4),
                           Text(
-                            '${du.DateUtils.formatDate(d.estimatedStartDate!)} ~ ${d.estimatedEndDate != null ? du.DateUtils.formatDate(d.estimatedEndDate!) : ''}',
+                            '${l10n.get('estimatedDate')}: ${du.DateUtils.formatDate(d.estimatedStartDate!)}${d.estimatedEndDate != null ? ' ~ ${du.DateUtils.formatDate(d.estimatedEndDate!)}' : ''}',
+                            maxLines: 1,
+                            overflow: TextOverflow.ellipsis,
+                            style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
+                          ),
+                        ],
+                        if (d.bxNo.isNotEmpty) ...[
+                          const SizedBox(height: 4),
+                          Text(
+                            '${l10n.get('expenseNo')}: $d.bxNo',
                             maxLines: 1,
                             overflow: TextOverflow.ellipsis,
                             style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),

+ 58 - 38
lib/features/expense_apply/expense_apply_list_page.dart

@@ -93,10 +93,10 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage>
   Widget _dateChip(TextEditingController ctrl, String hint, TDThemeData tdTheme, AppColorsExtension colors) {
     final text = ctrl.text;
     return Container(
-      height: 40, padding: const EdgeInsets.symmetric(horizontal: 16),
+      height: 40, padding: const EdgeInsets.symmetric(horizontal: 12),
       decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
       child: Row(children: [
-        Icon(Icons.calendar_today, size: 16, color: colors.textSecondary), const SizedBox(width: 8),
+        Icon(Icons.calendar_today, size: 16, color: colors.textSecondary), const SizedBox(width: 6),
         Expanded(child: Text(text.isNotEmpty ? text : hint, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14, color: text.isNotEmpty ? colors.textPrimary : colors.textSecondary))),
         if (text.isNotEmpty) GestureDetector(onTap: () { ctrl.clear(); setState(() {}); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
       ]),
@@ -109,44 +109,64 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage>
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final tdTheme = TDTheme.of(context);
 
-    setNavBarTitle(context, ref, NavBarConfig(title: l10n.get('expenseApplyList'), showBack: true, onBack: () => SystemNavigator.pop()));
-    return Column(children: [
-      Container(
-        color: colors.bgCard,
-        child: Column(mainAxisSize: MainAxisSize.min, children: [
-          Padding(padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row(children: [
-            Expanded(child: GestureDetector(onTap: () => _pickDate(_startDateCtrl), child: _dateChip(_startDateCtrl, l10n.get('filterStartDate'), tdTheme, colors))),
-            const SizedBox(width: 8), Text('—', style: TextStyle(fontSize: 14, color: colors.textSecondary)), const SizedBox(width: 8),
-            Expanded(child: GestureDetector(onTap: () => _pickDate(_endDateCtrl), child: _dateChip(_endDateCtrl, l10n.get('filterEndDate'), tdTheme, colors))),
-          ])),
-          const SizedBox(height: 8),
-          Padding(padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Row(children: [
-            Expanded(
-              child: Container(
-                height: 40, padding: const EdgeInsets.symmetric(horizontal: 16),
-                decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
-                child: Row(children: [
-                  Expanded(child: TextField(controller: _keywordCtrl, style: TextStyle(fontSize: 14, color: colors.textPrimary), decoration: InputDecoration(hintText: l10n.get('searchExpenseApply'), hintStyle: TextStyle(fontSize: 14, color: colors.textSecondary), border: InputBorder.none, isCollapsed: true), onChanged: (_) => setState(() {}))),
-                  if (_keywordCtrl.text.isNotEmpty) GestureDetector(onTap: () { _keywordCtrl.clear(); setState(() {}); _applyFilter(); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
-                ]),
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('expenseApplyList'),
+      showBack: true,
+      onBack: () => SystemNavigator.pop(),
+      showRight: true,
+      rightWidget: GestureDetector(
+        onTap: () => context.push('/expense-apply/create'),
+        child: Icon(Icons.add, color: colors.textPrimary, size: 22),
+      ),
+    ));
+    return Scaffold(
+      body: Column(children: [
+        Container(
+          decoration: BoxDecoration(
+            color: colors.bgCard,
+            border: Border(bottom: BorderSide(color: tdTheme.componentStrokeColor)),
+          ),
+          child: Column(mainAxisSize: MainAxisSize.min, children: [
+            Padding(padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row(children: [
+              Expanded(child: GestureDetector(onTap: () => _pickDate(_startDateCtrl), child: _dateChip(_startDateCtrl, l10n.get('filterStartDate'), tdTheme, colors))),
+              const SizedBox(width: 8), Text('—', style: TextStyle(fontSize: 14, color: colors.textSecondary)), const SizedBox(width: 8),
+              Expanded(child: GestureDetector(onTap: () => _pickDate(_endDateCtrl), child: _dateChip(_endDateCtrl, l10n.get('filterEndDate'), tdTheme, colors))),
+            ])),
+            const SizedBox(height: 8),
+            Padding(padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Row(children: [
+              Expanded(
+                child: Container(
+                  height: 40, padding: const EdgeInsets.symmetric(horizontal: 16),
+                  decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
+                  child: Row(children: [
+                    Expanded(child: TextField(controller: _keywordCtrl, style: TextStyle(fontSize: 14, color: colors.textPrimary), decoration: InputDecoration(hintText: l10n.get('searchExpenseApply'), hintStyle: TextStyle(fontSize: 14, color: colors.textSecondary), border: InputBorder.none, isCollapsed: true), onChanged: (_) => setState(() {}))),
+                    if (_keywordCtrl.text.isNotEmpty) GestureDetector(onTap: () { _keywordCtrl.clear(); setState(() {}); _applyFilter(); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
+                  ]),
+                ),
+              ),
+              const SizedBox(width: 8),
+              GestureDetector(
+                onTap: () {
+                  final dir = ref.read(expenseApplySortDirProvider);
+                  ref.read(expenseApplySortDirProvider.notifier).state = dir == 'ASC' ? 'DESC' : 'ASC';
+                  _applyFilter();
+                },
+                child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: Center(child: Icon(ref.watch(expenseApplySortDirProvider) == 'ASC' ? Icons.arrow_upward : Icons.arrow_downward, color: Colors.white, size: 20))),
               ),
-            ),
-            const SizedBox(width: 8),
-            GestureDetector(
-              onTap: () {
-                final dir = ref.read(expenseApplySortDirProvider);
-                ref.read(expenseApplySortDirProvider.notifier).state = dir == 'ASC' ? 'DESC' : 'ASC';
-                _applyFilter();
-              },
-              child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: Center(child: Icon(ref.watch(expenseApplySortDirProvider) == 'ASC' ? Icons.arrow_upward : Icons.arrow_downward, color: Colors.white, size: 20))),
-            ),
-            const SizedBox(width: 8),
-            GestureDetector(onTap: _applyFilter, 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))),
-          ])),
-        ]),
+              const SizedBox(width: 8),
+              GestureDetector(onTap: _applyFilter, 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))),
+            ])),
+          ]),
+        ),
+        Expanded(child: Container(color: colors.bgPage, child: _ExpenseApplyListContent(refreshCtrl: _refreshCtrl))),
+      ]),
+      floatingActionButton: FloatingActionButton(
+        onPressed: () => context.push('/report/expense-apply-detail'),
+        backgroundColor: colors.primary,
+        shape: const CircleBorder(),
+        child: const Icon(Icons.bar_chart, color: Colors.white),
       ),
-      Expanded(child: Container(color: colors.bgPage, child: _ExpenseApplyListContent(refreshCtrl: _refreshCtrl))),
-    ]);
+    );
   }
 }
 

+ 4 - 0
lib/features/expense_apply/expense_apply_model.dart

@@ -240,6 +240,7 @@ class ExpenseApplyDetailModel {
   final DateTime? estimatedStartDate;
   final DateTime? estimatedEndDate;
   final double estimatedAmount;
+  final String bxNo;
   final String remark;
   final int sortOrder;
   final DateTime createTime;
@@ -263,6 +264,7 @@ class ExpenseApplyDetailModel {
     this.estimatedStartDate,
     this.estimatedEndDate,
     this.estimatedAmount = 0.0,
+    this.bxNo = '',
     this.remark = '',
     this.sortOrder = 1,
     required this.createTime,
@@ -290,6 +292,7 @@ class ExpenseApplyDetailModel {
           ? DateTime.parse(json['endDd'] as String)
           : null,
       estimatedAmount: (json['amtnYj'] as num?)?.toDouble() ?? 0.0,
+      bxNo: json['bxNo'] as String? ?? '',
       remark: json['rem'] as String? ?? '',
       sqMan: json['sqMan'] as String? ?? '',
       sqName: json['sqName'] as String? ?? '',
@@ -321,6 +324,7 @@ class ExpenseApplyDetailModel {
     'estimatedStartDate': estimatedStartDate?.toIso8601String(),
     'estimatedEndDate': estimatedEndDate?.toIso8601String(),
     'estimatedAmount': estimatedAmount,
+    'bxNo': bxNo,
     'remark': remark,
     'sortOrder': sortOrder,
     'createTime': createTime.toIso8601String(),

+ 103 - 0
lib/features/expense_apply/report_model.dart

@@ -0,0 +1,103 @@
+/// 报表汇总数据
+class ReportSummary {
+  final double totalAmount;
+  final int totalCount;
+  final int approvedCount;
+  final double approvedAmount;
+
+  const ReportSummary({
+    this.totalAmount = 0,
+    this.totalCount = 0,
+    this.approvedCount = 0,
+    this.approvedAmount = 0,
+  });
+
+  factory ReportSummary.fromJson(Map<String, dynamic> json) {
+    return ReportSummary(
+      totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0,
+      totalCount: json['totalCount'] as int? ?? 0,
+      approvedCount: json['approvedCount'] as int? ?? 0,
+      approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0,
+    );
+  }
+}
+
+/// 月度报表数据
+class ReportMonthlyItem {
+  final int month;
+  final double amount;
+  final double approvedAmount;
+  final int count;
+
+  const ReportMonthlyItem({
+    this.month = 0,
+    this.amount = 0,
+    this.approvedAmount = 0,
+    this.count = 0,
+  });
+
+  factory ReportMonthlyItem.fromJson(Map<String, dynamic> json) {
+    return ReportMonthlyItem(
+      month: json['month'] as int? ?? 0,
+      amount: (json['amount'] as num?)?.toDouble() ?? 0,
+      approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0,
+      count: json['count'] as int? ?? 0,
+    );
+  }
+}
+
+/// 报表明细项
+class ReportDetailItem {
+  final String billNo;
+  final String? billDate;
+  final double amount;
+  final bool isApproved;
+  final String reason;
+
+  const ReportDetailItem({
+    this.billNo = '',
+    this.billDate,
+    this.amount = 0,
+    this.isApproved = false,
+    this.reason = '',
+  });
+
+  factory ReportDetailItem.fromJson(Map<String, dynamic> json) {
+    return ReportDetailItem(
+      billNo: json['billNo'] as String? ?? '',
+      billDate: json['billDate'] as String?,
+      amount: (json['amount'] as num?)?.toDouble() ?? 0,
+      isApproved: json['isApproved'] as bool? ?? false,
+      reason: json['reason'] as String? ?? '',
+    );
+  }
+}
+
+/// 报表完整数据
+class ReportData {
+  final ReportSummary summary;
+  final List<ReportMonthlyItem> monthly;
+  final List<ReportDetailItem> details;
+
+  const ReportData({
+    this.summary = const ReportSummary(),
+    this.monthly = const [],
+    this.details = const [],
+  });
+
+  factory ReportData.fromJson(Map<String, dynamic> json) {
+    return ReportData(
+      summary: json['summary'] != null
+          ? ReportSummary.fromJson(json['summary'] as Map<String, dynamic>)
+          : const ReportSummary(),
+      monthly: (json['monthly'] as List<dynamic>?)
+              ?.map((e) => ReportMonthlyItem.fromJson(e as Map<String, dynamic>))
+              .toList() ??
+          [],
+      details: (json['details'] as List<dynamic>?)
+              ?.map((e) => ReportDetailItem.fromJson(e as Map<String, dynamic>))
+              .toList() ??
+          [],
+    );
+  }
+}

+ 1 - 1
lib/features/expense_apply/widgets/expense_apply_detail_dialog.dart

@@ -684,7 +684,7 @@ class _ExpenseApplyDetailDialogState
                 color: tdTheme.textColorPrimary,
               ),
               decoration: InputDecoration(
-                hintText: _l10n.get('enterEstimatedAmount'),
+                hintText: '>0',
                 hintStyle: TextStyle(
                   color: tdTheme.textColorPlaceholder,
                 ),

+ 217 - 461
lib/features/report/expense_apply_detail_report_page.dart

@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
 import 'package:go_router/go_router.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:fl_chart/fl_chart.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../shared/widgets/nav_bar_config.dart';
 import '../../core/theme/app_colors_extension.dart';
-import '../../core/auth/role_provider.dart';
+import '../expense_apply/expense_apply_api.dart';
+import '../expense_apply/report_model.dart';
 
 /// 事前申请明细报表 - 页面22
 
@@ -18,245 +20,159 @@ class ExpenseApplyDetailReportPage extends ConsumerStatefulWidget {
 
 class _ExpenseApplyDetailReportPageState
     extends ConsumerState<ExpenseApplyDetailReportPage> {
-  int _timeFilterIdx = 0;
-  DateTime? _customStart;
-  DateTime? _customEnd;
-  int _statusFilterIdx = 0;
+  final _startCtrl = TextEditingController();
+  final _endCtrl = TextEditingController();
+  bool _loading = true;
+  String? _error;
+  ReportData? _data;
 
-  // Mock 近12月数据(单位:k)
-  static const _months = [
-    '1月',
-    '2月',
-    '3月',
-    '4月',
-    '5月',
-    '6月',
-    '7月',
-    '8月',
-    '9月',
-    '10月',
-    '11月',
-    '12月',
-  ];
-  static const _amountApply = [
-    128.5,
-    152.0,
-    83.0,
-    201.0,
-    186.0,
-    224.0,
-    147.0,
-    98.0,
-    165.0,
-    192.0,
-    113.0,
-    250.0,
-  ];
-  static const _amountApproved = [
-    102.0,
-    128.0,
-    65.0,
-    173.0,
-    151.0,
-    196.0,
-    112.0,
-    75.0,
-    138.0,
-    160.0,
-    92.0,
-    215.0,
-  ];
+  @override
+  void initState() {
+    super.initState();
+    final now = DateTime.now();
+    _startCtrl.text = '${now.year}-01-01';
+    _endCtrl.text = '${now.year}-12-31';
+    _loadData();
+  }
+
+  @override
+  void dispose() {
+    _startCtrl.dispose();
+    _endCtrl.dispose();
+    super.dispose();
+  }
+
+  Future<void> _loadData() async {
+    setState(() {
+      _loading = true;
+      _error = null;
+    });
+    try {
+      final api = ref.read(expenseApplyApiProvider);
+      final data = await api.getExpenseApplyReport(
+        startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
+        endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
+      );
+      if (mounted) setState(() { _data = data; _loading = false; });
+    } catch (e) {
+      if (mounted) setState(() { _error = e.toString(); _loading = false; });
+    }
+  }
+
+  void _pickDate(TextEditingController ctrl) {
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final now = DateTime.now();
+    TDPicker.showDatePicker(
+      context,
+      title: l10n.get('selectDate'),
+      backgroundColor: colors.bgCard,
+      useYear: true,
+      useMonth: true,
+      useDay: true,
+      useHour: false,
+      useMinute: false,
+      useSecond: false,
+      useWeekDay: false,
+      dateStart: const [2020, 1, 1],
+      dateEnd: [now.year + 1, 12, 31],
+      initialDate: [now.year, now.month, now.day],
+      onConfirm: (selected) {
+        ctrl.text =
+            '${selected['year']}-${selected['month'].toString().padLeft(2, '0')}-${selected['day'].toString().padLeft(2, '0')}';
+        setState(() {});
+        Navigator.of(context).pop();
+      },
+    );
+  }
 
-  // 部门明细
-  final _deptList = [
-    _DeptItem(name: '张三', apply: 250.0, approved: 215.0),
-    _DeptItem(name: '李四', apply: 182.0, approved: 150.0),
-    _DeptItem(name: '王五', apply: 325.0, approved: 283.0),
-    _DeptItem(name: '赵六', apply: 148.0, approved: 120.0),
-    _DeptItem(name: '钱七', apply: 221.0, approved: 196.0),
-  ];
+  void _applyFilter() {
+    _loadData();
+  }
 
   @override
   Widget build(BuildContext context) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
-    final role = ref.watch(currentRoleProvider);
-    setNavBarTitle(context, ref, NavBarConfig(
-      title: l10n.get('reportExpenseApplyDetail'),
-      showBack: true,
-      onBack: () => context.pop(),
-    ));
-    final showDetail = role != 'employee';
-    final showExport = role == 'finance' || role == 'admin';
+    setNavBarTitle(
+      context,
+      ref,
+      NavBarConfig(
+        title: l10n.get('reportExpenseApplyDetail'),
+        showBack: true,
+        onBack: () => context.pop(),
+      ),
+    );
+    if (_loading) {
+      return Scaffold(
+        body: Center(child: CircularProgressIndicator(color: colors.primary)),
+      );
+    }
+    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 Scaffold(
       body: SingleChildScrollView(
         child: Column(
           children: [
-            _buildTimeFilter(),
-            _buildStatusFilter(),
+            _buildDateFilter(),
             _buildStatCards(),
             _buildChartSection(),
-            if (showDetail) _buildDeptListSection(),
+            _buildDetailList(),
             const SizedBox(height: 80),
           ],
         ),
       ),
-      floatingActionButton: showExport
-          ? FloatingActionButton.small(
-              onPressed: () {
-                ScaffoldMessenger.of(context).showSnackBar(
-                  SnackBar(
-                    content: Text(l10n.get('exportPlaceholder')),
-                    duration: const Duration(seconds: 2),
-                  ),
-                );
-              },
-              backgroundColor: colors.primary,
-              child: const Icon(Icons.download, color: Colors.white),
-            )
-          : null,
     );
   }
 
-  // ── 时间筛选 ──
-  Widget _buildTimeFilter() {
+  // ── 日期筛选 ──
+  Widget _buildDateFilter() {
+    final tdTheme = TDTheme.of(context);
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
-    final timeLabels = [
-      l10n.get('filterThisMonth'),
-      l10n.get('filterThisQuarter'),
-      l10n.get('filterThisYear'),
-    ];
     return Container(
-      width: double.infinity,
-      padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
-      color: colors.bgCard,
-      child: Row(
-        children: [
-          ...List.generate(timeLabels.length, (i) {
-            final sel = i == _timeFilterIdx;
-            return Padding(
-              padding: const EdgeInsets.only(right: 8),
-              child: GestureDetector(
-                onTap: () => setState(() => _timeFilterIdx = i),
-                child: Container(
-                  padding: const EdgeInsets.symmetric(
-                    horizontal: 14,
-                    vertical: 6,
-                  ),
-                  decoration: BoxDecoration(
-                    color: sel ? colors.primary : colors.bgPage,
-                    borderRadius: BorderRadius.circular(16),
-                  ),
-                  child: Text(
-                    timeLabels[i],
-                    style: TextStyle(
-                      fontSize: 14,
-                      fontWeight: sel ? FontWeight.w600 : FontWeight.normal,
-                      color: sel ? Colors.white : colors.textSecondary,
-                    ),
-                  ),
-                ),
-              ),
-            );
-          }),
-          const Spacer(),
+      decoration: BoxDecoration(
+        color: colors.bgCard,
+        border: Border(bottom: BorderSide(color: tdTheme.componentStrokeColor)),
+      ),
+      child: Padding(
+        padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
+        child: Row(children: [
+          Expanded(child: GestureDetector(onTap: () => _pickDate(_startCtrl), child: _dateChip(_startCtrl, l10n.get('filterStartDate'), tdTheme, colors))),
+          const SizedBox(width: 8),
+          Text('—', style: TextStyle(fontSize: 14, color: colors.textSecondary)),
+          const SizedBox(width: 8),
+          Expanded(child: GestureDetector(onTap: () => _pickDate(_endCtrl), child: _dateChip(_endCtrl, l10n.get('filterEndDate'), tdTheme, colors))),
+          const SizedBox(width: 8),
           GestureDetector(
-            onTap: _pickCustomDateRange,
-            child: Container(
-              padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
-              decoration: BoxDecoration(
-                border: Border.all(color: colors.border),
-                borderRadius: BorderRadius.circular(16),
-              ),
-              child: Row(
-                mainAxisSize: MainAxisSize.min,
-                children: [
-                  Icon(Icons.date_range, size: 14, color: colors.textSecondary),
-                  const SizedBox(width: 4),
-                  Text(
-                    _customStart != null && _customEnd != null
-                        ? '${_customStart!.month}/${_customStart!.day}-${_customEnd!.month}/${_customEnd!.day}'
-                        : l10n.get('custom'),
-                    style: TextStyle(fontSize: 14, color: colors.textSecondary),
-                  ),
-                ],
-              ),
-            ),
+            onTap: _applyFilter,
+            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)),
           ),
-        ],
+        ]),
       ),
     );
   }
 
-  Future<void> _pickCustomDateRange() async {
-    final range = await showDateRangePicker(
-      context: context,
-      firstDate: DateTime(2020),
-      lastDate: DateTime.now(),
-      initialDateRange: _customStart != null && _customEnd != null
-          ? DateTimeRange(start: _customStart!, end: _customEnd!)
-          : null,
-    );
-    if (range != null) {
-      setState(() {
-        _customStart = range.start;
-        _customEnd = range.end;
-        _timeFilterIdx = -1;
-      });
-    }
-  }
-
-  // ── 状态筛选 ──
-  Widget _buildStatusFilter() {
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final l10n = AppLocalizations.of(context);
-    final statusLabels = [
-      l10n.get('filterAll'),
-      l10n.get('approved'),
-      l10n.get('rejected'),
-      l10n.get('withdrawn'),
-    ];
+  Widget _dateChip(
+      TextEditingController ctrl, String hint, TDThemeData tdTheme, AppColorsExtension colors) {
+    final text = ctrl.text;
     return Container(
-      width: double.infinity,
-      padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
-      color: colors.bgCard,
-      child: Row(
-        children: [
-          Text(
-            '${l10n.get('filterStatus')}:',
-            style: TextStyle(fontSize: 13, color: colors.textSecondary),
-          ),
-          const SizedBox(width: 4),
-          ...List.generate(statusLabels.length, (i) {
-            final sel = i == _statusFilterIdx;
-            return Padding(
-              padding: const EdgeInsets.only(right: 6),
-              child: GestureDetector(
-                onTap: () => setState(() => _statusFilterIdx = i),
-                child: Container(
-                  padding: const EdgeInsets.symmetric(
-                    horizontal: 10,
-                    vertical: 4,
-                  ),
-                  decoration: BoxDecoration(
-                    color: sel ? colors.primaryLight : colors.bgPage,
-                    borderRadius: BorderRadius.circular(12),
-                    border: sel ? Border.all(color: colors.primary) : null,
-                  ),
-                  child: Text(
-                    statusLabels[i],
-                    style: TextStyle(
-                      fontSize: 12,
-                      color: sel ? colors.primary : colors.textSecondary,
-                    ),
-                  ),
-                ),
-              ),
-            );
-          }),
-        ],
-      ),
+      height: 36, padding: const EdgeInsets.symmetric(horizontal: 8),
+      decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(18), border: Border.all(color: tdTheme.componentStrokeColor)),
+      child: Row(children: [
+        Icon(Icons.calendar_today, size: 14, color: colors.textSecondary), const SizedBox(width: 4),
+        Expanded(child: Text(text.isNotEmpty ? text : hint, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: text.isNotEmpty ? colors.textPrimary : colors.textSecondary))),
+        if (text.isNotEmpty) GestureDetector(onTap: () { ctrl.clear(); setState(() {}); }, child: Icon(Icons.close, size: 16, color: colors.textSecondary)),
+      ]),
     );
   }
 
@@ -264,6 +180,7 @@ class _ExpenseApplyDetailReportPageState
   Widget _buildStatCards() {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
+    final summary = _data?.summary ?? const ReportSummary();
     return Padding(
       padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
       child: Column(
@@ -273,7 +190,7 @@ class _ExpenseApplyDetailReportPageState
               Expanded(
                 child: _statCard(
                   l10n.get('statTotalApply'),
-                  '¥1,939,500',
+                  '¥${summary.totalAmount.toStringAsFixed(2)}',
                   colors.amountPrimary,
                 ),
               ),
@@ -281,7 +198,7 @@ class _ExpenseApplyDetailReportPageState
               Expanded(
                 child: _statCard(
                   l10n.get('statMonthCount'),
-                  '25 笔',
+                  '${summary.totalCount} 笔',
                   colors.textPrimary,
                 ),
               ),
@@ -293,7 +210,7 @@ class _ExpenseApplyDetailReportPageState
               Expanded(
                 child: _statCard(
                   l10n.get('statApprovedCount'),
-                  '18 笔',
+                  '${summary.approvedCount} 笔',
                   colors.textPrimary,
                 ),
               ),
@@ -301,7 +218,7 @@ class _ExpenseApplyDetailReportPageState
               Expanded(
                 child: _statCard(
                   l10n.get('statApprovedAmount'),
-                  '¥1,607,000',
+                  '¥${summary.approvedAmount.toStringAsFixed(2)}',
                   colors.amountPrimary,
                 ),
               ),
@@ -351,11 +268,6 @@ class _ExpenseApplyDetailReportPageState
   Widget _buildChartSection() {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
-    final role = ref.watch(currentRoleProvider);
-    final isManager = role == 'manager';
-    final title = isManager
-        ? l10n.get('chartDeptApplyCompare')
-        : l10n.get('chartTitle4');
     return Padding(
       padding: const EdgeInsets.all(12),
       child: Container(
@@ -376,7 +288,7 @@ class _ExpenseApplyDetailReportPageState
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
             Text(
-              title,
+              l10n.get('chartTitle4'),
               style: TextStyle(
                 fontSize: 14,
                 fontWeight: FontWeight.w600,
@@ -394,7 +306,7 @@ class _ExpenseApplyDetailReportPageState
             const SizedBox(height: 12),
             SizedBox(
               height: 200,
-              child: isManager ? _buildDeptBarChart() : _buildDualLineChart(),
+              child: _buildDualLineChart(),
             ),
           ],
         ),
@@ -424,12 +336,31 @@ class _ExpenseApplyDetailReportPageState
   // 双折线图
   Widget _buildDualLineChart() {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
+
+    // 从 monthly 构建 12 个月的数据,无数据的月份默认为 0
+    final monthlyMap = <int, ReportMonthlyItem>{};
+    for (final item in (_data?.monthly ?? [])) {
+      monthlyMap[item.month] = item;
+    }
+    final monthLabels = List.generate(12, (i) => '${i + 1}月');
+    final amountData = List.generate(12, (i) {
+      return monthlyMap[i + 1]?.amount ?? 0;
+    });
+    final approvedData = List.generate(12, (i) {
+      return monthlyMap[i + 1]?.approvedAmount ?? 0;
+    });
+
+    // 计算 Y 轴最大值
+    final allValues = [...amountData, ...approvedData];
+    final maxVal = allValues.reduce((a, b) => a > b ? a : b);
+    final maxY = (maxVal > 0 ? maxVal * 1.2 : 100).toDouble();
+
     return LineChart(
       LineChartData(
         gridData: FlGridData(
           show: true,
           drawVerticalLine: false,
-          horizontalInterval: 50,
+          horizontalInterval: maxY / 5,
           getDrawingHorizontalLine: (v) =>
               FlLine(color: colors.border, strokeWidth: 0.5),
         ),
@@ -437,9 +368,9 @@ class _ExpenseApplyDetailReportPageState
           leftTitles: AxisTitles(
             sideTitles: SideTitles(
               showTitles: true,
-              reservedSize: 40,
+              reservedSize: 48,
               getTitlesWidget: (v, meta) => Text(
-                '¥${v.toInt()}k',
+                '¥${v.toInt()}',
                 style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
               ),
             ),
@@ -451,11 +382,11 @@ class _ExpenseApplyDetailReportPageState
               reservedSize: 26,
               getTitlesWidget: (v, meta) {
                 final i = v.toInt();
-                if (i < 0 || i >= _months.length) return const SizedBox();
+                if (i < 0 || i >= monthLabels.length) return const SizedBox();
                 return Padding(
                   padding: const EdgeInsets.only(top: 4),
                   child: Text(
-                    _months[i],
+                    monthLabels[i],
                     style: TextStyle(
                       fontSize: 9,
                       color: colors.textPlaceholder,
@@ -474,13 +405,13 @@ class _ExpenseApplyDetailReportPageState
         ),
         borderData: FlBorderData(show: false),
         minY: 0,
-        maxY: 300,
+        maxY: maxY,
         lineTouchData: LineTouchData(
           enabled: true,
           touchTooltipData: LineTouchTooltipData(
             getTooltipItems: (spots) => spots.map((s) {
               return LineTooltipItem(
-                '${_months[s.spotIndex]}\n¥${s.y.toInt()}k',
+                '${monthLabels[s.spotIndex]}\n¥${s.y.toStringAsFixed(0)}',
                 const TextStyle(
                   color: Colors.white,
                   fontSize: 12,
@@ -494,7 +425,7 @@ class _ExpenseApplyDetailReportPageState
           LineChartBarData(
             spots: List.generate(
               12,
-              (i) => FlSpot(i.toDouble(), _amountApply[i]),
+              (i) => FlSpot(i.toDouble(), amountData[i]),
             ),
             isCurved: true,
             color: colors.primary,
@@ -516,7 +447,7 @@ class _ExpenseApplyDetailReportPageState
           LineChartBarData(
             spots: List.generate(
               12,
-              (i) => FlSpot(i.toDouble(), _amountApproved[i]),
+              (i) => FlSpot(i.toDouble(), approvedData[i]),
             ),
             isCurved: true,
             color: colors.success,
@@ -540,245 +471,70 @@ class _ExpenseApplyDetailReportPageState
     );
   }
 
-  // 部门柱状图(经理视图)
-  Widget _buildDeptBarChart() {
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    return BarChart(
-      BarChartData(
-        maxY: 400,
-        barTouchData: BarTouchData(
-          enabled: true,
-          touchTooltipData: BarTouchTooltipData(
-            getTooltipItem: (group, groupIndex, rod, rodIndex) {
-              final item = _deptList[group.x.toInt()];
-              return BarTooltipItem(
-                '${item.name}\n${rod.toY.toInt()}k',
-                const TextStyle(color: Colors.white, fontSize: 12),
-              );
-            },
-          ),
-        ),
-        titlesData: FlTitlesData(
-          leftTitles: AxisTitles(
-            sideTitles: SideTitles(
-              showTitles: true,
-              reservedSize: 36,
-              getTitlesWidget: (v, meta) => Text(
-                '${v.toInt()}k',
-                style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
-              ),
-            ),
-          ),
-          bottomTitles: AxisTitles(
-            sideTitles: SideTitles(
-              showTitles: true,
-              reservedSize: 28,
-              getTitlesWidget: (v, meta) {
-                final i = v.toInt();
-                if (i < 0 || i >= _deptList.length) return const SizedBox();
-                return Padding(
-                  padding: const EdgeInsets.only(top: 4),
-                  child: Text(
-                    _deptList[i].name,
-                    style: TextStyle(
-                      fontSize: 10,
-                      color: colors.textPlaceholder,
-                    ),
-                  ),
-                );
-              },
-            ),
-          ),
-          topTitles: const AxisTitles(
-            sideTitles: SideTitles(showTitles: false),
-          ),
-          rightTitles: const AxisTitles(
-            sideTitles: SideTitles(showTitles: false),
-          ),
-        ),
-        borderData: FlBorderData(show: false),
-        gridData: FlGridData(
-          show: true,
-          drawVerticalLine: false,
-          horizontalInterval: 100,
-          getDrawingHorizontalLine: (v) =>
-              FlLine(color: colors.border, strokeWidth: 0.5),
-        ),
-        barGroups: List.generate(_deptList.length, (i) {
-          return BarChartGroupData(
-            x: i,
-            barRods: [
-              BarChartRodData(
-                toY: _deptList[i].apply,
-                color: colors.primary,
-                width: 16,
-                borderRadius: const BorderRadius.vertical(
-                  top: Radius.circular(3),
-                ),
-              ),
-              BarChartRodData(
-                toY: _deptList[i].approved,
-                color: colors.success,
-                width: 16,
-                borderRadius: const BorderRadius.vertical(
-                  top: Radius.circular(3),
-                ),
-              ),
-            ],
-          );
-        }),
-      ),
-    );
-  }
-
-  // ── 部门明细列表 ──
-  Widget _buildDeptListSection() {
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+  // ── 明细列表 ──
+  Widget _buildDetailList() {
+    final details = _data?.details ?? [];
+    if (details.isEmpty) return const SizedBox.shrink();
     final l10n = AppLocalizations.of(context);
-    final role = ref.watch(currentRoleProvider);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final headerStyle = TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.textSecondary);
+    final rowStyle = TextStyle(fontSize: 13, color: colors.textPrimary);
+    final amountStyle = TextStyle(fontSize: 13, color: colors.amountPrimary, fontWeight: FontWeight.w600);
     return Padding(
-      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
+      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
       child: Container(
         width: double.infinity,
-        decoration: BoxDecoration(
-          color: colors.bgCard,
-          borderRadius: BorderRadius.circular(8),
-          boxShadow: const [
-            BoxShadow(
-              color: Color(0x08000000),
-              blurRadius: 4,
-              offset: Offset(0, 1),
-            ),
-          ],
-        ),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            Padding(
-              padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
-              child: Row(
-                children: [
-                  Text(
-                    l10n.get('deptDashboard'),
-                    style: TextStyle(
-                      fontSize: 14,
-                      fontWeight: FontWeight.w600,
-                      color: colors.textPrimary,
-                    ),
-                  ),
-                  if (role == 'manager')
-                    Text(
-                      l10n.get('clickChartToFilter'),
-                      style: TextStyle(
-                        fontSize: 11,
-                        color: colors.textPlaceholder,
-                      ),
-                    ),
-                ],
-              ),
-            ),
-            Container(
+        decoration: BoxDecoration(color: colors.bgCard, borderRadius: BorderRadius.circular(8)),
+        child: Column(children: [
+          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)),
+          ])),
+          Container(
+            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+            color: colors.bgDisabled,
+            child: Row(children: [
+              Expanded(flex: 3, child: Text(l10n.get('expenseApplyNo'), style: headerStyle)),
+              const SizedBox(width: 8),
+              SizedBox(width: 80, child: Text(l10n.get('date'), style: headerStyle)),
+              const SizedBox(width: 8),
+              SizedBox(width: 80, child: Text(l10n.get('estimatedAmount'), textAlign: TextAlign.right, style: headerStyle)),
+              const SizedBox(width: 8),
+              SizedBox(width: 60, child: Text(l10n.get('filterStatus'), textAlign: TextAlign.center, style: headerStyle)),
+            ]),
+          ),
+          ...details.map((d) {
+            final approved = d.isApproved;
+            final chipColor = approved ? colors.success : colors.warning;
+            return Padding(
               padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
-              decoration: BoxDecoration(
-                color: colors.bgDisabled,
-              ),
-              child: Row(
-                children: [
-                  SizedBox(
-                    width: 100,
-                    child: Text(
-                      l10n.get('creator'),
-                      style: TextStyle(
-                        fontSize: 12,
-                        fontWeight: FontWeight.w600,
-                        color: colors.textSecondary,
-                      ),
-                    ),
-                  ),
-                  Expanded(
-                    child: Text(
-                      l10n.get('yearTotalApp'),
-                      textAlign: TextAlign.right,
-                      style: TextStyle(
-                        fontSize: 12,
-                        fontWeight: FontWeight.w600,
-                        color: colors.textSecondary,
-                      ),
-                    ),
+              child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
+                Expanded(flex: 3, child: Text(d.billNo, style: rowStyle, maxLines: 2, overflow: TextOverflow.ellipsis)),
+                const SizedBox(width: 8),
+                SizedBox(width: 80, child: Text(d.billDate != null && d.billDate!.length >= 10 ? d.billDate!.substring(0, 10) : '-', style: rowStyle, maxLines: 2)),
+                const SizedBox(width: 8),
+                SizedBox(width: 80, child: Text('¥${d.amount.toStringAsFixed(2)}', textAlign: TextAlign.right, style: amountStyle, maxLines: 1, overflow: TextOverflow.ellipsis)),
+                const SizedBox(width: 8),
+                SizedBox(width: 60, child: Container(
+                  padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+                  decoration: BoxDecoration(
+                    color: chipColor.withValues(alpha: 0.1),
+                    borderRadius: BorderRadius.circular(10),
+                    border: Border.all(color: chipColor, width: 0.5),
                   ),
-                  const SizedBox(width: 16),
-                  Expanded(
-                    child: Text(
-                      l10n.get('approvedAmount'),
-                      textAlign: TextAlign.right,
-                      style: TextStyle(
-                        fontSize: 12,
-                        fontWeight: FontWeight.w600,
-                        color: colors.textSecondary,
-                      ),
-                    ),
+                  child: Text(
+                    approved ? l10n.get('approved') : l10n.get('pending'),
+                    textAlign: TextAlign.center,
+                    style: TextStyle(fontSize: 11, color: chipColor, fontWeight: FontWeight.w500),
                   ),
-                ],
-              ),
-            ),
-            ..._deptList.map(
-              (d) => Container(
-                padding: const EdgeInsets.symmetric(
-                  horizontal: 16,
-                  vertical: 10,
-                ),
-                child: Row(
-                  children: [
-                    SizedBox(
-                      width: 100,
-                      child: Text(
-                        d.name,
-                        style: TextStyle(
-                          fontSize: 13,
-                          color: colors.textPrimary,
-                        ),
-                      ),
-                    ),
-                    Expanded(
-                      child: Text(
-                        '¥${d.apply.toStringAsFixed(1)}k',
-                        textAlign: TextAlign.right,
-                        style: TextStyle(
-                          fontSize: 13,
-                          color: colors.textPrimary,
-                        ),
-                      ),
-                    ),
-                    const SizedBox(width: 16),
-                    Expanded(
-                      child: Text(
-                        '¥${d.approved.toStringAsFixed(1)}k',
-                        textAlign: TextAlign.right,
-                        style: TextStyle(
-                          fontSize: 13,
-                          color: colors.textPrimary,
-                        ),
-                      ),
-                    ),
-                  ],
-                ),
-              ),
-            ),
-            const SizedBox(height: 8),
-          ],
-        ),
+                )),
+              ]),
+            );
+          }),
+          const SizedBox(height: 8),
+        ]),
       ),
     );
   }
 }
-
-class _DeptItem {
-  final String name;
-  final double apply;
-  final double approved;
-  const _DeptItem({
-    required this.name,
-    required this.apply,
-    required this.approved,
-  });
-}

+ 241 - 520
lib/features/report/expense_detail_report_page.dart

@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
 import 'package:go_router/go_router.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:fl_chart/fl_chart.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../shared/widgets/nav_bar_config.dart';
 import '../../core/theme/app_colors_extension.dart';
-import '../../core/auth/role_provider.dart';
+import '../expense/expense_api.dart';
+import '../expense_apply/report_model.dart';
 
 /// 费用报销明细报表 - 页面23
 
@@ -18,297 +20,122 @@ class ExpenseDetailReportPage extends ConsumerStatefulWidget {
 
 class _ExpenseDetailReportPageState
     extends ConsumerState<ExpenseDetailReportPage> {
-  int _timeFilterIdx = 0;
-  DateTime? _customStart;
-  DateTime? _customEnd;
-  int _statusFilterIdx = 0;
-  int _payFilterIdx = 0;
+  final _startCtrl = TextEditingController();
+  final _endCtrl = TextEditingController();
+  bool _loading = true;
+  String? _error;
+  ReportData? _data;
 
-  static const _months = [
-    '1月',
-    '2月',
-    '3月',
-    '4月',
-    '5月',
-    '6月',
-    '7月',
-    '8月',
-    '9月',
-    '10月',
-    '11月',
-    '12月',
-  ];
-  static const _amountExpense = [
-    85.0,
-    120.0,
-    65.0,
-    150.0,
-    140.0,
-    180.0,
-    110.0,
-    72.0,
-    130.0,
-    155.0,
-    90.0,
-    200.0,
-  ];
-  static const _amountApproved = [
-    72.0,
-    105.0,
-    55.0,
-    135.0,
-    120.0,
-    160.0,
-    95.0,
-    60.0,
-    115.0,
-    140.0,
-    78.0,
-    178.0,
-  ];
-
-  final _deptList = [
-    _DeptItem(name: '张三', expense: 200.0, approved: 178.0),
-    _DeptItem(name: '李四', expense: 150.0, approved: 130.0),
-    _DeptItem(name: '王五', expense: 280.0, approved: 250.0),
-    _DeptItem(name: '赵六', expense: 120.0, approved: 105.0),
-    _DeptItem(name: '钱七', expense: 185.0, approved: 165.0),
-  ];
+  @override
+  void initState() {
+    super.initState();
+    final now = DateTime.now();
+    _startCtrl.text = '${now.year}-01-01';
+    _endCtrl.text = '${now.year}-12-31';
+    WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
+  }
 
   @override
-  Widget build(BuildContext context) {
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final l10n = AppLocalizations.of(context);
-    final role = ref.watch(currentRoleProvider);
-    setNavBarTitle(context, ref, NavBarConfig(
-      title: l10n.get('reportExpenseDetail'),
-      showBack: true,
-      onBack: () => context.pop(),
-    ));
-    final showDetail = role != 'employee';
-    final showExport = role == 'finance' || role == 'admin';
-    return Scaffold(
-      body: SingleChildScrollView(
-        child: Column(
-          children: [
-            _buildTimeFilter(),
-            _buildStatusFilters(),
-            _buildStatCards(),
-            _buildChartSection(),
-            if (showDetail) _buildDeptListSection(),
-            const SizedBox(height: 80),
-          ],
-        ),
-      ),
-      floatingActionButton: showExport
-          ? FloatingActionButton.small(
-              onPressed: () {
-                ScaffoldMessenger.of(context).showSnackBar(
-                  SnackBar(
-                    content: Text(l10n.get('exportPlaceholder')),
-                    duration: const Duration(seconds: 2),
-                  ),
-                );
-              },
-              backgroundColor: colors.primary,
-              child: const Icon(Icons.download, color: Colors.white),
-            )
-          : null,
-    );
+  void dispose() {
+    _startCtrl.dispose();
+    _endCtrl.dispose();
+    super.dispose();
   }
 
-  // ── 时间筛选 ──
-  Widget _buildTimeFilter() {
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+  Future<void> _loadData() async {
+    setState(() {
+      _loading = true;
+      _error = null;
+    });
+    try {
+      final api = ref.read(expenseApiProvider);
+      final data = await api.getExpenseReport(
+        startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
+        endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
+      );
+      if (mounted) {
+        setState(() {
+          _data = data;
+          _loading = false;
+        });
+      }
+    } catch (e) {
+      if (mounted) {
+        setState(() {
+          _error = e.toString();
+          _loading = false;
+        });
+      }
+    }
+  }
+
+  // ── 日期选择 ──
+  void _pickDate(TextEditingController ctrl) {
     final l10n = AppLocalizations.of(context);
-    final timeLabels = [
-      l10n.get('filterThisMonth'),
-      l10n.get('filterThisQuarter'),
-      l10n.get('filterThisYear'),
-    ];
-    return Container(
-      width: double.infinity,
-      padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
-      color: colors.bgCard,
-      child: Row(
-        children: [
-          ...List.generate(timeLabels.length, (i) {
-            final sel = i == _timeFilterIdx;
-            return Padding(
-              padding: const EdgeInsets.only(right: 8),
-              child: GestureDetector(
-                onTap: () => setState(() => _timeFilterIdx = i),
-                child: Container(
-                  padding: const EdgeInsets.symmetric(
-                    horizontal: 14,
-                    vertical: 6,
-                  ),
-                  decoration: BoxDecoration(
-                    color: sel ? colors.primary : colors.bgPage,
-                    borderRadius: BorderRadius.circular(16),
-                  ),
-                  child: Text(
-                    timeLabels[i],
-                    style: TextStyle(
-                      fontSize: 14,
-                      fontWeight: sel ? FontWeight.w600 : FontWeight.normal,
-                      color: sel ? Colors.white : colors.textSecondary,
-                    ),
-                  ),
-                ),
-              ),
-            );
-          }),
-          const Spacer(),
-          GestureDetector(
-            onTap: _pickCustomDateRange,
-            child: Container(
-              padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
-              decoration: BoxDecoration(
-                border: Border.all(color: colors.border),
-                borderRadius: BorderRadius.circular(16),
-              ),
-              child: Row(
-                mainAxisSize: MainAxisSize.min,
-                children: [
-                  Icon(Icons.date_range, size: 14, color: colors.textSecondary),
-                  const SizedBox(width: 4),
-                  Text(
-                    _customStart != null && _customEnd != null
-                        ? '${_customStart!.month}/${_customStart!.day}-${_customEnd!.month}/${_customEnd!.day}'
-                        : l10n.get('custom'),
-                    style: TextStyle(fontSize: 14, color: colors.textSecondary),
-                  ),
-                ],
-              ),
-            ),
-          ),
-        ],
-      ),
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final now = DateTime.now();
+    TDPicker.showDatePicker(
+      context, title: l10n.get('selectDate'), backgroundColor: colors.bgCard,
+      useYear: true, useMonth: true, useDay: true,
+      useHour: false, useMinute: false, useSecond: false, useWeekDay: false,
+      dateStart: const [2020, 1, 1], dateEnd: [now.year + 1, 12, 31],
+      initialDate: [now.year, now.month, now.day],
+      onConfirm: (selected) {
+        ctrl.text = '${selected['year']}-${selected['month'].toString().padLeft(2, '0')}-${selected['day'].toString().padLeft(2, '0')}';
+        setState(() {});
+        Navigator.of(context).pop();
+      },
     );
   }
 
-  Future<void> _pickCustomDateRange() async {
-    final range = await showDateRangePicker(
-      context: context,
-      firstDate: DateTime(2020),
-      lastDate: DateTime.now(),
-      initialDateRange: _customStart != null && _customEnd != null
-          ? DateTimeRange(start: _customStart!, end: _customEnd!)
-          : null,
+  // ── 日期 chip ──
+  Widget _dateChip(TextEditingController ctrl, String hint, TDThemeData tdTheme, AppColorsExtension colors) {
+    final text = ctrl.text;
+    return Container(
+      height: 36, padding: const EdgeInsets.symmetric(horizontal: 8),
+      decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(18), border: Border.all(color: tdTheme.componentStrokeColor)),
+      child: Row(children: [
+        Icon(Icons.calendar_today, size: 14, color: colors.textSecondary), const SizedBox(width: 4),
+        Expanded(child: Text(text.isNotEmpty ? text : hint, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: text.isNotEmpty ? colors.textPrimary : colors.textSecondary))),
+        if (text.isNotEmpty) GestureDetector(onTap: () { ctrl.clear(); setState(() {}); }, child: Icon(Icons.close, size: 16, color: colors.textSecondary)),
+      ]),
     );
-    if (range != null) {
-      setState(() {
-        _customStart = range.start;
-        _customEnd = range.end;
-        _timeFilterIdx = -1;
-      });
-    }
   }
 
-  // ── 审批状态 + 付款状态 ──
-  Widget _buildStatusFilters() {
+  // ── 日期过滤区 ──
+  Widget _buildDateFilter() {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
-    final statusLabels = [
-      l10n.get('filterAll'),
-      l10n.get('approved'),
-      l10n.get('rejected'),
-    ];
-    final payLabels = [
-      l10n.get('filterAll'),
-      l10n.get('statusWaitPay'),
-      l10n.get('paid'),
-    ];
+    final tdTheme = TDTheme.of(context);
     return Container(
-      width: double.infinity,
-      padding: const EdgeInsets.fromLTRB(12, 4, 12, 12),
-      color: colors.bgCard,
-      child: Column(
-        crossAxisAlignment: CrossAxisAlignment.start,
-        children: [
-          Row(
-            children: [
-              Text(
-                '${l10n.get('filterStatus')}:',
-                style: TextStyle(fontSize: 13, color: colors.textSecondary),
-              ),
-              const SizedBox(width: 4),
-              ...List.generate(statusLabels.length, (i) {
-                final sel = i == _statusFilterIdx;
-                return Padding(
-                  padding: const EdgeInsets.only(right: 6),
-                  child: GestureDetector(
-                    onTap: () => setState(() => _statusFilterIdx = i),
-                    child: Container(
-                      padding: const EdgeInsets.symmetric(
-                        horizontal: 10,
-                        vertical: 4,
-                      ),
-                      decoration: BoxDecoration(
-                        color: sel ? colors.primaryLight : colors.bgPage,
-                        borderRadius: BorderRadius.circular(12),
-                        border: sel ? Border.all(color: colors.primary) : null,
-                      ),
-                      child: Text(
-                        statusLabels[i],
-                        style: TextStyle(
-                          fontSize: 12,
-                          color: sel ? colors.primary : colors.textSecondary,
-                        ),
-                      ),
-                    ),
-                  ),
-                );
-              }),
-            ],
-          ),
-          const SizedBox(height: 6),
-          Row(
-            children: [
-              Text(
-                '${l10n.get('filterPayment')}:',
-                style: TextStyle(fontSize: 13, color: colors.textSecondary),
-              ),
-              const SizedBox(width: 4),
-              ...List.generate(payLabels.length, (i) {
-                final sel = i == _payFilterIdx;
-                return Padding(
-                  padding: const EdgeInsets.only(right: 6),
-                  child: GestureDetector(
-                    onTap: () => setState(() => _payFilterIdx = i),
-                    child: Container(
-                      padding: const EdgeInsets.symmetric(
-                        horizontal: 10,
-                        vertical: 4,
-                      ),
-                      decoration: BoxDecoration(
-                        color: sel ? colors.primaryLight : colors.bgPage,
-                        borderRadius: BorderRadius.circular(12),
-                        border: sel ? Border.all(color: colors.primary) : null,
-                      ),
-                      child: Text(
-                        payLabels[i],
-                        style: TextStyle(
-                          fontSize: 12,
-                          color: sel ? colors.primary : colors.textSecondary,
-                        ),
-                      ),
-                    ),
-                  ),
-                );
-              }),
-            ],
+      decoration: BoxDecoration(
+        color: colors.bgCard,
+        border: Border(bottom: BorderSide(color: tdTheme.componentStrokeColor)),
+      ),
+      child: Padding(
+        padding: const EdgeInsets.fromLTRB(12, 8, 12, 8),
+        child: Row(children: [
+          Expanded(child: GestureDetector(onTap: () => _pickDate(_startCtrl), child: _dateChip(_startCtrl, l10n.get('filterStartDate'), tdTheme, colors))),
+          const SizedBox(width: 8),
+          Text('—', style: TextStyle(fontSize: 14, color: colors.textSecondary)),
+          const SizedBox(width: 8),
+          Expanded(child: GestureDetector(onTap: () => _pickDate(_endCtrl), child: _dateChip(_endCtrl, l10n.get('filterEndDate'), tdTheme, colors))),
+          const SizedBox(width: 8),
+          GestureDetector(
+            onTap: _loadData,
+            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)),
           ),
-        ],
+        ]),
       ),
     );
   }
 
-  // ── 4 张数值卡片 ──
+  // ── 4 张统计卡片 ──
   Widget _buildStatCards() {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
+    final summary = _data?.summary ?? const ReportSummary();
+    final pendingCount = summary.totalCount - summary.approvedCount;
     return Padding(
       padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
       child: Column(
@@ -318,7 +145,7 @@ class _ExpenseDetailReportPageState
               Expanded(
                 child: _statCard(
                   l10n.get('statTotalApproved'),
-                  '¥1,497,000',
+                  '¥${summary.totalAmount.toStringAsFixed(2)}',
                   colors.amountPrimary,
                 ),
               ),
@@ -326,7 +153,7 @@ class _ExpenseDetailReportPageState
               Expanded(
                 child: _statCard(
                   l10n.get('statMonthCount'),
-                  '32 笔',
+                  '${summary.totalCount} 笔',
                   colors.textPrimary,
                 ),
               ),
@@ -338,7 +165,7 @@ class _ExpenseDetailReportPageState
               Expanded(
                 child: _statCard(
                   l10n.get('statPendingApprove'),
-                  '5 笔',
+                  '$pendingCount 笔',
                   colors.textPrimary,
                 ),
               ),
@@ -346,7 +173,7 @@ class _ExpenseDetailReportPageState
               Expanded(
                 child: _statCard(
                   l10n.get('statPendingPayment'),
-                  '8 笔',
+                  '0 笔',
                   colors.textPrimary,
                 ),
               ),
@@ -396,11 +223,6 @@ class _ExpenseDetailReportPageState
   Widget _buildChartSection() {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
-    final role = ref.watch(currentRoleProvider);
-    final isManager = role == 'manager';
-    final title = isManager
-        ? l10n.get('chartDeptExpenseCompare')
-        : l10n.get('chartTitle1');
     return Padding(
       padding: const EdgeInsets.all(12),
       child: Container(
@@ -421,7 +243,7 @@ class _ExpenseDetailReportPageState
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
             Text(
-              title,
+              l10n.get('chartTitle1'),
               style: TextStyle(
                 fontSize: 14,
                 fontWeight: FontWeight.w600,
@@ -439,7 +261,7 @@ class _ExpenseDetailReportPageState
             const SizedBox(height: 12),
             SizedBox(
               height: 200,
-              child: isManager ? _buildDeptBarChart() : _buildDualLineChart(),
+              child: _buildDualLineChart(),
             ),
           ],
         ),
@@ -468,12 +290,34 @@ class _ExpenseDetailReportPageState
 
   Widget _buildDualLineChart() {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final monthly = _data?.monthly ?? [];
+    final months = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
+
+    // 构建 12 个月的点,API 未返回的月份填 0
+    final amountSpots = List.generate(12, (i) {
+      final idx = monthly.indexWhere((m) => m.month == i + 1);
+      return FlSpot(i.toDouble(), idx >= 0 ? monthly[idx].amount : 0);
+    });
+    final approvedSpots = List.generate(12, (i) {
+      final idx = monthly.indexWhere((m) => m.month == i + 1);
+      return FlSpot(i.toDouble(), idx >= 0 ? monthly[idx].approvedAmount : 0);
+    });
+
+    // 计算合理的 Y 轴最大值
+    final allValues = <double>[];
+    for (final m in monthly) {
+      allValues.add(m.amount);
+      allValues.add(m.approvedAmount);
+    }
+    final maxVal = allValues.isEmpty ? 100.0 : allValues.reduce((a, b) => a > b ? a : b);
+    final maxY = maxVal * 1.2;
+
     return LineChart(
       LineChartData(
         gridData: FlGridData(
           show: true,
           drawVerticalLine: false,
-          horizontalInterval: 40,
+          horizontalInterval: maxY > 0 ? maxY / 5 : 20,
           getDrawingHorizontalLine: (v) =>
               FlLine(color: colors.border, strokeWidth: 0.5),
         ),
@@ -481,11 +325,14 @@ class _ExpenseDetailReportPageState
           leftTitles: AxisTitles(
             sideTitles: SideTitles(
               showTitles: true,
-              reservedSize: 40,
-              getTitlesWidget: (v, meta) => Text(
-                '¥${v.toInt()}k',
-                style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
-              ),
+              reservedSize: 50,
+              getTitlesWidget: (v, meta) {
+                final val = v.toInt();
+                return Text(
+                  '¥$val',
+                  style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
+                );
+              },
             ),
           ),
           bottomTitles: AxisTitles(
@@ -495,11 +342,11 @@ class _ExpenseDetailReportPageState
               reservedSize: 26,
               getTitlesWidget: (v, meta) {
                 final i = v.toInt();
-                if (i < 0 || i >= _months.length) return const SizedBox();
+                if (i < 0 || i >= months.length) return const SizedBox();
                 return Padding(
                   padding: const EdgeInsets.only(top: 4),
                   child: Text(
-                    _months[i],
+                    months[i],
                     style: TextStyle(
                       fontSize: 9,
                       color: colors.textPlaceholder,
@@ -518,13 +365,14 @@ class _ExpenseDetailReportPageState
         ),
         borderData: FlBorderData(show: false),
         minY: 0,
-        maxY: 240,
+        maxY: maxY > 0 ? maxY : 100,
         lineTouchData: LineTouchData(
           enabled: true,
           touchTooltipData: LineTouchTooltipData(
             getTooltipItems: (spots) => spots.map((s) {
+              final mi = s.spotIndex;
               return LineTooltipItem(
-                '${_months[s.spotIndex]}\n¥${s.y.toInt()}k',
+                '${months[mi]}\n¥${s.y.toStringAsFixed(0)}',
                 const TextStyle(
                   color: Colors.white,
                   fontSize: 12,
@@ -536,10 +384,7 @@ class _ExpenseDetailReportPageState
         ),
         lineBarsData: [
           LineChartBarData(
-            spots: List.generate(
-              12,
-              (i) => FlSpot(i.toDouble(), _amountExpense[i]),
-            ),
+            spots: amountSpots,
             isCurved: true,
             color: colors.primary,
             barWidth: 2.5,
@@ -558,10 +403,7 @@ class _ExpenseDetailReportPageState
             ),
           ),
           LineChartBarData(
-            spots: List.generate(
-              12,
-              (i) => FlSpot(i.toDouble(), _amountApproved[i]),
-            ),
+            spots: approvedSpots,
             isCurved: true,
             color: colors.success,
             barWidth: 2.5,
@@ -584,244 +426,123 @@ class _ExpenseDetailReportPageState
     );
   }
 
-  Widget _buildDeptBarChart() {
+  @override
+  Widget build(BuildContext context) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    return BarChart(
-      BarChartData(
-        maxY: 350,
-        barTouchData: BarTouchData(
-          enabled: true,
-          touchTooltipData: BarTouchTooltipData(
-            getTooltipItem: (group, groupIndex, rod, rodIndex) {
-              final item = _deptList[group.x.toInt()];
-              return BarTooltipItem(
-                '${item.name}\n${rod.toY.toInt()}k',
-                const TextStyle(color: Colors.white, fontSize: 12),
-              );
-            },
-          ),
-        ),
-        titlesData: FlTitlesData(
-          leftTitles: AxisTitles(
-            sideTitles: SideTitles(
-              showTitles: true,
-              reservedSize: 36,
-              getTitlesWidget: (v, meta) => Text(
-                '${v.toInt()}k',
-                style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
-              ),
-            ),
-          ),
-          bottomTitles: AxisTitles(
-            sideTitles: SideTitles(
-              showTitles: true,
-              reservedSize: 28,
-              getTitlesWidget: (v, meta) {
-                final i = v.toInt();
-                if (i < 0 || i >= _deptList.length) return const SizedBox();
-                return Padding(
-                  padding: const EdgeInsets.only(top: 4),
-                  child: Text(
-                    _deptList[i].name,
-                    style: TextStyle(
-                      fontSize: 10,
-                      color: colors.textPlaceholder,
-                    ),
+    final l10n = AppLocalizations.of(context);
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('reportExpenseDetail'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
+    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)),
+                        ),
+                      ),
+                    ],
                   ),
-                );
-              },
-            ),
-          ),
-          topTitles: const AxisTitles(
-            sideTitles: SideTitles(showTitles: false),
-          ),
-          rightTitles: const AxisTitles(
-            sideTitles: SideTitles(showTitles: false),
-          ),
-        ),
-        borderData: FlBorderData(show: false),
-        gridData: FlGridData(
-          show: true,
-          drawVerticalLine: false,
-          horizontalInterval: 100,
-          getDrawingHorizontalLine: (v) =>
-              FlLine(color: colors.border, strokeWidth: 0.5),
-        ),
-        barGroups: List.generate(_deptList.length, (i) {
-          return BarChartGroupData(
-            x: i,
-            barRods: [
-              BarChartRodData(
-                toY: _deptList[i].expense,
-                color: colors.primary,
-                width: 16,
-                borderRadius: const BorderRadius.vertical(
-                  top: Radius.circular(3),
                 ),
-              ),
-              BarChartRodData(
-                toY: _deptList[i].approved,
-                color: colors.success,
-                width: 16,
-                borderRadius: const BorderRadius.vertical(
-                  top: Radius.circular(3),
-                ),
-              ),
+              )
+            else ...[
+              _buildStatCards(),
+              _buildChartSection(),
+              _buildDetailList(),
             ],
-          );
-        }),
+            const SizedBox(height: 80),
+          ],
+        ),
       ),
     );
   }
 
-  // ── 部门明细列表 ──
-  Widget _buildDeptListSection() {
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+  // ── 明细列表 ──
+  Widget _buildDetailList() {
+    final details = _data?.details ?? [];
+    if (details.isEmpty) return const SizedBox.shrink();
     final l10n = AppLocalizations.of(context);
-    final role = ref.watch(currentRoleProvider);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final headerStyle = TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: colors.textSecondary);
+    final rowStyle = TextStyle(fontSize: 13, color: colors.textPrimary);
+    final amountStyle = TextStyle(fontSize: 13, color: colors.amountPrimary, fontWeight: FontWeight.w600);
     return Padding(
-      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
+      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
       child: Container(
         width: double.infinity,
-        decoration: BoxDecoration(
-          color: colors.bgCard,
-          borderRadius: BorderRadius.circular(8),
-          boxShadow: const [
-            BoxShadow(
-              color: Color(0x08000000),
-              blurRadius: 4,
-              offset: Offset(0, 1),
-            ),
-          ],
-        ),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            Padding(
-              padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
-              child: Row(
-                children: [
-                  Text(
-                    l10n.get('deptDashboard'),
-                    style: TextStyle(
-                      fontSize: 14,
-                      fontWeight: FontWeight.w600,
-                      color: colors.textPrimary,
-                    ),
-                  ),
-                  if (role == 'manager')
-                    Text(
-                      l10n.get('clickChartToFilter'),
-                      style: TextStyle(
-                        fontSize: 11,
-                        color: colors.textPlaceholder,
-                      ),
-                    ),
-                ],
-              ),
-            ),
-            Container(
+        decoration: BoxDecoration(color: colors.bgCard, borderRadius: BorderRadius.circular(8)),
+        child: Column(children: [
+          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)),
+          ])),
+          Container(
+            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+            color: colors.bgDisabled,
+            child: Row(children: [
+              Expanded(flex: 3, child: Text(l10n.get('expenseNo'), style: headerStyle)),
+              const SizedBox(width: 8),
+              SizedBox(width: 80, child: Text(l10n.get('date'), style: headerStyle)),
+              const SizedBox(width: 8),
+              SizedBox(width: 80, child: Text(l10n.get('expenseAmount'), textAlign: TextAlign.right, style: headerStyle)),
+              const SizedBox(width: 8),
+              SizedBox(width: 60, child: Text(l10n.get('filterStatus'), textAlign: TextAlign.center, style: headerStyle)),
+            ]),
+          ),
+          ...details.map((d) {
+            final approved = d.isApproved;
+            final chipColor = approved ? colors.success : colors.warning;
+            return Padding(
               padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
-              decoration: BoxDecoration(
-                color: colors.bgDisabled,
-              ),
-              child: Row(
-                children: [
-                  SizedBox(
-                    width: 100,
-                    child: Text(
-                      l10n.get('creator'),
-                      style: TextStyle(
-                        fontSize: 12,
-                        fontWeight: FontWeight.w600,
-                        color: colors.textSecondary,
-                      ),
-                    ),
-                  ),
-                  Expanded(
-                    child: Text(
-                      l10n.get('expenseAmount'),
-                      textAlign: TextAlign.right,
-                      style: TextStyle(
-                        fontSize: 12,
-                        fontWeight: FontWeight.w600,
-                        color: colors.textSecondary,
-                      ),
-                    ),
+              child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
+                Expanded(flex: 3, child: Text(d.billNo, style: rowStyle, maxLines: 2, overflow: TextOverflow.ellipsis)),
+                const SizedBox(width: 8),
+                SizedBox(width: 80, child: Text(d.billDate != null && d.billDate!.length >= 10 ? d.billDate!.substring(0, 10) : '-', style: rowStyle, maxLines: 2)),
+                const SizedBox(width: 8),
+                SizedBox(width: 80, child: Text('¥${d.amount.toStringAsFixed(2)}', textAlign: TextAlign.right, style: amountStyle, maxLines: 1, overflow: TextOverflow.ellipsis)),
+                const SizedBox(width: 8),
+                SizedBox(width: 60, child: Container(
+                  padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+                  decoration: BoxDecoration(
+                    color: chipColor.withValues(alpha: 0.1),
+                    borderRadius: BorderRadius.circular(10),
+                    border: Border.all(color: chipColor, width: 0.5),
                   ),
-                  const SizedBox(width: 16),
-                  Expanded(
-                    child: Text(
-                      l10n.get('approvedAmount'),
-                      textAlign: TextAlign.right,
-                      style: TextStyle(
-                        fontSize: 12,
-                        fontWeight: FontWeight.w600,
-                        color: colors.textSecondary,
-                      ),
-                    ),
+                  child: Text(
+                    approved ? l10n.get('approved') : l10n.get('pending'),
+                    textAlign: TextAlign.center,
+                    style: TextStyle(fontSize: 11, color: chipColor, fontWeight: FontWeight.w500),
                   ),
-                ],
-              ),
-            ),
-            ..._deptList.map(
-              (d) => Container(
-                padding: const EdgeInsets.symmetric(
-                  horizontal: 16,
-                  vertical: 10,
-                ),
-                child: Row(
-                  children: [
-                    SizedBox(
-                      width: 100,
-                      child: Text(
-                        d.name,
-                        style: TextStyle(
-                          fontSize: 13,
-                          color: colors.textPrimary,
-                        ),
-                      ),
-                    ),
-                    Expanded(
-                      child: Text(
-                        '¥${d.expense.toStringAsFixed(1)}k',
-                        textAlign: TextAlign.right,
-                        style: TextStyle(
-                          fontSize: 13,
-                          color: colors.textPrimary,
-                        ),
-                      ),
-                    ),
-                    const SizedBox(width: 16),
-                    Expanded(
-                      child: Text(
-                        '¥${d.approved.toStringAsFixed(1)}k',
-                        textAlign: TextAlign.right,
-                        style: TextStyle(
-                          fontSize: 13,
-                          color: colors.textPrimary,
-                        ),
-                      ),
-                    ),
-                  ],
-                ),
-              ),
-            ),
-            const SizedBox(height: 8),
-          ],
-        ),
+                )),
+              ]),
+            );
+          }),
+          const SizedBox(height: 8),
+        ]),
       ),
     );
   }
 }
-
-class _DeptItem {
-  final String name;
-  final double expense;
-  final double approved;
-  const _DeptItem({
-    required this.name,
-    required this.expense,
-    required this.approved,
-  });
-}

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

@@ -79,9 +79,7 @@ class AppScaffold extends ConsumerWidget {
     if (path.startsWith('/announcement/detail')) return l10n.get('announcementDetail');
     if (path.startsWith('/announcement/create')) return l10n.get('announcementCreate');
 
-    // ── 报表 ──
-    if (path.startsWith('/report/expense-apply')) return l10n.get('expenseApplyReport');
-    if (path.startsWith('/report/expense')) return l10n.get('expenseReport');
+    // ── 报表 ──(标题由页面自行设置)
     if (path.startsWith('/report/overtime')) return l10n.get('overtimeReport');
     if (path.startsWith('/report/vehicle')) return l10n.get('vehicleReport');
     if (path.startsWith('/report/outing-log')) return l10n.get('outingLogReport');

+ 129 - 119
lib/shared/widgets/skeleton_list_card.dart

@@ -23,30 +23,32 @@ class SkeletonListCard extends StatelessWidget {
         color: colors.bgCard,
         borderRadius: BorderRadius.circular(8),
       ),
-      child: 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),
-            ],
-            [
-              TDSkeletonRowColObj.text(width: 100, height: 14, flex: 0),
-              TDSkeletonRowColObj.spacer(flex: 1),
-              TDSkeletonRowColObj.rect(width: 48, height: 20, flex: 0),
+      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),
+              ],
+              [
+                TDSkeletonRowColObj.text(width: 100, height: 14, flex: 0),
+                TDSkeletonRowColObj.spacer(flex: 1),
+                TDSkeletonRowColObj.rect(width: 48, height: 20, flex: 0),
+              ],
             ],
-          ],
-          style: TDSkeletonRowColStyle(rowSpacing: (_) => 6),
+            style: TDSkeletonRowColStyle(rowSpacing: (_) => 6),
+          ),
         ),
-      ),
+      ]),
     );
   }
 }
@@ -73,34 +75,36 @@ class SkeletonVehicleCard extends StatelessWidget {
         color: colors.bgCard,
         borderRadius: BorderRadius.circular(8),
       ),
-      child: 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),
-            ],
-            // R2: applicationNo + purpose tag
-            [
-              TDSkeletonRowColObj.text(width: 140, height: 14, flex: 0),
-              TDSkeletonRowColObj.spacer(flex: 1),
-              TDSkeletonRowColObj.rect(width: 40, height: 16, flex: 0),
-            ],
-            // R3: route + date
-            [
-              TDSkeletonRowColObj.text(height: 15, flex: 2),
-              TDSkeletonRowColObj.spacer(flex: 1),
-              TDSkeletonRowColObj.text(width: 120, height: 14, flex: 0),
+      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),
+              ],
+              // R2: applicationNo + purpose tag
+              [
+                TDSkeletonRowColObj.text(width: 140, height: 14, flex: 0),
+                TDSkeletonRowColObj.spacer(flex: 1),
+                TDSkeletonRowColObj.rect(width: 40, height: 16, flex: 0),
+              ],
+              // R3: route + date
+              [
+                TDSkeletonRowColObj.text(height: 15, flex: 2),
+                TDSkeletonRowColObj.spacer(flex: 1),
+                TDSkeletonRowColObj.text(width: 120, height: 14, flex: 0),
+              ],
             ],
-          ],
-          style: TDSkeletonRowColStyle(
-            rowSpacing: (_) => 6,
+            style: TDSkeletonRowColStyle(
+              rowSpacing: (_) => 6,
+            ),
           ),
         ),
-      ),
+      ]),
     );
   }
 }
@@ -129,30 +133,32 @@ class SkeletonOutingLogCard extends StatelessWidget {
         color: colors.bgCard,
         borderRadius: BorderRadius.circular(8),
       ),
-      child: 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),
-            ],
-            // 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),
+      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),
+              ],
+              // 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),
+              ],
             ],
-          ],
-          style: TDSkeletonRowColStyle(rowSpacing: (_) => 4),
+            style: TDSkeletonRowColStyle(rowSpacing: (_) => 4),
+          ),
         ),
-      ),
+      ]),
     );
   }
 }
@@ -177,24 +183,26 @@ class SkeletonAnnouncementCard extends StatelessWidget {
         color: colors.bgCard,
         borderRadius: BorderRadius.circular(8),
       ),
-      child: 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),
+      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),
+              ],
             ],
-          ],
-          style: TDSkeletonRowColStyle(rowSpacing: (_) => 8),
+            style: TDSkeletonRowColStyle(rowSpacing: (_) => 8),
+          ),
         ),
-      ),
+      ]),
     );
   }
 }
@@ -221,42 +229,44 @@ class SkeletonImportCard extends StatelessWidget {
         color: colors.bgCard,
         borderRadius: BorderRadius.circular(12),
       ),
-      child: 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),
-            ],
-            // R6: detail sub-text
-            [
-              TDSkeletonRowColObj.spacer(width: 54),
-              TDSkeletonRowColObj.text(height: 14, flex: 2),
+      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),
+              ],
+              // R6: detail sub-text
+              [
+                TDSkeletonRowColObj.spacer(width: 54),
+                TDSkeletonRowColObj.text(height: 14, flex: 2),
+              ],
             ],
-          ],
-          style: TDSkeletonRowColStyle(rowSpacing: (_) => 8),
+            style: TDSkeletonRowColStyle(rowSpacing: (_) => 8),
+          ),
         ),
-      ),
+      ]),
     );
   }
 }