chengc 2 日 前
コミット
f6e621b339

+ 4 - 1
assets/i18n/en.json

@@ -943,6 +943,9 @@
     "discard": "Discard",
     "restore": "Restore",
     "noAttachment": "No attachments",
-    "attachServiceUnavailable": "Attachment service unavailable"
+    "attachServiceUnavailable": "Attachment service unavailable",
+    "downloadFailed": "Download failed",
+    "openFailed": "Open failed",
+    "downloading": "Loading…"
   }
 }

+ 4 - 1
assets/i18n/zh_CN.json

@@ -793,6 +793,9 @@
     "discard": "丢弃",
     "restore": "恢复",
     "noAttachment": "暂无附件",
-    "attachServiceUnavailable": "附件服务暂不可用"
+    "attachServiceUnavailable": "附件服务暂不可用",
+    "downloadFailed": "下载失败",
+    "openFailed": "打开失败",
+    "downloading": "加载中…"
   }
 }

+ 4 - 1
assets/i18n/zh_TW.json

@@ -793,6 +793,9 @@
     "discard": "丟棄",
     "restore": "恢復",
     "noAttachment": "暫無附件",
-    "attachServiceUnavailable": "附件服務暫不可用"
+    "attachServiceUnavailable": "附件服務暫不可用",
+    "downloadFailed": "下載失敗",
+    "openFailed": "開啟失敗",
+    "downloading": "載入中…"
   }
 }

+ 18 - 0
lib/core/network/api_client.dart

@@ -1,3 +1,4 @@
+import 'dart:typed_data';
 import 'package:dio/dio.dart';
 import '../navigation/host_app_channel.dart';
 import 'api_exception.dart';
@@ -140,4 +141,21 @@ class ApiClient {
     );
     return ApiResponse.fromJson(response.data, fromJsonT);
   }
+
+  /// 下载文件,返回原始字节
+  Future<Uint8List?> downloadFile(String path, {Map<String, dynamic>? queryParameters}) async {
+    try {
+      final response = await _dio.get(
+        path,
+        queryParameters: queryParameters,
+        options: Options(responseType: ResponseType.bytes),
+      );
+      if (response.data is Uint8List) {
+        return response.data as Uint8List;
+      }
+      return null;
+    } catch (_) {
+      return null;
+    }
+  }
 }

+ 17 - 7
lib/features/expense/expense_api.dart

@@ -1,4 +1,5 @@
 import 'dart:convert';
+import 'dart:typed_data';
 import 'package:dio/dio.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import '../../core/network/api_client.dart';
@@ -163,6 +164,11 @@ class ExpenseApi {
     return null;
   }
 
