feat(log): 更新任务执行日志组件,优化日志展示和请求管理
This commit is contained in:
parent
137ee257bb
commit
1b744ff0c5
@ -7,8 +7,8 @@ VITE_APP_ENV = 'development'
|
|||||||
# 招商车研物联网平台/开发环境
|
# 招商车研物联网平台/开发环境
|
||||||
VITE_APP_BASE_API = '/dev-api'
|
VITE_APP_BASE_API = '/dev-api'
|
||||||
|
|
||||||
VITE_API_URL = http://192.168.0.199:14080
|
VITE_API_URL = http://192.168.0.222:14080
|
||||||
VITE_WS_URL = ws://192.168.0.199:14080/ws
|
VITE_WS_URL = ws://192.168.0.222:14080/ws
|
||||||
VITE_PORT = 5174
|
VITE_PORT = 5174
|
||||||
VITE_INSPECTION_TYPE = "inspection"
|
VITE_INSPECTION_TYPE = "inspection"
|
||||||
VITE_MINIO_URL = "http://192.168.28.10:9000/cmvr-iot/"
|
VITE_MINIO_URL = "http://192.168.28.10:9000/cmvr-iot/"
|
||||||
|
|||||||
@ -155,19 +155,25 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 添加或修改检测项日志对话框 -->
|
<!-- 与运行任务页保持一致,按检测项查看执行状态并展开节点日志。 -->
|
||||||
<el-drawer
|
<el-dialog
|
||||||
v-model="open"
|
v-model="open"
|
||||||
:destroy-on-close="true"
|
:destroy-on-close="true"
|
||||||
:size="580"
|
width="1000px"
|
||||||
:title="title"
|
title="任务执行日志"
|
||||||
append-to-body
|
append-to-body
|
||||||
modal-class="test-log"
|
@close="closeLogDialog"
|
||||||
@close="cancel"
|
|
||||||
@open="getLogList"
|
|
||||||
>
|
>
|
||||||
<TaskExecutionLog v-loading="logLoading" :list="logList" :height="560" />
|
<TaskLog
|
||||||
</el-drawer>
|
v-if="open"
|
||||||
|
:key="logQueryParams.instId"
|
||||||
|
v-loading="logLoading"
|
||||||
|
:task-id="logTaskId"
|
||||||
|
:task-status="logTaskStatus"
|
||||||
|
:list="logList"
|
||||||
|
:height="560"
|
||||||
|
/>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 添加或修改检测项日志对话框 -->
|
<!-- 添加或修改检测项日志对话框 -->
|
||||||
<el-drawer
|
<el-drawer
|
||||||
@ -218,7 +224,7 @@ import { appendParamsToPath } from "@/utils/fn.js";
|
|||||||
import { getTaskConfig } from "@/api/test/config.js";
|
import { getTaskConfig } from "@/api/test/config.js";
|
||||||
import { onMounted } from "vue";
|
import { onMounted } from "vue";
|
||||||
import { fetchAllPagedRows } from '@/utils/pagedRequest'
|
import { fetchAllPagedRows } from '@/utils/pagedRequest'
|
||||||
import { TaskExecutionLog } from '@cmvr/iot-common'
|
import TaskLog from './task-log.vue'
|
||||||
|
|
||||||
import { useContainerHeight } from "@/hooks/tableHeight";
|
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||||
|
|
||||||
@ -232,6 +238,10 @@ const instTableRef = ref();
|
|||||||
const selectedInstId = ref('');
|
const selectedInstId = ref('');
|
||||||
const rerunningId = ref('');
|
const rerunningId = ref('');
|
||||||
const logList = ref([]);
|
const logList = ref([]);
|
||||||
|
const logTaskId = ref('');
|
||||||
|
const logTaskStatus = ref(null);
|
||||||
|
// 请求序号覆盖弹窗关闭和实例切换,防止慢响应污染下一次打开的日志。
|
||||||
|
let logRequestId = 0;
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const openDashboard = ref(false);
|
const openDashboard = ref(false);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
@ -402,26 +412,47 @@ function handleExport() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询实例日志列表 */
|
/**
|
||||||
|
* 完整加载当前实例的分页日志,仅最新请求可以更新列表和加载状态。
|
||||||
|
* 关闭弹窗或切换实例会使请求失效;接口异常交由现有请求层提示。
|
||||||
|
* @returns {Promise<void>} 日志加载结束后完成,不返回业务数据。
|
||||||
|
*/
|
||||||
async function getLogList() {
|
async function getLogList() {
|
||||||
|
const requestId = ++logRequestId;
|
||||||
logLoading.value = true;
|
logLoading.value = true;
|
||||||
logList.value = [];
|
logList.value = [];
|
||||||
try {
|
try {
|
||||||
const response = await fetchAllPagedRows(getInst, {
|
const response = await fetchAllPagedRows(getInst, {
|
||||||
instId: logQueryParams.value.instId,
|
instId: logQueryParams.value.instId,
|
||||||
});
|
});
|
||||||
logList.value = response.rows;
|
if (requestId === logRequestId) {
|
||||||
logTotal.value = response.total;
|
logList.value = response.rows;
|
||||||
|
logTotal.value = response.total;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
logLoading.value = false;
|
if (requestId === logRequestId) logLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开实例日志时同步任务定义和实例状态,让未执行项与最终执行状态均能正确展示。
|
||||||
|
* @param {Object} row 当前实例行,id 用于查日志,taskId 用于查编排,status 用于推导终态。
|
||||||
|
* @returns {Promise<void>} 本次实例日志加载完成后结束。
|
||||||
|
*/
|
||||||
function handleOpenLogDialog(row) {
|
function handleOpenLogDialog(row) {
|
||||||
console.log(row)
|
|
||||||
console.log('open', open.value)
|
|
||||||
logQueryParams.value.instId = row.id;
|
logQueryParams.value.instId = row.id;
|
||||||
|
logTaskId.value = row.taskId;
|
||||||
|
logTaskStatus.value = row.status;
|
||||||
open.value = true;
|
open.value = true;
|
||||||
|
return getLogList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭日志时使未完成请求失效并清空数据,避免再次打开时闪现旧实例内容。 */
|
||||||
|
function closeLogDialog() {
|
||||||
|
logRequestId += 1;
|
||||||
|
logList.value = [];
|
||||||
|
logLoading.value = false;
|
||||||
|
reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentLog = ref({});
|
const currentLog = ref({});
|
||||||
|
|||||||
641
src/views/test/log/task-log.vue
Normal file
641
src/views/test/log/task-log.vue
Normal file
@ -0,0 +1,641 @@
|
|||||||
|
<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>
|
||||||
Loading…
Reference in New Issue
Block a user