chengc 3 days ago
parent
commit
e709407e59

+ 54 - 3
lib/features/expense/expense_api.dart

@@ -1,8 +1,11 @@
+import 'dart:convert';
+import 'package:dio/dio.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import '../../core/network/api_client.dart';
 import '../../core/network/api_client.dart';
 import '../../core/network/api_response.dart';
 import '../../core/network/api_response.dart';
 import '../../app.dart';
 import '../../app.dart';
 import '../../shared/models/pagination_model.dart';
 import '../../shared/models/pagination_model.dart';
+import '../../shared/models/bill_attachment.dart';
 import '../../core/utils/date_utils.dart' as du;
 import '../../core/utils/date_utils.dart' as du;
 import 'expense_model.dart';
 import 'expense_model.dart';
 import 'expense_list_controller.dart';
 import 'expense_list_controller.dart';
@@ -127,14 +130,28 @@ class ExpenseApi {
     return ExpenseModel.fromJson(response.data!);
     return ExpenseModel.fromJson(response.data!);
   }
   }
 
 
-  /// 提交审批
-  Future<void> submit(Map<String, dynamic> data) async {
-    await _client.post('/OA/BillSave', data: {
+  /// 提交审批,返回报销单号
+  Future<String> submit(Map<String, dynamic> data) async {
+    final response = await _client.post<Map<String, dynamic>>('/OA/BillSave', data: {
       'erpCategory': 'MasterService',
       'erpCategory': 'MasterService',
       'billId': 'BX',
       'billId': 'BX',
       'procId': '',
       'procId': '',
       'data': data,
       'data': data,
     });
     });
+    final resData = response.data;
+    if (resData != null) {
+      final resultData = resData['resultData'] as Map<String, dynamic>?;
+      if (resultData != null) {
+        final bxNo = resultData['BX_NO'] as String?;
+        if (bxNo != null && bxNo.isNotEmpty) return bxNo;
+        final headData = resultData['HeadData'] as Map<String, dynamic>?;
+        if (headData != null) {
+          final hdBxNo = headData['BX_NO'] as String?;
+          if (hdBxNo != null && hdBxNo.isNotEmpty) return hdBxNo;
+        }
+      }
+    }
+    throw Exception('BillSave succeeded but could not extract bill number');
   }
   }
 
 
   /// 财务核销
   /// 财务核销
@@ -259,6 +276,40 @@ class ExpenseApi {
     return response.data!;
     return response.data!;
   }
   }
 
 
+  /// 获取单据附件列表
+  Future<List<BillAttachment>> getAttachments(String bilId, String bilNo, {int? srcItm}) async {
+    final params = <String, dynamic>{
+      'bilId': bilId,
+      'bilNo': bilNo,
+    };
+    if (srcItm != null) params['srcItm'] = srcItm;
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetAttachments',
+      queryParameters: params,
+    );
+    final data = response.data;
+    // data 可能是 { Result: { list: [...] } } 或直接是 list
+    final list = (data?['Result']?['list'] as List<dynamic>?) ??
+        (data?['list'] as List<dynamic>?) ??
+        [];
+    return list.map((e) => BillAttachment.fromJson(e as Map<String, dynamic>)).toList();
+  }
+
+  /// 上传附件
+  /// [metadata] Map: { BIL_ID, BIL_NO, SRCITM, ITM, TAG, EFF_DD, USR, FILENAME, EXT }
+  Future<Map<String, dynamic>> uploadAttachment(
+    String filePath,
+    Map<String, dynamic> metadata,
+  ) async {
+    final fileName = (metadata['FILENAME'] as String?) ?? filePath.split('/').last;
+    final response = await _client.uploadMultipart<Map<String, dynamic>>(
+      '/OA/UploadAttachment',
+      files: [await MultipartFile.fromFile(filePath, filename: fileName)],
+      extraFields: {'metadata': json.encode(metadata)}, // proper JSON encoding
+    );
+    return response.data ?? {};
+  }
+
   /// 审核执行(通过/驳回/反审核)
   /// 审核执行(通过/驳回/反审核)
   Future<Map<String, dynamic>> executeApproval({
   Future<Map<String, dynamic>> executeApproval({
     required String bilId, required String bilNo, int bilItm = 0,
     required String bilId, required String bilNo, int bilItm = 0,

+ 16 - 1
lib/features/expense/expense_apply_import_page.dart

@@ -85,7 +85,8 @@ class ExpenseApplyImportPage extends ConsumerStatefulWidget {
   ConsumerState<ExpenseApplyImportPage> createState() => _ExpenseApplyImportPageState();
   ConsumerState<ExpenseApplyImportPage> createState() => _ExpenseApplyImportPageState();
 }
 }
 
 
-class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage> {
+class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
+    with WidgetsBindingObserver {
   final _aeNoCtrl = TextEditingController();
   final _aeNoCtrl = TextEditingController();
   final _startDateCtrl = TextEditingController();
   final _startDateCtrl = TextEditingController();
   final _endDateCtrl = TextEditingController();
   final _endDateCtrl = TextEditingController();
@@ -105,17 +106,31 @@ class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
     _endDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${DateTime(now.year, now.month + 1, 0).day.toString().padLeft(2, '0')}';
     _endDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${DateTime(now.year, now.month + 1, 0).day.toString().padLeft(2, '0')}';
     _scrollCtrl = ScrollController()..addListener(_onScroll);
     _scrollCtrl = ScrollController()..addListener(_onScroll);
     _refreshCtrl = EasyRefreshController();
     _refreshCtrl = EasyRefreshController();
+    WidgetsBinding.instance.addObserver(this);
     WidgetsBinding.instance.addPostFrameCallback((_) => _load());
     WidgetsBinding.instance.addPostFrameCallback((_) => _load());
   }
   }
 
 
   @override
   @override
   void dispose() {
   void dispose() {
+    WidgetsBinding.instance.removeObserver(this);
     _aeNoCtrl.dispose(); _startDateCtrl.dispose(); _endDateCtrl.dispose();
     _aeNoCtrl.dispose(); _startDateCtrl.dispose(); _endDateCtrl.dispose();
     _scrollCtrl.dispose();
     _scrollCtrl.dispose();
     _refreshCtrl.dispose();
     _refreshCtrl.dispose();
     super.dispose();
     super.dispose();
   }
   }
 
 
+  @override
+  void didChangeAppLifecycleState(AppLifecycleState state) {
+    if (state == AppLifecycleState.resumed) {
+      _aeNoCtrl.clear();
+      final now = DateTime.now();
+      _startDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
+      _endDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${DateTime(now.year, now.month + 1, 0).day.toString().padLeft(2, '0')}';
+      _items = []; _page = 1; _hasMore = true; _generation++;
+      _load();
+    }
+  }
+
   void _onScroll() {
   void _onScroll() {
     if (!_loading && _hasMore && _scrollCtrl.position.pixels >= _scrollCtrl.position.maxScrollExtent - 200) {
     if (!_loading && _hasMore && _scrollCtrl.position.pixels >= _scrollCtrl.position.maxScrollExtent - 200) {
       _load(append: true);
       _load(append: true);

+ 25 - 1
lib/features/expense/expense_create_page.dart

@@ -988,7 +988,31 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
         LoadingDialog.show(context, text: l10n.get('submitting'));
         LoadingDialog.show(context, text: l10n.get('submitting'));
         try {
         try {
           final data = _buildSubmitData(state);
           final data = _buildSubmitData(state);
-          await ref.read(expenseApiProvider).submit(data);
+          final api = ref.read(expenseApiProvider);
+          final billNo = await api.submit(data);
+          // 上传附件(表头 + 表身)
+          if (_attachmentController.files.isNotEmpty) {
+            final today = _today();
+            final usr = HostAppChannel.usr;
+            for (var i = 0; i < _attachmentController.files.length; i++) {
+              final file = _attachmentController.files[i];
+              try {
+                await api.uploadAttachment(file.path, {
+                  'BIL_ID': 'BX',
+                  'BIL_NO': billNo,
+                  'SRCITM': 0,
+                  'ITM': i + 1,
+                  'TAG': 1,
+                  'EFF_DD': today,
+                  'USR': usr,
+                  'FILENAME': file.name,
+                  'EXT': file.name.split('.').last,
+                });
+              } catch (_) {
+                // Attachment upload failure is non-fatal
+              }
+            }
+          }
           await ExpenseCreateController.deleteDraft();
           await ExpenseCreateController.deleteDraft();
           if (mounted) {
           if (mounted) {
             LoadingDialog.hide(context);
             LoadingDialog.hide(context);

+ 248 - 392
lib/features/expense/expense_detail_page.dart

@@ -6,13 +6,12 @@ import '../../shared/widgets/nav_bar_config.dart';
 import '../../core/utils/date_utils.dart' as du;
 import '../../core/utils/date_utils.dart' as du;
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
 import '../../shared/widgets/form_field_row.dart';
-import '../../shared/widgets/status_banner.dart';
 import 'expense_model.dart';
 import 'expense_model.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../shared/widgets/loading_dialog.dart';
 import '../../shared/widgets/loading_dialog.dart';
+import '../../shared/models/bill_attachment.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors_extension.dart';
 import '../../core/theme/app_colors_extension.dart';
-import '../../core/auth/role_provider.dart';
 import 'expense_api.dart';
 import 'expense_api.dart';
 import '../../shared/widgets/approval_actions.dart';
 import '../../shared/widgets/approval_actions.dart';
 import '../../shared/widgets/approval_timeline.dart';
 import '../../shared/widgets/approval_timeline.dart';
@@ -29,6 +28,7 @@ class ExpenseDetailPage extends ConsumerStatefulWidget {
 
 
 class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
 class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
   ExpenseModel? _expense;
   ExpenseModel? _expense;
+  List<BillAttachment> _attachments = [];
   List<ApprovalRecord> _timelineRecords = [];
   List<ApprovalRecord> _timelineRecords = [];
   List<String> _timelineChain = [];
   List<String> _timelineChain = [];
   String _timelineCurrentApproverId = '';
   String _timelineCurrentApproverId = '';
@@ -75,6 +75,13 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
           _timelineCurrentApproverId = expense.currentApproverId;
           _timelineCurrentApproverId = expense.currentApproverId;
         });
         });
       }
       }
+
+      // 3. 加载附件(非致命)
+      try {
+        _attachments = await api.getAttachments('BX', widget.billNo);
+      } catch (_) {
+        _attachments = [];
+      }
     } catch (e) {
     } catch (e) {
       setState(() => _error = e.toString());
       setState(() => _error = e.toString());
     } finally {
     } finally {
@@ -86,8 +93,6 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
   Widget build(BuildContext context) {
   Widget build(BuildContext context) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
     final l10n = AppLocalizations.of(context);
-    final isFinance = ref.watch(isFinanceProvider);
-    final isAdmin = ref.watch(isAdminProvider);
 
 
     ref.read(navBarConfigProvider.notifier).update(
     ref.read(navBarConfigProvider.notifier).update(
           NavBarConfig(
           NavBarConfig(
@@ -138,25 +143,13 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
             padding: const EdgeInsets.all(16),
             padding: const EdgeInsets.all(16),
             child: Column(
             child: Column(
               children: [
               children: [
-                _buildStatusBanner(expense, l10n, colors),
-                const SizedBox(height: 8),
-                _buildSubmitTime(expense, l10n, colors),
-                const SizedBox(height: 16),
                 _buildBasicInfoSection(expense, l10n, colors),
                 _buildBasicInfoSection(expense, l10n, colors),
                 const SizedBox(height: 16),
                 const SizedBox(height: 16),
                 _buildExpenseDetailSection(expense, l10n, colors),
                 _buildExpenseDetailSection(expense, l10n, colors),
                 const SizedBox(height: 16),
                 const SizedBox(height: 16),
-                _buildAttachmentSection(expense, l10n, colors),
-                if (isFinance) ...[
-                  const SizedBox(height: 16),
-                  _buildComplianceSection(expense, l10n, colors),
-                ],
+                _buildAttachmentSection(l10n, colors),
                 const SizedBox(height: 16),
                 const SizedBox(height: 16),
                 _buildApprovalSection(l10n, colors),
                 _buildApprovalSection(l10n, colors),
-                if (isFinance || isAdmin) ...[
-                  const SizedBox(height: 16),
-                  _buildArchiveSection(expense, l10n, colors),
-                ],
               ],
               ],
             ),
             ),
           ),
           ),
@@ -175,45 +168,22 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
 
 
   void _handleApprove() async {
   void _handleApprove() async {
     final l10n = AppLocalizations.of(context);
     final l10n = AppLocalizations.of(context);
-    final confirmed = await showDialog<bool>(
-      context: context,
-      builder: (ctx) => TDAlertDialog(
-        title: l10n.get('confirmApprove'),
-        content: l10n.get('approvalComment'),
-        leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx, false)),
-        rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () => Navigator.pop(ctx, true)),
-      ),
+    final rem = await _showOpinionDialog(
+      title: l10n.get('confirmApprove'),
+      hint: l10n.get('approvalComment'),
     );
     );
-    if (confirmed != true || !mounted) return;
-    await _doAudit('approve');
+    if (rem == null || !mounted) return;
+    await _doAudit('approve', rem: rem);
   }
   }
 
 
   void _handleReject() async {
   void _handleReject() async {
     final l10n = AppLocalizations.of(context);
     final l10n = AppLocalizations.of(context);
-    final confirmed = await showDialog<bool>(
-      context: context,
-      builder: (ctx) => TDAlertDialog(
-        title: l10n.get('confirmReject'),
-        content: l10n.get('approvalComment'),
-        leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx, false)),
-        rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () => Navigator.pop(ctx, true)),
-      ),
-    );
-    if (confirmed != true || !mounted) return;
-    // Open reason input
-    final remResult = await showDialog<String>(
-      context: context,
-      builder: (ctx) {
-        final ctrl = TextEditingController();
-        return TDAlertDialog(
-          title: l10n.get('rejectReason'),
-          content: l10n.get('pleaseEnter'),
-          leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)),
-          rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () => Navigator.pop(ctx, ctrl.text)),
-        );
-      },
+    final rem = await _showOpinionDialog(
+      title: l10n.get('confirmReject'),
+      hint: l10n.get('rejectReason'),
     );
     );
-    await _doAudit('reject', rem: remResult ?? '');
+    if (rem == null || !mounted) return;
+    await _doAudit('reject', rem: rem);
   }
   }
 
 
   void _handleReverseAudit() async {
   void _handleReverseAudit() async {
@@ -228,7 +198,35 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
       ),
       ),
     );
     );
     if (confirmed != true || !mounted) return;
     if (confirmed != true || !mounted) return;
