chengc преди 1 седмица
родител
ревизия
8f48882f48

+ 9 - 1
assets/i18n/en.json

@@ -321,6 +321,8 @@
     "estimatedEndDate": "Estimated End Date",
     "estimatedDate": "Estimated Date",
     "detailList": "Detail List",
+    "auditTrail": "Audit Trail",
+    "bodyDetailList": "Body Details",
     "items": "items",
     "selectEstimatedStartDate": "Please select estimated start date",
     "selectEstimatedEndDate": "Please select estimated end date",
@@ -382,6 +384,10 @@
     "fileTooLarge": "File size exceeds limit",
     "imageSizeLimit": "Image must be ≤${max}MB",
     "fileSizeLimit": "File must be ≤${max}MB",
+    "fileLimitHint": "At most ${limit} file(s) can be selected",
+    "dateRangeLimit": "Date range cannot exceed 12 months",
+    "selectDateRange": "Please select start and end dates",
+    "endDateBeforeStart": "End date cannot be earlier than start date",
     "addExpenseDetailFirst": "Please add expense items in the details"
   },
   "expense": {
@@ -702,11 +708,13 @@
   },
   "report": {
     "reports": "Reports",
-    "chartTitle1": "Reimbursement vs Approved Amount (Last 12 Months)",
+    "chartTitle1": "Expense & Approved Amount Trend",
     "chartTitle2": "Overtime Hours Trend (Last 12 Months)",
     "chartTitle3": "Vehicle Trips vs Total Cost (Last 12 Months)",
     "chartTitle4": "Monthly Trend",
     "chartTitle5": "Visits vs Avg Rating (Last 12 Months)",
+    "chartTitle6": "Apply Amount Trend",
+    "chartTitle8": "Personnel Expense Comparison",
     "chartDesc1": "Dual line comparison chart",
     "chartDesc2": "Bar trend chart",
     "chartDesc3": "Dual-axis line chart",

+ 9 - 1
assets/i18n/zh_CN.json

@@ -321,6 +321,8 @@
     "estimatedEndDate": "预计结束日期",
     "estimatedDate": "预计日期",
     "detailList": "明细列表",
+    "auditTrail": "审核过程",
+    "bodyDetailList": "表身明细",
     "items": "项",
     "selectEstimatedStartDate": "请选择预计开始日期",
     "selectEstimatedEndDate": "请选择预计结束日期",
@@ -382,6 +384,10 @@
     "fileTooLarge": "文件大小超过限制",
     "imageSizeLimit": "图片大小不能超过${max}MB",
     "fileSizeLimit": "文件大小不能超过${max}MB",
+    "fileLimitHint": "最多还能选择${limit}个文件",
+    "dateRangeLimit": "查询日期范围不能超过12个月",
+    "selectDateRange": "请选择开始和结束日期",
+    "endDateBeforeStart": "截止日期不能早于开始日期",
     "addExpenseDetailFirst": "请在明细中添加费用项"
   },
   "expense": {
@@ -702,11 +708,13 @@
   },
   "report": {
     "reports": "报表",
-    "chartTitle1": "近12个月报销金额 vs 审批通过金额",
+    "chartTitle1": "报销金额与核准金额趋势",
     "chartTitle2": "近12个月加班工时趋势",
     "chartTitle3": "近12个月用车次数 vs 累计费用",
     "chartTitle4": "近12个月申请金额 vs 已通过金额",
     "chartTitle5": "近12个月拜访次数 vs 平均评分",
+    "chartTitle6": "申请金额趋势",
+    "chartTitle8": "人员报销金额对比",
     "chartDesc1": "双折线对比图",
     "chartDesc2": "柱状趋势图",
     "chartDesc3": "双轴折线图",

+ 9 - 1
assets/i18n/zh_TW.json

@@ -321,6 +321,8 @@
     "estimatedEndDate": "預計結束日期",
     "estimatedDate": "預計日期",
     "detailList": "明細列表",
+    "auditTrail": "審核過程",
+    "bodyDetailList": "表身明細",
     "items": "項",
     "selectEstimatedStartDate": "請選擇預計開始日期",
     "selectEstimatedEndDate": "請選擇預計結束日期",
@@ -382,6 +384,10 @@
     "fileTooLarge": "文件大小超過限制",
     "imageSizeLimit": "圖片大小不能超過${max}MB",
     "fileSizeLimit": "文件大小不能超過${max}MB",
+    "fileLimitHint": "最多還能選擇${limit}個文件",
+    "dateRangeLimit": "查詢日期範圍不能超過12個月",
+    "selectDateRange": "請選擇開始和結束日期",
+    "endDateBeforeStart": "截止日期不能早於開始日期",
     "addExpenseDetailFirst": "請在明細中添加費用項"
   },
   "expense": {
@@ -702,11 +708,13 @@
   },
   "report": {
     "reports": "報表",
-    "chartTitle1": "近12個月報销金額 vs 審批通過金額",
+    "chartTitle1": "報銷金額與核准金額趨勢",
     "chartTitle2": "近12個月加班工時趋势",
     "chartTitle3": "近12個月用車次數 vs 累計費用",
     "chartTitle4": "近12個月申請金額 vs 已通過金額",
     "chartTitle5": "近12個月拜訪次數 vs 平均評分",
+    "chartTitle6": "申請金額趨勢",
+    "chartTitle8": "人員報銷金額對比",
     "chartDesc1": "双折線对比圖",
     "chartDesc2": "柱狀趋势圖",
     "chartDesc3": "双轴折線圖",

+ 1 - 1
lib/core/data/api_cache.dart

@@ -7,7 +7,7 @@ class ApiCache {
   final String? _keyPrefix;
   final Map<String, _CacheEntry> _store = {};
 
-  ApiCache({this.ttlSeconds = 120, String? keyPrefix}) : _keyPrefix = keyPrefix;
+  ApiCache({this.ttlSeconds = 60, String? keyPrefix}) : _keyPrefix = keyPrefix;
 
   String _fullKey(String key) =>
       _keyPrefix != null ? '${_keyPrefix}_$key' : key;

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

@@ -19,6 +19,7 @@ class HostAppChannel {
   static String? _baseUrl;
   static String? _sn;
   static String? _loginId;
+  static String? _compNo;
   static String? _dep;
   static String? _depName;
   static String? _usr;
@@ -28,6 +29,7 @@ class HostAppChannel {
   static String get baseUrl => _baseUrl ?? '';
   static String get sn => _sn ?? '';
   static String get loginId => _loginId ?? '';
+  static String get compNo => _compNo ?? '';
   static String get dep => _dep ?? '';
   static String get depName => _depName ?? '';
   static String get usr => _usr ?? '';
@@ -57,6 +59,7 @@ class HostAppChannel {
         _baseUrl = result['baseUrl'] as String?;
         _sn = result['sn'] as String?;
         _loginId = result['loginId'] as String?;
+        _compNo = result['compNo'] as String?;
         _dep = result['dep'] as String?;
         _depName = result['depName'] as String?;
         _usr = result['usr'] as String?;
@@ -118,6 +121,7 @@ class HostAppChannel {
         _baseUrl = result['baseUrl'] as String? ?? _baseUrl;
         _sn = result['sn'] as String? ?? _sn;
         _loginId = result['loginId'] as String? ?? _loginId;
+        _compNo = result['compNo'] as String? ?? _compNo;
         _dep = result['dep'] as String? ?? _dep;
         _depName = result['depName'] as String? ?? _depName;
         _usr = result['usr'] as String? ?? _usr;

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

@@ -134,7 +134,7 @@ class EmployeeItem {
 
 class ExpenseApi {
   final ApiClient _client;
-  final _cache = ApiCache(keyPrefix: HostAppChannel.sn);
+  final _cache = ApiCache(keyPrefix: '${HostAppChannel.sn}_${HostAppChannel.compNo}_${HostAppChannel.usr}');
   ExpenseApi(this._client);
 
   /// 清除所有基础资料缓存(picker 下拉刷新时调用)。
@@ -579,6 +579,57 @@ class ExpenseApi {
     return response.data!;
   }
 
+  /// 下属报销金额对比
+  Future<List<SubordinateReportItem>> getExpenseSubordinateReport({
+    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<dynamic>(
+      '/OA/GetExpenseSubordinateReport',
+      queryParameters: params,
+    );
+    final list = (response.data as List<dynamic>?)
+            ?.map((e) => SubordinateReportItem.fromJson(e as Map<String, dynamic>))
+            .toList() ??
+        [];
+    return list;
+  }
+
+  /// 费用报销单表身明细(分页)
+  Future<Map<String, dynamic>> getExpenseBodyReport({
+    String? startDate,
+    String? endDate,
+    int page = 1,
+    int size = 20,
+  }) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetExpenseBodyReport',
+      queryParameters: {
+        if (startDate != null) 'startDate': startDate,
+        if (endDate != null) 'endDate': endDate,
+        'page': page,
+        'size': size,
+      },
+    );
+    return response.data!;
+  }
+
+  /// 审核过程明细
+  Future<List<Map<String, dynamic>>> getAuditTrail(String billId, String billNo) async {
+    final response = await _client.get('/OA/GetAuditTrail', queryParameters: {'billId': billId, 'billNo': billNo});
+    final list = (response.data as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
+    return list;
+  }
+
+  /// 会计科目树(级联选择器数据源)
+  Future<dynamic> getAcctSubjects() async {
+    final response = await _client.get('/OA/GetAcctSubjects');
+    return response.data;
+  }
+
   /// 获取单据状态
   Future<Map<String, dynamic>> getBillStatus(String billNo) async {
     final response = await _client.get<Map<String, dynamic>>(

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

@@ -54,6 +54,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
   List<CurrencyItem> _currencies = [];
   List<EmployeeItem> _employees = [];
   EmployeeItem? _selEmployee;
+  dynamic _acctTree;
   bool _firstBuild = true;
   bool _refDataLoading = true;
   bool _addingDetail = false;
@@ -116,6 +117,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
         api.getDepartments(),
         api.getCurrencies(),
         api.getEmployees(),
+        api.getAcctSubjects(),
       ]);
       if (!mounted) {
         completer.complete();
@@ -125,6 +127,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
         _departments = results[0] as List<DepartmentItem>;
         _currencies = results[1] as List<CurrencyItem>;
         _employees = results[2] as List<EmployeeItem>;
+        _acctTree = _convertAcctTree(results[3]);
         _refDataLoading = false;
         _autoSelectDept();
         _autoSelectEmployee();
@@ -308,6 +311,17 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
     return match.isNotEmpty ? '${match.first.curId}/${match.first.name}' : code;
   }
 
+  List<Map<String, dynamic>> _convertAcctTree(dynamic tree) {
+    if (tree is! List) return [];
+    return (tree as List).map<Map<String, dynamic>>((item) {
+      final map = Map<String, dynamic>.from(item is Map ? item : {});
+      if (map.containsKey('children') && map['children'] != null) {
+        map['children'] = _convertAcctTree(map['children']);
+      }
+      return map;
+    }).toList();
+  }
+
   void _autoSelectDept() {
     if (_selectedDeptId.isNotEmpty) return;
     final dep = HostAppChannel.dep;
@@ -1199,6 +1213,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
             ref.read(expenseApiProvider).checkAttachHealth(),
         canEditApprovedAmount: _canEditApprovedAmount,
         billFileRights: _billFileRights,
+        acctTree: _acctTree,
       );
       if (result != null && mounted) {
         final now = DateTime.now();

+ 75 - 68
lib/features/expense/expense_detail_page.dart

@@ -10,7 +10,7 @@ import '../../shared/widgets/form_field_row.dart';
 import '../../shared/widgets/app_skeletons.dart';
 import '../../shared/widgets/attachment_download_helper.dart';
 import '../../shared/widgets/action_bar.dart';
-import '../../shared/widgets/status_banner.dart';
+import '../../shared/widgets/audit_trail_dialog.dart';
 import 'expense_model.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../shared/models/bill_attachment.dart';
@@ -156,26 +156,6 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
             padding: const EdgeInsets.all(16),
             child: Column(
               children: [
-                if (_statusLoading) ...[
-                  const Padding(
-                    padding: EdgeInsets.symmetric(vertical: 8),
-                    child: Center(
-                      child: TDLoading(
-                        size: TDLoadingSize.small,
-                        icon: TDLoadingIcon.activity,
-                      ),
-                    ),
-                  ),
-                ],
-                Builder(
-                  builder: (ctx) {
-                    final badge = _buildStatusBadge(l10n);
-                    if (badge == null) return const SizedBox.shrink();
-                    return Column(
-                      children: [badge, const SizedBox(height: 16)],
-                    );
-                  },
-                ),
                 _buildBasicInfoSection(expense, l10n, colors),
                 const SizedBox(height: 16),
                 _buildExpenseDetailSection(expense, l10n, colors),
@@ -187,7 +167,8 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
             ),
           ),
         ),
-        if (_canEdit)
+        // TODO: 等 ERP 提供"能否修改单据"接口后,恢复为 if (_canEdit)
+        if (false)
           ActionBar(
             showLeft: false,
             showCenter: false,
@@ -203,60 +184,85 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
     );
   }
 
-  // ═══ 状态 badge ═══
-  Widget? _buildStatusBadge(AppLocalizations l10n) {
+  // ═══ 状态 tag(标题行右侧) ═══
+  Widget? _buildStatusTag(AppLocalizations l10n) {
     final isClosed = _billStatus?['isClosed'] == true;
     final isTransferred = _billStatus?['isTransferred'] == true;
     final approvalStatus = _billStatus?['approvalStatus'] as String?;
+    final approvalText = _billStatus?['approvalText'] as String? ?? '';
+
+    IconData icon;
+    String text;
+    Color color;
 
     if (isClosed) {
-      return StatusBanner(
-        icon: Icons.lock_outline,
-        statusText: l10n.get('statusClosed'),
-        subText: '',
-        color: Colors.blueGrey,
-      );
+      icon = Icons.lock_outline;
+      text = l10n.get('statusClosed');
+      color = Colors.blue;
+    } else if (isTransferred) {
+      icon = Icons.swap_horiz;
+      text = l10n.get('statusTransferred');
+      color = Colors.blue;
+    } else if (approvalStatus == null || approvalStatus.isEmpty) {
+      return null;
+    } else {
+      switch (approvalStatus) {
+        case 'SHCOUNT0':
+          icon = Icons.edit_note;
+          color = Colors.teal;
+          break;
+        case 'SHCOUNT1':
+          icon = Icons.hourglass_empty;
+          color = Colors.blueGrey;
+          break;
+        case 'SHCOUNT2':
+          icon = Icons.cancel_outlined;
+          color = Colors.red;
+          break;
+        case 'SHCOUNT3':
+          icon = Icons.undo;
+          color = Colors.deepOrange;
+          break;
+        case 'SHCOUNT4':
+          icon = Icons.check_circle_outline;
+          color = Colors.green;
+          break;
+        default:
+          return null;
+      }
+      text = approvalText;
     }
-    if (isTransferred) {
-      return StatusBanner(
-        icon: Icons.swap_horiz,
-        statusText: l10n.get('statusTransferred'),
-        subText: '',
-        color: Colors.blueGrey,
+
+    final tag = Container(
+      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+      decoration: BoxDecoration(
+        color: color.withValues(alpha: 0.1),
+        borderRadius: BorderRadius.circular(6),
+        border: Border.all(color: color.withValues(alpha: 0.3), width: 0.5),
+      ),
+      child: Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Icon(icon, size: 18, color: color),
+          const SizedBox(width: 4),
+          Text(text, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: color)),
+        ],
+      ),
+    );
+    final canTap = approvalStatus != null && approvalStatus.isNotEmpty && approvalText.isNotEmpty;
+    if (canTap) {
+      return GestureDetector(
+        onTap: () => _showAuditTrail('BX'),
+        child: tag,
       );
     }
-    switch (approvalStatus) {
-      case 'SHCOUNT4':
-        return StatusBanner(
-          icon: Icons.check_circle_outline,
-          statusText: l10n.get('statusApproved'),
-          subText: '',
-          color: Colors.green,
-        );
-      case 'SHCOUNT5':
-        return StatusBanner(
-          icon: Icons.cancel_outlined,
-          statusText: l10n.get('statusFinalRejected'),
-          subText: '',
-          color: Colors.red,
-        );
-      case 'SHCOUNT1':
-        return StatusBanner(
-          icon: Icons.hourglass_empty,
-          statusText: l10n.get('statusPendingReview'),
-          subText: '',
-          color: Colors.blue,
-        );
-      case 'SHCOUNT2':
-        return StatusBanner(
-          icon: Icons.block_outlined,
-          statusText: l10n.get('statusReviewRejected'),
-          subText: '',
-          color: Colors.deepOrange,
-        );
-      default:
-        return null;
-    }
+    return tag;
+  }
+
+  Future<void> _showAuditTrail(String billId) async {
+    final api = ref.read(expenseApiProvider);
+    final billNo = widget.billNo;
+    await AuditTrailDialog.show(context, fetcher: () => api.getAuditTrail(billId, billNo));
   }
 
   // ═══ 基本信息 ═══
@@ -268,6 +274,7 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
     return FormSection(
       title: l10n.get('basicInfo'),
       leadingIcon: Icons.info_outline,
+      trailing: _buildStatusTag(l10n),
       children: [
         FormFieldRow(
           label: l10n.get('expenseNo'),

+ 15 - 0
lib/features/expense/expense_edit_page.dart

@@ -54,6 +54,7 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
   // ── 参考数据(从 API 加载) ──
   List<CurrencyItem> _currencies = [];
   EmployeeItem? _selEmployee;
+  dynamic _acctTree;
   bool _firstBuild = true;
   bool _refDataLoading = true;
   bool _addingDetail = false;
@@ -101,11 +102,13 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
       final results = await Future.wait([
         api.getCurrencies(),
         api.fetchDetail(widget.billNo),
+        api.getAcctSubjects(),
       ]);
       if (!mounted) return;
       final expense = results[1] as ExpenseModel;
       setState(() {
         _currencies = results[0] as List<CurrencyItem>;
+        _acctTree = _convertAcctTree(results[2]);
         _expense = expense;
         _expenseDate = expense.expenseDate != null
             ? du.DateUtils.formatDate(expense.expenseDate!)
@@ -239,6 +242,17 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
     _recalculateAmount();
   }
 
+  List<Map<String, dynamic>> _convertAcctTree(dynamic tree) {
+    if (tree is! List) return [];
+    return (tree as List).map<Map<String, dynamic>>((item) {
+      final map = Map<String, dynamic>.from(item is Map ? item : {});
+      if (map.containsKey('children') && map['children'] != null) {
+        map['children'] = _convertAcctTree(map['children']);
+      }
+      return map;
+    }).toList();
+  }
+
   void _recalculateAmount() {
     if (_expense == null) return;
     var totalAmount = 0.0;
@@ -874,6 +888,7 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
             ref.read(expenseApiProvider).checkAttachHealth(),
         showAttachments: false,
         canEditApprovedAmount: _canEditApprovedAmount,
+        acctTree: _acctTree,
       );
       if (result != null && mounted) {
         final now = DateTime.now();

+ 45 - 2
lib/features/expense/expense_list_page.dart

@@ -30,6 +30,9 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage> {
   bool _firstBuild = true;
   bool _invalidateDone = false;
 
+  final _scrollCtrl = ScrollController();
+  bool _showBackToTop = false;
+
   @override
   void initState() {
     super.initState();
@@ -39,6 +42,12 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage> {
     _endDateCtrl.text =
         '${now.year}-${now.month.toString().padLeft(2, '0')}-${_daysInMonth(now.year, now.month).toString().padLeft(2, '0')}';
     _refreshCtrl = EasyRefreshController();
+    _scrollCtrl.addListener(() {
+      final show = _scrollCtrl.hasClients && _scrollCtrl.offset > 300;
+      if (show != _showBackToTop && mounted) {
+        setState(() => _showBackToTop = show);
+      }
+    });
     WidgetsBinding.instance.addPostFrameCallback((_) {
       ref.read(expenseDateStartProvider.notifier).state = DateTime(
         now.year,
@@ -61,6 +70,7 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage> {
     _startDateCtrl.dispose();
     _endDateCtrl.dispose();
     _refreshCtrl.dispose();
+    _scrollCtrl.dispose();
     super.dispose();
   }
 
@@ -287,11 +297,40 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage> {
                     : _ExpenseListContent(
                         refreshCtrl: _refreshCtrl,
                         onRefresh: _doRefresh,
+                        scrollCtrl: _scrollCtrl,
                       ),
               ),
             ),
           ],
         ),
+        AnimatedPositioned(
+          duration: const Duration(milliseconds: 200),
+          bottom: _showBackToTop ? 80 : -60,
+          left: 0,
+          right: 0,
+          child: Center(
+            child: GestureDetector(
+              onTap: () {
+                if (_scrollCtrl.hasClients) {
+                  _scrollCtrl.jumpTo(0);
+                }
+              },
+              child: AnimatedOpacity(
+                duration: const Duration(milliseconds: 200),
+                opacity: _showBackToTop ? 1.0 : 0.0,
+                child: Container(
+                  width: 36, height: 36,
+                  decoration: BoxDecoration(
+                    color: colors.primary400,
+                    shape: BoxShape.circle,
+                    boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.15), blurRadius: 6, offset: const Offset(0, 2))],
+                  ),
+                  child: const Icon(Icons.keyboard_arrow_up, color: Colors.white, size: 22),
+                ),
+              ),
+            ),
+          ),
+        ),
         Positioned(
           right: 16,
           bottom: 16,
@@ -310,9 +349,11 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage> {
 class _ExpenseListContent extends ConsumerWidget {
   final EasyRefreshController refreshCtrl;
   final Future<void> Function() onRefresh;
+  final ScrollController scrollCtrl;
   const _ExpenseListContent({
     required this.refreshCtrl,
     required this.onRefresh,
+    required this.scrollCtrl,
   });
 
   @override
@@ -357,6 +398,7 @@ class _ExpenseListContent extends ConsumerWidget {
         );
       }
       return ListView.builder(
+        controller: scrollCtrl,
         padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
         itemCount: oldItems.length,
         itemBuilder: (_, i) {
@@ -403,6 +445,7 @@ class _ExpenseListContent extends ConsumerWidget {
       );
     }
     return ListView.builder(
+      controller: scrollCtrl,
       padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
       itemCount: items.length + 1,
       itemBuilder: (_, i) {
@@ -458,12 +501,12 @@ class _ExpenseListContent extends ConsumerWidget {
         Container(
           padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
           decoration: BoxDecoration(
-            color: colors.warning.withValues(alpha: 0.1),
+            color: colors.primary.withValues(alpha: 0.1),
             borderRadius: BorderRadius.circular(4),
           ),
           child: Text(
             l10n.get('statusClosed'),
-            style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.warning),
+            style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.primary),
           ),
         ),
       );

+ 1 - 1
lib/features/expense/expense_model.dart

@@ -99,7 +99,7 @@ class ExpenseModel {
       deptId: json['dept'] as String? ?? '',
       deptName: json['deptName'] as String? ?? json['dept'] as String? ?? '',
       currencyCode: json['curId'] as String? ?? '',
-      isGenerateVoucher: (json['vohId'] as String?) == 'T',
+      isGenerateVoucher: (json['vohId'] as String?) == 'T' || json['isGenerateVoucher'] == true,
       voucherNo: json['vohNo'] as String? ?? '',
       approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0.0,
       totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0.0,

+ 100 - 11
lib/features/expense/widgets/expense_detail_dialog.dart

@@ -79,6 +79,7 @@ class ExpenseDetailDialog extends StatefulWidget {
   final bool showAttachments;
   final bool canEditApprovedAmount;
   final BillFileRights? billFileRights;
+  final dynamic acctTree;
 
   const ExpenseDetailDialog({
     super.key,
@@ -89,6 +90,7 @@ class ExpenseDetailDialog extends StatefulWidget {
     this.showAttachments = true,
     this.canEditApprovedAmount = false,
     this.billFileRights,
+    required this.acctTree,
   });
 
   /// 显示弹窗,返回 [ExpenseDetailInputData] 或 `null`(取消时)。
@@ -101,6 +103,7 @@ class ExpenseDetailDialog extends StatefulWidget {
     bool showAttachments = true,
     bool canEditApprovedAmount = false,
     BillFileRights? billFileRights,
+    required dynamic acctTree,
   }) {
     FocusScope.of(context).unfocus();
     return Navigator.push<ExpenseDetailInputData>(
@@ -116,6 +119,7 @@ class ExpenseDetailDialog extends StatefulWidget {
           showAttachments: showAttachments,
           canEditApprovedAmount: canEditApprovedAmount,
           billFileRights: billFileRights,
+          acctTree: acctTree,
         ),
       ),
     );
@@ -984,18 +988,103 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     );
   }
 
-  // ── 会计科目(只读,选择类别后自动带出) ──
+  // ── 会计科目(级联选择器,支持手动修改) ──
   Widget _buildAcctSubjectCard(AppColorsExtension colors) {
-    final id = _selCat.accNo.isNotEmpty
-        ? _selCat.accNo
-        : widget.initialData?.acctSubjectId ?? '';
-    final name = _selCat.accName.isNotEmpty
-        ? _selCat.accName
-        : widget.initialData?.acctSubjectName ?? '';
-    return _readOnlyCard(
-      label: _l10n.get('acctSubject'),
-      value: id.isNotEmpty ? '$id${name.isNotEmpty ? '/$name' : ''}' : '',
-      colors: colors,
+    final tdTheme = TDTheme.of(context);
+    final hasValue = _selCat.accNo.isNotEmpty;
+    final displayValue = hasValue
+        ? (_selCat.accName.startsWith(_selCat.accNo)
+              ? _selCat.accName
+              : (_selCat.accName.toString().isNotEmpty
+                    ? '${_selCat.accNo}/${_selCat.accName}'
+                    : _selCat.accNo))
+        : _l10n.get('pleaseSelect');
+    return GestureDetector(
+      onTap: () {
+        if (widget.acctTree == null) {
+          TDToast.showText(_l10n.get('loading'), context: context);
+          return;
+        }
+        FocusManager.instance.primaryFocus?.unfocus();
+        TDCascader.showMultiCascader(
+          context,
+          title: _l10n.get('acctSubject'),
+          data: widget.acctTree as List<Map>,
+          theme: 'step',
+          onChange: (List<MultiCascaderListModel> selected) {
+            setState(() {
+              _selCat = CostProjectItem(
+                idx1: _selCat.idx1,
+                name: _selCat.name,
+                accNo: (selected.last.value ?? '').replaceFirst('self:', ''),
+                accName: (selected.last.label ?? '')
+                    .replaceFirst(RegExp(r'^> '), '')
+                    .replaceFirst('${selected.last.value ?? ''}/', ''),
+              );
+            });
+          },
+        );
+      },
+      child: Container(
+        padding: const EdgeInsets.only(left: 16, right: 10, top: 12, bottom: 12),
+        decoration: BoxDecoration(
+          color: tdTheme.bgColorContainer,
+          borderRadius: BorderRadius.circular(tdTheme.radiusDefault),
+          border: Border.all(color: tdTheme.componentStrokeColor),
+        ),
+        child: Row(
+          children: [
+            TDText(
+              _l10n.get('acctSubject'),
+              maxLines: 1,
+              overflow: TextOverflow.visible,
+              font: tdTheme.fontBodyLarge,
+              fontWeight: FontWeight.w400,
+              style: const TextStyle(letterSpacing: 0),
+            ),
+            const SizedBox(width: 12),
+            Expanded(
+              child: Row(
+                mainAxisAlignment: MainAxisAlignment.end,
+                mainAxisSize: MainAxisSize.max,
+                children: [
+                  Flexible(
+                    child: TDText(
+                      displayValue,
+                      maxLines: 1,
+                      overflow: TextOverflow.ellipsis,
+                      font: tdTheme.fontBodyLarge,
+                      fontWeight: FontWeight.w400,
+                      textColor: hasValue ? tdTheme.textColorPrimary : tdTheme.textColorPlaceholder,
+                      textAlign: TextAlign.end,
+                    ),
+                  ),
+                  const SizedBox(width: 4),
+                  SizedBox(
+                    width: 18,
+                    height: 18,
+                    child: hasValue
+                        ? GestureDetector(
+                            onTap: () {
+                              setState(() {
+                                _selCat = CostProjectItem(
+                                  idx1: _selCat.idx1,
+                                  name: _selCat.name,
+                                  accNo: '',
+                                  accName: '',
+                                );
+                              });
+                            },
+                            child: Icon(Icons.close, size: 18, color: tdTheme.textColorPlaceholder),
+                          )
+                        : Icon(Icons.chevron_right, size: 18, color: tdTheme.textColorPlaceholder),
+                  ),
+                ],
+              ),
+            ),
+          ],
+        ),
+      ),
     );
   }
 

+ 46 - 1
lib/features/expense_apply/expense_apply_api.dart

@@ -80,7 +80,7 @@ class DepartmentItem {
 
 class ExpenseApplyApi {
   final ApiClient _client;
-  final _cache = ApiCache(keyPrefix: HostAppChannel.sn);
+  final _cache = ApiCache(keyPrefix: '${HostAppChannel.sn}_${HostAppChannel.compNo}_${HostAppChannel.usr}');
   ExpenseApplyApi(this._client);
 
   /// 清除所有基础资料缓存(picker 下拉刷新时调用)。
@@ -411,6 +411,51 @@ class ExpenseApplyApi {
     return response.data!;
   }
 
+  /// 审核过程明细
+  Future<List<Map<String, dynamic>>> getAuditTrail(String billId, String billNo) async {
+    final response = await _client.get('/OA/GetAuditTrail', queryParameters: {'billId': billId, 'billNo': billNo});
+    final list = (response.data as List<dynamic>?)?.cast<Map<String, dynamic>>() ?? [];
+    return list;
+  }
+
+  /// 下属报销申请金额对比
+  Future<List<SubordinateReportItem>> getExpenseApplySubordinateReport({
+    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<dynamic>(
+      '/OA/GetExpenseApplySubordinateReport',
+      queryParameters: params,
+    );
+    final list = (response.data as List<dynamic>?)
+            ?.map((e) => SubordinateReportItem.fromJson(e as Map<String, dynamic>))
+            .toList() ??
+        [];
+    return list;
+  }
+
+  /// 报销申请单表身明细(分页)
+  Future<Map<String, dynamic>> getExpenseApplyBodyReport({
+    String? startDate,
+    String? endDate,
+    int page = 1,
+    int size = 20,
+  }) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetExpenseApplyBodyReport',
+      queryParameters: {
+        if (startDate != null) 'startDate': startDate,
+        if (endDate != null) 'endDate': endDate,
+        'page': page,
+        'size': size,
+      },
+    );
+    return response.data!;
+  }
+
   /// 获取单据状态
   Future<Map<String, dynamic>> getBillStatus(String billNo) async {
     final response = await _client.get<Map<String, dynamic>>(

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

@@ -323,6 +323,8 @@ class _ExpenseApplyCreatePageState
             'endDate': d.endDate,
             'estimatedAmount': d.estimatedAmount,
             'remark': d.remark,
+            'sqMan': d.sqMan,
+            'sqManName': d.sqManName,
           },
         )
         .toList();
@@ -696,7 +698,6 @@ class _ExpenseApplyCreatePageState
           }),
         const SizedBox(height: 8),
         Container(
-          height: 36,
           padding: const EdgeInsets.symmetric(vertical: 8),
           child: Row(
             mainAxisAlignment: MainAxisAlignment.spaceBetween,

+ 72 - 61
lib/features/expense_apply/expense_apply_detail_page.dart

@@ -15,7 +15,7 @@ import '../../shared/widgets/app_skeletons.dart';
 import '../../shared/models/bill_file_rights.dart';
 import '../../shared/widgets/attachment_download_helper.dart';
 import '../../shared/widgets/action_bar.dart';
-import '../../shared/widgets/status_banner.dart';
+import '../../shared/widgets/audit_trail_dialog.dart';
 import 'expense_apply_model.dart';
 import 'widgets/expense_apply_detail_view_dialog.dart';
 import '../../core/i18n/app_localizations.dart';
@@ -182,26 +182,6 @@ class _ExpenseApplyDetailPageState
             padding: const EdgeInsets.all(16),
             child: Column(
               children: [
-                if (_statusLoading) ...[
-                  const Padding(
-                    padding: EdgeInsets.symmetric(vertical: 8),
-                    child: Center(
-                      child: TDLoading(
-                        size: TDLoadingSize.small,
-                        icon: TDLoadingIcon.activity,
-                      ),
-                    ),
-                  ),
-                ],
-                Builder(
-                  builder: (ctx) {
-                    final badge = _buildStatusBadge(l10n);
-                    if (badge == null) return const SizedBox.shrink();
-                    return Column(
-                      children: [badge, const SizedBox(height: 16)],
-                    );
-                  },
-                ),
                 _buildBasicInfoSection(app, l10n, colors),
                 const SizedBox(height: 16),
                 _buildExpenseDetailSection(app, l10n, colors),
@@ -213,7 +193,8 @@ class _ExpenseApplyDetailPageState
             ),
           ),
         ),
-        if (_canEdit)
+        // TODO: 等 ERP 提供"能否修改单据"接口后,恢复为 if (_canEdit)
+        if (false)
           ActionBar(
             showLeft: false,
             showCenter: false,
@@ -229,51 +210,80 @@ class _ExpenseApplyDetailPageState
     );
   }
 
-  // ═══ 状态徽章 ═══
-  Widget? _buildStatusBadge(AppLocalizations l10n) {
+  // ═══ 状态 tag(标题行右侧) ═══
+  Widget? _buildStatusTag(AppLocalizations l10n) {
     final isTransferred = _billStatus?['isTransferred'] == true;
     final approvalStatus = _billStatus?['approvalStatus'] as String?;
+    final approvalText = _billStatus?['approvalText'] as String? ?? '';
+
+    IconData icon;
+    String text;
+    Color color;
 
     if (isTransferred) {
-      return StatusBanner(
-        icon: Icons.swap_horiz,
-        statusText: l10n.get('statusConvertedToExpense'),
-        subText: '',
-        color: Colors.blueGrey,
-      );
+      icon = Icons.swap_horiz;
+      text = l10n.get('statusConvertedToExpense');
+      color = Colors.blue;
+    } else if (approvalStatus == null || approvalStatus.isEmpty) {
+      return null;
+    } else {
+      switch (approvalStatus) {
+        case 'SHCOUNT0':
+          icon = Icons.edit_note;
+          color = Colors.teal;
+          break;
+        case 'SHCOUNT1':
+          icon = Icons.hourglass_empty;
+          color = Colors.blueGrey;
+          break;
+        case 'SHCOUNT2':
+          icon = Icons.cancel_outlined;
+          color = Colors.red;
+          break;
+        case 'SHCOUNT3':
+          icon = Icons.undo;
+          color = Colors.deepOrange;
+          break;
+        case 'SHCOUNT4':
+          icon = Icons.check_circle_outline;
+          color = Colors.green;
+          break;
+        default:
+          return null;
+      }
+      text = approvalText;
     }
-    switch (approvalStatus) {
-      case 'SHCOUNT4':
-        return StatusBanner(
-          icon: Icons.check_circle_outline,
-          statusText: l10n.get('statusApproved'),
-          subText: '',
-          color: Colors.green,
-        );
-      case 'SHCOUNT5':
-        return StatusBanner(
-          icon: Icons.cancel_outlined,
-          statusText: l10n.get('statusFinalRejected'),
-          subText: '',
-          color: Colors.red,
-        );
-      case 'SHCOUNT1':
-        return StatusBanner(
-          icon: Icons.hourglass_empty,
-          statusText: l10n.get('statusPendingReview'),
-          subText: '',
-          color: Colors.blue,
-        );
-      case 'SHCOUNT2':
-        return StatusBanner(
-          icon: Icons.block_outlined,
-          statusText: l10n.get('statusReviewRejected'),
-          subText: '',
-          color: Colors.deepOrange,
-        );
-      default:
-        return null;
+
+    final tag = Container(
+      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
+      decoration: BoxDecoration(
+        color: color.withValues(alpha: 0.1),
+        borderRadius: BorderRadius.circular(6),
+        border: Border.all(color: color.withValues(alpha: 0.3), width: 0.5),
+      ),
+      child: Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Icon(icon, size: 18, color: color),
+          const SizedBox(width: 4),
+          Text(text, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: color)),
+        ],
+      ),
+    );
+    final canTap = approvalStatus != null && approvalStatus.isNotEmpty && approvalText.isNotEmpty;
+    if (canTap) {
+      return GestureDetector(
+        onTap: () => _showAuditTrail('AE'),
+        child: tag,
+      );
     }
+    return tag;
+  }
+
+  Future<void> _showAuditTrail(String billId) async {
+    final api = ref.read(expenseApplyApiProvider);
+    final billNo = widget.billNo;
+    await AuditTrailDialog.show(context, fetcher: () => api.getAuditTrail(billId, billNo));
   }
 
   // ═══ 基本信息 ═══
@@ -285,6 +295,7 @@ class _ExpenseApplyDetailPageState
     return FormSection(
       title: l10n.get('basicInfo'),
       leadingIcon: Icons.info_outline,
+      trailing: _buildStatusTag(l10n),
       children: [
         FormFieldRow(
           label: l10n.get('expenseApplyNo'),

+ 0 - 1
lib/features/expense_apply/expense_apply_edit_page.dart

@@ -616,7 +616,6 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
           }),
         const SizedBox(height: 8),
         Container(
-          height: 36,
           padding: const EdgeInsets.symmetric(vertical: 8),
           child: Row(
             mainAxisAlignment: MainAxisAlignment.spaceBetween,

+ 43 - 4
lib/features/expense_apply/expense_apply_list_page.dart

@@ -29,6 +29,9 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage> {
   bool _firstBuild = true;
   bool _invalidateDone = false;
 
+  final _scrollCtrl = ScrollController();
+  bool _showBackToTop = false;
+
   @override
   void initState() {
     super.initState();
@@ -36,6 +39,12 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage> {
     _startDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
     _endDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${DateTime(now.year, now.month + 1, 0).day.toString().padLeft(2, '0')}';
     _refreshCtrl = EasyRefreshController();
+    _scrollCtrl.addListener(() {
+      final show = _scrollCtrl.hasClients && _scrollCtrl.offset > 300;
+      if (show != _showBackToTop && mounted) {
+        setState(() => _showBackToTop = show);
+      }
+    });
     WidgetsBinding.instance.addPostFrameCallback((_) {
       ref.read(expenseApplyDateStartProvider.notifier).state = DateTime(now.year, now.month, 1);
       ref.read(expenseApplyDateEndProvider.notifier).state = DateTime(now.year, now.month, DateTime(now.year, now.month + 1, 0).day);
@@ -50,6 +59,7 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage> {
     _startDateCtrl.dispose();
     _endDateCtrl.dispose();
     _refreshCtrl.dispose();
+    _scrollCtrl.dispose();
     super.dispose();
   }
 
@@ -152,8 +162,36 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage> {
             ])),
           ]),
         ),
-        Expanded(child: Container(color: colors.bgPage, child: _firstBuild ? const Center(child: SkeletonLoadingList()) : _ExpenseApplyListContent(refreshCtrl: _refreshCtrl, onRefresh: _doRefresh))),
+        Expanded(child: Container(color: colors.bgPage, child: _firstBuild ? const Center(child: SkeletonLoadingList()) : _ExpenseApplyListContent(refreshCtrl: _refreshCtrl, onRefresh: _doRefresh, scrollCtrl: _scrollCtrl))),
       ]),
+      AnimatedPositioned(
+        duration: const Duration(milliseconds: 200),
+        bottom: _showBackToTop ? 80 : -60,
+        left: 0,
+        right: 0,
+        child: Center(
+          child: GestureDetector(
+            onTap: () {
+              if (_scrollCtrl.hasClients) {
+                _scrollCtrl.jumpTo(0);
+              }
+            },
+            child: AnimatedOpacity(
+              duration: const Duration(milliseconds: 200),
+              opacity: _showBackToTop ? 1.0 : 0.0,
+              child: Container(
+                width: 36, height: 36,
+                decoration: BoxDecoration(
+                  color: colors.primary400,
+                  shape: BoxShape.circle,
+                  boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.15), blurRadius: 6, offset: const Offset(0, 2))],
+                ),
+                child: const Icon(Icons.keyboard_arrow_up, color: Colors.white, size: 22),
+              ),
+            ),
+          ),
+        ),
+      ),
       Positioned(
         right: 16, bottom: 16,
         child: FloatingActionButton(
@@ -170,7 +208,8 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage> {
 class _ExpenseApplyListContent extends ConsumerWidget {
   final EasyRefreshController refreshCtrl;
   final Future<void> Function() onRefresh;
-  const _ExpenseApplyListContent({required this.refreshCtrl, required this.onRefresh});
+  final ScrollController scrollCtrl;
+  const _ExpenseApplyListContent({required this.refreshCtrl, required this.onRefresh, required this.scrollCtrl});
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
@@ -187,7 +226,7 @@ class _ExpenseApplyListContent extends ConsumerWidget {
     if (itemsAsync.isReloading) {
       final oldItems = itemsAsync.valueOrNull ?? [];
       if (oldItems.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenseApplications'))]);
-      return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: oldItems.length, itemBuilder: (_, i) {
+      return ListView.builder(controller: scrollCtrl, padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: oldItems.length, itemBuilder: (_, i) {
         final m = oldItems[i];
         final card = ListCard(
           cardNo: m.expenseApplyNo, amount: formatAmount(m.estimatedAmount),
@@ -214,7 +253,7 @@ class _ExpenseApplyListContent extends ConsumerWidget {
     if (itemsAsync.hasError) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('loadFailed'))]);
     final items = itemsAsync.requireValue;
     if (items.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenseApplications'))]);
-    return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: items.length + 1, itemBuilder: (_, i) {
+    return ListView.builder(controller: scrollCtrl, padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: items.length + 1, itemBuilder: (_, i) {
       if (i == items.length) return ListFooter(itemCount: items.length);
       final m = items[i];
       final card = ListCard(

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

@@ -110,3 +110,27 @@ class ReportData {
     );
   }
 }
+
+/// 下属报销金额对比数据
+class SubordinateReportItem {
+  final String usr;
+  final String usrName;
+  final double amount;
+  final int cnt;
+
+  const SubordinateReportItem({
+    this.usr = '',
+    this.usrName = '',
+    this.amount = 0,
+    this.cnt = 0,
+  });
+
+  factory SubordinateReportItem.fromJson(Map<String, dynamic> json) {
+    return SubordinateReportItem(
+      usr: json['usr'] as String? ?? '',
+      usrName: json['usrName'] as String? ?? '',
+      amount: (json['amount'] as num?)?.toDouble() ?? 0,
+      cnt: json['cnt'] as int? ?? 0,
+    );
+  }
+}

+ 257 - 259
lib/features/report/expense_apply_detail_report_page.dart

@@ -6,6 +6,7 @@ import '../../core/i18n/app_localizations.dart';
 import '../../core/theme/app_colors_extension.dart';
 import '../../shared/widgets/app_skeletons.dart';
 import 'package:skeletonizer/skeletonizer.dart';
+import '../../shared/widgets/list_footer.dart';
 import '../expense_apply/expense_apply_api.dart';
 import '../expense_apply/report_model.dart';
 import '../../core/utils/amount_utils.dart';
@@ -31,24 +32,34 @@ class _ExpenseApplyDetailReportPageState
   int _detailPage = 1;
   int _detailTotal = 0;
   bool _detailLoading = false;
+  bool _detailLoadingMore = false;
+  final _scrollCtrl = ScrollController();
+  String _activeStartDate = '';
+  String _activeEndDate = '';
 
   @override
   void initState() {
     super.initState();
     final now = DateTime.now();
-    _startCtrl.text = '${now.year}-01-01';
-    _endCtrl.text = '${now.year}-12-31';
+    final twelveMonthsAgo = DateTime(now.year, now.month - 11, 1);
+    _startCtrl.text = '${twelveMonthsAgo.year}-${twelveMonthsAgo.month.toString().padLeft(2, '0')}-01';
+    final lastDay = DateTime(now.year, now.month + 1, 0).day;
+    _endCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${lastDay.toString().padLeft(2, '0')}';
     _loadData();
+    _scrollCtrl.addListener(_onScroll);
   }
 
   @override
   void dispose() {
     _startCtrl.dispose();
     _endCtrl.dispose();
+    _scrollCtrl.dispose();
     super.dispose();
   }
 
   Future<void> _loadData() async {
+    _activeStartDate = _startCtrl.text;
+    _activeEndDate = _endCtrl.text;
     setState(() {
       _loading = true;
       _error = null;
@@ -84,7 +95,7 @@ class _ExpenseApplyDetailReportPageState
       final result = await api.getExpenseApplyReportDetail(
         startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
         endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
-        page: _detailPage,
+        page: 1,
       );
       if (!mounted) return;
       final list =
@@ -95,6 +106,7 @@ class _ExpenseApplyDetailReportPageState
       setState(() {
         _details = list;
         _detailTotal = result['total'] as int? ?? 0;
+        _detailPage = 1;
         _detailLoading = false;
       });
     } catch (_) {
@@ -102,6 +114,40 @@ class _ExpenseApplyDetailReportPageState
     }
   }
 
+  Future<void> _loadMoreDetails() async {
+    if (_detailLoadingMore) return;
+    if (_details.length >= _detailTotal) return;
+    setState(() => _detailLoadingMore = true);
+    try {
+      final api = ref.read(expenseApplyApiProvider);
+      final result = await api.getExpenseApplyReportDetail(
+        startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
+        endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
+        page: _detailPage + 1,
+      );
+      if (!mounted) return;
+      final list =
+          (result['list'] as List<dynamic>?)
+              ?.map((e) => ReportDetailItem.fromJson(e as Map<String, dynamic>))
+              .toList() ??
+          [];
+      setState(() {
+        _details = [..._details, ...list];
+        _detailPage++;
+        _detailLoadingMore = false;
+      });
+    } catch (_) {
+      if (mounted) setState(() => _detailLoadingMore = false);
+    }
+  }
+
+  void _onScroll() {
+    if (_scrollCtrl.position.pixels < _scrollCtrl.position.maxScrollExtent - 200) return;
+    if (_detailTotal > 0 && _details.length < _detailTotal && !_detailLoadingMore) {
+      _loadMoreDetails();
+    }
+  }
+
   void _pickDate(TextEditingController ctrl) {
     final l10n = AppLocalizations.of(context);
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
@@ -129,7 +175,31 @@ class _ExpenseApplyDetailReportPageState
     );
   }
 
+  bool _validateDateRange() {
+    final l10n = AppLocalizations.of(context);
+    if (_startCtrl.text.isEmpty || _endCtrl.text.isEmpty) {
+      TDToast.showText(l10n.get('selectDateRange'), context: context);
+      return false;
+    }
+    final start = DateTime.tryParse(_activeStartDate);
+    final end = DateTime.tryParse(_activeEndDate);
+    if (start == null || end == null) {
+      TDToast.showText(l10n.get('selectDateRange'), context: context);
+      return false;
+    }
+    if (end.isBefore(start)) {
+      TDToast.showText(l10n.get('endDateBeforeStart'), context: context);
+      return false;
+    }
+    if (end.difference(start).inDays > 366) {
+      TDToast.showText(l10n.get('dateRangeLimit'), context: context);
+      return false;
+    }
+    return true;
+  }
+
   void _applyFilter() {
+    if (!_validateDateRange()) return;
     _details = [];
     _detailPage = 1;
     _detailTotal = 0;
@@ -188,6 +258,7 @@ class _ExpenseApplyDetailReportPageState
       );
     }
     return SingleChildScrollView(
+      controller: _scrollCtrl,
       child: Column(
         children: [
           _buildDateFilter(),
@@ -308,53 +379,29 @@ class _ExpenseApplyDetailReportPageState
     );
   }
 
-  // ── 4 张统计卡片(2×2)──
+  // ── 2 张统计卡片(一行两列)──
   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(
+      child: Row(
         children: [
-          Row(
-            children: [
-              Expanded(
-                child: _statCard(
-                  l10n.get('statTotalCount'),
-                  '${summary.totalCount} 笔',
-                  colors.textPrimary,
-                ),
-              ),
-              const SizedBox(width: 8),
-              Expanded(
-                child: _statCard(
-                  l10n.get('statTransferredCount'),
-                  '${summary.transferredCount} 笔',
-                  colors.textPrimary,
-                ),
-              ),
-            ],
+          Expanded(
+            child: _statCard(
+              l10n.get('statTotalCount'),
+              '${summary.totalCount} ${l10n.get('unitItem')}',
+              colors.textPrimary,
+            ),
           ),
-          const SizedBox(height: 8),
-          Row(
-            children: [
-              Expanded(
-                child: _statCard(
-                  l10n.get('statTotalApply'),
-                  formatAmount(summary.totalAmount),
-                  colors.amountPrimary,
-                ),
-              ),
-              const SizedBox(width: 8),
-              Expanded(
-                child: _statCard(
-                  l10n.get('statTransferredAmount'),
-                  formatAmount(summary.transferredAmount),
-                  colors.textPrimary,
-                ),
-              ),
-            ],
+          const SizedBox(width: 8),
+          Expanded(
+            child: _statCard(
+              l10n.get('statTotalApply'),
+              formatAmount(summary.totalAmount),
+              colors.amountPrimary,
+            ),
           ),
         ],
       ),
@@ -420,7 +467,7 @@ class _ExpenseApplyDetailReportPageState
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
             Text(
-              l10n.get('chartTitle4'),
+              l10n.get('chartTitle6'),
               style: TextStyle(
                 fontSize: 14,
                 fontWeight: FontWeight.w600,
@@ -430,58 +477,63 @@ class _ExpenseApplyDetailReportPageState
             const SizedBox(height: 8),
             Row(
               children: [
-                _legendDot(colors.primary, l10n.get('yearTotalApp')),
-                const SizedBox(width: 16),
-                _legendDot(colors.success, l10n.get('transferredAmount')),
+                Container(
+                  width: 8,
+                  height: 8,
+                  decoration: BoxDecoration(color: colors.amountPrimary, shape: BoxShape.circle),
+                ),
+                const SizedBox(width: 4),
+                Text(
+                  l10n.get('yearTotalApp'),
+                  style: TextStyle(fontSize: 11, color: colors.textSecondary),
+                ),
               ],
             ),
-            const SizedBox(height: 12),
-            SizedBox(height: 200, child: _buildDualLineChart()),
+            const SizedBox(height: 16),
+            SizedBox(height: 200, child: _buildApplyAmountLineChart()),
           ],
         ),
       ),
     );
   }
 
-  Widget _legendDot(Color color, String label) {
+  // 申请金额折线图
+  Widget _buildApplyAmountLineChart() {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    return Row(
-      mainAxisSize: MainAxisSize.min,
-      children: [
-        Container(
-          width: 8,
-          height: 8,
-          decoration: BoxDecoration(color: color, shape: BoxShape.circle),
-        ),
-        const SizedBox(width: 4),
-        Text(
-          label,
-          style: TextStyle(fontSize: 11, color: colors.textSecondary),
-        ),
-      ],
-    );
-  }
 
-  // 双折线图
-  Widget _buildDualLineChart() {
-    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    // 根据日期区间生成月份列表
+    final start = DateTime.tryParse(_activeStartDate);
+    final end = DateTime.tryParse(_activeEndDate);
+    final monthLabels = <String>[];
+    final monthKeys = <int>[];
+    if (start != null && end != null) {
+      var cur = DateTime(start.year, start.month, 1);
+      final endMonth = DateTime(end.year, end.month, 1);
+      while (!cur.isAfter(endMonth)) {
+        monthLabels.add('${cur.month}月');
+        monthKeys.add(cur.month);
+        cur = DateTime(cur.year, cur.month + 1, 1);
+      }
+    }
 
-    // 从 monthly 构建 12 个月的数据,无数据的月份默认为 0
-    final monthlyMap = <int, ReportMonthlyItem>{};
+    // 建立 API 数据索引
+    final monthlyMap = <int, double>{};
     for (final item in (_data?.monthly ?? [])) {
-      monthlyMap[item.month] = item;
+      monthlyMap[item.month] = item.amount;
+    }
+
+    // 按日期区间生成金额数据,无数据的月份为 0
+    final amountData = monthKeys.map((m) => monthlyMap[m] ?? 0.0).toList();
+
+    if (amountData.isEmpty) {
+      return SizedBox(
+        height: 200,
+        child: Center(child: Text(AppLocalizations.of(context).get('noData'), style: TextStyle(fontSize: 14, color: colors.textPlaceholder))),
+      );
     }
-    final monthLabels = List.generate(12, (i) => '${i + 1}月');
-    final amountData = List.generate(12, (i) {
-      return monthlyMap[i + 1]?.amount ?? 0;
-    });
-    final transferredData = List.generate(12, (i) {
-      return monthlyMap[i + 1]?.transferredAmount ?? 0;
-    });
 
     // 计算 Y 轴最大值
-    final allValues = [...amountData, ...transferredData];
-    final maxVal = allValues.reduce((a, b) => a > b ? a : b);
+    final maxVal = amountData.reduce((a, b) => a > b ? a : b);
     final maxY = (maxVal > 0 ? maxVal * 1.2 : 100).toDouble();
 
     return LineChart(
@@ -489,7 +541,7 @@ class _ExpenseApplyDetailReportPageState
         gridData: FlGridData(
           show: true,
           drawVerticalLine: false,
-          horizontalInterval: maxY / 5,
+          horizontalInterval: maxY / 4,
           getDrawingHorizontalLine: (v) =>
               FlLine(color: colors.border, strokeWidth: 0.5),
         ),
@@ -497,7 +549,8 @@ class _ExpenseApplyDetailReportPageState
           leftTitles: AxisTitles(
             sideTitles: SideTitles(
               showTitles: true,
-              reservedSize: 48,
+              reservedSize: 80,
+              interval: maxY / 4,
               getTitlesWidget: (v, meta) => Text(
                 formatAmount(v.toDouble()),
                 style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
@@ -553,46 +606,25 @@ class _ExpenseApplyDetailReportPageState
         lineBarsData: [
           LineChartBarData(
             spots: List.generate(
-              12,
+              amountData.length,
               (i) => FlSpot(i.toDouble(), amountData[i]),
             ),
             isCurved: true,
-            color: colors.primary,
-            barWidth: 2.5,
-            dotData: FlDotData(
-              show: true,
-              getDotPainter: (spot, percent, barData, index) =>
-                  FlDotCirclePainter(
-                    radius: 3,
-                    color: colors.primary,
-                    strokeWidth: 0,
-                  ),
-            ),
-            belowBarData: BarAreaData(
-              show: true,
-              color: colors.primary.withValues(alpha: 0.08),
-            ),
-          ),
-          LineChartBarData(
-            spots: List.generate(
-              12,
-              (i) => FlSpot(i.toDouble(), transferredData[i]),
-            ),
-            isCurved: true,
-            color: colors.success,
+            preventCurveOverShooting: true,
+            color: colors.amountPrimary,
             barWidth: 2.5,
             dotData: FlDotData(
               show: true,
               getDotPainter: (spot, percent, barData, index) =>
                   FlDotCirclePainter(
                     radius: 3,
-                    color: colors.success,
+                    color: colors.amountPrimary,
                     strokeWidth: 0,
                   ),
             ),
             belowBarData: BarAreaData(
               show: true,
-              color: colors.success.withValues(alpha: 0.08),
+              color: colors.amountPrimary.withValues(alpha: 0.08),
             ),
           ),
         ],
@@ -642,20 +674,10 @@ class _ExpenseApplyDetailReportPageState
       );
     }
 
-    final tableData = _details.map((d) => {
-      'billNo': d.billNo,
-      'billDate': d.billDate != null && d.billDate!.length >= 10
-          ? d.billDate!.substring(0, 10)
-          : '-',
-      'amount': d.amount,
-      'reason': d.reason,
-      'isTransferred': d.isTransferred ? '1' : '',
-    }).toList();
-
-    final totalPages = (_detailTotal / 20).ceil();
+    final hasMore = _details.length < _detailTotal;
 
     return Padding(
-      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+      padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
       child: Container(
         width: double.infinity,
         decoration: BoxDecoration(
@@ -663,6 +685,7 @@ class _ExpenseApplyDetailReportPageState
           borderRadius: BorderRadius.circular(8),
         ),
         child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
           children: [
             Padding(
               padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
@@ -676,155 +699,130 @@ class _ExpenseApplyDetailReportPageState
                       color: colors.textPrimary,
                     ),
                   ),
+                  const Spacer(),
+                  Text(
+                    '${l10n.get('total')} $_detailTotal ${l10n.get('unitItem')}',
+                    style: TextStyle(fontSize: 12, color: colors.textSecondary),
+                  ),
                 ],
               ),
             ),
-            TDTable(
-              bordered: true,
-              stripe: true,
-              columns: [
-                TDTableCol(
-                  title: l10n.get('expenseApplyNo'),
-                  colKey: 'billNo',
-                  width: 180,
-                  ellipsis: true,
-                ),
-                TDTableCol(
-                  title: l10n.get('date'),
-                  colKey: 'billDate',
-                  width: 150,
-                ),
-                TDTableCol(
-                  title: l10n.get('estimatedAmount'),
-                  colKey: 'amount',
-                  width: 180,
-                  align: TDTableColAlign.right,
-                  cellBuilder: (context, rowIndex) {
-                    final amount = (tableData[rowIndex]['amount'] as num).toDouble();
-                    return Text(
-                      formatAmount(amount),
-                      style: TextStyle(
-                        fontSize: 13,
-                        color: colors.amountPrimary,
-                        fontWeight: FontWeight.w600,
+            Divider(height: 1, color: colors.border),
+            ListView.builder(
+              shrinkWrap: true,
+              physics: const NeverScrollableScrollPhysics(),
+              padding: EdgeInsets.zero,
+              itemCount: _details.length + 1,
+              itemBuilder: (_, i) {
+                if (i == _details.length) {
+                  if (_detailLoadingMore) {
+                    return const Padding(
+                      padding: EdgeInsets.symmetric(vertical: 16),
+                      child: Center(
+                        child: TDLoading(
+                          size: TDLoadingSize.small,
+                          icon: TDLoadingIcon.activity,
+                        ),
                       ),
                     );
-                  },
-                ),
-                TDTableCol(
-                  title: l10n.get('applyReason'),
-                  colKey: 'reason',
-                  width: 200,
-                  ellipsis: true,
-                ),
-                TDTableCol(
-                  title: l10n.get('transferStatus'),
-                  colKey: 'isTransferred',
-                  width: 150,
-                  align: TDTableColAlign.center,
-                  cellBuilder: (context, rowIndex) {
-                    final transferred = tableData[rowIndex]['isTransferred'] as String;
-                    return transferred == '1'
-                        ? Container(
-                            padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
-                            decoration: BoxDecoration(
-                              color: colors.success.withValues(alpha: 0.1),
-                              borderRadius: BorderRadius.circular(10),
-                              border: Border.all(
-                                  color: colors.success, width: 0.5),
-                            ),
-                            child: Text(
-                              l10n.get('statusConvertedToExpense'),
-                              style: TextStyle(
-                                fontSize: 11,
-                                color: colors.success,
-                                fontWeight: FontWeight.w500,
-                              ),
-                            ),
-                          )
-                        : const SizedBox.shrink();
-                  },
-                ),
-              ],
-              data: tableData,
-              empty: TDTableEmpty(text: l10n.get('noDetailData')),
-              footerWidget: _detailTotal > 0
-                  ? Container(
-                      padding: const EdgeInsets.symmetric(
-                        horizontal: 16,
-                        vertical: 10,
+                  }
+                  return ListFooter(
+                    itemCount: _details.length,
+                    hasMore: hasMore,
+                  );
+                }
+                return _buildDetailItem(_details[i]);
+              },
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _buildDetailItem(ReportDetailItem d) {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final l10n = AppLocalizations.of(context);
+    final billDate = d.billDate != null && d.billDate!.length >= 10
+        ? d.billDate!.substring(0, 10)
+        : '-';
+    return Column(
+      children: [
+        Padding(
+          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Row(
+                mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                children: [
+                  Flexible(
+                    child: Text(
+                      d.billNo,
+                      maxLines: 1,
+                      overflow: TextOverflow.ellipsis,
+                      style: TextStyle(
+                        fontSize: 14,
+                        fontWeight: FontWeight.w600,
+                        color: colors.textPrimary,
                       ),
+                    ),
+                  ),
+                  const SizedBox(width: 12),
+                  Text(
+                    formatAmount(d.amount),
+                    maxLines: 1,
+                    overflow: TextOverflow.ellipsis,
+                    style: TextStyle(
+                      fontSize: 16,
+                      fontWeight: FontWeight.w700,
+                      color: colors.amountPrimary,
+                    ),
+                  ),
+                ],
+              ),
+              const SizedBox(height: 6),
+              Row(
+                mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                children: [
+                  Text(
+                    billDate,
+                    style: TextStyle(fontSize: 12, color: colors.textSecondary),
+                  ),
+                  if (d.isTransferred)
+                    Container(
+                      padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
                       decoration: BoxDecoration(
-                        border: Border(
-                          top: BorderSide(color: colors.border, width: 0.5),
-                        ),
+                        color: colors.primary.withValues(alpha: 0.1),
+                        borderRadius: BorderRadius.circular(4),
+                        border: Border.all(color: colors.primary, width: 0.5),
                       ),
-                      child: Row(
-                        mainAxisAlignment: MainAxisAlignment.center,
-                        children: [
-                          GestureDetector(
-                            onTap: _detailPage > 1
-                                ? () {
-                                    _detailPage--;
-                                    _loadDetails();
-                                  }
-                                : null,
-                            child: Icon(
-                              Icons.chevron_left,
-                              size: 20,
-                              color: _detailPage > 1
-                                  ? colors.textPrimary
-                                  : colors.textPlaceholder,
-                            ),
-                          ),
-                          const SizedBox(width: 16),
-                          Text(
-                            '$_detailPage',
-                            style: TextStyle(
-                              fontSize: 13,
-                              color: colors.textPrimary,
-                            ),
-                          ),
-                          const SizedBox(width: 4),
-                          Text(
-                            '/ $totalPages',
-                            style: TextStyle(
-                              fontSize: 13,
-                              color: colors.textSecondary,
-                            ),
-                          ),
-                          const SizedBox(width: 16),
-                          GestureDetector(
-                            onTap: _detailPage < totalPages
-                                ? () {
-                                    _detailPage++;
-                                    _loadDetails();
-                                  }
-                                : null,
-                            child: Icon(
-                              Icons.chevron_right,
-                              size: 20,
-                              color: _detailPage < totalPages
-                                  ? colors.textPrimary
-                                  : colors.textPlaceholder,
-                            ),
-                          ),
-                          const SizedBox(width: 16),
-                          Text(
-                            '${l10n.get('total')} $_detailTotal ${l10n.get('items')}',
-                            style: TextStyle(
-                              fontSize: 12,
-                              color: colors.textSecondary,
-                            ),
-                          ),
-                        ],
+                      child: Text(
+                        l10n.get('statusConvertedToExpense'),
+                        style: TextStyle(
+                          fontSize: 11,
+                          fontWeight: FontWeight.w500,
+                          color: colors.primary,
+                        ),
                       ),
-                    )
-                  : null,
-            ),
-          ],
+                    ),
+                ],
+              ),
+              if (d.reason.isNotEmpty) ...[
+                const SizedBox(height: 6),
+                Text(
+                  d.reason,
+                  maxLines: 2,
+                  overflow: TextOverflow.ellipsis,
+                  style: TextStyle(fontSize: 14, color: colors.textSecondary),
+                ),
+              ],
+            ],
+          ),
         ),
-      ),
+        Divider(height: 1, color: colors.border),
+      ],
     );
   }
+
 }

+ 356 - 256
lib/features/report/expense_detail_report_page.dart

@@ -6,6 +6,7 @@ import '../../core/i18n/app_localizations.dart';
 import '../../core/theme/app_colors_extension.dart';
 import '../../shared/widgets/app_skeletons.dart';
 import 'package:skeletonizer/skeletonizer.dart';
+import '../../shared/widgets/list_footer.dart';
 import '../expense/expense_api.dart';
 import '../expense_apply/report_model.dart';
 import '../../core/utils/amount_utils.dart';
@@ -31,13 +32,22 @@ class _ExpenseDetailReportPageState
   int _detailPage = 1;
   int _detailTotal = 0;
   bool _detailLoading = false;
+  bool _detailLoadingMore = false;
+  final _scrollCtrl = ScrollController();
+  List<SubordinateReportItem> _subordinateData = [];
+  bool _subordinateLoading = false;
+  String _activeStartDate = '';
+  String _activeEndDate = '';
 
   @override
   void initState() {
     super.initState();
     final now = DateTime.now();
-    _startCtrl.text = '${now.year}-01-01';
-    _endCtrl.text = '${now.year}-12-31';
+    final elevenMonthsAgo = DateTime(now.year, now.month - 11, 1);
+    _startCtrl.text = '${elevenMonthsAgo.year}-${elevenMonthsAgo.month.toString().padLeft(2, '0')}-01';
+    final lastDay = DateTime(now.year, now.month + 1, 0).day;
+    _endCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${lastDay.toString().padLeft(2, '0')}';
+    _scrollCtrl.addListener(_onScroll);
     WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
   }
 
@@ -45,10 +55,13 @@ class _ExpenseDetailReportPageState
   void dispose() {
     _startCtrl.dispose();
     _endCtrl.dispose();
+    _scrollCtrl.dispose();
     super.dispose();
   }
 
   Future<void> _loadData() async {
+    _activeStartDate = _startCtrl.text;
+    _activeEndDate = _endCtrl.text;
     setState(() {
       _loading = true;
       _error = null;
@@ -65,6 +78,7 @@ class _ExpenseDetailReportPageState
           _loading = false;
         });
         _loadDetails();
+        _loadSubordinateData();
       }
     } catch (e) {
       if (mounted) {
@@ -84,7 +98,7 @@ class _ExpenseDetailReportPageState
       final result = await api.getExpenseReportDetail(
         startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
         endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
-        page: _detailPage,
+        page: 1,
       );
       if (!mounted) return;
       final list =
@@ -95,6 +109,7 @@ class _ExpenseDetailReportPageState
       setState(() {
         _details = list;
         _detailTotal = result['total'] as int? ?? 0;
+        _detailPage = 1;
         _detailLoading = false;
       });
     } catch (_) {
@@ -102,6 +117,78 @@ class _ExpenseDetailReportPageState
     }
   }
 
+  Future<void> _loadMoreDetails() async {
+    if (_detailLoadingMore) return;
+    if (_details.length >= _detailTotal) return;
+    setState(() => _detailLoadingMore = true);
+    try {
+      final api = ref.read(expenseApiProvider);
+      final result = await api.getExpenseReportDetail(
+        startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
+        endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
+        page: _detailPage + 1,
+      );
+      if (!mounted) return;
+      final list =
+          (result['list'] as List<dynamic>?)
+              ?.map((e) => ReportDetailItem.fromJson(e as Map<String, dynamic>))
+              .toList() ??
+          [];
+      setState(() {
+        _details = [..._details, ...list];
+        _detailPage++;
+        _detailLoadingMore = false;
+      });
+    } catch (_) {
+      if (mounted) setState(() => _detailLoadingMore = false);
+    }
+  }
+
+  void _onScroll() {
+    if (_scrollCtrl.position.pixels < _scrollCtrl.position.maxScrollExtent - 200) return;
+    if (_detailTotal > 0 && _details.length < _detailTotal && !_detailLoadingMore) {
+      _loadMoreDetails();
+    }
+  }
+
+  Future<void> _loadSubordinateData() async {
+    if (_subordinateLoading) return;
+    setState(() => _subordinateLoading = true);
+    try {
+      final api = ref.read(expenseApiProvider);
+      final data = await api.getExpenseSubordinateReport(
+        startDate: _startCtrl.text.isNotEmpty ? _startCtrl.text : null,
+        endDate: _endCtrl.text.isNotEmpty ? _endCtrl.text : null,
+      );
+      if (mounted) setState(() { _subordinateData = data; _subordinateLoading = false; });
+    } catch (_) {
+      if (mounted) setState(() => _subordinateLoading = false);
+    }
+  }
+
+  bool _validateDateRange() {
+    final l10n = AppLocalizations.of(context);
+    if (_startCtrl.text.isEmpty || _endCtrl.text.isEmpty) {
+      TDToast.showText(l10n.get('selectDateRange'), context: context);
+      return false;
+    }
+    final start = DateTime.tryParse(_activeStartDate);
+    final end = DateTime.tryParse(_activeEndDate);
+    if (start == null || end == null) {
+      TDToast.showText(l10n.get('selectDateRange'), context: context);
+      return false;
+    }
+    if (end.isBefore(start)) {
+      TDToast.showText(l10n.get('endDateBeforeStart'), context: context);
+      return false;
+    }
+    if (end.difference(start).inDays > 366) {
+      TDToast.showText(l10n.get('dateRangeLimit'), context: context);
+      return false;
+    }
+    return true;
+  }
+
   // ── 日期选择 ──
   void _pickDate(TextEditingController ctrl) {
     final l10n = AppLocalizations.of(context);
@@ -223,6 +310,7 @@ class _ExpenseDetailReportPageState
             const SizedBox(width: 8),
             GestureDetector(
               onTap: () {
+                if (!_validateDateRange()) return;
                 _details = [];
                 _detailPage = 1;
                 _detailTotal = 0;
@@ -246,7 +334,7 @@ class _ExpenseDetailReportPageState
     );
   }
 
-  // ── 5 张统计卡片(2+2+1)──
+  // ── 3 张统计卡片(2+1)──
   Widget _buildStatCards() {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
@@ -260,51 +348,25 @@ class _ExpenseDetailReportPageState
               Expanded(
                 child: _statCard(
                   l10n.get('statExpenseTotalCount'),
-                  '${summary.totalCount} ',
+                  '${summary.totalCount} ${l10n.get('unitItem')}',
                   colors.textPrimary,
                 ),
               ),
               const SizedBox(width: 8),
               Expanded(
                 child: _statCard(
-                  l10n.get('statTransferredToPmt'),
-                  '${summary.transferredCount} 笔',
-                  colors.textPrimary,
-                ),
-              ),
-            ],
-          ),
-          const SizedBox(height: 8),
-          Row(
-            children: [
-              Expanded(
-                child: _statCard(
                   l10n.get('statExpenseTotal'),
                   formatAmount(summary.totalAmount),
                   colors.amountPrimary,
                 ),
               ),
-              const SizedBox(width: 8),
-              Expanded(
-                child: _statCard(
-                  l10n.get('statTransferredToPmtAmount'),
-                  formatAmount(summary.transferredAmount),
-                  colors.textPrimary,
-                ),
-              ),
             ],
           ),
           const SizedBox(height: 8),
-          Row(
-            children: [
-              Expanded(
-                child: _statCard(
-                  l10n.get('statApprovedAmount'),
-                  formatAmount(summary.approvedAmount),
-                  colors.success,
-                ),
-              ),
-            ],
+          _statCard(
+            l10n.get('statApprovedAmount'),
+            formatAmount(summary.approvedAmount),
+            colors.success,
           ),
         ],
       ),
@@ -314,6 +376,7 @@ class _ExpenseDetailReportPageState
   Widget _statCard(String label, String value, Color valueColor) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     return Container(
+      width: double.infinity,
       padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8),
       decoration: BoxDecoration(
         color: colors.bgCard,
@@ -380,15 +443,13 @@ class _ExpenseDetailReportPageState
             const SizedBox(height: 8),
             Row(
               children: [
-                _legendDot(colors.primary, l10n.get('expenseAmount')),
-                const SizedBox(width: 16),
-                _legendDot(colors.warning, l10n.get('approvedAmountLabel')),
+                _legendDot(colors.amountPrimary, l10n.get('expenseAmount')),
                 const SizedBox(width: 16),
-                _legendDot(colors.success, l10n.get('transferredAmount')),
+                _legendDot(colors.success, l10n.get('approvedAmountLabel')),
               ],
             ),
-            const SizedBox(height: 12),
-            SizedBox(height: 200, child: _buildTripleLineChart()),
+            const SizedBox(height: 16),
+            SizedBox(height: 200, child: _buildDualLineChart()),
           ],
         ),
       ),
@@ -414,28 +475,42 @@ class _ExpenseDetailReportPageState
     );
   }
 
-  // 三折线图
-  Widget _buildTripleLineChart() {
+  // 双折线图(报销金额 + 核准金额)
+  Widget _buildDualLineChart() {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
 
-    // 从 monthly 构建 12 个月的数据,无数据的月份默认为 0
+    // 根据日期区间生成月份列表
+    final start = DateTime.tryParse(_activeStartDate);
+    final end = DateTime.tryParse(_activeEndDate);
+    final monthLabels = <String>[];
+    final monthKeys = <int>[];
+    if (start != null && end != null) {
+      var cur = DateTime(start.year, start.month, 1);
+      final endMonth = DateTime(end.year, end.month, 1);
+      while (!cur.isAfter(endMonth)) {
+        monthLabels.add('${cur.month}月');
+        monthKeys.add(cur.month);
+        cur = DateTime(cur.year, cur.month + 1, 1);
+      }
+    }
+
+    // 建立 API 数据索引
     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;
-    });
-    final transferredData = List.generate(12, (i) {
-      return monthlyMap[i + 1]?.transferredAmount ?? 0;
-    });
 
-    // 计算 Y 轴最大值
-    final allValues = [...amountData, ...approvedData, ...transferredData];
+    final amountData = monthKeys.map((m) => monthlyMap[m]?.amount ?? 0.0).toList();
+    final approvedData = monthKeys.map((m) => monthlyMap[m]?.approvedAmount ?? 0.0).toList();
+
+    if (amountData.isEmpty) {
+      return SizedBox(
+        height: 200,
+        child: Center(child: Text(AppLocalizations.of(context).get('noData'), style: TextStyle(fontSize: 14, color: colors.textPlaceholder))),
+      );
+    }
+
+    final allValues = [...amountData, ...approvedData];
     final maxVal = allValues.reduce((a, b) => a > b ? a : b);
     final maxY = (maxVal > 0 ? maxVal * 1.2 : 100).toDouble();
 
@@ -444,7 +519,7 @@ class _ExpenseDetailReportPageState
         gridData: FlGridData(
           show: true,
           drawVerticalLine: false,
-          horizontalInterval: maxY / 5,
+          horizontalInterval: maxY / 4,
           getDrawingHorizontalLine: (v) =>
               FlLine(color: colors.border, strokeWidth: 0.5),
         ),
@@ -452,7 +527,8 @@ class _ExpenseDetailReportPageState
           leftTitles: AxisTitles(
             sideTitles: SideTitles(
               showTitles: true,
-              reservedSize: 48,
+              reservedSize: 80,
+              interval: maxY / 4,
               getTitlesWidget: (v, meta) => Text(
                 formatAmount(v.toDouble()),
                 style: TextStyle(fontSize: 10, color: colors.textPlaceholder),
@@ -503,59 +579,36 @@ class _ExpenseDetailReportPageState
           ),
         ),
         lineBarsData: [
-          // 报销金额 - primary/蓝
           LineChartBarData(
             spots: List.generate(
-              12,
+              amountData.length,
               (i) => FlSpot(i.toDouble(), amountData[i]),
             ),
             isCurved: true,
-            color: colors.primary,
+            preventCurveOverShooting: true,
+            color: colors.amountPrimary,
             barWidth: 2.5,
             dotData: FlDotData(
               show: true,
               getDotPainter: (spot, percent, barData, index) =>
                   FlDotCirclePainter(
                     radius: 3,
-                    color: colors.primary,
+                    color: colors.amountPrimary,
                     strokeWidth: 0,
                   ),
             ),
             belowBarData: BarAreaData(
               show: true,
-              color: colors.primary.withValues(alpha: 0.08),
+              color: colors.amountPrimary.withValues(alpha: 0.08),
             ),
           ),
-          // 核准金额 - warning/橙
           LineChartBarData(
             spots: List.generate(
-              12,
+              approvedData.length,
               (i) => FlSpot(i.toDouble(), approvedData[i]),
             ),
             isCurved: true,
-            color: colors.warning,
-            barWidth: 2.5,
-            dotData: FlDotData(
-              show: true,
-              getDotPainter: (spot, percent, barData, index) =>
-                  FlDotCirclePainter(
-                    radius: 3,
-                    color: colors.warning,
-                    strokeWidth: 0,
-                  ),
-            ),
-            belowBarData: BarAreaData(
-              show: true,
-              color: colors.warning.withValues(alpha: 0.08),
-            ),
-          ),
-          // 已转收支单金额 - success/绿
-          LineChartBarData(
-            spots: List.generate(
-              12,
-              (i) => FlSpot(i.toDouble(), transferredData[i]),
-            ),
-            isCurved: true,
+            preventCurveOverShooting: true,
             color: colors.success,
             barWidth: 2.5,
             dotData: FlDotData(
@@ -577,6 +630,92 @@ class _ExpenseDetailReportPageState
     );
   }
 
+  // ── 柱形图(下属报销金额对比) ──
+  Widget _buildBarChartSection() {
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    if (_subordinateLoading && _subordinateData.isEmpty) {
+      return const Padding(
+        padding: EdgeInsets.only(top: 16),
+        child: Center(child: TDLoading(size: TDLoadingSize.small, icon: TDLoadingIcon.activity)),
+      );
+    }
+    if (_subordinateData.isEmpty) return const SizedBox.shrink();
+
+    return Padding(
+      padding: const EdgeInsets.symmetric(horizontal: 12),
+      child: Container(
+        width: double.infinity,
+        padding: const EdgeInsets.all(16),
+        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: [
+            Text(
+              l10n.get('chartTitle8'),
+              style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.textPrimary),
+            ),
+            const SizedBox(height: 16),
+            SizedBox(
+              height: 220,
+              child: SingleChildScrollView(
+                scrollDirection: Axis.horizontal,
+                child: SizedBox(
+                  width: (_subordinateData.length * 60.0).clamp(MediaQuery.of(context).size.width - 56, double.infinity),
+                  child: BarChart(
+                BarChartData(
+                  alignment: _subordinateData.length == 1 ? BarChartAlignment.center : BarChartAlignment.spaceAround,
+                  maxY: (_subordinateData.map((e) => e.amount).reduce((a, b) => a > b ? a : b) * 1.2).clamp(1, double.infinity),
+                  barGroups: _subordinateData.asMap().entries.map((e) {
+                    final barColors = [colors.chart1, colors.chart2, colors.chart3, colors.chart4, colors.chart5, colors.primary, colors.success, colors.warning, colors.infoText, colors.danger];
+                    return BarChartGroupData(
+                      x: e.key,
+                      barRods: [BarChartRodData(toY: e.value.amount, color: barColors[e.key % barColors.length], width: _subordinateData.length == 1 ? 40 : 20, borderRadius: const BorderRadius.vertical(top: Radius.circular(4)))],
+                    );
+                  }).toList(),
+                  titlesData: FlTitlesData(
+                    leftTitles: AxisTitles(
+                      sideTitles: SideTitles(showTitles: true, reservedSize: 80, interval: (_subordinateData.map((e) => e.amount).reduce((a, b) => a > b ? a : b) * 1.2).clamp(1, double.infinity) / 4, getTitlesWidget: (v, _) => Text(formatAmount(v), style: TextStyle(fontSize: 10, color: colors.textPlaceholder))),
+                    ),
+                    bottomTitles: AxisTitles(
+                      sideTitles: SideTitles(
+                        showTitles: true,
+                        reservedSize: 40,
+                        getTitlesWidget: (v, _) {
+                          final i = v.toInt();
+                          if (i < 0 || i >= _subordinateData.length) return const SizedBox();
+                          return Padding(
+                            padding: const EdgeInsets.only(top: 4),
+                            child: Text(
+                              _subordinateData[i].usrName.length > 4 ? '${_subordinateData[i].usrName.substring(0, 4)}…' : _subordinateData[i].usrName,
+                              style: TextStyle(fontSize: 9, color: colors.textPlaceholder),
+                              maxLines: 2,
+                              overflow: TextOverflow.ellipsis,
+                            ),
+                          );
+                        },
+                      ),
+                    ),
+                    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: (_subordinateData.map((e) => e.amount).reduce((a, b) => a > b ? a : b) * 1.2).clamp(1, double.infinity) / 5, getDrawingHorizontalLine: (v) => FlLine(color: colors.border, strokeWidth: 0.5)),
+                ),
+              ),
+            ),
+            ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
   @override
   Widget build(BuildContext context) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
@@ -645,11 +784,14 @@ class _ExpenseDetailReportPageState
       );
     }
     return SingleChildScrollView(
+      controller: _scrollCtrl,
       child: Column(
         children: [
           _buildDateFilter(),
           _buildStatCards(),
           _buildChartSection(),
+          _buildBarChartSection(),
+          const SizedBox(height: 12),
           _buildDetailList(),
           const SizedBox(height: 80),
         ],
@@ -657,7 +799,7 @@ class _ExpenseDetailReportPageState
     );
   }
 
-  // ── 明细列表(TDTable)──
+  // ── 明细列表 ──
   Widget _buildDetailList() {
     final l10n = AppLocalizations.of(context);
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
@@ -699,21 +841,10 @@ class _ExpenseDetailReportPageState
       );
     }
 
-    final tableData = _details.map((d) => {
-      'billNo': d.billNo,
-      'billDate': d.billDate != null && d.billDate!.length >= 10
-          ? d.billDate!.substring(0, 10)
-          : '-',
-      'amount': d.amount,
-      'approvedAmount': d.approvedAmount,
-      'reason': d.reason,
-      'isTransferred': d.isTransferred ? '1' : '',
-    }).toList();
-
-    final totalPages = (_detailTotal / 20).ceil();
+    final hasMore = _details.length < _detailTotal;
 
     return Padding(
-      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+      padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
       child: Container(
         width: double.infinity,
         decoration: BoxDecoration(
@@ -721,6 +852,7 @@ class _ExpenseDetailReportPageState
           borderRadius: BorderRadius.circular(8),
         ),
         child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
           children: [
             Padding(
               padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
@@ -734,172 +866,140 @@ class _ExpenseDetailReportPageState
                       color: colors.textPrimary,
                     ),
                   ),
+                  const Spacer(),
+                  Text(
+                    '${l10n.get('total')} $_detailTotal ${l10n.get('unitItem')}',
+                    style: TextStyle(fontSize: 12, color: colors.textSecondary),
+                  ),
                 ],
               ),
             ),
-            TDTable(
-              bordered: true,
-              stripe: true,
-              columns: [
-                TDTableCol(
-                  title: l10n.get('expenseNo'),
-                  colKey: 'billNo',
-                  width: 180,
-                  ellipsis: true,
-                ),
-                TDTableCol(
-                  title: l10n.get('date'),
-                  colKey: 'billDate',
-                  width: 150,
-                ),
-                TDTableCol(
-                  title: l10n.get('expenseAmount'),
-                  colKey: 'amount',
-                  width: 180,
-                  align: TDTableColAlign.right,
-                  cellBuilder: (context, rowIndex) {
-                    final amount = (tableData[rowIndex]['amount'] as num).toDouble();
-                    return Text(
-                      formatAmount(amount),
-                      style: TextStyle(
-                        fontSize: 13,
-                        color: colors.amountPrimary,
-                        fontWeight: FontWeight.w600,
+            Divider(height: 1, color: colors.border),
+            ListView.builder(
+              shrinkWrap: true,
+              physics: const NeverScrollableScrollPhysics(),
+              padding: EdgeInsets.zero,
+              itemCount: _details.length + 1,
+              itemBuilder: (_, i) {
+                if (i == _details.length) {
+                  if (_detailLoadingMore) {
+                    return const Padding(
+                      padding: EdgeInsets.symmetric(vertical: 16),
+                      child: Center(
+                        child: TDLoading(
+                          size: TDLoadingSize.small,
+                          icon: TDLoadingIcon.activity,
+                        ),
                       ),
                     );
-                  },
-                ),
-                TDTableCol(
-                  title: l10n.get('approvedAmountLabel'),
-                  colKey: 'approvedAmount',
-                  width: 180,
-                  align: TDTableColAlign.right,
-                  cellBuilder: (context, rowIndex) {
-                    final amount = (tableData[rowIndex]['approvedAmount'] as num).toDouble();
-                    return Text(
-                      formatAmount(amount),
+                  }
+                  return ListFooter(
+                    itemCount: _details.length,
+                    hasMore: hasMore,
+                  );
+                }
+                return _buildDetailItem(_details[i]);
+              },
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _buildDetailItem(ReportDetailItem d) {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final l10n = AppLocalizations.of(context);
+    final billDate = d.billDate != null && d.billDate!.length >= 10
+        ? d.billDate!.substring(0, 10)
+        : '-';
+    return Column(
+      children: [
+        Padding(
+          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Row(
+                mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                children: [
+                  Flexible(
+                    child: Text(
+                      d.billNo,
+                      maxLines: 1,
+                      overflow: TextOverflow.ellipsis,
                       style: TextStyle(
-                        fontSize: 13,
-                        color: colors.amountPrimary,
+                        fontSize: 14,
                         fontWeight: FontWeight.w600,
+                        color: colors.textPrimary,
                       ),
-                    );
-                  },
-                ),
-                TDTableCol(
-                  title: l10n.get('expenseApplyReason'),
-                  colKey: 'reason',
-                  width: 200,
-                  ellipsis: true,
+                    ),
+                  ),
+                  const SizedBox(width: 12),
+                  Text(
+                    formatAmount(d.amount),
+                    maxLines: 1,
+                    overflow: TextOverflow.ellipsis,
+                    style: TextStyle(
+                      fontSize: 16,
+                      fontWeight: FontWeight.w700,
+                      color: colors.amountPrimary,
+                    ),
+                  ),
+                ],
+              ),
+              const SizedBox(height: 6),
+              Row(
+                mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                children: [
+                  Text(
+                    billDate,
+                    style: TextStyle(fontSize: 12, color: colors.textSecondary),
+                  ),
+                  if (d.approvedAmount > 0)
+                    Text(
+                      formatAmount(d.approvedAmount),
+                      style: TextStyle(
+                        fontSize: 16,
+                        fontWeight: FontWeight.w700,
+                        color: colors.success,
+                      ),
+                    ),
+                ],
+              ),
+              if (d.reason.isNotEmpty) ...[
+                const SizedBox(height: 6),
+                Text(
+                  d.reason,
+                  maxLines: 2,
+                  overflow: TextOverflow.ellipsis,
+                  style: TextStyle(fontSize: 14, color: colors.textSecondary),
                 ),
-                TDTableCol(
-                  title: l10n.get('transferStatus'),
-                  colKey: 'isTransferred',
-                  width: 150,
-                  align: TDTableColAlign.center,
-                  cellBuilder: (context, rowIndex) {
-                    final transferred = tableData[rowIndex]['isTransferred'] as String;
-                    return transferred == '1'
-                        ? Container(
-                            padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
-                            decoration: BoxDecoration(
-                              color: colors.success.withValues(alpha: 0.1),
-                              borderRadius: BorderRadius.circular(10),
-                              border: Border.all(
-                                  color: colors.success, width: 0.5),
-                            ),
-                            child: Text(
-                              l10n.get('statusTransferred'),
-                              style: TextStyle(
-                                fontSize: 11,
-                                color: colors.success,
-                                fontWeight: FontWeight.w500,
-                              ),
-                            ),
-                          )
-                        : const SizedBox.shrink();
-                  },
+              ],
+              if (d.isTransferred) ...[
+                const SizedBox(height: 6),
+                Align(
+                  alignment: Alignment.centerRight,
+                  child: Container(
+                    padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
+                    decoration: BoxDecoration(
+                      color: colors.primary.withValues(alpha: 0.1),
+                      borderRadius: BorderRadius.circular(4),
+                      border: Border.all(color: colors.primary, width: 0.5),
+                    ),
+                    child: Text(
+                      l10n.get('statusTransferred'),
+                      style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: colors.primary),
+                    ),
+                  ),
                 ),
               ],
-              data: tableData,
-              empty: TDTableEmpty(text: l10n.get('noDetailData')),
-              footerWidget: _detailTotal > 0
-                  ? Container(
-                      padding: const EdgeInsets.symmetric(
-                        horizontal: 16,
-                        vertical: 10,
-                      ),
-                      decoration: BoxDecoration(
-                        border: Border(
-                          top: BorderSide(color: colors.border, width: 0.5),
-                        ),
-                      ),
-                      child: Row(
-                        mainAxisAlignment: MainAxisAlignment.center,
-                        children: [
-                          GestureDetector(
-                            onTap: _detailPage > 1
-                                ? () {
-                                    _detailPage--;
-                                    _loadDetails();
-                                  }
-                                : null,
-                            child: Icon(
-                              Icons.chevron_left,
-                              size: 20,
-                              color: _detailPage > 1
-                                  ? colors.textPrimary
-                                  : colors.textPlaceholder,
-                            ),
-                          ),
-                          const SizedBox(width: 16),
-                          Text(
-                            '$_detailPage',
-                            style: TextStyle(
-                              fontSize: 13,
-                              color: colors.textPrimary,
-                            ),
-                          ),
-                          const SizedBox(width: 4),
-                          Text(
-                            '/ $totalPages',
-                            style: TextStyle(
-                              fontSize: 13,
-                              color: colors.textSecondary,
-                            ),
-                          ),
-                          const SizedBox(width: 16),
-                          GestureDetector(
-                            onTap: _detailPage < totalPages
-                                ? () {
-                                    _detailPage++;
-                                    _loadDetails();
-                                  }
-                                : null,
-                            child: Icon(
-                              Icons.chevron_right,
-                              size: 20,
-                              color: _detailPage < totalPages
-                                  ? colors.textPrimary
-                                  : colors.textPlaceholder,
-                            ),
-                          ),
-                          const SizedBox(width: 16),
-                          Text(
-                            '${l10n.get('total')} $_detailTotal ${l10n.get('items')}',
-                            style: TextStyle(
-                              fontSize: 12,
-                              color: colors.textSecondary,
-                            ),
-                          ),
-                        ],
-                      ),
-                    )
-                  : null,
-            ),
-          ],
+            ],
+          ),
         ),
-      ),
+        Divider(height: 1, color: colors.border),
+      ],
     );
   }
+
 }

+ 12 - 0
lib/shared/widgets/attachment_picker.dart

@@ -225,6 +225,14 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
     final available = widget.controller.maxCount - widget.controller.count;
     if (available <= 0) return;
     final picker = ImagePicker();
+    // pickMultiImage 在某些平台上 limit=1 时无响应,兜底用单选
+    if (available == 1) {
+      final img = await picker.pickImage(source: ImageSource.gallery);
+      if (img == null) return;
+      final file = await AttachmentFile.fromXFile(img);
+      if (!_checkOversized(file)) widget.controller.addFile(file);
+      return;
+    }
     final images = await picker.pickMultiImage(limit: available);
     if (!mounted || images.isEmpty) return;
     for (final img in images) {
@@ -245,6 +253,10 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
       allowMultiple: true,
     );
     if (!mounted || result == null || result.files.isEmpty) return;
+    if (result.files.length > available) {
+      final l10n = AppLocalizations.of(context);
+      TDToast.showText(l10n.getString('fileLimitHint', args: {'limit': '$available'}), context: context);
+    }
     for (final pf in result.files) {
       if (widget.controller.isFull) break;
       if (pf.path == null) continue;

+ 144 - 0
lib/shared/widgets/audit_trail_dialog.dart

@@ -0,0 +1,144 @@
+import 'package:flutter/material.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+import '../../core/theme/app_colors.dart';
+import '../../core/theme/app_colors_extension.dart';
+import '../../core/i18n/app_localizations.dart';
+
+/// 审核过程明细弹窗
+///
+/// 调用 [AuditTrailDialog.show] 弹出,自动拉取数据并展示。
+class AuditTrailDialog extends StatelessWidget {
+  final Future<List<Map<String, dynamic>>> Function() fetcher;
+
+  const AuditTrailDialog({super.key, required this.fetcher});
+
+  /// 显示弹窗,[fetcher] 负责调 API 获取数据。
+  static Future<void> show(
+    BuildContext context, {
+    required Future<List<Map<String, dynamic>>> Function() fetcher,
+  }) {
+    return Navigator.push(
+      context,
+      TDSlidePopupRoute(
+        slideTransitionFrom: SlideTransitionFrom.bottom,
+        builder: (_) => AuditTrailDialog(fetcher: fetcher),
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    return FutureBuilder<List<Map<String, dynamic>>>(
+      future: fetcher(),
+      builder: (ctx, snapshot) {
+        return SafeArea(
+          child: SizedBox(
+            height: MediaQuery.of(context).size.height * 0.8,
+            child: Container(
+              decoration: BoxDecoration(
+                color: colors.bgPage,
+                borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
+              ),
+              child: Column(
+                children: [
+                  Padding(
+                    padding: const EdgeInsets.fromLTRB(20, 16, 12, 12),
+                    child: Row(
+                      children: [
+                        Text(
+                          l10n.get('auditTrail'),
+                          style: TextStyle(fontSize: AppFontSizes.title, fontWeight: FontWeight.w600, color: colors.textPrimary),
+                        ),
+                        const Spacer(),
+                        GestureDetector(
+                          onTap: () => Navigator.pop(context),
+                          child: Icon(Icons.close, size: 20, color: colors.textSecondary),
+                        ),
+                      ],
+                    ),
+                  ),
+                  Divider(height: 1, color: colors.border),
+                  Flexible(
+                    child: _buildContent(snapshot, colors),
+                  ),
+                ],
+              ),
+            ),
+          ),
+        );
+      },
+    );
+  }
+
+  Widget _buildContent(AsyncSnapshot<List<Map<String, dynamic>>> snapshot, AppColorsExtension colors) {
+    if (snapshot.connectionState == ConnectionState.waiting) {
+      return const Center(child: TDLoading(size: TDLoadingSize.medium, icon: TDLoadingIcon.activity));
+    }
+    if (snapshot.hasError || !snapshot.hasData || snapshot.data!.isEmpty) {
+      return const Center(child: Text('—', style: TextStyle(fontSize: 14, color: Color(0xFF999999))));
+    }
+    final items = snapshot.data!;
+    return ListView.builder(
+      shrinkWrap: true,
+      padding: const EdgeInsets.all(16),
+      itemCount: items.length,
+      itemBuilder: (_, i) {
+        final item = items[i];
+        final action = item['action']?.toString() ?? '';
+        Color iconColor;
+        IconData icon;
+        if (action == 'Y') {
+          icon = Icons.check_circle;
+          iconColor = colors.success;
+        } else if (action == 'N') {
+          icon = Icons.cancel;
+          iconColor = colors.danger;
+        } else {
+          icon = Icons.hourglass_empty;
+          iconColor = colors.infoText;
+        }
+        return Padding(
+          padding: const EdgeInsets.only(bottom: 12),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Row(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  Icon(icon, size: 18, color: iconColor),
+                  const SizedBox(width: 8),
+                  Expanded(
+                    child: Column(
+                      crossAxisAlignment: CrossAxisAlignment.start,
+                      children: [
+                        Row(
+                          children: [
+                            Expanded(child: Text(item['user']?.toString() ?? '', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.textPrimary))),
+                            Text(item['date']?.toString() ?? '', style: TextStyle(fontSize: 12, color: colors.textSecondary)),
+                          ],
+                        ),
+                        if ((item['node']?.toString() ?? '').isNotEmpty) ...[
+                          const SizedBox(height: 2),
+                          Text(item['node']?.toString() ?? '', style: TextStyle(fontSize: 13, color: colors.textSecondary)),
+                        ],
+                      ],
+                    ),
+                  ),
+                ],
+              ),
+              if ((item['remark']?.toString() ?? '').isNotEmpty) ...[
+                const SizedBox(height: 4),
+                Padding(
+                  padding: const EdgeInsets.only(left: 26),
+                  child: Text(item['remark']?.toString() ?? '', style: TextStyle(fontSize: 13, color: colors.textSecondary)),
+                ),
+              ],
+            ],
+          ),
+        );
+      },
+    );
+  }
+}

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

@@ -12,6 +12,7 @@ class FormSection extends StatelessWidget {
   final IconData? actionIcon;
   final VoidCallback? onActionTap;
   final bool showAction;
+  final Widget? trailing;
   final List<Widget> children;
 
   const FormSection({
@@ -22,6 +23,7 @@ class FormSection extends StatelessWidget {
     this.actionIcon,
     this.onActionTap,
     this.showAction = false,
+    this.trailing,
     required this.children,
   });
 
@@ -57,6 +59,7 @@ class FormSection extends StatelessWidget {
                   ),
                 ],
               ),
+              if (trailing != null) trailing!, // ignore: prefer_null_aware_operator
               if (showAction)
                 GestureDetector(
                   onTap: onActionTap,

+ 25 - 22
lib/shared/widgets/list_card.dart

@@ -70,29 +70,32 @@ class ListCard extends StatelessWidget {
                 ),
               ],
             ),
-            if (subAmount != null && subAmount!.isNotEmpty) ...[
-              const SizedBox(height: 2),
-              Align(
-                alignment: Alignment.centerRight,
-                child: Text(
-                  subAmount!,
-                  maxLines: 1,
-                  overflow: TextOverflow.ellipsis,
-                  style: TextStyle(
-                    fontSize: 16,
-                    fontWeight: FontWeight.w700,
-                    color: Colors.green,
-                  ),
-                ),
-              ),
-            ],
-            if (sub.isNotEmpty) ...[
+            if (sub.isNotEmpty || (subAmount != null && subAmount!.isNotEmpty)) ...[
               const SizedBox(height: 4),
-              Text(
-                sub,
-                maxLines: 1,
-                overflow: TextOverflow.ellipsis,
-                style: TextStyle(fontSize: 13, color: colors.textSecondary),
+              Row(
+                mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                children: [
+                  if (sub.isNotEmpty)
+                    Flexible(
+                      child: Text(
+                        sub,
+                        maxLines: 1,
+                        overflow: TextOverflow.ellipsis,
+                        style: TextStyle(fontSize: 13, color: colors.textSecondary),
+                      ),
+                    ),
+                  if (subAmount != null && subAmount!.isNotEmpty)
+                    Text(
+                      subAmount!,
+                      maxLines: 1,
+                      overflow: TextOverflow.ellipsis,
+                      style: TextStyle(
+                        fontSize: 16,
+                        fontWeight: FontWeight.w700,
+                        color: Colors.green,
+                      ),
+                    ),
+                ],
               ),
             ],
             if (desc.isNotEmpty) ...[