+  /// 下载附件文件字节
+  Future<Uint8List?> downloadAttachment(String id) async {
+    return await _client.downloadFile('/OA/DownloadAttachment', queryParameters: {'id': id});
+  }
+
   /// 检测附件服务是否可用
   Future<bool> checkAttachHealth() async {
     try {
@@ -302,16 +308,20 @@ class ExpenseApi {
       'bilNo': bilNo,
     };
     if (srcItm != null) params['srcItm'] = srcItm;
-    final response = await _client.get<Map<String, dynamic>>(
+    final response = await _client.get<dynamic>(
       '/OA/GetAttachments',
       queryParameters: params,
     );
-    final data = response.data;
-    // data 可能是 { Result: { list: [...] } } 或直接是 list
-    final list = (data?['Result']?['list'] as List<dynamic>?) ??
-        (data?['list'] as List<dynamic>?) ??
-        [];
-    return list.map((e) => BillAttachment.fromJson(e as Map<String, dynamic>)).toList();
+    final body = response.data;
+    if (body is! Map) return [];
+    final result = body['Result'];
+    if (result is! Map) return [];
+    final documents = result['documents'];
+    if (documents is! List) return [];
+    return documents
+        .whereType<Map<String, dynamic>>()
+        .map((e) => BillAttachment.fromJson(e))
+        .toList();
   }
 
   /// 上传附件

+ 134 - 172
lib/features/expense/expense_detail_page.dart

@@ -1,21 +1,23 @@
+import 'dart:typed_data';
 import 'package:flutter/material.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:go_router/go_router.dart';
 import '../../shared/widgets/nav_bar_config.dart';
+import '../../shared/widgets/loading_dialog.dart';
 import '../../core/utils/date_utils.dart' as du;
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
 import 'expense_model.dart';
 import '../../core/i18n/app_localizations.dart';
-import '../../shared/widgets/loading_dialog.dart';
 import '../../shared/models/bill_attachment.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors_extension.dart';
 import 'expense_api.dart';
-import '../../shared/widgets/approval_actions.dart';
-import '../../shared/widgets/approval_timeline.dart';
-import '../../shared/models/approval_status.dart';
+import 'dart:io';
+import 'package:path_provider/path_provider.dart';
+import 'package:open_filex/open_filex.dart';
+import '../expense_apply/widgets/attachment_preview_page.dart';
 
 class ExpenseDetailPage extends ConsumerStatefulWidget {
   final String billNo;
@@ -31,9 +33,6 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
   ExpenseModel? _expense;
   List<BillAttachment> _attachments = [];
   bool _attachAvailable = false;
-  List<ApprovalRecord> _timelineRecords = [];
-  List<String> _timelineChain = [];
-  String _timelineCurrentApproverId = '';
   bool _isLoading = true;
   String? _error;
 
@@ -50,9 +49,15 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
     super.dispose();
   }
 
+  bool _openingFile = false;
+
   @override
   void didChangeAppLifecycleState(AppLifecycleState state) {
     if (state == AppLifecycleState.resumed) {
+      if (_openingFile) {
+        _openingFile = false;
+        return;
+      }
       _attachments = [];
       _attachAvailable = false;
       _loadData();
@@ -70,40 +75,18 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
       final expense = await api.fetchDetail(widget.billNo);
       setState(() => _expense = expense);
 
-      // 2. 加载审批时间线(非致命——失败时回退到 expense 模型数据
+      // 2. 加载附件(非致命
       try {
-        final timelineData = await api.fetchApprovalTimeline('BX', widget.billNo);
-        setState(() {
-          _timelineRecords = (timelineData['records'] as List<dynamic>?)
-                  ?.map((e) => ApprovalRecord.fromJson(e as Map<String, dynamic>))
-                  .toList() ??
-              [];
-          _timelineChain = (timelineData['chain'] as List<dynamic>?)
-                  ?.map((e) => e as String)
-                  .toList() ??
-              [];
-          _timelineCurrentApproverId =
-              (timelineData['currentApproverId'] as String?) ?? '';
-        });
+        _attachAvailable = await api.checkAttachHealth();
       } catch (_) {
-        // 回退到 expense 自带的审批数据
-        setState(() {
-          _timelineRecords = expense.approvalRecords;
-          _timelineChain = expense.approvalChain;
-          _timelineCurrentApproverId = expense.currentApproverId;
-        });
+        _attachAvailable = false;
       }
-
-      // 3. 加载附件(非致命)
-      try {
-        if (mounted) setState(() => _attachAvailable = false);
-        _attachAvailable = await api.checkAttachHealth();
-        if (_attachAvailable) {
+      if (_attachAvailable) {
+        try {
           _attachments = await api.getAttachments('BX', widget.billNo);
+        } catch (_) {
+          _attachments = [];
         }
-      } catch (_) {
-        _attachAvailable = false;
-        _attachments = [];
       }
     } catch (e) {
       setState(() => _error = e.toString());
@@ -157,118 +140,23 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
 
     final expense = _expense!;
 
-    return Column(
-      children: [
-        Expanded(
-          child: SingleChildScrollView(
-            physics: const AlwaysScrollableScrollPhysics(),
-            padding: const EdgeInsets.all(16),
-            child: Column(
-              children: [
-                _buildBasicInfoSection(expense, l10n, colors),
-                const SizedBox(height: 16),
-                _buildExpenseDetailSection(expense, l10n, colors),
-                const SizedBox(height: 16),
-                _buildAttachmentSection(l10n, colors),
-                const SizedBox(height: 16),
-                _buildApprovalSection(l10n, colors),
-              ],
-            ),
-          ),
-        ),
-        ApprovalActions(
-          queryId: widget.queryId,
-          onApprove: () => _handleApprove(),
-          onReject: () => _handleReject(),
-          onReverseAudit: () => _handleReverseAudit(),
-        ),
-      ],
-    );
-  }
-
-  // ── 审核操作 handler ──
-
-  void _handleApprove() async {
-    final l10n = AppLocalizations.of(context);
-    final rem = await _showOpinionDialog(
-      title: l10n.get('confirmApprove'),
-      hint: l10n.get('approvalComment'),
-    );
-    if (rem == null || !mounted) return;
-    await _doAudit('approve', rem: rem);
-  }
-
-  void _handleReject() async {
-    final l10n = AppLocalizations.of(context);
-    final rem = await _showOpinionDialog(
-      title: l10n.get('confirmReject'),
-      hint: l10n.get('rejectReason'),
-    );
-    if (rem == null || !mounted) return;
-    await _doAudit('reject', rem: rem);
-  }
-
-  void _handleReverseAudit() async {
-    final l10n = AppLocalizations.of(context);
-    final confirmed = await showDialog<bool>(
-      context: context,
-      builder: (ctx) => TDAlertDialog(
-        title: l10n.get('withdrawConfirm'),
-        content: l10n.get('withdrawConfirmTip'),
-        leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx, false)),
-        rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () => Navigator.pop(ctx, true)),
-      ),
-    );
-    if (confirmed != true || !mounted) return;
-    final rem = await _showOpinionDialog(
-      title: l10n.get('withdrawConfirm'),
-      hint: l10n.get('approvalComment'),
-    );
-    if (rem == null || !mounted) return;
-    await _doAudit('reverseAudit', rem: rem);
-  }
-
-  /// 弹出审批意见输入框,返回 null 表示取消
-  Future<String?> _showOpinionDialog({
-    required String title,
-    required String hint,
-  }) async {
-    final ctrl = TextEditingController();
-    return showDialog<String>(
-      context: context,
-      builder: (ctx) => TDAlertDialog(
-        title: title,
-        content: hint,
-        leftBtn: TDDialogButtonOptions(
-          title: AppLocalizations.of(context).get('cancel'),
-          action: () => Navigator.pop(ctx),
-        ),
-        rightBtn: TDDialogButtonOptions(
-          title: AppLocalizations.of(context).get('confirm'),
-          action: () => Navigator.pop(ctx, ctrl.text),
+    return Expanded(
+      child: SingleChildScrollView(
+        physics: const AlwaysScrollableScrollPhysics(),
+        padding: const EdgeInsets.all(16),
+        child: Column(
+          children: [
+            _buildBasicInfoSection(expense, l10n, colors),
+            const SizedBox(height: 16),
+            _buildExpenseDetailSection(expense, l10n, colors),
+            const SizedBox(height: 16),
+            _buildAttachmentSection(l10n, colors),
+          ],
         ),
       ),
     );
   }
 
-  Future<void> _doAudit(String action, {String rem = ''}) async {
-    try {
-      LoadingDialog.show(context);
-      final api = ref.read(expenseApiProvider);
-      await api.executeApproval(bilId: 'BX', bilNo: widget.billNo, action: action, rem: rem);
-      if (mounted) {
-        LoadingDialog.hide(context);
-        TDToast.showSuccess(AppLocalizations.of(context).get('submitSuccess'), context: context);
-        if (mounted) context.pop();
-      }
-    } catch (e) {
-      if (mounted) {
-        LoadingDialog.hide(context);
-        TDToast.showFail(e.toString(), context: context);
-      }
-    }
-  }
-
   // ═══ 基本信息 ═══
   Widget _buildBasicInfoSection(
       ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
@@ -531,39 +419,68 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
   }
 
   Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
-    return Container(
-      margin: const EdgeInsets.symmetric(vertical: 4),
-      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
-      decoration: BoxDecoration(
-        color: colors.bgPage,
-        borderRadius: BorderRadius.circular(8),
-      ),
-      child: Row(children: [
-        Icon(_fileTypeIcon(a.ext), size: 24, color: colors.primary),
-        const SizedBox(width: 10),
-        Expanded(
-          child: Text(a.fileName,
-              maxLines: 1, overflow: TextOverflow.ellipsis,
-              style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPrimary)),
+    final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(a.ext.toLowerCase());
+    return GestureDetector(
+      onTap: () => _openAttachment(a),
+      child: Container(
+        margin: const EdgeInsets.symmetric(vertical: 4),
+        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+        decoration: BoxDecoration(
+          color: colors.bgPage,
+          borderRadius: BorderRadius.circular(8),
         ),
-      ]),
+        child: Row(children: [
+          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)),
+          ),
+        ]),
+      ),
     );
   }
 
-  // ═══ 审核流程 — 使用 ApprovalTimeline 组件 ═══
-  Widget _buildApprovalSection(
-      AppLocalizations l10n, AppColorsExtension colors) {
-    return FormSection(
-      title: l10n.get('approvalFlow'),
-      leadingIcon: Icons.fact_check_outlined,
-      children: [
-        ApprovalTimeline(
-          records: _timelineRecords,
-          chain: _timelineChain,
-          currentApproverId: _timelineCurrentApproverId,
-        ),
-      ],
-    );
+  Future<void> _openAttachment(BillAttachment a) async {
+    final l10n = AppLocalizations.of(context);
+    final ext = a.ext.toLowerCase();
+    final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(ext);
+
+    if (isImage) {
+      // 图片 → 弹窗预览,内部自动下载并显示 loading
+      final api = ref.read(expenseApiProvider);
+      AttachmentPreview.show(context,
+        loader: api.downloadAttachment(a.id),
+        fileName: a.fileName,
+        loadingText: l10n.get('loading'),
+      );
+      return;
+    }
+
+    // 非图片 → 下载后调用系统工具打开
+    try {
+      LoadingDialog.show(context, text: l10n.get('downloading'));
+      final api = ref.read(expenseApiProvider);
+      final bytes = await api.downloadAttachment(a.id);
+      if (!mounted) return;
+      LoadingDialog.hide(context);
+      if (bytes == null) {
+        TDToast.showText(l10n.get('downloadFailed'), context: context);
+        return;
+      }
+      final dir = await getTemporaryDirectory();
+      final file = File('${dir.path}/${a.fileName}');
+      await file.writeAsBytes(bytes);
+      _openingFile = true;
+      await OpenFilex.open(file.path);
+    } catch (_) {
+      if (mounted) LoadingDialog.hide(context);
+      if (mounted) TDToast.showText(l10n.get('openFailed'), context: context);
+    }
   }
 
   IconData _fileTypeIcon(String ext) {
@@ -576,3 +493,48 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
     }
   }
 }
+
+/// 附件缩略图 — 自动调用 DownloadAttachment 加载图片
+class _ExpAttachmentThumbnail extends StatefulWidget {
+  final ExpenseApi api;
+  final BillAttachment attachment;
+  final double size;
+  const _ExpAttachmentThumbnail({required this.api, required this.attachment, required this.size});
+  @override
+  State<_ExpAttachmentThumbnail> createState() => _ExpAttachmentThumbnailState();
+}
+
+class _ExpAttachmentThumbnailState extends State<_ExpAttachmentThumbnail> {
+  Uint8List? _bytes;
+  bool _loading = true;
+
+  @override
+  void initState() {
+    super.initState();
+    _load();
+  }
+
+  Future<void> _load() async {
+    try {
+      final bytes = await widget.api.downloadAttachment(widget.attachment.id);
+      if (mounted) setState(() { _bytes = bytes; _loading = false; });
+    } catch (_) {
+      if (mounted) setState(() => _loading = false);
+    }
+  }
+
+  @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))));
+    }
+    if (_bytes != null) {
+      return ClipRRect(
+        borderRadius: BorderRadius.circular(4),
+        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);
+  }
+}