-    await _doAudit('reverseAudit');
+    final rem = await _showOpinionDialog(
+      title: l10n.get('withdrawConfirm'),
+      hint: l10n.get('approvalComment'),
+    );
+    if (rem == null || !mounted) return;
+    await _doAudit('reverseAudit', rem: rem);
+  }
+
+  /// 弹出审批意见输入框,返回 null 表示取消
+  Future<String?> _showOpinionDialog({
+    required String title,
+    required String hint,
+  }) async {
+    final ctrl = TextEditingController();
+    return showDialog<String>(
+      context: context,
+      builder: (ctx) => TDAlertDialog(
+        title: title,
+        content: hint,
+        leftBtn: TDDialogButtonOptions(
+          title: AppLocalizations.of(context).get('cancel'),
+          action: () => Navigator.pop(ctx),
+        ),
+        rightBtn: TDDialogButtonOptions(
+          title: AppLocalizations.of(context).get('confirm'),
+          action: () => Navigator.pop(ctx, ctrl.text),
+        ),
+      ),
+    );
   }
   }
 
 
   Future<void> _doAudit(String action, {String rem = ''}) async {
   Future<void> _doAudit(String action, {String rem = ''}) async {
@@ -249,56 +247,9 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
     }
     }
   }
   }
 
 
-  // ════════════════════════════════════════════════════════════════
-  // 以下为展示组件,结构与原版一致,数据来源改为 _expense
-  // ════════════════════════════════════════════════════════════════
-
-  Widget _buildStatusBanner(
-      ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
-    final (icon, color, label) = switch (expense.status) {
-      'approved' => (Icons.check_circle, colors.success, l10n.get('approved')),
-      'rejected' => (Icons.cancel, colors.danger, l10n.get('rejected')),
-      'draft' => (Icons.edit, colors.statusGray, l10n.get('draft')),
-      _ => (Icons.schedule, colors.warning, l10n.get('pending')),
-    };
-    final approverText = switch (expense.status) {
-      'approved' when expense.approvalRecords.isNotEmpty =>
-        '${l10n.get('approver')}:${expense.approvalRecords.last.approverName}',
-      'rejected' when expense.approvalRecords.isNotEmpty =>
-        '${l10n.get('rejecter')}:${expense.approvalRecords.last.approverName}',
-      'pending' when expense.currentApproverId.isNotEmpty =>
-        '${l10n.get('currentApprover')}:${expense.currentApproverId}',
-      _ => '',
-    };
-    return StatusBanner(
-        icon: icon, statusText: label, subText: approverText, color: color);
-  }
-
-  Widget _buildSubmitTime(
-      ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
-    return Padding(
-      padding: const EdgeInsets.only(left: 4),
-      child: Text(
-        '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(expense.createTime)}',
-        style:
-            TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
-      ),
-    );
-  }
-
-  // ═══ 基本信息 + 收款账户 — 对应 create 页 basicInfo + 数据库 Expense 字段 ═══
+  // ═══ 基本信息 ═══
   Widget _buildBasicInfoSection(
   Widget _buildBasicInfoSection(
       ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
       ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
-    var pms = expense.paymentMethod;
-    if (pms == 'bankTransfer') {
-      pms = l10n.get('bankTransfer');
-    } else if (pms == 'cash') {
-      pms = l10n.get('cash');
-    } else if (pms == 'alipay') {
-      pms = l10n.get('alipay');
-    } else if (pms == 'wechat') {
-      pms = l10n.get('wechat');
-    }
     return FormSection(
     return FormSection(
       title: l10n.get('basicInfo'),
       title: l10n.get('basicInfo'),
       leadingIcon: Icons.info_outline,
       leadingIcon: Icons.info_outline,
@@ -310,61 +261,91 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
             showArrow: false),
             showArrow: false),
         const SizedBox(height: 16),
         const SizedBox(height: 16),
         FormFieldRow(
         FormFieldRow(
-            label: l10n.get('applicant'),
-            value: expense.applicantName,
+            label: l10n.get('voucherNo'),
+            value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-',
             readOnly: true,
             readOnly: true,
             showArrow: false),
             showArrow: false),
         const SizedBox(height: 16),
         const SizedBox(height: 16),
         FormFieldRow(
         FormFieldRow(
-            label: l10n.get('department'),
-            value: expense.deptName,
+            label: l10n.get('expensePersonnel'),
+            value: expense.applicantId.isNotEmpty
+                ? '${expense.applicantId}/${expense.applicantName}'
+                : expense.applicantName,
             readOnly: true,
             readOnly: true,
             showArrow: false),
             showArrow: false),
         const SizedBox(height: 16),
         const SizedBox(height: 16),
         FormFieldRow(
         FormFieldRow(
-            label: l10n.get('date'),
-            value: du.DateUtils.formatDateTime(expense.createTime),
+            label: l10n.get('expenseDept'),
+            value: expense.deptId.isNotEmpty
+                ? '${expense.deptId}/${expense.deptName}'
+                : expense.deptName,
             readOnly: true,
             readOnly: true,
             showArrow: false),
             showArrow: false),
         const SizedBox(height: 16),
         const SizedBox(height: 16),
         FormFieldRow(
         FormFieldRow(
-            label: l10n.get('currency'),
-            value: expense.currencyCode,
+            label: l10n.get('date'),
+            value: du.DateUtils.formatDateTime(expense.createTime),
             readOnly: true,
             readOnly: true,
             showArrow: false),
             showArrow: false),
         const SizedBox(height: 16),
         const SizedBox(height: 16),
         FormFieldRow(
         FormFieldRow(
-            label: l10n.get('feeReason'),
-            value: expense.purpose,
+            label: l10n.get('currency'),
+            value: expense.currencyCode.isNotEmpty ? expense.currencyCode : '-',
             readOnly: true,
             readOnly: true,
             showArrow: false),
             showArrow: false),
         const SizedBox(height: 16),
         const SizedBox(height: 16),
-        FormFieldRow(
-            label: l10n.get('expenseAmount'),
-            value: '¥${expense.totalAmount.toStringAsFixed(2)}',
-            readOnly: true,
-            showArrow: false),
-        if (expense.approvedAmount > 0) ...[
-          const SizedBox(height: 16),
-          FormFieldRow(
-              label: l10n.get('approvedAmount'),
-              value: '¥${expense.approvedAmount.toStringAsFixed(2)}',
-              readOnly: true,
-              showArrow: false),
-        ],
+        SizedBox(
+          height: 24,
+          child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
+            Text(l10n.get('feeReason'),
+                style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
+            const SizedBox(width: 8),
+            Expanded(
+              child: Text(expense.purpose.isNotEmpty ? expense.purpose : '-',
+                  textAlign: TextAlign.end,
+                  maxLines: 2,
+                  overflow: TextOverflow.ellipsis,
+                  style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w500, color: colors.textPrimary)),
+            ),
+          ]),
+        ),
         const SizedBox(height: 16),
         const SizedBox(height: 16),
         FormFieldRow(
         FormFieldRow(
             label: l10n.get('paymentMethod'),
             label: l10n.get('paymentMethod'),
-            value: pms.isNotEmpty ? pms : '-',
+            value: expense.paymentMethod.isNotEmpty ? expense.paymentMethod : '-',
             readOnly: true,
             readOnly: true,
             showArrow: false),
             showArrow: false),
