CMVR-IOT-UI/src/views/inspection/runTask/task-log.vue

470 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="task-log" :style="{ height: `${height}px` }">
<!-- 折叠状态只展示点位名称和执行状态,节点日志需要点击点位后才展开。 -->
<div class="task-log__heading">
<span>点位</span>
<span>状态</span>
</div>
<div v-if="loading" class="task-log__empty">点位加载中...</div>
<div v-else-if="!groups.length" class="task-log__empty">暂无点位</div>
<div v-else class="task-log__groups">
<section v-for="(group, index) in groups" :key="group.key" class="task-log__group">
<!-- 整行可点击;aria-expanded 同步展开状态,便于键盘和辅助技术识别。 -->
<button
type="button"
class="task-log__group-header"
:aria-expanded="isExpanded(group.key)"
@click="toggleGroup(group.key)"
>
<el-icon class="task-log__expand-icon" :class="{ 'is-expanded': isExpanded(group.key) }">
<ArrowRight />
</el-icon>
<span class="task-log__order">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="task-log__name" :title="group.fullName">{{ group.name }}</span>
<span class="task-log__statuses">
<el-tag :type="group.status.type" size="small">{{ group.status.label }}</el-tag>
</span>
</button>
<!-- 展开后只渲染当前点位对应的节点日志,避免不同点位的日志混在一起。 -->
<div v-if="isExpanded(group.key)" class="task-log__details">
<div class="task-log__details-heading">
<span>节点执行日志</span>
<span class="task-log__details-count">{{ group.logs.length }} 条</span>
</div>
<div v-if="group.nodeNames.length" class="task-log__nodes">
节点:{{ group.nodeNames.join('、') }}
</div>
<TaskExecutionLog
v-if="group.logs.length"
:list="group.logs"
:height="logHeight(group.logs)"
/>
<div v-else class="task-log__details-empty">该点位暂未产生节点日志</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { ArrowRight } from '@element-plus/icons-vue'
import { getWaypointByTaskId } from '@/api/inspection/task'
import TaskExecutionLog from '@/views/flow/components/TaskExecutionLog.vue'
/**
* taskId: 任务定义 ID。当父组件没有传入 points 时,用它查询任务所属的全部点位。
* taskStatus: 任务实例状态,用于在任务结束、失败或终止后推导点位的最终状态。
* list: 当前任务实例产生的日志;巡检日志接口中的 itemId 是点位的 detectItemId。
* points: 父组件已经查询到的点位列表,传入后优先使用,避免当前任务重复请求点位接口。
* height: 整个点位日志区域的高度,超出的点位和节点日志在区域内部滚动。
*/
const props = defineProps({
taskId: { type: [String, Number], default: '' },
taskStatus: { type: [String, Number], default: null },
list: { type: Array, default: () => [] },
points: { type: Array, default: () => [] },
height: { type: Number, default: 150 },
})
const taskPoints = ref([])
const loading = ref(false)
// 默认全部折叠,页面初始只显示点位和状态,不提前占用节点日志空间。
const expandedKeys = ref(new Set())
// 请求序号用于防止快速切换任务时,旧任务的点位响应覆盖新任务。
let requestId = 0
/**
* 后端 ID 可能以数字或字符串返回。统一转成字符串后再建立索引,避免 1 和 "1" 匹配失败。
* 空值不参与点位和日志归类,后续会进入“未关联点位”兜底分组,避免日志静默丢失。
*/
const valueOf = (value) => value === null || value === undefined || value === '' ? '' : String(value)
/**
* 点位的唯一归属键。业务约定点位 detectItemId 与点位 ID 一一对应,日志 itemId 保存的是
* detectItemId,因此日志归类必须使用这个字段,而不是直接拿日志 itemId 去匹配点位 id。
*/
const pointKeyOf = (point) => valueOf(point?.detectItemId)
// 巡检日志接口中的 itemId 就是点位的 detectItemId,这是唯一的主归类字段。
const logKeyOf = (log) => valueOf(log?.itemId)
// 点位名称兼容不同接口 DTO,正常情况下使用 waypointName。
const nameOf = (point) => point?.waypointName || point?.name || point?.pointName || '未命名点位'
/**
* 从日志中提取节点名称,用于展开区域的节点摘要。
* 兼容节点名称位于顶层、node 对象或 actualResult/extraInfo JSON 字段的情况。
*/
const parseObject = (value) => {
if (!value) return {}
if (typeof value === 'object') return value
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
const uniqueValues = (values) => [...new Set(values.map(valueOf).filter(Boolean))]
const nodeNameOf = (log) => {
const actualResult = parseObject(log?.actualResult)
const extraInfo = parseObject(log?.extraInfo)
return log?.nodeName
|| log?.nodeTitle
|| log?.node?.name
|| log?.node?.nodeName
|| actualResult.nodeName
|| extraInfo.nodeName
|| log?.nodeId
|| ''
}
const normalizedStatus = (value) => String(value ?? '').trim().toLowerCase()
/**
* 判断日志是否表示该点位已经执行完成。
* 普通节点完成只能说明流程继续推进,进度达到 100 或明确的成功状态才作为完成候选;
* 顺序执行任务中,如果后面的点位已经有日志,前面的点位也会由 statusOf 统一判定完成。
*/
const isPointCompletionLog = (log) => {
const statuses = [
log?.itemStatus,
log?.executionStatus,
log?.runStatus,
log?.resultStatus,
log?.state,
log?.status,
].map(normalizedStatus)
return statuses.some((status) => ['0', 'success', 'succeeded', 'completed', 'complete', 'finished'].includes(status))
|| Number(log?.progress) >= 100
|| log?.completed === true
|| log?.finished === true
}
/**
* 根据点位日志和任务实例状态计算点位状态。
* 优先级为:失败 > 终止 > 暂停 > 已完成 > 执行中;没有日志的点位固定为未执行。
* 对失败、终止和暂停任务,按顺序执行规则把最后一个产生日志的点位视为中断点。
*/
const statusOf = (logs, execution = {}) => {
if (!logs.length) return { label: '未执行', type: 'info' }
const statuses = logs.flatMap((log) => [
log?.itemStatus,
log?.executionStatus,
log?.runStatus,
log?.resultStatus,
log?.state,
log?.status,
].map(normalizedStatus))
if (statuses.some((status) => ['2', 'failed', 'failure', 'error', 'exception'].includes(status))) {
return { label: '执行失败', type: 'danger' }
}
if (statuses.some((status) => ['4', 'stopped', 'terminated', 'aborted'].includes(status))) {
return { label: '已终止', type: 'warning' }
}
if (statuses.some((status) => ['3', 'paused', 'pause'].includes(status))) {
return { label: '已暂停', type: 'warning' }
}
const taskStatus = execution.taskStatus === null || execution.taskStatus === undefined || execution.taskStatus === ''
? Number.NaN
: Number(execution.taskStatus)
// 任务整体完成时,所有实际产生过日志的点位都视为完成。
if (taskStatus === 0) return { label: '已完成', type: 'success' }
// 失败、终止或暂停时,最后一个有日志的点位是任务中断位置。
if (taskStatus === 2 || taskStatus === 4 || taskStatus === 3) {
if (execution.groupIndex < execution.lastLoggedIndex) {
return { label: '已完成', type: 'success' }
}
if (taskStatus === 2) return { label: '执行失败', type: 'danger' }
if (taskStatus === 4) return { label: '已终止', type: 'warning' }
return { label: '已暂停', type: 'warning' }
}
// 后续点位已经产生日志,说明当前点位已经执行完毕。
if (execution.groupIndex < execution.lastLoggedIndex) {
return { label: '已完成', type: 'success' }
}
if (logs.some(isPointCompletionLog)) {
return { label: '已完成', type: 'success' }
}
return { label: '执行中', type: 'primary' }
}
// 父组件传入点位时优先使用;历史日志弹窗没有现成点位时再使用 taskId 请求。
const taskPointsSource = computed(() => props.points.length ? props.points : taskPoints.value)
/**
* 将点位和日志合并成页面分组:
* 1. 先为每个点位创建空日志分组,保证没有执行过的点位也展示为未执行;
* 2. 使用 point.detectItemId 建立索引;
* 3. 使用 log.itemId 查找对应分组,确保一个点位的节点日志不会进入其他点位;
* 4. 最后计算节点摘要和点位执行状态。
*/
const groups = computed(() => {
const result = taskPointsSource.value.map((point, index) => {
const pointKey = pointKeyOf(point)
const name = nameOf(point)
return {
key: `point-${pointKey || index}`,
name,
fullName: name,
pointKey,
logs: [],
}
})
const keyToGroup = new Map()
result.forEach((group) => {
if (group.pointKey) keyToGroup.set(group.pointKey, group)
})
const unmatchedLogs = []
props.list.forEach((log) => {
const group = keyToGroup.get(logKeyOf(log))
if (group) group.logs.push(log)
else unmatchedLogs.push(log)
})
// 后端数据异常或历史日志字段不完整时保留日志,避免用户看不到已经产生的执行记录。
// if (unmatchedLogs.length) {
// result.push({
// key: 'unmatched',
// name: '未关联点位',
// fullName: '未关联点位',
// pointKey: '',
// logs: unmatchedLogs,
// })
// }
const lastLoggedIndex = result.reduce((lastIndex, group, index) => (
group.logs.length ? index : lastIndex
), -1)
return result.map((group, index) => ({
...group,
nodeNames: uniqueValues(group.logs.map(nodeNameOf)),
status: statusOf(group.logs, {
groupIndex: index,
lastLoggedIndex,
taskStatus: props.taskStatus,
}),
}))
})
const isExpanded = (key) => expandedKeys.value.has(key)
// 使用新 Set 替换旧值,确保 Vue 能可靠追踪集合变化并立即更新展开区域。
const toggleGroup = (key) => {
const next = new Set(expandedKeys.value)
if (next.has(key)) next.delete(key)
else next.add(key)
expandedKeys.value = next
}
// 节点日志较少时避免留下大块空白,日志较多时在点位详情内部滚动。
const logHeight = (logs) => Math.max(120, Math.min(320, 78 + logs.length * 58))
/**
* 根据任务 ID 查询点位列表。父组件传入 points 时不重复请求;请求版本号保证快速切换
* 历史任务时,旧任务响应不会覆盖当前任务的点位列表。
*/
const loadTaskPoints = async (taskId) => {
const currentRequestId = ++requestId
if (!taskId || props.points.length) {
taskPoints.value = []
loading.value = false
return
}
loading.value = true
try {
const res = await getWaypointByTaskId(taskId)
if (currentRequestId === requestId) {
taskPoints.value = Array.isArray(res?.data)
? res.data
: Array.isArray(res?.rows)
? res.rows
: []
}
} catch {
if (currentRequestId === requestId) taskPoints.value = []
} finally {
if (currentRequestId === requestId) loading.value = false
}
}
// taskId 或外部点位列表变化时重新加载数据;计算分组会自动响应日志 list 的实时更新。
watch(
() => [props.taskId, props.points.length],
([taskId]) => loadTaskPoints(taskId),
{ immediate: true },
)
// 切换任务后清理旧任务的展开项,避免新任务复用旧任务的展开状态。
watch(() => groups.value.map((group) => group.key), (keys) => {
expandedKeys.value = new Set([...expandedKeys.value].filter((key) => keys.includes(key)))
})
</script>
<style scoped lang="scss">
.task-log {
margin-top: 12px;
overflow-y: auto;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
}
.task-log__heading,
.task-log__group-header {
display: grid;
grid-template-columns: 22px 34px minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.task-log__heading {
padding: 10px 14px;
color: #98a2b3;
font-size: 12px;
border-bottom: 1px solid #f0f2f5;
}
.task-log__heading span:first-child {
grid-column: 3;
}
.task-log__heading span:last-child {
grid-column: 4;
}
.task-log__groups {
padding: 0 8px 8px;
}
.task-log__group {
border-bottom: 1px solid #f0f2f5;
}
.task-log__group:last-child {
border-bottom: 0;
}
.task-log__group-header {
width: 100%;
min-height: 48px;
padding: 8px 6px;
color: #344054;
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
}
.task-log__group-header:hover {
background: #f8fafc;
}
.task-log__expand-icon {
color: #98a2b3;
transition: transform 0.2s ease;
}
.task-log__expand-icon.is-expanded {
transform: rotate(90deg);
}
.task-log__order {
color: #98a2b3;
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.task-log__name {
min-width: 0;
overflow: hidden;
font-size: 14px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-log__statuses {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-self: end;
}
.task-log__details {
margin: 0 6px 10px 64px;
padding: 10px 12px 12px;
background: #f8fafc;
border-left: 2px solid #d0d5dd;
}
.task-log__details-heading {
display: flex;
align-items: center;
justify-content: space-between;
color: #475467;
font-size: 13px;
font-weight: 500;
}
.task-log__details-count,
.task-log__nodes {
color: #98a2b3;
font-size: 12px;
font-weight: 400;
}
.task-log__nodes {
margin-top: 6px;
line-height: 18px;
word-break: break-word;
}
.task-log__details :deep(.task-log) {
margin-top: 8px;
border-color: #eaecf0;
}
.task-log__empty,
.task-log__details-empty {
padding: 28px 12px;
color: #98a2b3;
font-size: 13px;
text-align: center;
}
.task-log__details-empty {
padding: 18px 0 6px;
}
@media (max-width: 640px) {
.task-log__details {
margin-left: 12px;
}
.task-log__heading,
.task-log__group-header {
grid-template-columns: 22px 26px minmax(0, 1fr) auto;
gap: 6px;
}
}
</style>