ApprovalFlow.vue 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. <template>
  2. <view class="flow-container">
  3. <uv-empty v-if="loading" mode="list" text="加载中..." icon-size="80"></uv-empty>
  4. <uv-empty
  5. v-else-if="!list.length"
  6. mode="data"
  7. text="暂无审批记录"
  8. icon-size="80"
  9. ></uv-empty>
  10. <view v-else class="flow-timeline">
  11. <view
  12. v-for="(node, index) in list"
  13. :key="node.nodeId || index"
  14. class="node-wrapper"
  15. >
  16. <!-- 左侧时间轴 -->
  17. <view class="timeline-side">
  18. <view
  19. class="dot-outer"
  20. :class="{ 'is-current': currentNode === node.nodeId }"
  21. >
  22. <view class="dot-inner">
  23. <text class="idx">{{ index + 1 }}</text>
  24. </view>
  25. </view>
  26. <view
  27. v-if="index !== list.length - 1"
  28. class="line-path"
  29. ></view>
  30. </view>
  31. <!-- 右侧内容卡片 -->
  32. <view
  33. class="node-card"
  34. :class="{ 'active-node': currentNode === node.nodeId }"
  35. >
  36. <view class="node-header">
  37. <view class="title-section">
  38. <text class="node-title">{{ node.nodeTitle || '流程节点' }}</text>
  39. <view class="method-tag" v-if="node.type !== 'start' && node.nodeType !== 'start'">
  40. <uv-icon name="info-circle" size="22" color="#909399"></uv-icon>
  41. <text>{{ getMethodName(node) }}</text>
  42. </view>
  43. </view>
  44. </view>
  45. <!-- 审批人列表 -->
  46. <view class="approvers-area">
  47. <view
  48. v-for="(item, idx) in node.items"
  49. :key="idx"
  50. class="approver-box"
  51. >
  52. <view class="box-top">
  53. <view class="user-info">
  54. <view class="avatar-stub">{{ (item.userName || '?').substring(0, 1) }}</view>
  55. <text class="user-name">{{ item.userName || '--' }}</text>
  56. </view>
  57. <text class="status-badge" :class="getStatusClass(item)">
  58. {{ getStatusLabel(item) }}
  59. </text>
  60. </view>
  61. <view class="box-content">
  62. <view class="info-item" v-if="getDate(item)">
  63. <uv-icon name="clock" size="24" color="#C0C4CC"></uv-icon>
  64. <text class="info-val">{{ formatDate(getDate(item),'YYYY-MM-DD HH:mm') }}</text>
  65. </view>
  66. <view class="opinion-bubble" v-if="getOpinion(item)">
  67. <text class="opinion-text">{{ getOpinion(item) }}</text>
  68. </view>
  69. </view>
  70. </view>
  71. </view>
  72. </view>
  73. </view>
  74. </view>
  75. </view>
  76. </template>
  77. <script setup lang="ts">
  78. /**
  79. * 统一审批记录组件
  80. * 兼容两种数据结构(Execution 和 Flow API)
  81. */
  82. import { flowApprovalResultOptions, flowApprovalModelOptions } from '@/constants';
  83. import { formatDate } from '@/utils/date';
  84. const props = defineProps({
  85. list: { type: Array as () => any[], default: () => [] },
  86. currentNode: { type: String, default: '' },
  87. loading: { type: Boolean, default: false }
  88. });
  89. const getMethodName = (node: any) => {
  90. const model = node.actType || node.nodeModel;
  91. if (!model) return '--';
  92. const found = flowApprovalModelOptions.find((opt: any) => opt.dictValue === model);
  93. return found ? found.dictLabel : '--';
  94. };
  95. const getStatusLabel = (item: any) => {
  96. const val = item.approveResult || item.approvalResult;
  97. if (!val) return '处理中';
  98. const opt = flowApprovalResultOptions.find((i: any) => i.dictValue === val);
  99. return opt ? opt.dictLabel : val;
  100. };
  101. const getStatusClass = (item: any) => {
  102. const val = item.approveResult || item.approvalResult;
  103. if (!val) return 'is-primary';
  104. const opt = flowApprovalResultOptions.find(o => o.dictValue === val);
  105. return opt ? `is-${opt.type}` : 'is-primary';
  106. };
  107. const getDate = (item: any) => item.approveDate || item.approvalDate;
  108. const getOpinion = (item: any) => item.approveOpinion || item.approvalDesc;
  109. </script>
  110. <style lang="scss" scoped>
  111. .flow-container {
  112. padding: 20rpx 0;
  113. }
  114. .flow-timeline {
  115. display: flex;
  116. flex-direction: column;
  117. }
  118. .node-wrapper {
  119. display: flex;
  120. gap: 20rpx;
  121. }
  122. /* 时间轴样式 */
  123. .timeline-side {
  124. width: 60rpx;
  125. display: flex;
  126. flex-direction: column;
  127. align-items: center;
  128. flex-shrink: 0;
  129. .dot-outer {
  130. width: 44rpx;
  131. height: 44rpx;
  132. border-radius: 50%;
  133. background-color: #fff;
  134. border: 4rpx solid #E4E7ED;
  135. display: flex;
  136. align-items: center;
  137. justify-content: center;
  138. z-index: 2;
  139. transition: all 0.3s ease;
  140. &.is-current {
  141. border-color: #1c9bfd;
  142. box-shadow: 0 0 12rpx rgba(28, 155, 253, 0.4);
  143. .dot-inner { background-color: #1c9bfd; .idx { color: #fff; } }
  144. }
  145. }
  146. .dot-inner {
  147. width: 30rpx;
  148. height: 30rpx;
  149. border-radius: 50%;
  150. background-color: #F2F6FC;
  151. display: flex;
  152. align-items: center;
  153. justify-content: center;
  154. transition: all 0.3s ease;
  155. .idx {
  156. font-size: 18rpx;
  157. color: #909399;
  158. font-weight: bold;
  159. }
  160. }
  161. .line-path {
  162. width: 3rpx;
  163. flex: 1;
  164. background-color: #EBEEF5;
  165. margin: 10rpx 0;
  166. }
  167. }
  168. /* 节点卡片样式 */
  169. .node-card {
  170. flex: 1;
  171. background-color: #F8F9FA;
  172. border: 1rpx solid #EBEEF5;
  173. border-radius: 20rpx;
  174. padding: 28rpx;
  175. margin-bottom: 30rpx;
  176. box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.06);
  177. position: relative;
  178. transition: transform 0.2s ease;
  179. &.active-node {
  180. border: 1rpx solid rgba(28, 155, 253, 0.3);
  181. background: linear-gradient(180deg, #f0f7ff 0%, #ffffff 100%);
  182. box-shadow: 0 8rpx 24rpx rgba(28, 155, 253, 0.12);
  183. }
  184. .node-header {
  185. margin-bottom: 24rpx;
  186. .title-section {
  187. display: flex;
  188. align-items: center;
  189. justify-content: space-between;
  190. .node-title {
  191. font-size: 30rpx;
  192. font-weight: 600;
  193. color: #303133;
  194. }
  195. .method-tag {
  196. display: flex;
  197. align-items: center;
  198. gap: 6rpx;
  199. font-size: 22rpx;
  200. color: #909399;
  201. background-color: #F2F6FC;
  202. padding: 4rpx 14rpx;
  203. border-radius: 100rpx;
  204. }
  205. }
  206. }
  207. }
  208. /* 审批人样式 */
  209. .approvers-area {
  210. display: flex;
  211. flex-direction: column;
  212. gap: 20rpx;
  213. }
  214. .approver-box {
  215. background-color: #fff;
  216. border: 1rpx solid #ebeef5;
  217. border-radius: 16rpx;
  218. padding: 20rpx;
  219. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.02);
  220. .box-top {
  221. display: flex;
  222. justify-content: space-between;
  223. align-items: center;
  224. margin-bottom: 16rpx;
  225. .user-info {
  226. display: flex;
  227. align-items: center;
  228. gap: 16rpx;
  229. .avatar-stub {
  230. width: 48rpx;
  231. height: 48rpx;
  232. background: linear-gradient(135deg, #1c9bfd 0%, #15a982 100%);
  233. color: #fff;
  234. border-radius: 50%;
  235. display: flex;
  236. align-items: center;
  237. justify-content: center;
  238. font-size: 24rpx;
  239. font-weight: bold;
  240. }
  241. .user-name {
  242. font-size: 28rpx;
  243. font-weight: 500;
  244. color: #303133;
  245. }
  246. }
  247. }
  248. }
  249. /* 状态徽章 */
  250. .status-badge {
  251. font-size: 22rpx;
  252. padding: 4rpx 16rpx;
  253. border-radius: 100rpx;
  254. font-weight: 500;
  255. &.is-success { background-color: #f0f9eb; color: #67c23a; }
  256. &.is-error { background-color: #fef0f0; color: #f56c6c; }
  257. &.is-warning { background-color: #fdf6ec; color: #e6a23c; }
  258. &.is-primary { background-color: #ecf5ff; color: #409eff; }
  259. }
  260. .box-content {
  261. .info-item {
  262. display: flex;
  263. align-items: center;
  264. gap: 10rpx;
  265. margin-bottom: 12rpx;
  266. .info-val {
  267. font-size: 24rpx;
  268. color: #909399;
  269. }
  270. }
  271. .opinion-bubble {
  272. background-color: #F8F9FA;
  273. padding: 16rpx 20rpx;
  274. border-radius: 12rpx;
  275. position: relative;
  276. border: 1rpx solid #EBEEF5;
  277. .opinion-text {
  278. font-size: 26rpx;
  279. color: #606266;
  280. line-height: 1.6;
  281. }
  282. }
  283. }
  284. </style>