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