+ 24 - 8
lib/features/expense_apply/expense_apply_api.dart

@@ -1,4 +1,5 @@
 import 'dart:convert';
+import 'package:flutter/foundation.dart';
 import 'package:dio/dio.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import '../../core/network/api_client.dart';
@@ -151,12 +152,22 @@ class ExpenseApplyApi {
     return null;
   }
 
+  /// 下载附件文件字节
+  Future<Uint8List?> downloadAttachment(String id) async {
+    return await _client.downloadFile('/OA/DownloadAttachment', queryParameters: {'id': id});
+  }
+
   /// 检测附件服务是否可用
   Future<bool> checkAttachHealth() async {
     try {
       final response = await _client.get<Map<String, dynamic>>('/OA/CheckAttachHealth');
-      return response.data?['available'] as bool? ?? false;
-    } catch (_) {
+      debugPrint('[checkAttachHealth] response.data: ${response.data}');
+      debugPrint('[checkAttachHealth] response.data type: ${response.data.runtimeType}');
+      final available = response.data?['available'] as bool? ?? false;
+      debugPrint('[checkAttachHealth] available: $available');
+      return available;
+    } catch (e) {
+      debugPrint('[checkAttachHealth] error: $e');
       return false;
     }
   }
@@ -177,15 +188,20 @@ class ExpenseApplyApi {
       'bilNo': bilNo,
     };
     if (srcItm != null) params['srcItm'] = srcItm;
