chengc 2 周之前
父節點
當前提交
340ac66753

+ 5 - 2
assets/i18n/en.json

@@ -83,7 +83,7 @@
     "statusWaitPay": "statusWaitPay",
     "editApply": "Edit Pre-Application",
     "editExpense": "Edit Expense Report",
-    "statusTransferred": "Transferred",
+    "statusTransferred": "Converted to Voucher",
     "statusClosed": "Closed",
     "statusPendingReview": "Pending Review",
     "statusReviewing": "Under Review",
@@ -170,6 +170,7 @@
     "filterDateStartAfterEnd": "filterDateStartAfterEnd",
     "filterDateEndBeforeStart": "filterDateEndBeforeStart",
     "filterStatus": "filterStatus",
+    "transferStatus": "Transfer Status",
     "filterPayment": "filterPayment",
     "filterThisMonth": "filterThisMonth",
     "filterThisQuarter": "filterThisQuarter",
@@ -958,9 +959,11 @@
     "restore": "Restore",
     "noAttachment": "No attachments",
     "attachServiceUnavailable": "Attachment service unavailable",
+    "noAttachmentPermission": "No attachment permission. Please contact admin to configure in ERP Special Password Settings",
     "downloadFailed": "Download failed",
+    "downloadSuccess": "Saved to device",
     "openFailed": "Open failed",
-    "downloading": "Loading…",
+    "downloading": "Downloading…",
     "headerAttachments": "Header Attachments",
     "detailLine": "Detail Line"
   }

+ 5 - 2
assets/i18n/zh_CN.json

@@ -80,7 +80,7 @@
     "statusWaitPay": "待付款",
     "editApply": "修改报销申请",
     "editExpense": "修改报销",
-    "statusTransferred": "已转单",
+    "statusTransferred": "已转收支单",
     "statusClosed": "已结案",
     "statusPendingReview": "待审核",
     "statusReviewing": "审核中",
@@ -170,6 +170,7 @@
     "filterDateStartAfterEnd": "起始日期不能大于结束日期",
     "filterDateEndBeforeStart": "结束日期不能小于起始日期",
     "filterStatus": "审批状态",
+    "transferStatus": "转单状态",
     "filterPayment": "付款状态",
     "filterThisMonth": "本月",
     "filterThisQuarter": "本季",
@@ -808,9 +809,11 @@
     "restore": "恢复",
     "noAttachment": "暂无附件",
     "attachServiceUnavailable": "附件服务暂不可用",
+    "noAttachmentPermission": "暂无附件操作权限,请联系管理员在ERP「特殊密码资料」中配置",
     "downloadFailed": "下载失败",
+    "downloadSuccess": "已保存到本地",
     "openFailed": "打开失败",
-    "downloading": "载中…",
+    "downloading": "载中…",
     "headerAttachments": "表头附件",
     "detailLine": "明细行"
   }

+ 5 - 2
assets/i18n/zh_TW.json

@@ -83,7 +83,7 @@
     "statusWaitPay": "待付款",
     "editApply": "修改報銷申請",
     "editExpense": "修改報銷",
-    "statusTransferred": "已轉單",
+    "statusTransferred": "已轉收支單",
     "statusClosed": "已結案",
     "statusPendingReview": "待審核",
     "statusReviewing": "審核中",
@@ -170,6 +170,7 @@
     "filterDateStartAfterEnd": "起始日期不能大于結束日期",
     "filterDateEndBeforeStart": "結束日期不能小于起始日期",
     "filterStatus": "審批狀態",
+    "transferStatus": "轉單狀態",
     "filterPayment": "付款狀態",
     "filterThisMonth": "本月",
     "filterThisQuarter": "本季",
@@ -808,9 +809,11 @@
     "restore": "恢復",
     "noAttachment": "暫無附件",
     "attachServiceUnavailable": "附件服務暫不可用",
+    "noAttachmentPermission": "暫無附件操作權限,請聯繫管理員在ERP「特殊密碼資料」中配置",
     "downloadFailed": "下載失敗",
+    "downloadSuccess": "已儲存到本地",
     "openFailed": "開啟失敗",
-    "downloading": "載中…",
+    "downloading": "載中…",
     "headerAttachments": "表頭附件",
     "detailLine": "明細行"
   }

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

@@ -1,4 +1,7 @@
+import 'dart:io';
+
 import 'package:flutter/services.dart';
+import 'package:path_provider/path_provider.dart';
 
 /// 从原生宿主 App(Android/iOS)获取 ERP 连接配置。
 ///
@@ -11,6 +14,7 @@ import 'package:flutter/services.dart';
 /// 返回的字段可能为空,因此不标记 initialized,允许后续重试。
 class HostAppChannel {
   static const _channel = MethodChannel('com.tboss.oa/config');
+  static const _fileChannel = MethodChannel('com.tboss.oa/file');
 
   static String? _baseUrl;
   static String? _sn;
@@ -69,6 +73,40 @@ class HostAppChannel {
     await _tryInit();
   }
 
+  /// 保存文件到手机公共 Downloads 目录(Android 通过 MediaStore,iOS 写应用文档)。
+  /// 返回保存后的文件 URI(Android)或文件路径(iOS),失败返回 null。
+  static Future<String?> saveFileToDownloads(
+    Uint8List bytes,
+    String fileName,
+    String mimeType,
+  ) async {
+    if (Platform.isAndroid) {
+      try {
+        final uri = await _fileChannel.invokeMethod<String>(
+          'saveToDownloads',
+          {
+            'bytes': bytes,
+            'fileName': fileName,
+            'mimeType': mimeType,
+          },
+        );
+        return uri;
+      } catch (_) {
+        return null;
+      }
+    } else {
+      // iOS: 写应用文档目录,用户在「文件」App 中查看
+      try {
+        final dir = await getApplicationDocumentsDirectory();
+        final file = File('${dir.path}/$fileName');
+        await file.writeAsBytes(bytes);
+        return file.path;
+      } catch (_) {
+        return null;
+      }
+    }
+  }
+
   /// 重新从宿主 App 拉取配置(用于用户切换 / 登录完成后刷新)。
   static Future<void> refresh() async {
     try {

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

@@ -7,6 +7,7 @@ import '../../core/network/api_response.dart';
 import '../../app.dart';
 import '../../shared/models/pagination_model.dart';
 import '../../shared/models/bill_attachment.dart';
+import '../../shared/models/bill_file_rights.dart';
 import '../../core/navigation/host_app_channel.dart';
 import '../../core/utils/date_utils.dart' as du;
 import 'expense_model.dart';
@@ -500,6 +501,24 @@ class ExpenseApi {
     );
     return response.data!;
   }
+
+  /// 检查当前用户是否拥有指定特殊密码权限
+  Future<bool> checkSpcRight(String ctrlID) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/CheckSpcRight',
+      queryParameters: {'ctrlID': ctrlID},
+    );
+    return response.data?['hasRight'] == true;
+  }
+
+  /// 获取指定单据别的附件操作权限
+  Future<BillFileRights> getBillFileRights(String billId) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetBillFileRights',
+      queryParameters: {'billId': billId},
+    );
+    return BillFileRights.fromJson(response.data!);
+  }
 }
 
 /// 费用报销审批列表项

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

@@ -22,6 +22,7 @@ import '../../core/theme/app_colors_extension.dart';
 import '../../core/navigation/host_app_channel.dart';
 import '../../core/data/mock_api_data.dart';
 import 'widgets/expense_detail_dialog.dart';
+import '../../shared/models/bill_file_rights.dart';
 import '../../shared/widgets/action_bar.dart';
 import '../../shared/widgets/loading_dialog.dart';
 import '../../shared/widgets/attachment_picker.dart';
@@ -44,6 +45,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
   final _detailsSectionKey = GlobalKey();
   late final AttachmentPickerController _attachmentController;
   bool _attachAvailable = false;
+  BillFileRights _billFileRights = BillFileRights.none;
   late Future<bool> _draftFuture;
   bool _draftHandled = false;
   // ── 参考数据(从 API 加载) ──
@@ -57,6 +59,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
   bool _firstBuild = true;
   bool _refDataLoading = true;
   bool _addingDetail = false;
+  bool _canEditApprovedAmount = false;
 
   // ── 已导入申请单号集合,用于申请事由去重拼接 ──
   final _importedAeNos = <String>{};
@@ -77,6 +80,8 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
     _attachmentController = AttachmentPickerController(maxCount: 9)
       ..addListener(() => setState(() {}));
     _checkAttachHealth();
+    _checkApprovedAmountRight();
+    _checkBillFileRights();
     _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
     _costTypes = [];
@@ -973,6 +978,26 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
     }
   }
 
+  Future<void> _checkApprovedAmountRight() async {
+    try {
+      final api = ref.read(expenseApiProvider);
+      final ok = await api.checkSpcRight('MONCJ_AMTN_SH');
+      if (mounted) setState(() => _canEditApprovedAmount = ok);
+    } catch (_) {
+      if (mounted) setState(() => _canEditApprovedAmount = false);
+    }
+  }
+
+  Future<void> _checkBillFileRights() async {
+    try {
+      final api = ref.read(expenseApiProvider);
+      final rights = await api.getBillFileRights('BX');
+      if (mounted) setState(() => _billFileRights = rights);
+    } catch (_) {
+      if (mounted) setState(() => _billFileRights = BillFileRights.none);
+    }
+  }
+
   Widget _buildAttachmentSection(
     ExpenseCreateController controller,
     ExpenseCreateState state,
@@ -990,6 +1015,16 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
           ),
         ),
       );
+    } else if (!_billFileRights.canManageAttachments) {
+      children.add(
+        Text(
+          l10n.get('noAttachmentPermission'),
+          style: TextStyle(
+            fontSize: AppFontSizes.caption,
+            color: colors.textPlaceholder,
+          ),
+        ),
+      );
     } else {
       children.addAll([
         Text(
@@ -1007,7 +1042,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
       leadingIcon: Icons.attach_file_outlined,
       children: [
         ...children,
-        if (_attachAvailable)
+        if (_attachAvailable && _billFileRights.canManageAttachments)
           AttachmentPicker(
             controller: _attachmentController,
             maxImageSizeMB: 10,
@@ -1218,6 +1253,8 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
         initialData: initialData,
         checkAttachHealth: () =>
             ref.read(expenseApiProvider).checkAttachHealth(),
+        canEditApprovedAmount: _canEditApprovedAmount,
+        billFileRights: _billFileRights,
       );
       if (result != null && mounted) {
         final now = DateTime.now();

+ 550 - 206
lib/features/expense/expense_detail_page.dart

@@ -8,11 +8,13 @@ import '../../core/utils/date_utils.dart' as du;
 import '../../shared/widgets/form_section.dart';
 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 'expense_model.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../shared/models/bill_attachment.dart';
+import '../../shared/models/bill_file_rights.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors_extension.dart';
 import 'expense_api.dart';
@@ -31,11 +33,11 @@ class ExpenseDetailPage extends ConsumerStatefulWidget {
   ConsumerState<ExpenseDetailPage> createState() => _ExpenseDetailPageState();
 }
 
-class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
-{
+class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
   ExpenseModel? _expense;
   List<BillAttachment> _attachments = [];
   bool _attachAvailable = false;
+  BillFileRights _billFileRights = BillFileRights.none;
   bool _isLoading = true;
   String? _error;
   Map<String, dynamic>? _billStatus;
@@ -64,13 +66,20 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
       final expense = await api.fetchDetail(widget.billNo);
       setState(() => _expense = expense);
 
-      // 2. 加载附件(非致命)
+      // 2. 加载附件权限(非致命)
+      BillFileRights billFileRights = BillFileRights.none;
+      try {
+        billFileRights = await api.getBillFileRights('BX');
+      } catch (_) {}
+
+      // 3. 加载附件(非致命)
       try {
         _attachAvailable = await api.checkAttachHealth();
       } catch (_) {
         _attachAvailable = false;
       }
-      if (_attachAvailable) {
+      _billFileRights = billFileRights;
+      if (_attachAvailable && billFileRights.canBrowseAttachments) {
         try {
           _attachments = await api.getAttachments('BX', widget.billNo);
         } catch (_) {
@@ -95,7 +104,9 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
     } catch (e) {
       setState(() => _error = e.toString());
     } finally {
-      setState(() { _isLoading = false; });
+      setState(() {
+        _isLoading = false;
+      });
     }
   }
 
@@ -117,7 +128,10 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
             const SizedBox(height: 12),
             Text(
               _error!,
-              style: TextStyle(fontSize: AppFontSizes.body, color: colors.danger),
+              style: TextStyle(
+                fontSize: AppFontSizes.body,
+                color: colors.danger,
+              ),
               textAlign: TextAlign.center,
             ),
             const SizedBox(height: 16),
@@ -144,14 +158,23 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
                 if (_statusLoading) ...[
                   const Padding(
                     padding: EdgeInsets.symmetric(vertical: 8),
-                    child: Center(child: TDLoading(size: TDLoadingSize.small, icon: TDLoadingIcon.activity)),
+                    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)]);
-                }),
+                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),
@@ -169,7 +192,9 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
             showCenter: false,
             rightLabel: l10n.get('editExpense'),
             onRightTap: () async {
-              final result = await GoRouter.of(context).push('/expense/edit/${widget.billNo}');
+              final result = await GoRouter.of(
+                context,
+              ).push('/expense/edit/${widget.billNo}');
               if (result == true && mounted) _loadData();
             },
           ),
@@ -184,20 +209,50 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
     final approvalStatus = _billStatus?['approvalStatus'] as String?;
 
     if (isClosed) {
-      return StatusBanner(icon: Icons.lock_outline, statusText: l10n.get('statusClosed'), subText: '', color: Colors.blueGrey);
+      return StatusBanner(
+        icon: Icons.lock_outline,
+        statusText: l10n.get('statusClosed'),
+        subText: '',
+        color: Colors.blueGrey,
+      );
     }
     if (isTransferred) {
-      return StatusBanner(icon: Icons.swap_horiz, statusText: l10n.get('statusTransferred'), subText: '', color: Colors.blueGrey);
+      return StatusBanner(
+        icon: Icons.swap_horiz,
+        statusText: l10n.get('statusTransferred'),
+        subText: '',
+        color: Colors.blueGrey,
+      );
     }
     switch (approvalStatus) {
       case 'SHCOUNT4':
-        return StatusBanner(icon: Icons.check_circle_outline, statusText: l10n.get('statusApproved'), subText: '', color: Colors.green);
+        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);
+        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);
+        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);
+        return StatusBanner(
+          icon: Icons.block_outlined,
+          statusText: l10n.get('statusReviewRejected'),
+          subText: '',
+          color: Colors.deepOrange,
+        );
       default:
         return null;
     }