+        const SizedBox(height: 16),
+        SizedBox(
+          height: 24,
+          child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
+            Text(l10n.get('remark'),
+                style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
+            const SizedBox(width: 8),
+            Expanded(
+              child: Text(expense.remark.isNotEmpty ? expense.remark : '-',
+                  textAlign: TextAlign.end,
+                  maxLines: 2,
+                  overflow: TextOverflow.ellipsis,
+                  style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w500, color: colors.textPrimary)),
+            ),
+          ]),
+        ),
       ],
       ],
     );
     );
   }
   }
 
 
-  // ═══ 费用明细 — 对应 create 页 detailSection + 数据库 ExpenseDetail ═══
+  // ═══ 费用明细 ═══
   Widget _buildExpenseDetailSection(
   Widget _buildExpenseDetailSection(
       ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
       ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
+    final totalAmount = expense.details.fold<double>(
+      0,
+      (sum, d) => sum + d.totalAmount,
+    );
+    final totalApproved = expense.details.fold<double>(
+      0,
+      (sum, d) => sum + d.approvedAmount,
+    );
     return FormSection(
     return FormSection(
       title: l10n.get('expenseDetails'),
       title: l10n.get('expenseDetails'),
       leadingIcon: Icons.receipt_long_outlined,
       leadingIcon: Icons.receipt_long_outlined,
@@ -372,254 +353,169 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
         if (expense.details.isEmpty)
         if (expense.details.isEmpty)
           Padding(
           Padding(
             padding: const EdgeInsets.symmetric(vertical: 8),
             padding: const EdgeInsets.symmetric(vertical: 8),
-            child: Text(
-              l10n.get('noDetailData'),
-              style: TextStyle(
-                  fontSize: AppFontSizes.body, color: colors.textPlaceholder),
-            ),
+            child: Text(l10n.get('noDetailData'),
+                style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)),
           )
           )
         else
         else
           ...expense.details.asMap().entries.map((e) {
           ...expense.details.asMap().entries.map((e) {
             final d = e.value;
             final d = e.value;
+            final title = d.categoryName.isNotEmpty
+                ? '${d.expenseCategory}/${d.categoryName}'
+                : (d.remark.isNotEmpty ? d.remark : (d.acctSubjectId.isNotEmpty ? d.acctSubjectId : '--'));
             return Container(
             return Container(
-              margin: const EdgeInsets.symmetric(vertical: 8),
+              margin: const EdgeInsets.symmetric(vertical: 6),
               padding: const EdgeInsets.all(12),
               padding: const EdgeInsets.all(12),
-              decoration: BoxDecoration(
-                  color: colors.bgPage,
-                  borderRadius: BorderRadius.circular(8)),
-              child: Column(
-                  crossAxisAlignment: CrossAxisAlignment.start,
-                  children: [
-                    Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
-                      Expanded(
-                        child: Text(
-                          d.purpose.isNotEmpty ? d.purpose : d.expenseCategory,
-                          style: TextStyle(
-                              fontSize: AppFontSizes.subtitle,
-                              fontWeight: FontWeight.w600,
-                              color: colors.textPrimary),
-                        ),
-                      ),
-                      const SizedBox(width: 16),
-                      Text(
-                        '¥${d.totalAmount.toStringAsFixed(2)}',
-                        style: TextStyle(
-                            fontSize: AppFontSizes.subtitle,
-                            fontWeight: FontWeight.w600,
-                            color: colors.amountPrimary),
-                      ),
-                    ]),
-                    const SizedBox(height: 4),
-                    Text(
-                      '¥${d.amount.toStringAsFixed(2)} + 税${d.taxAmount.toStringAsFixed(2)}${d.bankName.isNotEmpty ? ' | ${d.bankName}' : ''}',
-                      style: TextStyle(
-                          fontSize: AppFontSizes.caption,
-                          color: colors.textSecondary),
-                    ),
-                    if (d.projectName.isNotEmpty) ...[
-                      const SizedBox(height: 4),
-                      Text(
-                        '${l10n.get('relatedProject')}:${d.projectName}  |  ${l10n.get('budgetSubject')}:${d.acctSubjectName}',
-                        style: TextStyle(
-                            fontSize: AppFontSizes.caption,
-                            color: colors.textSecondary),
-                      ),
-                    ],
-                    if (d.costDeptName.isNotEmpty) ...[
-                      const SizedBox(height: 4),
-                      Text(
-                        '${l10n.get('costDept')}:${d.costDeptName}',
-                        style: TextStyle(
-                            fontSize: AppFontSizes.caption,
-                            color: colors.textSecondary),
-                      ),
-                    ],
-                    if (d.customerVendorName.isNotEmpty) ...[
-                      const SizedBox(height: 4),
-                      Text(
-                        '${l10n.get('customerVendor')}:${d.customerVendorName}',
-                        style: TextStyle(
-                            fontSize: AppFontSizes.caption,
-                            color: colors.textSecondary),
-                      ),
-                    ],
-                    if (d.approvedAmount > 0) ...[
-                      const SizedBox(height: 4),
-                      Text(
-                        '${l10n.get('approvedAmount')}:¥${d.approvedAmount.toStringAsFixed(2)}',
-                        style: TextStyle(
-                            fontSize: AppFontSizes.caption,
-                            color: colors.success),
-                      ),
-                    ],
-                    if (d.remark.isNotEmpty) ...[
-                      const SizedBox(height: 4),
-                      Text(
-                        d.remark,
-                        maxLines: 2,
-                        overflow: TextOverflow.ellipsis,
-                        style: TextStyle(
-                            fontSize: AppFontSizes.caption,
-                            color: colors.textSecondary),
-                      ),
-                    ],
-                    if (d.attachments.isNotEmpty) ...[
-                      const SizedBox(height: 8),
-                      Wrap(
-                        spacing: 6,
-                        runSpacing: 6,
-                        children: d.attachments.map((path) {
-                          final name = path.split('/').last.split('\\').last;
-                          return Container(
-                            width: 60,
-                            height: 60,
-                            decoration: BoxDecoration(
-                              color: colors.primaryLight,
-                              borderRadius: BorderRadius.circular(4),
+              decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
+              child: Row(
+                children: [
+                  Expanded(
+                    child: Column(
+                      crossAxisAlignment: CrossAxisAlignment.start,
+                      children: [
+                        Row(
+                          children: [
+                            Expanded(
+                              child: Text(title,
+                                  style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w500, color: colors.textPrimary)),
                             ),
                             ),
-                            child: Column(
-                              mainAxisAlignment: MainAxisAlignment.center,
+                            Column(
+                              crossAxisAlignment: CrossAxisAlignment.end,
                               children: [
                               children: [
-                                Icon(_fileTypeIcon(path),
-                                    size: 24, color: colors.primary),
-                                const SizedBox(height: 2),
-                                Padding(
-                                  padding:
-                                      const EdgeInsets.symmetric(horizontal: 2),
-                                  child: Text(
-                                    name,
-                                    maxLines: 1,
-                                    overflow: TextOverflow.ellipsis,
-                                    style: TextStyle(
-                                        fontSize: 9,
-                                        color: colors.textSecondary),
-                                  ),
-                                ),
+                                Text('¥${d.totalAmount.toStringAsFixed(2)}',
+                                    style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.amountPrimary)),
+                                if (d.approvedAmount > 0)
+                                  Text('¥${d.approvedAmount.toStringAsFixed(2)}',
+                                      style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.success)),
                               ],
                               ],
                             ),
                             ),
-                          );
-                        }).toList(),
-                      ),
-                    ],
-                  ]),
+                          ],
+                        ),
+                        const SizedBox(height: 2),
+                        Text('${l10n.get('amountExcludingTax')}: ¥${d.amount.toStringAsFixed(2)}',
+                            style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                        if (d.taxAmount > 0)
+                          Text('${l10n.get('taxAmount')}: ¥${d.taxAmount.toStringAsFixed(2)}',
+                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                        if (d.approvedAmount > 0)
+                          Text('${l10n.get('approvedAmount')}: ¥${d.approvedAmount.toStringAsFixed(2)}',
+                              style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w500, color: colors.success)),
+                        if (d.acctSubjectName.isNotEmpty)
+                          Text('${l10n.get('acctSubject')}: ${d.acctSubjectId}/${d.acctSubjectName}',
+                              maxLines: 1, overflow: TextOverflow.ellipsis,
+                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                        if (d.aeNo.isNotEmpty)
+                          Text('${l10n.get('expenseApplyNo')}: ${d.aeNo}',
+                              maxLines: 1, overflow: TextOverflow.ellipsis,
+                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                        if (d.aeDd.isNotEmpty)
+                          Text('${l10n.get('applyDate')}: ${d.aeDd}',
+                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                        if (d.projectName.isNotEmpty)
+                          Text('${l10n.get('project')}: ${d.projectId}/${d.projectName}',
+                              maxLines: 1, overflow: TextOverflow.ellipsis,
+                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                        if (d.costDeptName.isNotEmpty)
+                          Text('${l10n.get('dept')}: ${d.costDeptId}/${d.costDeptName}',
+                              maxLines: 1, overflow: TextOverflow.ellipsis,
+                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                        if (d.customerVendorName.isNotEmpty)
+                          Text('${l10n.get('customerVendor')}: ${d.customerVendorName}',
+                              maxLines: 1, overflow: TextOverflow.ellipsis,
+                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                        if (d.remark.isNotEmpty)
+                          Text(d.remark,
+                              maxLines: 2, overflow: TextOverflow.ellipsis,
+                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                      ],
+                    ),
+                  ),
+                ],
+              ),
             );
             );
           }),
           }),
         if (expense.details.isNotEmpty) ...[
         if (expense.details.isNotEmpty) ...[
           const SizedBox(height: 8),
           const SizedBox(height: 8),
-          Row(
-              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+          Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
+            Text(l10n.get('total'),
+                style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
+            Column(
+              crossAxisAlignment: CrossAxisAlignment.end,
               children: [
               children: [
-                Text(
-                  l10n.get('total'),
-                  style: TextStyle(
-                      fontSize: AppFontSizes.body,
-                      fontWeight: FontWeight.w600,
-                      color: colors.textPrimary),
-                ),
-                Text(
-                  '¥${expense.totalAmount.toStringAsFixed(2)}',
-                  style: TextStyle(
-                      fontSize: AppFontSizes.subtitle,
-                      fontWeight: FontWeight.w700,
-                      color: colors.amountPrimary),
-                ),
-              ]),
+                Text('¥${totalAmount.toStringAsFixed(2)}',
+                    style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
+                if (totalApproved > 0)
+                  Text('${l10n.get('approvedAmount')} ¥${totalApproved.toStringAsFixed(2)}',
+                      style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.success)),
+              ],
+            ),
+          ]),
         ],
         ],
       ],
       ],
     );
     );
   }
   }
 
 
   // ═══ 附件 ═══
   // ═══ 附件 ═══
