expense_apply_detail_page.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  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('applicant'), value: app.applicantName, readOnly: true, showArrow: false),
  169. const SizedBox(height: 16),
  170. FormFieldRow(label: l10n.get('department'), value: app.deptName, readOnly: true, showArrow: false),
  171. const SizedBox(height: 16),
  172. FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDate(app.createTime), 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('feeReason'), 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 && d.acctSubjectName.isNotEmpty) ...[
  239. const SizedBox(height: 4),
  240. Text(
  241. '${d.acctSubjectId}/${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. '${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. '${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.remark.isNotEmpty) ...[
  275. const SizedBox(height: 4),
  276. Text(
  277. d.remark,
  278. maxLines: 2,
  279. overflow: TextOverflow.ellipsis,
  280. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
  281. ),
  282. ],
  283. ],
  284. ),
  285. ),
  286. ],
  287. ),
  288. );
  289. }),
  290. if (app.details.isNotEmpty) ...[
  291. const SizedBox(height: 8),
  292. Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
  293. Text(l10n.get('total'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
  294. Text('¥${app.estimatedAmount.toStringAsFixed(2)}',
  295. style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
  296. ]),
  297. ],
  298. ],
  299. );
  300. }
  301. // ═══ 附件 ═══
  302. Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
  303. final children = <Widget>[];
  304. if (!_attachAvailable) {
  305. children.add(Text(l10n.get('attachServiceUnavailable'),
  306. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
  307. } else if (_attachments.isEmpty) {
  308. children.add(Text(l10n.get('noAttachment'),
  309. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
  310. } else {
  311. children.addAll(_attachments.map((a) => _buildAttachmentRow(a, colors)));
  312. }
  313. return FormSection(
  314. title: l10n.get('attachments'),
  315. leadingIcon: Icons.attach_file_outlined,
  316. children: children,
  317. );
  318. }
  319. Widget _buildAttachmentRow(BillAttachment a, AppColorsExtension colors) {
  320. final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(a.ext.toLowerCase());
  321. return GestureDetector(
  322. onTap: () => _openAttachment(a),
  323. child: Container(
  324. margin: const EdgeInsets.symmetric(vertical: 4),
  325. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  326. decoration: BoxDecoration(
  327. color: colors.bgPage,
  328. borderRadius: BorderRadius.circular(8),
  329. ),
  330. child: Row(children: [
  331. if (isImage)
  332. _AttachmentThumbnail(api: ref.read(expenseApplyApiProvider), attachment: a, size: 40)
  333. else
  334. Icon(_fileTypeIcon(a.ext), size: 40, color: colors.primary),
  335. const SizedBox(width: 10),
  336. Expanded(
  337. child: Text(a.fileName,
  338. maxLines: 1, overflow: TextOverflow.ellipsis,
  339. style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPrimary)),
  340. ),
  341. ]),
  342. ),
  343. );
  344. }
  345. Future<void> _openAttachment(BillAttachment a) async {
  346. final l10n = AppLocalizations.of(context);
  347. final ext = a.ext.toLowerCase();
  348. final isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].contains(ext);
  349. if (isImage) {
  350. // 图片 → 弹窗预览,内部自动下载并显示 loading
  351. final api = ref.read(expenseApplyApiProvider);
  352. AttachmentPreview.show(context,
  353. loader: api.downloadAttachment(a.id),
  354. fileName: a.fileName,
  355. loadingText: l10n.get('loading'),
  356. );
  357. return;
  358. }
  359. // 非图片 → 下载后调用系统工具打开
  360. try {
  361. LoadingDialog.show(context, text: l10n.get('downloading'));
  362. final api = ref.read(expenseApplyApiProvider);
  363. final bytes = await api.downloadAttachment(a.id);
  364. if (!mounted) return;
  365. LoadingDialog.hide(context);
  366. if (bytes == null) {
  367. TDToast.showText(l10n.get('downloadFailed'), context: context);
  368. return;
  369. }
  370. final dir = await getTemporaryDirectory();
  371. final file = File('${dir.path}/${a.fileName}');
  372. await file.writeAsBytes(bytes);
  373. _openingFile = true;
  374. await OpenFilex.open(file.path);
  375. } catch (_) {
  376. if (mounted) LoadingDialog.hide(context);
  377. if (mounted) TDToast.showText(l10n.get('openFailed'), context: context);
  378. }
  379. }
  380. IconData _fileTypeIcon(String ext) {
  381. switch (ext.toLowerCase()) {
  382. case 'pdf': return Icons.picture_as_pdf;
  383. case 'doc': case 'docx': return Icons.description;
  384. case 'xls': case 'xlsx': return Icons.table_chart;
  385. case 'jpg': case 'jpeg': case 'png': case 'gif': case 'bmp': return Icons.image_outlined;
  386. default: return Icons.insert_drive_file;
  387. }
  388. }
  389. Widget _buildPageFooter(AppColorsExtension colors) {
  390. final l10n = AppLocalizations.of(context);
  391. return Center(
  392. child: Padding(
  393. padding: const EdgeInsets.only(bottom: 16),
  394. child: Row(
  395. mainAxisSize: MainAxisSize.min,
  396. children: [
  397. Icon(Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder),
  398. const SizedBox(width: 6),
  399. Text(l10n.get('pageFooter'),
  400. style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
  401. ],
  402. ),
  403. ),
  404. );
  405. }
  406. Widget _buildUrgencyChip(String urgency, AppLocalizations l10n, AppColorsExtension colors) {
  407. final (label, color) = switch (urgency) {
  408. '3' || 'critical' => (l10n.get('critical'), colors.danger),
  409. '2' || 'urgent' => (l10n.get('urgent'), colors.warning),
  410. '1' || 'normal' => (l10n.get('normal'), colors.primary),
  411. _ => (l10n.get('normal'), colors.primary),
  412. };
  413. return Container(
  414. padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
  415. decoration: BoxDecoration(
  416. color: color.withValues(alpha: 0.1),
  417. borderRadius: BorderRadius.circular(4),
  418. border: Border.all(color: color, width: 0.5),
  419. ),
  420. child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: color)),
  421. );
  422. }
  423. }
  424. /// 附件缩略图 — 自动调用 DownloadAttachment 加载图片
  425. class _AttachmentThumbnail extends StatefulWidget {
  426. final ExpenseApplyApi api;
  427. final BillAttachment attachment;
  428. final double size;
  429. const _AttachmentThumbnail({required this.api, required this.attachment, required this.size});
  430. @override
  431. State<_AttachmentThumbnail> createState() => _AttachmentThumbnailState();
  432. }
  433. class _AttachmentThumbnailState extends State<_AttachmentThumbnail> {
  434. Uint8List? _bytes;
  435. bool _loading = true;
  436. @override
  437. void initState() {
  438. super.initState();
  439. _load();
  440. }
  441. Future<void> _load() async {
  442. try {
  443. final bytes = await widget.api.downloadAttachment(widget.attachment.id);
  444. if (mounted) setState(() { _bytes = bytes; _loading = false; });
  445. } catch (_) {
  446. if (mounted) setState(() => _loading = false);
  447. }
  448. }
  449. @override
  450. Widget build(BuildContext context) {
  451. if (_loading) {
  452. return SizedBox(width: widget.size, height: widget.size,
  453. child: const Center(child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))));
  454. }
  455. if (_bytes != null) {
  456. return ClipRRect(
  457. borderRadius: BorderRadius.circular(4),
  458. child: Image.memory(_bytes!, width: widget.size, height: widget.size, fit: BoxFit.cover),
  459. );
  460. }
  461. return Icon(Icons.broken_image, size: widget.size * 0.6, color: Colors.grey);
  462. }
  463. }