-    final response = await _client.get<Map<String, dynamic>>(
+    final response = await _client.get<dynamic>(
       '/OA/GetAttachments',
       queryParameters: params,
     );
-    final data = response.data;
-    final list = (data?['Result']?['list'] as List<dynamic>?) ??
-        (data?['list'] as List<dynamic>?) ??
-        [];
-    return list.map((e) => BillAttachment.fromJson(e as Map<String, dynamic>)).toList();
+    final body = response.data;
+    if (body is! Map) return [];
+    final result = body['Result'];
+    if (result is! Map) return [];
+    final documents = result['documents'];
+    if (documents is! List) return [];
+    return documents
+        .whereType<Map<String, dynamic>>()
+        .map((e) => BillAttachment.fromJson(e))
+        .toList();
   }
 
   /// 上传附件

+ 131 - 20
lib/features/expense_apply/expense_apply_detail_page.dart

@@ -1,8 +1,14 @@
+import 'dart:typed_data';
+import 'dart:io';
 import 'package:flutter/material.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:open_filex/open_filex.dart';
+import 'widgets/attachment_preview_page.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:go_router/go_router.dart';
 import '../../shared/widgets/nav_bar_config.dart';
+import '../../shared/widgets/loading_dialog.dart';
 import '../../core/utils/date_utils.dart' as du;
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
@@ -43,9 +49,15 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
     super.dispose();
   }
 
