CMVR-IOT-UI/src/views/intelligenceTest/runningTask/task-log.vue
lixiaolong f944d33d58 feat(flow): 添加机械臂使能控制节点并优化任务执行日志显示
- 新增开启机械臂使能和关闭机械臂使能两个服务节点
- 在机器人设备工具类中注册ARM_ENABLE和ARM_DISABLE操作类型
- 优化运行任务页面中的状态判断逻辑,支持多种终端状态
- 为任务日志组件增加任务状态属性传递
- 增强日志组件中检测项状态推导功能,支持重复执行和分析状态显示
- 添加执行上下文解析以正确处理重复检测项的日志归类
- 改进日志详情过滤,排除执行上下文相关字段
- 实现检测项完成状态和分析结果的正确标识与展示
2026-08-18 11:19:52 +08:00

595 lines
19 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 v-if="group.repeatLabel" class="task-log__repeat">{{ group.repeatLabel }}</span>
</span>
<span class="task-log__statuses">
<el-tag :type="group.status.type" size="small">{{ group.status.label }}</el-tag>
<el-tag v-if="group.analysisStatus" :type="group.analysisStatus.type" effect="plain" size="small">
{{ group.analysisStatus.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 { getTestCaseByTaskId } from '@/api/intelligenceTest/taskManage'
import TaskExecutionLog from '@/views/flow/components/TaskExecutionLog.vue'
/**
* taskId: 任务定义 ID,用于查询该任务绑定的全部检测项。
* list: 当前任务实例产生的日志,检测项加载完成后会按 itemId/itemOrder 分组。
* items: 可选的外部检测项数据;传入后优先使用,可避免父组件已经查询时重复请求。
* height: 整个检测项日志区域的固定高度,内部超出部分滚动显示。
*/
const props = defineProps({
taskId: { type: [String, Number], default: '' },
taskStatus: { type: [String, Number], default: null },
list: { type: Array, default: () => [] },
items: { type: Array, default: () => [] },
height: { type: Number, default: 150 },
})
// taskItems 保存根据 taskId 查询到的检测项;items prop 存在时不会使用这里的数据。
const taskItems = ref([])
const loading = ref(false)
// 默认 Set 为空,因此所有检测项初始都是折叠状态,符合“默认只展示检测项和状态”的要求。
const expandedKeys = ref(new Set())
// 每次检测项请求递增版本号,只允许最后一次请求更新页面,避免快速切换任务时旧结果覆盖新任务。
let requestId = 0
/**
* 接口中的 ID 可能是数字或字符串。统一转为字符串后再比较,避免 1 与 "1" 无法匹配。
* null、undefined 和空字符串都视为无效 ID,不参与日志分组。
*/
const valueOf = (value) => value === null || value === undefined || value === '' ? '' : String(value)
/**
* 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 {}
}
}
// 标准化、过滤空值并去重,防止同一个 ID 的多个字段别名重复建立索引。
const uniqueValues = (values) => [...new Set(values.map(valueOf).filter(Boolean))]
/**
* 生成检测项所有可能的关联键。
* 当前业务主要使用 detectItemId,但保留 itemId/testCaseId/caseId/id,兼容历史数据和不同接口 DTO。
*/
const itemKeysOf = (item) => uniqueValues([
item?.detectItemId,
item?.itemId,
item?.testCaseId,
item?.caseId,
item?.id,
])
/**
* 生成日志所有可能的检测项关联键。
* 除日志顶层字段外,也读取 actualResult 和 extraInfo,兼容节点执行器把上下文放进扩展字段的情况。
*/
const logKeysOf = (log) => {
const actualResult = parseObject(log?.actualResult)
const extraInfo = parseObject(log?.extraInfo)
const executionContext = parseObject(actualResult?._executionContext)
return uniqueValues([
log?.itemId,
log?.detectItemId,
log?.testItemId,
log?.testCaseId,
log?.caseId,
log?.item?.id,
log?.item?.detectItemId,
actualResult.itemId,
actualResult.detectItemId,
executionContext.itemId,
extraInfo.itemId,
extraInfo.detectItemId,
])
}
const logExecutionContextOf = (log) => {
const actualResult = parseObject(log?.actualResult)
const context = parseObject(actualResult?._executionContext)
return {
itemOrder: log?.itemOrder ?? context.itemOrder,
itemOccurrence: log?.itemOccurrence ?? context.itemOccurrence,
}
}
// 检测项名称优先使用任务用例名称,其他字段作为不同后端版本的兼容回退。
const nameOf = (item) => item?.caseName || item?.detectName || item?.itemName || item?.name || '未命名检测项'
/**
* 提取执行节点名称,用于展开区域的节点汇总。
* 如果没有可读名称,最后使用 nodeId,至少能让用户定位到具体流程节点。
*/
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()
const isItemCompletionLog = (log) => {
const actualResult = parseObject(log?.actualResult)
const context = parseObject(actualResult?._executionContext)
return String(context.eventType || '').toUpperCase() === 'NODE_COMPLETED'
&& String(context.nodeType || '').toUpperCase() === 'END'
}
/**
* 根据一个检测项下的全部日志推导检测项状态。
* 优先级为:失败 > 终止 > 暂停 > 完成 > 执行中;没有日志时固定为“未执行”。
* 完成状态同时识别状态字段、progress=100 以及 completed/finished 布尔标记。
*/
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) {
return execution.groupIndex < execution.lastLoggedIndex
? { label: '已完成', type: 'success' }
: { label: '执行失败', type: 'danger' }
}
if (taskStatus === 4) {
return execution.groupIndex < execution.lastLoggedIndex
? { label: '已完成', type: 'success' }
: { label: '已终止', type: 'warning' }
}
if (execution.groupIndex < execution.lastLoggedIndex) {
return { label: '已完成', type: 'success' }
}
if (logs.some(isItemCompletionLog)) {
return { label: '已完成', type: 'success' }
}
if (
statuses.some((status) => ['0', 'success', 'succeeded', 'completed', 'complete', 'finished'].includes(status))
|| logs.some((log) => Number(log?.progress) >= 100 || log?.completed === true || log?.finished === true)
) {
return { label: '已完成', type: 'success' }
}
return { label: '执行中', type: 'primary' }
}
const booleanValue = (value) => {
if (value === true || value === false) return value
if (typeof value === 'string') {
if (value.toLowerCase() === 'true') return true
if (value.toLowerCase() === 'false') return false
}
return null
}
const analysisVerdictOf = (log) => {
const output = {
...parseObject(log?.actualResult),
...parseObject(log?.extraInfo),
}
const testStatus = String(log?.testStatus ?? '')
const analysisType = String(output.analysisType || '').toUpperCase()
const profileCode = String(output.profileCode || '').toLowerCase()
const hasAnalysis = Boolean(
analysisType.includes('AUDIO') || analysisType.includes('VIDEO')
|| profileCode.includes('audio') || profileCode.includes('video')
|| output.analysisStatus
|| output.passed !== undefined || output.matched !== undefined
|| ['1', '2'].includes(testStatus)
)
if (!hasAnalysis) return null
const analysisStatus = String(output.analysisStatus || '').toUpperCase()
if (['FAILED', 'ERROR'].includes(analysisStatus)) return false
const passed = booleanValue(output.passed)
if (passed !== null) return passed
const matched = booleanValue(output.matched)
if (matched !== null) return matched
if (testStatus === '1') return true
return false
}
const analysisStatusOf = (logs) => {
const verdicts = logs.map(analysisVerdictOf).filter((value) => value !== null)
if (!verdicts.length) return null
return verdicts.every(Boolean)
? { label: '通过', type: 'success' }
: { label: '不通过', type: 'danger' }
}
// 父组件明确传入 items 时直接使用,否则使用 taskId 请求到的检测项。
const taskItemsSource = computed(() => props.items.length ? props.items : taskItems.value)
/**
* 将“检测项列表”和“任务实例日志”合并成页面需要的分组结构:
* 1. 先为每个检测项创建空分组,保证尚未执行的检测项也能显示;
* 2. 建立检测项 ID 到分组的索引,再把每条日志放入对应分组;
* 3. 最后计算节点名称集合和检测项状态。
*/
const groups = computed(() => {
const occurrenceByItemKey = new Map()
const totalByItemKey = new Map()
taskItemsSource.value.forEach((item) => {
const primaryKey = itemKeysOf(item)[0]
if (primaryKey) totalByItemKey.set(primaryKey, (totalByItemKey.get(primaryKey) || 0) + 1)
})
// 每个检测项先建立空日志数组,所以初始状态能够由 statusOf 正确显示为“未执行”。
const result = taskItemsSource.value.map((item, index) => {
const primaryKey = itemKeysOf(item)[0] || `item-${index}`
const occurrence = (occurrenceByItemKey.get(primaryKey) || 0) + 1
occurrenceByItemKey.set(primaryKey, occurrence)
const repeated = (totalByItemKey.get(primaryKey) || 0) > 1
const repeatLabel = repeated ? `第 ${occurrence} 次` : ''
const name = nameOf(item)
return {
key: `order-${index + 1}:${primaryKey}:${occurrence}`,
name,
fullName: repeatLabel ? `${name}(${repeatLabel})` : name,
itemKeys: itemKeysOf(item),
occurrence,
repeatLabel,
logs: [],
}
})
const keyToGroups = new Map()
// 一个检测项可能同时存在 detectItemId、testCaseId 等多个 ID,全部映射到同一个分组。
taskItemsSource.value.forEach((item, index) => {
const group = result[index]
itemKeysOf(item).forEach((key) => {
if (!keyToGroups.has(key)) keyToGroups.set(key, [])
keyToGroups.get(key).push(group)
})
})
const unmatchedLogs = []
props.list.forEach((log) => {
const executionContext = logExecutionContextOf(log)
const order = Number(executionContext.itemOrder)
const occurrence = Number(executionContext.itemOccurrence)
// itemOrder 是任务内的 1 基序号,存在时可以准确处理同一检测项被重复编排的情况。
const orderedGroup = Number.isInteger(order) && order > 0 ? result[order - 1] : null
// Older persisted logs did not contain itemOrder. Their monotonically increasing
// progress still identifies the binding segment and keeps repeated items separate.
const progress = Number(log?.progress)
const progressOrder = Number.isFinite(progress) && progress > 0 && result.length
? Math.max(1, Math.min(result.length, Math.ceil(progress * result.length / 100)))
: 0
const progressGroup = progressOrder > 0 ? result[progressOrder - 1] : null
const logKeys = logKeysOf(log)
const compatibleProgressGroup = progressGroup
&& logKeys.some((key) => progressGroup.itemKeys.includes(key))
? progressGroup
: null
// 没有有效序号时,再使用日志 itemId 与检测项 detectItemId 等字段进行匹配。
const matchedGroup = orderedGroup || compatibleProgressGroup || logKeys.map((key) => {
const candidates = keyToGroups.get(key) || []
return Number.isInteger(occurrence) && occurrence > 0
? candidates[occurrence - 1]
: candidates[0]
}).find(Boolean)
if (matchedGroup) matchedGroup.logs.push(log)
else unmatchedLogs.push(log)
})
// 无法关联的日志不能静默丢失,统一放入兜底分组,便于发现后端字段或历史数据问题。
const lastLoggedIndex = result.reduce((lastIndex, group, index) => (
group.logs.length ? index : lastIndex
), -1)
if (unmatchedLogs.length) {
result.push({
key: 'unmatched',
name: '未关联检测项',
fullName: '未关联检测项',
itemKeys: [],
repeatLabel: '',
logs: unmatchedLogs,
})
}
return result.map((group, index) => ({
...group,
nodeNames: uniqueValues(group.logs.map(nodeNameOf)),
status: statusOf(group.logs, {
groupIndex: index,
lastLoggedIndex,
taskStatus: props.taskStatus,
}),
analysisStatus: analysisStatusOf(group.logs),
}))
})
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 查询其绑定的检测项。
* 当 taskId 为空或父组件已经传入 items 时不发请求;请求结束前再次切换任务时,
* 通过 currentRequestId 与全局 requestId 比较,忽略已经过期的响应和 loading 状态更新。
*/
const loadTaskItems = async (taskId) => {
const currentRequestId = ++requestId
if (!taskId || props.items.length) {
taskItems.value = []
loading.value = false
return
}
loading.value = true
try {
const res = await getTestCaseByTaskId(taskId)
if (currentRequestId === requestId) {
taskItems.value = Array.isArray(res?.data) ? res.data : Array.isArray(res?.rows) ? res.rows : []
}
} catch {
if (currentRequestId === requestId) taskItems.value = []
} finally {
if (currentRequestId === requestId) loading.value = false
}
}
// immediate 保证组件首次拿到 taskId 时立即查询,而不需要父组件额外触发初始化方法。
watch(() => props.taskId, loadTaskItems, { immediate: true })
// 检测项切换后清除已经不存在的展开 key,避免新任务意外继承旧任务的展开状态。
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__repeat {
display: inline-block;
margin-left: 6px;
color: #409eff;
font-size: 12px;
font-weight: 500;
}
.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>