expense_detail_page.dart 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911
  1. import 'dart:typed_data';
  2. import 'package:flutter/material.dart';
  3. import 'package:tdesign_flutter/tdesign_flutter.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import '../../shared/widgets/loading_dialog.dart';
  6. import '../../core/utils/date_utils.dart' as du;
  7. import '../../shared/widgets/form_section.dart';
  8. import '../../shared/widgets/form_field_row.dart';
  9. import '../../shared/widgets/app_skeletons.dart';
  10. import '../../shared/widgets/attachment_download_helper.dart';
  11. import '../../core/navigation/host_app_channel.dart';
  12. import 'expense_model.dart';
  13. import '../../core/i18n/app_localizations.dart';
  14. import '../../shared/models/bill_attachment.dart';
  15. import '../../shared/models/bill_file_rights.dart';
  16. import '../../core/theme/app_colors.dart';
  17. import '../../core/theme/app_colors_extension.dart';
  18. import 'expense_api.dart';
  19. import 'dart:io';
  20. import 'package:path_provider/path_provider.dart';
  21. import 'package:open_filex/open_filex.dart';
  22. import '../../shared/widgets/attachment_preview_page.dart';
  23. import 'widgets/expense_detail_view_dialog.dart';
  24. import '../../core/utils/amount_utils.dart';
  25. import '../../shared/widgets/bill_status_bar.dart';
  26. import 'package:go_router/go_router.dart';
  27. class ExpenseDetailPage extends ConsumerStatefulWidget {
  28. final String billNo;
  29. final int queryId;
  30. const ExpenseDetailPage({super.key, required this.billNo, this.queryId = 0});
  31. @override
  32. ConsumerState<ExpenseDetailPage> createState() => _ExpenseDetailPageState();
  33. }
  34. class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
  35. ExpenseModel? _expense;
  36. List<BillAttachment> _attachments = [];
  37. bool _attachAvailable = false;
  38. BillFileRights _billFileRights = BillFileRights.none;
  39. bool _isLoading = true;
  40. String? _error;
  41. BillStatusBar? _billStatusBar;
  42. @override
  43. void initState() {
  44. super.initState();
  45. _loadData();
  46. }
  47. @override
  48. void dispose() {
  49. super.dispose();
  50. }
  51. Future<void> _loadData() async {
  52. setState(() {
  53. _isLoading = true;
  54. _error = null;
  55. });
  56. try {
  57. final api = ref.read(expenseApiProvider);
  58. // 1. 加载报销详情(主表 + 明细)
  59. final expense = await api.fetchDetail(widget.billNo);
  60. setState(() => _expense = expense);
  61. // 2. 加载附件权限(非致命)
  62. BillFileRights billFileRights = BillFileRights.none;
  63. try {
  64. billFileRights = await api.getBillFileRights('BX');
  65. } catch (_) {}
  66. // 3. 加载附件(非致命)
  67. try {
  68. _attachAvailable = await api.checkAttachHealth();
  69. } catch (_) {
  70. _attachAvailable = false;
  71. }
  72. _billFileRights = billFileRights;
  73. if (_attachAvailable && billFileRights.canBrowseAttachments) {
  74. try {
  75. _attachments = await api.getAttachments('BX', widget.billNo);
  76. } catch (_) {
  77. _attachments = [];
  78. }
  79. }
  80. // 3. 获取单据状态 + 审核配置(非致命)
  81. try {
  82. final results = await Future.wait([
  83. api.getBillStatus(widget.billNo),
  84. api.getBillAuditConfig('BX'),
  85. ]);
  86. final Map<String, dynamic> status = results[0];
  87. final Map<String, dynamic> auditConfig = results[1];
  88. final canManualSubmit =
  89. auditConfig['hasAuditFlow'] == true &&
  90. auditConfig['autoSubmit'] != true;
  91. if (mounted) {
  92. setState(() {
  93. final l10n = AppLocalizations.of(context);
  94. _billStatusBar = BillStatusBar(
  95. billStatus: status,
  96. canManualSubmit: canManualSubmit,
  97. closedText: l10n.get('statusClosed'),
  98. transferredText: l10n.get('statusTransferred'),
  99. onEdit: status['canEdit'] == true
  100. ? () => GoRouter.of(context)
  101. .push('/expense/edit/${widget.billNo}')
  102. .then((result) {
  103. if (result == true && mounted) _loadData();
  104. })
  105. : null,
  106. onSubmit: () async {
  107. final l10n = AppLocalizations.of(context);
  108. final dd = _expense?.expenseDate != null
  109. ? du.DateUtils.formatDate(_expense!.expenseDate!)
  110. : '';
  111. LoadingDialog.show(context, text: l10n.get('submitting'));
  112. try {
  113. await api.shSubmit(bilNo: widget.billNo, bilDd: dd);
  114. } finally {
  115. if (mounted) LoadingDialog.hide(context);
  116. }
  117. if (mounted) _loadData();
  118. },
  119. onCancelSubmit: () async {
  120. final l10n = AppLocalizations.of(context);
  121. final dd = _expense?.expenseDate != null
  122. ? du.DateUtils.formatDate(_expense!.expenseDate!)
  123. : '';
  124. LoadingDialog.show(context, text: l10n.get('submitting'));
  125. try {
  126. await api.shSubmit(
  127. bilNo: widget.billNo,
  128. bilDd: dd,
  129. isCancel: true,
  130. );
  131. } finally {
  132. if (mounted) LoadingDialog.hide(context);
  133. }
  134. if (mounted) _loadData();
  135. },
  136. onTapStatusTag: () => _showAuditTrail('BX'),
  137. );
  138. });
  139. }
  140. } catch (_) {
  141. // 获取单据状态失败,忽略
  142. }
  143. } catch (e) {
  144. setState(() => _error = e.toString());
  145. } finally {
  146. setState(() {
  147. _isLoading = false;
  148. });
  149. }
  150. }
  151. @override
  152. Widget build(BuildContext context) {
  153. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  154. final l10n = AppLocalizations.of(context);
  155. if (_isLoading) {
  156. return const SkeletonDetailPage();
  157. }
  158. if (_error != null) {
  159. return Center(
  160. child: Column(
  161. mainAxisAlignment: MainAxisAlignment.center,
  162. children: [
  163. Icon(Icons.error_outline, size: 48, color: colors.danger),
  164. const SizedBox(height: 12),
  165. Text(
  166. _error!,
  167. style: TextStyle(
  168. fontSize: AppFontSizes.body,
  169. color: colors.danger,
  170. ),
  171. textAlign: TextAlign.center,
  172. ),
  173. const SizedBox(height: 16),
  174. TDButton(
  175. text: l10n.get('retry'),
  176. theme: TDButtonTheme.primary,
  177. onTap: _loadData,
  178. ),
  179. ],
  180. ),
  181. );
  182. }
  183. final expense = _expense!;
  184. return Column(
  185. children: [
  186. Expanded(
  187. child: SingleChildScrollView(
  188. physics: const AlwaysScrollableScrollPhysics(),
  189. padding: const EdgeInsets.all(16),
  190. child: Column(
  191. children: [
  192. _buildBasicInfoSection(expense, l10n, colors),
  193. const SizedBox(height: 16),
  194. _buildExpenseDetailSection(expense, l10n, colors),
  195. const SizedBox(height: 16),
  196. _buildAttachmentSection(l10n, colors),
  197. const SizedBox(height: 24),
  198. _buildPageFooter(colors),
  199. ],
  200. ),
  201. ),
  202. ),
  203. _billStatusBar?.buildActions(context) ?? const SizedBox.shrink(),
  204. ],
  205. );
  206. }
  207. Future<void> _showAuditTrail(String billId) async {
  208. await HostAppChannel.showAuditTrail(billId, widget.billNo);
  209. }
  210. // ═══ 基本信息 ═══
  211. Widget _buildBasicInfoSection(
  212. ExpenseModel expense,
  213. AppLocalizations l10n,
  214. AppColorsExtension colors,
  215. ) {
  216. return FormSection(
  217. title: l10n.get('basicInfo'),
  218. leadingIcon: Icons.info_outline,
  219. trailing:
  220. _billStatusBar?.buildStatusTag(context) ?? const SizedBox.shrink(),
  221. children: [
  222. FormFieldRow(
  223. label: l10n.get('expenseNo'),
  224. value: expense.expenseNo,
  225. readOnly: true,
  226. showArrow: false,
  227. ),
  228. const SizedBox(height: 16),
  229. FormFieldRow(
  230. label: l10n.get('date'),
  231. value: du.DateUtils.formatDate(expense.createTime),
  232. readOnly: true,
  233. showArrow: false,
  234. ),
  235. const SizedBox(height: 16),
  236. FormFieldRow(
  237. label: l10n.get('expensePersonnel'),
  238. value: expense.applicantId.isNotEmpty
  239. ? '${expense.applicantId}${expense.applicantName.isNotEmpty ? '/${expense.applicantName}' : ''}'
  240. : '-',
  241. readOnly: true,
  242. showArrow: false,
  243. ),
  244. const SizedBox(height: 16),
  245. FormFieldRow(
  246. label: l10n.get('expenseDept'),
  247. value: expense.deptId.isNotEmpty
  248. ? '${expense.deptId}${expense.deptName.isNotEmpty ? '/${expense.deptName}' : ''}'
  249. : '-',
  250. readOnly: true,
  251. showArrow: false,
  252. ),
  253. const SizedBox(height: 16),
  254. FormFieldRow(
  255. label: l10n.get('expenseReason'),
  256. value: expense.purpose.isNotEmpty ? expense.purpose : '-',
  257. readOnly: true,
  258. showArrow: false,
  259. bold: true,
  260. showMoreOnOverflow: true,
  261. ),
  262. const SizedBox(height: 16),
  263. FormFieldRow(
  264. label: l10n.get('voucherNo'),
  265. value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-',
  266. readOnly: true,
  267. showArrow: false,
  268. ),
  269. // TODO: 支付方式、币别暂时隐藏
  270. // const SizedBox(height: 16),
  271. // FormFieldRow(
  272. // label: l10n.get('currency'),
  273. // value: expense.currencyCode.isNotEmpty ? expense.currencyCode : '-',
  274. // readOnly: true,
  275. // showArrow: false,
  276. // ),
  277. // TODO: 支付方式、币别暂时隐藏
  278. // const SizedBox(height: 16),
  279. // FormFieldRow(
  280. // label: l10n.get('paymentMethod'),
  281. // value: expense.paymentMethod.isNotEmpty ? expense.paymentMethod : '-',
  282. // readOnly: true,
  283. // showArrow: false,
  284. // ),
  285. // const SizedBox(height: 16),
  286. FormFieldRow(
  287. label: l10n.get('remark'),
  288. value: expense.remark.isNotEmpty ? expense.remark : '-',
  289. readOnly: true,
  290. showArrow: false,
  291. ),
  292. ],
  293. );
  294. }
  295. // ═══ 费用明细 ═══
  296. Widget _buildExpenseDetailSection(
  297. ExpenseModel expense,
  298. AppLocalizations l10n,
  299. AppColorsExtension colors,
  300. ) {
  301. final totalAmount = expense.details.fold<double>(
  302. 0,
  303. (sum, d) => sum + d.totalAmount,
  304. );
  305. final totalApproved = expense.details.fold<double>(
  306. 0,
  307. (sum, d) => sum + d.approvedAmount,
  308. );
  309. return FormSection(
  310. title: l10n.get('expenseDetails'),
  311. leadingIcon: Icons.receipt_long_outlined,
  312. children: [
  313. if (expense.details.isEmpty)
  314. Padding(
  315. padding: const EdgeInsets.symmetric(vertical: 8),
  316. child: Text(
  317. l10n.get('noDetailData'),
  318. style: TextStyle(
  319. fontSize: AppFontSizes.body,
  320. color: colors.textPlaceholder,
  321. ),
  322. ),
  323. )
  324. else
  325. ...expense.details.asMap().entries.map((e) {
  326. final d = e.value;
  327. final title = d.categoryName.isNotEmpty
  328. ? '${d.expenseCategory}/${d.categoryName}'
  329. : d.expenseCategory;
  330. return GestureDetector(
  331. onTap: () => _showExpenseDetailDialog(context, d),
  332. child: Container(
  333. margin: const EdgeInsets.symmetric(vertical: 6),
  334. padding: const EdgeInsets.all(12),
  335. decoration: BoxDecoration(
  336. color: colors.bgPage,
  337. borderRadius: BorderRadius.circular(8),
  338. ),
  339. child: Row(
  340. children: [
  341. Expanded(
  342. child: Column(
  343. crossAxisAlignment: CrossAxisAlignment.start,
  344. children: [
  345. Row(
  346. children: [
  347. Expanded(
  348. child: Text(
  349. title,
  350. style: TextStyle(
  351. fontSize: AppFontSizes.body,
  352. fontWeight: FontWeight.w500,
  353. color: colors.textPrimary,
  354. ),
  355. ),
  356. ),
  357. Column(
  358. crossAxisAlignment: CrossAxisAlignment.end,
  359. children: [
  360. Text(
  361. formatAmount(d.totalAmount),
  362. style: TextStyle(
  363. fontSize: AppFontSizes.body,
  364. fontWeight: FontWeight.w600,
  365. color: colors.amountPrimary,
  366. ),
  367. ),
  368. if (d.approvedAmount > 0)
  369. Text(
  370. formatAmount(d.approvedAmount),
  371. style: TextStyle(
  372. fontSize: AppFontSizes.body,
  373. fontWeight: FontWeight.w600,
  374. color: colors.success,
  375. ),
  376. ),
  377. ],
  378. ),
  379. ],
  380. ),
  381. const SizedBox(height: 2),
  382. Text(
  383. '${l10n.get('amountExcludingTax')}: ${formatAmount(d.amount)}',
  384. style: TextStyle(
  385. fontSize: AppFontSizes.caption,
  386. color: colors.textSecondary,
  387. ),
  388. ),
  389. if (d.taxAmount > 0)
  390. Text(
  391. '${l10n.get('taxAmount')}: ${formatAmount(d.taxAmount)}',
  392. style: TextStyle(
  393. fontSize: AppFontSizes.caption,
  394. color: colors.textSecondary,
  395. ),
  396. ),
  397. if (d.taxRate > 0)
  398. Text(
  399. '${l10n.get('taxRate')}: ${d.taxRate.toStringAsFixed(0)}%',
  400. style: TextStyle(
  401. fontSize: AppFontSizes.caption,
  402. color: colors.textSecondary,
  403. ),
  404. ),
  405. if (d.acctSubjectId.isNotEmpty)
  406. Text(
  407. '${l10n.get('acctSubject')}: ${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/${d.acctSubjectName}' : ''}',
  408. maxLines: 1,
  409. overflow: TextOverflow.ellipsis,
  410. style: TextStyle(
  411. fontSize: AppFontSizes.caption,
  412. color: colors.textSecondary,
  413. ),
  414. ),
  415. if (d.aeNo.isNotEmpty)
  416. Text(
  417. '${l10n.get('expenseApplyNo')}: ${d.aeNo}',
  418. maxLines: 1,
  419. overflow: TextOverflow.ellipsis,
  420. style: TextStyle(
  421. fontSize: AppFontSizes.caption,
  422. color: colors.textSecondary,
  423. ),
  424. ),
  425. if (d.aeDd.isNotEmpty)
  426. Text(
  427. '${l10n.get('applyDate')}: ${d.aeDd.length >= 10 ? d.aeDd.substring(0, 10) : d.aeDd}',
  428. style: TextStyle(
  429. fontSize: AppFontSizes.caption,
  430. color: colors.textSecondary,
  431. ),
  432. ),
  433. if (d.projectId.isNotEmpty)
  434. Text(
  435. '${l10n.get('project')}: ${d.projectId}${d.projectName.isNotEmpty ? '/${d.projectName}' : ''}',
  436. maxLines: 1,
  437. overflow: TextOverflow.ellipsis,
  438. style: TextStyle(
  439. fontSize: AppFontSizes.caption,
  440. color: colors.textSecondary,
  441. ),
  442. ),
  443. if (d.costDeptId.isNotEmpty)
  444. Text(
  445. '${l10n.get('costDept')}: ${d.costDeptId}${d.costDeptName.isNotEmpty ? '/${d.costDeptName}' : ''}',
  446. maxLines: 1,
  447. overflow: TextOverflow.ellipsis,
  448. style: TextStyle(
  449. fontSize: AppFontSizes.caption,
  450. color: colors.textSecondary,
  451. ),
  452. ),
  453. if (d.customerVendorId.isNotEmpty)
  454. Text(
  455. '${l10n.get('customerVendor')}: ${d.customerVendorId}${d.customerVendorName.isNotEmpty ? '/${d.customerVendorName}' : ''}',
  456. maxLines: 1,
  457. overflow: TextOverflow.ellipsis,
  458. style: TextStyle(
  459. fontSize: AppFontSizes.caption,
  460. color: colors.textSecondary,
  461. ),
  462. ),
  463. if (d.sqMan.isNotEmpty)
  464. Text(
  465. '${l10n.get('applicant')}: ${d.sqMan}${d.sqManName.isNotEmpty ? '/${d.sqManName}' : ''}',
  466. style: TextStyle(
  467. fontSize: AppFontSizes.caption,
  468. color: colors.textSecondary,
  469. ),
  470. ),
  471. if (d.bankAccountName.isNotEmpty)
  472. Text(
  473. '${l10n.get('bankAccountName')}: ${d.bankAccountName}',
  474. maxLines: 1,
  475. overflow: TextOverflow.ellipsis,
  476. style: TextStyle(
  477. fontSize: AppFontSizes.caption,
  478. color: colors.textSecondary,
  479. ),
  480. ),
  481. if (d.bankName.isNotEmpty)
  482. Text(
  483. '${l10n.get('bankName')}: ${d.bankName}',
  484. maxLines: 1,
  485. overflow: TextOverflow.ellipsis,
  486. style: TextStyle(
  487. fontSize: AppFontSizes.caption,
  488. color: colors.textSecondary,
  489. ),
  490. ),
  491. if (d.bankAccount.isNotEmpty)
  492. Text(
  493. '${l10n.get('bankAccount')}: ${d.bankAccount}',
  494. maxLines: 1,
  495. overflow: TextOverflow.ellipsis,
  496. style: TextStyle(
  497. fontSize: AppFontSizes.caption,
  498. color: colors.textSecondary,
  499. ),
  500. ),
  501. if (d.remark.isNotEmpty)
  502. Text(
  503. '${l10n.get('remark')}: ${d.remark}',
  504. maxLines: 2,
  505. overflow: TextOverflow.ellipsis,
  506. style: TextStyle(
  507. fontSize: AppFontSizes.caption,
  508. color: colors.textSecondary,
  509. ),
  510. ),
  511. ],
  512. ),
  513. ),
  514. ],
  515. ),
  516. ),
  517. );
  518. }),
  519. if (expense.details.isNotEmpty) ...[
  520. const SizedBox(height: 8),
  521. Row(
  522. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  523. children: [
  524. Text(
  525. l10n.get('totalExpense'),
  526. style: TextStyle(
  527. fontSize: AppFontSizes.body,
  528. fontWeight: FontWeight.w600,
  529. color: colors.textPrimary,
  530. ),
  531. ),
  532. Text(
  533. formatAmount(totalAmount),
  534. style: TextStyle(
  535. fontSize: AppFontSizes.subtitle,
  536. fontWeight: FontWeight.w700,
  537. color: colors.amountPrimary,
  538. ),
  539. ),
  540. ],
  541. ),
  542. const SizedBox(height: 4),
  543. Row(
  544. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  545. children: [
  546. Text(
  547. l10n.get('approvedTotal'),
  548. style: TextStyle(
  549. fontSize: AppFontSizes.body,
  550. fontWeight: FontWeight.w600,
  551. color: colors.textPrimary,
  552. ),
  553. ),
  554. Text(
  555. formatAmount(totalApproved),
  556. style: TextStyle(
  557. fontSize: AppFontSizes.subtitle,
  558. fontWeight: FontWeight.w700,
  559. color: totalApproved > 0
  560. ? colors.success
  561. : colors.textPrimary,
  562. ),
  563. ),
  564. ],
  565. ),
  566. ],
  567. ],
  568. );
  569. }
  570. void _showExpenseDetailDialog(BuildContext context, ExpenseDetailModel d) {
  571. ExpenseDetailViewDialog.show(context, d);
  572. }
  573. // ═══ 附件 ═══
  574. Widget _buildAttachmentSection(
  575. AppLocalizations l10n,
  576. AppColorsExtension colors,
  577. ) {
  578. if (!_attachAvailable) {
  579. return FormSection(
  580. title: l10n.get('attachments'),
  581. leadingIcon: Icons.attach_file_outlined,
  582. children: [
  583. Text(
  584. l10n.get('attachServiceUnavailable'),
  585. style: TextStyle(
  586. fontSize: AppFontSizes.body,
  587. color: colors.textPlaceholder,
  588. ),
  589. ),
  590. ],
  591. );
  592. }
  593. if (!_billFileRights.canBrowseAttachments) {
  594. return FormSection(
  595. title: l10n.get('attachments'),
  596. leadingIcon: Icons.attach_file_outlined,
  597. children: [
  598. Text(
  599. l10n.get('noAttachmentPermission'),
  600. style: TextStyle(
  601. fontSize: AppFontSizes.body,
  602. color: colors.textPlaceholder,
  603. ),
  604. ),
  605. ],
  606. );
  607. }
  608. final headerAtts = _attachments.where((a) => a.isHeader).toList();
  609. final bodyGroups = <int, List<BillAttachment>>{};
  610. for (final a in _attachments.where((a) => a.isBody)) {
  611. bodyGroups.putIfAbsent(a.srcItm, () => []).add(a);
  612. }
  613. final children = <Widget>[];
  614. if (_attachments.isEmpty) {
  615. children.add(
  616. Text(
  617. l10n.get('noAttachment'),
  618. style: TextStyle(
  619. fontSize: AppFontSizes.body,
  620. color: colors.textPlaceholder,
  621. ),
  622. ),
  623. );
  624. } else {
  625. // 表头附件
  626. if (headerAtts.isNotEmpty) {
  627. children.add(
  628. Padding(
  629. padding: const EdgeInsets.only(bottom: 8),
  630. child: Text(
  631. l10n.get('headerAttachments'),
  632. style: TextStyle(
  633. fontSize: AppFontSizes.caption,
  634. fontWeight: FontWeight.w600,
  635. color: colors.textSecondary,
  636. ),
  637. ),
  638. ),
  639. );
  640. for (final a in headerAtts) {
  641. children.add(_buildAttachmentRow(a, colors));
  642. }
  643. }
  644. // 表身附件(按明细行分组)
  645. for (final entry in bodyGroups.entries) {
  646. children.add(const SizedBox(height: 8));
  647. children.add(
  648. Padding(
  649. padding: const EdgeInsets.only(bottom: 8),
  650. child: Text(
  651. '${l10n.get('detailLine')} ${entry.key}',
  652. style: TextStyle(
  653. fontSize: AppFontSizes.caption,
  654. fontWeight: FontWeight.w600,
  655. color: colors.textSecondary,
  656. ),
  657. ),
  658. ),
  659. );
  660. for (final a in entry.value) {
  661. children.add(_buildAttachmentRow(a, colors));
  662. }
  663. }
  664. }
  665. return FormSection(
  666. title: l10n.get('attachments'),
  667. leadingIcon: Icons.attach_file_outlined,
  668. children: children,
  669. );
  670. }
  671. Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
  672. final isImage = [
  673. 'jpg',
  674. 'jpeg',
  675. 'png',
  676. 'gif',
  677. 'bmp',
  678. 'webp',
  679. ].contains(a.ext.toLowerCase());
  680. return GestureDetector(
  681. onTap: () => _openAttachment(a),
  682. child: Container(
  683. margin: const EdgeInsets.symmetric(vertical: 4),
  684. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  685. decoration: BoxDecoration(
  686. color: colors.bgPage,
  687. borderRadius: BorderRadius.circular(8),
  688. ),
  689. child: Row(
  690. children: [
  691. if (isImage)
  692. _ExpAttachmentThumbnail(
  693. api: ref.read(expenseApiProvider),
  694. attachment: a,
  695. size: 40,
  696. )
  697. else
  698. Icon(_fileTypeIcon(a.ext), size: 40, color: colors.primary),
  699. const SizedBox(width: 10),
  700. Expanded(
  701. child: Text(
  702. a.fileName,
  703. maxLines: 1,
  704. overflow: TextOverflow.ellipsis,
  705. style: TextStyle(
  706. fontSize: AppFontSizes.body,
  707. color: colors.textPrimary,
  708. ),
  709. ),
  710. ),
  711. const SizedBox(width: 8),
  712. GestureDetector(
  713. onTap: () => AttachmentDownloadHelper.downloadAndSave(
  714. context,
  715. a,
  716. ref.read(expenseApiProvider).downloadAttachment,
  717. ),
  718. child: Icon(
  719. Icons.download_outlined,
  720. size: 22,
  721. color: colors.primary,
  722. ),
  723. ),
  724. ],
  725. ),
  726. ),
  727. );
  728. }
  729. Future<void> _openAttachment(BillAttachment a) async {
  730. final l10n = AppLocalizations.of(context);
  731. final ext = a.ext.toLowerCase();
  732. final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(ext);
  733. if (isImage) {
  734. // 图片 → 弹窗预览,内部自动下载并显示 loading
  735. final api = ref.read(expenseApiProvider);
  736. AttachmentPreview.show(
  737. context,
  738. loader: api.downloadAttachment(a.id),
  739. fileName: a.fileName,
  740. loadingText: l10n.get('loading'),
  741. );
  742. return;
  743. }
  744. // 非图片 → 下载后调用系统工具打开
  745. try {
  746. LoadingDialog.show(context, text: l10n.get('downloading'));
  747. final api = ref.read(expenseApiProvider);
  748. final bytes = await api.downloadAttachment(a.id);
  749. if (!mounted) return;
  750. LoadingDialog.hide(context);
  751. if (bytes == null) {
  752. TDToast.showText(l10n.get('downloadFailed'), context: context);
  753. return;
  754. }
  755. final dir = await getTemporaryDirectory();
  756. final file = File('${dir.path}/${a.fileName}');
  757. await file.writeAsBytes(bytes);
  758. await OpenFilex.open(file.path);
  759. } catch (_) {
  760. if (mounted) LoadingDialog.hide(context);
  761. if (mounted) TDToast.showText(l10n.get('openFailed'), context: context);
  762. }
  763. }
  764. IconData _fileTypeIcon(String ext) {
  765. switch (ext.toLowerCase()) {
  766. case 'pdf':
  767. return Icons.picture_as_pdf;
  768. case 'doc':
  769. case 'docx':
  770. return Icons.description;
  771. case 'xls':
  772. case 'xlsx':
  773. return Icons.table_chart;
  774. case 'jpg':
  775. case 'jpeg':
  776. case 'png':
  777. case 'gif':
  778. case 'bmp':
  779. return Icons.image_outlined;
  780. default:
  781. return Icons.insert_drive_file;
  782. }
  783. }
  784. Widget _buildPageFooter(AppColorsExtension colors) {
  785. final l10n = AppLocalizations.of(context);
  786. return Center(
  787. child: Padding(
  788. padding: const EdgeInsets.only(bottom: 16),
  789. child: Row(
  790. mainAxisSize: MainAxisSize.min,
  791. children: [
  792. Icon(
  793. Icons.rocket_launch_outlined,
  794. size: 16,
  795. color: colors.textPlaceholder,
  796. ),
  797. const SizedBox(width: 6),
  798. Text(
  799. l10n.get('pageFooter'),
  800. style: TextStyle(
  801. fontSize: AppFontSizes.caption,
  802. color: colors.textPlaceholder,
  803. ),
  804. ),
  805. ],
  806. ),
  807. ),
  808. );
  809. }
  810. }
  811. /// 附件缩略图 — 自动调用 DownloadAttachment 加载图片
  812. class _ExpAttachmentThumbnail extends StatefulWidget {
  813. final ExpenseApi api;
  814. final BillAttachment attachment;
  815. final double size;
  816. const _ExpAttachmentThumbnail({
  817. required this.api,
  818. required this.attachment,
  819. required this.size,
  820. });
  821. @override
  822. State<_ExpAttachmentThumbnail> createState() =>
  823. _ExpAttachmentThumbnailState();
  824. }
  825. class _ExpAttachmentThumbnailState extends State<_ExpAttachmentThumbnail> {
  826. Uint8List? _bytes;
  827. bool _loading = true;
  828. @override
  829. void initState() {
  830. super.initState();
  831. _load();
  832. }
  833. Future<void> _load() async {
  834. try {
  835. final bytes = await widget.api.downloadAttachment(widget.attachment.id);
  836. if (mounted) {
  837. setState(() {
  838. _bytes = bytes;
  839. _loading = false;
  840. });
  841. }
  842. } catch (_) {
  843. if (mounted) setState(() => _loading = false);
  844. }
  845. }
  846. @override
  847. Widget build(BuildContext context) {
  848. if (_loading) {
  849. return SizedBox(
  850. width: widget.size,
  851. height: widget.size,
  852. child: const Center(
  853. child: SizedBox(
  854. width: 16,
  855. height: 16,
  856. child: CircularProgressIndicator(strokeWidth: 2),
  857. ),
  858. ),
  859. );
  860. }
  861. if (_bytes != null) {
  862. return ClipRRect(
  863. borderRadius: BorderRadius.circular(4),
  864. child: Image.memory(
  865. _bytes!,
  866. width: widget.size,
  867. height: widget.size,
  868. fit: BoxFit.cover,
  869. ),
  870. );
  871. }
  872. return Icon(
  873. Icons.broken_image,
  874. size: widget.size * 0.6,
  875. color: Colors.grey,
  876. );
  877. }
  878. }