+  bool _openingFile = false;
+
   @override
   void didChangeAppLifecycleState(AppLifecycleState state) {
     if (state == AppLifecycleState.resumed) {
+      if (_openingFile) {
+        _openingFile = false;
+        return;
+      }
       _attachments = [];
       _attachAvailable = false;
       _loadData();
@@ -59,18 +71,27 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
       final detail = await api.fetchDetail(widget.billNo);
 
       // Load attachments (non-critical, best-effort)
-      List<BillAttachment> attachments = [];
       bool attachAvailable = false;
       try {
-        if (mounted) setState(() => _attachAvailable = false);
         attachAvailable = await api.checkAttachHealth();
-        if (attachAvailable) {
+        debugPrint('[Attach] checkAttachHealth result: $attachAvailable');
+      } catch (e) {
+        debugPrint('[Attach] checkAttachHealth error: $e');
+        attachAvailable = false;
+      }
+
+      List<BillAttachment> attachments = [];
+      if (attachAvailable) {
+        try {
           attachments = await api.getAttachments('AE', widget.billNo);
+          debugPrint('[Attach] getAttachments count: ${attachments.length}');
+        } catch (e) {
+          debugPrint('[Attach] getAttachments error: $e');
+          // 附件列表加载失败不影响服务可用状态,保持空列表
         }
-      } catch (_) {
-        attachAvailable = false;
       }
 
+      debugPrint('[Attach] final state: attachAvailable=$attachAvailable, count=${attachments.length}');
       if (mounted) {
         setState(() {
           _data = detail;
@@ -317,25 +338,70 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
   }
 
   Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
-    return Container(
-      margin: const EdgeInsets.symmetric(vertical: 4),
-      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
-      decoration: BoxDecoration(
-        color: colors.bgPage,
-        borderRadius: BorderRadius.circular(8),
-      ),
-      child: Row(children: [
-        Icon(_fileTypeIcon(a.ext), size: 24, color: colors.primary),
-        const SizedBox(width: 10),
-        Expanded(
-          child: Text(a.fileName,
-              maxLines: 1, overflow: TextOverflow.ellipsis,
-              style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPrimary)),
+    final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(a.ext.toLowerCase());
+    return GestureDetector(
+      onTap: () => _openAttachment(a),
+      child: Container(
+        margin: const EdgeInsets.symmetric(vertical: 4),
+        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+        decoration: BoxDecoration(
+          color: colors.bgPage,
+          borderRadius: BorderRadius.circular(8),
         ),
-      ]),
+        child: Row(children: [
+          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)),
+          ),
+        ]),
+      ),
     );
   }
 
+  Future<void> _openAttachment(BillAttachment a) async {
+    final l10n = AppLocalizations.of(context);
+    final ext = a.ext.toLowerCase();
+    final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(ext);
+
+    if (isImage) {
+      // 图片 → 弹窗预览,内部自动下载并显示 loading
+      final api = ref.read(expenseApplyApiProvider);
+      AttachmentPreview.show(context,
+        loader: api.downloadAttachment(a.id),
+        fileName: a.fileName,
+        loadingText: l10n.get('loading'),
+      );
+      return;
+    }
+
+    // 非图片 → 下载后调用系统工具打开
+    try {
+      LoadingDialog.show(context, text: l10n.get('downloading'));
+      final api = ref.read(expenseApplyApiProvider);
+      final bytes = await api.downloadAttachment(a.id);
+      if (!mounted) return;
+      LoadingDialog.hide(context);
+      if (bytes == null) {
+        TDToast.showText(l10n.get('downloadFailed'), context: context);
+        return;
+      }
+      final dir = await getTemporaryDirectory();
+      final file = File('${dir.path}/${a.fileName}');
+      await file.writeAsBytes(bytes);
+      _openingFile = true;
+      await OpenFilex.open(file.path);
+    } catch (_) {
+      if (mounted) LoadingDialog.hide(context);
+      if (mounted) TDToast.showText(l10n.get('openFailed'), context: context);
+    }
+  }
+
   IconData _fileTypeIcon(String ext) {
     switch (ext.toLowerCase()) {
       case 'pdf': return Icons.picture_as_pdf;
@@ -382,3 +448,48 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
     );
   }
 }
+
+/// 附件缩略图 — 自动调用 DownloadAttachment 加载图片
+class _AttachmentThumbnail extends StatefulWidget {
+  final ExpenseApplyApi api;
+  final BillAttachment attachment;
+  final double size;
+  const _AttachmentThumbnail({required this.api, required this.attachment, required this.size});
+  @override
+  State<_AttachmentThumbnail> createState() => _AttachmentThumbnailState();
+}
+
+class _AttachmentThumbnailState extends State<_AttachmentThumbnail> {
+  Uint8List? _bytes;
+  bool _loading = true;
+
+  @override
+  void initState() {
+    super.initState();
+    _load();
+  }
+
+  Future<void> _load() async {
+    try {
+      final bytes = await widget.api.downloadAttachment(widget.attachment.id);
+      if (mounted) setState(() { _bytes = bytes; _loading = false; });
+    } catch (_) {
+      if (mounted) setState(() => _loading = false);
+    }
+  }
+
+  @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))));
+    }
+    if (_bytes != null) {
+      return ClipRRect(
+        borderRadius: BorderRadius.circular(4),
+        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);
+  }
+}

