feat: 重构
@ -1,82 +0,0 @@
|
||||
<template>
|
||||
<div class="errPage-container">
|
||||
<el-button icon="arrow-left" class="pan-back-btn" @click="back">
|
||||
返回
|
||||
</el-button>
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<h1 class="text-jumbo text-ginormous">
|
||||
401错误!
|
||||
</h1>
|
||||
<h2>您没有访问权限!</h2>
|
||||
<h6>对不起,您没有访问权限,请不要进行非法操作!您可以返回主页面</h6>
|
||||
<ul class="list-unstyled">
|
||||
<li class="link-type">
|
||||
<router-link to="/">
|
||||
回首页
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<img :src="errGif" width="313" height="428" alt="Girl has dropped her ice cream.">
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import errImage from "@/assets/401_images/401.gif"
|
||||
|
||||
let { proxy } = getCurrentInstance()
|
||||
|
||||
const errGif = ref(errImage + "?" + +new Date())
|
||||
|
||||
function back() {
|
||||
if (proxy.$route.query.noGoBack) {
|
||||
proxy.$router.push({ path: "/" })
|
||||
} else {
|
||||
proxy.$router.go(-1)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.errPage-container {
|
||||
width: 800px;
|
||||
max-width: 100%;
|
||||
margin: 100px auto;
|
||||
.pan-back-btn {
|
||||
background: #008489;
|
||||
color: #fff;
|
||||
border: none !important;
|
||||
}
|
||||
.pan-gif {
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
.pan-img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
.text-jumbo {
|
||||
font-size: 60px;
|
||||
font-weight: 700;
|
||||
color: #484848;
|
||||
}
|
||||
.list-unstyled {
|
||||
font-size: 14px;
|
||||
li {
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
a {
|
||||
color: #008489;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,227 +0,0 @@
|
||||
<template>
|
||||
<div class="wscn-http404-container">
|
||||
<div class="wscn-http404">
|
||||
<div class="pic-404">
|
||||
<img class="pic-404__parent" src="@/assets/404_images/404.png" alt="404">
|
||||
<img class="pic-404__child left" src="@/assets/404_images/404_cloud.png" alt="404">
|
||||
<img class="pic-404__child mid" src="@/assets/404_images/404_cloud.png" alt="404">
|
||||
<img class="pic-404__child right" src="@/assets/404_images/404_cloud.png" alt="404">
|
||||
</div>
|
||||
<div class="bullshit">
|
||||
<div class="bullshit__oops">
|
||||
404错误!
|
||||
</div>
|
||||
<div class="bullshit__headline">
|
||||
{{ message }}
|
||||
</div>
|
||||
<div class="bullshit__info">
|
||||
对不起,您正在寻找的页面不存在。尝试检查URL的错误,然后按浏览器上的刷新按钮或尝试在我们的应用程序中找到其他内容。
|
||||
</div>
|
||||
<router-link to="/index" class="bullshit__return-home">
|
||||
返回首页
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
let message = computed(() => {
|
||||
return '找不到网页!'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wscn-http404-container{
|
||||
transform: translate(-50%,-50%);
|
||||
position: absolute;
|
||||
top: 40%;
|
||||
left: 50%;
|
||||
}
|
||||
.wscn-http404 {
|
||||
position: relative;
|
||||
width: 1200px;
|
||||
padding: 0 50px;
|
||||
overflow: hidden;
|
||||
.pic-404 {
|
||||
position: relative;
|
||||
float: left;
|
||||
width: 600px;
|
||||
overflow: hidden;
|
||||
&__parent {
|
||||
width: 100%;
|
||||
}
|
||||
&__child {
|
||||
position: absolute;
|
||||
&.left {
|
||||
width: 80px;
|
||||
top: 17px;
|
||||
left: 220px;
|
||||
opacity: 0;
|
||||
animation-name: cloudLeft;
|
||||
animation-duration: 2s;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
&.mid {
|
||||
width: 46px;
|
||||
top: 10px;
|
||||
left: 420px;
|
||||
opacity: 0;
|
||||
animation-name: cloudMid;
|
||||
animation-duration: 2s;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
&.right {
|
||||
width: 62px;
|
||||
top: 100px;
|
||||
left: 500px;
|
||||
opacity: 0;
|
||||
animation-name: cloudRight;
|
||||
animation-duration: 2s;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
@keyframes cloudLeft {
|
||||
0% {
|
||||
top: 17px;
|
||||
left: 220px;
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
top: 33px;
|
||||
left: 188px;
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
top: 81px;
|
||||
left: 92px;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
top: 97px;
|
||||
left: 60px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes cloudMid {
|
||||
0% {
|
||||
top: 10px;
|
||||
left: 420px;
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
top: 40px;
|
||||
left: 360px;
|
||||
opacity: 1;
|
||||
}
|
||||
70% {
|
||||
top: 130px;
|
||||
left: 180px;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
top: 160px;
|
||||
left: 120px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes cloudRight {
|
||||
0% {
|
||||
top: 100px;
|
||||
left: 500px;
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
top: 120px;
|
||||
left: 460px;
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
top: 180px;
|
||||
left: 340px;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
top: 200px;
|
||||
left: 300px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.bullshit {
|
||||
position: relative;
|
||||
float: left;
|
||||
width: 300px;
|
||||
padding: 30px 0;
|
||||
overflow: hidden;
|
||||
&__oops {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
line-height: 40px;
|
||||
color: #1482f0;
|
||||
opacity: 0;
|
||||
margin-bottom: 20px;
|
||||
animation-name: slideUp;
|
||||
animation-duration: 0.5s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
&__headline {
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
color: #222;
|
||||
font-weight: bold;
|
||||
opacity: 0;
|
||||
margin-bottom: 10px;
|
||||
animation-name: slideUp;
|
||||
animation-duration: 0.5s;
|
||||
animation-delay: 0.1s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
&__info {
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
color: grey;
|
||||
opacity: 0;
|
||||
margin-bottom: 30px;
|
||||
animation-name: slideUp;
|
||||
animation-duration: 0.5s;
|
||||
animation-delay: 0.2s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
&__return-home {
|
||||
display: block;
|
||||
float: left;
|
||||
width: 110px;
|
||||
height: 36px;
|
||||
background: #1482f0;
|
||||
border-radius: 100px;
|
||||
text-align: center;
|
||||
color: #ffffff;
|
||||
opacity: 0;
|
||||
font-size: 14px;
|
||||
line-height: 36px;
|
||||
cursor: pointer;
|
||||
animation-name: slideUp;
|
||||
animation-duration: 0.5s;
|
||||
animation-delay: 0.3s;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
0% {
|
||||
transform: translateY(60px);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,281 +0,0 @@
|
||||
<template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="机器人" required>
|
||||
<el-select v-model="robotId" filterable style="width: 100%" @change="loadDevices">
|
||||
<el-option v-for="item in robotOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="队列总超时(毫秒)" required>
|
||||
<el-input-number v-model="totalTimeoutMs" :min="1" :max="2147483647" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="动作标识(可选)">
|
||||
<el-input v-model="actionId" placeholder="留空时自动生成" />
|
||||
</el-form-item>
|
||||
<div v-for="(step, index) in steps" :key="step.runtimeKey" class="single-queue-step">
|
||||
<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>
|
||||
<el-tooltip content="下移一步" placement="top">
|
||||
<el-button :icon="ArrowDown" circle size="small" :disabled="index === steps.length - 1" aria-label="下移一步" @click="moveStep(index, 1)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="复制步骤" placement="top">
|
||||
<el-button :icon="CopyDocument" circle size="small" aria-label="复制步骤" @click="copyStep(index)" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item label="步骤名称">
|
||||
<el-input v-model="step.stepName" maxlength="50" show-word-limit placeholder="例如:移动到取料位" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="deviceKind(step.type)" label="执行设备" required>
|
||||
<el-select v-model="step.deviceId" filterable :loading="deviceLoading" style="width: 100%">
|
||||
<el-option v-for="device in devicesForStep(step)" :key="device.deviceId"
|
||||
:label="device.deviceName && device.deviceName !== device.deviceId ? `${device.deviceName} (${device.deviceId})` : device.deviceId"
|
||||
:value="device.deviceId" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="single-queue-step__params">{{ stepSummary(step) }}</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowDown, ArrowUp, CopyDocument, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { getRobotDevicesByRobotId } from '@/api/resource/robot'
|
||||
import { DEVICE_KIND } from '@/utils/robotDevice'
|
||||
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep, toActionQueueExecutionSteps } from '@/utils/actionQueue'
|
||||
|
||||
/**
|
||||
* 组件输入配置,仅在当前单次动作队列运行表单内使用。
|
||||
* nodeParams 提供流程节点参数,robotOptions 提供可选机器人,initialRobotId 用于初始化默认机器人。
|
||||
*/
|
||||
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 = {
|
||||
DELAY: '等待', ARM_MOVE_J: '机械臂关节运动', ARM_MOVE_L: '机械臂直线运动',
|
||||
AGV_NAVIGATE_TO_POSE: '底盘导航到坐标', AGV_NAVIGATE_TO_STATION: '底盘导航到站点', AGV_FOLLOW_PATH: '底盘按路径导航',
|
||||
}
|
||||
/** 当前选中的机器人 ID,作用于设备查询、校验和最终运行请求。 */
|
||||
const robotId = ref('')
|
||||
/** 本次动作队列标识;为空时由服务端自动生成,仅随当前运行请求提交。 */
|
||||
const actionId = ref('')
|
||||
/** 整个动作队列的超时时间(毫秒),作用于当前运行请求。 */
|
||||
const totalTimeoutMs = ref(60000)
|
||||
/** 当前节点解析出的动作步骤副本,组件内可补充 deviceId,不回写原始 props。 */
|
||||
const steps = ref([])
|
||||
/** 当前机器人挂载的设备列表,用于为各动作步骤筛选可执行设备。 */
|
||||
const devices = ref([])
|
||||
/** 设备接口请求状态,仅控制当前组件设备选择框的加载效果。 */
|
||||
const deviceLoading = ref(false)
|
||||
let runtimeKeySequence = 0
|
||||
const createRuntimeKey = () => `queue_runtime_${Date.now()}_${runtimeKeySequence += 1}`
|
||||
|
||||
/**
|
||||
* 获取动作类型的展示名称。
|
||||
* 先查询本地中文映射,未配置的类型直接显示原值,避免未知动作显示为空。
|
||||
*/
|
||||
const actionName = (type) => labels[type] || type
|
||||
|
||||
/**
|
||||
* 根据动作名前缀判断所需设备类型。
|
||||
* 当前组件只处理机械臂和底盘队列动作;等待等无设备动作返回 null。
|
||||
*/
|
||||
const deviceKind = (type) => type?.startsWith('ARM_') ? DEVICE_KIND.ARM : (type?.startsWith('AGV_') ? DEVICE_KIND.AGV : null)
|
||||
|
||||
/**
|
||||
* 筛选单个步骤可使用的设备,范围限定为当前所选机器人的在线设备结果。
|
||||
* 将接口 deviceKind 与动作要求统一转为数值后比较,兼容字符串和数字类型。
|
||||
*/
|
||||
const devicesForStep = (step) => devices.value.filter((device) => Number(device.deviceKind) === deviceKind(step.type))
|
||||
|
||||
/**
|
||||
* 生成步骤参数摘要,仅用于当前组件的只读预览。
|
||||
* 等待和站点导航使用易读文案,其余动作保留完整 JSON 信息。
|
||||
*/
|
||||
const stepSummary = (step) => {
|
||||
if (step.type === 'DELAY') return `等待 ${step.params?.durationMs || 0} 毫秒`
|
||||
if (step.type === 'AGV_NAVIGATE_TO_STATION') return `站点 ${step.params?.stationId || '-'}`
|
||||
return JSON.stringify(step.params || {})
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用节点参数重新初始化表单,作用于首次渲染及输入属性变化。
|
||||
* 步骤:1. 将参数数组转成按名称索引的对象;2. 设置基本字段并深拷贝步骤;
|
||||
* 3. 应用默认机器人;4. 若存在机器人,则加载设备并为步骤建立默认设备选择。
|
||||
*/
|
||||
const initData = async () => {
|
||||
const values = Object.fromEntries(props.nodeParams.map((item) => [item.name, item.input]))
|
||||
actionId.value = values.actionId || ''
|
||||
totalTimeoutMs.value = Number(values.totalTimeoutMs || 60000)
|
||||
steps.value = JSON.parse(JSON.stringify(Array.isArray(values.steps) ? values.steps : [])).map((step, index) => ({
|
||||
...step,
|
||||
stepName: getActionQueueStepName(step, index),
|
||||
runtimeKey: createRuntimeKey(),
|
||||
}))
|
||||
robotId.value = props.initialRobotId || ''
|
||||
if (robotId.value) await loadDevices(robotId.value)
|
||||
}
|
||||
|
||||
const moveStep = (index, offset) => {
|
||||
moveActionQueueStep(steps.value, index, offset)
|
||||
}
|
||||
|
||||
const copyStep = (index) => {
|
||||
const source = steps.value[index]
|
||||
if (!source) return
|
||||
const copied = copyActionQueueStep(source, { runtimeKey: createRuntimeKey() })
|
||||
steps.value.splice(index + 1, 0, copied)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载指定机器人的设备并重建各步骤设备选择,影响当前组件的 devices 和 steps。
|
||||
* 切换机器人后旧 deviceId 已不再可靠,因此先清空设备及所有步骤选择;无机器人时直接结束。
|
||||
* 接口返回后逐步按动作类型筛选设备,并默认选择第一个匹配项;finally 保证加载状态复位。
|
||||
*/
|
||||
const loadDevices = async (value) => {
|
||||
// 机器人发生变化时,旧设备可能属于另一台机器人,必须整体清除以避免误提交。
|
||||
devices.value = []
|
||||
steps.value.forEach((step) => { step.deviceId = '' })
|
||||
if (!value) return
|
||||
deviceLoading.value = true
|
||||
try {
|
||||
const response = await getRobotDevicesByRobotId(value)
|
||||
devices.value = Array.isArray(response.data) ? response.data : []
|
||||
// 每个步骤独立按设备类型匹配;有候选项时预选首项,减少单一设备场景下的重复操作。
|
||||
steps.value.forEach((step) => {
|
||||
const options = devicesForStep(step)
|
||||
if (options.length) step.deviceId = options[0].deviceId
|
||||
})
|
||||
} finally {
|
||||
deviceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前动作队列是否具备运行条件,供父组件在提交前调用。
|
||||
* 依次检查机器人、队列非空及每个需要设备的步骤;首次失败即提示并终止,全部通过返回 true。
|
||||
*/
|
||||
const validate = () => {
|
||||
if (!robotId.value) return ElMessage.warning('请选择机器人'), false
|
||||
if (!steps.value.length) return ElMessage.warning('动作队列不能为空'), false
|
||||
// 等待类步骤无需设备;机械臂或底盘步骤必须存在与当前机器人匹配的 deviceId。
|
||||
for (let index = 0; index < steps.value.length; index += 1) {
|
||||
const step = steps.value[index]
|
||||
if (deviceKind(step.type) && !step.deviceId) {
|
||||
ElMessage.warning(`步骤 ${index + 1} 没有可用的执行设备`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
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,
|
||||
actionId: actionId.value,
|
||||
totalTimeoutMs: totalTimeoutMs.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,供父组件处理兼容字段或运行上下文。 */
|
||||
const getRobotId = () => robotId.value
|
||||
|
||||
/** 监听节点配置和默认机器人变化,深度变化时重建当前组件的表单及设备选项。 */
|
||||
watch(() => [props.nodeParams, props.initialRobotId], initData, { immediate: true, deep: true })
|
||||
|
||||
/** 向父组件开放提交前校验、载荷读取和机器人 ID 读取能力。 */
|
||||
defineExpose({ validate, validateStep, getPayload, getStepPayload, getRobotId })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.single-queue-step {
|
||||
margin-bottom: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #dfe4ec;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.single-queue-step__header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
margin-bottom: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.single-queue-step__title {
|
||||
overflow: hidden;
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.single-queue-step__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.single-queue-step__actions :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.single-queue-step__params {
|
||||
overflow: hidden;
|
||||
color: #7a8494;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@ -1,251 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:节点组件库侧栏,负责分类、搜索和拖拽创建。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="component-library">
|
||||
<div class="library-header">
|
||||
<div class="library-heading">
|
||||
<div>
|
||||
<div class="library-title">组件库</div>
|
||||
<div class="library-subtitle">{{ totalCount }} 个可用组件</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-input v-model="keyword" clearable placeholder="搜索组件" :prefix-icon="Search" />
|
||||
</div>
|
||||
|
||||
<div class="library-content">
|
||||
<el-collapse v-if="filteredGroups.length" v-model="activeNames">
|
||||
<el-collapse-item
|
||||
v-for="item in filteredGroups"
|
||||
:key="item.collapseTitle"
|
||||
:name="item.collapseTitle"
|
||||
class="collapse-item"
|
||||
>
|
||||
<template #title>
|
||||
<div class="category-title">
|
||||
<span>{{ item.collapseTitle }}</span>
|
||||
<span class="category-count">{{ item.nodeList.length }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="node-list">
|
||||
<div
|
||||
v-for="node in item.nodeList"
|
||||
:key="`${node.type}-${node.action || node.name}`"
|
||||
class="node-item"
|
||||
draggable="true"
|
||||
@dragstart="handleDragCustom($event, node)"
|
||||
>
|
||||
<div class="node-icon"><img :src="node.icon" alt="" /></div>
|
||||
<div class="node-content">
|
||||
<div class="node-name">{{ node.name }}</div>
|
||||
<div class="node-desc">{{ node.desc }}</div>
|
||||
</div>
|
||||
<el-icon class="drag-handle"><Rank /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<el-empty v-else :image-size="72" description="没有匹配的组件" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Rank, Search } from "@element-plus/icons-vue";
|
||||
import { collapseList } from "../config";
|
||||
|
||||
const keyword = ref("");
|
||||
const activeNames = ref(collapseList[0] ? [collapseList[0].collapseTitle] : []);
|
||||
const totalCount = computed(() => collapseList.reduce((total, group) => total + group.nodeList.length, 0));
|
||||
const filteredGroups = computed(() => {
|
||||
const query = keyword.value.trim().toLowerCase();
|
||||
if (!query) return collapseList;
|
||||
return collapseList
|
||||
.map((group) => ({
|
||||
...group,
|
||||
nodeList: group.nodeList.filter((node) => (
|
||||
[node.name, node.desc, node.action, node.type]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(query))
|
||||
)),
|
||||
}))
|
||||
.filter((group) => group.nodeList.length > 0);
|
||||
});
|
||||
|
||||
watch(keyword, (value) => {
|
||||
if (value.trim()) activeNames.value = filteredGroups.value.map((group) => group.collapseTitle);
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理 handleDragCustom 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} event 触发操作的浏览器事件
|
||||
* @param {*} node 当前流程节点
|
||||
*/
|
||||
const handleDragCustom = (event, node) => {
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
event.dataTransfer.setData("flowNode", JSON.stringify(node));
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.component-library {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.library-header {
|
||||
padding: 16px 14px 14px;
|
||||
border-bottom: 1px solid #e4e8ef;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.library-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.library-title {
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.library-subtitle {
|
||||
margin-top: 2px;
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.library-content {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
:deep(.el-collapse) {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
:deep(.collapse-item) {
|
||||
.el-collapse-item__header {
|
||||
height: 46px;
|
||||
padding: 0 14px;
|
||||
border-bottom-color: #e8ebf0;
|
||||
background: #f8fafc;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-collapse-item__wrap {
|
||||
border-bottom-color: #e8ebf0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.el-collapse-item__content {
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.category-count {
|
||||
display: grid;
|
||||
min-width: 22px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
place-items: center;
|
||||
border: 1px solid #dfe4ec;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #7a8597;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.node-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.node-item {
|
||||
display: grid;
|
||||
min-height: 62px;
|
||||
grid-template-columns: 34px minmax(0, 1fr) 18px;
|
||||
gap: 9px;
|
||||
align-items: center;
|
||||
padding: 9px 8px 9px 10px;
|
||||
border: 1px solid #e1e6ed;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
cursor: grab;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #f2a071;
|
||||
box-shadow: 0 5px 14px rgba(31, 41, 55, 0.08);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
&:active { cursor: grabbing; }
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
background: #f1f5f9;
|
||||
|
||||
img {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.node-content { min-width: 0; }
|
||||
|
||||
.node-name {
|
||||
overflow: hidden;
|
||||
color: #202938;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-desc {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
color: #7a8597;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
color: #a8b0bf;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@ -1,242 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:递归参数表单,负责嵌套字段的增删和类型切换。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<!-- components/FormItemRecursive.vue -->
|
||||
<template>
|
||||
<div class="form-item-recursive">
|
||||
<!-- 循环渲染当前层级的所有表单项 -->
|
||||
<div v-for="(item, index) in currentList" :key="index">
|
||||
<el-row>
|
||||
<!-- 名称输入 -->
|
||||
<el-form-item
|
||||
:label="isFirstLevel && index === 0 ? '变量名' : ''"
|
||||
:prop="`${propPath}.${index}.name`"
|
||||
:rules="[{ required: true, message: '请输入', trigger: 'blur' }]"
|
||||
>
|
||||
<el-input
|
||||
class="param-name"
|
||||
v-model="item.name"
|
||||
:disabled="item.disabled || false"
|
||||
placeholder="请输入"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 类型选择 -->
|
||||
<el-form-item
|
||||
:label="isFirstLevel && index === 0 ? '变量类型' : ''"
|
||||
:prop="`${propPath}.${index}.type`"
|
||||
:rules="[{ required: true, message: '请输入', trigger: 'blur' }]"
|
||||
>
|
||||
<el-select
|
||||
v-model="item.type"
|
||||
class="param-type"
|
||||
@change="handleTypeChange(item)"
|
||||
>
|
||||
<el-option label="String" value="string" />
|
||||
<el-option label="Number" value="number" />
|
||||
<el-option label="Boolean" value="boolean" />
|
||||
<el-option v-if="depth < endDepth" label="Object" value="object" />
|
||||
<el-option label="Array<String>" value="array<string>" />
|
||||
<el-option label="Array<Number>" value="array<number>" />
|
||||
<el-option label="Array<Boolean>" value="array<boolean>" />
|
||||
<el-option v-if="depth < endDepth" label="Array<Object>" value="array<object>" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 描述输入 -->
|
||||
<el-form-item
|
||||
:label="isFirstLevel && index === 0 ? '描述' : ''"
|
||||
:prop="`${propPath}.${index}.desc`"
|
||||
:rules="[{ required: true, message: '请输入', trigger: 'blur' }]"
|
||||
>
|
||||
<el-input
|
||||
class="param-desc"
|
||||
v-model="item.desc"
|
||||
placeholder="请输入"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 是否必填 -->
|
||||
<el-form-item v-if="formType === 'input'" :label="isFirstLevel && index === 0 ? '必填' : ''">
|
||||
<el-switch
|
||||
v-model="item.required"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 删除按钮(第一层第一个不能删) -->
|
||||
<el-button
|
||||
:icon="Minus"
|
||||
circle
|
||||
size="small"
|
||||
@click="handleDelete(index)"
|
||||
class="deleteBtn"
|
||||
/>
|
||||
|
||||
<!-- 添加子项按钮(当类型是object/array<object>时) -->
|
||||
<el-button
|
||||
v-if="['object', 'array<object>'].includes(item.type)"
|
||||
:icon="Plus"
|
||||
circle
|
||||
size="small"
|
||||
@click="handleAddChild(item)"
|
||||
class="addBtn"
|
||||
/>
|
||||
</el-row>
|
||||
|
||||
<!-- 递归渲染子项(如果有子项且类型匹配) -->
|
||||
<!-- 层级越深,缩进越多 -->
|
||||
<div
|
||||
v-if="['object', 'array<object>'].includes(item.type) && item.children.length"
|
||||
class="nested-form-items"
|
||||
:style="{ marginLeft: `${depth * 20}px` }"
|
||||
>
|
||||
<!-- 递归调用自身,处理下一层级 -->
|
||||
<!-- 非第一层 -->
|
||||
<!-- 复用添加逻辑 -->
|
||||
<FormItemRecursive
|
||||
:formType="formType"
|
||||
:current-list="item.children"
|
||||
:prop-path="`${propPath}.${index}.children`"
|
||||
:depth="depth + 1"
|
||||
:is-first-level="false"
|
||||
:endDepth="endDepth"
|
||||
:parent-path="[...parentPath, index] "
|
||||
@add-item="handleAddChild(item)"
|
||||
@delete-item="handleChildDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Plus, Minus } from "@element-plus/icons-vue";
|
||||
|
||||
// 定义props
|
||||
const { formType, currentList, propPath, depth, isFirstLevel, endDepth, parentPath } = defineProps({
|
||||
formType: {
|
||||
type: String,
|
||||
default: 'input'
|
||||
},
|
||||
currentList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
propPath: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
isFirstLevel: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
endDepth: {
|
||||
type: Number,
|
||||
default: 2
|
||||
},
|
||||
parentPath: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
});
|
||||
|
||||
// 定义emit事件
|
||||
const emit = defineEmits(['add-item', 'delete-item'])
|
||||
|
||||
// 处理类型变化(清空或添加子项)
|
||||
/**
|
||||
* 处理 handleTypeChange 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} item 调用方传入的 item 参数
|
||||
*/
|
||||
const handleTypeChange = (item) => {
|
||||
if (!["object", "array<object>"].includes(item.type)) {
|
||||
item.children = []; // 非对象类型清空子项
|
||||
} else if (item.children.length === 0) {
|
||||
handleAddChild(item); // 首次切换为对象类型时添加一个子项
|
||||
}
|
||||
};
|
||||
|
||||
// 添加子项
|
||||
/**
|
||||
* 处理 handleAddChild 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} parentItem 调用方传入的 parentItem 参数
|
||||
*/
|
||||
const handleAddChild = (parentItem) => {
|
||||
parentItem.children.push({
|
||||
name: "",
|
||||
type: "string",
|
||||
desc: "",
|
||||
required: false,
|
||||
children: [],
|
||||
});
|
||||
// emit("add-item", parentItem); // 通知父组件(可选)
|
||||
};
|
||||
|
||||
// 删除当前项
|
||||
/**
|
||||
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} index 目标项索引
|
||||
*/
|
||||
const handleDelete = (index) => {
|
||||
// 完整路径 = 父级路径 + 当前索引(如果是顶层,parentPath为undefined)
|
||||
const fullPath = parentPath ? [...parentPath, index] : [index];
|
||||
emit("delete-item", fullPath); // 向上传递完整路径
|
||||
}
|
||||
|
||||
// 接收子组件的删除事件,继续向上传递
|
||||
/**
|
||||
* 处理 handleChildDelete 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} childFullPath 调用方传入的 childFullPath 参数
|
||||
*/
|
||||
const handleChildDelete = (childFullPath) => {
|
||||
emit("delete-item", childFullPath);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nested-form-items {
|
||||
border-left: 1px dashed #ccc; /* 层级连接线 */
|
||||
padding-left: 12px;
|
||||
margin-left: 0 !important;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
margin-bottom: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
margin-bottom: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.el-row) {
|
||||
align-items: end;
|
||||
|
||||
.el-form-item {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.param-name {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.param-type {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.param-desc {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
@ -1,217 +0,0 @@
|
||||
<template>
|
||||
<div class="loop-results">
|
||||
<div class="loop-results__summary">
|
||||
<span>{{ history.length }} 次循环</span>
|
||||
<el-tag v-if="analysisSummary" :type="analysisSummary.type" size="small">
|
||||
{{ analysisSummary.label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!history.length" description="暂无循环结果" :image-size="56" />
|
||||
<el-collapse v-else v-model="expandedIterations" class="loop-results__iterations">
|
||||
<el-collapse-item v-for="iteration in history" :key="iteration.iteration" :name="iteration.iteration">
|
||||
<template #title>
|
||||
<div class="loop-results__iteration-title">
|
||||
<strong>第 {{ iteration.iteration }} 次</strong>
|
||||
<el-tag :type="statusMeta(iteration.status).type" size="small">
|
||||
{{ statusMeta(iteration.status).label }}
|
||||
</el-tag>
|
||||
<el-tag v-if="analysisMeta(iteration.analysisStatus)" :type="analysisMeta(iteration.analysisStatus).type" size="small">
|
||||
{{ analysisMeta(iteration.analysisStatus).label }}
|
||||
</el-tag>
|
||||
<span>{{ formatDuration(iteration.durationMs) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="loop-results__nodes">
|
||||
<details v-for="node in iteration.nodes || []" :key="`${iteration.iteration}:${node.nodeId}`" class="loop-results__node">
|
||||
<summary>
|
||||
<span class="loop-results__node-name">{{ node.nodeName }}</span>
|
||||
<el-tag :type="statusMeta(node.status).type" size="small">
|
||||
{{ statusMeta(node.status).label }}
|
||||
</el-tag>
|
||||
<el-tag v-if="node.passed !== null && node.passed !== undefined" :type="node.passed ? 'success' : 'danger'" size="small">
|
||||
{{ node.passed ? '通过' : '未通过' }}
|
||||
</el-tag>
|
||||
<span class="loop-results__duration">{{ formatDuration(node.durationMs) }}</span>
|
||||
</summary>
|
||||
<div v-if="node.error" class="loop-results__error">{{ node.error }}</div>
|
||||
<div v-if="hasValue(node.input)" class="loop-results__data">
|
||||
<div class="loop-results__label">输入</div>
|
||||
<JsonViewer :value="node.input" copyable boxed sort theme="light" />
|
||||
</div>
|
||||
<div v-if="hasValue(node.output)" class="loop-results__data">
|
||||
<div class="loop-results__label">输出</div>
|
||||
<JsonViewer :value="node.output" copyable boxed sort theme="light" />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
history: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const expandedIterations = ref([])
|
||||
let initialized = false
|
||||
|
||||
watch(() => props.history, (history) => {
|
||||
if (initialized || !history?.length) return
|
||||
const active = history.find(item => String(item.status || '').toUpperCase() === 'RUNNING')
|
||||
expandedIterations.value = [active?.iteration || history[history.length - 1].iteration]
|
||||
initialized = true
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
const statusMeta = (status) => {
|
||||
const value = String(status || '').toUpperCase()
|
||||
if (value === 'SUCCESS') return { label: '执行完成', type: 'success' }
|
||||
if (value === 'FAILED') return { label: '执行失败', type: 'danger' }
|
||||
if (value === 'STOPPED') return { label: '已停止', type: 'info' }
|
||||
if (value === 'PAUSED') return { label: '已暂停', type: 'warning' }
|
||||
return { label: '执行中', type: 'warning' }
|
||||
}
|
||||
|
||||
const analysisMeta = (status) => {
|
||||
if (status === 'PASSED') return { label: '分析通过', type: 'success' }
|
||||
if (status === 'FAILED') return { label: '分析未通过', type: 'danger' }
|
||||
if (status === 'RUNNING') return { label: '分析中', type: 'warning' }
|
||||
return null
|
||||
}
|
||||
|
||||
const analysisSummary = computed(() => {
|
||||
const statuses = props.history.map(item => item.analysisStatus).filter(Boolean)
|
||||
if (!statuses.length) return null
|
||||
if (statuses.includes('FAILED')) return { label: '存在未通过', type: 'danger' }
|
||||
if (statuses.includes('RUNNING')) return { label: '分析中', type: 'warning' }
|
||||
return { label: '全部通过', type: 'success' }
|
||||
})
|
||||
|
||||
const formatDuration = (value) => {
|
||||
const duration = Number(value)
|
||||
if (!Number.isFinite(duration) || duration < 0) return '-'
|
||||
if (duration < 1000) return `${duration} ms`
|
||||
return `${(duration / 1000).toFixed(2)} s`
|
||||
}
|
||||
|
||||
const hasValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') return false
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
if (typeof value === 'object') return Object.keys(value).length > 0
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.loop-results__summary {
|
||||
display: flex;
|
||||
min-height: 32px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 2px 8px;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loop-results__iterations {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.loop-results__iteration-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.loop-results__iteration-title > span:last-child {
|
||||
margin-left: auto;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loop-results__nodes {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 4px 0 10px;
|
||||
}
|
||||
|
||||
.loop-results__node {
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.loop-results__node summary {
|
||||
display: flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 3px 4px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.loop-results__node summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loop-results__node summary::before {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 6px solid #909399;
|
||||
content: '';
|
||||
transition: transform 0.16s ease;
|
||||
}
|
||||
|
||||
.loop-results__node[open] summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.loop-results__node-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loop-results__duration {
|
||||
margin-left: auto;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loop-results__error {
|
||||
margin: 4px 4px 8px 18px;
|
||||
padding: 7px 9px;
|
||||
border-left: 3px solid var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
color: var(--el-color-danger-dark-2);
|
||||
line-height: 18px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.loop-results__data {
|
||||
margin: 6px 4px 10px 18px;
|
||||
}
|
||||
|
||||
.loop-results__label {
|
||||
margin-bottom: 5px;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@ -1,202 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:节点运行状态面板,展示耗时、输入、输出和错误。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div v-if="showState !== 'NORMAL'" class="node-state" :class="`node-state--${showState.toLowerCase()}`">
|
||||
<div class="node-state__summary">
|
||||
<el-icon class="node-state__icon">
|
||||
<SuccessFilled v-if="props.state === 'SUCCESS'" />
|
||||
<CircleCloseFilled v-else-if="['FAILED', 'STOPPED'].includes(props.state)" />
|
||||
<VideoPause v-else-if="props.state === 'PAUSED'" />
|
||||
<Loading v-else />
|
||||
</el-icon>
|
||||
<span class="node-state__text">{{ stateText }}</span>
|
||||
<span class="node-state__runtime">{{ props.runtimes }} ms</span>
|
||||
</div>
|
||||
|
||||
<el-popover
|
||||
ref="popoverRef"
|
||||
trigger="click"
|
||||
placement="right-start"
|
||||
:width="props.popoverWidth"
|
||||
:teleported="true"
|
||||
:popper-class="props.popperClass"
|
||||
@show="popoverVisible = true"
|
||||
@hide="popoverVisible = false"
|
||||
>
|
||||
<template #reference>
|
||||
<button type="button" class="node-state__result nodrag nopan" @mousedown.stop @click.stop>
|
||||
<el-icon><View /></el-icon>
|
||||
<span>{{ popoverVisible ? "收起结果" : "查看结果" }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="result-section">
|
||||
<div class="result-section__title">输入</div>
|
||||
<div class="result-section__content"><slot name="input" /></div>
|
||||
</div>
|
||||
<div v-if="slots.output" class="result-section result-section--output">
|
||||
<div class="result-section__title">输出</div>
|
||||
<div class="result-section__content"><slot name="output" /></div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, useSlots, watch } from "vue";
|
||||
import { CircleCloseFilled, Loading, SuccessFilled, VideoPause, View } from "@element-plus/icons-vue";
|
||||
|
||||
const props = defineProps({
|
||||
state: { type: String, default: "NORMAL" },
|
||||
runtimes: { type: Number, default: 0 },
|
||||
popoverWidth: { type: Number, default: 480 },
|
||||
popperClass: { type: String, default: "flow-result-popover" },
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const popoverRef = ref();
|
||||
const popoverVisible = ref(false);
|
||||
const showState = ref("NORMAL");
|
||||
const stateText = computed(() => ({
|
||||
SUCCESS: "执行成功",
|
||||
RUNNING: "执行中",
|
||||
FAILED: "执行失败",
|
||||
PAUSED: "已暂停",
|
||||
STOPPED: "已停止",
|
||||
NORMAL: "",
|
||||
}[props.state] || props.state));
|
||||
|
||||
watch(() => props.state, (value) => {
|
||||
showState.value = value;
|
||||
}, { immediate: true });
|
||||
|
||||
/**
|
||||
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const closePopover = () => {
|
||||
showState.value = "NORMAL";
|
||||
popoverVisible.value = false;
|
||||
popoverRef.value?.hide?.();
|
||||
};
|
||||
|
||||
defineExpose({ closePopover });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node-state {
|
||||
display: flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: -4px -4px 10px;
|
||||
padding: 5px 7px 5px 9px;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 6px;
|
||||
background: #f7f9fc;
|
||||
color: #526071;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.node-state__summary {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.node-state__icon { font-size: 16px; }
|
||||
.node-state__text { font-weight: 600; white-space: nowrap; }
|
||||
|
||||
.node-state__runtime {
|
||||
padding-left: 6px;
|
||||
border-left: 1px solid currentColor;
|
||||
opacity: 0.72;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-state__result {
|
||||
display: flex;
|
||||
height: 26px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 7px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.node-state--success {
|
||||
border-color: #c8ead5;
|
||||
background: #effaf3;
|
||||
color: #1e7d49;
|
||||
}
|
||||
|
||||
.node-state--failed,
|
||||
.node-state--stopped {
|
||||
border-color: #f1cecb;
|
||||
background: #fff3f2;
|
||||
color: #c33b32;
|
||||
}
|
||||
|
||||
.node-state--paused {
|
||||
border-color: #d9dee7;
|
||||
background: #f3f5f8;
|
||||
color: #657084;
|
||||
}
|
||||
|
||||
.node-state--running {
|
||||
border-color: #cbdcf8;
|
||||
background: #f0f6ff;
|
||||
color: #2f6fca;
|
||||
|
||||
.node-state__icon { animation: rotate-animation 1.2s linear infinite; }
|
||||
}
|
||||
|
||||
@keyframes rotate-animation {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.flow-result-popover {
|
||||
max-width: min(480px, calc(100vw - 32px));
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
|
||||
.result-section {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.result-section--output { border-top: 1px solid #e8ebf0; }
|
||||
|
||||
.result-section__title {
|
||||
margin-bottom: 8px;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.result-section__content {
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
color: #475467;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.flow-loop-result-popover {
|
||||
max-width: min(720px, calc(100vw - 32px));
|
||||
|
||||
.result-section__content {
|
||||
max-height: 520px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,338 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:节点标题栏,负责重命名、删除和单节点执行。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node-title">
|
||||
<div class="node-title__main">
|
||||
<div class="node-title__identity">
|
||||
<div class="node-title__icon">
|
||||
<img v-if="props.icon" :src="props.icon" alt="" />
|
||||
<el-icon v-else><Operation /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="node-title__text">
|
||||
<div v-if="!isEditing" class="node-title__name-row">
|
||||
<span class="node-title__name" :title="localName">{{ localName }}</span>
|
||||
<span v-if="typeLabel" class="node-title__type">{{ typeLabel }}</span>
|
||||
<el-tooltip content="重命名" placement="top">
|
||||
<button
|
||||
type="button"
|
||||
class="node-icon-button node-title__edit nodrag nopan"
|
||||
aria-label="重命名节点"
|
||||
@mousedown.stop
|
||||
@click.stop="startEditing"
|
||||
>
|
||||
<el-icon><EditPen /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div v-else class="node-title__editor nodrag nopan" @mousedown.stop @click.stop>
|
||||
<el-input
|
||||
ref="nameInputRef"
|
||||
v-model="localName"
|
||||
maxlength="40"
|
||||
@keydown.enter.prevent="confirmNodeName"
|
||||
@keydown.esc.prevent="cancelNodeName"
|
||||
/>
|
||||
<button type="button" class="node-icon-button is-confirm" aria-label="确认名称" @click="confirmNodeName">
|
||||
<el-icon><Select /></el-icon>
|
||||
</button>
|
||||
<button type="button" class="node-icon-button" aria-label="取消重命名" @click="cancelNodeName">
|
||||
<el-icon><CloseBold /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="node-title__actions nodrag nopan" @mousedown.stop>
|
||||
<el-tooltip v-if="canExecute" content="执行当前节点" placement="top">
|
||||
<button type="button" class="node-icon-button is-run" aria-label="执行当前节点" @click.stop="execute">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="isSelectArea ? '解散分组' : '删除节点'" placement="top">
|
||||
<button
|
||||
type="button"
|
||||
class="node-icon-button is-danger"
|
||||
:aria-label="isSelectArea ? '解散分组' : '删除节点'"
|
||||
@click.stop="deleteNode"
|
||||
>
|
||||
<el-icon><FolderRemove v-if="isSelectArea" /><Delete v-else /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.nodeDesc" class="node-title__description" :title="props.nodeDesc">
|
||||
{{ props.nodeDesc }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { CloseBold, Delete, EditPen, FolderRemove, Operation, Select, VideoPlay } from "@element-plus/icons-vue";
|
||||
import { ElMessageBox } from "element-plus";
|
||||
import { emitter } from "@/utils/eventBus";
|
||||
|
||||
const props = defineProps({
|
||||
icon: { type: String, default: "" },
|
||||
nodeId: { type: String, default: "" },
|
||||
nodeProperties: { type: Object, default: () => ({}) },
|
||||
nodeDesc: { type: String, default: "" },
|
||||
nodeName: { type: String, default: "" },
|
||||
nodeType: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emits = defineEmits(["setNodeName"]);
|
||||
const isEditing = ref(false);
|
||||
const localName = ref("");
|
||||
const nameInputRef = ref();
|
||||
const isSelectArea = computed(() => props.nodeType === "selectArea");
|
||||
const flowContextActions = new Set([
|
||||
"NONE",
|
||||
"START",
|
||||
"END",
|
||||
"SUB_START",
|
||||
"SUB_END",
|
||||
"START_LOOP",
|
||||
"SUB_FLOW",
|
||||
"STOP_LOOP",
|
||||
"BRANCH",
|
||||
"GET_CURRENT_OBJECT",
|
||||
]);
|
||||
const canExecute = computed(() => {
|
||||
if (isSelectArea.value) return false;
|
||||
const action = String(props.nodeProperties?.action || "").toUpperCase();
|
||||
return Boolean(action) && !flowContextActions.has(action);
|
||||
});
|
||||
const typeLabel = computed(() => {
|
||||
if (!props.nodeType || ["NONE", "selectArea"].includes(props.nodeType)) return "";
|
||||
return { EDGE: "设备", LLM: "模型", AE: "评估" }[props.nodeType] || props.nodeType;
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理 sourceName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const sourceName = () => props.nodeProperties?.name || props.nodeName || "未命名节点";
|
||||
|
||||
watch(sourceName, (value) => {
|
||||
if (!isEditing.value) localName.value = value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理 startEditing 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const startEditing = async () => {
|
||||
isEditing.value = true;
|
||||
await nextTick();
|
||||
nameInputRef.value?.focus?.();
|
||||
nameInputRef.value?.select?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* 确认 confirmNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const confirmNodeName = () => {
|
||||
const name = localName.value.trim();
|
||||
if (!name) return;
|
||||
localName.value = name;
|
||||
emits("setNodeName", name);
|
||||
isEditing.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 cancelNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const cancelNodeName = () => {
|
||||
localName.value = sourceName();
|
||||
isEditing.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除 deleteNode 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const deleteNode = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
isSelectArea.value
|
||||
? "解散后内部节点和连线会保留,是否继续?"
|
||||
: "删除后相关连线也会一并移除,是否继续?",
|
||||
isSelectArea.value ? "解散分组" : "删除节点",
|
||||
{
|
||||
confirmButtonText: isSelectArea.value ? "解散" : "删除",
|
||||
cancelButtonText: "取消",
|
||||
confirmButtonClass: "el-button--danger",
|
||||
type: "warning",
|
||||
});
|
||||
if (isSelectArea.value) window.lf?.ungroup(props.nodeId);
|
||||
else window.lf?.deleteNode(props.nodeId);
|
||||
emitter.emit("deleteNode", props.nodeId);
|
||||
} catch (error) {
|
||||
// Cancelled by the user.
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行 execute 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const execute = () => {
|
||||
document.dispatchEvent(new CustomEvent("singleNodeExecution", {
|
||||
bubbles: false,
|
||||
cancelable: false,
|
||||
// 携带节点 ID,供单节点执行弹窗按节点恢复上次选择的机器人。
|
||||
detail: { ...props.nodeProperties, nodeId: props.nodeId },
|
||||
}));
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
localName.value = sourceName();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node-title { min-width: 0; }
|
||||
|
||||
.node-title__main {
|
||||
display: flex;
|
||||
min-height: 38px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.node-title__identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.node-title__icon {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 36px;
|
||||
place-items: center;
|
||||
border-radius: 7px;
|
||||
background: #f1f5f9;
|
||||
color: var(--node-accent, #475569);
|
||||
|
||||
img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.node-title__text { min-width: 0; flex: 1; }
|
||||
|
||||
.node-title__name-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.node-title__name {
|
||||
overflow: hidden;
|
||||
color: #1f2937;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
line-height: 22px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-title__type {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid #dfe4ec;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
color: #697386;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.node-title__edit {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: 0 0 24px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.node-title__actions {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.16s ease;
|
||||
}
|
||||
|
||||
.node-title:hover,
|
||||
.node-title:focus-within {
|
||||
.node-title__actions,
|
||||
.node-title__edit { opacity: 1; }
|
||||
}
|
||||
|
||||
.node-icon-button {
|
||||
display: grid;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: #7b8494;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #dfe4ec;
|
||||
background: #f8fafc;
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
&.is-run:hover {
|
||||
border-color: #b7dfc7;
|
||||
background: #effaf3;
|
||||
color: #168447;
|
||||
}
|
||||
|
||||
&.is-danger:hover {
|
||||
border-color: #f2c5c2;
|
||||
background: #fff3f2;
|
||||
color: #d93025;
|
||||
}
|
||||
|
||||
&.is-confirm { color: #168447; }
|
||||
}
|
||||
|
||||
.node-title__editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, 1fr) 28px 28px;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.node-title__description {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
color: #7a8494;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
@media (hover: none) {
|
||||
.node-title__actions,
|
||||
.node-title__edit { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
@ -1,50 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:动态节点包装器,挂载具体节点并同步内容变化。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__box">
|
||||
<component :is="componentId" :model="props.model" :properties="props.properties" ref="nodeRef" @contentChange="contentChange"></component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
// import ArmNode from '../nodes/mechanical/arm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
properties: Object,
|
||||
component: Object,
|
||||
});
|
||||
|
||||
const emits = defineEmits(['contentChange', 'bindRef'])
|
||||
|
||||
const componentId = ref(props.component)
|
||||
|
||||
/**
|
||||
* 处理 contentChange 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const contentChange = () => {
|
||||
emits('contentChange')
|
||||
}
|
||||
|
||||
const nodeRef = ref()
|
||||
onMounted(() => {
|
||||
emits('bindRef', nodeRef.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node__box {
|
||||
width: 320px;
|
||||
height: auto;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
cursor: default;
|
||||
border-radius: 12px;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 5px 15px 0#00000008;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@ -1,161 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:节点参数抽屉,统一触发参数校验和保存。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="props.drawer"
|
||||
custom-class="flow-params-drawer"
|
||||
:title="props.data.properties?.name || '参数配置'"
|
||||
class="flow-params-drawer"
|
||||
:size="drawerSize"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
:before-close="confirmSave"
|
||||
>
|
||||
<component
|
||||
:is="currentComponent"
|
||||
ref="paramsComponentRef"
|
||||
:data="props.data"
|
||||
v-bind="componentExtraProps"
|
||||
@save-success="handleSaveSuccess"
|
||||
@save-error="handleSaveError"
|
||||
@extract-step="handleExtractStep"
|
||||
/>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { emitter } from '@/utils/eventBus'
|
||||
|
||||
// 导入各子组件
|
||||
import StartParams from './params/StartParams.vue'
|
||||
import BasicNodeParams from './params/BasicNodeParams.vue'
|
||||
import CodeNodeParams from './params/CodeNodeParams.vue'
|
||||
import HttpNodeParams from './params/HttpNodeParams.vue'
|
||||
import SdAgentNodeParams from './params/SdAgentNodeParams.vue'
|
||||
import RecognizeNodeParams from './params/RecognizeNodeParams.vue'
|
||||
import DeviceUniversal from './params/DeviceUniversal.vue'
|
||||
import SwitchNodeParams from './params/SwitchNodeParams.vue'
|
||||
import ActionQueueParams from './params/ActionQueueParams.vue'
|
||||
import MediaAnalysisParams from './params/MediaAnalysisParams.vue'
|
||||
import ImageAnalysisParams from './params/ImageAnalysisParams.vue'
|
||||
import EndParams from './params/EndParams.vue'
|
||||
import SubFlowParams from './params/SubFlowParams.vue'
|
||||
|
||||
const props = defineProps({
|
||||
drawer: Boolean,
|
||||
data: Object,
|
||||
flowId: String
|
||||
})
|
||||
|
||||
const emits = defineEmits(['close', 'extract-step'])
|
||||
const paramsComponentRef = ref(null)
|
||||
|
||||
/**
|
||||
* 分支条件包含左值、运算符和右值三列,仅为分支节点提供更宽的编辑空间。
|
||||
*/
|
||||
const drawerSize = computed(() => (
|
||||
props.data?.type === 'branch' ? 'min(920px, 96vw)' : 'min(560px, 92vw)'
|
||||
))
|
||||
|
||||
// 根据节点类型决定渲染哪个子组件
|
||||
const currentComponent = computed(() => {
|
||||
const type = props.data?.type
|
||||
if (type === 'start') return StartParams
|
||||
if (type === 'end') return EndParams
|
||||
if (type === 'subFlow') return SubFlowParams
|
||||
if (['currentLoop', 'serviceNode', 'sleep', 'loop'].includes(type)) return BasicNodeParams
|
||||
if (type === 'code') return CodeNodeParams
|
||||
if (type === 'http') return HttpNodeParams
|
||||
if (type === 'sdAgent') return SdAgentNodeParams
|
||||
if (type === 'recognize') return RecognizeNodeParams
|
||||
if (['deviceUniversal', 'device_universal'].includes(type)) return DeviceUniversal
|
||||
if (type === 'actionQueue') return ActionQueueParams
|
||||
if (type === 'mediaAnalysis') return MediaAnalysisParams
|
||||
// 图片分析支持多图、参考图和自由提示词输出,使用独立面板保持媒体分析面板聚焦音视频。
|
||||
if (type === 'imageAnalysis') return ImageAnalysisParams
|
||||
if (type === 'branch') return SwitchNodeParams
|
||||
return null
|
||||
})
|
||||
|
||||
const componentExtraProps = computed(() => (
|
||||
props.data?.type === 'subFlow' ? { flowId: props.flowId } : {}
|
||||
))
|
||||
|
||||
// 这里提供一个公开方法供父组件调用
|
||||
/**
|
||||
* 确认 confirmSave 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const confirmSave = async () => {
|
||||
if (paramsComponentRef.value && typeof paramsComponentRef.value.validateAndSave === 'function') {
|
||||
await paramsComponentRef.value.validateAndSave()
|
||||
} else {
|
||||
emits('close')
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露给父组件使用
|
||||
defineExpose({ confirmSave })
|
||||
|
||||
// 保存成功 / 失败处理
|
||||
/**
|
||||
* 处理 handleSaveSuccess 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const handleSaveSuccess = () => {
|
||||
emitter.emit('setProperties', { id: props.data.id })
|
||||
emitter.emit("throwAnError", { id: props.data.id, hasError: false, type: props.data.type });
|
||||
// 无错误,关闭抽屉
|
||||
emits('close')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 handleSaveError 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const handleSaveError = () => {
|
||||
emitter.emit('setProperties', { id: props.data.id })
|
||||
emitter.emit("throwAnError", { id: props.data.id, hasError: true, type: props.data.type });
|
||||
emits('close')
|
||||
}
|
||||
|
||||
const handleExtractStep = (payload) => emits('extract-step', payload)
|
||||
|
||||
// 组件卸载时清理事件监听
|
||||
// 注意:emitter.off 应在各子组件中自行清理,父组件不再重复
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.flow-params-drawer {
|
||||
.el-drawer__header {
|
||||
min-height: 62px;
|
||||
margin-bottom: 0 !important;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #e4e8ef;
|
||||
color: #1f2937;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.el-drawer__body {
|
||||
padding: 0 20px 24px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.el-collapse {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.el-collapse-item__header {
|
||||
height: 50px;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.form__container {
|
||||
padding: 4px 0 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,411 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:运行前资源分配编辑器,根据流程定义的机器人角色和设备用途,为本次运行生成 runtimeAssignments。
|
||||
* 输入/输出:roles 描述资源需求,modelValue 承载已有分配;组件通过 v-model 回传当前选择。
|
||||
* 重要约束:只加载在线机器人;AGV 角色可按 requiredMapId 过滤;同一检测项内部遵守设备复用规则。
|
||||
-->
|
||||
<template>
|
||||
<div class="runtime-assignments">
|
||||
<!-- 每个 role 对应一个运行时机器人角色,目前每个角色只分配一个 member。 -->
|
||||
<div v-for="role in roles" :key="role.roleKey" class="runtime-role">
|
||||
<div class="runtime-role__title">
|
||||
<span>{{ role.displayName }}</span>
|
||||
<el-tag v-if="role.required" size="small" type="info">必需</el-tag>
|
||||
</div>
|
||||
<el-form label-position="top">
|
||||
<!-- 机器人选项会先限制为在线机器人;包含 AGV 用途时再按任务地图过滤。 -->
|
||||
<el-form-item label="执行机器人" :required="role.required">
|
||||
<el-select
|
||||
:model-value="memberOf(role).robotId"
|
||||
clearable
|
||||
filterable
|
||||
:loading="robotLoading || autoAssigning"
|
||||
placeholder="请选择机器人"
|
||||
style="width: 100%"
|
||||
@change="(robotId) => changeRobot(role, robotId)"
|
||||
>
|
||||
<el-option v-for="item in robotOptionsOf(role)" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<!-- deviceSlots 是该角色所需的设备用途,例如底盘、相机、机械臂;选项依赖当前机器人。 -->
|
||||
<el-form-item v-for="slot in role.deviceSlots" :key="slot.slotKey" :label="slot.displayName" :required="slot.required">
|
||||
<el-select
|
||||
v-model="memberOf(role).devices[slot.slotKey]"
|
||||
filterable
|
||||
:loading="autoAssigning || loadingRoleKey === role.roleKey"
|
||||
:disabled="!memberOf(role).robotId"
|
||||
:placeholder="memberOf(role).robotId ? '请选择设备' : '请先选择机器人'"
|
||||
style="width: 100%"
|
||||
@change="emitValue"
|
||||
>
|
||||
<el-option v-for="item in optionsOf(role, slot)" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<!-- 已选择机器人但没有可选设备时给出局部提示,最终阻断仍由 validate 统一完成。 -->
|
||||
<div v-if="memberOf(role).robotId && !optionsOf(role, slot).length" class="runtime-role__error">
|
||||
该机器人没有匹配的在线设备
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<!-- 列表加载失败和设备加载失败统一聚合为一个错误提示,避免多个异步错误同时占用表单空间。 -->
|
||||
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon />
|
||||
<el-empty v-if="!roles.length" :image-size="52" description="当前任务不需要机器人" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { getRobotList, getRobotDevicesByRobotId } from '@/api/resource/robot'
|
||||
import { toDeviceOptions } from '@/utils/robotDevice'
|
||||
|
||||
/**
|
||||
* roles 来自流程起点的 resourceRoles,描述本次任务需要哪些机器人角色和设备用途。
|
||||
* modelValue 是父组件缓存或回显的 runtimeAssignments,本组件只维护一份可编辑副本并通过 update:modelValue 同步出去。
|
||||
* requiredMapId 用于有 AGV 用途时筛出位于同一地图的机器人。
|
||||
*/
|
||||
const props = defineProps({
|
||||
roles: { type: Array, default: () => [] },
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
requiredMapId: { type: [String, Number], default: '' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// assignments 是表单直接修改的运行时分配副本,避免直接改父组件传入的 modelValue。
|
||||
const assignments = ref([])
|
||||
|
||||
// robotOptions 保存在线机器人列表;devicesByRole 保存“角色 -> 当前机器人在线设备”的下拉数据。
|
||||
const robotOptions = ref([])
|
||||
const devicesByRole = reactive({})
|
||||
|
||||
// 三类 loading 分开维护,模板可精确显示机器人列表、自动分配或某个角色设备加载状态。
|
||||
const robotLoading = ref(false)
|
||||
const autoAssigning = ref(false)
|
||||
const loadingRoleKey = ref('')
|
||||
|
||||
const robotLoadError = ref('')
|
||||
const deviceLoadErrors = reactive({})
|
||||
|
||||
// 缓存按机器人 ID 发起的设备列表请求;多个角色尝试同一机器人时复用同一个 Promise。
|
||||
const onlineDevicesByRobot = new Map()
|
||||
|
||||
// 对外只展示一个当前最重要的加载错误,validate 也复用该聚合结果。
|
||||
const loadError = computed(() => robotLoadError.value || Object.values(deviceLoadErrors).find(Boolean) || '')
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value || []))
|
||||
|
||||
// initialize 会保证每个 role 都有一个 assignment/member,因此模板和校验可以按 roleKey 直接取值。
|
||||
const assignmentOf = (role) => assignments.value.find((item) => item.roleKey === role.roleKey)
|
||||
const memberOf = (role) => assignmentOf(role).members[0]
|
||||
const emitValue = () => emit('update:modelValue', clone(assignments.value))
|
||||
|
||||
/**
|
||||
* 加载在线机器人列表。
|
||||
*
|
||||
* 这里不直接处理地图过滤,因为是否需要地图约束取决于具体 role 是否包含 AGV 设备用途;
|
||||
* role 级过滤由 robotOptionsOf 统一计算。
|
||||
*/
|
||||
const loadRobots = async () => {
|
||||
robotLoading.value = true
|
||||
robotLoadError.value = ''
|
||||
try {
|
||||
const response = await getRobotList({
|
||||
pageNum: 1,
|
||||
pageSize: 1000,
|
||||
connectStatus: '1',
|
||||
})
|
||||
robotOptions.value = (response.rows || []).map((robot) => ({
|
||||
label: `${robot.robotName || robot.robotId} (${robot.robotId})`,
|
||||
value: robot.robotId,
|
||||
currentMapId: robot.currentMapId,
|
||||
}))
|
||||
} catch (error) {
|
||||
robotOptions.value = []
|
||||
robotLoadError.value = '机器人列表加载失败,请稍后重试'
|
||||
} finally {
|
||||
robotLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回某个角色可选的机器人。
|
||||
*
|
||||
* 只有包含移动底盘设备用途的角色才要求机器人位于任务地图,其他纯传感器/执行器角色不受 requiredMapId 限制。
|
||||
*/
|
||||
const robotOptionsOf = (role) => {
|
||||
const requiresTaskMap = (role.deviceSlots || []).some((slot) => Number(slot.deviceKind) === 1)
|
||||
if (!props.requiredMapId || !requiresTaskMap) return robotOptions.value
|
||||
return robotOptions.value.filter((robot) => String(robot.currentMapId || '') === String(props.requiredMapId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某台机器人当前在线设备。
|
||||
*
|
||||
* 缓存的是请求 Promise,而不只是结果数组:并发自动分配多个角色时,相同机器人只会触发一次接口请求;
|
||||
* 失败时清掉缓存,避免后续重试继续拿到已 rejected 的 Promise。
|
||||
*/
|
||||
const getOnlineDevices = async (robotId) => {
|
||||
if (!onlineDevicesByRobot.has(robotId)) {
|
||||
const request = getRobotDevicesByRobotId(robotId).then((response) => (
|
||||
Array.isArray(response.data) ? response.data : []
|
||||
))
|
||||
onlineDevicesByRobot.set(robotId, request)
|
||||
}
|
||||
try {
|
||||
return await onlineDevicesByRobot.get(robotId)
|
||||
} catch (error) {
|
||||
onlineDevicesByRobot.delete(robotId)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 设备复用规则按“检测项 + 第几次出现”隔离;没有检测项上下文时退回到任务级作用域。
|
||||
const itemScopeOf = (role) => role.itemId == null
|
||||
? 'task'
|
||||
: `${role.itemId}:${role.itemOccurrence || 1}`
|
||||
|
||||
const deviceMatchesSlot = (device, slot) => {
|
||||
return Boolean(device?.deviceId)
|
||||
&& (slot.deviceKind == null || Number(device.deviceKind) === Number(slot.deviceKind))
|
||||
}
|
||||
|
||||
/**
|
||||
* 在不修改当前表单的前提下,为一个角色预演设备选择。
|
||||
*
|
||||
* committedUses 是前面角色已经确定占用的物理设备;函数复制一份 plannedUses 后再尝试写入,
|
||||
* 只有整个角色的必需设备都能满足时才返回计划,避免失败候选污染后续匹配。
|
||||
*/
|
||||
const planRoleDevices = (role, robotId, devices, committedUses, preferredDevices = {}) => {
|
||||
const plannedUses = new Map(committedUses)
|
||||
const selectedDevices = {}
|
||||
for (const slot of role.deviceSlots || []) {
|
||||
// 先按设备类型筛选,deviceKind 为空表示该用途接受任意设备。
|
||||
const matchingDevices = devices.filter((device) => deviceMatchesSlot(device, slot))
|
||||
const preferredId = preferredDevices[slot.slotKey]
|
||||
// 自动分配时优先保留用户已有选择;若该设备不可用,再按列表顺序寻找替代设备。
|
||||
const orderedDevices = preferredId
|
||||
? [
|
||||
...matchingDevices.filter((device) => device.deviceId === preferredId),
|
||||
...matchingDevices.filter((device) => device.deviceId !== preferredId),
|
||||
]
|
||||
: matchingDevices
|
||||
const selected = orderedDevices.find((device) => {
|
||||
// 同一个检测项内部用 role/item/robot/device 组成物理占用键,确保复用判断落到真实设备。
|
||||
const physicalKey = `${itemScopeOf(role)}:${robotId}:${device.deviceId}`
|
||||
const existingUse = plannedUses.get(physicalKey)
|
||||
return !existingUse || (slot.allowReuse && existingUse.allowReuse)
|
||||
})
|
||||
if (!selected && slot.required) return null
|
||||
selectedDevices[slot.slotKey] = selected?.deviceId || ''
|
||||
if (selected) {
|
||||
plannedUses.set(`${itemScopeOf(role)}:${robotId}:${selected.deviceId}`, {
|
||||
allowReuse: slot.allowReuse === true,
|
||||
})
|
||||
}
|
||||
}
|
||||
return { selectedDevices, plannedUses }
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前在线机器人和设备,按角色顺序自动补齐可行分配。
|
||||
*
|
||||
* 该算法采用顺序贪心策略:每个角色选择第一台能满足全部必需设备的机器人;
|
||||
* 找不到可行候选时清空该角色,交给用户手动选择或 validate 给出明确提示。
|
||||
*/
|
||||
const autoAssignRoles = async () => {
|
||||
let committedUses = new Map()
|
||||
autoAssigning.value = true
|
||||
try {
|
||||
for (const role of props.roles) {
|
||||
const member = memberOf(role)
|
||||
const candidates = robotOptionsOf(role)
|
||||
let match = null
|
||||
for (const robot of candidates) {
|
||||
try {
|
||||
const devices = (role.deviceSlots || []).length
|
||||
? await getOnlineDevices(robot.value)
|
||||
: []
|
||||
const preferredDevices = robot.value === member.robotId ? member.devices : {}
|
||||
const plan = planRoleDevices(role, robot.value, devices, committedUses, preferredDevices)
|
||||
if (plan) {
|
||||
match = { robot, devices, ...plan }
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
// A failed device query makes this robot ineligible for automatic assignment.
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
member.robotId = match.robot.value
|
||||
member.devices = match.selectedDevices
|
||||
devicesByRole[role.roleKey] = match.devices
|
||||
committedUses = match.plannedUses
|
||||
} else {
|
||||
member.robotId = ''
|
||||
member.devices = Object.fromEntries((role.deviceSlots || []).map((slot) => [slot.slotKey, '']))
|
||||
devicesByRole[role.roleKey] = []
|
||||
}
|
||||
}
|
||||
emitValue()
|
||||
} finally {
|
||||
autoAssigning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成某个设备用途的下拉选项。
|
||||
*
|
||||
* 同一角色内已经被其他用途占用、且不允许复用的设备会被过滤掉;
|
||||
* 当前 slot 已选中的值会保留在选项中,避免选项刷新时把正在编辑的值瞬间隐藏。
|
||||
*/
|
||||
const optionsOf = (role, slot) => {
|
||||
const member = memberOf(role)
|
||||
const unavailableDeviceIds = new Set((role.deviceSlots || [])
|
||||
.filter((otherSlot) => (
|
||||
otherSlot.slotKey !== slot.slotKey
|
||||
&& (!slot.allowReuse || !otherSlot.allowReuse)
|
||||
))
|
||||
.map((otherSlot) => member.devices[otherSlot.slotKey])
|
||||
.filter(Boolean))
|
||||
return toDeviceOptions((devicesByRole[role.roleKey] || []).filter((device) => (
|
||||
deviceMatchesSlot(device, slot)
|
||||
&& (!unavailableDeviceIds.has(device.deviceId) || member.devices[slot.slotKey] === device.deviceId)
|
||||
)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 按角色当前选择的机器人加载设备列表,并根据 preserve 决定是否尽量保留旧设备选择。
|
||||
*
|
||||
* 用户切换机器人时 preserve=false,设备用途全部重新选;初始化/自动刷新时 preserve=true,
|
||||
* 已选设备仍然可用则保留,否则回退到当前第一个可用设备。
|
||||
*/
|
||||
const loadDevices = async (role, preserve = true) => {
|
||||
const member = memberOf(role)
|
||||
if (!member.robotId) {
|
||||
devicesByRole[role.roleKey] = []
|
||||
return
|
||||
}
|
||||
loadingRoleKey.value = role.roleKey
|
||||
delete deviceLoadErrors[role.roleKey]
|
||||
try {
|
||||
devicesByRole[role.roleKey] = await getOnlineDevices(member.robotId)
|
||||
;(role.deviceSlots || []).forEach((slot) => {
|
||||
const options = optionsOf(role, slot)
|
||||
if (!preserve || !options.some((option) => option.value === member.devices[slot.slotKey])) {
|
||||
member.devices[slot.slotKey] = options[0]?.value || ''
|
||||
}
|
||||
})
|
||||
emitValue()
|
||||
} catch (error) {
|
||||
devicesByRole[role.roleKey] = []
|
||||
deviceLoadErrors[role.roleKey] = `“${role.displayName}”的设备列表加载失败,请重新选择机器人`
|
||||
} finally {
|
||||
loadingRoleKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 机器人发生变化后,先清空该角色下所有设备选择,再基于新机器人加载并填充可用设备。
|
||||
const changeRobot = async (role, robotId) => {
|
||||
const member = memberOf(role)
|
||||
member.robotId = robotId
|
||||
member.devices = Object.fromEntries((role.deviceSlots || []).map((slot) => [slot.slotKey, '']))
|
||||
await loadDevices(role, false)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据最新 props 重建内部状态。
|
||||
*
|
||||
* 这里会清空设备缓存和加载错误,随后把 modelValue、旧版 legacyRobotId/legacyDeviceId 回填到 assignments;
|
||||
* emitValue 先把规范化结构同步给父组件,再加载在线资源并执行自动匹配。
|
||||
*/
|
||||
const initialize = async () => {
|
||||
Object.keys(deviceLoadErrors).forEach((roleKey) => delete deviceLoadErrors[roleKey])
|
||||
Object.keys(devicesByRole).forEach((roleKey) => delete devicesByRole[roleKey])
|
||||
onlineDevicesByRobot.clear()
|
||||
const existing = clone(props.modelValue)
|
||||
assignments.value = props.roles.map((role) => {
|
||||
const existingAssignment = existing.find((item) => item.roleKey === role.roleKey)
|
||||
const existingMember = existingAssignment?.members?.[0]
|
||||
return {
|
||||
roleKey: role.roleKey,
|
||||
...(role.sourceRoleKey ? { sourceRoleKey: role.sourceRoleKey } : {}),
|
||||
...(role.itemId != null ? { itemId: role.itemId } : {}),
|
||||
...(role.itemOccurrence != null ? { itemOccurrence: role.itemOccurrence } : {}),
|
||||
members: [{
|
||||
...existingMember,
|
||||
memberKey: `${role.roleKey}_1`,
|
||||
robotId: existingMember?.robotId || role.legacyRobotId || '',
|
||||
devices: Object.fromEntries((role.deviceSlots || []).map((slot) => [
|
||||
slot.slotKey,
|
||||
existingMember?.devices?.[slot.slotKey] || slot.legacyDeviceId || '',
|
||||
])),
|
||||
}],
|
||||
}
|
||||
})
|
||||
emitValue()
|
||||
await loadRobots()
|
||||
await autoAssignRoles()
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交运行前的最终校验。
|
||||
*
|
||||
* 返回空字符串表示通过;返回中文错误文案表示父组件应阻断执行并提示用户。
|
||||
* 校验顺序从异步加载状态、必填机器人/设备,到同一检测项内部的物理设备冲突。
|
||||
*/
|
||||
const validate = () => {
|
||||
if (robotLoading.value || autoAssigning.value) return '正在自动匹配在线机器人和设备,请稍候'
|
||||
const physicalDeviceUses = new Map()
|
||||
for (const role of props.roles) {
|
||||
const member = memberOf(role)
|
||||
if (role.required && !member.robotId) return `请为“${role.displayName}”选择机器人`
|
||||
if (!member.robotId) continue
|
||||
for (const slot of role.deviceSlots || []) {
|
||||
if (slot.required && !member.devices[slot.slotKey]) return `请为“${role.displayName}”选择“${slot.displayName}”`
|
||||
const deviceId = member.devices[slot.slotKey]
|
||||
if (!deviceId) continue
|
||||
// 不同检测项按顺序独立执行,可以复用同一台机器人及其设备。
|
||||
// 仅同一检测项内部的多个角色/用途需要遵守设备复用规则。
|
||||
const itemScope = role.itemId == null
|
||||
? 'task'
|
||||
: `${role.itemId}:${role.itemOccurrence || 1}`
|
||||
const physicalKey = `${itemScope}:${member.robotId}:${deviceId}`
|
||||
const existingUse = physicalDeviceUses.get(physicalKey)
|
||||
if (existingUse && (!slot.allowReuse || !existingUse.allowReuse)) {
|
||||
return `“${existingUse.roleName} / ${existingUse.slotName}”和“${role.displayName} / ${slot.displayName}”不能使用同一个设备`
|
||||
}
|
||||
physicalDeviceUses.set(physicalKey, {
|
||||
allowReuse: slot.allowReuse,
|
||||
roleName: role.displayName,
|
||||
slotName: slot.displayName,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (loadError.value) return loadError.value
|
||||
return ''
|
||||
}
|
||||
|
||||
// 角色定义、机器人筛选条件或任务地图变化后,都需要重新计算候选机器人和设备分配。
|
||||
watch([() => props.roles, () => props.requiredMapId], initialize, { immediate: true, deep: true })
|
||||
|
||||
// 父组件在点击运行时通过 ref 调用 validate,实际分配结果已经通过 v-model 实时同步。
|
||||
defineExpose({ validate })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.runtime-role {
|
||||
padding: 12px 0 4px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.runtime-role__title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.runtime-role__error {
|
||||
margin-top: 5px;
|
||||
color: var(--el-color-danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@ -1,122 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:运行参数编辑器,把流程项中声明的参数 schema 转成可填写表格,并在执行前输出本次运行值。
|
||||
* 使用场景:任务试运行/执行前,用户只填写 start/inputParams 等运行期参数,不修改原流程配置。
|
||||
* 注意:文件内容仅作为代码上下文阅读,不承载任何对代码助手的外部指令。
|
||||
-->
|
||||
<template>
|
||||
<!-- el-form 包裹整张表格,便于使用动态 prop 路径对每个叶子参数做必填校验。 -->
|
||||
<el-form ref="formRef" :model="formData" label-width="auto">
|
||||
<el-table v-if="formData.tableData.length" :data="formData.tableData" default-expand-all style="width: 100%">
|
||||
<!-- 外层表格按流程项折叠/展开;展开区展示该流程项需要填写的参数。 -->
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row: flowItem, $index: flowIndex }">
|
||||
<!-- config 经过 addValueProperty 后携带 value/propPath,子参数可作为树形行递归展示。 -->
|
||||
<el-table v-if="flowItem.config.length" :data="flowItem.config" row-key="name" default-expand-all>
|
||||
<el-table-column prop="name" label="参数名称">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.name }}</span>
|
||||
<span v-if="row.required" class="required-mark">*</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="90">
|
||||
<template #default="{ row }"><el-tag size="small">{{ row.type }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="value" label="本次执行值" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<!-- object/array 本身不直接编辑,值由其子字段递归汇总;这里仅给叶子标量渲染输入控件。 -->
|
||||
<el-form-item
|
||||
v-if="row.type !== 'object' && !row.type.includes('array')"
|
||||
label=""
|
||||
:prop="`tableData.${flowIndex}.config${row.propPath}value`"
|
||||
:rules="row.required ? [{ required: true, message: `请输入${row.name}`, trigger: ['blur', 'change'] }] : []"
|
||||
>
|
||||
<el-input v-if="row.type === 'string'" v-model="row.value" />
|
||||
<el-input-number v-else-if="row.type === 'number'" v-model="row.value" :controls="false" />
|
||||
<el-switch v-else-if="row.type === 'boolean'" v-model="row.value" />
|
||||
<el-input v-else v-model="row.value" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="执行顺序" prop="orderNum" width="90" align="center" />
|
||||
<el-table-column label="流程项" prop="itemName" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<el-empty v-else description="当前任务没有需要填写的运行参数" :image-size="52" />
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { addValueProperty, formatTableData } from '@/utils/flow'
|
||||
|
||||
/**
|
||||
* items 是待执行的流程项列表,每项的 config 是运行参数 schema,可以是数组对象或 JSON 字符串。
|
||||
* 组件不会直接修改 props.items,而是通过 resetItems 深拷贝后写入 formData.tableData。
|
||||
*/
|
||||
const props = defineProps({ items: { type: Array, default: () => [] } })
|
||||
|
||||
// formRef 负责触发 Element Plus 表单校验;tableData 是带 value 字段的可编辑流程项副本。
|
||||
const formRef = ref()
|
||||
const formData = reactive({ tableData: [] })
|
||||
|
||||
/**
|
||||
* 把后端/流程配置中的参数 schema 转成表单可编辑结构。
|
||||
*
|
||||
* addValueProperty 会为每个参数补默认 value 和 propPath;propPath 用于拼出 el-form-item 的校验路径,
|
||||
* 因此这里必须深拷贝 schema,避免把临时 value 写回原始流程项配置。
|
||||
*/
|
||||
const parseConfig = (config) => {
|
||||
if (!config) return []
|
||||
const schema = typeof config === 'string' ? JSON.parse(config) : config
|
||||
return addValueProperty(JSON.parse(JSON.stringify(schema || [])))
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置表格数据。
|
||||
*
|
||||
* orderNum 缺失时按当前数组顺序补齐,保证 UI 上始终有清晰的执行顺序;
|
||||
* config 在这里统一解析,模板层只负责渲染,不再关心原始格式是字符串还是数组。
|
||||
*/
|
||||
const resetItems = (items) => {
|
||||
formData.tableData = (items || []).map((item, index) => ({
|
||||
...item,
|
||||
orderNum: item.orderNum ?? index + 1,
|
||||
config: parseConfig(item.config),
|
||||
}))
|
||||
}
|
||||
|
||||
// 对外暴露的校验方法:没有表单实例时视为通过;校验失败时统一返回 false。
|
||||
const validate = async () => !formRef.value || formRef.value.validate().catch(() => false)
|
||||
|
||||
/**
|
||||
* 组装本次运行参数。
|
||||
*
|
||||
* 同一个 itemId 可能在任务中出现多次,因此用 itemId:occurrence 作为 key;
|
||||
* formatTableData 会把树形参数数组恢复为普通对象,供执行接口直接消费。
|
||||
*/
|
||||
const getRunParams = () => {
|
||||
const occurrences = new Map()
|
||||
const result = {}
|
||||
formData.tableData.forEach((item) => {
|
||||
const itemId = String(item.itemId)
|
||||
const occurrence = (occurrences.get(itemId) || 0) + 1
|
||||
occurrences.set(itemId, occurrence)
|
||||
result[`${itemId}:${occurrence}`] = formatTableData(item.config, {})
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// 父组件切换任务项或刷新配置时,立即重建表格,deep 用于捕获 config 内部的变更。
|
||||
watch(() => props.items, resetItems, { immediate: true, deep: true })
|
||||
|
||||
// 父组件通过 ref 触发表单校验、读取运行参数,也可以在必要时主动重置表格。
|
||||
defineExpose({ validate, getRunParams, resetItems })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.required-mark { margin-left: 6px; color: var(--el-color-danger); }
|
||||
:deep(.el-form-item) { margin-bottom: 0; }
|
||||
:deep(.el-input-number) { width: 100%; }
|
||||
</style>
|
||||
@ -1,173 +0,0 @@
|
||||
<template>
|
||||
<div class="subflow-results">
|
||||
<div class="subflow-results__summary">
|
||||
<div>
|
||||
<strong>{{ data.name || '子流程' }}</strong>
|
||||
<span v-if="data.versionNo">V{{ data.versionNo }}</span>
|
||||
</div>
|
||||
<div class="subflow-results__summary-status">
|
||||
<el-tag v-if="analysisMeta" :type="analysisMeta.type" size="small">{{ analysisMeta.label }}</el-tag>
|
||||
<el-tag :type="statusMeta(data.status).type" size="small">{{ statusMeta(data.status).label }}</el-tag>
|
||||
<span>{{ formatDuration(data.durationMs) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!executions.length" description="暂无内部节点日志" :image-size="56" />
|
||||
<el-collapse v-else v-model="expandedExecutions" class="subflow-results__groups">
|
||||
<el-collapse-item v-for="execution in executions" :key="execution.key" :name="execution.key">
|
||||
<template #title>
|
||||
<div class="subflow-results__group-title">
|
||||
<strong>{{ execution.label }}</strong>
|
||||
<el-tag :type="statusMeta(execution.status).type" size="small">
|
||||
{{ statusMeta(execution.status).label }}
|
||||
</el-tag>
|
||||
<span>{{ execution.nodes?.length || 0 }} 个节点</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="subflow-results__nodes">
|
||||
<details v-for="(node, index) in execution.nodes || []" :key="`${execution.key}:${node.nodeId}:${index}`">
|
||||
<summary>
|
||||
<span class="subflow-results__order">{{ index + 1 }}</span>
|
||||
<span class="subflow-results__node-name">{{ node.nodeName }}</span>
|
||||
<span v-if="node.actionLabel && node.actionLabel !== node.nodeName" class="subflow-results__action">
|
||||
{{ node.actionLabel }}
|
||||
</span>
|
||||
<el-tag :type="statusMeta(node.status).type" size="small">{{ statusMeta(node.status).label }}</el-tag>
|
||||
<el-tag v-if="node.passed !== null && node.passed !== undefined" :type="node.passed ? 'success' : 'danger'" size="small">
|
||||
{{ node.passed ? '通过' : '未通过' }}
|
||||
</el-tag>
|
||||
<span class="subflow-results__duration">{{ formatDuration(node.durationMs) }}</span>
|
||||
</summary>
|
||||
<div v-if="node.error" class="subflow-results__error">{{ node.error }}</div>
|
||||
<div v-if="hasValue(node.input)" class="subflow-results__data">
|
||||
<div class="subflow-results__label">输入</div>
|
||||
<JsonViewer :value="node.input" copyable boxed sort theme="light" />
|
||||
</div>
|
||||
<div v-if="hasValue(node.output)" class="subflow-results__data">
|
||||
<div class="subflow-results__label">输出</div>
|
||||
<JsonViewer :value="node.output" copyable boxed sort theme="light" />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<details v-if="hasValue(data.output)" class="subflow-results__final-output">
|
||||
<summary>子流程最终输出</summary>
|
||||
<JsonViewer :value="data.output" copyable boxed sort theme="light" />
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({ data: { type: Object, default: () => ({}) } })
|
||||
const executions = computed(() => Array.isArray(props.data.executions) ? props.data.executions : [])
|
||||
const expandedExecutions = ref([])
|
||||
let initialized = false
|
||||
|
||||
watch(executions, (groups) => {
|
||||
if (initialized || !groups.length) return
|
||||
const active = groups.find((group) => String(group.status || '').toUpperCase() === 'RUNNING')
|
||||
expandedExecutions.value = [active?.key || groups[groups.length - 1].key]
|
||||
initialized = true
|
||||
}, { immediate: true })
|
||||
|
||||
const statusMeta = (status) => {
|
||||
const value = String(status || '').toUpperCase()
|
||||
if (value === 'SUCCESS') return { label: '执行完成', type: 'success' }
|
||||
if (value === 'FAILED') return { label: '执行失败', type: 'danger' }
|
||||
if (value === 'STOPPED') return { label: '已停止', type: 'info' }
|
||||
if (value === 'PAUSED') return { label: '已暂停', type: 'warning' }
|
||||
return { label: '执行中', type: 'warning' }
|
||||
}
|
||||
|
||||
const analysisMeta = computed(() => {
|
||||
if (props.data.analysisStatus === 'PASSED') return { label: '分析通过', type: 'success' }
|
||||
if (props.data.analysisStatus === 'FAILED') return { label: '分析未通过', type: 'danger' }
|
||||
if (props.data.analysisStatus === 'RUNNING') return { label: '分析中', type: 'warning' }
|
||||
return null
|
||||
})
|
||||
|
||||
const formatDuration = (value) => {
|
||||
const duration = Number(value)
|
||||
if (!Number.isFinite(duration) || duration < 0) return '-'
|
||||
if (duration < 1000) return `${duration} ms`
|
||||
return `${(duration / 1000).toFixed(2)} s`
|
||||
}
|
||||
|
||||
const hasValue = (value) => {
|
||||
if (value === null || value === undefined || value === '') return false
|
||||
if (Array.isArray(value)) return value.length > 0
|
||||
if (typeof value === 'object') return Object.keys(value).length > 0
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.subflow-results__summary,
|
||||
.subflow-results__summary-status,
|
||||
.subflow-results__group-title,
|
||||
.subflow-results__nodes summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.subflow-results__summary {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 2px 2px 12px;
|
||||
color: #344054;
|
||||
}
|
||||
|
||||
.subflow-results__summary > div,
|
||||
.subflow-results__summary-status { display: flex; align-items: center; gap: 8px; }
|
||||
.subflow-results__summary span { color: #667085; font-size: 12px; }
|
||||
.subflow-results__groups { border-top: 0; }
|
||||
|
||||
.subflow-results__group-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.subflow-results__group-title > span:last-child {
|
||||
margin-left: auto;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.subflow-results__nodes { display: grid; gap: 5px; padding-bottom: 10px; }
|
||||
.subflow-results__nodes details { border-bottom: 1px solid #ebeef5; }
|
||||
|
||||
.subflow-results__nodes summary {
|
||||
min-height: 36px;
|
||||
gap: 7px;
|
||||
padding: 3px 4px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.subflow-results__nodes summary::-webkit-details-marker { display: none; }
|
||||
.subflow-results__order { width: 20px; color: #98a2b3; font-size: 11px; text-align: center; }
|
||||
.subflow-results__node-name { min-width: 0; overflow: hidden; color: #303133; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.subflow-results__action { color: #98a2b3; font-size: 11px; white-space: nowrap; }
|
||||
.subflow-results__duration { margin-left: auto; color: #909399; font-size: 12px; white-space: nowrap; }
|
||||
|
||||
.subflow-results__error {
|
||||
margin: 4px 4px 8px 31px;
|
||||
padding: 7px 9px;
|
||||
border-left: 3px solid var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
color: var(--el-color-danger-dark-2);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subflow-results__data { margin: 6px 4px 10px 31px; }
|
||||
.subflow-results__label { margin-bottom: 5px; color: #667085; font-size: 12px; font-weight: 600; }
|
||||
.subflow-results__final-output { margin-top: 10px; padding-top: 10px; border-top: 1px solid #ebeef5; }
|
||||
.subflow-results__final-output summary { margin-bottom: 8px; color: #344054; cursor: pointer; font-size: 12px; font-weight: 600; }
|
||||
</style>
|
||||
@ -1,889 +0,0 @@
|
||||
<template>
|
||||
<div class="task-log" :style="{ height: `${height}px` }">
|
||||
<!-- 独立滚动层负责监听用户位置;外层仅提供边框和悬浮按钮的定位上下文。 -->
|
||||
<div ref="messageContainer" class="task-log__scroll" @scroll.passive="handleScroll">
|
||||
<!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 -->
|
||||
<template v-for="(log, index) in displayLogs" :key="log.__displayKey || log.id || index">
|
||||
<button
|
||||
v-if="log.__iterationHeader"
|
||||
type="button"
|
||||
class="task-log__iteration-header"
|
||||
@click="toggleIteration(log.__iterationKey)"
|
||||
>
|
||||
<el-icon :class="{ 'is-expanded': isIterationExpanded(log.__iterationKey) }"><ArrowRight /></el-icon>
|
||||
<span class="task-log__iteration-title">{{ log.__iterationLabel }}</span>
|
||||
<el-tag :type="log.__statusType" size="small">{{ log.__statusLabel }}</el-tag>
|
||||
<el-tag v-if="log.__analysisLabel" :type="log.__analysisType" size="small">
|
||||
{{ log.__analysisLabel }}
|
||||
</el-tag>
|
||||
<span class="task-log__iteration-count">{{ log.__count }} 条日志</span>
|
||||
</button>
|
||||
<div v-else class="task-log__item" :class="{ 'is-iteration-child': iterationPathOf(log).length }">
|
||||
<div class="task-log__time">{{ formatDate(logTimeOf(log)) }}</div>
|
||||
<div class="task-log__body">
|
||||
<div class="task-log__content">{{ logContentOf(log) }}</div>
|
||||
|
||||
<div v-if="displayErrorOf(log)" class="task-log__error">
|
||||
<strong>失败原因</strong>
|
||||
<span>{{ displayErrorOf(log) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 只有 analysisOf 能确认是音频或视频分析日志时,才展示结构化分析结果。 -->
|
||||
<div v-if="analysisOf(log)" class="task-log__analysis">
|
||||
<!-- 标题行展示分析类型、执行状态、模型名称和耗时;缺失字段不会留下空占位。 -->
|
||||
<div class="task-log__analysis-heading">
|
||||
<el-tag :type="analysisOf(log).tagType" size="small">
|
||||
{{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }}
|
||||
</el-tag>
|
||||
<span v-if="isDebugView && 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>
|
||||
|
||||
<!-- 常用结果采用四列摘要,便于快速比较;详细扩展字段放到下方 JSON 区域。 -->
|
||||
<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">
|
||||
<span>{{ analysisOf(log).isAudio ? '实际声音' : '识别结果' }}</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>
|
||||
|
||||
<!-- 关键事件可能来自模型输出数组,analysisOf 已限制最多展示前 20 条。 -->
|
||||
<div v-if="analysisOf(log).events.length" class="task-log__analysis-events">
|
||||
<div class="task-log__analysis-label">关键事件</div>
|
||||
<div v-for="(event, eventIndex) in analysisOf(log).events" :key="eventIndex" class="task-log__analysis-event">
|
||||
{{ event }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未被结构化消费的字段以格式化 JSON 展示,保留排查模型输出的能力。 -->
|
||||
<pre v-if="isDebugView && analysisOf(log).details" class="task-log__analysis-details">{{ analysisOf(log).details }}</pre>
|
||||
</div>
|
||||
|
||||
<pre v-if="isDebugView && genericOutputOf(log)" class="task-log__output">{{ genericOutputOf(log) }}</pre>
|
||||
<div v-else-if="!isDebugView && businessSummaryOf(log)" class="task-log__business-summary">
|
||||
{{ businessSummaryOf(log) }}
|
||||
</div>
|
||||
|
||||
<!-- 同一日志可同时携带多种媒体;mediaOf 负责从新旧字段提取并按“类型+URL”去重。 -->
|
||||
<div v-if="mediaOf(log).length" class="task-log__media">
|
||||
<template v-for="media in mediaOf(log)" :key="`${media.type}:${media.url}`">
|
||||
<el-image
|
||||
v-if="media.type === 'image'"
|
||||
class="task-log__image"
|
||||
:src="media.url"
|
||||
:preview-src-list="imageUrlsOf(log)"
|
||||
preview-teleported
|
||||
fit="cover"
|
||||
/>
|
||||
<!-- 视频和音频只预加载元数据,避免日志列表初次渲染时下载完整媒体文件。 -->
|
||||
<video v-else-if="media.type === 'video'" class="task-log__video" :src="media.url" controls preload="metadata" />
|
||||
<audio v-else class="task-log__audio" :src="media.url" controls preload="metadata" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 用户离开底部后显示悬浮入口;点击后回到最新日志并恢复自动跟随。 -->
|
||||
<el-tooltip v-if="!isAtBottom" content="跳到最新日志" placement="left">
|
||||
<el-button
|
||||
class="task-log__jump-latest"
|
||||
type="primary"
|
||||
:icon="Bottom"
|
||||
circle
|
||||
aria-label="跳到最新日志"
|
||||
@click.stop="scrollToBottom"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { ArrowRight, Bottom } from '@element-plus/icons-vue'
|
||||
import { formatDate } from '@/utils/index'
|
||||
|
||||
/**
|
||||
* list: 任务或流程节点产生的日志数组。组件只负责展示,不修改日志对象。
|
||||
* height: 日志容器固定高度;内容超出后在容器内滚动,默认 150px。
|
||||
*/
|
||||
const props = defineProps({
|
||||
list: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 150 },
|
||||
mode: { type: String, default: 'debug' },
|
||||
})
|
||||
|
||||
const isDebugView = computed(() => props.mode !== 'user')
|
||||
|
||||
// 保存日志滚动容器 DOM,用于首次渲染和日志更新后滚动到最新一条。
|
||||
const messageContainer = ref(null)
|
||||
|
||||
// 使用少量像素容差吸收浏览器对子像素和缩放比例的取整误差,避免视觉上已到底部却仍显示按钮。
|
||||
const BOTTOM_THRESHOLD = 8
|
||||
|
||||
// 初始状态视为位于底部;组件挂载后会根据真实滚动尺寸完成第一次定位。
|
||||
const isAtBottom = ref(true)
|
||||
|
||||
// 以日志对象本身作为弱引用键缓存媒体解析结果;日志对象回收后缓存会自动释放,不产生长期内存占用。
|
||||
const mediaCache = new WeakMap()
|
||||
|
||||
/**
|
||||
* 将扩展结果安全转换为普通对象。
|
||||
* 后端历史数据可能返回 JSON 字符串,新版接口可能直接返回对象;空值、基础类型或非法 JSON 均降级为空对象,
|
||||
* 避免一条格式异常的日志阻断整个列表渲染。
|
||||
*/
|
||||
const parseObject = (value) => {
|
||||
if (!value) return {}
|
||||
if (typeof value === 'object') return value
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const outputOf = (log) => ({
|
||||
...parseObject(log?.paramsOut),
|
||||
...parseObject(log?.actualResult),
|
||||
...parseObject(log?.extraInfo),
|
||||
})
|
||||
|
||||
const executionContextOf = (log) => outputOf(log)._executionContext || {}
|
||||
|
||||
const rawIterationPathOf = (log) => {
|
||||
const values = [log?.iterations, executionContextOf(log).iterations, log?.iteration]
|
||||
for (const value of values) {
|
||||
const path = Array.isArray(value)
|
||||
? value.filter(item => item !== null && item !== '')
|
||||
: value === null || value === undefined || value === ''
|
||||
? []
|
||||
: String(value).split(/[-.,/]/).map(item => item.trim()).filter(Boolean)
|
||||
if (path.length) return path
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Historical NODE_STARTED records were persisted before the loop path was copied
|
||||
// into the event. Pair them with the next terminal event of the same node so old
|
||||
// task logs can be folded without rewriting database rows.
|
||||
const inferredIterationPaths = computed(() => {
|
||||
const result = new WeakMap()
|
||||
const pendingStarts = new Map()
|
||||
;(Array.isArray(props.list) ? props.list : []).forEach((log) => {
|
||||
const eventType = String(executionContextOf(log).eventType || '').toUpperCase()
|
||||
const nodeKey = String(log?.nodeId || log?.nodeName || log?.executionStep || '')
|
||||
if (!nodeKey) return
|
||||
if (eventType === 'NODE_STARTED') {
|
||||
if (!rawIterationPathOf(log).length) {
|
||||
if (!pendingStarts.has(nodeKey)) pendingStarts.set(nodeKey, [])
|
||||
pendingStarts.get(nodeKey).push(log)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!['NODE_COMPLETED', 'NODE_FAILED'].includes(eventType)) return
|
||||
const pending = pendingStarts.get(nodeKey)
|
||||
if (!pending?.length) return
|
||||
const startedLog = pending.shift()
|
||||
const path = rawIterationPathOf(log)
|
||||
if (path.length) result.set(startedLog, path)
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const iterationPathOf = (log) => {
|
||||
const path = rawIterationPathOf(log)
|
||||
return path.length ? path : inferredIterationPaths.value.get(log) || []
|
||||
}
|
||||
|
||||
const executionKeyOf = (log) => {
|
||||
const context = executionContextOf(log)
|
||||
const nodeKey = log?.nodeId || log?.executionStep || log?.nodeName || log?.logContent || ''
|
||||
return `${nodeKey}:${iterationPathOf(log).join('.')}:${context.itemOccurrence || ''}`
|
||||
}
|
||||
|
||||
// User-facing task logs keep an unmatched start event while a node is running, then
|
||||
// replace it with the terminal event. Structural wrapper nodes do not add business value.
|
||||
const visibleLogs = computed(() => {
|
||||
const source = Array.isArray(props.list) ? props.list : []
|
||||
if (isDebugView.value) return source
|
||||
const terminalKeys = new Set(source
|
||||
.filter((log) => ['NODE_COMPLETED', 'NODE_FAILED'].includes(
|
||||
String(executionContextOf(log).eventType || '').toUpperCase(),
|
||||
))
|
||||
.map(executionKeyOf))
|
||||
const structuralTypes = new Set(['START', 'END', 'SUB_START', 'SUB_END', 'SUB_FLOW', 'LOOP'])
|
||||
return source.filter((log) => {
|
||||
const context = executionContextOf(log)
|
||||
const eventType = String(context.eventType || '').toUpperCase()
|
||||
const nodeType = String(context.nodeType || log?.nodeType || '').toUpperCase()
|
||||
if (eventType === 'NODE_STARTED' && terminalKeys.has(executionKeyOf(log))) return false
|
||||
if (structuralTypes.has(nodeType) && !errorOf(log)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const logTimeOf = (log) => log?.logTime || log?.createTime || log?.startTime || log?.endTime
|
||||
const logContentOf = (log) => log?.logContent || log?.message
|
||||
|| [log?.action || log?.nodeType, log?.status].filter(Boolean).join(' - ')
|
||||
|| '节点执行记录'
|
||||
|
||||
const analysisPassedOf = (log) => {
|
||||
const output = outputOf(log)
|
||||
const analysisType = String(output.analysisType || '').toUpperCase()
|
||||
const profileCode = String(output.profileCode || '').toLowerCase()
|
||||
const isDecisionAnalysis = analysisType.includes('AUDIO') || analysisType.includes('VIDEO')
|
||||
|| profileCode.includes('audio') || profileCode.includes('video')
|
||||
|| [3, 4].includes(Number(log?.logType))
|
||||
if (!isDecisionAnalysis) return null
|
||||
if (['FAILED', 'ERROR'].includes(String(output.analysisStatus || '').toUpperCase())) return false
|
||||
if (output.passed === undefined || output.passed === null) return null
|
||||
return output.passed === true || String(output.passed).toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
const expandedIterations = ref(new Set())
|
||||
const isIterationExpanded = key => expandedIterations.value.has(key)
|
||||
const toggleIteration = (key) => {
|
||||
const next = new Set(expandedIterations.value)
|
||||
next.has(key) ? next.delete(key) : next.add(key)
|
||||
expandedIterations.value = next
|
||||
}
|
||||
|
||||
const statusOfIteration = (logs) => {
|
||||
const statuses = logs.map(log => String(log?.status ?? log?.logStatus ?? '').toUpperCase())
|
||||
const contexts = logs.map(log => String(executionContextOf(log).eventType || '').toUpperCase())
|
||||
if (statuses.some(status => ['2', 'FAILED', 'FAILURE', 'ERROR'].includes(status))
|
||||
|| contexts.includes('NODE_FAILED')) {
|
||||
return { label: '执行失败', type: 'danger' }
|
||||
}
|
||||
|
||||
const latestEventByNode = new Map()
|
||||
logs.forEach((log, index) => {
|
||||
const eventType = String(executionContextOf(log).eventType || '').toUpperCase()
|
||||
if (!eventType) return
|
||||
const nodeKey = String(log?.nodeId || log?.nodeName || log?.executionStep || index)
|
||||
latestEventByNode.set(nodeKey, eventType)
|
||||
})
|
||||
if ([...latestEventByNode.values()].some(eventType => eventType === 'NODE_STARTED')) {
|
||||
return { label: '执行中', type: 'warning' }
|
||||
}
|
||||
if (latestEventByNode.size) return { label: '执行完成', type: 'success' }
|
||||
if (statuses.some(status => ['RUNNING', '1'].includes(status))) {
|
||||
return { label: '执行中', type: 'warning' }
|
||||
}
|
||||
return { label: '执行完成', type: 'success' }
|
||||
}
|
||||
|
||||
const displayLogs = computed(() => {
|
||||
const source = visibleLogs.value
|
||||
const groups = new Map()
|
||||
source.forEach((log) => {
|
||||
const path = iterationPathOf(log)
|
||||
if (!path.length) return
|
||||
const key = path.join('.')
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key).push(log)
|
||||
})
|
||||
|
||||
const emitted = new Set()
|
||||
const result = []
|
||||
source.forEach((log, sourceIndex) => {
|
||||
const path = iterationPathOf(log)
|
||||
if (!path.length) {
|
||||
result.push(log)
|
||||
return
|
||||
}
|
||||
const key = path.join('.')
|
||||
if (emitted.has(key)) return
|
||||
emitted.add(key)
|
||||
const logs = groups.get(key) || []
|
||||
const status = statusOfIteration(logs)
|
||||
const decisions = logs.map(analysisPassedOf).filter(value => value !== null)
|
||||
const analysisPassed = decisions.length ? decisions.every(Boolean) : null
|
||||
result.push({
|
||||
__iterationHeader: true,
|
||||
__displayKey: `iteration:${key}:${sourceIndex}`,
|
||||
__iterationKey: key,
|
||||
__iterationLabel: `第 ${path.join('.')} 次循环`,
|
||||
__count: logs.length,
|
||||
__statusLabel: status.label,
|
||||
__statusType: status.type,
|
||||
__analysisLabel: analysisPassed === null ? '' : analysisPassed ? '分析通过' : '分析未通过',
|
||||
__analysisType: analysisPassed ? 'success' : 'danger',
|
||||
})
|
||||
if (isIterationExpanded(key)) result.push(...logs)
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
const genericOutputOf = (log) => {
|
||||
if (analysisOf(log)) return ''
|
||||
const output = { ...outputOf(log) }
|
||||
delete output._executionContext
|
||||
delete output.errorMessage
|
||||
if (!Object.keys(output).length) return ''
|
||||
return JSON.stringify(output, null, 2)
|
||||
}
|
||||
|
||||
const businessSummaryOf = (log) => {
|
||||
if (analysisOf(log) || mediaOf(log).length) return ''
|
||||
const output = { ...outputOf(log) }
|
||||
delete output._executionContext
|
||||
if (typeof output.result === 'string' && output.result.trim()) return output.result.trim()
|
||||
if (['number', 'boolean'].includes(typeof output.result)) return `结果:${output.result}`
|
||||
if (Array.isArray(output.coordinates) && output.coordinates.length) {
|
||||
return `坐标:${output.coordinates.join(', ')}`
|
||||
}
|
||||
if (Number.isFinite(Number(output.completedSteps))) return `已完成 ${output.completedSteps} 个步骤`
|
||||
if (output.success === true) return '操作成功'
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一个或多个媒体 URL 追加到结果数组。
|
||||
* seen 使用“媒体类型:URL”作为去重键,因此同一 URL 以图片和视频两种类型出现时仍会分别保留;
|
||||
* 非字符串、空白字符串和已经添加过的条目会被忽略。
|
||||
*/
|
||||
const addMedia = (result, seen, type, value) => {
|
||||
const values = Array.isArray(value) ? value : [value]
|
||||
values.forEach((item) => {
|
||||
const url = typeof item === 'string' ? item.trim() : ''
|
||||
const key = `${type}:${url}`
|
||||
if (url && !seen.has(key)) {
|
||||
seen.add(key)
|
||||
result.push({ type, url })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 分析结果的计算相对复杂,使用 WeakMap 保证模板多次调用 analysisOf(log) 时只解析一次。
|
||||
const analysisCache = new WeakMap()
|
||||
|
||||
const errorCache = new WeakMap()
|
||||
const errorOf = (log) => {
|
||||
if (!log || typeof log !== 'object') return ''
|
||||
if (errorCache.has(log)) return errorCache.get(log)
|
||||
const actualResult = parseObject(log.actualResult)
|
||||
const extraInfo = parseObject(log.extraInfo)
|
||||
const paramsOut = parseObject(log.paramsOut)
|
||||
const status = String(log.status ?? log.logStatus ?? '').trim().toLowerCase()
|
||||
const failed = ['2', 'failed', 'failure', 'error'].includes(status)
|
||||
const candidates = [
|
||||
log.errorMessage,
|
||||
log.errorStack,
|
||||
actualResult.errorMessage,
|
||||
actualResult.error,
|
||||
extraInfo.errorMessage,
|
||||
extraInfo.error,
|
||||
paramsOut.errorMessage,
|
||||
paramsOut.error,
|
||||
failed ? log.message : '',
|
||||
]
|
||||
const result = candidates.find(value => typeof value === 'string' && value.trim())?.trim() || ''
|
||||
errorCache.set(log, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const displayErrorOf = (log) => {
|
||||
const error = errorOf(log)
|
||||
if (!error || isDebugView.value) return error
|
||||
return error
|
||||
.split(/\r?\n/)[0]
|
||||
.replace(/^(?:[\w$.]+(?:Exception|Error):\s*)+/, '')
|
||||
.replace(/^执行异常[::]\s*/, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 把日志中的音视频分析结果转换成模板直接使用的展示模型。
|
||||
* 返回 null 表示该日志不是可展示的音视频分析结果;否则返回状态、模型、置信度、结论和扩展详情等字段。
|
||||
*/
|
||||
const analysisOf = (log) => {
|
||||
// 防御非对象输入,同时优先读取缓存,避免模板中多个字段重复做 JSON.parse 和格式化工作。
|
||||
if (!log || typeof log !== 'object') return null
|
||||
if (analysisCache.has(log)) return analysisCache.get(log)
|
||||
|
||||
// extraInfo 后展开,因此同名字段会覆盖 actualResult;这是为了让后续补充/修正的信息优先显示。
|
||||
const output = {
|
||||
...parseObject(log.paramsOut),
|
||||
...parseObject(log.actualResult),
|
||||
...parseObject(log.extraInfo),
|
||||
}
|
||||
|
||||
// 新日志通过 analysisType/profileCode 标识媒体类型,logType=3/4 用于兼容旧版视频/音频日志。
|
||||
const analysisType = String(output.analysisType || '').toUpperCase()
|
||||
const profileCode = String(output.profileCode || '').toLowerCase()
|
||||
const isAudio = analysisType.includes('AUDIO') || profileCode.includes('audio') || Number(log.logType) === 4
|
||||
const isVideo = analysisType.includes('VIDEO') || profileCode.includes('video') || Number(log.logType) === 3
|
||||
// 图片分析没有历史 logType 枚举,只依赖新协议中的 analysisType/profileCode 识别。
|
||||
const isImage = analysisType.includes('IMAGE') || profileCode.includes('image')
|
||||
|
||||
// 仅有媒体类型还不足以展示分析面板,至少要存在一个分析状态或业务结果字段。
|
||||
const hasResult = Boolean(
|
||||
output.analysisStatus
|
||||
|| output.analysisType
|
||||
|| output.profileCode
|
||||
|| output.label
|
||||
|| output.labelName
|
||||
|| output.summary
|
||||
|| output.conclusion
|
||||
|| output.resultText
|
||||
|| output.result !== undefined
|
||||
|| output.passed !== undefined
|
||||
)
|
||||
|
||||
// 非分析日志也缓存 null,避免之后每次渲染都重复执行同一套识别逻辑。
|
||||
if (!hasResult || (!isAudio && !isVideo && !isImage)) {
|
||||
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)
|
||||
|
||||
// 置信度兼容 0~1 和 0~100 两种协议,统一限制到 0~100 并保留一位小数。
|
||||
// 非数字值返回 null,模板据此隐藏置信度字段,而不是展示 NaN。
|
||||
const confidence = Number.isFinite(confidenceValue)
|
||||
? Math.max(0, Math.min(100, confidenceValue <= 1 ? confidenceValue * 100 : confidenceValue)).toFixed(1)
|
||||
: null
|
||||
|
||||
// 常见成功/失败枚举转换为中文;未知状态保留原值,完全缺失时使用中性的“已返回结果”。
|
||||
const statusLabel = status === 'SUCCEEDED' || status === 'SUCCESS'
|
||||
|| status === 'COMPLETED'
|
||||
? '分析完成'
|
||||
: status === 'FAILED' || status === 'ERROR'
|
||||
? '分析失败'
|
||||
: status || '已返回结果'
|
||||
|
||||
// 复制一份原始输出用于生成扩展详情,后续删除操作不会修改日志本身或合并后的 output。
|
||||
const detailSource = { ...output }
|
||||
|
||||
// _executionContext 只用于前端把日志归属到任务中的检测项和执行次数,属于内部路由元数据。
|
||||
// 它与已经结构化展示的分析字段一起从“详情”中移除,避免用户看到重复且难以理解的底层上下文。
|
||||
;[
|
||||
'analysisStatus', 'analysisType', 'profileCode', 'analysisModel', 'analysisTimingMs',
|
||||
'mediaUrl', 'mediaUrls', 'audioUrl', 'videoUrl', 'imageUrl', 'label', 'labelName', 'matched',
|
||||
'confidence', 'passed', 'summary', 'conclusion', 'events', '_executionContext',
|
||||
'expectedLabel', 'expectedLabelName', 'decisionReason', 'resultText', 'result',
|
||||
].forEach((key) => delete detailSource[key])
|
||||
|
||||
// 所有已知字段删除后还有剩余内容时,才生成详情 JSON,避免显示空对象。
|
||||
const hasDetails = Object.keys(detailSource).length > 0
|
||||
|
||||
// 统一生成展示模型,模板不需要了解各版本日志字段和状态枚举的差异。
|
||||
const result = {
|
||||
title: isAudio ? '声音类型检测' : isImage ? '通用图片分析' : '视频分析',
|
||||
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 || '',
|
||||
targetLabel: output.expectedLabelName || output.expectedLabel || '',
|
||||
matchLabel: isAudio ? '' : matched === true ? '匹配' : matched === false ? '未匹配' : '',
|
||||
confidence,
|
||||
passedLabel: passed === true ? '通过' : passed === false ? '未通过' : '',
|
||||
// 图片分析由提示词决定输出形态,优先展示模型文本或字符串结果,避免把枚举型原因当摘要。
|
||||
summary: output.summary || output.conclusion || output.resultText
|
||||
|| (typeof output.result === 'string' ? output.result : '')
|
||||
|| (/^[A-Z_]+$/.test(String(output.decisionReason || '')) ? '' : output.decisionReason)
|
||||
|| '',
|
||||
isAudio,
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 从日志的新旧字段中提取所有可播放/预览的媒体资源。
|
||||
* 返回值格式统一为 { type, url },模板无需关心 URL 原来位于 actualResult、extraInfo 还是日志顶层。
|
||||
*/
|
||||
const mediaOf = (log) => {
|
||||
if (!log || typeof log !== 'object') return []
|
||||
if (mediaCache.has(log)) return mediaCache.get(log)
|
||||
|
||||
const result = []
|
||||
const seen = new Set()
|
||||
|
||||
// 与分析结果相同,extraInfo 中的同名字段优先于 actualResult。
|
||||
const output = {
|
||||
...parseObject(log.paramsOut),
|
||||
...parseObject(log.actualResult),
|
||||
...parseObject(log.extraInfo),
|
||||
}
|
||||
|
||||
// 优先读取类型明确的新字段,它们不需要额外推断媒体类型。
|
||||
addMedia(result, seen, 'image', output.imageUrl)
|
||||
addMedia(result, seen, 'video', output.videoUrl)
|
||||
addMedia(result, seen, 'audio', output.audioUrl)
|
||||
const analysisType = String(output.analysisType || '').toUpperCase()
|
||||
const profileCode = String(output.profileCode || '').toLowerCase()
|
||||
|
||||
// mediaUrl 本身没有类型,必须结合分析类型或 profileCode 后才能作为视频/音频加入。
|
||||
if (analysisType.includes('VIDEO') || profileCode.includes('video')) addMedia(result, seen, 'video', output.mediaUrl)
|
||||
if (analysisType.includes('AUDIO') || profileCode.includes('audio')) addMedia(result, seen, 'audio', output.mediaUrl)
|
||||
if (analysisType.includes('IMAGE') || profileCode.includes('image')) {
|
||||
addMedia(result, seen, 'image', output.mediaUrls)
|
||||
addMedia(result, seen, 'image', output.mediaUrl)
|
||||
}
|
||||
addMedia(result, seen, 'image', log.screenshotUrl)
|
||||
addMedia(result, seen, 'video', log.videoUrl)
|
||||
|
||||
// 旧版日志把媒体统一放在顶层 mediaUrl,并通过 logType=2/3/4 区分图片、视频和音频。
|
||||
const legacyType = Number(log.logType) === 2 ? 'image'
|
||||
: Number(log.logType) === 3 ? 'video'
|
||||
: Number(log.logType) === 4 ? 'audio' : ''
|
||||
if (legacyType) addMedia(result, seen, legacyType, log.mediaUrl)
|
||||
|
||||
// 即使没有找到媒体也缓存空数组,避免无媒体日志在每次渲染时重复扫描全部字段。
|
||||
mediaCache.set(log, result)
|
||||
return result
|
||||
}
|
||||
|
||||
// Element Plus 图片预览需要同一条日志下的纯图片 URL 数组,视频和音频不会进入预览列表。
|
||||
const imageUrlsOf = (log) => mediaOf(log)
|
||||
.filter((item) => item.type === 'image')
|
||||
.map((item) => item.url)
|
||||
|
||||
/**
|
||||
* 根据滚动层的剩余距离更新“是否位于底部”。
|
||||
* 用户拖动滚动条、滚轮滚动以及程序修改 scrollTop 都会触发 scroll 事件,因此只需要维护一个状态源。
|
||||
*/
|
||||
const updateBottomState = () => {
|
||||
const container = messageContainer.value
|
||||
if (!container) {
|
||||
isAtBottom.value = true
|
||||
return
|
||||
}
|
||||
|
||||
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight
|
||||
isAtBottom.value = distanceToBottom <= BOTTOM_THRESHOLD
|
||||
}
|
||||
|
||||
// 滚动事件只做轻量尺寸计算,不写入 scrollTop,避免干扰用户正在进行的历史日志浏览。
|
||||
const handleScroll = () => updateBottomState()
|
||||
|
||||
/**
|
||||
* 将滚动位置移动到最新日志,并恢复自动跟随状态。
|
||||
* nextTick 确保新日志已经渲染,scrollHeight 是更新后的完整内容高度。
|
||||
*/
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
const container = messageContainer.value
|
||||
if (!container) return
|
||||
container.scrollTop = container.scrollHeight
|
||||
isAtBottom.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志变化时先读取更新前的滚动状态:
|
||||
* - 原本在底部,说明用户正在追踪最新日志,DOM 更新后继续滚到底部;
|
||||
* - 原本不在底部,说明用户正在查看历史内容,只更新按钮状态,不改变其 scrollTop。
|
||||
* deep 监听同时覆盖追加日志和现有日志内容更新。
|
||||
*/
|
||||
watch(() => props.list, () => {
|
||||
if (isAtBottom.value) {
|
||||
scrollToBottom()
|
||||
} else {
|
||||
nextTick(updateBottomState)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 首次进入日志视图仍默认展示最新一条,此时尚不存在用户主动选择的历史位置。
|
||||
onMounted(scrollToBottom)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 外层容器固定高度并裁剪内容,同时作为“跳到最新日志”悬浮按钮的定位上下文。 */
|
||||
.task-log {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
margin-top: 12px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 只有内部区域负责滚动,悬浮按钮不会随日志内容一起移出可视区域。 */
|
||||
.task-log__scroll {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 固定在滚动视口右下角,不参与日志排版;稳定尺寸避免按钮出现时挤压或移动内容。 */
|
||||
.task-log__jump-latest {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 2;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
/* 桌面端使用“时间 + 内容”两列布局;内容列 minmax(0, 1fr) 允许长文本正确收缩。 */
|
||||
.task-log__item {
|
||||
display: grid;
|
||||
grid-template-columns: 145px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.task-log__iteration-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin: 8px 0;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 6px;
|
||||
background: #f7f8fa;
|
||||
color: #303133;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.task-log__iteration-header .el-icon {
|
||||
flex: 0 0 auto;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
.task-log__iteration-header .el-icon.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.task-log__iteration-title {
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.task-log__iteration-count {
|
||||
margin-left: auto;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-log__item.is-iteration-child {
|
||||
margin-left: 18px;
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.task-log__time {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-log__body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-log__content {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.task-log__error {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 7px;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
color: var(--el-color-danger-dark-2);
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.task-log__error span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 分析结果使用左侧强调线与普通日志正文区分,但保持在同一条日志的内容列中。 */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* 原始扩展详情限制最大高度并允许内部滚动,长 JSON 不会无限撑高单条日志。 */
|
||||
.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__output {
|
||||
max-height: 180px;
|
||||
margin: 8px 0 0;
|
||||
overflow: auto;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid #67c23a;
|
||||
background: #f5f7fa;
|
||||
color: #475467;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-log__business-summary {
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid #67c23a;
|
||||
background: #f3faf0;
|
||||
color: #3f5f39;
|
||||
line-height: 20px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 媒体资源允许自动换行;每种播放器使用稳定尺寸,资源加载后不会造成明显布局跳动。 */
|
||||
.task-log__media {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.task-log__image {
|
||||
width: 96px;
|
||||
height: 72px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.task-log__video {
|
||||
width: min(320px, 100%);
|
||||
max-height: 220px;
|
||||
background: #111;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.task-log__audio {
|
||||
width: min(340px, 100%);
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* 窄屏改为上下布局,并把分析摘要缩减为两列,保证字段名称和结果仍有可读宽度。 */
|
||||
@media (max-width: 640px) {
|
||||
.task-log__item {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.task-log__analysis-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,319 +0,0 @@
|
||||
<!-- 流程试运行:分配机器人角色并收集业务运行参数。 -->
|
||||
<template>
|
||||
<el-drawer :model-value="props.drawer" title="试运行" size="min(620px, 96vw)" :before-close="handleClose">
|
||||
<div class="section-heading">本次执行安排</div>
|
||||
<div class="assignment-tip">流程只保存角色分工,以下机器人和设备仅用于本次运行。</div>
|
||||
|
||||
<div v-for="role in roles" :key="role.roleKey" class="assignment-row">
|
||||
<div class="assignment-row__title">
|
||||
<span>{{ role.displayName }}</span>
|
||||
<el-tag v-if="role.required" size="small" type="info">必需</el-tag>
|
||||
</div>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="执行机器人" required>
|
||||
<el-select
|
||||
:model-value="getMember(role).robotId"
|
||||
filterable
|
||||
:loading="robotLoading"
|
||||
placeholder="请选择机器人"
|
||||
style="width: 100%"
|
||||
@change="(robotId) => handleRobotChange(role, robotId)"
|
||||
>
|
||||
<el-option v-for="item in robotOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-for="slot in role.deviceSlots" :key="slot.slotKey" :label="slot.displayName" :required="slot.required">
|
||||
<el-select
|
||||
v-model="getMember(role).devices[slot.slotKey]"
|
||||
filterable
|
||||
:loading="loadingRoleKey === role.roleKey"
|
||||
:disabled="!getMember(role).robotId"
|
||||
:placeholder="getMember(role).robotId ? '请选择设备' : '请先选择机器人'"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in getSlotOptions(role, slot)"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="getMember(role).robotId && !getSlotOptions(role, slot).length" class="assignment-error">
|
||||
该机器人没有匹配的在线设备
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div class="section-heading section-heading--params">运行参数</div>
|
||||
<el-form ref="ruleFormRef" :model="{ tableData }" label-width="auto">
|
||||
<el-table :data="tableData" row-key="name" default-expand-all style="width: 100%; margin-bottom: 20px">
|
||||
<el-table-column prop="name" label="参数">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.name }}</span><span v-if="row.required" class="required-mark">*</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="100">
|
||||
<template #default="{ row }"><el-tag>{{ row.type }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="value" label="值">
|
||||
<template #default="{ row }">
|
||||
<el-form-item
|
||||
v-if="row.type !== 'object' && !row.type.includes('array')"
|
||||
:prop="`tableData${row.propPath}value`"
|
||||
:rules="row.required ? [{ required: true, message: '请填写参数', trigger: 'blur' }] : []"
|
||||
>
|
||||
<el-input v-if="row.type === 'string'" v-model="row.value" />
|
||||
<el-input-number v-else-if="row.type === 'number'" v-model="row.value" :controls="false" />
|
||||
<el-switch v-else-if="row.type === 'boolean'" v-model="row.value" />
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-else-if="row.type !== 'object'"
|
||||
:prop="`tableData${row.propPath}value`"
|
||||
:rules="row.required ? [{ required: true, message: '请填写参数', trigger: 'blur' }] : []"
|
||||
>
|
||||
<el-input v-model="row.value" placeholder="数组格式,例如:[1,2,3]" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!tableData.length" :image-size="52" description="没有需要填写的运行参数" />
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="confirm">开始运行</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { flowExecuteTrial } from '@/api/flow/flow'
|
||||
import { getRobotList, getRobotDevicesByRobotId } from '@/api/resource/robot'
|
||||
import { emitter } from '@/utils/eventBus'
|
||||
import { formatTableData, getStartNodeFormData } from '@/utils/flow'
|
||||
import { toDeviceOptions } from '@/utils/robotDevice'
|
||||
import {
|
||||
getResourceRoles,
|
||||
normalizeFlowResourceSchema,
|
||||
sanitizeFlowResourceDefinition,
|
||||
validateFlowResources,
|
||||
} from '@/utils/flowResources'
|
||||
|
||||
const props = defineProps({ drawer: Boolean, flowId: String })
|
||||
const emits = defineEmits(['close', 'changeState'])
|
||||
|
||||
const ruleFormRef = ref()
|
||||
const tableData = ref([])
|
||||
const roles = ref([])
|
||||
const runtimeAssignments = ref([])
|
||||
const robotOptions = ref([])
|
||||
const devicesByRole = reactive({})
|
||||
const robotLoading = ref(false)
|
||||
const loadingRoleKey = ref('')
|
||||
const submitting = ref(false)
|
||||
const trialRunCache = new Map()
|
||||
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value || []))
|
||||
const cacheKey = () => props.flowId || '__new_flow__'
|
||||
|
||||
const mergeCachedValues = (source, cached) => {
|
||||
const cachedByName = new Map((cached || []).map((item) => [item.name, item]))
|
||||
return (source || []).map((item, index) => {
|
||||
const cachedItem = cached?.[index]?.name === item.name ? cached[index] : cachedByName.get(item.name)
|
||||
if (!cachedItem) return item
|
||||
const merged = { ...item, value: cachedItem.value }
|
||||
if (Array.isArray(item.children)) merged.children = mergeCachedValues(item.children, cachedItem.children)
|
||||
return merged
|
||||
})
|
||||
}
|
||||
|
||||
const getAssignment = (role) => runtimeAssignments.value.find((item) => item.roleKey === role.roleKey)
|
||||
const getMember = (role) => getAssignment(role).members[0]
|
||||
|
||||
const createAssignments = (cached = []) => roles.value.map((role) => {
|
||||
const old = cached.find((item) => item.roleKey === role.roleKey)
|
||||
return old || {
|
||||
roleKey: role.roleKey,
|
||||
members: [{
|
||||
memberKey: `${role.roleKey}_1`,
|
||||
robotId: role.legacyRobotId || '',
|
||||
devices: Object.fromEntries((role.deviceSlots || []).map((slot) => [slot.slotKey, slot.legacyDeviceId || ''])),
|
||||
}],
|
||||
}
|
||||
})
|
||||
|
||||
const remember = () => {
|
||||
trialRunCache.set(cacheKey(), {
|
||||
tableData: clone(tableData.value),
|
||||
runtimeAssignments: clone(runtimeAssignments.value),
|
||||
})
|
||||
}
|
||||
|
||||
const loadRobotOptions = async () => {
|
||||
robotLoading.value = true
|
||||
try {
|
||||
const response = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: 1 })
|
||||
robotOptions.value = (response.rows || []).map((robot) => ({
|
||||
label: `${robot.robotName || robot.robotId} (${robot.robotId})`,
|
||||
value: robot.robotId,
|
||||
}))
|
||||
} finally {
|
||||
robotLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getSlotOptions = (role, slot) => toDeviceOptions((devicesByRole[role.roleKey] || []).filter((device) => (
|
||||
slot.deviceKind == null || Number(device.deviceKind) === Number(slot.deviceKind)
|
||||
)))
|
||||
|
||||
const loadRoleDevices = async (role, preserveValues = true) => {
|
||||
const member = getMember(role)
|
||||
if (!member.robotId) {
|
||||
devicesByRole[role.roleKey] = []
|
||||
return
|
||||
}
|
||||
loadingRoleKey.value = role.roleKey
|
||||
try {
|
||||
const response = await getRobotDevicesByRobotId(member.robotId)
|
||||
devicesByRole[role.roleKey] = Array.isArray(response.data) ? response.data : []
|
||||
role.deviceSlots.forEach((slot) => {
|
||||
const options = getSlotOptions(role, slot)
|
||||
const currentValid = options.some((option) => option.value === member.devices[slot.slotKey])
|
||||
if (!preserveValues || !currentValid) member.devices[slot.slotKey] = options.length === 1 ? options[0].value : ''
|
||||
})
|
||||
} catch (error) {
|
||||
devicesByRole[role.roleKey] = []
|
||||
ElMessage.error(`读取“${role.displayName}”的设备失败`)
|
||||
} finally {
|
||||
loadingRoleKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleRobotChange = async (role, robotId) => {
|
||||
const member = getMember(role)
|
||||
member.robotId = robotId
|
||||
member.devices = Object.fromEntries(role.deviceSlots.map((slot) => [slot.slotKey, '']))
|
||||
await loadRoleDevices(role, false)
|
||||
}
|
||||
|
||||
const initialize = async () => {
|
||||
const graph = normalizeFlowResourceSchema(globalThis.lf.getGraphData())
|
||||
roles.value = getResourceRoles(graph)
|
||||
const cached = trialRunCache.get(cacheKey())
|
||||
tableData.value = mergeCachedValues(getStartNodeFormData(), cached?.tableData)
|
||||
runtimeAssignments.value = createAssignments(cached?.runtimeAssignments)
|
||||
await loadRobotOptions()
|
||||
await Promise.all(roles.value.filter((role) => getMember(role).robotId).map((role) => loadRoleDevices(role)))
|
||||
}
|
||||
|
||||
watch(() => props.drawer, (open) => { if (open) initialize() })
|
||||
|
||||
const handleClose = () => {
|
||||
remember()
|
||||
ruleFormRef.value?.clearValidate()
|
||||
emits('close')
|
||||
}
|
||||
|
||||
const validateAssignments = () => {
|
||||
for (const role of roles.value) {
|
||||
const member = getMember(role)
|
||||
if (role.required && !member.robotId) return `请为“${role.displayName}”选择机器人`
|
||||
for (const slot of role.deviceSlots || []) {
|
||||
if (slot.required && !member.devices[slot.slotKey]) return `请为“${role.displayName}”选择“${slot.displayName}”`
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const confirm = async () => {
|
||||
const currentGraph = globalThis.lf.getGraphData()
|
||||
const resourceValidation = validateFlowResources(currentGraph)
|
||||
if (resourceValidation.errors.length) {
|
||||
ElMessage.warning(resourceValidation.errors[0].message)
|
||||
return
|
||||
}
|
||||
const assignmentError = validateAssignments()
|
||||
if (assignmentError) {
|
||||
ElMessage.warning(assignmentError)
|
||||
return
|
||||
}
|
||||
const formValid = await ruleFormRef.value?.validate().catch(() => false)
|
||||
if (formValid === false) return
|
||||
|
||||
remember()
|
||||
submitting.value = true
|
||||
try {
|
||||
const formData = formatTableData(tableData.value)
|
||||
resourceValidation.graph.nodes.forEach((node) => emitter.emit('contentChange', { id: node.id }))
|
||||
const executionDefinition = sanitizeFlowResourceDefinition(globalThis.lf.getGraphData())
|
||||
const firstRobotId = runtimeAssignments.value.flatMap((item) => item.members).find((member) => member.robotId)?.robotId || ''
|
||||
const response = await flowExecuteTrial({
|
||||
flowData: JSON.stringify(executionDefinition),
|
||||
itemId: props.flowId,
|
||||
robotId: firstRobotId,
|
||||
runtimeAssignments: runtimeAssignments.value,
|
||||
runParams: formData,
|
||||
})
|
||||
if (response.code !== 200) {
|
||||
ElMessage.error(response.msg)
|
||||
return
|
||||
}
|
||||
emits('changeState', { type: 'testRunning', instId: response.msg })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.section-heading {
|
||||
margin: 6px 0 10px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.section-heading--params {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.assignment-tip {
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.assignment-row {
|
||||
padding: 14px 0 4px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.assignment-row__title {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.assignment-error,
|
||||
.required-mark {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.assignment-error {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
margin-left: 6px;
|
||||
}
|
||||
</style>
|
||||
@ -1,585 +0,0 @@
|
||||
<template>
|
||||
<div class="queue-editor">
|
||||
<div class="queue-editor__heading">执行设置</div>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="由谁执行" required>
|
||||
<el-select v-model="formData.executionTarget.roleKey" style="width: 100%" @change="handleRoleChange">
|
||||
<el-option v-for="role in roles" :key="role.roleKey" :label="role.displayName" :value="role.roleKey" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="队列总超时(毫秒)" required>
|
||||
<el-input-number v-model="formData.totalTimeoutMs" :min="1" :max="2147483647" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="动作标识(可选)">
|
||||
<el-input v-model="formData.actionId" placeholder="留空时执行端自动生成" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="queue-editor__toolbar">
|
||||
<div>
|
||||
<div class="queue-editor__heading">动作步骤</div>
|
||||
<div class="queue-editor__hint">步骤会在边缘端严格按顺序执行。</div>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="addStep"><el-icon><Plus /></el-icon>添加步骤</el-button>
|
||||
</div>
|
||||
|
||||
<el-collapse v-model="activeStepKeys" class="queue-steps">
|
||||
<el-collapse-item v-for="(step, index) in formData.steps" :key="step.localKey" :name="step.localKey">
|
||||
<template #title>
|
||||
<div class="queue-step__header">
|
||||
<div class="queue-step__title">
|
||||
<span class="queue-step__index">{{ index + 1 }}</span>
|
||||
<span class="queue-step__name">{{ getActionQueueStepName(step, index) }}</span>
|
||||
<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>
|
||||
<el-tooltip content="下移一步" placement="top">
|
||||
<el-button :icon="ArrowDown" circle size="small" :disabled="index === formData.steps.length - 1" aria-label="下移一步" @click="moveStep(index, 1)" />
|
||||
</el-tooltip>
|
||||
<el-tooltip content="复制步骤" placement="top">
|
||||
<el-button :icon="CopyDocument" circle size="small" aria-label="复制步骤" @click="copyStep(index)" />
|
||||
</el-tooltip>
|
||||
<el-dropdown v-if="step.sourceNode" trigger="click" @command="(placement) => requestExtractStep(index, placement)">
|
||||
<el-button :icon="MoreFilled" circle size="small" aria-label="移出队列" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="before">移到队列前并连接</el-dropdown-item>
|
||||
<el-dropdown-item command="after">移到队列后并连接</el-dropdown-item>
|
||||
<el-dropdown-item command="detached" divided>仅移出,不连线</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<el-tooltip content="删除步骤" placement="top">
|
||||
<el-button :icon="Delete" circle size="small" type="danger" plain aria-label="删除步骤" @click="removeStep(index)" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="queue-step">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="步骤名称">
|
||||
<el-input v-model="step.stepName" maxlength="50" show-word-limit placeholder="例如:移动到取料位" />
|
||||
</el-form-item>
|
||||
<el-form-item label="动作" required>
|
||||
<el-select v-model="step.type" style="width: 100%" @change="handleStepTypeChange(step)">
|
||||
<el-option v-for="item in actionTypes" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="deviceKindForType(step.type)" label="使用设备" required>
|
||||
<el-select v-model="step.deviceSlotKey" style="width: 100%">
|
||||
<el-option
|
||||
v-for="slot in matchingSlots(step.type)"
|
||||
:key="slot.slotKey"
|
||||
:label="slot.displayName"
|
||||
:value="slot.slotKey"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="!matchingSlots(step.type).length" class="queue-editor__warning">当前角色没有对应设备用途。</div>
|
||||
</el-form-item>
|
||||
<div v-if="stepHelperLabel(step.type)" class="queue-step__helper">
|
||||
<el-button type="primary" plain :icon="Location" @click="openStepHelper(step)">
|
||||
{{ stepHelperLabel(step.type) }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-form-item label="步骤超时(毫秒)">
|
||||
<el-input-number v-model="step.timeoutMs" :min="0" :max="2147483647" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<template v-for="field in fieldsForType(step.type)" :key="field.key">
|
||||
<el-form-item :label="field.label" :required="field.required !== false">
|
||||
<el-select
|
||||
v-if="field.key === 'stationId' && step.stationOptions?.length"
|
||||
v-model="step.params[field.key]"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="option in step.stationOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
<el-select v-else-if="field.options" v-model="step.params[field.key]" style="width: 100%">
|
||||
<el-option v-for="option in field.options" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
<el-input-number
|
||||
v-else-if="field.number"
|
||||
v-model="step.params[field.key]"
|
||||
:min="field.min"
|
||||
:max="field.max ?? Infinity"
|
||||
:step="field.step || 1"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<el-input v-else v-model="step.params[field.key]" :type="field.multiline ? 'textarea' : 'text'" :rows="3" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
<el-empty v-if="!formData.steps.length" description="请添加至少一个动作步骤" :image-size="72" />
|
||||
|
||||
<el-dialog v-model="helperDialogVisible" :title="stepHelperLabel(helperStep?.type)" width="460" append-to-body>
|
||||
<div class="queue-helper__tip">在线设备仅用于读取参数,不会写入流程定义。</div>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="机器人" required>
|
||||
<el-select v-model="helperForm.robotId" filterable style="width: 100%" @change="loadHelperDevices">
|
||||
<el-option v-for="item in helperRobotOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="执行设备" required>
|
||||
<el-select v-model="helperForm.deviceId" filterable :loading="helperDeviceLoading" style="width: 100%">
|
||||
<el-option v-for="item in helperDeviceOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="helperDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="helperSubmitting" @click="applyStepHelper">确认读取</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from '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/resource/robot'
|
||||
import {
|
||||
createDeviceSlot,
|
||||
createResourceKey,
|
||||
FLOW_NODE_RESOURCE_OWNER,
|
||||
getResourceRoles,
|
||||
getStartNode,
|
||||
pruneUnusedNodeResources,
|
||||
} from '@/utils/flowResources'
|
||||
import { DEVICE_KIND, toDeviceOptions } from '@/utils/robotDevice'
|
||||
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep } from '@/utils/actionQueue'
|
||||
|
||||
const props = defineProps({ data: { type: Object, required: true } })
|
||||
const emit = defineEmits(['save-success', 'save-error', 'extract-step'])
|
||||
|
||||
const actionTypes = [
|
||||
{ value: 'DELAY', label: '等待' },
|
||||
{ value: 'ARM_MOVE_J', label: '机械臂关节运动' },
|
||||
{ value: 'ARM_MOVE_L', label: '机械臂直线运动' },
|
||||
{ value: 'AGV_NAVIGATE_TO_POSE', label: '底盘导航到坐标' },
|
||||
{ value: 'AGV_NAVIGATE_TO_STATION', label: '底盘导航到站点' },
|
||||
{ value: 'AGV_FOLLOW_PATH', label: '底盘按路径导航' },
|
||||
]
|
||||
|
||||
const commonMotionFields = [
|
||||
{ key: 'velocity', label: '速度', number: true, min: 0, step: 0.01, required: false },
|
||||
{ key: 'acceleration', label: '加速度', number: true, min: 0, step: 0.01, required: false },
|
||||
]
|
||||
const fieldsByType = {
|
||||
DELAY: [{ key: 'durationMs', label: '等待时长(毫秒)', number: true, min: 0 }],
|
||||
ARM_MOVE_J: [{ key: 'positions', label: '关节位置(JSON 数组)', multiline: true }, ...commonMotionFields],
|
||||
ARM_MOVE_L: [
|
||||
...['x', 'y', 'z', 'rx', 'ry', 'rz'].map((key) => ({ key, label: key.toUpperCase(), number: true })),
|
||||
{ key: 'frame', label: '坐标系', options: ['BASE', 'TOOL', 'WORLD', 'USER'].map((value) => ({ value, label: value })) },
|
||||
...commonMotionFields,
|
||||
],
|
||||
AGV_NAVIGATE_TO_POSE: [
|
||||
...['x', 'y', 'theta'].map((key) => ({ key, label: key === 'theta' ? '角度' : key.toUpperCase(), number: true })),
|
||||
{ key: 'maxSpeed', label: '最大速度', number: true, min: 0, step: 0.01, required: false },
|
||||
{ key: 'blockedTimeoutMs', label: '遇障超时(毫秒)', number: true, min: 1, step: 1000 },
|
||||
],
|
||||
AGV_NAVIGATE_TO_STATION: [
|
||||
{ key: 'stationId', label: '站点 ID' },
|
||||
{ key: 'maxSpeed', label: '最大速度', number: true, min: 0, step: 0.01, required: false },
|
||||
{ key: 'blockedTimeoutMs', label: '遇障超时(毫秒)', number: true, min: 1, step: 1000 },
|
||||
],
|
||||
AGV_FOLLOW_PATH: [
|
||||
{ key: 'path', label: '路径段(JSON 数组)', multiline: true },
|
||||
{ key: 'maxSpeed', label: '最大速度', number: true, min: 0, step: 0.01, required: false },
|
||||
{ key: 'blockedTimeoutMs', label: '遇障超时(毫秒)', number: true, min: 1, step: 1000 },
|
||||
],
|
||||
}
|
||||
const defaultParams = {
|
||||
DELAY: { durationMs: 1000 },
|
||||
ARM_MOVE_J: { positions: '[0, 0, 0, 0, 0, 0]', velocity: 0.1, acceleration: 0.2 },
|
||||
ARM_MOVE_L: { x: 0, y: 0, z: 0, rx: 0, ry: 0, rz: 0, frame: 'BASE', velocity: 2.1, acceleration: 2.2 },
|
||||
AGV_NAVIGATE_TO_POSE: { x: 0, y: 0, theta: 0, maxSpeed: 0.5, blockedTimeoutMs: 60000 },
|
||||
AGV_NAVIGATE_TO_STATION: { stationId: '', maxSpeed: 0.5, blockedTimeoutMs: 60000 },
|
||||
AGV_FOLLOW_PATH: { path: '[]', maxSpeed: 0.5, blockedTimeoutMs: 60000 },
|
||||
}
|
||||
const defaultTimeoutByType = {
|
||||
DELAY: 5000,
|
||||
ARM_MOVE_J: 10000,
|
||||
ARM_MOVE_L: 3000,
|
||||
AGV_NAVIGATE_TO_POSE: 60000,
|
||||
AGV_NAVIGATE_TO_STATION: 60000,
|
||||
AGV_FOLLOW_PATH: 120000,
|
||||
}
|
||||
|
||||
const roles = ref([])
|
||||
const formData = reactive({ actionId: '', totalTimeoutMs: 60000, steps: [], executionTarget: {} })
|
||||
const activeStepKeys = ref([])
|
||||
const helperDialogVisible = ref(false)
|
||||
const helperStep = ref(null)
|
||||
const helperRobotOptions = ref([])
|
||||
const helperDeviceOptions = ref([])
|
||||
const helperDeviceLoading = ref(false)
|
||||
const helperSubmitting = ref(false)
|
||||
const helperForm = reactive({ robotId: '', deviceId: '' })
|
||||
const fieldsForType = (type) => fieldsByType[type] || []
|
||||
const actionLabel = (type) => actionTypes.find((item) => item.value === type)?.label || '未选择动作'
|
||||
const deviceKindForType = (type) => type?.startsWith('ARM_') ? DEVICE_KIND.ARM : (type?.startsWith('AGV_') ? DEVICE_KIND.AGV : null)
|
||||
const stepHelperLabel = (type) => ({
|
||||
ARM_MOVE_J: '读取当前关节位置',
|
||||
ARM_MOVE_L: '读取当前末端位姿',
|
||||
AGV_NAVIGATE_TO_POSE: '读取底盘当前位置',
|
||||
AGV_NAVIGATE_TO_STATION: '加载底盘站点',
|
||||
}[type] || '')
|
||||
const selectedRole = () => roles.value.find((role) => role.roleKey === formData.executionTarget.roleKey)
|
||||
const matchingSlots = (type) => (selectedRole()?.deviceSlots || []).filter((slot) => Number(slot.deviceKind) === deviceKindForType(type))
|
||||
|
||||
const normalizeStep = (source = {}, index = 0) => {
|
||||
const params = { ...(defaultParams[source.type || 'DELAY'] || {}), ...(source.params || {}) }
|
||||
for (const key of ['positions', 'path']) {
|
||||
if (Array.isArray(params[key])) params[key] = JSON.stringify(params[key])
|
||||
}
|
||||
return {
|
||||
localKey: source.localKey || createResourceKey('queue_step'),
|
||||
stepId: source.stepId || `step_${index + 1}`,
|
||||
stepName: String(source.stepName || '').trim() || `步骤 ${index + 1}`,
|
||||
type: source.type || 'DELAY',
|
||||
timeoutMs: source.timeoutMs === undefined || source.timeoutMs === null
|
||||
? defaultTimeoutByType[source.type || 'DELAY']
|
||||
: Number(source.timeoutMs),
|
||||
deviceBindingKey: source.deviceBindingKey || createResourceKey('queue_device'),
|
||||
deviceSlotKey: source.deviceSlotKey || '',
|
||||
stationOptions: [],
|
||||
...(source.sourceNode ? { sourceNode: JSON.parse(JSON.stringify(source.sourceNode)) } : {}),
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
const initData = () => {
|
||||
roles.value = getResourceRoles()
|
||||
const properties = props.data.properties || {}
|
||||
const params = Object.fromEntries((properties.nodeParams || []).map((item) => [item.name, item.input]))
|
||||
formData.actionId = params.actionId || ''
|
||||
formData.totalTimeoutMs = Number(params.totalTimeoutMs || 60000)
|
||||
formData.steps = (Array.isArray(params.steps) ? params.steps : []).map(normalizeStep)
|
||||
formData.executionTarget = JSON.parse(JSON.stringify(properties.executionTarget || {}))
|
||||
formData.executionTarget.roleKey ||= roles.value[0]?.roleKey || ''
|
||||
formData.steps.forEach((step) => ensureStepSlot(step, false))
|
||||
activeStepKeys.value = []
|
||||
}
|
||||
|
||||
const persistRoles = () => {
|
||||
const graph = lf.getGraphData()
|
||||
const start = getStartNode(graph)
|
||||
if (!start) return
|
||||
lf.setProperties(start.id, { ...start.properties, resourceRoles: JSON.parse(JSON.stringify(roles.value)) })
|
||||
}
|
||||
|
||||
const ensureStepSlot = (step, createWhenMissing = true) => {
|
||||
const kind = deviceKindForType(step.type)
|
||||
if (!kind) {
|
||||
step.deviceSlotKey = ''
|
||||
return
|
||||
}
|
||||
const role = selectedRole()
|
||||
if (!role) return
|
||||
const options = matchingSlots(step.type)
|
||||
if (options.some((slot) => slot.slotKey === step.deviceSlotKey)) return
|
||||
let slot = options[0]
|
||||
if (!slot && createWhenMissing) {
|
||||
slot = createDeviceSlot(kind, role.deviceSlots || [])
|
||||
slot.managedBy = FLOW_NODE_RESOURCE_OWNER
|
||||
role.deviceSlots ||= []
|
||||
role.deviceSlots.push(slot)
|
||||
persistRoles()
|
||||
}
|
||||
step.deviceSlotKey = slot?.slotKey || ''
|
||||
}
|
||||
|
||||
const addStep = () => {
|
||||
const step = normalizeStep({}, formData.steps.length)
|
||||
formData.steps.push(step)
|
||||
activeStepKeys.value = [...activeStepKeys.value, step.localKey]
|
||||
}
|
||||
const removeStep = (index) => {
|
||||
const [removed] = formData.steps.splice(index, 1)
|
||||
activeStepKeys.value = activeStepKeys.value.filter((key) => key !== removed?.localKey)
|
||||
}
|
||||
const moveStep = (index, offset) => {
|
||||
moveActionQueueStep(formData.steps, index, offset)
|
||||
}
|
||||
const copyStep = (index) => {
|
||||
const source = formData.steps[index]
|
||||
if (!source) return
|
||||
const copied = copyActionQueueStep(source, {
|
||||
localKey: createResourceKey('queue_step'),
|
||||
stepId: `step_${formData.steps.length + 1}`,
|
||||
deviceBindingKey: createResourceKey('queue_device'),
|
||||
stationOptions: [],
|
||||
})
|
||||
formData.steps.splice(index + 1, 0, copied)
|
||||
activeStepKeys.value = [...new Set([...activeStepKeys.value, copied.localKey])]
|
||||
}
|
||||
const handleStepTypeChange = (step) => {
|
||||
step.params = { ...(defaultParams[step.type] || {}) }
|
||||
step.timeoutMs = defaultTimeoutByType[step.type] || 0
|
||||
step.stationOptions = []
|
||||
ensureStepSlot(step)
|
||||
}
|
||||
const handleRoleChange = () => formData.steps.forEach((step) => ensureStepSlot(step))
|
||||
|
||||
const loadHelperRobots = async () => {
|
||||
const response = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' })
|
||||
helperRobotOptions.value = (response.rows || []).map((robot) => ({
|
||||
value: robot.robotId,
|
||||
label: `${robot.robotName || robot.robotId} (${robot.robotId})`,
|
||||
}))
|
||||
}
|
||||
|
||||
const loadHelperDevices = async (robotId) => {
|
||||
helperForm.deviceId = ''
|
||||
helperDeviceOptions.value = []
|
||||
if (!robotId || !helperStep.value) return
|
||||
helperDeviceLoading.value = true
|
||||
try {
|
||||
const response = await getRobotDevicesByRobotId(robotId, {
|
||||
deviceKind: deviceKindForType(helperStep.value.type),
|
||||
})
|
||||
const devices = Array.isArray(response.data) ? response.data : []
|
||||
helperDeviceOptions.value = toDeviceOptions(devices)
|
||||
if (devices.length) helperForm.deviceId = devices[0].deviceId
|
||||
else ElMessage.warning('当前机器人没有匹配的在线设备')
|
||||
} finally {
|
||||
helperDeviceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openStepHelper = async (step) => {
|
||||
helperStep.value = step
|
||||
helperForm.deviceId = ''
|
||||
helperDialogVisible.value = true
|
||||
try {
|
||||
await loadHelperRobots()
|
||||
if (helperForm.robotId && helperRobotOptions.value.some((item) => item.value === helperForm.robotId)) {
|
||||
await loadHelperDevices(helperForm.robotId)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载机器人设备失败:', error)
|
||||
ElMessage.error('加载机器人设备失败')
|
||||
}
|
||||
}
|
||||
|
||||
const stationOptions = (stations) => (Array.isArray(stations) ? stations : [])
|
||||
.filter((station) => station?.id !== undefined && station?.id !== null && String(station.id).trim())
|
||||
.map((station) => {
|
||||
const value = String(station.id).trim()
|
||||
const description = String(station.description || '').trim()
|
||||
return { value, label: description ? `${value} - ${description}` : value }
|
||||
})
|
||||
|
||||
const applyStepHelper = async () => {
|
||||
const step = helperStep.value
|
||||
if (!helperForm.robotId || !helperForm.deviceId) {
|
||||
ElMessage.warning('请选择机器人和执行设备')
|
||||
return
|
||||
}
|
||||
helperSubmitting.value = true
|
||||
try {
|
||||
const target = { robotId: helperForm.robotId, deviceId: helperForm.deviceId }
|
||||
if (step.type === 'ARM_MOVE_J') {
|
||||
const response = await getArmJointState(target)
|
||||
const positions = response?.data?.position || response?.data?.positions
|
||||
if (!Array.isArray(positions) || !positions.length) throw new Error('机械臂未返回有效的关节位置')
|
||||
step.params.positions = JSON.stringify(positions)
|
||||
} else if (step.type === 'ARM_MOVE_L') {
|
||||
const response = await getArmStatus(target)
|
||||
const pose = response?.data
|
||||
if (!pose) throw new Error('机械臂未返回当前末端位姿')
|
||||
;['x', 'y', 'z', 'rx', 'ry', 'rz'].forEach((key) => {
|
||||
if (pose[key] !== undefined && pose[key] !== null) step.params[key] = Number(pose[key])
|
||||
})
|
||||
} else if (step.type === 'AGV_NAVIGATE_TO_POSE') {
|
||||
const response = await getAgvStatus(target)
|
||||
const pose = response?.data?.pose
|
||||
if (!pose) throw new Error('底盘未返回当前位置')
|
||||
;['x', 'y', 'theta'].forEach((key) => {
|
||||
if (pose[key] !== undefined && pose[key] !== null) step.params[key] = Number(pose[key])
|
||||
})
|
||||
} else if (step.type === 'AGV_NAVIGATE_TO_STATION') {
|
||||
const response = await getAgvStations(target)
|
||||
const options = stationOptions(response?.data)
|
||||
if (!options.length) throw new Error('底盘未返回可用站点')
|
||||
step.stationOptions = options
|
||||
if (!options.some((option) => option.value === step.params.stationId)) step.params.stationId = ''
|
||||
ElMessage.success(`已加载${options.length}个站点`)
|
||||
}
|
||||
helperDialogVisible.value = false
|
||||
} catch (error) {
|
||||
console.error('读取动作参数失败:', error)
|
||||
ElMessage.error(error?.message || '读取动作参数失败')
|
||||
} finally {
|
||||
helperSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const parseJsonFields = (step) => {
|
||||
const params = { ...step.params }
|
||||
for (const key of ['positions', 'path']) {
|
||||
if (typeof params[key] !== 'string') continue
|
||||
try {
|
||||
const parsed = JSON.parse(params[key])
|
||||
if (!Array.isArray(parsed)) throw new Error()
|
||||
params[key] = parsed
|
||||
} catch {
|
||||
throw new Error(`${fieldsForType(step.type).find((field) => field.key === key)?.label || key}格式不正确`)
|
||||
}
|
||||
}
|
||||
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('请添加至少一个动作步骤')
|
||||
const steps = formData.steps.map((step, index) => {
|
||||
if (!step.type) throw new Error(`步骤 ${index + 1} 未选择动作`)
|
||||
if (deviceKindForType(step.type) && !step.deviceSlotKey) 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_${index + 1}`,
|
||||
stepName: String(step.stepName || '').trim() || `步骤 ${index + 1}`,
|
||||
type: step.type,
|
||||
timeoutMs: Number(step.timeoutMs || 0),
|
||||
...(deviceKindForType(step.type) ? {
|
||||
deviceBindingKey: step.deviceBindingKey,
|
||||
deviceSlotKey: step.deviceSlotKey,
|
||||
} : {}),
|
||||
...(step.sourceNode ? { sourceNode: JSON.parse(JSON.stringify(step.sourceNode)) } : {}),
|
||||
params,
|
||||
}
|
||||
})
|
||||
return {
|
||||
steps,
|
||||
executionTarget: {
|
||||
roleKey: formData.executionTarget.roleKey,
|
||||
dispatchPolicy: 'ONE',
|
||||
deviceBindings: steps.filter((step) => step.deviceBindingKey).map((step) => ({
|
||||
parameterName: step.deviceBindingKey,
|
||||
slotKey: step.deviceSlotKey,
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const persistQueueDefinition = ({ steps, executionTarget }) => {
|
||||
lf.setProperties(props.data.id, {
|
||||
...props.data.properties,
|
||||
executionTarget,
|
||||
nodeParams: [
|
||||
{ name: 'actionId', type: 'input', input: formData.actionId, required: false },
|
||||
{ name: 'totalTimeoutMs', type: 'input', componentType: 'number', input: formData.totalTimeoutMs, required: true },
|
||||
{ name: 'steps', type: 'input', input: steps, required: true },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const requestExtractStep = (index, placement) => {
|
||||
try {
|
||||
persistQueueDefinition(buildQueueDefinition())
|
||||
emit('extract-step', { queueId: props.data.id, index, placement })
|
||||
} catch (error) {
|
||||
ElMessage.warning(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const validateAndSave = async () => {
|
||||
try {
|
||||
persistQueueDefinition(buildQueueDefinition())
|
||||
const cleanup = pruneUnusedNodeResources(lf.getGraphData())
|
||||
if (cleanup.start) {
|
||||
lf.setProperties(cleanup.start.id, {
|
||||
...cleanup.start.properties,
|
||||
resourceRoles: cleanup.start.properties.resourceRoles,
|
||||
})
|
||||
}
|
||||
emit('save-success')
|
||||
} catch (error) {
|
||||
ElMessage.warning(error.message)
|
||||
emit('save-error')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.data, initData, { immediate: true, deep: true })
|
||||
defineExpose({ validateAndSave })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.queue-editor { padding-top: 16px; }
|
||||
.queue-editor__heading { color: #344054; font-size: 14px; font-weight: 650; }
|
||||
.queue-editor__hint { margin-top: 3px; color: #7a8494; font-size: 12px; }
|
||||
.queue-editor__toolbar { display: flex; margin: 14px 0 12px; align-items: center; justify-content: space-between; }
|
||||
.queue-steps { border-top: 0; }
|
||||
.queue-steps :deep(.el-collapse-item) { margin-bottom: 10px; border: 1px solid #dfe4ec; border-radius: 8px; overflow: hidden; }
|
||||
.queue-steps :deep(.el-collapse-item__header) { min-height: 54px; height: auto; padding: 8px 12px; border-bottom: 0; }
|
||||
.queue-steps :deep(.el-collapse-item__wrap) { border-top: 1px solid #edf0f4; border-bottom: 0; }
|
||||
.queue-steps :deep(.el-collapse-item__content) { padding-bottom: 0; }
|
||||
.queue-step { padding: 14px; background: #fff; }
|
||||
.queue-step__header { display: flex; width: 100%; min-width: 0; align-items: center; justify-content: space-between; color: #1f2937; font-weight: 650; }
|
||||
.queue-step__title { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
||||
.queue-step__index { display: inline-flex; width: 24px; height: 24px; flex: 0 0 24px; align-items: center; justify-content: center; border-radius: 50%; background: #eef4ff; color: var(--el-color-primary); font-size: 12px; }
|
||||
.queue-step__name { overflow: hidden; max-width: 150px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.queue-step__action { overflow: hidden; color: #667085; font-size: 12px; font-weight: 400; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.queue-step__actions { display: flex; flex: 0 0 auto; margin-right: 8px; align-items: center; }
|
||||
.queue-step__actions .el-button + .el-button { margin-left: 6px; }
|
||||
.queue-step__helper { margin: -2px 0 16px; }
|
||||
.queue-editor__warning { color: var(--el-color-warning); font-size: 12px; }
|
||||
.queue-helper__tip { margin: -4px 0 16px; padding: 9px 12px; border-left: 3px solid var(--el-color-primary); background: var(--el-fill-color-light); color: #667085; font-size: 12px; line-height: 18px; }
|
||||
</style>
|
||||
@ -1,598 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:通用节点参数面板,配置输入输出及机器人动作调试。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<RobotExecutionTarget
|
||||
v-if="isRobotNode"
|
||||
:data="props.data"
|
||||
v-model="formData.executionTarget"
|
||||
/>
|
||||
<el-collapse v-model="activeNames">
|
||||
<!-- 输入参数 -->
|
||||
<el-collapse-item name="1" icon-position="left">
|
||||
<template #title>
|
||||
输入参数
|
||||
<el-button :circle="true" size="small" @click="(e) => addFormItem(e, 'nodeParams')" class="addFormItem">
|
||||
<el-icon :size="14">
|
||||
<Plus />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="form__container">
|
||||
<el-button type="primary" v-if="hasDeviceDebugAction" size="small" @click="openRobotForm(props.data.properties.action)">{{ robotActionButtonText }}</el-button>
|
||||
<el-form :inline="true" :model="formData" :rules="rules" ref="dynamicFormRef" label-position="top"
|
||||
label-width="auto">
|
||||
<div v-for="(property, index) in formData.nodeParams" :key="index">
|
||||
<el-row>
|
||||
<el-form-item :label="index === 0 ? '参数名' : ''" :prop="`nodeParams.${index}.name`"
|
||||
:rules="[{ required: true, message: '请输入参数名', trigger: 'blur' }]">
|
||||
<el-input :disabled="property?.disabled || false" class="param-name"
|
||||
v-model="property.name" placeholder="请输入" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`">
|
||||
<el-select v-model="property.type" class="param-type" :disabled="property?.forceSelect || false" @change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
<el-form-item v-if="property.type === 'input'"
|
||||
:rules="testRule(property)"
|
||||
:prop="`nodeParams.${index}.input`">
|
||||
<el-input-number class="param-value" v-if="property.componentType === 'number'"
|
||||
v-model="property.input" :min="property.min ?? 0" :max="property.max || Infinity"
|
||||
:controls="property?.controls || true" :step-strictly="true" :step="property.step || 1" placeholder="请输入" clearable />
|
||||
<el-select v-model="property.input" class="param-value"
|
||||
v-else-if="property.componentType === 'select'">
|
||||
<el-option v-for="item in property.selectOptions" :key="item.value"
|
||||
:label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-input class="param-value" v-else v-model="property.input" placeholder="请输入"
|
||||
clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="property.type === 'quote'"
|
||||
:rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]"
|
||||
:prop="`nodeParams.${index}.quote`">
|
||||
<el-cascader :ref="(el) => { if (el) cascaderRefs[index] = el }"
|
||||
v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions"
|
||||
placeholder="请选择"
|
||||
@visible-change="(visible) => visibleChange(visible, index, property.quote)"
|
||||
@change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
<el-button :icon="Minus" circle size="small" :disabled="property?.disabled || false"
|
||||
@click="handleDelete(index)" class="deleteBtn" />
|
||||
</el-row>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<!-- 输出参数 -->
|
||||
<el-collapse-item name="2" icon-position="left">
|
||||
<template #title>
|
||||
输出参数
|
||||
<el-button :circle="true" size="small" @click="(e) => addFormItem(e, 'outputParams')"
|
||||
class="addFormItem">
|
||||
<el-icon :size="14">
|
||||
<Plus />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="form__container">
|
||||
<el-form :inline="true" :model="formData" label-position="top" label-width="auto" :rules="outputRules"
|
||||
ref="outputFormRef">
|
||||
<FormItemRecursive formType="output" :current-list="formData.outputParams" prop-path="outputParams"
|
||||
:depth="0" :is-first-level="true" :endDepth="2" :parent-path="[]"
|
||||
@delete-item="(path) => deleteTopLevelItem(path, 'outputParams')" />
|
||||
</el-form>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="robotDialogTitle"
|
||||
width="460"
|
||||
>
|
||||
<div class="robot-device-tip">设备来自机器人最近一次在线上报,单个匹配设备会自动锁定。</div>
|
||||
<el-form ref="robotFormRef" :model="robotForm" :rules="robotFormRules" label-width="90px">
|
||||
<el-form-item label="机器人" prop="robotId">
|
||||
<el-select v-model="robotForm.robotId" filterable placeholder="请选择在线机器人"
|
||||
style="width: 100%" @change="loadRobotDevices">
|
||||
<el-option v-for="item in robotOptions" :key="item.value"
|
||||
:label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="执行设备" prop="deviceId">
|
||||
<el-select v-model="robotForm.deviceId" filterable :loading="deviceLoading"
|
||||
:disabled="deviceOptions.length === 1" placeholder="请先选择机器人" style="width: 100%">
|
||||
<el-option v-for="item in deviceOptions" :key="item.value"
|
||||
:label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<div v-if="deviceOptions.length === 1" class="device-auto-hint">已自动匹配并锁定</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="stationListLoading" @click="submitRobotForm">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, reactive, watch, nextTick } from 'vue'
|
||||
import { Plus, Minus } from '@element-plus/icons-vue'
|
||||
import FormItemRecursive from '../FormItemRecursive.vue'
|
||||
import RobotExecutionTarget from './RobotExecutionTarget.vue'
|
||||
import { getInput } from '@/utils/flow'
|
||||
import { useQuote } from './useQuote.js'
|
||||
import { getArmStatus, getArmJointState, getAgvStatus, getAgvStations } from '@/api/flow/flow'
|
||||
import { getRobotList, getRobotDevicesByRobotId } from '@/api/resource/robot'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getDeviceKindForAction, toDeviceOptions } from '@/utils/robotDevice'
|
||||
import { actionRequiresDevice } from '@/utils/flowResources'
|
||||
|
||||
const props = defineProps({
|
||||
data: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save-success', 'save-error'])
|
||||
|
||||
const formData = reactive({
|
||||
nodeParams: [],
|
||||
outputParams: [],
|
||||
executionTarget: {}
|
||||
})
|
||||
|
||||
const rules = reactive({})
|
||||
const outputRules = reactive({})
|
||||
const dynamicFormRef = ref()
|
||||
const outputFormRef = ref()
|
||||
const activeNames = ref(['1', '2'])
|
||||
|
||||
// 使用共享的 quote 逻辑
|
||||
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
|
||||
|
||||
// 添加表单项
|
||||
/**
|
||||
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} e 触发操作的浏览器事件
|
||||
* @param {*} type 目标类型
|
||||
*/
|
||||
const addFormItem = (e, type) => {
|
||||
e.stopPropagation()
|
||||
const obj = {
|
||||
name: '',
|
||||
type: type === 'nodeParams' ? 'input' : 'string',
|
||||
required: false,
|
||||
children: [],
|
||||
input: ''
|
||||
}
|
||||
if (!formData[type]) formData[type] = []
|
||||
formData[type].push(obj)
|
||||
}
|
||||
|
||||
// 删除输入参数(非递归)
|
||||
/**
|
||||
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} index 目标项索引
|
||||
*/
|
||||
const handleDelete = (index) => {
|
||||
formData.nodeParams.splice(index, 1)
|
||||
}
|
||||
|
||||
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
|
||||
/**
|
||||
* 删除 deleteTopLevelItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} fullPath 字段完整路径
|
||||
* @param {*} propPath 字段校验路径
|
||||
*/
|
||||
const deleteTopLevelItem = (fullPath, propPath) => {
|
||||
let currentLevel = formData[propPath]
|
||||
for (let i = 0; i < fullPath.length - 1; i++) {
|
||||
currentLevel = currentLevel[fullPath[i]].children
|
||||
}
|
||||
const lastIndex = fullPath[fullPath.length - 1]
|
||||
currentLevel.splice(lastIndex, 1)
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
/**
|
||||
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const initData = () => {
|
||||
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
|
||||
const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || []))
|
||||
formData.nodeParams = nodeParams;
|
||||
formData.outputParams = outputParams;
|
||||
formData.executionTarget = JSON.parse(JSON.stringify(props.data.properties.executionTarget || {}));
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
() => {
|
||||
initData()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// 保存验证
|
||||
/**
|
||||
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const validateAndSave = async () => {
|
||||
let inputValid = true
|
||||
let outputValid = true
|
||||
if (dynamicFormRef.value) {
|
||||
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
|
||||
}
|
||||
if (outputFormRef.value) {
|
||||
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
|
||||
}
|
||||
if (isRobotNode.value && !formData.executionTarget.roleKey) inputValid = false
|
||||
if (isRobotNode.value
|
||||
&& actionRequiresDevice(props.data.properties.action)
|
||||
&& !formData.executionTarget.deviceSlotKey) {
|
||||
inputValid = false
|
||||
ElMessage.warning('请选择该节点使用的设备用途')
|
||||
}
|
||||
|
||||
// 非机器人节点不应携带机器人执行目标,避免后端将空对象误判为无效角色引用。
|
||||
const properties = {
|
||||
...props.data.properties,
|
||||
...formData
|
||||
}
|
||||
if (!isRobotNode.value) delete properties.executionTarget
|
||||
lf.setProperties(props.data.id, properties)
|
||||
|
||||
if (inputValid && outputValid) {
|
||||
emit('save-success')
|
||||
} else {
|
||||
emit('save-error')
|
||||
}
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const robotFormRef = ref(null)
|
||||
const robotFormRules = reactive({
|
||||
robotId: [{ required: true, message: '请选择机器人', trigger: 'change' }],
|
||||
deviceId: [{ required: true, message: '设备ID不能为空', trigger: 'blur' }]
|
||||
})
|
||||
const robotForm = reactive({
|
||||
deviceId: '',
|
||||
robotId: ''
|
||||
})
|
||||
const robotOptions = ref([])
|
||||
const deviceOptions = ref([])
|
||||
const deviceLoading = ref(false)
|
||||
|
||||
/**
|
||||
* 加载 loadRobotOptions 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const loadRobotOptions = async () => {
|
||||
const response = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' })
|
||||
robotOptions.value = (response.rows || []).map(robot => ({
|
||||
value: robot.robotId,
|
||||
label: `${robot.robotName || robot.robotId} (${robot.robotId})`
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 loadRobotDevices 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} robotId 机器人 ID
|
||||
*/
|
||||
const loadRobotDevices = async (robotId) => {
|
||||
robotForm.deviceId = ''
|
||||
deviceOptions.value = []
|
||||
if (!robotId) return
|
||||
deviceLoading.value = true
|
||||
try {
|
||||
const response = await getRobotDevicesByRobotId(robotId, {
|
||||
deviceKind: getDeviceKindForAction(robotFormAction.value)
|
||||
})
|
||||
const devices = Array.isArray(response.data) ? response.data : []
|
||||
deviceOptions.value = toDeviceOptions(devices)
|
||||
if (devices.length > 0) robotForm.deviceId = devices[0].deviceId
|
||||
if (devices.length === 0) ElMessage.warning('当前机器人没有匹配的在线设备')
|
||||
} finally {
|
||||
deviceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 testRule 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} property 参数属性
|
||||
*/
|
||||
const testRule = (property) => {
|
||||
if (property?.name === 'stationId' && props.data.properties.action === 'AGV_MOVE_TO_STATION') {
|
||||
return [
|
||||
{ required: true, message: '请从站点列表中选择站点', trigger: 'change' },
|
||||
{ validator: () => {
|
||||
const selectedFromList = property.selectOptions?.some(option => option.value === property.input)
|
||||
if (selectedFromList) return Promise.resolve()
|
||||
return Promise.reject(new Error('请重新加载并选择站点'))
|
||||
}, trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
if (property?.name === 'acceleration') {
|
||||
return [
|
||||
{ required: true, message: '请输入参数值', trigger: 'blur' },
|
||||
{ validator: () => {
|
||||
if (formData.nodeParams.find(param => param.name === 'acceleration')?.input > formData.nodeParams.find(param => param.name === 'velocity')?.input) {
|
||||
return Promise.resolve()
|
||||
} else {
|
||||
return Promise.reject(new Error('加速度必须大于速度'))
|
||||
}
|
||||
}, trigger: 'change' }
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
if (property?.name === 'velocity') {
|
||||
return [
|
||||
{ required: true, message: '请输入参数值', trigger: 'blur' },
|
||||
{ validator: () => {
|
||||
if (formData.nodeParams.find(param => param.name === 'acceleration')?.input > formData.nodeParams.find(param => param.name === 'velocity')?.input) {
|
||||
return Promise.resolve()
|
||||
} else {
|
||||
return Promise.reject(new Error('加速度必须大于速度'))
|
||||
}
|
||||
}, trigger: 'change' }
|
||||
]
|
||||
|
||||
}
|
||||
return [{ required: property?.required ?? true, message: '请输入参数值', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const robotFormAction = ref()
|
||||
const stationListLoading = ref(false)
|
||||
const POSITION_ACTIONS = ['AGV_MOVE_TO_POINT', 'ARM_MOVE_TO_POINT', 'ARM_MOVE_TO_J']
|
||||
const isRobotNode = computed(() => props.data.properties?.nodeType === 'EDGE')
|
||||
const hasDeviceDebugAction = computed(() => (
|
||||
POSITION_ACTIONS.includes(props.data.properties.action)
|
||||
|| props.data.properties.action === 'AGV_MOVE_TO_STATION'
|
||||
))
|
||||
const robotActionButtonText = computed(() => {
|
||||
if (props.data.properties.action === 'AGV_MOVE_TO_STATION') return '选择机器人并加载站点'
|
||||
if (POSITION_ACTIONS.includes(props.data.properties.action)) return '选择机器人并获取位置'
|
||||
return '选择机器人设备'
|
||||
})
|
||||
const robotDialogTitle = computed(() => {
|
||||
if (robotFormAction.value === 'AGV_MOVE_TO_STATION') return '选择AGV并加载站点'
|
||||
if (POSITION_ACTIONS.includes(robotFormAction.value)) return '选择机器人并读取当前位置'
|
||||
return '选择机器人设备'
|
||||
})
|
||||
|
||||
/**
|
||||
* 处理 submitRobotForm 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const submitRobotForm = () => {
|
||||
if (robotFormAction.value === 'AGV_MOVE_TO_POINT') {
|
||||
robotFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
const response = await getAgvStatus({ deviceId: robotForm.deviceId, robotId: robotForm.robotId })
|
||||
if (response && response.data) {
|
||||
dialogVisible.value = false
|
||||
const { x, y, theta } = response.data.pose
|
||||
const testMap = {
|
||||
x: x,
|
||||
y: y,
|
||||
theta: theta
|
||||
}
|
||||
// 将获取到的位置信息设置到 formData 中
|
||||
formData.nodeParams.forEach(param => {
|
||||
if (testMap.hasOwnProperty(param.name)) {
|
||||
param.input = testMap[param.name]
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取位置信息失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (robotFormAction.value === 'AGV_MOVE_TO_STATION') {
|
||||
robotFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
stationListLoading.value = true
|
||||
try {
|
||||
const response = await getAgvStations({
|
||||
deviceId: robotForm.deviceId,
|
||||
robotId: robotForm.robotId
|
||||
})
|
||||
const stationOptions = toStationOptions(response?.data)
|
||||
if (stationOptions.length === 0) {
|
||||
throw new Error('AGV未返回可用站点')
|
||||
}
|
||||
const stationParam = formData.nodeParams.find(param => param.name === 'stationId')
|
||||
if (stationParam) {
|
||||
stationParam.selectOptions = stationOptions
|
||||
if (!stationOptions.some(item => item.value === stationParam.input)) {
|
||||
stationParam.input = ''
|
||||
}
|
||||
}
|
||||
dialogVisible.value = false
|
||||
ElMessage.success(`已加载${stationOptions.length}个AGV站点`)
|
||||
} catch (error) {
|
||||
console.error('加载AGV站点列表失败:', error)
|
||||
ElMessage.error(error?.message || '加载AGV站点列表失败')
|
||||
} finally {
|
||||
stationListLoading.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (robotFormAction.value === 'ARM_MOVE_TO_POINT') {
|
||||
robotFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
const response = await getArmStatus({ deviceId: robotForm.deviceId, robotId: robotForm.robotId })
|
||||
if (response && response.data) {
|
||||
dialogVisible.value = false
|
||||
const { x, y, z, rx, ry, rz } = response.data
|
||||
const testMap = {
|
||||
x: x,
|
||||
y: y,
|
||||
z: z,
|
||||
rx: rx,
|
||||
ry: ry,
|
||||
rz: rz,
|
||||
}
|
||||
// 将获取到的位置信息设置到 formData 中
|
||||
formData.nodeParams.forEach(param => {
|
||||
if (testMap.hasOwnProperty(param.name)) {
|
||||
param.input = testMap[param.name]
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取位置信息失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (robotFormAction.value === 'ARM_MOVE_TO_J') {
|
||||
robotFormRef.value.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
const response = await getArmJointState({
|
||||
deviceId: robotForm.deviceId,
|
||||
robotId: robotForm.robotId
|
||||
})
|
||||
if (response && response.data) {
|
||||
const target = response.data.position || response.data.positions
|
||||
if (!Array.isArray(target) || target.length === 0) {
|
||||
throw new Error('机械臂未返回有效的关节位置')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
const targetParam = formData.nodeParams.find(param => param.name === 'target')
|
||||
if (targetParam) targetParam.input = JSON.stringify(target)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取机械臂关节位置失败:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 toStationOptions 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} stations 站点集合
|
||||
*/
|
||||
const toStationOptions = (stations) => (Array.isArray(stations) ? stations : [])
|
||||
.filter(station => station?.id !== undefined && station?.id !== null && String(station.id).trim())
|
||||
.map(station => {
|
||||
const id = String(station.id).trim()
|
||||
const description = station.description ? String(station.description).trim() : ''
|
||||
return {
|
||||
value: id,
|
||||
label: description ? `${id} - ${description}` : id
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 打开 openRobotForm 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} action 节点动作类型
|
||||
*/
|
||||
const openRobotForm = async (action) => {
|
||||
dialogVisible.value = true
|
||||
robotFormAction.value = action
|
||||
robotForm.deviceId = ''
|
||||
await loadRobotOptions()
|
||||
if (robotForm.robotId) await loadRobotDevices(robotForm.robotId)
|
||||
}
|
||||
|
||||
defineExpose({ validateAndSave })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form__container {
|
||||
margin: 12px 0;
|
||||
|
||||
:deep(.el-row) {
|
||||
align-items: end;
|
||||
|
||||
.el-form-item {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.param-name {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.param-type {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.sub-properties {
|
||||
margin-left: 10px;
|
||||
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gSon-properties {
|
||||
margin-left: 10px;
|
||||
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
margin-bottom: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.robot-device-tip {
|
||||
margin: -4px 0 18px;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.device-auto-hint {
|
||||
color: var(--el-color-success);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@ -1,501 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:代码节点参数面板,编辑并校验脚本和输入输出字段。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<el-collapse v-model="activeNames">
|
||||
<el-collapse v-model="activeNames">
|
||||
<el-collapse-item name="1" icon-position="left">
|
||||
<template #title>
|
||||
输入参数
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
effect="dark"
|
||||
content="这里输入的变量可以被下方的代码引用。"
|
||||
placement="top"
|
||||
>
|
||||
<el-icon class="header-icon" style="margin-left: 6px">
|
||||
<info-filled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<div class="form__container">
|
||||
<el-form
|
||||
:inline="true"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
ref="dynamicFormRef"
|
||||
label-position="top"
|
||||
label-width="auto"
|
||||
>
|
||||
<div v-for="(property, index) in formData.nodeParams" :key="index">
|
||||
<el-row v-if="property.name !== 'code'">
|
||||
<el-form-item
|
||||
:label="index === 0 ? '参数名' : ''"
|
||||
:prop="`nodeParams.${index}.name`"
|
||||
:rules="[
|
||||
{ required: true, message: '请输入参数名', trigger: 'blur' },
|
||||
]"
|
||||
>
|
||||
<el-input
|
||||
:disabled="property?.disabled || false"
|
||||
class="param-name"
|
||||
v-model="property.name"
|
||||
placeholder="请输入"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="index === 0 ? '参数值' : ''"
|
||||
:prop="`nodeParams.${index}.type`"
|
||||
>
|
||||
<el-select
|
||||
v-model="property.type"
|
||||
class="param-type"
|
||||
@change="handleTypeChange(property)"
|
||||
>
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
<el-form-item
|
||||
v-if="property.type === 'input'"
|
||||
:rules="[
|
||||
{
|
||||
required: property?.required ?? true,
|
||||
message: '请输入参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]"
|
||||
:prop="`nodeParams.${index}.input`"
|
||||
>
|
||||
<el-input-number
|
||||
class="param-value"
|
||||
v-if="property.componentType === 'number'"
|
||||
v-model="property.input"
|
||||
:min="0"
|
||||
:max="property.max || Infinity"
|
||||
:controls="false"
|
||||
:step-strictly="true"
|
||||
placeholder="请输入"
|
||||
clearable
|
||||
/>
|
||||
<el-select
|
||||
v-model="property.input"
|
||||
class="param-value"
|
||||
v-else-if="property.componentType === 'select'"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in property.selectOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
class="param-value"
|
||||
v-else
|
||||
v-model="property.input"
|
||||
placeholder="请输入"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="property.type === 'quote'"
|
||||
:rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]"
|
||||
:prop="`nodeParams.${index}.quote`"
|
||||
>
|
||||
<el-cascader
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) cascaderRefs[index] = el;
|
||||
}
|
||||
"
|
||||
v-model="property.quote"
|
||||
:props="{ checkStrictly: true }"
|
||||
:options="quoteOptions"
|
||||
placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
"
|
||||
@change="(value) => cascaderChange(value, property)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
<el-button
|
||||
:icon="Minus"
|
||||
circle
|
||||
size="small"
|
||||
@click="handleDelete(index)"
|
||||
:disabled="property?.disabled || false"
|
||||
class="deleteBtn"
|
||||
/>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="代码" name="2" icon-position="left">
|
||||
<div class="code__container">
|
||||
<div ref="codeRef" style="width: 100%; height: 100%"></div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item name="3" icon-position="left">
|
||||
<template #title>
|
||||
输出参数
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
effect="dark"
|
||||
content="这里定义的输出变量可以被后续节点引用。"
|
||||
placement="top"
|
||||
>
|
||||
<el-icon class="header-icon" style="margin-left: 6px">
|
||||
<info-filled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<el-button
|
||||
:circle="true"
|
||||
size="small"
|
||||
@click="(e) => addFormItem(e, 'outputParams')"
|
||||
class="addFormItem"
|
||||
>
|
||||
<el-icon :size="14"><Plus /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="form__container">
|
||||
<el-form
|
||||
:inline="true"
|
||||
:model="formData"
|
||||
label-position="top"
|
||||
label-width="auto"
|
||||
:rules="outputRules"
|
||||
ref="outputFormRef"
|
||||
>
|
||||
<FormItemRecursive
|
||||
formType="output"
|
||||
:current-list="formData.outputParams"
|
||||
prop-path="outputParams"
|
||||
:depth="0"
|
||||
:is-first-level="true"
|
||||
:endDepth="2"
|
||||
:parent-path="[]"
|
||||
@delete-item="(path) => deleteTopLevelItem(path, 'outputParams')"
|
||||
/>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-collapse>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { Plus, Minus } from '@element-plus/icons-vue'
|
||||
import FormItemRecursive from '../FormItemRecursive.vue'
|
||||
import * as monaco from 'monaco-editor';
|
||||
import { getInput } from '@/utils/flow'
|
||||
import { useQuote } from './useQuote.js'
|
||||
|
||||
const props = defineProps({
|
||||
data: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save-success', 'save-error'])
|
||||
|
||||
const formData = reactive({
|
||||
nodeParams: [],
|
||||
outputParams: []
|
||||
})
|
||||
|
||||
const codeRef = ref()
|
||||
let editorInstance
|
||||
|
||||
/**
|
||||
* 判断 hasErrors 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
function hasErrors() {
|
||||
const model = editorInstance.getModel();
|
||||
if (!model) return false;
|
||||
|
||||
// 获取当前模型的所有标记
|
||||
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
|
||||
// 检查是否存在错误级别标记
|
||||
return markers.some(marker => marker.severity === monaco.MarkerSeverity.Error);
|
||||
}
|
||||
/**
|
||||
* 初始化 initEditor 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} type 目标类型
|
||||
*/
|
||||
const initEditor = (type) => {
|
||||
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
||||
target: monaco.languages.typescript.ScriptTarget.ES2020,
|
||||
allowNonTsExtensions: true, // 允许非 ts 扩展名(.js)
|
||||
checkJs: true, // 对 .js 文件进行类型检查
|
||||
strict: true, // 启用所有严格检查
|
||||
noImplicitAny: false, // 禁止隐式 any
|
||||
noUnusedLocals: true, // 未使用的局部变量报错(可选)
|
||||
noUnusedParameters: true, // 未使用的参数报错(可选)
|
||||
});
|
||||
|
||||
// 确保语义验证开启(默认就是开启的,但可以显式设置)
|
||||
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: false, // 开启语义检查
|
||||
noSyntaxValidation: false, // 开启语法检查
|
||||
});
|
||||
|
||||
editorInstance = monaco.editor.create(codeRef.value, {
|
||||
value: ['{}'].join('\n'),
|
||||
language: type === 'code' ? 'javascript' : 'json',
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: true,
|
||||
scrollBeyondLastLine: false,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
quickSuggestions: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
// 自动完成
|
||||
suggest: {
|
||||
showWords: true,
|
||||
showFunctions: true,
|
||||
showVariables: true,
|
||||
showClasses: true,
|
||||
showModules: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 设置代码值
|
||||
/**
|
||||
* 设置 setValue 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} value 待处理的新值
|
||||
*/
|
||||
const setValue = (value) => {
|
||||
if (editorInstance) {
|
||||
editorInstance.setValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
// 销毁编辑器
|
||||
/**
|
||||
* 销毁 disposeEditor 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const disposeEditor = () => {
|
||||
if (editorInstance) {
|
||||
editorInstance.dispose()
|
||||
editorInstance = null
|
||||
}
|
||||
}
|
||||
|
||||
const rules = reactive({})
|
||||
const outputRules = reactive({})
|
||||
const dynamicFormRef = ref()
|
||||
const outputFormRef = ref()
|
||||
const activeNames = ref(['1', '2'])
|
||||
|
||||
// 使用共享的 quote 逻辑
|
||||
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
|
||||
|
||||
// 添加表单项
|
||||
/**
|
||||
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} e 触发操作的浏览器事件
|
||||
* @param {*} type 目标类型
|
||||
*/
|
||||
const addFormItem = (e, type) => {
|
||||
e.stopPropagation()
|
||||
const obj = {
|
||||
name: '',
|
||||
type: type === 'nodeParams' ? 'input' : 'string',
|
||||
required: false,
|
||||
children: [],
|
||||
input: ''
|
||||
}
|
||||
if (!formData[type]) formData[type] = []
|
||||
formData[type].push(obj)
|
||||
}
|
||||
|
||||
// 删除输入参数(非递归)
|
||||
/**
|
||||
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} index 目标项索引
|
||||
*/
|
||||
const handleDelete = (index) => {
|
||||
formData.nodeParams.splice(index, 1)
|
||||
}
|
||||
|
||||
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
|
||||
/**
|
||||
* 删除 deleteTopLevelItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} fullPath 字段完整路径
|
||||
* @param {*} propPath 字段校验路径
|
||||
*/
|
||||
const deleteTopLevelItem = (fullPath, propPath) => {
|
||||
let currentLevel = formData[propPath]
|
||||
for (let i = 0; i < fullPath.length - 1; i++) {
|
||||
currentLevel = currentLevel[fullPath[i]].children
|
||||
}
|
||||
const lastIndex = fullPath[fullPath.length - 1]
|
||||
currentLevel.splice(lastIndex, 1)
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
/**
|
||||
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const initData = () => {
|
||||
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
|
||||
const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || []))
|
||||
formData.nodeParams = nodeParams;
|
||||
formData.outputParams = outputParams;
|
||||
const codeParams = props.data.properties.nodeParams.find(item => item.name === 'code')
|
||||
if (codeParams) {
|
||||
setTimeout(() => {
|
||||
if (editorInstance) {
|
||||
setValue(codeParams.input)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存验证
|
||||
/**
|
||||
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const validateAndSave = async () => {
|
||||
const targetObj = formData.nodeParams.find(item => item.name === 'code');
|
||||
if (targetObj) {
|
||||
// 存在:修改该对象的 input 属性
|
||||
targetObj.input = editorInstance.getValue();
|
||||
} else {
|
||||
formData.nodeParams.push(
|
||||
{ name: "code", type: "input", input: editorInstance.getValue() }
|
||||
)
|
||||
}
|
||||
|
||||
let inputValid = true
|
||||
let outputValid = true
|
||||
if (dynamicFormRef.value) {
|
||||
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
|
||||
}
|
||||
if (outputFormRef.value) {
|
||||
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
|
||||
}
|
||||
|
||||
// 保存到 lf
|
||||
lf.setProperties(props.data.id, {
|
||||
...props.data.properties,
|
||||
...formData
|
||||
})
|
||||
|
||||
if (hasErrors()) {
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
|
||||
if (inputValid && outputValid) {
|
||||
emit('save-success')
|
||||
} else {
|
||||
emit('save-error')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initEditor('code')
|
||||
initData()
|
||||
})
|
||||
})
|
||||
|
||||
defineExpose({ validateAndSave })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposeEditor()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form__container {
|
||||
margin: 12px 0;
|
||||
|
||||
:deep(.el-row) {
|
||||
align-items: end;
|
||||
|
||||
.el-form-item {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.param-name {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.param-type {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.sub-properties {
|
||||
margin-left: 10px;
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gSon-properties {
|
||||
margin-left: 10px;
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
margin-bottom: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.code__container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
text-align: initial;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,521 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:通用设备参数面板,选择设备并配置脚本和参数。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<RobotExecutionTarget :data="props.data" v-model="formData.executionTarget" />
|
||||
<el-collapse v-model="activeNames">
|
||||
<el-collapse v-model="activeNames">
|
||||
<el-collapse-item name="1" icon-position="left">
|
||||
<template #title>
|
||||
输入参数
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
effect="dark"
|
||||
content="这里输入的变量可以被下方的代码引用。"
|
||||
placement="top"
|
||||
>
|
||||
<el-icon class="header-icon" style="margin-left: 6px">
|
||||
<info-filled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<div class="form__container">
|
||||
<el-form
|
||||
:inline="true"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
ref="dynamicFormRef"
|
||||
label-position="top"
|
||||
label-width="auto"
|
||||
>
|
||||
<div v-for="(property, index) in formData.nodeParams" :key="index">
|
||||
<el-row v-if="property.name !== 'requestJson'">
|
||||
<el-form-item
|
||||
:label="index === 0 ? '参数名' : ''"
|
||||
:prop="`nodeParams.${index}.name`"
|
||||
:rules="[
|
||||
{ required: true, message: '请输入参数名', trigger: 'blur' },
|
||||
]"
|
||||
>
|
||||
<el-input
|
||||
:disabled="property?.disabled || false"
|
||||
class="param-name"
|
||||
v-model="property.name"
|
||||
placeholder="请输入"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="index === 0 ? '参数值' : ''"
|
||||
:prop="`nodeParams.${index}.type`"
|
||||
>
|
||||
<el-select
|
||||
v-model="property.type"
|
||||
class="param-type"
|
||||
@change="handleTypeChange(property)"
|
||||
>
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
<el-form-item
|
||||
v-if="property.type === 'input'"
|
||||
:rules="[
|
||||
{
|
||||
required: property?.required ?? true,
|
||||
message: '请输入参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]"
|
||||
:prop="`nodeParams.${index}.input`"
|
||||
>
|
||||
<el-input-number
|
||||
class="param-value"
|
||||
v-if="property.componentType === 'number'"
|
||||
v-model="property.input"
|
||||
:min="0"
|
||||
:max="property.max || Infinity"
|
||||
:controls="false"
|
||||
:step-strictly="true"
|
||||
placeholder="请输入"
|
||||
clearable
|
||||
/>
|
||||
<el-select
|
||||
v-model="property.input"
|
||||
class="param-value"
|
||||
v-else-if="property.componentType === 'select'"
|
||||
:disabled="property.deviceLocked"
|
||||
no-data-text="请先选择机器人设备"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in property.selectOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
class="param-value"
|
||||
v-else
|
||||
v-model="property.input"
|
||||
placeholder="请输入"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="property.type === 'quote'"
|
||||
:rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]"
|
||||
:prop="`nodeParams.${index}.quote`"
|
||||
>
|
||||
<el-cascader
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) cascaderRefs[index] = el;
|
||||
}
|
||||
"
|
||||
v-model="property.quote"
|
||||
:props="{ checkStrictly: true }"
|
||||
:options="quoteOptions"
|
||||
placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
"
|
||||
@change="(value) => cascaderChange(value, property)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
<el-button
|
||||
:icon="Minus"
|
||||
circle
|
||||
size="small"
|
||||
@click="handleDelete(index)"
|
||||
:disabled="property?.disabled || false"
|
||||
class="deleteBtn"
|
||||
/>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="JSON" name="2" icon-position="left">
|
||||
<div class="code__container">
|
||||
<div ref="codeRef" style="width: 100%; height: 100%"></div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item name="3" icon-position="left">
|
||||
<template #title>
|
||||
输出参数
|
||||
<el-tooltip
|
||||
class="box-item"
|
||||
effect="dark"
|
||||
content="这里定义的输出变量可以被后续节点引用。"
|
||||
placement="top"
|
||||
>
|
||||
<el-icon class="header-icon" style="margin-left: 6px">
|
||||
<info-filled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<el-button
|
||||
:circle="true"
|
||||
size="small"
|
||||
@click="(e) => addFormItem(e, 'outputParams')"
|
||||
class="addFormItem"
|
||||
>
|
||||
<el-icon :size="14"><Plus /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="form__container">
|
||||
<el-form
|
||||
:inline="true"
|
||||
:model="formData"
|
||||
label-position="top"
|
||||
label-width="auto"
|
||||
:rules="outputRules"
|
||||
ref="outputFormRef"
|
||||
>
|
||||
<FormItemRecursive
|
||||
formType="output"
|
||||
:current-list="formData.outputParams"
|
||||
prop-path="outputParams"
|
||||
:depth="0"
|
||||
:is-first-level="true"
|
||||
:endDepth="2"
|
||||
:parent-path="[]"
|
||||
@delete-item="(path) => deleteTopLevelItem(path, 'outputParams')"
|
||||
/>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-collapse>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
|
||||
import { Plus, Minus } from '@element-plus/icons-vue'
|
||||
import FormItemRecursive from '../FormItemRecursive.vue'
|
||||
import RobotExecutionTarget from './RobotExecutionTarget.vue'
|
||||
import * as monaco from 'monaco-editor';
|
||||
import { getInput } from '@/utils/flow'
|
||||
import { useQuote } from './useQuote.js'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
data: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save-success', 'save-error'])
|
||||
|
||||
const formData = reactive({
|
||||
nodeParams: [],
|
||||
outputParams: [],
|
||||
executionTarget: {}
|
||||
})
|
||||
|
||||
const codeRef = ref()
|
||||
let editorInstance
|
||||
|
||||
/**
|
||||
* 判断 hasErrors 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
function hasErrors() {
|
||||
const model = editorInstance.getModel();
|
||||
if (!model) return false;
|
||||
|
||||
// 获取当前模型的所有标记
|
||||
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
|
||||
// 检查是否存在错误级别标记
|
||||
return markers.some(marker => marker.severity === monaco.MarkerSeverity.Error);
|
||||
}
|
||||
/**
|
||||
* 初始化 initEditor 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} type 目标类型
|
||||
*/
|
||||
const initEditor = (type) => {
|
||||
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
||||
target: monaco.languages.typescript.ScriptTarget.ES2020,
|
||||
allowNonTsExtensions: true, // 允许非 ts 扩展名(.js)
|
||||
checkJs: true, // 对 .js 文件进行类型检查
|
||||
strict: true, // 启用所有严格检查
|
||||
noImplicitAny: false, // 禁止隐式 any
|
||||
noUnusedLocals: true, // 未使用的局部变量报错(可选)
|
||||
noUnusedParameters: true, // 未使用的参数报错(可选)
|
||||
});
|
||||
|
||||
// 确保语义验证开启(默认就是开启的,但可以显式设置)
|
||||
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: false, // 开启语义检查
|
||||
noSyntaxValidation: false, // 开启语法检查
|
||||
});
|
||||
|
||||
editorInstance = monaco.editor.create(codeRef.value, {
|
||||
value: ['{}'].join('\n'),
|
||||
language: type === 'code' ? 'javascript' : 'json',
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: true,
|
||||
scrollBeyondLastLine: false,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
quickSuggestions: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
// 自动完成
|
||||
suggest: {
|
||||
showWords: true,
|
||||
showFunctions: true,
|
||||
showVariables: true,
|
||||
showClasses: true,
|
||||
showModules: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 设置代码值
|
||||
/**
|
||||
* 设置 setValue 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} value 待处理的新值
|
||||
*/
|
||||
const setValue = (value) => {
|
||||
if (editorInstance) {
|
||||
editorInstance.setValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
// 销毁编辑器
|
||||
/**
|
||||
* 销毁 disposeEditor 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const disposeEditor = () => {
|
||||
if (editorInstance) {
|
||||
editorInstance.dispose()
|
||||
editorInstance = null
|
||||
}
|
||||
}
|
||||
|
||||
const rules = reactive({})
|
||||
const outputRules = reactive({})
|
||||
const dynamicFormRef = ref()
|
||||
const outputFormRef = ref()
|
||||
const activeNames = ref(['1', '2'])
|
||||
|
||||
// 使用共享的 quote 逻辑
|
||||
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
|
||||
|
||||
// 添加表单项
|
||||
/**
|
||||
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} e 触发操作的浏览器事件
|
||||
* @param {*} type 目标类型
|
||||
*/
|
||||
const addFormItem = (e, type) => {
|
||||
e.stopPropagation()
|
||||
const obj = {
|
||||
name: '',
|
||||
type: type === 'nodeParams' ? 'input' : 'string',
|
||||
required: false,
|
||||
children: [],
|
||||
input: ''
|
||||
}
|
||||
if (!formData[type]) formData[type] = []
|
||||
formData[type].push(obj)
|
||||
}
|
||||
|
||||
// 删除输入参数(非递归)
|
||||
/**
|
||||
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} index 目标项索引
|
||||
*/
|
||||
const handleDelete = (index) => {
|
||||
formData.nodeParams.splice(index, 1)
|
||||
}
|
||||
|
||||
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
|
||||
/**
|
||||
* 删除 deleteTopLevelItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} fullPath 字段完整路径
|
||||
* @param {*} propPath 字段校验路径
|
||||
*/
|
||||
const deleteTopLevelItem = (fullPath, propPath) => {
|
||||
let currentLevel = formData[propPath]
|
||||
for (let i = 0; i < fullPath.length - 1; i++) {
|
||||
currentLevel = currentLevel[fullPath[i]].children
|
||||
}
|
||||
const lastIndex = fullPath[fullPath.length - 1]
|
||||
currentLevel.splice(lastIndex, 1)
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
/**
|
||||
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const initData = () => {
|
||||
const properties = props.data?.properties || {}
|
||||
const nodeParams = JSON.parse(JSON.stringify(properties.nodeParams || []))
|
||||
const outputParams = JSON.parse(JSON.stringify(properties.outputParams || []))
|
||||
formData.nodeParams = nodeParams;
|
||||
formData.outputParams = outputParams;
|
||||
formData.executionTarget = JSON.parse(JSON.stringify(properties.executionTarget || {}));
|
||||
const codeParams = nodeParams.find(item => item.name === 'requestJson')
|
||||
if (codeParams) {
|
||||
nextTick(() => {
|
||||
if (editorInstance) {
|
||||
setValue(codeParams.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
initData,
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// 保存验证
|
||||
/**
|
||||
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const validateAndSave = async () => {
|
||||
const targetObj = formData.nodeParams.find(item => item.name === 'requestJson');
|
||||
if (targetObj) {
|
||||
// 存在:修改该对象的 input 属性
|
||||
targetObj.input = editorInstance.getValue();
|
||||
} else {
|
||||
formData.nodeParams.push(
|
||||
{ name: "requestJson", type: "input", input: editorInstance.getValue() }
|
||||
)
|
||||
}
|
||||
|
||||
let inputValid = true
|
||||
let outputValid = true
|
||||
if (dynamicFormRef.value) {
|
||||
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
|
||||
}
|
||||
if (outputFormRef.value) {
|
||||
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
|
||||
}
|
||||
if (!formData.executionTarget.roleKey || !formData.executionTarget.deviceSlotKey) {
|
||||
inputValid = false
|
||||
ElMessage.warning('请选择执行角色和设备用途')
|
||||
}
|
||||
|
||||
// 保存到 lf
|
||||
lf.setProperties(props.data.id, {
|
||||
...props.data.properties,
|
||||
...formData
|
||||
})
|
||||
|
||||
if (hasErrors()) {
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
|
||||
if (inputValid && outputValid) {
|
||||
emit('save-success')
|
||||
} else {
|
||||
emit('save-error')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initEditor('requestJson')
|
||||
const codeParams = formData.nodeParams.find(item => item.name === 'requestJson')
|
||||
if (codeParams) setValue(codeParams.input)
|
||||
})
|
||||
})
|
||||
|
||||
defineExpose({ validateAndSave })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposeEditor()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.form__container {
|
||||
margin: 12px 0;
|
||||
|
||||
:deep(.el-row) {
|
||||
align-items: end;
|
||||
|
||||
.el-form-item {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.param-name {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.param-type {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.sub-properties {
|
||||
margin-left: 10px;
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gSon-properties {
|
||||
margin-left: 10px;
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
margin-bottom: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.code__container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
text-align: initial;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,158 +0,0 @@
|
||||
<!-- End node output contract and value references. -->
|
||||
<template>
|
||||
<div class="end-params">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<div class="section-title">流程输出</div>
|
||||
<div class="section-note">输出值来自开始参数或前面节点的输出。</div>
|
||||
</div>
|
||||
<el-button type="primary" plain :icon="Plus" @click="addOutput">添加输出</el-button>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!outputs.length" description="当前流程没有输出" :image-size="72" />
|
||||
<div v-for="(item, index) in outputs" :key="item.key" class="output-row">
|
||||
<el-form-item label="输出名称" required>
|
||||
<el-input v-model="item.name" maxlength="40" placeholder="例如:result" />
|
||||
</el-form-item>
|
||||
<el-form-item label="引用来源" required>
|
||||
<el-cascader
|
||||
v-model="item.quote"
|
||||
:options="quoteOptions"
|
||||
:props="{ checkStrictly: true }"
|
||||
placeholder="选择开始参数或节点输出"
|
||||
style="width: 100%"
|
||||
@visible-change="refreshQuoteOptions"
|
||||
@change="(value) => changeQuote(item, value)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<div class="output-row__meta">
|
||||
<el-tag effect="plain" type="info">{{ item.valueType || '自动识别' }}</el-tag>
|
||||
<el-input v-model="item.desc" placeholder="输出说明(可选)" />
|
||||
<el-button circle :icon="Delete" aria-label="删除输出" @click="outputs.splice(index, 1)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getUpstreamInput } from '@/utils/flow'
|
||||
|
||||
const props = defineProps({ data: Object })
|
||||
const emit = defineEmits(['save-success', 'save-error'])
|
||||
const outputs = ref([])
|
||||
const quoteOptions = ref([])
|
||||
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value ?? null))
|
||||
const key = () => `output_${Date.now()}_${Math.random().toString(16).slice(2)}`
|
||||
|
||||
const refreshQuoteOptions = (visible = true) => {
|
||||
if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || []
|
||||
}
|
||||
|
||||
const findDefinition = (quote) => {
|
||||
if (!Array.isArray(quote) || quote.length < 3) return null
|
||||
const graph = globalThis.lf.getGraphData()
|
||||
const source = graph.nodes.find((node) => node.id === quote[0])
|
||||
let definitions = quote[1] === 'input'
|
||||
? [...(source?.properties?.inputParams || []), ...(source?.properties?.nodeParams || [])]
|
||||
: source?.properties?.outputParams || []
|
||||
let definition = null
|
||||
quote.slice(2).forEach((name) => {
|
||||
definition = definitions.find((item) => item.name === name)
|
||||
definitions = definition?.children || []
|
||||
})
|
||||
return definition
|
||||
}
|
||||
|
||||
const changeQuote = (item, value) => {
|
||||
item.quoteType = value?.[1] || ''
|
||||
const definition = findDefinition(value)
|
||||
if (!definition) return
|
||||
item.valueType = definition.type || 'string'
|
||||
item.children = clone(definition.children || [])
|
||||
if (!item.name) item.name = definition.name
|
||||
if (!item.desc) item.desc = definition.desc || ''
|
||||
}
|
||||
|
||||
const addOutput = () => outputs.value.push({
|
||||
key: key(),
|
||||
name: '',
|
||||
valueType: 'string',
|
||||
quote: [],
|
||||
quoteType: '',
|
||||
desc: '',
|
||||
children: [],
|
||||
})
|
||||
|
||||
const init = () => {
|
||||
const paramsByName = new Map((props.data.properties?.nodeParams || []).map((item) => [item.name, item]))
|
||||
outputs.value = (props.data.properties?.outputParams || []).map((output) => {
|
||||
const param = paramsByName.get(output.name) || {}
|
||||
return {
|
||||
key: key(),
|
||||
name: output.name || '',
|
||||
valueType: output.type || 'string',
|
||||
quote: Array.isArray(param.quote) ? [...param.quote] : [],
|
||||
quoteType: param.quoteType || param.quote?.[1] || '',
|
||||
desc: output.desc || '',
|
||||
children: clone(output.children || []),
|
||||
}
|
||||
})
|
||||
refreshQuoteOptions()
|
||||
}
|
||||
|
||||
const validateAndSave = () => {
|
||||
const names = outputs.value.map((item) => item.name.trim())
|
||||
if (names.some((name) => !name)) {
|
||||
ElMessage.warning('请填写流程输出名称')
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
if (new Set(names).size !== names.length) {
|
||||
ElMessage.warning('流程输出名称不能重复')
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
if (outputs.value.some((item) => !Array.isArray(item.quote) || item.quote.length < 3)) {
|
||||
ElMessage.warning('每个流程输出都必须选择引用来源')
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
const nodeParams = outputs.value.map((item) => ({
|
||||
name: item.name.trim(),
|
||||
type: 'quote',
|
||||
input: '',
|
||||
quote: [...item.quote],
|
||||
quoteType: item.quoteType || item.quote[1],
|
||||
required: true,
|
||||
}))
|
||||
const outputParams = outputs.value.map((item) => ({
|
||||
name: item.name.trim(),
|
||||
type: item.valueType || 'string',
|
||||
desc: item.desc || '',
|
||||
children: clone(item.children || []),
|
||||
}))
|
||||
globalThis.lf.setProperties(props.data.id, {
|
||||
...props.data.properties,
|
||||
nodeParams,
|
||||
outputParams,
|
||||
outputType: 'json',
|
||||
})
|
||||
emit('save-success')
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
defineExpose({ validateAndSave })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.end-params { padding-top: 14px; }
|
||||
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
.section-title { color: #1f2937; font-size: 14px; font-weight: 650; }
|
||||
.section-note { margin-top: 4px; color: #7a8494; font-size: 12px; }
|
||||
.output-row { margin-bottom: 12px; padding: 14px; border: 1px solid #e4e8ef; border-radius: 6px; background: #fff; }
|
||||
.output-row__meta { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; }
|
||||
</style>
|
||||
@ -1,704 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:HTTP 节点参数面板,配置请求及输出字段。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<el-form :inline="true" :model="httpNodeData" :rules="rules" class="http-form" ref="dynamicFormRef"
|
||||
label-position="top" label-width="auto">
|
||||
<el-collapse v-model="activeNames">
|
||||
<el-collapse-item name="1" icon-position="left">
|
||||
<template #title>
|
||||
请求配置
|
||||
<el-tooltip class="box-item" effect="dark" content="支持HTTP/HTTPS协议" placement="top">
|
||||
<el-icon class="header-icon" style="margin-left: 6px">
|
||||
<info-filled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<div class="form__container">
|
||||
<div v-for="(property, index) in httpNodeData.config" :key="index">
|
||||
<el-row>
|
||||
<el-form-item :label="index === 0 ? '参数名' : ''" :prop="`config.${index}.name`" :rules="[
|
||||
{ required: true, message: '请输入参数名', trigger: 'blur' },
|
||||
]">
|
||||
<el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入"
|
||||
clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`config.${index}.input`">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
<el-form-item v-if="property.type === 'input'" :rules="[
|
||||
{
|
||||
required: property?.required ?? true,
|
||||
message: '请输入参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]" :prop="`config.${index}.input`">
|
||||
<el-input-number class="param-value" v-if="property.componentType === 'number'"
|
||||
v-model="property.input" :min="0" :max="property.max || Infinity"
|
||||
:controls="false" :step-strictly="true" placeholder="请输入" clearable />
|
||||
<el-select v-model="property.input" class="param-value"
|
||||
v-else-if="property.componentType === 'select'">
|
||||
<el-option v-for="item in property.selectOptions" :key="item.value"
|
||||
:label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-input class="param-value" v-else v-model="property.input" placeholder="请输入"
|
||||
clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="property.type === 'quote'" :rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]" :prop="`config.${index}.quote`">
|
||||
<el-cascader :ref="(el) => {
|
||||
if (el) cascaderRefs[index] = el;
|
||||
}
|
||||
" v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
" @change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item name="2" icon-position="left">
|
||||
<template #title>
|
||||
Header配置
|
||||
<el-button :circle="true" size="small" @click="(e) => addHttpNodeFormItem(e, 'headers')"
|
||||
class="addFormItem">
|
||||
<el-icon :size="14">
|
||||
<Plus />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="form__container">
|
||||
<div v-for="(property, index) in httpNodeData.headers" :key="index">
|
||||
<el-row>
|
||||
<el-form-item :label="index === 0 ? '参数名' : ''"
|
||||
:prop="httpNodeData.headers.length > index ? `headers.${index}.name` : undefined"
|
||||
:rules="[
|
||||
{ required: true, message: '请输入参数名', trigger: 'blur' },
|
||||
]">
|
||||
<el-input class="param-name" v-model="property.name" placeholder="请输入" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''"
|
||||
:prop="httpNodeData.headers.length > index ? `headers.${index}.input` : undefined">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
<el-form-item v-if="property.type === 'input'" :rules="[
|
||||
{
|
||||
required: property?.required ?? true,
|
||||
message: '请输入参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]" :prop="`headers.${index}.input`">
|
||||
<el-input-number class="param-value" v-if="property.componentType === 'number'"
|
||||
v-model="property.input" :min="0" :max="property.max || Infinity"
|
||||
:controls="false" :step-strictly="true" placeholder="请输入" clearable />
|
||||
<el-select v-model="property.input" class="param-value"
|
||||
v-else-if="property.componentType === 'select'">
|
||||
<el-option v-for="item in property.selectOptions" :key="item.value"
|
||||
:label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-input class="param-value" v-else v-model="property.input" placeholder="请输入"
|
||||
clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="property.type === 'quote'" :rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]" :prop="`headers.${index}.quote`">
|
||||
<el-cascader :ref="(el) => {
|
||||
if (el) cascaderRefs[index] = el;
|
||||
}
|
||||
" v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
" @change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
<el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('headers', index)"
|
||||
class="deleteBtn" />
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item name="3" icon-position="left">
|
||||
<template #title>
|
||||
Param配置
|
||||
<el-button :circle="true" size="small" @click="(e) => addHttpNodeFormItem(e, 'params')"
|
||||
class="addFormItem">
|
||||
<el-icon :size="14">
|
||||
<Plus />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="form__container">
|
||||
<div v-for="(property, index) in httpNodeData.params" :key="index">
|
||||
<el-row>
|
||||
<el-form-item :label="index === 0 ? '参数名' : ''"
|
||||
:prop="httpNodeData.params.length > index ? `params.${index}.name` : undefined" :rules="[
|
||||
{ required: true, message: '请输入参数名123', trigger: 'blur' },
|
||||
]">
|
||||
<el-input class="param-name" v-model="property.name" placeholder="请输入" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''"
|
||||
:prop="httpNodeData.params.length > index ? `params.${index}.input` : undefined">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
<el-form-item v-if="property.type === 'input'" :rules="[
|
||||
{
|
||||
required: property?.required ?? true,
|
||||
message: '请输入参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]" :prop="httpNodeData.params.length > index ? `params.${index}.input` : undefined">
|
||||
<el-input-number class="param-value" v-if="property.componentType === 'number'"
|
||||
v-model="property.input" :min="0" :max="property.max || Infinity"
|
||||
:controls="false" :step-strictly="true" placeholder="请输入" clearable />
|
||||
<el-select v-model="property.input" class="param-value"
|
||||
v-else-if="property.componentType === 'select'">
|
||||
<el-option v-for="item in property.selectOptions" :key="item.value"
|
||||
:label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-input class="param-value" v-else v-model="property.input" placeholder="请输入"
|
||||
clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="property.type === 'quote'" :rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]" :prop="`params.${index}.quote`">
|
||||
<el-cascader :ref="(el) => {
|
||||
if (el) cascaderRefs[index] = el;
|
||||
}
|
||||
" v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
" @change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
<el-button :icon="Minus" circle size="small" @click="handleHttpNodeDelete('params', index)"
|
||||
class="deleteBtn" />
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item name="4" title="Body配置" icon-position="left">
|
||||
<template #title>
|
||||
Body配置
|
||||
<el-button
|
||||
:circle="true"
|
||||
size="small"
|
||||
@click="(e) => addHttpNodeFormItem(e, 'body')"
|
||||
class="addFormItem"
|
||||
v-if="httpNodeData?.body?.type === 'form-data'"
|
||||
>
|
||||
<el-icon :size="14"><Plus /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="form__container">
|
||||
<el-select v-model="httpNodeData.body.type">
|
||||
<el-option label="JSON" value="json" />
|
||||
<el-option label="form-data" value="form-data" />
|
||||
</el-select>
|
||||
<div class="code__container" v-show="httpNodeData?.body?.type === 'json'">
|
||||
<div ref="codeRef" style="width: 100%; height: 100%"></div>
|
||||
</div>
|
||||
<div class="test" v-if="httpNodeData?.body?.type === 'form-data'">
|
||||
<div v-for="(property, index) in httpNodeData.body.formData" :key="index">
|
||||
<el-row>
|
||||
<el-form-item
|
||||
:label="index === 0 ? '参数名' : ''"
|
||||
:prop="`body.formData.${index}.name`"
|
||||
:rules="[
|
||||
{ required: true, message: '请输入参数名', trigger: 'blur' },
|
||||
]"
|
||||
>
|
||||
<el-input
|
||||
class="param-name"
|
||||
v-model="property.name"
|
||||
placeholder="请输入"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
:label="index === 0 ? '参数值' : ''"
|
||||
:prop="`body.formData.${index}.input`"
|
||||
>
|
||||
<el-select
|
||||
v-model="property.type"
|
||||
class="param-type"
|
||||
@change="handleTypeChange(property)"
|
||||
>
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
<el-form-item
|
||||
v-if="property.type === 'input'"
|
||||
:rules="[
|
||||
{
|
||||
required: property?.required ?? true,
|
||||
message: '请输入参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]"
|
||||
:prop="`body.formData.${index}.input`"
|
||||
>
|
||||
<el-input-number
|
||||
class="param-value"
|
||||
v-if="property.componentType === 'number'"
|
||||
v-model="property.input"
|
||||
:min="0"
|
||||
:max="property.max || Infinity"
|
||||
:controls="false"
|
||||
:step-strictly="true"
|
||||
placeholder="请输入"
|
||||
clearable
|
||||
/>
|
||||
<el-select
|
||||
v-model="property.input"
|
||||
class="param-value"
|
||||
v-else-if="property.componentType === 'select'"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in property.selectOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
class="param-value"
|
||||
v-else
|
||||
v-model="property.input"
|
||||
placeholder="请输入"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="property.type === 'quote'"
|
||||
:rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]"
|
||||
:prop="`body.formData.${index}.quote`"
|
||||
>
|
||||
<el-cascader
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) cascaderRefs[index] = el;
|
||||
}
|
||||
"
|
||||
v-model="property.quote"
|
||||
:props="{ checkStrictly: true }"
|
||||
:options="quoteOptions"
|
||||
placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote, 'httpBody')
|
||||
"
|
||||
@change="(value) => cascaderChange(value, property)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
<el-button
|
||||
:icon="Minus"
|
||||
circle
|
||||
size="small"
|
||||
@click="handleHttpNodeDelete('body', index)"
|
||||
class="deleteBtn"
|
||||
/>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { Plus, Minus } from '@element-plus/icons-vue'
|
||||
import FormItemRecursive from '../FormItemRecursive.vue'
|
||||
import * as monaco from 'monaco-editor';
|
||||
import { getInput, transformHttpNodeData } from '@/utils/flow'
|
||||
import { useQuote } from './useQuote.js'
|
||||
|
||||
const props = defineProps({
|
||||
data: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save-success', 'save-error'])
|
||||
|
||||
const formData = reactive({
|
||||
nodeParams: [],
|
||||
outputParams: []
|
||||
})
|
||||
|
||||
const codeRef = ref()
|
||||
let editorInstance
|
||||
|
||||
/**
|
||||
* 判断 hasErrors 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
function hasErrors() {
|
||||
const model = editorInstance.getModel();
|
||||
if (!model) return false;
|
||||
|
||||
// 获取当前模型的所有标记
|
||||
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
|
||||
// 检查是否存在错误级别标记
|
||||
return markers.some(marker => marker.severity === monaco.MarkerSeverity.Error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 initEditor 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} type 目标类型
|
||||
*/
|
||||
const initEditor = (type) => {
|
||||
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
|
||||
target: monaco.languages.typescript.ScriptTarget.ES2020,
|
||||
allowNonTsExtensions: true, // 允许非 ts 扩展名(.js)
|
||||
checkJs: true, // 对 .js 文件进行类型检查
|
||||
strict: true, // 启用所有严格检查
|
||||
noImplicitAny: false, // 禁止隐式 any
|
||||
noUnusedLocals: true, // 未使用的局部变量报错(可选)
|
||||
noUnusedParameters: true, // 未使用的参数报错(可选)
|
||||
});
|
||||
|
||||
// 确保语义验证开启(默认就是开启的,但可以显式设置)
|
||||
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
|
||||
noSemanticValidation: false, // 开启语义检查
|
||||
noSyntaxValidation: false, // 开启语法检查
|
||||
});
|
||||
|
||||
editorInstance = monaco.editor.create(codeRef.value, {
|
||||
value: ['{}'].join('\n'),
|
||||
language: 'json',
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
roundedSelection: true,
|
||||
scrollBeyondLastLine: false,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
quickSuggestions: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
// 自动完成
|
||||
suggest: {
|
||||
showWords: true,
|
||||
showFunctions: true,
|
||||
showVariables: true,
|
||||
showClasses: true,
|
||||
showModules: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 设置代码值
|
||||
/**
|
||||
* 设置 setValue 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} value 待处理的新值
|
||||
*/
|
||||
const setValue = (value) => {
|
||||
if (editorInstance) {
|
||||
editorInstance.setValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
// 销毁编辑器
|
||||
/**
|
||||
* 销毁 disposeEditor 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const disposeEditor = () => {
|
||||
if (editorInstance) {
|
||||
editorInstance.dispose()
|
||||
editorInstance = null
|
||||
}
|
||||
}
|
||||
|
||||
const rules = reactive({})
|
||||
const dynamicFormRef = ref()
|
||||
const activeNames = ref(['1', '2', '3', '4'])
|
||||
|
||||
// 使用共享的 quote 逻辑
|
||||
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
|
||||
|
||||
// 添加表单项
|
||||
/**
|
||||
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} e 触发操作的浏览器事件
|
||||
* @param {*} type 目标类型
|
||||
*/
|
||||
const addFormItem = (e, type) => {
|
||||
e.stopPropagation()
|
||||
const obj = {
|
||||
name: '',
|
||||
type: type === 'nodeParams' ? 'input' : 'string',
|
||||
required: false,
|
||||
children: [],
|
||||
input: ''
|
||||
}
|
||||
if (!formData[type]) formData[type] = []
|
||||
formData[type].push(obj)
|
||||
}
|
||||
|
||||
// 删除输入参数(非递归)
|
||||
/**
|
||||
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} index 目标项索引
|
||||
*/
|
||||
const handleDelete = (index) => {
|
||||
formData.nodeParams.splice(index, 1)
|
||||
}
|
||||
|
||||
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
|
||||
/**
|
||||
* 删除 deleteTopLevelItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} fullPath 字段完整路径
|
||||
* @param {*} propPath 字段校验路径
|
||||
*/
|
||||
const deleteTopLevelItem = (fullPath, propPath) => {
|
||||
let currentLevel = formData[propPath]
|
||||
for (let i = 0; i < fullPath.length - 1; i++) {
|
||||
currentLevel = currentLevel[fullPath[i]].children
|
||||
}
|
||||
const lastIndex = fullPath[fullPath.length - 1]
|
||||
currentLevel.splice(lastIndex, 1)
|
||||
}
|
||||
|
||||
// HTTP 节点数据
|
||||
const httpNodeData = ref({
|
||||
body: {
|
||||
bodyType: 'json',
|
||||
json: '{}',
|
||||
formData: []
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 新增 addHttpNodeFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} e 触发操作的浏览器事件
|
||||
* @param {*} type 目标类型
|
||||
*/
|
||||
const addHttpNodeFormItem = (e, type) => {
|
||||
e.stopPropagation();
|
||||
if (type === 'body') {
|
||||
if (!httpNodeData.value.body.formData) {
|
||||
httpNodeData.value.body.formData = [];
|
||||
}
|
||||
httpNodeData.value.body.formData.push({ name: "", type: "input", input: ""});
|
||||
} else {
|
||||
if (!httpNodeData.value[type]) {
|
||||
httpNodeData.value[type] = [];
|
||||
}
|
||||
httpNodeData.value[type].push({ name: "", type: "input", input: ""});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 handleHttpNodeDelete 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} type 目标类型
|
||||
* @param {*} index 目标项索引
|
||||
*/
|
||||
const handleHttpNodeDelete = (type, index) => {
|
||||
if (type === 'body') {
|
||||
httpNodeData.value.body.formData.splice(index, 1);
|
||||
} else {
|
||||
httpNodeData.value[type].splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化数据
|
||||
/**
|
||||
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const initData = () => {
|
||||
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
|
||||
const transformedData = transformHttpNodeData(nodeParams);
|
||||
|
||||
httpNodeData.value = transformedData;
|
||||
if (httpNodeData.value.body.type === 'json') {
|
||||
nextTick(() => {
|
||||
initEditor('http');
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (editorInstance) {
|
||||
setValue(httpNodeData.value.body.json || '{}')
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存验证
|
||||
/**
|
||||
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const validateAndSave = async () => {
|
||||
// 分区校验动态输入、请求头、查询参数和请求体;JSON 请求体还需通过编辑器语法检查。
|
||||
// 全部分区有效后才一次性写回节点属性,防止半成品配置覆盖画布中的可用数据。
|
||||
let inputValid = true
|
||||
if (dynamicFormRef.value) {
|
||||
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
|
||||
}
|
||||
|
||||
|
||||
const nodeParams = []
|
||||
nodeParams.push({ name: "config", type: "input", input: "", children: httpNodeData.value.config, disabled: true, required: true })
|
||||
if (httpNodeData.value?.headers?.length > 0) {
|
||||
nodeParams.push({ name: "headers", type: "input", input: "", children: httpNodeData.value.headers, disabled: true, required: true })
|
||||
}
|
||||
if (httpNodeData.value?.params?.length > 0) {
|
||||
nodeParams.push({ name: "params", type: "input", input: "", children: httpNodeData.value.params, disabled: true, required: true })
|
||||
}
|
||||
|
||||
const bodyObj = {
|
||||
name: "body", type: "input", input: "", children: [
|
||||
{ name: 'bodyType', type: "input", input: httpNodeData.value.body.type, disabled: true },
|
||||
{ name: 'json', type: "input", input: httpNodeData.value.body.type === 'json' ? editorInstance.getValue() : '' },
|
||||
{ name: 'formData', type: "input", input: "", children: httpNodeData.value.body.formData }
|
||||
]
|
||||
}
|
||||
nodeParams.push(bodyObj)
|
||||
|
||||
// 保存到 lf
|
||||
lf.setProperties(props.data.id, {
|
||||
...props.data.properties,
|
||||
nodeParams
|
||||
})
|
||||
|
||||
if (httpNodeData.value.body.type === 'json') {
|
||||
if (hasErrors()) {
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (inputValid) {
|
||||
emit('save-success')
|
||||
} else {
|
||||
emit('save-error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
initData()
|
||||
})
|
||||
})
|
||||
|
||||
defineExpose({ validateAndSave })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disposeEditor()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.http-form {
|
||||
width: 100%;
|
||||
|
||||
.el-collapse {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.form__container {
|
||||
margin: 12px 0;
|
||||
|
||||
:deep(.el-row) {
|
||||
align-items: end;
|
||||
|
||||
.el-form-item {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.param-name {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.param-type {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.sub-properties {
|
||||
margin-left: 10px;
|
||||
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gSon-properties {
|
||||
margin-left: 10px;
|
||||
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
margin-bottom: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.code__container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
text-align: initial;
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,431 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:通用图片分析节点参数编辑器,支持引用上游图片或直接填写 HTTP/HTTPS 图片地址。
|
||||
* 数据流向:从 nodeParams 回填表单,保存时写回 IMAGE_ANALYZE 执行协议和统一输出字段。
|
||||
-->
|
||||
<template>
|
||||
<div class="image-analysis-params">
|
||||
<div class="intro">
|
||||
<div class="intro__title">通用图片分析</div>
|
||||
<div class="intro__desc">根据提示词分析一张或多张图片,模型结果保持提示词要求的内容和格式。</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top">
|
||||
<el-form-item label="分析方式">
|
||||
<el-segmented v-model="formData.analysisMethod" :options="methodOptions" block />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="isReferenceMatch ? '大图来源' : (isTextLocate ? '目标图片来源' : '图片来源')" prop="imageMode">
|
||||
<el-segmented v-model="formData.imageMode" :options="sourceOptions" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="formData.imageMode === 'quote'" label="引用上游图片" prop="imageQuote">
|
||||
<el-cascader
|
||||
ref="imageCascaderRef"
|
||||
v-model="formData.imageQuote"
|
||||
:options="quoteOptions"
|
||||
:props="{ checkStrictly: true }"
|
||||
placeholder="选择图片节点的 imageUrl 输出"
|
||||
style="width: 100%"
|
||||
@visible-change="loadQuoteOptions"
|
||||
@change="handleQuoteChange"
|
||||
/>
|
||||
<div class="field-hint" v-if="isVisionAnalysis">上游输出为图片数组时,将按原顺序全部分析,最多 12 张。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-else label="图片地址" prop="imageUrlsText">
|
||||
<el-input
|
||||
v-model="formData.imageUrlsText"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="isLocationMethod ? '输入一张目标图片的 HTTP/HTTPS 地址' : '每行一个 HTTP/HTTPS 图片地址,最多 12 张'"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="!isReferenceMatch" :label="isTextLocate ? '目标描述' : '分析提示词'" prop="prompt">
|
||||
<el-input
|
||||
v-model="formData.prompt"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
maxlength="4000"
|
||||
show-word-limit
|
||||
:placeholder="isTextLocate ? '例如:底部应用栏中的设置按钮' : '例如:读取图片中的仪表盘数值,只输出数字。或:识别所有仪表,按 JSON 输出名称、数值和单位。'"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="!isReferenceMatch" label="分析模式">
|
||||
<el-segmented v-model="formData.analysisMode" :options="modeOptions" block />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="异步执行">
|
||||
<el-switch v-model="formData.asynchronous" />
|
||||
<div class="field-hint">开启后分析在后台并行执行,流程结束前统一等待分析结果。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="isTextLocate" label="最低定位置信度">
|
||||
<el-slider v-model="formData.analysisTuning.locateConfidenceThreshold" :min="0.2" :max="0.95" :step="0.01" show-input />
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="isReferenceMatch">
|
||||
<el-form-item label="小图来源">
|
||||
<el-segmented v-model="formData.referenceMode" :options="sourceOptions" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="formData.referenceMode === 'quote'" label="引用待查小图" prop="referenceQuote">
|
||||
<el-cascader
|
||||
ref="referenceCascaderRef"
|
||||
v-model="formData.referenceQuote"
|
||||
:options="quoteOptions"
|
||||
:props="{ checkStrictly: true }"
|
||||
placeholder="选择待查小图输出"
|
||||
style="width: 100%"
|
||||
@visible-change="loadQuoteOptions"
|
||||
@change="handleReferenceQuoteChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="小图地址" prop="referenceImageUrl">
|
||||
<el-input v-model="formData.referenceImageUrl" clearable placeholder="输入待查小图的 HTTP/HTTPS 地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最低匹配分数">
|
||||
<el-slider v-model="formData.analysisTuning.matchThreshold" :min="0.2" :max="0.95" :step="0.01" show-input />
|
||||
</el-form-item>
|
||||
<el-form-item label="选择匹配结果">
|
||||
<el-select v-model="formData.analysisTuning.matchSelector" style="width: 100%">
|
||||
<el-option v-for="item in matchSelectorOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<template v-if="formData.analysisTuning.matchSelector === 'INDEX'">
|
||||
<el-form-item label="排序方式">
|
||||
<el-segmented v-model="formData.analysisTuning.matchSort" :options="matchSortOptions" block />
|
||||
</el-form-item>
|
||||
<el-form-item label="选择第几个">
|
||||
<el-input-number v-model="formData.analysisTuning.matchIndex" :min="1" :max="20" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<el-collapse v-if="!isReferenceMatch" v-model="advancedSections" class="advanced-settings">
|
||||
<el-collapse-item v-if="isVisionAnalysis" title="参考图片(可选)" name="reference">
|
||||
<el-form-item label="参考图片来源">
|
||||
<el-segmented v-model="formData.referenceMode" :options="sourceOptions" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="formData.referenceMode === 'quote'" label="引用参考图片" prop="referenceQuote">
|
||||
<el-cascader
|
||||
ref="referenceCascaderRef"
|
||||
v-model="formData.referenceQuote"
|
||||
:options="quoteOptions"
|
||||
:props="{ checkStrictly: true }"
|
||||
placeholder="选择参考图片输出"
|
||||
style="width: 100%"
|
||||
clearable
|
||||
@visible-change="loadQuoteOptions"
|
||||
@change="handleReferenceQuoteChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="参考图片地址" prop="referenceImageUrl">
|
||||
<el-input v-model="formData.referenceImageUrl" clearable placeholder="HTTP/HTTPS 图片地址" />
|
||||
</el-form-item>
|
||||
<div class="field-hint">参考图片会作为最后一张图片传入;请在提示词中说明它的用途。</div>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item title="高级设置" name="advanced">
|
||||
<el-form-item label="图片最大宽度">
|
||||
<el-slider v-model="formData.analysisTuning.maxWidth" :min="640" :max="1600" :step="64" show-input />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isVisionAnalysis" label="最多分析图片">
|
||||
<el-input-number v-model="formData.analysisTuning.maxImages" :min="1" :max="12" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大输出长度">
|
||||
<el-input-number v-model="formData.analysisTuning.maxOutputTokens" :min="32" :max="4096" :step="32" />
|
||||
</el-form-item>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-form>
|
||||
|
||||
<el-divider content-position="left">节点输出</el-divider>
|
||||
<div class="output-list">
|
||||
<div v-for="item in outputSummary" :key="item.name" class="output-item">
|
||||
<code>{{ item.name }}</code><span>{{ item.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { getUpstreamInput } from '@/utils/flow'
|
||||
import { IMAGE_ANALYSIS_OUTPUT_PARAMS } from '@/utils/flowResources'
|
||||
|
||||
const props = defineProps({ data: Object })
|
||||
const emit = defineEmits(['save-success', 'save-error'])
|
||||
|
||||
const formRef = ref()
|
||||
const imageCascaderRef = ref()
|
||||
const referenceCascaderRef = ref()
|
||||
const quoteOptions = ref([])
|
||||
const advancedSections = ref([])
|
||||
|
||||
// 这里的 value 会直接写入 nodeParams.type,需与流程参数协议中的 quote/input 保持一致。
|
||||
const sourceOptions = [
|
||||
{ label: '引用节点输出', value: 'quote' },
|
||||
{ label: '输入图片地址', value: 'input' },
|
||||
]
|
||||
const modeOptions = [
|
||||
{ label: '快速', value: 'FAST' },
|
||||
{ label: '精准', value: 'ACCURATE' },
|
||||
]
|
||||
const methodOptions = [
|
||||
{ label: '智能分析', value: 'VISION_MODEL' },
|
||||
{ label: '描述找图', value: 'TEXT_LOCATE' },
|
||||
{ label: '精确找图', value: 'REFERENCE_MATCH' },
|
||||
]
|
||||
const matchSelectorOptions = [
|
||||
{ label: '最佳匹配', value: 'BEST' },
|
||||
{ label: '按序号选择', value: 'INDEX' },
|
||||
{ label: '左上', value: 'TOP_LEFT' },
|
||||
{ label: '右上', value: 'TOP_RIGHT' },
|
||||
{ label: '左下', value: 'BOTTOM_LEFT' },
|
||||
{ label: '右下', value: 'BOTTOM_RIGHT' },
|
||||
]
|
||||
const matchSortOptions = [
|
||||
{ label: '先行后列', value: 'ROW_MAJOR' },
|
||||
{ label: '先列后行', value: 'COLUMN_MAJOR' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 使用稳定的 reactive 对象承载表单状态;initData 只覆盖字段,避免 Element Plus 校验引用失效。
|
||||
*/
|
||||
const formData = reactive({
|
||||
analysisMethod: 'VISION_MODEL',
|
||||
imageMode: 'quote',
|
||||
imageQuote: [],
|
||||
imageQuoteType: 'output',
|
||||
imageUrlsText: '',
|
||||
referenceMode: 'input',
|
||||
referenceQuote: [],
|
||||
referenceQuoteType: 'output',
|
||||
referenceImageUrl: '',
|
||||
prompt: '',
|
||||
analysisMode: 'FAST',
|
||||
asynchronous: false,
|
||||
analysisTuning: {
|
||||
maxWidth: 896,
|
||||
maxImages: 12,
|
||||
maxOutputTokens: 384,
|
||||
matchThreshold: 0.48,
|
||||
matchSelector: 'BEST',
|
||||
matchIndex: 1,
|
||||
matchSort: 'ROW_MAJOR',
|
||||
locateConfidenceThreshold: 0.45,
|
||||
},
|
||||
})
|
||||
|
||||
const isReferenceMatch = computed(() => formData.analysisMethod === 'REFERENCE_MATCH')
|
||||
const isTextLocate = computed(() => formData.analysisMethod === 'TEXT_LOCATE')
|
||||
const isVisionAnalysis = computed(() => formData.analysisMethod === 'VISION_MODEL')
|
||||
const isLocationMethod = computed(() => isReferenceMatch.value || isTextLocate.value)
|
||||
|
||||
const outputSummary = [
|
||||
{ name: 'result', desc: '按提示词返回的结果;JSON、数组和数字会自动解析' },
|
||||
{ name: 'resultText', desc: '模型返回的原始文本' },
|
||||
{ name: 'matched', desc: '找图结果是否达到最低分数' },
|
||||
{ name: 'matchScore', desc: '找图的匹配分数或定位置信度' },
|
||||
{ name: 'coordinates', desc: '大图原始尺寸坐标 [x1,y1,x2,y2]' },
|
||||
{ name: 'center', desc: '目标中心点坐标 [x,y]' },
|
||||
{ name: 'matchedDescription', desc: '模型实际定位的目标描述' },
|
||||
{ name: 'refinementMethod', desc: '坐标精修方式' },
|
||||
{ name: 'matchCount', desc: '找到的匹配项数量' },
|
||||
{ name: 'matches', desc: '全部匹配项及其序号、坐标和分数' },
|
||||
{ name: 'selectedMatchIndex', desc: '当前选择的匹配序号' },
|
||||
]
|
||||
|
||||
// 文本输入允许换行或逗号分隔,保存前统一转成执行端需要的 URL 数组。
|
||||
const parseUrls = value => String(value || '').split(/\r?\n|,/).map(item => item.trim()).filter(Boolean)
|
||||
const validUrl = value => /^https?:\/\//i.test(value)
|
||||
const hasReference = () => formData.referenceMode === 'quote'
|
||||
? formData.referenceQuote.length > 0
|
||||
: Boolean(formData.referenceImageUrl.trim())
|
||||
|
||||
/**
|
||||
* 条件校验只约束当前可见的图片来源,隐藏模式不阻塞保存;直接输入仅接受 HTTP/HTTPS。
|
||||
*/
|
||||
const rules = {
|
||||
imageQuote: [{
|
||||
validator: (_, value) => formData.imageMode !== 'quote' || value?.length
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请选择图片输出')),
|
||||
trigger: 'change',
|
||||
}],
|
||||
imageUrlsText: [{
|
||||
validator: (_, value) => {
|
||||
if (formData.imageMode !== 'input') return Promise.resolve()
|
||||
const urls = parseUrls(value)
|
||||
if (!urls.length) return Promise.reject(new Error('请至少输入一个图片地址'))
|
||||
const maximum = isLocationMethod.value ? 1 : 12
|
||||
if (urls.length > maximum) return Promise.reject(new Error(isLocationMethod.value ? '找图只能输入一张目标图片' : '最多输入 12 张图片'))
|
||||
return urls.every(validUrl)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('图片地址必须是 HTTP/HTTPS 地址'))
|
||||
},
|
||||
trigger: 'blur',
|
||||
}],
|
||||
referenceImageUrl: [{
|
||||
validator: (_, value) => {
|
||||
if (formData.referenceMode !== 'input') return Promise.resolve()
|
||||
if (isReferenceMatch.value && !String(value || '').trim()) return Promise.reject(new Error('请输入待查小图地址'))
|
||||
return !value || validUrl(String(value).trim())
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请输入有效的参考图片地址'))
|
||||
},
|
||||
trigger: 'blur',
|
||||
}],
|
||||
referenceQuote: [{
|
||||
validator: (_, value) => !isReferenceMatch.value || formData.referenceMode !== 'quote' || value?.length
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请选择待查小图输出')),
|
||||
trigger: 'change',
|
||||
}],
|
||||
prompt: [{
|
||||
validator: (_, value) => isReferenceMatch.value || String(value || '').trim()
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请输入分析提示词')),
|
||||
trigger: 'blur',
|
||||
}],
|
||||
}
|
||||
|
||||
// 引用选项按需加载,上游流程较大时避免每次渲染都遍历画布。
|
||||
const loadQuoteOptions = visible => { if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || [] }
|
||||
const handleQuoteChange = () => {
|
||||
formData.imageQuoteType = imageCascaderRef.value?.getCheckedNodes?.(true)?.[0]?.data?.type || 'output'
|
||||
}
|
||||
const handleReferenceQuoteChange = () => {
|
||||
formData.referenceQuoteType = referenceCascaderRef.value?.getCheckedNodes?.(true)?.[0]?.data?.type || 'output'
|
||||
}
|
||||
const getParam = (params, name) => params.find(item => item.name === name)
|
||||
|
||||
/**
|
||||
* 兼容旧版图片识别节点:把 taskType、targetDescription、instruction 合成新的自由提示词。
|
||||
*/
|
||||
const legacyPrompt = (params) => {
|
||||
const target = String(getParam(params, 'targetDescription')?.input || '').trim()
|
||||
const instruction = String(getParam(params, 'instruction')?.input || '').trim()
|
||||
const taskType = String(getParam(params, 'taskType')?.input || '').toUpperCase()
|
||||
const parts = [target || instruction]
|
||||
if (taskType === 'ICON_TEMPLATE_LOCATE') {
|
||||
parts.push('最后一张图片是参考图片,请在其他图片中查找对应目标并返回坐标。')
|
||||
}
|
||||
parts.push('只返回分析结果,不输出通过、未通过或置信度。')
|
||||
return parts.filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 将持久化 nodeParams 回填到表单,并为缺少新字段的旧节点补默认值。
|
||||
*/
|
||||
const initData = () => {
|
||||
loadQuoteOptions(true)
|
||||
const params = props.data?.properties?.nodeParams || []
|
||||
const images = getParam(params, 'imageUrl')
|
||||
const reference = getParam(params, 'referenceImageUrl')
|
||||
const tuning = getParam(params, 'analysisTuning')?.input || {}
|
||||
const asynchronous = getParam(params, 'asynchronous')
|
||||
formData.imageMode = images?.type === 'input' ? 'input' : 'quote'
|
||||
formData.imageQuote = Array.isArray(images?.quote) ? [...images.quote] : []
|
||||
formData.imageQuoteType = images?.quoteType || 'output'
|
||||
const staticImages = Array.isArray(images?.input) ? [...images.input] : parseUrls(images?.input || '')
|
||||
const savedMethod = String(getParam(params, 'analysisMethod')?.input || '').toUpperCase()
|
||||
const savedPrompt = getParam(params, 'prompt')?.input || legacyPrompt(params)
|
||||
const coordinatePrompt = /(坐标|位置|小图|参考图|图标|coordinate|position|bbox|template)/i.test(savedPrompt)
|
||||
const legacyReferenceMatch = !savedMethod && coordinatePrompt
|
||||
&& ((reference && staticImages.length === 1) || (!reference && staticImages.length === 2))
|
||||
const legacyTextLocate = !savedMethod && coordinatePrompt && !reference && staticImages.length === 1
|
||||
formData.analysisMethod = savedMethod === 'REFERENCE_MATCH' || legacyReferenceMatch
|
||||
? 'REFERENCE_MATCH'
|
||||
: (savedMethod === 'TEXT_LOCATE' || legacyTextLocate ? 'TEXT_LOCATE' : 'VISION_MODEL')
|
||||
formData.imageUrlsText = (legacyReferenceMatch ? staticImages.slice(0, 1) : staticImages).join('\n')
|
||||
formData.referenceMode = reference?.type === 'quote' ? 'quote' : 'input'
|
||||
formData.referenceQuote = Array.isArray(reference?.quote) ? [...reference.quote] : []
|
||||
formData.referenceQuoteType = reference?.quoteType || 'output'
|
||||
formData.referenceImageUrl = reference?.input || (legacyReferenceMatch ? staticImages[1] : '')
|
||||
formData.prompt = savedPrompt
|
||||
const savedMode = String(getParam(params, 'analysisMode')?.input || '').toUpperCase()
|
||||
formData.analysisMode = savedMode === 'ACCURATE' ? 'ACCURATE' : 'FAST'
|
||||
formData.asynchronous = asynchronous?.input === true || asynchronous?.input === 'true'
|
||||
formData.analysisTuning.maxWidth = Number(tuning.maxWidth) || (formData.analysisMode === 'FAST' ? 896 : 1280)
|
||||
formData.analysisTuning.maxImages = Number(tuning.maxImages) || 12
|
||||
formData.analysisTuning.maxOutputTokens = Number(tuning.maxOutputTokens)
|
||||
|| (formData.analysisMode === 'FAST' ? 384 : 512)
|
||||
formData.analysisTuning.matchThreshold = Number(tuning.matchThreshold) || 0.48
|
||||
formData.analysisTuning.matchSelector = matchSelectorOptions.some(item => item.value === tuning.matchSelector)
|
||||
? tuning.matchSelector
|
||||
: 'BEST'
|
||||
formData.analysisTuning.matchIndex = Math.max(1, Math.min(20, Number(tuning.matchIndex) || 1))
|
||||
formData.analysisTuning.matchSort = tuning.matchSort === 'COLUMN_MAJOR' ? 'COLUMN_MAJOR' : 'ROW_MAJOR'
|
||||
formData.analysisTuning.locateConfidenceThreshold = Number(tuning.locateConfidenceThreshold) || 0.45
|
||||
advancedSections.value = isVisionAnalysis.value && hasReference() ? ['reference'] : []
|
||||
}
|
||||
watch(() => props.data, initData, { immediate: true, deep: true })
|
||||
|
||||
/**
|
||||
* 媒体参数统一保存为 quote/input 二选一结构;未使用的一侧清空,避免执行端误读。
|
||||
*/
|
||||
const buildMediaParam = (name, mode, input, quote, quoteType, required = true) => ({
|
||||
name,
|
||||
type: mode,
|
||||
input: mode === 'input' ? input : '',
|
||||
quote: mode === 'quote' ? [...quote] : '',
|
||||
quoteType,
|
||||
required,
|
||||
disabled: true,
|
||||
})
|
||||
|
||||
/**
|
||||
* 校验通过后重建 IMAGE_ANALYZE 节点属性,确保 action、类型和输出契约同步升级。
|
||||
*/
|
||||
const validateAndSave = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
const nodeParams = [
|
||||
{ name: 'profileCode', type: 'input', input: 'common.image_analysis.v1', required: true, disabled: true },
|
||||
buildMediaParam('imageUrl', formData.imageMode, parseUrls(formData.imageUrlsText), formData.imageQuote, formData.imageQuoteType),
|
||||
{ name: 'analysisMethod', type: 'input', input: formData.analysisMethod, required: false, disabled: true },
|
||||
{ name: 'prompt', type: 'input', input: isReferenceMatch.value ? '在大图中查找小图,返回原图像素坐标 [x1,y1,x2,y2]' : formData.prompt.trim(), required: true, disabled: true, componentType: 'textarea' },
|
||||
{ name: 'analysisMode', type: 'input', input: formData.analysisMode, required: false, disabled: true, componentType: 'select', selectOptions: modeOptions },
|
||||
{ name: 'analysisTuning', type: 'input', input: { ...formData.analysisTuning }, required: false, disabled: true },
|
||||
{ name: 'asynchronous', type: 'input', input: formData.asynchronous, required: false, disabled: true, componentType: 'switch' },
|
||||
]
|
||||
if (!isTextLocate.value && hasReference()) {
|
||||
// 参考图存在时插入到图片来源和提示词之间,保持执行端按固定顺序读取。
|
||||
nodeParams.splice(3, 0, buildMediaParam(
|
||||
'referenceImageUrl', formData.referenceMode, formData.referenceImageUrl.trim(),
|
||||
formData.referenceQuote, formData.referenceQuoteType, false,
|
||||
))
|
||||
}
|
||||
const properties = {
|
||||
...props.data.properties,
|
||||
name: '通用图片分析',
|
||||
action: 'IMAGE_ANALYZE',
|
||||
nodeType: 'LLM',
|
||||
nodeParams,
|
||||
outputType: 'json',
|
||||
outputParams: JSON.parse(JSON.stringify(IMAGE_ANALYSIS_OUTPUT_PARAMS)),
|
||||
}
|
||||
// 图片分析由 LLM 服务执行,不绑定机器人或设备资源。
|
||||
delete properties.executionTarget
|
||||
lf.setProperties(props.data.id, properties)
|
||||
emit('save-success')
|
||||
} catch {
|
||||
emit('save-error')
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ validateAndSave })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.image-analysis-params { padding-top: 16px; }
|
||||
.intro { margin-bottom: 22px; padding: 14px 16px; border-left: 3px solid var(--el-color-primary); background: var(--el-fill-color-light); }
|
||||
.intro__title { color: var(--el-text-color-primary); font-weight: 650; }
|
||||
.intro__desc, .field-hint { margin-top: 5px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 18px; }
|
||||
.advanced-settings { margin-bottom: 18px; }
|
||||
.output-list { display: grid; gap: 8px; }
|
||||
.output-item { display: flex; align-items: baseline; gap: 12px; color: var(--el-text-color-regular); font-size: 13px; }
|
||||
.output-item code { min-width: 92px; color: var(--el-color-primary); }
|
||||
</style>
|
||||
@ -1,567 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:媒体分析节点的参数编辑器,同时服务于“声音类型检测”和“视频智能分析”节点。
|
||||
* 数据流向:从 props.data.properties.nodeParams 回填表单,校验通过后再组装为标准 nodeParams
|
||||
* 并写回 LogicFlow 节点属性;本组件不直接调用媒体分析接口。
|
||||
* 作用范围:仅服务于流程设计器参数抽屉,由 ParamsDrawer 调用 validateAndSave 完成保存。
|
||||
-->
|
||||
<template>
|
||||
<div class="media-analysis-params">
|
||||
<!-- 标题和说明由节点 action 决定,同一组件在音频、视频节点下呈现不同业务语义。 -->
|
||||
<div class="media-analysis-intro">
|
||||
<div class="media-analysis-intro__title">{{ title }}</div>
|
||||
<div class="media-analysis-intro__desc">{{ description }}</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-position="top">
|
||||
<!--
|
||||
profileCode 是后端选择分析配置和模型策略的场景编码。
|
||||
allow-create 用于兼容后端临时新增、前端尚未内置的场景,不把可用值限制在下拉选项内。
|
||||
-->
|
||||
<el-form-item label="分析场景" prop="profileCode">
|
||||
<el-select
|
||||
v-model="formData.profileCode"
|
||||
filterable
|
||||
allow-create
|
||||
default-first-option
|
||||
placeholder="选择或输入场景编码"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in profileOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="field-hint">场景决定识别标签、判断阈值和分析要求。</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 音频检测需要一个期望声音;视频分析改由 instruction 描述通过标准,因此不显示该字段。 -->
|
||||
<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-segmented v-model="formData.mediaMode" :options="mediaModes" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 引用模式保存级联路径和输出类型;打开下拉时才查询上游节点,避免每次渲染都遍历流程图。 -->
|
||||
<el-form-item v-if="formData.mediaMode === 'quote'" label="引用节点输出" prop="mediaQuote">
|
||||
<el-cascader
|
||||
ref="mediaCascaderRef"
|
||||
v-model="formData.mediaQuote"
|
||||
:options="quoteOptions"
|
||||
:props="{ checkStrictly: true }"
|
||||
placeholder="选择上游录音或录像输出"
|
||||
style="width: 100%"
|
||||
@visible-change="loadQuoteOptions"
|
||||
@change="handleQuoteChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 直接输入模式只接受 HTTP/HTTPS 地址,具体格式在 rules.mediaUrl 中按当前模式条件校验。 -->
|
||||
<el-form-item v-else :label="`${mediaLabel}地址`" prop="mediaUrl">
|
||||
<el-input v-model="formData.mediaUrl" type="textarea" :rows="3" placeholder="请输入 HTTP/HTTPS 媒体地址" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="异步执行">
|
||||
<el-switch v-model="formData.asynchronous" />
|
||||
<div class="field-hint">开启后分析在后台并行执行,流程结束前统一等待分析结果。</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 视频模型依赖自然语言标准判断是否通过;音频节点使用 expectedLabel,不需要 instruction。 -->
|
||||
<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="例如:车辆启动后,仪表必须在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 @change="handleAnalysisModeChange" />
|
||||
<div class="field-hint">{{ modeDescription }}</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="isVideo" label="输出内容">
|
||||
<!-- detailedOutput 只影响视频节点:默认精简,需要事件证据和时间范围时再让后端返回完整结果。 -->
|
||||
<el-switch
|
||||
v-model="formData.detailedOutput"
|
||||
inline-prompt
|
||||
active-text="详细"
|
||||
inactive-text="精简"
|
||||
/>
|
||||
<div class="field-hint">精简结果响应更快;需要逐步事件和时间范围时再启用详细输出。</div>
|
||||
</el-form-item>
|
||||
|
||||
<!--
|
||||
高级参数只对视频抽帧分析生效。默认折叠以降低普通用户的配置负担,isTuned 用于提示
|
||||
当前值已经偏离模式预设;“恢复默认”只重置当前模式的参数,不切换分析模式。
|
||||
-->
|
||||
<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.sampleFps" :min="0.25" :max="6" :step="0.25" 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>
|
||||
|
||||
<!-- 输出字段只作契约说明,实际 outputParams 由节点配置定义,不能在这里编辑。 -->
|
||||
<el-divider content-position="left">节点输出</el-divider>
|
||||
<div class="output-list">
|
||||
<div v-for="item in outputSummary" :key="item.name" class="output-item">
|
||||
<code>{{ item.name }}</code>
|
||||
<span>{{ item.desc }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { Refresh } from '@element-plus/icons-vue'
|
||||
import { getUpstreamInput } from '@/utils/flow'
|
||||
|
||||
/**
|
||||
* data 是流程画布当前节点模型,核心字段包括 id、properties.action 和 properties.nodeParams。
|
||||
* 保存成功/失败事件由 ParamsDrawer 接收,用于更新节点错误状态并关闭参数抽屉。
|
||||
*/
|
||||
const props = defineProps({ data: Object })
|
||||
const emit = defineEmits(['save-success', 'save-error'])
|
||||
|
||||
// formRef 触发表单校验;mediaCascaderRef 用于读取级联选择器最终选中节点的元数据。
|
||||
const formRef = ref()
|
||||
const mediaCascaderRef = ref()
|
||||
|
||||
// 上游节点输出在级联选择器展开时按需加载;高级设置默认保持折叠。
|
||||
const quoteOptions = ref([])
|
||||
const advancedSections = ref([])
|
||||
|
||||
/**
|
||||
* 三种视频分析模式的推荐参数:
|
||||
* maxFrames 控制抽取帧数,maxWidth 控制送入模型前的缩放宽度,confidenceThreshold 是通过阈值;
|
||||
* fallbackToAccurate 仅 AUTO 模式有意义,表示快速分析置信度不足时是否升级为精确分析。
|
||||
*/
|
||||
const analysisPresets = {
|
||||
FAST: { sampleFps: 1, maxWidth: 896, confidenceThreshold: 0.7, fallbackToAccurate: false },
|
||||
AUTO: { sampleFps: 2, maxWidth: 960, confidenceThreshold: 0.75, fallbackToAccurate: true },
|
||||
ACCURATE: { sampleFps: 3, maxWidth: 1120, confidenceThreshold: 0.8, fallbackToAccurate: false },
|
||||
}
|
||||
/**
|
||||
* 表单使用一个稳定的 reactive 对象,initData 只更新字段而不替换对象,保证 Element Plus
|
||||
* 的表单项、校验状态和 v-model 引用始终指向同一个实例。
|
||||
*/
|
||||
const formData = reactive({
|
||||
profileCode: '',
|
||||
expectedLabel: '',
|
||||
expectedLabelName: '',
|
||||
mediaMode: 'quote',
|
||||
mediaQuote: [],
|
||||
mediaQuoteType: 'output',
|
||||
mediaUrl: '',
|
||||
instruction: '',
|
||||
analysisMode: 'AUTO',
|
||||
detailedOutput: false,
|
||||
asynchronous: false,
|
||||
analysisTuning: { ...analysisPresets.AUTO },
|
||||
})
|
||||
|
||||
// action 是区分音频节点和视频节点的唯一业务判别字段,后续文案、参数名和校验都由它派生。
|
||||
const isVideo = computed(() => props.data?.properties?.action === 'VIDEO_ANALYZE')
|
||||
|
||||
// 以下计算属性统一收敛音频/视频差异,模板不需要重复判断具体 action 字符串。
|
||||
const title = computed(() => isVideo.value ? '视频智能分析' : '声音类型检测')
|
||||
const description = computed(() => isVideo.value
|
||||
? '对录像抽帧并按所选场景生成结构化分析结果。'
|
||||
: '判断录音是否属于指定的目标声音类型,并输出通过或不通过。')
|
||||
const mediaLabel = computed(() => isVideo.value ? '视频来源' : '音频来源')
|
||||
|
||||
// nodeParams 中的媒体参数名属于执行协议:视频使用 videoUrl,音频使用 audioUrl。
|
||||
const mediaName = computed(() => isVideo.value ? 'videoUrl' : 'audioUrl')
|
||||
|
||||
// mediaMode 的值会直接写入媒体参数 type,因此必须与流程参数协议中的 quote/input 保持一致。
|
||||
const mediaModes = [
|
||||
{ label: '引用节点输出', value: 'quote' },
|
||||
{ label: '直接输入地址', value: 'input' },
|
||||
]
|
||||
const analysisModes = [
|
||||
{ label: '快速', value: 'FAST' },
|
||||
{ label: '均衡', value: 'AUTO' },
|
||||
{ label: '精确', value: 'ACCURATE' },
|
||||
]
|
||||
// 声音类型同时保存机器可读编码和中文名称;allow-create 允许现场扩展未预置类型。
|
||||
const soundTypeOptions = [
|
||||
{ label: '开机', value: 'POWER_ON' },
|
||||
{ label: '关机', value: 'POWER_OFF' },
|
||||
{ label: '设防', value: 'ARMED' },
|
||||
{ label: '解防', value: 'DISARMED' },
|
||||
{ label: '鸣笛寻车', value: 'FIND_VEHICLE_HORN' },
|
||||
]
|
||||
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])
|
||||
})
|
||||
// 不同媒体类型使用不同场景编码,默认值也从这里取得,避免音视频节点互相沿用 profileCode。
|
||||
const profileOptions = computed(() => isVideo.value
|
||||
? [{ label: '爱玛测试视频分析', value: 'aima.power_video.v1' }]
|
||||
: [{ label: '爱玛车辆声音类型检测', value: 'aima.power_state.v1' }])
|
||||
/**
|
||||
* 输出摘要与运行时返回对象保持一致,仅负责向流程设计用户解释后续节点可引用的字段。
|
||||
* 音频结果强调目标声音与实际声音的比较;视频结果强调证据、结论和实际分析模式。
|
||||
*/
|
||||
const outputSummary = computed(() => isVideo.value
|
||||
? [
|
||||
{ name: 'result', desc: '分析结果' },
|
||||
{ name: 'passed', desc: '是否通过' },
|
||||
{ name: 'confidence', desc: '判定置信度' },
|
||||
{ name: 'evidenceSufficient', desc: '证据是否充分' },
|
||||
{ name: 'conclusion', desc: '分析结论' },
|
||||
{ name: 'summary', desc: '分析摘要' },
|
||||
{ name: 'events', desc: '关键事件列表' },
|
||||
{ name: 'analysisMode', desc: '实际使用的分析模式' },
|
||||
{ name: 'fallback', desc: '是否切换到精确分析' },
|
||||
{ name: 'detailedOutput', desc: '是否返回详细事件证据' },
|
||||
]
|
||||
: [
|
||||
{ name: 'passed', desc: '是否检测到目标声音' },
|
||||
{ name: 'expectedLabelName', desc: '目标声音类型' },
|
||||
{ name: 'labelName', desc: '实际声音类型' },
|
||||
{ name: 'matched', desc: '是否识别到已知声音' },
|
||||
{ name: 'confidence', desc: '最高匹配度' },
|
||||
{ name: 'decisionReason', desc: '检测结论' },
|
||||
{ name: 'scores', desc: '各分类匹配度' },
|
||||
])
|
||||
|
||||
/**
|
||||
* 校验规则包含多个条件字段:
|
||||
* - expectedLabel 只在音频节点必填;instruction 只在视频节点必填;
|
||||
* - mediaQuote/mediaUrl 根据 mediaMode 二选一校验,隐藏字段不会阻塞保存;
|
||||
* - 直接地址限制为 HTTP/HTTPS,避免执行端收到本地路径或无法识别的协议。
|
||||
*/
|
||||
const rules = {
|
||||
profileCode: [{ required: true, message: '请选择或输入分析场景', trigger: 'change' }],
|
||||
expectedLabel: [{
|
||||
validator: (_, value) => isVideo.value || String(value || '').trim()
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请选择目标声音类型')),
|
||||
trigger: 'change',
|
||||
}],
|
||||
mediaQuote: [{
|
||||
validator: (_, value) => formData.mediaMode !== 'quote' || (Array.isArray(value) && value.length)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请选择媒体输出')),
|
||||
trigger: 'change',
|
||||
}],
|
||||
mediaUrl: [{
|
||||
validator: (_, value) => formData.mediaMode !== 'input' || /^https?:\/\//i.test(String(value || '').trim())
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请输入有效的 HTTP/HTTPS 地址')),
|
||||
trigger: 'blur',
|
||||
}],
|
||||
instruction: [{
|
||||
validator: (_, value) => !isVideo.value || String(value || '').trim()
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error('请输入明确的通过标准')),
|
||||
trigger: 'blur',
|
||||
}],
|
||||
}
|
||||
|
||||
// 使用 Object.assign 保留 analysisTuning 的响应式引用,只覆盖当前模式定义的预设字段。
|
||||
const restorePreset = () => {
|
||||
Object.assign(formData.analysisTuning, analysisPresets[formData.analysisMode] || analysisPresets.AUTO)
|
||||
}
|
||||
|
||||
// 模式切换后直接采用新模式预设,用户仍可在高级设置中继续微调。
|
||||
const handleAnalysisModeChange = () => restorePreset()
|
||||
|
||||
// 同时维护编码和展示名称;用户创建自定义类型时,输入值本身作为可读名称兜底。
|
||||
const handleExpectedLabelChange = (value) => {
|
||||
const option = soundTypeOptions.find(item => item.value === value)
|
||||
formData.expectedLabelName = option?.label || String(value || '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* analysisTuning 的历史数据可能是对象或 JSON 字符串。这里统一解析为普通对象;
|
||||
* 非法 JSON 返回空对象,后续 normalizeStoredTuning 会用模式预设补齐全部字段。
|
||||
*/
|
||||
const parseStoredTuning = (value) => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) return value
|
||||
if (typeof value !== 'string' || !value.trim()) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// 将旧配置或外部写入值限制在当前 UI 支持范围内,NaN 等非法值回退到模式默认值。
|
||||
const clamp = (value, minimum, maximum, fallback) => {
|
||||
const number = Number(value)
|
||||
return Number.isFinite(number) ? Math.min(maximum, Math.max(minimum, number)) : fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准化持久化的高级参数,保证滑块始终接收到合法数字,并兼容缺少新字段的旧流程数据。
|
||||
* 帧数和宽度必须为整数;fallbackToAccurate 只接受真正的布尔值,不把字符串误判为 true。
|
||||
*/
|
||||
const normalizeStoredTuning = (value, preset) => {
|
||||
const stored = parseStoredTuning(value)
|
||||
return {
|
||||
sampleFps: Math.round(clamp(stored.sampleFps, 0.25, 6, preset.sampleFps) * 100) / 100,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// getInput 根据当前节点 ID 返回可引用的上游输出,只在级联面板打开时刷新以减少无效计算。
|
||||
const loadQuoteOptions = (visible) => {
|
||||
if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 级联路径只记录选中位置,quoteType 需要从叶子节点元数据单独取得;
|
||||
* 读取不到元数据时回退为 output,兼容旧版上游输出选项。
|
||||
*/
|
||||
const handleQuoteChange = () => {
|
||||
const checked = mediaCascaderRef.value?.getCheckedNodes?.(true)?.[0]
|
||||
formData.mediaQuoteType = checked?.data?.type || 'output'
|
||||
}
|
||||
|
||||
/**
|
||||
* 将节点已保存的 nodeParams 回填到表单。
|
||||
*
|
||||
* nodeParams 是按 name 存储的数组而不是对象,因此先逐项定位;缺失字段使用当前节点类型的默认值。
|
||||
* 媒体参数的 type 决定“引用/输入”模式,quote、quoteType 和 input 分别恢复对应控件。
|
||||
* 旧版音频节点可能只有 expectedLabel 没有 expectedLabelName,此时通过选项表补齐中文名称。
|
||||
* 视频高级配置经过 normalizeStoredTuning 清洗,非 AUTO 模式强制关闭仅 AUTO 支持的精确分析回退。
|
||||
*/
|
||||
const initData = () => {
|
||||
loadQuoteOptions(true)
|
||||
const params = props.data?.properties?.nodeParams || []
|
||||
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 instruction = params.find(item => item.name === 'instruction')
|
||||
const analysisMode = params.find(item => item.name === 'analysisMode')
|
||||
const detailedOutput = params.find(item => item.name === 'detailedOutput')
|
||||
const analysisTuning = params.find(item => item.name === 'analysisTuning')
|
||||
const asynchronous = params.find(item => item.name === 'asynchronous')
|
||||
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.mediaQuote = Array.isArray(media?.quote) ? [...media.quote] : []
|
||||
formData.mediaQuoteType = media?.quoteType || 'output'
|
||||
formData.mediaUrl = media?.input || ''
|
||||
formData.instruction = instruction?.input || ''
|
||||
formData.analysisMode = analysisPresets[analysisMode?.input] ? analysisMode.input : 'AUTO'
|
||||
// 兼容旧流程或外部写入的字符串值,表单内部统一使用布尔值驱动开关。
|
||||
formData.detailedOutput = detailedOutput?.input === true || detailedOutput?.input === 'true'
|
||||
formData.asynchronous = asynchronous?.input === true || asynchronous?.input === 'true'
|
||||
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 })
|
||||
|
||||
/**
|
||||
* 校验并保存当前媒体分析节点。
|
||||
*
|
||||
* 保存过程重新构造 nodeParams,而不是修改原数组,避免残留另一种媒体类型的专属字段:
|
||||
* 1. 所有节点保存 profileCode 和当前媒体参数;
|
||||
* 2. 音频节点额外保存 expectedLabel/expectedLabelName;
|
||||
* 3. 视频节点额外保存 instruction、analysisMode 和 analysisTuning;
|
||||
* 4. 删除旧的 executionTarget 后,通过 LogicFlow 全局实例 lf 一次性写回 properties。
|
||||
*
|
||||
* ParamsDrawer 只依赖 save-success/save-error,不需要了解 Element Plus 的校验异常细节。
|
||||
*/
|
||||
const validateAndSave = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
// 公共参数始终位于数组前部,保持与 config.js 中节点初始定义一致,便于调试和序列化比较。
|
||||
const nodeParams = [
|
||||
{
|
||||
name: 'profileCode',
|
||||
type: 'input',
|
||||
input: formData.profileCode.trim(),
|
||||
required: true,
|
||||
disabled: true,
|
||||
componentType: 'select',
|
||||
selectOptions: profileOptions.value,
|
||||
},
|
||||
{
|
||||
name: mediaName.value,
|
||||
type: formData.mediaMode,
|
||||
input: formData.mediaMode === 'input' ? formData.mediaUrl.trim() : '',
|
||||
quote: formData.mediaMode === 'quote' ? [...formData.mediaQuote] : '',
|
||||
quoteType: formData.mediaQuoteType,
|
||||
required: true,
|
||||
disabled: true,
|
||||
},
|
||||
]
|
||||
nodeParams.push({
|
||||
name: 'asynchronous',
|
||||
type: 'input',
|
||||
input: formData.asynchronous,
|
||||
required: false,
|
||||
disabled: true,
|
||||
componentType: 'switch',
|
||||
})
|
||||
// 音频节点把目标类型插入 profileCode 与媒体参数之间,不保存任何视频专属参数。
|
||||
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,
|
||||
},
|
||||
)
|
||||
}
|
||||
// 视频节点保留自然语言标准和分析策略;高级参数复制为普通对象,避免泄漏 Vue 响应式代理。
|
||||
if (isVideo.value) {
|
||||
nodeParams.push({
|
||||
name: 'instruction',
|
||||
type: 'input',
|
||||
input: formData.instruction.trim(),
|
||||
required: true,
|
||||
disabled: true,
|
||||
})
|
||||
nodeParams.push({
|
||||
name: 'analysisMode',
|
||||
type: 'input',
|
||||
input: formData.analysisMode,
|
||||
required: false,
|
||||
disabled: true,
|
||||
componentType: 'select',
|
||||
selectOptions: analysisModes,
|
||||
})
|
||||
// 该开关控制后端是否返回 events 等较重字段,默认 false 可降低单节点执行响应成本。
|
||||
nodeParams.push({
|
||||
name: 'detailedOutput',
|
||||
type: 'input',
|
||||
input: formData.detailedOutput,
|
||||
required: false,
|
||||
disabled: true,
|
||||
componentType: 'switch',
|
||||
})
|
||||
nodeParams.push({
|
||||
name: 'analysisTuning',
|
||||
type: 'input',
|
||||
input: { ...formData.analysisTuning },
|
||||
required: false,
|
||||
disabled: true,
|
||||
})
|
||||
}
|
||||
// 保留节点名称、action 等其他属性,仅替换本组件负责的 nodeParams。
|
||||
const properties = { ...props.data.properties, nodeParams }
|
||||
|
||||
// executionTarget 属于旧执行模型字段,媒体分析节点统一由流程运行时调度,保存时主动移除。
|
||||
delete properties.executionTarget
|
||||
|
||||
// lf 由流程设计器页面提供为全局 LogicFlow 实例;setProperties 会触发画布节点属性更新。
|
||||
lf.setProperties(props.data.id, properties)
|
||||
emit('save-success')
|
||||
} catch (error) {
|
||||
emit('save-error')
|
||||
}
|
||||
}
|
||||
|
||||
// ParamsDrawer 在关闭前通过组件 ref 调用该方法,因此必须显式暴露。
|
||||
defineExpose({ validateAndSave })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.media-analysis-params { padding-top: 16px; }
|
||||
.media-analysis-intro {
|
||||
margin-bottom: 22px;
|
||||
padding: 14px 16px;
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
.media-analysis-intro__title { color: var(--el-text-color-primary); font-weight: 650; }
|
||||
.media-analysis-intro__desc, .field-hint { margin-top: 5px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 18px; }
|
||||
.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>
|
||||
@ -1,387 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:识别节点参数面板,配置识别条件和告警规则。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<el-form :inline="true" :model="RecognizeNodeData" :rules="rules" ref="dynamicFormRef" class="http-form"
|
||||
label-position="top" label-width="auto">
|
||||
<el-collapse v-model="activeNames">
|
||||
<el-collapse-item name="1" title="识别配置" icon-position="left">
|
||||
<div v-for="(property, index) in RecognizeNodeData.nodeParams" class="form__container" :key="index">
|
||||
<el-row>
|
||||
<el-form-item :label="index === 0 ? '参数名' : ''" :prop="`nodeParams.${index}.name`"
|
||||
:rules="[{ required: true, message: '请输入参数名', trigger: 'blur' }]">
|
||||
<el-input :disabled="property?.disabled || false" class="param-name" v-model="property.name"
|
||||
placeholder="请输入" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`">
|
||||
<el-select v-model="property.type" class="param-type"
|
||||
@change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
<el-form-item v-if="property.type === 'input'" :rules="{ required: true, message: '请输入参数值', trigger: 'blur' }"
|
||||
:prop="`nodeParams.${index}.input`">
|
||||
<el-input-number class="param-value" v-if="property.componentType === 'number'"
|
||||
v-model="property.input" :min="0" :max="property.max || Infinity"
|
||||
:controls="property?.controls || true" :step-strictly="true"
|
||||
:step="property.step || 1" placeholder="请输入" clearable />
|
||||
<el-select v-model="property.input" class="param-value"
|
||||
v-else-if="property.componentType === 'select'">
|
||||
<el-option v-for="item in property.selectOptions" :key="item.value"
|
||||
:label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-input class="param-value" v-else v-model="property.input" placeholder="请输入"
|
||||
clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="property.type === 'quote'"
|
||||
:rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]"
|
||||
:prop="`nodeParams.${index}.quote`">
|
||||
<el-cascader :ref="(el) => { if (el) cascaderRefs[index] = el }"
|
||||
v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions"
|
||||
placeholder="请选择"
|
||||
@visible-change="(visible) => visibleChange(visible, index, property.quote)"
|
||||
@change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item name="2" title="告警级别" icon-position="left">
|
||||
<template #title>
|
||||
告警级别
|
||||
<el-button :circle="true" size="small" @click="addAlarmRule" class="addFormItem">
|
||||
<el-icon :size="14"><Plus /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="form__container alarm-rules">
|
||||
<el-row v-for="(rule, index) in RecognizeNodeData.alarmRules" :key="index">
|
||||
<el-form-item label="告警级别" :prop="`alarmRules.${index}.level`"
|
||||
:rules="[{ required: true, message: '请输入告警级别', trigger: 'change' }]">
|
||||
<el-input-number v-model="rule.level" :min="1" :max="3" :step="1"
|
||||
:step-strictly="true" />
|
||||
</el-form-item>
|
||||
<el-form-item label="比较符" :prop="`alarmRules.${index}.operator`"
|
||||
:rules="[{ required: true, message: '请选择比较符', trigger: 'change' }]">
|
||||
<el-select v-model="rule.operator" placeholder="请选择">
|
||||
<el-option v-for="operator in alarmOperators" :key="operator.value"
|
||||
:label="operator.label" :value="operator.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="阈值" :prop="`alarmRules.${index}.threshold`"
|
||||
:rules="[{ required: true, message: '请输入阈值', trigger: 'change' }]">
|
||||
<el-input-number v-model="rule.threshold" :controls="false" />
|
||||
</el-form-item>
|
||||
<el-form-item label="告警信息" :prop="`alarmRules.${index}.message`"
|
||||
:rules="[{ required: true, message: '请输入告警信息', trigger: 'blur' }]">
|
||||
<el-input v-model="rule.message" placeholder="请输入告警信息" clearable />
|
||||
</el-form-item>
|
||||
<el-button type="danger" link class="deleteBtn" @click="deleteAlarmRule(index)">
|
||||
<el-icon><Minus /></el-icon>
|
||||
</el-button>
|
||||
</el-row>
|
||||
<el-empty v-if="!RecognizeNodeData.alarmRules.length" description="暂无告警规则" :image-size="60" />
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item name="3" icon-position="left">
|
||||
<template #title>
|
||||
输出参数
|
||||
<el-tooltip class="box-item" effect="dark" content="这里定义的输出变量可以被后续节点引用。" placement="top">
|
||||
<el-icon class="header-icon" style="margin-left: 6px">
|
||||
<info-filled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<el-button :circle="true" size="small" @click="(e) => addRecognizeItem(e)" class="addFormItem">
|
||||
<el-icon :size="14">
|
||||
<Plus />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="form__container">
|
||||
<el-form :inline="true" :model="RecognizeNodeData" label-position="top" label-width="auto"
|
||||
:rules="outputRules" ref="outputFormRef">
|
||||
<FormItemRecursive formType="output" :current-list="RecognizeNodeData.outputParams"
|
||||
prop-path="outputParams" :depth="0" :is-first-level="true" :endDepth="2" :parent-path="[]"
|
||||
@delete-item="(path) => deleteRecognizeItem(path, 'outputParams')" />
|
||||
</el-form>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, nextTick } from 'vue'
|
||||
import { Plus, Minus } from '@element-plus/icons-vue'
|
||||
import FormItemRecursive from '../FormItemRecursive.vue'
|
||||
import { transformRecognizeNodeData } from '@/utils/flow'
|
||||
import { useQuote } from './useQuote.js'
|
||||
|
||||
const props = defineProps({
|
||||
data: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save-success', 'save-error'])
|
||||
|
||||
const formData = reactive({
|
||||
nodeParams: [],
|
||||
outputParams: []
|
||||
})
|
||||
|
||||
const rules = reactive({})
|
||||
const outputRules = reactive({})
|
||||
const dynamicFormRef = ref()
|
||||
const outputFormRef = ref()
|
||||
const activeNames = ref(['1', '2'])
|
||||
|
||||
// 使用共享的 quote 逻辑
|
||||
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
|
||||
|
||||
// 添加表单项
|
||||
/**
|
||||
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} e 触发操作的浏览器事件
|
||||
* @param {*} type 目标类型
|
||||
*/
|
||||
const addFormItem = (e, type) => {
|
||||
e.stopPropagation()
|
||||
const obj = {
|
||||
name: '',
|
||||
type: type === 'nodeParams' ? 'input' : 'string',
|
||||
required: false,
|
||||
children: [],
|
||||
input: ''
|
||||
}
|
||||
if (!formData[type]) formData[type] = []
|
||||
formData[type].push(obj)
|
||||
}
|
||||
|
||||
// 商道智能体
|
||||
const RecognizeNodeData = ref({})
|
||||
|
||||
const alarmOperators = [
|
||||
{ label: '大于(GT)', value: 'GT' },
|
||||
{ label: '大于等于(GTE)', value: 'GTE' },
|
||||
{ label: '小于(LT)', value: 'LT' },
|
||||
{ label: '小于等于(LTE)', value: 'LTE' },
|
||||
{ label: '等于(EQ)', value: 'EQ' },
|
||||
{ label: '不等于(NE)', value: 'NE' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 新增 addAlarmRule 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} e 触发操作的浏览器事件
|
||||
*/
|
||||
const addAlarmRule = (e) => {
|
||||
e.stopPropagation()
|
||||
RecognizeNodeData.value.alarmRules.push({
|
||||
level: 1,
|
||||
operator: 'GTE',
|
||||
threshold: 0,
|
||||
message: ''
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 deleteAlarmRule 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} index 目标项索引
|
||||
*/
|
||||
const deleteAlarmRule = (index) => {
|
||||
RecognizeNodeData.value.alarmRules.splice(index, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增 addRecognizeItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} e 触发操作的浏览器事件
|
||||
*/
|
||||
const addRecognizeItem = (e) => {
|
||||
e.stopPropagation();
|
||||
RecognizeNodeData.value.outputParams.push({ name: "", type: "string", input: "" });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 deleteRecognizeItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} fullPath 字段完整路径
|
||||
* @param {*} propPath 字段校验路径
|
||||
*/
|
||||
const deleteRecognizeItem = (fullPath, propPath) => {
|
||||
// 从顶层数据开始查找
|
||||
let currentLevel = RecognizeNodeData.value[propPath];
|
||||
|
||||
// 遍历路径(除最后一个索引,因为最后一个是要删除的项)
|
||||
for (let i = 0; i < fullPath.length - 1; i++) {
|
||||
const index = fullPath[i];
|
||||
// 进入下一层级
|
||||
currentLevel = currentLevel[index].children;
|
||||
}
|
||||
|
||||
// 最后一个索引是当前层级要删除的项
|
||||
const lastIndex = fullPath[fullPath.length - 1];
|
||||
currentLevel.splice(lastIndex, 1);
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
/**
|
||||
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const initData = () => {
|
||||
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
|
||||
const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || []))
|
||||
const transformedData = transformRecognizeNodeData(nodeParams);
|
||||
RecognizeNodeData.value = {
|
||||
...transformedData,
|
||||
outputParams
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
() => {
|
||||
initData()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// 保存验证
|
||||
/**
|
||||
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const validateAndSave = async () => {
|
||||
let inputValid = true
|
||||
let outputValid = true
|
||||
if (dynamicFormRef.value) {
|
||||
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
|
||||
}
|
||||
if (outputFormRef.value) {
|
||||
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
|
||||
}
|
||||
|
||||
const nodeParams = []
|
||||
nodeParams.push(...RecognizeNodeData.value.nodeParams)
|
||||
nodeParams.push({
|
||||
name: "alarmRules",
|
||||
type: "input",
|
||||
input: RecognizeNodeData.value.alarmRules.map(({ level, operator, threshold, message }) => ({
|
||||
level,
|
||||
operator,
|
||||
threshold,
|
||||
message
|
||||
})),
|
||||
disabled: true
|
||||
})
|
||||
lf.setProperties(props.data.id, {
|
||||
...props.data.properties,
|
||||
nodeParams,
|
||||
outputParams: RecognizeNodeData.value.outputParams
|
||||
});
|
||||
|
||||
|
||||
if (inputValid && outputValid) {
|
||||
emit('save-success')
|
||||
} else {
|
||||
emit('save-error')
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ validateAndSave })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.http-form {
|
||||
width: 100%;
|
||||
|
||||
.el-collapse {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.form__container {
|
||||
margin: 12px 0;
|
||||
|
||||
:deep(.el-row) {
|
||||
align-items: end;
|
||||
|
||||
.el-form-item {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.param-name {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.param-type {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.sub-properties {
|
||||
margin-left: 10px;
|
||||
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gSon-properties {
|
||||
margin-left: 10px;
|
||||
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
margin-bottom: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.alarm-rules {
|
||||
:deep(.el-row) {
|
||||
flex-wrap: nowrap;
|
||||
|
||||
.el-form-item {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.el-select,
|
||||
.el-input,
|
||||
.el-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,120 +0,0 @@
|
||||
<template>
|
||||
<div class="execution-target">
|
||||
<div class="execution-target__heading">执行设置</div>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="由谁执行" required>
|
||||
<el-select v-model="localTarget.roleKey" style="width: 100%" @change="handleRoleChange">
|
||||
<el-option
|
||||
v-for="role in roles"
|
||||
:key="role.roleKey"
|
||||
:label="role.displayName"
|
||||
:value="role.roleKey"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="hasDeviceParameter" label="使用设备" required>
|
||||
<el-select
|
||||
v-model="deviceSlotKey"
|
||||
style="width: 100%"
|
||||
:placeholder="matchingSlots.length ? '请选择设备用途' : '请先在开始节点配置设备用途'"
|
||||
@change="emitTarget"
|
||||
>
|
||||
<el-option
|
||||
v-for="slot in matchingSlots"
|
||||
:key="slot.slotKey"
|
||||
:label="`${slot.displayName} · ${getDeviceKindName(slot.deviceKind)}`"
|
||||
:value="slot.slotKey"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="!matchingSlots.length" class="execution-target__warning">
|
||||
当前角色没有匹配的设备用途,请先打开开始节点添加。
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { getDeviceKindForAction } from '@/utils/robotDevice'
|
||||
import { actionRequiresDevice, getDeviceKindName, getResourceRoles } from '@/utils/flowResources'
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Object, required: true },
|
||||
modelValue: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const roles = ref([])
|
||||
const localTarget = reactive({ roleKey: '', dispatchPolicy: 'ONE', deviceSlotKey: '' })
|
||||
const deviceSlotKey = ref('')
|
||||
|
||||
const hasDeviceParameter = computed(() => (
|
||||
actionRequiresDevice(props.data.properties?.action)
|
||||
))
|
||||
const requiredDeviceKind = computed(() => getDeviceKindForAction(props.data.properties?.action))
|
||||
const selectedRole = computed(() => roles.value.find((role) => role.roleKey === localTarget.roleKey))
|
||||
const matchingSlots = computed(() => (selectedRole.value?.deviceSlots || []).filter((slot) => (
|
||||
requiredDeviceKind.value == null
|
||||
|| slot.deviceKind == null
|
||||
|| Number(slot.deviceKind) === Number(requiredDeviceKind.value)
|
||||
)))
|
||||
|
||||
const emitTarget = () => {
|
||||
emit('update:modelValue', {
|
||||
roleKey: localTarget.roleKey,
|
||||
dispatchPolicy: 'ONE',
|
||||
deviceSlotKey: hasDeviceParameter.value ? deviceSlotKey.value : '',
|
||||
})
|
||||
}
|
||||
|
||||
const selectDefaultSlot = () => {
|
||||
const currentValid = matchingSlots.value.some((slot) => slot.slotKey === deviceSlotKey.value)
|
||||
if (!currentValid) deviceSlotKey.value = matchingSlots.value.length === 1 ? matchingSlots.value[0].slotKey : ''
|
||||
}
|
||||
|
||||
const handleRoleChange = () => {
|
||||
deviceSlotKey.value = ''
|
||||
selectDefaultSlot()
|
||||
emitTarget()
|
||||
}
|
||||
|
||||
const syncFromModel = () => {
|
||||
roles.value = getResourceRoles()
|
||||
const value = props.modelValue || {}
|
||||
localTarget.roleKey = roles.value.some((role) => role.roleKey === value.roleKey)
|
||||
? value.roleKey
|
||||
: roles.value[0]?.roleKey || ''
|
||||
localTarget.dispatchPolicy = value.dispatchPolicy || 'ONE'
|
||||
deviceSlotKey.value = value.deviceSlotKey || value.deviceBindings?.[0]?.slotKey || ''
|
||||
selectDefaultSlot()
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.data.id, () => props.modelValue],
|
||||
syncFromModel,
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.execution-target {
|
||||
margin: 12px 0 2px;
|
||||
padding: 14px 0 2px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.execution-target__heading {
|
||||
margin-bottom: 12px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.execution-target__warning {
|
||||
margin-top: 6px;
|
||||
color: var(--el-color-warning-dark-2);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
</style>
|
||||
@ -1,390 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:智能体参数面板,配置智能体调用和语音合成。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<el-form :inline="true" :model="sdAgentNodeData" :rules="rules" ref="dynamicFormRef" class="http-form"
|
||||
label-position="top" label-width="auto">
|
||||
<el-collapse v-model="activeNames">
|
||||
<el-collapse-item name="1" title="请求配置" icon-position="left">
|
||||
<div class="form__container">
|
||||
<div v-for="(property, index) in sdAgentNodeData.config" :key="index">
|
||||
<el-row>
|
||||
<el-form-item :label="index === 0 ? '参数名' : ''" :prop="`config.${index}.name`" :rules="[
|
||||
{ required: true, message: '请输入参数名', trigger: 'blur' },
|
||||
]">
|
||||
<el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`config.${index}.input`">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
<el-form-item v-if="property.type === 'input'" :rules="[
|
||||
{
|
||||
required: property?.required ?? true,
|
||||
message: '请输入参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]" :prop="`config.${index}.input`">
|
||||
<el-input-number class="param-value" v-if="property.componentType === 'number'"
|
||||
v-model="property.input" :min="0" :max="property.max || Infinity" :controls="false"
|
||||
:step-strictly="true" placeholder="请输入" clearable />
|
||||
<el-select v-model="property.input" class="param-value"
|
||||
v-else-if="property.componentType === 'select'">
|
||||
<el-option v-for="item in property.selectOptions" :key="item.value" :label="item.label"
|
||||
:value="item.value" />
|
||||
</el-select>
|
||||
<el-input class="param-value" v-else v-model="property.input" placeholder="请输入" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="property.type === 'quote'" :rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]" :prop="`config.${index}.quote`">
|
||||
<el-cascader :ref="(el) => {
|
||||
if (el) cascaderRefs[index] = el;
|
||||
}
|
||||
" v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
" @change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
</el-row>
|
||||
</div>
|
||||
<el-row>
|
||||
<span style="margin-right: 24px">是否调用TTS接口</span>
|
||||
<el-switch size="small" v-model="sdAgentNodeData.invokeTts" @change="handleTtsChange" />
|
||||
</el-row>
|
||||
<div v-if="sdAgentNodeData.invokeTts">
|
||||
<div style="margin: 10px 0">TTS参数设置</div>
|
||||
<div v-for="(property, index) in sdAgentNodeData.tts" :key="index">
|
||||
<el-row>
|
||||
<el-form-item :label="index === 0 ? '参数名' : ''" :prop="`tts.${index}.name`" :rules="[
|
||||
{ required: true, message: '请输入参数名', trigger: 'blur' },
|
||||
]">
|
||||
<el-input :disabled="true" class="param-name" v-model="property.name" placeholder="请输入" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`tts.${index}.input`">
|
||||
<el-select v-model="property.type" class="param-type" @change="handleTypeChange(property)">
|
||||
<el-option label="引用" value="quote" />
|
||||
<el-option label="输入" value="input" />
|
||||
</el-select>
|
||||
<el-form-item v-if="property.type === 'input'" :rules="[
|
||||
{
|
||||
required: property?.required ?? true,
|
||||
message: '请输入参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]" :prop="`tts.${index}.input`">
|
||||
<el-input-number class="param-value" v-if="property.componentType === 'number'"
|
||||
v-model="property.input" :min="0" :max="property.max || Infinity" :controls="false"
|
||||
:step-strictly="true" placeholder="请输入" clearable />
|
||||
<el-select v-model="property.input" class="param-value"
|
||||
v-else-if="property.componentType === 'select'">
|
||||
<el-option v-for="item in property.selectOptions" :key="item.value" :label="item.label"
|
||||
:value="item.value" />
|
||||
</el-select>
|
||||
<el-input class="param-value" v-else v-model="property.input" placeholder="请输入" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="property.type === 'quote'" :rules="[
|
||||
{
|
||||
required: true,
|
||||
message: '请选择参数值',
|
||||
trigger: 'blur',
|
||||
},
|
||||
]" :prop="`tts.${index}.quote`">
|
||||
<el-cascader :ref="(el) => {
|
||||
if (el) cascaderRefs[index] = el;
|
||||
}
|
||||
" v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions" placeholder="请选择"
|
||||
@visible-change="
|
||||
(visible) => visibleChange(visible, index, property.quote)
|
||||
" @change="(value) => cascaderChange(value, property)" />
|
||||
</el-form-item>
|
||||
</el-form-item>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item name="3" icon-position="left">
|
||||
<template #title>
|
||||
输出参数
|
||||
<el-tooltip class="box-item" effect="dark" content="这里定义的输出变量可以被后续节点引用。" placement="top">
|
||||
<el-icon class="header-icon" style="margin-left: 6px">
|
||||
<info-filled />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
<el-button :circle="true" size="small" @click="(e) => addSdAgentItem(e)" class="addFormItem">
|
||||
<el-icon :size="14">
|
||||
<Plus />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="form__container">
|
||||
<el-form :inline="true" :model="sdAgentNodeData" label-position="top" label-width="auto" :rules="outputRules"
|
||||
ref="outputFormRef">
|
||||
<FormItemRecursive formType="output" :current-list="sdAgentNodeData.outputParams" prop-path="outputParams"
|
||||
:depth="0" :is-first-level="true" :endDepth="2" :parent-path="[]"
|
||||
@delete-item="(path) => deleteSdAgentItem(path, 'outputParams')" />
|
||||
</el-form>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, nextTick } from 'vue'
|
||||
import { Plus, Minus } from '@element-plus/icons-vue'
|
||||
import FormItemRecursive from '../FormItemRecursive.vue'
|
||||
import { getInput, transformSdAgentNodeData } from '@/utils/flow'
|
||||
import { useQuote } from './useQuote.js'
|
||||
|
||||
const props = defineProps({
|
||||
data: Object
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save-success', 'save-error'])
|
||||
|
||||
const formData = reactive({
|
||||
nodeParams: [],
|
||||
outputParams: []
|
||||
})
|
||||
|
||||
const rules = reactive({})
|
||||
const outputRules = reactive({})
|
||||
const dynamicFormRef = ref()
|
||||
const outputFormRef = ref()
|
||||
const activeNames = ref(['1', '2'])
|
||||
|
||||
// 使用共享的 quote 逻辑
|
||||
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
|
||||
|
||||
// 添加表单项
|
||||
/**
|
||||
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} e 触发操作的浏览器事件
|
||||
* @param {*} type 目标类型
|
||||
*/
|
||||
const addFormItem = (e, type) => {
|
||||
e.stopPropagation()
|
||||
const obj = {
|
||||
name: '',
|
||||
type: type === 'nodeParams' ? 'input' : 'string',
|
||||
required: false,
|
||||
children: [],
|
||||
input: ''
|
||||
}
|
||||
if (!formData[type]) formData[type] = []
|
||||
formData[type].push(obj)
|
||||
}
|
||||
|
||||
// 商道智能体
|
||||
const sdAgentNodeData = ref({})
|
||||
|
||||
/**
|
||||
* 处理 voiceOptions 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const voiceOptions = () => {
|
||||
return [{
|
||||
value: 'x4_xiaoyan',
|
||||
label: 'x4_xiaoyan'
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 handleTtsChange 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} val 变更值
|
||||
*/
|
||||
const handleTtsChange = (val) => {
|
||||
if (val) {
|
||||
sdAgentNodeData.value.tts = []
|
||||
if (!sdAgentNodeData.value.tts || sdAgentNodeData.value.tts.length === 0) {
|
||||
sdAgentNodeData.value.tts = [
|
||||
{ name: 'url', type: "input", input: "", disabled: true },
|
||||
{ name: 'text', type: "input", input: "", disabled: true },
|
||||
{ name: 'speed', type: "input", input: "", max: 100, disabled: true },
|
||||
{ name: 'voice', type: "input", input: "", componentType: 'select', selectOptions: voiceOptions(), disabled: true },
|
||||
{ name: 'volume', type: "input", input: "", componentType: 'number', max: 100, disabled: true }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增 addSdAgentItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} e 触发操作的浏览器事件
|
||||
*/
|
||||
const addSdAgentItem = (e) => {
|
||||
e.stopPropagation();
|
||||
sdAgentNodeData.value.outputParams.push({ name: "", type: "string", input: "" });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 deleteSdAgentItem 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} fullPath 字段完整路径
|
||||
* @param {*} propPath 字段校验路径
|
||||
*/
|
||||
const deleteSdAgentItem = (fullPath, propPath) => {
|
||||
// 从顶层数据开始查找
|
||||
let currentLevel = sdAgentNodeData.value[propPath];
|
||||
|
||||
// 遍历路径(除最后一个索引,因为最后一个是要删除的项)
|
||||
for (let i = 0; i < fullPath.length - 1; i++) {
|
||||
const index = fullPath[i];
|
||||
// 进入下一层级
|
||||
currentLevel = currentLevel[index].children;
|
||||
}
|
||||
|
||||
// 最后一个索引是当前层级要删除的项
|
||||
const lastIndex = fullPath[fullPath.length - 1];
|
||||
currentLevel.splice(lastIndex, 1);
|
||||
}
|
||||
|
||||
// 初始化数据
|
||||
/**
|
||||
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const initData = () => {
|
||||
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
|
||||
const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || []))
|
||||
const transformedData = transformSdAgentNodeData(nodeParams);
|
||||
sdAgentNodeData.value = {
|
||||
...transformedData,
|
||||
outputParams
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
() => {
|
||||
initData()
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
// 保存验证
|
||||
/**
|
||||
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const validateAndSave = async () => {
|
||||
let inputValid = true
|
||||
let outputValid = true
|
||||
if (dynamicFormRef.value) {
|
||||
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
|
||||
}
|
||||
if (outputFormRef.value) {
|
||||
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
|
||||
}
|
||||
|
||||
const nodeParams = []
|
||||
nodeParams.push({ name: "config", type: "input", input: "", children: sdAgentNodeData.value.config, required: true })
|
||||
nodeParams.push({ name: "invokeTts", type: "input", input: sdAgentNodeData.value.invokeTts })
|
||||
if (sdAgentNodeData.value.invokeTts) {
|
||||
nodeParams.push({ name: "tts", type: "input", input: "", children: sdAgentNodeData.value.tts })
|
||||
}
|
||||
lf.setProperties(props.data.id, {
|
||||
...props.data.properties,
|
||||
nodeParams,
|
||||
outputParams: sdAgentNodeData.value.outputParams
|
||||
});
|
||||
|
||||
|
||||
if (inputValid && outputValid) {
|
||||
emit('save-success')
|
||||
} else {
|
||||
emit('save-error')
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ validateAndSave })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.http-form {
|
||||
width: 100%;
|
||||
|
||||
.el-collapse {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.form__container {
|
||||
margin: 12px 0;
|
||||
|
||||
:deep(.el-row) {
|
||||
align-items: end;
|
||||
|
||||
.el-form-item {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.param-name {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.param-type {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.param-value {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
.sub-properties {
|
||||
margin-left: 10px;
|
||||
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gSon-properties {
|
||||
margin-left: 10px;
|
||||
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
margin-bottom: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,233 +0,0 @@
|
||||
<!-- 开始节点维护流程级机器人角色和业务输入参数。 -->
|
||||
<template>
|
||||
<el-collapse v-model="activeNames">
|
||||
<el-collapse-item name="roles" title="机器人角色">
|
||||
<div class="role-intro">节点按角色分工,试运行时再安排具体机器人和设备。</div>
|
||||
<div v-for="(role, roleIndex) in formData.resourceRoles" :key="role.roleKey" class="role-row">
|
||||
<div class="role-row__header">
|
||||
<el-input v-model="role.displayName" maxlength="30" placeholder="例如:巡检机器人" />
|
||||
<el-button
|
||||
circle
|
||||
:icon="Delete"
|
||||
:disabled="formData.resourceRoles.length === 1"
|
||||
aria-label="删除机器人角色"
|
||||
@click="deleteRole(roleIndex)"
|
||||
/>
|
||||
</div>
|
||||
<div class="role-row__section">
|
||||
<div class="role-row__label">设备用途</div>
|
||||
<div v-if="!role.deviceSlots.length" class="empty-tip">当前角色暂不需要设备</div>
|
||||
<div v-for="(slot, slotIndex) in role.deviceSlots" :key="slot.slotKey" class="device-row">
|
||||
<el-input v-model="slot.displayName" maxlength="30" placeholder="例如:前方摄像头" />
|
||||
<el-select v-model="slot.deviceKind" placeholder="设备类型">
|
||||
<el-option v-for="item in deviceKinds" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-button circle :icon="Minus" aria-label="删除设备用途" @click="deleteDeviceSlot(role, slotIndex)" />
|
||||
</div>
|
||||
<el-button text type="primary" :icon="Plus" @click="addDeviceSlot(role)">添加设备用途</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" plain :icon="Plus" @click="addRole">添加机器人角色</el-button>
|
||||
</el-collapse-item>
|
||||
|
||||
<el-collapse-item name="inputs">
|
||||
<template #title>
|
||||
输入参数
|
||||
<el-button circle size="small" class="addFormItem" @click="(event) => addFormItem(event, 'inputParams')">
|
||||
<el-icon :size="14"><Plus /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
<el-form
|
||||
ref="dynamicFormRef"
|
||||
:inline="true"
|
||||
:model="formData"
|
||||
label-position="top"
|
||||
label-width="auto"
|
||||
>
|
||||
<FormItemRecursive
|
||||
formType="input"
|
||||
:current-list="formData.inputParams"
|
||||
prop-path="inputParams"
|
||||
:depth="0"
|
||||
:is-first-level="true"
|
||||
:endDepth="2"
|
||||
:parent-path="[]"
|
||||
@delete-item="(path) => deleteTopLevelItem(path, 'inputParams')"
|
||||
/>
|
||||
</el-form>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { Delete, Minus, Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import FormItemRecursive from '../FormItemRecursive.vue'
|
||||
import {
|
||||
createDefaultRobotRole,
|
||||
createDeviceSlot,
|
||||
createResourceKey,
|
||||
normalizeFlowResourceSchema,
|
||||
USER_RESOURCE_OWNER,
|
||||
} from '@/utils/flowResources'
|
||||
|
||||
const props = defineProps({ data: Object })
|
||||
const emit = defineEmits(['save-success', 'save-error'])
|
||||
|
||||
const deviceKinds = [
|
||||
{ label: '移动底盘', value: 1 },
|
||||
{ label: '机械臂', value: 2 },
|
||||
{ label: '仿生头', value: 4 },
|
||||
{ label: '摄像头', value: 5 },
|
||||
{ label: '麦克风', value: 9 },
|
||||
{ label: '机器人本体', value: 12 },
|
||||
{ label: '扬声器', value: 13 },
|
||||
]
|
||||
|
||||
const formData = reactive({ resourceRoles: [], inputParams: [] })
|
||||
const dynamicFormRef = ref()
|
||||
const activeNames = ref(['roles', 'inputs'])
|
||||
|
||||
const addRole = () => {
|
||||
formData.resourceRoles.push({
|
||||
...createDefaultRobotRole(),
|
||||
roleKey: createResourceKey('robot_role'),
|
||||
displayName: `机器人角色${formData.resourceRoles.length + 1}`,
|
||||
managedBy: USER_RESOURCE_OWNER,
|
||||
})
|
||||
}
|
||||
|
||||
const addDeviceSlot = (role) => {
|
||||
role.deviceSlots.push({
|
||||
...createDeviceSlot(null, role.deviceSlots),
|
||||
managedBy: USER_RESOURCE_OWNER,
|
||||
})
|
||||
}
|
||||
|
||||
const referencedNodes = (predicate) => globalThis.lf.getGraphData().nodes.filter((node) => predicate(node.properties?.executionTarget))
|
||||
|
||||
const deleteRole = (index) => {
|
||||
const role = formData.resourceRoles[index]
|
||||
const references = referencedNodes((target) => target?.roleKey === role.roleKey)
|
||||
if (references.length) {
|
||||
ElMessage.warning(`“${role.displayName}”仍被 ${references.length} 个节点使用,请先调整这些节点`)
|
||||
return
|
||||
}
|
||||
formData.resourceRoles.splice(index, 1)
|
||||
}
|
||||
|
||||
const deleteDeviceSlot = (role, index) => {
|
||||
const slot = role.deviceSlots[index]
|
||||
const references = referencedNodes((target) => (
|
||||
target?.roleKey === role.roleKey
|
||||
&& (target.deviceSlotKey === slot.slotKey
|
||||
|| target.deviceBindings?.some((binding) => binding.slotKey === slot.slotKey))
|
||||
))
|
||||
if (references.length) {
|
||||
ElMessage.warning(`“${slot.displayName}”仍被 ${references.length} 个节点使用,请先调整这些节点`)
|
||||
return
|
||||
}
|
||||
role.deviceSlots.splice(index, 1)
|
||||
}
|
||||
|
||||
const addFormItem = (event, type) => {
|
||||
event.stopPropagation()
|
||||
formData[type].push({ name: '', type: 'string', required: false, children: [], input: '' })
|
||||
}
|
||||
|
||||
const deleteTopLevelItem = (fullPath, propPath) => {
|
||||
let currentLevel = formData[propPath]
|
||||
for (let index = 0; index < fullPath.length - 1; index += 1) {
|
||||
currentLevel = currentLevel[fullPath[index]].children
|
||||
}
|
||||
currentLevel.splice(fullPath[fullPath.length - 1], 1)
|
||||
}
|
||||
|
||||
const initData = () => {
|
||||
const graph = normalizeFlowResourceSchema(globalThis.lf.getGraphData())
|
||||
const start = graph.nodes.find((node) => node.id === props.data.id)
|
||||
formData.resourceRoles = JSON.parse(JSON.stringify(start?.properties?.resourceRoles || []))
|
||||
formData.inputParams = JSON.parse(JSON.stringify(start?.properties?.inputParams || []))
|
||||
}
|
||||
|
||||
watch(() => props.data.id, initData, { immediate: true })
|
||||
|
||||
const validateAndSave = async () => {
|
||||
const names = formData.resourceRoles.map((role) => role.displayName.trim())
|
||||
const invalidRole = formData.resourceRoles.some((role) => !role.displayName.trim())
|
||||
const duplicateRole = new Set(names).size !== names.length
|
||||
const invalidSlot = formData.resourceRoles.some((role) => role.deviceSlots.some((slot) => (
|
||||
!slot.displayName.trim() || slot.deviceKind == null
|
||||
)))
|
||||
|
||||
if (invalidRole || duplicateRole || invalidSlot) {
|
||||
ElMessage.warning(invalidRole ? '请填写机器人角色名称' : duplicateRole ? '机器人角色名称不能重复' : '请完整填写设备用途和类型')
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
|
||||
let inputValid = true
|
||||
if (dynamicFormRef.value) {
|
||||
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
|
||||
}
|
||||
globalThis.lf.setProperties(props.data.id, {
|
||||
...props.data.properties,
|
||||
schemaVersion: 2,
|
||||
resourceRoles: JSON.parse(JSON.stringify(formData.resourceRoles)),
|
||||
inputParams: JSON.parse(JSON.stringify(formData.inputParams)),
|
||||
})
|
||||
emit(inputValid ? 'save-success' : 'save-error')
|
||||
}
|
||||
|
||||
defineExpose({ validateAndSave })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.role-intro {
|
||||
margin-bottom: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.role-row {
|
||||
margin-bottom: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.role-row__header,
|
||||
.device-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.role-row__section {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.role-row__label {
|
||||
margin-bottom: 8px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.device-row {
|
||||
grid-template-columns: minmax(0, 1fr) 132px minmax(0, 1fr) auto;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@media (max-width: 720px) { .device-row { grid-template-columns: 1fr; } }
|
||||
|
||||
.empty-tip {
|
||||
margin-bottom: 6px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@ -1,291 +0,0 @@
|
||||
<!-- Published sub-flow selector and synchronized contract editor. -->
|
||||
<template>
|
||||
<div class="subflow-params">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="已发布工作流" required>
|
||||
<el-select
|
||||
v-model="selectedVersionId"
|
||||
filterable
|
||||
:loading="loading"
|
||||
placeholder="选择同一项目的已发布工作流"
|
||||
style="width: 100%"
|
||||
@change="selectVersion"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in options"
|
||||
:key="item.versionId"
|
||||
:label="`${item.detectName} · V${item.versionNo}`"
|
||||
:value="item.versionId"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="latestOption && selection.versionId && latestOption.versionId !== selection.versionId"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="version-alert"
|
||||
>
|
||||
<template #title>
|
||||
已发布 V{{ latestOption.versionNo }},当前节点仍使用 V{{ selection.versionNo }}
|
||||
</template>
|
||||
<el-button size="small" type="warning" plain @click="applyOption(latestOption)">同步最新发布版本</el-button>
|
||||
</el-alert>
|
||||
<el-alert
|
||||
v-else-if="optionsLoaded && selection.versionId && !selectedOptionAvailable"
|
||||
title="当前子流程已不可用或会形成循环引用,请重新选择"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="version-alert"
|
||||
/>
|
||||
|
||||
<template v-if="selection.versionId">
|
||||
<div class="contract-header">
|
||||
<div>
|
||||
<div class="contract-title">{{ selection.detectName }}</div>
|
||||
<div class="contract-meta">{{ projectLabel }} · 发布版本 V{{ selection.versionNo }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contract-section">
|
||||
<div class="contract-section__title">子流程输入</div>
|
||||
<div class="contract-section__note">所有输入必须引用当前流程的开始参数或前面节点输出。</div>
|
||||
<el-empty v-if="!inputs.length" description="子流程没有输入参数" :image-size="64" />
|
||||
<div v-for="item in inputs" :key="item.name" class="contract-row">
|
||||
<div class="contract-row__name">
|
||||
<span>{{ item.name }}</span>
|
||||
<el-tag size="small" effect="plain" type="info">{{ item.dataType }}</el-tag>
|
||||
</div>
|
||||
<el-cascader
|
||||
v-model="item.quote"
|
||||
:options="quoteOptions"
|
||||
:props="{ checkStrictly: true }"
|
||||
placeholder="选择引用来源"
|
||||
style="width: 100%"
|
||||
@visible-change="refreshQuoteOptions"
|
||||
@change="(value) => { item.quoteType = value?.[1] || '' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contract-section">
|
||||
<div class="contract-section__title">子流程输出</div>
|
||||
<el-empty v-if="!outputs.length" description="子流程没有输出参数" :image-size="64" />
|
||||
<div v-for="item in outputs" :key="item.name" class="output-contract">
|
||||
<span>{{ item.name }}</span>
|
||||
<el-tag size="small" effect="plain" type="info">{{ item.type }}</el-tag>
|
||||
<span class="output-contract__desc">{{ item.desc || '无说明' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { flowSubFlowOptions } from '@/api/flow/flow'
|
||||
import { getUpstreamInput } from '@/utils/flow'
|
||||
import {
|
||||
FLOW_NODE_RESOURCE_OWNER,
|
||||
pruneUnusedNodeResources,
|
||||
} from '@/utils/flowResources'
|
||||
|
||||
const props = defineProps({ data: Object, flowId: String })
|
||||
const emit = defineEmits(['save-success', 'save-error'])
|
||||
const loading = ref(false)
|
||||
const optionsLoaded = ref(false)
|
||||
const options = ref([])
|
||||
const selectedVersionId = ref('')
|
||||
const inputs = ref([])
|
||||
const outputs = ref([])
|
||||
const quoteOptions = ref([])
|
||||
const selection = reactive({ itemId: '', detectName: '', projectCode: '', versionId: '', versionNo: null, resourceRoles: [] })
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value ?? null))
|
||||
|
||||
const latestOption = computed(() => options.value.find((item) => item.itemId === selection.itemId))
|
||||
const selectedOptionAvailable = computed(() => options.value.some((item) => item.versionId === selection.versionId))
|
||||
const projectLabel = computed(() => selection.projectCode === 'AIMA' ? '爱玛' : '长安')
|
||||
|
||||
const refreshQuoteOptions = (visible = true) => {
|
||||
if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || []
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
const stored = props.data.properties?.subFlow || {}
|
||||
Object.assign(selection, {
|
||||
itemId: stored.itemId || '',
|
||||
detectName: stored.detectName || '',
|
||||
projectCode: stored.projectCode || '',
|
||||
versionId: stored.versionId || '',
|
||||
versionNo: stored.versionNo ?? null,
|
||||
resourceRoles: clone(stored.resourceRoles || []),
|
||||
})
|
||||
selectedVersionId.value = selection.versionId
|
||||
const outputByName = new Map((props.data.properties?.outputParams || []).map((item) => [item.name, item]))
|
||||
inputs.value = (props.data.properties?.nodeParams || []).map((item) => ({
|
||||
name: item.name,
|
||||
dataType: item.dataType || 'string',
|
||||
quote: Array.isArray(item.quote) ? [...item.quote] : [],
|
||||
quoteType: item.quoteType || item.quote?.[1] || '',
|
||||
required: true,
|
||||
}))
|
||||
outputs.value = [...outputByName.values()].map(clone)
|
||||
refreshQuoteOptions()
|
||||
}
|
||||
|
||||
const applyOption = (option) => {
|
||||
if (!option) return
|
||||
const existing = new Map(inputs.value.map((item) => [item.name, item]))
|
||||
Object.assign(selection, {
|
||||
itemId: option.itemId,
|
||||
detectName: option.detectName,
|
||||
projectCode: option.projectCode,
|
||||
versionId: option.versionId,
|
||||
versionNo: option.versionNo,
|
||||
resourceRoles: clone(option.resourceRoles || []),
|
||||
})
|
||||
selectedVersionId.value = option.versionId
|
||||
inputs.value = (option.inputParams || []).map((item) => ({
|
||||
name: item.name,
|
||||
dataType: item.type || 'string',
|
||||
quote: Array.isArray(existing.get(item.name)?.quote) ? [...existing.get(item.name).quote] : [],
|
||||
quoteType: existing.get(item.name)?.quoteType || '',
|
||||
required: true,
|
||||
}))
|
||||
outputs.value = clone(option.outputParams || [])
|
||||
}
|
||||
|
||||
const selectVersion = (versionId) => applyOption(options.value.find((item) => item.versionId === versionId))
|
||||
|
||||
const loadOptions = async () => {
|
||||
if (!props.flowId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await flowSubFlowOptions(props.flowId)
|
||||
options.value = response.data || []
|
||||
if (!selection.versionId && options.value.length === 1) applyOption(options.value[0])
|
||||
} finally {
|
||||
loading.value = false
|
||||
optionsLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const mergeResourceRoles = () => {
|
||||
const graph = globalThis.lf.getGraphData()
|
||||
const start = graph.nodes.find((node) => node.type === 'start')
|
||||
if (!start) throw new Error('当前流程缺少开始节点')
|
||||
const roles = clone(start.properties?.resourceRoles || [])
|
||||
for (const childRole of selection.resourceRoles || []) {
|
||||
let parentRole = roles.find((role) => role.roleKey === childRole.roleKey)
|
||||
if (!parentRole) {
|
||||
const addedRole = clone(childRole)
|
||||
addedRole.managedBy = FLOW_NODE_RESOURCE_OWNER
|
||||
;(addedRole.deviceSlots || []).forEach((slot) => { slot.managedBy = FLOW_NODE_RESOURCE_OWNER })
|
||||
roles.push(addedRole)
|
||||
continue
|
||||
}
|
||||
parentRole.required = parentRole.required !== false || childRole.required !== false
|
||||
parentRole.quantity ||= { min: 1, max: 1 }
|
||||
parentRole.quantity.min = Math.max(Number(parentRole.quantity.min || 0), Number(childRole.quantity?.min || 0))
|
||||
parentRole.quantity.max = Math.max(Number(parentRole.quantity.max || 0), Number(childRole.quantity?.max || 0))
|
||||
parentRole.deviceSlots ||= []
|
||||
for (const childSlot of childRole.deviceSlots || []) {
|
||||
const parentSlot = parentRole.deviceSlots.find((slot) => slot.slotKey === childSlot.slotKey)
|
||||
if (!parentSlot) {
|
||||
parentRole.deviceSlots.push({
|
||||
...clone(childSlot),
|
||||
managedBy: FLOW_NODE_RESOURCE_OWNER,
|
||||
})
|
||||
} else if (Number(parentSlot.deviceKind) !== Number(childSlot.deviceKind)) {
|
||||
throw new Error(`机器人角色“${childRole.displayName}”的设备用途“${childSlot.displayName}”类型冲突`)
|
||||
} else {
|
||||
parentSlot.required = parentSlot.required !== false || childSlot.required !== false
|
||||
parentSlot.allowReuse = parentSlot.allowReuse === true && childSlot.allowReuse === true
|
||||
}
|
||||
}
|
||||
}
|
||||
return { graph, start, roles }
|
||||
}
|
||||
|
||||
const validateAndSave = () => {
|
||||
if (!selection.versionId) {
|
||||
ElMessage.warning('请选择已发布工作流')
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
if (optionsLoaded.value && !selectedOptionAvailable.value) {
|
||||
ElMessage.warning('当前子流程已不可用或会形成循环引用,请重新选择')
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
if (inputs.value.some((item) => !Array.isArray(item.quote) || item.quote.length < 3)) {
|
||||
ElMessage.warning('子流程的所有输入参数都必须选择引用来源')
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
let resources
|
||||
try {
|
||||
resources = mergeResourceRoles()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message)
|
||||
emit('save-error')
|
||||
return
|
||||
}
|
||||
const nodeParams = inputs.value.map((item) => ({
|
||||
name: item.name,
|
||||
type: 'quote',
|
||||
input: '',
|
||||
quote: [...item.quote],
|
||||
quoteType: item.quoteType || item.quote[1],
|
||||
dataType: item.dataType,
|
||||
required: true,
|
||||
disabled: true,
|
||||
}))
|
||||
const nodeProperties = {
|
||||
...props.data.properties,
|
||||
subFlow: clone(selection),
|
||||
nodeParams,
|
||||
outputParams: clone(outputs.value),
|
||||
outputType: 'json',
|
||||
}
|
||||
const prospectiveGraph = clone(resources.graph)
|
||||
const prospectiveStart = prospectiveGraph.nodes.find((node) => node.id === resources.start.id)
|
||||
const prospectiveNode = prospectiveGraph.nodes.find((node) => node.id === props.data.id)
|
||||
prospectiveStart.properties = { ...prospectiveStart.properties, resourceRoles: resources.roles }
|
||||
prospectiveNode.properties = nodeProperties
|
||||
const cleanup = pruneUnusedNodeResources(prospectiveGraph)
|
||||
globalThis.lf.setProperties(resources.start.id, {
|
||||
...resources.start.properties,
|
||||
resourceRoles: cleanup.start.properties.resourceRoles,
|
||||
})
|
||||
globalThis.lf.setProperties(props.data.id, nodeProperties)
|
||||
emit('save-success')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
restore()
|
||||
await loadOptions()
|
||||
})
|
||||
defineExpose({ validateAndSave })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.subflow-params { padding-top: 14px; }
|
||||
.version-alert { margin-bottom: 16px; }
|
||||
.version-alert :deep(.el-alert__content) { width: 100%; }
|
||||
.version-alert .el-button { margin-top: 8px; }
|
||||
.contract-header { margin-bottom: 16px; padding: 14px; border: 1px solid #dce5ef; border-radius: 6px; background: #f8fafc; }
|
||||
.contract-title { color: #1f2937; font-size: 15px; font-weight: 650; }
|
||||
.contract-meta { margin-top: 4px; color: #697386; font-size: 12px; }
|
||||
.contract-section { margin-top: 18px; }
|
||||
.contract-section__title { color: #344054; font-size: 13px; font-weight: 650; }
|
||||
.contract-section__note { margin: 4px 0 10px; color: #7a8494; font-size: 12px; }
|
||||
.contract-row { margin-top: 10px; padding: 12px; border: 1px solid #e4e8ef; border-radius: 6px; }
|
||||
.contract-row__name { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; color: #344054; font-size: 13px; font-weight: 600; }
|
||||
.output-contract { display: grid; grid-template-columns: minmax(100px, auto) auto minmax(0, 1fr); align-items: center; gap: 10px; min-height: 38px; border-bottom: 1px solid #edf0f4; color: #344054; font-size: 13px; }
|
||||
.output-contract__desc { overflow: hidden; color: #7a8494; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
@ -1,413 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:分支节点专用参数组件,独立负责条件组的新增、删除、引用选择、校验和保存。
|
||||
* 作用范围:仅用于 branch 类型节点,不与通用节点参数表单共享业务逻辑。
|
||||
-->
|
||||
<template>
|
||||
<div class="switch-params">
|
||||
<div class="switch-params__header">
|
||||
<div>
|
||||
<div class="switch-params__title">分支条件</div>
|
||||
<div class="switch-params__description">条件按从上到下的顺序匹配,每个条件对应节点上的一个输出锚点。</div>
|
||||
</div>
|
||||
<el-button type="primary" :icon="Plus" @click="addCondition">添加条件</el-button>
|
||||
</div>
|
||||
|
||||
<el-form ref="formRef" :model="formData" label-position="top">
|
||||
<div v-for="(condition, conditionIndex) in formData.nodeParams" :key="condition.id" class="condition-card">
|
||||
<div class="condition-card__header">
|
||||
<div class="condition-card__identity">
|
||||
<span>{{ conditionIndex === 0 ? "If" : "Else If" }}</span>
|
||||
<el-input
|
||||
v-model="condition.label"
|
||||
maxlength="20"
|
||||
show-word-limit
|
||||
placeholder="请输入条件名称"
|
||||
class="condition-name"
|
||||
/>
|
||||
</div>
|
||||
<el-button
|
||||
circle
|
||||
plain
|
||||
type="danger"
|
||||
:icon="Minus"
|
||||
:disabled="formData.nodeParams.length === 1"
|
||||
@click="removeCondition(conditionIndex)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-form-item label="条件关系">
|
||||
<el-radio-group v-model="condition.withOr">
|
||||
<el-radio-button value="and">AND</el-radio-button>
|
||||
<el-radio-button value="or">OR</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<div v-for="(item, itemIndex) in condition.list" :key="itemIndex" class="condition-row">
|
||||
<div class="condition-row__fields">
|
||||
<el-form-item
|
||||
label="引用变量"
|
||||
:prop="`nodeParams.${conditionIndex}.list.${itemIndex}.name${item.nameType === 'quote' ? 'Quote' : ''}`"
|
||||
:rules="requiredRule(item.nameType === 'quote' ? '请选择引用变量' : '请输入变量值')"
|
||||
>
|
||||
<el-input v-if="item.nameType === 'input'" v-model="item.name" placeholder="请输入变量值">
|
||||
<template #prepend>
|
||||
<el-select v-model="item.nameType" class="value-type" @change="resetNameValue(item)">
|
||||
<el-option label="输入" value="input" />
|
||||
<el-option label="引用" value="quote" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-input>
|
||||
<div v-else class="reference-field">
|
||||
<el-select v-model="item.nameType" class="value-type" @change="resetNameValue(item)">
|
||||
<el-option label="输入" value="input" />
|
||||
<el-option label="引用" value="quote" />
|
||||
</el-select>
|
||||
<el-cascader
|
||||
v-model="item.nameQuote"
|
||||
:options="quoteOptions"
|
||||
:show-all-levels="false"
|
||||
placeholder="请选择"
|
||||
@visible-change="(visible) => refreshQuoteOptions(visible, item, 'nameQuote')"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="判断方式"
|
||||
:prop="`nodeParams.${conditionIndex}.list.${itemIndex}.condition`"
|
||||
:rules="requiredRule('请选择判断方式')"
|
||||
>
|
||||
<el-select v-model="item.condition">
|
||||
<el-option v-for="option in conditionOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item
|
||||
label="比较值"
|
||||
:prop="`nodeParams.${conditionIndex}.list.${itemIndex}.${item.type === 'quote' ? 'quote' : 'input'}`"
|
||||
:rules="requiresComparison(item.condition) ? requiredRule(item.type === 'quote' ? '请选择比较变量' : '请输入比较值') : []"
|
||||
>
|
||||
<el-input
|
||||
v-if="item.type === 'input'"
|
||||
v-model="item.input"
|
||||
:disabled="!requiresComparison(item.condition)"
|
||||
placeholder="请输入比较值"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-select v-model="item.type" class="value-type" @change="resetCompareValue(item)">
|
||||
<el-option label="输入" value="input" />
|
||||
<el-option label="引用" value="quote" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-input v-else model-value="" readonly :disabled="!requiresComparison(item.condition)" placeholder="请选择比较变量">
|
||||
<template #prepend>
|
||||
<el-select v-model="item.type" class="value-type" @change="resetCompareValue(item)">
|
||||
<el-option label="输入" value="input" />
|
||||
<el-option label="引用" value="quote" />
|
||||
</el-select>
|
||||
</template>
|
||||
<template #append>
|
||||
<el-cascader
|
||||
v-model="item.quote"
|
||||
:disabled="!requiresComparison(item.condition)"
|
||||
:options="quoteOptions"
|
||||
:show-all-levels="false"
|
||||
@visible-change="(visible) => refreshQuoteOptions(visible, item, 'quote')"
|
||||
/>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="condition-row__actions">
|
||||
<el-button circle :icon="Plus" @click="addConditionItem(conditionIndex)" />
|
||||
<el-button
|
||||
circle
|
||||
:icon="Minus"
|
||||
:disabled="condition.list.length === 1"
|
||||
@click="removeConditionItem(conditionIndex, itemIndex)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="else-tip"><strong>Else</strong><span>所有条件均不匹配时,从 Else 锚点继续执行。</span></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from "vue";
|
||||
import { Plus, Minus } from "@element-plus/icons-vue";
|
||||
import { v4 as randomUUID } from "uuid";
|
||||
import { getUpstreamInput, removeSwitchEdge } from "@/utils/flow";
|
||||
|
||||
const props = defineProps({ data: Object });
|
||||
const emit = defineEmits(["save-success", "save-error"]);
|
||||
const formRef = ref(null);
|
||||
const quoteOptions = ref([]);
|
||||
const originalConditionIds = ref([]);
|
||||
const formData = reactive({ nodeParams: [] });
|
||||
|
||||
const conditionOptions = [
|
||||
{ label: "等于", value: "equal" },
|
||||
{ label: "不等于", value: "notEqualTo" },
|
||||
{ label: "为空", value: "null" },
|
||||
{ label: "不为空", value: "notNull" },
|
||||
{ label: "包含", value: "include" },
|
||||
{ label: "不包含", value: "notInclude" },
|
||||
{ label: "大于", value: "greaterThan" },
|
||||
{ label: "大于等于", value: "greaterThanOrEqual" },
|
||||
{ label: "小于", value: "lessThan" },
|
||||
{ label: "小于等于", value: "lessThanOrEqual" },
|
||||
];
|
||||
|
||||
/** 创建一条与原有数据结构兼容的判断项。 */
|
||||
const createConditionItem = () => ({
|
||||
name: "",
|
||||
nameQuote: "",
|
||||
nameType: "input",
|
||||
condition: "",
|
||||
type: "input",
|
||||
input: "",
|
||||
quote: "",
|
||||
});
|
||||
|
||||
/** 创建具有稳定锚点 ID 的条件组。 */
|
||||
const createCondition = (label = "") => ({
|
||||
id: randomUUID().replace(/-/g, ""),
|
||||
label,
|
||||
withOr: "and",
|
||||
list: [createConditionItem()],
|
||||
});
|
||||
|
||||
/**
|
||||
* 将旧版本可能保存的点号字符串统一转换为级联组件需要的路径数组。
|
||||
* @param {string|string[]} value 已保存的引用值
|
||||
* @returns {string[]} 级联选择路径
|
||||
*/
|
||||
const normalizeQuotePath = (value) => {
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === "string" && value) return value.split(".").filter(Boolean);
|
||||
return [];
|
||||
};
|
||||
|
||||
/** 深拷贝已保存条件,确保取消编辑时不会污染节点属性。 */
|
||||
const initData = () => {
|
||||
// 必须先提供选项树再赋已有值,否则级联组件无法将节点 ID 路径解析成可见标签。
|
||||
quoteOptions.value = getUpstreamInput(props.data.id) || [];
|
||||
const source = props.data?.properties?.nodeParams || props.data?.properties?.conditions || [];
|
||||
formData.nodeParams = JSON.parse(JSON.stringify(source));
|
||||
if (!formData.nodeParams.length) formData.nodeParams.push(createCondition());
|
||||
formData.nodeParams.forEach((condition, index) => {
|
||||
if (!condition.label) condition.label = `条件 ${index + 1}`;
|
||||
condition.list?.forEach((item) => {
|
||||
if (item.nameType === "quote") item.nameQuote = normalizeQuotePath(item.nameQuote);
|
||||
if (item.type === "quote") item.quote = normalizeQuotePath(item.quote);
|
||||
});
|
||||
});
|
||||
originalConditionIds.value = formData.nodeParams.map((condition) => condition.id);
|
||||
};
|
||||
|
||||
/** 返回 Element Plus 表单必填规则。 */
|
||||
const requiredRule = (message) => [{ required: true, message, trigger: ["blur", "change"] }];
|
||||
|
||||
/** 判断当前运算符是否需要比较值。 */
|
||||
const requiresComparison = (condition) => !["null", "notNull"].includes(condition);
|
||||
|
||||
/** 新增一个分支条件,保存后将生成同 ID 的节点锚点。 */
|
||||
const addCondition = () => {
|
||||
formData.nodeParams.push(createCondition(`条件 ${formData.nodeParams.length + 1}`));
|
||||
};
|
||||
|
||||
/** 删除条件;实际连线清理延迟到保存成功时执行。 */
|
||||
const removeCondition = (index) => {
|
||||
if (formData.nodeParams.length > 1) formData.nodeParams.splice(index, 1);
|
||||
};
|
||||
|
||||
/** 向指定条件组追加判断项。 */
|
||||
const addConditionItem = (conditionIndex) => {
|
||||
formData.nodeParams[conditionIndex].list.push(createConditionItem());
|
||||
};
|
||||
|
||||
/** 删除指定判断项,同时保证每个条件至少保留一项。 */
|
||||
const removeConditionItem = (conditionIndex, itemIndex) => {
|
||||
const list = formData.nodeParams[conditionIndex].list;
|
||||
if (list.length > 1) list.splice(itemIndex, 1);
|
||||
};
|
||||
|
||||
/** 切换左值类型时清空另一种类型遗留的数据。 */
|
||||
const resetNameValue = (item) => {
|
||||
if (item.nameType === "input") item.nameQuote = "";
|
||||
else item.name = "";
|
||||
};
|
||||
|
||||
/** 切换比较值类型时清空另一种类型遗留的数据。 */
|
||||
const resetCompareValue = (item) => {
|
||||
if (item.type === "input") item.quote = "";
|
||||
else item.input = "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 打开引用选择器时获取当前节点可引用的上游输出,并保留当前选值。
|
||||
* @param {boolean} visible 选择器是否打开
|
||||
* @param {object} item 当前判断项
|
||||
* @param {"nameQuote"|"quote"} field 当前引用字段
|
||||
*/
|
||||
const refreshQuoteOptions = (visible, item, field) => {
|
||||
if (!visible) return;
|
||||
quoteOptions.value = getUpstreamInput(props.data.id) || [];
|
||||
item[field] = item[field] || "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验并保存条件。先清理已删除条件的连线,再写入条件和锚点集合。
|
||||
* 新条件锚点的位置由 switch.vue 渲染摘要后自动测量并补齐。
|
||||
*/
|
||||
const validateAndSave = async () => {
|
||||
const valid = await formRef.value?.validate().then(() => true).catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
try {
|
||||
const conditions = JSON.parse(JSON.stringify(formData.nodeParams));
|
||||
const activeIds = new Set(conditions.map((condition) => condition.id));
|
||||
const removedIds = originalConditionIds.value.filter((id) => !activeIds.has(id));
|
||||
removedIds.forEach((id) => removeSwitchEdge(props.data.id, id));
|
||||
|
||||
const properties = window.lf.getProperties(props.data.id);
|
||||
const anchor = (properties.anchor || []).filter((item) => (
|
||||
item.id === `${props.data.id}_else` || activeIds.has(item.id)
|
||||
));
|
||||
window.lf.setProperties(props.data.id, {
|
||||
...properties,
|
||||
nodeParams: conditions,
|
||||
anchor,
|
||||
});
|
||||
emit("save-success");
|
||||
} catch {
|
||||
emit("save-error");
|
||||
}
|
||||
};
|
||||
|
||||
watch(() => props.data, initData, { immediate: true, deep: true });
|
||||
defineExpose({ validateAndSave });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.switch-params {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.switch-params__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.switch-params__title {
|
||||
color: #1f2937;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.switch-params__description {
|
||||
margin-top: 4px;
|
||||
color: #7a8494;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.condition-card {
|
||||
margin-bottom: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid #e4e8ef;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.condition-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
color: #3978c5;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.condition-card__identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.condition-name {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.condition-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed #dfe4ec;
|
||||
}
|
||||
|
||||
.condition-row + .condition-row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.condition-row__fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(120px, 0.8fr) minmax(0, 1.4fr);
|
||||
flex: 1;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.condition-row__actions {
|
||||
display: flex;
|
||||
padding-bottom: 18px;
|
||||
}
|
||||
|
||||
.value-type {
|
||||
width: 74px;
|
||||
}
|
||||
|
||||
.reference-field {
|
||||
display: grid;
|
||||
grid-template-columns: 74px minmax(0, 1fr);
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.else-tip {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #f0d9a7;
|
||||
border-radius: 8px;
|
||||
background: #fffaf0;
|
||||
color: #7a8494;
|
||||
font-size: 12px;
|
||||
|
||||
strong {
|
||||
color: #b7791f;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
:deep(.el-input-group__append) {
|
||||
width: 120px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.el-input-group__append .el-cascader) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@ -1,43 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { getUpstreamInput } from '@/utils/flow'
|
||||
|
||||
export function useQuote(nodeId) {
|
||||
const quoteOptions = ref([])
|
||||
const cascaderRefs = ref([])
|
||||
|
||||
const refreshQuoteOptions = () => {
|
||||
quoteOptions.value = getUpstreamInput(nodeId) || []
|
||||
}
|
||||
|
||||
// The option tree must exist before Cascader can render a persisted path.
|
||||
refreshQuoteOptions()
|
||||
|
||||
const handleTypeChange = (property) => {
|
||||
if (!property) return
|
||||
if (property.type === 'input') {
|
||||
property.quote = []
|
||||
} else {
|
||||
property.input = ''
|
||||
refreshQuoteOptions()
|
||||
}
|
||||
}
|
||||
|
||||
const cascaderChange = (value, property) => {
|
||||
if (!property) return
|
||||
property.quote = Array.isArray(value) ? [...value] : []
|
||||
property.quoteType = value?.[1] === 'input' ? 'input' : 'output'
|
||||
}
|
||||
|
||||
const visibleChange = (visible) => {
|
||||
if (visible) refreshQuoteOptions()
|
||||
}
|
||||
|
||||
return {
|
||||
quoteOptions,
|
||||
cascaderRefs,
|
||||
handleTypeChange,
|
||||
cascaderChange,
|
||||
visibleChange,
|
||||
refreshQuoteOptions,
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
/**
|
||||
* 文件说明:流程事件订阅组合逻辑,并在卸载时释放监听器。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
*/
|
||||
import { onUnmounted } from "vue";
|
||||
import { emitter } from "@/utils/eventBus";
|
||||
|
||||
export const useFlowEvents = () => {
|
||||
const listeners = [];
|
||||
|
||||
/**
|
||||
* 订阅 onFlowEvent 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} type 目标类型
|
||||
* @param {*} handler 事件处理函数
|
||||
*/
|
||||
const onFlowEvent = (type, handler) => {
|
||||
emitter.on(type, handler);
|
||||
listeners.push([type, handler]);
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
listeners.forEach(([type, handler]) => emitter.off(type, handler));
|
||||
listeners.length = 0;
|
||||
});
|
||||
|
||||
return { onFlowEvent };
|
||||
};
|
||||
@ -1,897 +0,0 @@
|
||||
/**
|
||||
* 文件说明:定义流程设计器可用的节点、表单字段及下拉选项等静态配置。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
*/
|
||||
import cameraSvg from './icon/camera.svg'
|
||||
import videoSvg from './icon/video.svg'
|
||||
import loopSvg from './icon/loop.svg'
|
||||
import stopLoopSvg from './icon/stopLoop.svg'
|
||||
import switchSvg from './icon/switch.svg'
|
||||
import microphoneSvg from './icon/microphone.svg'
|
||||
import sleepSvg from './icon/sleep.svg'
|
||||
import planSvg from './icon/plan.svg'
|
||||
import awakenSvg from './icon/awaken.svg'
|
||||
import dialogueSvg from './icon/dialogue.svg'
|
||||
import audioSvg from './icon/audio.svg'
|
||||
import touchSvg from './icon/touch.svg'
|
||||
import expressionSvg from './icon/expression.svg'
|
||||
|
||||
/**
|
||||
* 处理与 http method options 相关的状态或数据,该影响范围仅限当前模块。
|
||||
*/
|
||||
const httpMethodOptions = () => {
|
||||
return [{
|
||||
value: 'POST',
|
||||
label: 'POST'
|
||||
}, {
|
||||
value: 'GET',
|
||||
label: 'GET'
|
||||
}, {
|
||||
value: 'PUT',
|
||||
label: 'PUT'
|
||||
}, {
|
||||
value: 'DELETE',
|
||||
label: 'DELETE'
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理与 express options 相关的状态或数据,该影响范围仅限当前模块。
|
||||
*/
|
||||
const expressOptions = () => {
|
||||
return [{
|
||||
value: 1,
|
||||
label: '高兴'
|
||||
}, {
|
||||
value: 2,
|
||||
label: '惊讶'
|
||||
}, {
|
||||
value: 3,
|
||||
label: '愤怒'
|
||||
}, {
|
||||
value: 4,
|
||||
label: '悲伤'
|
||||
}, {
|
||||
value: 5,
|
||||
label: '睡觉'
|
||||
}, {
|
||||
value: 6,
|
||||
label: '打哈欠'
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理与 task type options 相关的状态或数据,该影响范围仅限当前模块。
|
||||
*/
|
||||
const taskTypeOptions = () => {
|
||||
return [{
|
||||
value: 'voice',
|
||||
label: '语音交互'
|
||||
}, {
|
||||
value: 'touch',
|
||||
label: '触控交互'
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理与 voice options 相关的状态或数据,该影响范围仅限当前模块。
|
||||
*/
|
||||
const voiceOptions = () => {
|
||||
return [{
|
||||
value: 'x4_xiaoyan',
|
||||
label: 'x4_xiaoyan'
|
||||
}]
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理与 inspection event types 相关的状态或数据,该影响范围仅限当前模块。
|
||||
*/
|
||||
const inspectionEventTypes = () => {
|
||||
return [{
|
||||
value: 'No-Glove',
|
||||
label: '没戴手套'
|
||||
}, {
|
||||
value: 'No-Helmet-Glove',
|
||||
label: '没戴头盔'
|
||||
}]
|
||||
}
|
||||
|
||||
export const collapseList = [
|
||||
{
|
||||
collapseTitle: "相机",
|
||||
nodeList: [
|
||||
{
|
||||
icon: cameraSvg,
|
||||
name: "开启相机",
|
||||
type: "serviceNode",
|
||||
desc: "在获取相机照片时,需要先启动相机",
|
||||
action: 'CAMERA_START',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [],
|
||||
outputParams: [],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: cameraSvg,
|
||||
name: "获取图片",
|
||||
type: "serviceNode",
|
||||
desc: "获取相机拍摄的照片",
|
||||
action: 'CAMERA_GETRGBIMAGE',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [],
|
||||
outputType: 'img',
|
||||
outputParams: [{ name: 'imageUrl', type: 'array<string>', desc: '图片地址数组', children: [], disabled: true }]
|
||||
},
|
||||
{
|
||||
icon: cameraSvg,
|
||||
name: "关闭相机",
|
||||
type: "serviceNode",
|
||||
desc: "获取相机照片后,关闭该相机",
|
||||
action: 'CAMERA_STOP',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [],
|
||||
outputParams: [],
|
||||
outputType: 'json'
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
collapseTitle: "录像",
|
||||
nodeList: [
|
||||
{
|
||||
icon: videoSvg,
|
||||
name: "开启录像",
|
||||
type: "serviceNode",
|
||||
desc: "开启摄像头,并开始录像",
|
||||
action: 'CAMERA_RECORDING_START',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [],
|
||||
outputParams: [],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: videoSvg,
|
||||
name: "结束录像",
|
||||
type: "serviceNode",
|
||||
desc: "停止录像,并关闭摄像头",
|
||||
action: 'CAMERA_RECORDING_STOP',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [],
|
||||
outputType: 'video',
|
||||
outputParams: [{ name: 'videoUrl', type: 'array<string>', children: [], desc: '视频播放地址数组', disabled: true}]
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
collapseTitle: "功能",
|
||||
nodeList: [
|
||||
{
|
||||
icon: loopSvg,
|
||||
name: "循环",
|
||||
type: "loop",
|
||||
action: 'START_LOOP',
|
||||
desc: "实现对列表对象循环执行一系列任务",
|
||||
nodeParams: [{ name: "loopNum", type: "input", input: null, quote: "" }],
|
||||
outputParams: [],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: planSvg,
|
||||
name: "子流程",
|
||||
type: "subFlow",
|
||||
action: 'SUB_FLOW',
|
||||
nodeType: 'NONE',
|
||||
desc: "执行同一项目中已发布的工作流",
|
||||
subFlow: {},
|
||||
nodeParams: [],
|
||||
outputParams: [],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: stopLoopSvg,
|
||||
name: "结束循环",
|
||||
type: "stopLoop",
|
||||
action: 'STOP_LOOP',
|
||||
desc: "用于立即终止当前所在的循环,跳出循环体",
|
||||
},
|
||||
// {
|
||||
// icon: startSvg,
|
||||
// name: "单次循环开始",
|
||||
// type: "subStart",
|
||||
// action: 'SUB_START',
|
||||
// desc: "循环体内部工作流的开始节点,开始循环体内部的单次流程",
|
||||
// },
|
||||
// {
|
||||
// icon: endSvg,
|
||||
// name: "单次循环结束",
|
||||
// type: "subEnd",
|
||||
// action: 'SUB_END',
|
||||
// desc: "循环体内部工作流的终止节点,结束循环体内部的单次流程",
|
||||
// },
|
||||
{
|
||||
icon: switchSvg,
|
||||
name: "分支",
|
||||
type: "branch",
|
||||
action: 'BRANCH',
|
||||
desc: "连接多个下游分支,根据设定的条件按照顺序查找的方式来匹配运行的分支,如果匹配到某条件则只运行该条件对应的分支,否则继续匹配下一条件直至结束",
|
||||
},
|
||||
{
|
||||
icon: sleepSvg,
|
||||
name: "睡眠",
|
||||
type: "sleep",
|
||||
desc: "用于睡眠整个流程,表示延迟多少毫秒",
|
||||
action: 'sleep',
|
||||
nodeParams: [
|
||||
{ name: "delayMs", type: "input", componentType: 'number', input: 0, disabled: true }
|
||||
],
|
||||
outputParams: [],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: expressionSvg,
|
||||
name: "HTTP请求",
|
||||
type: "http",
|
||||
desc: "HTTP请求",
|
||||
action: 'HTTP',
|
||||
nodeType: 'HTTP',
|
||||
nodeParams: [
|
||||
{ name: "config", type: "input", input: "", children: [
|
||||
{ name: "url", type: "input", input: "", disabled: true, required: true },
|
||||
{ name: "method", type: "input", input: "POST", componentType: 'select', selectOptions: httpMethodOptions(), disabled: true },
|
||||
{ name: "timeout", type: "input", input: "10000", componentType: 'number', required: false, disabled: true }
|
||||
], disabled: true, required: true },
|
||||
{ name: "headers", type: "input", input: "", children: [
|
||||
{ name: "", type: "input", input: ""}
|
||||
], disabled: true, required: true },
|
||||
{ name: "params", type: "input", input: "", children: [
|
||||
{ name: "", type: "input", input: ""}
|
||||
], disabled: true, required: true },
|
||||
{ name: "body", type: "input", input: "", children: [
|
||||
{ name: 'bodyType', type: "input", input: "json", disabled: true },
|
||||
{ name: 'json', type: "input", input: '{"a": 123}' },
|
||||
{ name: 'formData', type: "input", input: "", children: []}
|
||||
], required: true },
|
||||
],
|
||||
outputType: 'json',
|
||||
outputParams: [{ name: 'result', type: 'Object', children: [], desc: 'HTTP请求结果'}]
|
||||
},
|
||||
{
|
||||
icon: expressionSvg,
|
||||
name: "代码",
|
||||
type: "code",
|
||||
desc: "js代码",
|
||||
action: 'CODE',
|
||||
canAddFormItem: true,
|
||||
nodeParams: [
|
||||
{ name: "input", type: "input", input: "", required: true },
|
||||
{ name: "code", type: "input", input: `
|
||||
// 方法定义不能修改
|
||||
/**
|
||||
* 处理与 r 相关的状态或数据,该影响范围仅限当前模块。
|
||||
* @param {*} params 待处理的参数集合
|
||||
*/
|
||||
function handler(params) {
|
||||
// 返回值是一个可序列化成 json 的 dict 或 object
|
||||
const result ={
|
||||
type: 2,
|
||||
message: params.input
|
||||
}
|
||||
return result
|
||||
}
|
||||
`, required: true }
|
||||
],
|
||||
outputType: 'json',
|
||||
outputParams: [{ name: 'result', type: 'object', children: [
|
||||
{name: 'type', type: 'number', desc: '占位属性'},
|
||||
{name: 'message', type: 'string', desc: '占位属性'}
|
||||
], desc: 'js节点返回'}]
|
||||
},
|
||||
{
|
||||
icon: microphoneSvg,
|
||||
name: "获取当前循环对象",
|
||||
type: "currentLoop",
|
||||
desc: "获取当前循环对象",
|
||||
action: 'GET_CURRENT_OBJECT',
|
||||
nodeParams: [
|
||||
{ name: "array", type: "input", input: "" }
|
||||
],
|
||||
outputType: 'json',
|
||||
outputParams: [
|
||||
{ name: 'index', type: 'number', desc: '索引'},
|
||||
{ name: 'object', type: 'object', children: [
|
||||
{ name: 'test', type: 'string', desc: '占位属性' }
|
||||
], desc: '当前循环对象', }
|
||||
]
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
collapseTitle: "机器人",
|
||||
nodeList: [
|
||||
{
|
||||
icon: videoSvg,
|
||||
name: "AGV控制",
|
||||
type: "serviceNode",
|
||||
desc: "控制机器人的底盘",
|
||||
action: 'AGV_MOVE_TO_POINT',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [
|
||||
{ name: "x", type: "input", input: "", disabled: true },
|
||||
{ name: "y", type: "input", input: "", disabled: true },
|
||||
{ name: "theta", type: "input", input: "", disabled: true },
|
||||
{ name: "blockedTimeoutMs", type: "input", componentType: "number", input: 60000, min: 1, required: true, disabled: true },
|
||||
],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: videoSvg,
|
||||
name: "AGV站点导航",
|
||||
type: "serviceNode",
|
||||
desc: "从AGV站点列表选择目标站点并导航",
|
||||
action: 'AGV_MOVE_TO_STATION',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [
|
||||
{ name: "stationId", type: "input", input: "", componentType: 'select', selectOptions: [], forceSelect: true, disabled: true },
|
||||
{ name: "blockedTimeoutMs", type: "input", componentType: "number", input: 60000, min: 1, required: true, disabled: true }
|
||||
],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: cameraSvg,
|
||||
name: "通用指令",
|
||||
type: "deviceUniversal",
|
||||
desc: "执行设备通用指令",
|
||||
action: 'DEVICE_EXECUTE_JSON_COMMAND',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [
|
||||
{ name: "requestJson", type: "input", input: `{
|
||||
"a":123
|
||||
}`, required: false, disabled: true },
|
||||
],
|
||||
outputParams: [],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: planSvg,
|
||||
name: "动作队列",
|
||||
type: "actionQueue",
|
||||
desc: "在边缘端按顺序连续执行多个动作",
|
||||
action: 'SYSTEM_EXECUTE_ACTION_QUEUE',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [
|
||||
{ name: "actionId", type: "input", input: "", required: false },
|
||||
{ name: "totalTimeoutMs", type: "input", componentType: 'number', input: 60000, required: true },
|
||||
{ name: "steps", type: "input", input: [], required: true },
|
||||
],
|
||||
outputParams: [
|
||||
{ name: 'actionId', type: 'string', desc: '动作队列标识' },
|
||||
{ name: 'completedSteps', type: 'number', desc: '已完成步骤数' },
|
||||
{ name: 'result', type: 'string', desc: '执行结果' },
|
||||
],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: videoSvg,
|
||||
name: "机械臂",
|
||||
type: "serviceNode",
|
||||
desc: "控制机器人的手臂",
|
||||
action: 'ARM_MOVE_TO_POINT',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [
|
||||
{ name: "x", type: "input", input: "", disabled: true },
|
||||
{ name: "y", type: "input", input: "", disabled: true },
|
||||
{ name: "z", type: "input", input: "", disabled: true },
|
||||
{ name: "rx", type: "input", input: "", disabled: true },
|
||||
{ name: "ry", type: "input", input: "", disabled: true },
|
||||
{ name: "rz", type: "input", input: "", disabled: true },
|
||||
{ name: "velocity", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.1, disabled: true },
|
||||
{ name: "acceleration", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.2, disabled: true },
|
||||
],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: videoSvg,
|
||||
name: "机械臂关节运动",
|
||||
type: "serviceNode",
|
||||
desc: "通过关节空间插值运动到目标关节位置",
|
||||
action: 'ARM_MOVE_TO_J',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [
|
||||
{ name: "target", type: "input", input: "[0, 0, 0, 0, 0, 0]", disabled: true },
|
||||
{ name: "velocity", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.1, disabled: true },
|
||||
{ name: "acceleration", type: "input", componentType: 'number', max: 5, step: 0.01, input: 0.2, disabled: true },
|
||||
],
|
||||
outputType: 'json'
|
||||
},
|
||||
// 使能状态由两个独立节点显式控制。节点本身不接收参数,也不依赖前一运动节点的隐式使能,
|
||||
// 因而可在流程开始、异常恢复或任务收尾位置按业务需要自由编排。
|
||||
{
|
||||
icon: videoSvg,
|
||||
name: "开启机械臂使能",
|
||||
type: "serviceNode",
|
||||
desc: "开启机械臂使能,后续运动节点不会自动使能",
|
||||
action: 'ARM_ENABLE',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [],
|
||||
outputParams: [],
|
||||
outputType: 'json'
|
||||
},
|
||||
// 关闭节点与开启节点使用不同 action,边缘执行器据此下发去使能指令;
|
||||
// 空 outputParams 表示该节点只负责设备状态切换,不向后续节点暴露业务输出。
|
||||
{
|
||||
icon: videoSvg,
|
||||
name: "关闭机械臂使能",
|
||||
type: "serviceNode",
|
||||
desc: "关闭机械臂使能",
|
||||
action: 'ARM_DISABLE',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [],
|
||||
outputParams: [],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: cameraSvg,
|
||||
name: "开始监听报警事件",
|
||||
type: "serviceNode",
|
||||
desc: "开始监听报警事件",
|
||||
action: 'INSPECTION_ALERT_LISTEN_START',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [
|
||||
{ name: "robotId", type: "input", input: "", disabled: true },
|
||||
{ name: "eventTypes", type: "input", input: "", componentType: 'select', selectOptions: inspectionEventTypes(), disabled: true }
|
||||
],
|
||||
outputParams: [],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: cameraSvg,
|
||||
name: "结束监听报警事件",
|
||||
type: "serviceNode",
|
||||
desc: "结束监听报警事件",
|
||||
action: 'INSPECTION_ALERT_LISTEN_STOP',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [
|
||||
{ name: "robotId", type: "input", input: "", disabled: true },
|
||||
{ name: "eventTypes", type: "input", input: "", componentType: 'select', selectOptions: inspectionEventTypes(), disabled: true }
|
||||
],
|
||||
outputParams: [],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: dialogueSvg,
|
||||
name: "巡检仪表读数识别",
|
||||
type: "recognize",
|
||||
desc: "巡检仪表读数识别",
|
||||
action: 'INSPECTION_METER_RECOGNIZE',
|
||||
nodeType: "LLM",
|
||||
outputType: 'json',
|
||||
nodeParams: [
|
||||
{ name: 'imageUrl', type: "input", input: "", disabled: true },
|
||||
{ name: 'resultName', type: "input", input: "", disabled: true },
|
||||
{ name: "alarmRules", type: "input", input: [], disabled: true }
|
||||
],
|
||||
outputParams: []
|
||||
},
|
||||
{
|
||||
icon: cameraSvg,
|
||||
name: "人工巡检判断",
|
||||
type: "serviceNode",
|
||||
desc: "创建人工巡检判断任务",
|
||||
action: 'INSPECTION_MANUAL_REVIEW_CREATE',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [
|
||||
{ name: "resultName", type: "input", input: "", disabled: true },
|
||||
{ name: "videoUrl", type: "input", input: "", required: false, disabled: true },
|
||||
{ name: "imageUrl", type: "input", input: "", required: false, disabled: true },
|
||||
{ name: "reviewCriteria", type: "input", input: "", disabled: true },
|
||||
{ name: "defaultAlarmLevel", type: "input", componentType: 'number', max: 3, step: 1, input: 1, disabled: true },
|
||||
],
|
||||
outputParams: [],
|
||||
outputType: 'json'
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
collapseTitle: "音频",
|
||||
nodeList: [
|
||||
{
|
||||
icon: microphoneSvg,
|
||||
name: "启动麦克风",
|
||||
type: "serviceNode",
|
||||
desc: "用于启动麦克风的节点",
|
||||
action: 'MICROPHONE_START',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [],
|
||||
outputType: 'json'
|
||||
},
|
||||
{
|
||||
icon: microphoneSvg,
|
||||
name: "停止麦克风",
|
||||
type: "serviceNode",
|
||||
desc: "用于停止麦克风的节点",
|
||||
action: 'MICROPHONE_STOP',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [],
|
||||
outputType: 'audio',
|
||||
outputParams: [{ name: 'audioUrl', type: 'array<string>', children: [], desc: '录音播放地址数组', disabled: true }]
|
||||
},
|
||||
{
|
||||
icon: audioSvg,
|
||||
name: "播放音频",
|
||||
type: "serviceNode",
|
||||
desc: "用于播放音频的节点",
|
||||
action: 'SPEAKER_PLAYAUDIO',
|
||||
nodeType: 'EDGE',
|
||||
nodeParams: [
|
||||
{ name: "audioPath", type: "input", input: "", disabled: true }
|
||||
],
|
||||
outputType: 'json'
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
collapseTitle: "大模型",
|
||||
nodeList: [
|
||||
{
|
||||
icon: microphoneSvg,
|
||||
name: "声音类型检测",
|
||||
type: "mediaAnalysis",
|
||||
desc: "判断录音是否属于指定的声音类型",
|
||||
action: "AUDIO_EVENT_CLASSIFY",
|
||||
nodeType: "LLM",
|
||||
nodeParams: [
|
||||
{ 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: "asynchronous", type: "input", input: false, required: false, disabled: true, componentType: "switch" },
|
||||
],
|
||||
outputType: "json",
|
||||
outputParams: [
|
||||
{ name: "passed", type: "boolean", desc: "是否检测到目标声音", disabled: true },
|
||||
{ name: "expectedLabel", type: "string", 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: "decisionReason", type: "string", desc: "检测结论", disabled: true },
|
||||
{ name: "scores", type: "object", desc: "各分类匹配度", children: [], disabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: videoSvg,
|
||||
name: "视频智能分析",
|
||||
type: "mediaAnalysis",
|
||||
desc: "按分析场景理解测试录像并输出结构化结论",
|
||||
action: "VIDEO_ANALYZE",
|
||||
nodeType: "LLM",
|
||||
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: true, disabled: true },
|
||||
{ name: "analysisMode", type: "input", input: "AUTO", required: false, disabled: true },
|
||||
// 默认返回精简结论;用户开启详细输出时,执行端才补充事件证据。
|
||||
{ name: "detailedOutput", type: "input", input: false, required: false, disabled: true, componentType: "switch" },
|
||||
{ name: "analysisTuning", type: "input", input: { sampleFps: 2, maxWidth: 960, confidenceThreshold: 0.75, fallbackToAccurate: true }, required: false, disabled: true },
|
||||
{ name: "asynchronous", type: "input", input: false, required: false, disabled: true, componentType: "switch" },
|
||||
],
|
||||
outputType: "json",
|
||||
outputParams: [
|
||||
{ name: "result", type: "string", 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: "summary", type: "string", desc: "分析摘要", disabled: true },
|
||||
{ name: "events", type: "array<object>", desc: "关键事件", children: [], disabled: true },
|
||||
{ name: "analysisMode", type: "string", desc: "实际分析模式", disabled: true },
|
||||
{ name: "fallback", type: "boolean", desc: "是否切换精确分析", disabled: true },
|
||||
{ name: "detailedOutput", type: "boolean", desc: "是否返回详细事件证据", disabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
// 通用图片分析不内置通过/不通过判定,返回结构完全由用户提示词约定。
|
||||
icon: cameraSvg,
|
||||
name: "通用图片分析",
|
||||
type: "imageAnalysis",
|
||||
desc: "按照提示词分析一张或多张图片并返回指定内容",
|
||||
action: 'IMAGE_ANALYZE',
|
||||
nodeType: "LLM",
|
||||
nodeParams: [
|
||||
{ name: "profileCode", type: "input", input: "common.image_analysis.v1", required: true, disabled: true },
|
||||
{ name: "imageUrl", type: "quote", input: "", quote: "", required: true, disabled: true },
|
||||
{ name: "analysisMethod", type: "input", input: "VISION_MODEL", required: false, disabled: true },
|
||||
{ name: "prompt", type: "input", input: "", required: true, disabled: true, componentType: "textarea" },
|
||||
{ name: "analysisMode", type: "input", input: "FAST", required: false, disabled: true },
|
||||
{ name: "analysisTuning", type: "input", input: { maxWidth: 896, maxImages: 12, maxOutputTokens: 384, matchThreshold: 0.48, matchSelector: "BEST", matchIndex: 1, matchSort: "ROW_MAJOR", locateConfidenceThreshold: 0.45 }, required: false, disabled: true },
|
||||
{ name: "asynchronous", type: "input", input: false, required: false, disabled: true, componentType: "switch" },
|
||||
],
|
||||
outputType: "json",
|
||||
outputParams: [
|
||||
{ name: "result", type: "object", desc: "按提示词返回的结果", children: [], disabled: true },
|
||||
{ name: "resultText", type: "string", desc: "模型返回的原始文本", disabled: true },
|
||||
{ name: "matched", type: "boolean", desc: "找图是否匹配", disabled: true },
|
||||
{ name: "matchScore", type: "number", desc: "匹配分数或定位置信度", disabled: true },
|
||||
{ name: "coordinates", type: "array<number>", desc: "原图坐标 [x1,y1,x2,y2]", children: [], disabled: true },
|
||||
{ name: "center", type: "array<number>", desc: "目标中心点 [x,y]", children: [], disabled: true },
|
||||
{ name: "matchedDescription", type: "string", desc: "模型实际定位的目标描述", disabled: true },
|
||||
{ name: "refinementMethod", type: "string", desc: "坐标精修方式", disabled: true },
|
||||
{ name: "matchCount", type: "number", desc: "匹配项数量", disabled: true },
|
||||
{ name: "matches", type: "array<object>", desc: "全部匹配项", children: [], disabled: true },
|
||||
{ name: "selectedMatchIndex", type: "number", desc: "当前选择的匹配序号", disabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: microphoneSvg,
|
||||
name: "获取触控坐标",
|
||||
type: "serviceNode",
|
||||
desc: "用于获取触控坐标的节点",
|
||||
action: 'TOUCH_COORDINATES',
|
||||
nodeType: "LLM",
|
||||
nodeParams: [
|
||||
{ name: "targetFeature", type: "input", input: "运动模式", disabled: true },
|
||||
{ name: "manufacturer", type: "input", input: "xiaomi", disabled: true },
|
||||
{ name: "vehType", type: "input", input: "su7", disabled: true },
|
||||
],
|
||||
outputType: 'json',
|
||||
outputParams: [{ name: 'coordinates', type: 'object', children: [
|
||||
{ name: 'x', type: 'number', desc: 'x坐标' },
|
||||
{ name: 'y', type: 'number', desc: 'y坐标' }
|
||||
], desc: '坐标对象', }]
|
||||
}, {
|
||||
icon: dialogueSvg,
|
||||
name: "tts语音合成",
|
||||
type: "serviceNode",
|
||||
desc: "将指令文本(text)处理合成语音,输出语音路径(audioPath)",
|
||||
action: 'GENERATE_ADVANCED_AUDIO',
|
||||
nodeType: "LLM",
|
||||
outputType: 'json',
|
||||
nodeParams: [{ name: 'text', type: "input", input: "", disabled: true }],
|
||||
outputParams: [{ name: 'audioPath', type: 'string', desc: '语音路径', disabled: true}]
|
||||
}, {
|
||||
icon: dialogueSvg,
|
||||
name: "意图识别",
|
||||
type: "serviceNode",
|
||||
desc: "意图识别,输出类型(type) 1: 语音交互 2: 触控交互 3: 闲聊 4: 知识问答",
|
||||
action: 'INTENT_RECOGNITION',
|
||||
nodeType: "LLM",
|
||||
outputType: 'json',
|
||||
nodeParams: [{ name: 'text', type: "input", input: "", disabled: true }],
|
||||
outputParams: [{ name: 'type', type: 'string', desc: '1:语音交互 2:触控交互 3: 闲聊 4:知识问答', disabled: true}]
|
||||
}, {
|
||||
icon: dialogueSvg,
|
||||
name: "商道智能体",
|
||||
type: "sdAgent",
|
||||
desc: "通过apiKey调用商道大模型",
|
||||
action: 'AI_AGENT_PLATFORM',
|
||||
nodeType: "LLM",
|
||||
outputType: 'json',
|
||||
nodeParams: [
|
||||
{ name: "config", type: "input", input: "", children: [
|
||||
{ name: 'apiKey', type: "input", input: "", disabled: true },
|
||||
{ name: 'text', type: "input", input: "", disabled: true },
|
||||
]},
|
||||
{ name: 'invokeTts', type: "input", input: false },
|
||||
{ name: 'tts', type: "input", input: "", children: [
|
||||
{ name: 'url', type: "input", input: "", disabled: true },
|
||||
{ name: 'text', type: "input", input: "", disabled: true },
|
||||
{ name: 'speed', type: "input", input: "", max: 100, disabled: true },
|
||||
{ name: 'voice', type: "input", input: "", componentType: 'select', selectOptions: voiceOptions(), disabled: true },
|
||||
{ name: 'volume', type: "input", input: "", componentType: 'number', max: 100, disabled: true }
|
||||
], disabled: true }
|
||||
],
|
||||
outputParams: [{ name: 'result', type: 'string', desc: '商道大模型的返回文案', disabled: true}]
|
||||
}, {
|
||||
icon: dialogueSvg,
|
||||
name: "tts语音播放",
|
||||
type: "serviceNode",
|
||||
desc: "调用tts将文案转换为语音播放",
|
||||
action: 'AI_TTS',
|
||||
nodeType: "LLM",
|
||||
outputType: 'json',
|
||||
nodeParams: [
|
||||
{ name: 'url', type: "input", input: "", disabled: true },
|
||||
{ name: 'text', type: "input", input: "", disabled: true },
|
||||
{ name: 'speed', type: "input", input: "", max: 100, disabled: true },
|
||||
{ name: 'voice', type: "input", input: "", componentType: 'select', selectOptions: voiceOptions(), disabled: true },
|
||||
{ name: 'volume', type: "input", input: "", componentType: 'number', max: 100, disabled: true }
|
||||
]
|
||||
}, {
|
||||
icon: dialogueSvg,
|
||||
name: "获取当前页面名称",
|
||||
type: "serviceNode",
|
||||
desc: "获取当前页面名称",
|
||||
action: 'GET_CURRENT_PAGE',
|
||||
nodeType: "LLM",
|
||||
outputType: 'json',
|
||||
nodeParams: [
|
||||
{ name: 'url', type: "input", input: "", disabled: true },
|
||||
],
|
||||
outputParams: [{ name: 'pageName', type: 'string', desc: '当前页面名称', disabled: true}]
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
collapseTitle: "语音交互",
|
||||
nodeList: [
|
||||
{
|
||||
icon: planSvg,
|
||||
name: "方案处理",
|
||||
type: "serviceNode",
|
||||
desc: "语音交互-方案处理,输入方案id(schemeId),输出唤醒语料(wakeCorpus)和测试语料数组(testCorpus)",
|
||||
action: 'VI_SCHEME',
|
||||
nodeParams: [{ name: 'schemeId', type: "input", input: "", disabled: true }],
|
||||
outputType: 'json',
|
||||
outputParams: [
|
||||
{ name: 'wakeCorpus', type: 'object', desc: '唤醒语料', children: []},
|
||||
{ name: 'testCorpus', type: 'array<object>', desc: '测试语料数组', children: []},
|
||||
{ name: 'sceneType', type: 'number', desc: '1:唤醒测试,2:单次对话测试,3:连续对话测试', disabled: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: awakenSvg,
|
||||
name: "唤醒语料",
|
||||
type: "serviceNode",
|
||||
desc: "唤醒语料处理,输入唤醒语料(wakeCorpus),输出语音路径(audioPath)",
|
||||
action: 'VI_CORPUS_WAKE',
|
||||
nodeType: "EDGE",
|
||||
outputType: 'json',
|
||||
nodeParams: [{ name: 'wakeCorpus', type: "input", input: "", disabled: true }],
|
||||
outputParams: [{ name: 'audioPath', type: 'string', desc: '语音路径', disabled: true}]
|
||||
},
|
||||
{
|
||||
icon: dialogueSvg,
|
||||
name: "单次对话语料",
|
||||
type: "serviceNode",
|
||||
desc: "测试语料-单次对话语料处理,输入单次对话语料(testCorpus),输出语音路径(audioPath)",
|
||||
action: 'VI_CORPUS_SINGLE',
|
||||
nodeType: "EDGE",
|
||||
outputType: 'json',
|
||||
nodeParams: [{ name: 'testCorpus', type: "input", input: "", disabled: true }],
|
||||
outputParams: [{ name: 'audioPath', type: 'string', desc: '语音路径', disabled: true}]
|
||||
},
|
||||
{
|
||||
icon: dialogueSvg,
|
||||
name: "连续对话语料",
|
||||
type: "serviceNode",
|
||||
desc: "测试语料-连续对话语料处理,输入连续对话语料(testCorpus),输出语音路径(audioPath)",
|
||||
action: 'VI_CORPUS_CONTINUOUS',
|
||||
nodeType: "EDGE",
|
||||
outputType: 'json',
|
||||
nodeParams: [{ name: 'testCorpus', type: "input", input: "", disabled: true }],
|
||||
outputParams: [{ name: 'audioPath', type: 'string', desc: '语音路径', disabled: true}]
|
||||
},
|
||||
{
|
||||
icon: audioSvg,
|
||||
name: "播放语料",
|
||||
type: "serviceNode",
|
||||
desc: "用于播放语料的节点,需要输入语音路径,执行设备由流程角色配置",
|
||||
action: 'VI_PLAY_CORPUS',
|
||||
nodeType: "EDGE",
|
||||
outputType: 'json',
|
||||
nodeParams: [
|
||||
{ name: 'audioPath', type: "input", input: "", disabled: true },
|
||||
{ name: 'bioId', type: "input", input: "", disabled: true, required: false }
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
collapseTitle: "触控交互",
|
||||
nodeList: [
|
||||
{
|
||||
icon: touchSvg,
|
||||
name: "触控",
|
||||
type: "serviceNode",
|
||||
desc: "去触控坐标的点位",
|
||||
action: 'TOUCH',
|
||||
nodeType: "EDGE",
|
||||
nodeParams: [
|
||||
{ name: "touchParams", type: "input", input: "", disabled: true }
|
||||
],
|
||||
outputType: 'json',
|
||||
},
|
||||
{
|
||||
icon: expressionSvg,
|
||||
name: "开始说话",
|
||||
type: "serviceNode",
|
||||
desc: "控制机器人说话",
|
||||
action: 'BIO_HEAD_SPEAK_START',
|
||||
nodeType: "EDGE",
|
||||
nodeParams: [],
|
||||
outputType: 'json',
|
||||
},
|
||||
{
|
||||
icon: expressionSvg,
|
||||
name: "停止说话",
|
||||
type: "serviceNode",
|
||||
desc: "停止机器人说话",
|
||||
action: 'BIO_HEAD_SPEAK_STOP',
|
||||
nodeType: "EDGE",
|
||||
nodeParams: [],
|
||||
outputType: 'json',
|
||||
},
|
||||
{
|
||||
icon: expressionSvg,
|
||||
name: "表情",
|
||||
type: "serviceNode",
|
||||
desc: "控制机器人的面部表情",
|
||||
action: 'BIO_HEAD_SPECIAL_EXPRESSION',
|
||||
nodeType: "EDGE",
|
||||
nodeParams: [
|
||||
{ name: "expressKey", type: "input",componentType: 'select', selectOptions: expressOptions(), disabled: true },
|
||||
],
|
||||
outputType: 'json',
|
||||
},
|
||||
{
|
||||
icon: touchSvg,
|
||||
name: "路径搜索",
|
||||
type: "serviceNode",
|
||||
desc: "路径搜索",
|
||||
action: 'TI_PATH_SEARCH',
|
||||
nodeType: "EDGE",
|
||||
nodeParams: [
|
||||
{ name: "currentLocation", type: "input", input: "", disabled: true },
|
||||
{ name: "funcId", type: "input", input: "", disabled: true },
|
||||
{ name: "vehicleConfigId", type: "input", input: "", disabled: true },
|
||||
],
|
||||
outputType: 'json',
|
||||
outputParams: [
|
||||
{ name: 'nextLocation', type: 'string', desc: '下一路径位置', disabled: true},
|
||||
{ name: 'isEnd', type: 'string', desc: '是否结束', disabled: true},
|
||||
{ name: 'iconUrl', type: 'string', desc: '图标路径', disabled: true}
|
||||
]
|
||||
},
|
||||
{
|
||||
icon: touchSvg,
|
||||
name: "获取触控二维坐标",
|
||||
type: "serviceNode",
|
||||
desc: "获取触控二维坐标",
|
||||
action: 'TI_TOUCH_COORDINATES',
|
||||
nodeType: "EDGE",
|
||||
nodeParams: [
|
||||
{ name: "imageUrl", type: "input", input: "", disabled: true },
|
||||
{ name: "iconUrl", type: "input", input: "", disabled: true }
|
||||
],
|
||||
outputType: 'json',
|
||||
outputParams: [
|
||||
{ name: 'touchCoordinates', type: 'string', desc: '触控二维坐标', disabled: true},
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
collapseTitle: "AI评估",
|
||||
nodeList: [
|
||||
{
|
||||
icon: expressionSvg,
|
||||
name: "AI评估预处理",
|
||||
type: "serviceNode",
|
||||
desc: "AI评估预处理",
|
||||
action: 'AI_EVALUATION_PRE',
|
||||
nodeType: "AE",
|
||||
nodeParams: [
|
||||
{ name: "taskType", type: "input", input: "", componentType: 'select', selectOptions: taskTypeOptions(), disabled: true },
|
||||
{ name: "wakeCorpus", type: "input", input: "", disabled: true, required: false },
|
||||
{ name: "testCorpus", type: "input", input: "", disabled: true, required: false },
|
||||
{ name: "sceneType", type: "input", input: "", disabled: true, required: false },
|
||||
],
|
||||
outputType: 'json',
|
||||
},
|
||||
{
|
||||
icon: touchSvg,
|
||||
name: "AI评估执行",
|
||||
type: "serviceNode",
|
||||
desc: "AI评估执行",
|
||||
action: 'AI_EVALUATION_END',
|
||||
nodeType: "AE",
|
||||
nodeParams: [
|
||||
{ name: "videoUrl", type: "input", input: "", disabled: true }
|
||||
],
|
||||
outputType: 'json',
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
@ -1 +0,0 @@
|
||||
<svg t="1758526814700" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11094" width="200" height="200"><path d="M983.779623 312.719408a510.292483 510.292483 0 0 0-109.795373-162.701951A511.088926 511.088926 0 0 0 512.000853 0.001707a508.756485 508.756485 0 0 0-361.983396 150.01575A511.088926 511.088926 0 0 0 0.001707 512.000853a508.756485 508.756485 0 0 0 150.01575 361.983397 511.088926 511.088926 0 0 0 162.701951 109.738484c63.089673 26.680844 130.161561 40.163489 199.281445 40.163488a508.756485 508.756485 0 0 0 199.281446-40.163488 508.358264 508.358264 0 0 0 162.701951-109.738484 511.088926 511.088926 0 0 0 109.738484-162.701951c26.680844-63.089673 40.163489-130.161561 40.163488-199.281446a508.642708 508.642708 0 0 0-40.106599-199.281445z m-55.295908 375.181597a451.583247 451.583247 0 0 1-96.881617 143.701093 451.583247 451.583247 0 0 1-143.701093 96.881617c-55.751018 23.60885-114.915364 35.498608-175.900152 35.498607a448.397475 448.397475 0 0 1-175.900151-35.498607 451.583247 451.583247 0 0 1-143.701094-96.881617 451.583247 451.583247 0 0 1-96.881616-143.701093A448.397475 448.397475 0 0 1 60.019384 512.000853c0-60.984787 11.889758-120.206022 35.498608-175.900151 22.755518-53.816799 55.409685-102.172274 96.881616-143.701094a451.583247 451.583247 0 0 1 143.701094-96.881616A448.397475 448.397475 0 0 1 512.000853 60.019384c60.984787 0 120.206022 11.889758 175.900152 35.498608 53.816799 22.755518 102.172274 55.409685 143.701093 96.881616 41.52882 41.52882 74.126099 89.884295 96.881617 143.701094 23.60885 55.751018 35.498608 114.915364 35.498607 175.900151s-11.889758 120.206022-35.498607 175.900152z" fill="#65BEE0" p-id="11095"></path><path d="M263.965711 416.029458a30.094172 30.094172 0 0 0-29.980394 29.980394v131.982002a30.094172 30.094172 0 0 0 60.017677 0V446.009852a30.094172 30.094172 0 0 0-29.980394-29.980394z m124.017571-63.999894a30.094172 30.094172 0 0 0-29.980394 29.980395v259.981789c0 16.49775 13.482644 29.980394 29.980394 29.980394 16.49775 0 30.037283-13.482644 30.037284-29.980394v-259.981789a30.094172 30.094172 0 0 0-30.037284-29.980395zM512.000853 256.00128a30.094172 30.094172 0 0 0-29.980394 29.980394v452.038358c0 16.49775 13.482644 29.980394 29.980394 29.980395 16.49775 0 29.980394-13.482644 29.980395-29.980395V285.981674A30.094172 30.094172 0 0 0 512.000853 256.00128z m248.035142 160.028178a30.094172 30.094172 0 0 0-30.037283 29.980394v131.982002c0 16.49775 13.482644 29.980394 29.980395 29.980395 16.49775 0 30.037283-13.482644 30.037283-29.980395V446.009852a30.094172 30.094172 0 0 0-30.037283-29.980394z m-124.017571-63.999894a30.094172 30.094172 0 0 0-30.037283 29.980395v259.981789a30.094172 30.094172 0 0 0 60.017678 0v-259.981789a30.094172 30.094172 0 0 0-29.980395-29.980395z" fill="#65BEE0" p-id="11096"></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@ -1 +0,0 @@
|
||||
<svg t="1758526221559" class="icon" viewBox="0 0 1142 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3938" width="200" height="200"><path d="M477.13739 76.859452c-260.63725 0-471.90562 211.26837-471.90562 471.90562 0 260.542127 211.26837 471.90562 471.90562 471.90562 260.63725 0 471.90562-211.26837 471.90562-471.90562C948.947887 288.127822 737.77464 76.859452 477.13739 76.859452zM477.13739 874.08602c-37.00288 0-67.156897-29.393033-67.156897-65.349559 0-4.661031 3.614677-8.370831 8.085462-8.370831l118.047747 0c4.470785 0 8.085462 3.7098 8.085462 8.370831C544.294287 844.692987 514.140269 874.08602 477.13739 874.08602zM696.966837 750.235764 254.834742 750.235764c-3.424431 0-6.278124-2.853693-6.278124-6.46837l0-47.085927c0-28.441802 32.532095-50.985973 75.337483-53.459173-5.23177-15.505063-7.895216-31.485741-7.895216-47.751788L315.998885 464.676266c0-66.776405 43.281003-124.991732 108.154947-146.394426L424.153832 304.964608c0-27.775941 23.210033-50.320111 51.746958-50.320111 28.536925 0 51.746958 22.544171 51.746958 50.320111l0 13.222109c64.873943 21.402694 108.154947 79.618021 108.154947 146.394426l0 130.889364c0 16.170924-2.663446 32.151602-7.990339 47.751788 42.805388 2.4732 75.337483 25.017371 75.337483 53.459173l0 47.085927C703.244961 747.382072 700.486391 750.235764 696.966837 750.235764z" p-id="3939" fill="#65BEE0"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1751611040437" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11706" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M721.6896 390.4512H629.952c-7.36-0.059733-13.9008-3.8912-17.117867-9.992533l-33.9072-62.916267H445.073067L411.144533 380.501333c-3.2 6.058667-9.736533 9.890133-17.0496 9.949867H302.3104c-25.984 0-46.3488 18.875733-46.3488 42.973867v230.8736c0 24.085333 20.356267 42.965333 46.3488 42.965333h419.383467c26.001067 0 46.357333-18.884267 46.357333-42.965333V433.425067c-0.008533-24.098133-20.360533-42.973867-46.3616-42.973867zM512 688.1152c-82.824533 0-150.212267-62.468267-150.212267-139.2512C361.787733 472.072533 429.175467 409.6 512 409.6c82.833067 0 150.2336 62.472533 150.2336 139.268267 0 76.782933-67.400533 139.246933-150.2336 139.246933z" fill="#65BEE0" p-id="11707"></path><path d="M512 0C229.2224 0 0 229.230933 0 512c0 282.7648 229.2224 512 512 512 282.781867 0 512-229.2352 512-512C1024 229.230933 794.781867 0 512 0zM292.974933 298.734933c0-3.106133 2.722133-5.6192 6.058667-5.6192h24.840533c3.3536 0 6.058667 2.5088 6.058667 5.6192v23.022934c0 3.106133-2.7136 5.614933-6.058667 5.614933h-24.840533c-3.336533 0-6.058667-2.513067-6.058667-5.614933v-23.022934zM806.4 664.298667c0 43.285333-37.998933 78.4896-84.7104 78.4896H302.3104C255.598933 742.792533 217.6 707.584 217.6 664.298667V433.425067C217.6 390.126933 255.598933 354.901333 302.3104 354.901333h79.803733l34.154667-63.1552c2.999467-5.504 8.708267-9.122133 15.291733-9.710933a15.581867 15.581867 0 0 1 1.979734-0.0256h157.102933c7.240533 0 13.768533 3.720533 17.066667 9.7024l34.176 63.189333h79.799466c46.715733 0 84.714667 35.2256 84.714667 78.523734v230.8736z" fill="#65BEE0" p-id="11708"></path><path d="M512 445.124267c-29.9776 0-58.0864 10.773333-79.189333 30.331733-21.085867 19.549867-32.699733 45.623467-32.699734 73.408s11.613867 53.8496 32.699734 73.399467c21.098667 19.5584 49.207467 30.331733 79.189333 30.331733 29.981867 0 58.082133-10.773333 79.185067-30.331733s32.7168-45.623467 32.7168-73.399467c0-27.784533-11.618133-53.8496-32.7168-73.408-21.085867-19.554133-49.2032-30.331733-79.185067-30.331733zM609.809067 549.333333c-0.174933 9.642667-8.759467 17.493333-19.1488 17.493334h-0.290134c-5.1328-0.0768-9.915733-1.992533-13.482666-5.410134-3.549867-3.383467-5.474133-7.867733-5.4016-12.599466 0-14.784-6.173867-28.6336-17.348267-39.0016-11.157333-10.333867-26.048-16.055467-41.9456-16.1024-10.487467 0.580267-19.524267-6.8864-20.2496-16.520534-0.3584-4.7232 1.288533-9.309867 4.650667-12.898133 3.362133-3.605333 8.021333-5.764267 13.141333-6.097067 0.827733-0.059733 1.646933-0.059733 2.466133-0.017066 53.853867 0.093867 97.621333 40.7296 97.621334 90.670933v0.482133h-0.0128z" fill="#65BEE0" p-id="11709"></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.9 KiB |
@ -1 +0,0 @@
|
||||
<svg t="1762763163012" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="23408" width="200" height="200"><path d="M0 0m234.057143 0l555.885714 0q234.057143 0 234.057143 234.057143l0 555.885714q0 234.057143-234.057143 234.057143l-555.885714 0q-234.057143 0-234.057143-234.057143l0-555.885714q0-234.057143 234.057143-234.057143Z" fill="#65BEE0" p-id="23409" data-spm-anchor-id="a313x.search_index.0.i15.2cb83a81yhcKKA" class=""></path><path d="M735.319771 277.942857H376.451657A69.266286 69.266286 0 0 0 307.2 347.194514v197.953829A69.266286 69.266286 0 0 0 376.451657 614.4H446.171429s3.657143 43.885714-25.6 58.514286c0 0 72.206629-14.628571 94.6176-58.514286h220.130742A69.266286 69.266286 0 0 0 804.571429 545.148343V347.194514A69.266286 69.266286 0 0 0 735.319771 277.942857zM446.171429 482.742857a29.257143 29.257143 0 0 1-29.257143-29.257143 29.257143 29.257143 0 0 1 29.257143-29.257143 29.257143 29.257143 0 0 1 29.257142 29.257143 29.257143 29.257143 0 0 1-29.257142 29.257143z m109.714285 0a29.257143 29.257143 0 0 1-29.257143-29.257143 29.257143 29.257143 0 0 1 29.257143-29.257143 29.257143 29.257143 0 0 1 29.257143 29.257143 29.257143 29.257143 0 0 1-29.257143 29.257143z m109.714286 0a29.257143 29.257143 0 0 1-29.257143-29.257143 29.257143 29.257143 0 0 1 29.257143-29.257143 29.257143 29.257143 0 0 1 29.257143 29.257143 29.257143 29.257143 0 0 1-29.257143 29.257143z" fill="#ffffff" opacity=".34" p-id="23410" data-spm-anchor-id="a313x.search_index.0.i17.2cb83a81yhcKKA" class="selected"></path><path d="M662.176914 351.085714H303.3088A69.266286 69.266286 0 0 0 234.057143 420.337371v197.953829A69.266286 69.266286 0 0 0 303.3088 687.542857H373.028571s3.657143 43.885714-25.6 58.514286c0 0 72.206629-14.628571 94.6176-58.514286h220.130743A69.266286 69.266286 0 0 0 731.428571 618.2912V420.337371A69.266286 69.266286 0 0 0 662.176914 351.085714zM373.028571 555.885714a29.257143 29.257143 0 0 1-29.257142-29.257143 29.257143 29.257143 0 0 1 29.257142-29.257142 29.257143 29.257143 0 0 1 29.257143 29.257142 29.257143 29.257143 0 0 1-29.257143 29.257143z m109.714286 0a29.257143 29.257143 0 0 1-29.257143-29.257143 29.257143 29.257143 0 0 1 29.257143-29.257142 29.257143 29.257143 0 0 1 29.257143 29.257142 29.257143 29.257143 0 0 1-29.257143 29.257143z m109.714286 0a29.257143 29.257143 0 0 1-29.257143-29.257143 29.257143 29.257143 0 0 1 29.257143-29.257142 29.257143 29.257143 0 0 1 29.257143 29.257142 29.257143 29.257143 0 0 1-29.257143 29.257143z" fill="#ffffff" p-id="23411" data-spm-anchor-id="a313x.search_index.0.i14.2cb83a81yhcKKA" class="selected"></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@ -1 +0,0 @@
|
||||
<svg class="llm-icon llm-icon-workflow-end " width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"><rect width="20" height="20" fill="#65BEE0" rx="6"></rect><path fill="#fff" d="M13.143 5.858c.368.261.695.557.98.888.287.331.532.689.736 1.073a5.41 5.41 0 0 1 .625 2.524 5.3 5.3 0 0 1-.435 2.139 5.594 5.594 0 0 1-1.176 1.74 5.597 5.597 0 0 1-1.74 1.176 5.296 5.296 0 0 1-2.14.435c-.75 0-1.46-.145-2.125-.435a5.67 5.67 0 0 1-1.747-1.176 5.459 5.459 0 0 1-1.176-1.74 5.36 5.36 0 0 1-.429-2.139c0-.433.051-.858.153-1.274a5.65 5.65 0 0 1 .435-1.189c.188-.376.421-.727.699-1.054.278-.327.588-.62.931-.882a.755.755 0 0 1 .582-.147.769.769 0 0 1 .509.306c.13.172.18.364.147.576a.773.773 0 0 1-.306.515 3.804 3.804 0 0 0-1.183 1.384 3.892 3.892 0 0 0-.41 1.765c0 .54.101 1.048.306 1.526a3.937 3.937 0 0 0 2.09 2.095c.477.209.985.313 1.525.313.539 0 1.047-.104 1.525-.313.478-.208.895-.49 1.25-.845.356-.356.638-.772.846-1.25.208-.478.313-.987.313-1.526 0-.637-.148-1.24-.442-1.807a3.823 3.823 0 0 0-1.237-1.404.738.738 0 0 1-.325-.502.774.774 0 0 1 .129-.588.75.75 0 0 1 .502-.313.774.774 0 0 1 .588.129Zm-3.15 4.522a.743.743 0 0 1-.544-.227.744.744 0 0 1-.227-.545V4.95a.76.76 0 0 1 .227-.551.733.733 0 0 1 .545-.233c.22 0 .406.077.557.232a.763.763 0 0 1 .227.552v4.657a.743.743 0 0 1-.227.545.758.758 0 0 1-.557.227Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@ -1 +0,0 @@
|
||||
<svg t="1762999534829" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4760" width="200" height="200"><path d="M512 979C263.472 979 62 777.528 62 529S263.472 79 512 79s450 201.472 450 450-201.472 450-450 450zM337 479c41.421 0 75-33.579 75-75s-33.579-75-75-75-75 33.579-75 75 33.579 75 75 75z m350 0c41.421 0 75-33.579 75-75s-33.579-75-75-75-75 33.579-75 75 33.579 75 75 75zM312 629c0 110.457 89.543 200 200 200s200-89.543 200-200H312z" fill="#65BEE0" p-id="4761"></path></svg>
|
||||
|
Before Width: | Height: | Size: 520 B |
@ -1,3 +0,0 @@
|
||||
<svg t="1754464559780" class="icon" viewBox="0 0 1025 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2721" width="200" height="200">
|
||||
<path d="M933.68106667 450.448H221.44853333c-26.4512 0-48.81493333 17.27786667-56.51626666 41.184l-62.14186667 191.44533333V153.7312h296.784c0 65.55733333 53.104 118.66026667 118.66346667 118.66026667h296.71786666V391.05066667h59.3248V272.39146667c0-32.74453333-26.57813333-59.33226667-59.3248-59.33226667H518.16746667c-32.74133333 0-59.32906667-26.5824-59.32906667-59.32693333 0-32.74666667-26.51733333-59.3312-59.33013333-59.3312H102.7904c-32.74346667 0-59.32906667 26.58453333-59.32906667 59.3312v712.09813333c0 32.74666667 26.58453333 59.3248 59.32906667 59.3248H814.89066667c26.4512 0 48.81493333-17.2736 56.51733333-41.184h0.06506667l121.40586666-374.19093333c0-32.7456-26.45226667-59.26506667-59.19786666-59.33226667z m-115.712 415.38133333h-715.17866667l115.57973333-356.05013333h715.1744L817.97013333 865.82933333z" fill="#65BEE0" p-id="2722"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1013 B |
@ -1 +0,0 @@
|
||||
<svg class="llm-icon llm-icon-cycle-vertices " width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"><rect width="20" height="20" fill="#65BEE0" rx="6"></rect><path fill="#fff" fill-rule="evenodd" d="M15.7028 9.15436C15.7418 9.67543 15.7028 10.8288 15.5783 11.3128L17.5 11.5591L15.447 12.8685L13.3941 14.1797L11.8043 12.3341L10.2135 10.4894L12.6676 10.748C12.7142 10.4456 12.739 10.1376 12.7371 9.82282C12.7199 7.26403 10.3951 5.10365 8.10917 4.31062C11.1453 3.53947 15.4252 5.38796 15.7028 9.15436ZM4.23534 10.689C4.21062 10.1679 4.27718 9.01452 4.41505 8.53528L2.5 8.23956L4.58621 6.98061L6.67147 5.72356L8.21378 7.60913L9.75609 9.49186L7.3095 9.17427C7.2553 9.47379 7.22297 9.78187 7.21632 10.0966C7.16877 12.6545 9.43755 14.8719 11.7035 15.7239C8.64928 16.417 4.41695 14.4621 4.23534 10.689Z" clip-rule="evenodd"></path></svg>
|
||||
|
Before Width: | Height: | Size: 868 B |
@ -1,4 +0,0 @@
|
||||
<svg t="1754641759358" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6343" width="200" height="200">
|
||||
<path d="M512 512m-512 0a512 512 0 1 0 1024 0 512 512 0 1 0-1024 0Z" fill="#65BEE0" p-id="6344"></path>
|
||||
<path d="M512 691.2c-112.64 0-204.8-92.16-204.8-204.8v-25.6c0-7.68 5.12-12.8 12.8-12.8s12.8 5.12 12.8 12.8V485.12c0 98.56 80.64 179.2 179.2 179.2s179.2-80.64 179.2-179.2v-23.04-1.28c0-7.68 5.12-12.8 12.8-12.8s12.8 5.12 12.8 12.8v25.6c0 112.64-92.16 204.8-204.8 204.8z m12.8 38.4h38.4c7.68 0 12.8 5.12 12.8 12.8s-5.12 12.8-12.8 12.8h-102.4c-7.68 0-12.8-5.12-12.8-12.8s5.12-12.8 12.8-12.8h38.4v-38.4h25.6v38.4z m-12.8-89.6c-84.48 0-153.6-69.12-153.6-153.6v-64c0-84.48 69.12-153.6 153.6-153.6s153.6 69.12 153.6 153.6v64c0 84.48-69.12 153.6-153.6 153.6z" fill="#fff" p-id="6345">
|
||||
</path></svg>
|
||||
|
Before Width: | Height: | Size: 840 B |
@ -1 +0,0 @@
|
||||
<svg t="1758526128932" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2666" width="200" height="200"><path d="M512 575.146667c-10.922667 0-21.845333-2.730667-31.402667-8.192L352.426667 494.933333l-48.810667 27.477334L512 639.658667l208.384-117.248L671.573333 494.933333l-128.170666 72.192c-9.557333 5.290667-20.48 8.021333-31.402667 8.021334z" fill="#65BEE0" p-id="2667"></path><path d="M512 708.437333c-10.922667 0-21.845333-2.730667-31.402667-8.192l-146.432-82.432-30.378666 17.237334L512 752.128l208.384-117.248-30.378667-17.237333-146.432 82.432c-9.728 5.632-20.650667 8.362667-31.573333 8.362666z" fill="#65BEE0" p-id="2668"></path><path d="M512 34.133333C248.149333 34.133333 34.133333 248.149333 34.133333 512s214.016 477.866667 477.866667 477.866667 477.866667-214.016 477.866667-477.866667S775.850667 34.133333 512 34.133333z m248.149333 432.128c20.138667 11.434667 32.768 32.768 32.768 55.978667 0 23.210667-12.458667 44.544-32.597333 55.978667l-0.853333 0.341333 0.682666 0.341333c20.138667 11.264 32.768 32.768 32.768 55.808 0 23.210667-12.458667 44.544-32.768 55.978667l-216.746666 122.026667a64.529067 64.529067 0 0 1-62.976 0l-216.746667-122.026667a64.494933 64.494933 0 0 1-32.768-55.978667c0-23.210667 12.629333-44.544 32.768-55.978666l0.682667-0.341334-0.682667-0.341333a64.494933 64.494933 0 0 1-32.768-55.978667c0-23.210667 12.629333-44.544 32.768-55.978666l18.944-10.581334-18.944-10.752a64.494933 64.494933 0 0 1-32.768-55.978666c0-23.210667 12.458667-44.544 32.768-55.978667l216.746667-122.026667c19.456-10.922667 43.52-10.922667 62.976 0l216.746666 122.026667c20.138667 11.434667 32.768 32.768 32.768 55.978667 0 23.210667-12.629333 44.544-32.768 55.978666l-18.944 10.752 18.944 10.752z" fill="#65BEE0" p-id="2669"></path><path d="M720.384 389.12L512 271.701333 303.616 389.12 512 506.368z" fill="#65BEE0" p-id="2670"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@ -1 +0,0 @@
|
||||
<svg t="1754883869250" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6366" width="200" height="200"><path d="M511.942201 0.978972C230.218595 0.978972 1.018709 229.934416 1.018709 511.356985c0 281.420161 229.199886 510.373196 510.923492 510.373197 281.726014 0 510.921084-228.953036 510.921084-510.373197C1022.863285 229.934416 793.668215 0.978972 511.942201 0.978972z m84.587765 239.952925h133.102877c8.273699 0 15.674391 4.957476 18.846116 12.63151 3.140417 7.601785 1.293255 16.53054-4.591415 22.216526l-95.881467 94.255867h105.926467c11.23229 0 20.366955 9.139481 20.366955 20.366954 0 11.23229-9.134665 20.371771-20.366955 20.371772H598.204936c-8.273699 0-15.66837-4.957476-18.836483-12.624285-3.146438-7.604193-1.299275-16.532948 4.591415-22.218934l95.882671-94.260684h-83.312573c-11.226269 0-20.366955-9.139481-20.366955-20.366954 0-11.23229 9.140685-20.371771 20.366955-20.371772z m134.256451 490.555334c-58.528804 58.436085-136.258948 90.615729-218.863482 90.615728-82.637046 0-160.333474-32.182052-218.770763-90.615728-58.430064-58.5276-90.614525-136.222824-90.614525-218.770763 0-164.332448 128.456071-300.023039 292.439317-308.918078 0.356428-0.016858 0.704426-0.025287 1.048812-0.025287 5.907549 0 11.47312 2.498607 15.266185 6.862438 4.10494 4.721463 5.882262 10.910783 4.873186 16.980891a321.126932 321.126932 0 0 0-4.350586 52.586335c0 90.50856 20.866676 158.725936 60.345863 197.283951 27.012646 26.394918 63.881241 39.780224 109.590689 39.780224 1.812242 0 3.659405-0.025287 5.494526-0.065024 36.498922-0.829658 70.802682-15.056663 99.19769-41.144523 3.738879-3.472762 8.649393-5.386153 13.816391-5.386153a20.238111 20.238111 0 0 1 7.465716 1.414873c7.185149 2.834564 12.222099 9.665694 12.833806 17.408364 0.558725 7.670421 0.838087 15.479319 0.842903 23.221989 0 82.641862-32.179644 160.340699-90.615728 218.770763z" fill="#64bee0" p-id="6367"></path><path d="M681.386256 557.917765c-56.540756 0-102.916098-17.292765-137.829157-51.395433-25.397884-24.834343-44.40656-58.383102-56.496203-99.710656-10.669953-36.404998-16.078985-79.036644-16.078984-126.710057 0.089107-11.544164 0.60689-22.433272 1.570209-33.232069C341.690162 266.020315 243.273965 379.91222 243.273965 512.711651c0 148.134253 120.514717 268.64897 268.64897 268.64897 141.259774 0 258.335445-109.519644 267.978259-249.956985-28.292655 16.821944-59.169411 25.714575-91.830714 26.438268-2.231286 0.054187-4.480634 0.075861-6.684224 0.075861z" fill="#64bee0" p-id="6368"></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@ -1,6 +0,0 @@
|
||||
<svg class="llm-icon llm-icon-workflow-start " width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20">
|
||||
<rect width="20" height="20" fill="#65BEE0" rx="6"></rect>
|
||||
<g clip-path="url(#svg_7692d94d0f__a)">
|
||||
<path fill="#fff" d="M13.448 8.979c.736.483.736 1.692 0 2.176l-5.793 3.808C6.92 15.447 6 14.843 6 13.875V6.258c0-.967.92-1.571 1.655-1.088l5.793 3.809Z"></path>
|
||||
</g>
|
||||
<defs><clipPath id="svg_7692d94d0f__a"><path fill="#fff" d="M3 3h14v14H3z"></path></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 515 B |
@ -1 +0,0 @@
|
||||
<svg class="llm-icon llm-icon-break " width="1em" height="1em" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"><rect width="20" height="20" fill="#65BEE0" rx="6"></rect><g clip-path="url(#svg_6c6d9b5201__clip0_23494_1035106)"><path fill="#fff" fill-rule="evenodd" d="M9.99998 3.58331C13.5438 3.58331 16.4166 6.45615 16.4166 9.99998C16.4166 13.5438 13.5438 16.4166 9.99998 16.4166C6.45615 16.4166 3.58331 13.5438 3.58331 9.99998C3.58331 6.45615 6.45615 3.58331 9.99998 3.58331ZM11.8083 7.37498C12.2593 7.37498 12.625 7.74061 12.625 8.19165V11.8083C12.625 12.2593 12.2593 12.625 11.8083 12.625H8.19165C7.74061 12.625 7.37498 12.2593 7.37498 11.8083V8.19165C7.37498 7.74061 7.74061 7.37498 8.19165 7.37498H11.8083Z" clip-rule="evenodd"></path></g><defs><clipPath id="svg_6c6d9b5201__clip0_23494_1035106"><path fill="#fff" transform="translate(3 3)" d="M0 0H14V14H0z"></path></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 914 B |
@ -1,7 +0,0 @@
|
||||
<svg t="1752560873348" class="icon" fill="none" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="18291" width="1em" height="1em" style="transform: rotate(-90deg);">
|
||||
<rect width="20" height="20" fill="#65BEE0" rx="6"></rect>
|
||||
<path d="M0 0m192 0l640 0q192 0 192 192l0 640q0 192-192 192l-640 0q-192 0-192-192l0-640q0-192 192-192Z" fill="#65BEE0" p-id="18292"></path>
|
||||
<path d="M832 0a192 192 0 0 1 192 192v640a192 192 0 0 1-192 192H192a192 192 0 0 1-192-192V192a192 192 0 0 1 192-192h640z m0 32H192a160 160 0 0 0-159.84 153.056L32 192v640a160 160 0 0 0 153.056 159.84L192 992h640a160 160 0 0 0 159.84-153.056L992 832V192a160 160 0 0 0-153.056-159.84L832 32z" fill="#65BEE0" p-id="18293"></path>
|
||||
<path d="M396.064 434.688a20.8 20.8 0 0 1 25.632 8.224l1.568 3.008a20.8 20.8 0 0 1-8.224 25.632l-118.592 73.92-0.032 135.776 25.216-26.24a20.8 20.8 0 0 1 26.784-2.72l2.624 2.144a20.8 20.8 0 0 1 2.752 26.784l-2.144 2.624-61.472 64a20.8 20.8 0 0 1-26.816 2.72l-2.624-2.144-66.528-64a20.8 20.8 0 0 1 26.24-32.096l2.624 2.144 31.808 30.56-0.064-0.736v-150.336l0.224-3.04a20.8 20.8 0 0 1 9.6-14.624l128.416-80.032z m120.192-228.928a20.8 20.8 0 0 1 20.576 17.728l0.224 3.072-0.032 148.992 225.824 140.736a20.8 20.8 0 0 1 9.568 14.624l0.224 3.04v150.336l-0.096 0.992 29.12-30.24a20.8 20.8 0 0 1 26.752-2.752l2.624 2.144a20.8 20.8 0 0 1 2.752 26.784l-2.144 2.624-61.472 64a20.8 20.8 0 0 1-26.816 2.72l-2.624-2.144-66.528-64a20.8 20.8 0 0 1 26.24-32.096l2.624 2.144 27.936 26.88v-135.872l-225.76-140.736a20.8 20.8 0 0 1-9.6-14.592l-0.192-3.04V226.56a20.8 20.8 0 0 1 20.8-20.8z" fill="#fff" p-id="18294"></path>
|
||||
<defs><clipPath id="svg_7692d94d0f__a"><path fill="#fff" d="M3 3h14v14H3z"></path></clipPath></defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@ -1 +0,0 @@
|
||||
<svg t="1762762024042" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7115" width="200" height="200"><path d="M512 512m-512 0a512 512 0 1 0 1024 0 512 512 0 1 0-1024 0Z" fill="#65BEE0" p-id="7116" data-spm-anchor-id="a313x.search_index.0.i4.2cb83a81yhcKKA" class="selected"></path><path d="M440.018824 310.452706c6.625882 0 13.703529 2.198588 20.781176 7.68 11.294118 8.884706 17.709176 22.588235 17.769412 36.954353l0.391529 108.242823 0.120471 43.248942 0.150588 31.442823s20.239059-27.648 49.935059-27.648c15.932235 0 34.484706 7.890824 54.091294 32.165647 0 0 18.191059-30.539294 47.435294-30.539294 15.36 0 33.731765 8.342588 54.091294 33.912471 0 0 17.588706-34.424471 41.562353-34.424471 8.071529 0 16.865882 3.915294 25.99153 14.486588 10.752 12.739765 16.564706 29.063529 16.564705 45.808941v230.098824s-0.722824 5.963294-7.168 14.938353a63.608471 63.608471 0 0 1-51.83247 26.352941l-239.555765 0.060235a73.396706 73.396706 0 0 1-57.313882-27.557647l-131.373177-164.111059a46.019765 46.019765 0 0 1-6.595764-46.983529c4.517647-10.390588 13.251765-19.275294 29.635764-19.275294 8.432941 0 18.763294 2.349176 31.62353 7.951059 42.736941 19.154824 58.006588 59.542588 58.006588 59.542588l0.692706-146.100706 0.210823-43.158588 0.512-105.682824c0.060235-14.546824 6.716235-28.400941 18.31153-37.255529 7.228235-5.571765 16.263529-10.149647 25.961412-10.149647zM436.705882 180.705882a165.647059 165.647059 0 0 1 123.723294 275.787294 38.068706 38.068706 0 0 1-29.455058 12.468706h-1.987765a8.794353 8.794353 0 0 1-8.734118-8.734117l-0.090353-15.450353a15.058824 15.058824 0 0 1 4.336942-10.541177l0.090352-0.090353a123.392 123.392 0 0 0 36.352-87.792941c0-33.189647-12.950588-64.391529-36.352-87.883294A123.663059 123.663059 0 0 0 436.705882 222.117647c-33.189647 0-64.391529 12.950588-87.883294 36.352a123.663059 123.663059 0 0 0-36.352 87.883294c0 33.189647 12.950588 64.391529 36.352 87.883294l0.090353 0.060236c3.162353 3.162353 4.969412 7.499294 4.909177 11.956705a21.353412 21.353412 0 0 1-36.74353 14.697412A165.647059 165.647059 0 0 1 436.705882 180.705882z" fill="#ffffff" p-id="7117" data-spm-anchor-id="a313x.search_index.0.i3.2cb83a81yhcKKA" class=""></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
@ -1,3 +0,0 @@
|
||||
<svg t="1752214385087" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="22912" id="mx_n_1752214385087" width="200" height="200">
|
||||
<path d="M22.658333 502.958333c0 79.929167 12.929167 142.658333 44.195834 212.2375 23.866667 53.104167 53.633333 94.970833 92.716666 137.2125 39.466667 42.645833 92.1125 77.8375 143.766667 103.866667 44.129167 22.2375 126.375 47.141667 191.6625 47.141667h40.6875c89.683333 0 200.170833-41.408333 265.1125-90.433334l25.291667-18.933333c3.383333-2.754167 4.916667-4.875 8.308333-7.604167 48.625-39.1375 106.620833-114.6 129.158333-169.75 31.991667-78.2875 44.475-123.704167 44.475-213.733333 0-107.270833-39.370833-208.329167-101.341666-291.2875-4.275-5.729167-8.595833-11.6375-13.2375-16.829167-65.329167-73.079167-121.3-113.216667-215.470834-148.9-46.554167-17.641667-96.358333-26.429167-146.5625-27.525h-21.558333c-53.033333 1.141667-106.058333 10.654167-155.725 27.195834-8.720833 2.904167-14.5875 4.85-21.945833 8.125-41.483333 18.475-80.633333 37.0875-116.158334 66.029166l-24.075 20.141667C114.5125 205.3875 61.308333 296.270833 36.454167 394.729167c-7.570833 29.975-13.795833 70.329167-13.795834 108.229166z" fill="#65BEE0" p-id="22913"></path>
|
||||
<path d="M677.225 610.004167c9.7 2.791667 58.783333 32.220833 71.15 39.3375 23.420833 13.475 46.275 26.466667 69.804167 39.845833 42.375 24.1 32.45-40.475 32.45-67.75V400.391667c0-27.479167 8.641667-90.616667-31.95-67.2375a9526.625 9526.625 0 0 1-69.891667 39.745833c-11.554167 6.525-63.3125 37.641667-71.145833 39.829167-0.591667-61.695833 6.2-118.329167-88.875-118.291667H277.404167c-38.979167 0-60.320833 1.220833-80.0625 24.183333-21.291667 24.758333-17.229167 49.6125-17.229167 87.075v206.904167c0 38.2-4.554167 61.533333 15.345833 88.9875 15.979167 22.054167 41.204167 26.425 78.404167 26.420833h311.358333c38.166667 0.004167 58.758333-3.583333 77.6-27.204166 18.229167-22.845833 15.033333-57.375 14.404167-90.8z" fill="#fff" p-id="22914" data-spm-anchor-id="a313x.search_index.0.i4.7c873a81tBoiJp" class=""></path></svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@ -1,24 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:流程正常结束节点。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container">
|
||||
<div class="terminal-node">
|
||||
<div class="terminal-node__icon"><img src="../../icon/end.svg" alt="" /></div>
|
||||
<div class="terminal-node__content">
|
||||
<div class="terminal-node__title">流程结束</div>
|
||||
<div class="terminal-node__description">结束当前工作流并输出最终结果</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
model: Object,
|
||||
graphModel: Object,
|
||||
properties: Object,
|
||||
text: String,
|
||||
});
|
||||
</script>
|
||||
@ -1,128 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:节点分组容器并展示成员数量。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="group__container">
|
||||
<NodeTitle
|
||||
:icon="group"
|
||||
:nodeId="props.model.id"
|
||||
:nodeProperties="props.properties"
|
||||
:nodeName="props.properties?.name || '分组'"
|
||||
nodeType="selectArea"
|
||||
nodeDesc="组织相关流程节点"
|
||||
@setNodeName="setNodeName"
|
||||
/>
|
||||
<div class="group-summary">
|
||||
<span>分组区域</span>
|
||||
<span class="group-summary__count">{{ props.childCount || 0 }} 个节点</span>
|
||||
</div>
|
||||
<div class="child__container">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import NodeTitle from '../../components/NodeTitle.vue'
|
||||
import group from '../../icon/group.svg'
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
graphModel: Object,
|
||||
properties: Object,
|
||||
nowTime: Number,
|
||||
childCount: Number,
|
||||
});
|
||||
|
||||
const emits = defineEmits(["contentChange"]);
|
||||
|
||||
/**
|
||||
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} name 名称或事件名称
|
||||
*/
|
||||
const setNodeName = (name) => {
|
||||
const properties = lf.getProperties(props.model.id);
|
||||
lf.setProperties(props.model.id, { ...properties, name });
|
||||
emits("contentChange");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.properties?.action !== "NONE") {
|
||||
lf.setProperties(props.model.id, { ...props.properties, action: "NONE" });
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.group__container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
padding: 16px;
|
||||
cursor: default;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #b8c5d8;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 5px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #8fa0b8;
|
||||
box-shadow: 0 8px 24px rgba(31, 41, 55, 0.08);
|
||||
}
|
||||
|
||||
.title {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.group-summary {
|
||||
display: flex;
|
||||
height: 30px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-summary__count {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #d9e0ea;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.child__container {
|
||||
min-height: 80px;
|
||||
flex: 1;
|
||||
background-color: #f6f8fb;
|
||||
background-image: radial-gradient(#d6dde8 0.7px, transparent 0.7px);
|
||||
background-size: 16px 16px;
|
||||
border: 1px dashed #aeb9c9;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.el-row {
|
||||
align-items: end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,169 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:通用服务节点并响应运行状态。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container" :class="props.model.id">
|
||||
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<template #output>
|
||||
<div v-if="nodeOperatingStatus != 'FAILED'">
|
||||
<JsonViewer
|
||||
v-if="!['img', 'video', 'audio'].includes(props.properties.outputType)"
|
||||
:value="outputJsonData"
|
||||
copyable
|
||||
boxed
|
||||
sort
|
||||
theme="light"
|
||||
/>
|
||||
<el-image
|
||||
v-if="props.properties.outputType === 'img'"
|
||||
v-for="item in outputJsonData.imageUrl"
|
||||
style="width: 60px; height: 60px"
|
||||
:src="item"
|
||||
:preview-src-list="outputJsonData.imageUrl"
|
||||
:preview-teleported="true"
|
||||
show-progress
|
||||
fit="fill"
|
||||
/>
|
||||
<IPlayer
|
||||
v-if="props.properties.outputType === 'video'"
|
||||
v-for="item in outputJsonData.videoUrl"
|
||||
:videoUrl="item"
|
||||
/>
|
||||
<audio
|
||||
v-for="item in audioUrls"
|
||||
:key="item"
|
||||
class="node-audio-player"
|
||||
:src="item"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>{{ errorInfoData }}</div>
|
||||
</template>
|
||||
</NodeState>
|
||||
<NodeTitle
|
||||
:icon="props.properties.icon"
|
||||
:nodeId="props.model.id"
|
||||
:nodeProperties="props.properties"
|
||||
:nodeType="props.properties.nodeType || 'NONE'"
|
||||
:nodeName="props.properties.name"
|
||||
:nodeDesc="props.properties.desc"
|
||||
@setNodeName="setNodeName"
|
||||
/>
|
||||
<!-- @zoom="zoom" -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from "vue";
|
||||
import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
import { useFlowEvents } from "../../composables/useFlowEvents";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
properties: Object,
|
||||
});
|
||||
|
||||
const emits = defineEmits(["contentChange"]);
|
||||
|
||||
/**
|
||||
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} name 名称或事件名称
|
||||
*/
|
||||
const setNodeName = (name) => {
|
||||
const properties = lf.getProperties(props.model.id);
|
||||
lf.setProperties(props.model.id, {
|
||||
...properties,
|
||||
name
|
||||
});
|
||||
emits("contentChange");
|
||||
}
|
||||
|
||||
const nodeOperatingStatus = ref("NORMAL");
|
||||
const runtimes = ref(0);
|
||||
const inputJsonData = ref({});
|
||||
const outputJsonData = ref({});
|
||||
const errorInfoData = ref('');
|
||||
|
||||
const audioUrls = computed(() => {
|
||||
const value = outputJsonData.value?.audioUrl;
|
||||
if (Array.isArray(value)) return value.filter(Boolean);
|
||||
if (typeof value === "string" && value) return [value];
|
||||
return [];
|
||||
});
|
||||
|
||||
const nodeStateRef = ref(null);
|
||||
|
||||
/**
|
||||
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const closePopover = () => {
|
||||
if (nodeStateRef.value) {
|
||||
nodeStateRef.value.closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
onFlowEvent("changeNodeState", (data) => {
|
||||
if (data.nodeId === props.model.id) {
|
||||
nodeOperatingStatus.value = data.status;
|
||||
if (data.endTime && data.startTime) {
|
||||
runtimes.value = data.endTime - data.startTime;
|
||||
}
|
||||
let inputData = {};
|
||||
let output = {};
|
||||
errorInfoData.value = data?.message || ''
|
||||
try {
|
||||
inputData = JSON.parse(data.paramsIn) || {};
|
||||
output = JSON.parse(data.paramsOut) || {};
|
||||
} catch (error) {
|
||||
inputData = data.paramsIn || {};
|
||||
output = data.paramsOut || {};
|
||||
}
|
||||
inputJsonData.value = inputData;
|
||||
outputJsonData.value = output;
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("contentChange", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
nodeOperatingStatus.value = "NORMAL";
|
||||
inputJsonData.value = {};
|
||||
outputJsonData.value = {};
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("setProperties", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
defineExpose({ closePopover })
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node__container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.node-audio-player {
|
||||
width: min(320px, 100%);
|
||||
height: 36px;
|
||||
}
|
||||
</style>
|
||||
@ -1,101 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:流程入口节点并响应运行状态。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container" :class="props.model.id">
|
||||
<keep-alive>
|
||||
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<!-- <template #output>
|
||||
<JsonViewer :value="outputJsonData" copyable boxed sort theme="light" />
|
||||
</template> -->
|
||||
</NodeState>
|
||||
</keep-alive>
|
||||
<div class="terminal-node">
|
||||
<div class="terminal-node__icon"><img src="../../icon/start.svg" alt="" /></div>
|
||||
<div class="terminal-node__content">
|
||||
<div class="terminal-node__title">流程开始</div>
|
||||
<div class="terminal-node__description">接收流程输入并启动工作流</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
import { useFlowEvents } from "../../composables/useFlowEvents";
|
||||
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
properties: Object,
|
||||
});
|
||||
const emits = defineEmits(["contentChange"]);
|
||||
|
||||
|
||||
const nodeOperatingStatus = ref("NORMAL");
|
||||
const runtimes = ref(0);
|
||||
const inputJsonData = ref({});
|
||||
const outputJsonData = ref({});
|
||||
|
||||
const nodeStateRef = ref(null);
|
||||
|
||||
/**
|
||||
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const closePopover = () => {
|
||||
if (nodeStateRef.value) {
|
||||
nodeStateRef.value.closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onFlowEvent("changeNodeState", (data) => {
|
||||
if (data.nodeId === props.model.id) {
|
||||
nodeOperatingStatus.value = data.status;
|
||||
if (data.endTime && data.startTime) {
|
||||
runtimes.value = data.endTime - data.startTime;
|
||||
}
|
||||
let inputData = {};
|
||||
let output = {};
|
||||
try {
|
||||
inputData = JSON.parse(data.paramsIn) || {};
|
||||
output = JSON.parse(data.paramsOut) || {};
|
||||
} catch (error) {
|
||||
inputData = data.paramsIn || {};
|
||||
output = data.paramsOut || {};
|
||||
}
|
||||
inputJsonData.value = inputData;
|
||||
outputJsonData.value = output;
|
||||
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("contentChange", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
nodeOperatingStatus.value = "NORMAL";
|
||||
inputJsonData.value = {};
|
||||
outputJsonData.value = {};
|
||||
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("setProperties", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
defineExpose({ closePopover })
|
||||
|
||||
</script>
|
||||
@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<div class="action-queue-node">
|
||||
<ServiceNode ref="serviceNodeRef" v-bind="props" @content-change="$emit('contentChange')" />
|
||||
<div class="action-queue-node__summary">{{ stepCount }} 个步骤</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import ServiceNode from '../common/serviceNode.vue'
|
||||
|
||||
const props = defineProps({ model: Object, properties: Object })
|
||||
defineEmits(['contentChange'])
|
||||
const serviceNodeRef = ref()
|
||||
const stepCount = computed(() => {
|
||||
const steps = props.properties?.nodeParams?.find((item) => item.name === 'steps')?.input
|
||||
return Array.isArray(steps) ? steps.length : 0
|
||||
})
|
||||
const closePopover = () => serviceNodeRef.value?.closePopover?.()
|
||||
defineExpose({ closePopover })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action-queue-node__summary { margin-top: 7px; color: #667085; font-size: 11px; line-height: 16px; }
|
||||
</style>
|
||||
@ -1,146 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:代码执行节点并展示运行状态。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container" :class="props.model.id">
|
||||
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<template #output>
|
||||
<div v-if="nodeOperatingStatus != 'FAILED'">
|
||||
<JsonViewer
|
||||
v-if="!['img', 'video'].includes(props.properties.outputType)"
|
||||
:value="outputJsonData"
|
||||
copyable
|
||||
boxed
|
||||
sort
|
||||
theme="light"
|
||||
/>
|
||||
<el-image
|
||||
v-if="props.properties.outputType === 'img'"
|
||||
v-for="item in outputJsonData.imageUrl"
|
||||
style="width: 60px; height: 60px"
|
||||
:src="item"
|
||||
:preview-src-list="outputJsonData.imageUrl"
|
||||
:preview-teleported="true"
|
||||
show-progress
|
||||
fit="fill"
|
||||
/>
|
||||
<IPlayer
|
||||
v-if="props.properties.outputType === 'video'"
|
||||
v-for="item in outputJsonData.videoUrl"
|
||||
:videoUrl="item"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>{{ errorInfoData }}</div>
|
||||
</template>
|
||||
</NodeState>
|
||||
<NodeTitle
|
||||
:icon="props.properties.icon"
|
||||
:nodeId="props.model.id"
|
||||
:nodeProperties="props.properties"
|
||||
:nodeType="props.properties.nodeType || 'NONE'"
|
||||
:nodeName="props.properties.name"
|
||||
:nodeDesc="props.properties.desc"
|
||||
@setNodeName="setNodeName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import { useFlowEvents } from "../../composables/useFlowEvents";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
properties: Object,
|
||||
});
|
||||
|
||||
const emits = defineEmits(["contentChange"]);
|
||||
|
||||
/**
|
||||
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} name 名称或事件名称
|
||||
*/
|
||||
const setNodeName = (name) => {
|
||||
const properties = lf.getProperties(props.model.id);
|
||||
lf.setProperties(props.model.id, {
|
||||
...properties,
|
||||
name
|
||||
});
|
||||
emits("contentChange");
|
||||
}
|
||||
|
||||
const nodeOperatingStatus = ref("NORMAL");
|
||||
const runtimes = ref(0);
|
||||
const inputJsonData = ref({});
|
||||
const outputJsonData = ref({});
|
||||
const errorInfoData = ref('');
|
||||
|
||||
const nodeStateRef = ref(null);
|
||||
|
||||
/**
|
||||
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const closePopover = () => {
|
||||
if (nodeStateRef.value) {
|
||||
nodeStateRef.value.closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
onFlowEvent("changeNodeState", (data) => {
|
||||
if (data.nodeId === props.model.id) {
|
||||
nodeOperatingStatus.value = data.status;
|
||||
if (data.endTime && data.startTime) {
|
||||
runtimes.value = data.endTime - data.startTime;
|
||||
}
|
||||
let inputData = {};
|
||||
let output = {};
|
||||
errorInfoData.value = data?.message || ''
|
||||
try {
|
||||
inputData = JSON.parse(data.paramsIn) || {};
|
||||
output = JSON.parse(data.paramsOut) || {};
|
||||
} catch (error) {
|
||||
inputData = data.paramsIn || {};
|
||||
output = data.paramsOut || {};
|
||||
}
|
||||
inputJsonData.value = inputData;
|
||||
outputJsonData.value = output;
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("contentChange", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
nodeOperatingStatus.value = "NORMAL";
|
||||
inputJsonData.value = {};
|
||||
outputJsonData.value = {};
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("setProperties", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
defineExpose({ closePopover })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node__container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@ -1,262 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:当前循环上下文节点并展示运行状态。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container" :class="props.model.id">
|
||||
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<template #output>
|
||||
<JsonViewer v-if="!['img', 'video'].includes(props.properties.outputType)" :value="outputJsonData" copyable boxed sort theme="light" />
|
||||
<el-image
|
||||
v-if="props.properties.outputType === 'img'"
|
||||
v-for="item in outputJsonData.imageUrl"
|
||||
style="width: 60px; height: 60px"
|
||||
:src="item"
|
||||
:preview-src-list="outputJsonData.imageUrl"
|
||||
:preview-teleported="true"
|
||||
show-progress
|
||||
fit="fill"
|
||||
/>
|
||||
<IPlayer v-if="props.properties.outputType === 'video'" v-for="item in outputJsonData.videoUrl" :videoUrl="item" />
|
||||
</template>
|
||||
</NodeState>
|
||||
<NodeTitle
|
||||
:icon="props.properties.icon"
|
||||
:nodeId="props.model.id"
|
||||
:nodeProperties="props.properties"
|
||||
:nodeName="props.properties.name"
|
||||
:nodeDesc="props.properties.desc"
|
||||
@setNodeName="setNodeName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
import { useFlowEvents } from "../../composables/useFlowEvents";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
properties: Object,
|
||||
});
|
||||
|
||||
const emits = defineEmits(["contentChange"]);
|
||||
|
||||
/**
|
||||
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} name 名称或事件名称
|
||||
*/
|
||||
const setNodeName = (name) => {
|
||||
const properties = lf.getProperties(props.model.id);
|
||||
lf.setProperties(props.model.id, {
|
||||
...properties,
|
||||
name
|
||||
});
|
||||
emits("contentChange");
|
||||
}
|
||||
|
||||
const nodeOperatingStatus = ref("NORMAL");
|
||||
const runtimes = ref(0);
|
||||
const inputJsonData = ref({});
|
||||
const outputJsonData = ref({});
|
||||
|
||||
const nodeStateRef = ref(null);
|
||||
|
||||
/**
|
||||
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const closePopover = () => {
|
||||
if (nodeStateRef.value) {
|
||||
nodeStateRef.value.closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onFlowEvent("changeNodeState", (data) => {
|
||||
if (data.nodeId === props.model.id) {
|
||||
nodeOperatingStatus.value = data.status;
|
||||
if (data.endTime && data.startTime) {
|
||||
runtimes.value = data.endTime - data.startTime;
|
||||
}
|
||||
let inputData = {};
|
||||
let output = {};
|
||||
try {
|
||||
inputData = JSON.parse(data.paramsIn) || {};
|
||||
output = JSON.parse(data.paramsOut) || {};
|
||||
} catch (error) {
|
||||
inputData = data.paramsIn || {};
|
||||
output = data.paramsOut || {};
|
||||
}
|
||||
inputJsonData.value = inputData;
|
||||
outputJsonData.value = output;
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("contentChange", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
nodeOperatingStatus.value = "NORMAL";
|
||||
inputJsonData.value = {};
|
||||
outputJsonData.value = {};
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("setProperties", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
defineExpose({ closePopover })
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node__container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
||||
.input__container {
|
||||
width: 100%;
|
||||
background-color: #fafbfc;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.title {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.tag {
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
background: #1664ff;
|
||||
border-radius: 0 4px 4px 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #0c0d0e;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
.addFormItem {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form__container {
|
||||
margin: 12px 0;
|
||||
|
||||
.sub-properties {
|
||||
margin-left: 10px;
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gSon-properties {
|
||||
margin-left: 10px;
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.output__container {
|
||||
width: 100%;
|
||||
background-color: #fafbfc;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.title {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.tag {
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
background: #1664ff;
|
||||
border-radius: 0 4px 4px 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #0c0d0e;
|
||||
}
|
||||
}
|
||||
|
||||
.form__container {
|
||||
margin: 12px 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-row) {
|
||||
align-items: end;
|
||||
|
||||
.el-input {
|
||||
--el-input-width: 170px;
|
||||
}
|
||||
|
||||
.el-select {
|
||||
--el-select-width: 170px;
|
||||
}
|
||||
|
||||
.el-cascader {
|
||||
--el-form-inline-content-width: 170px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,146 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:通用设备执行节点并展示运行状态。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container" :class="props.model.id">
|
||||
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<template #output>
|
||||
<div v-if="nodeOperatingStatus != 'FAILED'">
|
||||
<JsonViewer
|
||||
v-if="!['img', 'video'].includes(props.properties.outputType)"
|
||||
:value="outputJsonData"
|
||||
copyable
|
||||
boxed
|
||||
sort
|
||||
theme="light"
|
||||
/>
|
||||
<el-image
|
||||
v-if="props.properties.outputType === 'img'"
|
||||
v-for="item in outputJsonData.imageUrl"
|
||||
style="width: 60px; height: 60px"
|
||||
:src="item"
|
||||
:preview-src-list="outputJsonData.imageUrl"
|
||||
:preview-teleported="true"
|
||||
show-progress
|
||||
fit="fill"
|
||||
/>
|
||||
<IPlayer
|
||||
v-if="props.properties.outputType === 'video'"
|
||||
v-for="item in outputJsonData.videoUrl"
|
||||
:videoUrl="item"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>{{ errorInfoData }}</div>
|
||||
</template>
|
||||
</NodeState>
|
||||
<NodeTitle
|
||||
:icon="props.properties.icon"
|
||||
:nodeId="props.model.id"
|
||||
:nodeProperties="props.properties"
|
||||
:nodeType="props.properties.nodeType || 'NONE'"
|
||||
:nodeName="props.properties.name"
|
||||
:nodeDesc="props.properties.desc"
|
||||
@setNodeName="setNodeName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import { useFlowEvents } from "../../composables/useFlowEvents.js";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
properties: Object,
|
||||
});
|
||||
|
||||
const emits = defineEmits(["contentChange"]);
|
||||
|
||||
/**
|
||||
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} name 名称或事件名称
|
||||
*/
|
||||
const setNodeName = (name) => {
|
||||
const properties = lf.getProperties(props.model.id);
|
||||
lf.setProperties(props.model.id, {
|
||||
...properties,
|
||||
name
|
||||
});
|
||||
emits("contentChange");
|
||||
}
|
||||
|
||||
const nodeOperatingStatus = ref("NORMAL");
|
||||
const runtimes = ref(0);
|
||||
const inputJsonData = ref({});
|
||||
const outputJsonData = ref({});
|
||||
const errorInfoData = ref('');
|
||||
|
||||
const nodeStateRef = ref(null);
|
||||
|
||||
/**
|
||||
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const closePopover = () => {
|
||||
if (nodeStateRef.value) {
|
||||
nodeStateRef.value.closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
onFlowEvent("changeNodeState", (data) => {
|
||||
if (data.nodeId === props.model.id) {
|
||||
nodeOperatingStatus.value = data.status;
|
||||
if (data.endTime && data.startTime) {
|
||||
runtimes.value = data.endTime - data.startTime;
|
||||
}
|
||||
let inputData = {};
|
||||
let output = {};
|
||||
errorInfoData.value = data?.message || ''
|
||||
try {
|
||||
inputData = JSON.parse(data.paramsIn) || {};
|
||||
output = JSON.parse(data.paramsOut) || {};
|
||||
} catch (error) {
|
||||
inputData = data.paramsIn || {};
|
||||
output = data.paramsOut || {};
|
||||
}
|
||||
inputJsonData.value = inputData;
|
||||
outputJsonData.value = output;
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("contentChange", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
nodeOperatingStatus.value = "NORMAL";
|
||||
inputJsonData.value = {};
|
||||
outputJsonData.value = {};
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("setProperties", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
defineExpose({ closePopover })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node__container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@ -1,146 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:HTTP 请求执行节点并展示运行状态。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container" :class="props.model.id">
|
||||
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<template #output>
|
||||
<div v-if="nodeOperatingStatus != 'FAILED'">
|
||||
<JsonViewer
|
||||
v-if="!['img', 'video'].includes(props.properties.outputType)"
|
||||
:value="outputJsonData"
|
||||
copyable
|
||||
boxed
|
||||
sort
|
||||
theme="light"
|
||||
/>
|
||||
<el-image
|
||||
v-if="props.properties.outputType === 'img'"
|
||||
v-for="item in outputJsonData.imageUrl"
|
||||
style="width: 60px; height: 60px"
|
||||
:src="item"
|
||||
:preview-src-list="outputJsonData.imageUrl"
|
||||
:preview-teleported="true"
|
||||
show-progress
|
||||
fit="fill"
|
||||
/>
|
||||
<IPlayer
|
||||
v-if="props.properties.outputType === 'video'"
|
||||
v-for="item in outputJsonData.videoUrl"
|
||||
:videoUrl="item"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>{{ errorInfoData }}</div>
|
||||
</template>
|
||||
</NodeState>
|
||||
<NodeTitle
|
||||
:icon="props.properties.icon"
|
||||
:nodeId="props.model.id"
|
||||
:nodeProperties="props.properties"
|
||||
:nodeType="props.properties.nodeType || 'NONE'"
|
||||
:nodeName="props.properties.name"
|
||||
:nodeDesc="props.properties.desc"
|
||||
@setNodeName="setNodeName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
import { useFlowEvents } from "../../composables/useFlowEvents";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
properties: Object,
|
||||
});
|
||||
|
||||
const emits = defineEmits(["contentChange"]);
|
||||
|
||||
/**
|
||||
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} name 名称或事件名称
|
||||
*/
|
||||
const setNodeName = (name) => {
|
||||
const properties = lf.getProperties(props.model.id);
|
||||
lf.setProperties(props.model.id, {
|
||||
...properties,
|
||||
name
|
||||
});
|
||||
emits("contentChange");
|
||||
}
|
||||
|
||||
const nodeOperatingStatus = ref("NORMAL");
|
||||
const runtimes = ref(0);
|
||||
const inputJsonData = ref({});
|
||||
const outputJsonData = ref({});
|
||||
const errorInfoData = ref('');
|
||||
|
||||
const nodeStateRef = ref(null);
|
||||
|
||||
/**
|
||||
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const closePopover = () => {
|
||||
if (nodeStateRef.value) {
|
||||
nodeStateRef.value.closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onFlowEvent("changeNodeState", (data) => {
|
||||
if (data.nodeId === props.model.id) {
|
||||
nodeOperatingStatus.value = data.status;
|
||||
if (data.endTime && data.startTime) {
|
||||
runtimes.value = data.endTime - data.startTime;
|
||||
}
|
||||
let inputData = {};
|
||||
let output = {};
|
||||
errorInfoData.value = data?.message || ''
|
||||
try {
|
||||
inputData = JSON.parse(data.paramsIn) || {};
|
||||
output = JSON.parse(data.paramsOut) || {};
|
||||
} catch (error) {
|
||||
inputData = data.paramsIn || {};
|
||||
output = data.paramsOut || {};
|
||||
}
|
||||
inputJsonData.value = inputData;
|
||||
outputJsonData.value = output;
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("contentChange", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
nodeOperatingStatus.value = "NORMAL";
|
||||
inputJsonData.value = {};
|
||||
outputJsonData.value = {};
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("setProperties", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
defineExpose({ closePopover })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node__container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@ -1,265 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:循环容器节点,管理循环体节点拖入和布局。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
class="group__container"
|
||||
:class="[props.model.id, { 'group__container--dragover': isDragOver }]"
|
||||
@dragenter.prevent="isDragOver = true"
|
||||
@dragleave="handleDragleave"
|
||||
>
|
||||
<NodeTitle
|
||||
:icon="loop"
|
||||
:nodeId="props.model.id"
|
||||
:nodeProperties="props.properties"
|
||||
:nodeName="props.properties?.name || '循环'"
|
||||
nodeDesc="循环执行一系列任务,直至输出所有结果"
|
||||
:showZoom="false"
|
||||
@setNodeName="setNodeName"
|
||||
/>
|
||||
<div class="loop-body-title">
|
||||
<span>循环体</span>
|
||||
<div class="loop-body-actions">
|
||||
<span class="loop-body-count">{{ actualChildCount }} 个节点</span>
|
||||
<NodeState
|
||||
ref="nodeStateRef"
|
||||
:state="nodeOperatingStatus"
|
||||
:runtimes="runtimes"
|
||||
:popover-width="720"
|
||||
popper-class="flow-result-popover flow-loop-result-popover"
|
||||
>
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<template #output>
|
||||
<LoopIterationResults :history="iterationHistory" />
|
||||
</template>
|
||||
</NodeState>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="child__container"
|
||||
@drop="handleDrop"
|
||||
@dragover="handleDragover"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import LoopIterationResults from "../../components/LoopIterationResults.vue";
|
||||
import loop from "../../icon/loop.svg";
|
||||
import { emitter } from "@/utils/eventBus";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useFlowEvents } from "../../composables/useFlowEvents";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
graphModel: Object,
|
||||
properties: Object,
|
||||
nowTime: Number,
|
||||
childCount: Number,
|
||||
});
|
||||
|
||||
|
||||
const emits = defineEmits(["bindRef", "addToGroup", "contentChange"]);
|
||||
const isDragOver = ref(false);
|
||||
const actualChildCount = computed(() => props.childCount ?? props.model?.children?.size ?? 0);
|
||||
const nodeOperatingStatus = ref("NORMAL");
|
||||
const runtimes = ref(0);
|
||||
const inputJsonData = ref({});
|
||||
const outputJsonData = ref({});
|
||||
const nodeStateRef = ref(null);
|
||||
const iterationHistory = computed(() => Array.isArray(outputJsonData.value?.iterationHistory)
|
||||
? outputJsonData.value.iterationHistory
|
||||
: []);
|
||||
|
||||
const parseRuntimeValue = (value) => {
|
||||
if (!value) return {};
|
||||
if (typeof value === "object") return value;
|
||||
try {
|
||||
return JSON.parse(value) || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
onFlowEvent("changeNodeState", (data) => {
|
||||
if (String(data?.nodeId || "") !== String(props.model.id)) return;
|
||||
nodeOperatingStatus.value = data.status || "NORMAL";
|
||||
if (data.endTime && data.startTime) runtimes.value = data.endTime - data.startTime;
|
||||
inputJsonData.value = parseRuntimeValue(data.paramsIn);
|
||||
outputJsonData.value = parseRuntimeValue(data.paramsOut);
|
||||
emits("contentChange");
|
||||
});
|
||||
|
||||
onFlowEvent("contentChange", (data) => {
|
||||
if (String(data?.id || "") !== String(props.model.id)) return;
|
||||
nodeOperatingStatus.value = "NORMAL";
|
||||
inputJsonData.value = {};
|
||||
outputJsonData.value = {};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理 handleDragover 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} e 触发操作的浏览器事件
|
||||
*/
|
||||
const handleDragover = (e) => {
|
||||
e.preventDefault();
|
||||
isDragOver.value = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 handleDragleave 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} e 触发操作的浏览器事件
|
||||
*/
|
||||
const handleDragleave = (e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) isDragOver.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 handleDrop 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} e 触发操作的浏览器事件
|
||||
*/
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isDragOver.value = false;
|
||||
|
||||
let flowNode = e.dataTransfer.getData("flowNode");
|
||||
if (!flowNode) return;
|
||||
flowNode = JSON.parse(flowNode);
|
||||
if (["start", "end", "loop", "selectArea"].includes(flowNode.type)) return;
|
||||
const point = lf.getPointByClient(e.clientX, e.clientY);
|
||||
const { type, ...other } = flowNode;
|
||||
const node = lf.addNode({
|
||||
type,
|
||||
x: point.canvasOverlayPosition.x,
|
||||
y: point.canvasOverlayPosition.y,
|
||||
properties: {
|
||||
parentId: props.model.id,
|
||||
...other,
|
||||
},
|
||||
});
|
||||
if (
|
||||
!["selectArea", "branch", "stopLoop", "subStart", "subEnd"].includes(node.type)
|
||||
) {
|
||||
emitter.emit("openParamsDrawer", node);
|
||||
}
|
||||
|
||||
lf.addToGroup(props.model.id, node.id, point.canvasOverlayPosition);
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} name 名称或事件名称
|
||||
*/
|
||||
const setNodeName = (name) => {
|
||||
const properties = lf.getProperties(props.model.id);
|
||||
lf.setProperties(props.model.id, {
|
||||
...properties,
|
||||
name
|
||||
});
|
||||
emits("contentChange");
|
||||
}
|
||||
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.group__container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
cursor: default;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #cfd7e6;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
|
||||
&:hover { border-color: #aeb9c9; }
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 5px;
|
||||
height: 100%;
|
||||
border-radius: 8px 0 0 8px;
|
||||
background: #ff6b35;
|
||||
}
|
||||
|
||||
&--dragover {
|
||||
border-color: #ff6b35;
|
||||
box-shadow: inset 0 0 0 2px rgba(255, 107, 53, 0.14);
|
||||
}
|
||||
|
||||
.title {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.loop-body-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 30px;
|
||||
margin-top: 4px;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.loop-body-count {
|
||||
padding: 2px 8px;
|
||||
color: #667085;
|
||||
border: 1px solid #dfe4ec;
|
||||
background: #f8fafc;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.loop-body-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.loop-body-actions :deep(.node-state) {
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
padding: 2px 5px 2px 7px;
|
||||
}
|
||||
|
||||
.child__container {
|
||||
min-height: 300px;
|
||||
flex: 1;
|
||||
background-color: #f7f9fc;
|
||||
border: 1px dashed #aeb9c9;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
&--dragover .child__container {
|
||||
background-color: #fff8f3;
|
||||
border-color: #ff6b35;
|
||||
}
|
||||
|
||||
.el-row {
|
||||
align-items: end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,130 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:识别执行节点并展示运行状态。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container" :class="props.model.id">
|
||||
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<template #output>
|
||||
<JsonViewer v-if="!['img', 'video'].includes(props.properties.outputType)" :value="outputJsonData" copyable boxed sort theme="light" />
|
||||
<el-image
|
||||
v-if="props.properties.outputType === 'img'"
|
||||
v-for="item in outputJsonData.imageUrl"
|
||||
style="width: 60px; height: 60px"
|
||||
:src="item"
|
||||
:preview-src-list="outputJsonData.imageUrl"
|
||||
:preview-teleported="true"
|
||||
show-progress
|
||||
fit="fill"
|
||||
/>
|
||||
<IPlayer v-if="props.properties.outputType === 'video'" v-for="item in outputJsonData.videoUrl" :videoUrl="item" />
|
||||
</template>
|
||||
</NodeState>
|
||||
<NodeTitle
|
||||
:icon="props.properties.icon"
|
||||
:nodeId="props.model.id"
|
||||
:nodeProperties="props.properties"
|
||||
:nodeName="props.properties.name"
|
||||
:nodeType="props.properties.nodeType || 'NONE'"
|
||||
:nodeDesc="props.properties.desc"
|
||||
@setNodeName="setNodeName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
import { useFlowEvents } from "../../composables/useFlowEvents";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
properties: Object,
|
||||
});
|
||||
|
||||
const emits = defineEmits(["contentChange"]);
|
||||
|
||||
/**
|
||||
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} name 名称或事件名称
|
||||
*/
|
||||
const setNodeName = (name) => {
|
||||
const properties = lf.getProperties(props.model.id);
|
||||
lf.setProperties(props.model.id, {
|
||||
...properties,
|
||||
name
|
||||
});
|
||||
emits("contentChange");
|
||||
}
|
||||
|
||||
const nodeOperatingStatus = ref("NORMAL");
|
||||
const runtimes = ref(0);
|
||||
const inputJsonData = ref({});
|
||||
const outputJsonData = ref({});
|
||||
|
||||
const nodeStateRef = ref(null);
|
||||
|
||||
/**
|
||||
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const closePopover = () => {
|
||||
if (nodeStateRef.value) {
|
||||
nodeStateRef.value.closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onFlowEvent("changeNodeState", (data) => {
|
||||
if (data.nodeId === props.model.id) {
|
||||
nodeOperatingStatus.value = data.status;
|
||||
if (data.endTime && data.startTime) {
|
||||
runtimes.value = data.endTime - data.startTime;
|
||||
}
|
||||
let inputData = {};
|
||||
let output = {};
|
||||
try {
|
||||
inputData = JSON.parse(data.paramsIn) || {};
|
||||
output = JSON.parse(data.paramsOut) || {};
|
||||
} catch (error) {
|
||||
inputData = data.paramsIn || {};
|
||||
output = data.paramsOut || {};
|
||||
}
|
||||
inputJsonData.value = inputData;
|
||||
outputJsonData.value = output;
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("contentChange", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
nodeOperatingStatus.value = "NORMAL";
|
||||
inputJsonData.value = {};
|
||||
outputJsonData.value = {};
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("setProperties", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
defineExpose({ closePopover })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node__container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@ -1,130 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:智能体执行节点并展示运行状态。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container" :class="props.model.id">
|
||||
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<template #output>
|
||||
<JsonViewer v-if="!['img', 'video'].includes(props.properties.outputType)" :value="outputJsonData" copyable boxed sort theme="light" />
|
||||
<el-image
|
||||
v-if="props.properties.outputType === 'img'"
|
||||
v-for="item in outputJsonData.imageUrl"
|
||||
style="width: 60px; height: 60px"
|
||||
:src="item"
|
||||
:preview-src-list="outputJsonData.imageUrl"
|
||||
:preview-teleported="true"
|
||||
show-progress
|
||||
fit="fill"
|
||||
/>
|
||||
<IPlayer v-if="props.properties.outputType === 'video'" v-for="item in outputJsonData.videoUrl" :videoUrl="item" />
|
||||
</template>
|
||||
</NodeState>
|
||||
<NodeTitle
|
||||
:icon="props.properties.icon"
|
||||
:nodeId="props.model.id"
|
||||
:nodeProperties="props.properties"
|
||||
:nodeName="props.properties.name"
|
||||
:nodeType="props.properties.nodeType || 'NONE'"
|
||||
:nodeDesc="props.properties.desc"
|
||||
@setNodeName="setNodeName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
import { useFlowEvents } from "../../composables/useFlowEvents";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
properties: Object,
|
||||
});
|
||||
|
||||
const emits = defineEmits(["contentChange"]);
|
||||
|
||||
/**
|
||||
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} name 名称或事件名称
|
||||
*/
|
||||
const setNodeName = (name) => {
|
||||
const properties = lf.getProperties(props.model.id);
|
||||
lf.setProperties(props.model.id, {
|
||||
...properties,
|
||||
name
|
||||
});
|
||||
emits("contentChange");
|
||||
}
|
||||
|
||||
const nodeOperatingStatus = ref("NORMAL");
|
||||
const runtimes = ref(0);
|
||||
const inputJsonData = ref({});
|
||||
const outputJsonData = ref({});
|
||||
|
||||
const nodeStateRef = ref(null);
|
||||
|
||||
/**
|
||||
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const closePopover = () => {
|
||||
if (nodeStateRef.value) {
|
||||
nodeStateRef.value.closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onFlowEvent("changeNodeState", (data) => {
|
||||
if (data.nodeId === props.model.id) {
|
||||
nodeOperatingStatus.value = data.status;
|
||||
if (data.endTime && data.startTime) {
|
||||
runtimes.value = data.endTime - data.startTime;
|
||||
}
|
||||
let inputData = {};
|
||||
let output = {};
|
||||
try {
|
||||
inputData = JSON.parse(data.paramsIn) || {};
|
||||
output = JSON.parse(data.paramsOut) || {};
|
||||
} catch (error) {
|
||||
inputData = data.paramsIn || {};
|
||||
output = data.paramsOut || {};
|
||||
}
|
||||
inputJsonData.value = inputData;
|
||||
outputJsonData.value = output;
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("contentChange", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
nodeOperatingStatus.value = "NORMAL";
|
||||
inputJsonData.value = {};
|
||||
outputJsonData.value = {};
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("setProperties", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
defineExpose({ closePopover })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node__container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@ -1,129 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:延时等待节点并展示运行状态。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container" :class="props.model.id">
|
||||
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<template #output>
|
||||
<JsonViewer v-if="!['img', 'video'].includes(props.properties.outputType)" :value="outputJsonData" copyable boxed sort theme="light" />
|
||||
<el-image
|
||||
v-if="props.properties.outputType === 'img'"
|
||||
v-for="item in outputJsonData.imageUrl"
|
||||
style="width: 60px; height: 60px"
|
||||
:src="item"
|
||||
:preview-src-list="outputJsonData.imageUrl"
|
||||
:preview-teleported="true"
|
||||
show-progress
|
||||
fit="fill"
|
||||
/>
|
||||
<IPlayer v-if="props.properties.outputType === 'video'" v-for="item in outputJsonData.videoUrl" :videoUrl="item" />
|
||||
</template>
|
||||
</NodeState>
|
||||
<NodeTitle
|
||||
:icon="props.properties.icon"
|
||||
:nodeId="props.model.id"
|
||||
:nodeProperties="props.properties"
|
||||
:nodeName="props.properties.name"
|
||||
:nodeDesc="props.properties.desc"
|
||||
@setNodeName="setNodeName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import "vue3-json-viewer/dist/index.css";
|
||||
import { useFlowEvents } from "../../composables/useFlowEvents";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
properties: Object,
|
||||
});
|
||||
|
||||
const emits = defineEmits(["contentChange"]);
|
||||
|
||||
/**
|
||||
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} name 名称或事件名称
|
||||
*/
|
||||
const setNodeName = (name) => {
|
||||
const properties = lf.getProperties(props.model.id);
|
||||
lf.setProperties(props.model.id, {
|
||||
...properties,
|
||||
name
|
||||
});
|
||||
emits("contentChange");
|
||||
}
|
||||
|
||||
const nodeOperatingStatus = ref("NORMAL");
|
||||
const runtimes = ref(0);
|
||||
const inputJsonData = ref({});
|
||||
const outputJsonData = ref({});
|
||||
|
||||
const nodeStateRef = ref(null);
|
||||
|
||||
/**
|
||||
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
*/
|
||||
const closePopover = () => {
|
||||
if (nodeStateRef.value) {
|
||||
nodeStateRef.value.closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onFlowEvent("changeNodeState", (data) => {
|
||||
if (data.nodeId === props.model.id) {
|
||||
nodeOperatingStatus.value = data.status;
|
||||
if (data.endTime && data.startTime) {
|
||||
runtimes.value = data.endTime - data.startTime;
|
||||
}
|
||||
let inputData = {};
|
||||
let output = {};
|
||||
try {
|
||||
inputData = JSON.parse(data.paramsIn) || {};
|
||||
output = JSON.parse(data.paramsOut) || {};
|
||||
} catch (error) {
|
||||
inputData = data.paramsIn || {};
|
||||
output = data.paramsOut || {};
|
||||
}
|
||||
inputJsonData.value = inputData;
|
||||
outputJsonData.value = output;
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("contentChange", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
nodeOperatingStatus.value = "NORMAL";
|
||||
inputJsonData.value = {};
|
||||
outputJsonData.value = {};
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
|
||||
onFlowEvent("setProperties", (data) => {
|
||||
if (data.id === props.model.id) {
|
||||
emits("contentChange");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
defineExpose({ closePopover })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node__container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
@ -1,205 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:终止循环节点,用于结束当前循环。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container" >
|
||||
<!-- 状态栏 -->
|
||||
<NodeTitle :icon="stopLoop" :nodeId="props.model.id" :nodeProperties="props.properties" :nodeName="props.properties?.name || '结束循环'" nodeDesc="用于立即终止当前所在的循环,跳出循环体" @setNodeName="setNodeName" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import NodeTitle from '../../components/NodeTitle.vue'
|
||||
import stopLoop from '../../icon/stopLoop.svg'
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
graphModel: Object,
|
||||
properties: Object,
|
||||
text: String,
|
||||
});
|
||||
|
||||
/**
|
||||
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
|
||||
* @param {*} name 名称或事件名称
|
||||
*/
|
||||
const setNodeName = (name) => {
|
||||
const properties = lf.getProperties(props.model.id);
|
||||
lf.setProperties(props.model.id, {
|
||||
...properties,
|
||||
name
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@keyframes rotateAnimation {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.node__container {
|
||||
width: 300px;
|
||||
height: auto;
|
||||
background: #fff;
|
||||
padding: 12px;
|
||||
cursor: default;
|
||||
border-radius: 12px;
|
||||
border: 2px solid white;
|
||||
box-shadow: 0 5px 15px 0#00000008;
|
||||
position: relative;
|
||||
|
||||
.title__container {
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.subTitle {
|
||||
color: #737a87;
|
||||
font-size: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.input__container {
|
||||
width: 100%;
|
||||
background-color: #fafbfc;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.title {
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.tag {
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
background: #1664ff;
|
||||
border-radius: 0 4px 4px 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #0c0d0e;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
.addFormItem {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form__container {
|
||||
margin: 12px 0;
|
||||
|
||||
.sub-properties {
|
||||
margin-left: 10px;
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gSon-properties {
|
||||
margin-left: 10px;
|
||||
.zw {
|
||||
margin-left: 15px;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -4px;
|
||||
width: 10px;
|
||||
border-left: 1px solid gray;
|
||||
border-bottom: 1px solid gray;
|
||||
border-bottom-left-radius: 4px;
|
||||
background-color: transparent;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
margin-bottom: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
margin-bottom: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.el-row {
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
--el-input-width: 170px;
|
||||
}
|
||||
|
||||
.el-select {
|
||||
--el-select-width: 170px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.popover__container {
|
||||
min-width: 300px !important;
|
||||
|
||||
.params__container {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.paramsTitle {
|
||||
color: #0c0d0e;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,19 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:循环体单次执行出口节点。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container">
|
||||
<div class="terminal-node">
|
||||
<div class="terminal-node__icon"><img src="../../icon/end.svg" alt="" /></div>
|
||||
<div class="terminal-node__content">
|
||||
<div class="terminal-node__title">单次循环结束</div>
|
||||
<div class="terminal-node__description">结束循环体内的本次执行</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ model: Object, graphModel: Object, properties: Object });
|
||||
</script>
|
||||
@ -1,97 +0,0 @@
|
||||
<template>
|
||||
<div class="node__container" :class="props.model.id">
|
||||
<NodeState
|
||||
ref="nodeStateRef"
|
||||
:state="nodeOperatingStatus"
|
||||
:runtimes="runtimes"
|
||||
:popover-width="760"
|
||||
popper-class="flow-result-popover flow-subflow-result-popover"
|
||||
>
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<template #output>
|
||||
<SubFlowExecutionResults v-if="nodeOperatingStatus !== 'FAILED'" :data="subFlowExecution" />
|
||||
<div v-else class="subflow-error">{{ errorInfoData }}</div>
|
||||
</template>
|
||||
</NodeState>
|
||||
<NodeTitle
|
||||
:icon="props.properties.icon"
|
||||
:node-id="props.model.id"
|
||||
:node-properties="props.properties"
|
||||
:node-type="props.properties.nodeType || 'NONE'"
|
||||
:node-name="props.properties.name"
|
||||
:node-desc="props.properties.desc"
|
||||
@set-node-name="setNodeName"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import NodeState from '../../components/NodeState.vue'
|
||||
import NodeTitle from '../../components/NodeTitle.vue'
|
||||
import SubFlowExecutionResults from '../../components/SubFlowExecutionResults.vue'
|
||||
import { useFlowEvents } from '../../composables/useFlowEvents'
|
||||
|
||||
const props = defineProps({ model: Object, properties: Object })
|
||||
const emits = defineEmits(['contentChange'])
|
||||
const { onFlowEvent } = useFlowEvents()
|
||||
const nodeStateRef = ref(null)
|
||||
const nodeOperatingStatus = ref('NORMAL')
|
||||
const runtimes = ref(0)
|
||||
const inputJsonData = ref({})
|
||||
const outputJsonData = ref({})
|
||||
const errorInfoData = ref('')
|
||||
const subFlowExecution = computed(() => outputJsonData.value?.subFlowExecution || {
|
||||
name: props.properties?.subFlow?.detectName || props.properties?.name,
|
||||
versionNo: props.properties?.subFlow?.versionNo,
|
||||
output: outputJsonData.value,
|
||||
executions: [],
|
||||
})
|
||||
|
||||
const parseValue = (value) => {
|
||||
if (!value) return {}
|
||||
if (typeof value === 'object') return value
|
||||
try { return JSON.parse(value) || {} } catch { return value }
|
||||
}
|
||||
|
||||
const setNodeName = (name) => {
|
||||
const properties = lf.getProperties(props.model.id)
|
||||
lf.setProperties(props.model.id, { ...properties, name })
|
||||
emits('contentChange')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onFlowEvent('changeNodeState', (data) => {
|
||||
if (data.nodeId !== props.model.id) return
|
||||
nodeOperatingStatus.value = data.status
|
||||
runtimes.value = data.endTime && data.startTime ? data.endTime - data.startTime : 0
|
||||
inputJsonData.value = parseValue(data.paramsIn)
|
||||
outputJsonData.value = parseValue(data.paramsOut)
|
||||
errorInfoData.value = data?.message || ''
|
||||
emits('contentChange')
|
||||
})
|
||||
onFlowEvent('contentChange', (data) => {
|
||||
if (data.id !== props.model.id) return
|
||||
nodeOperatingStatus.value = 'NORMAL'
|
||||
inputJsonData.value = {}
|
||||
outputJsonData.value = {}
|
||||
emits('contentChange')
|
||||
})
|
||||
})
|
||||
|
||||
defineExpose({ closePopover: () => nodeStateRef.value?.closePopover?.() })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.node__container { width: 100%; height: auto; }
|
||||
.subflow-error { color: var(--el-color-danger); line-height: 20px; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.flow-subflow-result-popover {
|
||||
max-width: min(760px, calc(100vw - 32px));
|
||||
.result-section__content { max-height: 560px; }
|
||||
}
|
||||
</style>
|
||||
@ -1,19 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:循环体单次执行入口节点。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container">
|
||||
<div class="terminal-node">
|
||||
<div class="terminal-node__icon"><img src="../../icon/start.svg" alt="" /></div>
|
||||
<div class="terminal-node__content">
|
||||
<div class="terminal-node__title">单次循环开始</div>
|
||||
<div class="terminal-node__description">开始循环体内的一次执行</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ model: Object, graphModel: Object, properties: Object });
|
||||
</script>
|
||||
@ -1,319 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:条件分支节点,展示已保存的条件摘要并同步每个条件对应的画布锚点。
|
||||
* 作用范围:仅服务于流程设计器模块;条件编辑由 SwitchNodeParams 组件独立负责。
|
||||
-->
|
||||
<template>
|
||||
<div class="node__container" ref="switchRef">
|
||||
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
|
||||
<template #input>
|
||||
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
|
||||
</template>
|
||||
<template #output>
|
||||
<div v-if="nodeOperatingStatus !== 'FAILED'">
|
||||
<JsonViewer :value="outputJsonData" copyable boxed sort theme="light" />
|
||||
</div>
|
||||
<div v-else>{{ errorInfoData }}</div>
|
||||
</template>
|
||||
</NodeState>
|
||||
|
||||
<NodeTitle
|
||||
:icon="switchSvg"
|
||||
:nodeId="props.model.id"
|
||||
:nodeProperties="props.properties"
|
||||
:nodeName="props.properties?.name || '分支'"
|
||||
nodeDesc="双击节点配置分支条件;流程将按顺序匹配条件,并只运行首个匹配条件对应的分支"
|
||||
@setNodeName="setNodeName"
|
||||
/>
|
||||
|
||||
<div class="condition-summary">
|
||||
<div class="condition-summary__title">
|
||||
<span>分支条件</span>
|
||||
<span class="condition-summary__count">{{ conditions.length }} 个</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(condition, index) in conditions"
|
||||
:id="condition.id"
|
||||
:key="condition.id"
|
||||
:data-branch-anchor-id="condition.id"
|
||||
class="condition-summary__item"
|
||||
>
|
||||
<span class="condition-summary__order">{{ condition.label || `条件 ${index + 1}` }}</span>
|
||||
<span class="condition-summary__text">{{ conditionSummary(condition) }}</span>
|
||||
</div>
|
||||
<div
|
||||
:id="`${props.model.id}_else`"
|
||||
:data-branch-anchor-id="`${props.model.id}_else`"
|
||||
class="condition-summary__item condition-summary__item--else"
|
||||
>
|
||||
<span class="condition-summary__order">Else</span>
|
||||
<span class="condition-summary__text">以上条件均不满足时执行</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import NodeTitle from "../../components/NodeTitle.vue";
|
||||
import NodeState from "../../components/NodeState.vue";
|
||||
import switchSvg from "../../icon/switch.svg";
|
||||
import { useFlowEvents } from "../../composables/useFlowEvents";
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
graphModel: Object,
|
||||
properties: Object,
|
||||
nowTime: Number,
|
||||
});
|
||||
|
||||
const emits = defineEmits(["contentChange", "syncAnchors"]);
|
||||
const { onFlowEvent } = useFlowEvents();
|
||||
const switchRef = ref(null);
|
||||
const nodeStateRef = ref(null);
|
||||
const conditions = computed(() => props.properties?.nodeParams || props.properties?.conditions || []);
|
||||
|
||||
const nodeOperatingStatus = ref("NORMAL");
|
||||
const runtimes = ref(0);
|
||||
const inputJsonData = ref({});
|
||||
const outputJsonData = ref({});
|
||||
const errorInfoData = ref("");
|
||||
let anchorSyncFrame = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
const conditionTextMap = {
|
||||
equal: "等于",
|
||||
notEqualTo: "不等于",
|
||||
null: "为空",
|
||||
notNull: "不为空",
|
||||
include: "包含",
|
||||
notInclude: "不包含",
|
||||
greaterThan: "大于",
|
||||
greaterThanOrEqual: "大于等于",
|
||||
lessThan: "小于",
|
||||
lessThanOrEqual: "小于等于",
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成画布卡片中的完整条件说明,多个判断项按配置的 AND/OR 关系连接。
|
||||
* @param {object} condition 当前条件组
|
||||
* @returns {string} 可直接展示的条件表达式
|
||||
*/
|
||||
const conditionSummary = (condition) => {
|
||||
if (!condition.list?.length) return "未配置判断";
|
||||
const relation = condition.withOr === "or" ? " OR " : " AND ";
|
||||
return condition.list.map((item) => {
|
||||
const left = item.nameType === "quote"
|
||||
? formatQuote(item.nameQuote)
|
||||
: String(item.name || "未填写变量");
|
||||
const operator = conditionTextMap[item.condition] || "未选择判断";
|
||||
if (["null", "notNull"].includes(item.condition)) return `${left} ${operator}`;
|
||||
const right = item.type === "quote"
|
||||
? formatQuote(item.quote)
|
||||
: String(item.input ?? "未填写比较值");
|
||||
return `${left} ${operator} ${right}`;
|
||||
}).join(relation);
|
||||
};
|
||||
|
||||
/**
|
||||
* 将级联引用路径格式化为易读文本。
|
||||
* @param {string|string[]} quote 引用路径
|
||||
* @returns {string} 点号连接的引用名称
|
||||
*/
|
||||
const formatQuote = (quote) => {
|
||||
const path = Array.isArray(quote)
|
||||
? quote.filter(Boolean)
|
||||
: String(quote || "").split(".").filter(Boolean);
|
||||
if (!path.length) return "未选择引用";
|
||||
|
||||
const [nodeId, direction, ...fieldPath] = path;
|
||||
const graphNode = window.lf?.getGraphData().nodes.find((node) => node.id === nodeId);
|
||||
const nodeName = graphNode?.properties?.name || graphNode?.name || nodeId;
|
||||
const directionName = direction === "input"
|
||||
? "输入"
|
||||
: direction === "output" ? "输出" : direction;
|
||||
return [nodeName, directionName, ...fieldPath].filter(Boolean).join(".");
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据摘要项的实际纵向中心同步分支锚点;范围仅限当前分支节点。
|
||||
*/
|
||||
const syncAnchorPositions = () => {
|
||||
const root = switchRef.value;
|
||||
if (!root) return;
|
||||
// Handle 的 top 相对于 FlowNode 外壳定位,不能使用 switch 根元素作为坐标原点。
|
||||
// 外层卡片存在 padding;若以 rootRect.top 计算,所有锚点都会统一向上偏移。
|
||||
const positioningParent = root.closest(".flow-node-shell");
|
||||
if (!positioningParent) return;
|
||||
const parentRect = positioningParent.getBoundingClientRect();
|
||||
const scale = positioningParent.offsetWidth > 0
|
||||
? parentRect.width / positioningParent.offsetWidth
|
||||
: 1;
|
||||
const positions = [...root.querySelectorAll("[data-branch-anchor-id]")].map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
id: element.dataset.branchAnchorId,
|
||||
height: Math.round(((rect.top - parentRect.top + rect.height / 2) / scale) * 100) / 100,
|
||||
};
|
||||
});
|
||||
emits("syncAnchors", positions);
|
||||
};
|
||||
|
||||
/**
|
||||
* 将同一帧内的多次尺寸变化合并为一次锚点同步;范围仅限当前分支节点。
|
||||
*/
|
||||
const scheduleAnchorSync = () => {
|
||||
if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame);
|
||||
anchorSyncFrame = requestAnimationFrame(() => {
|
||||
anchorSyncFrame = null;
|
||||
syncAnchorPositions();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存节点名称;范围仅限当前分支节点。
|
||||
* @param {string} name 新的节点名称
|
||||
*/
|
||||
const setNodeName = (name) => {
|
||||
const properties = window.lf.getProperties(props.model.id);
|
||||
window.lf.setProperties(props.model.id, { ...properties, name });
|
||||
emits("contentChange");
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭节点运行状态浮层;范围仅限当前分支节点。
|
||||
*/
|
||||
const closePopover = () => {
|
||||
nodeStateRef.value?.closePopover();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => conditions.value.map((condition) => condition.id),
|
||||
() => nextTick(scheduleAnchorSync),
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
scheduleAnchorSync();
|
||||
resizeObserver = new ResizeObserver(scheduleAnchorSync);
|
||||
if (switchRef.value) resizeObserver.observe(switchRef.value);
|
||||
});
|
||||
|
||||
onFlowEvent("changeNodeState", (data) => {
|
||||
if (data.nodeId !== props.model.id) return;
|
||||
nodeOperatingStatus.value = data.status;
|
||||
if (data.endTime && data.startTime) runtimes.value = data.endTime - data.startTime;
|
||||
errorInfoData.value = data?.message || "";
|
||||
try {
|
||||
inputJsonData.value = JSON.parse(data.paramsIn) || {};
|
||||
outputJsonData.value = JSON.parse(data.paramsOut) || {};
|
||||
} catch {
|
||||
inputJsonData.value = data.paramsIn || {};
|
||||
outputJsonData.value = data.paramsOut || {};
|
||||
}
|
||||
emits("contentChange");
|
||||
});
|
||||
|
||||
onFlowEvent("contentChange", (data) => {
|
||||
if (data.id !== props.model.id) return;
|
||||
nodeOperatingStatus.value = "NORMAL";
|
||||
inputJsonData.value = {};
|
||||
outputJsonData.value = {};
|
||||
emits("contentChange");
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect();
|
||||
if (anchorSyncFrame !== null) cancelAnimationFrame(anchorSyncFrame);
|
||||
});
|
||||
|
||||
defineExpose({ closePopover });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.node__container {
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
padding: 16px;
|
||||
border: 1px solid #dfe4ec;
|
||||
border-left: 4px solid var(--node-accent, #b7791f);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 24px rgba(31, 41, 55, 0.09);
|
||||
box-sizing: border-box;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.condition-summary {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e8ebf0;
|
||||
}
|
||||
|
||||
.condition-summary__title,
|
||||
.condition-summary__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.condition-summary__title {
|
||||
margin-bottom: 8px;
|
||||
color: #344054;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.condition-summary__count {
|
||||
color: #98a2b3;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.condition-summary__item {
|
||||
min-height: 44px;
|
||||
margin-top: 6px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e4e8ef;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
width: 17px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -18px;
|
||||
border-top: 1px solid var(--node-accent, #b7791f);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.condition-summary__item--else {
|
||||
background: #fffaf0;
|
||||
}
|
||||
|
||||
.condition-summary__order {
|
||||
width: 96px;
|
||||
flex: 0 0 96px;
|
||||
color: #3978c5;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.condition-summary__text {
|
||||
flex: 1;
|
||||
color: #7a8494;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
white-space: normal;
|
||||
}
|
||||
</style>
|
||||
@ -1,95 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:Vue Flow 连线渲染组件,负责路径展示及选中状态下的删除操作。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<BaseEdge
|
||||
:id="id"
|
||||
:path="edgePath"
|
||||
:marker-end="markerEnd"
|
||||
:interaction-width="interactionWidth || 32"
|
||||
:style="style"
|
||||
/>
|
||||
|
||||
<EdgeLabelRenderer v-if="selected && !flowStore.disableForm">
|
||||
<div class="edge-delete-action nodrag nopan" :style="deleteActionStyle">
|
||||
<el-tooltip content="删除连线" placement="top">
|
||||
<button type="button" aria-label="删除连线" @click.stop="deleteEdge">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { BaseEdge, EdgeLabelRenderer, getBezierPath } from "@vue-flow/core";
|
||||
import { Delete } from "@element-plus/icons-vue";
|
||||
import { useFlowStore } from "@/store/modules/flow";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = defineProps({
|
||||
id: String,
|
||||
sourceX: Number,
|
||||
sourceY: Number,
|
||||
targetX: Number,
|
||||
targetY: Number,
|
||||
sourcePosition: String,
|
||||
targetPosition: String,
|
||||
markerEnd: String,
|
||||
interactionWidth: Number,
|
||||
selected: Boolean,
|
||||
style: Object,
|
||||
});
|
||||
|
||||
const flowStore = useFlowStore();
|
||||
const pathData = computed(() => getBezierPath({
|
||||
sourceX: props.sourceX,
|
||||
sourceY: props.sourceY,
|
||||
sourcePosition: props.sourcePosition,
|
||||
targetX: props.targetX,
|
||||
targetY: props.targetY,
|
||||
targetPosition: props.targetPosition,
|
||||
}));
|
||||
const edgePath = computed(() => pathData.value[0]);
|
||||
const deleteActionStyle = computed(() => ({
|
||||
transform: `translate(-50%, -50%) translate(${pathData.value[1]}px, ${pathData.value[2]}px)`,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 删除与 edge 相关的状态或数据,该影响范围仅限当前组件。
|
||||
*/
|
||||
const deleteEdge = () => {
|
||||
window.lf?.deleteEdge(props.id);
|
||||
window.lf?.clearSelectElements();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.edge-delete-action {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
pointer-events: all;
|
||||
|
||||
button {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 1px solid #efb8b4;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: #d93025;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(31, 41, 55, 0.2);
|
||||
|
||||
&:hover {
|
||||
border-color: #d93025;
|
||||
background: #fff1f0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,419 +0,0 @@
|
||||
<!--
|
||||
* 文件说明:Vue Flow 节点壳组件,负责锚点、连接规则、分支位置及分组交互适配。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
class="flow-node-shell"
|
||||
:class="[`flow-node-shell--${flowType}`, {
|
||||
'flow-node-shell--selected': selected,
|
||||
'flow-node-shell--queue-drop-active': queueDropActive,
|
||||
}]"
|
||||
>
|
||||
<Handle
|
||||
v-if="showTarget"
|
||||
:id="targetHandleId"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
:connectable="connectable && !flowStore.disableForm"
|
||||
:is-valid-connection="isValidLoopConnection"
|
||||
class="flow-handle flow-handle--target"
|
||||
/>
|
||||
<div class="flow-node-content" :class="{ 'flow-node-card': !isGroup }">
|
||||
<component
|
||||
:is="nodeComponent"
|
||||
ref="componentRef"
|
||||
:model="model"
|
||||
:graph-model="model"
|
||||
:properties="properties"
|
||||
:child-count="isGroup ? childNodeCount : undefined"
|
||||
@content-change="contentChanged"
|
||||
@bind-ref="bindComponent"
|
||||
@add-anchor="addAnchor"
|
||||
@remove-anchor="removeAnchor"
|
||||
@change-anchor="changeAnchor"
|
||||
@sync-anchors="syncBranchAnchors"
|
||||
@add-to-group="addToGroup"
|
||||
/>
|
||||
<div v-if="executionLabel" class="flow-node-executor">{{ executionLabel }}</div>
|
||||
</div>
|
||||
|
||||
<template v-if="flowType === 'branch'">
|
||||
<template v-for="anchor in branchAnchors" :key="anchor.id">
|
||||
<Handle
|
||||
:id="anchor.id"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
:connectable="connectable && !flowStore.disableForm"
|
||||
:is-valid-connection="isValidLoopConnection"
|
||||
class="flow-handle flow-handle--source flow-handle--branch"
|
||||
:style="{ top: `${Math.max(40, Number(anchor.height || 0))}px` }"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<Handle
|
||||
v-else-if="showSource"
|
||||
:id="`${id}_1`"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
:connectable="connectable && !flowStore.disableForm"
|
||||
:is-valid-connection="isValidLoopConnection"
|
||||
class="flow-handle flow-handle--source"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { Handle, Position, useVueFlow } from "@vue-flow/core";
|
||||
import { useFlowStore } from "@/store/modules/flow";
|
||||
import StartNode from "../nodes/common/startNode.vue";
|
||||
import EndNode from "../nodes/common/endNode.vue";
|
||||
import ServiceNode from "../nodes/common/serviceNode.vue";
|
||||
import SelectAreaNode from "../nodes/common/selectAreaNode.vue";
|
||||
import LoopNode from "../nodes/function/loop.vue";
|
||||
import BranchNode from "../nodes/function/switch.vue";
|
||||
import StopLoopNode from "../nodes/function/stopLoop.vue";
|
||||
import SubStartNode from "../nodes/function/subStart.vue";
|
||||
import SubEndNode from "../nodes/function/subEnd.vue";
|
||||
import SleepNode from "../nodes/function/sleep.vue";
|
||||
import HttpNode from "../nodes/function/httpNode.vue";
|
||||
import CodeNode from "../nodes/function/codeNode.vue";
|
||||
import CurrentLoopNode from "../nodes/function/currentLoop.vue";
|
||||
import SdAgentNode from "../nodes/function/sdAgent.vue";
|
||||
import RecognizeNode from "../nodes/function/recognize.vue";
|
||||
import DeviceUniversal from "../nodes/function/deviceUniversal.vue";
|
||||
import ActionQueue from "../nodes/function/actionQueue.vue";
|
||||
import SubFlowNode from "../nodes/function/subFlow.vue";
|
||||
|
||||
const props = defineProps({
|
||||
id: String,
|
||||
data: Object,
|
||||
selected: Boolean,
|
||||
queueDropActive: Boolean,
|
||||
connectable: [Boolean, Number, String, Function],
|
||||
});
|
||||
|
||||
const componentMap = {
|
||||
start: StartNode,
|
||||
end: EndNode,
|
||||
serviceNode: ServiceNode,
|
||||
selectArea: SelectAreaNode,
|
||||
loop: LoopNode,
|
||||
branch: BranchNode,
|
||||
stopLoop: StopLoopNode,
|
||||
subStart: SubStartNode,
|
||||
subEnd: SubEndNode,
|
||||
sleep: SleepNode,
|
||||
http: HttpNode,
|
||||
code: CodeNode,
|
||||
currentLoop: CurrentLoopNode,
|
||||
sdAgent: SdAgentNode,
|
||||
recognize: RecognizeNode,
|
||||
deviceUniversal: DeviceUniversal,
|
||||
device_universal: DeviceUniversal,
|
||||
actionQueue: ActionQueue,
|
||||
subFlow: SubFlowNode,
|
||||
};
|
||||
|
||||
const flowStore = useFlowStore();
|
||||
const vueFlow = useVueFlow("main-flow");
|
||||
const componentRef = ref();
|
||||
const flowType = computed(() => props.data.flowType);
|
||||
const properties = computed(() => props.data.properties || {});
|
||||
const executionLabel = computed(() => {
|
||||
const roleKey = properties.value.executionTarget?.roleKey;
|
||||
if (!roleKey) return "";
|
||||
const graph = window.lf?.getGraphData?.();
|
||||
const start = graph?.nodes?.find((node) => node.type === "start");
|
||||
const role = start?.properties?.resourceRoles?.find((item) => item.roleKey === roleKey);
|
||||
if (!role) return "未配置机器人角色";
|
||||
const bindings = properties.value.executionTarget?.deviceBindings || [];
|
||||
if (bindings.length > 1) return `${role.displayName} · ${bindings.length} 个设备用途`;
|
||||
const slotKey = properties.value.executionTarget?.deviceSlotKey
|
||||
|| properties.value.executionTarget?.deviceBindings?.[0]?.slotKey;
|
||||
const slot = role.deviceSlots?.find((item) => item.slotKey === slotKey);
|
||||
return slot ? `${role.displayName} · ${slot.displayName}` : role.displayName;
|
||||
});
|
||||
const nodeComponent = computed(() => componentMap[flowType.value] || ServiceNode);
|
||||
const isGroup = computed(() => ["loop", "selectArea"].includes(flowType.value));
|
||||
const childNodeCount = computed(() => (
|
||||
(vueFlow.getNodes.value || []).filter((node) => node.parentNode === props.id).length
|
||||
));
|
||||
const showTarget = computed(() => !["start", "subStart", "selectArea"].includes(flowType.value));
|
||||
const showSource = computed(() => !["end", "stopLoop", "subEnd", "selectArea", "branch"].includes(flowType.value));
|
||||
const targetHandleId = computed(() => flowType.value === "branch" ? `${props.id}_entry` : `${props.id}_3`);
|
||||
const branchAnchors = computed(() => {
|
||||
const reservedIds = new Set([`${props.id}_1`, `${props.id}_3`, `${props.id}_entry`]);
|
||||
const conditionDefs = properties.value.nodeParams || properties.value.conditions || [];
|
||||
const conditionIds = new Set(conditionDefs.map((condition) => condition?.id).filter(Boolean));
|
||||
const elseId = `${props.id}_else`;
|
||||
const seen = new Set();
|
||||
return (properties.value.anchor || []).filter((anchor) => {
|
||||
if (!anchor?.id || reservedIds.has(anchor.id) || seen.has(anchor.id)) return false;
|
||||
if (conditionIds.size && anchor.id !== elseId && !conditionIds.has(anchor.id)) return false;
|
||||
seen.add(anchor.id);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const model = computed(() => window.lf?.getNodeModelById(props.id));
|
||||
|
||||
/**
|
||||
* 获取与 loop scope 相关的状态或数据,该影响范围仅限当前组件。
|
||||
* @param {*} nodeId 流程节点 ID
|
||||
*/
|
||||
const getLoopScope = (nodeId) => {
|
||||
const node = vueFlow.findNode(nodeId);
|
||||
const parent = node?.parentNode ? vueFlow.findNode(node.parentNode) : null;
|
||||
return parent?.data?.flowType === "loop" ? parent.id : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断与 valid loop connection 相关的状态或数据,该影响范围仅限当前组件。
|
||||
* @param {*} connection 待校验或创建的连接信息
|
||||
*/
|
||||
const isValidLoopConnection = (connection) => {
|
||||
if (!connection.source || !connection.target || connection.source === connection.target) return false;
|
||||
return getLoopScope(connection.source) === getLoopScope(connection.target);
|
||||
};
|
||||
|
||||
/**
|
||||
* 同步与 anchors 相关的状态或数据,该影响范围仅限当前组件。
|
||||
* @param {*} anchors 需要同步的锚点集合
|
||||
*/
|
||||
const syncAnchors = (anchors) => {
|
||||
window.lf?.setProperties(props.id, { ...window.lf.getProperties(props.id), anchor: anchors });
|
||||
vueFlow.updateNodeInternals([props.id]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 新增与 anchor 相关的状态或数据,该影响范围仅限当前组件。
|
||||
* @param {*} height 锚点纵向位置
|
||||
* @param {*} anchorId 分支锚点 ID
|
||||
*/
|
||||
const addAnchor = (height, anchorId) => {
|
||||
const anchors = [...branchAnchors.value];
|
||||
if (!anchors.some((anchor) => anchor.id === anchorId)) {
|
||||
anchors.push({ id: anchorId, name: "right", height });
|
||||
syncAnchors(anchors);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除与 anchor 相关的状态或数据,该影响范围仅限当前组件。
|
||||
* @param {*} anchorId 分支锚点 ID
|
||||
*/
|
||||
const removeAnchor = (anchorId) => syncAnchors(branchAnchors.value.filter((anchor) => anchor.id !== anchorId));
|
||||
/**
|
||||
* 更新与 anchor 相关的状态或数据,该影响范围仅限当前组件。
|
||||
* @param {*} height 锚点纵向位置
|
||||
* @param {*} anchorId 分支锚点 ID
|
||||
*/
|
||||
const changeAnchor = (height, anchorId) => syncAnchors(branchAnchors.value.map((anchor) => anchor.id === anchorId ? { ...anchor, height } : anchor));
|
||||
/**
|
||||
* 同步与 branch anchors 相关的状态或数据,该影响范围仅限当前组件。
|
||||
* @param {*} positions 分支锚点位置集合
|
||||
*/
|
||||
const syncBranchAnchors = (positions = []) => {
|
||||
if (!positions.length) return;
|
||||
const positionMap = new Map(positions.map((item) => [item.id, Number(item.height || 0)]));
|
||||
const current = [...(properties.value.anchor || [])];
|
||||
let changed = false;
|
||||
const anchors = current.map((anchor) => {
|
||||
if (!positionMap.has(anchor.id)) return anchor;
|
||||
const height = positionMap.get(anchor.id);
|
||||
positionMap.delete(anchor.id);
|
||||
if (Math.abs(Number(anchor.height || 0) - height) < 0.5) return anchor;
|
||||
changed = true;
|
||||
return { ...anchor, height };
|
||||
});
|
||||
positionMap.forEach((height, id) => {
|
||||
anchors.push({ id, name: "right", height });
|
||||
changed = true;
|
||||
});
|
||||
if (changed) syncAnchors(anchors);
|
||||
else vueFlow.updateNodeInternals([props.id]);
|
||||
};
|
||||
/**
|
||||
* 处理与 content changed 相关的状态或数据,该影响范围仅限当前组件。
|
||||
*/
|
||||
const contentChanged = () => {
|
||||
vueFlow.updateNodeInternals([props.id]);
|
||||
window.lf?.markChanged();
|
||||
};
|
||||
/**
|
||||
* 绑定与 component 相关的状态或数据,该影响范围仅限当前组件。
|
||||
* @param {*} instance 节点组件实例
|
||||
*/
|
||||
const bindComponent = (instance) => window.lf?.registerComponentInstance(props.id, instance);
|
||||
/**
|
||||
* 新增与 to group 相关的状态或数据,该影响范围仅限当前组件。
|
||||
* @param {*} childId 子节点 ID
|
||||
* @param {*} dropCenter 拖放中心点坐标
|
||||
*/
|
||||
const addToGroup = (childId, dropCenter) => window.lf?.addToGroup(props.id, childId, dropCenter);
|
||||
|
||||
onMounted(() => bindComponent(componentRef.value));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.flow-node-shell {
|
||||
--node-accent: #4f6b8a;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
|
||||
&--queue-drop-active .flow-node-content {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.35), 0 10px 24px rgba(47, 128, 237, 0.18);
|
||||
}
|
||||
|
||||
&--start,
|
||||
&--subStart { --node-accent: #239565; }
|
||||
|
||||
&--end,
|
||||
&--subEnd,
|
||||
&--stopLoop { --node-accent: #d45252; }
|
||||
|
||||
&--branch { --node-accent: #b7791f; }
|
||||
&--loop,
|
||||
&--currentLoop { --node-accent: #df6b35; }
|
||||
&--http,
|
||||
&--code { --node-accent: #3978c5; }
|
||||
&--sdAgent,
|
||||
&--recognize { --node-accent: #7a5ab8; }
|
||||
|
||||
&--loop,
|
||||
&--selectArea {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:hover .flow-node-card {
|
||||
border-color: #cbd3df;
|
||||
box-shadow: 0 10px 26px rgba(31, 41, 55, 0.11);
|
||||
}
|
||||
|
||||
&--selected .flow-node-card {
|
||||
border-color: #2f80ed;
|
||||
box-shadow: 0 0 0 2px rgba(47, 128, 237, 0.2), 0 10px 26px rgba(31, 41, 55, 0.12);
|
||||
}
|
||||
|
||||
&--selected.flow-node-shell--branch :deep(.node__container),
|
||||
&--selected.flow-node-shell--loop :deep(.group__container),
|
||||
&--selected.flow-node-shell--selectArea :deep(.group__container) {
|
||||
border-color: #2f80ed;
|
||||
box-shadow: 0 0 0 2px rgba(47, 128, 237, 0.18);
|
||||
}
|
||||
}
|
||||
|
||||
.flow-node-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.flow-node-executor {
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
color: #667085;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.flow-node-card {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 13px 14px 13px 16px;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border: 1px solid #dfe4ec;
|
||||
border-left: 4px solid var(--node-accent);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 7px 20px rgba(31, 41, 55, 0.08);
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
|
||||
:deep(.node__container) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:deep(.terminal-node) {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
:deep(.terminal-node__icon) {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 38px;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
:deep(.terminal-node__icon img) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
:deep(.terminal-node__content) { min-width: 0; }
|
||||
|
||||
:deep(.terminal-node__title) {
|
||||
color: #1f2937;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
:deep(.terminal-node__description) {
|
||||
overflow: hidden;
|
||||
margin-top: 3px;
|
||||
color: #7a8494;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.flow-handle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #fff;
|
||||
background: var(--node-accent);
|
||||
box-shadow: 0 0 0 1px var(--node-accent), 0 2px 5px rgba(31, 41, 55, 0.18);
|
||||
z-index: 10;
|
||||
transition: transform 0.14s ease, box-shadow 0.14s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.18), 0 2px 6px rgba(31, 41, 55, 0.2);
|
||||
}
|
||||
|
||||
&--target {
|
||||
left: -6px;
|
||||
background: #fff;
|
||||
border-color: var(--node-accent);
|
||||
}
|
||||
|
||||
&--source { right: -6px; }
|
||||
&--branch { transform: translate(50%, -50%); }
|
||||
|
||||
&--branch:hover { transform: translate(50%, -50%) scale(1.18); }
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
@ -1,990 +0,0 @@
|
||||
/**
|
||||
* 文件说明:旧流程数据与 Vue Flow 状态、操作 API 之间的兼容适配层。
|
||||
* 作用范围:仅服务于流程设计器模块。
|
||||
*/
|
||||
import { nextTick } from "vue";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { MarkerType } from "@vue-flow/core";
|
||||
|
||||
/**
|
||||
* 克隆 clone 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} value 待处理的新值
|
||||
*/
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value ?? {}));
|
||||
|
||||
const GROUP_TYPES = new Set(["loop", "selectArea"]);
|
||||
const TARGET_ONLY_TYPES = new Set(["end", "stopLoop", "subEnd"]);
|
||||
const SOURCE_ONLY_TYPES = new Set(["start", "subStart"]);
|
||||
const LOOP_BODY_INSET = { left: 24, top: 112, right: 24, bottom: 24 };
|
||||
const GROUP_BODY_INSET = { left: 24, top: 120, right: 24, bottom: 24 };
|
||||
const LOOP_MIN_SIZE = { width: 520, height: 360 };
|
||||
|
||||
/**
|
||||
* 处理 loopChildExtent 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
const loopChildExtent = () => ({
|
||||
range: "parent",
|
||||
padding: [
|
||||
LOOP_BODY_INSET.top,
|
||||
LOOP_BODY_INSET.right,
|
||||
LOOP_BODY_INSET.bottom,
|
||||
LOOP_BODY_INSET.left,
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* 编组 groupChildExtent 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
const groupChildExtent = () => ({
|
||||
range: "parent",
|
||||
padding: [
|
||||
GROUP_BODY_INSET.top,
|
||||
GROUP_BODY_INSET.right,
|
||||
GROUP_BODY_INSET.bottom,
|
||||
GROUP_BODY_INSET.left,
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理 nodeSize 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} node 当前流程节点
|
||||
*/
|
||||
const nodeSize = (node) => {
|
||||
// 分支节点采用统一的标准卡片尺寸,忽略旧流程数据中遗留的大尺寸记录。
|
||||
if (node.type === "branch") return { width: 372, height: 180 };
|
||||
const width = Number(node.properties?.width || node.width);
|
||||
const height = Number(node.properties?.height || node.height);
|
||||
if (Number.isFinite(width) && Number.isFinite(height)) {
|
||||
return { width, height };
|
||||
}
|
||||
if (GROUP_TYPES.has(node.type)) return { width: 720, height: 460 };
|
||||
if (TARGET_ONLY_TYPES.has(node.type) || SOURCE_ONLY_TYPES.has(node.type)) {
|
||||
return { width: 328, height: 120 };
|
||||
}
|
||||
return { width: 372, height: 180 };
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 edgeId 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
const edgeId = () => `edge_${uuid().replace(/-/g, "")}`;
|
||||
/**
|
||||
* 处理 nodeId 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
const nodeId = () => uuid().replace(/-/g, "");
|
||||
|
||||
/**
|
||||
* 处理 toVueNode 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} node 当前流程节点
|
||||
* @param {*} sizeOverride 调用方传入的 sizeOverride 参数
|
||||
* @param {*} positionOverride 调用方传入的 positionOverride 参数
|
||||
*/
|
||||
const toVueNode = (node, sizeOverride, positionOverride) => {
|
||||
const normalized = clone(node);
|
||||
normalized.id ||= nodeId();
|
||||
normalized.properties ||= {};
|
||||
const overrideWidth = Number(sizeOverride?.width);
|
||||
const overrideHeight = Number(sizeOverride?.height);
|
||||
const size = Number.isFinite(overrideWidth) && overrideWidth > 0
|
||||
&& Number.isFinite(overrideHeight) && overrideHeight > 0
|
||||
? { width: overrideWidth, height: overrideHeight }
|
||||
: nodeSize(normalized);
|
||||
const center = {
|
||||
x: Number(normalized.x) || 0,
|
||||
y: Number(normalized.y) || 0,
|
||||
};
|
||||
const overrideX = Number(positionOverride?.x);
|
||||
const overrideY = Number(positionOverride?.y);
|
||||
const position = Number.isFinite(overrideX) && Number.isFinite(overrideY)
|
||||
? { x: overrideX, y: overrideY }
|
||||
: {
|
||||
x: center.x - size.width / 2,
|
||||
y: center.y - size.height / 2,
|
||||
};
|
||||
|
||||
const dimensions = GROUP_TYPES.has(normalized.type)
|
||||
? { width: size.width, height: size.height }
|
||||
: { width: size.width };
|
||||
|
||||
return {
|
||||
id: normalized.id,
|
||||
type: "flow-node",
|
||||
position,
|
||||
...dimensions,
|
||||
draggable: normalized.draggable !== false,
|
||||
selectable: true,
|
||||
deletable: !SOURCE_ONLY_TYPES.has(normalized.type) && normalized.type !== "end",
|
||||
connectable: true,
|
||||
zIndex: GROUP_TYPES.has(normalized.type) ? 0 : 2,
|
||||
data: {
|
||||
flowType: normalized.type,
|
||||
properties: normalized.properties,
|
||||
children: Array.isArray(normalized.children) ? normalized.children : [],
|
||||
text: normalized.text,
|
||||
backend: normalized,
|
||||
originalCenter: center,
|
||||
originalPosition: position,
|
||||
size,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 toVueEdge 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} edge 调用方传入的 edge 参数
|
||||
*/
|
||||
const toVueEdge = (edge) => {
|
||||
const normalized = clone(edge);
|
||||
normalized.id ||= edgeId();
|
||||
return {
|
||||
id: normalized.id,
|
||||
source: normalized.sourceNodeId,
|
||||
target: normalized.targetNodeId,
|
||||
sourceHandle: normalized.sourceAnchorId || null,
|
||||
targetHandle: normalized.targetAnchorId || null,
|
||||
type: "flow-edge",
|
||||
animated: true,
|
||||
selectable: true,
|
||||
focusable: true,
|
||||
deletable: true,
|
||||
interactionWidth: 32,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: "#ff6b35",
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
style: { stroke: "#ff6b35", strokeWidth: 2 },
|
||||
data: { backend: normalized },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 关联 attachChildrenToGroups 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} nodes 调用方传入的 nodes 参数
|
||||
*/
|
||||
const attachChildrenToGroups = (nodes) => {
|
||||
// 先建立节点索引和显式父子关系,再用旧数据中的 children 补齐缺失关系。
|
||||
// 最后统一换算相对坐标,避免父节点尚未就绪时提前计算造成位置漂移。
|
||||
const nodeLookup = new Map(nodes.map((node) => [node.id, node]));
|
||||
const declaredParents = new Map();
|
||||
|
||||
nodes.filter((node) => GROUP_TYPES.has(node.data.flowType)).forEach((group) => {
|
||||
(group.data.children || []).forEach((childId) => {
|
||||
if (!declaredParents.has(childId)) declaredParents.set(childId, group.id);
|
||||
});
|
||||
});
|
||||
|
||||
nodes.forEach((child) => {
|
||||
const parentId = child.data.properties?.parentId || declaredParents.get(child.id);
|
||||
const parent = nodeLookup.get(parentId);
|
||||
if (!parent || !GROUP_TYPES.has(parent.data.flowType) || GROUP_TYPES.has(child.data.flowType)) return;
|
||||
|
||||
const isLoop = parent.data.flowType === "loop";
|
||||
const relativePosition = {
|
||||
x: isLoop
|
||||
? Math.max(child.position.x - parent.position.x, LOOP_BODY_INSET.left)
|
||||
: child.position.x - parent.position.x,
|
||||
y: isLoop
|
||||
? Math.max(child.position.y - parent.position.y, LOOP_BODY_INSET.top)
|
||||
: child.position.y - parent.position.y,
|
||||
};
|
||||
child.parentNode = parentId;
|
||||
child.extent = isLoop ? loopChildExtent() : groupChildExtent();
|
||||
child.expandParent = false;
|
||||
child.position = relativePosition;
|
||||
child.zIndex = 5;
|
||||
child.data.properties = { ...child.data.properties, parentId };
|
||||
parent.data.children = Array.from(new Set([...(parent.data.children || []), child.id]));
|
||||
});
|
||||
|
||||
nodes.filter((node) => node.data.flowType === "loop").forEach((group) => {
|
||||
const children = nodes.filter((node) => node.parentNode === group.id);
|
||||
if (!children.length) return;
|
||||
const requiredWidth = Math.max(...children.map((child) => (
|
||||
child.position.x + Number(child.data.size?.width || 0) + LOOP_BODY_INSET.right
|
||||
)));
|
||||
const requiredHeight = Math.max(...children.map((child) => (
|
||||
child.position.y + Number(child.data.size?.height || 0) + LOOP_BODY_INSET.bottom
|
||||
)));
|
||||
const width = Math.max(Number(group.data.size?.width || 0), LOOP_MIN_SIZE.width, requiredWidth);
|
||||
const height = Math.max(Number(group.data.size?.height || 0), LOOP_MIN_SIZE.height, requiredHeight);
|
||||
group.width = width;
|
||||
group.height = height;
|
||||
group.data.size = { width, height };
|
||||
group.data.properties = { ...group.data.properties, width, height };
|
||||
});
|
||||
|
||||
return nodes.sort((left, right) => Number(Boolean(left.parentNode)) - Number(Boolean(right.parentNode)));
|
||||
};
|
||||
|
||||
export const toVueFlowData = (graph = {}, options = {}) => {
|
||||
const nodes = attachChildrenToGroups((graph.nodes || []).map((node) => (
|
||||
toVueNode(node, options.nodeSizes?.get(node.id), options.nodePositions?.get(node.id))
|
||||
)));
|
||||
const nodeLookup = new Map(nodes.map((node) => [node.id, node]));
|
||||
const edges = (graph.edges || []).map((edge) => {
|
||||
const vueEdge = toVueEdge(edge);
|
||||
const source = nodeLookup.get(vueEdge.source);
|
||||
const target = nodeLookup.get(vueEdge.target);
|
||||
if (source?.parentNode || target?.parentNode) vueEdge.zIndex = 4;
|
||||
return vueEdge;
|
||||
});
|
||||
return { nodes, edges };
|
||||
};
|
||||
|
||||
export const createFlowFacade = (store) => {
|
||||
const handlers = new Map();
|
||||
const componentInstances = new Map();
|
||||
const loopLayoutStates = new Map();
|
||||
let historySuspended = false;
|
||||
let history = [];
|
||||
let historyIndex = -1;
|
||||
|
||||
/**
|
||||
* 获取 getNodes 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
const getNodes = () => store.getNodes.value || [];
|
||||
/**
|
||||
* 获取 getEdges 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
const getEdges = () => store.getEdges.value || [];
|
||||
|
||||
/**
|
||||
* 处理 backendNode 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} node 当前流程节点
|
||||
*/
|
||||
const backendNode = (node) => {
|
||||
const result = clone(node.data.backend);
|
||||
const size = {
|
||||
width: Number(node.width || node.dimensions?.width || node.data.size?.width || 0),
|
||||
height: Number(node.height || node.dimensions?.height || node.data.size?.height || 0),
|
||||
};
|
||||
const parent = node.parentNode ? store.findNode(node.parentNode) : null;
|
||||
const parentPosition = parent?.position || parent?.computedPosition || { x: 0, y: 0 };
|
||||
const absolutePosition = parent
|
||||
? { x: parentPosition.x + node.position.x, y: parentPosition.y + node.position.y }
|
||||
: (node.position || node.computedPosition);
|
||||
result.id = node.id;
|
||||
result.type = node.data.flowType;
|
||||
if (GROUP_TYPES.has(result.type)) {
|
||||
result.x = absolutePosition.x + size.width / 2;
|
||||
result.y = absolutePosition.y + size.height / 2;
|
||||
} else {
|
||||
const originalCenter = node.data.originalCenter || {
|
||||
x: absolutePosition.x + size.width / 2,
|
||||
y: absolutePosition.y + size.height / 2,
|
||||
};
|
||||
const originalPosition = node.data.originalPosition || absolutePosition;
|
||||
result.x = originalCenter.x + absolutePosition.x - originalPosition.x;
|
||||
result.y = originalCenter.y + absolutePosition.y - originalPosition.y;
|
||||
}
|
||||
result.properties = clone(node.data.properties || {});
|
||||
if (parent?.data?.flowType === "selectArea") delete result.properties.parentId;
|
||||
if (GROUP_TYPES.has(result.type)) {
|
||||
result.properties.width = size.width;
|
||||
result.properties.height = size.height;
|
||||
}
|
||||
if (node.data.children?.length || GROUP_TYPES.has(result.type)) {
|
||||
result.children = [...(node.data.children || [])];
|
||||
} else {
|
||||
delete result.children;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 backendEdge 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} edge 调用方传入的 edge 参数
|
||||
*/
|
||||
const backendEdge = (edge) => ({
|
||||
...clone(edge.data?.backend),
|
||||
id: edge.id,
|
||||
type: edge.data?.backend?.type || "bezier",
|
||||
sourceNodeId: edge.source,
|
||||
targetNodeId: edge.target,
|
||||
sourceAnchorId: edge.sourceHandle || `${edge.source}_1`,
|
||||
targetAnchorId: edge.targetHandle || `${edge.target}_3`,
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理 emit 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} name 名称或事件名称
|
||||
* @param {*} payload 事件数据
|
||||
*/
|
||||
const emit = (name, payload) => {
|
||||
(handlers.get(name) || []).forEach((handler) => handler(payload));
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 recordHistory 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
const recordHistory = () => {
|
||||
if (historySuspended) return;
|
||||
const graph = facade.getGraphData();
|
||||
const serialized = JSON.stringify(graph);
|
||||
if (history[historyIndex]?.serialized === serialized) return;
|
||||
history = history.slice(0, historyIndex + 1);
|
||||
history.push({ serialized, graph: clone(graph) });
|
||||
if (history.length > 100) history.shift();
|
||||
historyIndex = history.length - 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新 changed 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
const changed = () => {
|
||||
if (historySuspended) return;
|
||||
recordHistory();
|
||||
emit("history:change", { data: facade.getGraphData() });
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取 getNodeRect 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} node 当前流程节点
|
||||
*/
|
||||
const getNodeRect = (node) => {
|
||||
const parent = node.parentNode ? store.findNode(node.parentNode) : null;
|
||||
const parentPosition = parent?.position || parent?.computedPosition || { x: 0, y: 0 };
|
||||
const position = parent
|
||||
? { x: parentPosition.x + node.position.x, y: parentPosition.y + node.position.y }
|
||||
: (node.position || node.computedPosition);
|
||||
return {
|
||||
x: Number(position?.x || 0),
|
||||
y: Number(position?.y || 0),
|
||||
width: Number(node.width || node.dimensions?.width || node.data.size?.width || 0),
|
||||
height: Number(node.height || node.dimensions?.height || node.data.size?.height || 0),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取 getLoopLayoutState 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} group 调用方传入的 group 参数
|
||||
*/
|
||||
const getLoopLayoutState = (group) => {
|
||||
const current = getNodeRect(group);
|
||||
const existing = loopLayoutStates.get(group.id);
|
||||
/**
|
||||
* 处理 if 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} !existing 调用方传入的 !existing 参数
|
||||
*/
|
||||
if (!existing) {
|
||||
const state = { base: { ...current }, last: { ...current } };
|
||||
loopLayoutStates.set(group.id, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
const sizeChanged = Math.abs(current.width - existing.last.width) > 1
|
||||
|| Math.abs(current.height - existing.last.height) > 1;
|
||||
/**
|
||||
* 处理 if 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} sizeChanged 调用方传入的 sizeChanged 参数
|
||||
*/
|
||||
if (sizeChanged) {
|
||||
existing.base = { ...current };
|
||||
} else {
|
||||
const dx = current.x - existing.last.x;
|
||||
const dy = current.y - existing.last.y;
|
||||
existing.base.x += dx;
|
||||
existing.base.y += dy;
|
||||
}
|
||||
existing.last = { ...current };
|
||||
return existing;
|
||||
};
|
||||
|
||||
/**
|
||||
* 布局 layoutLoop 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} group 调用方传入的 group 参数
|
||||
* @param {*} activeChildId 调用方传入的 activeChildId 参数
|
||||
* @param {*} activePosition 调用方传入的 activePosition 参数
|
||||
*/
|
||||
const layoutLoop = (group, activeChildId, activePosition) => {
|
||||
const state = getLoopLayoutState(group);
|
||||
const children = getNodes().filter((node) => node.parentNode === group.id);
|
||||
if (!children.length) return false;
|
||||
|
||||
const snapshots = children.map((child) => {
|
||||
const rect = getNodeRect(child);
|
||||
const position = child.id === activeChildId && activePosition
|
||||
? { x: activePosition.x, y: activePosition.y }
|
||||
: { x: rect.x, y: rect.y };
|
||||
return { child, position, width: rect.width, height: rect.height };
|
||||
});
|
||||
const contentLeft = Math.min(...snapshots.map(({ position }) => position.x));
|
||||
const contentTop = Math.min(...snapshots.map(({ position }) => position.y));
|
||||
const contentRight = Math.max(...snapshots.map(({ position, width }) => position.x + width));
|
||||
const contentBottom = Math.max(...snapshots.map(({ position, height }) => position.y + height));
|
||||
const left = Math.min(state.base.x, contentLeft - LOOP_BODY_INSET.left);
|
||||
const top = Math.min(state.base.y, contentTop - LOOP_BODY_INSET.top);
|
||||
const right = Math.max(
|
||||
state.base.x + Math.max(state.base.width, LOOP_MIN_SIZE.width),
|
||||
contentRight + LOOP_BODY_INSET.right,
|
||||
);
|
||||
const bottom = Math.max(
|
||||
state.base.y + Math.max(state.base.height, LOOP_MIN_SIZE.height),
|
||||
contentBottom + LOOP_BODY_INSET.bottom,
|
||||
);
|
||||
const width = right - left;
|
||||
const height = bottom - top;
|
||||
const properties = { ...group.data.properties, width, height };
|
||||
|
||||
store.updateNode(group.id, {
|
||||
position: { x: left, y: top },
|
||||
computedPosition: { ...group.computedPosition, x: left, y: top },
|
||||
dimensions: { width, height },
|
||||
width,
|
||||
height,
|
||||
});
|
||||
store.updateNodeData(group.id, {
|
||||
...group.data,
|
||||
properties,
|
||||
size: { width, height },
|
||||
}, { replace: true });
|
||||
snapshots.forEach(({ child, position }) => {
|
||||
store.updateNode(child.id, {
|
||||
position: { x: position.x - left, y: position.y - top },
|
||||
computedPosition: { ...child.computedPosition, x: position.x, y: position.y },
|
||||
extent: loopChildExtent(),
|
||||
expandParent: false,
|
||||
});
|
||||
});
|
||||
state.last = { x: left, y: top, width, height };
|
||||
nextTick(() => store.updateNodeInternals([group.id, ...children.map((child) => child.id)]));
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取 getAnchors 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} node 当前流程节点
|
||||
*/
|
||||
const getAnchors = (node) => {
|
||||
const type = node.data.flowType;
|
||||
/**
|
||||
* 处理 if 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} type 目标类型
|
||||
*/
|
||||
if (type === "branch") {
|
||||
return [
|
||||
{ id: `${node.id}_entry`, name: "left" },
|
||||
...(node.data.properties?.anchor || []).map((item) => ({ ...item, name: "right" })),
|
||||
];
|
||||
}
|
||||
if (TARGET_ONLY_TYPES.has(type)) return [{ id: `${node.id}_3`, name: "left" }];
|
||||
if (SOURCE_ONLY_TYPES.has(type)) return [{ id: `${node.id}_1`, name: "right" }];
|
||||
if (type === "selectArea") return [];
|
||||
return [
|
||||
{ id: `${node.id}_1`, name: "right" },
|
||||
{ id: `${node.id}_3`, name: "left" },
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理 modelFor 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} id 目标元素 ID
|
||||
*/
|
||||
const modelFor = (id) => {
|
||||
const node = store.findNode(id);
|
||||
if (!node) return null;
|
||||
const model = {
|
||||
id,
|
||||
type: node.data.flowType,
|
||||
get x() { return backendNode(node).x; },
|
||||
get y() { return backendNode(node).y; },
|
||||
get width() { return node.dimensions?.width || node.data.size?.width || 0; },
|
||||
get height() { return node.dimensions?.height || node.data.size?.height || 0; },
|
||||
get left() { return getNodeRect(node).x; },
|
||||
get top() { return getNodeRect(node).y; },
|
||||
get parentId() { return node.parentNode || null; },
|
||||
get anchors() { return getAnchors(node); },
|
||||
get children() { return new Set(node.data.children || []); },
|
||||
set children(value) {
|
||||
const children = Array.from(value || []);
|
||||
store.updateNodeData(id, { ...node.data, children }, { replace: true });
|
||||
changed();
|
||||
},
|
||||
get draggable() { return node.draggable !== false; },
|
||||
set draggable(value) { store.updateNode(id, { draggable: Boolean(value) }); },
|
||||
getProperties: () => facade.getProperties(id),
|
||||
setProperties: (properties) => facade.setProperties(id, properties),
|
||||
setComponentInstance: (instance) => componentInstances.set(id, instance),
|
||||
closePopover: () => componentInstances.get(id)?.closePopover?.(),
|
||||
updateSize: () => store.updateNodeInternals([id]),
|
||||
setCustomProperties: () => store.updateNodeInternals([id]),
|
||||
};
|
||||
return model;
|
||||
};
|
||||
|
||||
const facade = {
|
||||
/**
|
||||
* 订阅 on 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} name 名称或事件名称
|
||||
* @param {*} handler 事件处理函数
|
||||
*/
|
||||
on(name, handler) {
|
||||
const list = handlers.get(name) || [];
|
||||
list.push(handler);
|
||||
handlers.set(name, list);
|
||||
return () => facade.off(name, handler);
|
||||
},
|
||||
/**
|
||||
* 订阅 once 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} name 名称或事件名称
|
||||
* @param {*} handler 事件处理函数
|
||||
*/
|
||||
once(name, handler) {
|
||||
/**
|
||||
* 处理 wrapped 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} payload 事件数据
|
||||
*/
|
||||
const wrapped = (payload) => {
|
||||
facade.off(name, wrapped);
|
||||
handler(payload);
|
||||
};
|
||||
facade.on(name, wrapped);
|
||||
},
|
||||
/**
|
||||
* 取消订阅 off 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} name 名称或事件名称
|
||||
* @param {*} handler 事件处理函数
|
||||
*/
|
||||
off(name, handler) {
|
||||
if (!handler) handlers.delete(name);
|
||||
else handlers.set(name, (handlers.get(name) || []).filter((item) => item !== handler));
|
||||
},
|
||||
emit,
|
||||
markChanged: changed,
|
||||
/**
|
||||
* 获取 getGraphData 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
getGraphData() {
|
||||
return {
|
||||
nodes: getNodes().map(backendNode),
|
||||
edges: getEdges().map(backendEdge),
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 渲染 render 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} graph 调用方传入的 graph 参数
|
||||
* @param {*} options 行为配置
|
||||
*/
|
||||
render(graph, options = {}) {
|
||||
historySuspended = true;
|
||||
loopLayoutStates.clear();
|
||||
const nodeSizes = options.preserveNodeSizes === true
|
||||
? new Map(getNodes().map((node) => [node.id, {
|
||||
width: Number(node.dimensions?.width || node.width || node.data.size?.width || 0),
|
||||
height: Number(node.dimensions?.height || node.height || node.data.size?.height || 0),
|
||||
}]))
|
||||
: options.nodeSizes;
|
||||
const nodePositions = options.preserveNodePositions === true
|
||||
? new Map(getNodes().map((node) => {
|
||||
const rect = getNodeRect(node);
|
||||
return [node.id, { x: rect.x, y: rect.y }];
|
||||
}))
|
||||
: options.nodePositions;
|
||||
const data = toVueFlowData(graph, { nodeSizes, nodePositions });
|
||||
/**
|
||||
* 处理 finishRender 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
const finishRender = () => {
|
||||
historySuspended = false;
|
||||
if (options.resetHistory !== false) {
|
||||
history = [];
|
||||
historyIndex = -1;
|
||||
recordHistory();
|
||||
}
|
||||
nextTick(() => store.updateNodeInternals());
|
||||
};
|
||||
/**
|
||||
* 处理 applyData 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
const applyData = () => {
|
||||
store.setNodes(data.nodes);
|
||||
store.setEdges(data.edges);
|
||||
finishRender();
|
||||
};
|
||||
|
||||
if (options.replaceNodes === true) {
|
||||
// Changing parentNode is structural in Vue Flow. Unmount in one tick
|
||||
// and remount in the next so node wrappers and edges cannot retain
|
||||
// different graph-node instances for the same id.
|
||||
store.setEdges([]);
|
||||
store.setNodes([]);
|
||||
nextTick(applyData);
|
||||
} else {
|
||||
applyData();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 新增 addNode 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} node 当前流程节点
|
||||
*/
|
||||
addNode(node) {
|
||||
const vueNode = toVueNode(node);
|
||||
store.addNodes(vueNode);
|
||||
changed();
|
||||
return backendNode(vueNode);
|
||||
},
|
||||
/**
|
||||
* 删除 deleteNode 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} id 目标元素 ID
|
||||
*/
|
||||
deleteNode(id) {
|
||||
const target = store.findNode(id);
|
||||
if (target?.data?.flowType === "selectArea") {
|
||||
facade.ungroup(id);
|
||||
return;
|
||||
}
|
||||
const parentId = target?.parentNode;
|
||||
const childIds = target?.data?.children || [];
|
||||
const removeChildren = target?.data?.flowType === "loop" && childIds.length > 0;
|
||||
const nodeIds = removeChildren ? [id, ...childIds] : id;
|
||||
const removedIds = new Set(Array.isArray(nodeIds) ? nodeIds : [nodeIds]);
|
||||
store.removeNodes(nodeIds, true, removeChildren);
|
||||
getNodes().forEach((node) => {
|
||||
if (node.data.children?.includes(id) && !removedIds.has(node.id)) {
|
||||
store.updateNodeData(node.id, {
|
||||
...node.data,
|
||||
children: node.data.children.filter((childId) => childId !== id),
|
||||
}, { replace: true });
|
||||
}
|
||||
});
|
||||
componentInstances.delete(id);
|
||||
childIds.forEach((childId) => componentInstances.delete(childId));
|
||||
changed();
|
||||
if (parentId) nextTick(() => facade.resizeGroupToChildren(parentId));
|
||||
},
|
||||
/**
|
||||
* 新增 addEdge 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} edge 调用方传入的 edge 参数
|
||||
*/
|
||||
addEdge(edge) {
|
||||
const vueEdge = toVueEdge(edge);
|
||||
const source = store.findNode(vueEdge.source);
|
||||
const target = store.findNode(vueEdge.target);
|
||||
if (source?.parentNode || target?.parentNode) vueEdge.zIndex = 4;
|
||||
store.addEdges(vueEdge);
|
||||
changed();
|
||||
return backendEdge(vueEdge);
|
||||
},
|
||||
/**
|
||||
* 删除 deleteEdge 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} id 目标元素 ID
|
||||
*/
|
||||
deleteEdge(id) {
|
||||
store.removeEdges(id);
|
||||
changed();
|
||||
},
|
||||
/**
|
||||
* 删除 deleteEdgeByNodeId 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} id 目标元素 ID
|
||||
*/
|
||||
deleteEdgeByNodeId(id) {
|
||||
store.removeEdges(getEdges().filter((edge) => edge.source === id || edge.target === id));
|
||||
changed();
|
||||
},
|
||||
/**
|
||||
* 获取 getProperties 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} id 目标元素 ID
|
||||
*/
|
||||
getProperties(id) {
|
||||
return clone(store.findNode(id)?.data?.properties || {});
|
||||
},
|
||||
/**
|
||||
* 设置 setProperties 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} id 目标元素 ID
|
||||
* @param {*} properties 调用方传入的 properties 参数
|
||||
*/
|
||||
setProperties(id, properties) {
|
||||
const node = store.findNode(id);
|
||||
if (!node) return;
|
||||
store.updateNodeData(id, { ...node.data, properties: clone(properties) }, { replace: true });
|
||||
nextTick(() => store.updateNodeInternals([id]));
|
||||
changed();
|
||||
},
|
||||
getNodeModelById: modelFor,
|
||||
/**
|
||||
* 获取 getSelectElements 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
getSelectElements() {
|
||||
return {
|
||||
nodes: (store.getSelectedNodes.value || []).map(backendNode),
|
||||
edges: (store.getSelectedEdges.value || []).map(backendEdge),
|
||||
};
|
||||
},
|
||||
/**
|
||||
* 清理 clearSelectElements 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
clearSelectElements() {
|
||||
store.removeSelectedElements();
|
||||
},
|
||||
/**
|
||||
* 处理 selectElementById 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} id 目标元素 ID
|
||||
*/
|
||||
selectElementById(id) {
|
||||
store.removeSelectedElements();
|
||||
if (!id) return;
|
||||
const node = store.findNode(id);
|
||||
if (node) store.addSelectedNodes([node]);
|
||||
const edge = store.findEdge(id);
|
||||
if (edge) store.addSelectedEdges([edge]);
|
||||
},
|
||||
/**
|
||||
* 处理 selectElementsByIds 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} ids 调用方传入的 ids 参数
|
||||
*/
|
||||
selectElementsByIds(ids = []) {
|
||||
store.removeSelectedElements();
|
||||
const nodes = ids.map((id) => store.findNode(id)).filter(Boolean);
|
||||
if (nodes.length) store.addSelectedNodes(nodes);
|
||||
},
|
||||
/**
|
||||
* 处理 focusOn 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} { id } 调用方传入的 { id } 参数
|
||||
*/
|
||||
focusOn({ id }) {
|
||||
const node = store.findNode(id);
|
||||
if (!node) return;
|
||||
const center = backendNode(node);
|
||||
store.setCenter(center.x, center.y, { zoom: Math.max(store.viewport.value?.zoom || 1, 0.7), duration: 300 });
|
||||
},
|
||||
/**
|
||||
* 处理 fitView 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
fitView() {
|
||||
return store.fitView({ padding: 0.18, duration: 300 });
|
||||
},
|
||||
/**
|
||||
* 处理 translateCenter 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
translateCenter() {
|
||||
return facade.fitView();
|
||||
},
|
||||
/**
|
||||
* 缩放 zoom 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} scale 缩放比例
|
||||
* @param {*} center 缩放中心
|
||||
*/
|
||||
zoom(scale, center) {
|
||||
if (Array.isArray(center) && center.length === 2) {
|
||||
return store.setCenter(center[0], center[1], { zoom: scale, duration: 250 });
|
||||
}
|
||||
return store.zoomTo(scale, { duration: 250 });
|
||||
},
|
||||
/**
|
||||
* 获取 getPointByClient 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} x 客户端横坐标
|
||||
* @param {*} y 客户端纵坐标
|
||||
*/
|
||||
getPointByClient(x, y) {
|
||||
const point = store.screenToFlowCoordinate({ x, y });
|
||||
return { canvasOverlayPosition: point, domOverlayPosition: point };
|
||||
},
|
||||
/**
|
||||
* 新增 addToGroup 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} groupId 分组节点 ID
|
||||
* @param {*} childId 子节点 ID
|
||||
* @param {*} dropCenter 拖放中心坐标
|
||||
*/
|
||||
addToGroup(groupId, childId, dropCenter) {
|
||||
const group = store.findNode(groupId);
|
||||
const child = store.findNode(childId);
|
||||
if (!group || !child || GROUP_TYPES.has(child.data.flowType)) return;
|
||||
const previousParentId = child.parentNode || child.data?.properties?.parentId;
|
||||
const graph = facade.getGraphData();
|
||||
const groupData = graph.nodes.find((node) => node.id === groupId);
|
||||
const childData = graph.nodes.find((node) => node.id === childId);
|
||||
if (!groupData || !childData) return;
|
||||
|
||||
// 节点从循环体外进入循环体后,原连线的两端已处于不同执行作用域,必须全部断开。
|
||||
// 在重新渲染父子结构前直接过滤图数据,可保证入边、出边和层级变更在同一次更新中完成。
|
||||
const enteringLoop = group.data.flowType === "loop" && previousParentId !== groupId;
|
||||
if (enteringLoop) {
|
||||
graph.edges = graph.edges.filter((edge) => (
|
||||
edge.sourceNodeId !== childId && edge.targetNodeId !== childId
|
||||
));
|
||||
}
|
||||
|
||||
groupData.children = Array.from(new Set([...(groupData.children || []), childId]));
|
||||
childData.properties = { ...(childData.properties || {}), parentId: groupId };
|
||||
if (dropCenter) {
|
||||
childData.x = dropCenter.x;
|
||||
childData.y = dropCenter.y;
|
||||
}
|
||||
facade.render(graph, {
|
||||
resetHistory: false,
|
||||
replaceNodes: true,
|
||||
preserveNodeSizes: true,
|
||||
preserveNodePositions: true,
|
||||
});
|
||||
nextTick(() => {
|
||||
store.updateNodeInternals([groupId, childId]);
|
||||
changed();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 编组 groupNodes 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} groupNode 调用方传入的 groupNode 参数
|
||||
* @param {*} childIds 子节点 ID 集合
|
||||
*/
|
||||
groupNodes(groupNode, childIds = []) {
|
||||
// 先计算选中节点的外接矩形确定容器尺寸,再把子节点坐标转换为容器内相对坐标。
|
||||
// 与分组外节点相连的边保持不变,此处仅更新节点层级和几何信息。
|
||||
const graph = facade.getGraphData();
|
||||
const selectedIds = new Set(childIds);
|
||||
const children = graph.nodes.filter((node) => (
|
||||
selectedIds.has(node.id)
|
||||
&& !GROUP_TYPES.has(node.type)
|
||||
&& !store.findNode(node.id)?.parentNode
|
||||
));
|
||||
if (!children.length) return null;
|
||||
|
||||
const group = clone(groupNode);
|
||||
group.id ||= nodeId();
|
||||
group.type = "selectArea";
|
||||
group.properties = { ...(group.properties || {}), action: "NONE" };
|
||||
group.children = children.map((node) => node.id);
|
||||
children.forEach((node) => {
|
||||
node.properties = { ...(node.properties || {}), parentId: group.id };
|
||||
});
|
||||
graph.nodes.push(group);
|
||||
facade.render(graph, {
|
||||
resetHistory: false,
|
||||
replaceNodes: true,
|
||||
preserveNodeSizes: true,
|
||||
preserveNodePositions: true,
|
||||
});
|
||||
nextTick(() => {
|
||||
store.updateNodeInternals([group.id, ...group.children]);
|
||||
changed();
|
||||
});
|
||||
return clone(group);
|
||||
},
|
||||
/**
|
||||
* 处理 ungroup 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} groupId 分组节点 ID
|
||||
*/
|
||||
ungroup(groupId) {
|
||||
const graph = facade.getGraphData();
|
||||
const group = graph.nodes.find((node) => node.id === groupId && node.type === "selectArea");
|
||||
if (!group) return false;
|
||||
const childIds = new Set([
|
||||
...(group.children || []),
|
||||
...graph.nodes.filter((node) => node.properties?.parentId === groupId).map((node) => node.id),
|
||||
]);
|
||||
graph.nodes = graph.nodes.filter((node) => node.id !== groupId);
|
||||
graph.nodes.forEach((node) => {
|
||||
if (!childIds.has(node.id)) return;
|
||||
const properties = { ...(node.properties || {}) };
|
||||
delete properties.parentId;
|
||||
node.properties = properties;
|
||||
});
|
||||
facade.render(graph, {
|
||||
resetHistory: false,
|
||||
replaceNodes: true,
|
||||
preserveNodeSizes: true,
|
||||
preserveNodePositions: true,
|
||||
});
|
||||
nextTick(() => {
|
||||
store.updateNodeInternals([...childIds]);
|
||||
changed();
|
||||
});
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* 准备 prepareLoopChildDrag 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} childId 子节点 ID
|
||||
*/
|
||||
prepareLoopChildDrag(childId) {
|
||||
const child = store.findNode(childId);
|
||||
const group = child?.parentNode ? store.findNode(child.parentNode) : null;
|
||||
if (!group || group.data.flowType !== "loop") return;
|
||||
store.updateNode(childId, {
|
||||
extent: loopChildExtent(),
|
||||
expandParent: false,
|
||||
});
|
||||
getLoopLayoutState(group);
|
||||
const rect = getNodeRect(child);
|
||||
return { groupId: group.id, position: { x: rect.x, y: rect.y } };
|
||||
},
|
||||
/**
|
||||
* 布局 layoutLoopDuringDrag 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} groupId 分组节点 ID
|
||||
* @param {*} childId 子节点 ID
|
||||
* @param {*} position 调用方传入的 position 参数
|
||||
*/
|
||||
layoutLoopDuringDrag(groupId, childId, position) {
|
||||
const group = store.findNode(groupId);
|
||||
if (!group || group.data.flowType !== "loop" || !position) return;
|
||||
layoutLoop(group, childId, position);
|
||||
},
|
||||
/**
|
||||
* 调整 resizeGroupToChildren 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} groupId 分组节点 ID
|
||||
* @param {*} options 行为配置
|
||||
*/
|
||||
resizeGroupToChildren(groupId, options = {}) {
|
||||
// 循环容器沿用统一的内边距和最小尺寸约束,并按配置决定是否记录变更。
|
||||
// 拖拽过程可关闭变更标记,避免高频移动产生多余历史记录。
|
||||
const group = store.findNode(groupId);
|
||||
if (!group || group.data.flowType !== "loop") return;
|
||||
if (layoutLoop(group) && options.markChanged !== false) changed();
|
||||
},
|
||||
/**
|
||||
* 移动 moveNodeBy 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} id 目标元素 ID
|
||||
* @param {*} dx 横向位移
|
||||
* @param {*} dy 纵向位移
|
||||
*/
|
||||
moveNodeBy(id, dx, dy) {
|
||||
const node = store.findNode(id);
|
||||
if (!node) return;
|
||||
store.updateNode(id, {
|
||||
position: {
|
||||
x: node.position.x + dx,
|
||||
y: node.position.y + dy,
|
||||
},
|
||||
});
|
||||
changed();
|
||||
},
|
||||
/**
|
||||
* 撤销 undo 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
undo() {
|
||||
if (historyIndex <= 0) return false;
|
||||
historyIndex -= 1;
|
||||
const snapshot = clone(history[historyIndex].graph);
|
||||
facade.render(snapshot, { resetHistory: false, replaceNodes: true });
|
||||
nextTick(() => emit("history:change", { data: facade.getGraphData() }));
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* 重做 redo 对应的数据或交互;作用范围仅限画布适配层。
|
||||
*/
|
||||
redo() {
|
||||
if (historyIndex >= history.length - 1) return false;
|
||||
historyIndex += 1;
|
||||
const snapshot = clone(history[historyIndex].graph);
|
||||
facade.render(snapshot, { resetHistory: false, replaceNodes: true });
|
||||
nextTick(() => emit("history:change", { data: facade.getGraphData() }));
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* 注册 registerComponentInstance 对应的数据或交互;作用范围仅限画布适配层。
|
||||
* @param {*} id 目标元素 ID
|
||||
* @param {*} instance 组件实例
|
||||
*/
|
||||
registerComponentInstance(id, instance) {
|
||||
if (instance) componentInstances.set(id, instance);
|
||||
},
|
||||
extension: {
|
||||
selectionSelect: {
|
||||
openSelectionSelect: () => emit("selection:open"),
|
||||
closeSelectionSelect: () => emit("selection:close"),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return facade;
|
||||
};
|
||||
@ -1,452 +0,0 @@
|
||||
<template>
|
||||
<div class="result-list">
|
||||
<el-card shadow="never" class="search-card">
|
||||
<el-form ref="queryFormRef" :model="queryParams" :inline="true" label-width="80px">
|
||||
<el-form-item
|
||||
label="所属任务"
|
||||
prop="taskId"
|
||||
>
|
||||
<el-select
|
||||
v-model="queryParams.taskId"
|
||||
filterable
|
||||
placeholder="请选择所属任务"
|
||||
clearable
|
||||
class="query-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="task in taskOptions"
|
||||
:key="task.value"
|
||||
:label="task.label"
|
||||
:value="task.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="结果名称" prop="resultName">
|
||||
<el-input
|
||||
v-model="queryParams.resultName"
|
||||
placeholder="请输入结果名称"
|
||||
clearable
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="结果类型" prop="resultType">
|
||||
<el-select v-model="queryParams.resultType" clearable placeholder="全部" class="query-select">
|
||||
<el-option
|
||||
v-for="item in resultTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="resultStatus">
|
||||
<el-select v-model="queryParams.resultStatus" clearable placeholder="全部" class="query-select">
|
||||
<el-option
|
||||
v-for="item in resultStatusOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="告警级别" prop="alarmLevel">
|
||||
<el-select v-model="queryParams.alarmLevel" clearable placeholder="全部" class="query-select">
|
||||
<el-option
|
||||
v-for="item in alarmLevelOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleQuery">查询</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-table v-loading="loading" :data="tableData" border stripe>
|
||||
<el-table-column prop="id" label="结果 ID" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="resultName" label="结果名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="resultType" label="结果类型" min-width="110">
|
||||
<template #default="{ row }">
|
||||
{{ getOptionLabel(resultTypeOptions, row.resultType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="resultStatus" label="状态" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusTagType(row.resultStatus)">
|
||||
{{ getOptionLabel(resultStatusOptions, row.resultStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alarmLevel" label="告警级别" min-width="110">
|
||||
<template #default="{ row }">
|
||||
{{ row.alarmLevel == null ? '-' : getOptionLabel(alarmLevelOptions, row.alarmLevel) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alarmLevel" label="图片/视频" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
@click="handleOpenMedia(row)"
|
||||
>
|
||||
查看
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="taskName" label="所属任务" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="createTime" label="创建时间" min-width="180" />
|
||||
<el-table-column label="操作" fixed="right" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.resultStatus === 'PENDING'"
|
||||
type="primary"
|
||||
link
|
||||
@click="openReviewDialog(row)"
|
||||
>
|
||||
复核
|
||||
</el-button>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无巡检结果" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.pageNum"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="reviewDialogVisible"
|
||||
title="人工复核"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
@closed="resetReviewForm"
|
||||
>
|
||||
<el-form
|
||||
ref="reviewFormRef"
|
||||
:model="reviewForm"
|
||||
:rules="reviewRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="复核结果" prop="resultStatus">
|
||||
<el-select v-model="reviewForm.resultStatus" placeholder="请选择复核结果">
|
||||
<el-option label="异常" value="ABNORMAL" />
|
||||
<el-option label="正常" value="NORMAL" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="告警级别" prop="alarmLevel">
|
||||
<el-select
|
||||
v-model="reviewForm.alarmLevel"
|
||||
:disabled="reviewForm.resultStatus !== 'ABNORMAL'"
|
||||
clearable
|
||||
placeholder="请选择告警级别"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in alarmLevelOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="复核说明" prop="reviewRemark">
|
||||
<el-input
|
||||
v-model="reviewForm.reviewRemark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入复核说明"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="reviewDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="reviewSubmitting" @click="submitReview">
|
||||
提交
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="mediaDialogVisible"
|
||||
title="图片/视频"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-image
|
||||
v-if="mediaData.evidenceType === 'IMAGE'"
|
||||
:src="mediaData.evidenceUrl"
|
||||
style="width: 100%; height: 100%"
|
||||
/>
|
||||
<IPlayer
|
||||
v-if="mediaData.evidenceType === 'VIDEO'"
|
||||
:videoUrl="mediaData.evidenceUrl"
|
||||
/>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getTaskList } from '@/api/inspection/task'
|
||||
import {
|
||||
getInspectionResultList,
|
||||
reviewInspectionResult
|
||||
} from '@/api/inspection/result'
|
||||
|
||||
|
||||
const queryFormRef = ref()
|
||||
const reviewFormRef = ref()
|
||||
const loading = ref(false)
|
||||
const reviewSubmitting = ref(false)
|
||||
const reviewDialogVisible = ref(false)
|
||||
const tableData = ref([])
|
||||
const taskOptions = ref([])
|
||||
const total = ref(0)
|
||||
const currentReviewId = ref(null)
|
||||
|
||||
const queryParams = reactive({
|
||||
taskId: null,
|
||||
resultName: '',
|
||||
resultType: null,
|
||||
resultStatus: null,
|
||||
alarmLevel: null,
|
||||
pageNum: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
|
||||
const reviewForm = reactive({
|
||||
resultStatus: '',
|
||||
alarmLevel: null,
|
||||
reviewRemark: '',
|
||||
reviewVersion: 0
|
||||
})
|
||||
|
||||
const resultTypeOptions = [
|
||||
{ label: '劳保穿戴', value: 'PPE' },
|
||||
{ label: '仪表识别', value: 'METER' },
|
||||
{ label: '人工巡检', value: 'MANUAL' }
|
||||
]
|
||||
|
||||
const resultStatusOptions = [
|
||||
{ label: '待处理', value: 'PENDING' },
|
||||
{ label: '正常', value: 'NORMAL' },
|
||||
{ label: '异常', value: 'ABNORMAL' },
|
||||
{ label: '识别失败', value: 'RECOGNIZE_FAILED' }
|
||||
]
|
||||
|
||||
const alarmLevelOptions = [
|
||||
{ label: '1(提示)', value: 1 },
|
||||
{ label: '2(警告)', value: 2 },
|
||||
{ label: '3(严重)', value: 3 }
|
||||
]
|
||||
|
||||
const validateAlarmLevel = (_rule, value, callback) => {
|
||||
if (reviewForm.resultStatus === 'ABNORMAL' && value == null) {
|
||||
callback(new Error('异常结果必须选择告警级别'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
}
|
||||
|
||||
const reviewRules = {
|
||||
resultStatus: [
|
||||
{ required: true, message: '请选择复核结果', trigger: 'change' }
|
||||
],
|
||||
alarmLevel: [
|
||||
{ validator: validateAlarmLevel, trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
const getOptionLabel = (options, value) => {
|
||||
return options.find(item => item.value === value)?.label || value || '-'
|
||||
}
|
||||
|
||||
const getStatusTagType = (status) => {
|
||||
if (status === 'ABNORMAL') return 'danger'
|
||||
if (status === 'NORMAL') return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const buildQueryParams = () => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(queryParams).filter(([, value]) => value !== '' && value != null)
|
||||
)
|
||||
}
|
||||
|
||||
// 查询失败的消息由项目统一请求拦截器展示。
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getInspectionResultList(buildQueryParams())
|
||||
tableData.value = response.rows || response.data?.rows || []
|
||||
total.value = response.total ?? response.data?.total ?? 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadTaskOptions = async () => {
|
||||
try {
|
||||
const response = await getTaskList({ pageNum: 1, pageSize: 10000 })
|
||||
const rows = response.rows || response.data?.rows || []
|
||||
taskOptions.value = rows.map(task => ({
|
||||
label: task.taskName || task.name || `任务 ${task.id ?? task.taskId}`,
|
||||
value: task.id ?? task.taskId
|
||||
}))
|
||||
|
||||
} catch {
|
||||
// 请求层已统一提示错误,保留空选项等待用户重试页面。
|
||||
taskOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuery = async () => {
|
||||
try {
|
||||
await queryFormRef.value?.validate()
|
||||
queryParams.pageNum = 1
|
||||
await fetchList()
|
||||
} catch {
|
||||
// 表单项会展示“所属任务”必填提示。
|
||||
}
|
||||
}
|
||||
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value?.resetFields()
|
||||
Object.assign(queryParams, {
|
||||
resultName: '',
|
||||
resultType: null,
|
||||
resultStatus: null,
|
||||
alarmLevel: null,
|
||||
pageNum: 1,
|
||||
pageSize: 10
|
||||
})
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleSizeChange = () => {
|
||||
queryParams.pageNum = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const openReviewDialog = (row) => {
|
||||
currentReviewId.value = row.id
|
||||
Object.assign(reviewForm, {
|
||||
resultStatus: row.resultStatus || 'ABNORMAL',
|
||||
alarmLevel: row.alarmLevel ?? null,
|
||||
reviewRemark: '',
|
||||
reviewVersion: row.reviewVersion ?? 0
|
||||
})
|
||||
reviewDialogVisible.value = true
|
||||
nextTick(() => reviewFormRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
const resetReviewForm = () => {
|
||||
currentReviewId.value = null
|
||||
Object.assign(reviewForm, {
|
||||
resultStatus: '',
|
||||
alarmLevel: null,
|
||||
reviewRemark: '',
|
||||
reviewVersion: 0
|
||||
})
|
||||
reviewFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
const submitReview = async () => {
|
||||
try {
|
||||
await reviewFormRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
reviewSubmitting.value = true
|
||||
try {
|
||||
await reviewInspectionResult(currentReviewId.value, {
|
||||
alarmLevel: reviewForm.resultStatus === 'ABNORMAL' ? reviewForm.alarmLevel : null,
|
||||
resultStatus: reviewForm.resultStatus,
|
||||
reviewRemark: reviewForm.reviewRemark,
|
||||
reviewVersion: reviewForm.reviewVersion
|
||||
})
|
||||
ElMessage.success('复核成功')
|
||||
reviewDialogVisible.value = false
|
||||
await fetchList()
|
||||
} finally {
|
||||
reviewSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const mediaDialogVisible = ref(false)
|
||||
const mediaData = ref(null)
|
||||
|
||||
const handleOpenMedia = (row) => {
|
||||
mediaDialogVisible.value = true
|
||||
mediaData.value = row
|
||||
}
|
||||
|
||||
|
||||
watch(
|
||||
() => reviewForm.resultStatus,
|
||||
status => {
|
||||
if (status !== 'ABNORMAL') {
|
||||
reviewForm.alarmLevel = null
|
||||
nextTick(() => reviewFormRef.value?.clearValidate('alarmLevel'))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTaskOptions()
|
||||
await fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.result-list {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 16px;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.query-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-dialog .el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,620 +0,0 @@
|
||||
import { useInspectionStore } from "@/store/modules/inspection";
|
||||
|
||||
const inspectionStore = useInspectionStore();
|
||||
|
||||
|
||||
/**
|
||||
* 6位十六进制颜色字符串转为 CSS rgba 字符串
|
||||
* @param {string} hex - 如 "#ff6e40"
|
||||
* @param {number} alpha - 透明度 0~1
|
||||
* @returns {string} CSS rgba 字符串
|
||||
*/
|
||||
function hexToCssRgba(hexString, alpha) {
|
||||
const r = parseInt(hexString.slice(1, 3), 16); // 提取 R 分量
|
||||
const g = parseInt(hexString.slice(3, 5), 16); // 提取 G 分量
|
||||
const b = parseInt(hexString.slice(5, 7), 16); // 提取 B 分量
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将世界坐标转换为 Canvas 屏幕像素坐标
|
||||
* 注意:世界坐标 Y 轴向上,Canvas Y 轴向下,因此需要翻转
|
||||
* @param {number} worldX - 世界坐标 X(米)
|
||||
* @param {number} worldY - 世界坐标 Y(米)
|
||||
* @returns {{x: number, y: number}} Canvas 上的屏幕像素坐标
|
||||
*/
|
||||
export const worldToScreen = (worldX, worldY, dataStore) => {
|
||||
const store = dataStore || inspectionStore;
|
||||
const camera = store.camera
|
||||
const screenX = (worldX - camera.centerX) * camera.pixelsPerMeter + store.canvasWidthPx / 2; // 水平偏移 + 居中
|
||||
const screenY = -(worldY - camera.centerY) * camera.pixelsPerMeter + store.canvasHeightPx / 2; // 垂直翻转 + 居中
|
||||
return { x: screenX, y: screenY };
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 Canvas 屏幕像素坐标反算为世界坐标
|
||||
* 与 worldToScreen 互为逆运算
|
||||
* @param {number} screenX - Canvas 像素 X
|
||||
* @param {number} screenY - Canvas 像素 Y
|
||||
* @returns {{x: number, y: number}} 世界坐标(米)
|
||||
*/
|
||||
export const screenToWorld = (screenX, screenY, dataStore) => {
|
||||
const store = dataStore || inspectionStore;
|
||||
const camera = store.camera;
|
||||
const worldX = (screenX - store.canvasWidthPx / 2) / camera.pixelsPerMeter + camera.centerX; // 水平反算
|
||||
const worldY = -(screenY - store.canvasHeightPx / 2) / camera.pixelsPerMeter + camera.centerY; // 垂直反算(带翻转)
|
||||
return { x: worldX, y: worldY };
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 0xAARRGGBB 整数颜色解码为 {r, g, b, a} 对象
|
||||
* @param {number} argbInt - 32位 ARGB 整数,如 0xCCFF0040
|
||||
* @returns {{r:number, g:number, b:number, a:number}|null} RGBA 分量 (0~255),无效输入返回 null
|
||||
*/
|
||||
export const decodeArgbInteger = (argbInt) => {
|
||||
if (typeof argbInt !== 'number') return null; // 非数字类型直接返回
|
||||
return {
|
||||
a: (argbInt >>> 24) & 0xFF, // 高8位:Alpha 透明度
|
||||
r: (argbInt >>> 16) & 0xFF, // 次高8位:Red
|
||||
g: (argbInt >>> 8) & 0xFF, // 次低8位:Green
|
||||
b: argbInt & 0xFF, // 低8位:Blue
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 decodeArgbInteger 的结果转为 CSS rgba() 字符串
|
||||
* @param {{r,g,b,a}} colorObj - RGBA 分量对象
|
||||
* @param {number} [alphaOverride] - 可选,覆盖 alpha 值(0~1)
|
||||
* @returns {string|null} CSS rgba 字符串,如 "rgba(255,0,64,0.3)"
|
||||
*/
|
||||
export const toCssRgba = (colorObj, alphaOverride) => {
|
||||
if (!colorObj) return null; // 空对象返回 null
|
||||
const alpha = alphaOverride !== undefined // 如果提供了覆盖值
|
||||
? alphaOverride // 使用覆盖值
|
||||
: colorObj.a / 255; // 否则将 0~255 映射到 0~1
|
||||
return `rgba(${colorObj.r},${colorObj.g},${colorObj.b},${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 栅格底图绘制函数
|
||||
* 将预渲染的离屏 Canvas 图像绘制到主 Canvas 上,并根据当前地图范围进行缩放和平移
|
||||
* @param {*} canvasCtx
|
||||
* @param {*} map
|
||||
* @param {*} gridOffscreen
|
||||
*/
|
||||
export const drawGrid = (canvasCtx, map, dataStore) => {
|
||||
const store = dataStore || inspectionStore;
|
||||
const topLeftScreen = worldToScreen(map.originX, map.maxY, store); // 图像左上角(世界坐标→屏幕)
|
||||
const bottomRightScreen = worldToScreen(map.maxX, map.originY, store); // 图像右下角(世界坐标→屏幕)
|
||||
canvasCtx.imageSmoothingEnabled = false; // 关闭抗锯齿(保持像素清晰)
|
||||
canvasCtx.drawImage(
|
||||
store.gridOffscreen, // 离屏 Canvas 图像源
|
||||
topLeftScreen.x, topLeftScreen.y, // 目标左上角
|
||||
bottomRightScreen.x - topLeftScreen.x, // 目标宽度
|
||||
bottomRightScreen.y - topLeftScreen.y // 目标高度
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 网格辅助线(每1米一条)
|
||||
* @param {*} canvasCtx
|
||||
* @param {*} canvasWidthPx
|
||||
* @param {*} canvasHeightPx
|
||||
*/
|
||||
export const drawGridlines = (canvasCtx, dataStore) => {
|
||||
// 计算当前可视范围的世界坐标
|
||||
const store = dataStore || inspectionStore;
|
||||
const viewTopLeft = screenToWorld(0, 0, store); // 视口左上角世界坐标
|
||||
const viewBottomRight = screenToWorld(store.canvasWidthPx, store.canvasHeightPx, store); // 视口右下角世界坐标
|
||||
const viewMinX = Math.floor(Math.min(viewTopLeft.x, viewBottomRight.x)); // 可视X最小整数
|
||||
const viewMaxX = Math.ceil(Math.max(viewTopLeft.x, viewBottomRight.x)); // 可视X最大整数
|
||||
const viewMinY = Math.floor(Math.min(viewTopLeft.y, viewBottomRight.y)); // 可视Y最小整数
|
||||
const viewMaxY = Math.ceil(Math.max(viewTopLeft.y, viewBottomRight.y)); // 可视Y最大整数
|
||||
|
||||
// 绘制1米间隔的浅色网格线
|
||||
canvasCtx.strokeStyle = 'rgba(30,50,80,0.25)'; // 淡蓝灰色
|
||||
canvasCtx.lineWidth = 0.5; // 细线
|
||||
canvasCtx.beginPath();
|
||||
for (let worldX = viewMinX; worldX <= viewMaxX; worldX++) { // 逐列绘制垂直线
|
||||
const screenPos = worldToScreen(worldX, 0, store);
|
||||
canvasCtx.moveTo(screenPos.x, 0); // 从画布顶部
|
||||
canvasCtx.lineTo(screenPos.x, store.canvasHeightPx); // 到画布底部
|
||||
}
|
||||
for (let worldY = viewMinY; worldY <= viewMaxY; worldY++) { // 逐行绘制水平线
|
||||
const screenPos = worldToScreen(0, worldY, store);
|
||||
canvasCtx.moveTo(0, screenPos.y); // 从画布左边
|
||||
canvasCtx.lineTo(store.canvasWidthPx, screenPos.y); // 到画布右边
|
||||
}
|
||||
canvasCtx.stroke();
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制高级区域列表
|
||||
* @param {*} canvasCtx
|
||||
* @param {*} map
|
||||
* @param {*} dataStore
|
||||
*/
|
||||
export const drawAdvancedAreaList = (canvasCtx, map, dataStore) => {
|
||||
const store = dataStore || inspectionStore;
|
||||
for (let areaIndex = 0; areaIndex < map.advancedAreas.length; areaIndex++) {
|
||||
const area = map.advancedAreas[areaIndex]; // 当前区域
|
||||
const vertices = area.posGroup || []; // 多边形顶点数组
|
||||
if (vertices.length < 3) continue; // 少于3个顶点无法构成多边形,跳过
|
||||
|
||||
// ── 颜色:优先使用 attribute 中的 ARGB 整数,否则用默认品红色 ──
|
||||
let fillColorCss = 'rgba(224,64,251,0.12)'; // 默认填充(品红半透明)
|
||||
let strokeColorCss = 'rgba(224,64,251,0.5)'; // 默认描边(品红较不透明)
|
||||
if (area.attribute) {
|
||||
const brushColor = decodeArgbInteger(area.attribute.colorBrush); // 解码填充色
|
||||
const penColor = decodeArgbInteger(area.attribute.colorPen); // 解码描边色
|
||||
if (brushColor) {
|
||||
// 使用原始 alpha 值的一半作为填充透明度,最小 0.06
|
||||
fillColorCss = toCssRgba(brushColor, Math.max(0.06, (brushColor.a / 255) * 0.5));
|
||||
}
|
||||
if (penColor) {
|
||||
strokeColorCss = toCssRgba(penColor, 0.6); // 描边60%不透明
|
||||
}
|
||||
}
|
||||
|
||||
// ── 绘制多边形 ──
|
||||
canvasCtx.fillStyle = fillColorCss; // 设置填充色
|
||||
canvasCtx.strokeStyle = strokeColorCss; // 设置描边色
|
||||
canvasCtx.lineWidth = 1.2; // 描边宽度
|
||||
canvasCtx.beginPath();
|
||||
const firstVertexScreen = worldToScreen(vertices[0].x, vertices[0].y, store); // 第一个顶点的屏幕位置
|
||||
canvasCtx.moveTo(firstVertexScreen.x, firstVertexScreen.y); // 移动到起点
|
||||
for (let i = 1; i < vertices.length; i++) { // 依次连接后续顶点
|
||||
const vertexScreen = worldToScreen(vertices[i].x, vertices[i].y, store);
|
||||
canvasCtx.lineTo(vertexScreen.x, vertexScreen.y);
|
||||
}
|
||||
canvasCtx.closePath(); // 闭合路径
|
||||
canvasCtx.fill(); // 填充
|
||||
canvasCtx.stroke(); // 描边
|
||||
|
||||
// ── 在多边形质心处绘制区域名称标签 ──
|
||||
let centroidWorldX = 0, centroidWorldY = 0; // 质心世界坐标
|
||||
for (const v of vertices) { centroidWorldX += v.x; centroidWorldY += v.y; } // 累加顶点坐标
|
||||
centroidWorldX /= vertices.length; // 求平均得到质心 X
|
||||
centroidWorldY /= vertices.length; // 求平均得到质心 Y
|
||||
const centroidScreen = worldToScreen(centroidWorldX, centroidWorldY, store);
|
||||
canvasCtx.font = '500 32px "JetBrains Mono"'; // 标签字体
|
||||
canvasCtx.fillStyle = strokeColorCss; // 与描边同色
|
||||
canvasCtx.textAlign = 'center'; // 居中对齐
|
||||
canvasCtx.fillText(area.instanceName || '', centroidScreen.x, centroidScreen.y + 3); // 绘制文字
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 绘制高级线列表
|
||||
* @param {*} canvasCtx
|
||||
* @param {*} map
|
||||
*/
|
||||
export const drawAdvancedLineList = (canvasCtx, map, dataStore) => {
|
||||
const store = dataStore || inspectionStore;
|
||||
for (let advLineIndex = 0; advLineIndex < map.advancedLines.length; advLineIndex++) {
|
||||
const advLine = map.advancedLines[advLineIndex]; // 当前高级线
|
||||
const lineData = advLine.line; // 内部的 line 对象
|
||||
if (!lineData || !lineData.startPos || !lineData.endPos) continue; // 缺少端点则跳过
|
||||
|
||||
const startScreen = worldToScreen(lineData.startPos.x, lineData.startPos.y, store);
|
||||
const endScreen = worldToScreen(lineData.endPos.x, lineData.endPos.y, store);
|
||||
|
||||
// ── 根据 className 决定颜色和线型 ──
|
||||
let lineColorHex = '#ff6e40'; // 默认橙色
|
||||
let lineDashPattern = []; // 默认实线(无虚线)
|
||||
const classNameLower = (advLine.className || '').toLowerCase();
|
||||
if (classNameLower.includes('forbidden')) { // 禁行线 → 红色虚线
|
||||
lineColorHex = '#ff1744';
|
||||
lineDashPattern = [8, 4]; // 虚线模式:8px实 4px空
|
||||
} else if (classNameLower.includes('virtual')) { // 虚拟线 → 橙色虚线
|
||||
lineColorHex = '#ffab40';
|
||||
lineDashPattern = [4, 4];
|
||||
}
|
||||
|
||||
// 绘制高级线
|
||||
canvasCtx.save();
|
||||
canvasCtx.shadowColor = lineColorHex; // 辉光色与线条同色
|
||||
canvasCtx.shadowBlur = 4; // 辉光半径
|
||||
canvasCtx.strokeStyle = lineColorHex; // 线条颜色
|
||||
canvasCtx.lineWidth = 2.5; // 线宽(比基础线粗)
|
||||
canvasCtx.setLineDash(lineDashPattern); // 设置虚线模式
|
||||
canvasCtx.globalAlpha = 0.8; // 80%不透明
|
||||
canvasCtx.beginPath();
|
||||
canvasCtx.moveTo(startScreen.x, startScreen.y);
|
||||
canvasCtx.lineTo(endScreen.x, endScreen.y);
|
||||
canvasCtx.stroke();
|
||||
canvasCtx.setLineDash([]); // 恢复实线
|
||||
canvasCtx.restore();
|
||||
|
||||
// 在中点绘制名称标签
|
||||
// const midpointScreenX = (startScreen.x + endScreen.x) / 2;
|
||||
// const midpointScreenY = (startScreen.y + endScreen.y) / 2;
|
||||
// canvasCtx.font = '500 8px "JetBrains Mono"';
|
||||
// canvasCtx.fillStyle = hexToCssRgba(lineColorHex, 0.55); // 55%不透明的文字
|
||||
// canvasCtx.textAlign = 'center';
|
||||
// canvasCtx.fillText(advLine.instanceName || advLine.className || '', midpointScreenX, midpointScreenY - 6);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 绘制基础直线列表
|
||||
* @param {*} canvasCtx
|
||||
* @param {*} map
|
||||
* @param {*} dataStore
|
||||
*/
|
||||
export const drawNormalLines = (canvasCtx, map, dataStore) => {
|
||||
const store = dataStore || inspectionStore;
|
||||
for (let lineIndex = 0; lineIndex < map.normalLines.length; lineIndex++) {
|
||||
const line = map.normalLines[lineIndex]; // 当前直线
|
||||
const startScreen = worldToScreen(line.startPos.x, line.startPos.y, store); // 起点屏幕位置
|
||||
const endScreen = worldToScreen(line.endPos.x, line.endPos.y, store); // 终点屏幕位置
|
||||
|
||||
// 绘制发光直线
|
||||
canvasCtx.save();
|
||||
canvasCtx.shadowColor = '#00e5ff'; // 阴影颜色(产生辉光)
|
||||
canvasCtx.shadowBlur = 5; // 辉光扩散半径
|
||||
canvasCtx.strokeStyle = '#00e5ff'; // 线条颜色:青色
|
||||
canvasCtx.lineWidth = 2; // 线宽
|
||||
canvasCtx.globalAlpha = 0.8; // 80%不透明
|
||||
canvasCtx.beginPath();
|
||||
canvasCtx.moveTo(startScreen.x, startScreen.y); // 从起点
|
||||
canvasCtx.lineTo(endScreen.x, endScreen.y); // 到终点
|
||||
canvasCtx.stroke();
|
||||
canvasCtx.restore();
|
||||
|
||||
// 在中点绘制方向箭头
|
||||
const midpointX = (startScreen.x + endScreen.x) / 2; // 中点X
|
||||
const midpointY = (startScreen.y + endScreen.y) / 2; // 中点Y
|
||||
const arrowAngle = Math.atan2(endScreen.y - startScreen.y, endScreen.x - startScreen.x); // 线段方向角
|
||||
const arrowLength = 5; // 箭头长度
|
||||
canvasCtx.fillStyle = 'rgba(0,229,255,0.6)'; // 箭头填充色
|
||||
canvasCtx.beginPath();
|
||||
canvasCtx.moveTo(midpointX + Math.cos(arrowAngle) * arrowLength, midpointY + Math.sin(arrowAngle) * arrowLength); // 箭头尖端
|
||||
canvasCtx.lineTo(midpointX + Math.cos(arrowAngle + 2.5) * 4, midpointY + Math.sin(arrowAngle + 2.5) * 4); // 左翼
|
||||
canvasCtx.lineTo(midpointX + Math.cos(arrowAngle - 2.5) * 4, midpointY + Math.sin(arrowAngle - 2.5) * 4); // 右翼
|
||||
canvasCtx.closePath();
|
||||
canvasCtx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 绘制高级曲线列表
|
||||
* @param {*} canvasCtx
|
||||
* @param {*} map
|
||||
* @param {*} dataStore
|
||||
*/
|
||||
export const drawAdvancedCurveList = (canvasCtx, map, dataStore) => {
|
||||
const store = dataStore || inspectionStore;
|
||||
for (let curveIndex = 0; curveIndex < map.advancedCurves.length; curveIndex++) {
|
||||
const curve = map.advancedCurves[curveIndex]; // 当前曲线
|
||||
|
||||
// 提取起点和终点的 pos(注意:startPos/endPos 是 {instanceName, pos:{x,y}} 结构)
|
||||
const startPoint = curve.startPos?.pos; // 起点世界坐标
|
||||
const endPoint = curve.endPos?.pos; // 终点世界坐标
|
||||
const controlPoint1 = curve.controlPos1; // 第一控制点(直接 {x,y})
|
||||
const controlPoint2 = curve.controlPos2; // 第二控制点(直接 {x,y})
|
||||
if (!startPoint || !endPoint) continue; // 缺少端点则跳过
|
||||
|
||||
// 转换为屏幕坐标
|
||||
const startScreen = worldToScreen(startPoint.x, startPoint.y, store);
|
||||
const endScreen = worldToScreen(endPoint.x, endPoint.y, store);
|
||||
const cp1Screen = controlPoint1 ? worldToScreen(controlPoint1.x, controlPoint1.y, store) : null; // 可能不存在
|
||||
const cp2Screen = controlPoint2 ? worldToScreen(controlPoint2.x, controlPoint2.y, store) : null;
|
||||
|
||||
// ── 绘制贝塞尔曲线(虚线)──
|
||||
canvasCtx.save();
|
||||
canvasCtx.shadowColor = '#76ff03'; // 酸绿色辉光
|
||||
canvasCtx.shadowBlur = 5;
|
||||
canvasCtx.strokeStyle = '#76ff03'; // 酸绿色线条
|
||||
canvasCtx.lineWidth = 2;
|
||||
canvasCtx.setLineDash([8, 5]); // 虚线模式
|
||||
canvasCtx.globalAlpha = 0.85;
|
||||
canvasCtx.beginPath();
|
||||
canvasCtx.moveTo(startScreen.x, startScreen.y); // 起点
|
||||
if (cp1Screen && cp2Screen) {
|
||||
// 有2个控制点 → 三次贝塞尔 (cubic bezier)
|
||||
canvasCtx.bezierCurveTo(cp1Screen.x, cp1Screen.y, cp2Screen.x, cp2Screen.y, endScreen.x, endScreen.y);
|
||||
} else if (cp1Screen) {
|
||||
// 仅1个控制点 → 二次贝塞尔 (quadratic bezier)
|
||||
canvasCtx.quadraticCurveTo(cp1Screen.x, cp1Screen.y, endScreen.x, endScreen.y);
|
||||
} else {
|
||||
// 无控制点 → 直线
|
||||
canvasCtx.lineTo(endScreen.x, endScreen.y);
|
||||
}
|
||||
canvasCtx.stroke();
|
||||
canvasCtx.setLineDash([]); // 恢复实线
|
||||
canvasCtx.restore();
|
||||
|
||||
// ── 在 t=0.5 处绘制方向箭头 ──
|
||||
if (cp1Screen && cp2Screen) {
|
||||
const t = 0.5; // 参数 t = 0.5(曲线中点)
|
||||
const oneMinusT = 0.5; // 1-t = 0.5
|
||||
// 三次贝塞尔公式计算中点坐标
|
||||
const arrowX = oneMinusT ** 3 * startScreen.x + 3 * oneMinusT ** 2 * t * cp1Screen.x + 3 * oneMinusT * t ** 2 * cp2Screen.x + t ** 3 * endScreen.x;
|
||||
const arrowY = oneMinusT ** 3 * startScreen.y + 3 * oneMinusT ** 2 * t * cp1Screen.y + 3 * oneMinusT * t ** 2 * cp2Screen.y + t ** 3 * endScreen.y;
|
||||
// 三次贝塞尔的一阶导数(切线方向)
|
||||
const tangentX = 3 * oneMinusT ** 2 * (cp1Screen.x - startScreen.x) + 6 * oneMinusT * t * (cp2Screen.x - cp1Screen.x) + 3 * t ** 2 * (endScreen.x - cp2Screen.x);
|
||||
const tangentY = 3 * oneMinusT ** 2 * (cp1Screen.y - startScreen.y) + 6 * oneMinusT * t * (cp2Screen.y - cp1Screen.y) + 3 * t ** 2 * (endScreen.y - cp2Screen.y);
|
||||
const tangentAngle = Math.atan2(tangentY, tangentX); // 切线角度
|
||||
|
||||
// 绘制箭头
|
||||
canvasCtx.fillStyle = 'rgba(118,255,3,0.7)';
|
||||
canvasCtx.beginPath();
|
||||
canvasCtx.moveTo(arrowX + Math.cos(tangentAngle) * 5, arrowY + Math.sin(tangentAngle) * 5);
|
||||
canvasCtx.lineTo(arrowX + Math.cos(tangentAngle + 2.5) * 4, arrowY + Math.sin(tangentAngle + 2.5) * 4);
|
||||
canvasCtx.lineTo(arrowX + Math.cos(tangentAngle - 2.5) * 4, arrowY + Math.sin(tangentAngle - 2.5) * 4);
|
||||
canvasCtx.closePath();
|
||||
canvasCtx.fill();
|
||||
|
||||
// 绘制控制点小圆(辅助调试用)
|
||||
canvasCtx.fillStyle = 'rgba(118,255,3,0.25)'; // 浅绿色半透明
|
||||
for (const controlScreen of [cp1Screen, cp2Screen]) {
|
||||
canvasCtx.beginPath();
|
||||
canvasCtx.arc(controlScreen.x, controlScreen.y, 2.5, 0, Math.PI * 2); // 半径2.5px的小圆
|
||||
canvasCtx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 曲线名称标签 ──
|
||||
// canvasCtx.font = '500 7px "JetBrains Mono"';
|
||||
// canvasCtx.fillStyle = 'rgba(118,255,3,0.5)';
|
||||
// canvasCtx.textAlign = 'center';
|
||||
// const labelScreenY = Math.min(startScreen.y, endScreen.y) - 8; // 标签放在最高点上方
|
||||
// canvasCtx.fillText(curve.instanceName || '', (startScreen.x + endScreen.x) / 2, labelScreenY);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 绘制高级点列表
|
||||
* @param {*} canvasCtx
|
||||
* @param {*} map
|
||||
* @param {*} dataStore
|
||||
*/
|
||||
export const drawAdvancedPointList = (canvasCtx, map, dataStore) => {
|
||||
const store = dataStore || inspectionStore;
|
||||
for (let pointIndex = 0; pointIndex < map.advancedPoints.length; pointIndex++) {
|
||||
const advPoint = map.advancedPoints[pointIndex]; // 当前高级点
|
||||
const pointScreen = worldToScreen(advPoint.pos.x, advPoint.pos.y, store); // 屏幕位置
|
||||
|
||||
// 判断是否被鼠标悬停
|
||||
const isHovered = store.hoveredElement &&
|
||||
store.hoveredElement.kind === 'point' &&
|
||||
store.hoveredElement.index === pointIndex;
|
||||
|
||||
// ── 根据 className 决定颜色 ──
|
||||
let pointColorHex = '#ffd600'; // 默认琥珀色
|
||||
const classNameLower = (advPoint.className || '').toLowerCase();
|
||||
if (classNameLower.includes('landmark') || classNameLower.includes('land')) {
|
||||
pointColorHex = '#ffd600'; // LandMark → 琥珀色
|
||||
} else if (classNameLower.includes('charge')) {
|
||||
pointColorHex = '#22c55e'; // 充电点 → 绿色
|
||||
} else if (classNameLower.includes('load')) {
|
||||
pointColorHex = '#3b82f6'; // 装载点 → 蓝色
|
||||
} else if (classNameLower.includes('work') || classNameLower.includes('process')) {
|
||||
pointColorHex = '#a855f7'; // 工位 → 紫色
|
||||
} else if (classNameLower.includes('station')) {
|
||||
pointColorHex = '#00e5ff'; // 站点 → 青色
|
||||
}
|
||||
|
||||
// ── 绘制菱形标记 ──
|
||||
const diamondRadius = isHovered ? 8 : 5; // 悬停时放大
|
||||
const pulseAlpha = isHovered ? Math.sin(store.animationTimeSeconds * 5) * 0.2 + 0.8 : 1; // 悬停时脉冲闪烁
|
||||
|
||||
canvasCtx.save();
|
||||
canvasCtx.globalAlpha = pulseAlpha; // 应用透明度
|
||||
canvasCtx.shadowColor = pointColorHex; // 辉光颜色
|
||||
canvasCtx.shadowBlur = isHovered ? 18 : 8; // 悬停时增强辉光
|
||||
|
||||
// 绘制旋转45度的正方形(菱形)
|
||||
canvasCtx.fillStyle = pointColorHex;
|
||||
canvasCtx.beginPath();
|
||||
canvasCtx.moveTo(pointScreen.x, pointScreen.y - diamondRadius); // 上顶点
|
||||
canvasCtx.lineTo(pointScreen.x + diamondRadius * 0.7, pointScreen.y); // 右顶点
|
||||
canvasCtx.lineTo(pointScreen.x, pointScreen.y + diamondRadius); // 下顶点
|
||||
canvasCtx.lineTo(pointScreen.x - diamondRadius * 0.7, pointScreen.y); // 左顶点
|
||||
canvasCtx.closePath();
|
||||
canvasCtx.fill();
|
||||
|
||||
// 中心白色小圆点(指示精确位置)
|
||||
canvasCtx.shadowBlur = 0;
|
||||
canvasCtx.fillStyle = '#fff';
|
||||
canvasCtx.globalAlpha = 0.9;
|
||||
canvasCtx.beginPath();
|
||||
canvasCtx.arc(pointScreen.x, pointScreen.y, 1.8, 0, Math.PI * 2); // 半径1.8px
|
||||
canvasCtx.fill();
|
||||
canvasCtx.restore();
|
||||
|
||||
// ── 方向箭头(基于 dir 字段,弧度)──
|
||||
if (advPoint.dir !== undefined && advPoint.dir !== null) {
|
||||
// dir 可能是字符串(smap 样例中为 "-1.5759999999999958")或数字
|
||||
const dirRadians = typeof advPoint.dir === 'string' ? parseFloat(advPoint.dir) : advPoint.dir;
|
||||
if (!isNaN(dirRadians)) { // 有效方向角
|
||||
const arrowLengthPx = 12; // 箭头长度(屏幕像素)
|
||||
canvasCtx.strokeStyle = hexToCssRgba(pointColorHex, 0.5); // 半透明的线条
|
||||
canvasCtx.lineWidth = 1;
|
||||
canvasCtx.beginPath();
|
||||
canvasCtx.moveTo(pointScreen.x, pointScreen.y); // 从点中心出发
|
||||
// 注意:世界坐标 Y 向上,但 atan2 使用的 dir 是标准数学角,屏幕 Y 翻转后取负
|
||||
canvasCtx.lineTo(
|
||||
pointScreen.x + Math.cos(dirRadians) * arrowLengthPx,
|
||||
pointScreen.y - Math.sin(dirRadians) * arrowLengthPx // Y 翻转
|
||||
);
|
||||
canvasCtx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 名称标签 ──
|
||||
canvasCtx.font = '500 12px "JetBrains Mono"';
|
||||
canvasCtx.fillStyle = hexToCssRgba(pointColorHex, 0.7);
|
||||
canvasCtx.textAlign = 'center';
|
||||
canvasCtx.fillText(advPoint.instanceName || '', pointScreen.x, pointScreen.y - diamondRadius - 4);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 绘制地图边界
|
||||
* @param {*} canvasCtx
|
||||
* @param {*} map
|
||||
* @param {*} dataStore
|
||||
*/
|
||||
export const drawMapBoundary = (canvasCtx, map, dataStore) => {
|
||||
const store = dataStore || inspectionStore;
|
||||
const boundaryTopLeft = worldToScreen(map.originX, map.maxY, store); // 边界左上角屏幕位置
|
||||
const boundaryBottomRight = worldToScreen(map.maxX, map.originY, store); // 边界右下角屏幕位置
|
||||
canvasCtx.strokeStyle = 'rgba(0,229,160,0.1)'; // 极淡的青绿色
|
||||
canvasCtx.lineWidth = 1;
|
||||
canvasCtx.setLineDash([5, 4]); // 虚线
|
||||
canvasCtx.strokeRect(
|
||||
boundaryTopLeft.x, boundaryTopLeft.y,
|
||||
boundaryBottomRight.x - boundaryTopLeft.x,
|
||||
boundaryBottomRight.y - boundaryTopLeft.y
|
||||
);
|
||||
canvasCtx.setLineDash([]); // 恢复实线
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 normalPosList 构建占据栅格图像
|
||||
*
|
||||
* 原理:normalPosList 中每个点标记一个自由通行格子。
|
||||
* 未出现在列表中的格子 = 障碍物(墙壁)
|
||||
* 墙壁格子如果紧邻自由格子,则标记为"墙壁边缘",渲染高亮
|
||||
*
|
||||
* @param {Object} parsedMap - parseSmapJson 的输出
|
||||
* @param {boolean} showEdgeGlow - 是否计算墙壁边缘高亮效果
|
||||
* @returns {HTMLCanvasElement} 离屏 Canvas 元素,包含渲染好的栅格图像
|
||||
*/
|
||||
export const buildOccupancyGridImage = (parsedMap, showEdgeGlow) => {
|
||||
const gridW = parsedMap.gridWidth; // 栅格宽度(像素)
|
||||
const gridH = parsedMap.gridHeight; // 栅格高度(像素)
|
||||
const originX = parsedMap.originX; // 世界坐标原点 X
|
||||
const originY = parsedMap.originY; // 世界坐标原点 Y
|
||||
const resolution = parsedMap.resolution; // 分辨率
|
||||
|
||||
// ── 第一步:遍历 normalPosList,标记自由空间 ──
|
||||
const occupancyGrid = new Uint8Array(gridW * gridH); // 0=墙壁, 1=自由空间
|
||||
for (const freeSpacePoint of parsedMap.normalPositions) {
|
||||
// 将世界坐标转换为栅格像素坐标
|
||||
const gridColX = Math.floor((freeSpacePoint.x - originX) / resolution); // 列号(从左到右)
|
||||
const pixelRowFromBottom = Math.floor((freeSpacePoint.y - originY) / resolution); // 行号(从下到上,世界坐标系)
|
||||
const imageRowY = gridH - 1 - pixelRowFromBottom; // 翻转为图像行号(从上到下)
|
||||
// 安全边界检查后标记为自由空间
|
||||
if (gridColX >= 0 && gridColX < gridW && imageRowY >= 0 && imageRowY < gridH) {
|
||||
occupancyGrid[imageRowY * gridW + gridColX] = 1; // 标记该格为自由
|
||||
}
|
||||
}
|
||||
|
||||
// ── 第二步:遍历每个格子,按类型着色 ──
|
||||
const offscreenCanvas = document.createElement('canvas'); // 创建离屏 Canvas
|
||||
offscreenCanvas.width = gridW; // 设置尺寸
|
||||
offscreenCanvas.height = gridH;
|
||||
const offscreenCtx = offscreenCanvas.getContext('2d'); // 获取上下文
|
||||
const imageData = offscreenCtx.createImageData(gridW, gridH);// 创建 ImageData
|
||||
const pixelData = imageData.data; // 像素数据数组 (RGBA 扁平排列)
|
||||
|
||||
// 定义三种颜色
|
||||
const FREE_SPACE_R = 10, FREE_SPACE_G = 16, FREE_SPACE_B = 30; // 自由空间:深蓝黑
|
||||
const WALL_INTERIOR_R = 22, WALL_INTERIOR_G = 36, WALL_INTERIOR_B = 64; // 墙壁内部:深蓝
|
||||
const WALL_EDGE_R = 45, WALL_EDGE_G = 130, WALL_EDGE_B = 255; // 墙壁边缘:亮蓝
|
||||
|
||||
for (let rowY = 0; rowY < gridH; rowY++) { // 逐行扫描
|
||||
for (let colX = 0; colX < gridW; colX++) { // 逐列扫描
|
||||
const gridIndex = rowY * gridW + colX; // 一维数组索引
|
||||
const pixelOffset = gridIndex << 2; // RGBA 偏移(×4)
|
||||
|
||||
if (occupancyGrid[gridIndex] === 1) {
|
||||
// ── 自由空间 ──
|
||||
pixelData[pixelOffset] = FREE_SPACE_R; // R
|
||||
pixelData[pixelOffset + 1] = FREE_SPACE_G; // G
|
||||
pixelData[pixelOffset + 2] = FREE_SPACE_B; // B
|
||||
pixelData[pixelOffset + 3] = 255; // A(不透明)
|
||||
} else {
|
||||
// ── 障物(墙壁)── 判断是否为边缘
|
||||
let isAdjacentToFreeSpace = false; // 是否紧邻自由空间
|
||||
if (showEdgeGlow) { // 仅在启用边缘效果时检测
|
||||
if (rowY > 0 && occupancyGrid[(rowY - 1) * gridW + colX] === 1) isAdjacentToFreeSpace = true; // 上方
|
||||
else if (rowY < gridH - 1 && occupancyGrid[(rowY + 1) * gridW + colX] === 1) isAdjacentToFreeSpace = true; // 下方
|
||||
else if (colX > 0 && occupancyGrid[rowY * gridW + colX - 1] === 1) isAdjacentToFreeSpace = true; // 左方
|
||||
else if (colX < gridW - 1 && occupancyGrid[rowY * gridW + colX + 1] === 1) isAdjacentToFreeSpace = true; // 右方
|
||||
}
|
||||
if (isAdjacentToFreeSpace) {
|
||||
// 墙壁边缘:亮蓝色
|
||||
pixelData[pixelOffset] = WALL_EDGE_R;
|
||||
pixelData[pixelOffset + 1] = WALL_EDGE_G;
|
||||
pixelData[pixelOffset + 2] = WALL_EDGE_B;
|
||||
pixelData[pixelOffset + 3] = 255;
|
||||
} else {
|
||||
// 墙壁内部:深蓝色
|
||||
pixelData[pixelOffset] = WALL_INTERIOR_R;
|
||||
pixelData[pixelOffset + 1] = WALL_INTERIOR_G;
|
||||
pixelData[pixelOffset + 2] = WALL_INTERIOR_B;
|
||||
pixelData[pixelOffset + 3] = 255;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 将 ImageData 绘制到离屏 Canvas 上
|
||||
offscreenCtx.putImageData(imageData, 0, 0);
|
||||
return offscreenCanvas;
|
||||
}
|
||||
|
||||
export class MobileRobot {
|
||||
constructor(id, name, x, y, angleRad = 0, color = '#15ff00', imageUrl = null) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.x = x; // 世界坐标X (米)
|
||||
this.y = y; // 世界坐标Y (米)
|
||||
this.angle = angleRad; // 朝向弧度
|
||||
this.imageUrl = imageUrl || '/bot.svg';
|
||||
this.color = color; // 颜色
|
||||
// 预加载图片 (可选)
|
||||
this.imgElement = null;
|
||||
if (this.imageUrl) {
|
||||
this.init(color, this.imageUrl)
|
||||
}
|
||||
}
|
||||
|
||||
async init(color, imageUrl) {
|
||||
const response = await fetch(imageUrl);
|
||||
let svgText = await response.text();
|
||||
// 替换颜色
|
||||
svgText = svgText.replace(/currentColor/g, color);
|
||||
svgText = svgText.replace(/stroke="[^"]*"/g, `stroke="${color}"`);
|
||||
// 创建Blob URL
|
||||
const blob = new Blob([svgText], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
this.imgElement = new Image();
|
||||
this.imgElement.src = url;
|
||||
}
|
||||
// 命中检测: 检查世界坐标是否在机器人区域内 (半径0.35米)
|
||||
hitTest(worldX, worldY, pixelsPerMeter){
|
||||
const dx = worldX - this.x;
|
||||
const dy = worldY - this.y;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
const hitRadius = 0.35; // 米
|
||||
return distance < hitRadius;
|
||||
}
|
||||
|
||||
// 绘制到canvas上下文 (屏幕坐标)
|
||||
draw(ctx, screenX, screenY, color, scale = 1) {
|
||||
const size = 24; // 绘制大小px
|
||||
if (this.imgElement && this.imgElement.complete) {
|
||||
ctx.save();
|
||||
ctx.translate(screenX, screenY);
|
||||
ctx.rotate(-this.angle); // canvas Y向下, 角度翻转适配
|
||||
ctx.drawImage(this.imgElement, -size / 2, -size / 2, size, size);
|
||||
ctx.restore();
|
||||
}
|
||||
// 名字标签
|
||||
ctx.font = '12px "JetBrains Mono"';
|
||||
ctx.fillStyle = color;
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillText(this.name, screenX, screenY - 20);
|
||||
}
|
||||
|
||||
// 更新位置
|
||||
setPosition(x, y) { this.x = x; this.y = y; }
|
||||
setAngle(rad) { this.angle = rad; }
|
||||
}
|
||||
|
||||
@ -1,161 +0,0 @@
|
||||
<template>
|
||||
<div class="header-container">
|
||||
<div class="left">
|
||||
<div class="date">
|
||||
<div>{{ currentTime }}</div>
|
||||
<div class="week">{{ weekText }}</div>
|
||||
</div>
|
||||
<div class="binary-strip">{{ binaryText }}</div>
|
||||
</div>
|
||||
<div class="application-title">两江鱼复智能网联新能源汽车产业基地数字化平台</div>
|
||||
<div class="right">
|
||||
<div class="binary-strip">{{ binaryText }}</div>
|
||||
<div class="btn-container">
|
||||
<button class="nav-btn" :class="{ active: activePage === 'home' }" @click="handleBtn('home')">地图总览</button>
|
||||
<button class="nav-btn" :class="{ active: activePage === 'monitoring' }" @click="handleBtn('monitoring')">智能监控</button>
|
||||
<button class="nav-btn" :class="{ active: activePage === 'manualTakeover' }" @click="handleBtn('manualTakeover')">人工接管</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const emits = defineEmits(['activePage'])
|
||||
|
||||
const currentTime = ref('')
|
||||
const weekText = ref('')
|
||||
|
||||
const binaryText = ref('13513034532438213')
|
||||
|
||||
const weekNames = ['星期日','星期一','星期二','星期三','星期四','星期五','星期六'];
|
||||
const updateClock = () => {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const mo = String(now.getMonth()+1).padStart(2,'0');
|
||||
const d = String(now.getDate()).padStart(2,'0');
|
||||
const h = String(now.getHours()).padStart(2,'0');
|
||||
const mi = String(now.getMinutes()).padStart(2,'0');
|
||||
const s = String(now.getSeconds()).padStart(2,'0');
|
||||
currentTime.value = `${y}-${mo}-${d} ${h}:${mi}:${s}`
|
||||
weekText.value = weekNames[now.getDay()]
|
||||
}
|
||||
|
||||
const genBinary = (len) => {
|
||||
let str = '';
|
||||
for (let i = 0; i < len; i++) str += Math.round(Math.random()).toString();
|
||||
return str;
|
||||
}
|
||||
|
||||
const animateBinary = () => {
|
||||
binaryText.value = genBinary(30)
|
||||
}
|
||||
|
||||
const activePage = ref('home')
|
||||
const handleBtn = (page) => {
|
||||
activePage.value = page
|
||||
emits("activePage", page);
|
||||
}
|
||||
|
||||
let clockTimer, binaryTimer;
|
||||
onMounted(() => {
|
||||
updateClock();
|
||||
clockTimer = setInterval(updateClock, 1000);
|
||||
animateBinary()
|
||||
binaryTimer = setInterval(animateBinary, 400);
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(clockTimer)
|
||||
clearInterval(binaryTimer)
|
||||
})
|
||||
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.header-container {
|
||||
// width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin: 0 20px;
|
||||
}
|
||||
|
||||
.left {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
|
||||
.date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
color: #00c2ff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
|
||||
.week {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.application-title {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: #00c2ff;
|
||||
letter-spacing: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
|
||||
.btn-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px ;
|
||||
|
||||
.nav-btn {
|
||||
background: rgba(0, 60, 140, 0.5);
|
||||
border: 1px solid rgba(0, 194, 255, 0.35);
|
||||
color: #a0d8f0;
|
||||
padding: 6px 20px;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: rgba(0, 120, 220, 0.7);
|
||||
border-color: #00c2ff;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 194, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.binary-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: 'Orbitron', monospace;
|
||||
font-size: 10px;
|
||||
color: rgba(0, 194, 255, 0.3);
|
||||
letter-spacing: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
</style>
|
||||
@ -1,325 +0,0 @@
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="card">
|
||||
<div class="card__head">
|
||||
<h3>方向控制</h3>
|
||||
<el-tag size="small" effect="dark" type="info">长按持续移动</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="dpad">
|
||||
<button class="dpad__btn dpad__btn--up" @mousedown="onPress('forward')" @mouseup="onRelease"
|
||||
@mouseleave="onRelease" @touchstart.prevent="onPress('forward')" @touchend.prevent="onRelease">
|
||||
<svg viewBox="0 0 14 14">
|
||||
<path d="M7 1L13 11H1Z" fill="currentColor" />
|
||||
</svg>
|
||||
<small>前进</small>
|
||||
</button>
|
||||
|
||||
<button class="dpad__btn dpad__btn--lt" @mousedown="onPress('left')" @mouseup="onRelease"
|
||||
@touchstart.prevent="onPress('left')" @touchend.prevent="onRelease">
|
||||
<svg viewBox="0 0 14 14">
|
||||
<path d="M1 7L11 1V13Z" fill="currentColor" />
|
||||
</svg>
|
||||
<small>左转</small>
|
||||
</button>
|
||||
|
||||
<div class="dpad__core"><span /></div>
|
||||
|
||||
<button class="dpad__btn dpad__btn--rt" @mousedown="onPress('right')" @mouseup="onRelease"
|
||||
@touchstart.prevent="onPress('right')" @touchend.prevent="onRelease">
|
||||
<svg viewBox="0 0 14 14">
|
||||
<path d="M13 7L3 1V13Z" fill="currentColor" />
|
||||
</svg>
|
||||
<small>右转</small>
|
||||
</button>
|
||||
|
||||
<button class="dpad__btn dpad__btn--dn" @mousedown="onPress('backward')" @mouseup="onRelease"
|
||||
@touchstart.prevent="onPress('backward')" @touchend.prevent="onRelease">
|
||||
<svg viewBox="0 0 14 14">
|
||||
<path d="M7 13L1 3H13Z" fill="currentColor" />
|
||||
</svg>
|
||||
<small>后退</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Parameters -->
|
||||
<div class="card">
|
||||
<div class="card__head">
|
||||
<h3>参数设置</h3>
|
||||
</div>
|
||||
|
||||
<div class="param" v-for="p in paramList" :key="p.key">
|
||||
<div class="param__head">
|
||||
<span>{{ p.label }}</span>
|
||||
<b class="param__val">{{ formatVal(p) }}</b>
|
||||
</div>
|
||||
<el-slider v-model="p.model" :min="p.min" :max="p.max" :step="p.step" :show-tooltip="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<script setup>
|
||||
import { moveRobot, stopRobot } from '@/api/resource/robot.js'
|
||||
|
||||
const props = defineProps({
|
||||
robot: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
deviceId: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const maxSpeed = ref(0.6)
|
||||
const rotationAngle = ref(0)
|
||||
|
||||
const paramList = reactive([
|
||||
{ key: 'speed', label: '速度', model: maxSpeed, min: 0, max: 1, step: 0.1, unit: 'm/s', digits: 1 },
|
||||
{ key: 'rot', label: '旋转角速度', model: rotationAngle, min: -1, max: 1, step: 0.1, unit: 'm/s', digits: 0 },
|
||||
])
|
||||
|
||||
function formatVal(p) {
|
||||
const v = typeof p.model === 'object' ? p.model.value : p.model
|
||||
return (typeof v === 'number' ? v.toFixed(p.digits) : v) + ' ' + p.unit
|
||||
}
|
||||
|
||||
const moveRobotData = ref({
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
vz: 0
|
||||
})
|
||||
|
||||
/* long-press stubs */
|
||||
const onPress = async (_dir) => {
|
||||
if (_dir === 'forward') {
|
||||
moveRobotData.value.vx = maxSpeed.value
|
||||
} else if (_dir === 'backward') {
|
||||
moveRobotData.value.vx = -maxSpeed.value
|
||||
} else if (_dir === 'left') {
|
||||
moveRobotData.value.vy = maxSpeed.value
|
||||
} else {
|
||||
moveRobotData.value.vy = -maxSpeed.value
|
||||
}
|
||||
moveRobotData.value.vz = rotationAngle.value
|
||||
const res = await moveRobot({
|
||||
robotId: props.robot.robotId,
|
||||
deviceId: props.deviceId,
|
||||
...moveRobotData.value
|
||||
})
|
||||
}
|
||||
|
||||
function onRelease() {
|
||||
moveRobotData.value.vx = 0;
|
||||
moveRobotData.value.vy = 0;
|
||||
stopRobot({
|
||||
robotId: props.robot.robotId,
|
||||
deviceId: props.deviceId,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.panel {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow-y: auto;
|
||||
// background: #0F1E38;
|
||||
|
||||
.card {
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid rgba(0, 195, 255, 0.08);
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h3 {
|
||||
font-family: 'Rajdhani', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
color: #475a72;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tag) {
|
||||
font-size: 10px;
|
||||
height: 20px;
|
||||
background: rgba(0, 195, 255, 0.08);
|
||||
border-color: rgba(0,195,255,0.08);
|
||||
color: #00c3ff;
|
||||
}
|
||||
|
||||
.dpad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 76px);
|
||||
grid-template-rows: repeat(3, 64px);
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
margin-bottom: 18px;
|
||||
|
||||
&__btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
border: 1px solid rgba(0, 195, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
background: #0d1a3b;
|
||||
color: #c0cfdf;
|
||||
cursor: pointer;
|
||||
transition: all .15s ease;
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 10px;
|
||||
letter-spacing: .5px;
|
||||
color: #475a72;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(0, 195, 255, 0.08);
|
||||
background: #101930;
|
||||
color: #00c3ff;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #00c3ff;
|
||||
color: #060a12;
|
||||
border-color: #00c3ff;
|
||||
box-shadow: 0 0 24px rgba(0,195,255,0.14);
|
||||
transform: scale(.94);
|
||||
|
||||
small {
|
||||
color: #060a12;
|
||||
}
|
||||
}
|
||||
|
||||
&--up {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
&--lt {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
&--rt {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
&--dn {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
}
|
||||
}
|
||||
|
||||
&__core {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(0,195,255,0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 195, 255, 0.03);
|
||||
|
||||
span {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #00c3ff;
|
||||
opacity: .3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.estop {
|
||||
width: 100%;
|
||||
height: 42px !important;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
border-color: #ff2d55 !important;
|
||||
color: #ff2d55 !important;
|
||||
background: rgba(255, 45, 85, 0.06) !important;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 45, 85, 0.14) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.param {
|
||||
margin-bottom: 18px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: #475a72;
|
||||
}
|
||||
|
||||
&__val {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #00c3ff;
|
||||
}
|
||||
|
||||
:deep(.el-slider) {
|
||||
.el-slider__runway {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.el-slider__bar {
|
||||
height: 4px;
|
||||
background: #00c3ff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.el-slider__button-wrapper {
|
||||
top: -16px;
|
||||
}
|
||||
|
||||
.el-slider__button {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #00c3ff;
|
||||
background: #0f1628;
|
||||
box-shadow: 0 0 8px rgba(0, 195, 255, 0.3);
|
||||
transition: box-shadow .2s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 14px rgba(0, 195, 255, 0.35);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,419 +0,0 @@
|
||||
<template>
|
||||
<div class="robotic-arm-box">
|
||||
<div class="robotic-arm-status">
|
||||
<div class="coord-display">
|
||||
<div class="coord-item">
|
||||
<div class="coord-key key-x">X</div>
|
||||
<div class="coord-val" id="coordX">{{ pose.x?.toFixed(3) }}</div>
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<div class="coord-key key-y">Y</div>
|
||||
<div class="coord-val" id="coordY">{{ pose.y?.toFixed(3) }}</div>
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<div class="coord-key key-z">Z</div>
|
||||
<div class="coord-val" id="coordZ">{{ pose.z?.toFixed(3) }}</div>
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<div class="coord-key key-x">RX</div>
|
||||
<div class="coord-val" id="coordX">{{ pose.rx?.toFixed(3) }}</div>
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<div class="coord-key key-y">RY</div>
|
||||
<div class="coord-val" id="coordY">{{ pose.ry?.toFixed(3) }}</div>
|
||||
</div>
|
||||
<div class="coord-item">
|
||||
<div class="coord-key key-z">RZ</div>
|
||||
<div class="coord-val" id="coordZ">{{ pose.rz?.toFixed(3) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="axis-group">
|
||||
<!-- X Axis -->
|
||||
<div class="axis-row x">
|
||||
<div class="axis-label x">X</div>
|
||||
<div class="axis-slider-wrap">
|
||||
<div class="axis-name">前后平移</div>
|
||||
<div>
|
||||
<el-button
|
||||
type="primary"
|
||||
circle
|
||||
@mousedown="move('x', '-')"
|
||||
@mouseup="stop"
|
||||
@touchstart.prevent="move('x', '-')"
|
||||
@touchend.prevent="stop"
|
||||
>
|
||||
<el-icon size="24">
|
||||
<Remove />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
circle
|
||||
@mousedown="move('x', '+')"
|
||||
@mouseup="stop"
|
||||
@touchstart.prevent="move('x', '+')"
|
||||
@touchend.prevent="stop"
|
||||
>
|
||||
<el-icon size="24">
|
||||
<CirclePlus />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Y Axis -->
|
||||
<div class="axis-row y">
|
||||
<div class="axis-label y">Y</div>
|
||||
<div class="axis-slider-wrap">
|
||||
<div class="axis-name">左右平移</div>
|
||||
<div>
|
||||
<el-button
|
||||
type="primary"
|
||||
circle
|
||||
@mousedown="move('y', '-')"
|
||||
@mouseup="stop"
|
||||
@touchstart.prevent="move('y', '-')"
|
||||
@touchend.prevent="stop"
|
||||
>
|
||||
<el-icon size="24">
|
||||
<Remove />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
circle
|
||||
@mousedown="move('y', '+')"
|
||||
@mouseup="stop"
|
||||
@touchstart.prevent="move('y', '+')"
|
||||
@touchend.prevent="stop"
|
||||
>
|
||||
<el-icon size="24">
|
||||
<CirclePlus />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Z Axis -->
|
||||
<div class="axis-row z">
|
||||
<div class="axis-label z">Z</div>
|
||||
<div class="axis-slider-wrap">
|
||||
<div class="axis-name">上下平移</div>
|
||||
<div>
|
||||
<el-button
|
||||
type="primary"
|
||||
circle
|
||||
@mousedown="move('z', '-')"
|
||||
@mouseup="stop"
|
||||
@touchstart.prevent="move('z', '-')"
|
||||
@touchend.prevent="stop"
|
||||
>
|
||||
<el-icon size="24">
|
||||
<Remove />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
circle
|
||||
@mousedown="move('z', '+')"
|
||||
@mouseup="stop"
|
||||
@touchstart.prevent="move('z', '+')"
|
||||
@touchend.prevent="stop"
|
||||
>
|
||||
<el-icon size="24">
|
||||
<CirclePlus />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="speed-container">
|
||||
<div class="label">速度:</div>
|
||||
<el-slider v-model="speed" :min="0.01" :step="0.01" :max="1" />
|
||||
<div class="proportion">{{( speed / 1 * 100).toFixed(0) }} %</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { getPoseApi, speedLApi, stopMotionApi } from '@/api/inspection/cockpit';
|
||||
import { reactive } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
robot: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
deviceId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'end'
|
||||
}
|
||||
})
|
||||
|
||||
const pose = ref({
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
rx: 0,
|
||||
ry: 0,
|
||||
rz: 0
|
||||
})
|
||||
|
||||
const speed = ref(1) // 速度百分比,范围 0.1 - 5
|
||||
|
||||
const errorNum = ref(0)
|
||||
/**
|
||||
* 获取机械臂末端状态信息
|
||||
*/
|
||||
const getPose = async (robot) => {
|
||||
try {
|
||||
const res = await getPoseApi({
|
||||
deviceId: props.deviceId,
|
||||
robotId: robot.robotId
|
||||
})
|
||||
if (res.code === 200) {
|
||||
const { x, y, z, rx, ry, rz } = res.data
|
||||
pose.value = { x, y, z, rx, ry, rz }
|
||||
} else {
|
||||
console.error('获取姿态失败:', res.message)
|
||||
errorNum.value++
|
||||
if (errorNum.value > 3) {
|
||||
clearInterval(timer.value)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
errorNum.value++
|
||||
if (errorNum.value > 3) {
|
||||
clearInterval(timer.value)
|
||||
}
|
||||
console.error('获取姿态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const speedData = reactive({
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
vz: 0,
|
||||
wx: 0,
|
||||
wy: 0,
|
||||
wz: 0
|
||||
})
|
||||
|
||||
/**
|
||||
* 机械臂移动
|
||||
* @param axis 坐标轴
|
||||
* @param direction 方向
|
||||
*/
|
||||
const move = async (axis, direction) => {
|
||||
speedData.vx = 0
|
||||
speedData.vy = 0
|
||||
speedData.vz = 0
|
||||
speedData.wx = 0
|
||||
speedData.wy = 0
|
||||
speedData.wz = 0
|
||||
if (props.type === 'end') {
|
||||
// 末端移动
|
||||
const speedValue = speed.value
|
||||
switch (axis) {
|
||||
case 'x':
|
||||
speedData.vx = direction === '+' ? speedValue : -speedValue
|
||||
break
|
||||
case 'y':
|
||||
speedData.vy = direction === '+' ? speedValue : -speedValue
|
||||
break
|
||||
case 'z':
|
||||
speedData.vz = direction === '+' ? speedValue : -speedValue
|
||||
break
|
||||
}
|
||||
} else if (props.type === 'posture') {
|
||||
// 关节移动
|
||||
const jointSpeed = speed.value
|
||||
switch (axis) {
|
||||
case 'x':
|
||||
speedData.wx = direction === '+' ? jointSpeed : -jointSpeed
|
||||
break
|
||||
case 'y':
|
||||
speedData.wy = direction === '+' ? jointSpeed : -jointSpeed
|
||||
break
|
||||
case 'z':
|
||||
speedData.wz = direction === '+' ? jointSpeed : -jointSpeed
|
||||
break
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
speedLApi({
|
||||
deviceId: props.deviceId,
|
||||
robotId: props.robot.robotId,
|
||||
duration: 60,
|
||||
acceleration: speed.value + 0.1,
|
||||
...speedData
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止机械臂移动
|
||||
*/
|
||||
const stop = async () => {
|
||||
await stopMotionApi({
|
||||
deviceId: props.deviceId,
|
||||
robotId: props.robot.robotId
|
||||
})
|
||||
}
|
||||
|
||||
const timer = ref(null)
|
||||
watch(() => props.robot, (newRobot) => {
|
||||
if (newRobot && newRobot.robotId) {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value)
|
||||
}
|
||||
timer.value = setInterval(() => {
|
||||
getPose(newRobot)
|
||||
}, 100)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.robotic-arm-box {
|
||||
margin-top: 12px;
|
||||
|
||||
.robotic-arm-status {
|
||||
.coord-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: #070b1a;
|
||||
border: 1px solid #1c2a52;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.coord-item {
|
||||
width: calc((100% - 24px) / 3);
|
||||
text-align: center;
|
||||
padding: 6px 0;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
|
||||
.coord-key {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.key-x {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.key-y {
|
||||
color: #34d399;
|
||||
;
|
||||
}
|
||||
|
||||
.key-z {
|
||||
color: #38bdf8;
|
||||
;
|
||||
}
|
||||
|
||||
.coord-val {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #e8edf8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.axis-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.axis-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: #0c1228;
|
||||
border: 1px solid #1c2a52;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #2a3f7a;
|
||||
background: #3b82f608;
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.x {
|
||||
background: #f8717126;
|
||||
color: #f87171;
|
||||
border: 1px solid #f871714d;
|
||||
}
|
||||
|
||||
.y {
|
||||
background: #34d39926;
|
||||
color: #34d399;
|
||||
border: 1px solid #34d3994d;
|
||||
}
|
||||
|
||||
.z {
|
||||
background: #38bdf826;
|
||||
color: #38bdf8;
|
||||
border: 1px solid #38bdf84d;
|
||||
}
|
||||
|
||||
|
||||
.axis-slider-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.speed-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.label,
|
||||
.proportion {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,622 +0,0 @@
|
||||
<template>
|
||||
<div class="monitor-container">
|
||||
<div class="left-container">
|
||||
<div class="robot-container">
|
||||
<div class="title">选择机器人</div>
|
||||
<div class="robot-box">
|
||||
<div v-for="item, index in robotList" class="robot" :class="{ active: activeBot === index }"
|
||||
@click="changeBot(index)">
|
||||
<div>
|
||||
<SvgIcon name="bot" :color="colorList[index]" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="bot-name">{{ item.robotName }}</div>
|
||||
<div class="bot-local" :class="agvRuntimeStatusClass(item.status)">{{ agvRuntimeStatusLabel(item.status) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split-line"></div>
|
||||
<div class="mode-container">
|
||||
<div class="title">控制模式</div>
|
||||
<div class="mode-box">
|
||||
<div class="mode-item" :class="{ activeMode: activeMode === 'auto' }" @click="changeMode('auto')">
|
||||
<SvgIcon name="cpu" :color="activeMode === 'auto' ? '#00FF88' : '#fff'" :size="16" />
|
||||
<span class="label">自动巡检</span>
|
||||
</div>
|
||||
<div class="mode-item" :class="{ activeMode: activeMode === 'artificial' }"
|
||||
@click="changeMode('artificial')">
|
||||
<SvgIcon name="gamepad" :color="activeMode === 'artificial' ? '#00FF88' : '#fff'" :size="16" />
|
||||
<span class="label">人工接管</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="activeMode === 'artificial'" class="artificial-panel">
|
||||
<el-radio-group v-model="activeController" size="small" @change="handlerController">
|
||||
<el-radio :value="1">底盘</el-radio>
|
||||
<el-radio :value="2">机械臂</el-radio>
|
||||
<el-radio :value="3">相机</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="controller-panel">人工控制面板</div>
|
||||
<div class="device-binding">
|
||||
<span>设备随机器人自动切换</span>
|
||||
<el-tag size="small" :type="activeDeviceId ? 'success' : 'danger'">
|
||||
{{ activeDeviceId || '无匹配在线设备' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<DirectionControl v-if="activeController === 1" :robot="activeBotData"
|
||||
:device-id="deviceByKind(1)" />
|
||||
<div v-if="activeController === 2" class="robotic-arm-controller-container">
|
||||
<el-radio-group v-model="activeRoboticArm" size="small">
|
||||
<el-radio :value="1">末端控制</el-radio>
|
||||
<el-radio :value="2">关节控制</el-radio>
|
||||
<el-radio :value="3">姿态控制</el-radio>
|
||||
</el-radio-group>
|
||||
|
||||
<RoboticArm :type="activeRoboticArm === 1 ? 'end' : 'posture'" :robot="activeBotData"
|
||||
:device-id="deviceByKind(2)"
|
||||
v-if="activeController === 2 && [1, 3].includes(activeRoboticArm)" />
|
||||
|
||||
<div class="control-panel" v-if="activeRoboticArm === 2">
|
||||
<div class="joint-list">
|
||||
<div v-for="joint in jointControls" :key="joint.name" class="joint-item">
|
||||
<label :for="joint.name">{{ joint.name }}</label>
|
||||
<el-input
|
||||
v-model="joint.value"
|
||||
readonly
|
||||
>
|
||||
<template #prepend>
|
||||
<el-button
|
||||
@mousedown="updateJoint(joint.name, '-')"
|
||||
@mouseup="stop"
|
||||
@touchstart.prevent="updateJoint(joint.name, '-')"
|
||||
@touchend.prevent="stop"
|
||||
>
|
||||
-
|
||||
</el-button>
|
||||
</template>
|
||||
<template #append>
|
||||
<el-button
|
||||
@mousedown="updateJoint(joint.name, '+')"
|
||||
@mouseup="stop"
|
||||
@touchstart.prevent="updateJoint(joint.name, '+')"
|
||||
@touchend.prevent="stop"
|
||||
>
|
||||
+
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
<div class="speed-container">
|
||||
<div class="label">速度:</div>
|
||||
<el-slider v-model="jointSpeed" :min="0.01" :step="0.01" :max="1" />
|
||||
<div class="proportion">{{( jointSpeed / 1 * 100).toFixed(0) }} %</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="video-container">
|
||||
<div v-if="activeMode === 'auto' || activeController === 1" class="map-container">
|
||||
<MapCanvas v-if="robotList.length > 0" :robotList="currentRobot" ref="mapCanvasRef" />
|
||||
</div>
|
||||
<div v-if="activeMode === 'artificial' && activeController === 2" class="model-container">
|
||||
<UrdfViewer ref="urdfViewerRef" modelPath="/inspection/elfin10/elfin10.urdf.xacro" />
|
||||
</div>
|
||||
<div v-if="activeMode === 'artificial' && activeController === 3" class="monitor-container">监控</div>
|
||||
</div>
|
||||
<div class="right-container">
|
||||
<div class="container-title">车载监控</div>
|
||||
<div class="monitor-container">
|
||||
<IPlayer :isLive="true" :videoUrl="videoUrl" />
|
||||
</div>
|
||||
<div class="split-line"></div>
|
||||
<div class="container-title">语音对话</div>
|
||||
<div class="voice-container">
|
||||
<VoiceConversation :robot-id="activeBotData.robotId"
|
||||
:microphone-device-id="deviceByKind(9)" :speaker-device-id="deviceByKind(13)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { ElMessage } from 'element-plus'
|
||||
import MapCanvas from "../MapCanvas.vue";
|
||||
import SvgIcon from "@/components/SvgIcon";
|
||||
import UrdfViewer from '../UrdfView.vue'
|
||||
import DirectionControl from './DirectionControl.vue'
|
||||
import RoboticArm from "./RoboticArm.vue";
|
||||
import { getRobotList, getRobotDevicesByRobotId } from '@/api/resource/robot'
|
||||
import { getJointStateApi, moveJApi, torqueOnApi, speedJApi, stopMotionApi, clearFault } from '@/api/inspection/cockpit'
|
||||
import { Remove, CirclePlus } from '@element-plus/icons-vue'
|
||||
import VoiceConversation from "./VoiceConversation.vue";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
import { agvRuntimeStatusClass, agvRuntimeStatusLabel } from '@/utils/agvRuntimeStatus'
|
||||
|
||||
const robotList = ref([])
|
||||
const robotDevices = ref([])
|
||||
const deviceByKind = (kind) => robotDevices.value.find(device => device.deviceKind === kind)?.deviceId || ''
|
||||
const activeDeviceId = computed(() => deviceByKind(activeController.value === 2 ? 2 : activeController.value === 3 ? 5 : 1))
|
||||
|
||||
const loadRobotDevices = async (robotId) => {
|
||||
robotDevices.value = []
|
||||
if (!robotId) return
|
||||
const response = await getRobotDevicesByRobotId(robotId)
|
||||
robotDevices.value = Array.isArray(response.data) ? response.data : []
|
||||
}
|
||||
|
||||
const fetchRobotList = async (refreshDevices = true) => {
|
||||
try {
|
||||
const res = await getRobotList({
|
||||
robotType: 2,
|
||||
})
|
||||
if (res.code === 200) {
|
||||
const currentRobotId = activeBotData.value.robotId
|
||||
robotList.value = res.rows
|
||||
const currentIndex = robotList.value.findIndex(item => item.robotId === currentRobotId)
|
||||
activeBot.value = currentIndex >= 0 ? currentIndex : 0
|
||||
activeBotData.value = robotList.value[activeBot.value] || {}
|
||||
if (refreshDevices) await loadRobotDevices(activeBotData.value.robotId)
|
||||
} else {
|
||||
console.error('获取机器人列表失败:', res.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取机器人列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const activeBot = ref(0)
|
||||
const activeBotData = ref({})
|
||||
const changeBot = async (index) => {
|
||||
activeBot.value = index
|
||||
activeBotData.value = robotList.value[activeBot.value] || {}
|
||||
await loadRobotDevices(activeBotData.value.robotId)
|
||||
}
|
||||
|
||||
const currentRobot = computed(() => {
|
||||
return [robotList.value[activeBot.value]] || []
|
||||
})
|
||||
|
||||
const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#fff']
|
||||
|
||||
const activeMode = ref('auto')
|
||||
const changeMode = (mode) => {
|
||||
activeMode.value = mode
|
||||
}
|
||||
|
||||
const activeController = ref(1)
|
||||
|
||||
const speed = ref(0.8)
|
||||
|
||||
const activeRoboticArm = ref(1)
|
||||
|
||||
const urdfViewerRef = ref()
|
||||
const jointControls = ref()
|
||||
|
||||
const animationId = ref(null)
|
||||
|
||||
const handlerController = (value) => {
|
||||
if (value === 2) {
|
||||
if (!deviceByKind(2)) {
|
||||
ElMessage.warning('当前机器人没有在线机械臂')
|
||||
return
|
||||
}
|
||||
// 开启机械臂使能
|
||||
torqueOnApi({
|
||||
deviceId: deviceByKind(2),
|
||||
robotId: activeBotData.value.robotId
|
||||
}).catch(error => {
|
||||
console.error('开启机械臂使能失败:', error)
|
||||
clearFault({
|
||||
deviceId: deviceByKind(2),
|
||||
robotId: activeBotData.value.robotId
|
||||
})
|
||||
})
|
||||
jointControls.value = urdfViewerRef.value.jointControls
|
||||
if (animationId.value) {
|
||||
clearInterval(animationId.value)
|
||||
}
|
||||
animationId.value = setInterval(() => {
|
||||
getJointState(activeBotData.value)
|
||||
}, 100)
|
||||
} else {
|
||||
if (animationId.value) {
|
||||
clearInterval(animationId.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const jointNamsMap = {
|
||||
'joint_1' :'elfin_joint1',
|
||||
'joint_2': 'elfin_joint2',
|
||||
'joint_3': 'elfin_joint3',
|
||||
'joint_4': 'elfin_joint4',
|
||||
'joint_5': 'elfin_joint5',
|
||||
'joint_6': 'elfin_joint6',
|
||||
}
|
||||
|
||||
const errCount = ref(0)
|
||||
|
||||
/**
|
||||
* 获取关节状态
|
||||
* @param robot
|
||||
*/
|
||||
const getJointState = async (robot) => {
|
||||
try {
|
||||
const res = await getJointStateApi({
|
||||
deviceId: deviceByKind(2),
|
||||
robotId: robot.robotId
|
||||
})
|
||||
if (res.code === 200) {
|
||||
setTimeout(() => {
|
||||
const names = res.data.name
|
||||
const positions = res.data.position
|
||||
names.forEach((name, index) => {
|
||||
urdfViewerRef.value.updateJoint(jointNamsMap[name] || name, positions[index].toFixed(3))
|
||||
})
|
||||
}, 500)
|
||||
} else {
|
||||
errCount.value += 1
|
||||
if (errCount.value > 2) {
|
||||
clearInterval(animationId.value)
|
||||
errCount.value = 0
|
||||
}
|
||||
console.error('获取关节状态失败:', res.message)
|
||||
}
|
||||
} catch (error) {
|
||||
errCount.value += 1
|
||||
if (errCount.value > 2) {
|
||||
clearInterval(animationId.value)
|
||||
errCount.value = 0
|
||||
}
|
||||
console.error('获取关节状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const jointSpeed = ref(0.8)
|
||||
const updateJoint = async (jointName, _dir) => {
|
||||
const velocities = jointControls.value.map(joint => {
|
||||
return 0
|
||||
})
|
||||
const index = jointControls.value.findIndex(joint => joint.name === jointName)
|
||||
velocities[index] = jointSpeed.value * (_dir === '+' ? 1 : -1)
|
||||
const res = await speedJApi({
|
||||
deviceId: deviceByKind(2),
|
||||
robotId: activeBotData.value.robotId,
|
||||
acceleration: jointSpeed.value + 0.1,
|
||||
duration: 60,
|
||||
velocities: velocities
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止机械臂移动
|
||||
*/
|
||||
const stop = async () => {
|
||||
await stopMotionApi({
|
||||
deviceId: deviceByKind(2),
|
||||
robotId: activeBotData.value.robotId
|
||||
})
|
||||
}
|
||||
|
||||
const videoUrl = ref('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8')
|
||||
|
||||
let robotRefreshTimer
|
||||
|
||||
onMounted(() => {
|
||||
fetchRobotList()
|
||||
robotRefreshTimer = window.setInterval(() => {
|
||||
if (!document.hidden) fetchRobotList(false)
|
||||
}, 500)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearInterval(robotRefreshTimer)
|
||||
if (animationId.value) {
|
||||
clearInterval(animationId.value)
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.monitor-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
||||
.left-container {
|
||||
width: 360px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
|
||||
.robot-container {
|
||||
|
||||
.title {
|
||||
color: #8899BB;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.robot-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.robot {
|
||||
flex: 0 0 calc(50% - 10px);
|
||||
background: #0F1E38;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
|
||||
.bot-name {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bot-local {
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.online {
|
||||
color: #0f8;
|
||||
}
|
||||
|
||||
.unknown,
|
||||
.offline,
|
||||
.stopped {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.manual,
|
||||
.running {
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.charging,
|
||||
.paused {
|
||||
color: #ffb300;
|
||||
}
|
||||
|
||||
.fault,
|
||||
.emergency {
|
||||
color: #ff6b6d;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
border-color: #00c2ff;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 194, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.split-line {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
margin: 16px 0;
|
||||
background: #1E3A5F;
|
||||
}
|
||||
|
||||
.mode-container {
|
||||
.title {
|
||||
color: #8899BB;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mode-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.mode-item {
|
||||
height: 36px;
|
||||
flex: 0 0 calc(50% - 10px);
|
||||
background: #0F1E38;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
cursor: pointer;
|
||||
|
||||
.label {
|
||||
color: #8899BB;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.activeMode {
|
||||
border-color: #00FF88;
|
||||
|
||||
.label {
|
||||
color: #00FF88;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.controller-panel {
|
||||
padding: 16px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.robotic-arm-controller-container {
|
||||
:deep(.el-radio-group) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-container {
|
||||
flex: 1;
|
||||
border: 1px solid #1E3A5F;
|
||||
border-top: 0;
|
||||
|
||||
.map-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #00c2ff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.model-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #00c2ff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.monitor-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #00c2ff;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-container {
|
||||
width: 360px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
|
||||
.container-title {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.split-line {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
margin: 16px 0;
|
||||
background: #1E3A5F;
|
||||
}
|
||||
|
||||
.monitor-container {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.voice-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.device-binding {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: 10px 0 14px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
margin-top: 12px;
|
||||
background: #0F1E38;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
color: #ccc;
|
||||
max-height: 80%;
|
||||
overflow-y: auto;
|
||||
|
||||
.joint-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(0, 195, 255, 0.08);
|
||||
border-radius: 5px;
|
||||
background: #0b182c;
|
||||
|
||||
&:hover {
|
||||
border-color: #2a3f7a;
|
||||
background: rgba(59, 130, 246, 0.031372549);
|
||||
}
|
||||
|
||||
label {
|
||||
width: 120px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input[type=range] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.angle {
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
:deep(.el-input-group__prepend) {
|
||||
background: #080b10;
|
||||
border-right: 0px;
|
||||
box-shadow: 0 0 0 1px #272727 inset;
|
||||
}
|
||||
|
||||
:deep(.el-input-group__append) {
|
||||
background: #080b10;
|
||||
border-left: 0px;
|
||||
box-shadow: 0 0 0 1px #272727 inset;
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
background-color: #0e0e0e;
|
||||
box-shadow: 0 0 0 1px #272727 inset;
|
||||
}
|
||||
}
|
||||
|
||||
.speed-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.label,
|
||||
.proportion {
|
||||
width: 100px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,527 +0,0 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<canvas id="map-canvas"></canvas>
|
||||
<div id="tooltip">
|
||||
<div class="tooltip-title"></div>
|
||||
<div class="tooltip-detail"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, watch } from 'vue';
|
||||
|
||||
import {
|
||||
worldToScreen,
|
||||
screenToWorld,
|
||||
drawGrid,
|
||||
drawGridlines,
|
||||
drawAdvancedAreaList,
|
||||
drawAdvancedLineList,
|
||||
drawNormalLines,
|
||||
drawAdvancedCurveList,
|
||||
drawAdvancedPointList,
|
||||
drawMapBoundary,
|
||||
buildOccupancyGridImage,
|
||||
MobileRobot
|
||||
} from '../canvasUtils';
|
||||
import { getMapJson } from '@/api/resource/robot'
|
||||
import { useInspectionStore } from "@/store/modules/inspection";
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const props = defineProps({
|
||||
robotList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const inspectionStore = useInspectionStore();
|
||||
|
||||
|
||||
/**
|
||||
* 应用全局状态对象,集中管理所有可变数据
|
||||
* - parsedMap: 解析后的地图数据结构
|
||||
* - layerVisibility: 各图层的可见性开关
|
||||
* - mouseState: 鼠标位置与拖拽状态
|
||||
* - isMapLoaded: 地图是否已加载完成
|
||||
*/
|
||||
const applicationState = {
|
||||
parsedMap: null, // 解析后的完整地图数据
|
||||
mouseState: { // 鼠标交互状态
|
||||
screenX: 0, // 鼠标在 Canvas 上的像素 X
|
||||
screenY: 0, // 鼠标在 Canvas 上的像素 Y
|
||||
worldX: 0, // 鼠标对应的世界坐标 X
|
||||
worldY: 0, // 鼠标对应的世界坐标 Y
|
||||
isDragging: false, // 是否正在拖拽平移
|
||||
},
|
||||
isMapLoaded: false, // 地图加载完成标志,
|
||||
layerVisibility: { // 各图层是否可见
|
||||
grid: true, // 占据栅格底图
|
||||
edges: true, // 墙壁边缘发光
|
||||
gridlines: true, // 1米网格辅助线
|
||||
normalLines: true, // normalLineList
|
||||
advLines: true, // advancedLineList
|
||||
curves: true, // advancedCurveList
|
||||
points: true, // advancedPointList
|
||||
areas: true, // advancedAreaList
|
||||
robots: true, // 机器人位置和朝向
|
||||
},
|
||||
robots: [] // 存储机器人实例
|
||||
};
|
||||
|
||||
let mapCanvas; // 主渲染画布 DOM 元素
|
||||
let canvasCtx; // 2D 渲染上下文
|
||||
|
||||
// 保存当前动画帧 ID,组件卸载时用于停止 Canvas 渲染循环。
|
||||
let animationFrameId;
|
||||
// 地图请求版本号:当多个地图请求并发时,只允许最后一次请求更新画布。
|
||||
let mapLoadVersion = 0;
|
||||
|
||||
const init = () => {
|
||||
mapCanvas = document.getElementById('map-canvas');
|
||||
canvasCtx = mapCanvas.getContext('2d');
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据容器尺寸和设备像素比调整 Canvas 大小
|
||||
* 在窗口 resize 时自动调用
|
||||
*/
|
||||
function resizeCanvasToContainer() {
|
||||
const containerRect = mapCanvas.parentElement.getBoundingClientRect(); // 获取容器实际尺寸
|
||||
devicePixelRatio = window.devicePixelRatio || 1; // 获取设备像素比
|
||||
inspectionStore.canvasWidthPx = containerRect.width; // 更新逻辑宽度
|
||||
inspectionStore.canvasHeightPx = containerRect.height; // 逻辑高度 = 容器高度 - 状态栏高度
|
||||
mapCanvas.width = inspectionStore.canvasWidthPx * devicePixelRatio; // Canvas 物理像素宽
|
||||
mapCanvas.height = inspectionStore.canvasHeightPx * devicePixelRatio; // Canvas 物理像素高
|
||||
mapCanvas.style.width = inspectionStore.canvasWidthPx + 'px'; // CSS 显示宽度
|
||||
mapCanvas.style.height = inspectionStore.canvasHeightPx + 'px'; // CSS 显示高度
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 解析 SEER smap JSON 文件为内部地图数据结构
|
||||
*
|
||||
* @param {Object} rawJson - 原始 JSON 解析后的对象
|
||||
* @returns {Object} 解析后的统一地图数据结构
|
||||
*/
|
||||
function parseSmapJson(rawJson) {
|
||||
// ── 解析 header ──
|
||||
const header = rawJson.header || rawJson; // 兼容 header 嵌套或平铺
|
||||
const minimumPos = header.minPos || { x: 0, y: 0 }; // 地图左下角(世界坐标)
|
||||
const maximumPos = header.maxPos || { x: 1, y: 1 }; // 地图右上角(世界坐标)
|
||||
const resolution = header.resolution || 0.05; // 每像素代表的世界长度(米)
|
||||
|
||||
// ── 计算栅格尺寸 ──
|
||||
const worldWidth = maximumPos.x - minimumPos.x; // 地图世界宽度(米)
|
||||
const worldHeight = maximumPos.y - minimumPos.y; // 地图世界高度(米)
|
||||
const gridWidthPx = Math.ceil(worldWidth / resolution) + 1; // 栅格像素宽度
|
||||
const gridHeightPx = Math.ceil(worldHeight / resolution) + 1;// 栅格像素高度
|
||||
|
||||
// ── 构建统一数据结构 ──
|
||||
const parsedMap = {
|
||||
name: header.mapName || 'Untitled', // 地图名称
|
||||
version: header.version || '', // 版本号
|
||||
mapType: header.mapType || '', // 地图类型(如 "2D-Map")
|
||||
resolution: resolution, // 分辨率(米/像素)
|
||||
originX: minimumPos.x, // 地图左下角 X
|
||||
originY: minimumPos.y, // 地图左下角 Y
|
||||
maxX: maximumPos.x, // 地图右上角 X
|
||||
maxY: maximumPos.y, // 地图右上角 Y
|
||||
gridWidth: gridWidthPx, // 栅格宽度(像素)
|
||||
gridHeight: gridHeightPx, // 栅格高度(像素)
|
||||
// normalPosList: 自由空间点,直接拷贝
|
||||
normalPositions: rawJson.normalPosList || [],
|
||||
// normalLineList: 直接拷贝,每项含 {startPos:{x,y}, endPos:{x,y}}
|
||||
normalLines: rawJson.normalLineList || [],
|
||||
// advancedPointList: 直接拷贝,每项含 {className, instanceName, pos:{x,y}, dir, desc, property:[]}
|
||||
advancedPoints: rawJson.advancedPointList || [],
|
||||
// advancedLineList: 直接拷贝,每项含 {className, instanceName, line:{startPos, endPos}}
|
||||
advancedLines: rawJson.advancedLineList || [],
|
||||
// advancedCurveList: 直接拷贝,每项含 {className, instanceName, controlPos1, controlPos2, startPos, endPos}
|
||||
advancedCurves: rawJson.advancedCurveList || [],
|
||||
// advancedAreaList: 直接拷贝,每项含 {className, instanceName, dir, posGroup:[], attribute:{}}
|
||||
advancedAreas: rawJson.advancedAreaList || [],
|
||||
};
|
||||
|
||||
return parsedMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载地图到应用:构建栅格图像、设置相机、更新UI
|
||||
* @param {Object} parsedMap - parseSmapJson 的输出
|
||||
*/
|
||||
async function loadMapIntoApplication(parsedMap) {
|
||||
await new Promise(resolve => setTimeout(resolve, 30)); // 短暂等待让 UI 有机会刷新
|
||||
|
||||
// 保存地图数据到全局状态
|
||||
applicationState.parsedMap = parsedMap;
|
||||
// 构建栅格图像(带边缘高亮)
|
||||
inspectionStore.gridOffscreen = buildOccupancyGridImage(parsedMap, applicationState.layerVisibility.edges);
|
||||
|
||||
// ── 设置相机初始位置(地图中心)──
|
||||
inspectionStore.camera.centerX = (parsedMap.originX + parsedMap.maxX) / 2; // 水平居中
|
||||
inspectionStore.camera.centerY = (parsedMap.originY + parsedMap.maxY) / 2; // 垂直居中
|
||||
// 计算合适的缩放级别,使地图完整显示在画布内
|
||||
const worldWidthMeters = parsedMap.maxX - parsedMap.originX; // 地图世界宽度
|
||||
const worldHeightMeters = parsedMap.maxY - parsedMap.originY; // 地图世界高度
|
||||
const fitScaleX = inspectionStore.canvasWidthPx / worldWidthMeters; // 水平方向恰好填满的缩放
|
||||
const fitScaleY = inspectionStore.canvasHeightPx / worldHeightMeters; // 垂直方向恰好填满的缩放
|
||||
inspectionStore.camera.pixelsPerMeter = Math.max(1, Math.max(fitScaleX, fitScaleY) * 1); // 取较小值
|
||||
|
||||
// 标记加载完成并更新UI
|
||||
applicationState.isMapLoaded = true;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 主渲染函数,由 requestAnimationFrame 循环调用
|
||||
* 按层顺序绘制:栅格 → 网格线 → 区域 → 基础线 → 高级线 → 曲线 → 点 → 边界
|
||||
*
|
||||
* @param {number} timestamp - requestAnimationFrame 提供的时间戳(毫秒)
|
||||
*/
|
||||
function renderFrame(timestamp) {
|
||||
// 更新动画时间
|
||||
inspectionStore.animationTimeSeconds = (timestamp || 0) / 1000;
|
||||
|
||||
// 设置 Canvas 变换矩阵(考虑设备像素比)
|
||||
canvasCtx.setTransform(inspectionStore.devicePixelRatio, 0, 0, inspectionStore.devicePixelRatio, 0, 0);
|
||||
// canvasCtx.clearRect(0, 0, inspectionStore.canvasWidthPx, inspectionStore.canvasHeightPx); // 清空画布
|
||||
canvasCtx.clearRect(0, 0, 10000, 10000); // 清空画布
|
||||
|
||||
// 如果地图未加载,跳过绘制
|
||||
if (!applicationState.isMapLoaded) {
|
||||
animationFrameId = requestAnimationFrame(renderFrame); // 继续请求下一帧
|
||||
return;
|
||||
}
|
||||
|
||||
const map = applicationState.parsedMap; // 当前地图数据
|
||||
const layers = applicationState.layerVisibility; // 图层可见性
|
||||
|
||||
// 图层1: 占据栅格底图
|
||||
if (layers.grid && inspectionStore.gridOffscreen) {
|
||||
drawGrid(canvasCtx, map);
|
||||
}
|
||||
|
||||
// 图层2: 网格辅助线(每1米一条)
|
||||
if (layers.gridlines) {
|
||||
drawGridlines(canvasCtx)
|
||||
}
|
||||
|
||||
// 图层3: advancedAreaList(半透明多边形区域)
|
||||
if (layers.areas) {
|
||||
drawAdvancedAreaList(canvasCtx, map)
|
||||
}
|
||||
|
||||
// 图层4: normalLineList(基础直线,青色 #00e5ff)
|
||||
if (layers.normalLines) {
|
||||
drawNormalLines(canvasCtx, map);
|
||||
}
|
||||
|
||||
// 图层5: advancedLineList(高级线,如 ForbiddenLine)
|
||||
if (layers.advLines) {
|
||||
drawAdvancedLineList(canvasCtx, map);
|
||||
}
|
||||
|
||||
// 图层6: advancedCurveList(贝塞尔曲线路径,酸绿色 #76ff03)
|
||||
if (layers.curves) {
|
||||
drawAdvancedCurveList(canvasCtx, map);
|
||||
}
|
||||
|
||||
// 图层7: advancedPointList(高级点标记,菱形)
|
||||
if (layers.points) {
|
||||
drawAdvancedPointList(canvasCtx, map);
|
||||
}
|
||||
|
||||
if (layers.robots) {
|
||||
drawAllRobots(canvasCtx)
|
||||
}
|
||||
|
||||
// 地图边界虚线框
|
||||
drawMapBoundary(canvasCtx, map);
|
||||
|
||||
// 请求下一帧渲染
|
||||
animationFrameId = requestAnimationFrame(renderFrame);
|
||||
}
|
||||
|
||||
const drawAllRobots = (ctx) => {
|
||||
if (!applicationState.layerVisibility.robots) return;
|
||||
for (const robot of applicationState.robots) {
|
||||
const screenPos = worldToScreen(robot.x, robot.y);
|
||||
// 裁剪超出画布简易跳过
|
||||
if (screenPos.x + 20 < 0 || screenPos.x - 20 > inspectionStore.canvasWidthPx || screenPos.y + 20 < 0 || screenPos.y - 20 > inspectionStore.canvasHeightPx) {
|
||||
continue;
|
||||
}
|
||||
robot.draw(ctx, screenPos.x, screenPos.y, robot.color, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const bindEvent = () => {
|
||||
// ── 鼠标移动:更新坐标、拖拽平移、悬停检测 ──
|
||||
mapCanvas.addEventListener('mousemove', (mouseEvent) => {
|
||||
const canvasRect = mapCanvas.getBoundingClientRect(); // Canvas 在页面中的位置
|
||||
applicationState.mouseState.screenX = mouseEvent.clientX - canvasRect.left; // 鼠标在 Canvas 上的 X
|
||||
applicationState.mouseState.screenY = mouseEvent.clientY - canvasRect.top; // 鼠标在 Canvas 上的 Y
|
||||
// 反算世界坐标
|
||||
const worldPos = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY);
|
||||
applicationState.mouseState.worldX = worldPos.x;
|
||||
applicationState.mouseState.worldY = worldPos.y;
|
||||
|
||||
// 如果正在拖拽,平移相机
|
||||
if (applicationState.mouseState.isDragging) {
|
||||
inspectionStore.camera.centerX -= mouseEvent.movementX / inspectionStore.camera.pixelsPerMeter; // 水平平移
|
||||
inspectionStore.camera.centerY += mouseEvent.movementY / inspectionStore.camera.pixelsPerMeter; // 垂直平移(Y翻转)
|
||||
}
|
||||
|
||||
let hit = hitTestRobots(worldPos.x, worldPos.y);
|
||||
updateTooltipDisplay(hit);
|
||||
});
|
||||
|
||||
// ── 鼠标按下:开始拖拽 ──
|
||||
mapCanvas.addEventListener('mousedown', () => {
|
||||
applicationState.mouseState.isDragging = true; // 标记拖拽开始
|
||||
});
|
||||
|
||||
// ── 鼠标抬起:结束拖拽 ──
|
||||
mapCanvas.addEventListener('mouseup', () => {
|
||||
applicationState.mouseState.isDragging = false; // 标记拖拽结束
|
||||
});
|
||||
|
||||
// ── 鼠标离开画布:结束拖拽并隐藏 Tooltip ──
|
||||
mapCanvas.addEventListener('mouseleave', () => {
|
||||
applicationState.mouseState.isDragging = false;
|
||||
});
|
||||
|
||||
// ── 滚轮:缩放(向鼠标位置缩放)──
|
||||
mapCanvas.addEventListener('wheel', (wheelEvent) => {
|
||||
wheelEvent.preventDefault(); // 阻止页面滚动
|
||||
const zoomFactor = wheelEvent.deltaY > 0 ? 0.88 : 1.12; // 向下滚动=缩小,向上滚动=放大
|
||||
const mouseWorldBefore = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY); // 缩放前鼠标世界坐标
|
||||
inspectionStore.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, inspectionStore.camera.pixelsPerMeter * zoomFactor)); // 应用缩放并钳制范围
|
||||
const mouseWorldAfter = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY); // 缩放后鼠标世界坐标
|
||||
// 调整相机中心,使鼠标指向的世界坐标保持不变
|
||||
inspectionStore.camera.centerX += mouseWorldBefore.x - mouseWorldAfter.x;
|
||||
inspectionStore.camera.centerY += mouseWorldBefore.y - mouseWorldAfter.y;
|
||||
}, { passive: false }); // passive:false 允许 preventDefault
|
||||
}
|
||||
|
||||
|
||||
const updateTooltipDisplay = (hitResult) => {
|
||||
const tooltipEl = document.getElementById('tooltip');
|
||||
if (!hitResult) {
|
||||
tooltipEl.style.display = 'none';
|
||||
inspectionStore.hoveredElement = null;
|
||||
return;
|
||||
}
|
||||
inspectionStore.hoveredElement = hitResult;
|
||||
let title = '', details = '';
|
||||
if (hitResult.kind === 'robot') {
|
||||
const robot = hitResult.robot;
|
||||
title = `${robot.name}`;
|
||||
details = `<div class="tooltip-detail">位置: (${robot.x}, ${robot.y})</div>
|
||||
<div class="tooltip-detail">朝向: ${(robot.angle * 180 / Math.PI).toFixed(1)}°</div>
|
||||
<div class="tooltip-detail">ID: ${robot.id}</div>`;
|
||||
}
|
||||
tooltipEl.innerHTML = `<div class="tooltip-title" style="color:#00e5a0">${title}</div>${details}`;
|
||||
tooltipEl.style.display = 'block';
|
||||
tooltipEl.style.left = Math.min(applicationState.mouseState.screenX + 14, inspectionStore.canvasWidthPx - 220) + 'px';
|
||||
tooltipEl.style.top = Math.max(4, applicationState.mouseState.screenY - 8) + 'px';
|
||||
}
|
||||
|
||||
const hitTestRobots = (worldX, worldY) => {
|
||||
for (let i = 0; i < applicationState.robots.length; i++) {
|
||||
const robot = applicationState.robots[i];
|
||||
if (robot.hitTest(worldX, worldY)) {
|
||||
return { kind: 'robot', index: i, robot: robot };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const zoomCanvas = (zoomIn) => {
|
||||
const zoomFactor = zoomIn ? 1.12 : 0.88;
|
||||
const worldBefore = screenToWorld(inspectionStore.canvasWidthPx / 2, inspectionStore.canvasHeightPx / 2);
|
||||
inspectionStore.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, inspectionStore.camera.pixelsPerMeter * zoomFactor));
|
||||
const worldAfter = screenToWorld(inspectionStore.canvasWidthPx / 2, inspectionStore.canvasHeightPx / 2);
|
||||
inspectionStore.camera.centerX += worldBefore.x - worldAfter.x;
|
||||
inspectionStore.camera.centerY += worldBefore.y - worldAfter.y;
|
||||
}
|
||||
|
||||
const centerCanvasView = () => {
|
||||
const map = applicationState.parsedMap;
|
||||
if (!map) return;
|
||||
inspectionStore.camera.centerX = (map.originX + map.maxX) / 2;
|
||||
inspectionStore.camera.centerY = (map.originY + map.maxY) / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从机器人列表中选出地图数据来源。
|
||||
* 当前沿用原有规则:使用第一台已连接的机器人获取地图。
|
||||
*/
|
||||
const getConnectedRobot = (robotList = props.robotList) => {
|
||||
return robotList.find(item => item?.connectStatus == 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成地图来源的稳定标识。
|
||||
* 机器人轮询时位置等字段会不断变化,但机器人 ID 和地图名称不变,
|
||||
* 因此监听该标识可以避免位置更新触发底图重新加载。
|
||||
*/
|
||||
const getMapIdentity = (robotList = props.robotList) => {
|
||||
const robot = getConnectedRobot(robotList)
|
||||
return robot ? `${robot.id ?? ''}:${robot.robotMapName ?? ''}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求并加载机器人当前使用的地图。
|
||||
* @param {Object} connectedRobot 用于提供地图的已连接机器人
|
||||
*/
|
||||
const loadSourceMap = async (connectedRobot = getConnectedRobot()) => {
|
||||
if (!connectedRobot) return;
|
||||
|
||||
// 记录本次请求版本。若等待响应期间又发起了新请求,则丢弃本次旧结果。
|
||||
const currentLoadVersion = ++mapLoadVersion;
|
||||
|
||||
const res = await getMapJson({
|
||||
robotId: connectedRobot.id,
|
||||
mapName: connectedRobot.robotMapName
|
||||
});
|
||||
// 防止较慢返回的旧地图覆盖用户刚切换的新地图。
|
||||
if (currentLoadVersion !== mapLoadVersion) return;
|
||||
if (res.code === 200) {
|
||||
try {
|
||||
const rawJsonObject = JSON.parse(res.data); // 解析 JSON
|
||||
const parsedMap = parseSmapJson(rawJsonObject); // 转换为内部结构
|
||||
await loadMapIntoApplication(parsedMap); // 加载到应用
|
||||
} catch (parseError) {
|
||||
ElMessage.error('Parse error: ' + parseError.message); // 显示错误信息
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将接口返回的 "x,y,angle" 字符串转换为数值坐标。
|
||||
* 无位置或包含非法数值时返回 null,避免无效机器人进入绘制列表。
|
||||
*/
|
||||
const parseRobotPosition = (currentPosition) => {
|
||||
if (!currentPosition) return null
|
||||
const values = String(currentPosition).split(',').map(Number)
|
||||
if (values.length < 3 || values.some(value => !Number.isFinite(value))) return null
|
||||
return values
|
||||
}
|
||||
|
||||
/**
|
||||
* 将最新机器人列表同步到 Canvas 使用的机器人实例中。
|
||||
*
|
||||
* 已存在的机器人会复用原 MobileRobot 实例,仅修改位置和名称;新增机器人
|
||||
* 创建实例,接口中已移除或没有有效位置的机器人不会进入 nextRobots。
|
||||
* 该函数不会加载地图或重置相机,因此可安全用于高频轮询。
|
||||
*/
|
||||
const syncRobots = (robotList) => {
|
||||
// 用 ID 建立索引,避免为每台机器人遍历整个旧列表。
|
||||
const previousRobots = new Map(applicationState.robots.map(robot => [robot.id, robot]));
|
||||
const nextRobots = [];
|
||||
|
||||
robotList.forEach(item => {
|
||||
const position = parseRobotPosition(item.currentPosition)
|
||||
if (!position) return
|
||||
|
||||
const [x, y, angle] = position
|
||||
const robot = previousRobots.get(item.id)
|
||||
if (robot) {
|
||||
// 保留原实例和颜色,只更新轮询产生的动态信息。
|
||||
robot.name = item.robotName
|
||||
robot.x = x
|
||||
robot.y = y
|
||||
robot.angle = angle
|
||||
nextRobots.push(robot)
|
||||
return
|
||||
}
|
||||
|
||||
// 列表中首次出现的机器人需要创建绘制实例并分配颜色。
|
||||
nextRobots.push(new MobileRobot(
|
||||
item.id,
|
||||
item.robotName,
|
||||
x,
|
||||
y,
|
||||
angle,
|
||||
colorList[nextRobots.length % colorList.length]
|
||||
))
|
||||
})
|
||||
|
||||
// 整体替换绘制列表,同时自然移除本轮接口中不存在的机器人。
|
||||
applicationState.robots = nextRobots
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
window.addEventListener('resize', resizeCanvasToContainer);
|
||||
resizeCanvasToContainer();
|
||||
bindEvent()
|
||||
syncRobots(props.robotList)
|
||||
loadSourceMap()
|
||||
animationFrameId = requestAnimationFrame(renderFrame);
|
||||
})
|
||||
|
||||
const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#ff5100', '#fff', '#15ff00', '#ff00d4', '#00d4ff']
|
||||
|
||||
// 轮询只同步机器人实例,不重新请求、解析或重置地图。
|
||||
watch(() => props.robotList, syncRobots)
|
||||
|
||||
// 只有用于加载地图的机器人或地图名称改变时,才重新加载底图。
|
||||
watch(
|
||||
() => getMapIdentity(props.robotList),
|
||||
(newIdentity, oldIdentity) => {
|
||||
if (newIdentity && newIdentity !== oldIdentity) loadSourceMap()
|
||||
}
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
zoomCanvas,
|
||||
centerCanvasView
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', resizeCanvasToContainer)
|
||||
// 停止渲染循环,并使仍在进行中的地图请求全部失效。
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
mapLoadVersion += 1
|
||||
canvasCtx = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
#tooltip {
|
||||
position: absolute;
|
||||
display: none;
|
||||
background: #0b1120;
|
||||
border: 1px solid #2a3a5c;
|
||||
padding: 8px 12px;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
font-size: 10px;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, .5);
|
||||
|
||||
.tooltip-title {
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
:deep(.tooltip-detail) {
|
||||
color: #94a3b8;
|
||||
padding: 2px 0;
|
||||
font-size: 9px;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,433 +0,0 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="left-container">
|
||||
<div class="robot-container">
|
||||
<div class="robot-state">机器人状态</div>
|
||||
<div class="robot-info" v-for="item, index in robotList">
|
||||
<div class="title-box">
|
||||
<div><SvgIcon name="bot" :color="index === 0 ? '#00D4FF' : '#FFB300'" /></div>
|
||||
<div class="title">{{ item.robotName }}</div>
|
||||
<div class="state" :class="agvRuntimeStatusClass(item.status)">{{ agvRuntimeStatusLabel(item.status) }}</div>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<div class="item-box">
|
||||
<div class="item-title">编号</div>
|
||||
<div class="item-detail">{{ item.robotCode }}</div>
|
||||
</div>
|
||||
<div class="item-box">
|
||||
<div class="item-title">当前任务</div>
|
||||
<div class="item-detail">{{ getRunningTaskByRobotId(item.id) }}</div>
|
||||
</div>
|
||||
<div class="item-box">
|
||||
<div class="item-title">电量</div>
|
||||
<div class="item-detail">{{ item.batteryLevel }}%</div>
|
||||
</div>
|
||||
<div class="item-box">
|
||||
<div class="item-title">IP端口</div>
|
||||
<div class="item-detail">{{ `${item.ipAddress}: ${item.port}` }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split-line"></div>
|
||||
<div class="task-container">
|
||||
<div class="container-title">当前任务</div>
|
||||
<div class="task-box" v-for="item, index in taskList">
|
||||
<div class="title">{{ item.taskName }}</div>
|
||||
<div class="task-info">
|
||||
<div>{{ taskStatus[item.status] }}</div>
|
||||
<div>{{ item.remark }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-container">
|
||||
<div class="title-container">
|
||||
<div class="title">
|
||||
<SvgIcon name="map" color='#00D4FF' />
|
||||
<span>实时地图 — 工厂园区</span>
|
||||
</div>
|
||||
<div class="map-controller">
|
||||
<div class="btn-container">
|
||||
<SvgIcon name="zoom-in" color='#00D4FF' :size="16" @click="zoomCanvas(true)" />
|
||||
</div>
|
||||
<div class="btn-container">
|
||||
<SvgIcon name="zoom-out" color='#00D4FF' :size="16" @click="zoomCanvas(false)" />
|
||||
</div>
|
||||
<div class="btn-container">
|
||||
<SvgIcon name="locate-fixed" color='#00D4FF' :size="16" @click="centerCanvasView" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-box">
|
||||
<MapCanvas v-if="robotList.length > 0" :robotList="robotList" ref="mapCanvasRef" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="right-container">
|
||||
<!-- <div class="sensor-container">
|
||||
<div class="sensor-title">传感器数据</div>
|
||||
</div> -->
|
||||
<!-- <div class="split-line"></div> -->
|
||||
<div class="alarm-container">
|
||||
<div class="container-title">告警记录</div>
|
||||
<div class="alarm-box" v-for="item, index in 2">
|
||||
<div class="title">#01 检测到异常噪声</div>
|
||||
<div class="alarm-info">14:28:33 — 厂区A 东侧走廊</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SvgIcon from "@/components/SvgIcon";
|
||||
import MapCanvas from "./MapCanvas.vue";
|
||||
import { onMounted, onUnmounted } from "vue";
|
||||
import { getRobotList } from '@/api/resource/robot'
|
||||
import { getRunTaskList } from '@/api/inspection/runTask'
|
||||
import { agvRuntimeStatusClass, agvRuntimeStatusLabel } from '@/utils/agvRuntimeStatus'
|
||||
|
||||
const robotList = ref([])
|
||||
const getRobot = async () => {
|
||||
let robotType
|
||||
if (import.meta.env.VITE_INSPECTION_TYPE === 'inspection') {
|
||||
robotType = '2'
|
||||
} else {
|
||||
robotType = '1'
|
||||
}
|
||||
|
||||
const res = await getRobotList({
|
||||
robotType
|
||||
})
|
||||
|
||||
if (res.code === 200) {
|
||||
robotList.value = res.rows
|
||||
// getRobot()
|
||||
}
|
||||
}
|
||||
|
||||
const taskList = ref([])
|
||||
const taskStatus = {
|
||||
"0": "已完成",
|
||||
"1": "执行中",
|
||||
"2": "失败",
|
||||
"3": "已暂停",
|
||||
"4": "已终止",
|
||||
"5": "未执行"
|
||||
}
|
||||
const getRunningTask = async () => {
|
||||
let taskType
|
||||
if (import.meta.env.VITE_INSPECTION_TYPE === 'inspection') {
|
||||
taskType = '1'
|
||||
} else {
|
||||
taskType = '2'
|
||||
}
|
||||
const res = await getRunTaskList({
|
||||
taskType,
|
||||
statusList: ['1', '3']
|
||||
})
|
||||
if (res.code === 200) {
|
||||
taskList.value = res.rows
|
||||
}
|
||||
}
|
||||
|
||||
const getRunningTaskByRobotId = (robotId) => {
|
||||
const task = taskList.value.find(item => item.robotId === robotId)
|
||||
if (task) {
|
||||
return task.taskName
|
||||
}
|
||||
return '暂无'
|
||||
}
|
||||
|
||||
const mapCanvasRef = ref(null)
|
||||
|
||||
const zoomCanvas = (isZoomIn) => {
|
||||
if (mapCanvasRef.value) {
|
||||
mapCanvasRef.value.zoomCanvas(isZoomIn);
|
||||
}
|
||||
}
|
||||
|
||||
const centerCanvasView = () => {
|
||||
if (mapCanvasRef.value) {
|
||||
mapCanvasRef.value.centerCanvasView();
|
||||
}
|
||||
}
|
||||
|
||||
let robotRefreshTimer
|
||||
|
||||
onMounted(() => {
|
||||
getRobot()
|
||||
getRunningTask()
|
||||
robotRefreshTimer = window.setInterval(() => {
|
||||
if (!document.hidden) getRobot()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearInterval(robotRefreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.page-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
.left-container {
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
|
||||
.robot-container {
|
||||
max-height: 50%;
|
||||
overflow: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
.robot-state {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.robot-info {
|
||||
width: 268px;
|
||||
height: 148px;
|
||||
background: #0F1E38;
|
||||
border-radius: 10px;
|
||||
// border: 1px solid #00D4FF;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.title-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-family: Inter;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.state {
|
||||
font-size: 12px;
|
||||
border-radius: 12px;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.online {
|
||||
background: #0f83;
|
||||
color: #0f8;
|
||||
}
|
||||
|
||||
.unknown,
|
||||
.offline,
|
||||
.stopped {
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.manual,
|
||||
.running {
|
||||
background: rgba(0, 212, 255, 0.18);
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.charging,
|
||||
.paused {
|
||||
background: rgba(255, 179, 0, 0.2);
|
||||
color: #ffb300;
|
||||
}
|
||||
|
||||
.fault,
|
||||
.emergency {
|
||||
background: rgba(255, 77, 79, 0.2);
|
||||
color: #ff6b6d;
|
||||
}
|
||||
}
|
||||
|
||||
.info-box {
|
||||
.item-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 6px 0;
|
||||
|
||||
.item-title {
|
||||
color: #89B;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.item-detail {
|
||||
color: #e0e8f0;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.robot-info.last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.split-line {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
margin: 16px 0;
|
||||
background: #1E3A5F;
|
||||
}
|
||||
|
||||
.task-container {
|
||||
max-height: 50%;
|
||||
overflow-y: auto;
|
||||
|
||||
.container-title {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.task-box {
|
||||
height: 72px;
|
||||
background: #0F1E38;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-family: Inter;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 10px 0;
|
||||
font-size: 12px;
|
||||
color: #E0E8F0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
border: 1px solid #1E3A5F;
|
||||
border-top: 0;
|
||||
|
||||
.title-container {
|
||||
height: 52px;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.title {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.map-controller {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.btn-container {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: #1E3A5F;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
border-color: #00D4FF;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.map-box {
|
||||
height: calc(100% - 52px);
|
||||
}
|
||||
}
|
||||
|
||||
.right-container {
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
|
||||
.sensor-container {
|
||||
max-height: 50%;
|
||||
height: 45%;
|
||||
overflow-y: auto;
|
||||
|
||||
.sensor-title {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.split-line {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
margin: 16px 0;
|
||||
background: #1E3A5F;
|
||||
}
|
||||
|
||||
.alarm-container {
|
||||
max-height: 50%;
|
||||
overflow-y: auto;
|
||||
|
||||
.container-title {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alarm-box {
|
||||
height: 72px;
|
||||
background: #0F1E38;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.title {
|
||||
color: #FF6B6B;
|
||||
font-size: 14px;
|
||||
font-family: Inter;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.alarm-info {
|
||||
margin: 10px 0;
|
||||
font-size: 12px;
|
||||
color: #8899BB;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,209 +0,0 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="left-container">
|
||||
<div class="robot-container">
|
||||
<div class="title">选择机器人</div>
|
||||
<div class="robot-box">
|
||||
<div class="robot" :class="{ active: activeBot === 0 }" @click="changeBot(0)">
|
||||
<div><SvgIcon name="bot" color="#fff" /></div>
|
||||
<div class="bot-name">全部</div>
|
||||
</div>
|
||||
<div v-for="item, index in robotList" class="robot" :class="{ active: activeBot === index + 1 }" @click="changeBot(index + 1)">
|
||||
<div>
|
||||
<SvgIcon name="bot" :color="colorList[index]" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="bot-name">{{ item.robotName }}</div>
|
||||
<div class="bot-local" :class="agvRuntimeStatusClass(item.status)">{{ agvRuntimeStatusLabel(item.status) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="center-container">
|
||||
<div class="video-container" v-if="activeBot == 0">
|
||||
<div class="ttt" v-for="value in 4">
|
||||
<IPlayer :isLive="true" :videoUrl="videoUrl" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="single-video-container" v-else>
|
||||
<IPlayer :isLive="true" :videoUrl="videoUrl" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="activeBot != 0" class="right-container">
|
||||
<div class="container-title">OCR识别</div>
|
||||
<div class="ocr-container"></div>
|
||||
<div class="split-line"></div>
|
||||
<div class="container-title">红外热成像</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import SvgIcon from "@/components/SvgIcon";
|
||||
import IPlayer from "@/components/IPlayer/index.vue";
|
||||
import { getRobotList } from '@/api/resource/robot'
|
||||
import { agvRuntimeStatusClass, agvRuntimeStatusLabel } from '@/utils/agvRuntimeStatus'
|
||||
|
||||
const videoUrl = ref('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8')
|
||||
|
||||
const colorList = ['#00D4FF', '#FFB300', '#00FF88', '#fd00ff']
|
||||
|
||||
const robotList = ref([])
|
||||
|
||||
const activeBot = ref(0)
|
||||
const changeBot = (index) => {
|
||||
activeBot.value = index
|
||||
}
|
||||
|
||||
const fetchRobotList = async () => {
|
||||
try {
|
||||
const res = await getRobotList({
|
||||
robotType: 2,
|
||||
})
|
||||
if (res.code === 200) {
|
||||
robotList.value = res.rows
|
||||
} else {
|
||||
console.error('获取机器人列表失败:', res.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取机器人列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRobotList()
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
.left-container {
|
||||
width: 240px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
|
||||
.robot-container {
|
||||
|
||||
.title {
|
||||
color: #8899BB;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.robot-box {
|
||||
|
||||
.robot {
|
||||
background: #0F1E38;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
|
||||
.bot-name {
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bot-local {
|
||||
font-size: 11px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.online {
|
||||
color: #0f8;
|
||||
}
|
||||
|
||||
.unknown,
|
||||
.offline,
|
||||
.stopped {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.manual,
|
||||
.running {
|
||||
color: #00d4ff;
|
||||
}
|
||||
|
||||
.charging,
|
||||
.paused {
|
||||
color: #ffb300;
|
||||
}
|
||||
|
||||
.fault,
|
||||
.emergency {
|
||||
color: #ff6b6d;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
border-color: #00c2ff;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 10px rgba(0, 194, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #1e3a5f;
|
||||
border-top: 0;
|
||||
|
||||
.video-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.ttt {
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.single-video-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.right-container {
|
||||
width: 360px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
|
||||
.container-title {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ocr-container {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,292 +0,0 @@
|
||||
<template>
|
||||
<div class="elfin-viewer">
|
||||
<div ref="viewerContainer" class="viewer-container">
|
||||
<!-- Three.js 渲染 -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onBeforeUnmount, nextTick, reactive } from 'vue';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import URDFLoader from 'urdf-loader';
|
||||
|
||||
import { XacroAdapter } from '@/adapters/XacroAdapter.js'
|
||||
import { URDFAdapter } from '@/adapters/URDFAdapter.js'
|
||||
|
||||
const props = defineProps({
|
||||
modelPath: {
|
||||
type: String,
|
||||
default: '/inspection/elfin10/elfin10.urdf.xacro'
|
||||
},
|
||||
filePath: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
fileName: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const normalizePublicPath = (path) => {
|
||||
let normalized = path.trim().replace(/\\/g, '/')
|
||||
normalized = normalized.replace(/^[a-zA-Z]:.*?\/public\//, '/')
|
||||
normalized = normalized.replace(/^\.?\/?public\//, '/')
|
||||
return normalized.startsWith('/') ? normalized : `/${normalized}`
|
||||
}
|
||||
|
||||
const modelUrl = computed(() => normalizePublicPath(
|
||||
props.filePath && props.fileName
|
||||
? `${props.filePath.replace(/[\\/]?$/, '/')}${props.fileName}`
|
||||
: props.modelPath
|
||||
))
|
||||
|
||||
const modelDirectory = computed(() => modelUrl.value.slice(0, modelUrl.value.lastIndexOf('/') + 1))
|
||||
|
||||
const viewerContainer = ref(null);
|
||||
|
||||
let scene, camera, renderer, controls;
|
||||
|
||||
const animationId = ref(null);
|
||||
const jointControls = reactive([]);
|
||||
|
||||
// ============ 场景初始化 ============
|
||||
const initScene = () => {
|
||||
const container = viewerContainer.value;
|
||||
const width = container.clientWidth || 800;
|
||||
const height = container.clientHeight || 600;
|
||||
|
||||
scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color('#050a14');
|
||||
|
||||
camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 1000);
|
||||
camera.position.set(0, 0, 4);
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.1;
|
||||
controls.target.set(0, 0.5, 0);
|
||||
controls.update();
|
||||
|
||||
const ambient = new THREE.AmbientLight(0xffffff, 0.6)
|
||||
scene.add(ambient);
|
||||
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
|
||||
directionalLight.position.set(5, 10, 7);
|
||||
directionalLight.castShadow = true;
|
||||
scene.add(directionalLight);
|
||||
};
|
||||
|
||||
// ============ 关节控制(无需修改) ============
|
||||
const updateJoint = (jointName, angle) => {
|
||||
if (!robotInstance) return;
|
||||
|
||||
const joint = robotInstance.joints[jointName];
|
||||
|
||||
if (!joint) {
|
||||
console.warn(`未找到关节: ${jointName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ 使用 setJointValue 触发变换矩阵更新
|
||||
if (joint._jointType === 'revolute' || joint._jointType === 'continuous') {
|
||||
joint.setJointValue(angle);
|
||||
} else if (joint._jointType === 'prismatic') {
|
||||
joint.setJointValue(angle);
|
||||
}
|
||||
|
||||
// 同步滑块显示值
|
||||
const control = jointControls.find(c => c.name === jointName);
|
||||
if (control) control.value = angle;
|
||||
};
|
||||
|
||||
const resetJoints = () => {
|
||||
for (const joint of jointControls) {
|
||||
joint.value = 0;
|
||||
updateJoint(joint.name, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const fileMap = new Map()
|
||||
const resolveReferencedUrl = (reference, parentUrl) => {
|
||||
const normalized = reference.replace(/\\/g, '/')
|
||||
const rosPath = normalized.match(/^(?:file:\/\/)?\$\(find\s+[^)]+\)\/(.+)$/)
|
||||
if (rosPath) return `${modelDirectory.value}${rosPath[1]}`
|
||||
|
||||
const packagePath = normalized.match(/^package:\/\/[^/]+\/(.+)$/)
|
||||
if (packagePath) return `${modelDirectory.value}${packagePath[1]}`
|
||||
|
||||
if (/^(?:https?:|blob:|data:)/i.test(normalized)) return normalized
|
||||
return new URL(normalized, new URL(parentUrl, window.location.origin)).pathname
|
||||
}
|
||||
|
||||
const loadFiles = async () => {
|
||||
fileMap.clear()
|
||||
if (!modelUrl.value.toLowerCase().endsWith('.xacro')) return
|
||||
|
||||
const pendingUrls = [modelUrl.value]
|
||||
const visitedUrls = new Set()
|
||||
while (pendingUrls.length) {
|
||||
const url = pendingUrls.shift()
|
||||
if (visitedUrls.has(url)) continue
|
||||
visitedUrls.add(url)
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) throw new Error(`加载模型依赖失败:${url} (HTTP ${response.status})`)
|
||||
const blob = await response.blob()
|
||||
const fileName = url.split('/').pop()
|
||||
const file = new File([blob], fileName, { type: blob.type })
|
||||
fileMap.set(url, file)
|
||||
|
||||
if (!/\.(?:xacro|urdf|xml)$/i.test(fileName)) continue
|
||||
const content = await blob.text()
|
||||
const references = [...content.matchAll(/\b(?:filename|url)=["']([^"']+)["']/gi)]
|
||||
references.forEach(([, reference]) => {
|
||||
const referencedUrl = resolveReferencedUrl(reference, url)
|
||||
if (!visitedUrls.has(referencedUrl)) pendingUrls.push(referencedUrl)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const currentModel = ref()
|
||||
let robotInstance = null; // 保存 URDFRobot 对象
|
||||
|
||||
const parseUrdf = async (urdfContent) => {
|
||||
const loader = new URDFLoader()
|
||||
loader.parseCollision = true
|
||||
|
||||
const packageNames = [...urdfContent.matchAll(/package:\/\/([^/]+)/g)].map(match => match[1])
|
||||
loader.packages = Object.fromEntries(
|
||||
[...new Set(packageNames)].map(packageName => [packageName, `${modelDirectory.value}${packageName}`])
|
||||
)
|
||||
|
||||
const robot = loader.parse(urdfContent, modelDirectory.value)
|
||||
return URDFAdapter.convert(robot, urdfContent)
|
||||
}
|
||||
|
||||
const loadRobot = async () => {
|
||||
const response = await fetch(modelUrl.value);
|
||||
if (!response.ok) throw new Error(`加载模型失败:${modelUrl.value} (HTTP ${response.status})`);
|
||||
const content = await response.text();
|
||||
const fileName = modelUrl.value.split('/').pop()
|
||||
const lowerCaseFileName = fileName.toLowerCase()
|
||||
let model
|
||||
if (lowerCaseFileName.endsWith('.xacro')) {
|
||||
const file = fileMap.get(modelUrl.value) || new File([content], fileName, { type: 'text/xml' })
|
||||
model = await XacroAdapter.parse(content, file.name, fileMap, file);
|
||||
} else if (lowerCaseFileName.endsWith('.urdf')) {
|
||||
model = await parseUrdf(content)
|
||||
} else {
|
||||
throw new Error(`不支持的模型格式:${fileName},仅支持 .xacro 和 .urdf`)
|
||||
}
|
||||
|
||||
const robot = model.threeObject;
|
||||
|
||||
// 将机器人添加到场景
|
||||
scene.add(robot);
|
||||
robot.rotation.x = -Math.PI / 2;
|
||||
currentModel.value = model
|
||||
|
||||
robotInstance = robot;
|
||||
|
||||
// 获取关节条目数组
|
||||
let jointEntries = [];
|
||||
if (robot.joints instanceof Map) {
|
||||
jointEntries = [...robot.joints.entries()];
|
||||
} else if (typeof robot.joints === 'object' && robot.joints !== null) {
|
||||
jointEntries = Object.entries(robot.joints);
|
||||
} else if (Array.isArray(robot.joints)) {
|
||||
jointEntries = robot.joints.map((j, i) => [i, j]); // 索引作为名称
|
||||
} else {
|
||||
console.error('未知的 joints 结构:', robot.joints);
|
||||
}
|
||||
|
||||
// 填充关节控制列表
|
||||
for (const [name, joint] of jointEntries) {
|
||||
if (joint._jointType === 'fixed') continue;
|
||||
const limits = joint.limits || {};
|
||||
let lower = limits.lower !== undefined ? limits.lower : -Math.PI;
|
||||
let upper = limits.upper !== undefined ? limits.upper : Math.PI;
|
||||
// 如果是连续关节,范围设为 -PI ~ PI
|
||||
if (joint._jointType === 'continuous') {
|
||||
lower = -Math.PI;
|
||||
upper = Math.PI;
|
||||
}
|
||||
// 初始角度一般为 0
|
||||
jointControls.push({
|
||||
name,
|
||||
min: lower,
|
||||
max: upper,
|
||||
value: 0, // 因为 joint.angle 初始为 0
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ============ 动画循环 ============
|
||||
const animate = () => {
|
||||
animationId.value = requestAnimationFrame(animate);
|
||||
controls?.update();
|
||||
renderer?.render(scene, camera);
|
||||
};
|
||||
|
||||
// ============ 生命周期 ============
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
await loadFiles()
|
||||
initScene();
|
||||
await loadRobot();
|
||||
animate();
|
||||
window.addEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (animationId.value) cancelAnimationFrame(animationId.value);
|
||||
window.removeEventListener('resize', onResize);
|
||||
renderer?.dispose();
|
||||
controls?.dispose();
|
||||
});
|
||||
|
||||
const onResize = () => {
|
||||
const container = viewerContainer.value;
|
||||
if (!container || !camera || !renderer) return;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(width, height);
|
||||
};
|
||||
|
||||
defineExpose({ updateJoint, resetJoints, jointControls });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.elfin-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #050a14;
|
||||
|
||||
.viewer-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
@ -1,50 +0,0 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<Header @active-page="activePage" />
|
||||
</div>
|
||||
<div class="body-box">
|
||||
<MapOverview v-if="currentPage === 'home'" />
|
||||
<ManualTakeover v-if="currentPage === 'manualTakeover'" />
|
||||
<Monitor v-if="currentPage === 'monitoring'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Header from './components/Header.vue';
|
||||
import MapOverview from './components/MapOverview.vue';
|
||||
import ManualTakeover from './components/ManualTakeover/index.vue';
|
||||
import Monitor from './components/Monitor.vue';
|
||||
|
||||
// import autofit from 'autofit.js'
|
||||
// autofit.init()
|
||||
|
||||
|
||||
const currentPage = ref('home')
|
||||
const activePage = (page) => {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background-color: #000d2e;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.header {
|
||||
height: 64px;
|
||||
background: linear-gradient(180deg, rgba(0, 20, 70, 0.98) 0%, rgba(0, 10, 50, 0.95) 100%);
|
||||
border-bottom: 1px solid rgba(0, 194, 255, 0.3);
|
||||
}
|
||||
|
||||
.body-box {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,419 +0,0 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div ref="topContainerRef">
|
||||
<TableSearch
|
||||
:queryParams="queryParams"
|
||||
:showSearch="showSearch"
|
||||
label-width="90px"
|
||||
queryRef="queryRef"
|
||||
@refresh="resetQuery"
|
||||
@search="handleQuery"
|
||||
>
|
||||
<template #one>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="点位名称" prop="waypointName">
|
||||
<el-input
|
||||
v-model="queryParams.waypointName"
|
||||
clearable
|
||||
placeholder="请输入点位名称"
|
||||
style="width: 100%"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="所属地图" prop="mapId">
|
||||
<el-select
|
||||
v-model="queryParams.mapId"
|
||||
clearable
|
||||
placeholder="请选择所属地图"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in mapList"
|
||||
:key="item.id"
|
||||
:label="item.mapName"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="启用状态" prop="enabled">
|
||||
<el-select
|
||||
v-model="queryParams.enabled"
|
||||
clearable
|
||||
placeholder="请选择启用状态"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="启用" value="0" />
|
||||
<el-option label="禁用" value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</template>
|
||||
</TableSearch>
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
icon="Plus"
|
||||
plain
|
||||
type="primary"
|
||||
@click="handleAdd"
|
||||
>新增
|
||||
</el-button>
|
||||
</el-col>
|
||||
<right-toolbar
|
||||
v-model:showSearch="showSearch"
|
||||
@queryTable="getList"
|
||||
></right-toolbar>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div :style="containerHeight">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
height="100%"
|
||||
:data="tableDataList"
|
||||
>
|
||||
<el-table-column
|
||||
align="center"
|
||||
label="id"
|
||||
prop="id"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
align="center"
|
||||
label="点位名称"
|
||||
prop="waypointName"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
align="center"
|
||||
label="所属地图"
|
||||
prop="mapName"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
align="center"
|
||||
label="点位描述"
|
||||
prop="remark"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column align="center" label="启用状态" prop="enabled">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.enabled"
|
||||
active-text="启用"
|
||||
active-value="0"
|
||||
inactive-text="禁用"
|
||||
inactive-value="1"
|
||||
inline-prompt
|
||||
@change="handleChange(scope.row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="配置状态" prop="configStatus">
|
||||
<template #default="scope">
|
||||
<TableTags
|
||||
:options="[
|
||||
{
|
||||
label: '已配置',
|
||||
value: '0',
|
||||
type: 'success',
|
||||
},
|
||||
{
|
||||
label: '未配置',
|
||||
value: '1',
|
||||
type: 'warning',
|
||||
},
|
||||
]"
|
||||
:value="scope.row.configStatus"
|
||||
></TableTags>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
align="center"
|
||||
label="创建人"
|
||||
prop="createBy"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
align="center"
|
||||
label="创建时间"
|
||||
prop="createTime"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
align="center"
|
||||
class-name="small-padding fixed-width"
|
||||
label="操作"
|
||||
width="280"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
icon="Edit"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleUpdate(scope.row)"
|
||||
>
|
||||
修改
|
||||
</el-button>
|
||||
<el-button
|
||||
:loading="copyingId === scope.row.id"
|
||||
icon="CopyDocument"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleCopy(scope.row)"
|
||||
>复制</el-button>
|
||||
<el-button
|
||||
icon="Cpu"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleDeploy(scope.row)"
|
||||
>配置
|
||||
</el-button>
|
||||
<el-button
|
||||
icon="Delete"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleDelete(scope.row)"
|
||||
>删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
v-model:page="queryParams.pageNum"
|
||||
:total="total"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 添加或修改检测项配置对话框 -->
|
||||
<el-dialog v-model="open" :title="title" append-to-body width="500px">
|
||||
<el-form ref="pointRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="点位名称" prop="waypointName">
|
||||
<el-input v-model="form.waypointName" placeholder="请输入点位名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属地图" prop="mapId">
|
||||
<el-select
|
||||
v-model="form.mapId"
|
||||
clearable
|
||||
placeholder="请选择所属地图"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in mapList"
|
||||
:key="item.id"
|
||||
:label="item.mapName"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input
|
||||
v-model="form.remark"
|
||||
:rows="2"
|
||||
placeholder="请输入备注"
|
||||
type="textarea"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script name="Detect" setup>
|
||||
import {
|
||||
addPoint,
|
||||
copyPoint,
|
||||
deletePoint,
|
||||
getPointList,
|
||||
updatePoint,
|
||||
} from "@/api/inspection/point";
|
||||
import { listResourceMaps as getMapList } from "@/api/resource/map";
|
||||
import TableSearch from "@/components/TableSearch/index.vue";
|
||||
import { listGroup } from "@/api/system/group.js";
|
||||
import { appendParamsToPath } from "@/utils/fn.js";
|
||||
import TableTags from "@/components/TableTags/index.vue";
|
||||
import EditTable from "@/components/EditTable/index.vue";
|
||||
|
||||
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { el } from "element-plus/es/locale/index.mjs";
|
||||
import { map } from "lodash";
|
||||
|
||||
const topContainerRef = ref();
|
||||
const containerHeight = useContainerHeight(topContainerRef);
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
|
||||
const tableDataList = ref([]);
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const copyingId = ref("");
|
||||
const showSearch = ref(true);
|
||||
|
||||
const total = ref(0);
|
||||
const title = ref("");
|
||||
|
||||
const data = reactive({
|
||||
form: {},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
waypointName: null,
|
||||
enabled: null,
|
||||
mapId: null,
|
||||
},
|
||||
rules: {
|
||||
waypointName: [
|
||||
{ required: true, message: "点位名称不能为空", trigger: "blur" },
|
||||
],
|
||||
mapId: [
|
||||
{ required: true, message: "所属地图不能为空", trigger: "change" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const mapList = ref([]);
|
||||
// 获取地图列表
|
||||
const getMapArr = () => {
|
||||
getMapList({
|
||||
pageNum: 1,
|
||||
pageSize: 10000,
|
||||
}).then((response) => {
|
||||
mapList.value = response.rows;
|
||||
});
|
||||
}
|
||||
|
||||
const { queryParams, form, rules } = toRefs(data);
|
||||
|
||||
/** 查询点位列表 */
|
||||
function getList() {
|
||||
loading.value = true;
|
||||
getPointList(queryParams.value)
|
||||
.then((res) => {
|
||||
tableDataList.value = res.rows;
|
||||
total.value = res.total;
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 取消按钮
|
||||
function cancel() {
|
||||
open.value = false;
|
||||
reset();
|
||||
}
|
||||
|
||||
// 表单重置
|
||||
function reset() {
|
||||
pointRef.value.resetFields();
|
||||
form.value = {};
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleQuery() {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
function resetQuery() {
|
||||
handleQuery();
|
||||
}
|
||||
|
||||
/** 新增按钮操作 */
|
||||
function handleAdd() {
|
||||
isNew.value = true;
|
||||
open.value = true;
|
||||
title.value = "添加点位";
|
||||
}
|
||||
|
||||
/** 修改按钮操作 */
|
||||
function handleUpdate(row) {
|
||||
form.value = { ...row };
|
||||
isNew.value = false;
|
||||
open.value = true;
|
||||
title.value = "修改点位";
|
||||
}
|
||||
|
||||
const pointRef = ref();
|
||||
const isNew = ref(true);
|
||||
/** 提交按钮 */
|
||||
function submitForm() {
|
||||
pointRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
if (isNew.value) {
|
||||
addPoint(form.value).then(() => {
|
||||
ElMessage.success("添加成功");
|
||||
getList();
|
||||
open.value = false;
|
||||
reset();
|
||||
});
|
||||
} else {
|
||||
updatePoint(form.value).then(() => {
|
||||
ElMessage.success("修改成功");
|
||||
getList();
|
||||
open.value = false;
|
||||
reset();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
async function handleCopy(row) {
|
||||
copyingId.value = row.id;
|
||||
try {
|
||||
await copyPoint(row.id);
|
||||
ElMessage.success("复制成功");
|
||||
await getList();
|
||||
} finally {
|
||||
copyingId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
ElMessageBox.confirm("确定要删除该点位吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
deletePoint(row.id).then(() => {
|
||||
ElMessage.success("删除成功");
|
||||
getList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 配置按钮操作 */
|
||||
function handleDeploy(row) {
|
||||
const params = { itemId: row.detectItemId, name: row.waypointName, state: row.status };
|
||||
const fullPath = appendParamsToPath("/flow", params);
|
||||
const p = window.open(fullPath, "_blank");
|
||||
}
|
||||
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
getMapArr();
|
||||
});
|
||||
|
||||
</script>
|
||||
@ -1,824 +0,0 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="left">
|
||||
<div class="header">
|
||||
<div>任务队列</div>
|
||||
<el-button type="primary" @click="addTask">添加任务</el-button>
|
||||
</div>
|
||||
<div class="taskList-container">
|
||||
<div v-for="task in runTaskList" :key="task.id" class="task-item"
|
||||
:class="{ 'is-selected': isSelectedTask(task) }"
|
||||
:aria-current="isSelectedTask(task) ? 'true' : undefined" @click="selectTask(task)">
|
||||
<div class="task-content">
|
||||
<div class="task-title">{{ task.taskName }}</div>
|
||||
<div class="task-actions">
|
||||
<SvgIcon class="icon" v-if="task.status === '1'" name="pause" :size="24" color="#FFB300"
|
||||
@click.stop="pauseTask(task)" />
|
||||
<SvgIcon class="icon" v-if="task.status === '3' || task.status === '5'" name="start"
|
||||
:size="24" color="#00FF88" @click.stop="startTask(task)" />
|
||||
<SvgIcon class="icon" v-if="task.status != '5'" name="stop" :size="24" color="#d81e06"
|
||||
@click.stop="stopTask(task)" />
|
||||
<SvgIcon class="icon" v-if="task.status === '5'" name="remove" :size="24" color="#FFB300"
|
||||
@click.stop="removeTask(task)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="robot">主要机器人:{{ task.robotName || '执行时安排' }}</div>
|
||||
<el-progress :color="customColorMethod"
|
||||
:percentage="parseFloat(taskRunningInfo[task.id]?.progress?.toFixed(2)) || 0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right" ref="topContainerRef">
|
||||
<div class="header">
|
||||
<div class="label" :class="{ active: activePanel === 'current' }" @click="activePanel = 'current'">当前任务
|
||||
</div>
|
||||
<div class="label" :class="{ active: activePanel === 'history' }" @click="activePanel = 'history'">历史记录
|
||||
</div>
|
||||
</div>
|
||||
<div ref="contextContainerRef" class="context-container">
|
||||
<div v-if="activePanel === 'current'">
|
||||
<!-- 当前任务区域只展示左侧任务队列中选中的任务,避免同时渲染所有执行中任务。 -->
|
||||
<div v-if="selectedRunTask" :key="selectedRunTask.id" class="task"
|
||||
:data-task-id="selectedRunTask.id">
|
||||
<div class="task-header">
|
||||
<div class="header-container">
|
||||
<div class="task-info">
|
||||
<div class="title">{{ selectedRunTask.taskName }}</div>
|
||||
<div class="id">ID: {{ selectedRunTask.taskId }}</div>
|
||||
</div>
|
||||
<div class="robot">
|
||||
<div>主要机器人:{{ selectedRunTask.robotName || '按流程角色安排' }}</div>
|
||||
<div>开始时间: {{ formatDate(selectedRunTask.startTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<el-tag :type="selectedRunTask.status === '3' ? 'primary' : 'warning'">
|
||||
{{ taskStatusMap[selectedRunTask.status] || '未知状态' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-label">执行进度</div>
|
||||
<el-progress :color="customColorMethod"
|
||||
:percentage="parseFloat(taskRunningInfo[selectedRunTask.id]?.progress?.toFixed(2)) || 0" />
|
||||
<taskSteps :list="pointList_runningTask[selectedRunTask.taskId]"
|
||||
:activeStep="taskRunningInfo[selectedRunTask.id]?.activeStep || 0" />
|
||||
<div ref="taskLogContainerRef" class="run-log-container">
|
||||
<div ref="taskLogTitleRef">执行日志</div>
|
||||
<taskLog :height="taskLogHeight" :task-id="selectedRunTask.taskId"
|
||||
:task-status="selectedRunTask.status"
|
||||
:points="pointList_runningTask[selectedRunTask.taskId]"
|
||||
:list="taskLogList[selectedRunTask.id]" />
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无任务" />
|
||||
</div>
|
||||
<div v-else-if="activePanel === 'history'">
|
||||
<div :style="containerHeight">
|
||||
<el-table v-loading="loading" height="100%" :data="tableDataList">
|
||||
<el-table-column align="center" label="任务名称" prop="taskName" show-overflow-tooltip />
|
||||
<el-table-column align="center" label="任务状态" prop="status">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
|
||||
{{ taskStatusMap[scope.row.status] || '未知状态' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="任务类型" prop="taskType">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.taskType === '1' ? 'primary' : 'success'">
|
||||
{{ scope.row.taskType === '1' ? '巡检任务' : '讲解任务' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="主要机器人" prop="robotName" show-overflow-tooltip>
|
||||
<template #default="scope">{{ scope.row.robotName || '按流程角色安排' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="开始时间" prop="startTime">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.startTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="结束时间" prop="endTime">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.endTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" class-name="small-padding fixed-width" label="操作"
|
||||
width="260">
|
||||
<template #default="scope">
|
||||
<el-button icon="Edit" link type="primary" @click="handleOpenLog(scope.row)">
|
||||
日志
|
||||
</el-button>
|
||||
<el-button v-if="['0', '2'].includes(scope.row.status)" icon="RefreshRight" link
|
||||
type="primary" :loading="rerunningId === scope.row.id" @click="handleRerun(scope.row)">
|
||||
再次执行
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:limit="queryParams.pageSize"
|
||||
v-model:page="queryParams.pageNum" :total="total" @pagination="getHistoryList" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 添加任务弹窗 -->
|
||||
<el-dialog title="添加任务" v-model="open" width="500px" append-to-body>
|
||||
<el-form ref="runTaskRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="选择任务" prop="taskId">
|
||||
<el-select v-model="form.taskId" placeholder="请选择任务" clearable style="width: 100%">
|
||||
<el-option v-for="task in taskList" :key="task.id" :label="task.taskName" :value="task.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input type="textarea" v-model="form.remark" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="confirmTaskDialog">确 定</el-button>
|
||||
<el-button @click="cancelTaskDialog">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="任务执行日志" v-model="logDialogVisible" width="800px" append-to-body>
|
||||
<taskLog v-loading="logDialogLoading" :task-id="logDialogTaskId" :task-status="logDialogTaskStatus"
|
||||
:list="logDialogData" :height="500" />
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="本次执行安排" v-model="startDialogVisible" width="820px" append-to-body>
|
||||
<div class="start-dialog-content">
|
||||
<div class="start-section-title">机器人和设备</div>
|
||||
<RuntimeAssignmentsEditor
|
||||
ref="runtimeEditorRef"
|
||||
v-model="runtimeAssignments"
|
||||
:roles="runtimeRoles"
|
||||
:required-map-id="runtimeMapId"
|
||||
/>
|
||||
<div class="start-section-title start-section-title--params">运行参数</div>
|
||||
<RuntimeParamsEditor ref="runtimeParamsEditorRef" :items="runtimeParamItems" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="startDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="startSubmitting" @click="confirmStartTask">开始执行</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import SvgIcon from "@/components/SvgIcon";
|
||||
import taskSteps from "./task-steps.vue";
|
||||
import taskLog from "./task-log.vue";
|
||||
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||
import { getTaskList, getWaypointByTaskId } from "@/api/inspection/task";
|
||||
import { computed, onMounted, onUnmounted } from "vue";
|
||||
import { useElementBounding, useElementSize } from '@vueuse/core'
|
||||
import { addRunTask, getRunTaskList, startRunTask, rerunRunTask, pauseRunTask, stopRunTask, resumeRunTask, deleteRunTask, getTaskLogListApi } from "@/api/inspection/runTask";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { formatDate } from "@/utils/index";
|
||||
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 { getTaskConfig } from '@/api/test/config.js'
|
||||
import {
|
||||
createRuntimeAssignments,
|
||||
firstAssignedRobotId,
|
||||
loadFlowItemScopedRuntimeRoles,
|
||||
} from '@/utils/flowExecution'
|
||||
|
||||
const topContainerRef = ref();
|
||||
const containerHeight = useContainerHeight(topContainerRef);
|
||||
const contextContainerRef = ref(null)
|
||||
const taskLogContainerRef = ref(null)
|
||||
const taskLogTitleRef = ref(null)
|
||||
|
||||
// 通过元素边界实时获取当前任务内容区底部和日志容器顶部,避免依赖固定的 460px。
|
||||
const { bottom: contextContainerBottom } = useElementBounding(contextContainerRef)
|
||||
const { top: taskLogContainerTop } = useElementBounding(taskLogContainerRef)
|
||||
const { height: taskLogTitleHeight } = useElementSize(taskLogTitleRef)
|
||||
|
||||
const TASK_LOG_MARGIN_TOP = 12
|
||||
const CURRENT_TASK_BOTTOM_PADDING = 16
|
||||
const MIN_TASK_LOG_HEIGHT = 150
|
||||
|
||||
/**
|
||||
* 计算当前任务页面中日志组件可以使用的剩余高度。
|
||||
*
|
||||
* 计算基准是右侧内容区的底部,而不是窗口高度,因此页面外层布局、浏览器窗口
|
||||
* 尺寸或右侧面板高度发生变化时都能得到正确结果。日志容器顶部到内容区底部之间,
|
||||
* 还需要扣除“执行日志”标题高度、taskLog 组件自身的上间距,以及当前任务卡片的
|
||||
* 底部内边距,确保日志不会覆盖任务卡片边界。元素尚未挂载时使用最低高度作为初始值,
|
||||
* 待 VueUse 完成首次测量后会自动更新为实际剩余高度。
|
||||
*/
|
||||
const taskLogHeight = computed(() => {
|
||||
const remainingHeight = contextContainerBottom.value
|
||||
- taskLogContainerTop.value
|
||||
- taskLogTitleHeight.value
|
||||
- TASK_LOG_MARGIN_TOP
|
||||
- CURRENT_TASK_BOTTOM_PADDING
|
||||
|
||||
return Math.max(MIN_TASK_LOG_HEIGHT, Math.floor(remainingHeight))
|
||||
})
|
||||
|
||||
const socket = inject('ws');
|
||||
const startDialogVisible = ref(false)
|
||||
const startSubmitting = ref(false)
|
||||
const pendingStartTask = ref(null)
|
||||
const runtimeRoles = ref([])
|
||||
const runtimeAssignments = ref([])
|
||||
const runtimeEditorRef = ref()
|
||||
const runtimeMapId = ref('')
|
||||
const runtimeParamItems = ref([])
|
||||
const runtimeParamsEditorRef = ref()
|
||||
const selectedTaskId = ref('')
|
||||
const rerunningId = ref('')
|
||||
|
||||
const taskStatusMap = {
|
||||
'0': '已完成',
|
||||
'1': '执行中',
|
||||
'2': '失败',
|
||||
'3': '已暂停',
|
||||
'4': '已终止',
|
||||
'5': '未执行',
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
taskId: null,
|
||||
});
|
||||
|
||||
const rules = reactive({
|
||||
taskId: [{ required: true, message: '请选择任务', trigger: 'change' }],
|
||||
});
|
||||
|
||||
const taskList = ref([]);
|
||||
const runTaskList = ref([]);
|
||||
|
||||
// 接口和 WebSocket 可能分别以数字、字符串返回任务实例 ID,统一转换后再判断是否为选中任务。
|
||||
const isSelectedTask = (task) => String(task?.id ?? '') === String(selectedTaskId.value)
|
||||
|
||||
// 当前任务详情直接从完整任务队列中取得,因此未执行、执行中和已暂停任务都可以被选中查看。
|
||||
const selectedRunTask = computed(() => runTaskList.value.find(isSelectedTask) || null)
|
||||
|
||||
/**
|
||||
* 点击左侧任务卡片时切换右侧当前任务详情,并从历史记录面板返回当前任务面板。
|
||||
* 卡片内的启动、暂停、停止和删除图标均使用 .stop 阻止冒泡,执行操作时不会意外改变当前选择。
|
||||
*
|
||||
* @param {Object} task 左侧任务队列中被点击的任务实例
|
||||
*/
|
||||
const selectTask = (task) => {
|
||||
selectedTaskId.value = task.id
|
||||
activePanel.value = 'current'
|
||||
}
|
||||
|
||||
const normalizeTaskStatus = (tasks = []) => tasks.map(item => ({
|
||||
...item,
|
||||
status: String(item.status),
|
||||
}))
|
||||
|
||||
const getTaskData = () => {
|
||||
getTaskList({
|
||||
pageNum: 1,
|
||||
pageSize: 10000,
|
||||
}).then((res) => {
|
||||
taskList.value = res.rows;
|
||||
})
|
||||
}
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const addTask = () => {
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
const runTaskRef = ref();
|
||||
/**
|
||||
* 确定添加执行任务
|
||||
*/
|
||||
const confirmTaskDialog = () => {
|
||||
runTaskRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
addRunTask(form).then(() => {
|
||||
ElMessage.success("任务添加成功");
|
||||
getList();
|
||||
open.value = false;
|
||||
runTaskRef.value.resetFields();
|
||||
}).catch(() => {
|
||||
ElMessage.error("任务添加失败");
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const cancelTaskDialog = () => {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取任务队列并维护当前选中的任务:
|
||||
* 1. 页面首次加载时默认选中队列第一条任务;
|
||||
* 2. 队列刷新后,如果原任务仍存在,则保留用户当前选择;
|
||||
* 3. 如果原任务已经结束或被删除,则选择刷新后队列中的第一条任务;
|
||||
* 4. 如果任务队列为空,则清空选择并由模板展示空状态。
|
||||
*/
|
||||
const getList = () => {
|
||||
return getRunTaskList({
|
||||
statusList: ['1', '3', '5'],
|
||||
}).then((res) => {
|
||||
if (res.code === 200) {
|
||||
const tasks = normalizeTaskStatus(res.rows)
|
||||
runTaskList.value = tasks;
|
||||
if (!tasks.some(isSelectedTask)) {
|
||||
selectedTaskId.value = tasks[0]?.id ?? ''
|
||||
}
|
||||
for (let i = 0; i < runTaskList.value.length; i++) {
|
||||
getPointByTaskId(runTaskList.value[i].taskId)
|
||||
getTaskLogList(runTaskList.value[i].id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleRerun = async (task) => {
|
||||
rerunningId.value = task.id
|
||||
try {
|
||||
const res = await rerunRunTask(task.id)
|
||||
if (res.code === 200) {
|
||||
selectedTaskId.value = String(res.data || '')
|
||||
activePanel.value = 'current'
|
||||
await getList()
|
||||
await nextTick()
|
||||
document.querySelector(`[data-task-id="${selectedTaskId.value}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
ElMessage.success('已按上次参数创建并启动新任务')
|
||||
}
|
||||
} finally {
|
||||
rerunningId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const pointList_runningTask = ref({})
|
||||
/**
|
||||
* 获取正在执行任务的点位列表
|
||||
* @param taskId
|
||||
*/
|
||||
const getPointByTaskId = async (taskId) => {
|
||||
const res = await getWaypointByTaskId(taskId)
|
||||
if (res.code === 200) {
|
||||
pointList_runningTask.value[taskId] = res.data
|
||||
}
|
||||
}
|
||||
|
||||
const taskLogList = ref({})
|
||||
|
||||
/**
|
||||
* 获取正在执行任务的执行日志
|
||||
* @param taskInstanceId
|
||||
*/
|
||||
const getTaskLogList = async (taskInstanceId) => {
|
||||
const res = await fetchAllPagedRows(getTaskLogListApi, { taskInstanceId })
|
||||
if (res.code === 200) {
|
||||
taskLogList.value[taskInstanceId] = res.rows
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义进度条值不同区间所显示的颜色
|
||||
* @param percentage
|
||||
*/
|
||||
const customColorMethod = (percentage) => {
|
||||
if (percentage < 30) {
|
||||
return '#6f7ad3'
|
||||
}
|
||||
if (percentage < 70) {
|
||||
return '#1989fa'
|
||||
}
|
||||
return '#67c23a'
|
||||
}
|
||||
|
||||
const activePanel = ref('current');
|
||||
|
||||
const tableDataList = ref([]);
|
||||
const loading = ref(false);
|
||||
const total = ref(0);
|
||||
|
||||
const queryParams = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
/**
|
||||
* 开始执行任务
|
||||
* @param task
|
||||
*/
|
||||
const startTask = async (task) => {
|
||||
if (String(task.status) === '3') {
|
||||
let res = await resumeRunTask(task.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("任务已恢复");
|
||||
getList();
|
||||
}
|
||||
} else {
|
||||
const taskDefinition = taskList.value.find(item => item.id === task.taskId)
|
||||
if (!taskDefinition?.taskConfigId) {
|
||||
ElMessage.warning('当前任务没有关联流程编排')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const paramsResponse = await getTaskConfig({ taskId: taskDefinition.taskConfigId })
|
||||
const paramItems = paramsResponse.rows || []
|
||||
const roles = await loadFlowItemScopedRuntimeRoles(paramItems)
|
||||
runtimeAssignments.value = createRuntimeAssignments(roles, task.robotIdentity)
|
||||
runtimeMapId.value = taskDefinition.mapId || ''
|
||||
runtimeRoles.value = roles
|
||||
runtimeParamItems.value = paramItems
|
||||
pendingStartTask.value = task
|
||||
startDialogVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '工作流执行资源加载失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const confirmStartTask = async () => {
|
||||
const assignmentError = runtimeEditorRef.value?.validate()
|
||||
if (assignmentError) {
|
||||
ElMessage.warning(assignmentError)
|
||||
return
|
||||
}
|
||||
const paramsValid = await runtimeParamsEditorRef.value?.validate()
|
||||
if (paramsValid === false) {
|
||||
ElMessage.warning('请完整填写运行参数')
|
||||
return
|
||||
}
|
||||
startSubmitting.value = true
|
||||
try {
|
||||
const robotId = firstAssignedRobotId(runtimeAssignments.value)
|
||||
const res = await startRunTask(pendingStartTask.value.id, {
|
||||
robotId,
|
||||
runtimeAssignments: runtimeAssignments.value,
|
||||
runParams: runtimeParamsEditorRef.value?.getRunParams() || {}
|
||||
})
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('任务已开始')
|
||||
startDialogVisible.value = false
|
||||
getList()
|
||||
}
|
||||
} finally {
|
||||
startSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停执行任务
|
||||
* @param task
|
||||
*/
|
||||
const pauseTask = async (task) => {
|
||||
let res = await pauseRunTask(task.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("任务已暂停");
|
||||
getList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止执行任务
|
||||
* @param task
|
||||
*/
|
||||
const stopTask = async (task) => {
|
||||
let res = await stopRunTask(task.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("任务已停止");
|
||||
getList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除执行任务
|
||||
* @param task
|
||||
*/
|
||||
const removeTask = async (task) => {
|
||||
ElMessageBox.confirm('确定要删除该任务吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
let res = await deleteRunTask(task.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("任务已删除");
|
||||
getList();
|
||||
}
|
||||
}).catch(() => {
|
||||
// 取消删除
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史执行任务
|
||||
*/
|
||||
const getHistoryList = () => {
|
||||
loading.value = true;
|
||||
getRunTaskList({
|
||||
statusList: ['0', '2', '4'],
|
||||
pageNum: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
}).then((res) => {
|
||||
if (res.code === 200) {
|
||||
tableDataList.value = normalizeTaskStatus(res.rows);
|
||||
total.value = res.total;
|
||||
}
|
||||
loading.value = false;
|
||||
}).catch(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
const taskRunningInfo = ref({})
|
||||
|
||||
const socketChannelCallbacks = ref({})
|
||||
const channel = 'InspectionTaskInstance';
|
||||
|
||||
/**
|
||||
* 订阅websocket
|
||||
*/
|
||||
const subscribeDebounced = debounce(async (sub) => {
|
||||
if (!socket) {
|
||||
console.warn(`执行任务订阅失败: socket=${!!socket}`);
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send({
|
||||
type: 'channel_subscription',
|
||||
action: sub ? 'subscribe' : 'unsubscribe',
|
||||
channel,
|
||||
});
|
||||
|
||||
if (sub) {
|
||||
await nextTick();
|
||||
// 清理旧回调
|
||||
if (socketChannelCallbacks.value[channel]) {
|
||||
socket.off(channel, socketChannelCallbacks.value[channel]);
|
||||
}
|
||||
|
||||
const callback = (data) => {
|
||||
if (data !== '500') {
|
||||
// WebSocket 可能把状态作为字符串发送,先转成数字再判断,避免 '0' !== 0 导致终态被误当成运行日志。
|
||||
// 0=完成、2=失败、4=终止,这三种状态都会结束当前实例,不再追加实时日志和进度。
|
||||
const status = Number(data.status)
|
||||
const terminal = [0, 2, 4].includes(status)
|
||||
if (!terminal) {
|
||||
const steps = pointList_runningTask.value[data.taskId] || []
|
||||
let activeStep = Number.isInteger(data.itemOrder) && data.itemOrder > 0
|
||||
? data.itemOrder - 1
|
||||
: Math.max(steps.findIndex(item => item.detectItemId === data.itemId), 0)
|
||||
if (data.progress == 100) {
|
||||
activeStep += 1
|
||||
}
|
||||
taskRunningInfo.value[data.taskInstanceId] = {
|
||||
...data,
|
||||
activeStep
|
||||
}
|
||||
if (!taskLogList.value[data.taskInstanceId]) {
|
||||
taskLogList.value[data.taskInstanceId] = []
|
||||
}
|
||||
taskLogList.value[data.taskInstanceId].push({
|
||||
...data
|
||||
})
|
||||
} else {
|
||||
// 终态到达后清理内存中的实时快照,再从接口刷新当前队列与历史列表,保证页面以持久化结果为准。
|
||||
delete taskRunningInfo.value[data.taskInstanceId]
|
||||
delete taskLogList.value[data.taskInstanceId]
|
||||
getList()
|
||||
getHistoryList()
|
||||
}
|
||||
} else {
|
||||
// 错误处理:取消订阅后重新订阅
|
||||
subscribeDebounced(false);
|
||||
subscribeDebounced(true);
|
||||
}
|
||||
};
|
||||
socket.on(channel, callback);
|
||||
socketChannelCallbacks.value[channel] = callback;
|
||||
} else {
|
||||
if (socketChannelCallbacks.value[channel]) {
|
||||
socket.off(channel, socketChannelCallbacks.value[channel]);
|
||||
delete socketChannelCallbacks.value[channel];
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const logDialogVisible = ref(false)
|
||||
const logDialogData = ref([])
|
||||
// 历史日志组件需要任务定义 ID 查询该任务绑定的点位,并用任务终态推导未单独记录状态的点位结果。
|
||||
const logDialogTaskId = ref('')
|
||||
const logDialogTaskStatus = ref(null)
|
||||
const logDialogLoading = ref(false)
|
||||
let logDialogRequestId = 0
|
||||
const handleOpenLog = async (row) => {
|
||||
const requestId = ++logDialogRequestId
|
||||
logDialogVisible.value = true
|
||||
|
||||
// 先切换任务上下文,再请求日志;点位请求和日志请求可以并行,避免弹窗一直沿用上一次任务的数据。
|
||||
logDialogTaskId.value = row.taskId
|
||||
logDialogTaskStatus.value = row.status
|
||||
logDialogData.value = []
|
||||
logDialogLoading.value = true
|
||||
try {
|
||||
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()
|
||||
getHistoryList()
|
||||
}
|
||||
onMounted(() => {
|
||||
getTaskData();
|
||||
getList();
|
||||
getHistoryList();
|
||||
subscribeDebounced(true)
|
||||
socket?.on('open', refreshAfterSocketReconnect)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
subscribeDebounced(false)
|
||||
socket?.off('open', refreshAfterSocketReconnect)
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.left {
|
||||
width: 25%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.taskList-container {
|
||||
margin-top: 16px;
|
||||
height: calc(100% - 100px);
|
||||
overflow-y: auto;
|
||||
|
||||
.task-item {
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
box-shadow: inset 0 0 0 1px #eee;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background: var(--el-color-primary-light-9);
|
||||
box-shadow: inset 0 0 0 2px var(--el-color-primary);
|
||||
}
|
||||
|
||||
.task-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.task-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.robot {
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
.label {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 0 0 1px #eee;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: #409eff;
|
||||
box-shadow: inset 0 0 0 1px #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.context-container {
|
||||
height: calc(100% - 40px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.task {
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.id {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.robot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 50px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.progress-label {
|
||||
margin-top: 12px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.start-dialog-content {
|
||||
max-height: 65vh;
|
||||
padding-right: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.start-section-title {
|
||||
margin: 16px 0 10px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.start-section-title--params {
|
||||
margin-top: 22px;
|
||||
}
|
||||
</style>
|
||||
@ -1,469 +0,0 @@
|
||||
<template>
|
||||
<div class="task-log" :style="{ height: `${height}px` }">
|
||||
<!-- 折叠状态只展示点位名称和执行状态,节点日志需要点击点位后才展开。 -->
|
||||
<div class="task-log__heading">
|
||||
<span>点位</span>
|
||||
<span>状态</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="task-log__empty">点位加载中...</div>
|
||||
<div v-else-if="!groups.length" class="task-log__empty">暂无点位</div>
|
||||
<div v-else class="task-log__groups">
|
||||
<section v-for="(group, index) in groups" :key="group.key" class="task-log__group">
|
||||
<!-- 整行可点击;aria-expanded 同步展开状态,便于键盘和辅助技术识别。 -->
|
||||
<button
|
||||
type="button"
|
||||
class="task-log__group-header"
|
||||
:aria-expanded="isExpanded(group.key)"
|
||||
@click="toggleGroup(group.key)"
|
||||
>
|
||||
<el-icon class="task-log__expand-icon" :class="{ 'is-expanded': isExpanded(group.key) }">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
<span class="task-log__order">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||
<span class="task-log__name" :title="group.fullName">{{ group.name }}</span>
|
||||
<span class="task-log__statuses">
|
||||
<el-tag :type="group.status.type" size="small">{{ group.status.label }}</el-tag>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 展开后只渲染当前点位对应的节点日志,避免不同点位的日志混在一起。 -->
|
||||
<div v-if="isExpanded(group.key)" class="task-log__details">
|
||||
<div class="task-log__details-heading">
|
||||
<span>节点执行日志</span>
|
||||
<span class="task-log__details-count">{{ group.logs.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="group.nodeNames.length" class="task-log__nodes">
|
||||
节点:{{ group.nodeNames.join('、') }}
|
||||
</div>
|
||||
<TaskExecutionLog
|
||||
v-if="group.logs.length"
|
||||
:list="group.logs"
|
||||
:height="logHeight(group.logs)"
|
||||
/>
|
||||
<div v-else class="task-log__details-empty">该点位暂未产生节点日志</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ArrowRight } from '@element-plus/icons-vue'
|
||||
import { getWaypointByTaskId } from '@/api/inspection/task'
|
||||
import TaskExecutionLog from '@/views/flow/components/TaskExecutionLog.vue'
|
||||
|
||||
/**
|
||||
* taskId: 任务定义 ID。当父组件没有传入 points 时,用它查询任务所属的全部点位。
|
||||
* taskStatus: 任务实例状态,用于在任务结束、失败或终止后推导点位的最终状态。
|
||||
* list: 当前任务实例产生的日志;巡检日志接口中的 itemId 是点位的 detectItemId。
|
||||
* points: 父组件已经查询到的点位列表,传入后优先使用,避免当前任务重复请求点位接口。
|
||||
* height: 整个点位日志区域的高度,超出的点位和节点日志在区域内部滚动。
|
||||
*/
|
||||
const props = defineProps({
|
||||
taskId: { type: [String, Number], default: '' },
|
||||
taskStatus: { type: [String, Number], default: null },
|
||||
list: { type: Array, default: () => [] },
|
||||
points: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 150 },
|
||||
})
|
||||
|
||||
const taskPoints = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 默认全部折叠,页面初始只显示点位和状态,不提前占用节点日志空间。
|
||||
const expandedKeys = ref(new Set())
|
||||
|
||||
// 请求序号用于防止快速切换任务时,旧任务的点位响应覆盖新任务。
|
||||
let requestId = 0
|
||||
|
||||
/**
|
||||
* 后端 ID 可能以数字或字符串返回。统一转成字符串后再建立索引,避免 1 和 "1" 匹配失败。
|
||||
* 空值不参与点位和日志归类,后续会进入“未关联点位”兜底分组,避免日志静默丢失。
|
||||
*/
|
||||
const valueOf = (value) => value === null || value === undefined || value === '' ? '' : String(value)
|
||||
|
||||
/**
|
||||
* 点位的唯一归属键。业务约定点位 detectItemId 与点位 ID 一一对应,日志 itemId 保存的是
|
||||
* detectItemId,因此日志归类必须使用这个字段,而不是直接拿日志 itemId 去匹配点位 id。
|
||||
*/
|
||||
const pointKeyOf = (point) => valueOf(point?.detectItemId)
|
||||
|
||||
// 巡检日志接口中的 itemId 就是点位的 detectItemId,这是唯一的主归类字段。
|
||||
const logKeyOf = (log) => valueOf(log?.itemId)
|
||||
|
||||
// 点位名称兼容不同接口 DTO,正常情况下使用 waypointName。
|
||||
const nameOf = (point) => point?.waypointName || point?.name || point?.pointName || '未命名点位'
|
||||
|
||||
/**
|
||||
* 从日志中提取节点名称,用于展开区域的节点摘要。
|
||||
* 兼容节点名称位于顶层、node 对象或 actualResult/extraInfo JSON 字段的情况。
|
||||
*/
|
||||
const parseObject = (value) => {
|
||||
if (!value) return {}
|
||||
if (typeof value === 'object') return value
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueValues = (values) => [...new Set(values.map(valueOf).filter(Boolean))]
|
||||
|
||||
const nodeNameOf = (log) => {
|
||||
const actualResult = parseObject(log?.actualResult)
|
||||
const extraInfo = parseObject(log?.extraInfo)
|
||||
return log?.nodeName
|
||||
|| log?.nodeTitle
|
||||
|| log?.node?.name
|
||||
|| log?.node?.nodeName
|
||||
|| actualResult.nodeName
|
||||
|| extraInfo.nodeName
|
||||
|| log?.nodeId
|
||||
|| ''
|
||||
}
|
||||
|
||||
const normalizedStatus = (value) => String(value ?? '').trim().toLowerCase()
|
||||
|
||||
/**
|
||||
* 判断日志是否表示该点位已经执行完成。
|
||||
* 普通节点完成只能说明流程继续推进,进度达到 100 或明确的成功状态才作为完成候选;
|
||||
* 顺序执行任务中,如果后面的点位已经有日志,前面的点位也会由 statusOf 统一判定完成。
|
||||
*/
|
||||
const isPointCompletionLog = (log) => {
|
||||
const statuses = [
|
||||
log?.itemStatus,
|
||||
log?.executionStatus,
|
||||
log?.runStatus,
|
||||
log?.resultStatus,
|
||||
log?.state,
|
||||
log?.status,
|
||||
].map(normalizedStatus)
|
||||
|
||||
return statuses.some((status) => ['0', 'success', 'succeeded', 'completed', 'complete', 'finished'].includes(status))
|
||||
|| Number(log?.progress) >= 100
|
||||
|| log?.completed === true
|
||||
|| log?.finished === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据点位日志和任务实例状态计算点位状态。
|
||||
* 优先级为:失败 > 终止 > 暂停 > 已完成 > 执行中;没有日志的点位固定为未执行。
|
||||
* 对失败、终止和暂停任务,按顺序执行规则把最后一个产生日志的点位视为中断点。
|
||||
*/
|
||||
const statusOf = (logs, execution = {}) => {
|
||||
if (!logs.length) return { label: '未执行', type: 'info' }
|
||||
|
||||
const statuses = logs.flatMap((log) => [
|
||||
log?.itemStatus,
|
||||
log?.executionStatus,
|
||||
log?.runStatus,
|
||||
log?.resultStatus,
|
||||
log?.state,
|
||||
log?.status,
|
||||
].map(normalizedStatus))
|
||||
|
||||
if (statuses.some((status) => ['2', 'failed', 'failure', 'error', 'exception'].includes(status))) {
|
||||
return { label: '执行失败', type: 'danger' }
|
||||
}
|
||||
if (statuses.some((status) => ['4', 'stopped', 'terminated', 'aborted'].includes(status))) {
|
||||
return { label: '已终止', type: 'warning' }
|
||||
}
|
||||
if (statuses.some((status) => ['3', 'paused', 'pause'].includes(status))) {
|
||||
return { label: '已暂停', type: 'warning' }
|
||||
}
|
||||
|
||||
const taskStatus = execution.taskStatus === null || execution.taskStatus === undefined || execution.taskStatus === ''
|
||||
? Number.NaN
|
||||
: Number(execution.taskStatus)
|
||||
|
||||
// 任务整体完成时,所有实际产生过日志的点位都视为完成。
|
||||
if (taskStatus === 0) return { label: '已完成', type: 'success' }
|
||||
|
||||
// 失败、终止或暂停时,最后一个有日志的点位是任务中断位置。
|
||||
if (taskStatus === 2 || taskStatus === 4 || taskStatus === 3) {
|
||||
if (execution.groupIndex < execution.lastLoggedIndex) {
|
||||
return { label: '已完成', type: 'success' }
|
||||
}
|
||||
if (taskStatus === 2) return { label: '执行失败', type: 'danger' }
|
||||
if (taskStatus === 4) return { label: '已终止', type: 'warning' }
|
||||
return { label: '已暂停', type: 'warning' }
|
||||
}
|
||||
|
||||
// 后续点位已经产生日志,说明当前点位已经执行完毕。
|
||||
if (execution.groupIndex < execution.lastLoggedIndex) {
|
||||
return { label: '已完成', type: 'success' }
|
||||
}
|
||||
if (logs.some(isPointCompletionLog)) {
|
||||
return { label: '已完成', type: 'success' }
|
||||
}
|
||||
return { label: '执行中', type: 'primary' }
|
||||
}
|
||||
|
||||
// 父组件传入点位时优先使用;历史日志弹窗没有现成点位时再使用 taskId 请求。
|
||||
const taskPointsSource = computed(() => props.points.length ? props.points : taskPoints.value)
|
||||
|
||||
/**
|
||||
* 将点位和日志合并成页面分组:
|
||||
* 1. 先为每个点位创建空日志分组,保证没有执行过的点位也展示为未执行;
|
||||
* 2. 使用 point.detectItemId 建立索引;
|
||||
* 3. 使用 log.itemId 查找对应分组,确保一个点位的节点日志不会进入其他点位;
|
||||
* 4. 最后计算节点摘要和点位执行状态。
|
||||
*/
|
||||
const groups = computed(() => {
|
||||
const result = taskPointsSource.value.map((point, index) => {
|
||||
const pointKey = pointKeyOf(point)
|
||||
const name = nameOf(point)
|
||||
return {
|
||||
key: `point-${pointKey || index}`,
|
||||
name,
|
||||
fullName: name,
|
||||
pointKey,
|
||||
logs: [],
|
||||
}
|
||||
})
|
||||
|
||||
const keyToGroup = new Map()
|
||||
result.forEach((group) => {
|
||||
if (group.pointKey) keyToGroup.set(group.pointKey, group)
|
||||
})
|
||||
|
||||
const unmatchedLogs = []
|
||||
props.list.forEach((log) => {
|
||||
const group = keyToGroup.get(logKeyOf(log))
|
||||
if (group) group.logs.push(log)
|
||||
else unmatchedLogs.push(log)
|
||||
})
|
||||
|
||||
// 后端数据异常或历史日志字段不完整时保留日志,避免用户看不到已经产生的执行记录。
|
||||
// if (unmatchedLogs.length) {
|
||||
// result.push({
|
||||
// key: 'unmatched',
|
||||
// name: '未关联点位',
|
||||
// fullName: '未关联点位',
|
||||
// pointKey: '',
|
||||
// logs: unmatchedLogs,
|
||||
// })
|
||||
// }
|
||||
|
||||
const lastLoggedIndex = result.reduce((lastIndex, group, index) => (
|
||||
group.logs.length ? index : lastIndex
|
||||
), -1)
|
||||
|
||||
return result.map((group, index) => ({
|
||||
...group,
|
||||
nodeNames: uniqueValues(group.logs.map(nodeNameOf)),
|
||||
status: statusOf(group.logs, {
|
||||
groupIndex: index,
|
||||
lastLoggedIndex,
|
||||
taskStatus: props.taskStatus,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
const isExpanded = (key) => expandedKeys.value.has(key)
|
||||
|
||||
// 使用新 Set 替换旧值,确保 Vue 能可靠追踪集合变化并立即更新展开区域。
|
||||
const toggleGroup = (key) => {
|
||||
const next = new Set(expandedKeys.value)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
expandedKeys.value = next
|
||||
}
|
||||
|
||||
// 节点日志较少时避免留下大块空白,日志较多时在点位详情内部滚动。
|
||||
const logHeight = (logs) => Math.max(120, Math.min(320, 78 + logs.length * 58))
|
||||
|
||||
/**
|
||||
* 根据任务 ID 查询点位列表。父组件传入 points 时不重复请求;请求版本号保证快速切换
|
||||
* 历史任务时,旧任务响应不会覆盖当前任务的点位列表。
|
||||
*/
|
||||
const loadTaskPoints = async (taskId) => {
|
||||
const currentRequestId = ++requestId
|
||||
if (!taskId || props.points.length) {
|
||||
taskPoints.value = []
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getWaypointByTaskId(taskId)
|
||||
if (currentRequestId === requestId) {
|
||||
taskPoints.value = Array.isArray(res?.data)
|
||||
? res.data
|
||||
: Array.isArray(res?.rows)
|
||||
? res.rows
|
||||
: []
|
||||
}
|
||||
} catch {
|
||||
if (currentRequestId === requestId) taskPoints.value = []
|
||||
} finally {
|
||||
if (currentRequestId === requestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// taskId 或外部点位列表变化时重新加载数据;计算分组会自动响应日志 list 的实时更新。
|
||||
watch(
|
||||
() => [props.taskId, props.points.length],
|
||||
([taskId]) => loadTaskPoints(taskId),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 切换任务后清理旧任务的展开项,避免新任务复用旧任务的展开状态。
|
||||
watch(() => groups.value.map((group) => group.key), (keys) => {
|
||||
expandedKeys.value = new Set([...expandedKeys.value].filter((key) => keys.includes(key)))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.task-log {
|
||||
margin-top: 12px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.task-log__heading,
|
||||
.task-log__group-header {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 34px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-log__heading {
|
||||
padding: 10px 14px;
|
||||
color: #98a2b3;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.task-log__heading span:first-child {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.task-log__heading span:last-child {
|
||||
grid-column: 4;
|
||||
}
|
||||
|
||||
.task-log__groups {
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.task-log__group {
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.task-log__group:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.task-log__group-header {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 8px 6px;
|
||||
color: #344054;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.task-log__group-header:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.task-log__expand-icon {
|
||||
color: #98a2b3;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.task-log__expand-icon.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.task-log__order {
|
||||
color: #98a2b3;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.task-log__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-log__statuses {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.task-log__details {
|
||||
margin: 0 6px 10px 64px;
|
||||
padding: 10px 12px 12px;
|
||||
background: #f8fafc;
|
||||
border-left: 2px solid #d0d5dd;
|
||||
}
|
||||
|
||||
.task-log__details-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #475467;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-log__details-count,
|
||||
.task-log__nodes {
|
||||
color: #98a2b3;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.task-log__nodes {
|
||||
margin-top: 6px;
|
||||
line-height: 18px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-log__details :deep(.task-log) {
|
||||
margin-top: 8px;
|
||||
border-color: #eaecf0;
|
||||
}
|
||||
|
||||
.task-log__empty,
|
||||
.task-log__details-empty {
|
||||
padding: 28px 12px;
|
||||
color: #98a2b3;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.task-log__details-empty {
|
||||
padding: 18px 0 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.task-log__details {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.task-log__heading,
|
||||
.task-log__group-header {
|
||||
grid-template-columns: 22px 26px minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,117 +0,0 @@
|
||||
<template>
|
||||
<div v-if="list.length" class="step-current">
|
||||
<span class="step-current__count">{{ currentStepText }}</span>
|
||||
<span class="step-current__name">{{ currentStep?.waypointName || '未命名点位' }}</span>
|
||||
</div>
|
||||
<el-scrollbar ref="scrollbarRef" style="width: 100%;" class="step-scrollbar">
|
||||
<div class="step-wrapper" ref="stepWrapperRef">
|
||||
<el-steps :active="normalizedActiveStep" finish-status="success" process-status="process">
|
||||
<el-step v-for="(item, index) in list" :key="item.id || item.waypointId || item.detectItemId || index"
|
||||
:title="item.waypointName" :description="item.remark" />
|
||||
</el-steps>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
list: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
activeStep: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const scrollbarRef = ref(null)
|
||||
const stepWrapperRef = ref(null)
|
||||
|
||||
const currentStepIndex = computed(() => Math.min(Math.max(props.activeStep, 0), Math.max(props.list.length - 1, 0)))
|
||||
const currentStep = computed(() => props.list[currentStepIndex.value])
|
||||
const normalizedActiveStep = computed(() => Math.min(Math.max(props.activeStep, 0), props.list.length))
|
||||
const currentStepText = computed(() => props.activeStep >= props.list.length
|
||||
? `已完成 ${props.list.length}/${props.list.length}`
|
||||
: `当前 ${currentStepIndex.value + 1}/${props.list.length}`)
|
||||
|
||||
|
||||
/**
|
||||
* 滚动到当前激活的步骤
|
||||
*/
|
||||
const scrollToActiveStep = () => {
|
||||
nextTick(() => {
|
||||
// 获取 el-scrollbar 的可视区域宽度
|
||||
const scrollbarEl = scrollbarRef.value?.$el
|
||||
const wrapEl = scrollbarEl?.querySelector('.el-scrollbar__wrap')
|
||||
if (!wrapEl) return
|
||||
|
||||
const viewWidth = wrapEl.clientWidth
|
||||
|
||||
// 获取所有 step 元素
|
||||
const steps = stepWrapperRef.value?.querySelectorAll('.el-step')
|
||||
if (!steps || !steps[currentStepIndex.value]) return
|
||||
|
||||
// 计算激活 step 的位置
|
||||
const activeEl = steps[currentStepIndex.value]
|
||||
const stepLeft = activeEl.offsetLeft
|
||||
const stepWidth = activeEl.offsetWidth
|
||||
|
||||
// 让激活 step 居中显示
|
||||
const scrollLeft = stepLeft - viewWidth / 2 + stepWidth / 2
|
||||
scrollbarRef.value.setScrollLeft(Math.max(0, scrollLeft))
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.activeStep, props.list.length],
|
||||
scrollToActiveStep,
|
||||
{ immediate: true, flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.step-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
margin-top: 12px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.step-current__count {
|
||||
flex: none;
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-current__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.step-scrollbar {
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
|
||||
.step-wrapper {
|
||||
display: inline-block;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
white-space: nowrap;
|
||||
margin: 10px 0 20px 0;
|
||||
|
||||
:deep(.el-steps) {
|
||||
flex-wrap: nowrap;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
|
||||
.el-step {
|
||||
flex: 0 0 180px !important;
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,404 +0,0 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div ref="topContainerRef">
|
||||
<TableSearch :queryParams="queryParams" :showSearch="showSearch" label-width="90px" queryRef="queryRef"
|
||||
@refresh="resetQuery" @search="handleQuery">
|
||||
<template #one>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="任务名称" prop="taskName">
|
||||
<el-input v-model="queryParams.taskName" clearable placeholder="请输入任务名称" style="width: 100%"
|
||||
@keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="任务类型" prop="taskType">
|
||||
<el-select v-model="queryParams.taskType" clearable placeholder="请选择任务类型"
|
||||
style="width: 100%">
|
||||
<el-option label="巡检任务" value="1" />
|
||||
<el-option label="讲解任务" value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</template>
|
||||
</TableSearch>
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button icon="Plus" plain type="primary" @click="handleAdd">新增
|
||||
</el-button>
|
||||
</el-col>
|
||||
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div :style="containerHeight">
|
||||
<el-table v-loading="loading" height="100%" :data="tableDataList">
|
||||
<el-table-column align="center" label="id" prop="id" show-overflow-tooltip />
|
||||
<el-table-column align="center" label="任务名称" prop="taskName" show-overflow-tooltip />
|
||||
<el-table-column align="center" label="任务描述" prop="remark" show-overflow-tooltip />
|
||||
<el-table-column align="center" label="任务类型" prop="taskType">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.taskType === '1' ? 'primary' : 'success'">
|
||||
{{ scope.row.taskType === '1' ? '巡检任务' : '讲解任务' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="所属地图" prop="mapName" show-overflow-tooltip />
|
||||
<el-table-column align="center" label="创建人" prop="createBy" show-overflow-tooltip />
|
||||
<el-table-column align="center" label="创建时间" prop="createTime" show-overflow-tooltip />
|
||||
<el-table-column align="center" class-name="small-padding fixed-width" label="操作" width="320">
|
||||
<template #default="scope">
|
||||
<el-button icon="Edit" link type="primary" @click="handleUpdate(scope.row)">
|
||||
修改
|
||||
</el-button>
|
||||
<el-button :loading="copyingId === scope.row.id" icon="CopyDocument" link type="primary"
|
||||
@click="handleCopy(scope.row)">复制
|
||||
</el-button>
|
||||
<el-button icon="Cpu" link type="primary" @click="handleBindPoint(scope.row)">绑定点位
|
||||
</el-button>
|
||||
<el-button icon="Delete" link type="primary" @click="handleDelete(scope.row)">删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:limit="queryParams.pageSize" v-model:page="queryParams.pageNum"
|
||||
:total="total" @pagination="getList" />
|
||||
</div>
|
||||
|
||||
|
||||
<el-dialog v-model="open" :title="title" append-to-body width="500px">
|
||||
<el-form ref="taskRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="任务名称" prop="taskName">
|
||||
<el-input v-model="form.taskName" placeholder="请输入任务名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="任务类型" prop="taskType">
|
||||
<el-select v-model="form.taskType" clearable placeholder="请选择任务类型" style="width: 100%">
|
||||
<el-option label="巡检任务" value="1" />
|
||||
<el-option label="讲解任务" value="2" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属地图" prop="mapId">
|
||||
<el-select
|
||||
v-model="form.mapId"
|
||||
clearable
|
||||
placeholder="请选择所属地图"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in mapList"
|
||||
:key="item.id"
|
||||
:label="item.mapName"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" :rows="2" placeholder="请输入备注" type="textarea" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 点位绑定弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" title="点位绑定" width="800px">
|
||||
<el-row>
|
||||
<TableSearch :queryParams="pointQueryParams" :showSearch="showSearch" label-width="90px" @refresh="resetBindPointQuery" @search="handleBindPointQuery">
|
||||
<template #one>
|
||||
<el-col :lg="12" :md="12" :sm="24" :xl="12" :xs="24" :xxl="12">
|
||||
<el-form-item label="点位名称" prop="waypointName">
|
||||
<el-input v-model="pointQueryParams.waypointName" clearable placeholder="请输入点位名称"
|
||||
style="width: 100%" @keyup.enter="handleBindPointQuery" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</template>
|
||||
</TableSearch>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<div style="height: 55vh">
|
||||
<div class="task-title">待选择点位</div>
|
||||
|
||||
<draggable :animation="340" :forceFallback="true" :list="taskLeftList" class="draggable-group"
|
||||
ghostClass="dragClass" :group="{ name: 'people', pull: 'clone', put: false}"
|
||||
:clone="cloneBindingItem" itemKey="_bindingKey">
|
||||
<template #item="{ element, index }">
|
||||
<div class="list-group-item">
|
||||
<el-icon>
|
||||
<Tools />
|
||||
</el-icon>
|
||||
{{ element.waypointName }}
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div style="height: 55vh">
|
||||
<div class="task-title">点位排序</div>
|
||||
<draggable :animation="340" :forceFallback="true" :list="taskRightList" class="draggable-group"
|
||||
ghostClass="dragClass" :group="{ name: 'people', pull: false, put: true }" itemKey="_bindingKey">
|
||||
<template #item="{ element, index }">
|
||||
<div class="list-group-item">
|
||||
{{ index + 1 }} 、{{ element.waypointName }}
|
||||
<el-icon color="red" @click="removeFromB(index)" style="margin-left: 10px;"><Delete /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="custom-block">
|
||||
<div class="mb5">1、将待选择点位拖拽至点位排序中</div>
|
||||
<div>2、点位排序中可拖拽自定义其顺序</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="bindPoint">确 定</el-button>
|
||||
<el-button @click="cancelPoint">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script name="Detect" setup>
|
||||
import { addTask, copyTask, updateTask, getTaskList, deleteTask, getWaypointByTaskId, bindWaypointsToTask } from "../../../api/inspection/task";
|
||||
import { getPointList } from "../../../api/inspection/point";
|
||||
import TableSearch from "@/components/TableSearch/index.vue";
|
||||
|
||||
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import draggable from "vuedraggable";
|
||||
import { reactive, ref } from "vue";
|
||||
import { listResourceMaps as getMapList } from "@/api/resource/map";
|
||||
|
||||
const topContainerRef = ref();
|
||||
const containerHeight = useContainerHeight(topContainerRef);
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
|
||||
const tableDataList = ref([]);
|
||||
const open = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const copyingId = ref("");
|
||||
const showSearch = ref(true);
|
||||
|
||||
const total = ref(0);
|
||||
const title = ref("");
|
||||
|
||||
const data = reactive({
|
||||
form: {},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
taskName: null,
|
||||
taskType: null,
|
||||
},
|
||||
rules: {
|
||||
taskName: [
|
||||
{ required: true, message: "任务名称不能为空", trigger: "blur" },
|
||||
],
|
||||
taskType: [
|
||||
{ required: true, message: "请选择任务类型", trigger: "blur" },
|
||||
],
|
||||
mapId: [
|
||||
{ required: true, message: "请选择所属地图", trigger: "blur" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { queryParams, form, rules } = toRefs(data);
|
||||
|
||||
/** 查询任务列表 */
|
||||
function getList() {
|
||||
loading.value = false;
|
||||
getTaskList(queryParams.value).then((res) => {
|
||||
tableDataList.value = res.rows;
|
||||
total.value = res.total;
|
||||
loading.value = false;
|
||||
}).finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 取消按钮
|
||||
function cancel() {
|
||||
open.value = false;
|
||||
reset();
|
||||
}
|
||||
|
||||
// 表单重置
|
||||
function reset() {
|
||||
taskRef.value.resetFields();
|
||||
form.value = {};
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleQuery() {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
function resetQuery() {
|
||||
handleQuery();
|
||||
}
|
||||
|
||||
/** 新增按钮操作 */
|
||||
function handleAdd() {
|
||||
isNew.value = true;
|
||||
open.value = true;
|
||||
title.value = "添加任务";
|
||||
}
|
||||
|
||||
const mapList = ref([]);
|
||||
// 获取地图列表
|
||||
const getMapArr = () => {
|
||||
getMapList({
|
||||
pageNum: 1,
|
||||
pageSize: 10000,
|
||||
}).then((response) => {
|
||||
mapList.value = response.rows;
|
||||
});
|
||||
}
|
||||
|
||||
/** 修改按钮操作 */
|
||||
function handleUpdate(row) {
|
||||
form.value = { ...row };
|
||||
isNew.value = false;
|
||||
open.value = true;
|
||||
title.value = "修改任务";
|
||||
}
|
||||
|
||||
const taskRef = ref();
|
||||
const isNew = ref(true);
|
||||
/** 提交按钮 */
|
||||
function submitForm() {
|
||||
taskRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
if (isNew.value) {
|
||||
addTask(form.value).then(() => {
|
||||
ElMessage.success("添加成功");
|
||||
cancel();
|
||||
getList();
|
||||
});
|
||||
} else {
|
||||
updateTask(form.value).then(() => {
|
||||
ElMessage.success("修改成功");
|
||||
cancel();
|
||||
getList();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
async function handleCopy(row) {
|
||||
copyingId.value = row.id;
|
||||
try {
|
||||
await copyTask(row.id);
|
||||
ElMessage.success("复制成功");
|
||||
await getList();
|
||||
} finally {
|
||||
copyingId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
ElMessageBox.confirm("确定要删除该任务吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
deleteTask(row.id).then(() => {
|
||||
ElMessage.success("删除成功");
|
||||
getList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const pointQueryParams = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: 1000000,
|
||||
waypointName: null,
|
||||
mapId: null
|
||||
})
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleBindPointQuery() {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
function resetBindPointQuery() {
|
||||
handleBindPointQuery();
|
||||
}
|
||||
|
||||
const taskId = ref(null);
|
||||
let bindingSequence = 0
|
||||
const createBindingKey = () => `waypoint-${Date.now()}-${bindingSequence++}`
|
||||
const cloneBindingItem = (item) => ({ ...item, _bindingKey: createBindingKey() })
|
||||
const handleBindPoint = async (row) => {
|
||||
dialogVisible.value = true;
|
||||
taskId.value = row.id;
|
||||
pointQueryParams.mapId = row.mapId;
|
||||
taskLeftList.value = [];
|
||||
taskRightList.value = [];
|
||||
await getBindPointByTaskId(row.id)
|
||||
await getPointListData()
|
||||
};
|
||||
|
||||
/** 查询点位列表 */
|
||||
const getPointListData = async () => {
|
||||
const res = await getPointList(pointQueryParams)
|
||||
if (res.code === 200) {
|
||||
taskLeftList.value = res.rows.map(item => ({ ...item, _bindingKey: createBindingKey() }))
|
||||
}
|
||||
}
|
||||
|
||||
const getBindPointByTaskId = async (taskId) => {
|
||||
const res = await getWaypointByTaskId(taskId)
|
||||
if (res.code === 200) {
|
||||
taskRightList.value = res.data.map(item => ({ ...item, _bindingKey: createBindingKey() }));
|
||||
}
|
||||
}
|
||||
|
||||
const taskLeftList = ref([])
|
||||
const taskRightList = ref([])
|
||||
|
||||
const bindPoint = async () => {
|
||||
if (taskRightList.value.length === 0) {
|
||||
ElMessage.warning("请选择点位")
|
||||
return
|
||||
}
|
||||
const ids = taskRightList.value.map(item => item.id)
|
||||
const res = await bindWaypointsToTask(taskId.value, ids)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("绑定点位成功")
|
||||
cancelPoint()
|
||||
}
|
||||
};
|
||||
|
||||
const cancelPoint = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
const removeFromB = (index) => {
|
||||
taskRightList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
getMapArr();
|
||||
});
|
||||
|
||||
</script>
|
||||
@ -1,304 +0,0 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div ref="topContainerRef">
|
||||
<TableSearch
|
||||
:queryParams="queryParams"
|
||||
:showSearch="showSearch"
|
||||
label-width="90px"
|
||||
queryRef="queryRef"
|
||||
@refresh="resetQuery"
|
||||
@search="handleQuery"
|
||||
>
|
||||
<template #one>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="车辆名称" prop="vehicleName">
|
||||
<el-input
|
||||
v-model="queryParams.vehicleName"
|
||||
placeholder="请输入车辆名称"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="车型" prop="vehicleModel">
|
||||
<el-input
|
||||
v-model="queryParams.vehicleModel"
|
||||
placeholder="请输入车型"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</template>
|
||||
</TableSearch>
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
icon="Plus"
|
||||
@click="handleAdd"
|
||||
>新增</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="success"
|
||||
plain
|
||||
icon="Edit"
|
||||
:disabled="single"
|
||||
@click="handleUpdate"
|
||||
>修改</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
icon="Delete"
|
||||
:disabled="multiple"
|
||||
@click="handleDelete"
|
||||
>删除</el-button>
|
||||
</el-col>
|
||||
|
||||
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div :style="containerHeight">
|
||||
<!-- 表格数据 -->
|
||||
<el-table height="100%" v-loading="loading" :data="tableDataList" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column label="车辆名称" prop="vehicleName" />
|
||||
<el-table-column label="车型" prop="vehicleModel" />
|
||||
<el-table-column label="车架号" prop="vin" />
|
||||
<el-table-column label="颜色" prop="color"/>
|
||||
<el-table-column label="状态" align="center" width="100">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.status"
|
||||
active-value="0"
|
||||
inactive-value="1"
|
||||
@change="handleStatusChange(scope.row)"
|
||||
></el-switch>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" width="180"/>
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
||||
<template #default="scope">
|
||||
<el-tooltip content="修改" placement="top">
|
||||
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNum"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 添加或修改配置对话框 -->
|
||||
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
|
||||
<el-form ref="carRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="车辆名称" prop="vehicleName">
|
||||
<el-input v-model="form.vehicleName" placeholder="请输入车辆名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="车型" prop="vehicleModel">
|
||||
<el-input v-model="form.vehicleModel" placeholder="请输入车型" />
|
||||
</el-form-item>
|
||||
<el-form-item label="车架号" prop="vin">
|
||||
<el-input v-model="form.vin" placeholder="请输入车架号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="颜色" prop="vin">
|
||||
<el-input v-model="form.color" placeholder="请输入颜色" />
|
||||
</el-form-item>
|
||||
<el-form-item label="生产日期" prop="productionDate">
|
||||
<el-date-picker
|
||||
v-model="form.productionDate"
|
||||
type="date"
|
||||
style="width: 100%;"
|
||||
placeholder="Pick a day"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="Role">
|
||||
import TableSearch from "@/components/TableSearch/index.vue";
|
||||
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||
import { addVehicle, getVehicleList, updateVehicle, deleteVehicle } from "@/api/intelligenceTest/car.js"
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const topContainerRef = ref();
|
||||
const containerHeight = useContainerHeight(topContainerRef);
|
||||
|
||||
const tableDataList = ref([]);
|
||||
const open = ref(false);
|
||||
const loading = ref(true);
|
||||
const showSearch = ref(true);
|
||||
const ids = ref([]);
|
||||
const single = ref(true);
|
||||
const multiple = ref(true);
|
||||
const total = ref(0);
|
||||
const title = ref("");
|
||||
|
||||
|
||||
const data = reactive({
|
||||
form: {},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
vehicleName: undefined,
|
||||
vehicleModel: undefined
|
||||
},
|
||||
rules: {
|
||||
vehicleName: [{ required: true, message: "车辆名称不能为空", trigger: "blur" }],
|
||||
vehicleModel: [{ required: true, message: "车型不能为空", trigger: "blur" }],
|
||||
vin: [{ required: true, message: "车架号不能为空", trigger: "blur" }],
|
||||
color: [{ required: true, message: "颜色不能为空", trigger: "blur" }],
|
||||
productionDate: [{ required: true, message: "生产日期不能为空", trigger: "blur" }]
|
||||
},
|
||||
});
|
||||
|
||||
const { queryParams, form, rules } = toRefs(data);
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
const res = await getVehicleList(queryParams.value)
|
||||
if (res.code === 200) {
|
||||
tableDataList.value = res.rows;
|
||||
total.value = res.total;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleQuery() {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
function resetQuery() {
|
||||
handleQuery();
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
function handleDelete(row) {
|
||||
const deleteIds = row.id || ids.value;
|
||||
ElMessageBox.confirm(`确定要删除编号为${deleteIds}的车辆吗?`, "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
deleteVehicle(deleteIds).then(() => {
|
||||
ElMessage.success("删除成功");
|
||||
getList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/** 多选框选中数据 */
|
||||
function handleSelectionChange(selection) {
|
||||
ids.value = selection.map(item => item.id);
|
||||
single.value = selection.length != 1;
|
||||
multiple.value = !selection.length;
|
||||
}
|
||||
|
||||
/** 状态修改 */
|
||||
function handleStatusChange(row) {
|
||||
let text = row.status === "0" ? "启用" : "停用";
|
||||
ElMessageBox.confirm('确认要"' + text + '""' + row.vehicleName + '"车辆吗?', "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
updateVehicle({
|
||||
...row,
|
||||
}).then(() => {
|
||||
ElMessage.success(text + "成功");
|
||||
getList();
|
||||
});
|
||||
}).catch(() => {
|
||||
row.status = row.status === "0" ? "1" : "0";
|
||||
})
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
carRef.value.resetFields()
|
||||
};
|
||||
|
||||
const carRef = ref()
|
||||
const isNew = ref(true)
|
||||
|
||||
/** 添加 */
|
||||
function handleAdd() {
|
||||
open.value = true;
|
||||
isNew.value = true
|
||||
title.value = "添加";
|
||||
}
|
||||
|
||||
/** 修改 */
|
||||
function handleUpdate(row) {
|
||||
form.value = { ...row };
|
||||
open.value = true;
|
||||
isNew.value = false
|
||||
title.value = "修改";
|
||||
}
|
||||
|
||||
/** 提交按钮 */
|
||||
function submitForm() {
|
||||
carRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
if (isNew.value) {
|
||||
addVehicle(form.value).then(() => {
|
||||
ElMessage.success("添加成功");
|
||||
getList();
|
||||
open.value = false;
|
||||
reset();
|
||||
});
|
||||
} else {
|
||||
updateVehicle(form.value).then(() => {
|
||||
ElMessage.success("修改成功");
|
||||
getList();
|
||||
open.value = false;
|
||||
reset();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消按钮 */
|
||||
function cancel() {
|
||||
open.value = false;
|
||||
reset();
|
||||
}
|
||||
|
||||
getList();
|
||||
</script>
|
||||
@ -1,321 +0,0 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div ref="topContainerRef">
|
||||
<TableSearch
|
||||
:queryParams="queryParams"
|
||||
:showSearch="showSearch"
|
||||
label-width="90px"
|
||||
queryRef="queryRef"
|
||||
@refresh="resetQuery"
|
||||
@search="handleQuery"
|
||||
>
|
||||
<template #one>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="手机名称" prop="phoneName">
|
||||
<el-input
|
||||
v-model="queryParams.phoneName"
|
||||
placeholder="请输入手机名称"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="手机型号" prop="phoneModel">
|
||||
<el-input
|
||||
v-model="queryParams.phoneModel"
|
||||
placeholder="请输入手机型号"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="厂商" prop="manufacturer">
|
||||
<el-input
|
||||
v-model="queryParams.manufacturer"
|
||||
placeholder="请输入厂商"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</template>
|
||||
</TableSearch>
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
icon="Plus"
|
||||
@click="handleAdd"
|
||||
>新增</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="success"
|
||||
plain
|
||||
icon="Edit"
|
||||
:disabled="single"
|
||||
@click="handleUpdate"
|
||||
>修改</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
icon="Delete"
|
||||
:disabled="multiple"
|
||||
@click="handleDelete"
|
||||
>删除</el-button>
|
||||
</el-col>
|
||||
|
||||
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div :style="containerHeight">
|
||||
<!-- 表格数据 -->
|
||||
<el-table height="100%" v-loading="loading" :data="tableDataList" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column label="手机名称" prop="phoneName" :show-overflow-tooltip="true" width="180"/>
|
||||
<el-table-column label="手机型号" prop="phoneModel" />
|
||||
<el-table-column label="系统版本" prop="osVersion" />
|
||||
<el-table-column label="操作系统" prop="osSystem" width="100" />
|
||||
<el-table-column label="厂商" align="center" prop="manufacturer" />
|
||||
<el-table-column label="额外参数" align="center" prop="specifications" :show-overflow-tooltip="true" width="180"/>
|
||||
<el-table-column label="状态" align="center" width="100">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.status"
|
||||
active-value="0"
|
||||
inactive-value="1"
|
||||
@change="handleStatusChange(scope.row)"
|
||||
></el-switch>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" width="180"/>
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
||||
<template #default="scope">
|
||||
<el-tooltip content="修改" placement="top">
|
||||
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNum"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 添加或修改配置对话框 -->
|
||||
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
|
||||
<el-form ref="phoneRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="手机名称" prop="phoneName">
|
||||
<el-input v-model="form.phoneName" placeholder="请输入手机名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机型号" prop="phoneModel">
|
||||
<el-input v-model="form.phoneModel" placeholder="请输入手机型号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="操作系统" prop="osSystem">
|
||||
<el-select v-model="form.osSystem">
|
||||
<el-option label="Android" value="Android" />
|
||||
<el-option label="iOS" value="iOS" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="系统版本" prop="osVersion">
|
||||
<el-input v-model="form.osVersion" placeholder="请输入系统版本" />
|
||||
</el-form-item>
|
||||
<el-form-item label="厂商" prop="manufacturer">
|
||||
<el-input v-model="form.manufacturer" />
|
||||
</el-form-item>
|
||||
<el-form-item label="规格参数" prop="specifications">
|
||||
<el-input v-model="form.specifications" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="Role">
|
||||
import TableSearch from "@/components/TableSearch/index.vue";
|
||||
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||
import { addPhone, getPhoneList, updatePhone, deletePhone } from "@/api/intelligenceTest/mobilePhone.js"
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const topContainerRef = ref();
|
||||
const containerHeight = useContainerHeight(topContainerRef);
|
||||
|
||||
const tableDataList = ref([]);
|
||||
const open = ref(false);
|
||||
const loading = ref(true);
|
||||
const showSearch = ref(true);
|
||||
const ids = ref([]);
|
||||
const single = ref(true);
|
||||
const multiple = ref(true);
|
||||
const total = ref(0);
|
||||
const title = ref("");
|
||||
|
||||
|
||||
const data = reactive({
|
||||
form: {},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
phoneName: undefined,
|
||||
phoneModel: undefined,
|
||||
manufacturer: undefined
|
||||
},
|
||||
rules: {
|
||||
phoneName: [{ required: true, message: "手机名称不能为空", trigger: "blur" }],
|
||||
phoneModel: [{ required: true, message: "手机型号不能为空", trigger: "blur" }],
|
||||
manufacturer: [{ required: true, message: "厂商不能为空", trigger: "blur" }],
|
||||
osSystem: [{ required: true, message: "操作系统不能为空", trigger: "blur" }],
|
||||
osVersion: [{ required: true, message: "系统版本不能为空", trigger: "blur" }]
|
||||
},
|
||||
});
|
||||
|
||||
const { queryParams, form, rules } = toRefs(data);
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
const res = await getPhoneList(queryParams.value)
|
||||
if (res.code === 200) {
|
||||
tableDataList.value = res.rows;
|
||||
total.value = res.total;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleQuery() {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
function resetQuery() {
|
||||
handleQuery();
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
function handleDelete(row) {
|
||||
const deleteIds = row.id || ids.value;
|
||||
ElMessageBox.confirm(`确定要删除编号为${deleteIds}的手机吗?`, "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
deletePhone(deleteIds).then(() => {
|
||||
ElMessage.success("删除成功");
|
||||
getList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/** 多选框选中数据 */
|
||||
function handleSelectionChange(selection) {
|
||||
ids.value = selection.map(item => item.id);
|
||||
single.value = selection.length != 1;
|
||||
multiple.value = !selection.length;
|
||||
}
|
||||
|
||||
/** 状态修改 */
|
||||
function handleStatusChange(row) {
|
||||
let text = row.status === "0" ? "启用" : "停用";
|
||||
ElMessageBox.confirm('确认要"' + text + '""' + row.phoneName + '"手机吗?', "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
updatePhone({
|
||||
...row,
|
||||
}).then(() => {
|
||||
ElMessage.success(text + "成功");
|
||||
getList();
|
||||
});
|
||||
}).catch(() => {
|
||||
row.status = row.status === "0" ? "1" : "0";
|
||||
})
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
phoneRef.value.resetFields()
|
||||
};
|
||||
|
||||
const phoneRef = ref()
|
||||
const isNew = ref(true)
|
||||
|
||||
/** 添加 */
|
||||
function handleAdd() {
|
||||
open.value = true;
|
||||
isNew.value = true
|
||||
title.value = "添加";
|
||||
}
|
||||
|
||||
/** 修改 */
|
||||
function handleUpdate(row) {
|
||||
form.value = { ...row };
|
||||
open.value = true;
|
||||
isNew.value = false
|
||||
title.value = "修改";
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 提交按钮 */
|
||||
function submitForm() {
|
||||
phoneRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
if (isNew.value) {
|
||||
addPhone(form.value).then(() => {
|
||||
ElMessage.success("添加成功");
|
||||
getList();
|
||||
open.value = false;
|
||||
reset();
|
||||
});
|
||||
} else {
|
||||
updatePhone(form.value).then(() => {
|
||||
ElMessage.success("修改成功");
|
||||
getList();
|
||||
open.value = false;
|
||||
reset();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消按钮 */
|
||||
function cancel() {
|
||||
open.value = false;
|
||||
reset();
|
||||
}
|
||||
|
||||
getList();
|
||||
</script>
|
||||
@ -1,885 +0,0 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="left">
|
||||
<div class="header">
|
||||
<div>任务队列</div>
|
||||
<el-button type="primary" @click="addTask">添加任务</el-button>
|
||||
</div>
|
||||
<div class="taskList-container">
|
||||
<div v-for="task in runTaskList" :key="task.id" class="task-item"
|
||||
:class="{ 'is-selected': isSelectedTask(task) }"
|
||||
:aria-current="isSelectedTask(task) ? 'true' : undefined" @click="selectTask(task)">
|
||||
<div class="task-content">
|
||||
<div class="task-title">{{ task.taskName }}</div>
|
||||
<div class="task-actions">
|
||||
<SvgIcon class="icon" v-if="task.status == '1'" name="pause" :size="24" color="#FFB300"
|
||||
@click.stop="pauseTask(task)" />
|
||||
<SvgIcon class="icon" v-if="task.status == '3' || task.status == '5'" name="start"
|
||||
:size="24" color="#00FF88" @click.stop="startTask(task)" />
|
||||
<SvgIcon class="icon" v-if="task.status != '5'" name="stop" :size="24" color="#d81e06"
|
||||
@click.stop="stopTask(task)" />
|
||||
<SvgIcon class="icon" v-if="task.status == '5'" name="remove" :size="24" color="#FFB300"
|
||||
@click.stop="removeTask(task)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="robot">手机:{{ task.phoneName }}</div>
|
||||
<div class="robot">车辆:{{ task.vehicleName }}</div>
|
||||
<el-progress :color="customColorMethod"
|
||||
:percentage="parseFloat(taskRunningInfo[task.id]?.progress?.toFixed(2)) || 0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right" ref="topContainerRef">
|
||||
<div class="header">
|
||||
<div class="label" :class="{ active: activePanel === 'current' }" @click="activePanel = 'current'">当前任务
|
||||
</div>
|
||||
<div class="label" :class="{ active: activePanel === 'history' }" @click="activePanel = 'history'">历史记录
|
||||
</div>
|
||||
</div>
|
||||
<div ref="contextContainerRef" class="context-container">
|
||||
<div v-if="activePanel === 'current'">
|
||||
<!-- 当前任务区域只展示左侧队列选中的任务,不再同时渲染全部执行中任务。 -->
|
||||
<div v-if="selectedRunTask" :key="selectedRunTask.id" class="task"
|
||||
:data-task-id="selectedRunTask.id">
|
||||
<div class="task-header">
|
||||
<div class="header-container">
|
||||
<div class="task-info">
|
||||
<div class="title">{{ selectedRunTask.taskName }}</div>
|
||||
<div class="id">ID: {{ selectedRunTask.taskId }}</div>
|
||||
</div>
|
||||
<div class="robot">
|
||||
<div>手机:{{ selectedRunTask.phoneName }}</div>
|
||||
<div>车辆:{{ selectedRunTask.vehicleName }}</div>
|
||||
<div>开始时间: {{ formatDate(selectedRunTask.startTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<el-tag :type="selectedRunTask.status === '3' ? 'primary' : 'warning'">
|
||||
{{ taskStatusMap[selectedRunTask.status] || '未知状态' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-label">执行进度</div>
|
||||
<el-progress :color="customColorMethod"
|
||||
:percentage="parseFloat(taskRunningInfo[selectedRunTask.id]?.progress?.toFixed(2)) || 0" />
|
||||
<taskSteps :list="pointList_runningTask[selectedRunTask.taskId]"
|
||||
:activeStep="taskRunningInfo[selectedRunTask.id]?.activeStep || 0" />
|
||||
<div ref="taskLogContainerRef" class="run-log-container">
|
||||
<div ref="taskLogTitleRef">执行日志</div>
|
||||
<!--
|
||||
taskId 用于查询检测项,taskStatus 用于在完成/失败/终止后推导最后一个已执行检测项的状态;
|
||||
两者与实例日志分开传入,避免把任务级字段重复写进每一条日志。
|
||||
-->
|
||||
<taskLog :height="taskLogHeight" :task-id="selectedRunTask.taskId"
|
||||
:task-status="selectedRunTask.status" :list="taskLogList[selectedRunTask.id]" />
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无任务" />
|
||||
</div>
|
||||
<div v-else-if="activePanel === 'history'">
|
||||
<div :style="containerHeight">
|
||||
<el-table v-loading="loading" height="100%" :data="tableDataList">
|
||||
<el-table-column align="center" label="任务名称" prop="taskName" show-overflow-tooltip />
|
||||
<el-table-column align="center" label="任务状态" prop="status">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
|
||||
{{ taskStatusMap[scope.row.status] || '未知状态' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="任务类型" prop="taskType">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.taskType === '1' ? 'primary' : 'success'">
|
||||
{{ scope.row.taskType === '1' ? '巡检任务' : '讲解任务' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="机器人" prop="robotName" show-overflow-tooltip />
|
||||
<el-table-column align="center" label="开始时间" prop="startTime">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.startTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="结束时间" prop="endTime">
|
||||
<template #default="scope">
|
||||
{{ formatDate(scope.row.endTime) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" class-name="small-padding fixed-width" label="操作"
|
||||
width="260">
|
||||
<template #default="scope">
|
||||
<el-button icon="Edit" link type="primary" @click="handleOpenLog(scope.row)">
|
||||
日志
|
||||
</el-button>
|
||||
<el-button v-if="['0', '2'].includes(scope.row.status)" icon="RefreshRight" link
|
||||
type="primary" :loading="rerunningId === scope.row.id" @click="handleRerun(scope.row)">
|
||||
再次执行
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:limit="queryParams.pageSize"
|
||||
v-model:page="queryParams.pageNum" :total="total" @pagination="getHistoryList" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 添加任务弹窗 -->
|
||||
<el-dialog title="添加任务" v-model="open" width="500px" append-to-body>
|
||||
<el-form ref="runTaskRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="选择任务" prop="taskId">
|
||||
<el-select v-model="form.taskId" placeholder="请选择任务" clearable style="width: 100%">
|
||||
<el-option v-for="task in taskList" :key="task.id" :label="task.taskName" :value="task.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择手机" prop="phoneId">
|
||||
<el-select v-model="form.phoneId" placeholder="请选择手机" clearable style="width: 100%">
|
||||
<el-option v-for="phone in phoneList" :key="phone.id" :label="phone.phoneName"
|
||||
:value="phone.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择车辆" prop="vehicleId">
|
||||
<el-select v-model="form.vehicleId" placeholder="请选择车辆" clearable style="width: 100%">
|
||||
<el-option v-for="vehicle in carList" :key="vehicle.id" :label="vehicle.vehicleName"
|
||||
:value="vehicle.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input type="textarea" v-model="form.remark" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="confirmTaskDialog">确 定</el-button>
|
||||
<el-button @click="cancelTaskDialog">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="任务执行日志" v-model="logDialogVisible" width="800px" append-to-body>
|
||||
<!--
|
||||
历史弹窗没有 WebSocket 实时上下文,因此同时传入任务定义 ID 和历史终态;
|
||||
日志组件据此补齐未执行项,并正确区分最后一项是完成、失败还是终止。
|
||||
-->
|
||||
<taskLog v-loading="logDialogLoading" :task-id="logDialogTaskId" :task-status="logDialogTaskStatus"
|
||||
:list="logDialogData" :height="500" />
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="本次执行安排" v-model="startDialogVisible" width="820px" append-to-body>
|
||||
<div class="start-dialog-content">
|
||||
<div class="start-dialog-tip">手机和车辆沿用任务配置,这里只安排执行流程的机器人和设备。</div>
|
||||
<div class="start-section-title">机器人和设备</div>
|
||||
<RuntimeAssignmentsEditor ref="runtimeEditorRef" v-model="runtimeAssignments" :roles="runtimeRoles"/>
|
||||
<div class="start-section-title start-section-title--params">运行参数</div>
|
||||
<RuntimeParamsEditor ref="runtimeParamsEditorRef" :items="runtimeParamItems" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="startDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="startSubmitting" @click="confirmStartTask">开始执行</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
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";
|
||||
import { getVehicleList } from "@/api/intelligenceTest/car";
|
||||
import { computed, onMounted, onUnmounted } from "vue";
|
||||
import { useElementBounding, useElementSize } from '@vueuse/core'
|
||||
import {
|
||||
addTaskinstance,
|
||||
getTaskinstanceList,
|
||||
startTaskinstance,
|
||||
rerunTaskinstance,
|
||||
pauseTaskinstance,
|
||||
stopTaskinstance,
|
||||
resumeTaskinstance,
|
||||
deleteTaskinstance,
|
||||
getTaskLogListApi
|
||||
} from "@/api/intelligenceTest/runningTask";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { formatDate } from "@/utils/index";
|
||||
import { debounce } from 'lodash'; // 引入 lodash 的防抖函数
|
||||
import RuntimeAssignmentsEditor from '@/views/flow/components/RuntimeAssignmentsEditor.vue'
|
||||
import RuntimeParamsEditor from '@/views/flow/components/RuntimeParamsEditor.vue'
|
||||
import { getTaskConfig } from '@/api/test/config.js'
|
||||
import {
|
||||
createRuntimeAssignments,
|
||||
firstAssignedRobotId,
|
||||
loadFlowItemScopedRuntimeRoles,
|
||||
} from '@/utils/flowExecution'
|
||||
|
||||
const topContainerRef = ref();
|
||||
const containerHeight = useContainerHeight(topContainerRef);
|
||||
const contextContainerRef = ref(null)
|
||||
const taskLogContainerRef = ref(null)
|
||||
const taskLogTitleRef = ref(null)
|
||||
|
||||
// 通过元素边界实时获取当前任务内容区底部和日志容器顶部,避免依赖固定的 460px。
|
||||
const { bottom: contextContainerBottom } = useElementBounding(contextContainerRef)
|
||||
const { top: taskLogContainerTop } = useElementBounding(taskLogContainerRef)
|
||||
const { height: taskLogTitleHeight } = useElementSize(taskLogTitleRef)
|
||||
|
||||
const TASK_LOG_MARGIN_TOP = 12
|
||||
const CURRENT_TASK_BOTTOM_PADDING = 16
|
||||
const MIN_TASK_LOG_HEIGHT = 150
|
||||
|
||||
/**
|
||||
* 计算当前任务页面中日志组件可以使用的剩余高度。
|
||||
*
|
||||
* 计算基准是右侧内容区的底部,而不是窗口高度,因此页面外层布局、浏览器窗口
|
||||
* 尺寸或右侧面板高度发生变化时都能得到正确结果。日志容器顶部到内容区底部之间,
|
||||
* 还需要扣除“执行日志”标题高度、taskLog 组件自身的上间距,以及当前任务卡片的
|
||||
* 底部内边距,确保日志不会覆盖任务卡片边界。元素尚未挂载时使用最低高度作为初始值,
|
||||
* 待 VueUse 完成首次测量后会自动更新为实际剩余高度。
|
||||
*/
|
||||
const taskLogHeight = computed(() => {
|
||||
const remainingHeight = contextContainerBottom.value
|
||||
- taskLogContainerTop.value
|
||||
- taskLogTitleHeight.value
|
||||
- TASK_LOG_MARGIN_TOP
|
||||
- CURRENT_TASK_BOTTOM_PADDING
|
||||
|
||||
return Math.max(MIN_TASK_LOG_HEIGHT, Math.floor(remainingHeight))
|
||||
})
|
||||
|
||||
const socket = inject('ws');
|
||||
const startDialogVisible = ref(false)
|
||||
const startSubmitting = ref(false)
|
||||
const pendingStartTask = ref(null)
|
||||
const runtimeRoles = ref([])
|
||||
const runtimeAssignments = ref([])
|
||||
const runtimeEditorRef = ref()
|
||||
const runtimeParamItems = ref([])
|
||||
const runtimeParamsEditorRef = ref()
|
||||
const selectedTaskId = ref('')
|
||||
const rerunningId = ref('')
|
||||
|
||||
const taskStatusMap = {
|
||||
'0': '已完成',
|
||||
'1': '执行中',
|
||||
'2': '失败',
|
||||
'3': '已暂停',
|
||||
'4': '已终止',
|
||||
'5': '未执行',
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
taskId: null,
|
||||
});
|
||||
|
||||
const rules = reactive({
|
||||
taskId: [{ required: true, message: '请选择任务', trigger: 'change' }],
|
||||
phoneId: [{ required: true, message: '请选择手机', trigger: 'change' }],
|
||||
vehicleId: [{ required: true, message: '请选择车辆', trigger: 'change' }],
|
||||
});
|
||||
|
||||
const taskList = ref([]);
|
||||
const phoneList = ref([]);
|
||||
|
||||
const runTaskList = ref([]);
|
||||
|
||||
// 任务实例 ID 在接口和 WebSocket 中可能分别是数字或字符串,统一按字符串比较选中状态。
|
||||
const isSelectedTask = (task) => String(task?.id ?? '') === String(selectedTaskId.value)
|
||||
|
||||
// 右侧当前任务始终从完整任务队列中取选中的一条,包括尚未执行、执行中和已暂停任务。
|
||||
const selectedRunTask = computed(() => runTaskList.value.find(isSelectedTask) || null)
|
||||
|
||||
/**
|
||||
* 点击左侧任务卡片时切换右侧详情,并自动回到“当前任务”面板。
|
||||
* 卡片内部操作按钮使用 .stop 阻止冒泡,因此执行暂停/启动等命令时不会意外切换选择。
|
||||
*/
|
||||
const selectTask = (task) => {
|
||||
selectedTaskId.value = task.id
|
||||
activePanel.value = 'current'
|
||||
}
|
||||
|
||||
const normalizeTaskStatus = (tasks = []) => tasks.map(item => ({
|
||||
...item,
|
||||
status: String(item.status),
|
||||
}))
|
||||
|
||||
const getTaskData = () => {
|
||||
getTaskList({
|
||||
pageNum: 1,
|
||||
pageSize: 10000,
|
||||
}).then((res) => {
|
||||
taskList.value = res.rows;
|
||||
})
|
||||
}
|
||||
|
||||
const getPhoneData = (mapId = null) => {
|
||||
getPhoneList({
|
||||
pageNum: 1,
|
||||
pageSize: 10000
|
||||
}).then((res) => {
|
||||
phoneList.value = res.rows;
|
||||
})
|
||||
}
|
||||
|
||||
const carList = ref([])
|
||||
|
||||
const getCarData = (mapId = null) => {
|
||||
getVehicleList({
|
||||
pageNum: 1,
|
||||
pageSize: 10000
|
||||
}).then((res) => {
|
||||
carList.value = res.rows;
|
||||
})
|
||||
}
|
||||
|
||||
const open = ref(false);
|
||||
|
||||
const addTask = () => {
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
const runTaskRef = ref();
|
||||
/**
|
||||
* 确定添加执行任务
|
||||
*/
|
||||
const confirmTaskDialog = () => {
|
||||
runTaskRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
addTaskinstance(form).then(() => {
|
||||
ElMessage.success("任务添加成功");
|
||||
getList();
|
||||
open.value = false;
|
||||
runTaskRef.value.resetFields();
|
||||
}).catch(() => {
|
||||
ElMessage.error("任务添加失败");
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const cancelTaskDialog = () => {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取任务队列,并维护当前选中任务:
|
||||
* 1. 首次加载默认选择队列第一条;
|
||||
* 2. 刷新后原任务仍在队列中则保持选择;
|
||||
* 3. 原任务已结束或被删除时回退到新的第一条,队列为空时清空选择。
|
||||
*/
|
||||
const getList = () => {
|
||||
return getTaskinstanceList({
|
||||
statusList: ['1', '3', '5'],
|
||||
}).then((res) => {
|
||||
if (res.code === 200) {
|
||||
const tasks = normalizeTaskStatus(res.rows)
|
||||
runTaskList.value = tasks;
|
||||
if (!tasks.some(isSelectedTask)) {
|
||||
selectedTaskId.value = tasks[0]?.id ?? ''
|
||||
}
|
||||
for (let i = 0; i < runTaskList.value.length; i++) {
|
||||
getCaseByTaskId(runTaskList.value[i].taskId)
|
||||
getTaskLogList(runTaskList.value[i].id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleRerun = async (task) => {
|
||||
rerunningId.value = task.id
|
||||
try {
|
||||
const res = await rerunTaskinstance(task.id)
|
||||
if (res.code === 200) {
|
||||
selectedTaskId.value = String(res.data || '')
|
||||
activePanel.value = 'current'
|
||||
await getList()
|
||||
await nextTick()
|
||||
document.querySelector(`[data-task-id="${selectedTaskId.value}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
ElMessage.success('已按上次参数创建并启动新任务')
|
||||
}
|
||||
} finally {
|
||||
rerunningId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const pointList_runningTask = ref({})
|
||||
/**
|
||||
* 获取正在执行任务的用例列表
|
||||
* @param taskId
|
||||
*/
|
||||
const getCaseByTaskId = async (taskId) => {
|
||||
const res = await getTestCaseByTaskId(taskId)
|
||||
if (res.code === 200) {
|
||||
pointList_runningTask.value[taskId] = res.data
|
||||
}
|
||||
}
|
||||
|
||||
const taskLogList = ref({})
|
||||
|
||||
/**
|
||||
* 获取正在执行任务的执行日志
|
||||
* @param taskInstanceId
|
||||
*/
|
||||
const getTaskLogList = async (taskInstanceId) => {
|
||||
const res = await fetchAllPagedRows(getTaskLogListApi, { taskInstanceId })
|
||||
if (res.code === 200) {
|
||||
taskLogList.value[taskInstanceId] = res.rows
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义进度条值不同区间所显示的颜色
|
||||
* @param percentage
|
||||
*/
|
||||
const customColorMethod = (percentage) => {
|
||||
if (percentage < 30) {
|
||||
return '#6f7ad3'
|
||||
}
|
||||
if (percentage < 70) {
|
||||
return '#1989fa'
|
||||
}
|
||||
return '#67c23a'
|
||||
}
|
||||
|
||||
const activePanel = ref('current');
|
||||
|
||||
const tableDataList = ref([]);
|
||||
const loading = ref(false);
|
||||
const total = ref(0);
|
||||
|
||||
const queryParams = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
/**
|
||||
* 开始执行任务
|
||||
* @param task
|
||||
*/
|
||||
const startTask = async (task) => {
|
||||
if (String(task.status) === '3') {
|
||||
let res = await resumeTaskinstance(task.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("任务已恢复");
|
||||
getList();
|
||||
}
|
||||
} else {
|
||||
const taskDefinition = taskList.value.find(item => item.id === task.taskId)
|
||||
if (!taskDefinition?.taskConfigId) {
|
||||
ElMessage.warning('当前任务没有关联流程编排')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const paramsResponse = await getTaskConfig({ taskId: taskDefinition.taskConfigId })
|
||||
const paramItems = paramsResponse.rows || []
|
||||
const roles = await loadFlowItemScopedRuntimeRoles(paramItems)
|
||||
runtimeAssignments.value = createRuntimeAssignments(roles)
|
||||
runtimeRoles.value = roles
|
||||
runtimeParamItems.value = paramItems
|
||||
pendingStartTask.value = task
|
||||
startDialogVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '工作流执行资源加载失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const confirmStartTask = async () => {
|
||||
const assignmentError = runtimeEditorRef.value?.validate()
|
||||
if (assignmentError) {
|
||||
ElMessage.warning(assignmentError)
|
||||
return
|
||||
}
|
||||
const paramsValid = await runtimeParamsEditorRef.value?.validate()
|
||||
if (paramsValid === false) {
|
||||
ElMessage.warning('请完整填写运行参数')
|
||||
return
|
||||
}
|
||||
startSubmitting.value = true
|
||||
try {
|
||||
const robotId = firstAssignedRobotId(runtimeAssignments.value)
|
||||
const res = await startTaskinstance(pendingStartTask.value.id, {
|
||||
robotId,
|
||||
runtimeAssignments: runtimeAssignments.value,
|
||||
runParams: runtimeParamsEditorRef.value?.getRunParams() || {}
|
||||
})
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('任务已开始')
|
||||
startDialogVisible.value = false
|
||||
getList()
|
||||
}
|
||||
} finally {
|
||||
startSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停执行任务
|
||||
* @param task
|
||||
*/
|
||||
const pauseTask = async (task) => {
|
||||
let res = await pauseTaskinstance(task.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("任务已暂停");
|
||||
getList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止执行任务
|
||||
* @param task
|
||||
*/
|
||||
const stopTask = async (task) => {
|
||||
let res = await stopTaskinstance(task.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("任务已停止");
|
||||
getList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除执行任务
|
||||
* @param task
|
||||
*/
|
||||
const removeTask = async (task) => {
|
||||
ElMessageBox.confirm('确定要删除该任务吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(async () => {
|
||||
let res = await deleteTaskinstance(task.id);
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("任务已删除");
|
||||
getList();
|
||||
}
|
||||
}).catch(() => {
|
||||
// 取消删除
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取历史执行任务
|
||||
*/
|
||||
const getHistoryList = () => {
|
||||
loading.value = true;
|
||||
getTaskinstanceList({
|
||||
statusList: ['0', '2', '4'],
|
||||
pageNum: queryParams.pageNum,
|
||||
pageSize: queryParams.pageSize,
|
||||
}).then((res) => {
|
||||
if (res.code === 200) {
|
||||
tableDataList.value = normalizeTaskStatus(res.rows);
|
||||
total.value = res.total;
|
||||
}
|
||||
loading.value = false;
|
||||
}).catch(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
const taskRunningInfo = ref({})
|
||||
|
||||
const socketChannelCallbacks = ref({})
|
||||
const channel = 'AimaTaskInstance';
|
||||
|
||||
/**
|
||||
* 订阅websocket
|
||||
*/
|
||||
const subscribeDebounced = debounce(async (sub) => {
|
||||
if (!socket) {
|
||||
console.warn(`执行任务订阅失败: socket=${!!socket}`);
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send({
|
||||
type: 'channel_subscription',
|
||||
action: sub ? 'subscribe' : 'unsubscribe',
|
||||
channel,
|
||||
});
|
||||
|
||||
if (sub) {
|
||||
await nextTick();
|
||||
// 清理旧回调
|
||||
if (socketChannelCallbacks.value[channel]) {
|
||||
socket.off(channel, socketChannelCallbacks.value[channel]);
|
||||
}
|
||||
|
||||
const callback = (data) => {
|
||||
if (data !== '500') {
|
||||
// 统一处理数字和字符串状态。0=完成、2=失败、4=终止,均属于任务实例终态。
|
||||
// 终态消息只触发列表刷新,不作为普通节点日志追加,否则会产生一个无法归属检测项的伪日志。
|
||||
const status = Number(data.status)
|
||||
const terminal = [0, 2, 4].includes(status)
|
||||
if (!terminal) {
|
||||
const steps = pointList_runningTask.value[data.taskId] || []
|
||||
let activeStep = Number.isInteger(data.itemOrder) && data.itemOrder > 0
|
||||
? data.itemOrder - 1
|
||||
: Math.max(steps.findIndex(item => item.detectItemId === data.itemId), 0)
|
||||
if (data.progress == 100) {
|
||||
activeStep += 1
|
||||
}
|
||||
taskRunningInfo.value[data.taskInstanceId] = {
|
||||
...data,
|
||||
activeStep
|
||||
}
|
||||
if (!taskLogList.value[data.taskInstanceId]) {
|
||||
taskLogList.value[data.taskInstanceId] = []
|
||||
}
|
||||
taskLogList.value[data.taskInstanceId].push({
|
||||
...data
|
||||
})
|
||||
} else {
|
||||
// 清除实时缓存后重新查询任务队列和历史记录,让最终状态与后端持久化结果保持一致。
|
||||
delete taskRunningInfo.value[data.taskInstanceId]
|
||||
delete taskLogList.value[data.taskInstanceId]
|
||||
getList()
|
||||
getHistoryList()
|
||||
}
|
||||
} else {
|
||||
// 错误处理:取消订阅后重新订阅
|
||||
subscribeDebounced(false);
|
||||
subscribeDebounced(true);
|
||||
}
|
||||
};
|
||||
socket.on(channel, callback);
|
||||
socketChannelCallbacks.value[channel] = callback;
|
||||
} else {
|
||||
if (socketChannelCallbacks.value[channel]) {
|
||||
socket.off(channel, socketChannelCallbacks.value[channel]);
|
||||
delete socketChannelCallbacks.value[channel];
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const logDialogVisible = ref(false)
|
||||
const logDialogData = ref([])
|
||||
|
||||
// 历史表格行里的 taskId 是任务定义 ID;日志组件使用它查询本次任务包含的全部检测项。
|
||||
const logDialogTaskId = ref('')
|
||||
|
||||
// 保存当前历史实例的终态,供日志组件判断最后一个产生过日志的检测项是完成、失败还是被终止。
|
||||
const logDialogTaskStatus = ref(null)
|
||||
const logDialogLoading = ref(false)
|
||||
let logDialogRequestId = 0
|
||||
const handleOpenLog = async (row) => {
|
||||
const requestId = ++logDialogRequestId
|
||||
logDialogVisible.value = true
|
||||
|
||||
// 在请求实例日志前先更新 taskId,让检测项请求与日志请求并行执行,减少弹窗等待时间。
|
||||
logDialogTaskId.value = row.taskId
|
||||
|
||||
// 状态与 taskId 同步切换,防止连续打开不同历史任务时短暂沿用上一个实例的状态。
|
||||
logDialogTaskStatus.value = row.status
|
||||
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()
|
||||
getHistoryList()
|
||||
}
|
||||
onMounted(() => {
|
||||
getTaskData();
|
||||
getPhoneData();
|
||||
getCarData()
|
||||
getList();
|
||||
getHistoryList();
|
||||
subscribeDebounced(true)
|
||||
socket?.on('open', refreshAfterSocketReconnect)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
subscribeDebounced(false)
|
||||
socket?.off('open', refreshAfterSocketReconnect)
|
||||
})
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.page-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.left {
|
||||
width: 25%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.taskList-container {
|
||||
margin-top: 16px;
|
||||
height: calc(100% - 100px);
|
||||
overflow-y: auto;
|
||||
|
||||
.task-item {
|
||||
padding: 12px;
|
||||
background: #fafafa;
|
||||
box-shadow: inset 0 0 0 1px #eee;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background: var(--el-color-primary-light-9);
|
||||
box-shadow: inset 0 0 0 2px var(--el-color-primary);
|
||||
}
|
||||
|
||||
.task-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.task-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.robot {
|
||||
margin-top: 8px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
.label {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 0 0 1px #eee;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: #409eff;
|
||||
box-shadow: inset 0 0 0 1px #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.context-container {
|
||||
height: calc(100% - 40px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.task {
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.id {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.robot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 50px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.progress-label {
|
||||
margin-top: 12px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.start-dialog-tip {
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.start-dialog-content {
|
||||
max-height: 65vh;
|
||||
padding-right: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.start-section-title {
|
||||
margin: 16px 0 10px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.start-section-title--params {
|
||||
margin-top: 22px;
|
||||
}
|
||||
</style>
|
||||
@ -1,641 +0,0 @@
|
||||
<template>
|
||||
<div class="task-log" :style="{ height: `${height}px` }">
|
||||
<!-- 折叠状态下只保留检测项名称和状态两列,节点日志不会提前占用页面空间。 -->
|
||||
<div class="task-log__heading">
|
||||
<span>检测项</span>
|
||||
<span>状态</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="task-log__empty">检测项加载中...</div>
|
||||
<div v-else-if="!groups.length" class="task-log__empty">暂无检测项</div>
|
||||
<div v-else class="task-log__groups">
|
||||
<section v-for="(group, index) in groups" :key="group.key" class="task-log__group">
|
||||
<!-- 整行都可以点击;aria-expanded 同步当前展开状态,便于键盘和辅助技术识别。 -->
|
||||
<button
|
||||
type="button"
|
||||
class="task-log__group-header"
|
||||
:aria-expanded="isExpanded(group.key)"
|
||||
@click="toggleGroup(group.key)"
|
||||
>
|
||||
<el-icon class="task-log__expand-icon" :class="{ 'is-expanded': isExpanded(group.key) }">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
<span class="task-log__order">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||
<!-- 同一个检测项被任务重复编排时保留原名称,并用“第 N 次”明确区分每个执行分组。 -->
|
||||
<span class="task-log__name" :title="group.fullName">
|
||||
{{ group.name }}
|
||||
<span v-if="group.repeatLabel" class="task-log__repeat">{{ group.repeatLabel }}</span>
|
||||
</span>
|
||||
<!-- 执行状态和分析结论是两个维度:前者表示流程是否执行完成,后者表示检测结果是否通过。 -->
|
||||
<span class="task-log__statuses">
|
||||
<el-tag v-if="group.analysisStatus" :type="group.analysisStatus.type" effect="plain" size="small">
|
||||
{{ group.analysisStatus.label }}
|
||||
</el-tag>
|
||||
<el-tag :type="group.status.type" size="small">{{ group.status.label }}</el-tag>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- 展开后只把当前检测项已经归类的节点日志交给通用日志组件渲染。 -->
|
||||
<div v-if="isExpanded(group.key)" class="task-log__details">
|
||||
<div class="task-log__details-heading">
|
||||
<span>执行过程</span>
|
||||
</div>
|
||||
<TaskExecutionLog
|
||||
v-if="group.logs.length"
|
||||
:list="group.logs"
|
||||
:height="logHeight(group.logs)"
|
||||
mode="user"
|
||||
/>
|
||||
<div v-else class="task-log__details-empty">该检测项暂未产生节点日志</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ArrowRight } from '@element-plus/icons-vue'
|
||||
import { getTestCaseByTaskId } from '@/api/intelligenceTest/taskManage'
|
||||
import TaskExecutionLog from '@/views/flow/components/TaskExecutionLog.vue'
|
||||
|
||||
/**
|
||||
* taskId: 任务定义 ID,用于查询该任务绑定的全部检测项。
|
||||
* taskStatus: 当前任务实例状态,用于历史任务结束后判断最后一个有日志的检测项最终状态。
|
||||
* list: 当前任务实例产生的日志,检测项加载完成后会按 itemId/itemOrder 分组。
|
||||
* items: 可选的外部检测项数据;传入后优先使用,可避免父组件已经查询时重复请求。
|
||||
* height: 整个检测项日志区域的固定高度,内部超出部分滚动显示。
|
||||
*/
|
||||
const props = defineProps({
|
||||
taskId: { type: [String, Number], default: '' },
|
||||
taskStatus: { type: [String, Number], default: null },
|
||||
list: { type: Array, default: () => [] },
|
||||
items: { type: Array, default: () => [] },
|
||||
height: { type: Number, default: 150 },
|
||||
})
|
||||
|
||||
// taskItems 保存根据 taskId 查询到的检测项;items prop 存在时不会使用这里的数据。
|
||||
const taskItems = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 默认 Set 为空,因此所有检测项初始都是折叠状态,符合“默认只展示检测项和状态”的要求。
|
||||
const expandedKeys = ref(new Set())
|
||||
|
||||
// 每次检测项请求递增版本号,只允许最后一次请求更新页面,避免快速切换任务时旧结果覆盖新任务。
|
||||
let requestId = 0
|
||||
|
||||
/**
|
||||
* 接口中的 ID 可能是数字或字符串。统一转为字符串后再比较,避免 1 与 "1" 无法匹配。
|
||||
* null、undefined 和空字符串都视为无效 ID,不参与日志分组。
|
||||
*/
|
||||
const valueOf = (value) => value === null || value === undefined || value === '' ? '' : String(value)
|
||||
|
||||
/**
|
||||
* actualResult、extraInfo 在不同日志来源中可能是对象,也可能是 JSON 字符串。
|
||||
* 解析失败时返回空对象,让单条异常日志不会中断整个检测项列表的渲染。
|
||||
*/
|
||||
const parseObject = (value) => {
|
||||
if (!value) return {}
|
||||
if (typeof value === 'object') return value
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// 标准化、过滤空值并去重,防止同一个 ID 的多个字段别名重复建立索引。
|
||||
const uniqueValues = (values) => [...new Set(values.map(valueOf).filter(Boolean))]
|
||||
|
||||
/**
|
||||
* 生成检测项所有可能的关联键。
|
||||
* 当前业务主要使用 detectItemId,但保留 itemId/testCaseId/caseId/id,兼容历史数据和不同接口 DTO。
|
||||
*/
|
||||
const itemKeysOf = (item) => uniqueValues([
|
||||
item?.detectItemId,
|
||||
item?.itemId,
|
||||
item?.testCaseId,
|
||||
item?.caseId,
|
||||
item?.id,
|
||||
])
|
||||
|
||||
/**
|
||||
* 生成日志所有可能的检测项关联键。
|
||||
* 除日志顶层字段外,也读取 actualResult 和 extraInfo,兼容节点执行器把上下文放进扩展字段的情况。
|
||||
*/
|
||||
const logKeysOf = (log) => {
|
||||
const actualResult = parseObject(log?.actualResult)
|
||||
const extraInfo = parseObject(log?.extraInfo)
|
||||
|
||||
// 新版执行器把重复检测项的归属信息写入 actualResult._executionContext,旧版顶层字段仍继续兼容。
|
||||
const executionContext = parseObject(actualResult?._executionContext)
|
||||
return uniqueValues([
|
||||
log?.itemId,
|
||||
log?.detectItemId,
|
||||
log?.testItemId,
|
||||
log?.testCaseId,
|
||||
log?.caseId,
|
||||
log?.item?.id,
|
||||
log?.item?.detectItemId,
|
||||
actualResult.itemId,
|
||||
actualResult.detectItemId,
|
||||
executionContext.itemId,
|
||||
extraInfo.itemId,
|
||||
extraInfo.detectItemId,
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取日志在任务编排中的位置。
|
||||
* 顶层 itemOrder/itemOccurrence 优先,因为它们是日志接口的正式字段;缺失时再读取
|
||||
* actualResult._executionContext,以兼容由节点执行结果携带上下文的新格式。
|
||||
*/
|
||||
const logExecutionContextOf = (log) => {
|
||||
const actualResult = parseObject(log?.actualResult)
|
||||
const context = parseObject(actualResult?._executionContext)
|
||||
return {
|
||||
itemOrder: log?.itemOrder ?? context.itemOrder,
|
||||
itemOccurrence: log?.itemOccurrence ?? context.itemOccurrence,
|
||||
}
|
||||
}
|
||||
|
||||
// 检测项名称优先使用任务用例名称,其他字段作为不同后端版本的兼容回退。
|
||||
const nameOf = (item) => item?.caseName || item?.detectName || item?.itemName || item?.name || '未命名检测项'
|
||||
|
||||
/**
|
||||
* 提取执行节点名称,用于展开区域的节点汇总。
|
||||
* 如果没有可读名称,最后使用 nodeId,至少能让用户定位到具体流程节点。
|
||||
*/
|
||||
const nodeNameOf = (log) => {
|
||||
const actualResult = parseObject(log?.actualResult)
|
||||
const extraInfo = parseObject(log?.extraInfo)
|
||||
return log?.nodeName
|
||||
|| log?.nodeTitle
|
||||
|| log?.node?.name
|
||||
|| log?.node?.nodeName
|
||||
|| actualResult.nodeName
|
||||
|| extraInfo.nodeName
|
||||
|| log?.nodeId
|
||||
|| ''
|
||||
}
|
||||
|
||||
// 状态统一转为小写字符串,使数字枚举和英文状态值可以使用同一套判断逻辑。
|
||||
const normalizedStatus = (value) => String(value ?? '').trim().toLowerCase()
|
||||
|
||||
/**
|
||||
* 判断一条节点日志是否明确表示“当前检测项执行结束”。
|
||||
* 只有结束节点(nodeType=END)的完成事件(eventType=NODE_COMPLETED)才算检测项完成,
|
||||
* 普通业务节点完成只代表流程推进,不能提前把整个检测项标记为已完成。
|
||||
*/
|
||||
const isItemCompletionLog = (log) => {
|
||||
const actualResult = parseObject(log?.actualResult)
|
||||
const context = parseObject(actualResult?._executionContext)
|
||||
return String(context.eventType || '').toUpperCase() === 'NODE_COMPLETED'
|
||||
&& String(context.nodeType || '').toUpperCase() === 'END'
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据一个检测项下的全部日志推导检测项状态。
|
||||
* 优先级为:失败 > 终止 > 暂停 > 完成 > 执行中;没有日志时固定为“未执行”。
|
||||
* 对已经结束的任务,还会结合任务终态和“最后一个有日志的检测项”判断失败/终止落在哪一项;
|
||||
* 完成状态最后再兼容状态字段、结束节点事件、progress=100 以及 completed/finished 布尔标记。
|
||||
*/
|
||||
const statusOf = (logs, execution = {}) => {
|
||||
if (!logs.length) return { label: '未执行', type: 'info' }
|
||||
|
||||
const statuses = logs.flatMap((log) => [
|
||||
log?.itemStatus,
|
||||
log?.executionStatus,
|
||||
log?.runStatus,
|
||||
log?.resultStatus,
|
||||
log?.state,
|
||||
log?.status,
|
||||
].map(normalizedStatus))
|
||||
|
||||
if (statuses.some((status) => ['2', 'failed', 'failure', 'error', 'exception'].includes(status))) {
|
||||
return { label: '执行失败', type: 'danger' }
|
||||
}
|
||||
if (statuses.some((status) => ['4', 'stopped', 'terminated', 'aborted'].includes(status))) {
|
||||
return { label: '已终止', type: 'warning' }
|
||||
}
|
||||
if (statuses.some((status) => ['3', 'paused', 'pause'].includes(status))) {
|
||||
return { label: '已暂停', type: 'warning' }
|
||||
}
|
||||
|
||||
// taskStatus 为空时用 NaN 表示“无任务级结论”,避免 Number(null) 被错误转换为完成状态 0。
|
||||
const taskStatus = execution.taskStatus === null || execution.taskStatus === undefined || execution.taskStatus === ''
|
||||
? Number.NaN
|
||||
: Number(execution.taskStatus)
|
||||
|
||||
// 整个任务正常完成时,所有实际产生过日志的检测项都可视为已完成。
|
||||
if (taskStatus === 0) return { label: '已完成', type: 'success' }
|
||||
|
||||
// 任务失败时,最后一个有日志的检测项视为失败;它之前的检测项已经顺序执行完成。
|
||||
if (taskStatus === 2) {
|
||||
return execution.groupIndex < execution.lastLoggedIndex
|
||||
? { label: '已完成', type: 'success' }
|
||||
: { label: '执行失败', type: 'danger' }
|
||||
}
|
||||
|
||||
// 任务被终止时采用同样的位置规则,仅将当时正在执行的最后一项标记为“已终止”。
|
||||
if (taskStatus === 4) {
|
||||
return execution.groupIndex < execution.lastLoggedIndex
|
||||
? { label: '已完成', type: 'success' }
|
||||
: { label: '已终止', type: 'warning' }
|
||||
}
|
||||
|
||||
// 对仍在运行的任务,只要后续检测项已经产生日志,当前项就必然已经按顺序执行完成。
|
||||
if (execution.groupIndex < execution.lastLoggedIndex) {
|
||||
return { label: '已完成', type: 'success' }
|
||||
}
|
||||
if (logs.some(isItemCompletionLog)) {
|
||||
return { label: '已完成', type: 'success' }
|
||||
}
|
||||
if (
|
||||
statuses.some((status) => ['0', 'success', 'succeeded', 'completed', 'complete', 'finished'].includes(status))
|
||||
|| logs.some((log) => Number(log?.progress) >= 100 || log?.completed === true || log?.finished === true)
|
||||
) {
|
||||
return { label: '已完成', type: 'success' }
|
||||
}
|
||||
return { label: '执行中', type: 'primary' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 将分析结果中的布尔值规范为 true/false/null。
|
||||
* 部分接口会返回字符串 "true"/"false";null 表示字段缺失或值无法识别,调用方应继续尝试其他结论字段。
|
||||
*/
|
||||
const booleanValue = (value) => {
|
||||
if (value === true || value === false) return value
|
||||
if (typeof value === 'string') {
|
||||
if (value.toLowerCase() === 'true') return true
|
||||
if (value.toLowerCase() === 'false') return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从单条日志中提取分析判定:true=通过、false=不通过、null=这不是分析日志。
|
||||
* 识别范围包含音视频 analysisType/profileCode、analysisStatus、passed、matched 和旧版 testStatus。
|
||||
* 判定优先级为:分析执行失败 > passed > matched > testStatus,避免旧字段覆盖更明确的新字段。
|
||||
*/
|
||||
const analysisVerdictOf = (log) => {
|
||||
const output = {
|
||||
...parseObject(log?.actualResult),
|
||||
...parseObject(log?.extraInfo),
|
||||
}
|
||||
const testStatus = String(log?.testStatus ?? '')
|
||||
const analysisType = String(output.analysisType || '').toUpperCase()
|
||||
const profileCode = String(output.profileCode || '').toLowerCase()
|
||||
// 通用图片分析只返回提示词要求的内容,不承担检测项通过/不通过判定。
|
||||
if (analysisType.includes('IMAGE') || profileCode.includes('image')) return null
|
||||
// 其余展示判定仍限定在音频/视频分析结果内,避免普通执行日志误入检测结论。
|
||||
const hasAnalysis = Boolean(
|
||||
analysisType.includes('AUDIO') || analysisType.includes('VIDEO')
|
||||
|| profileCode.includes('audio') || profileCode.includes('video')
|
||||
|| output.analysisStatus
|
||||
|| output.passed !== undefined || output.matched !== undefined
|
||||
|| ['1', '2'].includes(testStatus)
|
||||
)
|
||||
if (!hasAnalysis) return null
|
||||
const analysisStatus = String(output.analysisStatus || '').toUpperCase()
|
||||
if (['FAILED', 'ERROR'].includes(analysisStatus)) return false
|
||||
const passed = booleanValue(output.passed)
|
||||
if (passed !== null) return passed
|
||||
const matched = booleanValue(output.matched)
|
||||
if (matched !== null) return matched
|
||||
if (testStatus === '1') return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总一个检测项下的全部分析日志。
|
||||
* 没有分析日志时不展示结论标签;只要任意一次分析不通过,检测项结论就是“不通过”。
|
||||
*/
|
||||
const analysisStatusOf = (logs) => {
|
||||
const verdicts = logs.map(analysisVerdictOf).filter((value) => value !== null)
|
||||
if (!verdicts.length) return null
|
||||
return verdicts.every(Boolean)
|
||||
? { label: '通过', type: 'success' }
|
||||
: { label: '不通过', type: 'danger' }
|
||||
}
|
||||
|
||||
// 父组件明确传入 items 时直接使用,否则使用 taskId 请求到的检测项。
|
||||
const taskItemsSource = computed(() => props.items.length ? props.items : taskItems.value)
|
||||
|
||||
/**
|
||||
* 将“检测项列表”和“任务实例日志”合并成页面需要的分组结构:
|
||||
* 1. 先为每个检测项创建空分组,保证尚未执行的检测项也能显示;
|
||||
* 2. 对重复编排的同一检测项记录出现次数,并建立一个 ID 对应多个候选分组的索引;
|
||||
* 3. 按 itemOrder、历史 progress、itemOccurrence/ID 的优先级把每条日志放入对应分组;
|
||||
* 4. 最后计算节点名称、流程执行状态和分析结论。
|
||||
*/
|
||||
const groups = computed(() => {
|
||||
// occurrenceByItemKey 记录当前遍历到第几次,totalByItemKey 用于判断是否需要显示“第 N 次”。
|
||||
const occurrenceByItemKey = new Map()
|
||||
const totalByItemKey = new Map()
|
||||
taskItemsSource.value.forEach((item) => {
|
||||
const primaryKey = itemKeysOf(item)[0]
|
||||
if (primaryKey) totalByItemKey.set(primaryKey, (totalByItemKey.get(primaryKey) || 0) + 1)
|
||||
})
|
||||
|
||||
// 每个检测项先建立空日志数组,所以初始状态能够由 statusOf 正确显示为“未执行”。
|
||||
const result = taskItemsSource.value.map((item, index) => {
|
||||
const primaryKey = itemKeysOf(item)[0] || `item-${index}`
|
||||
const occurrence = (occurrenceByItemKey.get(primaryKey) || 0) + 1
|
||||
occurrenceByItemKey.set(primaryKey, occurrence)
|
||||
const repeated = (totalByItemKey.get(primaryKey) || 0) > 1
|
||||
const repeatLabel = repeated ? `第 ${occurrence} 次` : ''
|
||||
const name = nameOf(item)
|
||||
return {
|
||||
// key 同时包含编排顺序、检测项 ID 和出现次数,重复检测项也能保持独立的展开状态。
|
||||
key: `order-${index + 1}:${primaryKey}:${occurrence}`,
|
||||
name,
|
||||
fullName: repeatLabel ? `${name}(${repeatLabel})` : name,
|
||||
itemKeys: itemKeysOf(item),
|
||||
occurrence,
|
||||
repeatLabel,
|
||||
logs: [],
|
||||
}
|
||||
})
|
||||
const keyToGroups = new Map()
|
||||
|
||||
// 一个检测项 ID 可能对应多个重复执行分组,因此索引值必须是候选数组,不能只保存第一个分组。
|
||||
taskItemsSource.value.forEach((item, index) => {
|
||||
const group = result[index]
|
||||
itemKeysOf(item).forEach((key) => {
|
||||
if (!keyToGroups.has(key)) keyToGroups.set(key, [])
|
||||
keyToGroups.get(key).push(group)
|
||||
})
|
||||
})
|
||||
|
||||
const unmatchedLogs = []
|
||||
props.list.forEach((log) => {
|
||||
const executionContext = logExecutionContextOf(log)
|
||||
const order = Number(executionContext.itemOrder)
|
||||
const occurrence = Number(executionContext.itemOccurrence)
|
||||
|
||||
// 第一优先级:itemOrder 是任务内的 1 基绝对序号,存在时可以直接定位,包括重复编排的检测项。
|
||||
const orderedGroup = Number.isInteger(order) && order > 0 ? result[order - 1] : null
|
||||
|
||||
// 第二优先级:旧持久化日志没有 itemOrder,但任务 progress 单调递增,可按检测项总数反推出所在区间。
|
||||
// 反推后还要校验日志 ID 是否属于该分组,防止进度精度或边界值把日志分配给错误检测项。
|
||||
const progress = Number(log?.progress)
|
||||
const progressOrder = Number.isFinite(progress) && progress > 0 && result.length
|
||||
? Math.max(1, Math.min(result.length, Math.ceil(progress * result.length / 100)))
|
||||
: 0
|
||||
const progressGroup = progressOrder > 0 ? result[progressOrder - 1] : null
|
||||
const logKeys = logKeysOf(log)
|
||||
const compatibleProgressGroup = progressGroup
|
||||
&& logKeys.some((key) => progressGroup.itemKeys.includes(key))
|
||||
? progressGroup
|
||||
: null
|
||||
|
||||
// 第三优先级:使用 itemId 等兼容键查找候选分组;如果上下文带 itemOccurrence,
|
||||
// 选择对应的第 N 个候选,否则退回第一个,兼容完全没有执行上下文的最早期日志。
|
||||
const matchedGroup = orderedGroup || compatibleProgressGroup || logKeys.map((key) => {
|
||||
const candidates = keyToGroups.get(key) || []
|
||||
return Number.isInteger(occurrence) && occurrence > 0
|
||||
? candidates[occurrence - 1]
|
||||
: candidates[0]
|
||||
}).find(Boolean)
|
||||
if (matchedGroup) matchedGroup.logs.push(log)
|
||||
else unmatchedLogs.push(log)
|
||||
})
|
||||
|
||||
// 最后一个有日志的索引用于解释任务级失败/终止状态:顺序执行模型下,更早的分组已完成,最后一组是中断点。
|
||||
const lastLoggedIndex = result.reduce((lastIndex, group, index) => (
|
||||
group.logs.length ? index : lastIndex
|
||||
), -1)
|
||||
|
||||
// 无法关联的日志不能静默丢失,统一放入兜底分组,便于发现后端字段或历史数据问题。
|
||||
// if (unmatchedLogs.length) {
|
||||
// result.push({
|
||||
// key: 'unmatched',
|
||||
// name: '未关联检测项',
|
||||
// fullName: '未关联检测项',
|
||||
// itemKeys: [],
|
||||
// repeatLabel: '',
|
||||
// logs: unmatchedLogs,
|
||||
// })
|
||||
// }
|
||||
|
||||
// 页面最终消费的分组在这里统一补齐节点摘要、执行状态和分析结论,模板不再重复计算业务规则。
|
||||
return result.map((group, index) => ({
|
||||
...group,
|
||||
nodeNames: uniqueValues(group.logs.map(nodeNameOf)),
|
||||
status: statusOf(group.logs, {
|
||||
groupIndex: index,
|
||||
lastLoggedIndex,
|
||||
taskStatus: props.taskStatus,
|
||||
}),
|
||||
analysisStatus: analysisStatusOf(group.logs),
|
||||
}))
|
||||
})
|
||||
|
||||
const isExpanded = (key) => expandedKeys.value.has(key)
|
||||
|
||||
// 使用新 Set 替换旧值,确保 Vue 能可靠追踪集合变化并立即更新展开区域。
|
||||
const toggleGroup = (key) => {
|
||||
const next = new Set(expandedKeys.value)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
expandedKeys.value = next
|
||||
}
|
||||
|
||||
// 展开日志设置最小、最大高度;少量日志不会留出大块空白,大量日志则在内部滚动。
|
||||
const logHeight = (logs) => Math.max(120, Math.min(320, 78 + logs.length * 58))
|
||||
|
||||
/**
|
||||
* 根据任务定义 ID 查询其绑定的检测项。
|
||||
* 当 taskId 为空或父组件已经传入 items 时不发请求;请求结束前再次切换任务时,
|
||||
* 通过 currentRequestId 与全局 requestId 比较,忽略已经过期的响应和 loading 状态更新。
|
||||
*/
|
||||
const loadTaskItems = async (taskId) => {
|
||||
const currentRequestId = ++requestId
|
||||
if (!taskId || props.items.length) {
|
||||
taskItems.value = []
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getTestCaseByTaskId(taskId)
|
||||
if (currentRequestId === requestId) {
|
||||
taskItems.value = Array.isArray(res?.data) ? res.data : Array.isArray(res?.rows) ? res.rows : []
|
||||
}
|
||||
} catch {
|
||||
if (currentRequestId === requestId) taskItems.value = []
|
||||
} finally {
|
||||
if (currentRequestId === requestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// immediate 保证组件首次拿到 taskId 时立即查询,而不需要父组件额外触发初始化方法。
|
||||
watch(() => props.taskId, loadTaskItems, { immediate: true })
|
||||
|
||||
// 检测项切换后清除已经不存在的展开 key,避免新任务意外继承旧任务的展开状态。
|
||||
watch(() => groups.value.map((group) => group.key), (keys) => {
|
||||
expandedKeys.value = new Set([...expandedKeys.value].filter((key) => keys.includes(key)))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.task-log {
|
||||
margin-top: 12px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.task-log__heading,
|
||||
.task-log__group-header {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 34px minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-log__heading {
|
||||
padding: 10px 14px;
|
||||
color: #98a2b3;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.task-log__heading span:first-child {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.task-log__heading span:last-child {
|
||||
grid-column: 4;
|
||||
}
|
||||
|
||||
.task-log__groups {
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
.task-log__group {
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.task-log__group:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.task-log__group-header {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 8px 6px;
|
||||
color: #344054;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.task-log__group-header:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.task-log__expand-icon {
|
||||
color: #98a2b3;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.task-log__expand-icon.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.task-log__order {
|
||||
color: #98a2b3;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.task-log__name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-log__repeat {
|
||||
// 重复次数作为名称的辅助信息,视觉权重低于检测项主名称。
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
color: #409eff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-log__statuses {
|
||||
// 状态区域允许换行,窄屏下两个标签不会挤压或遮挡检测项名称。
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.task-log__details {
|
||||
margin: 0 6px 10px 64px;
|
||||
padding: 10px 12px 12px;
|
||||
background: #f8fafc;
|
||||
border-left: 2px solid #d0d5dd;
|
||||
}
|
||||
|
||||
.task-log__details-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #475467;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.task-log__details-count,
|
||||
.task-log__nodes {
|
||||
color: #98a2b3;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.task-log__nodes {
|
||||
margin-top: 6px;
|
||||
line-height: 18px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-log__details :deep(.task-log) {
|
||||
margin-top: 8px;
|
||||
border-color: #eaecf0;
|
||||
}
|
||||
|
||||
.task-log__empty,
|
||||
.task-log__details-empty {
|
||||
padding: 28px 12px;
|
||||
color: #98a2b3;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.task-log__details-empty {
|
||||
padding: 18px 0 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.task-log__details {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.task-log__heading,
|
||||
.task-log__group-header {
|
||||
grid-template-columns: 22px 26px minmax(0, 1fr) auto;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,117 +0,0 @@
|
||||
<template>
|
||||
<div v-if="list.length" class="step-current">
|
||||
<span class="step-current__count">{{ currentStepText }}</span>
|
||||
<span class="step-current__name">{{ currentStep?.caseName || '未命名检测项' }}</span>
|
||||
</div>
|
||||
<el-scrollbar ref="scrollbarRef" style="width: 100%;" class="step-scrollbar">
|
||||
<div class="step-wrapper" ref="stepWrapperRef">
|
||||
<el-steps :active="normalizedActiveStep" finish-status="success" process-status="process">
|
||||
<el-step v-for="(item, index) in list" :key="item.id || item.testCaseId || item.detectItemId || index"
|
||||
:title="item.caseName" :description="item.remark" />
|
||||
</el-steps>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
list: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
activeStep: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const scrollbarRef = ref(null)
|
||||
const stepWrapperRef = ref(null)
|
||||
|
||||
const currentStepIndex = computed(() => Math.min(Math.max(props.activeStep, 0), Math.max(props.list.length - 1, 0)))
|
||||
const currentStep = computed(() => props.list[currentStepIndex.value])
|
||||
const normalizedActiveStep = computed(() => Math.min(Math.max(props.activeStep, 0), props.list.length))
|
||||
const currentStepText = computed(() => props.activeStep >= props.list.length
|
||||
? `已完成 ${props.list.length}/${props.list.length}`
|
||||
: `当前 ${currentStepIndex.value + 1}/${props.list.length}`)
|
||||
|
||||
|
||||
/**
|
||||
* 滚动到当前激活的步骤
|
||||
*/
|
||||
const scrollToActiveStep = () => {
|
||||
nextTick(() => {
|
||||
// 获取 el-scrollbar 的可视区域宽度
|
||||
const scrollbarEl = scrollbarRef.value?.$el
|
||||
const wrapEl = scrollbarEl?.querySelector('.el-scrollbar__wrap')
|
||||
if (!wrapEl) return
|
||||
|
||||
const viewWidth = wrapEl.clientWidth
|
||||
|
||||
// 获取所有 step 元素
|
||||
const steps = stepWrapperRef.value?.querySelectorAll('.el-step')
|
||||
if (!steps || !steps[currentStepIndex.value]) return
|
||||
|
||||
// 计算激活 step 的位置
|
||||
const activeEl = steps[currentStepIndex.value]
|
||||
const stepLeft = activeEl.offsetLeft
|
||||
const stepWidth = activeEl.offsetWidth
|
||||
|
||||
// 让激活 step 居中显示
|
||||
const scrollLeft = stepLeft - viewWidth / 2 + stepWidth / 2
|
||||
scrollbarRef.value.setScrollLeft(Math.max(0, scrollLeft))
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.activeStep, props.list.length],
|
||||
scrollToActiveStep,
|
||||
{ immediate: true, flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.step-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
margin-top: 12px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.step-current__count {
|
||||
flex: none;
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-current__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.step-scrollbar {
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
|
||||
.step-wrapper {
|
||||
display: inline-block;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
white-space: nowrap;
|
||||
margin: 10px 0 20px 0;
|
||||
|
||||
:deep(.el-steps) {
|
||||
flex-wrap: nowrap;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
|
||||
.el-step {
|
||||
flex: 0 0 180px !important;
|
||||
min-width: 180px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,376 +0,0 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div ref="topContainerRef">
|
||||
<TableSearch :queryParams="queryParams" :showSearch="showSearch" label-width="90px" queryRef="queryRef"
|
||||
@refresh="resetQuery" @search="handleQuery">
|
||||
<template #one>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="任务名称" prop="taskName">
|
||||
<el-input v-model="queryParams.taskName" clearable placeholder="请输入任务名称" style="width: 100%"
|
||||
@keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="任务类型" prop="taskType">
|
||||
<el-select v-model="queryParams.taskType" clearable placeholder="请选择任务类型"
|
||||
style="width: 100%">
|
||||
<el-option label="常规测试" value="1" />
|
||||
<el-option label="回归测试" value="2" />
|
||||
<el-option label="专项测试" value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</template>
|
||||
</TableSearch>
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button icon="Plus" plain type="primary" @click="handleAdd">新增
|
||||
</el-button>
|
||||
</el-col>
|
||||
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div :style="containerHeight">
|
||||
<el-table v-loading="loading" height="100%" :data="tableDataList">
|
||||
<el-table-column align="center" label="id" prop="id" show-overflow-tooltip />
|
||||
<el-table-column align="center" label="任务名称" prop="taskName" show-overflow-tooltip />
|
||||
<el-table-column align="center" label="任务描述" prop="remark" show-overflow-tooltip />
|
||||
<el-table-column align="center" label="任务类型" prop="taskType">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.taskType === '1' ? 'primary' : 'success'">
|
||||
{{ scope.row.taskType === '1' ? '巡检任务' : '讲解任务' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="创建人" prop="createBy" show-overflow-tooltip />
|
||||
<el-table-column align="center" label="创建时间" prop="createTime" show-overflow-tooltip />
|
||||
<el-table-column align="center" class-name="small-padding fixed-width" label="操作" width="320">
|
||||
<template #default="scope">
|
||||
<el-button icon="Edit" link type="primary" @click="handleUpdate(scope.row)">
|
||||
修改
|
||||
</el-button>
|
||||
<el-button :loading="copyingId === scope.row.id" icon="CopyDocument" link type="primary"
|
||||
@click="handleCopy(scope.row)">复制
|
||||
</el-button>
|
||||
<el-button icon="Cpu" link type="primary" @click="handleBindCase(scope.row)">绑定用例
|
||||
</el-button>
|
||||
<el-button icon="Delete" link type="primary" @click="handleDelete(scope.row)">删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:limit="queryParams.pageSize" v-model:page="queryParams.pageNum"
|
||||
:total="total" @pagination="getList" />
|
||||
</div>
|
||||
|
||||
|
||||
<el-dialog v-model="open" :title="title" append-to-body width="500px">
|
||||
<el-form ref="taskRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="任务名称" prop="taskName">
|
||||
<el-input v-model="form.taskName" placeholder="请输入任务名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="任务类型" prop="taskType">
|
||||
<el-select v-model="form.taskType" clearable placeholder="请选择任务类型" style="width: 100%">
|
||||
<el-option label="常规测试" value="1" />
|
||||
<el-option label="回归测试" value="2" />
|
||||
<el-option label="专项测试" value="3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" :rows="2" placeholder="请输入备注" type="textarea" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 点位绑定弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" title="测试用例绑定" width="800px">
|
||||
<el-row>
|
||||
<TableSearch :queryParams="caseQueryParams" :showSearch="showSearch" label-width="90px" @refresh="resetBindCaseQuery" @search="handleBindCaseQuery">
|
||||
<template #one>
|
||||
<el-col :lg="12" :md="12" :sm="24" :xl="12" :xs="24" :xxl="12">
|
||||
<el-form-item label="用例名称" prop="caseName">
|
||||
<el-input v-model="caseQueryParams.caseName" clearable placeholder="请输入用例名称"
|
||||
style="width: 100%" @keyup.enter="handleBindCaseQuery" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</template>
|
||||
</TableSearch>
|
||||
</el-row>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<div style="height: 55vh">
|
||||
<div class="task-title">待选择用例</div>
|
||||
|
||||
<draggable :animation="340" :forceFallback="true" :list="taskLeftList" class="draggable-group"
|
||||
ghostClass="dragClass" :group="{ name: 'people', pull: 'clone', put: false}"
|
||||
:clone="cloneBindingItem" itemKey="_bindingKey">
|
||||
<template #item="{ element, index }">
|
||||
<div class="list-group-item">
|
||||
<el-icon>
|
||||
<Tools />
|
||||
</el-icon>
|
||||
{{ element.caseName }}
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div style="height: 55vh">
|
||||
<div class="task-title">用例排序</div>
|
||||
<draggable :animation="340" :forceFallback="true" :list="taskRightList" class="draggable-group"
|
||||
ghostClass="dragClass" :group="{ name: 'people', pull: false, put: true }" itemKey="_bindingKey">
|
||||
<template #item="{ element, index }">
|
||||
<div class="list-group-item">
|
||||
{{ index + 1 }} 、{{ element.caseName }}
|
||||
<el-icon color="red" @click="removeFromB(index)" style="margin-left: 10px;"><Delete /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="custom-block">
|
||||
<div class="mb5">1、将待选择用例拖拽至用例排序中</div>
|
||||
<div>2、用例排序中可拖拽自定义其顺序</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="bindCase">确 定</el-button>
|
||||
<el-button @click="cancelCase">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script name="Detect" setup>
|
||||
import { addTask, copyTask, updateTask, getTaskList, deleteTask, getTestCaseByTaskId, bindTastCase} from "../../../api/intelligenceTest/taskManage";
|
||||
import { getTestCaseList } from "../../../api/intelligenceTest/testCase.js";
|
||||
import TableSearch from "@/components/TableSearch/index.vue";
|
||||
|
||||
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import draggable from "vuedraggable";
|
||||
import { reactive, ref } from "vue";
|
||||
import { listResourceMaps as getMapList } from "@/api/resource/map";
|
||||
|
||||
const topContainerRef = ref();
|
||||
const containerHeight = useContainerHeight(topContainerRef);
|
||||
|
||||
const { proxy } = getCurrentInstance();
|
||||
|
||||
const tableDataList = ref([]);
|
||||
const open = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const copyingId = ref("");
|
||||
const showSearch = ref(true);
|
||||
|
||||
const total = ref(0);
|
||||
const title = ref("");
|
||||
|
||||
const data = reactive({
|
||||
form: {},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
taskName: null,
|
||||
taskType: null,
|
||||
},
|
||||
rules: {
|
||||
taskName: [
|
||||
{ required: true, message: "任务名称不能为空", trigger: "blur" },
|
||||
],
|
||||
taskType: [
|
||||
{ required: true, message: "请选择任务类型", trigger: "blur" },
|
||||
],
|
||||
mapId: [
|
||||
{ required: true, message: "请选择所属地图", trigger: "blur" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { queryParams, form, rules } = toRefs(data);
|
||||
|
||||
/** 查询任务列表 */
|
||||
function getList() {
|
||||
loading.value = false;
|
||||
getTaskList(queryParams.value).then((res) => {
|
||||
tableDataList.value = res.rows;
|
||||
total.value = res.total;
|
||||
loading.value = false;
|
||||
}).finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 取消按钮
|
||||
function cancel() {
|
||||
open.value = false;
|
||||
reset();
|
||||
}
|
||||
|
||||
// 表单重置
|
||||
function reset() {
|
||||
taskRef.value.resetFields();
|
||||
form.value = {};
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleQuery() {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
function resetQuery() {
|
||||
handleQuery();
|
||||
}
|
||||
|
||||
/** 新增按钮操作 */
|
||||
function handleAdd() {
|
||||
isNew.value = true;
|
||||
open.value = true;
|
||||
title.value = "添加任务";
|
||||
}
|
||||
|
||||
/** 修改按钮操作 */
|
||||
function handleUpdate(row) {
|
||||
form.value = { ...row };
|
||||
isNew.value = false;
|
||||
open.value = true;
|
||||
title.value = "修改任务";
|
||||
}
|
||||
|
||||
const taskRef = ref();
|
||||
const isNew = ref(true);
|
||||
/** 提交按钮 */
|
||||
function submitForm() {
|
||||
taskRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
if (isNew.value) {
|
||||
addTask(form.value).then(() => {
|
||||
ElMessage.success("添加成功");
|
||||
cancel();
|
||||
getList();
|
||||
});
|
||||
} else {
|
||||
updateTask(form.value).then(() => {
|
||||
ElMessage.success("修改成功");
|
||||
cancel();
|
||||
getList();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
async function handleCopy(row) {
|
||||
copyingId.value = row.id;
|
||||
try {
|
||||
await copyTask(row.id);
|
||||
ElMessage.success("复制成功");
|
||||
await getList();
|
||||
} finally {
|
||||
copyingId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
ElMessageBox.confirm("确定要删除该任务吗?", "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
deleteTask(row.id).then(() => {
|
||||
ElMessage.success("删除成功");
|
||||
getList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const caseQueryParams = reactive({
|
||||
pageNum: 1,
|
||||
pageSize: 1000000,
|
||||
caseName: null
|
||||
})
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleBindCaseQuery() {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
function resetBindCaseQuery() {
|
||||
handleBindCaseQuery();
|
||||
}
|
||||
|
||||
const taskId = ref(null);
|
||||
let bindingSequence = 0
|
||||
const createBindingKey = () => `case-${Date.now()}-${bindingSequence++}`
|
||||
const cloneBindingItem = (item) => ({ ...item, _bindingKey: createBindingKey() })
|
||||
const handleBindCase = async (row) => {
|
||||
dialogVisible.value = true;
|
||||
taskId.value = row.id;
|
||||
taskLeftList.value = [];
|
||||
taskRightList.value = [];
|
||||
await getbindCaseByTaskId(row.id)
|
||||
await getTestCaseListData()
|
||||
};
|
||||
|
||||
/** 查询点位列表 */
|
||||
const getTestCaseListData = async () => {
|
||||
const res = await getTestCaseList(caseQueryParams)
|
||||
if (res.code === 200) {
|
||||
taskLeftList.value = res.rows.map(item => ({ ...item, _bindingKey: createBindingKey() }))
|
||||
}
|
||||
}
|
||||
|
||||
const getbindCaseByTaskId = async (taskId) => {
|
||||
const res = await getTestCaseByTaskId(taskId)
|
||||
if (res.code === 200) {
|
||||
taskRightList.value = res.data.map(item => ({ ...item, _bindingKey: createBindingKey() }));
|
||||
}
|
||||
}
|
||||
|
||||
const taskLeftList = ref([])
|
||||
const taskRightList = ref([])
|
||||
|
||||
const bindCase = async () => {
|
||||
if (taskRightList.value.length === 0) {
|
||||
ElMessage.warning("请选择用例")
|
||||
return
|
||||
}
|
||||
const ids = taskRightList.value.map(item => item.id)
|
||||
const res = await bindTastCase(taskId.value, ids)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success("绑定用例成功")
|
||||
cancelCase()
|
||||
}
|
||||
};
|
||||
|
||||
const cancelCase = () => {
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
|
||||
const removeFromB = (index) => {
|
||||
taskRightList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getList();
|
||||
});
|
||||
|
||||
</script>
|
||||
@ -1,344 +0,0 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div ref="topContainerRef">
|
||||
<TableSearch
|
||||
:queryParams="queryParams"
|
||||
:showSearch="showSearch"
|
||||
label-width="90px"
|
||||
queryRef="queryRef"
|
||||
@refresh="resetQuery"
|
||||
@search="handleQuery"
|
||||
>
|
||||
<template #one>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="用例编码" prop="caseCode">
|
||||
<el-input
|
||||
v-model="queryParams.caseCode"
|
||||
placeholder="请输入用例编码"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||
<el-form-item label="测试步骤" prop="testSteps">
|
||||
<el-input
|
||||
v-model="queryParams.testSteps"
|
||||
placeholder="请输入测试步骤"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</template>
|
||||
</TableSearch>
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
icon="Plus"
|
||||
@click="handleAdd"
|
||||
>新增</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="success"
|
||||
plain
|
||||
icon="Edit"
|
||||
:disabled="single"
|
||||
@click="handleUpdate"
|
||||
>修改</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
icon="Delete"
|
||||
:disabled="multiple"
|
||||
@click="handleDelete"
|
||||
>删除</el-button>
|
||||
</el-col>
|
||||
|
||||
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<div :style="containerHeight">
|
||||
<!-- 表格数据 -->
|
||||
<el-table height="100%" v-loading="loading" :data="tableDataList" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column label="用例名称" prop="caseName" />
|
||||
<el-table-column label="预期结果" prop="expectedResult" :show-overflow-tooltip="true" width="200" />
|
||||
<el-table-column label="前置条件" prop="precondition" show-overflow-tooltip="true" width="200" />
|
||||
<el-table-column label="测试步骤" align="center" prop="testSteps" show-overflow-tooltip="true" width="200"/>
|
||||
<el-table-column label="测试描述" align="center" prop="testDescription" show-overflow-tooltip="true" width="200" />
|
||||
<el-table-column label="状态" align="center" width="100">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.enabled"
|
||||
active-value="0"
|
||||
inactive-value="1"
|
||||
@change="handleStatusChange(scope.row)"
|
||||
></el-switch>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" label="配置状态" prop="configStatus">
|
||||
<template #default="scope">
|
||||
<TableTags
|
||||
:options="[
|
||||
{
|
||||
label: '已配置',
|
||||
value: '0',
|
||||
type: 'success',
|
||||
},
|
||||
{
|
||||
label: '未配置',
|
||||
value: '1',
|
||||
type: 'warning',
|
||||
},
|
||||
]"
|
||||
:value="scope.row.configStatus"
|
||||
></TableTags>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="280">
|
||||
<template #default="scope">
|
||||
<el-tooltip content="修改" placement="top">
|
||||
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
|
||||
</el-tooltip>
|
||||
<el-button :loading="copyingId === scope.row.id" link type="primary" icon="CopyDocument"
|
||||
@click="handleCopy(scope.row)">复制</el-button>
|
||||
<el-button
|
||||
icon="Cpu"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleDeploy(scope.row)"
|
||||
>配置
|
||||
</el-button>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNum"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 添加或修改配置对话框 -->
|
||||
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
|
||||
<el-form ref="testCaseRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="用例名称" prop="caseName">
|
||||
<el-input v-model="form.caseName" placeholder="请输入用例名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="预期结果" prop="expectedResult">
|
||||
<el-input type="textarea" v-model="form.expectedResult" placeholder="请输入预期结果" />
|
||||
</el-form-item>
|
||||
<el-form-item label="前置条件" prop="precondition">
|
||||
<el-input type="textarea" v-model="form.precondition" placeholder="请输入前置条件" />
|
||||
</el-form-item>
|
||||
<el-form-item label="测试步骤" prop="testSteps">
|
||||
<el-input type="textarea" v-model="form.testSteps" placeholder="请输入测试步骤" />
|
||||
</el-form-item>
|
||||
<el-form-item label="测试描述" prop="testDescription">
|
||||
<el-input type="textarea" v-model="form.testDescription" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="Role">
|
||||
import TableSearch from "@/components/TableSearch/index.vue";
|
||||
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||
import { addTestCase, copyTestCase, getTestCaseList, updateTestCase, deleteTestCase } from "@/api/intelligenceTest/testCase.js"
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { appendParamsToPath } from "@/utils/fn.js";
|
||||
import TableTags from "@/components/TableTags/index.vue";
|
||||
|
||||
const topContainerRef = ref();
|
||||
const containerHeight = useContainerHeight(topContainerRef);
|
||||
|
||||
const tableDataList = ref([]);
|
||||
const open = ref(false);
|
||||
const loading = ref(true);
|
||||
const copyingId = ref("");
|
||||
const showSearch = ref(true);
|
||||
const ids = ref([]);
|
||||
const single = ref(true);
|
||||
const multiple = ref(true);
|
||||
const total = ref(0);
|
||||
const title = ref("");
|
||||
|
||||
|
||||
const data = reactive({
|
||||
form: {},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
caseName: undefined,
|
||||
testSteps: undefined
|
||||
},
|
||||
rules: {
|
||||
caseName: [{ required: true, message: "用例名称不能为空", trigger: "blur" }],
|
||||
expectedResult: [{ required: true, message: "预期结果不能为空", trigger: "blur" }],
|
||||
testSteps: [{ required: true, message: "测试步骤为空", trigger: "blur" }],
|
||||
testDescription: [{ required: true, message: "测试描述不能为空", trigger: "blur" }]
|
||||
},
|
||||
});
|
||||
|
||||
const { queryParams, form, rules } = toRefs(data);
|
||||
|
||||
/** 查询列表 */
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
const res = await getTestCaseList(queryParams.value)
|
||||
if (res.code === 200) {
|
||||
tableDataList.value = res.rows;
|
||||
total.value = res.total;
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleQuery() {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
function resetQuery() {
|
||||
handleQuery();
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
async function handleCopy(row) {
|
||||
copyingId.value = row.id;
|
||||
try {
|
||||
await copyTestCase(row.id);
|
||||
ElMessage.success("复制成功");
|
||||
await getList();
|
||||
} finally {
|
||||
copyingId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
const deleteIds = row.id || ids.value;
|
||||
ElMessageBox.confirm(`确定要删除编号为${deleteIds}的测试用例吗?`, "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
deleteTestCase(deleteIds).then(() => {
|
||||
ElMessage.success("删除成功");
|
||||
getList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/** 多选框选中数据 */
|
||||
function handleSelectionChange(selection) {
|
||||
ids.value = selection.map(item => item.id);
|
||||
single.value = selection.length != 1;
|
||||
multiple.value = !selection.length;
|
||||
}
|
||||
|
||||
/** 状态修改 */
|
||||
function handleStatusChange(row) {
|
||||
let text = row.enabled === "0" ? "启用" : "停用";
|
||||
ElMessageBox.confirm('确认要"' + text + '""' + row.caseName + '"测试用例吗?', "提示", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}).then(() => {
|
||||
updateTestCase({
|
||||
...row,
|
||||
}).then(() => {
|
||||
ElMessage.success(text + "成功");
|
||||
getList();
|
||||
});
|
||||
}).catch(() => {
|
||||
row.enabled = row.enabled === "0" ? "1" : "0";
|
||||
})
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
testCaseRef.value.resetFields()
|
||||
};
|
||||
|
||||
const testCaseRef = ref()
|
||||
const isNew = ref(true)
|
||||
|
||||
/** 添加 */
|
||||
function handleAdd() {
|
||||
open.value = true;
|
||||
isNew.value = true
|
||||
title.value = "添加";
|
||||
}
|
||||
|
||||
/** 修改 */
|
||||
function handleUpdate(row) {
|
||||
form.value = { ...row };
|
||||
open.value = true;
|
||||
isNew.value = false
|
||||
title.value = "修改";
|
||||
}
|
||||
|
||||
/** 提交按钮 */
|
||||
function submitForm() {
|
||||
testCaseRef.value.validate((valid) => {
|
||||
if (valid) {
|
||||
if (isNew.value) {
|
||||
addTestCase(form.value).then(() => {
|
||||
ElMessage.success("添加成功");
|
||||
getList();
|
||||
open.value = false;
|
||||
reset();
|
||||
});
|
||||
} else {
|
||||
updateTestCase(form.value).then(() => {
|
||||
ElMessage.success("修改成功");
|
||||
getList();
|
||||
open.value = false;
|
||||
reset();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消按钮 */
|
||||
function cancel() {
|
||||
open.value = false;
|
||||
reset();
|
||||
}
|
||||
|
||||
/** 配置按钮操作 */
|
||||
function handleDeploy(row) {
|
||||
const params = { itemId: row.detectItemId, name: row.caseName, state: row.status };
|
||||
const fullPath = appendParamsToPath("/flow", params);
|
||||
const p = window.open(fullPath, "_blank");
|
||||
}
|
||||
|
||||
getList();
|
||||
</script>
|
||||
@ -1,239 +0,0 @@
|
||||
<template>
|
||||
<div class="login">
|
||||
<el-form ref="loginRef" :model="loginForm" :rules="loginRules" class="login-form">
|
||||
<h3 class="title">{{ title }}</h3>
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
type="text"
|
||||
size="large"
|
||||
auto-complete="off"
|
||||
placeholder="账号"
|
||||
>
|
||||
<template #prefix><svg-icon icon-class="user" class="el-input__icon input-icon" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
size="large"
|
||||
auto-complete="off"
|
||||
placeholder="密码"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<template #prefix><svg-icon icon-class="password" class="el-input__icon input-icon" /></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="code" v-if="captchaEnabled">
|
||||
<el-input
|
||||
v-model="loginForm.code"
|
||||
size="large"
|
||||
auto-complete="off"
|
||||
placeholder="验证码"
|
||||
style="width: 63%"
|
||||
@keyup.enter="handleLogin"
|
||||
>
|
||||
<template #prefix><svg-icon icon-class="validCode" class="el-input__icon input-icon" /></template>
|
||||
</el-input>
|
||||
<div class="login-code">
|
||||
<img :src="codeUrl" @click="getCode" class="login-code-img"/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-checkbox v-model="loginForm.rememberMe" style="margin:0px 0px 25px 0px;">记住密码</el-checkbox>
|
||||
<el-form-item style="width:100%;">
|
||||
<el-button
|
||||
:loading="loading"
|
||||
size="large"
|
||||
type="primary"
|
||||
style="width:100%;"
|
||||
@click.prevent="handleLogin"
|
||||
>
|
||||
<span v-if="!loading">登 录</span>
|
||||
<span v-else>登 录 中...</span>
|
||||
</el-button>
|
||||
<div style="float: right;" v-if="register">
|
||||
<router-link class="link-type" :to="'/register'">立即注册</router-link>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<!-- 底部 -->
|
||||
<div class="el-login-footer">
|
||||
<span>{{ footerContent }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getCodeImg } from "@/api/login"
|
||||
import Cookies from "js-cookie"
|
||||
import { encrypt, decrypt } from "@/utils/jsencrypt"
|
||||
import useUserStore from '@/store/modules/user'
|
||||
import defaultSettings from '@/settings'
|
||||
|
||||
const title = import.meta.env.VITE_APP_TITLE
|
||||
const footerContent = defaultSettings.footerContent
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const loginForm = ref({
|
||||
username: "admin",
|
||||
password: "admin123",
|
||||
rememberMe: false,
|
||||
code: "",
|
||||
uuid: ""
|
||||
})
|
||||
|
||||
const loginRules = {
|
||||
username: [{ required: true, trigger: "blur", message: "请输入您的账号" }],
|
||||
password: [{ required: true, trigger: "blur", message: "请输入您的密码" }],
|
||||
code: [{ required: true, trigger: "change", message: "请输入验证码" }]
|
||||
}
|
||||
|
||||
const codeUrl = ref("")
|
||||
const loading = ref(false)
|
||||
// 验证码开关
|
||||
const captchaEnabled = ref(true)
|
||||
// 注册开关
|
||||
const register = ref(false)
|
||||
const redirect = ref(undefined)
|
||||
|
||||
watch(route, (newRoute) => {
|
||||
redirect.value = newRoute.query && newRoute.query.redirect
|
||||
}, { immediate: true })
|
||||
|
||||
function handleLogin() {
|
||||
proxy.$refs.loginRef.validate(valid => {
|
||||
if (valid) {
|
||||
loading.value = true
|
||||
// 勾选了需要记住密码设置在 cookie 中设置记住用户名和密码
|
||||
if (loginForm.value.rememberMe) {
|
||||
Cookies.set("username", loginForm.value.username, { expires: 30 })
|
||||
Cookies.set("password", encrypt(loginForm.value.password), { expires: 30 })
|
||||
Cookies.set("rememberMe", loginForm.value.rememberMe, { expires: 30 })
|
||||
} else {
|
||||
// 否则移除
|
||||
Cookies.remove("username")
|
||||
Cookies.remove("password")
|
||||
Cookies.remove("rememberMe")
|
||||
}
|
||||
// 调用action的登录方法
|
||||
userStore.login(loginForm.value).then(() => {
|
||||
const query = route.query
|
||||
const otherQueryParams = Object.keys(query).reduce((acc, cur) => {
|
||||
if (cur !== "redirect") {
|
||||
acc[cur] = query[cur]
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
router.push({ path: redirect.value || "/", query: otherQueryParams })
|
||||
}).catch(() => {
|
||||
loading.value = false
|
||||
// 重新获取验证码
|
||||
if (captchaEnabled.value) {
|
||||
getCode()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getCode() {
|
||||
getCodeImg().then(res => {
|
||||
captchaEnabled.value = res.captchaEnabled === undefined ? true : res.captchaEnabled
|
||||
if (captchaEnabled.value) {
|
||||
codeUrl.value = "data:image/gif;base64," + res.img
|
||||
loginForm.value.uuid = res.uuid
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getCookie() {
|
||||
const username = Cookies.get("username")
|
||||
const password = Cookies.get("password")
|
||||
const rememberMe = Cookies.get("rememberMe")
|
||||
loginForm.value = {
|
||||
username: username === undefined ? loginForm.value.username : username,
|
||||
password: password === undefined ? loginForm.value.password : decrypt(password),
|
||||
rememberMe: rememberMe === undefined ? false : Boolean(rememberMe)
|
||||
}
|
||||
}
|
||||
|
||||
getCode()
|
||||
getCookie()
|
||||
</script>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
.login {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
.login::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
background-image: url("../assets/images/login-background.jpg");
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
opacity: 0.3; /* 只在这层做透明 */
|
||||
z-index: -1;
|
||||
}
|
||||
.title {
|
||||
margin: 0px auto 30px auto;
|
||||
text-align: center;
|
||||
color: #707070;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
width: 400px;
|
||||
padding: 25px 25px 5px 25px;
|
||||
.el-input {
|
||||
height: 40px;
|
||||
input {
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
.input-icon {
|
||||
height: 39px;
|
||||
width: 14px;
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
.login-tip {
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
.login-code {
|
||||
width: 33%;
|
||||
height: 40px;
|
||||
float: right;
|
||||
img {
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
.el-login-footer {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-family: Arial;
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.login-code-img {
|
||||
height: 40px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
</style>
|
||||
132
src/views/monitor/cache/index.vue
vendored
@ -1,132 +0,0 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="24" class="card-box">
|
||||
<el-card>
|
||||
<template #header><Monitor style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">基本信息</span></template>
|
||||
<div class="el-table el-table--enable-row-hover el-table--medium">
|
||||
<table cellspacing="0" style="width: 100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="el-table__cell is-leaf"><div class="cell">Redis版本</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.redis_version }}</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell">运行模式</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.redis_mode == "standalone" ? "单机" : "集群" }}</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell">端口</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.tcp_port }}</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell">客户端数</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.connected_clients }}</div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="el-table__cell is-leaf"><div class="cell">运行时间(天)</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.uptime_in_days }}</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell">使用内存</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.used_memory_human }}</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell">使用CPU</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ parseFloat(cache.info.used_cpu_user_children).toFixed(2) }}</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell">内存配置</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.maxmemory_human }}</div></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="el-table__cell is-leaf"><div class="cell">AOF是否开启</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.aof_enabled == "0" ? "否" : "是" }}</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell">RDB是否成功</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.rdb_last_bgsave_status }}</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell">Key数量</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell" v-if="cache.dbSize">{{ cache.dbSize }} </div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell">网络入口/出口</div></td>
|
||||
<td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.instantaneous_input_kbps }}kps/{{cache.info.instantaneous_output_kbps}}kps</div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12" class="card-box">
|
||||
<el-card>
|
||||
<template #header><PieChart style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">命令统计</span></template>
|
||||
<div class="el-table el-table--enable-row-hover el-table--medium">
|
||||
<div ref="commandstats" style="height: 420px" />
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="12" class="card-box">
|
||||
<el-card>
|
||||
<template #header><Odometer style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">内存信息</span></template>
|
||||
<div class="el-table el-table--enable-row-hover el-table--medium">
|
||||
<div ref="usedmemory" style="height: 420px" />
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="Cache">
|
||||
import { getCache } from '@/api/monitor/cache'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const cache = ref([])
|
||||
const commandstats = ref(null)
|
||||
const usedmemory = ref(null)
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
function getList() {
|
||||
proxy.$modal.loading("正在加载缓存监控数据,请稍候!")
|
||||
getCache().then(response => {
|
||||
proxy.$modal.closeLoading()
|
||||
cache.value = response.data
|
||||
|
||||
const commandstatsIntance = echarts.init(commandstats.value, "macarons")
|
||||
commandstatsIntance.setOption({
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: "{a} <br/>{b} : {c} ({d}%)"
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "命令",
|
||||
type: "pie",
|
||||
roseType: "radius",
|
||||
radius: [15, 95],
|
||||
center: ["50%", "38%"],
|
||||
data: response.data.commandStats,
|
||||
animationEasing: "cubicInOut",
|
||||
animationDuration: 1000
|
||||
}
|
||||
]
|
||||
})
|
||||
const usedmemoryInstance = echarts.init(usedmemory.value, "macarons")
|
||||
usedmemoryInstance.setOption({
|
||||
tooltip: {
|
||||
formatter: "{b} <br/>{a} : " + cache.value.info.used_memory_human
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: "峰值",
|
||||
type: "gauge",
|
||||
min: 0,
|
||||
max: 1000,
|
||||
detail: {
|
||||
formatter: cache.value.info.used_memory_human
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: parseFloat(cache.value.info.used_memory_human),
|
||||
name: "内存消耗"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
window.addEventListener("resize", () => {
|
||||
commandstatsIntance.resize()
|
||||
usedmemoryInstance.resize()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getList()
|
||||
</script>
|
||||
246
src/views/monitor/cache/list.vue
vendored
@ -1,246 +0,0 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="8">
|
||||
<el-card style="height: calc(100vh - 125px)">
|
||||
<template #header>
|
||||
<Collection style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">缓存列表</span>
|
||||
<el-button
|
||||
style="float: right; padding: 3px 0"
|
||||
link
|
||||
type="primary"
|
||||
icon="Refresh"
|
||||
@click="refreshCacheNames()"
|
||||
></el-button>
|
||||
</template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="cacheNames"
|
||||
:height="tableHeight"
|
||||
highlight-current-row
|
||||
@row-click="getCacheKeys"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column
|
||||
label="序号"
|
||||
width="60"
|
||||
type="index"
|
||||
></el-table-column>
|
||||
|
||||
<el-table-column
|
||||
label="缓存名称"
|
||||
align="center"
|
||||
prop="cacheName"
|
||||
:show-overflow-tooltip="true"
|
||||
:formatter="nameFormatter"
|
||||
></el-table-column>
|
||||
|
||||
<el-table-column
|
||||
label="备注"
|
||||
align="center"
|
||||
prop="remark"
|
||||
:show-overflow-tooltip="true"
|
||||
/>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
width="60"
|
||||
align="center"
|
||||
class-name="small-padding fixed-width"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
icon="Delete"
|
||||
@click="handleClearCacheName(scope.row)"
|
||||
></el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card style="height: calc(100vh - 125px)">
|
||||
<template #header>
|
||||
<Key style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">键名列表</span>
|
||||
<el-button
|
||||
style="float: right; padding: 3px 0"
|
||||
link
|
||||
type="primary"
|
||||
icon="Refresh"
|
||||
@click="refreshCacheKeys()"
|
||||
></el-button>
|
||||
</template>
|
||||
<el-table
|
||||
v-loading="subLoading"
|
||||
:data="cacheKeys"
|
||||
:height="tableHeight"
|
||||
highlight-current-row
|
||||
@row-click="handleCacheValue"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column
|
||||
label="序号"
|
||||
width="60"
|
||||
type="index"
|
||||
></el-table-column>
|
||||
<el-table-column
|
||||
label="缓存键名"
|
||||
align="center"
|
||||
:show-overflow-tooltip="true"
|
||||
:formatter="keyFormatter"
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="操作"
|
||||
width="60"
|
||||
align="center"
|
||||
class-name="small-padding fixed-width"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
icon="Delete"
|
||||
@click="handleClearCacheKey(scope.row)"
|
||||
></el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8">
|
||||
<el-card :bordered="false" style="height: calc(100vh - 125px)">
|
||||
<template #header>
|
||||
<Document style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">缓存内容</span>
|
||||
<el-button
|
||||
style="float: right; padding: 3px 0"
|
||||
link
|
||||
type="primary"
|
||||
icon="Refresh"
|
||||
@click="handleClearCacheAll()"
|
||||
>清理全部</el-button
|
||||
>
|
||||
</template>
|
||||
<el-form :model="cacheForm">
|
||||
<el-row :gutter="32">
|
||||
<el-col :offset="1" :span="22">
|
||||
<el-form-item label="缓存名称:" prop="cacheName">
|
||||
<el-input v-model="cacheForm.cacheName" :readOnly="true" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :offset="1" :span="22">
|
||||
<el-form-item label="缓存键名:" prop="cacheKey">
|
||||
<el-input v-model="cacheForm.cacheKey" :readOnly="true" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :offset="1" :span="22">
|
||||
<el-form-item label="缓存内容:" prop="cacheValue">
|
||||
<el-input
|
||||
v-model="cacheForm.cacheValue"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
:readOnly="true"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="CacheList">
|
||||
import { listCacheName, listCacheKey, getCacheValue, clearCacheName, clearCacheKey, clearCacheAll } from "@/api/monitor/cache"
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
|
||||
const cacheNames = ref([])
|
||||
const cacheKeys = ref([])
|
||||
const cacheForm = ref({})
|
||||
const loading = ref(true)
|
||||
const subLoading = ref(false)
|
||||
const nowCacheName = ref("")
|
||||
const tableHeight = ref(window.innerHeight - 200)
|
||||
|
||||
/** 查询缓存名称列表 */
|
||||
function getCacheNames() {
|
||||
loading.value = true
|
||||
listCacheName().then(response => {
|
||||
cacheNames.value = response.data
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 刷新缓存名称列表 */
|
||||
function refreshCacheNames() {
|
||||
getCacheNames()
|
||||
proxy.$modal.msgSuccess("刷新缓存列表成功")
|
||||
}
|
||||
|
||||
/** 清理指定名称缓存 */
|
||||
function handleClearCacheName(row) {
|
||||
clearCacheName(row.cacheName).then(response => {
|
||||
proxy.$modal.msgSuccess("清理缓存名称[" + row.cacheName + "]成功")
|
||||
getCacheKeys()
|
||||
})
|
||||
}
|
||||
|
||||
/** 查询缓存键名列表 */
|
||||
function getCacheKeys(row) {
|
||||
const cacheName = row !== undefined ? row.cacheName : nowCacheName.value
|
||||
if (cacheName === "") {
|
||||
return
|
||||
}
|
||||
subLoading.value = true
|
||||
listCacheKey(cacheName).then(response => {
|
||||
cacheKeys.value = response.data
|
||||
subLoading.value = false
|
||||
nowCacheName.value = cacheName
|
||||
})
|
||||
}
|
||||
|
||||
/** 刷新缓存键名列表 */
|
||||
function refreshCacheKeys() {
|
||||
getCacheKeys()
|
||||
proxy.$modal.msgSuccess("刷新键名列表成功")
|
||||
}
|
||||
|
||||
/** 清理指定键名缓存 */
|
||||
function handleClearCacheKey(cacheKey) {
|
||||
clearCacheKey(cacheKey).then(response => {
|
||||
proxy.$modal.msgSuccess("清理缓存键名[" + cacheKey + "]成功")
|
||||
getCacheKeys()
|
||||
})
|
||||
}
|
||||
|
||||
/** 列表前缀去除 */
|
||||
function nameFormatter(row) {
|
||||
return row.cacheName.replace(":", "")
|
||||
}
|
||||
|
||||
/** 键名前缀去除 */
|
||||
function keyFormatter(cacheKey) {
|
||||
return cacheKey.replace(nowCacheName.value, "")
|
||||
}
|
||||
|
||||
/** 查询缓存内容详细 */
|
||||
function handleCacheValue(cacheKey) {
|
||||
getCacheValue(nowCacheName.value, cacheKey).then(response => {
|
||||
cacheForm.value = response.data
|
||||
})
|
||||
}
|
||||
|
||||
/** 清理全部缓存 */
|
||||
function handleClearCacheAll() {
|
||||
clearCacheAll().then(response => {
|
||||
proxy.$modal.msgSuccess("清理全部缓存成功")
|
||||
})
|
||||
}
|
||||
|
||||
getCacheNames()
|
||||
</script>
|
||||
@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<i-frame v-model:src="url"></i-frame>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import iFrame from '@/components/iFrame'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
const url = ref(import.meta.env.VITE_APP_BASE_API + '/druid/login.html')
|
||||
</script>
|
||||
@ -1,217 +0,0 @@
|
||||
<template>
|
||||
<el-dialog :title="type === 'log' ? '调度日志详细' : '任务详细'" v-model="dialogVisible" width="780px" append-to-body>
|
||||
<div class="detail-wrap">
|
||||
<template v-if="type === 'log'">
|
||||
<!-- 基本信息 -->
|
||||
<div class="detail-card">
|
||||
<div class="detail-card-title">
|
||||
<el-icon><InfoFilled /></el-icon> 基本信息
|
||||
</div>
|
||||
<el-row class="detail-row">
|
||||
<el-col :span="12">
|
||||
<div class="detail-item"><span class="detail-label">日志编号</span><span class="detail-value">{{ form.jobLogId }}</span></div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">执行状态</span>
|
||||
<el-tag v-if="form.status == 0" type="success" size="small">正常</el-tag>
|
||||
<el-tag v-else type="danger" size="small">失败</el-tag>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row class="detail-row">
|
||||
<el-col :span="12">
|
||||
<div class="detail-item"><span class="detail-label">开始时间</span><span class="detail-value">{{ form.startTime }}</span></div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="detail-item"><span class="detail-label">结束时间</span><span class="detail-value">{{ form.endTime }}</span></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row class="detail-row">
|
||||
<el-col :span="12">
|
||||
<div class="detail-item"><span class="detail-label">记录时间</span><span class="detail-value">{{ form.createTime }}</span></div>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="form.status == 0 && form.startTime && form.endTime">
|
||||
<div class="detail-item"><span class="detail-label">执行耗时</span><span class="detail-value">{{ costTime }} 毫秒</span></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<!-- 任务信息 -->
|
||||
<div class="detail-card">
|
||||
<div class="detail-card-title">
|
||||
<el-icon><Clock /></el-icon> 任务信息
|
||||
</div>
|
||||
<el-row class="detail-row">
|
||||
<el-col :span="12">
|
||||
<div class="detail-item"><span class="detail-label">任务名称</span><span class="detail-value">{{ form.jobName }}</span></div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">任务分组</span>
|
||||
<dict-tag :options="sys_job_group" :value="form.jobGroup" />
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row class="detail-row">
|
||||
<el-col :span="24">
|
||||
<div class="detail-item"><span class="detail-label">日志信息</span><span class="detail-value">{{ form.jobMessage }}</span></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<!-- 调用目标 -->
|
||||
<div class="detail-card">
|
||||
<div class="detail-card-title">
|
||||
<el-icon><Operation /></el-icon> 调用目标
|
||||
</div>
|
||||
<div class="code-body">
|
||||
<div class="code-wrap"><pre class="code-pre">{{ form.invokeTarget || '(无)' }}</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 异常信息 -->
|
||||
<div class="detail-card" v-if="form.status == 1">
|
||||
<div class="detail-card-title error-title">
|
||||
<el-icon><Warning /></el-icon> 异常信息
|
||||
</div>
|
||||
<div class="error-body"><div class="error-msg">{{ form.exceptionInfo }}</div></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- 任务配置 -->
|
||||
<div class="detail-card">
|
||||
<div class="detail-card-title">
|
||||
<el-icon><Setting /></el-icon> 任务配置
|
||||
</div>
|
||||
<el-row class="detail-row">
|
||||
<el-col :span="12">
|
||||
<div class="detail-item"><span class="detail-label">任务编号</span><span class="detail-value">{{ form.jobId }}</span></div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="detail-item"><span class="detail-label">任务名称</span><span class="detail-value">{{ form.jobName }}</span></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row class="detail-row">
|
||||
<el-col :span="12">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">任务分组</span>
|
||||
<dict-tag :options="sys_job_group" :value="form.jobGroup" />
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">执行状态</span>
|
||||
<el-tag v-if="form.status == 0" type="success" size="small">正常</el-tag>
|
||||
<el-tag v-else type="info" size="small">暂停</el-tag>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<!-- 调度信息 -->
|
||||
<div class="detail-card">
|
||||
<div class="detail-card-title">
|
||||
<el-icon><Calendar /></el-icon> 调度信息
|
||||
</div>
|
||||
<el-row class="detail-row">
|
||||
<el-col :span="12">
|
||||
<div class="detail-item"><span class="detail-label">cron 表达式</span><span class="detail-value mono">{{ form.cronExpression }}</span></div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="detail-item"><span class="detail-label">下次执行时间</span><span class="detail-value">{{ parseTime(form.nextValidTime) }}</span></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row class="detail-row">
|
||||
<el-col :span="12">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">执行策略</span>
|
||||
<el-tag v-if="form.misfirePolicy == 0" type="info" size="small">默认策略</el-tag>
|
||||
<el-tag v-else-if="form.misfirePolicy == 1" type="warning" size="small">立即执行</el-tag>
|
||||
<el-tag v-else-if="form.misfirePolicy == 2" type="primary" size="small">执行一次</el-tag>
|
||||
<el-tag v-else-if="form.misfirePolicy == 3" type="danger" size="small">放弃执行</el-tag>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">并发执行</span>
|
||||
<el-tag v-if="form.concurrent == 0" type="success" size="small">允许</el-tag>
|
||||
<el-tag v-else type="danger" size="small">禁止</el-tag>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<!-- 执行方法 -->
|
||||
<div class="detail-card">
|
||||
<div class="detail-card-title">
|
||||
<el-icon><Operation /></el-icon> 执行方法
|
||||
</div>
|
||||
<div class="code-body">
|
||||
<div class="code-wrap"><pre class="code-pre">{{ form.invokeTarget || '(无)' }}</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 元信息 -->
|
||||
<div class="detail-card">
|
||||
<div class="detail-card-title">
|
||||
<el-icon><Document /></el-icon> 元信息
|
||||
</div>
|
||||
<el-row class="detail-row">
|
||||
<el-col :span="12">
|
||||
<div class="detail-item"><span class="detail-label">创建人</span><span class="detail-value">{{ form.createBy || '-' }}</span></div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="detail-item"><span class="detail-label">创建时间</span><span class="detail-value">{{ form.createTime }}</span></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row class="detail-row">
|
||||
<el-col :span="12">
|
||||
<div class="detail-item"><span class="detail-label">更新人</span><span class="detail-value">{{ form.updateBy || '-' }}</span></div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="detail-item"><span class="detail-label">更新时间</span><span class="detail-value">{{ form.updateTime || '-' }}</span></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row class="detail-row" v-if="form.remark">
|
||||
<el-col :span="24">
|
||||
<div class="detail-item"><span class="detail-label">备注</span><span class="detail-value">{{ form.remark }}</span></div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">关 闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup name="JobDetail">
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
row: { type: Object, default: () => ({}) },
|
||||
// 'job' 任务详细 | 'log' 调度日志详细
|
||||
type: { type: String, default: 'job' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible'])
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
})
|
||||
|
||||
const { proxy } = getCurrentInstance()
|
||||
const { sys_job_group } = useDict('sys_job_group')
|
||||
|
||||
const form = computed(() => props.row || {})
|
||||
|
||||
const costTime = computed(() => {
|
||||
if (!form.value.startTime || !form.value.endTime) return 0
|
||||
return new Date(form.value.endTime).getTime() - new Date(form.value.startTime).getTime()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-label {
|
||||
width: 80px;
|
||||
}
|
||||
</style>
|
||||
@ -1,435 +0,0 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch">
|
||||
<el-form-item label="任务名称" prop="jobName">
|
||||
<el-input
|
||||
v-model="queryParams.jobName"
|
||||
placeholder="请输入任务名称"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="handleQuery"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="任务组名" prop="jobGroup">
|
||||
<el-select v-model="queryParams.jobGroup" placeholder="请选择任务组名" clearable style="width: 200px">
|
||||
<el-option
|
||||
v-for="dict in sys_job_group"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="任务状态" prop="status">
|
||||
<el-select v-model="queryParams.status" placeholder="请选择任务状态" clearable style="width: 200px">
|
||||
<el-option
|
||||
v-for="dict in sys_job_status"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
icon="Plus"
|
||||
@click="handleAdd"
|
||||
v-hasPermi="['monitor:job:add']"
|
||||
>新增</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="success"
|
||||
plain
|
||||
icon="Edit"
|
||||
:disabled="single"
|
||||
@click="handleUpdate"
|
||||
v-hasPermi="['monitor:job:edit']"
|
||||
>修改</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
icon="Delete"
|
||||
:disabled="multiple"
|
||||
@click="handleDelete"
|
||||
v-hasPermi="['monitor:job:remove']"
|
||||
>删除</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="warning"
|
||||
plain
|
||||
icon="Download"
|
||||
@click="handleExport"
|
||||
v-hasPermi="['monitor:job:export']"
|
||||
>导出</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
type="info"
|
||||
plain
|
||||
icon="Operation"
|
||||
@click="handleJobLog"
|
||||
v-hasPermi="['monitor:job:query']"
|
||||
>日志</el-button>
|
||||
</el-col>
|
||||
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="jobList" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column label="任务编号" width="100" align="center" prop="jobId" />
|
||||
<el-table-column label="任务名称" align="center" :show-overflow-tooltip="true">
|
||||
<template #default="scope">
|
||||
<a class="link-type" style="cursor:pointer" @click="handleView(scope.row)">{{ scope.row.jobName }}</a>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="任务组名" align="center" prop="jobGroup">
|
||||
<template #default="scope">
|
||||
<dict-tag :options="sys_job_group" :value="scope.row.jobGroup" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="调用目标字符串" align="center" prop="invokeTarget" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="cron执行表达式" align="center" prop="cronExpression" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="状态" align="center">
|
||||
<template #default="scope">
|
||||
<el-switch
|
||||
v-model="scope.row.status"
|
||||
active-value="0"
|
||||
inactive-value="1"
|
||||
@change="handleStatusChange(scope.row)"
|
||||
></el-switch>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" width="200" class-name="small-padding fixed-width">
|
||||
<template #default="scope">
|
||||
<el-tooltip content="修改" placement="top">
|
||||
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['monitor:job:edit']"></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['monitor:job:remove']"></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="执行一次" placement="top">
|
||||
<el-button link type="primary" icon="CaretRight" @click="handleRun(scope.row)" v-hasPermi="['monitor:job:changeStatus']"></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="调度日志" placement="top">
|
||||
<el-button link type="primary" icon="Operation" @click="handleJobLog(scope.row)" v-hasPermi="['monitor:job:query']"></el-button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
:total="total"
|
||||
v-model:page="queryParams.pageNum"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
@pagination="getList"
|
||||
/>
|
||||
|
||||
<!-- 添加或修改定时任务对话框 -->
|
||||
<el-dialog :title="title" v-model="open" width="820px" append-to-body>
|
||||
<el-form ref="jobRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="任务名称" prop="jobName">
|
||||
<el-input v-model="form.jobName" placeholder="请输入任务名称" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="任务分组" prop="jobGroup">
|
||||
<el-select v-model="form.jobGroup" placeholder="请选择">
|
||||
<el-option
|
||||
v-for="dict in sys_job_group"
|
||||
:key="dict.value"
|
||||
:label="dict.label"
|
||||
:value="dict.value"
|
||||
></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item prop="invokeTarget">
|
||||
<template #label>
|
||||
<span>
|
||||
调用方法
|
||||
<el-tooltip placement="top">
|
||||
<template #content>
|
||||
<div>
|
||||
Bean调用示例:ryTask.ryParams('ry')
|
||||
<br />Class 类调用示例:com.cmvr.quartz.task.RyTask.ryParams('ry')
|
||||
<br />参数说明:支持字符串,布尔类型,长整型,浮点型,整型
|
||||
</div>
|
||||
</template>
|
||||
<el-icon><question-filled /></el-icon>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</template>
|
||||
<el-input v-model="form.invokeTarget" placeholder="请输入调用目标字符串" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24">
|
||||
<el-form-item label="cron表达式" prop="cronExpression">
|
||||
<el-input v-model="form.cronExpression" placeholder="请输入cron执行表达式">
|
||||
<template #append>
|
||||
<el-button type="primary" @click="handleShowCron">
|
||||
生成表达式
|
||||
<i class="el-icon-time el-icon--right"></i>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="24" v-if="form.jobId !== undefined">
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="form.status">
|
||||
<el-radio
|
||||
v-for="dict in sys_job_status"
|
||||
:key="dict.value"
|
||||
:value="dict.value"
|
||||
>{{ dict.label }}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="执行策略" prop="misfirePolicy">
|
||||
<el-radio-group v-model="form.misfirePolicy">
|
||||
<el-radio-button value="1">立即执行</el-radio-button>
|
||||
<el-radio-button value="2">执行一次</el-radio-button>
|
||||
<el-radio-button value="3">放弃执行</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="是否并发" prop="concurrent">
|
||||
<el-radio-group v-model="form.concurrent">
|
||||
<el-radio-button value="0">允许</el-radio-button>
|
||||
<el-radio-button value="1">禁止</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||
<el-button @click="cancel">取 消</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="Cron表达式生成器" v-model="openCron" append-to-body destroy-on-close>
|
||||
<crontab ref="crontabRef" @hide="openCron=false" @fill="crontabFill" :expression="expression"></crontab>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 任务详细 -->
|
||||
<job-detail v-model:visible="openView" :row="form" type="job" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="Job">
|
||||
import Crontab from '@/components/Crontab'
|
||||
import JobDetail from './detail'
|
||||
import { listJob, getJob, delJob, addJob, updateJob, runJob, changeJobStatus } from "@/api/monitor/job"
|
||||
|
||||
const router = useRouter()
|
||||
const { proxy } = getCurrentInstance()
|
||||
const { sys_job_group, sys_job_status } = useDict("sys_job_group", "sys_job_status")
|
||||
|
||||
const jobList = ref([])
|
||||
const open = ref(false)
|
||||
const loading = ref(true)
|
||||
const showSearch = ref(true)
|
||||
const ids = ref([])
|
||||
const single = ref(true)
|
||||
const multiple = ref(true)
|
||||
const total = ref(0)
|
||||
const title = ref("")
|
||||
const openView = ref(false)
|
||||
const openCron = ref(false)
|
||||
const expression = ref("")
|
||||
|
||||
const data = reactive({
|
||||
form: {},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
jobName: undefined,
|
||||
jobGroup: undefined,
|
||||
status: undefined
|
||||
},
|
||||
rules: {
|
||||
jobName: [{ required: true, message: "任务名称不能为空", trigger: "blur" }],
|
||||
invokeTarget: [{ required: true, message: "调用目标字符串不能为空", trigger: "blur" }],
|
||||
cronExpression: [{ required: true, message: "cron执行表达式不能为空", trigger: "change" }]
|
||||
}
|
||||
})
|
||||
|
||||
const { queryParams, form, rules } = toRefs(data)
|
||||
|
||||
/** 查询定时任务列表 */
|
||||
function getList() {
|
||||
loading.value = true
|
||||
listJob(queryParams.value).then(response => {
|
||||
jobList.value = response.rows
|
||||
total.value = response.total
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 取消按钮 */
|
||||
function cancel() {
|
||||
open.value = false
|
||||
reset()
|
||||
}
|
||||
|
||||
/** 表单重置 */
|
||||
function reset() {
|
||||
form.value = {
|
||||
jobId: undefined,
|
||||
jobName: undefined,
|
||||
jobGroup: undefined,
|
||||
invokeTarget: undefined,
|
||||
cronExpression: undefined,
|
||||
misfirePolicy: '1',
|
||||
concurrent: '1',
|
||||
status: "0"
|
||||
}
|
||||
proxy.resetForm("jobRef")
|
||||
}
|
||||
|
||||
/** 搜索按钮操作 */
|
||||
function handleQuery() {
|
||||
queryParams.value.pageNum = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
/** 重置按钮操作 */
|
||||
function resetQuery() {
|
||||
proxy.resetForm("queryRef")
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
// 多选框选中数据
|
||||
function handleSelectionChange(selection) {
|
||||
ids.value = selection.map(item => item.jobId)
|
||||
single.value = selection.length != 1
|
||||
multiple.value = !selection.length
|
||||
}
|
||||
|
||||
// 任务状态修改
|
||||
function handleStatusChange(row) {
|
||||
let text = row.status === "0" ? "启用" : "停用"
|
||||
proxy.$modal.confirm('确认要"' + text + '""' + row.jobName + '"任务吗?').then(function () {
|
||||
return changeJobStatus(row.jobId, row.status)
|
||||
}).then(() => {
|
||||
proxy.$modal.msgSuccess(text + "成功")
|
||||
}).catch(function () {
|
||||
row.status = row.status === "0" ? "1" : "0"
|
||||
})
|
||||
}
|
||||
|
||||
/* 立即执行一次 */
|
||||
function handleRun(row) {
|
||||
proxy.$modal.confirm('确认要立即执行一次"' + row.jobName + '"任务吗?').then(function () {
|
||||
return runJob(row.jobId, row.jobGroup)
|
||||
}).then(() => {
|
||||
proxy.$modal.msgSuccess("执行成功")
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/** 任务详细信息 */
|
||||
function handleView(row) {
|
||||
getJob(row.jobId).then(response => {
|
||||
form.value = response.data
|
||||
openView.value = true
|
||||
})
|
||||
}
|
||||
|
||||
/** cron表达式按钮操作 */
|
||||
function handleShowCron() {
|
||||
expression.value = form.value.cronExpression
|
||||
openCron.value = true
|
||||
}
|
||||
|
||||
/** 确定后回传值 */
|
||||
function crontabFill(value) {
|
||||
form.value.cronExpression = value
|
||||
}
|
||||
|
||||
/** 任务日志列表查询 */
|
||||
function handleJobLog(row) {
|
||||
const jobId = row.jobId || 0
|
||||
router.push('/monitor/job-log/index/' + jobId)
|
||||
}
|
||||
|
||||
/** 新增按钮操作 */
|
||||
function handleAdd() {
|
||||
reset()
|
||||
open.value = true
|
||||
title.value = "添加任务"
|
||||
}
|
||||
|
||||
/** 修改按钮操作 */
|
||||
function handleUpdate(row) {
|
||||
reset()
|
||||
const jobId = row.jobId || ids.value
|
||||
getJob(jobId).then(response => {
|
||||
form.value = response.data
|
||||
open.value = true
|
||||
title.value = "修改任务"
|
||||
})
|
||||
}
|
||||
|
||||
/** 提交按钮 */
|
||||
function submitForm() {
|
||||
proxy.$refs["jobRef"].validate(valid => {
|
||||
if (valid) {
|
||||
if (form.value.jobId != undefined) {
|
||||
updateJob(form.value).then(response => {
|
||||
proxy.$modal.msgSuccess("修改成功")
|
||||
open.value = false
|
||||
getList()
|
||||
})
|
||||
} else {
|
||||
addJob(form.value).then(response => {
|
||||
proxy.$modal.msgSuccess("新增成功")
|
||||
open.value = false
|
||||
getList()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除按钮操作 */
|
||||
function handleDelete(row) {
|
||||
const jobIds = row.jobId || ids.value
|
||||
proxy.$modal.confirm('是否确认删除定时任务编号为"' + jobIds + '"的数据项?').then(function () {
|
||||
return delJob(jobIds)
|
||||
}).then(() => {
|
||||
getList()
|
||||
proxy.$modal.msgSuccess("删除成功")
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/** 导出按钮操作 */
|
||||
function handleExport() {
|
||||
proxy.download("monitor/job/export", {
|
||||
...queryParams.value,
|
||||
}, `job_${new Date().getTime()}.xlsx`)
|
||||
}
|
||||
|
||||
getList()
|
||||
</script>
|
||||