-  Widget _buildAttachmentSection(
-      ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
+  Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
+    final headerAtts = _attachments.where((a) => a.isHeader).toList();
+    final bodyGroups = <int, List<BillAttachment>>{};
+    for (final a in _attachments.where((a) => a.isBody)) {
+      bodyGroups.putIfAbsent(a.srcItm, () => []).add(a);
+    }
+
+    final children = <Widget>[];
+    if (_attachments.isEmpty) {
+      children.add(Text(l10n.get('noAttachment'),
+          style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
+    } else {
+      // 表头附件
+      if (headerAtts.isNotEmpty) {
+        children.add(Padding(
+          padding: const EdgeInsets.only(bottom: 8),
+          child: Text(l10n.get('headerAttachments'),
+              style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.textSecondary)),
+        ));
+        for (final a in headerAtts) {
+          children.add(_buildAttachmentRow(a, colors));
+        }
+      }
+      // 表身附件(按明细行分组)
+      for (final entry in bodyGroups.entries) {
+        children.add(const SizedBox(height: 8));
+        children.add(Padding(
+          padding: const EdgeInsets.only(bottom: 8),
+          child: Text('${l10n.get('detailLine')} ${entry.key}',
+              style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.textSecondary)),
+        ));
+        for (final a in entry.value) {
+          children.add(_buildAttachmentRow(a, colors));
+        }
+      }
+    }
+
     return FormSection(
     return FormSection(
       title: l10n.get('attachments'),
       title: l10n.get('attachments'),
       leadingIcon: Icons.attach_file_outlined,
       leadingIcon: Icons.attach_file_outlined,
-      children: [
-        if (expense.attachments.isEmpty)
-          Text(l10n.get('noAttachment'),
-              style: TextStyle(
-                  fontSize: AppFontSizes.body, color: colors.textPlaceholder))
-        else
-          Wrap(
-              spacing: 8,
-              runSpacing: 8,
-              children: expense.attachments.map((path) {
-                final name = path.split('/').last.split('\\').last;
-                return SizedBox(
-                  width: 80,
-                  child: Column(
-                    mainAxisSize: MainAxisSize.min,
-                    children: [
-                      Container(
-                        width: 80,
-                        height: 80,
-                        decoration: BoxDecoration(
-                          color: colors.bgPage,
-                          borderRadius: BorderRadius.circular(4),
-                          border: Border.all(color: colors.border),
-                        ),
-                        child: Center(
-                          child: Icon(
-                            _fileTypeIcon(path),
-                            size: 28,
-                            color: colors.primary,
-                          ),
-                        ),
-                      ),
-                      const SizedBox(height: 4),
-                      Text(
-                        name,
-                        maxLines: 1,
-                        overflow: TextOverflow.ellipsis,
-                        style: TextStyle(
-                            fontSize: AppFontSizes.caption,
-                            color: colors.textSecondary),
-                      ),
-                    ],
-                  ),
-                );
-              }).toList()),
-      ],
+      children: children,
     );
     );
   }
   }
 
 
-  // ═══ 财务合规查验 ═══
-  Widget _buildComplianceSection(
-      ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
-    final checks = [
-      (expense.isInvoiceVerified, l10n.get('invoiceCheck1')),
-      (expense.isTaxIdMatched, l10n.get('invoiceCheck2')),
-      (expense.isCategoryCompliant, l10n.get('invoiceCheck3')),
-    ];
-    return FormSection(
-      title: l10n.get('invoiceCheck'),
-      leadingIcon: Icons.verified_outlined,
-      children: checks.asMap().entries.map((e) {
-        final (passed, text) = e.value;
-        return Padding(
-          padding: EdgeInsets.only(top: e.key > 0 ? 12 : 0),
-          child: SizedBox(
-            height: 24,
-            child: Row(children: [
-              Icon(
-                passed
-                    ? Icons.check_circle
-                    : Icons.radio_button_unchecked,
-                size: 16,
-                color: passed
-                    ? colors.success
-                    : colors.textPlaceholder,
-              ),
-              const SizedBox(width: 8),
-              Text(text,
-                  style: TextStyle(
-                      fontSize: AppFontSizes.subtitle,
-                      color: colors.textPrimary)),
-            ]),
-          ),
-        );
-      }).toList(),
+  Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
+    return Container(
+      margin: const EdgeInsets.symmetric(vertical: 4),
+      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+      decoration: BoxDecoration(
+        color: colors.bgPage,
+        borderRadius: BorderRadius.circular(8),
+      ),
+      child: Row(children: [
+        Icon(_fileTypeIcon(a.ext), size: 24, color: colors.primary),
+        const SizedBox(width: 10),
+        Expanded(
+          child: Text(a.fileName,
+              maxLines: 1, overflow: TextOverflow.ellipsis,
+              style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPrimary)),
+        ),
+      ]),
     );
     );
   }
   }
 
 
@@ -639,53 +535,13 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
     );
     );
   }
   }
 
 