@@ -205,78 +260,102 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
 
   // ═══ 基本信息 ═══
   Widget _buildBasicInfoSection(
-      ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
+    ExpenseModel expense,
+    AppLocalizations l10n,
+    AppColorsExtension colors,
+  ) {
     return FormSection(
       title: l10n.get('basicInfo'),
       leadingIcon: Icons.info_outline,
       children: [
         FormFieldRow(
-            label: l10n.get('expenseNo'),
-            value: expense.expenseNo,
-            readOnly: true,
-            showArrow: false),
+          label: l10n.get('expenseNo'),
+          value: expense.expenseNo,
+          readOnly: true,
+          showArrow: false,
+        ),
         const SizedBox(height: 16),
         FormFieldRow(
-            label: l10n.get('date'),
-            value: du.DateUtils.formatDate(expense.createTime),
-            readOnly: true,
-            showArrow: false),
+          label: l10n.get('date'),
+          value: du.DateUtils.formatDate(expense.createTime),
+          readOnly: true,
+          showArrow: false,
+        ),
         const SizedBox(height: 16),
         FormFieldRow(
-            label: l10n.get('expensePersonnel'),
-            value: expense.applicantName.isNotEmpty
-                ? '${expense.applicantId}/${expense.applicantName}'
-                : '-',
-            readOnly: true,
-            showArrow: false),
+          label: l10n.get('expensePersonnel'),
+          value: expense.applicantName.isNotEmpty
+              ? '${expense.applicantId}/${expense.applicantName}'
+              : '-',
+          readOnly: true,
+          showArrow: false,
+        ),
         const SizedBox(height: 16),
         FormFieldRow(
-            label: l10n.get('expenseDept'),
-            value: expense.deptName.isNotEmpty
-                ? '${expense.deptId}/${expense.deptName}'
-                : '-',
-            readOnly: true,
-            showArrow: false),
+          label: l10n.get('expenseDept'),
+          value: expense.deptName.isNotEmpty
+              ? '${expense.deptId}/${expense.deptName}'
+              : '-',
+          readOnly: true,
+          showArrow: false,
+        ),
         const SizedBox(height: 16),
         SizedBox(
           height: 24,
-          child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
-            Text(l10n.get('expenseReason'),
-                style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
-            const SizedBox(width: 8),
-            Expanded(
-              child: Text(expense.purpose.isNotEmpty ? expense.purpose : '-',
+          child: Row(
+            crossAxisAlignment: CrossAxisAlignment.center,
+            children: [
+              Text(
+                l10n.get('expenseReason'),
+                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)),
-            ),
-          ]),
+                  style: TextStyle(
+                    fontSize: AppFontSizes.subtitle,
+                    fontWeight: FontWeight.w500,
+                    color: colors.textPrimary,
+                  ),
+                ),
+              ),
+            ],
+          ),
         ),
         const SizedBox(height: 16),
         FormFieldRow(
-            label: l10n.get('voucherNo'),
-            value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-',
-            readOnly: true,
-            showArrow: false),
+          label: l10n.get('voucherNo'),
+          value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-',
+          readOnly: true,
+          showArrow: false,
+        ),
         const SizedBox(height: 16),
         FormFieldRow(
-            label: l10n.get('currency'),
-            value: expense.currencyCode.isNotEmpty ? expense.currencyCode : '-',
-            readOnly: true,
-            showArrow: false),
+          label: l10n.get('currency'),
+          value: expense.currencyCode.isNotEmpty ? expense.currencyCode : '-',
+          readOnly: true,
+          showArrow: false,
+        ),
         const SizedBox(height: 16),
         FormFieldRow(
-            label: l10n.get('paymentMethod'),
-            value: expense.paymentMethod.isNotEmpty ? expense.paymentMethod : '-',
-            readOnly: true,
-            showArrow: false),
+          label: l10n.get('paymentMethod'),
+          value: expense.paymentMethod.isNotEmpty ? expense.paymentMethod : '-',
+          readOnly: true,
+          showArrow: false,
+        ),
         const SizedBox(height: 16),
         FormFieldRow(
-            label: l10n.get('remark'),
-            value: expense.remark.isNotEmpty ? expense.remark : '-',
-            readOnly: true,
-            showArrow: false,
+          label: l10n.get('remark'),
+          value: expense.remark.isNotEmpty ? expense.remark : '-',
+          readOnly: true,
+          showArrow: false,
         ),
       ],
     );
@@ -284,7 +363,10 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
 
   // ═══ 费用明细 ═══
   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,
@@ -300,8 +382,13 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
         if (expense.details.isEmpty)
           Padding(
             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
           ...expense.details.asMap().entries.map((e) {
@@ -312,108 +399,237 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
             return GestureDetector(
               onTap: () => _showExpenseDetailDialog(context, d),
               child: Container(
-              margin: const EdgeInsets.symmetric(vertical: 6),
-              padding: const EdgeInsets.all(12),
-              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)),
+                margin: const EdgeInsets.symmetric(vertical: 6),
+                padding: const EdgeInsets.all(12),
+                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,
+                                  ),
+                                ),
+                              ),
+                              Column(
+                                crossAxisAlignment: CrossAxisAlignment.end,
+                                children: [
+                                  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,
+                                      ),
+                                    ),
+                                ],
+                              ),
+                            ],
+                          ),
+                          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,
+                              ),
                             ),
-                            Column(
-                              crossAxisAlignment: CrossAxisAlignment.end,
-                              children: [
-                                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)),
-                              ],
+                          if (d.taxRate > 0)
+                            Text(
+                              '${l10n.get('taxRate')}: ${d.taxRate.toStringAsFixed(0)}%',
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
                             ),
-                          ],
-                        ),
-                        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.taxRate > 0)
-                          Text('${l10n.get('taxRate')}: ${d.taxRate.toStringAsFixed(0)}%',
-                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
-                        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.length >= 10 ? d.aeDd.substring(0, 10) : 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('costDept')}: ${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.customerVendorId}/${d.customerVendorName}',
-                              maxLines: 1, overflow: TextOverflow.ellipsis,
-                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
-                        if (d.sqManName.isNotEmpty)
-                          Text('${l10n.get('applicant')}: ${d.sqMan}/${d.sqManName}',
-                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
-                        if (d.bankAccountName.isNotEmpty)
-                          Text('${l10n.get('bankAccountName')}: ${d.bankAccountName}',
-                              maxLines: 1, overflow: TextOverflow.ellipsis,
-                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
-                        if (d.bankName.isNotEmpty)
-                          Text('${l10n.get('bankName')}: ${d.bankName}',
-                              maxLines: 1, overflow: TextOverflow.ellipsis,
-                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
-                        if (d.bankAccount.isNotEmpty)
-                          Text('${l10n.get('bankAccount')}: ${d.bankAccount}',
-                              maxLines: 1, overflow: TextOverflow.ellipsis,
-                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
-                        if (d.remark.isNotEmpty)
-                          Text('${l10n.get('remark')}: ${d.remark}',
-                              maxLines: 2, overflow: TextOverflow.ellipsis,
-                              style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
-                      ],
+                          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.length >= 10 ? d.aeDd.substring(0, 10) : 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('costDept')}: ${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.customerVendorId}/${d.customerVendorName}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.sqManName.isNotEmpty)
+                            Text(
+                              '${l10n.get('applicant')}: ${d.sqMan}/${d.sqManName}',
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.bankAccountName.isNotEmpty)
+                            Text(
+                              '${l10n.get('bankAccountName')}: ${d.bankAccountName}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.bankName.isNotEmpty)
+                            Text(
+                              '${l10n.get('bankName')}: ${d.bankName}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.bankAccount.isNotEmpty)
+                            Text(
+                              '${l10n.get('bankAccount')}: ${d.bankAccount}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          if (d.remark.isNotEmpty)
+                            Text(
+                              '${l10n.get('remark')}: ${d.remark}',
+                              maxLines: 2,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                        ],
+                      ),
                     ),
-                  ),
-                ],
-              ),
+                  ],
+                ),
               ),
             );
           }),
         if (expense.details.isNotEmpty) ...[
           const SizedBox(height: 8),
-          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: [
-                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)),
-              ],
-            ),
-          ]),
+          Row(
+            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+            children: [
+              Text(
+                l10n.get('totalExpense'),
+                style: TextStyle(
+                  fontSize: AppFontSizes.body,
+                  fontWeight: FontWeight.w600,
+                  color: colors.textPrimary,
+                ),
+              ),
+              Text(
+                '¥${totalAmount.toStringAsFixed(2)}',
+                style: TextStyle(
+                  fontSize: AppFontSizes.subtitle,
+                  fontWeight: FontWeight.w700,
+                  color: colors.amountPrimary,
+                ),
+              ),
+            ],
+          ),
+          const SizedBox(height: 4),
+          Row(
+            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+            children: [
+              Text(
+                l10n.get('approvedTotal'),
+                style: TextStyle(
+                  fontSize: AppFontSizes.body,
+                  fontWeight: FontWeight.w600,
+                  color: colors.textPrimary,
+                ),
+              ),
+              Text(
+                '¥${totalApproved.toStringAsFixed(2)}',
+                style: TextStyle(
+                  fontSize: AppFontSizes.subtitle,
+                  fontWeight: FontWeight.w700,
+                  color: totalApproved > 0 ? colors.success : colors.textPrimary,
+                ),
+              ),
+            ],
+          ),
         ],
       ],
     );
@@ -424,13 +640,38 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
   }
 
   // ═══ 附件 ═══
-  Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
+  Widget _buildAttachmentSection(
+    AppLocalizations l10n,
+    AppColorsExtension colors,
+  ) {
     if (!_attachAvailable) {
       return FormSection(
         title: l10n.get('attachments'),
         leadingIcon: Icons.attach_file_outlined,
-        children: [Text(l10n.get('attachServiceUnavailable'),
-            style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))],
+        children: [
+          Text(
+            l10n.get('attachServiceUnavailable'),
+            style: TextStyle(
+              fontSize: AppFontSizes.body,
+              color: colors.textPlaceholder,
+            ),
+          ),
+        ],
+      );
+    }
+    if (!_billFileRights.canBrowseAttachments) {
+      return FormSection(
+        title: l10n.get('attachments'),
+        leadingIcon: Icons.attach_file_outlined,
+        children: [
+          Text(
+            l10n.get('noAttachmentPermission'),
+            style: TextStyle(
+              fontSize: AppFontSizes.body,
+              color: colors.textPlaceholder,
+            ),
+          ),
+        ],
       );
     }
 
@@ -442,16 +683,31 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
 
     final children = <Widget>[];
     if (_attachments.isEmpty) {
-      children.add(Text(l10n.get('noAttachment'),
-          style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
+      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)),
-        ));
+        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));
         }
@@ -459,11 +715,19 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
       // 表身附件(按明细行分组)
       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)),
-        ));
+        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));
         }
@@ -478,7 +742,14 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
   }
 
   Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
-    final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(a.ext.toLowerCase());
+    final isImage = [
+      'jpg',
+      'jpeg',
+      'png',
+      'gif',
+      'bmp',
+      'webp',
+    ].contains(a.ext.toLowerCase());
     return GestureDetector(
       onTap: () => _openAttachment(a),
       child: Container(
@@ -488,18 +759,43 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
           color: colors.bgPage,
           borderRadius: BorderRadius.circular(8),
         ),
-        child: Row(children: [
-          if (isImage)
-            _ExpAttachmentThumbnail(api: ref.read(expenseApiProvider), attachment: a, size: 40)
-          else
-            Icon(_fileTypeIcon(a.ext), size: 40, 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)),
-          ),
-        ]),
+        child: Row(
+          children: [
+            if (isImage)
+              _ExpAttachmentThumbnail(
+                api: ref.read(expenseApiProvider),
+                attachment: a,
+                size: 40,
+              )
+            else
+              Icon(_fileTypeIcon(a.ext), size: 40, 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,
+                ),
+              ),
+            ),
+            const SizedBox(width: 8),
+            GestureDetector(
+              onTap: () => AttachmentDownloadHelper.downloadAndSave(
+                    context,
+                    a,
+                    ref.read(expenseApiProvider).downloadAttachment,
+                  ),
+              child: Icon(
+                Icons.download_outlined,
+                size: 22,
+                color: colors.primary,
+              ),
+            ),
+          ],
+        ),
       ),
     );
   }
