CMVR-IOT-UI/src/views/test/log/task-log.vue

642 lines
23 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>
<!-- 同一个检测项被任务重复编排时保留原名称,并用“第 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 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>
<!-- 展开后只把当前检测项已经归类的节点日志交给通用日志组件渲染。 -->
<div v-if="isExpanded(group.key)" class="task-log__details">
<div class="task-log__details-heading">
<span>执行过程</span>
</div>
<TaskExecutionLog
v-if="group.logs.length"
:list="group.logs"
:height="logHeight(group.logs)"
mode="user"
/>
<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 { getTaskConfig } from '@/api/test/config'
import { TaskExecutionLog } from '@cmvr/iot-common'
/**
* taskId: 任务定义 ID,用于查询该任务绑定的全部检测项。
* taskStatus: 当前任务实例状态,用于历史任务结束后判断最后一个有日志的检测项最终状态。
* 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)
/**
* paramsOut、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 {}
}
}
/**
* 合并两套日志接口的执行结果,供分组上下文和分析判定使用。
* UI 流程日志使用 paramsOut,actualResult/extraInfo 保留较高优先级以兼容历史数据。
* @param {Object} log 原始日志;不修改其字段,节点详情仍直接使用原始日志。
* @returns {Object} 解析后的结果对象;非法 JSON 按空对象处理。
*/
const outputOf = (log) => ({
...parseObject(log?.paramsOut),
...parseObject(log?.actualResult),
...parseObject(log?.extraInfo),
})
/** 标准化、过滤空值并去重,返回关联键数组,避免字段别名重复建立索引。 */
const uniqueValues = (values) => [...new Set(values.map(valueOf).filter(Boolean))]
/**
* 生成检测项所有可能的关联键。
* 编排接口使用 itemId;保留其他关联字段,兼容不同来源的检测项数据。
*/
const itemKeysOf = (item) => uniqueValues([
item?.detectItemId,
item?.itemId,
item?.testCaseId,
item?.caseId,
item?.id,
])
/**
* 生成日志所有可能的检测项关联键。
* 除日志顶层字段外,也读取合并后的执行结果,兼容节点执行器把上下文放进扩展字段的情况。
*/
const logKeysOf = (log) => {
const actualResult = outputOf(log)
const extraInfo = parseObject(log?.extraInfo)
// 新版执行器把重复检测项的归属信息写入执行结果的 _executionContext,旧版顶层字段仍继续兼容。
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,
])
}
/**
* 提取日志在任务编排中的位置。
* 顶层 itemOrder/itemOccurrence 优先,因为它们是日志接口的正式字段;缺失时再读取
* 执行结果中的 _executionContext,以兼容由节点执行结果携带上下文的新格式。
*/
const logExecutionContextOf = (log) => {
const actualResult = outputOf(log)
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()
/**
* 判断一条节点日志是否明确表示“当前检测项执行结束”。
* 只有结束节点(nodeType=END)的完成事件(eventType=NODE_COMPLETED)才算检测项完成,
* 普通业务节点完成只代表流程推进,不能提前把整个检测项标记为已完成。
*/
const isItemCompletionLog = (log) => {
const actualResult = outputOf(log)
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' }
}
// 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' }
}
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' }
}
/**
* 将分析结果中的布尔值规范为 true/false/null。
* 部分接口会返回字符串 "true"/"false";null 表示字段缺失或值无法识别,调用方应继续尝试其他结论字段。
*/
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
}
/**
* 从单条日志中提取分析判定:true=通过、false=不通过、null=这不是分析日志。
* 识别范围包含音视频 analysisType/profileCode、analysisStatus、passed、matched 和旧版 testStatus。
* 判定优先级为:分析执行失败 > passed > matched > testStatus,避免旧字段覆盖更明确的新字段。
*/
const analysisVerdictOf = (log) => {
const output = outputOf(log)
const testStatus = String(log?.testStatus ?? '')
const analysisType = String(output.analysisType || '').toUpperCase()
const profileCode = String(output.profileCode || '').toLowerCase()
// 通用图片分析只返回提示词要求的内容,不承担检测项通过/不通过判定。
if (analysisType.includes('IMAGE') || profileCode.includes('image')) return null
// 其余展示判定仍限定在音频/视频分析结果内,避免普通执行日志误入检测结论。
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' }
}
/** 优先返回外部检测项,否则使用任务编排查询结果,避免父组件重复查询。 */
const taskItemsSource = computed(() => props.items.length ? props.items : taskItems.value)
/**
* 将“检测项列表”和“任务实例日志”合并成页面需要的分组结构:
* 1. 先为每个检测项创建空分组,保证尚未执行的检测项也能显示;
* 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) => {
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 同时包含编排顺序、检测项 ID 和出现次数,重复检测项也能保持独立的展开状态。
key: `order-${index + 1}:${primaryKey}:${occurrence}`,
name,
fullName: repeatLabel ? `${name}(${repeatLabel})` : name,
itemKeys: itemKeysOf(item),
occurrence,
repeatLabel,
logs: [],
}
})
const keyToGroups = new Map()
// 一个检测项 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)
})
})
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
// 第二优先级:旧持久化日志没有 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)))
: 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 等兼容键查找候选分组;如果上下文带 itemOccurrence,
// 选择对应的第 N 个候选,否则退回第一个,兼容完全没有执行上下文的最早期日志。
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)
})
// 最后一个有日志的索引用于解释任务级失败/终止状态:顺序执行模型下,更早的分组已完成,最后一组是中断点。
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,
}),
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
}
/** 根据日志数量返回 120~400px 的详情高度,少量日志减少留白,大量日志内部滚动。 */
const logHeight = (logs) => Math.max(120, Math.min(400, 100 + logs.length * 60))
/**
* 根据任务定义 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
taskItems.value = []
expandedKeys.value = new Set()
try {
const res = await getTaskConfig({ 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>