lxl-dev #3

Merged
zhanghao merged 122 commits from lxl-dev into dev 2026-08-24 13:55:18 +08:00
8 changed files with 945 additions and 90 deletions
Showing only changes of commit 81ecb383e9 - 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).targetLabel" class="task-log__analysis-field">
<span>目标声音</span>
@ -41,14 +47,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
@ -59,6 +71,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>
@ -72,14 +85,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
@ -91,6 +116,11 @@ const parseObject = (value) => {
}
}
/**
* 把一个或多个媒体 URL 追加到结果数组。
* seen 使用“媒体类型:URL”作为去重键,因此同一 URL 以图片和视频两种类型出现时仍会分别保留;
* 非字符串、空白字符串和已经添加过的条目会被忽略。
*/
const addMedia = (result, seen, type, value) => {
const values = Array.isArray(value) ? value : [value]
values.forEach((item) => {
@ -103,18 +133,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
@ -125,6 +168,8 @@ const analysisOf = (log) => {
|| output.conclusion
|| output.passed !== undefined
)
// 非分析日志也缓存 null,避免之后每次渲染都重复执行同一套识别逻辑。
if (!hasResult || (!isAudio && !isVideo)) {
analysisCache.set(log, null)
return null
@ -134,26 +179,42 @@ 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',
'expectedLabel', 'expectedLabelName', 'decisionReason',
].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',
@ -173,51 +234,75 @@ const analysisOf = (log) => {
: [],
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;
@ -227,6 +312,7 @@ onMounted(scrollToBottom)
border-radius: 8px;
}
/* 桌面端使用“时间 + 内容”两列布局;内容列 minmax(0, 1fr) 允许长文本正确收缩。 */
.task-log__item {
display: grid;
grid-template-columns: 145px minmax(0, 1fr);
@ -251,6 +337,7 @@ onMounted(scrollToBottom)
line-height: 20px;
}
/* 分析结果使用左侧强调线与普通日志正文区分,但保持在同一条日志的内容列中。 */
.task-log__analysis {
margin-top: 9px;
padding: 10px 12px;
@ -273,6 +360,7 @@ onMounted(scrollToBottom)
font-size: 12px;
}
/* 四列摘要分别承载识别结果、匹配状态、置信度和判断结果。 */
.task-log__analysis-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@ -312,6 +400,7 @@ onMounted(scrollToBottom)
line-height: 18px;
}
/* 原始扩展详情限制最大高度并允许内部滚动,长 JSON 不会无限撑高单条日志。 */
.task-log__analysis-details {
max-height: 180px;
margin: 9px 0 0;
@ -325,6 +414,7 @@ onMounted(scrollToBottom)
word-break: break-word;
}
/* 媒体资源允许自动换行;每种播放器使用稳定尺寸,资源加载后不会造成明显布局跳动。 */
.task-log__media {
display: flex;
flex-wrap: wrap;
@ -350,6 +440,7 @@ onMounted(scrollToBottom)
height: 40px;
}
/* 窄屏改为上下布局,并把分析摘要缩减为两列,保证字段名称和结果仍有可读宽度。 */
@media (max-width: 640px) {
.task-log__item {
grid-template-columns: 1fr;

View File

@ -1,15 +1,22 @@
<!--
* 文件说明:通用媒体分析节点配置,支持音频分类和视频分析场景。
* 作用范围:仅服务于流程设计器模块。
* 文件说明:媒体分析节点的参数编辑器,同时服务于“声音类型检测”和“视频智能分析”节点。
* 数据流向:从 props.data.properties.nodeParams 回填表单,校验通过后再组装为标准 nodeParams
* 并写回 LogicFlow 节点属性;本组件不直接调用媒体分析接口。
* 作用范围:仅服务于流程设计器参数抽屉,由 ParamsDrawer 调用 validateAndSave 完成保存。
-->
<template>
<div class="media-analysis-params">
<!-- 标题和说明由节点 action 决定,同一组件在音频、视频节点下呈现不同业务语义。 -->
<div class="media-analysis-intro">
<div class="media-analysis-intro__title">{{ title }}</div>
<div class="media-analysis-intro__desc">{{ description }}</div>
</div>
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top">
<!--
profileCode 是后端选择分析配置和模型策略的场景编码。
allow-create 用于兼容后端临时新增、前端尚未内置的场景,不把可用值限制在下拉选项内。
-->
<el-form-item label="分析场景" prop="profileCode">
<el-select
v-model="formData.profileCode"
@ -29,6 +36,7 @@
<div class="field-hint">场景决定识别标签、判断阈值和分析要求。</div>
</el-form-item>
<!-- 音频检测需要一个期望声音;视频分析改由 instruction 描述通过标准,因此不显示该字段。 -->
<el-form-item v-if="!isVideo" label="目标声音类型" prop="expectedLabel">
<el-select
v-model="formData.expectedLabel"
@ -49,10 +57,12 @@
<div class="field-hint">只有实际声音与目标类型一致时才通过;新增类型可直接输入编码或名称。</div>
</el-form-item>
<!-- 媒体既可以引用上游录音/录像节点的输出,也可以直接填写一个可访问的网络地址。 -->
<el-form-item :label="mediaLabel" prop="mediaMode">
<el-segmented v-model="formData.mediaMode" :options="mediaModes" />
</el-form-item>
<!-- 引用模式保存级联路径和输出类型;打开下拉时才查询上游节点,避免每次渲染都遍历流程图。 -->
<el-form-item v-if="formData.mediaMode === 'quote'" label="引用节点输出" prop="mediaQuote">
<el-cascader
ref="mediaCascaderRef"
@ -66,10 +76,12 @@
/>
</el-form-item>
<!-- 直接输入模式只接受 HTTP/HTTPS 地址,具体格式在 rules.mediaUrl 中按当前模式条件校验。 -->
<el-form-item v-else :label="`${mediaLabel}地址`" prop="mediaUrl">
<el-input v-model="formData.mediaUrl" type="textarea" :rows="3" placeholder="请输入 HTTP/HTTPS 媒体地址" />
</el-form-item>
<!-- 视频模型依赖自然语言标准判断是否通过;音频节点使用 expectedLabel,不需要 instruction。 -->
<el-form-item v-if="isVideo" label="通过标准" prop="instruction" required>
<el-input
v-model="formData.instruction"
@ -82,11 +94,16 @@
<div class="field-hint">未观察到完整证据或置信度不足时,结果按未通过处理。</div>
</el-form-item>
<!-- 切换分析模式时同步恢复该模式预设,避免旧模式的微调参数意外带入新模式。 -->
<el-form-item v-if="isVideo" label="分析模式">
<el-segmented v-model="formData.analysisMode" :options="analysisModes" block @change="handleAnalysisModeChange" />
<div class="field-hint">{{ modeDescription }}</div>
</el-form-item>
<!--
高级参数只对视频抽帧分析生效。默认折叠以降低普通用户的配置负担,isTuned 用于提示
当前值已经偏离模式预设;“恢复默认”只重置当前模式的参数,不切换分析模式。
-->
<el-collapse v-if="isVideo" v-model="advancedSections" class="analysis-tuning">
<el-collapse-item name="tuning">
<template #title>
@ -117,6 +134,7 @@
</el-collapse>
</el-form>
<!-- 输出字段只作契约说明,实际 outputParams 由节点配置定义,不能在这里编辑。 -->
<el-divider content-position="left">节点输出</el-divider>
<div class="output-list">
<div v-for="item in outputSummary" :key="item.name" class="output-item">
@ -132,18 +150,35 @@ import { computed, reactive, ref, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { getInput } from '@/utils/flow'
/**
* data 是流程画布当前节点模型,核心字段包括 id、properties.action 和 properties.nodeParams。
* 保存成功/失败事件由 ParamsDrawer 接收,用于更新节点错误状态并关闭参数抽屉。
*/
const props = defineProps({ data: Object })
const emit = defineEmits(['save-success', 'save-error'])
// formRef 触发表单校验;mediaCascaderRef 用于读取级联选择器最终选中节点的元数据。
const formRef = ref()
const mediaCascaderRef = ref()
// 上游节点输出在级联选择器展开时按需加载;高级设置默认保持折叠。
const quoteOptions = ref([])
const advancedSections = ref([])
/**
* 三种视频分析模式的推荐参数:
* maxFrames 控制抽取帧数,maxWidth 控制送入模型前的缩放宽度,confidenceThreshold 是通过阈值;
* fallbackToAccurate 仅 AUTO 模式有意义,表示快速分析置信度不足时是否升级为精确分析。
*/
const analysisPresets = {
FAST: { sampleFps: 1, maxWidth: 896, confidenceThreshold: 0.7, fallbackToAccurate: false },
AUTO: { sampleFps: 2, maxWidth: 960, confidenceThreshold: 0.75, fallbackToAccurate: true },
ACCURATE: { sampleFps: 3, maxWidth: 1120, confidenceThreshold: 0.8, fallbackToAccurate: false },
}
/**
* 表单使用一个稳定的 reactive 对象,initData 只更新字段而不替换对象,保证 Element Plus
* 的表单项、校验状态和 v-model 引用始终指向同一个实例。
*/
const formData = reactive({
profileCode: '',
expectedLabel: '',
@ -157,13 +192,20 @@ const formData = reactive({
analysisTuning: { ...analysisPresets.AUTO },
})
// action 是区分音频节点和视频节点的唯一业务判别字段,后续文案、参数名和校验都由它派生。
const isVideo = computed(() => props.data?.properties?.action === 'VIDEO_ANALYZE')
// 以下计算属性统一收敛音频/视频差异,模板不需要重复判断具体 action 字符串。
const title = computed(() => isVideo.value ? '视频智能分析' : '声音类型检测')
const description = computed(() => isVideo.value
? '对录像抽帧并按所选场景生成结构化分析结果。'
: '判断录音是否属于指定的目标声音类型,并输出通过或不通过。')
const mediaLabel = computed(() => isVideo.value ? '视频来源' : '音频来源')
// nodeParams 中的媒体参数名属于执行协议:视频使用 videoUrl,音频使用 audioUrl。
const mediaName = computed(() => isVideo.value ? 'videoUrl' : 'audioUrl')
// mediaMode 的值会直接写入媒体参数 type,因此必须与流程参数协议中的 quote/input 保持一致。
const mediaModes = [
{ label: '引用节点输出', value: 'quote' },
{ label: '直接输入地址', value: 'input' },
@ -173,6 +215,7 @@ const analysisModes = [
{ label: '均衡', value: 'AUTO' },
{ label: '精确', value: 'ACCURATE' },
]
// 声音类型同时保存机器可读编码和中文名称;allow-create 允许现场扩展未预置类型。
const soundTypeOptions = [
{ label: '开机', value: 'POWER_ON' },
{ label: '关机', value: 'POWER_OFF' },
@ -186,13 +229,20 @@ const modeDescriptions = {
ACCURATE: '使用高精度模型分析更多画面细节,耗时较长。',
}
const modeDescription = computed(() => modeDescriptions[formData.analysisMode] || '')
// 任意高级参数偏离当前模式预设即视为“已微调”,用于控制折叠标题中的提示标签。
const isTuned = computed(() => {
const preset = analysisPresets[formData.analysisMode] || analysisPresets.AUTO
return Object.keys(preset).some((key) => formData.analysisTuning[key] !== preset[key])
})
// 不同媒体类型使用不同场景编码,默认值也从这里取得,避免音视频节点互相沿用 profileCode。
const profileOptions = computed(() => isVideo.value
? [{ label: '爱玛测试视频分析', value: 'aima.power_video.v1' }]
: [{ label: '爱玛车辆声音类型检测', value: 'aima.power_state.v1' }])
/**
* 输出摘要与运行时返回对象保持一致,仅负责向流程设计用户解释后续节点可引用的字段。
* 音频结果强调目标声音与实际声音的比较;视频结果强调证据、结论和实际分析模式。
*/
const outputSummary = computed(() => isVideo.value
? [
{ name: 'passed', desc: '是否通过' },
@ -214,6 +264,12 @@ const outputSummary = computed(() => isVideo.value
{ name: 'scores', desc: '各分类匹配度' },
])
/**
* 校验规则包含多个条件字段:
* - expectedLabel 只在音频节点必填;instruction 只在视频节点必填;
* - mediaQuote/mediaUrl 根据 mediaMode 二选一校验,隐藏字段不会阻塞保存;
* - 直接地址限制为 HTTP/HTTPS,避免执行端收到本地路径或无法识别的协议。
*/
const rules = {
profileCode: [{ required: true, message: '请选择或输入分析场景', trigger: 'change' }],
expectedLabel: [{
@ -242,17 +298,24 @@ const rules = {
}],
}
// 使用 Object.assign 保留 analysisTuning 的响应式引用,只覆盖当前模式定义的预设字段。
const restorePreset = () => {
Object.assign(formData.analysisTuning, analysisPresets[formData.analysisMode] || analysisPresets.AUTO)
}
// 模式切换后直接采用新模式预设,用户仍可在高级设置中继续微调。
const handleAnalysisModeChange = () => restorePreset()
// 同时维护编码和展示名称;用户创建自定义类型时,输入值本身作为可读名称兜底。
const handleExpectedLabelChange = (value) => {
const option = soundTypeOptions.find(item => item.value === value)
formData.expectedLabelName = option?.label || String(value || '').trim()
}
/**
* analysisTuning 的历史数据可能是对象或 JSON 字符串。这里统一解析为普通对象;
* 非法 JSON 返回空对象,后续 normalizeStoredTuning 会用模式预设补齐全部字段。
*/
const parseStoredTuning = (value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) return value
if (typeof value !== 'string' || !value.trim()) return {}
@ -264,11 +327,16 @@ const parseStoredTuning = (value) => {
}
}
// 将旧配置或外部写入值限制在当前 UI 支持范围内,NaN 等非法值回退到模式默认值。
const clamp = (value, minimum, maximum, fallback) => {
const number = Number(value)
return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, number)) : fallback
}
/**
* 标准化持久化的高级参数,保证滑块始终接收到合法数字,并兼容缺少新字段的旧流程数据。
* 帧数和宽度必须为整数;fallbackToAccurate 只接受真正的布尔值,不把字符串误判为 true。
*/
const normalizeStoredTuning = (value, preset) => {
const stored = parseStoredTuning(value)
return {
@ -281,15 +349,28 @@ const normalizeStoredTuning = (value, preset) => {
}
}
// getInput 根据当前节点 ID 返回可引用的上游输出,只在级联面板打开时刷新以减少无效计算。
const loadQuoteOptions = (visible) => {
if (visible) quoteOptions.value = getInput(props.data.id) || []
}
/**
* 级联路径只记录选中位置,quoteType 需要从叶子节点元数据单独取得;
* 读取不到元数据时回退为 output,兼容旧版上游输出选项。
*/
const handleQuoteChange = () => {
const checked = mediaCascaderRef.value?.getCheckedNodes?.(true)?.[0]
formData.mediaQuoteType = checked?.data?.type || 'output'
}
/**
* 将节点已保存的 nodeParams 回填到表单。
*
* nodeParams 是按 name 存储的数组而不是对象,因此先逐项定位;缺失字段使用当前节点类型的默认值。
* 媒体参数的 type 决定“引用/输入”模式,quote、quoteType 和 input 分别恢复对应控件。
* 旧版音频节点可能只有 expectedLabel 没有 expectedLabelName,此时通过选项表补齐中文名称。
* 视频高级配置经过 normalizeStoredTuning 清洗,非 AUTO 模式强制关闭仅 AUTO 支持的精确分析回退。
*/
const initData = () => {
const params = props.data?.properties?.nodeParams || []
const profile = params.find(item => item.name === 'profileCode')
@ -316,11 +397,24 @@ const initData = () => {
if (formData.analysisMode !== 'AUTO') formData.analysisTuning.fallbackToAccurate = false
}
// 抽屉可能在不销毁组件的情况下切换节点;深度监听保证节点属性更新后表单立即重新回填。
watch(() => props.data, initData, { immediate: true, deep: true })
/**
* 校验并保存当前媒体分析节点。
*
* 保存过程重新构造 nodeParams,而不是修改原数组,避免残留另一种媒体类型的专属字段:
* 1. 所有节点保存 profileCode 和当前媒体参数;
* 2. 音频节点额外保存 expectedLabel/expectedLabelName;
* 3. 视频节点额外保存 instruction、analysisMode 和 analysisTuning;
* 4. 删除旧的 executionTarget 后,通过 LogicFlow 全局实例 lf 一次性写回 properties。
*
* ParamsDrawer 只依赖 save-success/save-error,不需要了解 Element Plus 的校验异常细节。
*/
const validateAndSave = async () => {
try {
await formRef.value.validate()
// 公共参数始终位于数组前部,保持与 config.js 中节点初始定义一致,便于调试和序列化比较。
const nodeParams = [
{
name: 'profileCode',
@ -341,6 +435,7 @@ const validateAndSave = async () => {
disabled: true,
},
]
// 音频节点把目标类型插入 profileCode 与媒体参数之间,不保存任何视频专属参数。
if (!isVideo.value) {
nodeParams.splice(1, 0,
{
@ -361,6 +456,7 @@ const validateAndSave = async () => {
},
)
}
// 视频节点保留自然语言标准和分析策略;高级参数复制为普通对象,避免泄漏 Vue 响应式代理。
if (isVideo.value) {
nodeParams.push({
name: 'instruction',
@ -386,8 +482,13 @@ const validateAndSave = async () => {
disabled: true,
})
}
// 保留节点名称、action 等其他属性,仅替换本组件负责的 nodeParams。
const properties = { ...props.data.properties, nodeParams }
// executionTarget 属于旧执行模型字段,媒体分析节点统一由流程运行时调度,保存时主动移除。
delete properties.executionTarget
// lf 由流程设计器页面提供为全局 LogicFlow 实例;setProperties 会触发画布节点属性更新。
lf.setProperties(props.data.id, properties)
emit('save-success')
} catch (error) {
@ -395,6 +496,7 @@ const validateAndSave = async () => {
}
}
// ParamsDrawer 在关闭前通过组件 ref 调用该方法,因此必须显式暴露。
defineExpose({ validateAndSave })
</script>

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

@ -6,18 +6,20 @@
<el-button type="primary" @click="addTask">添加任务</el-button>
</div>
<div class="taskList-container">
<div class="task-item" v-for="task in runTaskList">
<div v-for="task in runTaskList" :key="task.id" class="task-item"
:class="{ 'is-selected': isSelectedTask(task) }"
:aria-current="isSelectedTask(task) ? 'true' : undefined" @click="selectTask(task)">
<div class="task-content">
<div class="task-title">{{ task.taskName }}</div>
<div class="task-actions">
<SvgIcon class="icon" v-if="task.status === '1'" name="pause" :size="24" color="#FFB300"
@click="pauseTask(task)" />
@click.stop="pauseTask(task)" />
<SvgIcon class="icon" v-if="task.status === '3' || task.status === '5'" name="start"
:size="24" color="#00FF88" @click="startTask(task)" />
:size="24" color="#00FF88" @click.stop="startTask(task)" />
<SvgIcon class="icon" v-if="task.status != '5'" name="stop" :size="24" color="#d81e06"
@click="stopTask(task)" />
@click.stop="stopTask(task)" />
<SvgIcon class="icon" v-if="task.status === '5'" name="remove" :size="24" color="#FFB300"
@click="removeTask(task)" />
@click.stop="removeTask(task)" />
</div>
</div>
<div class="robot">主要机器人:{{ task.robotName || '执行时安排' }}</div>
@ -33,40 +35,43 @@
<div class="label" :class="{ active: activePanel === 'history' }" @click="activePanel = 'history'">历史记录
</div>
</div>
<div class="context-container">
<div ref="contextContainerRef" class="context-container">
<div v-if="activePanel === 'current'">
<!-- 当前任务内容 -->
<div class="task" v-for="runTask in runingTaskList" :key="runTask.id"
:data-task-id="runTask.id" :class="{ 'task--selected': selectedTaskId === runTask.id }"
@click="selectedTaskId = runTask.id">
<!-- 当前任务区域只展示左侧任务队列中选中的任务,避免同时渲染所有执行中任务。 -->
<div v-if="selectedRunTask" :key="selectedRunTask.id" class="task"
:data-task-id="selectedRunTask.id">
<div class="task-header">
<div class="header-container">
<div class="task-info">
<div class="title">{{ runTask.taskName }}</div>
<div class="id">ID: {{ runTask.taskId }}</div>
<div class="title">{{ selectedRunTask.taskName }}</div>
<div class="id">ID: {{ selectedRunTask.taskId }}</div>
</div>
<div class="robot">
<div>主要机器人:{{ runTask.robotName || '按流程角色安排' }}</div>
<div>开始时间: {{ formatDate(runTask.startTime) }}</div>
<div>主要机器人:{{ selectedRunTask.robotName || '按流程角色安排' }}</div>
<div>开始时间: {{ formatDate(selectedRunTask.startTime) }}</div>
</div>
</div>
<div>
<el-tag :type="runTask.status === '3' ? 'primary' : 'warning'">
{{ taskStatusMap[runTask.status] || '未知状态' }}
<el-tag :type="selectedRunTask.status === '3' ? 'primary' : 'warning'">
{{ taskStatusMap[selectedRunTask.status] || '未知状态' }}
</el-tag>
</div>
</div>
<div class="progress-label">执行进度</div>
<el-progress :color="customColorMethod"
:percentage="parseFloat(taskRunningInfo[runTask.id]?.progress?.toFixed(2)) || 0" />
<taskSteps :list="pointList_runningTask[runTask.taskId]"
:activeStep="taskRunningInfo[runTask.id]?.activeStep || 0" />
<div class="run-log-container">
<div>执行日志</div>
<taskLog :list="taskLogList[runTask.id]" />
:percentage="parseFloat(taskRunningInfo[selectedRunTask.id]?.progress?.toFixed(2)) || 0" />
<taskSteps :list="pointList_runningTask[selectedRunTask.taskId]"
:activeStep="taskRunningInfo[selectedRunTask.id]?.activeStep || 0" />
<div ref="taskLogContainerRef" class="run-log-container">
<div ref="taskLogTitleRef">执行日志</div>
<taskLog :height="taskLogHeight" :task-id="selectedRunTask.taskId"
:task-status="selectedRunTask.status"
:points="pointList_runningTask[selectedRunTask.taskId]"
:list="taskLogList[selectedRunTask.id]" />
</div>
</div>
<el-empty v-else description="暂无任务" />
</div>
<div v-else-if="activePanel === 'history'">
<div :style="containerHeight">
@ -141,8 +146,9 @@
</template>
</el-dialog>
<el-dialog title="任务执行日志" v-model="logDialogVisible" width="500px" append-to-body>
<taskLog v-loading="logDialogLoading" :list="logDialogData" :height="300" />
<el-dialog title="任务执行日志" v-model="logDialogVisible" width="800px" append-to-body>
<taskLog v-loading="logDialogLoading" :task-id="logDialogTaskId" :task-status="logDialogTaskStatus"
:list="logDialogData" :height="500" />
</el-dialog>
<el-dialog title="本次执行安排" v-model="startDialogVisible" width="820px" append-to-body>
@ -170,7 +176,8 @@ import taskSteps from "./task-steps.vue";
import taskLog from "./task-log.vue";
import { useContainerHeight } from "@/hooks/tableHeight";
import { getTaskList, getWaypointByTaskId } from "@/api/inspection/task";
import { onMounted, onUnmounted } from "vue";
import { computed, onMounted, onUnmounted } from "vue";
import { useElementBounding, useElementSize } from '@vueuse/core'
import { addRunTask, getRunTaskList, startRunTask, rerunRunTask, pauseRunTask, stopRunTask, resumeRunTask, deleteRunTask, getTaskLogListApi } from "@/api/inspection/runTask";
import { ElMessage, ElMessageBox } from "element-plus";
import { formatDate } from "@/utils/index";
@ -187,6 +194,38 @@ import {
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const contextContainerRef = ref(null)
const taskLogContainerRef = ref(null)
const taskLogTitleRef = ref(null)
// 通过元素边界实时获取当前任务内容区底部和日志容器顶部,避免依赖固定的 460px。
const { bottom: contextContainerBottom } = useElementBounding(contextContainerRef)
const { top: taskLogContainerTop } = useElementBounding(taskLogContainerRef)
const { height: taskLogTitleHeight } = useElementSize(taskLogTitleRef)
const TASK_LOG_MARGIN_TOP = 12
const CURRENT_TASK_BOTTOM_PADDING = 16
const MIN_TASK_LOG_HEIGHT = 150
/**
* 计算当前任务页面中日志组件可以使用的剩余高度。
*
* 计算基准是右侧内容区的底部,而不是窗口高度,因此页面外层布局、浏览器窗口
* 尺寸或右侧面板高度发生变化时都能得到正确结果。日志容器顶部到内容区底部之间,
* 还需要扣除“执行日志”标题高度、taskLog 组件自身的上间距,以及当前任务卡片的
* 底部内边距,确保日志不会覆盖任务卡片边界。元素尚未挂载时使用最低高度作为初始值,
* 待 VueUse 完成首次测量后会自动更新为实际剩余高度。
*/
const taskLogHeight = computed(() => {
const remainingHeight = contextContainerBottom.value
- taskLogContainerTop.value
- taskLogTitleHeight.value
- TASK_LOG_MARGIN_TOP
- CURRENT_TASK_BOTTOM_PADDING
return Math.max(MIN_TASK_LOG_HEIGHT, Math.floor(remainingHeight))
})
const socket = inject('ws');
const startDialogVisible = ref(false)
const startSubmitting = ref(false)
@ -220,6 +259,23 @@ const rules = reactive({
const taskList = ref([]);
const runTaskList = ref([]);
// 接口和 WebSocket 可能分别以数字、字符串返回任务实例 ID,统一转换后再判断是否为选中任务。
const isSelectedTask = (task) => String(task?.id ?? '') === String(selectedTaskId.value)
// 当前任务详情直接从完整任务队列中取得,因此未执行、执行中和已暂停任务都可以被选中查看。
const selectedRunTask = computed(() => runTaskList.value.find(isSelectedTask) || null)
/**
* 点击左侧任务卡片时切换右侧当前任务详情,并从历史记录面板返回当前任务面板。
* 卡片内的启动、暂停、停止和删除图标均使用 .stop 阻止冒泡,执行操作时不会意外改变当前选择。
*
* @param {Object} task 左侧任务队列中被点击的任务实例
*/
const selectTask = (task) => {
selectedTaskId.value = task.id
activePanel.value = 'current'
}
const normalizeTaskStatus = (tasks = []) => tasks.map(item => ({
...item,
status: String(item.status),
@ -264,9 +320,12 @@ const cancelTaskDialog = () => {
}
const runingTaskList = ref([])
/**
* 获取任务队列
* 获取任务队列并维护当前选中的任务:
* 1. 页面首次加载时默认选中队列第一条任务;
* 2. 队列刷新后,如果原任务仍存在,则保留用户当前选择;
* 3. 如果原任务已经结束或被删除,则选择刷新后队列中的第一条任务;
* 4. 如果任务队列为空,则清空选择并由模板展示空状态。
*/
const getList = () => {
return getRunTaskList({
@ -275,7 +334,9 @@ const getList = () => {
if (res.code === 200) {
const tasks = normalizeTaskStatus(res.rows)
runTaskList.value = tasks;
runingTaskList.value = tasks.filter(item => item.status !== '5')
if (!tasks.some(isSelectedTask)) {
selectedTaskId.value = tasks[0]?.id ?? ''
}
for (let i = 0; i < runTaskList.value.length; i++) {
getPointByTaskId(runTaskList.value[i].taskId)
getTaskLogList(runTaskList.value[i].id)
@ -506,6 +567,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 +590,7 @@ const subscribeDebounced = debounce(async (sub) => {
...data
})
} else {
// 终态到达后清理内存中的实时快照,再从接口刷新当前队列与历史列表,保证页面以持久化结果为准。
delete taskRunningInfo.value[data.taskInstanceId]
delete taskLogList.value[data.taskInstanceId]
getList()
@ -550,11 +614,18 @@ const subscribeDebounced = debounce(async (sub) => {
const logDialogVisible = ref(false)
const logDialogData = ref([])
// 历史日志组件需要任务定义 ID 查询该任务绑定的点位,并用任务终态推导未单独记录状态的点位结果。
const logDialogTaskId = ref('')
const logDialogTaskStatus = ref(null)
const logDialogLoading = ref(false)
let logDialogRequestId = 0
const handleOpenLog = async (row) => {
const requestId = ++logDialogRequestId
logDialogVisible.value = true
// 先切换任务上下文,再请求日志;点位请求和日志请求可以并行,避免弹窗一直沿用上一次任务的数据。
logDialogTaskId.value = row.taskId
logDialogTaskStatus.value = row.status
logDialogData.value = []
logDialogLoading.value = true
try {
@ -615,6 +686,17 @@ onUnmounted(() => {
box-shadow: inset 0 0 0 1px #eee;
border-radius: 8px;
margin-bottom: 16px;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
background: var(--el-fill-color-light);
}
&.is-selected {
background: var(--el-color-primary-light-9);
box-shadow: inset 0 0 0 2px var(--el-color-primary);
}
.task-content {
display: flex;
@ -681,10 +763,6 @@ onUnmounted(() => {
border-radius: 20px;
margin-bottom: 12px;
&--selected {
box-shadow: inset 0 0 0 2px var(--el-color-primary);
}
.task-header {
display: flex;
justify-content: space-between;

View File

@ -1,12 +1,469 @@
<template>
<TaskExecutionLog :list="list" :height="height" />
<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'
defineProps({
/**
* 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>

View File

@ -6,18 +6,20 @@
<el-button type="primary" @click="addTask">添加任务</el-button>
</div>
<div class="taskList-container">
<div class="task-item" v-for="task in runTaskList">
<div v-for="task in runTaskList" :key="task.id" class="task-item"
:class="{ 'is-selected': isSelectedTask(task) }"
:aria-current="isSelectedTask(task) ? 'true' : undefined" @click="selectTask(task)">
<div class="task-content">
<div class="task-title">{{ task.taskName }}</div>
<div class="task-actions">
<SvgIcon class="icon" v-if="task.status == '1'" name="pause" :size="24" color="#FFB300"
@click="pauseTask(task)" />
@click.stop="pauseTask(task)" />
<SvgIcon class="icon" v-if="task.status == '3' || task.status == '5'" name="start"
:size="24" color="#00FF88" @click="startTask(task)" />
:size="24" color="#00FF88" @click.stop="startTask(task)" />
<SvgIcon class="icon" v-if="task.status != '5'" name="stop" :size="24" color="#d81e06"
@click="stopTask(task)" />
@click.stop="stopTask(task)" />
<SvgIcon class="icon" v-if="task.status == '5'" name="remove" :size="24" color="#FFB300"
@click="removeTask(task)" />
@click.stop="removeTask(task)" />
</div>
</div>
<div class="robot">手机:{{ task.phoneName }}</div>
@ -34,43 +36,46 @@
<div class="label" :class="{ active: activePanel === 'history' }" @click="activePanel = 'history'">历史记录
</div>
</div>
<div class="context-container">
<div ref="contextContainerRef" class="context-container">
<div v-if="activePanel === 'current'">
<!-- 当前任务内容 -->
<div class="task" v-for="runTask in runingTaskList" :key="runTask.id"
:data-task-id="runTask.id" :class="{ 'task--selected': selectedTaskId === runTask.id }"
@click="selectedTaskId = runTask.id">
<!-- 当前任务区域只展示左侧队列选中的任务,不再同时渲染全部执行中任务。 -->
<div v-if="selectedRunTask" :key="selectedRunTask.id" class="task"
:data-task-id="selectedRunTask.id">
<div class="task-header">
<div class="header-container">
<div class="task-info">
<div class="title">{{ runTask.taskName }}</div>
<div class="id">ID: {{ runTask.taskId }}</div>
<div class="title">{{ selectedRunTask.taskName }}</div>
<div class="id">ID: {{ selectedRunTask.taskId }}</div>
</div>
<div class="robot">
<div>手机:{{ runTask.phoneName }}</div>
<div>车辆:{{ runTask.vehicleName }}</div>
<div>开始时间: {{ formatDate(runTask.startTime) }}</div>
<div>手机:{{ selectedRunTask.phoneName }}</div>
<div>车辆:{{ selectedRunTask.vehicleName }}</div>
<div>开始时间: {{ formatDate(selectedRunTask.startTime) }}</div>
</div>
</div>
<div>
<el-tag :type="runTask.status === '3' ? 'primary' : 'warning'">
{{ taskStatusMap[runTask.status] || '未知状态' }}
<el-tag :type="selectedRunTask.status === '3' ? 'primary' : 'warning'">
{{ taskStatusMap[selectedRunTask.status] || '未知状态' }}
</el-tag>
</div>
</div>
<div class="progress-label">执行进度</div>
<el-progress :color="customColorMethod"
:percentage="parseFloat(taskRunningInfo[runTask.id]?.progress?.toFixed(2)) || 0" />
<taskSteps :list="pointList_runningTask[runTask.taskId]"
:activeStep="taskRunningInfo[runTask.id]?.activeStep || 0" />
<div class="run-log-container">
<div>执行日志</div>
<!-- 传入任务定义 ID,task-log 会据此查询检测项并按检测项归类当前实例日志。 -->
<taskLog :height="460" :task-id="runTask.taskId" :task-status="runTask.status"
:list="taskLogList[runTask.id]" />
:percentage="parseFloat(taskRunningInfo[selectedRunTask.id]?.progress?.toFixed(2)) || 0" />
<taskSteps :list="pointList_runningTask[selectedRunTask.taskId]"
:activeStep="taskRunningInfo[selectedRunTask.id]?.activeStep || 0" />
<div ref="taskLogContainerRef" class="run-log-container">
<div ref="taskLogTitleRef">执行日志</div>
<!--
taskId 用于查询检测项,taskStatus 用于在完成/失败/终止后推导最后一个已执行检测项的状态;
两者与实例日志分开传入,避免把任务级字段重复写进每一条日志。
-->
<taskLog :height="taskLogHeight" :task-id="selectedRunTask.taskId"
:task-status="selectedRunTask.status" :list="taskLogList[selectedRunTask.id]" />
</div>
</div>
<el-empty v-else description="暂无任务" />
</div>
<div v-else-if="activePanel === 'history'">
<div :style="containerHeight">
@ -156,7 +161,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>
@ -185,7 +193,8 @@ import { useContainerHeight } from "@/hooks/tableHeight";
import { getTaskList, getTestCaseByTaskId } from "@/api/intelligenceTest/taskManage";
import { getPhoneList } from "@/api/intelligenceTest/mobilePhone";
import { getVehicleList } from "@/api/intelligenceTest/car";
import { onMounted, onUnmounted } from "vue";
import { computed, onMounted, onUnmounted } from "vue";
import { useElementBounding, useElementSize } from '@vueuse/core'
import {
addTaskinstance,
getTaskinstanceList,
@ -211,6 +220,38 @@ import {
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const contextContainerRef = ref(null)
const taskLogContainerRef = ref(null)
const taskLogTitleRef = ref(null)
// 通过元素边界实时获取当前任务内容区底部和日志容器顶部,避免依赖固定的 460px。
const { bottom: contextContainerBottom } = useElementBounding(contextContainerRef)
const { top: taskLogContainerTop } = useElementBounding(taskLogContainerRef)
const { height: taskLogTitleHeight } = useElementSize(taskLogTitleRef)
const TASK_LOG_MARGIN_TOP = 12
const CURRENT_TASK_BOTTOM_PADDING = 16
const MIN_TASK_LOG_HEIGHT = 150
/**
* 计算当前任务页面中日志组件可以使用的剩余高度。
*
* 计算基准是右侧内容区的底部,而不是窗口高度,因此页面外层布局、浏览器窗口
* 尺寸或右侧面板高度发生变化时都能得到正确结果。日志容器顶部到内容区底部之间,
* 还需要扣除“执行日志”标题高度、taskLog 组件自身的上间距,以及当前任务卡片的
* 底部内边距,确保日志不会覆盖任务卡片边界。元素尚未挂载时使用最低高度作为初始值,
* 待 VueUse 完成首次测量后会自动更新为实际剩余高度。
*/
const taskLogHeight = computed(() => {
const remainingHeight = contextContainerBottom.value
- taskLogContainerTop.value
- taskLogTitleHeight.value
- TASK_LOG_MARGIN_TOP
- CURRENT_TASK_BOTTOM_PADDING
return Math.max(MIN_TASK_LOG_HEIGHT, Math.floor(remainingHeight))
})
const socket = inject('ws');
const startDialogVisible = ref(false)
const startSubmitting = ref(false)
@ -247,6 +288,21 @@ const phoneList = ref([]);
const runTaskList = ref([]);
// 任务实例 ID 在接口和 WebSocket 中可能分别是数字或字符串,统一按字符串比较选中状态。
const isSelectedTask = (task) => String(task?.id ?? '') === String(selectedTaskId.value)
// 右侧当前任务始终从完整任务队列中取选中的一条,包括尚未执行、执行中和已暂停任务。
const selectedRunTask = computed(() => runTaskList.value.find(isSelectedTask) || null)
/**
* 点击左侧任务卡片时切换右侧详情,并自动回到“当前任务”面板。
* 卡片内部操作按钮使用 .stop 阻止冒泡,因此执行暂停/启动等命令时不会意外切换选择。
*/
const selectTask = (task) => {
selectedTaskId.value = task.id
activePanel.value = 'current'
}
const normalizeTaskStatus = (tasks = []) => tasks.map(item => ({
...item,
status: String(item.status),
@ -311,9 +367,11 @@ const cancelTaskDialog = () => {
}
const runingTaskList = ref([])
/**
* 获取任务队列
* 获取任务队列,并维护当前选中任务:
* 1. 首次加载默认选择队列第一条;
* 2. 刷新后原任务仍在队列中则保持选择;
* 3. 原任务已结束或被删除时回退到新的第一条,队列为空时清空选择。
*/
const getList = () => {
return getTaskinstanceList({
@ -322,7 +380,9 @@ const getList = () => {
if (res.code === 200) {
const tasks = normalizeTaskStatus(res.rows)
runTaskList.value = tasks;
runingTaskList.value = tasks.filter(item => item.status !== '5')
if (!tasks.some(isSelectedTask)) {
selectedTaskId.value = tasks[0]?.id ?? ''
}
for (let i = 0; i < runTaskList.value.length; i++) {
getCaseByTaskId(runTaskList.value[i].taskId)
getTaskLogList(runTaskList.value[i].id)
@ -552,6 +612,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 +635,7 @@ const subscribeDebounced = debounce(async (sub) => {
...data
})
} else {
// 清除实时缓存后重新查询任务队列和历史记录,让最终状态与后端持久化结果保持一致。
delete taskRunningInfo.value[data.taskInstanceId]
delete taskLogList.value[data.taskInstanceId]
getList()
@ -599,6 +662,8 @@ const logDialogData = ref([])
// 历史表格行里的 taskId 是任务定义 ID;日志组件使用它查询本次任务包含的全部检测项。
const logDialogTaskId = ref('')
// 保存当前历史实例的终态,供日志组件判断最后一个产生过日志的检测项是完成、失败还是被终止。
const logDialogTaskStatus = ref(null)
const logDialogLoading = ref(false)
let logDialogRequestId = 0
@ -608,6 +673,8 @@ const handleOpenLog = async (row) => {
// 在请求实例日志前先更新 taskId,让检测项请求与日志请求并行执行,减少弹窗等待时间。
logDialogTaskId.value = row.taskId
// 状态与 taskId 同步切换,防止连续打开不同历史任务时短暂沿用上一个实例的状态。
logDialogTaskStatus.value = row.status
logDialogData.value = []
logDialogLoading.value = true
@ -671,6 +738,17 @@ onUnmounted(() => {
box-shadow: inset 0 0 0 1px #eee;
border-radius: 8px;
margin-bottom: 16px;
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
&:hover {
background: var(--el-fill-color-light);
}
&.is-selected {
background: var(--el-color-primary-light-9);
box-shadow: inset 0 0 0 2px var(--el-color-primary);
}
.task-content {
display: flex;
@ -737,10 +815,6 @@ onUnmounted(() => {
border-radius: 20px;
margin-bottom: 12px;
&--selected {
box-shadow: inset 0 0 0 2px var(--el-color-primary);
}
.task-header {
display: flex;
justify-content: space-between;

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;