@@ -512,7 +808,8 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
     if (isImage) {
       // 图片 → 弹窗预览,内部自动下载并显示 loading
       final api = ref.read(expenseApiProvider);
-      AttachmentPreview.show(context,
+      AttachmentPreview.show(
+        context,
         loader: api.downloadAttachment(a.id),
         fileName: a.fileName,
         loadingText: l10n.get('loading'),
@@ -543,11 +840,22 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
 
   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;
+      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;
     }
   }
 
@@ -559,10 +867,19 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
         child: Row(
           mainAxisSize: MainAxisSize.min,
           children: [
-            Icon(Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder),
+            Icon(
+              Icons.rocket_launch_outlined,
+              size: 16,
+              color: colors.textPlaceholder,
+            ),
             const SizedBox(width: 6),
-            Text(l10n.get('pageFooter'),
-                style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
+            Text(
+              l10n.get('pageFooter'),
+              style: TextStyle(
+                fontSize: AppFontSizes.caption,
+                color: colors.textPlaceholder,
+              ),
+            ),
           ],
         ),
       ),
@@ -575,9 +892,14 @@ class _ExpAttachmentThumbnail extends StatefulWidget {
   final ExpenseApi api;
   final BillAttachment attachment;
   final double size;
-  const _ExpAttachmentThumbnail({required this.api, required this.attachment, required this.size});
+  const _ExpAttachmentThumbnail({
+    required this.api,
+    required this.attachment,
+    required this.size,
+  });
   @override
-  State<_ExpAttachmentThumbnail> createState() => _ExpAttachmentThumbnailState();
+  State<_ExpAttachmentThumbnail> createState() =>
+      _ExpAttachmentThumbnailState();
 }
 
 class _ExpAttachmentThumbnailState extends State<_ExpAttachmentThumbnail> {
@@ -593,7 +915,11 @@ class _ExpAttachmentThumbnailState extends State<_ExpAttachmentThumbnail> {
   Future<void> _load() async {
     try {
       final bytes = await widget.api.downloadAttachment(widget.attachment.id);
-      if (mounted) setState(() { _bytes = bytes; _loading = false; });
+      if (mounted)
+        setState(() {
+          _bytes = bytes;
+          _loading = false;
+        });
     } catch (_) {
       if (mounted) setState(() => _loading = false);
     }
@@ -602,15 +928,33 @@ class _ExpAttachmentThumbnailState extends State<_ExpAttachmentThumbnail> {
   @override
   Widget build(BuildContext context) {
     if (_loading) {
-      return SizedBox(width: widget.size, height: widget.size,
-          child: const Center(child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))));
+      return SizedBox(
+        width: widget.size,
+        height: widget.size,
+        child: const Center(
+          child: SizedBox(
+            width: 16,
+            height: 16,
+            child: CircularProgressIndicator(strokeWidth: 2),
+          ),
+        ),
+      );
     }
     if (_bytes != null) {
       return ClipRRect(
         borderRadius: BorderRadius.circular(4),
-        child: Image.memory(_bytes!, width: widget.size, height: widget.size, fit: BoxFit.cover),
+        child: Image.memory(
+          _bytes!,
+          width: widget.size,
+          height: widget.size,
+          fit: BoxFit.cover,
+        ),
       );
     }
-    return Icon(Icons.broken_image, size: widget.size * 0.6, color: Colors.grey);
+    return Icon(
+      Icons.broken_image,
+      size: widget.size * 0.6,
+      color: Colors.grey,
+    );
   }
 }

+ 19 - 1
lib/features/expense/expense_edit_page.dart

@@ -62,6 +62,7 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
   bool _refDataLoading = true;
   bool _addingDetail = false;
   bool _isSubmitting = false;
+  bool _canEditApprovedAmount = false;
   String? _loadingError;
 
   // ── 原单数据 ──
@@ -85,6 +86,17 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
     _billNo = widget.billNo;
     _loadData();
+    _checkApprovedAmountRight();
+  }
+
+  Future<void> _checkApprovedAmountRight() async {
+    try {
+      final api = ref.read(expenseApiProvider);
+      final ok = await api.checkSpcRight('MONCJ_AMTN_SH');
+      if (mounted) setState(() => _canEditApprovedAmount = ok);
+    } catch (_) {
+      if (mounted) setState(() => _canEditApprovedAmount = false);
+    }
   }
 
   Future<void> _loadData() async {
@@ -118,6 +130,7 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
         _remarkController.text = expense.remark;
         _refDataLoading = false;
         _firstBuild = false;
+        _recalculateAmount();
         _autoSelectDept();
         _autoSelectEmployee();
       });
