expense_apply_detail_page.dart 18 KB

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