-  // ═══ 财务归档 ═══
-  Widget _buildArchiveSection(
-      ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
-    return FormSection(
-      title: l10n.get('financialArchive'),
-      leadingIcon: Icons.archive_outlined,
-      children: [
-        FormFieldRow(
-            label: l10n.get('voucherNo'),
-            value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-',
-            readOnly: true,
-            showArrow: false),
-        const SizedBox(height: 16),
-        FormFieldRow(
-            label: l10n.get('bankTransferNo'),
-            value:
-                expense.bankTransferNo.isNotEmpty ? expense.bankTransferNo : '-',
-            readOnly: true,
-            showArrow: false),
-        const SizedBox(height: 16),
-        FormFieldRow(
-            label: l10n.get('paymentStatus'),
-            value: expense.paymentStatus == 'paid'
-                ? l10n.get('paid')
-                : l10n.get('unpaid'),
-            readOnly: true,
-            showArrow: false),
-      ],
-    );
-  }
-
-  IconData _fileTypeIcon(String path) {
-    final ext = path.split('.').last.toLowerCase();
-    switch (ext) {
-      case 'pdf':
-        return Icons.picture_as_pdf;
-      case 'doc':
-      case 'docx':
-        return Icons.description;
-      case 'xls':
-      case 'xlsx':
-        return Icons.table_chart;
-      case 'ppt':
-      case 'pptx':
-        return Icons.slideshow;
-      default:
-        return Icons.insert_drive_file;
+  IconData _fileTypeIcon(String ext) {
+    switch (ext.toLowerCase()) {
+      case 'pdf': return Icons.picture_as_pdf;
+      case 'doc': case 'docx': return Icons.description;
+      case 'xls': case 'xlsx': return Icons.table_chart;
+      case 'jpg': case 'jpeg': case 'png': case 'gif': case 'bmp': return Icons.image_outlined;
+      default: return Icons.insert_drive_file;
     }
     }
   }
   }
 }
 }

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