@@ -611,6 +624,10 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
 
   Widget _buildDetailSection(AppLocalizations l10n, AppColorsExtension colors) {
     final expense = _expense!;
+    final totalAmount = expense.details.fold<double>(
+      0,
+      (sum, d) => sum + d.totalAmount,
+    );
     final totalApproved = expense.details.fold<double>(
       0,
       (sum, d) => sum + d.approvedAmount,
@@ -849,7 +866,7 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
               ),
             ),
             Text(
-              '¥${expense.totalAmount.toStringAsFixed(2)}',
+              '¥${totalAmount.toStringAsFixed(2)}',
               style: TextStyle(
                 fontSize: AppFontSizes.subtitle,
                 fontWeight: FontWeight.w700,
@@ -936,6 +953,7 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
         checkAttachHealth: () =>
             ref.read(expenseApiProvider).checkAttachHealth(),
         showAttachments: false,
+        canEditApprovedAmount: _canEditApprovedAmount,
       );
       if (result != null && mounted) {
         final now = DateTime.now();

+ 385 - 102
lib/features/expense/expense_list_page.dart

@@ -15,7 +15,6 @@ import 'expense_list_controller.dart';
 import 'expense_model.dart';
 import 'expense_api.dart';
 
-
 class ExpenseListPage extends ConsumerStatefulWidget {
   const ExpenseListPage({super.key});
   @override
@@ -34,12 +33,22 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage> {
   void initState() {
     super.initState();
     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')}-${_daysInMonth(now.year, now.month).toString().padLeft(2, '0')}';
+    _startDateCtrl.text =
+        '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
+    _endDateCtrl.text =
+        '${now.year}-${now.month.toString().padLeft(2, '0')}-${_daysInMonth(now.year, now.month).toString().padLeft(2, '0')}';
     _refreshCtrl = EasyRefreshController();
     WidgetsBinding.instance.addPostFrameCallback((_) {
-      ref.read(expenseDateStartProvider.notifier).state = DateTime(now.year, now.month, 1);
-      ref.read(expenseDateEndProvider.notifier).state = DateTime(now.year, now.month, _daysInMonth(now.year, now.month));
+      ref.read(expenseDateStartProvider.notifier).state = DateTime(
+        now.year,
+        now.month,
+        1,
+      );
+      ref.read(expenseDateEndProvider.notifier).state = DateTime(
+        now.year,
+        now.month,
+        _daysInMonth(now.year, now.month),
+      );
       ref.invalidate(expenseMyListProvider(''));
       _invalidateDone = true;
       setState(() {});
@@ -61,13 +70,22 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage> {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final now = DateTime.now();
     TDPicker.showDatePicker(
-      context, title: l10n.get('selectDate'), backgroundColor: colors.bgCard,
-      useYear: true, useMonth: true, useDay: true,
-      useHour: false, useMinute: false, useSecond: false, useWeekDay: false,
-      dateStart: const [2020, 1, 1], dateEnd: [now.year + 1, 12, 31],
+      context,
+      title: l10n.get('selectDate'),
+      backgroundColor: colors.bgCard,
+      useYear: true,
+      useMonth: true,
+      useDay: true,
+      useHour: false,
+      useMinute: false,
+      useSecond: false,
+      useWeekDay: false,
+      dateStart: const [2020, 1, 1],
+      dateEnd: [now.year + 1, 12, 31],
       initialDate: [now.year, now.month, now.day],
       onConfirm: (selected) {
-        ctrl.text = '${selected['year']}-${selected['month'].toString().padLeft(2, '0')}-${selected['day'].toString().padLeft(2, '0')}';
+        ctrl.text =
+            '${selected['year']}-${selected['month'].toString().padLeft(2, '0')}-${selected['day'].toString().padLeft(2, '0')}';
         setState(() {});
         Navigator.of(context).pop();
       },
@@ -83,21 +101,61 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage> {
 
   Future<void> _doRefresh() async {
     ref.read(expenseKeywordProvider.notifier).state = _keywordCtrl.text.trim();
-    ref.read(expenseDateStartProvider.notifier).state = _startDateCtrl.text.isNotEmpty ? DateTime.tryParse(_startDateCtrl.text) : null;
-    ref.read(expenseDateEndProvider.notifier).state = _endDateCtrl.text.isNotEmpty ? DateTime.tryParse(_endDateCtrl.text) : null;
+    ref
+        .read(expenseDateStartProvider.notifier)
+        .state = _startDateCtrl.text.isNotEmpty
+        ? DateTime.tryParse(_startDateCtrl.text)
+        : null;
+    ref
+        .read(expenseDateEndProvider.notifier)
+        .state = _endDateCtrl.text.isNotEmpty
+        ? DateTime.tryParse(_endDateCtrl.text)
+        : null;
     ref.read(expenseRefreshProvider.notifier).state++;
   }
 
-  Widget _dateChip(TextEditingController ctrl, String hint, TDThemeData tdTheme, AppColorsExtension colors) {
+  Widget _dateChip(
+    TextEditingController ctrl,
+    String hint,
+    TDThemeData tdTheme,
+    AppColorsExtension colors,
+  ) {
     final text = ctrl.text;
     return Container(
-      height: 40, padding: const EdgeInsets.symmetric(horizontal: 12),
-      decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
-      child: Row(children: [
-        Icon(Icons.calendar_today, size: 16, color: colors.textSecondary), const SizedBox(width: 6),
-        Expanded(child: Text(text.isNotEmpty ? text : hint, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14, color: text.isNotEmpty ? colors.textPrimary : colors.textSecondary))),
-        if (text.isNotEmpty) GestureDetector(onTap: () { ctrl.clear(); setState(() {}); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
-      ]),
+      height: 40,
+      padding: const EdgeInsets.symmetric(horizontal: 12),
+      decoration: BoxDecoration(
+        color: colors.bgSecondaryContainer,
+        borderRadius: BorderRadius.circular(20),
+        border: Border.all(color: tdTheme.componentStrokeColor),
+      ),
+      child: Row(
+        children: [
+          Icon(Icons.calendar_today, size: 16, color: colors.textSecondary),
+          const SizedBox(width: 6),
+          Expanded(
+            child: Text(
+              text.isNotEmpty ? text : hint,
+              maxLines: 1,
+              overflow: TextOverflow.ellipsis,
+              style: TextStyle(
+                fontSize: 14,
+                color: text.isNotEmpty
+                    ? colors.textPrimary
+                    : colors.textSecondary,
+              ),
+            ),
+          ),
+          if (text.isNotEmpty)
+            GestureDetector(
+              onTap: () {
+                ctrl.clear();
+                setState(() {});
+              },
+              child: Icon(Icons.close, size: 18, color: colors.textSecondary),
+            ),
+        ],
+      ),
     );
   }
 
@@ -118,126 +176,351 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage> {
       });
     }
 
-    return Stack(children: [
-      Column(children: [
-        Container(
-          decoration: BoxDecoration(
-            color: colors.bgCard,
-            border: Border(bottom: BorderSide(color: tdTheme.componentStrokeColor)),
-          ),
-          child: Column(mainAxisSize: MainAxisSize.min, children: [
-            Padding(padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Row(children: [
-              Expanded(child: GestureDetector(behavior: HitTestBehavior.opaque, onTap: () => _pickDate(_startDateCtrl), child: _dateChip(_startDateCtrl, l10n.get('filterStartDate'), tdTheme, colors))),
-              const SizedBox(width: 8), Text('—', style: TextStyle(fontSize: 14, color: colors.textSecondary)), const SizedBox(width: 8),
-              Expanded(child: GestureDetector(behavior: HitTestBehavior.opaque, onTap: () => _pickDate(_endDateCtrl), child: _dateChip(_endDateCtrl, l10n.get('filterEndDate'), tdTheme, colors))),
-            ])),
-            const SizedBox(height: 8),
-            Padding(padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: Row(children: [
-              Expanded(
-                child: Container(
-                  height: 40, padding: const EdgeInsets.symmetric(horizontal: 16),
-                  decoration: BoxDecoration(color: colors.bgSecondaryContainer, borderRadius: BorderRadius.circular(20), border: Border.all(color: tdTheme.componentStrokeColor)),
-                  child: Row(children: [
-                    Expanded(child: TextField(controller: _keywordCtrl, style: TextStyle(fontSize: 14, color: colors.textPrimary), decoration: InputDecoration(hintText: l10n.get('searchExpense'), hintStyle: TextStyle(fontSize: 14, color: colors.textSecondary), border: InputBorder.none, isCollapsed: true), onChanged: (_) => setState(() {}))),
-                    if (_keywordCtrl.text.isNotEmpty) GestureDetector(onTap: () { _keywordCtrl.clear(); setState(() {}); _applyFilter(); }, child: Icon(Icons.close, size: 18, color: colors.textSecondary)),
-                  ]),
+    return Stack(
+      children: [
+        Column(
+          children: [
+            Container(
+              decoration: BoxDecoration(
+                color: colors.bgCard,
+                border: Border(
+                  bottom: BorderSide(color: tdTheme.componentStrokeColor),
                 ),
               ),
-              const SizedBox(width: 8),
-              GestureDetector(
-                onTap: () {
-                  final dir = ref.read(expenseSortDirProvider);
-                  ref.read(expenseSortDirProvider.notifier).state = dir == 'ASC' ? 'DESC' : 'ASC';
-                  _applyFilter();
-                },
-                child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: Center(child: Icon(ref.watch(expenseSortDirProvider) == 'ASC' ? Icons.arrow_upward : Icons.arrow_downward, color: Colors.white, size: 20))),
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                children: [
+                  Padding(
+                    padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
+                    child: Row(
+                      children: [
+                        Expanded(
+                          child: GestureDetector(
+                            behavior: HitTestBehavior.opaque,
+                            onTap: () => _pickDate(_startDateCtrl),
+                            child: _dateChip(
+                              _startDateCtrl,
+                              l10n.get('filterStartDate'),
+                              tdTheme,
+                              colors,
+                            ),
+                          ),
+                        ),
+                        const SizedBox(width: 8),
+                        Text(
+                          '—',
+                          style: TextStyle(
+                            fontSize: 14,
+                            color: colors.textSecondary,
+                          ),
+                        ),
+                        const SizedBox(width: 8),
+                        Expanded(
+                          child: GestureDetector(
+                            behavior: HitTestBehavior.opaque,
+                            onTap: () => _pickDate(_endDateCtrl),
+                            child: _dateChip(
+                              _endDateCtrl,
+                              l10n.get('filterEndDate'),
+                              tdTheme,
+                              colors,
+                            ),
+                          ),
+                        ),
+                      ],
+                    ),
+                  ),
+                  const SizedBox(height: 8),
+                  Padding(
+                    padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
+                    child: Row(
+                      children: [
+                        Expanded(
+                          child: Container(
+                            height: 40,
+                            padding: const EdgeInsets.symmetric(horizontal: 16),
+                            decoration: BoxDecoration(
+                              color: colors.bgSecondaryContainer,
+                              borderRadius: BorderRadius.circular(20),
+                              border: Border.all(
+                                color: tdTheme.componentStrokeColor,
+                              ),
+                            ),
+                            child: Row(
+                              children: [
+                                Expanded(
+                                  child: TextField(
+                                    controller: _keywordCtrl,
+                                    style: TextStyle(
+                                      fontSize: 14,
+                                      color: colors.textPrimary,
+                                    ),
+                                    decoration: InputDecoration(
+                                      hintText: l10n.get('searchExpense'),
+                                      hintStyle: TextStyle(
+                                        fontSize: 14,
+                                        color: colors.textSecondary,
+                                      ),
+                                      border: InputBorder.none,
+                                      isCollapsed: true,
+                                    ),
+                                    onChanged: (_) => setState(() {}),
+                                  ),
+                                ),
+                                if (_keywordCtrl.text.isNotEmpty)
+                                  GestureDetector(
+                                    onTap: () {
+                                      _keywordCtrl.clear();
+                                      setState(() {});
+                                      _applyFilter();
+                                    },
+                                    child: Icon(
+                                      Icons.close,
+                                      size: 18,
+                                      color: colors.textSecondary,
+                                    ),
+                                  ),
+                              ],
+                            ),
+                          ),
+                        ),
+                        const SizedBox(width: 8),
+                        GestureDetector(
+                          onTap: () {
+                            final dir = ref.read(expenseSortDirProvider);
+                            ref.read(expenseSortDirProvider.notifier).state =
+                                dir == 'ASC' ? 'DESC' : 'ASC';
+                            _applyFilter();
+                          },
+                          child: Container(
+                            width: 40,
+                            height: 40,
+                            decoration: BoxDecoration(
+                              color: colors.primary,
+                              borderRadius: BorderRadius.circular(20),
+                            ),
+                            child: Center(
+                              child: Icon(
+                                ref.watch(expenseSortDirProvider) == 'ASC'
+                                    ? Icons.arrow_upward
+                                    : Icons.arrow_downward,
+                                color: Colors.white,
+                                size: 20,
+                              ),
+                            ),
+                          ),
+                        ),
+                        const SizedBox(width: 8),
+                        GestureDetector(
+                          onTap: _applyFilter,
+                          child: Container(
+                            width: 40,
+                            height: 40,
+                            decoration: BoxDecoration(
+                              color: colors.primary,
+                              borderRadius: BorderRadius.circular(20),
+                            ),
+                            child: const Icon(
+                              Icons.search,
+                              color: Colors.white,
+                              size: 22,
+                            ),
+                          ),
+                        ),
+                      ],
+                    ),
+                  ),
+                ],
+              ),
+            ),
+            Expanded(
+              child: Container(
+                color: colors.bgPage,
+                child: _firstBuild
+                    ? const Center(child: SkeletonLoadingList())
+                    : _ExpenseListContent(
+                        refreshCtrl: _refreshCtrl,
+                        onRefresh: _doRefresh,
+                      ),
               ),
-              const SizedBox(width: 8),
-              GestureDetector(onTap: _applyFilter, child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: const Icon(Icons.search, color: Colors.white, size: 22))),
-            ])),
-          ]),
+            ),
+          ],
         ),
-        Expanded(child: Container(color: colors.bgPage, child: _firstBuild ? const Center(child: SkeletonLoadingList()) : _ExpenseListContent(refreshCtrl: _refreshCtrl, onRefresh: _doRefresh))),
-      ]),
-      Positioned(
-        right: 16, bottom: 16,
-        child: FloatingActionButton(
-          onPressed: () => context.push('/report/expense-detail'),
-          backgroundColor: colors.primary,
-          shape: const CircleBorder(),
-          child: const Icon(Icons.bar_chart, color: Colors.white),
+        Positioned(
+          right: 16,
+          bottom: 16,
+          child: FloatingActionButton(
+            onPressed: () => context.push('/report/expense-detail'),
+            backgroundColor: colors.primary,
+            shape: const CircleBorder(),
+            child: const Icon(Icons.bar_chart, color: Colors.white),
+          ),
         ),
-      ),
-    ]);
+      ],
+    );
   }
 }
 
 class _ExpenseListContent extends ConsumerWidget {
   final EasyRefreshController refreshCtrl;
   final Future<void> Function() onRefresh;
-  const _ExpenseListContent({required this.refreshCtrl, required this.onRefresh});
+  const _ExpenseListContent({
+    required this.refreshCtrl,
+    required this.onRefresh,
+  });
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     final r = ResponsiveHelper.of(context);
     final itemsAsync = ref.watch(expenseMyListProvider(''));
-    if (itemsAsync.isLoading && !itemsAsync.hasValue) return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: const SkeletonLoadingList()));
-    return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: EasyRefresh(controller: refreshCtrl, header: TDRefreshHeader(), onRefresh: onRefresh, child: _buildContent(itemsAsync, context, ref))));
+    if (itemsAsync.isLoading && !itemsAsync.hasValue)
+      return Center(
+        child: ConstrainedBox(
+          constraints: BoxConstraints(maxWidth: r.listMaxWidth),
+          child: const SkeletonLoadingList(),
+        ),
+      );
+    return Center(
+      child: ConstrainedBox(
+        constraints: BoxConstraints(maxWidth: r.listMaxWidth),
+        child: EasyRefresh(
+          controller: refreshCtrl,
+          header: TDRefreshHeader(),
+          onRefresh: onRefresh,
+          child: _buildContent(itemsAsync, context, ref),
+        ),
+      ),
+    );
   }
 
-  Widget _buildContent(AsyncValue<List<ExpenseModel>> itemsAsync, BuildContext context, WidgetRef ref) {
+  Widget _buildContent(
+    AsyncValue<List<ExpenseModel>> itemsAsync,
+    BuildContext context,
+    WidgetRef ref,
+  ) {
     final l10n = AppLocalizations.of(context);
     if (itemsAsync.isReloading) {
       final oldItems = itemsAsync.valueOrNull ?? [];
-      if (oldItems.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenses'))]);
-      return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: oldItems.length, itemBuilder: (_, i) {
-        final item = oldItems[i];
+      if (oldItems.isEmpty)
+        return ListView(
+          children: [
+            const SizedBox(height: 120),
+            EmptyState(message: l10n.get('noExpenses')),
+          ],
+        );
+      return ListView.builder(
+        padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
+        itemCount: oldItems.length,
+        itemBuilder: (_, i) {
+          final item = oldItems[i];
+          final applicant = item.deptName.isNotEmpty
+              ? '${item.applicantName} · ${item.deptName}'
+              : item.applicantName;
+          final card = ListCard(
+            cardNo: item.expenseNo,
+            amount: '¥${item.totalAmount.toStringAsFixed(2)}',
+            subAmount: item.totalAmtnSh > 0
+                ? '¥${item.totalAmtnSh.toStringAsFixed(2)}'
+                : null,
+            applicant: applicant,
+            description: item.purpose,
+            date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime),
+            statusTag: _buildStatusTags(item, l10n, context),
+            onTap: () {
+              context.push('/expense/detail/${item.expenseNo}');
+            },
+          );
+          return Padding(
+            padding: const EdgeInsets.only(bottom: 16),
+            child: card,
+          );
+        },
+      );
+    }
+    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('noExpenses')),
+        ],
+      );
+    return ListView.builder(
+      padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
+      itemCount: items.length + 1,
+      itemBuilder: (_, i) {
+        if (i == items.length) return ListFooter(itemCount: items.length);
+        final item = items[i];
         final applicant = item.deptName.isNotEmpty
             ? '${item.applicantName} · ${item.deptName}'
             : item.applicantName;
-        final card = ListCard(cardNo: item.expenseNo, amount: '¥${item.totalAmount.toStringAsFixed(2)}', applicant: applicant, description: item.purpose, date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime),
+        final card = ListCard(
+          cardNo: item.expenseNo,
+          amount: '¥${item.totalAmount.toStringAsFixed(2)}',
+          subAmount: item.totalAmtnSh > 0
+              ? '¥${item.totalAmtnSh.toStringAsFixed(2)}'
+              : null,
+          applicant: applicant,
+          description: item.purpose,
+          date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime),
           statusTag: _buildStatusTags(item, l10n, context),
-          onTap: () { context.push('/expense/detail/${item.expenseNo}'); });
+          onTap: () {
+            context.push('/expense/detail/${item.expenseNo}');
+          },
+        );
         return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
-      });
-    }
-    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('noExpenses'))]);
-    return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: items.length + 1, itemBuilder: (_, i) {
-      if (i == items.length) return ListFooter(itemCount: items.length);
-      final item = items[i];
-      final applicant = item.deptName.isNotEmpty
-          ? '${item.applicantName} · ${item.deptName}'
-          : item.applicantName;
-      final card = ListCard(cardNo: item.expenseNo, amount: '¥${item.totalAmount.toStringAsFixed(2)}', applicant: applicant, description: item.purpose, date: du.DateUtils.formatDate(item.expenseDate ?? item.createTime),
-        statusTag: _buildStatusTags(item, l10n, context),
-        onTap: () { context.push('/expense/detail/${item.expenseNo}'); });
-      return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
-    });
+      },
+    );
   }
 
   /// 构建状态 tag,放在卡片底部日期旁边
