forked from SmartBench/CMVR-IOT-UI
feat(flow): 添加动作队列步骤独立执行功能和媒体分析增强
- 在动作队列参数组件中添加单独执行按钮,允许独立运行指定步骤 - 实现步骤验证和构建逻辑,支持单步执行的参数处理 - 更新媒体分析参数界面,添加高级设置和参数微调功能 - 增加视频分析的置信度、通过标准等配置选项 - 优化任务执行日志显示,添加分析结果的结构化展示 - 集成分页请求工具,改进日志加载性能和用户体验 - 添加执行步骤索引跟踪,确保执行状态正确管理 - 实现防重复ID机制,保证分页数据完整性 - 新增分析结果解析和可视化展示功能 - 扩展媒体分析类型的处理和结果显示
This commit is contained in:
parent
cad4184a2f
commit
6be8bc9bfd
45
src/utils/pagedRequest.js
Normal file
45
src/utils/pagedRequest.js
Normal file
@ -0,0 +1,45 @@
|
||||
const DEFAULT_PAGE_SIZE = 200
|
||||
const DEFAULT_MAX_PAGES = 1000
|
||||
|
||||
/**
|
||||
* Loads every page from a standard TableDataInfo endpoint. Endpoints that ignore
|
||||
* page parameters are also supported because their first response already reaches total.
|
||||
*/
|
||||
export async function fetchAllPagedRows(fetchPage, params = {}, options = {}) {
|
||||
const pageSize = Math.max(1, Number(options.pageSize || DEFAULT_PAGE_SIZE))
|
||||
const maxPages = Math.max(1, Number(options.maxPages || DEFAULT_MAX_PAGES))
|
||||
const rows = []
|
||||
const seenIds = new Set()
|
||||
let lastResponse = { code: 200, rows: [], total: 0 }
|
||||
|
||||
for (let pageNum = 1; pageNum <= maxPages; pageNum += 1) {
|
||||
const response = await fetchPage({ ...params, pageNum, pageSize })
|
||||
lastResponse = response || lastResponse
|
||||
const batch = Array.isArray(response?.rows) ? response.rows : []
|
||||
let addedCount = 0
|
||||
batch.forEach((row) => {
|
||||
const id = row?.id
|
||||
if (id !== undefined && id !== null && id !== '') {
|
||||
const key = String(id)
|
||||
if (seenIds.has(key)) return
|
||||
seenIds.add(key)
|
||||
}
|
||||
rows.push(row)
|
||||
addedCount += 1
|
||||
})
|
||||
|
||||
const total = Number(response?.total)
|
||||
const hasTotal = Number.isFinite(total) && total >= 0
|
||||
if (addedCount === 0 && hasTotal && rows.length < total) {
|
||||
throw new Error('日志分页没有返回新记录,无法保证数据完整')
|
||||
}
|
||||
if (batch.length === 0
|
||||
|| addedCount === 0
|
||||
|| (hasTotal && rows.length >= total)
|
||||
|| (!hasTotal && batch.length < pageSize)) {
|
||||
return { ...lastResponse, rows, total: hasTotal ? total : rows.length }
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`日志记录超过 ${pageSize * maxPages} 条,未能完整加载`)
|
||||
}
|
||||
@ -15,6 +15,19 @@
|
||||
<div class="single-queue-step__header">
|
||||
<div class="single-queue-step__title">{{ getActionQueueStepName(step, index) }} · {{ actionName(step.type) }}</div>
|
||||
<div class="single-queue-step__actions">
|
||||
<el-tooltip content="单独执行此步骤" placement="top">
|
||||
<el-button
|
||||
:icon="VideoPlay"
|
||||
circle
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
aria-label="单独执行此步骤"
|
||||
:loading="executingStepIndex === index"
|
||||
:disabled="executingStepIndex >= 0 && executingStepIndex !== index"
|
||||
@click="emit('execute-step', index)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="上移一步" placement="top">
|
||||
<el-button :icon="ArrowUp" circle size="small" :disabled="index === 0" aria-label="上移一步" @click="moveStep(index, -1)" />
|
||||
</el-tooltip>
|
||||
@ -44,7 +57,7 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowDown, ArrowUp, CopyDocument } from '@element-plus/icons-vue'
|
||||
import { ArrowDown, ArrowUp, CopyDocument, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { getRobotDevicesByRobotId } from '@/api/inspection/robot'
|
||||
import { DEVICE_KIND } from '@/utils/robotDevice'
|
||||
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep, toActionQueueExecutionSteps } from '@/utils/actionQueue'
|
||||
@ -57,7 +70,9 @@ const props = defineProps({
|
||||
nodeParams: { type: Array, default: () => [] },
|
||||
robotOptions: { type: Array, default: () => [] },
|
||||
initialRobotId: { type: String, default: '' },
|
||||
executingStepIndex: { type: Number, default: -1 },
|
||||
})
|
||||
const emit = defineEmits(['execute-step'])
|
||||
|
||||
/** 动作类型到中文名称的映射,作用范围为当前组件的步骤标题展示。 */
|
||||
const labels = {
|
||||
@ -178,6 +193,17 @@ const validate = () => {
|
||||
return true
|
||||
}
|
||||
|
||||
const validateStep = (index) => {
|
||||
if (!robotId.value) return ElMessage.warning('请选择机器人'), false
|
||||
const step = steps.value[index]
|
||||
if (!step) return ElMessage.warning('未找到要执行的步骤'), false
|
||||
if (deviceKind(step.type) && !step.deviceId) {
|
||||
ElMessage.warning(`步骤 ${index + 1} 没有可用的执行设备`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 汇总当前表单数据为运行接口载荷;返回范围包含机器人、队列配置和全部步骤。 */
|
||||
const getPayload = () => ({
|
||||
robotId: robotId.value,
|
||||
@ -186,6 +212,17 @@ const getPayload = () => ({
|
||||
steps: toActionQueueExecutionSteps(steps.value),
|
||||
})
|
||||
|
||||
const getStepPayload = (index) => {
|
||||
const step = steps.value[index]
|
||||
const stepTimeoutMs = Math.max(0, Number(step?.timeoutMs || 0))
|
||||
return {
|
||||
robotId: robotId.value,
|
||||
actionId: '',
|
||||
totalTimeoutMs: Math.max(1000, stepTimeoutMs + 5000),
|
||||
steps: toActionQueueExecutionSteps(step ? [step] : []),
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回当前选中的机器人 ID,供父组件处理兼容字段或运行上下文。 */
|
||||
const getRobotId = () => robotId.value
|
||||
|
||||
@ -193,7 +230,7 @@ const getRobotId = () => robotId.value
|
||||
watch(() => [props.nodeParams, props.initialRobotId], initData, { immediate: true, deep: true })
|
||||
|
||||
/** 向父组件开放提交前校验、载荷读取和机器人 ID 读取能力。 */
|
||||
defineExpose({ validate, getPayload, getRobotId })
|
||||
defineExpose({ validate, validateStep, getPayload, getStepPayload, getRobotId })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -4,6 +4,47 @@
|
||||
<div class="task-log__time">{{ formatDate(log.logTime) }}</div>
|
||||
<div class="task-log__body">
|
||||
<div class="task-log__content">{{ log.logContent }}</div>
|
||||
<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 }}
|
||||
</el-tag>
|
||||
<span v-if="analysisOf(log).modelName" class="task-log__analysis-meta">
|
||||
模型:{{ analysisOf(log).modelName }}
|
||||
</span>
|
||||
<span v-if="analysisOf(log).timingMs !== null" class="task-log__analysis-meta">
|
||||
耗时:{{ analysisOf(log).timingMs }}ms
|
||||
</span>
|
||||
</div>
|
||||
<div class="task-log__analysis-grid">
|
||||
<div v-if="analysisOf(log).label" class="task-log__analysis-field">
|
||||
<span>识别结果</span>
|
||||
<strong>{{ analysisOf(log).label }}</strong>
|
||||
</div>
|
||||
<div v-if="analysisOf(log).matchLabel" class="task-log__analysis-field">
|
||||
<span>匹配状态</span>
|
||||
<strong>{{ analysisOf(log).matchLabel }}</strong>
|
||||
</div>
|
||||
<div v-if="analysisOf(log).confidence !== null" class="task-log__analysis-field">
|
||||
<span>置信度</span>
|
||||
<strong>{{ analysisOf(log).confidence }}%</strong>
|
||||
</div>
|
||||
<div v-if="analysisOf(log).passedLabel" class="task-log__analysis-field">
|
||||
<span>判断结果</span>
|
||||
<strong>{{ analysisOf(log).passedLabel }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="analysisOf(log).summary" class="task-log__analysis-summary">
|
||||
{{ analysisOf(log).summary }}
|
||||
</div>
|
||||
<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>
|
||||
<pre v-if="analysisOf(log).details" class="task-log__analysis-details">{{ analysisOf(log).details }}</pre>
|
||||
</div>
|
||||
<div v-if="mediaOf(log).length" class="task-log__media">
|
||||
<template v-for="media in mediaOf(log)" :key="`${media.type}:${media.url}`">
|
||||
<el-image
|
||||
@ -58,6 +99,77 @@ const addMedia = (result, seen, type, value) => {
|
||||
})
|
||||
}
|
||||
|
||||
const analysisCache = new WeakMap()
|
||||
const analysisOf = (log) => {
|
||||
if (!log || typeof log !== 'object') return null
|
||||
if (analysisCache.has(log)) return analysisCache.get(log)
|
||||
const output = {
|
||||
...parseObject(log.actualResult),
|
||||
...parseObject(log.extraInfo),
|
||||
}
|
||||
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
|
||||
|| output.profileCode
|
||||
|| output.label
|
||||
|| output.labelName
|
||||
|| output.summary
|
||||
|| output.conclusion
|
||||
|| output.passed !== undefined
|
||||
)
|
||||
if (!hasResult || (!isAudio && !isVideo)) {
|
||||
analysisCache.set(log, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const status = String(output.analysisStatus || '').toUpperCase()
|
||||
const matched = output.matched
|
||||
const passed = output.passed
|
||||
const confidenceValue = Number(output.confidence)
|
||||
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 || '已返回结果'
|
||||
const detailSource = { ...output }
|
||||
;[
|
||||
'analysisStatus', 'analysisType', 'profileCode', 'analysisModel', 'analysisTimingMs',
|
||||
'mediaUrl', 'audioUrl', 'videoUrl', 'imageUrl', 'label', 'labelName', 'matched',
|
||||
'confidence', 'passed', 'summary', 'conclusion', 'events',
|
||||
].forEach((key) => delete detailSource[key])
|
||||
const hasDetails = Object.keys(detailSource).length > 0
|
||||
const result = {
|
||||
title: isAudio ? '声音分析' : '视频分析',
|
||||
statusLabel,
|
||||
tagType: status === 'FAILED' || status === 'ERROR' || passed === false
|
||||
? 'danger'
|
||||
: matched === false ? 'warning' : 'success',
|
||||
modelName: typeof output.analysisModel === 'string'
|
||||
? output.analysisModel
|
||||
: output.analysisModel?.name || '',
|
||||
timingMs: Number.isFinite(Number(output.analysisTimingMs)) ? Number(output.analysisTimingMs) : null,
|
||||
label: output.labelName || output.label || '',
|
||||
matchLabel: matched === true ? '匹配' : matched === false ? '未匹配' : '',
|
||||
confidence,
|
||||
passedLabel: passed === true ? '通过' : passed === false ? '未通过' : '',
|
||||
summary: output.summary || output.conclusion || '',
|
||||
events: Array.isArray(output.events)
|
||||
? output.events.slice(0, 20).map((event) => typeof event === 'string' ? event : JSON.stringify(event))
|
||||
: [],
|
||||
details: hasDetails ? JSON.stringify(detailSource, null, 2) : '',
|
||||
}
|
||||
analysisCache.set(log, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const mediaOf = (log) => {
|
||||
if (!log || typeof log !== 'object') return []
|
||||
if (mediaCache.has(log)) return mediaCache.get(log)
|
||||
@ -70,6 +182,10 @@ const mediaOf = (log) => {
|
||||
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()
|
||||
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)
|
||||
const legacyType = Number(log.logType) === 2 ? 'image'
|
||||
@ -128,6 +244,80 @@ onMounted(scrollToBottom)
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.task-log__analysis {
|
||||
margin-top: 9px;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid #5b8ff9;
|
||||
background: #f6f8fc;
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
.task-log__analysis-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-log__analysis-meta,
|
||||
.task-log__analysis-field span,
|
||||
.task-log__analysis-label {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.task-log__analysis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.task-log__analysis-field {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.task-log__analysis-field strong {
|
||||
overflow: hidden;
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-log__analysis-summary {
|
||||
margin-top: 9px;
|
||||
color: #344054;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.task-log__analysis-events {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.task-log__analysis-event {
|
||||
margin-top: 3px;
|
||||
color: #475467;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.task-log__analysis-details {
|
||||
max-height: 180px;
|
||||
margin: 9px 0 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
color: #475467;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-log__media {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -158,5 +348,9 @@ onMounted(scrollToBottom)
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.task-log__analysis-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -33,6 +33,9 @@
|
||||
<span class="queue-step__action">{{ actionLabel(step.type) }}</span>
|
||||
</div>
|
||||
<div class="queue-step__actions" @click.stop>
|
||||
<el-tooltip content="单独执行此步骤" placement="top">
|
||||
<el-button :icon="VideoPlay" circle size="small" type="primary" plain aria-label="单独执行此步骤" @click="runStep(index)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="上移一步" placement="top">
|
||||
<el-button :icon="ArrowUp" circle size="small" :disabled="index === 0" aria-label="上移一步" @click="moveStep(index, -1)" />
|
||||
</el-tooltip>
|
||||
@ -141,7 +144,7 @@
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { ArrowDown, ArrowUp, CopyDocument, Delete, Location, MoreFilled, Plus } from '@element-plus/icons-vue'
|
||||
import { ArrowDown, ArrowUp, CopyDocument, Delete, Location, MoreFilled, Plus, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAgvStations, getAgvStatus, getArmJointState, getArmStatus } from '@/api/flow/flow'
|
||||
import { getRobotDevicesByRobotId, getRobotList } from '@/api/inspection/robot'
|
||||
@ -435,6 +438,48 @@ const parseJsonFields = (step) => {
|
||||
return params
|
||||
}
|
||||
|
||||
const buildStandaloneStep = (step, index) => {
|
||||
if (!step?.type) throw new Error(`步骤 ${index + 1} 未选择动作`)
|
||||
const params = parseJsonFields(step)
|
||||
fieldsForType(step.type).filter((field) => field.required !== false).forEach((field) => {
|
||||
if (params[field.key] === '' || params[field.key] == null) {
|
||||
throw new Error(`步骤 ${index + 1} 的${field.label}不能为空`)
|
||||
}
|
||||
})
|
||||
return {
|
||||
stepId: 'step_1',
|
||||
stepName: getActionQueueStepName(step, index),
|
||||
type: step.type,
|
||||
timeoutMs: Number(step.timeoutMs || 0),
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
const runStep = (index) => {
|
||||
try {
|
||||
const step = formData.steps[index]
|
||||
const executionStep = buildStandaloneStep(step, index)
|
||||
const stepTimeoutMs = Math.max(0, Number(executionStep.timeoutMs || 0))
|
||||
document.dispatchEvent(new CustomEvent('singleNodeExecution', {
|
||||
bubbles: false,
|
||||
cancelable: false,
|
||||
detail: {
|
||||
name: getActionQueueStepName(step, index),
|
||||
nodeType: 'EDGE',
|
||||
action: 'SYSTEM_EXECUTE_ACTION_QUEUE',
|
||||
nodeId: `${props.data.id}:queue-step:${step.localKey || index}`,
|
||||
nodeParams: [
|
||||
{ name: 'actionId', type: 'input', input: '', required: false },
|
||||
{ name: 'totalTimeoutMs', type: 'input', componentType: 'number', input: Math.max(1000, stepTimeoutMs + 5000), required: true },
|
||||
{ name: 'steps', type: 'input', input: [executionStep], required: true },
|
||||
],
|
||||
},
|
||||
}))
|
||||
} catch (error) {
|
||||
ElMessage.warning(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const buildQueueDefinition = () => {
|
||||
if (!formData.executionTarget.roleKey) throw new Error('请选择执行机器人角色')
|
||||
if (!formData.steps.length) throw new Error('请添加至少一个动作步骤')
|
||||
|
||||
@ -50,21 +50,51 @@
|
||||
<el-input v-model="formData.mediaUrl" type="textarea" :rows="3" placeholder="请输入 HTTP/HTTPS 媒体地址" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="isVideo" label="补充分析要求">
|
||||
<el-form-item v-if="isVideo" label="通过标准" prop="instruction" required>
|
||||
<el-input
|
||||
v-model="formData.instruction"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
placeholder="可选,例如:判断仪表是否点亮,并说明依据"
|
||||
placeholder="例如:车辆启动后,仪表必须在3秒内点亮"
|
||||
/>
|
||||
<div class="field-hint">未观察到完整证据或置信度不足时,结果按未通过处理。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="isVideo" label="分析模式">
|
||||
<el-segmented v-model="formData.analysisMode" :options="analysisModes" block />
|
||||
<div class="field-hint">自动模式优先快速分析,结果不完整时自动切换精确分析。</div>
|
||||
<el-segmented v-model="formData.analysisMode" :options="analysisModes" block @change="handleAnalysisModeChange" />
|
||||
<div class="field-hint">{{ modeDescription }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-collapse v-if="isVideo" v-model="advancedSections" class="analysis-tuning">
|
||||
<el-collapse-item name="tuning">
|
||||
<template #title>
|
||||
<div class="analysis-tuning__title">
|
||||
<span>高级设置</span>
|
||||
<el-tag v-if="isTuned" size="small" type="warning" effect="plain">已微调</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="analysis-tuning__body">
|
||||
<div class="analysis-tuning__toolbar">
|
||||
<span>当前模式参数</span>
|
||||
<el-button :icon="Refresh" text type="primary" @click.stop="restorePreset">恢复默认</el-button>
|
||||
</div>
|
||||
<el-form-item label="抽取帧数">
|
||||
<el-slider v-model="formData.analysisTuning.maxFrames" :min="2" :max="16" :step="1" show-input />
|
||||
</el-form-item>
|
||||
<el-form-item label="图片最大宽度">
|
||||
<el-slider v-model="formData.analysisTuning.maxWidth" :min="640" :max="1280" :step="64" show-input />
|
||||
</el-form-item>
|
||||
<el-form-item label="通过置信度">
|
||||
<el-slider v-model="formData.analysisTuning.confidenceThreshold" :min="0.5" :max="0.95" :step="0.05" show-input />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="formData.analysisMode === 'AUTO'" label="低置信度转精确分析">
|
||||
<el-switch v-model="formData.analysisTuning.fallbackToAccurate" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-form>
|
||||
|
||||
<el-divider content-position="left">节点输出</el-divider>
|
||||
@ -79,6 +109,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getInput } from '@/utils/flow'
|
||||
|
||||
const props = defineProps({ data: Object })
|
||||
@ -87,6 +118,12 @@ const emit = defineEmits(['save-success', 'save-error'])
|
||||
const formRef = ref()
|
||||
const mediaCascaderRef = ref()
|
||||
const quoteOptions = ref([])
|
||||
const advancedSections = ref([])
|
||||
const analysisPresets = {
|
||||
FAST: { maxFrames: 6, maxWidth: 896, confidenceThreshold: 0.7, fallbackToAccurate: false },
|
||||
AUTO: { maxFrames: 8, maxWidth: 960, confidenceThreshold: 0.75, fallbackToAccurate: true },
|
||||
ACCURATE: { maxFrames: 10, maxWidth: 1120, confidenceThreshold: 0.8, fallbackToAccurate: false },
|
||||
}
|
||||
const formData = reactive({
|
||||
profileCode: '',
|
||||
mediaMode: 'quote',
|
||||
@ -95,6 +132,7 @@ const formData = reactive({
|
||||
mediaUrl: '',
|
||||
instruction: '',
|
||||
analysisMode: 'AUTO',
|
||||
analysisTuning: { ...analysisPresets.AUTO },
|
||||
})
|
||||
|
||||
const isVideo = computed(() => props.data?.properties?.action === 'VIDEO_ANALYZE')
|
||||
@ -109,16 +147,28 @@ const mediaModes = [
|
||||
{ label: '直接输入地址', value: 'input' },
|
||||
]
|
||||
const analysisModes = [
|
||||
{ label: '自动', value: 'AUTO' },
|
||||
{ label: '快速', value: 'FAST' },
|
||||
{ label: '均衡', value: 'AUTO' },
|
||||
{ label: '精确', value: 'ACCURATE' },
|
||||
]
|
||||
const modeDescriptions = {
|
||||
FAST: '使用轻量模型直接判断,适合动作明显、批量执行的场景。',
|
||||
AUTO: '先快速判断,低置信度时自动切换精确分析。',
|
||||
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])
|
||||
})
|
||||
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: '是否通过' },
|
||||
{ name: 'confidence', desc: '判定置信度' },
|
||||
{ name: 'evidenceSufficient', desc: '证据是否充分' },
|
||||
{ name: 'conclusion', desc: '分析结论' },
|
||||
{ name: 'summary', desc: '分析摘要' },
|
||||
{ name: 'events', desc: '关键事件列表' },
|
||||
@ -147,6 +197,46 @@ const rules = {
|
||||
: Promise.reject(new Error('请输入有效的 HTTP/HTTPS 地址')),
|
||||
trigger: 'blur',
|
||||
}],
|
||||
instruction: [{
|
||||
validator: (_, value) => !isVideo.value || String(value || '').trim()
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请输入明确的通过标准')),
|
||||
trigger: 'blur',
|
||||
}],
|
||||
}
|
||||
|
||||
const restorePreset = () => {
|
||||
Object.assign(formData.analysisTuning, analysisPresets[formData.analysisMode] || analysisPresets.AUTO)
|
||||
}
|
||||
|
||||
const handleAnalysisModeChange = () => restorePreset()
|
||||
|
||||
const parseStoredTuning = (value) => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) return value
|
||||
if (typeof value !== 'string' || !value.trim()) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const clamp = (value, minimum, maximum, fallback) => {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, number)) : fallback
|
||||
}
|
||||
|
||||
const normalizeStoredTuning = (value, preset) => {
|
||||
const stored = parseStoredTuning(value)
|
||||
return {
|
||||
maxFrames: Math.round(clamp(stored.maxFrames, 2, 16, preset.maxFrames)),
|
||||
maxWidth: Math.round(clamp(stored.maxWidth, 640, 1280, preset.maxWidth)),
|
||||
confidenceThreshold: clamp(stored.confidenceThreshold, 0.5, 0.95, preset.confidenceThreshold),
|
||||
fallbackToAccurate: typeof stored.fallbackToAccurate === 'boolean'
|
||||
? stored.fallbackToAccurate
|
||||
: preset.fallbackToAccurate,
|
||||
}
|
||||
}
|
||||
|
||||
const loadQuoteOptions = (visible) => {
|
||||
@ -164,13 +254,17 @@ const initData = () => {
|
||||
const media = params.find(item => item.name === mediaName.value)
|
||||
const instruction = params.find(item => item.name === 'instruction')
|
||||
const analysisMode = params.find(item => item.name === 'analysisMode')
|
||||
const analysisTuning = params.find(item => item.name === 'analysisTuning')
|
||||
formData.profileCode = profile?.input || profileOptions.value[0]?.value || ''
|
||||
formData.mediaMode = media?.type === 'input' ? 'input' : 'quote'
|
||||
formData.mediaQuote = Array.isArray(media?.quote) ? [...media.quote] : []
|
||||
formData.mediaQuoteType = media?.quoteType || 'output'
|
||||
formData.mediaUrl = media?.input || ''
|
||||
formData.instruction = instruction?.input || ''
|
||||
formData.analysisMode = analysisMode?.input || 'AUTO'
|
||||
formData.analysisMode = analysisPresets[analysisMode?.input] ? analysisMode.input : 'AUTO'
|
||||
const preset = analysisPresets[formData.analysisMode] || analysisPresets.AUTO
|
||||
Object.assign(formData.analysisTuning, normalizeStoredTuning(analysisTuning?.input, preset))
|
||||
if (formData.analysisMode !== 'AUTO') formData.analysisTuning.fallbackToAccurate = false
|
||||
}
|
||||
|
||||
watch(() => props.data, initData, { immediate: true, deep: true })
|
||||
@ -203,7 +297,7 @@ const validateAndSave = async () => {
|
||||
name: 'instruction',
|
||||
type: 'input',
|
||||
input: formData.instruction.trim(),
|
||||
required: false,
|
||||
required: true,
|
||||
disabled: true,
|
||||
})
|
||||
nodeParams.push({
|
||||
@ -215,6 +309,13 @@ const validateAndSave = async () => {
|
||||
componentType: 'select',
|
||||
selectOptions: analysisModes,
|
||||
})
|
||||
nodeParams.push({
|
||||
name: 'analysisTuning',
|
||||
type: 'input',
|
||||
input: { ...formData.analysisTuning },
|
||||
required: false,
|
||||
disabled: true,
|
||||
})
|
||||
}
|
||||
const properties = { ...props.data.properties, nodeParams }
|
||||
delete properties.executionTarget
|
||||
@ -241,4 +342,12 @@ defineExpose({ validateAndSave })
|
||||
.output-list { display: grid; gap: 8px; }
|
||||
.output-item { display: flex; gap: 12px; align-items: baseline; color: var(--el-text-color-regular); font-size: 13px; }
|
||||
.output-item code { min-width: 92px; color: var(--el-color-primary); }
|
||||
.analysis-tuning { margin-bottom: 18px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.analysis-tuning :deep(.el-collapse-item__header) { min-height: 48px; height: auto; }
|
||||
.analysis-tuning :deep(.el-collapse-item__content) { padding-bottom: 4px; }
|
||||
.analysis-tuning__title { display: flex; min-width: 0; align-items: center; gap: 8px; font-weight: 650; }
|
||||
.analysis-tuning__body { padding: 4px 2px 0; }
|
||||
.analysis-tuning__toolbar { display: flex; min-height: 32px; margin-bottom: 10px; align-items: center; justify-content: space-between; color: var(--el-text-color-secondary); font-size: 12px; }
|
||||
.analysis-tuning :deep(.el-slider) { --el-slider-button-size: 16px; }
|
||||
.analysis-tuning :deep(.el-slider__runway.show-input) { margin-right: 18px; }
|
||||
</style>
|
||||
|
||||
@ -522,12 +522,16 @@ function handler(params) {
|
||||
nodeParams: [
|
||||
{ name: "profileCode", type: "input", input: "aima.power_video.v1", required: true, disabled: true },
|
||||
{ name: "videoUrl", type: "quote", input: "", quote: "", required: true, disabled: true },
|
||||
{ name: "instruction", type: "input", input: "", required: false, disabled: true },
|
||||
{ name: "instruction", type: "input", input: "", required: true, disabled: true },
|
||||
{ name: "analysisMode", type: "input", input: "AUTO", required: false, disabled: true },
|
||||
{ name: "analysisTuning", type: "input", input: { maxFrames: 8, maxWidth: 960, confidenceThreshold: 0.75, fallbackToAccurate: true }, required: false, disabled: true },
|
||||
],
|
||||
outputType: "json",
|
||||
outputParams: [
|
||||
{ name: "passed", type: "boolean", desc: "是否通过", disabled: true },
|
||||
{ name: "confidence", type: "number", desc: "判定置信度", disabled: true },
|
||||
{ name: "evidenceSufficient", type: "boolean", desc: "证据是否充分", disabled: true },
|
||||
{ name: "decisionReason", type: "string", desc: "判定原因", disabled: true },
|
||||
{ name: "conclusion", type: "string", desc: "分析结论", disabled: true },
|
||||
{ name: "summary", type: "string", desc: "分析摘要", disabled: true },
|
||||
{ name: "events", type: "array<object>", desc: "关键事件", children: [], disabled: true },
|
||||
|
||||
@ -185,6 +185,8 @@
|
||||
:node-params="formData.nodeParams"
|
||||
:robot-options="singleNodeRobotOptions"
|
||||
:initial-robot-id="singleNodeInitialRobotId"
|
||||
:executing-step-index="singleQueueStepExecutingIndex"
|
||||
@execute-step="executeActionQueueStep"
|
||||
/>
|
||||
<el-form
|
||||
v-else
|
||||
@ -1107,6 +1109,7 @@ const rules = reactive({});
|
||||
const executeForm = ref();
|
||||
const nodeAction = ref("");
|
||||
const singleNodeExecuting = ref(false);
|
||||
const singleQueueStepExecutingIndex = ref(-1);
|
||||
const activeSingleNodeId = ref("");
|
||||
// 单节点机器人选择按节点缓存;新节点默认沿用最近一次选择。
|
||||
const singleNodeRobotCache = new Map();
|
||||
@ -1277,6 +1280,7 @@ const singleNodeExecution = async (e) => {
|
||||
visible.value = true;
|
||||
nodeAction.value = normalizedAction;
|
||||
singleNodeExecuting.value = false;
|
||||
singleQueueStepExecutingIndex.value = -1;
|
||||
stationLoading.value = false;
|
||||
if (effectiveNodeType === "EDGE") {
|
||||
await loadSingleNodeRobots();
|
||||
@ -1372,6 +1376,26 @@ const execute = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const executeActionQueueStep = async (index) => {
|
||||
if (!actionQueueSingleRunRef.value?.validateStep?.(index)) return;
|
||||
rememberSingleNodeRobot();
|
||||
singleNodeExecuting.value = true;
|
||||
singleQueueStepExecutingIndex.value = index;
|
||||
try {
|
||||
const res = await flowAction({
|
||||
action: "SYSTEM_EXECUTE_ACTION_QUEUE",
|
||||
payload: actionQueueSingleRunRef.value.getStepPayload(index),
|
||||
});
|
||||
if (res.code === 200) ElMessage.success("当前步骤执行成功");
|
||||
else ElMessage.error(res.msg);
|
||||
} catch (error) {
|
||||
console.error("动作队列步骤执行失败:", error);
|
||||
} finally {
|
||||
singleNodeExecuting.value = false;
|
||||
singleQueueStepExecutingIndex.value = -1;
|
||||
}
|
||||
};
|
||||
|
||||
const showParamsDrawer = ref(false);
|
||||
const paramsDrawerData = ref({});
|
||||
|
||||
|
||||
@ -142,7 +142,7 @@
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="任务执行日志" v-model="logDialogVisible" width="500px" append-to-body>
|
||||
<taskLog :list="logDialogData" :height="300" />
|
||||
<taskLog v-loading="logDialogLoading" :list="logDialogData" :height="300" />
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="本次执行安排" v-model="startDialogVisible" width="820px" append-to-body>
|
||||
@ -175,6 +175,7 @@ import { addRunTask, getRunTaskList, startRunTask, rerunRunTask, pauseRunTask, s
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { formatDate } from "@/utils/index";
|
||||
import { debounce } from 'lodash'; // 引入 lodash 的防抖函数
|
||||
import { fetchAllPagedRows } from '@/utils/pagedRequest'
|
||||
import RuntimeAssignmentsEditor from '@/views/flow/components/RuntimeAssignmentsEditor.vue'
|
||||
import RuntimeParamsEditor from '@/views/flow/components/RuntimeParamsEditor.vue'
|
||||
import { getFlowParamsApi } from '@/api/device/terminal.js'
|
||||
@ -319,7 +320,7 @@ const taskLogList = ref({})
|
||||
* @param taskInstanceId
|
||||
*/
|
||||
const getTaskLogList = async (taskInstanceId) => {
|
||||
const res = await getTaskLogListApi({ taskInstanceId })
|
||||
const res = await fetchAllPagedRows(getTaskLogListApi, { taskInstanceId })
|
||||
if (res.code === 200) {
|
||||
taskLogList.value[taskInstanceId] = res.rows
|
||||
}
|
||||
@ -547,12 +548,21 @@ const subscribeDebounced = debounce(async (sub) => {
|
||||
|
||||
const logDialogVisible = ref(false)
|
||||
const logDialogData = ref([])
|
||||
const logDialogLoading = ref(false)
|
||||
let logDialogRequestId = 0
|
||||
const handleOpenLog = async (row) => {
|
||||
const requestId = ++logDialogRequestId
|
||||
logDialogVisible.value = true
|
||||
const res = await getTaskLogListApi({ taskInstanceId: row.id })
|
||||
if (res.code === 200) {
|
||||
logDialogData.value = []
|
||||
logDialogLoading.value = true
|
||||
try {
|
||||
const res = await fetchAllPagedRows(getTaskLogListApi, { taskInstanceId: row.id })
|
||||
if (requestId === logDialogRequestId && res.code === 200) {
|
||||
logDialogData.value = res.rows
|
||||
}
|
||||
} finally {
|
||||
if (requestId === logDialogRequestId) logDialogLoading.value = false
|
||||
}
|
||||
}
|
||||
const refreshAfterSocketReconnect = () => {
|
||||
getList()
|
||||
|
||||
@ -154,7 +154,7 @@
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="任务执行日志" v-model="logDialogVisible" width="500px" append-to-body>
|
||||
<taskLog :list="logDialogData" :height="300" />
|
||||
<taskLog v-loading="logDialogLoading" :list="logDialogData" :height="300" />
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="本次执行安排" v-model="startDialogVisible" width="820px" append-to-body>
|
||||
@ -176,6 +176,7 @@
|
||||
import SvgIcon from "@/components/SvgIcon";
|
||||
import taskSteps from "./task-steps.vue";
|
||||
import taskLog from "./task-log.vue";
|
||||
import { fetchAllPagedRows } from '@/utils/pagedRequest'
|
||||
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||
import { getTaskList, getTestCaseByTaskId } from "@/api/intelligenceTest/taskManage";
|
||||
import { getPhoneList } from "@/api/intelligenceTest/mobilePhone";
|
||||
@ -362,7 +363,7 @@ const taskLogList = ref({})
|
||||
* @param taskInstanceId
|
||||
*/
|
||||
const getTaskLogList = async (taskInstanceId) => {
|
||||
const res = await getTaskLogListApi({ taskInstanceId })
|
||||
const res = await fetchAllPagedRows(getTaskLogListApi, { taskInstanceId })
|
||||
if (res.code === 200) {
|
||||
taskLogList.value[taskInstanceId] = res.rows
|
||||
}
|
||||
@ -589,12 +590,21 @@ const subscribeDebounced = debounce(async (sub) => {
|
||||
|
||||
const logDialogVisible = ref(false)
|
||||
const logDialogData = ref([])
|
||||
const logDialogLoading = ref(false)
|
||||
let logDialogRequestId = 0
|
||||
const handleOpenLog = async (row) => {
|
||||
const requestId = ++logDialogRequestId
|
||||
logDialogVisible.value = true
|
||||
const res = await getTaskLogListApi({ taskInstanceId: row.id })
|
||||
if (res.code === 200) {
|
||||
logDialogData.value = []
|
||||
logDialogLoading.value = true
|
||||
try {
|
||||
const res = await fetchAllPagedRows(getTaskLogListApi, { taskInstanceId: row.id })
|
||||
if (requestId === logDialogRequestId && res.code === 200) {
|
||||
logDialogData.value = res.rows
|
||||
}
|
||||
} finally {
|
||||
if (requestId === logDialogRequestId) logDialogLoading.value = false
|
||||
}
|
||||
}
|
||||
const refreshAfterSocketReconnect = () => {
|
||||
getList()
|
||||
|
||||
@ -166,7 +166,7 @@
|
||||
@close="cancel"
|
||||
@open="getLogList"
|
||||
>
|
||||
<el-timeline>
|
||||
<el-timeline v-loading="logLoading">
|
||||
<el-timeline-item
|
||||
v-for="(item, index) in logList"
|
||||
:key="index.id"
|
||||
@ -246,6 +246,7 @@ import TableTags from "@/components/TableTags/index.vue";
|
||||
import { appendParamsToPath } from "@/utils/fn.js";
|
||||
import { getTaskConfig } from "@/api/test/config.js";
|
||||
import { onMounted } from "vue";
|
||||
import { fetchAllPagedRows } from '@/utils/pagedRequest'
|
||||
|
||||
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||
|
||||
@ -282,7 +283,7 @@ const data = reactive({
|
||||
},
|
||||
logQueryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 100000,
|
||||
pageSize: 200,
|
||||
instId: null,
|
||||
},
|
||||
rules: {},
|
||||
@ -339,7 +340,7 @@ function cancel() {
|
||||
function reset() {
|
||||
logQueryParams.value = {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
pageSize: 200,
|
||||
instId: null,
|
||||
};
|
||||
}
|
||||
@ -430,13 +431,18 @@ function handleExport() {
|
||||
}
|
||||
|
||||
/** 查询实例日志列表 */
|
||||
function getLogList() {
|
||||
async function getLogList() {
|
||||
logLoading.value = true;
|
||||
getInst(logQueryParams.value).then((response) => {
|
||||
logList.value = [];
|
||||
try {
|
||||
const response = await fetchAllPagedRows(getInst, {
|
||||
instId: logQueryParams.value.instId,
|
||||
});
|
||||
logList.value = response.rows;
|
||||
logTotal.value = response.total;
|
||||
} finally {
|
||||
logLoading.value = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenLogDialog(row) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user