+ 149 - 0
lib/features/expense_apply/widgets/attachment_preview_page.dart

@@ -0,0 +1,149 @@
+import 'dart:typed_data';
+import 'package:flutter/material.dart';
+
+/// 图片预览弹窗,支持捏合缩放。
+///
+/// 以半透明蒙版覆盖在当前页面上方,内部自动加载图片,
+/// 加载期间显示 loading 动画,加载失败显示错误提示。
+///
+/// 使用方式:
+/// ```dart
+/// AttachmentPreview.show(context,
+///   loader: api.downloadAttachment(id),
+///   fileName: 'photo.jpg',
+///   loadingText: '加载中…',
+/// );
+/// ```
+class AttachmentPreview {
+  AttachmentPreview._();
+
+  /// 显示图片预览弹窗。
+  static Future<void> show(
+    BuildContext context, {
+    required Future<Uint8List?> loader,
+    required String fileName,
+    required String loadingText,
+  }) {
+    return showGeneralDialog(
+      context: context,
+      barrierDismissible: true,
+      barrierLabel: 'Close',
+      barrierColor: Colors.black87,
+      transitionDuration: const Duration(milliseconds: 250),
+      pageBuilder: (context, animation, secondaryAnimation) {
+        return FadeTransition(
+          opacity: animation,
+          child: Material(
+            type: MaterialType.transparency,
+            child: _PreviewContent(
+              loader: loader,
+              fileName: fileName,
+              loadingText: loadingText,
+            ),
+          ),
+        );
+      },
+    );
+  }
+}
+
+class _PreviewContent extends StatefulWidget {
+  final Future<Uint8List?> loader;
+  final String fileName;
+  final String loadingText;
+
+  const _PreviewContent({
+    required this.loader,
+    required this.fileName,
+    required this.loadingText,
+  });
+
+  @override
+  State<_PreviewContent> createState() => _PreviewContentState();
+}
+
+class _PreviewContentState extends State<_PreviewContent> {
+  Uint8List? _bytes;
+  bool _loading = true;
+  String? _error;
+
+  @override
+  void initState() {
+    super.initState();
+    _load();
+  }
+
+  Future<void> _load() async {
+    try {
+      final bytes = await widget.loader;
+      if (!mounted) return;
+      if (bytes == null) {
+        setState(() { _error = 'Download failed'; _loading = false; });
+        return;
+      }
+      setState(() { _bytes = bytes; _loading = false; });
+    } catch (_) {
+      if (!mounted) return;
+      setState(() { _error = 'Open failed'; _loading = false; });
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return SafeArea(
+      child: Stack(
+        children: [
+          Center(child: _buildBody()),
+          Positioned(
+            top: 8,
+            right: 8,
+            child: IconButton(
+              icon: const Icon(Icons.close, color: Colors.white70, size: 28),
+              onPressed: () => Navigator.of(context).pop(),
+            ),
+          ),
+          Positioned(
+            top: 16,
+            left: 16,
+            right: 56,
+            child: Text(
+              widget.fileName,
+              style: const TextStyle(color: Colors.white, fontSize: 15),
+              overflow: TextOverflow.ellipsis,
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildBody() {
+    if (_loading) {
+      return Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          const CircularProgressIndicator(color: Colors.white),
+          const SizedBox(height: 16),
+          Text(widget.loadingText,
+              style: const TextStyle(color: Colors.white70, fontSize: 14)),
+        ],
+      );
+    }
+    if (_error != null || _bytes == null) {
+      return Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          const Icon(Icons.broken_image_outlined, color: Colors.white54, size: 48),
+          const SizedBox(height: 12),
+          Text(_error ?? 'Unknown error',
+              style: const TextStyle(color: Colors.white70, fontSize: 14)),
+        ],
+      );
+    }
+    return InteractiveViewer(
+      minScale: 0.5,
+      maxScale: 4.0,
+      child: Image.memory(_bytes!),
+    );
+  }
+}

+ 6 - 4
lib/shared/models/bill_attachment.dart

@@ -28,8 +28,9 @@ class BillAttachment {
 
   factory BillAttachment.fromJson(Map<String, dynamic> json) {
     return BillAttachment(
-      id: (json['_id'] as Map<String, dynamic>?)?['\$oid'] as String? ??
-          json['_id'] as String? ??
+      id: (json['_id'] is Map<String, dynamic>
+              ? (json['_id']['\$oid'] as String?)
+              : json['_id'] as String?) ??
           '',
       bilId: json['BIL_ID'] as String? ?? '',
       bilNo: json['BIL_NO'] as String? ?? '',
@@ -37,8 +38,9 @@ class BillAttachment {
       itm: json['ITM'] as int? ?? 1,
       fileName: json['FILENAME'] as String? ?? '',
       ext: json['EXT'] as String? ?? '',
-      fileId: (json['FILEID'] as Map<String, dynamic>?)?['\$oid'] as String? ??
-          json['FILEID'] as String? ??
+      fileId: (json['FILEID'] is Map<String, dynamic>
+              ? (json['FILEID']['\$oid'] as String?)
+              : json['FILEID'] as String?) ??
           '',
       tag: json['TAG'] as int? ?? 1,
       effDd: json['EFF_DD'] as String? ?? '',

+ 121 - 1
pubspec.lock

@@ -137,6 +137,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.1.2"
+  code_assets:
+    dependency: transitive
+    description:
+      name: code_assets
+      sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.2.1"
   code_builder:
     dependency: transitive
     description:
@@ -429,6 +437,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "2.3.2"
+  hooks:
+    dependency: transitive
+    description:
+      name: hooks
+      sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.0.2"
   http:
     dependency: transitive
     description:
@@ -533,6 +549,22 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.0.5"
+  jni:
+    dependency: transitive
+    description:
+      name: jni
+      sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.0.0"
+  jni_flutter:
+    dependency: transitive
+    description:
+      name: jni_flutter
+      sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.0.1"
   js:
     dependency: transitive
     description:
@@ -637,6 +669,22 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "2.0.0"
+  objective_c:
+    dependency: transitive
+    description:
+      name: objective_c
+      sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "9.4.1"
+  open_filex:
+    dependency: "direct main"
+    description:
+      name: open_filex
+      sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "4.7.0"
   package_config:
     dependency: transitive
     description:
@@ -669,6 +717,54 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.1.0"
+  path_provider:
+    dependency: "direct main"
+    description:
+      name: path_provider
+      sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.1.6"
+  path_provider_android:
+    dependency: transitive
+    description:
+      name: path_provider_android
+      sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.3.1"
+  path_provider_foundation:
+    dependency: transitive
+    description:
+      name: path_provider_foundation
+      sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.6.0"
+  path_provider_linux:
+    dependency: transitive
+    description:
+      name: path_provider_linux
+      sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.2.2"
+  path_provider_platform_interface:
+    dependency: transitive
+    description:
+      name: path_provider_platform_interface
+      sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.1.3"
+  path_provider_windows:
+    dependency: transitive
+    description:
+      name: path_provider_windows
+      sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.3.0"
   petitparser:
     dependency: transitive
     description:
@@ -677,6 +773,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "7.0.2"
+  platform:
+    dependency: transitive
+    description:
+      name: platform
+      sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.1.6"
   plugin_platform_interface:
     dependency: transitive
     description:
@@ -709,6 +813,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.5.0"
+  record_use:
+    dependency: transitive
+    description:
+      name: record_use
+      sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.6.0"
   riverpod:
     dependency: transitive
     description:
@@ -938,6 +1050,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "5.15.0"
+  xdg_directories:
+    dependency: transitive
+    description:
+      name: xdg_directories
+      sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.1.0"
   xml:
     dependency: transitive
     description:
@@ -956,4 +1076,4 @@ packages:
     version: "3.1.3"
 sdks:
   dart: ">=3.10.9 <4.0.0"
-  flutter: ">=3.38.0"
+  flutter: ">=3.38.4"

+ 2 - 0
pubspec.yaml

@@ -23,6 +23,8 @@ dependencies:
   image_picker: ^1.1.2
   file_picker: ^11.0.2
   marquee: ^2.3.0
+  open_filex: ^4.5.0
+  path_provider: ^2.1.0
 
 dev_dependencies:
   flutter_test: