|
|
@@ -82,91 +82,10 @@
|
|
|
<el-divider />
|
|
|
|
|
|
<div class="detail-body">
|
|
|
- <!-- 左侧:流程节点时间线 -->
|
|
|
+ <!-- 左侧:流程进度(支持分支可视化) -->
|
|
|
<div class="timeline-section">
|
|
|
<h4>流程进度</h4>
|
|
|
- <el-timeline>
|
|
|
- <el-timeline-item
|
|
|
- v-for="node in progress?.nodes"
|
|
|
- :key="node.nodeId"
|
|
|
- :type="timelineType(node.status)"
|
|
|
- :icon="timelineIcon(node.status)"
|
|
|
- :color="timelineColor(node.status)"
|
|
|
- >
|
|
|
- <div class="node-item" :class="{ 'current-node': node.status === 'current' }">
|
|
|
- <div class="node-title">
|
|
|
- <span class="node-name">{{ node.nodeName }}</span>
|
|
|
- <el-tag v-if="node.isMyTurn" type="danger" size="small" effect="dark">待我处理</el-tag>
|
|
|
- <el-button
|
|
|
- v-if="node.subNodes?.length"
|
|
|
- type="primary"
|
|
|
- size="small"
|
|
|
- link
|
|
|
- @click="toggleNodeExpand(node.nodeId)"
|
|
|
- >
|
|
|
- {{ isNodeExpanded(node.nodeId) ? '收起' : '展开' }} 子节点 ({{ node.subNodes.length }})
|
|
|
- </el-button>
|
|
|
- </div>
|
|
|
- <!-- 任务分配信息 -->
|
|
|
- <div v-if="node.tasks?.length && !node.subNodes?.length" class="node-tasks">
|
|
|
- <div
|
|
|
- v-for="task in node.tasks"
|
|
|
- :key="task.id"
|
|
|
- class="task-item"
|
|
|
- >
|
|
|
- <span class="task-assignee">{{ task.assigneeName || '用户' + task.assigneeId }}</span>
|
|
|
- <el-tag :type="taskStatusType(task.status)" size="small">
|
|
|
- {{ taskStatusText(task.status) }}
|
|
|
- </el-tag>
|
|
|
- <span v-if="task.comment" class="task-comment">{{ task.comment }}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <!-- 该节点的审批记录 -->
|
|
|
- <div v-if="getNodeRecords(node.nodeId).length && !node.subNodes?.length" class="node-records">
|
|
|
- <div
|
|
|
- v-for="record in getNodeRecords(node.nodeId)"
|
|
|
- :key="record.id"
|
|
|
- class="record-item"
|
|
|
- >
|
|
|
- <span class="record-operator">{{ record.operatorName || '用户' + record.operatorId }}</span>
|
|
|
- <el-tag :type="recordActionType(record.actionResult)" size="small">
|
|
|
- {{ recordActionText(record.actionResult) }}
|
|
|
- </el-tag>
|
|
|
- <span v-if="record.comment" class="record-comment">{{ record.comment }}</span>
|
|
|
- <span class="record-time">{{ record.createTime }}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <!-- 子节点列表 -->
|
|
|
- <div v-if="node.subNodes?.length && isNodeExpanded(node.nodeId)" class="sub-node-list">
|
|
|
- <div
|
|
|
- v-for="sub in node.subNodes"
|
|
|
- :key="sub.subNodeId"
|
|
|
- class="sub-node-item"
|
|
|
- :class="{ 'current-sub-node': sub.status === 'current' }"
|
|
|
- :ref="el => setSubNodeRef(node.nodeId, sub.subNodeId, el)"
|
|
|
- >
|
|
|
- <div class="sub-node-title">
|
|
|
- <span class="sub-node-name">{{ sub.subNodeName }}</span>
|
|
|
- <el-tag v-if="sub.isMyTurn" type="danger" size="small" effect="dark">待我处理</el-tag>
|
|
|
- </div>
|
|
|
- <div v-if="sub.tasks?.length" class="node-tasks">
|
|
|
- <div
|
|
|
- v-for="task in sub.tasks"
|
|
|
- :key="task.id"
|
|
|
- class="task-item"
|
|
|
- >
|
|
|
- <span class="task-assignee">{{ task.assigneeName || '用户' + task.assigneeId }}</span>
|
|
|
- <el-tag :type="taskStatusType(task.status)" size="small">
|
|
|
- {{ taskStatusText(task.status) }}
|
|
|
- </el-tag>
|
|
|
- <span v-if="task.comment" class="task-comment">{{ task.comment }}</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </el-timeline-item>
|
|
|
- </el-timeline>
|
|
|
+ <FlowTimeline :progress="progress" />
|
|
|
</div>
|
|
|
|
|
|
<!-- 右侧:审批操作区 -->
|
|
|
@@ -247,20 +166,21 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
-import { ref, reactive, computed, watch, nextTick } from 'vue'
|
|
|
+import { ref, reactive, computed, watch } from 'vue'
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
import { getProgress, getInstanceAttachments, updateInstanceFormData } from '@/api/flow/instance'
|
|
|
import { approveTask, rejectTask, returnTask, addSignTask, getTransferableUsers, listNextNodes } from '@/api/flow/task'
|
|
|
import type { NextNode } from '@/types/flow'
|
|
|
import { beforeFileUpload, getFileUrl, getFileName, parseAttachments, collectAttachmentUrls } from '@/utils/file'
|
|
|
import { useFileUpload } from '@/composables/useFileUpload'
|
|
|
-import { instanceStatusText, instanceStatusTagType, taskStatusText, taskStatusType, recordActionText, recordActionType } from '@/utils/flow'
|
|
|
+import { instanceStatusText, instanceStatusTagType } from '@/utils/flow'
|
|
|
import FilePreview from '@/components/FilePreview/index.vue'
|
|
|
import FormDataDisplay from '@/components/FormDataDisplay/index.vue'
|
|
|
import FlowFormFields from '@/components/FlowFormFields/index.vue'
|
|
|
import type { ProcessProgress, FlowTask, ApprovalAction, Attachment } from '@/types/flow'
|
|
|
import type { User } from '@/types/system'
|
|
|
-import { CircleCheck, Clock, Document } from '@element-plus/icons-vue'
|
|
|
+import { Document } from '@element-plus/icons-vue'
|
|
|
+import FlowTimeline from './FlowTimeline.vue'
|
|
|
|
|
|
const props = defineProps<{
|
|
|
modelValue: boolean
|
|
|
@@ -279,8 +199,6 @@ const visible = computed({
|
|
|
|
|
|
const loading = ref(false)
|
|
|
const progress = ref<ProcessProgress | null>(null)
|
|
|
-const expandedNodes = ref<Set<string>>(new Set())
|
|
|
-const subNodeRefs = ref<Record<string, Record<string, HTMLElement | null>>>({})
|
|
|
|
|
|
type ApprovalActionType = 'pass' | 'reject' | 'rollback'
|
|
|
|
|
|
@@ -374,29 +292,6 @@ const myPendingTask = computed<FlowTask | null>(() => {
|
|
|
return null
|
|
|
})
|
|
|
|
|
|
-function timelineType(status: string) {
|
|
|
- if (status === 'completed') return 'success'
|
|
|
- if (status === 'current') return 'warning'
|
|
|
- return 'info'
|
|
|
-}
|
|
|
-
|
|
|
-function timelineIcon(status: string) {
|
|
|
- if (status === 'completed') return CircleCheck
|
|
|
- if (status === 'current') return Clock
|
|
|
- return undefined
|
|
|
-}
|
|
|
-
|
|
|
-function getNodeRecords(nodeId: string) {
|
|
|
- if (!progress.value?.records) return []
|
|
|
- return progress.value.records.filter(r => r.nodeId === nodeId)
|
|
|
-}
|
|
|
-
|
|
|
-function timelineColor(status: string) {
|
|
|
- if (status === 'completed') return '#67C23A'
|
|
|
- if (status === 'current') return '#E6A23C'
|
|
|
- return '#909399'
|
|
|
-}
|
|
|
-
|
|
|
async function loadProgress() {
|
|
|
if (!props.instanceId) return
|
|
|
loading.value = true
|
|
|
@@ -404,7 +299,6 @@ async function loadProgress() {
|
|
|
// 静默加载进度,无权限时不在详情页弹全局错误提示
|
|
|
const res = await getProgress(props.instanceId, { silent: true })
|
|
|
progress.value = res
|
|
|
- autoExpandSubNodes()
|
|
|
} catch {
|
|
|
progress.value = null
|
|
|
} finally {
|
|
|
@@ -412,52 +306,6 @@ async function loadProgress() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function autoExpandSubNodes() {
|
|
|
- if (!progress.value?.nodes) return
|
|
|
- expandedNodes.value.clear()
|
|
|
- subNodeRefs.value = {}
|
|
|
- for (const node of progress.value.nodes) {
|
|
|
- if (!node.subNodes?.length) continue
|
|
|
- const hasCurrentSub = node.subNodes.some(s => s.status === 'current')
|
|
|
- if (node.status === 'current' || hasCurrentSub) {
|
|
|
- expandedNodes.value.add(node.nodeId)
|
|
|
- }
|
|
|
- }
|
|
|
- nextTick(() => scrollToCurrentSubNode())
|
|
|
-}
|
|
|
-
|
|
|
-function scrollToCurrentSubNode() {
|
|
|
- if (!progress.value?.nodes) return
|
|
|
- for (const node of progress.value.nodes) {
|
|
|
- if (!node.subNodes?.length || !isNodeExpanded(node.nodeId)) continue
|
|
|
- const currentSub = node.subNodes.find(s => s.status === 'current')
|
|
|
- if (currentSub) {
|
|
|
- const el = subNodeRefs.value[node.nodeId]?.[currentSub.subNodeId]
|
|
|
- el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
|
- return
|
|
|
- }
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-function setSubNodeRef(nodeId: string, subNodeId: string, el: any) {
|
|
|
- if (!subNodeRefs.value[nodeId]) {
|
|
|
- subNodeRefs.value[nodeId] = {}
|
|
|
- }
|
|
|
- subNodeRefs.value[nodeId][subNodeId] = el as HTMLElement | null
|
|
|
-}
|
|
|
-
|
|
|
-function toggleNodeExpand(nodeId: string) {
|
|
|
- if (expandedNodes.value.has(nodeId)) {
|
|
|
- expandedNodes.value.delete(nodeId)
|
|
|
- } else {
|
|
|
- expandedNodes.value.add(nodeId)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-function isNodeExpanded(nodeId: string): boolean {
|
|
|
- return expandedNodes.value.has(nodeId)
|
|
|
-}
|
|
|
-
|
|
|
async function submitApprove() {
|
|
|
const task = myPendingTask.value
|
|
|
if (!task) return
|
|
|
@@ -676,80 +524,6 @@ watch(() => props.modelValue, async (val) => {
|
|
|
font-size: 15px;
|
|
|
color: #303133;
|
|
|
}
|
|
|
-.node-item {
|
|
|
- padding: 4px 0;
|
|
|
-}
|
|
|
-.node-item.current-node {
|
|
|
- background: #fdf6ec;
|
|
|
- padding: 8px;
|
|
|
- border-radius: 4px;
|
|
|
-}
|
|
|
-.node-title {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 8px;
|
|
|
- margin-bottom: 6px;
|
|
|
-}
|
|
|
-.node-name {
|
|
|
- font-weight: bold;
|
|
|
- color: #303133;
|
|
|
-}
|
|
|
-.node-tasks {
|
|
|
- padding-left: 8px;
|
|
|
-}
|
|
|
-.task-item {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 8px;
|
|
|
- font-size: 13px;
|
|
|
- color: #606266;
|
|
|
- margin: 4px 0;
|
|
|
-}
|
|
|
-.task-comment {
|
|
|
- color: #909399;
|
|
|
-}
|
|
|
-.record-list {
|
|
|
- max-height: 300px;
|
|
|
- overflow-y: auto;
|
|
|
-}
|
|
|
-.node-records {
|
|
|
- margin-top: 8px;
|
|
|
- padding-left: 12px;
|
|
|
- border-left: 2px solid #e4e7ed;
|
|
|
-}
|
|
|
-.record-item {
|
|
|
- padding: 6px 0;
|
|
|
-}
|
|
|
-.record-item:last-child {
|
|
|
- border-bottom: none;
|
|
|
-}
|
|
|
-.record-header {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 10px;
|
|
|
- margin-bottom: 6px;
|
|
|
-}
|
|
|
-.record-operator {
|
|
|
- font-weight: bold;
|
|
|
- color: #303133;
|
|
|
-}
|
|
|
-.record-time {
|
|
|
- font-size: 12px;
|
|
|
- color: #909399;
|
|
|
-}
|
|
|
-.record-comment {
|
|
|
- font-size: 13px;
|
|
|
- color: #606266;
|
|
|
- background: #f5f7fa;
|
|
|
- padding: 6px 10px;
|
|
|
- border-radius: 4px;
|
|
|
- margin-bottom: 6px;
|
|
|
-}
|
|
|
-.record-attachments {
|
|
|
- display: flex;
|
|
|
- flex-wrap: wrap;
|
|
|
- gap: 8px;
|
|
|
-}
|
|
|
.section-title {
|
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
|
@@ -771,30 +545,4 @@ watch(() => props.modelValue, async (val) => {
|
|
|
color: #e6a23c;
|
|
|
line-height: 1.5;
|
|
|
}
|
|
|
-.sub-node-list {
|
|
|
- margin-top: 8px;
|
|
|
- padding-left: 12px;
|
|
|
- border-left: 2px solid #e4e7ed;
|
|
|
-}
|
|
|
-.sub-node-item {
|
|
|
- padding: 8px;
|
|
|
- margin: 8px 0;
|
|
|
- background: #f5f7fa;
|
|
|
- border-radius: 4px;
|
|
|
-}
|
|
|
-.sub-node-item.current-sub-node {
|
|
|
- background: #fdf6ec;
|
|
|
- border: 1px solid #f5dab1;
|
|
|
-}
|
|
|
-.sub-node-title {
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 8px;
|
|
|
- margin-bottom: 6px;
|
|
|
-}
|
|
|
-.sub-node-name {
|
|
|
- font-weight: bold;
|
|
|
- color: #303133;
|
|
|
- font-size: 13px;
|
|
|
-}
|
|
|
</style>
|