@@ -81,22 +81,22 @@ class ExpenseModel {
   factory ExpenseModel.fromJson(Map<String, dynamic> json) {
   factory ExpenseModel.fromJson(Map<String, dynamic> json) {
     return ExpenseModel(
     return ExpenseModel(
       id: json['id'] as String? ?? '',
       id: json['id'] as String? ?? '',
-      expenseNo: json['bxNo'] as String? ?? '',
-      expenseDate: json['bxDate'] != null
-          ? DateTime.parse(json['bxDate'] as String)
+      expenseNo: json['expenseNo'] as String? ?? '',
+      expenseDate: json['expenseDate'] != null
+          ? DateTime.parse(json['expenseDate'] as String)
           : null,
           : null,
-      applicantId: json['usr'] as String? ?? '',
-      applicantName: json['applicantName'] as String? ?? json['usr'] as String? ?? '',
-      deptId: json['dep'] as String? ?? '',
-      deptName: json['deptName'] as String? ?? json['dep'] as String? ?? '',
-      currencyCode: json['currencyCode'] as String? ?? '',
+      applicantId: json['usrNo'] as String? ?? '',
+      applicantName: json['applicantName'] as String? ?? json['usrNo'] as String? ?? '',
+      deptId: json['dept'] as String? ?? '',
+      deptName: json['deptName'] as String? ?? json['dept'] as String? ?? '',
+      currencyCode: json['curId'] as String? ?? '',
       isGenerateVoucher: json['isGenerateVoucher'] as bool? ?? false,
       isGenerateVoucher: json['isGenerateVoucher'] as bool? ?? false,
-      voucherNo: json['voucherNo'] as String? ?? '',
+      voucherNo: json['vohNo'] as String? ?? '',
       approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0.0,
       approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0.0,
       totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0.0,
       totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0.0,
       purpose: json['reason'] as String? ?? '',
       purpose: json['reason'] as String? ?? '',
       remark: json['rem'] as String? ?? '',
       remark: json['rem'] as String? ?? '',
-      paymentMethod: json['paymentMethod'] as String? ?? '',
+      paymentMethod: json['payId'] as String? ?? '',
       isInvoiceVerified: json['isInvoiceVerified'] as bool? ?? false,
       isInvoiceVerified: json['isInvoiceVerified'] as bool? ?? false,
       isTaxIdMatched: json['isTaxIdMatched'] as bool? ?? false,
       isTaxIdMatched: json['isTaxIdMatched'] as bool? ?? false,
       isCategoryCompliant: json['isCategoryCompliant'] as bool? ?? false,
       isCategoryCompliant: json['isCategoryCompliant'] as bool? ?? false,

+ 53 - 3
lib/features/expense_apply/expense_apply_api.dart

@@ -1,7 +1,10 @@
+import 'dart:convert';
+import 'package:dio/dio.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import '../../core/network/api_client.dart';
 import '../../core/network/api_client.dart';
 import '../../app.dart';
 import '../../app.dart';
 import '../../shared/models/pagination_model.dart';
 import '../../shared/models/pagination_model.dart';
+import '../../shared/models/bill_attachment.dart';
 import 'expense_apply_model.dart';
 import 'expense_apply_model.dart';
 
 
 final expenseApplyApiProvider = Provider<ExpenseApplyApi>(
 final expenseApplyApiProvider = Provider<ExpenseApplyApi>(
@@ -112,14 +115,29 @@ class ExpenseApplyApi {
     return list.map((e) => DepartmentItem.fromJson(e as Map<String, dynamic>)).toList();
     return list.map((e) => DepartmentItem.fromJson(e as Map<String, dynamic>)).toList();
   }
   }
 
 
-  /// 提交审批
-  Future<void> submit(Map<String, dynamic> data) async {
-    await _client.post('/OA/BillSave', data: {
+  /// 提交审批,返回申请单号
+  Future<String> submit(Map<String, dynamic> data) async {
+    final response = await _client.post<Map<String, dynamic>>('/OA/BillSave', data: {
       'erpCategory': 'MasterService',
       'erpCategory': 'MasterService',
       'billId': 'AE',
       'billId': 'AE',
       'procId': '',
       'procId': '',
       'data': data,
       'data': data,
     });
     });
+    final resData = response.data;
+    // 尝试从 resultData 或 HeadData 中提取 AE_NO
+    if (resData != null) {
+      final resultData = resData['resultData'] as Map<String, dynamic>?;
+      if (resultData != null) {
+        final aeNo = resultData['AE_NO'] as String?;
+        if (aeNo != null && aeNo.isNotEmpty) return aeNo;
+        final headData = resultData['HeadData'] as Map<String, dynamic>?;
+        if (headData != null) {
+          final hdAeNo = headData['AE_NO'] as String?;
+          if (hdAeNo != null && hdAeNo.isNotEmpty) return hdAeNo;
+        }
+      }
+    }
+    throw Exception('BillSave succeeded but could not extract bill number');
   }
   }
 
 
   /// 审批进度
   /// 审批进度
@@ -131,6 +149,38 @@ class ExpenseApplyApi {
     return response.data!;
     return response.data!;
   }
   }
 
 
+  /// 获取单据附件列表
+  Future<List<BillAttachment>> getAttachments(String bilId, String bilNo, {int? srcItm}) async {
+    final params = <String, dynamic>{
+      'bilId': bilId,
+      'bilNo': bilNo,
+    };
+    if (srcItm != null) params['srcItm'] = srcItm;
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetAttachments',
+      queryParameters: params,
+    );
+    final data = response.data;
+    final list = (data?['Result']?['list'] as List<dynamic>?) ??
+        (data?['list'] as List<dynamic>?) ??
+        [];
+    return list.map((e) => BillAttachment.fromJson(e as Map<String, dynamic>)).toList();
+  }
+
+  /// 上传附件
+  Future<Map<String, dynamic>> uploadAttachment(
+    String filePath,
+    Map<String, dynamic> metadata,
+  ) async {
+    final fileName = (metadata['FILENAME'] as String?) ?? filePath.split('/').last;
+    final response = await _client.uploadMultipart<Map<String, dynamic>>(
+      '/OA/UploadAttachment',
+      files: [await MultipartFile.fromFile(filePath, filename: fileName)],
+      extraFields: {'metadata': json.encode(metadata)},
+    );
+    return response.data ?? {};
+  }
+
   /// 审核执行(通过/驳回/反审核)
   /// 审核执行(通过/驳回/反审核)
   Future<Map<String, dynamic>> executeApproval({
   Future<Map<String, dynamic>> executeApproval({
     required String bilId, required String bilNo, int bilItm = 0,
     required String bilId, required String bilNo, int bilItm = 0,

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

@@ -865,7 +865,31 @@ class _ExpenseApplyCreatePageState
         LoadingDialog.show(context, text: l10n.get('submitting'));
         LoadingDialog.show(context, text: l10n.get('submitting'));
         try {
         try {
           final data = _buildSubmitData();
           final data = _buildSubmitData();
-          await ref.read(expenseApplyApiProvider).submit(data);
+          final api = ref.read(expenseApplyApiProvider);
+          final billNo = await api.submit(data);
+          // 上传表头附件
+          if (_attachmentController.files.isNotEmpty) {
+            final today = _today();
+            final usr = HostAppChannel.usr;
+            for (var i = 0; i < _attachmentController.files.length; i++) {
+              final file = _attachmentController.files[i];
+              try {
+                await api.uploadAttachment(file.path, {
+                  'BIL_ID': 'AE',
+                  'BIL_NO': billNo,
+                  'SRCITM': 0,
+                  'ITM': i + 1,
+                  'TAG': 1,
+                  'EFF_DD': today,
+                  'USR': usr,
+                  'FILENAME': file.name,
+                  'EXT': file.name.split('.').last,
+                });
+              } catch (_) {
+                // Attachment upload failure is non-fatal
+              }
+            }
+          }
           await DraftStorage.delete(_draftKey);
           await DraftStorage.delete(_draftKey);
           if (mounted) {
           if (mounted) {
             LoadingDialog.hide(context);
             LoadingDialog.hide(context);

+ 161 - 139
lib/features/expense_apply/expense_apply_detail_page.dart

@@ -6,13 +6,13 @@ import '../../shared/widgets/nav_bar_config.dart';
 import '../../core/utils/date_utils.dart' as du;
 import '../../core/utils/date_utils.dart' as du;
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
 import '../../shared/widgets/form_field_row.dart';
-import '../../shared/widgets/status_banner.dart';
 import '../../shared/widgets/approval_actions.dart';
 import '../../shared/widgets/approval_actions.dart';
 import '../../shared/widgets/approval_timeline.dart';
 import '../../shared/widgets/approval_timeline.dart';
 import '../../shared/models/approval_status.dart';
 import '../../shared/models/approval_status.dart';
 import 'expense_apply_model.dart';
 import 'expense_apply_model.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../shared/widgets/loading_dialog.dart';
 import '../../shared/widgets/loading_dialog.dart';
+import '../../shared/models/bill_attachment.dart';
 import 'expense_apply_api.dart';
 import 'expense_apply_api.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors_extension.dart';
 import '../../core/theme/app_colors_extension.dart';
@@ -30,6 +30,7 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
   bool _loading = true;
   bool _loading = true;
   String? _error;
   String? _error;
   ExpenseApplyModel? _data;
   ExpenseApplyModel? _data;
+  List<BillAttachment> _attachments = [];
 
 
   List<ApprovalRecord> _approvalRecords = [];
   List<ApprovalRecord> _approvalRecords = [];
   List<String> _approvalChain = [];
   List<String> _approvalChain = [];
@@ -53,11 +54,11 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
       String currentId = '';
       String currentId = '';
       try {
       try {
         final timeline = await api.fetchApprovalTimeline('AE', widget.billNo);
         final timeline = await api.fetchApprovalTimeline('AE', widget.billNo);
-        records = (timeline['approvalRecords'] as List<dynamic>?)
+        records = (timeline['records'] as List<dynamic>?)
                 ?.map((e) => ApprovalRecord.fromJson(e as Map<String, dynamic>))
                 ?.map((e) => ApprovalRecord.fromJson(e as Map<String, dynamic>))
                 .toList() ??
                 .toList() ??
             [];
             [];
-        chain = (timeline['approvalChain'] as List<dynamic>?)
+        chain = (timeline['chain'] as List<dynamic>?)
                 ?.map((e) => e as String)
                 ?.map((e) => e as String)
                 .toList() ??
                 .toList() ??
             [];
             [];
@@ -66,12 +67,21 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
         // Timeline load failure is non-fatal
         // Timeline load failure is non-fatal
       }
       }
 
 
+      // Load attachments (non-critical, best-effort)
+      List<BillAttachment> attachments = [];
+      try {
+        attachments = await api.getAttachments('AE', widget.billNo);
+      } catch (_) {
+        // Attachment load failure is non-fatal
+      }
+
       if (mounted) {
       if (mounted) {
         setState(() {
         setState(() {
           _data = detail;
           _data = detail;
           _approvalRecords = records;
           _approvalRecords = records;
           _approvalChain = chain;
           _approvalChain = chain;
           _currentApproverId = currentId;
           _currentApproverId = currentId;
+          _attachments = attachments;
           _loading = false;
           _loading = false;
         });
         });
       }
       }
@@ -84,45 +94,22 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
 
 
   void _handleApprove() async {
   void _handleApprove() async {
     final l10n = AppLocalizations.of(context);
     final l10n = AppLocalizations.of(context);
-    final confirmed = await showDialog<bool>(
-      context: context,
-      builder: (ctx) => TDAlertDialog(
-        title: l10n.get('confirmApprove'),
-        content: l10n.get('approvalComment'),
-        leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx, false)),
-        rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () => Navigator.pop(ctx, true)),
-      ),
+    final rem = await _showOpinionDialog(
+      title: l10n.get('confirmApprove'),
+      hint: l10n.get('approvalComment'),
     );
     );
-    if (confirmed != true || !mounted) return;
-    await _doAudit('approve');
+    if (rem == null || !mounted) return;
+    await _doAudit('approve', rem: rem);
   }
   }
 
 
   void _handleReject() async {
   void _handleReject() async {
     final l10n = AppLocalizations.of(context);
     final l10n = AppLocalizations.of(context);
-    final confirmed = await showDialog<bool>(
-      context: context,
-      builder: (ctx) => TDAlertDialog(
-        title: l10n.get('confirmReject'),
-        content: l10n.get('approvalComment'),
-        leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx, false)),
-        rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () => Navigator.pop(ctx, true)),
-      ),
+    final rem = await _showOpinionDialog(
+      title: l10n.get('confirmReject'),
+      hint: l10n.get('rejectReason'),
     );
     );
-    if (confirmed != true || !mounted) return;
-    // Open reason input
-    final remResult = await showDialog<String>(
-      context: context,
-      builder: (ctx) {
-        final ctrl = TextEditingController();
-        return TDAlertDialog(
-          title: l10n.get('rejectReason'),
-          content: l10n.get('pleaseEnter'),
-          leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)),
-          rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () => Navigator.pop(ctx, ctrl.text)),
-        );
-      },
-    );
-    await _doAudit('reject', rem: remResult ?? '');
+    if (rem == null || !mounted) return;
+    await _doAudit('reject', rem: rem);
   }
   }
 
 
   void _handleReverseAudit() async {
   void _handleReverseAudit() async {
@@ -137,7 +124,35 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
       ),
       ),
     );
     );
     if (confirmed != true || !mounted) return;
     if (confirmed != true || !mounted) return;
-    await _doAudit('reverseAudit');
+    final rem = await _showOpinionDialog(
+      title: l10n.get('withdrawConfirm'),
+      hint: l10n.get('approvalComment'),
+    );
+    if (rem == null || !mounted) return;
+    await _doAudit('reverseAudit', rem: rem);
+  }
+
+  /// 弹出审批意见输入框,返回 null 表示取消
+  Future<String?> _showOpinionDialog({
+    required String title,
+    required String hint,
+  }) async {
+    final ctrl = TextEditingController();
+    return showDialog<String>(
+      context: context,
+      builder: (ctx) => TDAlertDialog(
+        title: title,
+        content: hint,
+        leftBtn: TDDialogButtonOptions(
+          title: AppLocalizations.of(context).get('cancel'),
+          action: () => Navigator.pop(ctx),
+        ),
+        rightBtn: TDDialogButtonOptions(
+          title: AppLocalizations.of(context).get('confirm'),
+          action: () => Navigator.pop(ctx, ctrl.text),
+        ),
+      ),
+    );
   }
   }
 
 
   Future<void> _doAudit(String action, {String rem = ''}) async {
   Future<void> _doAudit(String action, {String rem = ''}) async {
@@ -214,15 +229,11 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
             padding: const EdgeInsets.all(16),
             padding: const EdgeInsets.all(16),
             child: Column(
             child: Column(
               children: [
               children: [
-                _buildStatusBanner(context, app, colors),
-                const SizedBox(height: 8),
-                _buildSubmitTime(context, app, colors),
-                const SizedBox(height: 16),
                 _buildBasicInfoSection(app, l10n, colors),
                 _buildBasicInfoSection(app, l10n, colors),
                 const SizedBox(height: 16),
                 const SizedBox(height: 16),
                 _buildExpenseDetailSection(app, l10n, colors),
                 _buildExpenseDetailSection(app, l10n, colors),
                 const SizedBox(height: 16),
                 const SizedBox(height: 16),
-                _buildAttachmentSection(app, l10n, colors),
+                _buildAttachmentSection(l10n, colors),
                 const SizedBox(height: 16),
                 const SizedBox(height: 16),
                 if (_approvalChain.isNotEmpty)
                 if (_approvalChain.isNotEmpty)
                   Container(
                   Container(
@@ -251,52 +262,6 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
     );
     );
   }
   }
 
 
-  Widget _buildStatusBanner(
-    BuildContext context,
-    ExpenseApplyModel app,
-    AppColorsExtension colors,
-  ) {
-    final l10n = AppLocalizations.of(context);
-    final (icon, color, label) = switch (app.status) {
-      'approved' => (Icons.check_circle, colors.success, l10n.get('approved')),
-      'rejected' => (Icons.cancel, colors.danger, l10n.get('rejected')),
-      'closed' => (Icons.check_circle, colors.success, l10n.get('closed')),
-      'withdrawn' => (Icons.cancel_outlined, colors.withdrawnText, l10n.get('withdrawn')),
-      'draft' => (Icons.edit, colors.statusGray, l10n.get('draft')),
-      _ => (Icons.schedule, colors.warning, l10n.get('pending')),
-    };
-    final approverText = switch (app.status) {
-      'approved' when app.approvalRecords.isNotEmpty =>
-        '${l10n.get('approver')}:${app.approvalRecords.last.approverName}',
-      'rejected' when app.approvalRecords.isNotEmpty =>
-        '${l10n.get('rejecter')}:${app.approvalRecords.last.approverName}',
-      'pending' when app.currentApproverId.isNotEmpty =>
-        '${l10n.get('currentApprover')}:${app.currentApproverId}',
-      _ => '',
-    };
-    return StatusBanner(
-      icon: icon,
-      statusText: label,
-      subText: approverText,
-      color: color,
-    );
-  }
-
-  Widget _buildSubmitTime(
-    BuildContext context,
-    ExpenseApplyModel app,
-    AppColorsExtension colors,
-  ) {
-    final l10n = AppLocalizations.of(context);
-    return Padding(
-      padding: const EdgeInsets.only(left: 4),
-      child: Text(
-        '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(app.createTime)}',
-        style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
-      ),
-    );
-  }
-
   // ═══ 基本信息 ═══
   // ═══ 基本信息 ═══
   Widget _buildBasicInfoSection(
   Widget _buildBasicInfoSection(
     ExpenseApplyModel app,
     ExpenseApplyModel app,
@@ -309,12 +274,6 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
       'critical' => l10n.get('critical'),
       'critical' => l10n.get('critical'),
       _ => app.urgency,
       _ => app.urgency,
     };
     };
-    String usageLabel = switch (app.usageStatus) {
-      'unused' => l10n.get('unused'),
-      'partially_used' => l10n.get('partiallyUsed'),
-      'fully_used' => l10n.get('fullyUsed'),
-      _ => app.usageStatus,
-    };
     return FormSection(
     return FormSection(
       title: l10n.get('basicInfo'),
       title: l10n.get('basicInfo'),
       leadingIcon: Icons.info_outline,
       leadingIcon: Icons.info_outline,
@@ -340,12 +299,6 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
         const SizedBox(height: 16),
         const SizedBox(height: 16),
         FormFieldRow(label: l10n.get('feeReason'), value: app.purpose, readOnly: true, showArrow: false),
         FormFieldRow(label: l10n.get('feeReason'), value: app.purpose, readOnly: true, showArrow: false),
         const SizedBox(height: 16),
         const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('validUntil'), value: app.validUntil != null ? du.DateUtils.formatDate(app.validUntil!) : '-', readOnly: true, showArrow: false),
-        const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('relatedContractNo'), value: app.referenceNo.isNotEmpty ? app.referenceNo : '-', readOnly: true, showArrow: false),
-        const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('usageStatus'), value: usageLabel, readOnly: true, showArrow: false),
-        const SizedBox(height: 16),
         FormFieldRow(label: l10n.get('remark'), value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false),
         FormFieldRow(label: l10n.get('remark'), value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false),
       ],
       ],
     );
     );
