expense_detail_page.dart 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967
  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 '../../core/navigation/host_app_channel.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. await HostAppChannel.showAuditTrail(billId, widget.billNo);
  245. }
  246. // ═══ 基本信息 ═══
  247. Widget _buildBasicInfoSection(
  248. ExpenseModel expense,
  249. AppLocalizations l10n,
  250. AppColorsExtension colors,
  251. ) {
  252. return FormSection(
  253. title: l10n.get('basicInfo'),
  254. leadingIcon: Icons.info_outline,
  255. trailing: _buildStatusTag(l10n),
  256. children: [
  257. FormFieldRow(
  258. label: l10n.get('expenseNo'),
  259. value: expense.expenseNo,
  260. readOnly: true,
  261. showArrow: false,
  262. ),
  263. const SizedBox(height: 16),
  264. FormFieldRow(
  265. label: l10n.get('date'),
  266. value: du.DateUtils.formatDate(expense.createTime),
  267. readOnly: true,
  268. showArrow: false,
  269. ),
  270. const SizedBox(height: 16),
  271. FormFieldRow(
  272. label: l10n.get('expensePersonnel'),
  273. value: expense.applicantId.isNotEmpty
  274. ? '${expense.applicantId}${expense.applicantName.isNotEmpty ? '/${expense.applicantName}' : ''}'
  275. : '-',
  276. readOnly: true,
  277. showArrow: false,
  278. ),
  279. const SizedBox(height: 16),
  280. FormFieldRow(
  281. label: l10n.get('expenseDept'),
  282. value: expense.deptId.isNotEmpty
  283. ? '${expense.deptId}${expense.deptName.isNotEmpty ? '/${expense.deptName}' : ''}'
  284. : '-',
  285. readOnly: true,
  286. showArrow: false,
  287. ),
  288. const SizedBox(height: 16),
  289. SizedBox(
  290. height: 24,
  291. child: Row(
  292. crossAxisAlignment: CrossAxisAlignment.center,
  293. children: [
  294. Text(
  295. l10n.get('expenseReason'),
  296. style: TextStyle(
  297. fontSize: AppFontSizes.subtitle,
  298. color: colors.textSecondary,
  299. ),
  300. ),
  301. const SizedBox(width: 8),
  302. Expanded(
  303. child: Text(
  304. expense.purpose.isNotEmpty ? expense.purpose : '-',
  305. textAlign: TextAlign.end,
  306. maxLines: 2,
  307. overflow: TextOverflow.ellipsis,
  308. style: TextStyle(
  309. fontSize: AppFontSizes.subtitle,
  310. fontWeight: FontWeight.w500,
  311. color: colors.textPrimary,
  312. ),
  313. ),
  314. ),
  315. ],
  316. ),
  317. ),
  318. const SizedBox(height: 16),
  319. FormFieldRow(
  320. label: l10n.get('voucherNo'),
  321. value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-',
  322. readOnly: true,
  323. showArrow: false,
  324. ),
  325. const SizedBox(height: 16),
  326. FormFieldRow(
  327. label: l10n.get('currency'),
  328. value: expense.currencyCode.isNotEmpty ? expense.currencyCode : '-',
  329. readOnly: true,
  330. showArrow: false,
  331. ),
  332. const SizedBox(height: 16),
  333. FormFieldRow(
  334. label: l10n.get('paymentMethod'),
  335. value: expense.paymentMethod.isNotEmpty ? expense.paymentMethod : '-',
  336. readOnly: true,
  337. showArrow: false,
  338. ),
  339. const SizedBox(height: 16),
  340. FormFieldRow(
  341. label: l10n.get('remark'),
  342. value: expense.remark.isNotEmpty ? expense.remark : '-',
  343. readOnly: true,
  344. showArrow: false,
  345. ),
  346. ],
  347. );
  348. }
  349. // ═══ 费用明细 ═══
  350. Widget _buildExpenseDetailSection(
  351. ExpenseModel expense,
  352. AppLocalizations l10n,
  353. AppColorsExtension colors,
  354. ) {
  355. final totalAmount = expense.details.fold<double>(
  356. 0,
  357. (sum, d) => sum + d.totalAmount,
  358. );
  359. final totalApproved = expense.details.fold<double>(
  360. 0,
  361. (sum, d) => sum + d.approvedAmount,
  362. );
  363. return FormSection(
  364. title: l10n.get('expenseDetails'),
  365. leadingIcon: Icons.receipt_long_outlined,
  366. children: [
  367. if (expense.details.isEmpty)
  368. Padding(
  369. padding: const EdgeInsets.symmetric(vertical: 8),
  370. child: Text(
  371. l10n.get('noDetailData'),
  372. style: TextStyle(
  373. fontSize: AppFontSizes.body,
  374. color: colors.textPlaceholder,
  375. ),
  376. ),
  377. )
  378. else
  379. ...expense.details.asMap().entries.map((e) {
  380. final d = e.value;
  381. final title = d.categoryName.isNotEmpty
  382. ? '${d.expenseCategory}/${d.categoryName}'
  383. : d.expenseCategory;
  384. return GestureDetector(
  385. onTap: () => _showExpenseDetailDialog(context, d),
  386. child: Container(
  387. margin: const EdgeInsets.symmetric(vertical: 6),
  388. padding: const EdgeInsets.all(12),
  389. decoration: BoxDecoration(
  390. color: colors.bgPage,
  391. borderRadius: BorderRadius.circular(8),
  392. ),
  393. child: Row(
  394. children: [
  395. Expanded(
  396. child: Column(
  397. crossAxisAlignment: CrossAxisAlignment.start,
  398. children: [
  399. Row(
  400. children: [
  401. Expanded(
  402. child: Text(
  403. title,
  404. style: TextStyle(
  405. fontSize: AppFontSizes.body,
  406. fontWeight: FontWeight.w500,
  407. color: colors.textPrimary,
  408. ),
  409. ),
  410. ),
  411. Column(
  412. crossAxisAlignment: CrossAxisAlignment.end,
  413. children: [
  414. Text(
  415. formatAmount(d.totalAmount),
  416. style: TextStyle(
  417. fontSize: AppFontSizes.body,
  418. fontWeight: FontWeight.w600,
  419. color: colors.amountPrimary,
  420. ),
  421. ),
  422. if (d.approvedAmount > 0)
  423. Text(
  424. formatAmount(d.approvedAmount),
  425. style: TextStyle(
  426. fontSize: AppFontSizes.body,
  427. fontWeight: FontWeight.w600,
  428. color: colors.success,
  429. ),
  430. ),
  431. ],
  432. ),
  433. ],
  434. ),
  435. const SizedBox(height: 2),
  436. Text(
  437. '${l10n.get('amountExcludingTax')}: ${formatAmount(d.amount)}',
  438. style: TextStyle(
  439. fontSize: AppFontSizes.caption,
  440. color: colors.textSecondary,
  441. ),
  442. ),
  443. if (d.taxAmount > 0)
  444. Text(
  445. '${l10n.get('taxAmount')}: ${formatAmount(d.taxAmount)}',
  446. style: TextStyle(
  447. fontSize: AppFontSizes.caption,
  448. color: colors.textSecondary,
  449. ),
  450. ),
  451. if (d.taxRate > 0)
  452. Text(
  453. '${l10n.get('taxRate')}: ${d.taxRate.toStringAsFixed(0)}%',
  454. style: TextStyle(
  455. fontSize: AppFontSizes.caption,
  456. color: colors.textSecondary,
  457. ),
  458. ),
  459. if (d.acctSubjectId.isNotEmpty)
  460. Text(
  461. '${l10n.get('acctSubject')}: ${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/${d.acctSubjectName}' : ''}',
  462. maxLines: 1,
  463. overflow: TextOverflow.ellipsis,
  464. style: TextStyle(
  465. fontSize: AppFontSizes.caption,
  466. color: colors.textSecondary,
  467. ),
  468. ),
  469. if (d.aeNo.isNotEmpty)
  470. Text(
  471. '${l10n.get('expenseApplyNo')}: ${d.aeNo}',
  472. maxLines: 1,
  473. overflow: TextOverflow.ellipsis,
  474. style: TextStyle(
  475. fontSize: AppFontSizes.caption,
  476. color: colors.textSecondary,
  477. ),
  478. ),
  479. if (d.aeDd.isNotEmpty)
  480. Text(
  481. '${l10n.get('applyDate')}: ${d.aeDd.length >= 10 ? d.aeDd.substring(0, 10) : d.aeDd}',
  482. style: TextStyle(
  483. fontSize: AppFontSizes.caption,
  484. color: colors.textSecondary,
  485. ),
  486. ),
  487. if (d.projectId.isNotEmpty)
  488. Text(
  489. '${l10n.get('project')}: ${d.projectId}${d.projectName.isNotEmpty ? '/${d.projectName}' : ''}',
  490. maxLines: 1,
  491. overflow: TextOverflow.ellipsis,
  492. style: TextStyle(
  493. fontSize: AppFontSizes.caption,
  494. color: colors.textSecondary,
  495. ),
  496. ),
  497. if (d.costDeptId.isNotEmpty)
  498. Text(
  499. '${l10n.get('costDept')}: ${d.costDeptId}${d.costDeptName.isNotEmpty ? '/${d.costDeptName}' : ''}',
  500. maxLines: 1,
  501. overflow: TextOverflow.ellipsis,
  502. style: TextStyle(
  503. fontSize: AppFontSizes.caption,
  504. color: colors.textSecondary,
  505. ),
  506. ),
  507. if (d.customerVendorId.isNotEmpty)
  508. Text(
  509. '${l10n.get('customerVendor')}: ${d.customerVendorId}${d.customerVendorName.isNotEmpty ? '/${d.customerVendorName}' : ''}',
  510. maxLines: 1,
  511. overflow: TextOverflow.ellipsis,
  512. style: TextStyle(
  513. fontSize: AppFontSizes.caption,
  514. color: colors.textSecondary,
  515. ),
  516. ),
  517. if (d.sqMan.isNotEmpty)
  518. Text(
  519. '${l10n.get('applicant')}: ${d.sqMan}${d.sqManName.isNotEmpty ? '/${d.sqManName}' : ''}',
  520. style: TextStyle(
  521. fontSize: AppFontSizes.caption,
  522. color: colors.textSecondary,
  523. ),
  524. ),
  525. if (d.bankAccountName.isNotEmpty)
  526. Text(
  527. '${l10n.get('bankAccountName')}: ${d.bankAccountName}',
  528. maxLines: 1,
  529. overflow: TextOverflow.ellipsis,
  530. style: TextStyle(
  531. fontSize: AppFontSizes.caption,
  532. color: colors.textSecondary,
  533. ),
  534. ),
  535. if (d.bankName.isNotEmpty)
  536. Text(
  537. '${l10n.get('bankName')}: ${d.bankName}',
  538. maxLines: 1,
  539. overflow: TextOverflow.ellipsis,
  540. style: TextStyle(
  541. fontSize: AppFontSizes.caption,
  542. color: colors.textSecondary,
  543. ),
  544. ),
  545. if (d.bankAccount.isNotEmpty)
  546. Text(
  547. '${l10n.get('bankAccount')}: ${d.bankAccount}',
  548. maxLines: 1,
  549. overflow: TextOverflow.ellipsis,
  550. style: TextStyle(
  551. fontSize: AppFontSizes.caption,
  552. color: colors.textSecondary,
  553. ),
  554. ),
  555. if (d.remark.isNotEmpty)
  556. Text(
  557. '${l10n.get('remark')}: ${d.remark}',
  558. maxLines: 2,
  559. overflow: TextOverflow.ellipsis,
  560. style: TextStyle(
  561. fontSize: AppFontSizes.caption,
  562. color: colors.textSecondary,
  563. ),
  564. ),
  565. ],
  566. ),
  567. ),
  568. ],
  569. ),
  570. ),
  571. );
  572. }),
  573. if (expense.details.isNotEmpty) ...[
  574. const SizedBox(height: 8),
  575. Row(
  576. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  577. children: [
  578. Text(
  579. l10n.get('totalExpense'),
  580. style: TextStyle(
  581. fontSize: AppFontSizes.body,
  582. fontWeight: FontWeight.w600,
  583. color: colors.textPrimary,
  584. ),
  585. ),
  586. Text(
  587. formatAmount(totalAmount),
  588. style: TextStyle(
  589. fontSize: AppFontSizes.subtitle,
  590. fontWeight: FontWeight.w700,
  591. color: colors.amountPrimary,
  592. ),
  593. ),
  594. ],
  595. ),
  596. const SizedBox(height: 4),
  597. Row(
  598. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  599. children: [
  600. Text(
  601. l10n.get('approvedTotal'),
  602. style: TextStyle(
  603. fontSize: AppFontSizes.body,
  604. fontWeight: FontWeight.w600,
  605. color: colors.textPrimary,
  606. ),
  607. ),
  608. Text(
  609. formatAmount(totalApproved),
  610. style: TextStyle(
  611. fontSize: AppFontSizes.subtitle,
  612. fontWeight: FontWeight.w700,
  613. color: totalApproved > 0 ? colors.success : colors.textPrimary,
  614. ),
  615. ),
  616. ],
  617. ),
  618. ],
  619. ],
  620. );
  621. }
  622. void _showExpenseDetailDialog(BuildContext context, ExpenseDetailModel d) {
  623. ExpenseDetailViewDialog.show(context, d);
  624. }
  625. // ═══ 附件 ═══
  626. Widget _buildAttachmentSection(
  627. AppLocalizations l10n,
  628. AppColorsExtension colors,
  629. ) {
  630. if (!_attachAvailable) {
  631. return FormSection(
  632. title: l10n.get('attachments'),
  633. leadingIcon: Icons.attach_file_outlined,
  634. children: [
  635. Text(
  636. l10n.get('attachServiceUnavailable'),
  637. style: TextStyle(
  638. fontSize: AppFontSizes.body,
  639. color: colors.textPlaceholder,
  640. ),
  641. ),
  642. ],
  643. );
  644. }
  645. if (!_billFileRights.canBrowseAttachments) {
  646. return FormSection(
  647. title: l10n.get('attachments'),
  648. leadingIcon: Icons.attach_file_outlined,
  649. children: [
  650. Text(
  651. l10n.get('noAttachmentPermission'),
  652. style: TextStyle(
  653. fontSize: AppFontSizes.body,
  654. color: colors.textPlaceholder,
  655. ),
  656. ),
  657. ],
  658. );
  659. }
  660. final headerAtts = _attachments.where((a) => a.isHeader).toList();
  661. final bodyGroups = <int, List<BillAttachment>>{};
  662. for (final a in _attachments.where((a) => a.isBody)) {
  663. bodyGroups.putIfAbsent(a.srcItm, () => []).add(a);
  664. }
  665. final children = <Widget>[];
  666. if (_attachments.isEmpty) {
  667. children.add(
  668. Text(
  669. l10n.get('noAttachment'),
  670. style: TextStyle(
  671. fontSize: AppFontSizes.body,
  672. color: colors.textPlaceholder,
  673. ),
  674. ),
  675. );
  676. } else {
  677. // 表头附件
  678. if (headerAtts.isNotEmpty) {
  679. children.add(
  680. Padding(
  681. padding: const EdgeInsets.only(bottom: 8),
  682. child: Text(
  683. l10n.get('headerAttachments'),
  684. style: TextStyle(
  685. fontSize: AppFontSizes.caption,
  686. fontWeight: FontWeight.w600,
  687. color: colors.textSecondary,
  688. ),
  689. ),
  690. ),
  691. );
  692. for (final a in headerAtts) {
  693. children.add(_buildAttachmentRow(a, colors));
  694. }
  695. }
  696. // 表身附件(按明细行分组)
  697. for (final entry in bodyGroups.entries) {
  698. children.add(const SizedBox(height: 8));
  699. children.add(
  700. Padding(
  701. padding: const EdgeInsets.only(bottom: 8),
  702. child: Text(
  703. '${l10n.get('detailLine')} ${entry.key}',
  704. style: TextStyle(
  705. fontSize: AppFontSizes.caption,
  706. fontWeight: FontWeight.w600,
  707. color: colors.textSecondary,
  708. ),
  709. ),
  710. ),
  711. );
  712. for (final a in entry.value) {
  713. children.add(_buildAttachmentRow(a, colors));
  714. }
  715. }
  716. }
  717. return FormSection(
  718. title: l10n.get('attachments'),
  719. leadingIcon: Icons.attach_file_outlined,
  720. children: children,
  721. );
  722. }
  723. Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
  724. final isImage = [
  725. 'jpg',
  726. 'jpeg',
  727. 'png',
  728. 'gif',
  729. 'bmp',
  730. 'webp',
  731. ].contains(a.ext.toLowerCase());
  732. return GestureDetector(
  733. onTap: () => _openAttachment(a),
  734. child: Container(
  735. margin: const EdgeInsets.symmetric(vertical: 4),
  736. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  737. decoration: BoxDecoration(
  738. color: colors.bgPage,
  739. borderRadius: BorderRadius.circular(8),
  740. ),
  741. child: Row(
  742. children: [
  743. if (isImage)
  744. _ExpAttachmentThumbnail(
  745. api: ref.read(expenseApiProvider),
  746. attachment: a,
  747. size: 40,
  748. )
  749. else
  750. Icon(_fileTypeIcon(a.ext), size: 40, color: colors.primary),
  751. const SizedBox(width: 10),
  752. Expanded(
  753. child: Text(
  754. a.fileName,
  755. maxLines: 1,
  756. overflow: TextOverflow.ellipsis,
  757. style: TextStyle(
  758. fontSize: AppFontSizes.body,
  759. color: colors.textPrimary,
  760. ),
  761. ),
  762. ),
  763. const SizedBox(width: 8),
  764. GestureDetector(
  765. onTap: () => AttachmentDownloadHelper.downloadAndSave(
  766. context,
  767. a,
  768. ref.read(expenseApiProvider).downloadAttachment,
  769. ),
  770. child: Icon(
  771. Icons.download_outlined,
  772. size: 22,
  773. color: colors.primary,
  774. ),
  775. ),
  776. ],
  777. ),
  778. ),
  779. );
  780. }
  781. Future<void> _openAttachment(BillAttachment a) async {
  782. final l10n = AppLocalizations.of(context);
  783. final ext = a.ext.toLowerCase();
  784. final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(ext);
  785. if (isImage) {
  786. // 图片 → 弹窗预览,内部自动下载并显示 loading
  787. final api = ref.read(expenseApiProvider);
  788. AttachmentPreview.show(
  789. context,
  790. loader: api.downloadAttachment(a.id),
  791. fileName: a.fileName,
  792. loadingText: l10n.get('loading'),
  793. );
  794. return;
  795. }
  796. // 非图片 → 下载后调用系统工具打开
  797. try {
  798. LoadingDialog.show(context, text: l10n.get('downloading'));
  799. final api = ref.read(expenseApiProvider);
  800. final bytes = await api.downloadAttachment(a.id);
  801. if (!mounted) return;
  802. LoadingDialog.hide(context);
  803. if (bytes == null) {
  804. TDToast.showText(l10n.get('downloadFailed'), context: context);
  805. return;
  806. }
  807. final dir = await getTemporaryDirectory();
  808. final file = File('${dir.path}/${a.fileName}');
  809. await file.writeAsBytes(bytes);
  810. await OpenFilex.open(file.path);
  811. } catch (_) {
  812. if (mounted) LoadingDialog.hide(context);
  813. if (mounted) TDToast.showText(l10n.get('openFailed'), context: context);
  814. }
  815. }
  816. IconData _fileTypeIcon(String ext) {
  817. switch (ext.toLowerCase()) {
  818. case 'pdf':
  819. return Icons.picture_as_pdf;
  820. case 'doc':
  821. case 'docx':
  822. return Icons.description;
  823. case 'xls':
  824. case 'xlsx':
  825. return Icons.table_chart;
  826. case 'jpg':
  827. case 'jpeg':
  828. case 'png':
  829. case 'gif':
  830. case 'bmp':
  831. return Icons.image_outlined;
  832. default:
  833. return Icons.insert_drive_file;
  834. }
  835. }
  836. Widget _buildPageFooter(AppColorsExtension colors) {
  837. final l10n = AppLocalizations.of(context);
  838. return Center(
  839. child: Padding(
  840. padding: const EdgeInsets.only(bottom: 16),
  841. child: Row(
  842. mainAxisSize: MainAxisSize.min,
  843. children: [
  844. Icon(
  845. Icons.rocket_launch_outlined,
  846. size: 16,
  847. color: colors.textPlaceholder,
  848. ),
  849. const SizedBox(width: 6),
  850. Text(
  851. l10n.get('pageFooter'),
  852. style: TextStyle(
  853. fontSize: AppFontSizes.caption,
  854. color: colors.textPlaceholder,
  855. ),
  856. ),
  857. ],
  858. ),
  859. ),
  860. );
  861. }
  862. }
  863. /// 附件缩略图 — 自动调用 DownloadAttachment 加载图片
  864. class _ExpAttachmentThumbnail extends StatefulWidget {
  865. final ExpenseApi api;
  866. final BillAttachment attachment;
  867. final double size;
  868. const _ExpAttachmentThumbnail({
  869. required this.api,
  870. required this.attachment,
  871. required this.size,
  872. });
  873. @override
  874. State<_ExpAttachmentThumbnail> createState() =>
  875. _ExpAttachmentThumbnailState();
  876. }
  877. class _ExpAttachmentThumbnailState extends State<_ExpAttachmentThumbnail> {
  878. Uint8List? _bytes;
  879. bool _loading = true;
  880. @override
  881. void initState() {
  882. super.initState();
  883. _load();
  884. }
  885. Future<void> _load() async {
  886. try {
  887. final bytes = await widget.api.downloadAttachment(widget.attachment.id);
  888. if (mounted) {
  889. setState(() {
  890. _bytes = bytes;
  891. _loading = false;
  892. });
  893. }
  894. } catch (_) {
  895. if (mounted) setState(() => _loading = false);
  896. }
  897. }
  898. @override
  899. Widget build(BuildContext context) {
  900. if (_loading) {
  901. return SizedBox(
  902. width: widget.size,
  903. height: widget.size,
  904. child: const Center(
  905. child: SizedBox(
  906. width: 16,
  907. height: 16,
  908. child: CircularProgressIndicator(strokeWidth: 2),
  909. ),
  910. ),
  911. );
  912. }
  913. if (_bytes != null) {
  914. return ClipRRect(
  915. borderRadius: BorderRadius.circular(4),
  916. child: Image.memory(
  917. _bytes!,
  918. width: widget.size,
  919. height: widget.size,
  920. fit: BoxFit.cover,
  921. ),
  922. );
  923. }
  924. return Icon(
  925. Icons.broken_image,
  926. size: widget.size * 0.6,
  927. color: Colors.grey,
  928. );
  929. }
  930. }