expense_apply_detail_page.dart 26 KB

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