lxl-dev #3

Merged
zhanghao merged 122 commits from lxl-dev into dev 2026-08-24 13:55:18 +08:00
6 changed files with 185 additions and 22 deletions
Showing only changes of commit 239065a7d3 - Show all commits

View File

@ -21,6 +21,8 @@ export const DEVICE_KIND_BY_ACTION = Object.freeze({
AGV_MOVE_TO_STATION: DEVICE_KIND.AGV,
ARM_MOVE_TO_POINT: DEVICE_KIND.ARM,
ARM_MOVE_TO_J: DEVICE_KIND.ARM,
// 机械臂使能/去使能虽然没有运动参数,仍必须绑定 ARM 设备,
// 这样运行前的资源校验和设备下拉筛选才能为节点分配正确的机械臂终端。
ARM_ENABLE: DEVICE_KIND.ARM,
ARM_DISABLE: DEVICE_KIND.ARM,
TOUCH: DEVICE_KIND.ARM,

View File

@ -1,10 +1,14 @@
<template>
<div ref="messageContainer" class="task-log" :style="{ height: `${height}px` }">
<!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 -->
<div v-for="(log, index) in list" :key="log.id || index" class="task-log__item">
<div class="task-log__time">{{ formatDate(log.logTime) }}</div>
<div class="task-log__body">
<div class="task-log__content">{{ log.logContent }}</div>
<!-- 只有 analysisOf 能确认是音频或视频分析日志时,才展示结构化分析结果。 -->
<div v-if="analysisOf(log)" class="task-log__analysis">
<!-- 标题行展示分析类型、执行状态、模型名称和耗时;缺失字段不会留下空占位。 -->
<div class="task-log__analysis-heading">
<el-tag :type="analysisOf(log).tagType" size="small">
{{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }}
@ -16,6 +20,8 @@
耗时:{{ analysisOf(log).timingMs }}ms
</span>
</div>
<!-- 常用结果采用四列摘要,便于快速比较;详细扩展字段放到下方 JSON 区域。 -->
<div class="task-log__analysis-grid">
<div v-if="analysisOf(log).label" class="task-log__analysis-field">
<span>识别结果</span>
@ -37,14 +43,20 @@
<div v-if="analysisOf(log).summary" class="task-log__analysis-summary">
{{ analysisOf(log).summary }}
</div>
<!-- 关键事件可能来自模型输出数组,analysisOf 已限制最多展示前 20 条。 -->
<div v-if="analysisOf(log).events.length" class="task-log__analysis-events">
<div class="task-log__analysis-label">关键事件</div>
<div v-for="(event, eventIndex) in analysisOf(log).events" :key="eventIndex" class="task-log__analysis-event">
{{ event }}
</div>
</div>
<!-- 未被结构化消费的字段以格式化 JSON 展示,保留排查模型输出的能力。 -->
<pre v-if="analysisOf(log).details" class="task-log__analysis-details">{{ analysisOf(log).details }}</pre>
</div>
<!-- 同一日志可同时携带多种媒体;mediaOf 负责从新旧字段提取并按“类型+URL”去重。 -->
<div v-if="mediaOf(log).length" class="task-log__media">
<template v-for="media in mediaOf(log)" :key="`${media.type}:${media.url}`">
<el-image
@ -55,6 +67,7 @@
preview-teleported
fit="cover"
/>
<!-- 视频和音频只预加载元数据,避免日志列表初次渲染时下载完整媒体文件。 -->
<video v-else-if="media.type === 'video'" class="task-log__video" :src="media.url" controls preload="metadata" />
<audio v-else class="task-log__audio" :src="media.url" controls preload="metadata" />
</template>
@ -68,14 +81,26 @@
import { nextTick, onMounted, ref, watch } from 'vue'
import { formatDate } from '@/utils/index'
/**
* list: 任务或流程节点产生的日志数组。组件只负责展示,不修改日志对象。
* height: 日志容器固定高度;内容超出后在容器内滚动,默认 150px。
*/
const props = defineProps({
list: { type: Array, default: () => [] },
height: { type: Number, default: 150 },
})
// 保存日志滚动容器 DOM,用于首次渲染和日志更新后滚动到最新一条。
const messageContainer = ref(null)
// 以日志对象本身作为弱引用键缓存媒体解析结果;日志对象回收后缓存会自动释放,不产生长期内存占用。
const mediaCache = new WeakMap()
/**
* 将扩展结果安全转换为普通对象。
* 后端历史数据可能返回 JSON 字符串,新版接口可能直接返回对象;空值、基础类型或非法 JSON 均降级为空对象,
* 避免一条格式异常的日志阻断整个列表渲染。
*/
const parseObject = (value) => {
if (!value) return {}
if (typeof value === 'object') return value
@ -87,6 +112,11 @@ const parseObject = (value) => {
}
}
/**
* 把一个或多个媒体 URL 追加到结果数组。
* seen 使用“媒体类型:URL”作为去重键,因此同一 URL 以图片和视频两种类型出现时仍会分别保留;
* 非字符串、空白字符串和已经添加过的条目会被忽略。
*/
const addMedia = (result, seen, type, value) => {
const values = Array.isArray(value) ? value : [value]
values.forEach((item) => {
@ -99,18 +129,31 @@ const addMedia = (result, seen, type, value) => {
})
}
// 分析结果的计算相对复杂,使用 WeakMap 保证模板多次调用 analysisOf(log) 时只解析一次。
const analysisCache = new WeakMap()
/**
* 把日志中的音视频分析结果转换成模板直接使用的展示模型。
* 返回 null 表示该日志不是可展示的音视频分析结果;否则返回状态、模型、置信度、结论和扩展详情等字段。
*/
const analysisOf = (log) => {
// 防御非对象输入,同时优先读取缓存,避免模板中多个字段重复做 JSON.parse 和格式化工作。
if (!log || typeof log !== 'object') return null
if (analysisCache.has(log)) return analysisCache.get(log)
// extraInfo 后展开,因此同名字段会覆盖 actualResult;这是为了让后续补充/修正的信息优先显示。
const output = {
...parseObject(log.actualResult),
...parseObject(log.extraInfo),
}
// 新日志通过 analysisType/profileCode 标识媒体类型,logType=3/4 用于兼容旧版视频/音频日志。
const analysisType = String(output.analysisType || '').toUpperCase()
const profileCode = String(output.profileCode || '').toLowerCase()
const isAudio = analysisType.includes('AUDIO') || profileCode.includes('audio') || Number(log.logType) === 4
const isVideo = analysisType.includes('VIDEO') || profileCode.includes('video') || Number(log.logType) === 3
// 仅有媒体类型还不足以展示分析面板,至少要存在一个分析状态或业务结果字段。
const hasResult = Boolean(
output.analysisStatus
|| output.analysisType
@ -121,6 +164,8 @@ const analysisOf = (log) => {
|| output.conclusion
|| output.passed !== undefined
)
// 非分析日志也缓存 null,避免之后每次渲染都重复执行同一套识别逻辑。
if (!hasResult || (!isAudio && !isVideo)) {
analysisCache.set(log, null)
return null
@ -130,25 +175,41 @@ const analysisOf = (log) => {
const matched = output.matched
const passed = output.passed
const confidenceValue = Number(output.confidence)
// 置信度兼容 0~1 和 0~100 两种协议,统一限制到 0~100 并保留一位小数。
// 非数字值返回 null,模板据此隐藏置信度字段,而不是展示 NaN。
const confidence = Number.isFinite(confidenceValue)
? Math.max(0, Math.min(100, confidenceValue <= 1 ? confidenceValue * 100 : confidenceValue)).toFixed(1)
: null
// 常见成功/失败枚举转换为中文;未知状态保留原值,完全缺失时使用中性的“已返回结果”。
const statusLabel = status === 'SUCCEEDED' || status === 'SUCCESS'
|| status === 'COMPLETED'
? '分析完成'
: status === 'FAILED' || status === 'ERROR'
? '分析失败'
: status || '已返回结果'
// 复制一份原始输出用于生成扩展详情,后续删除操作不会修改日志本身或合并后的 output。
const detailSource = { ...output }
// _executionContext 只用于前端把日志归属到任务中的检测项和执行次数,属于内部路由元数据。
// 它与已经结构化展示的分析字段一起从“详情”中移除,避免用户看到重复且难以理解的底层上下文。
;[
'analysisStatus', 'analysisType', 'profileCode', 'analysisModel', 'analysisTimingMs',
'mediaUrl', 'audioUrl', 'videoUrl', 'imageUrl', 'label', 'labelName', 'matched',
'confidence', 'passed', 'summary', 'conclusion', 'events', '_executionContext',
].forEach((key) => delete detailSource[key])
// 所有已知字段删除后还有剩余内容时,才生成详情 JSON,避免显示空对象。
const hasDetails = Object.keys(detailSource).length > 0
// 统一生成展示模型,模板不需要了解各版本日志字段和状态枚举的差异。
const result = {
title: isAudio ? '声音分析' : '视频分析',
statusLabel,
// 分析失败或业务判断未通过使用红色;仅匹配失败使用警告色,其余使用成功色。
tagType: status === 'FAILED' || status === 'ERROR' || passed === false
? 'danger'
: matched === false ? 'warning' : 'success',
@ -161,56 +222,83 @@ const analysisOf = (log) => {
confidence,
passedLabel: passed === true ? '通过' : passed === false ? '未通过' : '',
summary: output.summary || output.conclusion || '',
// 限制最多 20 条关键事件,防止模型返回超大数组导致单条日志撑满页面。
// 对象事件转成紧凑 JSON,字符串事件保持原样。
events: Array.isArray(output.events)
? output.events.slice(0, 20).map((event) => typeof event === 'string' ? event : JSON.stringify(event))
: [],
details: hasDetails ? JSON.stringify(detailSource, null, 2) : '',
}
// 正常分析结果也写入缓存,后续模板读取状态、模型、摘要等字段时直接复用同一对象。
analysisCache.set(log, result)
return result
}
/**
* 从日志的新旧字段中提取所有可播放/预览的媒体资源。
* 返回值格式统一为 { type, url },模板无需关心 URL 原来位于 actualResult、extraInfo 还是日志顶层。
*/
const mediaOf = (log) => {
if (!log || typeof log !== 'object') return []
if (mediaCache.has(log)) return mediaCache.get(log)
const result = []
const seen = new Set()
// 与分析结果相同,extraInfo 中的同名字段优先于 actualResult。
const output = {
...parseObject(log.actualResult),
...parseObject(log.extraInfo),
}
// 优先读取类型明确的新字段,它们不需要额外推断媒体类型。
addMedia(result, seen, 'image', output.imageUrl)
addMedia(result, seen, 'video', output.videoUrl)
addMedia(result, seen, 'audio', output.audioUrl)
const analysisType = String(output.analysisType || '').toUpperCase()
const profileCode = String(output.profileCode || '').toLowerCase()
// mediaUrl 本身没有类型,必须结合分析类型或 profileCode 后才能作为视频/音频加入。
if (analysisType.includes('VIDEO') || profileCode.includes('video')) addMedia(result, seen, 'video', output.mediaUrl)
if (analysisType.includes('AUDIO') || profileCode.includes('audio')) addMedia(result, seen, 'audio', output.mediaUrl)
addMedia(result, seen, 'image', log.screenshotUrl)
addMedia(result, seen, 'video', log.videoUrl)
// 旧版日志把媒体统一放在顶层 mediaUrl,并通过 logType=2/3/4 区分图片、视频和音频。
const legacyType = Number(log.logType) === 2 ? 'image'
: Number(log.logType) === 3 ? 'video'
: Number(log.logType) === 4 ? 'audio' : ''
if (legacyType) addMedia(result, seen, legacyType, log.mediaUrl)
// 即使没有找到媒体也缓存空数组,避免无媒体日志在每次渲染时重复扫描全部字段。
mediaCache.set(log, result)
return result
}
// Element Plus 图片预览需要同一条日志下的纯图片 URL 数组,视频和音频不会进入预览列表。
const imageUrlsOf = (log) => mediaOf(log)
.filter((item) => item.type === 'image')
.map((item) => item.url)
/**
* 将滚动位置移动到容器底部,让用户默认看到最新日志。
* nextTick 等待 Vue 完成 DOM 更新,否则新日志节点尚未插入时 scrollHeight 仍是旧值。
*/
const scrollToBottom = () => {
nextTick(() => {
if (messageContainer.value) messageContainer.value.scrollTop = messageContainer.value.scrollHeight
})
}
// deep 监听既覆盖追加/删除日志,也覆盖现有日志对象字段更新;组件挂载时同样执行一次初始定位。
watch(() => props.list, scrollToBottom, { deep: true })
onMounted(scrollToBottom)
</script>
<style scoped lang="scss">
/* 外层容器固定高度并独立滚动,避免持续增长的日志改变父页面布局。 */
.task-log {
margin-top: 12px;
padding: 12px;
@ -220,6 +308,7 @@ onMounted(scrollToBottom)
border-radius: 8px;
}
/* 桌面端使用“时间 + 内容”两列布局;内容列 minmax(0, 1fr) 允许长文本正确收缩。 */
.task-log__item {
display: grid;
grid-template-columns: 145px minmax(0, 1fr);
@ -244,6 +333,7 @@ onMounted(scrollToBottom)
line-height: 20px;
}
/* 分析结果使用左侧强调线与普通日志正文区分,但保持在同一条日志的内容列中。 */
.task-log__analysis {
margin-top: 9px;
padding: 10px 12px;
@ -266,6 +356,7 @@ onMounted(scrollToBottom)
font-size: 12px;
}
/* 四列摘要分别承载识别结果、匹配状态、置信度和判断结果。 */
.task-log__analysis-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@ -305,6 +396,7 @@ onMounted(scrollToBottom)
line-height: 18px;
}
/* 原始扩展详情限制最大高度并允许内部滚动,长 JSON 不会无限撑高单条日志。 */
.task-log__analysis-details {
max-height: 180px;
margin: 9px 0 0;
@ -318,6 +410,7 @@ onMounted(scrollToBottom)
word-break: break-word;
}
/* 媒体资源允许自动换行;每种播放器使用稳定尺寸,资源加载后不会造成明显布局跳动。 */
.task-log__media {
display: flex;
flex-wrap: wrap;
@ -343,6 +436,7 @@ onMounted(scrollToBottom)
height: 40px;
}
/* 窄屏改为上下布局,并把分析摘要缩减为两列,保证字段名称和结果仍有可读宽度。 */
@media (max-width: 640px) {
.task-log__item {
grid-template-columns: 1fr;

View File

@ -389,6 +389,8 @@ function handler(params) {
],
outputType: 'json'
},
// 使能状态由两个独立节点显式控制。节点本身不接收参数,也不依赖前一运动节点的隐式使能,
// 因而可在流程开始、异常恢复或任务收尾位置按业务需要自由编排。
{
icon: videoSvg,
name: "开启机械臂使能",
@ -400,6 +402,8 @@ function handler(params) {
outputParams: [],
outputType: 'json'
},
// 关闭节点与开启节点使用不同 action,边缘执行器据此下发去使能指令;
// 空 outputParams 表示该节点只负责设备状态切换,不向后续节点暴露业务输出。
{
icon: videoSvg,
name: "关闭机械臂使能",

View File

@ -506,6 +506,8 @@ const subscribeDebounced = debounce(async (sub) => {
const callback = (data) => {
if (data !== '500') {
// WebSocket 可能把状态作为字符串发送,先转成数字再判断,避免 '0' !== 0 导致终态被误当成运行日志。
// 0=完成、2=失败、4=终止,这三种状态都会结束当前实例,不再追加实时日志和进度。
const status = Number(data.status)
const terminal = [0, 2, 4].includes(status)
if (!terminal) {
@ -527,6 +529,7 @@ const subscribeDebounced = debounce(async (sub) => {
...data
})
} else {
// 终态到达后清理内存中的实时快照,再从接口刷新当前队列与历史列表,保证页面以持久化结果为准。
delete taskRunningInfo.value[data.taskInstanceId]
delete taskLogList.value[data.taskInstanceId]
getList()

View File

@ -66,7 +66,10 @@
:activeStep="taskRunningInfo[runTask.id]?.activeStep || 0" />
<div class="run-log-container">
<div>执行日志</div>
<!-- 传入任务定义 ID,task-log 会据此查询检测项并按检测项归类当前实例日志。 -->
<!--
taskId 用于查询检测项,taskStatus 用于在完成/失败/终止后推导最后一个已执行检测项的状态;
两者与实例日志分开传入,避免把任务级字段重复写进每一条日志。
-->
<taskLog :height="460" :task-id="runTask.taskId" :task-status="runTask.status"
:list="taskLogList[runTask.id]" />
</div>
@ -156,7 +159,10 @@
</el-dialog>
<el-dialog title="任务执行日志" v-model="logDialogVisible" width="800px" append-to-body>
<!-- 历史弹窗也传入对应任务定义 ID,确保没有实时进度数据时仍能展示完整检测项列表。 -->
<!--
历史弹窗没有 WebSocket 实时上下文,因此同时传入任务定义 ID 和历史终态;
日志组件据此补齐未执行项,并正确区分最后一项是完成、失败还是终止。
-->
<taskLog v-loading="logDialogLoading" :task-id="logDialogTaskId" :task-status="logDialogTaskStatus"
:list="logDialogData" :height="500" />
</el-dialog>
@ -552,6 +558,8 @@ const subscribeDebounced = debounce(async (sub) => {
const callback = (data) => {
if (data !== '500') {
// 统一处理数字和字符串状态。0=完成、2=失败、4=终止,均属于任务实例终态。
// 终态消息只触发列表刷新,不作为普通节点日志追加,否则会产生一个无法归属检测项的伪日志。
const status = Number(data.status)
const terminal = [0, 2, 4].includes(status)
if (!terminal) {
@ -573,6 +581,7 @@ const subscribeDebounced = debounce(async (sub) => {
...data
})
} else {
// 清除实时缓存后重新查询任务队列和历史记录,让最终状态与后端持久化结果保持一致。
delete taskRunningInfo.value[data.taskInstanceId]
delete taskLogList.value[data.taskInstanceId]
getList()
@ -599,6 +608,8 @@ const logDialogData = ref([])
// 历史表格行里的 taskId 是任务定义 ID;日志组件使用它查询本次任务包含的全部检测项。
const logDialogTaskId = ref('')
// 保存当前历史实例的终态,供日志组件判断最后一个产生过日志的检测项是完成、失败还是被终止。
const logDialogTaskStatus = ref(null)
const logDialogLoading = ref(false)
let logDialogRequestId = 0
@ -608,6 +619,8 @@ const handleOpenLog = async (row) => {
// 在请求实例日志前先更新 taskId,让检测项请求与日志请求并行执行,减少弹窗等待时间。
logDialogTaskId.value = row.taskId
// 状态与 taskId 同步切换,防止连续打开不同历史任务时短暂沿用上一个实例的状态。
logDialogTaskStatus.value = row.status
logDialogData.value = []
logDialogLoading.value = true

View File

@ -21,15 +21,17 @@
<ArrowRight />
</el-icon>
<span class="task-log__order">{{ String(index + 1).padStart(2, '0') }}</span>
<!-- 同一个检测项被任务重复编排时保留原名称,并用“第 N 次”明确区分每个执行分组。 -->
<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>
<el-tag :type="group.status.type" size="small">{{ group.status.label }}</el-tag>
</span>
</button>
@ -62,6 +64,7 @@ import TaskExecutionLog from '@/views/flow/components/TaskExecutionLog.vue'
/**
* taskId: 任务定义 ID,用于查询该任务绑定的全部检测项。
* taskStatus: 当前任务实例状态,用于历史任务结束后判断最后一个有日志的检测项最终状态。
* list: 当前任务实例产生的日志,检测项加载完成后会按 itemId/itemOrder 分组。
* items: 可选的外部检测项数据;传入后优先使用,可避免父组件已经查询时重复请求。
* height: 整个检测项日志区域的固定高度,内部超出部分滚动显示。
@ -127,6 +130,8 @@ const itemKeysOf = (item) => uniqueValues([
const logKeysOf = (log) => {
const actualResult = parseObject(log?.actualResult)
const extraInfo = parseObject(log?.extraInfo)
// 新版执行器把重复检测项的归属信息写入 actualResult._executionContext,旧版顶层字段仍继续兼容。
const executionContext = parseObject(actualResult?._executionContext)
return uniqueValues([
log?.itemId,
@ -144,6 +149,11 @@ const logKeysOf = (log) => {
])
}
/**
* 提取日志在任务编排中的位置。
* 顶层 itemOrder/itemOccurrence 优先,因为它们是日志接口的正式字段;缺失时再读取
* actualResult._executionContext,以兼容由节点执行结果携带上下文的新格式。
*/
const logExecutionContextOf = (log) => {
const actualResult = parseObject(log?.actualResult)
const context = parseObject(actualResult?._executionContext)
@ -176,6 +186,11 @@ const nodeNameOf = (log) => {
// 状态统一转为小写字符串,使数字枚举和英文状态值可以使用同一套判断逻辑。
const normalizedStatus = (value) => String(value ?? '').trim().toLowerCase()
/**
* 判断一条节点日志是否明确表示“当前检测项执行结束”。
* 只有结束节点(nodeType=END)的完成事件(eventType=NODE_COMPLETED)才算检测项完成,
* 普通业务节点完成只代表流程推进,不能提前把整个检测项标记为已完成。
*/
const isItemCompletionLog = (log) => {
const actualResult = parseObject(log?.actualResult)
const context = parseObject(actualResult?._executionContext)
@ -186,7 +201,8 @@ const isItemCompletionLog = (log) => {
/**
* 根据一个检测项下的全部日志推导检测项状态。
* 优先级为:失败 > 终止 > 暂停 > 完成 > 执行中;没有日志时固定为“未执行”。
* 完成状态同时识别状态字段、progress=100 以及 completed/finished 布尔标记。
* 对已经结束的任务,还会结合任务终态和“最后一个有日志的检测项”判断失败/终止落在哪一项;
* 完成状态最后再兼容状态字段、结束节点事件、progress=100 以及 completed/finished 布尔标记。
*/
const statusOf = (logs, execution = {}) => {
if (!logs.length) return { label: '未执行', type: 'info' }
@ -209,20 +225,30 @@ const statusOf = (logs, execution = {}) => {
if (statuses.some((status) => ['3', 'paused', 'pause'].includes(status))) {
return { label: '已暂停', type: 'warning' }
}
// taskStatus 为空时用 NaN 表示“无任务级结论”,避免 Number(null) 被错误转换为完成状态 0。
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' }
}
@ -238,6 +264,10 @@ const statusOf = (logs, execution = {}) => {
return { label: '执行中', type: 'primary' }
}
/**
* 将分析结果中的布尔值规范为 true/false/null。
* 部分接口会返回字符串 "true"/"false";null 表示字段缺失或值无法识别,调用方应继续尝试其他结论字段。
*/
const booleanValue = (value) => {
if (value === true || value === false) return value
if (typeof value === 'string') {
@ -247,6 +277,11 @@ const booleanValue = (value) => {
return null
}
/**
* 从单条日志中提取分析判定:true=通过、false=不通过、null=这不是分析日志。
* 识别范围包含音视频 analysisType/profileCode、analysisStatus、passed、matched 和旧版 testStatus。
* 判定优先级为:分析执行失败 > passed > matched > testStatus,避免旧字段覆盖更明确的新字段。
*/
const analysisVerdictOf = (log) => {
const output = {
...parseObject(log?.actualResult),
@ -273,6 +308,10 @@ const analysisVerdictOf = (log) => {
return false
}
/**
* 汇总一个检测项下的全部分析日志。
* 没有分析日志时不展示结论标签;只要任意一次分析不通过,检测项结论就是“不通过”。
*/
const analysisStatusOf = (logs) => {
const verdicts = logs.map(analysisVerdictOf).filter((value) => value !== null)
if (!verdicts.length) return null
@ -287,10 +326,12 @@ const taskItemsSource = computed(() => props.items.length ? props.items : taskIt
/**
* 将“检测项列表”和“任务实例日志”合并成页面需要的分组结构:
* 1. 先为每个检测项创建空分组,保证尚未执行的检测项也能显示;
* 2. 建立检测项 ID 到分组的索引,再把每条日志放入对应分组;
* 3. 最后计算节点名称集合和检测项状态。
* 2. 对重复编排的同一检测项记录出现次数,并建立一个 ID 对应多个候选分组的索引;
* 3. 按 itemOrder、历史 progress、itemOccurrence/ID 的优先级把每条日志放入对应分组;
* 4. 最后计算节点名称、流程执行状态和分析结论。
*/
const groups = computed(() => {
// occurrenceByItemKey 记录当前遍历到第几次,totalByItemKey 用于判断是否需要显示“第 N 次”。
const occurrenceByItemKey = new Map()
const totalByItemKey = new Map()
taskItemsSource.value.forEach((item) => {
@ -307,6 +348,7 @@ const groups = computed(() => {
const repeatLabel = repeated ? `第 ${occurrence} 次` : ''
const name = nameOf(item)
return {
// key 同时包含编排顺序、检测项 ID 和出现次数,重复检测项也能保持独立的展开状态。
key: `order-${index + 1}:${primaryKey}:${occurrence}`,
name,
fullName: repeatLabel ? `${name}(${repeatLabel})` : name,
@ -318,7 +360,7 @@ const groups = computed(() => {
})
const keyToGroups = new Map()
// 一个检测项可能同时存在 detectItemId、testCaseId 等多个 ID,全部映射到同一个分组。
// 一个检测项 ID 可能对应多个重复执行分组,因此索引值必须是候选数组,不能只保存第一个分组。
taskItemsSource.value.forEach((item, index) => {
const group = result[index]
itemKeysOf(item).forEach((key) => {
@ -333,11 +375,11 @@ const groups = computed(() => {
const order = Number(executionContext.itemOrder)
const occurrence = Number(executionContext.itemOccurrence)
// itemOrder 是任务内的 1 基序号,存在时可以准确处理同一检测项被重复编排的情况。
// 第一优先级: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.
// 第二优先级:旧持久化日志没有 itemOrder,但任务 progress 单调递增,可按检测项总数反推出所在区间。
// 反推后还要校验日志 ID 是否属于该分组,防止进度精度或边界值把日志分配给错误检测项。
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)))
@ -349,7 +391,8 @@ const groups = computed(() => {
? progressGroup
: null
// 没有有效序号时,再使用日志 itemId 与检测项 detectItemId 等字段进行匹配。
// 第三优先级:使用 itemId 等兼容键查找候选分组;如果上下文带 itemOccurrence,
// 选择对应的第 N 个候选,否则退回第一个,兼容完全没有执行上下文的最早期日志。
const matchedGroup = orderedGroup || compatibleProgressGroup || logKeys.map((key) => {
const candidates = keyToGroups.get(key) || []
return Number.isInteger(occurrence) && occurrence > 0
@ -360,22 +403,24 @@ const groups = computed(() => {
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,
})
}
// 无法关联的日志不能静默丢失,统一放入兜底分组,便于发现后端字段或历史数据问题。
// 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)),
@ -520,6 +565,7 @@ watch(() => groups.value.map((group) => group.key), (keys) => {
}
.task-log__repeat {
// 重复次数作为名称的辅助信息,视觉权重低于检测项主名称。
display: inline-block;
margin-left: 6px;
color: #409eff;
@ -528,6 +574,7 @@ watch(() => groups.value.map((group) => group.key), (keys) => {
}
.task-log__statuses {
// 状态区域允许换行,窄屏下两个标签不会挤压或遮挡检测项名称。
display: flex;
flex-wrap: wrap;
gap: 6px;