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/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 '../../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'; 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'; import 'widgets/expense_detail_view_dialog.dart'; class ExpenseDetailPage extends ConsumerStatefulWidget { final String billNo; final int queryId; const ExpenseDetailPage({super.key, required this.billNo, this.queryId = 0}); @override ConsumerState createState() => _ExpenseDetailPageState(); } class _ExpenseDetailPageState extends ConsumerState { ExpenseModel? _expense; List _attachments = []; bool _attachAvailable = false; BillFileRights _billFileRights = BillFileRights.none; bool _isLoading = true; String? _error; Map? _billStatus; bool _statusLoading = false; bool _canEdit = false; @override void initState() { super.initState(); _loadData(); } @override void dispose() { super.dispose(); } Future _loadData() async { setState(() { _isLoading = true; _error = null; }); try { final api = ref.read(expenseApiProvider); // 1. 加载报销详情(主表 + 明细) final expense = await api.fetchDetail(widget.billNo); setState(() => _expense = expense); // 2. 加载附件权限(非致命) BillFileRights billFileRights = BillFileRights.none; try { billFileRights = await api.getBillFileRights('BX'); } catch (_) {} // 3. 加载附件(非致命) try { _attachAvailable = await api.checkAttachHealth(); } catch (_) { _attachAvailable = false; } _billFileRights = billFileRights; if (_attachAvailable && billFileRights.canBrowseAttachments) { try { _attachments = await api.getAttachments('BX', widget.billNo); } catch (_) { _attachments = []; } } // 3. 获取单据状态(非致命) setState(() => _statusLoading = true); try { final status = await api.getBillStatus(widget.billNo); if (mounted) { setState(() { _billStatus = status; _canEdit = status['canEdit'] == true; _statusLoading = false; }); } } catch (_) { if (mounted) setState(() => _statusLoading = false); } } catch (e) { setState(() => _error = e.toString()); } finally { setState(() { _isLoading = false; }); } } @override Widget build(BuildContext context) { final colors = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context); if (_isLoading) { return const SkeletonDetailPage(); } if (_error != null) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.error_outline, size: 48, color: colors.danger), const SizedBox(height: 12), Text( _error!, style: TextStyle( fontSize: AppFontSizes.body, color: colors.danger, ), textAlign: TextAlign.center, ), const SizedBox(height: 16), TDButton( text: l10n.get('retry'), theme: TDButtonTheme.primary, onTap: _loadData, ), ], ), ); } final expense = _expense!; return Column( children: [ Expanded( child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), child: Column( children: [ if (_statusLoading) ...[ const Padding( padding: EdgeInsets.symmetric(vertical: 8), child: Center( child: TDLoading( size: TDLoadingSize.small, icon: TDLoadingIcon.activity, ), ), ), ], Builder( builder: (ctx) { final badge = _buildStatusBadge(l10n); if (badge == null) return const SizedBox.shrink(); return Column( children: [badge, const SizedBox(height: 16)], ); }, ), _buildBasicInfoSection(expense, l10n, colors), const SizedBox(height: 16), _buildExpenseDetailSection(expense, l10n, colors), const SizedBox(height: 16), _buildAttachmentSection(l10n, colors), const SizedBox(height: 24), _buildPageFooter(colors), ], ), ), ), if (_canEdit) ActionBar( showLeft: false, showCenter: false, rightLabel: l10n.get('editExpense'), onRightTap: () async { final result = await GoRouter.of( context, ).push('/expense/edit/${widget.billNo}'); if (result == true && mounted) _loadData(); }, ), ], ); } // ═══ 状态 badge ═══ Widget? _buildStatusBadge(AppLocalizations l10n) { final isClosed = _billStatus?['isClosed'] == true; final isTransferred = _billStatus?['isTransferred'] == true; final approvalStatus = _billStatus?['approvalStatus'] as String?; if (isClosed) { 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, ); } switch (approvalStatus) { case 'SHCOUNT4': return StatusBanner( icon: Icons.check_circle_outline, statusText: l10n.get('statusApproved'), subText: '', color: Colors.green, ); case 'SHCOUNT5': return StatusBanner( icon: Icons.cancel_outlined, statusText: l10n.get('statusFinalRejected'), subText: '', color: Colors.red, ); case 'SHCOUNT1': return StatusBanner( icon: Icons.hourglass_empty, statusText: l10n.get('statusPendingReview'), subText: '', color: Colors.blue, ); case 'SHCOUNT2': return StatusBanner( icon: Icons.block_outlined, statusText: l10n.get('statusReviewRejected'), subText: '', color: Colors.deepOrange, ); default: return null; } } // ═══ 基本信息 ═══ Widget _buildBasicInfoSection( 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, ), const SizedBox(height: 16), FormFieldRow( 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.applicantId.isNotEmpty ? '${expense.applicantId}${expense.applicantName.isNotEmpty ? '/${expense.applicantName}' : ''}' : '-', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('expenseDept'), value: expense.deptId.isNotEmpty ? '${expense.deptId}${expense.deptName.isNotEmpty ? '/${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 : '-', textAlign: TextAlign.end, maxLines: 2, overflow: TextOverflow.ellipsis, 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, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('currency'), value: expense.currencyCode.isNotEmpty ? expense.currencyCode : '-', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('paymentMethod'), value: expense.paymentMethod.isNotEmpty ? expense.paymentMethod : '-', readOnly: true, showArrow: false, ), const SizedBox(height: 16), FormFieldRow( label: l10n.get('remark'), value: expense.remark.isNotEmpty ? expense.remark : '-', readOnly: true, showArrow: false, ), ], ); } // ═══ 费用明细 ═══ Widget _buildExpenseDetailSection( ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors, ) { final totalAmount = expense.details.fold( 0, (sum, d) => sum + d.totalAmount, ); final totalApproved = expense.details.fold( 0, (sum, d) => sum + d.approvedAmount, ); return FormSection( title: l10n.get('expenseDetails'), leadingIcon: Icons.receipt_long_outlined, children: [ if (expense.details.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text( l10n.get('noDetailData'), style: TextStyle( fontSize: AppFontSizes.body, color: colors.textPlaceholder, ), ), ) else ...expense.details.asMap().entries.map((e) { final d = e.value; final title = d.categoryName.isNotEmpty ? '${d.expenseCategory}/${d.categoryName}' : d.expenseCategory; 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, ), ), ), 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, ), ), if (d.taxRate > 0) Text( '${l10n.get('taxRate')}: ${d.taxRate.toStringAsFixed(0)}%', style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.acctSubjectId.isNotEmpty) 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.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.projectId.isNotEmpty) Text( '${l10n.get('project')}: ${d.projectId}${d.projectName.isNotEmpty ? '/${d.projectName}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.costDeptId.isNotEmpty) Text( '${l10n.get('costDept')}: ${d.costDeptId}${d.costDeptName.isNotEmpty ? '/${d.costDeptName}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.customerVendorId.isNotEmpty) Text( '${l10n.get('customerVendor')}: ${d.customerVendorId}${d.customerVendorName.isNotEmpty ? '/${d.customerVendorName}' : ''}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: AppFontSizes.caption, color: colors.textSecondary, ), ), if (d.sqMan.isNotEmpty) Text( '${l10n.get('applicant')}: ${d.sqMan}${d.sqManName.isNotEmpty ? '/${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('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, ), ), ], ), ], ], ); } void _showExpenseDetailDialog(BuildContext context, ExpenseDetailModel d) { ExpenseDetailViewDialog.show(context, d); } // ═══ 附件 ═══ 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, ), ), ], ); } 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, ), ), ], ); } final headerAtts = _attachments.where((a) => a.isHeader).toList(); final bodyGroups = >{}; for (final a in _attachments.where((a) => a.isBody)) { bodyGroups.putIfAbsent(a.srcItm, () => []).add(a); } final children = []; if (_attachments.isEmpty) { children.add( Text( l10n.get('noAttachment'), style: TextStyle( fontSize: AppFontSizes.body, color: colors.textPlaceholder, ), ), ); } else { // 表头附件 if (headerAtts.isNotEmpty) { children.add( Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( l10n.get('headerAttachments'), style: TextStyle( fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.textSecondary, ), ), ), ); for (final a in headerAtts) { children.add(_buildAttachmentRow(a, colors)); } } // 表身附件(按明细行分组) for (final entry in bodyGroups.entries) { children.add(const SizedBox(height: 8)); children.add( Padding( padding: const EdgeInsets.only(bottom: 8), child: Text( '${l10n.get('detailLine')} ${entry.key}', style: TextStyle( fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.textSecondary, ), ), ), ); for (final a in entry.value) { children.add(_buildAttachmentRow(a, colors)); } } } return FormSection( title: l10n.get('attachments'), leadingIcon: Icons.attach_file_outlined, children: children, ); } Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) { 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, ), ), ), const SizedBox(width: 8), GestureDetector( onTap: () => AttachmentDownloadHelper.downloadAndSave( context, a, ref.read(expenseApiProvider).downloadAttachment, ), child: Icon( Icons.download_outlined, size: 22, color: colors.primary, ), ), ], ), ), ); } Future _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); 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; 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; } } Widget _buildPageFooter(AppColorsExtension colors) { final l10n = AppLocalizations.of(context); return Center( child: Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( mainAxisSize: MainAxisSize.min, children: [ 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, ), ), ], ), ), ); } } /// 附件缩略图 — 自动调用 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 _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, ); } }