approval_timeline.dart 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import 'package:flutter/material.dart';
  2. import '../../core/i18n/app_localizations.dart';
  3. import '../../core/theme/app_colors.dart';
  4. import '../models/approval_status.dart';
  5. class ApprovalTimeline extends StatelessWidget {
  6. final List<ApprovalRecord> records;
  7. final List<String> chain;
  8. final String currentApproverId;
  9. const ApprovalTimeline({
  10. super.key,
  11. required this.records,
  12. required this.chain,
  13. this.currentApproverId = '',
  14. });
  15. @override
  16. Widget build(BuildContext context) {
  17. final l10n = AppLocalizations.of(context);
  18. return Column(
  19. crossAxisAlignment: CrossAxisAlignment.start,
  20. children: [
  21. Text(
  22. l10n.get('approvalProgress'),
  23. style: TextStyle(
  24. fontSize: 13,
  25. fontWeight: FontWeight.w600,
  26. color: AppColors.textPrimary,
  27. ),
  28. ),
  29. const SizedBox(height: 12),
  30. ...chain.asMap().entries.map((entry) {
  31. final idx = entry.key;
  32. final approverId = entry.value;
  33. final record = records
  34. .where((r) => r.approverId == approverId)
  35. .firstOrNull;
  36. final isCurrent = approverId == currentApproverId;
  37. return _buildNode(
  38. l10n,
  39. idx,
  40. chain.length,
  41. approverId,
  42. record,
  43. isCurrent,
  44. );
  45. }),
  46. ],
  47. );
  48. }
  49. Widget _buildNode(
  50. AppLocalizations l10n,
  51. int index,
  52. int total,
  53. String approverId,
  54. ApprovalRecord? record,
  55. bool isCurrent,
  56. ) {
  57. final isDone = record != null && record.action == 'approve';
  58. final isRejected = record != null && record.action == 'reject';
  59. final isLast = index == total - 1;
  60. return IntrinsicHeight(
  61. child: Row(
  62. crossAxisAlignment: CrossAxisAlignment.start,
  63. children: [
  64. Column(
  65. children: [
  66. Container(
  67. width: 22,
  68. height: 22,
  69. decoration: BoxDecoration(
  70. color: isRejected
  71. ? AppColors.danger
  72. : isDone
  73. ? AppColors.success
  74. : isCurrent
  75. ? AppColors.primary
  76. : const Color(0xFFDDDDDD),
  77. shape: BoxShape.circle,
  78. ),
  79. child: Center(
  80. child: isRejected
  81. ? const Icon(Icons.close, size: 12, color: Colors.white)
  82. : isDone
  83. ? const Icon(Icons.check, size: 12, color: Colors.white)
  84. : isCurrent
  85. ? const Text(
  86. '●',
  87. style: TextStyle(color: Colors.white, fontSize: 10),
  88. )
  89. : null,
  90. ),
  91. ),
  92. if (!isLast)
  93. Container(
  94. width: 1.5,
  95. height: 36,
  96. color: isDone ? AppColors.success : const Color(0xFFDDDDDD),
  97. ),
  98. ],
  99. ),
  100. const SizedBox(width: 10),
  101. Expanded(
  102. child: Padding(
  103. padding: EdgeInsets.only(bottom: isLast ? 0 : 12),
  104. child: Column(
  105. crossAxisAlignment: CrossAxisAlignment.start,
  106. children: [
  107. Text(
  108. record?.approverName ?? '审批人$approverId',
  109. style: TextStyle(
  110. fontSize: 13,
  111. fontWeight: FontWeight.w500,
  112. color: isRejected
  113. ? AppColors.danger
  114. : isCurrent
  115. ? AppColors.primary
  116. : AppColors.textPrimary,
  117. ),
  118. ),
  119. if (record != null && record.action != 'pending') ...[
  120. const SizedBox(height: 2),
  121. Text(
  122. '${record.action == 'approve' ? l10n.get('approved') : l10n.get('rejected')} · ${record.approvalTime.toString().substring(0, 16)}',
  123. style: const TextStyle(
  124. fontSize: 11,
  125. color: AppColors.textPlaceholder,
  126. ),
  127. ),
  128. if (record.opinion.isNotEmpty)
  129. Padding(
  130. padding: const EdgeInsets.only(top: 2),
  131. child: Text(
  132. '${l10n.get('opinion')}${record.opinion}',
  133. style: const TextStyle(
  134. fontSize: 11,
  135. color: AppColors.textSecondary,
  136. ),
  137. ),
  138. ),
  139. ] else if (isCurrent) ...[
  140. const SizedBox(height: 2),
  141. Text(
  142. l10n.get('currentNode'),
  143. style: TextStyle(fontSize: 11, color: AppColors.primary),
  144. ),
  145. ] else ...[
  146. const SizedBox(height: 2),
  147. Text(
  148. l10n.get('waitHandle'),
  149. style: TextStyle(
  150. fontSize: 11,
  151. color: AppColors.textPlaceholder,
  152. ),
  153. ),
  154. ],
  155. ],
  156. ),
  157. ),
  158. ),
  159. ],
  160. ),
  161. );
  162. }
  163. }