expense_detail_page.dart 31 KB

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