expense_apply_detail_page.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  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 'widgets/attachment_preview_page.dart';
  7. import 'package:tdesign_flutter/tdesign_flutter.dart';
  8. import 'package:flutter_riverpod/flutter_riverpod.dart';
  9. import 'package:go_router/go_router.dart';
  10. import '../../shared/widgets/nav_bar_config.dart';
  11. import '../../shared/widgets/loading_dialog.dart';
  12. import '../../core/utils/date_utils.dart' as du;
  13. import '../../shared/widgets/form_section.dart';
  14. import '../../shared/widgets/form_field_row.dart';
  15. import 'expense_apply_model.dart';
  16. import '../../core/i18n/app_localizations.dart';
  17. import '../../shared/models/bill_attachment.dart';
  18. import 'expense_apply_api.dart';
  19. import '../../core/theme/app_colors.dart';
  20. import '../../core/theme/app_colors_extension.dart';
  21. class ExpenseApplyDetailPage extends ConsumerStatefulWidget {
  22. final String billNo;
  23. final int queryId;
  24. const ExpenseApplyDetailPage({super.key, required this.billNo, this.queryId = 0});
  25. @override
  26. ConsumerState<ExpenseApplyDetailPage> createState() => _ExpenseApplyDetailPageState();
  27. }
  28. class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
  29. with WidgetsBindingObserver {
  30. bool _loading = true;
  31. String? _error;
  32. ExpenseApplyModel? _data;
  33. List<BillAttachment> _attachments = [];
  34. bool _attachAvailable = false;
  35. @override
  36. void initState() {
  37. super.initState();
  38. WidgetsBinding.instance.addObserver(this);
  39. _loadData();
  40. }
  41. @override
  42. void dispose() {
  43. WidgetsBinding.instance.removeObserver(this);
  44. super.dispose();
  45. }
  46. bool _openingFile = false;
  47. @override
  48. void didChangeAppLifecycleState(AppLifecycleState state) {
  49. if (state == AppLifecycleState.resumed) {
  50. if (_openingFile) {
  51. _openingFile = false;
  52. return;
  53. }
  54. _attachments = [];
  55. _attachAvailable = false;
  56. _loadData();
  57. }
  58. }
  59. Future<void> _loadData() async {
  60. setState(() { _loading = true; _error = null; });
  61. try {
  62. final api = ref.read(expenseApplyApiProvider);
  63. final detail = await api.fetchDetail(widget.billNo);
  64. // Load attachments (non-critical, best-effort)
  65. bool attachAvailable = false;
  66. try {
  67. attachAvailable = await api.checkAttachHealth();
  68. debugPrint('[Attach] checkAttachHealth result: $attachAvailable');
  69. } catch (e) {
  70. debugPrint('[Attach] checkAttachHealth error: $e');
  71. attachAvailable = false;
  72. }
  73. List<BillAttachment> attachments = [];
  74. if (attachAvailable) {
  75. try {
  76. attachments = await api.getAttachments('AE', widget.billNo);
  77. debugPrint('[Attach] getAttachments count: ${attachments.length}');
  78. } catch (e) {
  79. debugPrint('[Attach] getAttachments error: $e');
  80. // 附件列表加载失败不影响服务可用状态,保持空列表
  81. }
  82. }
  83. debugPrint('[Attach] final state: attachAvailable=$attachAvailable, count=${attachments.length}');
  84. if (mounted) {
  85. setState(() {
  86. _data = detail;
  87. _attachments = attachments;
  88. _attachAvailable = attachAvailable;
  89. _loading = false;
  90. });
  91. }
  92. } catch (e) {
  93. if (mounted) {
  94. setState(() { _error = e.toString(); _loading = false; });
  95. }
  96. }
  97. }
  98. @override
  99. Widget build(BuildContext context) {
  100. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  101. final l10n = AppLocalizations.of(context);
  102. setNavBarTitle(context, ref, NavBarConfig(
  103. title: l10n.get('expenseApplyDetail'),
  104. showBack: true,
  105. onBack: () => context.pop(),
  106. ));
  107. if (_loading) {
  108. return const Center(
  109. child: TDLoading(size: TDLoadingSize.large, icon: TDLoadingIcon.activity),
  110. );
  111. }
  112. if (_error != null) {
  113. return Center(
  114. child: Column(
  115. mainAxisSize: MainAxisSize.min,
  116. children: [
  117. Icon(Icons.error_outline, size: 48, color: colors.danger),
  118. const SizedBox(height: 16),
  119. Padding(
  120. padding: const EdgeInsets.symmetric(horizontal: 32),
  121. child: Text(
  122. _error!,
  123. textAlign: TextAlign.center,
  124. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textSecondary),
  125. ),
  126. ),
  127. const SizedBox(height: 16),
  128. TDButton(
  129. text: l10n.get('retry'),
  130. size: TDButtonSize.medium,
  131. onTap: _loadData,
  132. ),
  133. ],
  134. ),
  135. );
  136. }
  137. final app = _data!;
  138. return Expanded(
  139. child: SingleChildScrollView(
  140. physics: const AlwaysScrollableScrollPhysics(),
  141. padding: const EdgeInsets.all(16),
  142. child: Column(
  143. children: [
  144. _buildBasicInfoSection(app, l10n, colors),
  145. const SizedBox(height: 16),
  146. _buildExpenseDetailSection(app, l10n, colors),
  147. const SizedBox(height: 16),
  148. _buildAttachmentSection(l10n, colors),
  149. const SizedBox(height: 24),
  150. _buildPageFooter(colors),
  151. ],
  152. ),
  153. ),
  154. );
  155. }
  156. // ═══ 基本信息 ═══
  157. Widget _buildBasicInfoSection(
  158. ExpenseApplyModel app,
  159. AppLocalizations l10n,
  160. AppColorsExtension colors,
  161. ) {
  162. return FormSection(
  163. title: l10n.get('basicInfo'),
  164. leadingIcon: Icons.info_outline,
  165. children: [
  166. FormFieldRow(label: l10n.get('expenseApplyNo'), value: app.expenseApplyNo, readOnly: true, showArrow: false),
  167. const SizedBox(height: 16),
  168. FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDate(app.createTime), readOnly: true, showArrow: false),
  169. const SizedBox(height: 16),
  170. FormFieldRow(label: l10n.get('applicant'), value: app.applicantName, readOnly: true, showArrow: false),
  171. const SizedBox(height: 16),
  172. FormFieldRow(label: l10n.get('department'), value: app.deptName, readOnly: true, showArrow: false),
  173. const SizedBox(height: 16),
  174. SizedBox(
  175. height: 24,
  176. child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  177. Text(l10n.get('emergencyLevel'), style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
  178. _buildUrgencyChip(app.urgency, l10n, colors),
  179. ]),
  180. ),
  181. const SizedBox(height: 16),
  182. FormFieldRow(label: l10n.get('applyReason'), value: app.purpose, readOnly: true, showArrow: false),
  183. const SizedBox(height: 16),
  184. FormFieldRow(label: l10n.get('remark'), value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false),
  185. ],
  186. );
  187. }
  188. // ═══ 费用明细 ═══
  189. Widget _buildExpenseDetailSection(
  190. ExpenseApplyModel app,
  191. AppLocalizations l10n,
  192. AppColorsExtension colors,
  193. ) {
  194. return FormSection(
  195. title: l10n.get('expenseDetails'),
  196. leadingIcon: Icons.receipt_long_outlined,
  197. children: [
  198. if (app.details.isEmpty)
  199. Padding(
  200. padding: const EdgeInsets.symmetric(vertical: 8),
  201. child: Text(l10n.get('noDetailData'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)),
  202. )
  203. else
  204. ...app.details.asMap().entries.map((e) {
  205. final d = e.value;
  206. final catLabel = d.categoryName.isNotEmpty
  207. ? '${d.expenseCategory}/${d.categoryName}'
  208. : d.expenseCategory;
  209. return Container(
  210. margin: const EdgeInsets.symmetric(vertical: 8),
  211. padding: const EdgeInsets.all(12),
  212. decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
  213. child: Row(
  214. crossAxisAlignment: CrossAxisAlignment.center,
  215. children: [
  216. Expanded(
  217. child: Column(
  218. crossAxisAlignment: CrossAxisAlignment.start,
  219. children: [
  220. Row(
  221. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  222. children: [
  223. Expanded(
  224. child: Text(
  225. catLabel,
  226. maxLines: 1,
  227. overflow: TextOverflow.ellipsis,
  228. style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPrimary),
  229. ),
  230. ),
  231. const SizedBox(width: 12),
  232. Text(
  233. '¥${d.estimatedAmount.toStringAsFixed(2)}',
  234. style: TextStyle(fontSize: AppFontSizes.caption, fontWeight: FontWeight.w600, color: colors.amountPrimary),
  235. ),
  236. ],
  237. ),
  238. if (d.acctSubjectId.isNotEmpty) ...[
  239. const SizedBox(height: 4),
  240. Text(
  241. '${l10n.get('acctSubject')}: ${d.acctSubjectId}${d.acctSubjectName.isNotEmpty ? '/$d.acctSubjectName' : ''}',
  242. maxLines: 1,
  243. overflow: TextOverflow.ellipsis,
  244. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  245. ),
  246. ],
  247. if (d.projectId.isNotEmpty && d.projectName.isNotEmpty) ...[
  248. const SizedBox(height: 4),
  249. Text(
  250. '${d.projectId}/${d.projectName}',
  251. maxLines: 1,
  252. overflow: TextOverflow.ellipsis,
  253. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  254. ),
  255. ],
  256. if (d.costDeptId.isNotEmpty && d.costDeptName.isNotEmpty) ...[
  257. const SizedBox(height: 4),
  258. Text(
  259. '${l10n.get('costDept')}: ${d.costDeptId}/${d.costDeptName}',
  260. maxLines: 1,
  261. overflow: TextOverflow.ellipsis,
  262. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  263. ),
  264. ],
  265. if (d.estimatedStartDate != null) ...[
  266. const SizedBox(height: 4),
  267. Text(
  268. '${l10n.get('estimatedDate')}: ${du.DateUtils.formatDate(d.estimatedStartDate!)}${d.estimatedEndDate != null ? ' ~ ${du.DateUtils.formatDate(d.estimatedEndDate!)}' : ''}',
  269. maxLines: 1,
  270. overflow: TextOverflow.ellipsis,
  271. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  272. ),
  273. ],
  274. if (d.bxNo.isNotEmpty) ...[
  275. const SizedBox(height: 4),
  276. Text(
  277. '${l10n.get('expenseNo')}: $d.bxNo',
  278. maxLines: 1,
  279. overflow: TextOverflow.ellipsis,
  280. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  281. ),
  282. ],
  283. if (d.remark.isNotEmpty) ...[
  284. const SizedBox(height: 4),
  285. Text(
  286. d.remark,
  287. maxLines: 2,
  288. overflow: TextOverflow.ellipsis,
  289. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  290. ),
  291. ],
  292. ],
  293. ),
  294. ),
  295. ],
  296. ),
  297. );
  298. }),
  299. if (app.details.isNotEmpty) ...[
  300. const SizedBox(height: 8),
  301. Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  302. Text(l10n.get('total'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  303. Text('¥${app.estimatedAmount.toStringAsFixed(2)}',
  304. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
  305. ]),
  306. ],
  307. ],
  308. );
  309. }
  310. // ═══ 附件 ═══
  311. Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
  312. final children = <Widget>[];
  313. if (!_attachAvailable) {
  314. children.add(Text(l10n.get('attachServiceUnavailable'),
  315. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
  316. } else if (_attachments.isEmpty) {
  317. children.add(Text(l10n.get('noAttachment'),
  318. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
  319. } else {
  320. children.addAll(_attachments.map((a) => _buildAttachmentRow(a, colors)));
  321. }
  322. return FormSection(
  323. title: l10n.get('attachments'),
  324. leadingIcon: Icons.attach_file_outlined,
  325. children: children,
  326. );
  327. }
  328. Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
  329. final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(a.ext.toLowerCase());
  330. return GestureDetector(
  331. onTap: () => _openAttachment(a),
  332. child: Container(
  333. margin: const EdgeInsets.symmetric(vertical: 4),
  334. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  335. decoration: BoxDecoration(
  336. color: colors.bgPage,
  337. borderRadius: BorderRadius.circular(8),
  338. ),
  339. child: Row(children: [
  340. if (isImage)
  341. _AttachmentThumbnail(api: ref.read(expenseApplyApiProvider), attachment: a, size: 40)
  342. else
  343. Icon(_fileTypeIcon(a.ext), size: 40, color: colors.primary),
  344. const SizedBox(width: 10),
  345. Expanded(
  346. child: Text(a.fileName,
  347. maxLines: 1, overflow: TextOverflow.ellipsis,
  348. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPrimary)),
  349. ),
  350. ]),
  351. ),
  352. );
  353. }
  354. Future<void> _openAttachment(BillAttachment a) async {
  355. final l10n = AppLocalizations.of(context);
  356. final ext = a.ext.toLowerCase();
  357. final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(ext);
  358. if (isImage) {
  359. // 图片 → 弹窗预览,内部自动下载并显示 loading
  360. final api = ref.read(expenseApplyApiProvider);
  361. AttachmentPreview.show(context,
  362. loader: api.downloadAttachment(a.id),
  363. fileName: a.fileName,
  364. loadingText: l10n.get('loading'),
  365. );
  366. return;
  367. }
  368. // 非图片 → 下载后调用系统工具打开
  369. try {
  370. LoadingDialog.show(context, text: l10n.get('downloading'));
  371. final api = ref.read(expenseApplyApiProvider);
  372. final bytes = await api.downloadAttachment(a.id);
  373. if (!mounted) return;
  374. LoadingDialog.hide(context);
  375. if (bytes == null) {
  376. TDToast.showText(l10n.get('downloadFailed'), context: context);
  377. return;
  378. }
  379. final dir = await getTemporaryDirectory();
  380. final file = File('${dir.path}/${a.fileName}');
  381. await file.writeAsBytes(bytes);
  382. _openingFile = true;
  383. await OpenFilex.open(file.path);
  384. } catch (_) {
  385. if (mounted) LoadingDialog.hide(context);
  386. if (mounted) TDToast.showText(l10n.get('openFailed'), context: context);
  387. }
  388. }
  389. IconData _fileTypeIcon(String ext) {
  390. switch (ext.toLowerCase()) {
  391. case 'pdf': return Icons.picture_as_pdf;
  392. case 'doc': case 'docx': return Icons.description;
  393. case 'xls': case 'xlsx': return Icons.table_chart;
  394. case 'jpg': case 'jpeg': case 'png': case 'gif': case 'bmp': return Icons.image_outlined;
  395. default: return Icons.insert_drive_file;
  396. }
  397. }
  398. Widget _buildPageFooter(AppColorsExtension colors) {
  399. final l10n = AppLocalizations.of(context);
  400. return Center(
  401. child: Padding(
  402. padding: const EdgeInsets.only(bottom: 16),
  403. child: Row(
  404. mainAxisSize: MainAxisSize.min,
  405. children: [
  406. Icon(Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder),
  407. const SizedBox(width: 6),
  408. Text(l10n.get('pageFooter'),
  409. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
  410. ],
  411. ),
  412. ),
  413. );
  414. }
  415. Widget _buildUrgencyChip(String urgency, AppLocalizations l10n, AppColorsExtension colors) {
  416. final (label, color) = switch (urgency) {
  417. '3' || 'critical' => (l10n.get('critical'), colors.danger),
  418. '2' || 'urgent' => (l10n.get('urgent'), colors.warning),
  419. '1' || 'normal' => (l10n.get('normal'), colors.primary),
  420. _ => (l10n.get('normal'), colors.primary),
  421. };
  422. return Container(
  423. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
  424. decoration: BoxDecoration(
  425. color: color.withValues(alpha: 0.1),
  426. borderRadius: BorderRadius.circular(4),
  427. border: Border.all(color: color, width: 0.5),
  428. ),
  429. child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: color)),
  430. );
  431. }
  432. }
  433. /// 附件缩略图 — 自动调用 DownloadAttachment 加载图片
  434. class _AttachmentThumbnail extends StatefulWidget {
  435. final ExpenseApplyApi api;
  436. final BillAttachment attachment;
  437. final double size;
  438. const _AttachmentThumbnail({required this.api, required this.attachment, required this.size});
  439. @override
  440. State<_AttachmentThumbnail> createState() => _AttachmentThumbnailState();
  441. }
  442. class _AttachmentThumbnailState extends State<_AttachmentThumbnail> {
  443. Uint8List? _bytes;
  444. bool _loading = true;
  445. @override
  446. void initState() {
  447. super.initState();
  448. _load();
  449. }
  450. Future<void> _load() async {
  451. try {
  452. final bytes = await widget.api.downloadAttachment(widget.attachment.id);
  453. if (mounted) setState(() { _bytes = bytes; _loading = false; });
  454. } catch (_) {
  455. if (mounted) setState(() => _loading = false);
  456. }
  457. }
  458. @override
  459. Widget build(BuildContext context) {
  460. if (_loading) {
  461. return SizedBox(width: widget.size, height: widget.size,
  462. child: const Center(child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))));
  463. }
  464. if (_bytes != null) {
  465. return ClipRRect(
  466. borderRadius: BorderRadius.circular(4),
  467. child: Image.memory(_bytes!, width: widget.size, height: widget.size, fit: BoxFit.cover),
  468. );
  469. }
  470. return Icon(Icons.broken_image, size: widget.size * 0.6, color: Colors.grey);
  471. }
  472. }