lxl-dev #3

Merged
zhanghao merged 122 commits from lxl-dev into dev 2026-08-24 13:55:18 +08:00
6 changed files with 256 additions and 18 deletions
Showing only changes of commit eb056f097c - Show all commits

View File

@ -16,3 +16,11 @@ export function status(data) {
data: data data: data
}) })
} }
export function clearFault(params) {
return request({
url: '/api/arm/clearFault',
method: 'get',
params
})
}

View File

@ -1,8 +1,33 @@
<script setup> <script setup>
import { reactive, toRefs, onUnmounted, ref, onMounted } from "vue"; import { computed, reactive, toRefs, onUnmounted, ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import { CircleClose } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { clearFault as clearArmFault } from "@/api/device/arm";
import UrdfViewer from "./components/UrdfViewer.vue"; import UrdfViewer from "./components/UrdfViewer.vue";
import JointControl from "./components/JointControl.vue"; import JointControl from "./components/JointControl.vue";
const route = useRoute();
const robotId = computed(() => String(route.query.robotId || ""));
const deviceId = computed(() => String(route.query.deviceId || ""));
const clearingFault = ref(false);
const handleClearFault = async () => {
if (!robotId.value || !deviceId.value) {
ElMessage.warning("缺少机器人或机械臂设备信息");
return;
}
clearingFault.value = true;
try {
await clearArmFault({ robotId: robotId.value, deviceId: deviceId.value });
ElMessage.success("机械臂故障状态已清除");
} catch (error) {
console.error("清除机械臂故障失败:", error);
} finally {
clearingFault.value = false;
}
};
const data = reactive({ const data = reactive({
form: { form: {
x: 11, x: 11,
@ -160,6 +185,15 @@ onUnmounted(() => {
>零位姿态</el-button >零位姿态</el-button
> >
<el-button @click="handleResets('init')">初始位置</el-button> <el-button @click="handleResets('init')">初始位置</el-button>
<el-button
type="danger"
plain
:loading="clearingFault"
:disabled="!robotId || !deviceId"
@click="handleClearFault"
>
<el-icon><CircleClose /></el-icon>清除故障
</el-button>
</div> </div>
<div class="pose-info flex align-center"> <div class="pose-info flex align-center">
<div class="pose-item">X: {{ form.x.toFixed(6) }} m</div> <div class="pose-item">X: {{ form.x.toFixed(6) }} m</div>

View File

@ -17,8 +17,12 @@
</span> </span>
</div> </div>
<div class="task-log__analysis-grid"> <div class="task-log__analysis-grid">
<div v-if="analysisOf(log).targetLabel" class="task-log__analysis-field">
<span>目标声音</span>
<strong>{{ analysisOf(log).targetLabel }}</strong>
</div>
<div v-if="analysisOf(log).label" class="task-log__analysis-field"> <div v-if="analysisOf(log).label" class="task-log__analysis-field">
<span>识别结果</span> <span>{{ analysisOf(log).isAudio ? '实际声音' : '识别结果' }}</span>
<strong>{{ analysisOf(log).label }}</strong> <strong>{{ analysisOf(log).label }}</strong>
</div> </div>
<div v-if="analysisOf(log).matchLabel" class="task-log__analysis-field"> <div v-if="analysisOf(log).matchLabel" class="task-log__analysis-field">
@ -144,10 +148,11 @@ const analysisOf = (log) => {
'analysisStatus', 'analysisType', 'profileCode', 'analysisModel', 'analysisTimingMs', 'analysisStatus', 'analysisType', 'profileCode', 'analysisModel', 'analysisTimingMs',
'mediaUrl', 'audioUrl', 'videoUrl', 'imageUrl', 'label', 'labelName', 'matched', 'mediaUrl', 'audioUrl', 'videoUrl', 'imageUrl', 'label', 'labelName', 'matched',
'confidence', 'passed', 'summary', 'conclusion', 'events', '_executionContext', 'confidence', 'passed', 'summary', 'conclusion', 'events', '_executionContext',
'expectedLabel', 'expectedLabelName', 'decisionReason',
].forEach((key) => delete detailSource[key]) ].forEach((key) => delete detailSource[key])
const hasDetails = Object.keys(detailSource).length > 0 const hasDetails = Object.keys(detailSource).length > 0
const result = { const result = {
title: isAudio ? '声音分析' : '视频分析', title: isAudio ? '声音类型检测' : '视频分析',
statusLabel, statusLabel,
tagType: status === 'FAILED' || status === 'ERROR' || passed === false tagType: status === 'FAILED' || status === 'ERROR' || passed === false
? 'danger' ? 'danger'
@ -157,10 +162,12 @@ const analysisOf = (log) => {
: output.analysisModel?.name || '', : output.analysisModel?.name || '',
timingMs: Number.isFinite(Number(output.analysisTimingMs)) ? Number(output.analysisTimingMs) : null, timingMs: Number.isFinite(Number(output.analysisTimingMs)) ? Number(output.analysisTimingMs) : null,
label: output.labelName || output.label || '', label: output.labelName || output.label || '',
matchLabel: matched === true ? '匹配' : matched === false ? '未匹配' : '', targetLabel: output.expectedLabelName || output.expectedLabel || '',
matchLabel: isAudio ? '' : matched === true ? '匹配' : matched === false ? '未匹配' : '',
confidence, confidence,
passedLabel: passed === true ? '通过' : passed === false ? '未通过' : '', passedLabel: passed === true ? '通过' : passed === false ? '未通过' : '',
summary: output.summary || output.conclusion || '', summary: output.decisionReason || output.summary || output.conclusion || '',
isAudio,
events: Array.isArray(output.events) events: Array.isArray(output.events)
? output.events.slice(0, 20).map((event) => typeof event === 'string' ? event : JSON.stringify(event)) ? output.events.slice(0, 20).map((event) => typeof event === 'string' ? event : JSON.stringify(event))
: [], : [],

View File

@ -29,6 +29,26 @@
<div class="field-hint">场景决定识别标签、判断阈值和分析要求。</div> <div class="field-hint">场景决定识别标签、判断阈值和分析要求。</div>
</el-form-item> </el-form-item>
<el-form-item v-if="!isVideo" label="目标声音类型" prop="expectedLabel">
<el-select
v-model="formData.expectedLabel"
filterable
allow-create
default-first-option
placeholder="请选择目标声音类型"
style="width: 100%"
@change="handleExpectedLabelChange"
>
<el-option
v-for="item in soundTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div class="field-hint">只有实际声音与目标类型一致时才通过;新增类型可直接输入编码或名称。</div>
</el-form-item>
<el-form-item :label="mediaLabel" prop="mediaMode"> <el-form-item :label="mediaLabel" prop="mediaMode">
<el-segmented v-model="formData.mediaMode" :options="mediaModes" /> <el-segmented v-model="formData.mediaMode" :options="mediaModes" />
</el-form-item> </el-form-item>
@ -126,6 +146,8 @@ const analysisPresets = {
} }
const formData = reactive({ const formData = reactive({
profileCode: '', profileCode: '',
expectedLabel: '',
expectedLabelName: '',
mediaMode: 'quote', mediaMode: 'quote',
mediaQuote: [], mediaQuote: [],
mediaQuoteType: 'output', mediaQuoteType: 'output',
@ -136,10 +158,10 @@ const formData = reactive({
}) })
const isVideo = computed(() => props.data?.properties?.action === 'VIDEO_ANALYZE') const isVideo = computed(() => props.data?.properties?.action === 'VIDEO_ANALYZE')
const title = computed(() => isVideo.value ? '视频智能分析' : '声音事件识别') const title = computed(() => isVideo.value ? '视频智能分析' : '声音类型检测')
const description = computed(() => isVideo.value const description = computed(() => isVideo.value
? '对录像抽帧并按所选场景生成结构化分析结果。' ? '对录像抽帧并按所选场景生成结构化分析结果。'
: '将录音与场景中的多类参考声音比较,低于阈值时返回“其他声音”。') : '判断录音是否属于指定的目标声音类型,并输出通过或不通过。')
const mediaLabel = computed(() => isVideo.value ? '视频来源' : '音频来源') const mediaLabel = computed(() => isVideo.value ? '视频来源' : '音频来源')
const mediaName = computed(() => isVideo.value ? 'videoUrl' : 'audioUrl') const mediaName = computed(() => isVideo.value ? 'videoUrl' : 'audioUrl')
const mediaModes = [ const mediaModes = [
@ -151,6 +173,13 @@ const analysisModes = [
{ label: '均衡', value: 'AUTO' }, { label: '均衡', value: 'AUTO' },
{ label: '精确', value: 'ACCURATE' }, { label: '精确', value: 'ACCURATE' },
] ]
const soundTypeOptions = [
{ label: '开机', value: 'POWER_ON' },
{ label: '关机', value: 'POWER_OFF' },
{ label: '设防', value: 'ARMED' },
{ label: '解防', value: 'DISARMED' },
{ label: '鸣笛寻车', value: 'FIND_VEHICLE_HORN' },
]
const modeDescriptions = { const modeDescriptions = {
FAST: '使用轻量模型直接判断,适合动作明显、批量执行的场景。', FAST: '使用轻量模型直接判断,适合动作明显、批量执行的场景。',
AUTO: '先快速判断,低置信度时自动切换精确分析。', AUTO: '先快速判断,低置信度时自动切换精确分析。',
@ -163,7 +192,7 @@ const isTuned = computed(() => {
}) })
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: '是否通过' },
@ -176,15 +205,23 @@ const outputSummary = computed(() => isVideo.value
{ name: 'fallback', desc: '是否切换到精确分析' }, { name: 'fallback', desc: '是否切换到精确分析' },
] ]
: [ : [
{ name: 'label', desc: '场景分类编码,未匹配时为 UNKNOWN' }, { name: 'passed', desc: '是否检测到目标声音' },
{ name: 'labelName', desc: '结果名称' }, { name: 'expectedLabelName', desc: '目标声音类型' },
{ name: 'matched', desc: '是否达到匹配阈值' }, { name: 'labelName', desc: '实际声音类型' },
{ name: 'matched', desc: '是否识别到已知声音' },
{ name: 'confidence', desc: '最高匹配度' }, { name: 'confidence', desc: '最高匹配度' },
{ name: 'decisionReason', desc: '检测结论' },
{ name: 'scores', desc: '各分类匹配度' }, { name: 'scores', desc: '各分类匹配度' },
]) ])
const rules = { const rules = {
profileCode: [{ required: true, message: '请选择或输入分析场景', trigger: 'change' }], profileCode: [{ required: true, message: '请选择或输入分析场景', trigger: 'change' }],
expectedLabel: [{
validator: (_, value) => isVideo.value || String(value || '').trim()
? Promise.resolve()
: Promise.reject(new Error('请选择目标声音类型')),
trigger: 'change',
}],
mediaQuote: [{ mediaQuote: [{
validator: (_, value) => formData.mediaMode !== 'quote' || (Array.isArray(value) && value.length) validator: (_, value) => formData.mediaMode !== 'quote' || (Array.isArray(value) && value.length)
? Promise.resolve() ? Promise.resolve()
@ -211,6 +248,11 @@ const restorePreset = () => {
const handleAnalysisModeChange = () => restorePreset() const handleAnalysisModeChange = () => restorePreset()
const handleExpectedLabelChange = (value) => {
const option = soundTypeOptions.find(item => item.value === value)
formData.expectedLabelName = option?.label || String(value || '').trim()
}
const parseStoredTuning = (value) => { const parseStoredTuning = (value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) return value if (value && typeof value === 'object' && !Array.isArray(value)) return value
if (typeof value !== 'string' || !value.trim()) return {} if (typeof value !== 'string' || !value.trim()) return {}
@ -251,11 +293,18 @@ const handleQuoteChange = () => {
const initData = () => { const initData = () => {
const params = props.data?.properties?.nodeParams || [] const params = props.data?.properties?.nodeParams || []
const profile = params.find(item => item.name === 'profileCode') const profile = params.find(item => item.name === 'profileCode')
const expectedLabel = params.find(item => item.name === 'expectedLabel')
const expectedLabelName = params.find(item => item.name === 'expectedLabelName')
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') 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.expectedLabel = expectedLabel?.input || ''
formData.expectedLabelName = expectedLabelName?.input || ''
if (formData.expectedLabel && !formData.expectedLabelName) {
handleExpectedLabelChange(formData.expectedLabel)
}
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'
@ -292,6 +341,26 @@ const validateAndSave = async () => {
disabled: true, disabled: true,
}, },
] ]
if (!isVideo.value) {
nodeParams.splice(1, 0,
{
name: 'expectedLabel',
type: 'input',
input: formData.expectedLabel.trim(),
required: true,
disabled: true,
componentType: 'select',
selectOptions: soundTypeOptions,
},
{
name: 'expectedLabelName',
type: 'input',
input: formData.expectedLabelName.trim() || formData.expectedLabel.trim(),
required: false,
disabled: true,
},
)
}
if (isVideo.value) { if (isVideo.value) {
nodeParams.push({ nodeParams.push({
name: 'instruction', name: 'instruction',

View File

@ -516,21 +516,27 @@ function handler(params) {
nodeList: [ nodeList: [
{ {
icon: microphoneSvg, icon: microphoneSvg,
name: "声音事件识别", name: "声音类型检测",
type: "mediaAnalysis", type: "mediaAnalysis",
desc: "将录音与分析场景中的多类参考声音进行匹配", desc: "判断录音是否属于指定的声音类型",
action: "AUDIO_EVENT_CLASSIFY", action: "AUDIO_EVENT_CLASSIFY",
nodeType: "LLM", nodeType: "LLM",
nodeParams: [ nodeParams: [
{ name: "profileCode", type: "input", input: "aima.power_state.v1", required: true, disabled: true }, { name: "profileCode", type: "input", input: "aima.power_state.v1", required: true, disabled: true },
{ name: "expectedLabel", type: "input", input: "", required: true, disabled: true },
{ name: "expectedLabelName", type: "input", input: "", required: false, disabled: true },
{ name: "audioUrl", type: "quote", input: "", quote: "", required: true, disabled: true }, { name: "audioUrl", type: "quote", input: "", quote: "", required: true, disabled: true },
], ],
outputType: "json", outputType: "json",
outputParams: [ outputParams: [
{ name: "label", type: "string", desc: "场景分类编码,未匹配时为UNKNOWN", disabled: true }, { name: "passed", type: "boolean", desc: "是否检测到目标声音", disabled: true },
{ name: "labelName", type: "string", desc: "分类名称", disabled: true }, { name: "expectedLabel", type: "string", desc: "目标声音类型编码", disabled: true },
{ name: "matched", type: "boolean", desc: "是否达到匹配阈值", disabled: true }, { name: "expectedLabelName", type: "string", desc: "目标声音类型名称", disabled: true },
{ name: "label", type: "string", desc: "实际声音类型编码,未匹配时为UNKNOWN", disabled: true },
{ name: "labelName", type: "string", desc: "实际声音类型名称", disabled: true },
{ name: "matched", type: "boolean", desc: "是否识别到已知声音", disabled: true },
{ name: "confidence", type: "number", desc: "最高匹配度", disabled: true }, { name: "confidence", type: "number", desc: "最高匹配度", disabled: true },
{ name: "decisionReason", type: "string", desc: "检测结论", disabled: true },
{ name: "scores", type: "object", desc: "各分类匹配度", children: [], disabled: true }, { name: "scores", type: "object", desc: "各分类匹配度", children: [], disabled: true },
], ],
}, },

View File

@ -21,6 +21,15 @@
<el-button v-if="hasRunResult" @click="clearRun"> <el-button v-if="hasRunResult" @click="clearRun">
<el-icon><Delete /></el-icon>清除结果 <el-icon><Delete /></el-icon>清除结果
</el-button> </el-button>
<el-button
type="warning"
plain
:loading="recoverSafetyLoading"
:disabled="flowState === 'testRunning'"
@click="openRecoverSafetyDialog"
>
<el-icon><Warning /></el-icon>清除错误状态
</el-button>
<el-button :type="groupSelectionPending ? 'primary' : 'default'" @click="group"> <el-button :type="groupSelectionPending ? 'primary' : 'default'" @click="group">
<el-icon><CollectionTag /></el-icon>{{ groupSelectionPending ? "取消分组" : "分组" }} <el-icon><CollectionTag /></el-icon>{{ groupSelectionPending ? "取消分组" : "分组" }}
</el-button> </el-button>
@ -163,6 +172,39 @@
/> />
</div> </div>
<el-dialog v-model="recoverSafetyDialogVisible" title="清除错误状态" width="480" append-to-body>
<el-form label-position="top">
<el-form-item label="机器人">
<el-select
v-model="recoverSafetyRobotIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
:loading="recoverRobotLoading"
placeholder="请选择需要恢复的在线机器人"
style="width: 100%"
>
<el-option
v-for="item in recoverRobotOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="recoverSafetyDialogVisible = false">取消</el-button>
<el-button
type="warning"
:loading="recoverSafetyLoading"
:disabled="recoverSafetyRobotIds.length === 0"
@click="confirmRecoverSafetyState"
>确认清除</el-button>
</template>
</el-dialog>
<el-dialog v-model="visible" :width="nodeAction === 'SYSTEM_EXECUTE_ACTION_QUEUE' ? '620' : '500'" class="single-node-dialog" append-to-body> <el-dialog v-model="visible" :width="nodeAction === 'SYSTEM_EXECUTE_ACTION_QUEUE' ? '620' : '500'" class="single-node-dialog" append-to-body>
<template #header="{ close, titleId, titleClass }"> <template #header="{ close, titleId, titleClass }">
<div class="my-header"> <div class="my-header">
@ -279,7 +321,7 @@ import TestRun from "./components/TestRun.vue";
import ParamsDrawer from "./components/ParamsDrawer.vue"; import ParamsDrawer from "./components/ParamsDrawer.vue";
import Aside from "./components/Aside.vue"; import Aside from "./components/Aside.vue";
import ActionQueueSingleRun from "./components/ActionQueueSingleRun.vue"; import ActionQueueSingleRun from "./components/ActionQueueSingleRun.vue";
import { ElMessage } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { useFlowStore } from "@/store/modules/flow"; import { useFlowStore } from "@/store/modules/flow";
import { emitter } from "@/utils/eventBus"; import { emitter } from "@/utils/eventBus";
import { convertToTree } from "@/utils/flow"; import { convertToTree } from "@/utils/flow";
@ -308,6 +350,7 @@ import {
} from "@/api/flow/flow"; } from "@/api/flow/flow";
import { getDetect } from "@/api/test/detect"; import { getDetect } from "@/api/test/detect";
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot' import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { recoverSafetyState } from '@/api/device/system'
import { getDeviceKindForAction, toDeviceOptions } from '@/utils/robotDevice' import { getDeviceKindForAction, toDeviceOptions } from '@/utils/robotDevice'
import { import {
CircleClose, CircleClose,
@ -321,6 +364,7 @@ import {
Upload, Upload,
VideoPause, VideoPause,
VideoPlay, VideoPlay,
Warning,
} from "@element-plus/icons-vue"; } from "@element-plus/icons-vue";
const flowContainerRef = ref(null); const flowContainerRef = ref(null);
@ -333,6 +377,76 @@ const queueDropTargetId = ref("");
const { onFlowEvent } = useFlowEvents(); const { onFlowEvent } = useFlowEvents();
window.lf = lf; window.lf = lf;
const recoverSafetyDialogVisible = ref(false);
const recoverRobotLoading = ref(false);
const recoverSafetyLoading = ref(false);
const recoverRobotOptions = ref([]);
const recoverSafetyRobotIds = ref([]);
const openRecoverSafetyDialog = async () => {
recoverSafetyDialogVisible.value = true;
recoverRobotLoading.value = true;
try {
const response = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' });
recoverRobotOptions.value = (response.rows || []).filter((robot) => robot.robotId).map((robot) => ({
value: robot.robotId,
label: `${robot.robotName || robot.robotId} (${robot.robotId})`,
}));
recoverSafetyRobotIds.value = recoverSafetyRobotIds.value.filter((robotId) => (
recoverRobotOptions.value.some((option) => option.value === robotId)
));
if (!recoverSafetyRobotIds.value.length && recoverRobotOptions.value.length === 1) {
recoverSafetyRobotIds.value = [recoverRobotOptions.value[0].value];
}
} catch (error) {
console.error("加载在线机器人失败:", error);
recoverRobotOptions.value = [];
} finally {
recoverRobotLoading.value = false;
}
};
const confirmRecoverSafetyState = async () => {
if (!recoverSafetyRobotIds.value.length) {
ElMessage.warning("请选择需要恢复的机器人");
return;
}
try {
await ElMessageBox.confirm(
`将清除 ${recoverSafetyRobotIds.value.length} 台机器人的可恢复错误状态,确认继续?`,
"安全状态确认",
{ type: "warning", confirmButtonText: "确认清除", cancelButtonText: "取消" },
);
} catch {
return;
}
recoverSafetyLoading.value = true;
const robotIds = [...recoverSafetyRobotIds.value];
try {
const results = await Promise.allSettled(robotIds.map((robotId) => recoverSafetyState({
robotId,
reason: "工作流设计器手动清除错误状态",
timeoutMs: 15000,
})));
const failedRobotIds = results.flatMap((result, index) => (
result.status === "rejected" ? [robotIds[index]] : []
));
if (!failedRobotIds.length) {
ElMessage.success(`已清除 ${robotIds.length} 台机器人的可恢复错误状态`);
recoverSafetyDialogVisible.value = false;
recoverSafetyRobotIds.value = [];
} else {
const successCount = robotIds.length - failedRobotIds.length;
recoverSafetyRobotIds.value = failedRobotIds;
ElMessage.error(successCount
? `${successCount} 台成功,${failedRobotIds.length} 台失败,请重试失败项`
: "清除错误状态失败,请检查机器人状态");
}
} finally {
recoverSafetyLoading.value = false;
}
};
const defaultEdgeOptions = { const defaultEdgeOptions = {
type: "flow-edge", type: "flow-edge",
animated: true, animated: true,