feat(flow): 添加动作队列步骤独立执行功能和媒体分析增强

- 在动作队列参数组件中添加单独执行按钮,允许独立运行指定步骤
- 实现步骤验证和构建逻辑,支持单步执行的参数处理
- 更新媒体分析参数界面,添加高级设置和参数微调功能
- 增加视频分析的置信度、通过标准等配置选项
- 优化任务执行日志显示,添加分析结果的结构化展示
- 集成分页请求工具,改进日志加载性能和用户体验
- 添加执行步骤索引跟踪,确保执行状态正确管理
- 实现防重复ID机制,保证分页数据完整性
- 新增分析结果解析和可视化展示功能
- 扩展媒体分析类型的处理和结果显示
This commit is contained in:
lixiaolong 2026-08-17 16:05:42 +08:00
parent cad4184a2f
commit 6be8bc9bfd
10 changed files with 511 additions and 27 deletions

45
src/utils/pagedRequest.js Normal file
View 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} 条,未能完整加载`)
}

View File

@ -15,6 +15,19 @@
<div class="single-queue-step__header"> <div class="single-queue-step__header">
<div class="single-queue-step__title">{{ getActionQueueStepName(step, index) }} · {{ actionName(step.type) }}</div> <div class="single-queue-step__title">{{ getActionQueueStepName(step, index) }} · {{ actionName(step.type) }}</div>
<div class="single-queue-step__actions"> <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-tooltip content="上移一步" placement="top">
<el-button :icon="ArrowUp" circle size="small" :disabled="index === 0" aria-label="上移一步" @click="moveStep(index, -1)" /> <el-button :icon="ArrowUp" circle size="small" :disabled="index === 0" aria-label="上移一步" @click="moveStep(index, -1)" />
</el-tooltip> </el-tooltip>
@ -44,7 +57,7 @@
<script setup> <script setup>
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus' 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 { getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { DEVICE_KIND } from '@/utils/robotDevice' import { DEVICE_KIND } from '@/utils/robotDevice'
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep, toActionQueueExecutionSteps } from '@/utils/actionQueue' import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep, toActionQueueExecutionSteps } from '@/utils/actionQueue'
@ -57,7 +70,9 @@ const props = defineProps({
nodeParams: { type: Array, default: () => [] }, nodeParams: { type: Array, default: () => [] },
robotOptions: { type: Array, default: () => [] }, robotOptions: { type: Array, default: () => [] },
initialRobotId: { type: String, default: '' }, initialRobotId: { type: String, default: '' },
executingStepIndex: { type: Number, default: -1 },
}) })
const emit = defineEmits(['execute-step'])
/** 动作类型到中文名称的映射,作用范围为当前组件的步骤标题展示。 */ /** 动作类型到中文名称的映射,作用范围为当前组件的步骤标题展示。 */
const labels = { const labels = {
@ -178,6 +193,17 @@ const validate = () => {
return true 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 = () => ({ const getPayload = () => ({
robotId: robotId.value, robotId: robotId.value,
@ -186,6 +212,17 @@ const getPayload = () => ({
steps: toActionQueueExecutionSteps(steps.value), 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,供父组件处理兼容字段或运行上下文。 */ /** 返回当前选中的机器人 ID,供父组件处理兼容字段或运行上下文。 */
const getRobotId = () => robotId.value const getRobotId = () => robotId.value
@ -193,7 +230,7 @@ const getRobotId = () => robotId.value
watch(() => [props.nodeParams, props.initialRobotId], initData, { immediate: true, deep: true }) watch(() => [props.nodeParams, props.initialRobotId], initData, { immediate: true, deep: true })
/** 向父组件开放提交前校验、载荷读取和机器人 ID 读取能力。 */ /** 向父组件开放提交前校验、载荷读取和机器人 ID 读取能力。 */
defineExpose({ validate, getPayload, getRobotId }) defineExpose({ validate, validateStep, getPayload, getStepPayload, getRobotId })
</script> </script>
<style scoped> <style scoped>

View File

@ -4,6 +4,47 @@
<div class="task-log__time">{{ formatDate(log.logTime) }}</div> <div class="task-log__time">{{ formatDate(log.logTime) }}</div>
<div class="task-log__body"> <div class="task-log__body">
<div class="task-log__content">{{ log.logContent }}</div> <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"> <div v-if="mediaOf(log).length" class="task-log__media">
<template v-for="media in mediaOf(log)" :key="`${media.type}:${media.url}`"> <template v-for="media in mediaOf(log)" :key="`${media.type}:${media.url}`">
<el-image <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) => { const mediaOf = (log) => {
if (!log || typeof log !== 'object') return [] if (!log || typeof log !== 'object') return []
if (mediaCache.has(log)) return mediaCache.get(log) if (mediaCache.has(log)) return mediaCache.get(log)
@ -70,6 +182,10 @@ const mediaOf = (log) => {
addMedia(result, seen, 'image', output.imageUrl) addMedia(result, seen, 'image', output.imageUrl)
addMedia(result, seen, 'video', output.videoUrl) addMedia(result, seen, 'video', output.videoUrl)
addMedia(result, seen, 'audio', output.audioUrl) 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, 'image', log.screenshotUrl)
addMedia(result, seen, 'video', log.videoUrl) addMedia(result, seen, 'video', log.videoUrl)
const legacyType = Number(log.logType) === 2 ? 'image' const legacyType = Number(log.logType) === 2 ? 'image'
@ -128,6 +244,80 @@ onMounted(scrollToBottom)
line-height: 20px; 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 { .task-log__media {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -158,5 +348,9 @@ onMounted(scrollToBottom)
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 3px; gap: 3px;
} }
.task-log__analysis-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
</style> </style>

View File

@ -33,6 +33,9 @@
<span class="queue-step__action">{{ actionLabel(step.type) }}</span> <span class="queue-step__action">{{ actionLabel(step.type) }}</span>
</div> </div>
<div class="queue-step__actions" @click.stop> <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-tooltip content="上移一步" placement="top">
<el-button :icon="ArrowUp" circle size="small" :disabled="index === 0" aria-label="上移一步" @click="moveStep(index, -1)" /> <el-button :icon="ArrowUp" circle size="small" :disabled="index === 0" aria-label="上移一步" @click="moveStep(index, -1)" />
</el-tooltip> </el-tooltip>
@ -141,7 +144,7 @@
<script setup> <script setup>
import { reactive, ref, watch } from 'vue' 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 { ElMessage } from 'element-plus'
import { getAgvStations, getAgvStatus, getArmJointState, getArmStatus } from '@/api/flow/flow' import { getAgvStations, getAgvStatus, getArmJointState, getArmStatus } from '@/api/flow/flow'
import { getRobotDevicesByRobotId, getRobotList } from '@/api/inspection/robot' import { getRobotDevicesByRobotId, getRobotList } from '@/api/inspection/robot'
@ -435,6 +438,48 @@ const parseJsonFields = (step) => {
return params 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 = () => { const buildQueueDefinition = () => {
if (!formData.executionTarget.roleKey) throw new Error('请选择执行机器人角色') if (!formData.executionTarget.roleKey) throw new Error('请选择执行机器人角色')
if (!formData.steps.length) throw new Error('请添加至少一个动作步骤') if (!formData.steps.length) throw new Error('请添加至少一个动作步骤')

View File

@ -50,21 +50,51 @@
<el-input v-model="formData.mediaUrl" type="textarea" :rows="3" placeholder="请输入 HTTP/HTTPS 媒体地址" /> <el-input v-model="formData.mediaUrl" type="textarea" :rows="3" placeholder="请输入 HTTP/HTTPS 媒体地址" />
</el-form-item> </el-form-item>
<el-form-item v-if="isVideo" label="补充分析要求"> <el-form-item v-if="isVideo" label="通过标准" prop="instruction" required>
<el-input <el-input
v-model="formData.instruction" v-model="formData.instruction"
type="textarea" type="textarea"
:rows="4" :rows="4"
maxlength="2000" maxlength="2000"
show-word-limit show-word-limit
placeholder="可选,例如:判断仪表是否点亮,并说明依据" placeholder="例如:车辆启动后,仪表必须在3秒内点亮"
/> />
<div class="field-hint">未观察到完整证据或置信度不足时,结果按未通过处理。</div>
</el-form-item> </el-form-item>
<el-form-item v-if="isVideo" label="分析模式"> <el-form-item v-if="isVideo" label="分析模式">
<el-segmented v-model="formData.analysisMode" :options="analysisModes" block /> <el-segmented v-model="formData.analysisMode" :options="analysisModes" block @change="handleAnalysisModeChange" />
<div class="field-hint">自动模式优先快速分析,结果不完整时自动切换精确分析。</div> <div class="field-hint">{{ modeDescription }}</div>
</el-form-item> </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-form>
<el-divider content-position="left">节点输出</el-divider> <el-divider content-position="left">节点输出</el-divider>
@ -79,6 +109,7 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import { getInput } from '@/utils/flow' import { getInput } from '@/utils/flow'
const props = defineProps({ data: Object }) const props = defineProps({ data: Object })
@ -87,6 +118,12 @@ const emit = defineEmits(['save-success', 'save-error'])
const formRef = ref() const formRef = ref()
const mediaCascaderRef = ref() const mediaCascaderRef = ref()
const quoteOptions = 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({ const formData = reactive({
profileCode: '', profileCode: '',
mediaMode: 'quote', mediaMode: 'quote',
@ -95,6 +132,7 @@ const formData = reactive({
mediaUrl: '', mediaUrl: '',
instruction: '', instruction: '',
analysisMode: 'AUTO', analysisMode: 'AUTO',
analysisTuning: { ...analysisPresets.AUTO },
}) })
const isVideo = computed(() => props.data?.properties?.action === 'VIDEO_ANALYZE') const isVideo = computed(() => props.data?.properties?.action === 'VIDEO_ANALYZE')
@ -109,16 +147,28 @@ const mediaModes = [
{ label: '直接输入地址', value: 'input' }, { label: '直接输入地址', value: 'input' },
] ]
const analysisModes = [ const analysisModes = [
{ label: '自动', value: 'AUTO' },
{ label: '快速', value: 'FAST' }, { label: '快速', value: 'FAST' },
{ label: '均衡', value: 'AUTO' },
{ label: '精确', value: 'ACCURATE' }, { 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 const profileOptions = computed(() => isVideo.value
? [{ label: '爱玛测试视频分析', value: 'aima.power_video.v1' }] ? [{ label: '爱玛测试视频分析', value: 'aima.power_video.v1' }]
: [{ label: '爱玛车辆声音事件识别', value: 'aima.power_state.v1' }]) : [{ label: '爱玛车辆声音事件识别', value: 'aima.power_state.v1' }])
const outputSummary = computed(() => isVideo.value const outputSummary = computed(() => isVideo.value
? [ ? [
{ name: 'passed', desc: '是否通过' }, { name: 'passed', desc: '是否通过' },
{ name: 'confidence', desc: '判定置信度' },
{ name: 'evidenceSufficient', desc: '证据是否充分' },
{ name: 'conclusion', desc: '分析结论' }, { name: 'conclusion', desc: '分析结论' },
{ name: 'summary', desc: '分析摘要' }, { name: 'summary', desc: '分析摘要' },
{ name: 'events', desc: '关键事件列表' }, { name: 'events', desc: '关键事件列表' },
@ -147,6 +197,46 @@ const rules = {
: Promise.reject(new Error('请输入有效的 HTTP/HTTPS 地址')), : Promise.reject(new Error('请输入有效的 HTTP/HTTPS 地址')),
trigger: 'blur', 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) => { const loadQuoteOptions = (visible) => {
@ -164,13 +254,17 @@ const initData = () => {
const media = params.find(item => item.name === mediaName.value) const media = params.find(item => item.name === mediaName.value)
const instruction = params.find(item => item.name === 'instruction') const instruction = params.find(item => item.name === 'instruction')
const analysisMode = params.find(item => item.name === 'analysisMode') 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.profileCode = profile?.input || profileOptions.value[0]?.value || ''
formData.mediaMode = media?.type === 'input' ? 'input' : 'quote' formData.mediaMode = media?.type === 'input' ? 'input' : 'quote'
formData.mediaQuote = Array.isArray(media?.quote) ? [...media.quote] : [] formData.mediaQuote = Array.isArray(media?.quote) ? [...media.quote] : []
formData.mediaQuoteType = media?.quoteType || 'output' formData.mediaQuoteType = media?.quoteType || 'output'
formData.mediaUrl = media?.input || '' formData.mediaUrl = media?.input || ''
formData.instruction = instruction?.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 }) watch(() => props.data, initData, { immediate: true, deep: true })
@ -203,7 +297,7 @@ const validateAndSave = async () => {
name: 'instruction', name: 'instruction',
type: 'input', type: 'input',
input: formData.instruction.trim(), input: formData.instruction.trim(),
required: false, required: true,
disabled: true, disabled: true,
}) })
nodeParams.push({ nodeParams.push({
@ -215,6 +309,13 @@ const validateAndSave = async () => {
componentType: 'select', componentType: 'select',
selectOptions: analysisModes, selectOptions: analysisModes,
}) })
nodeParams.push({
name: 'analysisTuning',
type: 'input',
input: { ...formData.analysisTuning },
required: false,
disabled: true,
})
} }
const properties = { ...props.data.properties, nodeParams } const properties = { ...props.data.properties, nodeParams }
delete properties.executionTarget delete properties.executionTarget
@ -241,4 +342,12 @@ defineExpose({ validateAndSave })
.output-list { display: grid; gap: 8px; } .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 { 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); } .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> </style>

View File

@ -522,12 +522,16 @@ function handler(params) {
nodeParams: [ nodeParams: [
{ name: "profileCode", type: "input", input: "aima.power_video.v1", required: true, disabled: true }, { name: "profileCode", type: "input", input: "aima.power_video.v1", required: true, disabled: true },
{ name: "videoUrl", type: "quote", input: "", quote: "", 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: "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", outputType: "json",
outputParams: [ outputParams: [
{ name: "passed", type: "boolean", desc: "是否通过", disabled: true }, { 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: "conclusion", type: "string", desc: "分析结论", disabled: true },
{ name: "summary", type: "string", desc: "分析摘要", disabled: true }, { name: "summary", type: "string", desc: "分析摘要", disabled: true },
{ name: "events", type: "array<object>", desc: "关键事件", children: [], disabled: true }, { name: "events", type: "array<object>", desc: "关键事件", children: [], disabled: true },

View File

@ -185,6 +185,8 @@
:node-params="formData.nodeParams" :node-params="formData.nodeParams"
:robot-options="singleNodeRobotOptions" :robot-options="singleNodeRobotOptions"
:initial-robot-id="singleNodeInitialRobotId" :initial-robot-id="singleNodeInitialRobotId"
:executing-step-index="singleQueueStepExecutingIndex"
@execute-step="executeActionQueueStep"
/> />
<el-form <el-form
v-else v-else
@ -1107,6 +1109,7 @@ const rules = reactive({});
const executeForm = ref(); const executeForm = ref();
const nodeAction = ref(""); const nodeAction = ref("");
const singleNodeExecuting = ref(false); const singleNodeExecuting = ref(false);
const singleQueueStepExecutingIndex = ref(-1);
const activeSingleNodeId = ref(""); const activeSingleNodeId = ref("");
// 单节点机器人选择按节点缓存;新节点默认沿用最近一次选择。 // 单节点机器人选择按节点缓存;新节点默认沿用最近一次选择。
const singleNodeRobotCache = new Map(); const singleNodeRobotCache = new Map();
@ -1277,6 +1280,7 @@ const singleNodeExecution = async (e) => {
visible.value = true; visible.value = true;
nodeAction.value = normalizedAction; nodeAction.value = normalizedAction;
singleNodeExecuting.value = false; singleNodeExecuting.value = false;
singleQueueStepExecutingIndex.value = -1;
stationLoading.value = false; stationLoading.value = false;
if (effectiveNodeType === "EDGE") { if (effectiveNodeType === "EDGE") {
await loadSingleNodeRobots(); 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 showParamsDrawer = ref(false);
const paramsDrawerData = ref({}); const paramsDrawerData = ref({});

View File

@ -142,7 +142,7 @@
</el-dialog> </el-dialog>
<el-dialog title="任务执行日志" v-model="logDialogVisible" width="500px" append-to-body> <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>
<el-dialog title="本次执行安排" v-model="startDialogVisible" width="820px" append-to-body> <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 { ElMessage, ElMessageBox } from "element-plus";
import { formatDate } from "@/utils/index"; import { formatDate } from "@/utils/index";
import { debounce } from 'lodash'; // 引入 lodash 的防抖函数 import { debounce } from 'lodash'; // 引入 lodash 的防抖函数
import { fetchAllPagedRows } from '@/utils/pagedRequest'
import RuntimeAssignmentsEditor from '@/views/flow/components/RuntimeAssignmentsEditor.vue' import RuntimeAssignmentsEditor from '@/views/flow/components/RuntimeAssignmentsEditor.vue'
import RuntimeParamsEditor from '@/views/flow/components/RuntimeParamsEditor.vue' import RuntimeParamsEditor from '@/views/flow/components/RuntimeParamsEditor.vue'
import { getFlowParamsApi } from '@/api/device/terminal.js' import { getFlowParamsApi } from '@/api/device/terminal.js'
@ -319,7 +320,7 @@ const taskLogList = ref({})
* @param taskInstanceId * @param taskInstanceId
*/ */
const getTaskLogList = async (taskInstanceId) => { const getTaskLogList = async (taskInstanceId) => {
const res = await getTaskLogListApi({ taskInstanceId }) const res = await fetchAllPagedRows(getTaskLogListApi, { taskInstanceId })
if (res.code === 200) { if (res.code === 200) {
taskLogList.value[taskInstanceId] = res.rows taskLogList.value[taskInstanceId] = res.rows
} }
@ -547,11 +548,20 @@ const subscribeDebounced = debounce(async (sub) => {
const logDialogVisible = ref(false) const logDialogVisible = ref(false)
const logDialogData = ref([]) const logDialogData = ref([])
const logDialogLoading = ref(false)
let logDialogRequestId = 0
const handleOpenLog = async (row) => { const handleOpenLog = async (row) => {
const requestId = ++logDialogRequestId
logDialogVisible.value = true logDialogVisible.value = true
const res = await getTaskLogListApi({ taskInstanceId: row.id }) logDialogData.value = []
if (res.code === 200) { logDialogLoading.value = true
logDialogData.value = res.rows 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 = () => { const refreshAfterSocketReconnect = () => {

View File

@ -154,7 +154,7 @@
</el-dialog> </el-dialog>
<el-dialog title="任务执行日志" v-model="logDialogVisible" width="500px" append-to-body> <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>
<el-dialog title="本次执行安排" v-model="startDialogVisible" width="820px" append-to-body> <el-dialog title="本次执行安排" v-model="startDialogVisible" width="820px" append-to-body>
@ -176,6 +176,7 @@
import SvgIcon from "@/components/SvgIcon"; import SvgIcon from "@/components/SvgIcon";
import taskSteps from "./task-steps.vue"; import taskSteps from "./task-steps.vue";
import taskLog from "./task-log.vue"; import taskLog from "./task-log.vue";
import { fetchAllPagedRows } from '@/utils/pagedRequest'
import { useContainerHeight } from "@/hooks/tableHeight"; import { useContainerHeight } from "@/hooks/tableHeight";
import { getTaskList, getTestCaseByTaskId } from "@/api/intelligenceTest/taskManage"; import { getTaskList, getTestCaseByTaskId } from "@/api/intelligenceTest/taskManage";
import { getPhoneList } from "@/api/intelligenceTest/mobilePhone"; import { getPhoneList } from "@/api/intelligenceTest/mobilePhone";
@ -362,7 +363,7 @@ const taskLogList = ref({})
* @param taskInstanceId * @param taskInstanceId
*/ */
const getTaskLogList = async (taskInstanceId) => { const getTaskLogList = async (taskInstanceId) => {
const res = await getTaskLogListApi({ taskInstanceId }) const res = await fetchAllPagedRows(getTaskLogListApi, { taskInstanceId })
if (res.code === 200) { if (res.code === 200) {
taskLogList.value[taskInstanceId] = res.rows taskLogList.value[taskInstanceId] = res.rows
} }
@ -589,11 +590,20 @@ const subscribeDebounced = debounce(async (sub) => {
const logDialogVisible = ref(false) const logDialogVisible = ref(false)
const logDialogData = ref([]) const logDialogData = ref([])
const logDialogLoading = ref(false)
let logDialogRequestId = 0
const handleOpenLog = async (row) => { const handleOpenLog = async (row) => {
const requestId = ++logDialogRequestId
logDialogVisible.value = true logDialogVisible.value = true
const res = await getTaskLogListApi({ taskInstanceId: row.id }) logDialogData.value = []
if (res.code === 200) { logDialogLoading.value = true
logDialogData.value = res.rows 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 = () => { const refreshAfterSocketReconnect = () => {

View File

@ -166,7 +166,7 @@
@close="cancel" @close="cancel"
@open="getLogList" @open="getLogList"
> >
<el-timeline> <el-timeline v-loading="logLoading">
<el-timeline-item <el-timeline-item
v-for="(item, index) in logList" v-for="(item, index) in logList"
:key="index.id" :key="index.id"
@ -246,6 +246,7 @@ import TableTags from "@/components/TableTags/index.vue";
import { appendParamsToPath } from "@/utils/fn.js"; import { appendParamsToPath } from "@/utils/fn.js";
import { getTaskConfig } from "@/api/test/config.js"; import { getTaskConfig } from "@/api/test/config.js";
import { onMounted } from "vue"; import { onMounted } from "vue";
import { fetchAllPagedRows } from '@/utils/pagedRequest'
import { useContainerHeight } from "@/hooks/tableHeight"; import { useContainerHeight } from "@/hooks/tableHeight";
@ -282,7 +283,7 @@ const data = reactive({
}, },
logQueryParams: { logQueryParams: {
pageNum: 1, pageNum: 1,
pageSize: 100000, pageSize: 200,
instId: null, instId: null,
}, },
rules: {}, rules: {},
@ -339,7 +340,7 @@ function cancel() {
function reset() { function reset() {
logQueryParams.value = { logQueryParams.value = {
pageNum: 1, pageNum: 1,
pageSize: 10, pageSize: 200,
instId: null, instId: null,
}; };
} }
@ -430,13 +431,18 @@ function handleExport() {
} }
/** 查询实例日志列表 */ /** 查询实例日志列表 */
function getLogList() { async function getLogList() {
logLoading.value = true; logLoading.value = true;
getInst(logQueryParams.value).then((response) => { logList.value = [];
try {
const response = await fetchAllPagedRows(getInst, {
instId: logQueryParams.value.instId,
});
logList.value = response.rows; logList.value = response.rows;
logTotal.value = response.total; logTotal.value = response.total;
} finally {
logLoading.value = false; logLoading.value = false;
}); }
} }
function handleOpenLogDialog(row) { function handleOpenLogDialog(row) {