expense_apply_detail_page.dart 18 KB

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