@@ -369,45 +322,88 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
         else
         else
           ...app.details.asMap().entries.map((e) {
           ...app.details.asMap().entries.map((e) {
             final d = e.value;
             final d = e.value;
+            final catLabel = d.categoryName.isNotEmpty
+                ? '${d.expenseCategory}/${d.categoryName}'
+                : d.expenseCategory;
             return Container(
             return Container(
               margin: const EdgeInsets.symmetric(vertical: 8),
               margin: const EdgeInsets.symmetric(vertical: 8),
               padding: const EdgeInsets.all(12),
               padding: const EdgeInsets.all(12),
               decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
               decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
-              child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
-                Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
+              child: Row(
+                crossAxisAlignment: CrossAxisAlignment.center,
+                children: [
                   Expanded(
                   Expanded(
-                    child: Text(d.purpose.isNotEmpty ? d.purpose : d.expenseCategory,
-                        style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary)),
+                    child: Column(
+                      crossAxisAlignment: CrossAxisAlignment.start,
+                      children: [
+                        Row(
+                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                          children: [
+                            Expanded(
+                              child: Text(
+                                catLabel,
+                                maxLines: 1,
+                                overflow: TextOverflow.ellipsis,
+                                style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPrimary),
+                              ),
+                            ),
+                            const SizedBox(width: 12),
+                            Text(
+                              '¥${d.estimatedAmount.toStringAsFixed(2)}',
+                              style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.amountPrimary),
+                            ),
+                          ],
+                        ),
+                        if (d.acctSubjectId.isNotEmpty && d.acctSubjectName.isNotEmpty) ...[
+                          const SizedBox(height: 4),
+                          Text(
+                            '${d.acctSubjectId}/${d.acctSubjectName}',
+                            maxLines: 1,
+                            overflow: TextOverflow.ellipsis,
+                            style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
+                          ),
+                        ],
+                        if (d.projectId.isNotEmpty && d.projectName.isNotEmpty) ...[
+                          const SizedBox(height: 4),
+                          Text(
+                            '${d.projectId}/${d.projectName}',
+                            maxLines: 1,
+                            overflow: TextOverflow.ellipsis,
+                            style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
+                          ),
+                        ],
+                        if (d.costDeptId.isNotEmpty && d.costDeptName.isNotEmpty) ...[
+                          const SizedBox(height: 4),
+                          Text(
+                            '${d.costDeptId}/${d.costDeptName}',
+                            maxLines: 1,
+                            overflow: TextOverflow.ellipsis,
+                            style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
+                          ),
+                        ],
+                        if (d.estimatedStartDate != null) ...[
+                          const SizedBox(height: 4),
+                          Text(
+                            '${du.DateUtils.formatDate(d.estimatedStartDate!)} ~ ${d.estimatedEndDate != null ? du.DateUtils.formatDate(d.estimatedEndDate!) : ''}',
+                            maxLines: 1,
+                            overflow: TextOverflow.ellipsis,
+                            style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
+                          ),
+                        ],
+                        if (d.remark.isNotEmpty) ...[
+                          const SizedBox(height: 4),
+                          Text(
+                            d.remark,
+                            maxLines: 2,
+                            overflow: TextOverflow.ellipsis,
+                            style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
+                          ),
+                        ],
+                      ],
+                    ),
                   ),
                   ),
-                  const SizedBox(width: 16),
-                  Text('¥${d.estimatedAmount.toStringAsFixed(2)}',
-                      style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.amountPrimary)),
-                ]),
-                if (d.expenseCategory.isNotEmpty && d.purpose != d.expenseCategory) ...[
-                  const SizedBox(height: 4),
-                  Text(d.expenseCategory, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
-                ],
-                if (d.projectName.isNotEmpty) ...[
-                  const SizedBox(height: 4),
-                  Text('${l10n.get('relatedProject')}:${d.projectName}  |  ${l10n.get('budgetSubject')}:${d.acctSubjectName}',
-                      style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
-                ],
-                if (d.costDeptName.isNotEmpty) ...[
-                  const SizedBox(height: 4),
-                  Text('${l10n.get('costDept')}:${d.costDeptName}',
-                      style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
                 ],
                 ],
-                if (d.estimatedStartDate != null) ...[
-                  const SizedBox(height: 4),
-                  Text('${du.DateUtils.formatDate(d.estimatedStartDate!)} ~ ${d.estimatedEndDate != null ? du.DateUtils.formatDate(d.estimatedEndDate!) : ''}',
-                      style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
-                ],
-                if (d.remark.isNotEmpty) ...[
-                  const SizedBox(height: 4),
-                  Text(d.remark, maxLines: 2, overflow: TextOverflow.ellipsis,
-                      style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
-                ],
-              ]),
+              ),
             );
             );
           }),
           }),
         if (app.details.isNotEmpty) ...[
         if (app.details.isNotEmpty) ...[
@@ -423,20 +419,46 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
   }
   }
 
 
   // ═══ 附件 ═══
   // ═══ 附件 ═══
-  Widget _buildAttachmentSection(ExpenseApplyModel app, AppLocalizations l10n, AppColorsExtension colors) {
+  Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
     return FormSection(
     return FormSection(
       title: l10n.get('attachments'),
       title: l10n.get('attachments'),
       leadingIcon: Icons.attach_file_outlined,
       leadingIcon: Icons.attach_file_outlined,
       children: [
       children: [
-        if (app.attachments.isEmpty)
+        if (_attachments.isEmpty)
           Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))
           Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))
         else
         else
-          Wrap(spacing: 8, runSpacing: 8, children: app.attachments.map((a) {
-            return Container(width: 80, height: 80,
-                decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border)),
-                child: Center(child: Icon(Icons.image_outlined, size: 24, color: colors.textPlaceholder)));
-          }).toList()),
+          ..._attachments.map((a) => _buildAttachmentRow(a, colors)),
       ],
       ],
     );
     );
   }
   }
+
+  Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
+    return Container(
+      margin: const EdgeInsets.symmetric(vertical: 4),
+      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+      decoration: BoxDecoration(
+        color: colors.bgPage,
+        borderRadius: BorderRadius.circular(8),
+      ),
+      child: Row(children: [
+        Icon(_fileTypeIcon(a.ext), size: 24, color: colors.primary),
+        const SizedBox(width: 10),
+        Expanded(
+          child: Text(a.fileName,
+              maxLines: 1, overflow: TextOverflow.ellipsis,
+              style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPrimary)),
+        ),
+      ]),
+    );
+  }
+
+  IconData _fileTypeIcon(String ext) {
+    switch (ext.toLowerCase()) {
+      case 'pdf': return Icons.picture_as_pdf;
+      case 'doc': case 'docx': return Icons.description;
+      case 'xls': case 'xlsx': return Icons.table_chart;
+      case 'jpg': case 'jpeg': case 'png': case 'gif': case 'bmp': return Icons.image_outlined;
+      default: return Icons.insert_drive_file;
+    }
+  }
 }
 }

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

