expense_detail_page.dart 32 KB

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