-  Widget _buildStatusTags(ExpenseModel item, AppLocalizations l10n, BuildContext context) {
+  Widget _buildStatusTags(
+    ExpenseModel item,
+    AppLocalizations l10n,
+    BuildContext context,
+  ) {
     final tags = <Widget>[];
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final isDark = Theme.of(context).brightness == Brightness.dark;
     final tagBg = isDark ? colors.bgSecondaryContainer : Colors.grey.shade200;
     final tagFg = isDark ? colors.textSecondary : Colors.grey.shade600;
     if (item.isTransferred == 1) {
-      tags.add(Container(
-        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
-        decoration: BoxDecoration(color: tagBg, borderRadius: BorderRadius.circular(4)),
-        child: Text(l10n.get('statusTransferred'), style: TextStyle(fontSize: 11, color: tagFg)),
-      ));
+      tags.add(
+        Container(
+          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+          decoration: BoxDecoration(
+            color: tagBg,
+            borderRadius: BorderRadius.circular(4),
+          ),
+          child: Text(
+            l10n.get('statusTransferred'),
+            style: TextStyle(fontSize: 11, color: tagFg),
+          ),
+        ),
+      );
       tags.add(const SizedBox(width: 6));
     }
     if (item.isClosed == 1) {
-      tags.add(Container(
-        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
-        decoration: BoxDecoration(color: tagBg, borderRadius: BorderRadius.circular(4)),
-        child: Text(l10n.get('statusClosed'), style: TextStyle(fontSize: 11, color: tagFg)),
-      ));
+      tags.add(
+        Container(
+          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+          decoration: BoxDecoration(
+            color: tagBg,
+            borderRadius: BorderRadius.circular(4),
+          ),
+          child: Text(
+            l10n.get('statusClosed'),
+            style: TextStyle(fontSize: 11, color: tagFg),
+          ),
+        ),
+      );
     }
     if (tags.isEmpty) return const SizedBox.shrink();
     return Row(mainAxisSize: MainAxisSize.min, children: tags);

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

@@ -14,6 +14,7 @@ class ExpenseModel {
   final String voucherNo;
   final double approvedAmount;
   final double totalAmount;
+  final double totalAmtnSh;
   final String purpose;
   final String remark;
   final String paymentMethod;
@@ -56,6 +57,7 @@ class ExpenseModel {
     this.voucherNo = '',
     this.approvedAmount = 0.0,
     this.totalAmount = 0.0,
+    this.totalAmtnSh = 0.0,
     this.purpose = '',
     this.remark = '',
     this.paymentMethod = '',
@@ -90,7 +92,8 @@ class ExpenseModel {
           ? DateTime.parse(json['expenseDate'] as String)
           : null,
       applicantId: json['usrNo'] as String? ?? '',
-      applicantName: json['applicantName'] as String? ?? 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? ?? '',
@@ -98,6 +101,7 @@ class ExpenseModel {
       voucherNo: json['vohNo'] as String? ?? '',
       approvedAmount: (json['approvedAmount'] as num?)?.toDouble() ?? 0.0,
       totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0.0,
+      totalAmtnSh: (json['totalAmtnSh'] as num?)?.toDouble() ?? 0.0,
       purpose: json['reason'] as String? ?? json['purpose'] as String? ?? '',
       remark: json['rem'] as String? ?? '',
       paymentMethod: json['payId'] as String? ?? '',
@@ -106,7 +110,9 @@ class ExpenseModel {
       isCategoryCompliant: json['isCategoryCompliant'] as bool? ?? false,
       bankTransferNo: json['bankTransferNo'] as String? ?? '',
       paymentStatus: json['paymentStatus'] as String? ?? 'unpaid',
-      status: json['clsDate'] != null ? 'closed' : (json['status'] as String? ?? 'draft'),
+      status: json['clsDate'] != null
+          ? 'closed'
+          : (json['status'] as String? ?? 'draft'),
       approvalInstanceId: json['approvalInstanceId'] as String? ?? '',
       previousInstanceIds: json['previousInstanceIds'] as String? ?? '',
       effectiveDate: json['effDd'] != null
@@ -162,6 +168,7 @@ class ExpenseModel {
     'voucherNo': voucherNo,
     'approvedAmount': approvedAmount,
     'totalAmount': totalAmount,
+    'totalAmtnSh': totalAmtnSh,
     'purpose': purpose,
     'remark': remark,
     'paymentMethod': paymentMethod,
@@ -201,6 +208,7 @@ class ExpenseModel {
     String? voucherNo,
     double? approvedAmount,
     double? totalAmount,
+    double? totalAmtnSh,
     String? purpose,
     String? remark,
     String? paymentMethod,
@@ -239,6 +247,7 @@ class ExpenseModel {
       voucherNo: voucherNo ?? this.voucherNo,
       approvedAmount: approvedAmount ?? this.approvedAmount,
       totalAmount: totalAmount ?? this.totalAmount,
+      totalAmtnSh: totalAmtnSh ?? this.totalAmtnSh,
       purpose: purpose ?? this.purpose,
       remark: remark ?? this.remark,
       paymentMethod: paymentMethod ?? this.paymentMethod,
@@ -362,20 +371,35 @@ class ExpenseDetailModel {
       expenseApplyDate: json['aeDd'] != null
           ? DateTime.parse(json['aeDd'] as String)
           : null,
-      expenseCategory: json['idxNo'] as String? ?? json['expenseCategory'] as String? ?? '',
-      categoryName: json['idxName'] as String? ?? json['categoryName'] as String? ?? '',
+      expenseCategory:
+          json['idxNo'] as String? ?? json['expenseCategory'] as String? ?? '',
+      categoryName:
+          json['idxName'] as String? ?? json['categoryName'] as String? ?? '',
       priority: json['priority'] as String? ?? '1',
       purpose: json['purpose'] 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? ?? '',
       costDeptId: json['dep'] as String? ?? '',
       costDeptName: json['depName'] as String? ?? json['dep'] as String? ?? '',
       acctSubjectId: json['accNo'] as String? ?? '',
       acctSubjectName: json['accName'] as String? ?? '',
-      amount: (json['amount'] as num?)?.toDouble() ?? (json['amtn'] as num?)?.toDouble() ?? 0.0,
-      taxRate: (json['taxRate'] as num?)?.toDouble() ?? (json['taxRto'] as num?)?.toDouble() ?? 0.0,
-      taxAmount: (json['taxAmount'] as num?)?.toDouble() ?? (json['tax'] as num?)?.toDouble() ?? 0.0,
-      totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? (json['amt'] as num?)?.toDouble() ?? 0.0,
+      amount:
+          (json['amount'] as num?)?.toDouble() ??
+          (json['amtn'] as num?)?.toDouble() ??
+          0.0,
+      taxRate:
+          (json['taxRate'] as num?)?.toDouble() ??
+          (json['taxRto'] as num?)?.toDouble() ??
+          0.0,
+      taxAmount:
+          (json['taxAmount'] as num?)?.toDouble() ??
+          (json['tax'] as num?)?.toDouble() ??
+          0.0,
+      totalAmount:
+          (json['totalAmount'] as num?)?.toDouble() ??
+          (json['amt'] as num?)?.toDouble() ??
+          0.0,
       currencyCode: json['currencyCode'] as String? ?? '',
       exchangeRate: (json['exchangeRate'] as num?)?.toDouble() ?? 1.0,
       baseAmount: (json['baseAmount'] as num?)?.toDouble() ?? 0.0,
@@ -387,7 +411,11 @@ class ExpenseDetailModel {
       bankAccountName: json['bankAccountName'] as String? ?? '',
       bankAccount: json['bankAccount'] as String? ?? '',
       sqMan: json['sqMan'] as String? ?? '',
-      sqManName: json['sqName'] as String? ?? json['sqManName'] as String? ?? json['sqMan'] as String? ?? '',
+      sqManName:
+          json['sqName'] as String? ??
+          json['sqManName'] as String? ??
+          json['sqMan'] as String? ??
+          '',
       aeNo: json['aeNo'] as String? ?? '',
       aeDd: json['aeDd'] as String? ?? '',
       remark: json['rem'] as String? ?? '',

+ 70 - 23
lib/features/expense/widgets/expense_detail_dialog.dart

@@ -7,6 +7,7 @@ import '../../../core/i18n/app_localizations.dart';
 import '../../../core/theme/app_colors.dart';
 import '../../../core/theme/app_colors_extension.dart';
 import '../../../core/data/mock_api_data.dart';
+import '../../../shared/models/bill_file_rights.dart';
 import '../expense_api.dart';
 import '../../../shared/widgets/attachment_picker.dart';
 
@@ -79,6 +80,8 @@ class ExpenseDetailDialog extends StatefulWidget {
   final ExpenseDetailInputData? initialData;
   final Future<bool> Function()? checkAttachHealth;
   final bool showAttachments;
+  final bool canEditApprovedAmount;
+  final BillFileRights? billFileRights;
 
   const ExpenseDetailDialog({
     super.key,
@@ -91,6 +94,8 @@ class ExpenseDetailDialog extends StatefulWidget {
     this.initialData,
     this.checkAttachHealth,
     this.showAttachments = true,
+    this.canEditApprovedAmount = false,
+    this.billFileRights,
   });
 
   /// 显示弹窗,返回 [ExpenseDetailInputData] 或 `null`(取消时)。
@@ -105,6 +110,8 @@ class ExpenseDetailDialog extends StatefulWidget {
     ExpenseDetailInputData? initialData,
     Future<bool> Function()? checkAttachHealth,
     bool showAttachments = true,
+    bool canEditApprovedAmount = false,
+    BillFileRights? billFileRights,
   }) {
     FocusScope.of(context).unfocus();
     return Navigator.push<ExpenseDetailInputData>(
@@ -122,6 +129,8 @@ class ExpenseDetailDialog extends StatefulWidget {
           initialData: initialData,
           checkAttachHealth: checkAttachHealth,
           showAttachments: showAttachments,
+          canEditApprovedAmount: canEditApprovedAmount,
+          billFileRights: billFileRights,
         ),
       ),
     );
@@ -206,7 +215,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
       text: d != null && d.amount > 0 ? d.amount.toStringAsFixed(2) : '',
     );
     if (d != null && d.customerVendorName.isNotEmpty) {
-      _selCustomer = CustomerVendor(id: '', name: d.customerVendorName);
+      _selCustomer = CustomerVendor(id: d.customerVendorId, name: d.customerVendorName);
     }
     _approvedAmountCtrl = TextEditingController(
       text: d != null && d.approvedAmount > 0
@@ -757,6 +766,9 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
   Widget _buildAeInfoCard(AppColorsExtension colors) {
     final d = widget.initialData!;
     final tdTheme = TDTheme.of(context);
+    final dateStr = d.aeDd.isNotEmpty
+        ? _formatDate(d.aeDd)
+        : d.aeDd;
     return Container(
       padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
       decoration: _cardDecoration(tdTheme),
@@ -764,12 +776,18 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
         children: [
           _readOnlyRow(tdTheme, _l10n.get('expenseApplyNo'), d.aeNo),
           const SizedBox(height: 8),
-          _readOnlyRow(tdTheme, _l10n.get('applyDate'), d.aeDd),
+          _readOnlyRow(tdTheme, _l10n.get('applyDate'), dateStr),
         ],
       ),
     );
   }
 
+  String _formatDate(String raw) {
+    final dt = DateTime.tryParse(raw);
+    if (dt == null) return raw;
+    return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
+  }
+
   Widget _readOnlyRow(TDThemeData tdTheme, String label, String value) {
     return Row(
       children: [
@@ -847,8 +865,18 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     );
   }
 
-  // ── 已充金额 ──
+  // ── 核准金额 ──
   Widget _buildApprovedAmountCard() {
+    if (!widget.canEditApprovedAmount) {
+      final value = _approvedAmountCtrl.text.isNotEmpty
+          ? '¥${double.tryParse(_approvedAmountCtrl.text)?.toStringAsFixed(2) ?? _approvedAmountCtrl.text}'
+          : '-';
+      return _readOnlyCard(
+        label: _l10n.get('approvedAmount'),
+        value: value,
+        colors: Theme.of(context).extension<AppColorsExtension>()!,
+      );
+    }
     return _inputCard(
       label: _l10n.get('approvedAmount'),
       required: false,
@@ -886,6 +914,24 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
   // ── 操作按钮 ──
   Widget _buildAttachmentCard(AppColorsExtension colors) {
     final tdTheme = TDTheme.of(context);
+    Widget? hint;
+    if (!_attachAvailable) {
+      hint = TDText(
+        _l10n.get('attachServiceUnavailable'),
+        font: tdTheme.fontBodySmall,
+        fontWeight: FontWeight.w400,
+        textColor: tdTheme.textColorPlaceholder,
+      );
+    } else if (widget.billFileRights != null &&
+        !widget.billFileRights!.canManageAttachments) {
+      hint = TDText(
+        _l10n.get('noAttachmentPermission'),
+        font: tdTheme.fontBodySmall,
+        fontWeight: FontWeight.w400,
+        textColor: tdTheme.textColorPlaceholder,
+      );
+    }
+
     return Column(
       crossAxisAlignment: CrossAxisAlignment.start,
       children: [
@@ -898,24 +944,20 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
             style: const TextStyle(letterSpacing: 0),
           ),
         ),
-        Padding(
-          padding: const EdgeInsets.only(left: 4, top: 4),
-          child: TDText(
-            _l10n.get('maxAttachment'),
-            font: tdTheme.fontBodySmall,
-            fontWeight: FontWeight.w400,
-            textColor: tdTheme.textColorPlaceholder,
-          ),
-        ),
         const SizedBox(height: 4),
-        if (!_attachAvailable)
-          TDText(
-            _l10n.get('attachServiceUnavailable'),
-            font: tdTheme.fontBodyMedium,
-            fontWeight: FontWeight.w400,
-            textColor: tdTheme.textColorPlaceholder,
-          )
-        else
+        if (hint != null) ...[
+          Padding(padding: const EdgeInsets.only(left: 4), child: hint),
+        ] else ...[
+          Padding(
+            padding: const EdgeInsets.only(left: 4, top: 4),
+            child: TDText(
+              _l10n.get('maxAttachment'),
+              font: tdTheme.fontBodySmall,
+              fontWeight: FontWeight.w400,
+              textColor: tdTheme.textColorPlaceholder,
+            ),
+          ),
+          const SizedBox(height: 4),
           AttachmentPicker(
             controller: _attachmentCtrl,
             maxImageSizeMB: 10,
@@ -934,17 +976,22 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
               if (context.mounted) TDToast.showText(reason, context: context);
             },
           ),
+        ],
       ],
     );
   }
 
   // ── 会计科目(只读,选择类别后自动带出) ──
   Widget _buildAcctSubjectCard(AppColorsExtension colors) {
+    final id = _selCat.acctSubjectId.isNotEmpty
+        ? _selCat.acctSubjectId
+        : widget.initialData?.acctSubjectId ?? '';
+    final name = _selCat.acctSubjectName.isNotEmpty
+        ? _selCat.acctSubjectName
+        : widget.initialData?.acctSubjectName ?? '';
     return _readOnlyCard(
       label: _l10n.get('acctSubject'),
-      value: _selCat.acctSubjectId.isNotEmpty
-          ? '${_selCat.acctSubjectId}/${_selCat.acctSubjectName}'
-          : '',
+      value: id.isNotEmpty ? '$id/$name' : '',
       colors: colors,
     );
   }

+ 21 - 12
lib/features/expense/widgets/expense_detail_view_dialog.dart

@@ -40,17 +40,19 @@ class ExpenseDetailViewDialog extends StatelessWidget {
         ? '${d.expenseCategory}/${d.categoryName}'
         : d.expenseCategory;
 
-    return Container(
-      decoration: BoxDecoration(
-        color: colors.bgCard,
-        borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
-      ),
-      child: SafeArea(
-        top: false,
-        child: Column(
-          mainAxisSize: MainAxisSize.min,
-          children: [
-            // 标题栏
+    return ConstrainedBox(
+      constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.8),
+      child: Container(
+        decoration: BoxDecoration(
+          color: colors.bgCard,
+          borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
+        ),
+        child: SafeArea(
+          top: false,
+          child: Column(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              // 标题栏
             Padding(
               padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
               child: Row(
@@ -84,7 +86,7 @@ class ExpenseDetailViewDialog extends StatelessWidget {
                     _row(l10n.get('costDept'), d.costDeptId.isNotEmpty && d.costDeptName.isNotEmpty ? '${d.costDeptId}/${d.costDeptName}' : '-', colors),
                     _row(l10n.get('customerVendor'), d.customerVendorName.isNotEmpty ? '${d.customerVendorId}/${d.customerVendorName}' : '-', colors),
                     _row(l10n.get('expenseApplyNo'), d.aeNo.isNotEmpty ? d.aeNo : '-', colors),
-                    _row(l10n.get('applyDate'), d.aeDd.isNotEmpty ? d.aeDd : '-', colors),
+                    _row(l10n.get('applyDate'), d.aeDd.isNotEmpty ? _formatDate(d.aeDd) : '-', colors),
                     _row(l10n.get('applicant'), d.sqManName.isNotEmpty ? '${d.sqMan}/${d.sqManName}' : '-', colors),
                     _row(l10n.get('bankName'), d.bankName.isNotEmpty ? d.bankName : '-', colors),
                     _row(l10n.get('bankAccountName'), d.bankAccountName.isNotEmpty ? d.bankAccountName : '-', colors),
@@ -97,9 +99,16 @@ class ExpenseDetailViewDialog extends StatelessWidget {
           ],
         ),
       ),
+      ),
     );
   }
 
+  String _formatDate(String raw) {
+    final dt = DateTime.tryParse(raw);
+    if (dt == null) return raw;
+    return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
+  }
+
   Widget _row(String label, String value, AppColorsExtension colors) {
     return Padding(
       padding: const EdgeInsets.only(bottom: 16),

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

@@ -6,6 +6,7 @@ import '../../core/network/api_client.dart';
 import '../../app.dart';
 import '../../shared/models/pagination_model.dart';
 import '../../shared/models/bill_attachment.dart';
+import '../../shared/models/bill_file_rights.dart';
 import 'expense_apply_model.dart';
 import 'report_model.dart';
 
@@ -351,6 +352,15 @@ class ExpenseApplyApi {
     return response.data!;
   }
 
+  /// 获取指定单据别的附件操作权限
+  Future<BillFileRights> getBillFileRights(String billId) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetBillFileRights',
+      queryParameters: {'billId': billId},
+    );
+    return BillFileRights.fromJson(response.data!);
+  }
+
   /// 员工查询
   Future<List<EmployeeItem>> getEmployees({
     String keyword = '',

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

@@ -12,6 +12,7 @@ import 'package:dio/dio.dart';
 import '../../core/network/api_exception.dart';
 import '../../shared/widgets/action_bar.dart';
 import '../../shared/widgets/loading_dialog.dart';
+import '../../shared/models/bill_file_rights.dart';
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
 import '../../shared/widgets/app_skeletons.dart';
@@ -54,6 +55,7 @@ class _ExpenseApplyCreatePageState
   // ── 附件 ──
   late final AttachmentPickerController _attachmentController;
   bool _attachAvailable = false;
+  BillFileRights _billFileRights = BillFileRights.none;
 
   // ── 草稿 ──
   late Future<bool> _draftFuture;
@@ -86,6 +88,7 @@ class _ExpenseApplyCreatePageState
     _attachmentController = AttachmentPickerController(maxCount: 9)
       ..addListener(() => setState(() {}));
     _checkAttachHealth();
+    _checkBillFileRights();
     _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
     _costTypes = [];
@@ -854,9 +857,20 @@ class _ExpenseApplyCreatePageState
     }
   }
 
+  Future<void> _checkBillFileRights() async {
+    try {
+      final api = ref.read(expenseApplyApiProvider);
+      final rights = await api.getBillFileRights('AE');
+      if (mounted) setState(() => _billFileRights = rights);
+    } catch (_) {
+      if (mounted) setState(() => _billFileRights = BillFileRights.none);
+    }
+  }
+
   Widget _buildAttachmentSection(AppLocalizations l10n) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final children = <Widget>[];
+
     if (!_attachAvailable) {
       children.add(
         Text(
@@ -867,6 +881,16 @@ class _ExpenseApplyCreatePageState
           ),
         ),
       );
+    } else if (!_billFileRights.canManageAttachments) {
+      children.add(
+        Text(
+          l10n.get('noAttachmentPermission'),
+          style: TextStyle(
+            fontSize: AppFontSizes.caption,
+            color: colors.textPlaceholder,
+          ),
+        ),
+      );
     } else {
       children.addAll([
         Text(
@@ -884,7 +908,7 @@ class _ExpenseApplyCreatePageState
       leadingIcon: Icons.attach_file_outlined,
       children: [
         ...children,
-        if (_attachAvailable)
+        if (_attachAvailable && _billFileRights.canManageAttachments)
           AttachmentPicker(
             controller: _attachmentController,
             maxImageSizeMB: 10,

+ 430 - 154
lib/features/expense_apply/expense_apply_detail_page.dart

@@ -12,6 +12,8 @@ import '../../core/utils/date_utils.dart' as du;
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
 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 'expense_apply_model.dart';
@@ -25,19 +27,25 @@ import '../../core/theme/app_colors_extension.dart';
 class ExpenseApplyDetailPage extends ConsumerStatefulWidget {
   final String billNo;
   final int queryId;
-  const ExpenseApplyDetailPage({super.key, required this.billNo, this.queryId = 0});
+  const ExpenseApplyDetailPage({
+    super.key,
+    required this.billNo,
+    this.queryId = 0,
+  });
 
   @override
-  ConsumerState<ExpenseApplyDetailPage> createState() => _ExpenseApplyDetailPageState();
+  ConsumerState<ExpenseApplyDetailPage> createState() =>
+      _ExpenseApplyDetailPageState();
 }
 
-class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
-{
+class _ExpenseApplyDetailPageState
+    extends ConsumerState<ExpenseApplyDetailPage> {
   bool _loading = true;
   String? _error;
   ExpenseApplyModel? _data;
   List<BillAttachment> _attachments = [];
   bool _attachAvailable = false;
+  BillFileRights _billFileRights = BillFileRights.none;
   Map<String, dynamic>? _billStatus;
   bool _statusLoading = false;
   bool _canEdit = false;
@@ -54,7 +62,10 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
   }
 
   Future<void> _loadData() async {
-    setState(() { _loading = true; _error = null; });
+    setState(() {
+      _loading = true;
+      _error = null;
+    });
     try {
       final api = ref.read(expenseApplyApiProvider);
       final detail = await api.fetchDetail(widget.billNo);
@@ -69,23 +80,31 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
         attachAvailable = false;
       }
 
+      // 加载附件权限
+      BillFileRights billFileRights = BillFileRights.none;
+      try {
+        billFileRights = await api.getBillFileRights('AE');
+      } catch (_) {}
+
       List<BillAttachment> attachments = [];
-      if (attachAvailable) {
+      if (attachAvailable && billFileRights.canBrowseAttachments) {
         try {
           attachments = await api.getAttachments('AE', widget.billNo);
           debugPrint('[Attach] getAttachments count: ${attachments.length}');
         } catch (e) {
           debugPrint('[Attach] getAttachments error: $e');
-          // 附件列表加载失败不影响服务可用状态,保持空列表
         }
       }
 
-      debugPrint('[Attach] final state: attachAvailable=$attachAvailable, count=${attachments.length}');
+      debugPrint(
+        '[Attach] final state: attachAvailable=$attachAvailable, count=${attachments.length}',
+      );
       if (mounted) {
         setState(() {
           _data = detail;
           _attachments = attachments;
           _attachAvailable = attachAvailable;
+          _billFileRights = billFileRights;
           _loading = false;
         });
       }
@@ -106,7 +125,10 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
       }
     } catch (e) {
       if (mounted) {
-        setState(() { _error = e.toString(); _loading = false; });
+        setState(() {
+          _error = e.toString();
+          _loading = false;
+        });
       }
     }
   }
@@ -132,7 +154,10 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
               child: Text(
                 _error!,
                 textAlign: TextAlign.center,
-                style: TextStyle(fontSize: AppFontSizes.body, color: colors.textSecondary),
+                style: TextStyle(
+                  fontSize: AppFontSizes.body,
+                  color: colors.textSecondary,
+                ),
               ),
             ),
             const SizedBox(height: 16),
@@ -159,14 +184,23 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
                 if (_statusLoading) ...[
                   const Padding(
                     padding: EdgeInsets.symmetric(vertical: 8),
-                    child: Center(child: TDLoading(size: TDLoadingSize.small, icon: TDLoadingIcon.activity)),
+                    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)]);
-                }),
+                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),
@@ -184,9 +218,9 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
             showCenter: false,
             rightLabel: l10n.get('editApply'),
             onRightTap: () async {
-              final result = await GoRouter.of(context).push(
-                '/expense-apply/edit/${widget.billNo}',
-              );
+              final result = await GoRouter.of(
+                context,
+              ).push('/expense-apply/edit/${widget.billNo}');
               if (result == true && mounted) _loadData();
             },
           ),
@@ -200,17 +234,42 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
     final approvalStatus = _billStatus?['approvalStatus'] as String?;
 
     if (isTransferred) {
-      return StatusBanner(icon: Icons.swap_horiz, statusText: l10n.get('statusConvertedToExpense'), subText: '', color: Colors.blueGrey);
+      return StatusBanner(
+        icon: Icons.swap_horiz,
+        statusText: l10n.get('statusConvertedToExpense'),
+        subText: '',
+        color: Colors.blueGrey,
+      );
     }
     switch (approvalStatus) {
       case 'SHCOUNT4':
-        return StatusBanner(icon: Icons.check_circle_outline, statusText: l10n.get('statusApproved'), subText: '', color: Colors.green);
+        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);
+        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);
+        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);
+        return StatusBanner(
+          icon: Icons.block_outlined,
+          statusText: l10n.get('statusReviewRejected'),
+          subText: '',
+          color: Colors.deepOrange,
+        );
       default:
         return null;
     }
@@ -226,25 +285,70 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
       title: l10n.get('basicInfo'),
       leadingIcon: Icons.info_outline,
       children: [
-        FormFieldRow(label: l10n.get('expenseApplyNo'), value: app.expenseApplyNo, readOnly: true, showArrow: false),
+        FormFieldRow(
+          label: l10n.get('expenseApplyNo'),
+          value: app.expenseApplyNo,
+          readOnly: true,
+          showArrow: false,
+        ),
         const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDate(app.createTime), readOnly: true, showArrow: false),
+        FormFieldRow(
+          label: l10n.get('date'),
+          value: du.DateUtils.formatDate(app.createTime),
+          readOnly: true,
+          showArrow: false,
+        ),
         const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('applicant'), value: app.applicantName.isNotEmpty ? '${app.applicantId}/${app.applicantName}' : '-', readOnly: true, showArrow: false),
+        FormFieldRow(
+          label: l10n.get('applicant'),
+          value: app.applicantName.isNotEmpty
+              ? '${app.applicantId}/${app.applicantName}'
+              : '-',
+          readOnly: true,
+          showArrow: false,
+        ),
         const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('department'), value: app.deptName.isNotEmpty ? '${app.deptId}/${app.deptName}' : '-', readOnly: true, showArrow: false),
+        FormFieldRow(
+          label: l10n.get('department'),
+          value: app.deptName.isNotEmpty
+              ? '${app.deptId}/${app.deptName}'
+              : '-',
+          readOnly: true,
+          showArrow: false,
+        ),
         const SizedBox(height: 16),
         SizedBox(
           height: 24,
-          child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
-            Text(l10n.get('emergencyLevel'), style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
-            _buildUrgencyChip(app.urgency, l10n, colors),
-          ]),
+          child: Row(
+            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+            children: [
+              Text(
+                l10n.get('emergencyLevel'),
+                style: TextStyle(
+                  fontSize: AppFontSizes.subtitle,
+                  color: colors.textSecondary,
+                ),
+              ),
+              _buildUrgencyChip(app.urgency, l10n, colors),
+            ],
+          ),
         ),
         const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('applyReason'), value: app.purpose.isNotEmpty ? app.purpose : '-', readOnly: true, showArrow: false, bold: true),
+        FormFieldRow(
+          label: l10n.get('applyReason'),
+          value: app.purpose.isNotEmpty ? app.purpose : '-',
+          readOnly: true,
+          showArrow: false,
+          bold: true,
+        ),
         const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('remark'), value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false, bold: false),
+        FormFieldRow(
+          label: l10n.get('remark'),
+          value: app.remark.isNotEmpty ? app.remark : '-',
+          readOnly: true,
+          showArrow: false,
+          bold: false,
+        ),
       ],
     );
   }
@@ -262,7 +366,13 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
         if (app.details.isEmpty)
           Padding(
             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
           ...app.details.asMap().entries.map((e) {
@@ -275,115 +385,188 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
               child: Container(
                 margin: const EdgeInsets.symmetric(vertical: 8),
                 padding: const EdgeInsets.all(12),
-                decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
+                decoration: BoxDecoration(
+                  color: colors.bgPage,
+                  borderRadius: BorderRadius.circular(8),
+                ),
                 child: Row(
-                crossAxisAlignment: CrossAxisAlignment.center,
-                children: [
-                  Expanded(
-                    child: Column(
-                      crossAxisAlignment: CrossAxisAlignment.start,
-                      children: [
-                        Row(
-                          mainAxisAlignment: MainAxisAlignment.spaceBetween,
-                          children: [
-                            Expanded(
-                              child: Text(
-                                catLabel,
-                                maxLines: 1,
-                                overflow: TextOverflow.ellipsis,
-                                style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPrimary),
+                  crossAxisAlignment: CrossAxisAlignment.center,
+                  children: [
+                    Expanded(
+                      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) ...[
+                            const SizedBox(height: 4),
+                            Text(
+                              '${l10n.get('acctSubject')}: ${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/${d.acctSubjectName}' : ''}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
                               ),
                             ),
-                            const SizedBox(width: 12),
+                          ],
+                          if (d.projectId.isNotEmpty &&
+                              d.projectName.isNotEmpty) ...[
+                            const SizedBox(height: 4),
                             Text(
-                              '¥${d.estimatedAmount.toStringAsFixed(2)}',
-                              style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.amountPrimary),
+                              '${l10n.get('project')}: ${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(
+                              '${l10n.get('costDept')}: ${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(
+                              '${l10n.get('estimatedDate')}: ${du.DateUtils.formatDate(d.estimatedStartDate!)}${d.estimatedEndDate != null ? ' ~ ${du.DateUtils.formatDate(d.estimatedEndDate!)}' : ''}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          ],
+                          if (d.bxNo.isNotEmpty) ...[
+                            const SizedBox(height: 4),
+                            Text(
+                              '${l10n.get('expenseNo')}: ${d.bxNo}',
+                              maxLines: 1,
+                              overflow: TextOverflow.ellipsis,
+                              style: TextStyle(
+                                fontSize: AppFontSizes.caption,
+                                color: colors.textSecondary,
+                              ),
+                            ),
+                          ],
+                          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.acctSubjectId.isNotEmpty) ...[
-                          const SizedBox(height: 4),
-                          Text(
-                            '${l10n.get('acctSubject')}: ${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/${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(
-                            '${l10n.get('project')}: ${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(
-                            '${l10n.get('costDept')}: ${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(
-                            '${l10n.get('estimatedDate')}: ${du.DateUtils.formatDate(d.estimatedStartDate!)}${d.estimatedEndDate != null ? ' ~ ${du.DateUtils.formatDate(d.estimatedEndDate!)}' : ''}',
-                            maxLines: 1,
-                            overflow: TextOverflow.ellipsis,
-                            style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
-                          ),
-                        ],
-                        if (d.bxNo.isNotEmpty) ...[
-                          const SizedBox(height: 4),
-                          Text(
-                            '${l10n.get('expenseNo')}: ${d.bxNo}',
-                            maxLines: 1,
-                            overflow: TextOverflow.ellipsis,
-                            style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
-                          ),
-                        ],
-                        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) ...[
           const SizedBox(height: 8),
-          Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
-            Text(l10n.get('total'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
-            Text('¥${app.details.fold<double>(0, (sum, d) => sum + d.estimatedAmount).toStringAsFixed(2)}',
-                style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
-          ]),
+          Row(
+            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+            children: [
+              Text(
+                l10n.get('total'),
+                style: TextStyle(
+                  fontSize: AppFontSizes.body,
+                  fontWeight: FontWeight.w600,
+                  color: colors.textPrimary,
+                ),
+              ),
+              Text(
+                '¥${app.details.fold<double>(0, (sum, d) => sum + d.estimatedAmount).toStringAsFixed(2)}',
+                style: TextStyle(
+                  fontSize: AppFontSizes.subtitle,
+                  fontWeight: FontWeight.w700,
+                  color: colors.amountPrimary,
+                ),
+              ),
+            ],
+          ),
         ],
       ],
     );
   }
 
   // ═══ 附件 ═══
-  Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
+  Widget _buildAttachmentSection(
+    AppLocalizations l10n,
+    AppColorsExtension colors,
+  ) {
     final children = <Widget>[];
     if (!_attachAvailable) {
-      children.add(Text(l10n.get('attachServiceUnavailable'),
-          style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
+      children.add(
+        Text(
+          l10n.get('attachServiceUnavailable'),
+          style: TextStyle(
+            fontSize: AppFontSizes.body,
+            color: colors.textPlaceholder,
+          ),
+        ),
+      );
+    } else if (!_billFileRights.canBrowseAttachments) {
+      children.add(
+        Text(
+          l10n.get('noAttachmentPermission'),
+          style: TextStyle(
+            fontSize: AppFontSizes.body,
+            color: colors.textPlaceholder,
+          ),
+        ),
+      );
     } else if (_attachments.isEmpty) {
-      children.add(Text(l10n.get('noAttachment'),
-          style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
+      children.add(
+        Text(
+          l10n.get('noAttachment'),
+          style: TextStyle(
+            fontSize: AppFontSizes.body,
+            color: colors.textPlaceholder,
+          ),
+        ),
+      );
     } else {
       children.addAll(_attachments.map((a) => _buildAttachmentRow(a, colors)));
     }
@@ -395,7 +578,14 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
   }
 
   Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
-    final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(a.ext.toLowerCase());
+    final isImage = [
+      'jpg',
+      'jpeg',
+      'png',
+      'gif',
+      'bmp',
+      'webp',
+    ].contains(a.ext.toLowerCase());
     return GestureDetector(
       onTap: () => _openAttachment(a),
       child: Container(
@@ -405,18 +595,43 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
           color: colors.bgPage,
           borderRadius: BorderRadius.circular(8),
         ),
-        child: Row(children: [
-          if (isImage)
-            _AttachmentThumbnail(api: ref.read(expenseApplyApiProvider), attachment: a, size: 40)
-          else
-            Icon(_fileTypeIcon(a.ext), size: 40, 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)),
-          ),
-        ]),
+        child: Row(
+          children: [
+            if (isImage)
+              _AttachmentThumbnail(
+                api: ref.read(expenseApplyApiProvider),
+                attachment: a,
+                size: 40,
+              )
+            else
+              Icon(_fileTypeIcon(a.ext), size: 40, 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,
+                ),
+              ),
+            ),
+            const SizedBox(width: 8),
+            GestureDetector(
+              onTap: () => AttachmentDownloadHelper.downloadAndSave(
+                    context,
+                    a,
+                    ref.read(expenseApplyApiProvider).downloadAttachment,
+                  ),
+              child: Icon(
+                Icons.download_outlined,
+                size: 22,
+                color: colors.primary,
+              ),
+            ),
+          ],
+        ),
       ),
     );
   }
@@ -429,7 +644,8 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
     if (isImage) {
       // 图片 → 弹窗预览,内部自动下载并显示 loading
       final api = ref.read(expenseApplyApiProvider);
-      AttachmentPreview.show(context,
+      AttachmentPreview.show(
+        context,
         loader: api.downloadAttachment(a.id),
         fileName: a.fileName,
         loadingText: l10n.get('loading'),
@@ -460,11 +676,22 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
 
   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;
+      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;
     }
   }
 
@@ -476,17 +703,30 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
         child: Row(
           mainAxisSize: MainAxisSize.min,
           children: [
-            Icon(Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder),
+            Icon(
+              Icons.rocket_launch_outlined,
+              size: 16,
+              color: colors.textPlaceholder,
+            ),
             const SizedBox(width: 6),
-            Text(l10n.get('pageFooter'),
-                style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
+            Text(
+              l10n.get('pageFooter'),
+              style: TextStyle(
+                fontSize: AppFontSizes.caption,
+                color: colors.textPlaceholder,
+              ),
+            ),
           ],
         ),
       ),
     );
   }
 
-  Widget _buildUrgencyChip(String urgency, AppLocalizations l10n, AppColorsExtension colors) {
+  Widget _buildUrgencyChip(
+    String urgency,
+    AppLocalizations l10n,
+    AppColorsExtension colors,
+  ) {
     final (label, color) = switch (urgency) {
       '3' || 'critical' => (l10n.get('critical'), colors.danger),
       '2' || 'urgent' => (l10n.get('urgent'), colors.warning),
@@ -500,11 +740,21 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
         borderRadius: BorderRadius.circular(4),
         border: Border.all(color: color, width: 0.5),
       ),
-      child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: color)),
+      child: Text(
+        label,
+        style: TextStyle(
+          fontSize: 11,
+          fontWeight: FontWeight.w500,
+          color: color,
+        ),
+      ),
     );
   }
 
-  void _showExpenseDetailDialog(BuildContext context, ExpenseApplyDetailModel d) {
+  void _showExpenseDetailDialog(
+    BuildContext context,
+    ExpenseApplyDetailModel d,
+  ) {
     ExpenseApplyDetailViewDialog.show(context, d);
   }
 }
@@ -514,7 +764,11 @@ class _AttachmentThumbnail extends StatefulWidget {
   final ExpenseApplyApi api;
   final BillAttachment attachment;
   final double size;
-  const _AttachmentThumbnail({required this.api, required this.attachment, required this.size});
+  const _AttachmentThumbnail({
+    required this.api,
+    required this.attachment,
+    required this.size,
+  });
   @override
   State<_AttachmentThumbnail> createState() => _AttachmentThumbnailState();
 }
@@ -532,7 +786,11 @@ class _AttachmentThumbnailState extends State<_AttachmentThumbnail> {
   Future<void> _load() async {
     try {
       final bytes = await widget.api.downloadAttachment(widget.attachment.id);
-      if (mounted) setState(() { _bytes = bytes; _loading = false; });
+      if (mounted)
+        setState(() {
+          _bytes = bytes;
+          _loading = false;
+        });
     } catch (_) {
       if (mounted) setState(() => _loading = false);
     }
@@ -541,15 +799,33 @@ class _AttachmentThumbnailState extends State<_AttachmentThumbnail> {
   @override
   Widget build(BuildContext context) {
     if (_loading) {
-      return SizedBox(width: widget.size, height: widget.size,
-          child: const Center(child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))));
+      return SizedBox(
+        width: widget.size,
+        height: widget.size,
+        child: const Center(
+          child: SizedBox(
+            width: 16,
+            height: 16,
+            child: CircularProgressIndicator(strokeWidth: 2),
+          ),
+        ),
+      );
     }
     if (_bytes != null) {
       return ClipRRect(
         borderRadius: BorderRadius.circular(4),
-        child: Image.memory(_bytes!, width: widget.size, height: widget.size, fit: BoxFit.cover),
+        child: Image.memory(
+          _bytes!,
+          width: widget.size,
+          height: widget.size,
+          fit: BoxFit.cover,
+        ),
       );
     }
-    return Icon(Icons.broken_image, size: widget.size * 0.6, color: Colors.grey);
+    return Icon(
+      Icons.broken_image,
+      size: widget.size * 0.6,
+      color: Colors.grey,
+    );
   }
 }

+ 3 - 3
lib/features/expense_apply/report_model.dart

@@ -51,14 +51,14 @@ class ReportDetailItem {
   final String billNo;
   final String? billDate;
   final double amount;
-  final bool isApproved;
+  final bool isTransferred;
   final String reason;
 
   const ReportDetailItem({
     this.billNo = '',
     this.billDate,
     this.amount = 0,
-    this.isApproved = false,
+    this.isTransferred = false,
     this.reason = '',
   });
 
@@ -67,7 +67,7 @@ class ReportDetailItem {
       billNo: json['billNo'] as String? ?? '',
       billDate: json['billDate'] as String?,
       amount: (json['amount'] as num?)?.toDouble() ?? 0,
-      isApproved: json['isApproved'] as bool? ?? false,
+      isTransferred: json['isTransferred'] as bool? ?? false,
       reason: json['reason'] as String? ?? '',
     );
   }

+ 14 - 11
lib/features/expense_apply/widgets/expense_apply_detail_view_dialog.dart

@@ -41,17 +41,19 @@ class ExpenseApplyDetailViewDialog extends StatelessWidget {
         ? '${d.expenseCategory}/${d.categoryName}'
         : d.expenseCategory;
 
-    return Container(
-      decoration: BoxDecoration(
-        color: colors.bgCard,
-        borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
-      ),
-      child: SafeArea(
-        top: false,
-        child: Column(
-          mainAxisSize: MainAxisSize.min,
-          children: [
-            // 标题栏
+    return ConstrainedBox(
+      constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.8),
+      child: Container(
+        decoration: BoxDecoration(
+          color: colors.bgCard,
+          borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
+        ),
+        child: SafeArea(
+          top: false,
+          child: Column(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              // 标题栏
             Padding(
               padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
               child: Row(
@@ -86,6 +88,7 @@ class ExpenseApplyDetailViewDialog extends StatelessWidget {
           ],
         ),
       ),
+      ),
     );
   }
 

+ 25 - 23
lib/features/report/expense_apply_detail_report_page.dart

@@ -702,7 +702,7 @@ class _ExpenseApplyDetailReportPageState
                   SizedBox(
                     width: 60,
                     child: Text(
-                      l10n.get('filterStatus'),
+                      l10n.get('transferStatus'),
                       textAlign: TextAlign.center,
                       style: headerStyle,
                     ),
@@ -711,8 +711,7 @@ class _ExpenseApplyDetailReportPageState
               ),
             ),
             ..._details.map((d) {
-              final approved = d.isApproved;
-              final chipColor = approved ? colors.success : colors.warning;
+              final transferred = d.isTransferred;
               return Padding(
                 padding: const EdgeInsets.symmetric(
                   horizontal: 16,
@@ -755,26 +754,29 @@ class _ExpenseApplyDetailReportPageState
                     const SizedBox(width: 8),
                     SizedBox(
                       width: 60,
-                      child: Container(
-                        padding: const EdgeInsets.symmetric(
-                          horizontal: 6,
-                          vertical: 2,
-                        ),
-                        decoration: BoxDecoration(
-                          color: chipColor.withValues(alpha: 0.1),
-                          borderRadius: BorderRadius.circular(10),
-                          border: Border.all(color: chipColor, width: 0.5),
-                        ),
-                        child: Text(
-                          approved ? l10n.get('approved') : l10n.get('pending'),
-                          textAlign: TextAlign.center,
-                          style: TextStyle(
-                            fontSize: 11,
-                            color: chipColor,
-                            fontWeight: FontWeight.w500,
-                          ),
-                        ),
-                      ),
+                      child: transferred
+                          ? Container(
+                              padding: const EdgeInsets.symmetric(
+                                horizontal: 6,
+                                vertical: 2,
+                              ),
+                              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'),
+                                textAlign: TextAlign.center,
+                                style: TextStyle(
+                                  fontSize: 11,
+                                  color: colors.success,
+                                  fontWeight: FontWeight.w500,
+                                ),
+                              ),
+                            )
+                          : const SizedBox.shrink(),
                     ),
                   ],
                 ),

+ 25 - 23
lib/features/report/expense_detail_report_page.dart

@@ -736,7 +736,7 @@ class _ExpenseDetailReportPageState
                   SizedBox(
                     width: 60,
                     child: Text(
-                      l10n.get('filterStatus'),
+                      l10n.get('transferStatus'),
                       textAlign: TextAlign.center,
                       style: headerStyle,
                     ),
@@ -745,8 +745,7 @@ class _ExpenseDetailReportPageState
               ),
             ),
             ..._details.map((d) {
-              final approved = d.isApproved;
-              final chipColor = approved ? colors.success : colors.warning;
+              final transferred = d.isTransferred;
               return Padding(
                 padding: const EdgeInsets.symmetric(
                   horizontal: 16,
@@ -789,26 +788,29 @@ class _ExpenseDetailReportPageState
                     const SizedBox(width: 8),
                     SizedBox(
                       width: 60,
-                      child: Container(
-                        padding: const EdgeInsets.symmetric(
-                          horizontal: 6,
-                          vertical: 2,
-                        ),
-                        decoration: BoxDecoration(
-                          color: chipColor.withValues(alpha: 0.1),
-                          borderRadius: BorderRadius.circular(10),
-                          border: Border.all(color: chipColor, width: 0.5),
-                        ),
-                        child: Text(
-                          approved ? l10n.get('approved') : l10n.get('pending'),
-                          textAlign: TextAlign.center,
-                          style: TextStyle(
-                            fontSize: 11,
-                            color: chipColor,
-                            fontWeight: FontWeight.w500,
-                          ),
-                        ),
-                      ),
+                      child: transferred
+                          ? Container(
+                              padding: const EdgeInsets.symmetric(
+                                horizontal: 6,
+                                vertical: 2,
+                              ),
+                              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'),
+                                textAlign: TextAlign.center,
+                                style: TextStyle(
+                                  fontSize: 11,
+                                  color: colors.success,
+                                  fontWeight: FontWeight.w500,
+                                ),
+                              ),
+                            )
+                          : const SizedBox.shrink(),
                     ),
                   ],
                 ),

+ 42 - 0
lib/shared/models/bill_file_rights.dart

@@ -0,0 +1,42 @@
+/// 单据附件操作权限
+///
+/// 来源: GET /OA/GetBillFileRights?billId=AE
+/// SPC_ID 格式: AE:1,1,1,1,1 (新增,下载,删除,查看,终审)
+class BillFileRights {
+  final bool canAdd;
+  final bool canDownload;
+  final bool canDelete;
+  final bool canView;
+  final bool canFinalApprove;
+
+  const BillFileRights({
+    required this.canAdd,
+    required this.canDownload,
+    required this.canDelete,
+    required this.canView,
+    required this.canFinalApprove,
+  });
+
+  factory BillFileRights.fromJson(Map<String, dynamic> json) => BillFileRights(
+    canAdd: json['canAdd'] == true,
+    canDownload: json['canDownload'] == true,
+    canDelete: json['canDelete'] == true,
+    canView: json['canView'] == true,
+    canFinalApprove: json['canFinalApprove'] == true,
+  );
+
+  /// 所有权限都为 false 的初始值
+  static const none = BillFileRights(
+    canAdd: false,
+    canDownload: false,
+    canDelete: false,
+    canView: false,
+    canFinalApprove: false,
+  );
+
+  /// 创建页:需要新增 + 查看权限
+  bool get canManageAttachments => canAdd && canView;
+
+  /// 详情页:需要查看 + 下载权限
+  bool get canBrowseAttachments => canView && canDownload;
+}

+ 94 - 0
lib/shared/widgets/attachment_download_helper.dart

@@ -0,0 +1,94 @@
+import 'dart:typed_data';
+
+import 'package:flutter/material.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+
+import '../../core/i18n/app_localizations.dart';
+import '../../core/navigation/host_app_channel.dart';
+import '../models/bill_attachment.dart';
+import '../widgets/loading_dialog.dart';
+
+/// 附件下载辅助类,供详情页复用下载逻辑。
+class AttachmentDownloadHelper {
+  /// 根据扩展名返回 MIME 类型
+  static String mimeType(String ext) {
+    switch (ext.toLowerCase()) {
+      case 'pdf':
+        return 'application/pdf';
+      case 'doc':
+        return 'application/msword';
+      case 'docx':
+        return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+      case 'xls':
+        return 'application/vnd.ms-excel';
+      case 'xlsx':
+        return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+      case 'jpg':
+      case 'jpeg':
+        return 'image/jpeg';
+      case 'png':
+        return 'image/png';
+      case 'gif':
+        return 'image/gif';
+      case 'bmp':
+        return 'image/bmp';
+      case 'webp':
+        return 'image/webp';
+      default:
+        return 'application/octet-stream';
+    }
+  }
+
+  /// 下载附件并保存到公共目录,成功显示路径弹窗。
+  static Future<void> downloadAndSave(
+    BuildContext context,
+    BillAttachment attachment,
+    Future<Uint8List?> Function(String id) downloader,
+  ) async {
+    final l10n = AppLocalizations.of(context);
+    try {
+      LoadingDialog.show(context, text: l10n.get('downloading'));
+      final bytes = await downloader(attachment.id);
+      if (!context.mounted) return;
+      LoadingDialog.hide(context);
+      if (bytes == null) {
+        TDToast.showText(l10n.get('downloadFailed'), context: context);
+        return;
+      }
+      final path = await HostAppChannel.saveFileToDownloads(
+        bytes,
+        attachment.fileName,
+        mimeType(attachment.ext),
+      );
+      if (!context.mounted) return;
+      if (path != null) {
+        _showSuccessDialog(context, l10n, path);
+      } else {
+        TDToast.showText(l10n.get('downloadFailed'), context: context);
+      }
+    } catch (_) {
+      if (context.mounted) LoadingDialog.hide(context);
+      if (context.mounted) {
+        TDToast.showText(l10n.get('downloadFailed'), context: context);
+      }
+    }
+  }
+
+  static void _showSuccessDialog(
+    BuildContext context,
+    AppLocalizations l10n,
+    String path,
+  ) {
+    showGeneralDialog(
+      context: context,
+      pageBuilder: (buildContext, animation, secondaryAnimation) {
+        return TDConfirmDialog(
+          title: l10n.get('downloadSuccess'),
+          content: path,
+          buttonStyle: TDDialogButtonStyle.text,
+          buttonText: l10n.get('confirm'),
+        );
+      },
+    );
+  }
+}

+ 88 - 21
lib/shared/widgets/list_card.dart

@@ -8,6 +8,7 @@ class ListCard extends StatelessWidget {
   final String? description;
   final String amount;
   final Color? amountColor;
+  final String? subAmount;
   final String date;
   final Widget? statusTag;
   final VoidCallback? onTap;
@@ -19,6 +20,7 @@ class ListCard extends StatelessWidget {
     this.description,
     required this.amount,
     this.amountColor,
+    this.subAmount,
     required this.date,
     this.statusTag,
     this.onTap,
@@ -33,28 +35,93 @@ class ListCard extends StatelessWidget {
       onTap: onTap,
       child: Container(
         padding: const EdgeInsets.all(12),
-        decoration: BoxDecoration(color: colors.bgCard, borderRadius: BorderRadius.circular(8)),
-        child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
-          Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
-            Flexible(child: Text(cardNo, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: colors.textPrimary))),
-            const SizedBox(width: 12),
-            Text(amount, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: amountColor ?? colors.amountPrimary)),
-          ]),
-          if (sub.isNotEmpty) ...[
-            const SizedBox(height: 4),
-            Text(sub, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 13, color: colors.textSecondary)),
+        decoration: BoxDecoration(
+          color: colors.bgCard,
+          borderRadius: BorderRadius.circular(8),
+        ),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                Flexible(
+                  child: Text(
+                    cardNo,
+                    maxLines: 1,
+                    overflow: TextOverflow.ellipsis,
+                    style: TextStyle(
+                      fontSize: 14,
+                      fontWeight: FontWeight.w600,
+                      color: colors.textPrimary,
+                    ),
+                  ),
+                ),
+                const SizedBox(width: 12),
+                Text(
+                  amount,
+                  maxLines: 1,
+                  overflow: TextOverflow.ellipsis,
+                  style: TextStyle(
+                    fontSize: 16,
+                    fontWeight: FontWeight.w700,
+                    color: amountColor ?? colors.amountPrimary,
+                  ),
+                ),
+              ],
+            ),
+            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) ...[
+              const SizedBox(height: 4),
+              Text(
+                sub,
+                maxLines: 1,
+                overflow: TextOverflow.ellipsis,
+                style: TextStyle(fontSize: 13, color: colors.textSecondary),
+              ),
+            ],
+            if (desc.isNotEmpty) ...[
+              const SizedBox(height: 4),
+              Text(
+                desc,
+                maxLines: 2,
+                overflow: TextOverflow.ellipsis,
+                style: TextStyle(fontSize: 14, color: colors.textSecondary),
+              ),
+            ],
+            const SizedBox(height: 8),
+            Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                Flexible(
+                  child: Text(
+                    date,
+                    maxLines: 1,
+                    overflow: TextOverflow.ellipsis,
+                    style: TextStyle(fontSize: 12, color: colors.textSecondary),
+                  ),
+                ),
+                const SizedBox(width: 8),
+                ?statusTag,
+              ],
+            ),
           ],
-          if (desc.isNotEmpty) ...[
-            const SizedBox(height: 4),
-            Text(desc, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 14, color: colors.textSecondary)),
-          ],
-          const SizedBox(height: 8),
-          Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
-            Flexible(child: Text(date, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: colors.textSecondary))),
-            const SizedBox(width: 8),
-            ?statusTag,
-          ]),
-        ]),
+        ),
       ),
     );
   }