@@ -227,6 +227,7 @@ class ExpenseApplyDetailModel {
   final String id;
   final String id;
   final String expenseApplyId;
   final String expenseApplyId;
   final String expenseCategory;
   final String expenseCategory;
+  final String categoryName;
   final String purpose;
   final String purpose;
   final String projectId;
   final String projectId;
   final String projectName;
   final String projectName;
@@ -249,6 +250,7 @@ class ExpenseApplyDetailModel {
     required this.id,
     required this.id,
     this.expenseApplyId = '',
     this.expenseApplyId = '',
     this.expenseCategory = '',
     this.expenseCategory = '',
+    this.categoryName = '',
     this.purpose = '',
     this.purpose = '',
     this.projectId = '',
     this.projectId = '',
     this.projectName = '',
     this.projectName = '',
@@ -273,6 +275,7 @@ class ExpenseApplyDetailModel {
       id: json['id'] as String? ?? '',
       id: json['id'] as String? ?? '',
       expenseApplyId: json['aeNo'] as String? ?? '',
       expenseApplyId: json['aeNo'] as String? ?? '',
       expenseCategory: json['typeNo'] as String? ?? '',
       expenseCategory: json['typeNo'] as String? ?? '',
+      categoryName: json['typeName'] as String? ?? '',
       purpose: json['purpose'] as String? ?? '',
       purpose: json['purpose'] as String? ?? '',
       projectId: json['objNo'] as String? ?? '',
       projectId: json['objNo'] as String? ?? '',
       projectName: json['objName'] as String? ?? json['projectName'] as String? ?? '',
       projectName: json['objName'] as String? ?? json['projectName'] as String? ?? '',
@@ -305,6 +308,7 @@ class ExpenseApplyDetailModel {
     'id': id,
     'id': id,
     'expenseApplyId': expenseApplyId,
     'expenseApplyId': expenseApplyId,
     'expenseCategory': expenseCategory,
     'expenseCategory': expenseCategory,
+    'categoryName': categoryName,
     'purpose': purpose,
     'purpose': purpose,
     'projectId': projectId,
     'projectId': projectId,
     'projectName': projectName,
     'projectName': projectName,

+ 76 - 0
lib/shared/models/bill_attachment.dart

@@ -0,0 +1,76 @@
+/// 单据附件模型(对应 MgrServer MongoDB erp_bill_atts 文档)
+class BillAttachment {
+  final String id;
+  final String bilId;
+  final String bilNo;
+  final int srcItm; // 0=表头附件, >0=表身行号
+  final int itm; // 附件序号
+  final String fileName;
+  final String ext;
+  final String fileId; // GridFS 文件 ID
+  final int tag;
+  final String effDd;
+  final String usr;
+
+  const BillAttachment({
+    required this.id,
+    required this.bilId,
+    required this.bilNo,
+    required this.srcItm,
+    required this.itm,
+    required this.fileName,
+    required this.ext,
+    required this.fileId,
+    required this.tag,
+    required this.effDd,
+    required this.usr,
+  });
+
+  factory BillAttachment.fromJson(Map<String, dynamic> json) {
+    return BillAttachment(
+      id: (json['_id'] as Map<String, dynamic>?)?['\$oid'] as String? ??
+          json['_id'] as String? ??
+          '',
+      bilId: json['BIL_ID'] as String? ?? '',
+      bilNo: json['BIL_NO'] as String? ?? '',
+      srcItm: json['SRCITM'] as int? ?? 0,
+      itm: json['ITM'] as int? ?? 1,
+      fileName: json['FILENAME'] as String? ?? '',
+      ext: json['EXT'] as String? ?? '',
+      fileId: (json['FILEID'] as Map<String, dynamic>?)?['\$oid'] as String? ??
+          json['FILEID'] as String? ??
+          '',
+      tag: json['TAG'] as int? ?? 1,
+      effDd: json['EFF_DD'] as String? ?? '',
+      usr: json['USR'] as String? ?? '',
+    );
+  }
+
+  /// 是否是表头附件
+  bool get isHeader => srcItm == 0;
+
+  /// 是否是表身附件
+  bool get isBody => srcItm > 0;
+
+  /// 文件图标(根据扩展名)
+  String get fileIcon {
+    switch (ext.toLowerCase()) {
+      case 'pdf':
+        return 'pdf';
+      case 'doc':
+      case 'docx':
+        return 'doc';
+      case 'xls':
+      case 'xlsx':
+        return 'xls';
+      case 'jpg':
+      case 'jpeg':
+      case 'png':
+      case 'gif':
+      case 'bmp':
+        return 'image';
+      default:
+        return 'file';
+    }
+  }
+}

+ 6 - 2
lib/shared/widgets/approval_actions.dart

@@ -41,15 +41,19 @@ class ApprovalActions extends StatelessWidget {
     return ColoredBox(
     return ColoredBox(
       color: colors.bgCard,
       color: colors.bgCard,
       child: SafeArea(
       child: SafeArea(
+        top: false,
         child: Padding(
         child: Padding(
-          padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
+          padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
           child: queryId >= 1 && queryId <= 3
           child: queryId >= 1 && queryId <= 3
               ? Row(children: [
               ? Row(children: [
                   Expanded(child: TDButton(text: l10n.get('reject'), size: TDButtonSize.large, theme: TDButtonTheme.danger, type: TDButtonType.outline, onTap: onReject)),
                   Expanded(child: TDButton(text: l10n.get('reject'), size: TDButtonSize.large, theme: TDButtonTheme.danger, type: TDButtonType.outline, onTap: onReject)),
                   const SizedBox(width: 12),
                   const SizedBox(width: 12),
                   Expanded(child: TDButton(text: l10n.get('approve'), size: TDButtonSize.large, theme: TDButtonTheme.primary, onTap: onApprove)),
                   Expanded(child: TDButton(text: l10n.get('approve'), size: TDButtonSize.large, theme: TDButtonTheme.primary, onTap: onApprove)),
                 ])
                 ])
-              : TDButton(text: l10n.get('withdrawApplication'), size: TDButtonSize.large, theme: TDButtonTheme.danger, type: TDButtonType.outline, onTap: onReverseAudit),
+              : SizedBox(
+                  width: double.infinity,
+                  child: TDButton(text: l10n.get('withdrawApplication'), size: TDButtonSize.large, theme: TDButtonTheme.danger, type: TDButtonType.outline, onTap: onReverseAudit),
+                ),
         ),
         ),
       ),
       ),
     );
     );

+ 31 - 10
lib/shared/widgets/attachment_picker.dart

@@ -428,7 +428,10 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
 }
 }
 
 
 // ═══════════════════════════════════════════════════════════════
 // ═══════════════════════════════════════════════════════════════
-// Marquee Text
+// Marquee Text(跑马灯循环滚动)
+// 技术方案: 文本复制两份 "text    text",AnimationController
+// 单向 repeat 0→1,每帧 jumpTo(value * singleTextWidth),
+// 滚动完一份文本宽度后自动重置从 0 开始,视觉上无缝循环。
 // ═══════════════════════════════════════════════════════════════
 // ═══════════════════════════════════════════════════════════════
 
 
 class _MarqueeText extends StatefulWidget {
 class _MarqueeText extends StatefulWidget {
@@ -445,32 +448,42 @@ class _MarqueeTextState extends State<_MarqueeText>
   late final AnimationController _controller;
   late final AnimationController _controller;
   final ScrollController _scrollController = ScrollController();
   final ScrollController _scrollController = ScrollController();
   bool _needsScroll = false;
   bool _needsScroll = false;
+  double _singleWidth = 0;
+
+  /// 每个字符的滚动时间(毫秒),控制滚动速度
+  static const int _msPerChar = 180;
 
 
   @override
   @override
   void initState() {
   void initState() {
     super.initState();
     super.initState();
     _controller = AnimationController(
     _controller = AnimationController(
       vsync: this,
       vsync: this,
-      duration: const Duration(milliseconds: 3000),
+      duration: Duration(
+        milliseconds: (widget.text.length * _msPerChar).clamp(3000, 12000),
+      ),
     );
     );
     WidgetsBinding.instance.addPostFrameCallback(_measure);
     WidgetsBinding.instance.addPostFrameCallback(_measure);
   }
   }
 
 
   void _measure(_) {
   void _measure(_) {
     if (!mounted || !_scrollController.hasClients) return;
     if (!mounted || !_scrollController.hasClients) return;
-    if (_scrollController.position.maxScrollExtent > 0) {
+    final max = _scrollController.position.maxScrollExtent;
+    if (max > 0) {
+      // max 是两份文本的总可滚动宽度,单份 = max/2
+      _singleWidth = max / 2;
       setState(() => _needsScroll = true);
       setState(() => _needsScroll = true);
       _controller.addListener(_onScroll);
       _controller.addListener(_onScroll);
-      _controller.repeat(reverse: true);
+      // 单向 repeat: 0→1→0(跳)→1→...  视觉上文本持续右移然后无缝重置
+      _controller.repeat();
+      // 从中间开始,避免初始就看到右边的重复文本
+      _scrollController.jumpTo(0);
     }
     }
   }
   }
 
 
   void _onScroll() {
   void _onScroll() {
-    if (!_scrollController.hasClients) return;
-    final max = _scrollController.position.maxScrollExtent;
-    if (max > 0) {
-      _scrollController.jumpTo(_controller.value * max);
-    }
+    if (!_scrollController.hasClients || _singleWidth <= 0) return;
+    // value 从 0→1 线性变化,对应滚动 0 → singleWidth
+    _scrollController.jumpTo(_controller.value * _singleWidth);
   }
   }
 
 
   @override
   @override
@@ -482,13 +495,21 @@ class _MarqueeTextState extends State<_MarqueeText>
 
 
   @override
   @override
   Widget build(BuildContext context) {
   Widget build(BuildContext context) {
+    // 复制文本两份,中间用空格隔开,保证无缝循环
+    const spacer = '       '; // 两份文本之间的视觉间距
     return SingleChildScrollView(
     return SingleChildScrollView(
       controller: _scrollController,
       controller: _scrollController,
       scrollDirection: Axis.horizontal,
       scrollDirection: Axis.horizontal,
       physics: _needsScroll
       physics: _needsScroll
           ? const NeverScrollableScrollPhysics()
           ? const NeverScrollableScrollPhysics()
           : const ClampingScrollPhysics(),
           : const ClampingScrollPhysics(),
-      child: Text(widget.text, style: widget.style, maxLines: 1),
+      child: Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Text(widget.text, style: widget.style, maxLines: 1),
+          Text(spacer + widget.text, style: widget.style, maxLines: 1),
+        ],
+      ),
     );
     );
   }
   }
 }
 }