Merge remote-tracking branch 'origin/feature/unified-resource-center' into dev

# Conflicts:
#	src/views/flow/components/LoopIterationResults.vue
#	src/views/flow/components/NodeState.vue
#	src/views/flow/components/NodeTitle.vue
#	src/views/flow/components/ParamsDrawer.vue
#	src/views/flow/components/SubFlowExecutionResults.vue
#	src/views/flow/components/TaskExecutionLog.vue
#	src/views/flow/components/TestRun.vue
#	src/views/flow/components/params/BasicNodeParams.vue
#	src/views/flow/components/params/CodeNodeParams.vue
#	src/views/flow/components/params/DeviceUniversal.vue
#	src/views/flow/components/params/EndParams.vue
#	src/views/flow/components/params/HttpNodeParams.vue
#	src/views/flow/components/params/MediaAnalysisParams.vue
#	src/views/flow/components/params/SdAgentNodeParams.vue
#	src/views/flow/components/params/SubFlowParams.vue
#	src/views/flow/components/params/SwitchNodeParams.vue
#	src/views/flow/components/params/useQuote.js
#	src/views/flow/config.js
#	src/views/flow/index.vue
#	src/views/flow/nodes/function/subFlow.vue
#	src/views/flow/nodes/function/switch.vue
#	src/views/flow/vue-flow/FlowNode.vue
This commit is contained in:
zhanghao 2026-08-26 14:16:03 +08:00
commit 9e438db4e9
203 changed files with 161 additions and 47512 deletions

View File

@ -7,8 +7,8 @@ VITE_APP_ENV = 'development'
# 招商车研物联网平台/开发环境
VITE_APP_BASE_API = '/dev-api'
VITE_API_URL = http://localhost:14080
VITE_WS_URL = ws://localhost:14080/ws
VITE_API_URL = http://192.168.0.199:14080
VITE_WS_URL = ws://192.168.0.199:14080/ws
VITE_PORT = 5174
VITE_INSPECTION_TYPE = "inspection"
VITE_MINIO_URL = "http://192.168.28.10:9000/cmvr-iot/"

View File

@ -14,6 +14,7 @@
"preview": "vite preview"
},
"dependencies": {
"@cmvr/iot-common": "1.0.0",
"@element-plus/icons-vue": "2.3.2",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",

81
pnpm-lock.yaml generated
View File

@ -11,6 +11,9 @@ importers:
.:
dependencies:
'@cmvr/iot-common':
specifier: 1.0.0
version: 1.0.0(@element-plus/icons-vue@2.3.2(vue@3.5.26))(core-js@3.49.0)(element-plus@2.13.1(vue@3.5.26))(pinia@3.0.4(vue@3.5.26))(vue-router@4.6.4(vue@3.5.26))(vue@3.5.26)
'@element-plus/icons-vue':
specifier: 2.3.2
version: 2.3.2(vue@3.5.26)
@ -182,6 +185,15 @@ packages:
'@bufbuild/protobuf@2.13.0':
resolution: {integrity: sha512-acq7c49vxfm1ggJ95P70TX7ABDM0vxr1SYD3BB0o0jnBLB4OAqeHyKuN+cD3w80gXEDQ2zxHpR6CUeA+O/aU9g==}
'@cmvr/iot-common@1.0.0':
resolution: {integrity: sha512-EIdZdRRgyhb4FfclQ6SoDT3NwIAY/7uI2re+zcwwcnc0a0ELKizTn4POFZt/O4mhr5Ikeg8J98cRB/cchV3E9g==}
peerDependencies:
'@element-plus/icons-vue': ^2.3.2
element-plus: ^2.13.1
pinia: ^3.0.4
vue: ^3.5.26
vue-router: ^4.6.4
'@codemirror/autocomplete@6.20.3':
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
@ -487,42 +499,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.6.0':
resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.6.0':
resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.6.0':
resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.6.0':
resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.6.0':
resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.6.0':
resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==}
@ -594,79 +600,66 @@ packages:
resolution: {integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.62.3':
resolution: {integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.62.3':
resolution: {integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.62.3':
resolution: {integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.62.3':
resolution: {integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.62.3':
resolution: {integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.62.3':
resolution: {integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.62.3':
resolution: {integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.62.3':
resolution: {integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.62.3':
resolution: {integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.62.3':
resolution: {integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.62.3':
resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.62.3':
resolution: {integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.62.3':
resolution: {integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==}
@ -2344,56 +2337,48 @@ packages:
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
libc: glibc
sass-embedded-linux-arm@1.97.2:
resolution: {integrity: sha512-yDRe1yifGHl6kibkDlRIJ2ZzAU03KJ1AIvsAh4dsIDgK5jx83bxZLV1ZDUv7a8KK/iV/80LZnxnu/92zp99cXQ==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
libc: glibc
sass-embedded-linux-musl-arm64@1.97.2:
resolution: {integrity: sha512-NfUqZSjHwnHvpSa7nyNxbWfL5obDjNBqhHUYmqbHUcmqBpFfHIQsUPgXME9DKn1yBlBc3mWnzMxRoucdYTzd2Q==}
engines: {node: '>=14.0.0'}
cpu: [arm64]
os: [linux]
libc: musl
sass-embedded-linux-musl-arm@1.97.2:
resolution: {integrity: sha512-GIO6xfAtahJAWItvsXZ3MD1HM6s8cKtV1/HL088aUpKJaw/2XjTCveiOO2AdgMpLNztmq9DZ1lx5X5JjqhS45g==}
engines: {node: '>=14.0.0'}
cpu: [arm]
os: [linux]
libc: musl
sass-embedded-linux-musl-riscv64@1.97.2:
resolution: {integrity: sha512-qtM4dJ5gLfvyTZ3QencfNbsTEShIWImSEpkThz+Y2nsCMbcMP7/jYOA03UWgPfEOKSehQQ7EIau7ncbFNoDNPQ==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
libc: musl
sass-embedded-linux-musl-x64@1.97.2:
resolution: {integrity: sha512-ZAxYOdmexcnxGnzdsDjYmNe3jGj+XW3/pF/n7e7r8y+5c6D2CQRrCUdapLgaqPt1edOPQIlQEZF8q5j6ng21yw==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
libc: musl
sass-embedded-linux-riscv64@1.97.2:
resolution: {integrity: sha512-reVwa9ZFEAOChXpDyNB3nNHHyAkPMD+FTctQKECqKiVJnIzv2EaFF6/t0wzyvPgBKeatA8jszAIeOkkOzbYVkQ==}
engines: {node: '>=14.0.0'}
cpu: [riscv64]
os: [linux]
libc: glibc
sass-embedded-linux-x64@1.97.2:
resolution: {integrity: sha512-bvAdZQsX3jDBv6m4emaU2OMTpN0KndzTAMgJZZrKUgiC0qxBmBqbJG06Oj/lOCoXGCxAvUOheVYpezRTF+Feog==}
engines: {node: '>=14.0.0'}
cpu: [x64]
os: [linux]
libc: glibc
sass-embedded-unknown-all@1.97.2:
resolution: {integrity: sha512-86tcYwohjPgSZtgeU9K4LikrKBJNf8ZW/vfsFbdzsRlvc73IykiqanufwQi5qIul0YHuu9lZtDWyWxM2dH/Rsg==}
@ -2993,6 +2978,48 @@ snapshots:
'@bufbuild/protobuf@2.13.0': {}
'@cmvr/iot-common@1.0.0(@element-plus/icons-vue@2.3.2(vue@3.5.26))(core-js@3.49.0)(element-plus@2.13.1(vue@3.5.26))(pinia@3.0.4(vue@3.5.26))(vue-router@4.6.4(vue@3.5.26))(vue@3.5.26)':
dependencies:
'@element-plus/icons-vue': 2.3.2(vue@3.5.26)
'@vue-flow/background': 1.3.2(@vue-flow/core@1.48.2(vue@3.5.26))(vue@3.5.26)
'@vue-flow/controls': 1.1.3(@vue-flow/core@1.48.2(vue@3.5.26))(vue@3.5.26)
'@vue-flow/core': 1.48.2(vue@3.5.26)
'@vueup/vue-quill': 1.2.0(vue@3.5.26)
'@vueuse/core': 14.1.0(vue@3.5.26)
ace-builds: 1.44.0
axios: 1.13.2
clipboard: 2.0.11
dayjs: 1.11.21
echarts: 5.6.0
element-plus: 2.13.1(vue@3.5.26)
file-saver: 2.0.5
fuse.js: 7.1.0
js-beautify: 1.15.4
js-cookie: 3.0.5
jsencrypt: 3.3.2
json-editor-vue: 0.18.1(vue@3.5.26)
lodash: 4.18.1
mitt: 3.0.1
monaco-editor: 0.55.1
nprogress: 0.2.0
pako: 2.2.0
pinia: 3.0.4(vue@3.5.26)
sortablejs: 1.15.7
splitpanes: 3.1.5
uuid: 11.1.1
vue: 3.5.26
vue-cropper: 1.1.1
vue-router: 4.6.4(vue@3.5.26)
vue3-ace-editor: 2.2.4(ace-builds@1.44.0)(vue@3.5.26)
vue3-json-viewer: 2.3.0(vue@3.5.26)
vuedraggable: 4.1.0(vue@3.5.26)
xgplayer: 3.0.26(core-js@3.49.0)
transitivePeerDependencies:
- '@typescript-eslint/types'
- '@vue/composition-api'
- core-js
- debug
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4

View File

@ -1,26 +0,0 @@
import request from '@/utils/request'
// 移动机械臂
export function moveJ(data) {
return request({
url: '/api/humanoid/moveJ',
method: 'post',
data: data
})
}
// 获取机械臂状态
export function status(data) {
return request({
url: '/api/humanoid/status',
method: 'post',
data: data
})
}
export function clearFault(params) {
return request({
url: '/api/arm/clearFault',
method: 'get',
params
})
}

View File

@ -1,9 +0,0 @@
import request from '@/utils/request'
export function getRgbStreamUrl(robotId, deviceId) {
return request({
url: '/api/edge/camera/rgb-stream-url',
method: 'get',
params: { robotId, deviceId }
})
}

View File

@ -1,19 +0,0 @@
import request from '@/utils/request'
// 获取灵巧手状态
export function status(query) {
return request({
url: '/api/dexHand/status',
method: 'get',
params: query
})
}
// 设置灵巧手弯曲角度
export function setDexHandAngle(query) {
return request({
url: '/api/dexHand/angle',
method: 'get',
params: query
})
}

View File

@ -1,46 +0,0 @@
import request from '@/utils/request'
// 开始录音
export function startMicrophoneApi(data) {
return request({
url: '/api/Microphone/start',
method: 'post',
data: data
})
}
// 暂停录音
export function pauseMicrophoneApi(data) {
return request({
url: '/api/Microphone/pause',
method: 'post',
data: data
})
}
// 恢复录音
export function resumeMicrophoneApi(data) {
return request({
url: '/api/Microphone/resume',
method: 'post',
data: data
})
}
// 停止录音
export function stopMicrophoneApi(data) {
return request({
url: '/api/Microphone/stop',
method: 'post',
data: data
})
}
// 播放音频
export function playSpeaker(data) {
return request({
url: '/api/Speaker/play',
method: 'post',
data: data
})
}

View File

@ -1,72 +0,0 @@
import request from '@/utils/request'
// 暂停播放
export function pause(data) {
return request({
url: '/api/Speaker/pause',
method: 'post',
data: data
})
}
// 播放音频
export function play(data) {
return request({
url: '/api/Speaker/play',
method: 'post',
data: data
})
}
// 恢复播放
export function resume(data) {
return request({
url: '/api/Speaker/resume',
method: 'post',
data: data
})
}
// 获取扬声器状态
export function status(query) {
return request({
url: '/api/Speaker/status',
method: 'get',
params: query
})
}
// 停止播放
export function stop(data) {
return request({
url: '/api/Speaker/stop',
method: 'post',
data: data
})
}
// 获取音量
export function getVolume(query) {
return request({
url: '/api/Speaker/volume',
method: 'get',
params: query
})
}
// 设置音量
export function setVolume(data) {
return request({
url: '/api/Speaker/volume',
method: 'post',
data: data
})
}
// 设置音量
export function getAll() {
return request({
url: '/api/Speaker/getAll',
method: 'get',
})
}

View File

@ -1,14 +0,0 @@
import request from "@/utils/request.js";
export function coordinateMarking(data) {
return request({
url: '/show/touch/coordinates',
method: 'post',
headers: {
isToken: false,
'Content-Type': 'multipart/form-data',
'Authorization':'Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImIzY2QwYjFjLWNlNjgtNDEwNy1hYzA1LTdmYTBlNmQ4NzJmNiJ9.2N9E9IDrFeCe-yRIBly60N0kYYmCscdrhyd8KHmhw2dxLi7B_A1i8bcWRTtx9YryFB3AhkvZkGauMx0CdZ0TDg'
},
data: data
})
}

View File

@ -1,30 +0,0 @@
import request from '@/utils/request'
const serviceHeaders = {
Authorization: 'eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImFiNmEyY2Y0LWM3MjQtNDgyMy1hM2NiLTk4N2NhZWNmMzZiNCJ9.oq-JVRbQuq_ujQ2V0h2jXC3woHPdMQKhnYJ8ixRm-C6Ez4GqJWyHYgmnkw5dbDyhWGdIUH02uv6XT8UiboHD4g',
isToken: false,
}
export function createTimbre(data) {
return request({ url: '/show/tts/create', method: 'post', headers: { ...serviceHeaders, 'Content-Type': 'multipart/form-data' }, data })
}
export function getTts() {
return request({ url: '/show/tts/query', method: 'get', headers: serviceHeaders })
}
export function createCorpus(data) {
return request({ url: '/show/tts/corpus', method: 'post', headers: serviceHeaders, data })
}
export function getSynthesize(data, config = {}) {
return request({ url: '/show/tts/synthesize', method: 'post', headers: serviceHeaders, responseType: 'blob', data }, config)
}
export function createAdvancedCorpus(data) {
return request({ url: '/show/tts/corpus/advanced', method: 'post', headers: serviceHeaders, data })
}
export function getAdvancedSynthesize(data, config = {}) {
return request({ url: '/show/tts/synthesize/advanced', method: 'post', headers: serviceHeaders, responseType: 'blob', data }, config)
}

View File

@ -1,4 +1,5 @@
import { createApp } from 'vue'
import { configureCmvrCommon } from '@cmvr/iot-common'
import Cookies from 'js-cookie'
@ -11,6 +12,7 @@ import '@/assets/styles/index.scss' // global css
import App from './App'
import store from './store'
import useUserStore from './store/modules/user'
import router from './router'
import directive from './directive' // directive
import WebSocketPlugin from './plugins/websocket'
@ -30,6 +32,16 @@ import { useDict } from '@/utils/dict'
import { getConfigKey } from "@/api/system/config"
import { parseTime, resetForm, addDateRange, handleTree, selectDictLabel, selectDictLabels } from '@/utils/ruoyi'
configureCmvrCommon({
apiBaseURL: import.meta.env.VITE_APP_BASE_API,
tokenKey: 'CMVR-Platform-Token',
storagePrefix: 'cmvr:platform',
router,
loginPath: '/login',
homePath: '/index'
})
import '@cmvr/iot-common/style.css'
// 分页组件
import Pagination from '@/components/Pagination'
// 自定义表格工具组件
@ -81,7 +93,7 @@ app.use(plugins)
app.use(elementIcons)
app.use(WebSocketPlugin, {
url: import.meta.env.VITE_WS_URL,
userId: 'your_user_id'
userId: () => useUserStore(store).id
})
app.component('svg-icon', SvgIcon)

View File

@ -1,10 +1,13 @@
const CachePrefix = 'cmvr:platform:'
const cacheKey = (key) => `${CachePrefix}${key}`
const sessionCache = {
set (key, value) {
if (!sessionStorage) {
return
}
if (key != null && value != null) {
sessionStorage.setItem(key, value)
sessionStorage.setItem(cacheKey(key), value)
}
},
get (key) {
@ -14,7 +17,7 @@ const sessionCache = {
if (key == null) {
return null
}
return sessionStorage.getItem(key)
return sessionStorage.getItem(cacheKey(key))
},
setJSON (key, jsonValue) {
if (jsonValue != null) {
@ -29,7 +32,7 @@ const sessionCache = {
return null
},
remove (key) {
sessionStorage.removeItem(key)
sessionStorage.removeItem(cacheKey(key))
}
}
const localCache = {
@ -38,7 +41,7 @@ const localCache = {
return
}
if (key != null && value != null) {
localStorage.setItem(key, value)
localStorage.setItem(cacheKey(key), value)
}
},
get (key) {
@ -48,7 +51,7 @@ const localCache = {
if (key == null) {
return null
}
return localStorage.getItem(key)
return localStorage.getItem(cacheKey(key))
},
setJSON (key, jsonValue) {
if (jsonValue != null) {
@ -63,7 +66,7 @@ const localCache = {
return null
},
remove (key) {
localStorage.removeItem(key)
localStorage.removeItem(cacheKey(key))
}
}

View File

@ -1,3 +1,5 @@
import { getToken } from '../utils/auth';
const MEDIA_HEADER_SIZE = 26;
const MEDIA_MAGIC = [0x43, 0x4d, 0x56, 0x46]; // CMVF
const MEDIA_MIME_TYPES = {
@ -18,6 +20,11 @@ class WebSocketManager {
this.maxQueueSize = 100;
this.status = false;
this.channelSubscriptions = new Set();
this.connectionUserId = null;
}
resolveUserId() {
return typeof this.userId === 'function' ? this.userId() : this.userId;
}
connect() {
@ -25,8 +32,19 @@ class WebSocketManager {
console.error('浏览器不支持 WebSocket');
return;
}
if (!this.url) {
console.error('WebSocket 地址未配置');
return;
}
this.ws = new WebSocket(`${this.url}?userId=${this.userId}`, [], { perMessageDeflate: true });
const userId = this.resolveUserId();
if (typeof this.userId === 'function' && !userId) return;
const connectionUrl = new URL(this.url, window.location.href);
if (userId) connectionUrl.searchParams.set('userId', userId);
const token = getToken();
if (token) connectionUrl.searchParams.set('token', token);
this.connectionUserId = userId || null;
this.ws = new WebSocket(connectionUrl.toString(), [], { perMessageDeflate: true });
this.ws.binaryType = 'arraybuffer'; // 确保接收 ArrayBuffer
this.ws.onopen = () => {
@ -168,6 +186,12 @@ class WebSocketManager {
this.channelSubscriptions.delete(payload.channel);
}
}
const userId = this.resolveUserId();
if (this.ws && this.ws.readyState !== WebSocket.CLOSED && this.connectionUserId !== userId) {
this.ws.close(4001, 'user identity changed');
return;
}
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) this.connect();
if (this.ws && this.ws.readyState === 1) {
this.ws.send(JSON.stringify(payload));
} else if (payload?.type !== 'channel_subscription') {

View File

@ -1,6 +1,15 @@
import { createWebHistory, createRouter } from "vue-router";
/* Layout */
import Layout from "@/layout";
import { createDeviceRoutes, resolveCommonView } from '@cmvr/iot-common'
const commonView = (view) => {
const component = resolveCommonView(view)
if (!component) {
throw new Error(`[cmvr-common] view not found: ${view}`)
}
return component
}
/**
* Note: 路由配置项
@ -33,129 +42,38 @@ export const constantRoutes = [
children: [
{
path: "/redirect/:path(.*)",
component: () => import("@/views/redirect/index.vue"),
component: commonView('redirect/index'),
},
],
},
{
path: "/login",
component: () => import("@/views/login"),
component: commonView('login'),
hidden: true,
},
{
path: "/register",
component: () => import("@/views/register"),
component: commonView('register'),
hidden: true,
},
{
path: "/:pathMatch(.*)*",
component: () => import("@/views/error/404"),
component: commonView('error/404'),
hidden: true,
},
{
path: "/401",
component: () => import("@/views/error/401"),
hidden: true,
},
{
path: "/device",
component: Layout,
children: [
{
path: "mechanical_arm/:id",
component: () =>
import("@/views/device/register/components/MechanicalArm/index.vue"),
name: "机械臂示教",
meta: { title: "机械臂详情" },
hidden: true,
},
{
path: "camera/:id",
component: () =>
import("@/views/device/register/components/Camera/index.vue"),
name: "摄像头示教",
meta: { title: "摄像头详情" },
hidden: true,
},
{
path: "head/:id",
component: () =>
import("@/views/device/register/components/Head/index.vue"),
name: "人工头示教",
meta: { title: "人工头详情" },
hidden: true,
},
{
path: "speaker/:id",
component: () =>
import("@/views/device/register/components/Speaker/index.vue"),
name: "扬声器示教",
meta: { title: "扬声器详情" },
hidden: true,
},
{
path: "hand/:id",
component: () =>
import("@/views/device/register/components/DexHand/index.vue"),
name: "灵巧手示教",
meta: { title: "灵巧手详情" },
hidden: true,
},{
path: "microphone/:id",
component: () =>
import("@/views/device/register/components/Microphone/index.vue"),
name: "麦克风示教",
meta: { title: "麦克风详情" },
hidden: true,
}
],
},
{
//语音合成
path: "/speech_synthesis",
component: () =>
import("@/views/device/register/components/SpeechSynthesis/index.vue"),
meta: { title: "语音合成", noLogin: true },
hidden: true,
},
{
//触摸交互
path: "/touch_interaction",
component: () =>
import("@/views/device/register/components/TouchInteraction/index.vue"),
meta: { title: "触摸交互", noLogin: true },
hidden: true,
},
{
//cockpit
path: "/inspection/cockpit",
component: () =>
import("@/views/inspection/cockpit/index.vue"),
meta: { title: "logicFlow", noLogin: true },
hidden: true,
},
{
// 流程运行状态
path: "/logic_flow",
component: () =>
import("@/views/device/register/components/VueFlow/index.vue"),
meta: { title: "流程运行状态", noLogin: true },
component: commonView('error/401'),
hidden: true,
},
...createDeviceRoutes(Layout),
{
// 流程编排
path: "/flow",
component: () => import("@/views/flow/index.vue"),
component: commonView('flow/index'),
meta: { title: "流程编排", noLogin: true },
hidden: true,
},
{
//电机路由
path: "/Motor/:id",
component: () =>
import("@/views/device/register/components/Motor/index.vue"),
hidden: true,
},
{
path: "",
component: Layout,
@ -183,7 +101,7 @@ export const constantRoutes = [
children: [
{
path: "profile/:activeTab?",
component: () => import("@/views/system/user/profile/index"),
component: commonView('system/user/profile/index'),
name: "Profile",
meta: { title: "个人中心", icon: "user" },
},
@ -201,7 +119,7 @@ export const dynamicRoutes = [
children: [
{
path: "role/:userId(\\d+)",
component: () => import("@/views/system/user/authRole"),
component: commonView('system/user/authRole'),
name: "AuthRole",
meta: { title: "分配角色", activeMenu: "/system/user" },
},
@ -215,7 +133,7 @@ export const dynamicRoutes = [
children: [
{
path: "user/:roleId(\\d+)",
component: () => import("@/views/system/role/authUser"),
component: commonView('system/role/authUser'),
name: "AuthUser",
meta: { title: "分配用户", activeMenu: "/system/role" },
},
@ -229,7 +147,7 @@ export const dynamicRoutes = [
children: [
{
path: "index/:dictId(\\d+)",
component: () => import("@/views/system/dict/data"),
component: commonView('system/dict/data'),
name: "Data",
meta: { title: "字典数据", activeMenu: "/system/dict" },
},
@ -299,7 +217,7 @@ export const dynamicRoutes = [
children: [
{
path: "index/:jobId(\\d+)",
component: () => import("@/views/monitor/job/log"),
component: commonView('monitor/job/log'),
name: "JobLog",
meta: { title: "调度日志", activeMenu: "/monitor/job" },
},
@ -313,7 +231,7 @@ export const dynamicRoutes = [
children: [
{
path: "index/:tableId(\\d+)",
component: () => import("@/views/tool/gen/editTable"),
component: commonView('tool/gen/editTable'),
name: "GenEdit",
meta: { title: "修改生成配置", activeMenu: "/tool/gen" },
},

View File

@ -4,6 +4,7 @@ import { getRouters } from '@/api/menu'
import Layout from '@/layout/index'
import ParentView from '@/components/ParentView'
import InnerLink from '@/layout/components/InnerLink'
import { commonViewRegistry } from '@cmvr/iot-common'
// 匹配views里面所有的.vue文件
const modules = import.meta.glob('./../../views/**/*.vue')
@ -150,9 +151,10 @@ export const loadView = (view) => {
const dir = path.split('views/')[1].split('.vue')[0]
if (dir === view) {
res = () => modules[path]()
break
}
}
return res
return res || commonViewRegistry[String(view || '').replace(/^\/+|\.vue$/g, '')]
}
export default usePermissionStore

View File

@ -1,6 +1,6 @@
import Cookies from 'js-cookie'
const TokenKey = 'Admin-Token'
const TokenKey = 'CMVR-Platform-Token'
export function getToken() {
return Cookies.get(TokenKey)

View File

@ -1,4 +1,4 @@
import JSEncrypt from 'jsencrypt/bin/jsencrypt.min'
import JSEncrypt from 'jsencrypt'
// 密钥对生成 http://web.chacuo.net/netrsakeypair

View File

@ -1,553 +0,0 @@
<template>
<div class="realsense-viewer">
<el-row type="flex" class="height-full">
<el-col :span="4" class="control-panel" style="border-right: 10px solid rgb(40, 50, 60);">
<div class="controls">
<el-form :model="form">
<el-form-item label="深度图像">
<el-switch v-model="form.stereoModule"></el-switch>
</el-form-item>
<el-form-item label="彩色图像">
<el-switch v-model="form.rgbCamera"></el-switch>
</el-form-item>
</el-form>
</div>
</el-col>
<el-col :span="20" class="video-panel" v-if="form.stereoModule || form.rgbCamera">
<div class="stream-container" :class="{ dual: form.stereoModule && form.rgbCamera }">
<div v-show="form.stereoModule" class="stream-item">
<div class="stream-wrapper">
<video ref="depthVideo" autoplay muted playsinline></video>
<span class="stream-state">{{ statusText(streamStatus.getDepthImageStream) }}</span>
</div>
</div>
<div v-show="form.rgbCamera" class="stream-item">
<div class="stream-wrapper">
<video ref="colorVideo" autoplay muted playsinline></video>
<span class="stream-state">{{ statusText(streamStatus.getRGBImageStream) }}</span>
</div>
</div>
</div>
</el-col>
<el-col v-else :span="20" style="align-self: center;">
<el-empty description="请选择模式" />
</el-col>
</el-row>
</div>
</template>
<script setup>
import { inject, reactive, toRefs, watch, onUnmounted, ref, nextTick } from 'vue';
import { getResourceDevice } from '@/api/resource/device';
import { getRgbStreamUrl } from "@/api/device/camera";
import { useRoute } from "vue-router";
const props = defineProps({
initialDeviceId: {
type: String,
default: null,
},
initialRobotId: {
type: String,
default: null,
},
});
const socket = inject('ws');
const route = useRoute();
const data = reactive({
form: {
stereoModule: false,
rgbCamera: false,
},
robotId: null,
cameraId: null,
type: 'camera',
callbacks: {},
});
const { form } = toRefs(data);
const depthVideo = ref(null);
const colorVideo = ref(null);
const streamStatus = reactive({
getRGBImageStream: 'idle',
getDepthImageStream: 'idle',
});
const mediaPlayers = new Map();
const retryTimers = new Map();
let rgbStreamGeneration = 0;
let rgbStreamRetryTimer = null;
let rgbStreamAbortController = null;
const MAX_QUEUE_BYTES = 2 * 1024 * 1024;
const STARTUP_BUFFER_SECONDS = 0.35;
const TARGET_LIVE_LATENCY_SECONDS = 0.45;
const MAX_PLAYED_BUFFER_SECONDS = 2;
const PLAYBACK_SYNC_INTERVAL_MS = 200;
const statusText = (status) => ({
connecting: '连接中',
live: '实时',
retrying: '重连中',
error: '播放异常',
}[status] || '未连接');
function getVideoRef(method) {
return method === 'getRGBImageStream' ? colorVideo : depthVideo;
}
async function startDirectRgbStream() {
if (!data.robotId || !data.cameraId || !colorVideo.value) return;
rgbStreamAbortController?.abort();
rgbStreamAbortController = null;
const generation = ++rgbStreamGeneration;
clearTimeout(rgbStreamRetryTimer);
rgbStreamRetryTimer = null;
streamStatus.getRGBImageStream = 'connecting';
try {
const response = await getRgbStreamUrl(data.robotId, data.cameraId);
if (generation !== rgbStreamGeneration || !data.form.rgbCamera) return;
const relativeUrl = response.data;
const baseUrl = import.meta.env.VITE_APP_BASE_API.replace(/\/$/, '');
const video = colorVideo.value;
if (!video) return;
const player = createMediaPlayer(
'getRGBImageStream',
video,
'video/mp4; codecs="avc1.42C01E"'
);
rgbStreamAbortController = new AbortController();
consumeDirectRgbStream(
`${baseUrl}${relativeUrl}`,
generation,
player,
rgbStreamAbortController.signal
);
} catch (error) {
if (generation === rgbStreamGeneration && data.form.rgbCamera) {
console.error('Failed to open the direct RGB video stream:', error);
scheduleDirectRgbRestart();
}
}
}
async function consumeDirectRgbStream(url, generation, player, signal) {
try {
const response = await fetch(url, { signal, cache: 'no-store' });
if (!response.ok || !response.body) {
throw new Error(`RGB video stream request failed: HTTP ${response.status}`);
}
const reader = response.body.getReader();
while (generation === rgbStreamGeneration && data.form.rgbCamera) {
const { done, value } = await reader.read();
if (done) break;
if (!value?.byteLength || player.destroyed) continue;
const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
player.queue.push(chunk);
player.queueBytes += chunk.byteLength;
if (player.queueBytes > MAX_QUEUE_BYTES) {
throw new Error(`RGB video stream queue exceeded ${MAX_QUEUE_BYTES} bytes`);
}
pumpMediaQueue(player);
}
if (!signal.aborted && generation === rgbStreamGeneration && data.form.rgbCamera) {
scheduleDirectRgbRestart();
}
} catch (error) {
if (error?.name !== 'AbortError' && generation === rgbStreamGeneration && data.form.rgbCamera) {
console.error('Direct RGB video stream interrupted:', error);
scheduleDirectRgbRestart();
}
}
}
function stopDirectRgbStream() {
rgbStreamGeneration++;
clearTimeout(rgbStreamRetryTimer);
rgbStreamRetryTimer = null;
rgbStreamAbortController?.abort();
rgbStreamAbortController = null;
destroyMediaPlayer('getRGBImageStream');
streamStatus.getRGBImageStream = 'idle';
}
function scheduleDirectRgbRestart() {
if (rgbStreamRetryTimer || !data.form.rgbCamera) return;
rgbStreamAbortController?.abort();
rgbStreamAbortController = null;
destroyMediaPlayer('getRGBImageStream');
streamStatus.getRGBImageStream = 'retrying';
rgbStreamRetryTimer = setTimeout(() => {
rgbStreamRetryTimer = null;
startDirectRgbStream();
}, 1000);
}
function createMediaPlayer(method, video, mimeType) {
if (!window.MediaSource || !MediaSource.isTypeSupported(mimeType)) {
streamStatus[method] = 'error';
throw new Error(`浏览器不支持媒体格式: ${mimeType}`);
}
destroyMediaPlayer(method);
const mediaSource = new MediaSource();
const objectUrl = URL.createObjectURL(mediaSource);
const state = {
method,
video,
mimeType,
mediaSource,
sourceBuffer: null,
objectUrl,
queue: [],
queueBytes: 0,
lastSequence: 0,
started: false,
syncTimer: null,
destroyed: false,
};
mediaPlayers.set(method, state);
video.src = objectUrl;
mediaSource.addEventListener('sourceopen', () => {
if (state.destroyed || state.sourceBuffer) return;
try {
const sourceBuffer = mediaSource.addSourceBuffer(mimeType);
state.sourceBuffer = sourceBuffer;
sourceBuffer.addEventListener('updateend', () => pumpMediaQueue(state));
sourceBuffer.addEventListener('error', () => restartMediaStream(method));
state.syncTimer = window.setInterval(() => maintainLivePlayback(state), PLAYBACK_SYNC_INTERVAL_MS);
pumpMediaQueue(state);
} catch (error) {
console.error('创建视频缓冲区失败:', error);
restartMediaStream(method);
}
}, { once: true });
return state;
}
function appendMediaPacket(method, packet) {
const video = getVideoRef(method).value;
if (!video) return;
let state = mediaPlayers.get(method);
if (!state || state.mimeType !== packet.mimeType || state.video !== video) {
state = createMediaPlayer(method, video, packet.mimeType);
}
if (state.lastSequence && packet.sequence !== state.lastSequence + 1) {
console.warn(`视频片段序号中断: ${state.lastSequence} -> ${packet.sequence}`);
scheduleRestart(method);
return;
}
state.lastSequence = packet.sequence;
state.queue.push(packet.data);
state.queueBytes += packet.data.byteLength;
if (state.queueBytes > MAX_QUEUE_BYTES) {
console.warn(`视频缓冲积压超过${MAX_QUEUE_BYTES / 1024 / 1024}MB,重新建立实时流`);
scheduleRestart(method);
return;
}
pumpMediaQueue(state);
}
function pumpMediaQueue(state) {
const sourceBuffer = state.sourceBuffer;
if (state.destroyed || !sourceBuffer || sourceBuffer.updating) return;
try {
maintainLivePlayback(state);
const buffered = sourceBuffer.buffered;
if (buffered.length > 0 && state.started) {
const oldestBufferedTime = buffered.start(0);
const removeEnd = state.video.currentTime - 1;
if (removeEnd - oldestBufferedTime > MAX_PLAYED_BUFFER_SECONDS) {
sourceBuffer.remove(oldestBufferedTime, removeEnd);
return;
}
}
if (state.queue.length === 0) return;
const chunk = state.queue.shift();
state.queueBytes -= chunk.byteLength;
sourceBuffer.appendBuffer(chunk);
streamStatus[state.method] = 'live';
} catch (error) {
console.error('追加视频片段失败:', error);
restartMediaStream(state.method);
}
}
function restartMediaStream(method) {
if (method === 'getRGBImageStream') {
scheduleDirectRgbRestart();
} else {
scheduleRestart(method);
}
}
function maintainLivePlayback(state) {
const { sourceBuffer, video } = state;
if (state.destroyed || !sourceBuffer || sourceBuffer.updating || sourceBuffer.buffered.length === 0) return;
const buffered = sourceBuffer.buffered;
const rangeIndex = buffered.length - 1;
const liveStart = buffered.start(rangeIndex);
const liveEnd = buffered.end(rangeIndex);
const bufferedDuration = liveEnd - liveStart;
if (!state.started) {
if (bufferedDuration < STARTUP_BUFFER_SECONDS) return;
video.currentTime = Math.max(liveStart, liveEnd - TARGET_LIVE_LATENCY_SECONDS);
video.playbackRate = 1;
state.started = true;
video.play().catch(() => {});
return;
}
if (video.paused) {
video.play().catch(() => {});
}
if (video.seeking) return;
const latency = liveEnd - video.currentTime;
let playbackRate = 1;
if (latency > 1.4) {
playbackRate = 1.2;
} else if (latency > 1) {
playbackRate = 1.12;
} else if (latency > 0.7) {
playbackRate = 1.05;
} else if (latency < 0.28) {
playbackRate = 0.98;
}
if (Math.abs(video.playbackRate - playbackRate) > 0.001) {
video.playbackRate = playbackRate;
}
}
function destroyMediaPlayer(method) {
const state = mediaPlayers.get(method);
if (!state) return;
state.destroyed = true;
state.queue = [];
state.queueBytes = 0;
if (state.syncTimer) {
clearInterval(state.syncTimer);
state.syncTimer = null;
}
const video = state.video;
if (video) {
video.playbackRate = 1;
video.pause();
video.removeAttribute('src');
video.load();
}
URL.revokeObjectURL(state.objectUrl);
mediaPlayers.delete(method);
}
function channelFor(method) {
return `edgeCameraServiceImpl/${method}/${data.robotId}/${data.cameraId}`;
}
async function setSubscription(enabled, method, force = false) {
if (!socket || !data.robotId || !data.cameraId) return;
await nextTick();
const channel = channelFor(method);
if (enabled) {
if (data.callbacks[channel] && !force) return;
streamStatus[method] = 'connecting';
if (!data.callbacks[channel]) {
const callback = (payload) => {
if (payload?.media) {
appendMediaPacket(method, payload);
} else if (payload === '500') {
scheduleRestart(method);
}
};
// 先注册前端回调,再通知后端启动,避免丢失初始化片段。
socket.on(channel, callback);
data.callbacks[channel] = callback;
}
} else {
clearTimeout(retryTimers.get(method));
retryTimers.delete(method);
}
socket.send({
type: 'channel_subscription',
action: enabled ? 'subscribe' : 'unsubscribe',
channel,
});
if (!enabled) {
if (data.callbacks[channel]) {
socket.off(channel, data.callbacks[channel]);
delete data.callbacks[channel];
}
destroyMediaPlayer(method);
streamStatus[method] = 'idle';
}
}
function scheduleRestart(method) {
if (retryTimers.has(method) || !data.form[method === 'getRGBImageStream' ? 'rgbCamera' : 'stereoModule']) return;
streamStatus[method] = 'retrying';
destroyMediaPlayer(method);
const timer = setTimeout(async () => {
retryTimers.delete(method);
await setSubscription(false, method);
await new Promise(resolve => setTimeout(resolve, 500));
if (data.form[method === 'getRGBImageStream' ? 'rgbCamera' : 'stereoModule']) {
await setSubscription(true, method);
}
}, 1000);
retryTimers.set(method, timer);
}
function handleSocketOpen() {
if (data.form.stereoModule && data.robotId && data.cameraId) {
destroyMediaPlayer('getDepthImageStream');
setSubscription(true, 'getDepthImageStream', true);
}
}
function handleSocketClose() {
destroyMediaPlayer('getDepthImageStream');
if (data.form.stereoModule) {
streamStatus.getDepthImageStream = 'retrying';
}
}
socket?.on('open', handleSocketOpen);
socket?.on('close', handleSocketClose);
watch(() => data.form.stereoModule, async (newVal) => {
if (!data.robotId || !data.cameraId) return;
await setSubscription(newVal, 'getDepthImageStream');
});
watch(() => data.form.rgbCamera, async (newVal) => {
if (!data.robotId || !data.cameraId) return;
if (newVal) {
await nextTick();
await startDirectRgbStream();
} else {
stopDirectRgbStream();
}
});
watch(
() => props.initialDeviceId || route.path.split('/')[3],
async (deviceId) => {
if (!deviceId) return;
const queryRobotId = props.initialRobotId || route.query.robotId;
const queryDeviceId = props.initialDeviceId || route.query.deviceId;
if (queryRobotId && queryDeviceId) {
data.cameraId = String(queryDeviceId);
data.robotId = String(queryRobotId);
if (data.form.stereoModule) await setSubscription(true, 'getDepthImageStream');
if (data.form.rgbCamera) await startDirectRgbStream();
return;
}
try {
const response = await getResourceDevice(deviceId);
data.cameraId = response.data.deviceId;
data.robotId = props.initialRobotId || response.data.robotId;
if (data.form.stereoModule) await setSubscription(true, 'getDepthImageStream');
if (data.form.rgbCamera) await startDirectRgbStream();
} catch (error) {
console.error('获取摄像头注册信息失败:', error);
}
},
{ immediate: true }
);
onUnmounted(() => {
socket?.off('open', handleSocketOpen);
socket?.off('close', handleSocketClose);
stopDirectRgbStream();
['getDepthImageStream'].forEach(method => {
clearTimeout(retryTimers.get(method));
if (data.robotId && data.cameraId) {
const channel = channelFor(method);
socket?.send({ type: 'channel_subscription', action: 'unsubscribe', channel });
if (data.callbacks[channel]) socket.off(channel, data.callbacks[channel]);
}
destroyMediaPlayer(method);
});
data.callbacks = {};
});
</script>
<style scoped lang="scss">
.realsense-viewer {
display: flex;
flex-direction: column;
flex: 1;
background-color: #000;
.height-full {
height: 100%;
}
.control-panel {
padding: 10px;
.controls {
padding: 10px;
background-color: rgb(50, 50, 50);
border-radius: 10px;
.el-form {
display: flex;
flex-direction: column;
}
}
}
.video-panel {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
.stream-container {
display: flex;
flex-direction: column;
flex-grow: 1;
min-height: 0;
&.dual {
.stream-item {
min-height: 50%;
}
}
.stream-item {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.stream-wrapper {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.stream-state {
position: absolute;
top: 12px;
right: 12px;
padding: 4px 9px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(0, 0, 0, 0.58);
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
line-height: 18px;
pointer-events: none;
}
}
}
}
}
}
</style>

View File

@ -1,36 +0,0 @@
<script setup>
const props = defineProps({
title: {
type: String,
}
})
</script>
<template>
<div class="control-card">
<div class="title" v-if="props.title">{{ props.title }}</div>
<div class="body">
<slot></slot>
</div>
</div>
</template>
<style scoped lang="scss">
.control-card {
@include br6;
padding: 10px;
.title {
color: #333;
font-size: 14px;
margin-bottom: 10px;
font-weight: 900;
}
.body {
height: calc(100% - 20px);
overflow-y: auto;
}
}
</style>

View File

@ -1,431 +0,0 @@
<template>
<div class="left-bottom-charts">
<el-tooltip :content="!isHandSeries ? '开启触觉流': '关闭触觉流'" placement="top">
<el-switch
v-model="isHandSeries"
@change="handleSwitchChange"
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #999; position: absolute;"
/>
</el-tooltip>
<canvas ref="handCanvas" class="hand-canvas"></canvas>
</div>
</template>
<script setup >
import handImage from '@/assets/images/hand2.png';
import { inject } from 'vue';
const emit = defineEmits(['update:bottomSeriesData']);
const socket = inject('ws');
const handCanvas = ref(null);
const robotId = ref(''); // 待您修改
const deviceId = ref(''); // 待您修改
const frameData = ref({ count: 0, lastTime: 0 });
const isHandSeries = ref(false);
const containerRef = ref(null)
// 1. 定义 props
const props = defineProps({
initialDeviceId: { // 示例 prop,用于从弹窗接收cameraId
type: String,
default: null,
},
initialRobotId: { // 示例 prop,用于从弹窗接收 robotId
type: String,
default: null,
},
});
// 2. 使用 watchEffect 来响应 props 和路由的变化
watchEffect(() => {
robotId.value = props.initialRobotId;
deviceId.value = props.initialDeviceId;
});
// 矩形定义
const rectangle = [
{ x: 24, y: 305, width: 21, height: 20, xCount: 3, yCount: 3 }, // PINKY.TIP
{ x: 17, y: 240, width: 42, height: 57, xCount: 8, yCount: 12 }, // PINKY.FINGER
{ x: 25, y: 152, width: 42, height: 51, xCount: 8, yCount: 10 }, // PINKY.PAD
{ x: 88, y: 339, width: 23, height: 22, xCount: 3, yCount: 3 }, // RING.TIP
{ x: 75, y: 276, width: 43, height: 56, xCount: 8, yCount: 12 }, // RING.FINGER
{ x: 88, y: 152, width: 44, height: 51, xCount: 8, yCount: 10 }, // RING.PAD
{ x: 153, y: 354, width: 22, height: 21, xCount: 3, yCount: 3 }, // MIDDLE_FINGER.TIP
{ x: 146, y: 283, width: 44, height: 56, xCount: 8, yCount: 12 }, // MIDDLE_FINGER.FINGER
{ x: 146, y: 152, width: 43, height: 51, xCount: 8, yCount: 10 }, // MIDDLE_FINGER.PAD
{ x: 224, y: 332, width: 23, height: 22, xCount: 3, yCount: 3 }, // INDEX.TIP
{ x: 209, y: 267, width: 44, height: 58, xCount: 8, yCount: 12 }, // INDEX.FINGER
{ x: 203, y: 152, width: 44, height: 52, xCount: 8, yCount: 10 }, // INDEX.PAD
{ x: 290, y: 225, width: 21, height: 21, xCount: 3, yCount: 3 }, // THUMB.TIP
{ x: 282, y: 161, width: 44, height: 57, xCount: 8, yCount: 12 }, // THUMB.FINGER
{ x: 289, y: 111, width: 22, height: 20, xCount: 3, yCount: 3 }, // THUMB.THUMB_MIDDLE
{ x: 269, y: 24, width: 57, height: 57, xCount: 8, yCount: 12 }, // THUMB.PAD
{ x: 53, y: 11, width: 188, height: 107, xCount: 14, yCount: 8 } // PALM.PALM_PAD
];
// 模拟的矩形数据(17 个二维数组)
const rectangleData = ref([]);
// 初始化矩形数据
const initializeRectangleData = () => {
rectangleData.value = rectangle.map(({ xCount, yCount }) =>
Array(yCount)
.fill()
.map(() => Array(xCount).fill().map(() => Math.floor(Math.random() * 0)))
);
};
// 防抖函数
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
};
// 初始化 Canvas
const initCanvas = () => {
const canvas = handCanvas.value;
if (!canvas) {
console.error('Canvas element is not available');
return;
}
const img = new Image();
img.onload = () => {
canvas.width = canvas.parentElement.offsetWidth;
canvas.height = canvas.parentElement.offsetHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
drawImage(ctx, img);
drawRectangles(ctx, img);
} else {
console.error('Failed to get 2D context');
}
};
img.onerror = () => console.error('Failed to load image:', handImage);
img.src = handImage;
};
// 绘制图片
const drawImage = (ctx, img) => {
const { width: canvasWidth, height: canvasHeight } = ctx.canvas;
const aspectRatio = img.width / img.height;
let drawWidth, drawHeight;
if (canvasWidth / canvasHeight > aspectRatio) {
drawHeight = canvasHeight;
drawWidth = drawHeight * aspectRatio;
} else {
drawWidth = canvasWidth;
drawHeight = drawWidth / aspectRatio;
}
const x = (canvasWidth - drawWidth) / 2;
const y = canvasHeight - drawHeight;
ctx.drawImage(img, x, y, drawWidth, drawHeight);
};
// 绘制矩形
const drawRectangles = (ctx, img) => {
const { width: canvasWidth, height: canvasHeight } = ctx.canvas;
const aspectRatio = img.width / img.height;
let drawWidth, drawHeight;
if (canvasWidth / canvasHeight > aspectRatio) {
drawHeight = canvasHeight;
drawWidth = drawHeight * aspectRatio;
} else {
drawWidth = canvasWidth;
drawHeight = drawWidth / aspectRatio;
}
const xOffset = Math.round((canvasWidth - drawWidth) / 2);
const yOffset = Math.round(canvasHeight - drawHeight);
const scaleX = drawWidth / img.width;
const scaleY = drawHeight / img.height;
rectangle.forEach((rect, index) => {
const x = Math.round(xOffset + rect.x * scaleX);
const y = Math.round(yOffset + (img.height - rect.y - rect.height) * scaleY);
const rectWidth = Math.round(rect.width * scaleX);
const rectHeight = Math.round(rect.height * scaleY);
const smallWidth = Math.round(rectWidth / rect.xCount);
const smallHeight = Math.round(rectHeight / rect.yCount);
const data = rectangleData.value[index];
if (!data) {
console.warn(`No data for rectangle at index ${index}`);
return;
}
for (let i = 0; i < rect.xCount; i++) {
for (let j = 0; j < rect.yCount; j++) {
const smallX = x + i * smallWidth;
const smallY = y + j * smallHeight;
const value = data[j][i];
// 非常淡橙色 (#FFF5E1, RGB: 255, 245, 225) 到深红 (#800000, RGB: 128, 0, 0)
const r = 128 + Math.floor(((255 - 128) * (4096 - value) + (128 - 0) * value) / 4096);
const g = Math.floor((245 * (4096 - value) + 0 * value) / 4096);
const b = Math.floor((225 * (4096 - value) + 0 * value) / 4096);
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
ctx.fillRect(smallX, smallY, smallWidth, smallHeight);
}
}
});
};
// 更新 Canvas
const updateCanvas = () => {
const canvas = handCanvas.value;
if (!canvas) {
console.error('Canvas element is not available during update');
return;
}
const img = new Image();
img.onload = () => {
canvas.width = canvas.parentElement.offsetWidth;
canvas.height = canvas.parentElement.offsetHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
drawImage(ctx, img);
drawRectangles(ctx, img);
}
};
img.onerror = () => console.error('Failed to load image:', handImage);
img.src = handImage;
};
// 传感器到图表的映射
const sensorMap = [
{ fingerType: 'PINKY', partType: 'TIP', key: 'littleFinger.end' },
{ fingerType: 'PINKY', partType: 'FINGER', key: 'littleFinger.tip' },
{ fingerType: 'PINKY', partType: 'PAD', key: 'littleFinger.middle' },
{ fingerType: 'RING', partType: 'TIP', key: 'ringFinger.end' },
{ fingerType: 'RING', partType: 'FINGER', key: 'ringFinger.tip' },
{ fingerType: 'RING', partType: 'PAD', key: 'ringFinger.middle' },
{ fingerType: 'MIDDLE_FINGER', partType: 'TIP', key: 'middleFinger.end' },
{ fingerType: 'MIDDLE_FINGER', partType: 'FINGER', key: 'middleFinger.tip' },
{ fingerType: 'MIDDLE_FINGER', partType: 'PAD', key: 'middleFinger.middle' },
{ fingerType: 'INDEX', partType: 'TIP', key: 'indexFinger.end' },
{ fingerType: 'INDEX', partType: 'FINGER', key: 'indexFinger.tip' },
{ fingerType: 'INDEX', partType: 'PAD', key: 'indexFinger.middle' },
{ fingerType: 'THUMB', partType: 'TIP', key: 'thumb.end' },
{ fingerType: 'THUMB', partType: 'FINGER', key: 'thumb.tip' },
{ fingerType: 'THUMB', partType: 'THUMB_MIDDLE', key: 'thumb.middle' },
{ fingerType: 'THUMB', partType: 'PAD', key: 'thumb.palm' },
{ fingerType: 'PALM', partType: 'PALM_PAD', key: 'palm.touch' }
];
// WebSocket 订阅
const subscribeSensorData = async (sub) => {
if (!socket || !robotId.value || !deviceId.value) {
console.warn(`订阅失败: socket=${!!socket}, robotId=${robotId.value}, deviceId=${deviceId.value}`);
return;
}
const channel = `edgeDexHandServiceImpl/getSensorDataStream/${robotId.value}/${deviceId.value}`;
console.log(sub ? '订阅' : '取消订阅', channel);
socket.send({
type: 'channel_subscription',
action: sub ? 'subscribe' : 'unsubscribe',
channel
});
if (sub) {
await nextTick();
if (data.callback) {
socket.off(channel, data.callback);
console.log('清理旧回调:', channel);
}
const callback = (playLoad) => {
requestAnimationFrame(() => {
const start = performance.now();
try {
// 解析 playLoad
const sensorData = typeof playLoad === 'string' ? JSON.parse(playLoad) : playLoad;
if (!Array.isArray(sensorData) || sensorData.length !== 17) {
console.error('无效的 payload 格式: 期望 17 个传感器对象', sensorData);
return;
}
// 转换为 rectangleData 和计算 maxData, avgData
const newRectangleData = sensorData.map((sensor, index) => {
const { xCount, yCount } = rectangle[index];
const { rows, cols, dataList } = sensor;
// 验证尺寸
if (rows !== yCount || cols !== xCount || !Array.isArray(dataList) || dataList.length !== rows) {
console.error(`传感器 ${sensor.sensorName} 数据尺寸不匹配: 期望 ${yCount}x${xCount}, 实际 ${rows}x${cols}`);
return rectangleData.value[index] || Array(yCount).fill().map(() => Array(xCount).fill(0));
}
// 提取 valuesList
return dataList.map(row => {
if (!row || !Array.isArray(row.valuesList) || row.valuesList.length !== cols) {
console.warn(`传感器 ${sensor.sensorName} 行数据无效`);
return Array(xCount).fill(0);
}
return row.valuesList;
});
});
// 计算 maxData 和 avgData
const newMaxData = {
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
};
const newAvgData = {
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
};
sensorData.forEach((sensor, index) => {
const { dataList, fingerType, partType } = sensor;
const map = sensorMap[index];
if (!map || map.fingerType !== fingerType || map.partType !== partType) {
console.warn(`传感器 ${sensor.sensorName} 映射不匹配: 期望 ${map.fingerType}.${map.partType}`);
return;
}
// 计算最大值和平均值
let maxValue = 0;
let sum = 0;
let count = 0;
dataList.forEach(row => {
row.valuesList.forEach(value => {
maxValue = Math.max(maxValue, value);
if (value > 0) {
sum += value;
count++;
}
});
});
const avgValue = count > 0 ? sum / count : 0;
// 更新 maxData 和 avgData
const [finger, part] = map.key.split('.');
newMaxData[finger][part] = Math.floor(maxValue);
newAvgData[finger][part] = Math.floor(avgValue);
});
// 更新 rectangleData 和 maxData, avgData
rectangleData.value = newRectangleData;
emit('update:topSeriesData', {maxData: newMaxData, avgData: newAvgData});
// 更新 Canvas
updateCanvas();
// 帧率和性能监控
frameData.value.count++;
const currentTime = performance.now();
if (frameData.value.lastTime && currentTime - frameData.value.lastTime >= 1000) {
const fps = frameData.value.count * 1000 / (currentTime - frameData.value.lastTime);
console.log(`渲染帧率: ${fps.toFixed(2)} fps, 通道: ${channel}`);
frameData.value.count = 0;
frameData.value.lastTime = currentTime;
} else if (!frameData.value.lastTime) {
frameData.value.lastTime = currentTime;
}
console.log(`数据更新耗时: ${(performance.now() - start).toFixed(2)}ms, 通道: ${channel}`);
if (performance.memory) {
console.log('当前内存使用:', {
usedJSHeapSize: (performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(2) + ' MB',
totalJSHeapSize: (performance.memory.totalJSHeapSize / 1024 / 1024).toFixed(2) + ' MB'
});
}
} catch (err) {
console.error('数据更新失败:', err, '通道:', channel);
console.error('payload 数据:', playLoad);
}
});
};
socket.on(channel, callback);
data.callback = callback;
console.log('注册新回调:', channel);
} else {
if (data.callback) {
socket.off(channel, data.callback);
delete data.callback;
console.log('清理回调:', channel);
}
}
};
// 数据对象
const data = reactive({
callback: null
});
const handleSwitchChange = (val) => {
subscribeSensorData(val)
}
// 生命周期钩子
onMounted(async () => {
await nextTick();
initializeRectangleData(); // 初始化数据
initCanvas(); // 初始化 Canvas
// 使用 ResizeObserver 监听父容器尺寸变化
const container = containerRef.value || handCanvas.value?.parentElement;
if (container) {
const resizeObserver = new ResizeObserver(debounce(updateCanvas, 10));
resizeObserver.observe(container);
// 保存 ResizeObserver 实例以便清理
data.resizeObserver = resizeObserver;
} else {
console.error('Failed to find container for ResizeObserver');
}
const debouncedResize = debounce(updateCanvas, 10);
window.addEventListener('resize', debouncedResize);
data.debouncedResize = debouncedResize; // 保存以便清理
});
onUnmounted(() => {
// 清理 ResizeObserver
if (data.resizeObserver) {
data.resizeObserver.disconnect();
delete data.resizeObserver;
}
// 清理 window 的 resize 事件
if (data.debouncedResize) {
window.removeEventListener('resize', data.debouncedResize);
delete data.debouncedResize;
}
// 清理 WebSocket 订阅
if (isHandSeries.value) {
subscribeSensorData(false);
}
});
</script>
<style>
.left-bottom-charts {
height: 100%;
}
.hand-canvas {
width: 100%;
height: 100%;
object-fit: contain;
}
</style>

View File

@ -1,270 +0,0 @@
<template>
<div class="left-top-hand" v-loading="loading">
<el-button
class="copy-button"
type="button"
:icon="isCopied ? 'Check' : 'CopyDocument'"
:disabled="isCopied"
@click="copyParams"
aria-label="复制当前参数"
>
{{ isCopied ? '完成' : '复制' }}
</el-button>
<img src="@/assets/images/hand.png" ref="handImage" class="hand-image" />
<div v-for="(slider, index) in sliders" :key="index" class="slider-container" :style="slider.style">
<span :class="`slider-title slider-title-${index}`">{{ slider.value }}</span>
<el-slider
v-model="slider.value"
:vertical="slider.vertical"
:height="slider.height + 'px'"
@change="updateSeriesData(slider.value, index)"
:class="`slider-${index}`"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { status, setDexHandAngle } from '@/api/device/dexHand';
const emit = defineEmits(['update:topSeriesData']);
const loading = ref(false);
const handImage = ref(null);
const containerRef = ref(null); // 新增:引用父容器
const defaultHandImageInfo = { width: 891, height: 981 };
const imageAspectRatio = defaultHandImageInfo.width / defaultHandImageInfo.height;
const robotId = ref('');
const deviceId = ref('');
// 1. 定义 props
const props = defineProps({
initialDeviceId: { // 示例 prop,用于从弹窗接收cameraId
type: String,
default: null,
},
initialRobotId: { // 示例 prop,用于从弹窗接收 robotId
type: String,
default: null,
},
});
// 2. 使用 watchEffect 来响应 props 和路由的变化
watchEffect(() => {
robotId.value = props.initialRobotId;
deviceId.value = props.initialDeviceId;
console.log(deviceId.value, robotId.value,props)
});
const sliders = ref([
{ value: 0, defalutHeight: 466, height: 0, x: 120, y: 140, vertical: true, style: {} }, // 小拇指
{ value: 0, defalutHeight: 466, height: 0, x: 230, y: 140, vertical: true, style: {} }, // 无名指
{ value: 0, defalutHeight: 466, height: 0, x: 373, y: 140, vertical: true, style: {} }, // 中指
{ value: 0, defalutHeight: 466, height: 0, x: 468, y: 140, vertical: true, style: {} }, // 食指
{ value: 0, defalutHeight: 346, height: 0, x: 608, y: 360, vertical: true, style: {} }, // 大拇指弯曲
{ value: 0, defalutHeight: 633, height: 0, x: 85, y: 723, vertical: false, style: {} }, // 大拇指旋转
]);
function updateSeriesData(value, i) {
console.log(deviceId.value, robotId.value)
loading.value = true;
setDexHandAngle({ deviceId: deviceId.value, robotId: robotId.value, value: value / 100, id: i }).then(() => {
loading.value = false;
updateToSeriesData();
}).catch(() => {
loading.value = false;
});
}
const isCopied = ref(false);
const copyParams = () => {
navigator.clipboard.writeText(JSON.stringify(sliders.value))
.then(() => {
isCopied.value = true;
setTimeout(() => {
isCopied.value = false;
}, 3000);
})
.catch(err => {
console.error('复制失败:', err);
});
};
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
};
const updateSliderPositions = () => {
const img = handImage.value;
if (img) {
const { width, height } = img.getBoundingClientRect();
console.log('Window resized - New dimensions:', width, height);
const widthRatio = width / height;
let realWidth = 0;
let realHeight = 0;
if (widthRatio > imageAspectRatio) {
realHeight = height;
realWidth = realHeight * imageAspectRatio;
} else {
realWidth = width;
realHeight = realWidth / imageAspectRatio;
}
const realImageAspectRatio = realWidth / defaultHandImageInfo.width;
console.log('Real dimensions:', realWidth, realHeight, realImageAspectRatio);
sliders.value.forEach((slider, index) => {
slider.height = slider.defalutHeight * realImageAspectRatio;
slider.style = {
top: (height / 2 - realHeight / 2) + slider.y * realImageAspectRatio - 16 + 'px',
left: (width / 2 - realWidth / 2) + slider.x * realImageAspectRatio - 16 + 'px',
position: 'absolute',
zIndex: 10,
width: slider.height + 'px',
};
});
}
};
const updateToSeriesData = () => {
status({ deviceId: deviceId.value, robotId: robotId.value }).then((res) => {
const newTopSeriesData = res.data.handsList.map(item => item.force);
emit('update:topSeriesData', newTopSeriesData);
});
};
const data = ref({
debouncedResize: null,
resizeObserver: null,
});
onMounted(() => {
updateSliderPositions();
// 使用 ResizeObserver 监听父容器尺寸变化
const container = containerRef.value || handImage.value?.parentElement;
if (container) {
const resizeObserver = new ResizeObserver(debounce(updateSliderPositions, 10));
resizeObserver.observe(container);
data.value.resizeObserver = resizeObserver;
} else {
console.error('Failed to find container for ResizeObserver');
}
const debouncedResize = debounce(updateSliderPositions, 0);
window.addEventListener('resize', debouncedResize);
data.value.debouncedResize = debouncedResize;
updateToSeriesData();
});
onUnmounted(() => {
// 清理 ResizeObserver
if (data.value.resizeObserver) {
data.value.resizeObserver.disconnect();
data.value.resizeObserver = null;
}
// 清理 window 的 resize 事件
if (data.value.debouncedResize) {
window.removeEventListener('resize', data.value.debouncedResize);
data.value.debouncedResize = null;
}
});
</script>
<style scoped>
.left-top-hand {
position: relative;
height: 100%;
}
.hand-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.slider-container {
position: absolute;
z-index: 10;
}
.slider-container ::v-deep(.el-slider__button) {
width: 25px !important;
height: 12px !important;
background-color: #409eff !important;
border-radius: 0 !important;
}
.slider-5 ::v-deep(.el-slider__button) {
width: 12px !important;
height: 25px !important;
background-color: #409eff !important;
border-radius: 0 !important;
}
.slider-container .el-slider__runway {
background-color: #007bff;
border-radius: 2px;
}
.slider-title {
color: #409eff;
position: absolute;
bottom: -20px;
left: 10px;
}
.slider-title-5 {
bottom: 8px;
left: -30px;
}
.image {
width: 100%;
height: 50%;
object-fit: contain;
}
@media (max-width: 768px) {
.slider-container {
width: 5vw;
}
}
.copy-button {
position: absolute;
background-color: #FFFfff;
padding: 5px 10px;
border-radius: 5px;
border: 0;
color: #646362;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
outline: none;
}
.copy-button:hover {
background-color: #FFF5E1;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.copy-button:active {
background-color: #FFDAB9;
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.copy-button:focus {
box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.3);
}
.copy-button:disabled {
background-color: #F0E8E0;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
</style>

View File

@ -1,229 +0,0 @@
<template>
<div ref="containerRef" style="width: 100%;height: 100%; margin: 0 auto;">
<div class="tabs">
<div
v-for="(tab, index) in tabs"
:key="index"
class="tab"
:class="{ active: activeTab === tab.value }"
@click="switchTab(tab.value)"
>
{{ tab.label }}
</div>
</div>
<div ref="chart" style="width: 100%; height: 100%;"></div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import * as echarts from 'echarts';
// 解构 props
const props = defineProps({
maxData: {
type: Object,
required: true,
default: () => ({
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
})
},
avgData: {
type: Object,
required: true,
default: () => ({
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
})
}
});
const chart = ref(null);
const containerRef = ref(null); // 新增:引用父容器
const activeTab = ref('avg'); // 默认选中平均值
// Tab 配置
const tabs = [
{ label: '最大值', value: 'max' },
{ label: '平均值', value: 'avg' }
];
let myChart;
// 防抖函数
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
};
const initChart = () => {
if (!chart.value) {
console.error('Chart element is null');
return;
}
myChart = echarts.init(chart.value);
const option = {
title: { text: '手指触觉数据' },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['指端', '指尖', '指中', '指腹', '手掌触觉'] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: ['小拇指', '无名指', '中指', '食指', '大拇指', '手掌'],
splitArea: { show: true }
},
yAxis: [{ type: 'value', name: '弯曲应力', max: 4000,interval: 1000 }],
series: [
{ name: '指端', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#FF5733' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] },
{ name: '指尖', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#33FF57' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] },
{ name: '指中', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#5733FF' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] },
{ name: '指腹', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#FF33A1' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] },
{ name: '手掌触觉', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#FFD700' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] }
]
};
myChart.setOption(option);
};
// 获取每个部分的数据,处理空值
const getPartData = (data, part) => {
return [
data.littleFinger[part] || null,
data.ringFinger[part] || null,
data.middleFinger[part] || null,
data.indexFinger[part] || null,
data.thumb[part] || null,
data.palm[part] || null
];
};
const updateChart = (data) => {
const seriesData = {
end: getPartData(data, 'end'),
tip: getPartData(data, 'tip'),
middle: getPartData(data, 'middle'),
palm: getPartData(data, 'palm'),
touch: getPartData(data, 'touch')
};
myChart.setOption({
series: [
{ name: '指端', data: seriesData.end },
{ name: '指尖', data: seriesData.tip },
{ name: '指中', data: seriesData.middle },
{ name: '指腹', data: seriesData.palm },
{ name: '手掌触觉', data: seriesData.touch }
]
});
};
const switchTab = (tab) => {
activeTab.value = tab;
updateChart(activeTab.value === 'max' ? props.maxData : props.avgData);
};
// 监听 props 变化,更新图表
watch([() => props.maxData, () => props.avgData, activeTab], () => {
updateChart(activeTab.value === 'max' ? props.maxData : props.avgData);
});
const data = ref({
resizeObserver: null,
debouncedResize: null,
});
onMounted(() => {
initChart();
updateChart(activeTab.value === 'max' ? props.maxData : props.avgData);
// 使用 ResizeObserver 监听父容器尺寸变化
const container = containerRef.value;
if (container) {
const resizeObserver = new ResizeObserver(debounce(() => {
if (myChart) {
console.log('Container resized:', container.offsetWidth, container.offsetHeight);
myChart.resize();
}
}, 10));
resizeObserver.observe(container);
data.value.resizeObserver = resizeObserver;
} else {
console.error('Failed to find container for ResizeObserver');
}
// 保留 window 的 resize 事件监听
const debouncedResize = debounce(() => {
if (myChart) {
myChart.resize();
}
}, 10);
window.addEventListener('resize', debouncedResize);
data.value.debouncedResize = debouncedResize;
});
onUnmounted(() => {
// 清理图表实例
if (myChart) {
myChart.dispose();
myChart = null;
}
// 清理 ResizeObserver
if (data.value.resizeObserver) {
data.value.resizeObserver.disconnect();
data.value.resizeObserver = null;
}
// 清理 window 的 resize 事件
if (data.value.debouncedResize) {
window.removeEventListener('resize', data.value.debouncedResize);
data.value.debouncedResize = null;
}
});
</script>
<style scoped>
.container {
display: flex;
width: 100%;
margin: 0 auto;
flex-direction: column;
}
.tabs {
position: absolute;
right: 50%;
justify-content: center;
background: rgba(0, 0, 0, 0);
border-radius: 10px 10px 0 0;
padding: 5px 0;
}
.tab {
padding: 10px 20px;
margin: 0 10px;
cursor: pointer;
font-size: 16px;
color: #333;
border-bottom: 3px solid transparent;
transition: all 0.3s ease;
}
.tab:hover {
background-color: #f5f5f5;
border-radius: 5px;
}
.tab.active {
border-bottom: 3px solid #007bff;
color: #007bff;
}
</style>

View File

@ -1,162 +0,0 @@
<template>
<div class="right-chart-panel" ref="containerRef">
<div ref="topChart" class="chart"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import * as echarts from 'echarts';
const props = defineProps({
topSeriesData: {
type: Array,
default: () => [0, 0, 0, 0, 0, 0],
validator: (value) => value.length === 6 && value.every((v) => typeof v === 'number'),
},
maxData: {
type: Object,
required: true,
default: () => ({
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
})
},
avgData: {
type: Object,
required: true,
default: () => ({
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
})
}
});
const topChart = ref(null);
const containerRef = ref(null); // 新增:引用父容器
let chart1Instance = null;
// 防抖函数
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
};
const topChartData = ref({
xAxis: {
type: 'category',
data: ['小拇指', '无名指', '中指', '食指', '大拇指弯曲', '大拇指旋转'],
},
yAxis: {
name: '受力',
type: 'value',
min: 0,
max: 10000,
interval: 2500,
axisLine: { show: true, lineStyle: { color: '#1f77b4' } },
axisLabel: { formatter: '{value} g' },
axisTick: { show: true },
},
series: [{ data: props.topSeriesData, type: 'bar' }],
});
const data = ref({
resizeObserver: null,
debouncedResize: null,
});
onMounted(() => {
try {
if (!topChart.value) {
console.error('Chart element is null');
return;
}
chart1Instance = echarts.init(topChart.value);
chart1Instance.setOption(topChartData.value);
// 使用 ResizeObserver 监听父容器尺寸变化
const container = containerRef.value;
if (container) {
const resizeObserver = new ResizeObserver(debounce(() => {
if (chart1Instance) {
console.log('Container resized:', container.offsetWidth, container.offsetHeight);
chart1Instance.resize();
}
}, 10));
resizeObserver.observe(container);
data.value.resizeObserver = resizeObserver;
} else {
console.error('Failed to find container for ResizeObserver');
}
// 保留 window 的 resize 事件监听
const debouncedResize = debounce(() => {
if (chart1Instance) {
chart1Instance.resize();
}
}, 10);
window.addEventListener('resize', debouncedResize);
data.value.debouncedResize = debouncedResize;
} catch (error) {
console.error('图表初始化失败:', error);
}
});
onUnmounted(() => {
// 清理图表实例
if (chart1Instance) {
chart1Instance.dispose();
chart1Instance = null;
}
// 清理 ResizeObserver
if (data.value.resizeObserver) {
data.value.resizeObserver.disconnect();
data.value.resizeObserver = null;
}
// 清理 window 的 resize 事件
if (data.value.debouncedResize) {
window.removeEventListener('resize', data.value.debouncedResize);
data.value.debouncedResize = null;
}
});
// 监听 props.topSeriesData
watch(
() => props.topSeriesData,
(newTopData) => {
if (chart1Instance) {
chart1Instance.setOption({ series: [{ data: newTopData, type: 'bar' }] });
}
},
{ immediate: true }
);
</script>
<style scoped>
.right-chart-panel {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
height: 50%;
padding: 10px;
}
.chart {
flex: 1;
height: 100%;
margin: 10px 0;
min-height: 200px; /* 确保图表有足够的高度 */
}
</style>

View File

@ -1,168 +0,0 @@
<!-- index.vue -->
<template>
<div class="container">
<div class="left-panel">
<div class="hand-container">
<!-- 确保这里使用的是 index.vue 中响应式的 deviceId ref -->
<LeftTopHand :initialDeviceId="deviceId" :initialRobotId="robotId" @update:topSeriesData="handleUpdateTopSeriesData" />
</div>
<div class="hand-container">
<!-- 确保这里使用的是 index.vue 中响应式的 robotId ref -->
<LeftBottomHand :initialDeviceId="deviceId" :initialRobotId="robotId" @update:bottomSeriesData="handleUpdateBottomSeriesData" />
</div>
</div>
<div class="right-panel">
<RightTopCharts :top-series-data="topSeriesData" style="height: 50%;" />
<RightBottomCharts :max-data="maxData" :avg-data="avgData" style="height: 50%;" />
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'; // 引入 watch
// 假设你的 router 实例是这样获取的
import { useRoute } from 'vue-router'; // 确保你已经安装并配置了 vue-router
import RightTopCharts from './RightTopCharts.vue';
import LeftTopHand from './LeftTopHand.vue';
import LeftBottomHand from './LeftBottomHand.vue';
import RightBottomCharts from './RightBottomCharts.vue';
import { getResourceDevice } from '@/api/resource/device';
// 1. 定义 props
const props = defineProps({
initialDeviceId: {
type: String,
default: null,
},
initialRobotId: {
type: String,
default: null,
},
});
// 响应式数据
const topSeriesData = ref([0, 0, 0, 0, 0, 0]);
const bottomSeriesData = ref([0, 0, 0, 0, 0, 0]);
const maxData = ref({
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
});
const avgData = ref({
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
});
// 声明组件内部使用的响应式变量
const deviceId = ref("");
const robotId = ref("");
const handleUpdateTopSeriesData = (newData) => {
topSeriesData.value = newData;
};
const handleUpdateBottomSeriesData = ({ maxData: newMaxData, avgData: newAvgData }) => {
maxData.value = newMaxData;
avgData.value = newAvgData;
};
// 获取路由实例
const route = useRoute();
// 统一处理获取 register 数据的方法
const fetchRegisterData = (currentDeviceId) => {
if (route.query.robotId && route.query.deviceId) {
robotId.value = String(route.query.robotId);
deviceId.value = String(route.query.deviceId);
return;
}
if (currentDeviceId) {
getResourceDevice(currentDeviceId).then(res => {
deviceId.value = res.data.deviceId;
robotId.value = res.data.robotId;
}).catch(error => {
console.error("Error fetching register:", error);
// 可以根据需要重置 deviceId 和 robotId
deviceId.value = "";
robotId.value = "";
});
} else {
// 如果 deviceId 为空,可以考虑重置 robotId
robotId.value = "";
}
};
// 2. 使用 watch 侦听 props 和路由的变化
// 当 initialDeviceId prop 变化时,更新 deviceId ref
watch(() => props.initialDeviceId, (newVal) => {
if (newVal) {
deviceId.value = newVal;
// 当 props.initialDeviceId 确定后,再调用 getRegister
fetchRegisterData(deviceId.value);
}
}, { immediate: true }); // immediate: true 会在组件挂载时立即执行一次 watch
// 如果 props.initialDeviceId 为空,则尝试从路由获取
// 注意:这里假设 route.path.split("/")[3] 是cameraId
// 如果你的路由结构不同,请调整这里的逻辑
watch(() => route.path, (newPath) => {
if (route.query.robotId && route.query.deviceId) {
robotId.value = String(route.query.robotId);
deviceId.value = String(route.query.deviceId);
return;
}
const routeDeviceId = newPath.split("/")[3];
if (!deviceId.value && routeDeviceId) { // 只有当 deviceId 尚未从 props 或其他地方设置时才从路由获取
deviceId.value = routeDeviceId;
fetchRegisterData(deviceId.value);
}
}, { immediate: true }); // immediate: true 确保在组件挂载时也检查路由
// 如果需要 initialRobotId prop 也影响 robotId ref,可以添加一个 watch
watch(() => props.initialRobotId, (newVal) => {
if (newVal) {
robotId.value = newVal;
}
}, { immediate: true });
</script>
<style scoped>
.container {
display: flex;
flex: 1; /* 占据父容器所有可用空间 */
width: 100%;
height: 100%; /* 占满父容器高度 */
min-height: 0; /* 防止 flex 子项最小高度问题 */
background-color: #fff;
border-radius: 20px;
overflow: hidden; /* 防止内容溢出 */
box-sizing: border-box; /* 包含 padding 和 border */
padding: 20px; /* 与父容器一致,避免双重 padding */
}
.left-panel,
.right-panel {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 50%;
height: 100%; /* 占满 container 高度 */
padding: 0; /* 移除子面板内边距,交给 container 处理 */
box-sizing: border-box;
overflow: hidden;
}
.hand-container {
position: relative;
height: 50%; /* 每个 hand-container 占左面板的 50% 高度 */
width: 100%;
overflow: hidden;
}
</style>

View File

@ -1,316 +0,0 @@
<template>
<div class="head-control">
<h2 class="title">人工头舵机控制</h2>
<div class="main-content">
<!-- 左侧:10个Yaw预设输入框 -->
<div class="preset-panel left-panel">
<h3>Yaw 预设位(左右转头)</h3>
<div v-for="(preset, index) in leftPresets" :key="index" class="preset-item">
<span class="label">位 {{ index + 1 }}</span>
<el-input-number
v-model="preset.value"
:min="0"
:max="1"
:step="0.01"
:precision="2"
size="small"
controls-position="right"
@change="onPresetChange"
/>
</div>
<el-button type="primary" size="small" @click="applyYawFromPresets">应用当前 Yaw</el-button>
</div>
<!-- 中央图片 + 20个控制点 -->
<div class="image-container">
<img
src="https://cdn.head-acoustics.com/_processed_/3/5/csm_HMS_V_frontal_seitlich_67c86d4fad.jpg"
alt="人工头"
class="head-image"
/>
<!-- 20 个控制点 -->
<div
v-for="(point, index) in points"
:key="index"
class="control-point"
:style="{ left: point.x + '%', top: point.y + '%' }"
:class="{ active: point.active }"
@click="applyPreset(index)"
:title="`点 ${index + 1}: Yaw=${point.yaw.toFixed(2)}, Pitch=${point.pitch.toFixed(2)}`"
>
{{ index + 1 }}
</div>
</div>
<!-- 右侧:10个Pitch预设输入框 -->
<div class="preset-panel right-panel">
<h3>Pitch 预设位(上下点头)</h3>
<div v-for="(preset, index) in rightPresets" :key="index" class="preset-item">
<span class="label">位 {{ index + 1 }}</span>
<el-input-number
v-model="preset.value"
:min="0"
:max="1"
:step="0.01"
:precision="2"
size="small"
controls-position="right"
@change="onPresetChange"
/>
</div>
<el-button type="primary" size="small" @click="applyPitchFromPresets">应用当前 Pitch</el-button>
</div>
</div>
<!-- 当前值显示与下发 -->
<div class="current-display">
<div class="current-item">
<span>当前 Yaw:</span>
<span class="value">{{ servo1.toFixed(2) }}</span>
</div>
<div class="current-item">
<span>当前 Pitch:</span>
<span class="value">{{ servo2.toFixed(2) }}</span>
</div>
<el-button type="primary" @click="sendServoValues">立即下发舵机值</el-button>
<el-button @click="resetToCenter">复位到中心 (0.50, 0.50)</el-button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRoute } from 'vue-router';
// import { setServo } from '@/api/device/head'; // 假设接口
const route = useRoute();
const robotId = ref(String(route.query.robotId || ''));
const deviceId = ref(String(route.query.deviceId || ''));
const servo1 = ref(0.5); // 当前 Yaw
const servo2 = ref(0.5); // 当前 Pitch
// 左侧10个Yaw预设(对应左侧10个点)
const leftPresets = ref([
{ value: 0.10 }, { value: 0.15 }, { value: 0.08 }, { value: 0.12 }, { value: 0.18 },
{ value: 0.05 }, { value: 0.10 }, { value: 0.15 }, { value: 0.08 }, { value: 0.12 }
]);
// 右侧10个Pitch预设(对应右侧10个点,但Pitch值可独立调整)
const rightPresets = ref([
{ value: 0.20 }, { value: 0.30 }, { value: 0.40 }, { value: 0.50 }, { value: 0.60 },
{ value: 0.70 }, { value: 0.80 }, { value: 0.90 }, { value: 0.95 }, { value: 1.00 }
]);
// 当预设值改变时,自动同步到对应的点(可选)
const onPresetChange = () => {
// 可选:同步到 points 数组的 yaw/pitch
leftPresets.value.forEach((p, i) => {
points.value[i].yaw = p.value;
});
rightPresets.value.forEach((p, i) => {
points.value[i + 10].pitch = p.value;
});
};
// 点击图片上的点 → 应用对应预设,并同步到输入框
const applyPreset = (index) => {
points.value.forEach(p => p.active = false);
points.value[index].active = true;
const yaw = points.value[index].yaw;
const pitch = points.value[index].pitch;
servo1.value = yaw;
servo2.value = pitch;
// 同步到左侧/右侧输入框(高亮当前对应的预设)
if (index < 10) {
leftPresets.value.forEach((p, i) => p.value = i === index ? yaw : p.value);
} else {
rightPresets.value.forEach((p, i) => p.value = i === (index - 10) ? pitch : p.value);
}
sendServoValues();
};
// 从左侧预设应用当前选中的Yaw(假设用户先在输入框选中一个,这里用最后一个修改的近似,或添加选中逻辑)
let lastLeftIndex = null;
let lastRightIndex = null;
// 简化:提供按钮直接应用当前所有输入框的平均或手动选择。这里提供“应用当前Yaw”按钮,使用第一个非默认的值或手动。
// 为简化,这里按钮应用“第一个输入框的值”作为Yaw,你可以改为添加选中高亮。
const applyYawFromPresets = () => {
servo1.value = leftPresets.value[0].value; // 示例:使用第一个,你可改为选中机制
sendServoValues();
};
const applyPitchFromPresets = () => {
servo2.value = rightPresets.value[0].value; // 示例:使用第一个
sendServoValues();
};
const sendServoValues = async () => {
try {
// await setServo({
// robotId: robotId.value,
// deviceId: deviceId.value,
// servo1: servo1.value,
// servo2: servo2.value,
// });
} catch (error) {
console.error('舵机下发失败:', error);
}
};
const resetToCenter = () => {
servo1.value = 0.5;
servo2.value = 0.5;
points.value.forEach(p => p.active = false);
sendServoValues();
};
// 20个点坐标(已调整适应新图片)
const points = ref([
// 左侧10点
{ x: 18, y: 20, yaw: 0.10, pitch: 0.20, active: false },
{ x: 22, y: 30, yaw: 0.15, pitch: 0.30, active: false },
{ x: 15, y: 40, yaw: 0.08, pitch: 0.40, active: false },
{ x: 20, y: 50, yaw: 0.12, pitch: 0.50, active: false },
{ x: 25, y: 60, yaw: 0.18, pitch: 0.60, active: false },
{ x: 12, y: 70, yaw: 0.05, pitch: 0.70, active: false },
{ x: 18, y: 80, yaw: 0.10, pitch: 0.80, active: false },
{ x: 22, y: 90, yaw: 0.15, pitch: 0.90, active: false },
{ x: 15, y: 95, yaw: 0.08, pitch: 0.95, active: false },
{ x: 20, y: 98, yaw: 0.12, pitch: 1.00, active: false },
// 右侧10点(Yaw偏大)
{ x: 82, y: 20, yaw: 0.90, pitch: 0.20, active: false },
{ x: 78, y: 30, yaw: 0.85, pitch: 0.30, active: false },
{ x: 85, y: 40, yaw: 0.92, pitch: 0.40, active: false },
{ x: 80, y: 50, yaw: 0.88, pitch: 0.50, active: false },
{ x: 75, y: 60, yaw: 0.82, pitch: 0.60, active: false },
{ x: 88, y: 70, yaw: 0.95, pitch: 0.70, active: false },
{ x: 82, y: 80, yaw: 0.90, pitch: 0.80, active: false },
{ x: 78, y: 90, yaw: 0.85, pitch: 0.90, active: false },
{ x: 85, y: 95, yaw: 0.92, pitch: 0.95, active: false },
{ x: 80, y: 98, yaw: 0.88, pitch: 1.00, active: false },
]);
</script>
<style scoped>
.head-control {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
background: #fff;
border-radius: 20px;
height: calc(100vh - 125px);
display: flex;
flex-direction: column;
}
.title {
text-align: center;
margin-bottom: 20px;
font-size: 24px;
}
.main-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 40px;
}
.preset-panel {
width: 220px;
background: #f5f7fa;
padding: 20px;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.preset-panel h3 {
margin: 0 0 10px 0;
text-align: center;
font-size: 16px;
}
.preset-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.label {
width: 50px;
font-size: 14px;
}
.image-container {
position: relative;
max-width: 600px;
}
.head-image {
width: 100%;
height: auto;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.control-point {
position: absolute;
width: 40px;
height: 40px;
background: rgba(64, 158, 255, 0.8);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
cursor: pointer;
transform: translate(-50%, -50%);
transition: all 0.2s;
border: 3px solid #fff;
}
.control-point:hover {
background: #409eff;
transform: translate(-50%, -50%) scale(1.2);
}
.control-point.active {
background: #f56c6c;
transform: translate(-50%, -50%) scale(1.4);
}
.current-display {
display: flex;
justify-content: center;
align-items: center;
gap: 40px;
margin-top: 30px;
flex-wrap: wrap;
}
.current-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 18px;
}
.value {
font-weight: bold;
color: #409eff;
min-width: 60px;
}
</style>

View File

@ -1,133 +0,0 @@
<template>
<div class="container">
<div class="joint-control" v-for="joint in jointList" :key="joint.name">
<div class="joint-header">
<span class="joint-name">{{ joint.displayName }}</span>
<span class="joint-type">{{ joint.type.toUpperCase() }}</span>
</div>
<div class="joint-info">
<span class="angle-display">{{ formatAngle(joint.value) }}</span>
<span class="limits">[{{ formatAngle(joint.min) }}, {{ formatAngle(joint.max) }}]</span>
</div>
<div class="slider-container">
<el-slider size="small" v-model="joint.value" :min="joint.min" :max="joint.max" :step="joint.step" @input="(value) => updateJointAngle(joint.name, value)"/>
<div class="slider-labels">
<span>{{ formatAngle(joint.min) }}</span>
<span>{{ formatAngle(joint.max) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
jointList: {
type: Array,
default: () => []
}
})
const emits = defineEmits(['updateJointAngle'])
const updateJointAngle = (name, value) => {
emits('updateJointAngle', { name, value })
}
/**
* 角度格式化
*/
const formatAngle = (radians) => {
const degrees = (radians * 180 / Math.PI).toFixed(1)
return `${degrees}°`
}
</script>
<style lang="scss" scoped>
.container {
width: 100%;
height: 100%;
.joint-control {
border-radius: 6px;
padding: 15px;
margin-bottom: 15px;
border: 1px solid #cfcfcf;
.joint-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.joint-name {
font-weight: 600;
font-size: 14px;
}
.joint-type {
font-size: 11px;
background: rgba(52, 152, 219, 0.3);
color: #3498db;
padding: 3px 8px;
border-radius: 12px;
font-weight: 500;
}
}
.joint-info {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 13px;
.angle-display {
color: #f1c40f;
font-weight: 600;
font-size: 14px;
}
.limits {
color: #95a5a6;
font-size: 11px;
font-family: monospace;
}
}
.slider-container {
padding: 0 5px;
.joint-slider {
width: 100%;
height: 8px;
-webkit-appearance: none;
background: #34495e;
border-radius: 4px;
outline: none;
margin-bottom: 8px;
cursor: pointer;
&::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #ecf0f1;
cursor: pointer;
border: 2px solid #3498db;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
}
}
.slider-labels {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #7f8c8d;
margin-top: 5px;
}
}
}
}
</style>

View File

@ -1,394 +0,0 @@
<template>
<!-- 模板部分保持不变 -->
<div class="urdf-viewer-container">
<div ref="viewerContainer" class="urdf-viewer"></div>
<div v-if="isLoading" class="loading-overlay">
<div class="loading-spinner"></div>
<p>正在解析URDF并加载模型...</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, reactive, computed } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import URDFLoader from 'urdf-loader'
const props = defineProps({
basePath: { type: String, required: true },
urdfPath: { type: String, required: true },
modelColor: { type: String, default: '#cbcbcb' },
showJointLimits: { type: Boolean, default: true }
})
const emits = defineEmits(['computedChange'])
const viewerContainer = ref(null)
const isLoading = ref(false)
// Three.js 核心对象
let scene = null
let camera = null
let renderer = null
let controls = null
let robot = null
// 关节控制相关状态
const jointControls = reactive([])
const availableJoints = ref({})
/**
* 从URDF XML中提取关节信息(使用浏览器原生DOMParser)
*/
const extractJointInfoFromURDF = (urdfXml) => {
try {
const joints = []
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(urdfXml, 'text/xml')
// 检查解析错误
const parseError = xmlDoc.getElementsByTagName('parsererror')
if (parseError.length > 0) {
throw new Error('XML解析错误: ' + parseError[0].textContent)
}
// 获取所有joint元素
const jointElements = xmlDoc.getElementsByTagName('joint')
for (let i = 0; i < jointElements.length; i++) {
const jointElement = jointElements[i]
const name = jointElement.getAttribute('name')
const type = jointElement.getAttribute('type')
// 只处理可动关节
if (type && ['revolute', 'continuous', 'prismatic'].includes(type)) {
const jointInfo = {
name: name,
displayName: name.replace(/_/g, ' '),
type: type,
value: 0,
step: 0.01
}
// 提取关节限制
const limitElements = jointElement.getElementsByTagName('limit')
if (limitElements.length > 0) {
const limit = limitElements[0]
const lower = limit.getAttribute('lower')
const upper = limit.getAttribute('upper')
const effort = limit.getAttribute('effort')
const velocity = limit.getAttribute('velocity')
// 处理旋转关节
if (type === 'revolute' || type === 'continuous') {
jointInfo.min = lower !== null ? parseFloat(lower) :
(type === 'revolute' ? -Math.PI : -Math.PI)
jointInfo.max = upper !== null ? parseFloat(upper) :
(type === 'revolute' ? Math.PI : Math.PI)
}
// 处理平移关节
else if (type === 'prismatic') {
jointInfo.min = lower !== null ? parseFloat(lower) : -1.0
jointInfo.max = upper !== null ? parseFloat(upper) : 1.0
jointInfo.step = 0.001
}
if (effort) jointInfo.maxEffort = parseFloat(effort)
if (velocity) jointInfo.maxVelocity = parseFloat(velocity)
} else {
// 如果没有限制,设置默认范围
if (type === 'continuous') {
jointInfo.min = -Math.PI
jointInfo.max = Math.PI
} else if (type === 'revolute') {
jointInfo.min = -Math.PI / 2
jointInfo.max = Math.PI / 2
} else if (type === 'prismatic') {
jointInfo.min = -0.5
jointInfo.max = 0.5
}
}
// 提取关节轴
const axisElements = jointElement.getElementsByTagName('axis')
if (axisElements.length > 0) {
const axis = axisElements[0].getAttribute('xyz')
if (axis) {
const axisValues = axis.split(' ').map(Number)
jointInfo.axis = new THREE.Vector3(...axisValues)
}
}
// 提取关节原点
const originElements = jointElement.getElementsByTagName('origin')
if (originElements.length > 0) {
const origin = originElements[0]
const xyz = origin.getAttribute('xyz')
const rpy = origin.getAttribute('rpy')
if (xyz) jointInfo.origin = xyz.split(' ').map(Number)
if (rpy) jointInfo.orientation = rpy.split(' ').map(Number)
}
joints.push(jointInfo)
}
}
// 按关节名称排序
joints.sort((a, b) => a.name.localeCompare(b.name))
console.log('从URDF提取的关节信息:', joints)
return joints
} catch (error) {
console.error('解析URDF失败:', error)
return []
}
}
/**
* 初始化Three.js场景
*/
const initScene = () => {
const container = viewerContainer.value
scene = new THREE.Scene()
scene.background = new THREE.Color(0xf0f0f0)
camera = new THREE.PerspectiveCamera(
75,
container.clientWidth / container.clientHeight,
0.01,
1000
)
camera.position.set(2, 2, 3)
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(container.clientWidth, container.clientHeight)
container.appendChild(renderer.domElement)
// 光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
scene.add(ambientLight)
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
directionalLight.position.set(10, 20, 10)
scene.add(directionalLight)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
}
/**
* 加载URDF机器人模型
*/
const loadRobot = async () => {
isLoading.value = true
try {
// 清除现有模型
if (robot) {
scene.remove(robot)
jointControls.length = 0
availableJoints.value = {}
}
// 1. 加载URDF文件内容
const urdfUrl = `${props.basePath}${props.urdfPath}`
const response = await fetch(urdfUrl)
const urdfXml = await response.text()
// 2. 解析URDF提取关节信息(同步调用)
const extractedJoints = extractJointInfoFromURDF(urdfXml)
// 3. 使用URDFLoader加载模型
const manager = new THREE.LoadingManager()
const loader = new URDFLoader(manager)
loader.packages = { '': props.basePath }
robot = await new Promise((resolve, reject) => {
loader.load(urdfUrl, resolve, undefined, reject)
})
// 4. 坐标轴修正
robot.rotation.x = -Math.PI / 2
robot.rotation.z = Math.PI / 2
robot.updateMatrixWorld(true)
// 应用颜色
robot.traverse((child) => {
if (child.isMesh && child.material) {
child.material.color.set(props.modelColor)
}
})
scene.add(robot)
// 5. 初始化关节控制系统
availableJoints.value = robot.joints
// 将提取的关节信息与加载的关节匹配
extractedJoints.forEach(jointInfo => {
if (availableJoints.value[jointInfo.name]) {
// 获取当前关节值
const currentValue = availableJoints.value[jointInfo.name].jointValue || 0
// 确保初始值在限制范围内
let initialValue = currentValue
if (props.showJointLimits) {
initialValue = Math.max(jointInfo.min, Math.min(jointInfo.max, currentValue))
}
// 添加到控制列表
jointControls.push({
...jointInfo,
value: initialValue
})
// 设置初始关节位置
availableJoints.value[jointInfo.name].setJointValue(initialValue)
} else {
console.warn(`关节 ${jointInfo.name} 在URDF模型中不存在或不可控`)
}
})
fitCameraToObject(robot)
} catch (error) {
console.error('加载URDF模型失败:', error)
} finally {
isLoading.value = false
}
}
/**
* 更新关节角度
*/
const updateJointAngle = (jointName, angle) => {
if (availableJoints.value[jointName]) {
try {
// 应用限制(如果启用)
let clampedAngle = angle
if (props.showJointLimits) {
const jointInfo = jointControls.find(j => j.name === jointName)
if (jointInfo) {
clampedAngle = Math.max(jointInfo.min, Math.min(jointInfo.max, angle))
}
}
// 应用关节角度
availableJoints.value[jointName].setJointValue(clampedAngle)
// 更新UI中的值
const jointControl = jointControls.find(j => j.name === jointName)
if (jointControl) {
jointControl.value = clampedAngle
}
} catch (error) {
console.error(`设置关节 ${jointName} 角度失败:`, error)
}
}
}
/**
* 调整相机视角
*/
const fitCameraToObject = (object) => {
const boundingBox = new THREE.Box3().setFromObject(object)
const center = boundingBox.getCenter(new THREE.Vector3())
const size = boundingBox.getSize(new THREE.Vector3())
const maxSize = Math.max(size.x, size.y, size.z)
const fitHeightDistance = maxSize / (2 * Math.atan((Math.PI * camera.fov) / 360))
const fitWidthDistance = fitHeightDistance / camera.aspect
const distance = 1.5 * Math.max(fitHeightDistance, fitWidthDistance)
const direction = new THREE.Vector3(0, 1, 1).normalize()
controls.target.copy(center)
camera.position.copy(center).add(direction.multiplyScalar(distance))
controls.update()
}
/**
* 动画循环
*/
const animate = () => {
requestAnimationFrame(animate)
controls.update()
renderer.render(scene, camera)
}
/**
* 窗口大小变化处理
*/
const onWindowResize = () => {
const container = viewerContainer.value
camera.aspect = container.clientWidth / container.clientHeight
camera.updateProjectionMatrix()
renderer.setSize(container.clientWidth, container.clientHeight)
}
/**
* 清理资源
*/
const dispose = () => {
window.removeEventListener('resize', onWindowResize)
controls?.dispose()
renderer?.dispose()
}
// 监听属性变化
watch(
() => [props.basePath, props.urdfPath],
() => {
loadRobot()
}
)
watch(jointControls, (newVal) => {
emits('computedChange', newVal)
})
// 生命周期
onMounted(() => {
initScene()
loadRobot()
window.addEventListener('resize', onWindowResize)
animate()
})
onBeforeUnmount(() => {
dispose()
})
defineExpose({
updateJointAngle
})
</script>
<style scoped>
/* 样式部分保持不变 */
.urdf-viewer-container {
height: 100%;
width: 100%;
.urdf-viewer {
width: 100%;
height: 100%;
overflow: hidden;
}
}
.loading-overlay p {
font-size: 16px;
color: #ecf0f1;
}
</style>

View File

@ -1,384 +0,0 @@
<script setup>
import { computed, reactive, toRefs, onUnmounted, ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import { CircleClose } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { clearFault as clearArmFault } from "@/api/device/arm";
import UrdfViewer from "./components/UrdfViewer.vue";
import JointControl from "./components/JointControl.vue";
const route = useRoute();
const robotId = computed(() => String(route.query.robotId || ""));
const deviceId = computed(() => String(route.query.deviceId || ""));
const clearingFault = ref(false);
const handleClearFault = async () => {
if (!robotId.value || !deviceId.value) {
ElMessage.warning("缺少机器人或机械臂设备信息");
return;
}
clearingFault.value = true;
try {
await clearArmFault({ robotId: robotId.value, deviceId: deviceId.value });
ElMessage.success("机械臂故障状态已清除");
} catch (error) {
console.error("清除机械臂故障失败:", error);
} finally {
clearingFault.value = false;
}
};
const data = reactive({
form: {
x: 11,
y: 11,
z: 11,
rx: 11,
ry: 11,
rz: 11,
arm: 1,
},
armList: [
{ label: "目标1", value: 1 },
{ label: "目标2", value: 2 },
{ label: "目标3", value: 3 },
],
});
const { form, armList } = toRefs(data);
const leftJointList = ref(null);
const rightJointList = ref(null);
const computedChange = (val) => {
leftJointList.value = val.filter((item) => {
return item.name[0] === "L";
});
rightJointList.value = val.filter((item) => {
return item.name[0] === "R";
});
};
const urdfViewerRef = ref(null);
const updateJointAngle = ({ name, value }) => {
if (urdfViewerRef.value) {
urdfViewerRef.value.updateJointAngle(name, value);
}
};
const coordinateSystem = ref(null); // 用于获取容器元素
const buttonPositions = ref([]); // 存储按钮的动态位置
const buttonDirections = [
{ target: "z_plus", direction: "top", label: "Z+" }, // left
{ target: "rz_plus", direction: "top-right", label: "RZ+" },
{ target: "x_plus", direction: "right", label: "X+" }, // bottom
{ target: "ry_plus", direction: "bottom-right", label: "RY+" },
{ target: "y_minus", direction: "bottom", label: "Y-" },
{ target: "ry_minus", direction: "bottom-left", label: "RY-" },
{ target: "z_minus", direction: "bottom", label: "Z-" },
{ target: "rx_minus", direction: "bottom-left", label: "RX-" },
{ target: "x_minus", direction: "left", label: "X-" },
{ target: "rx_plus", direction: "top-left", label: "RX+" },
{ target: "y_plus", direction: "left", label: "Y+" }, // right
{ target: "rz_minus", direction: "top-left", label: "RZ-" },
];
// 默认图片尺寸
const ORIGINAL_IMAGE_WIDTH = 1025;
const ORIGINAL_IMAGE_HEIGHT = 817;
const updateButtonPositions = () => {
if (coordinateSystem.value) {
const containerRect = coordinateSystem.value.getBoundingClientRect();
const containerWidth = containerRect.width;
const containerHeight = containerRect.height;
// 计算图片的实际显示尺寸(考虑 background-size: contain)
const imageAspectRatio = ORIGINAL_IMAGE_WIDTH / ORIGINAL_IMAGE_HEIGHT;
const containerAspectRatio = containerWidth / containerHeight;
let imageWidth, imageHeight, imageLeft, imageTop;
if (containerAspectRatio > imageAspectRatio) {
imageHeight = containerHeight;
imageWidth = imageHeight * imageAspectRatio;
imageLeft = (containerWidth - imageWidth) / 2;
imageTop = 0;
} else {
imageWidth = containerWidth;
imageHeight = imageWidth / imageAspectRatio;
imageLeft = 0;
imageTop = (containerHeight - imageHeight) / 2;
}
// 计算图片中心坐标
const centerX = imageLeft + imageWidth / 2;
const centerY = imageTop + imageHeight / 2;
// 计算圆的半径(根据图片尺寸的较小值比例确定,可调整比例系数)
const radiusScale = 0.85; // 半径系数,0-1之间,越大半径越大
const radius = Math.min(imageWidth, imageHeight) * radiusScale / 2;
// 定义方位对应的角度(弧度制),top为0度,顺时针增加
const directionAngles = {
"top": 0,
"top-right": Math.PI / 6, // 30度
"right": Math.PI / 3, // 60度
"bottom-right": Math.PI / 2, // 90度
"bottom": 2 * Math.PI / 3, // 120度
"bottom-left": 5 * Math.PI / 6, // 150度
"left": Math.PI, // 180度
"top-left": 7 * Math.PI / 6, // 210度
};
// 调整角度以均匀分布12个按钮(360/12=30度,即Math.PI/6弧度)
const totalButtons = buttonDirections.length;
const angleStep = 2 * Math.PI / totalButtons;
// 更新按钮位置
buttonPositions.value = buttonDirections.map((btn, index) => {
// 计算每个按钮的角度(从top开始顺时针排列)
const angle = index * angleStep;
// 计算按钮的坐标
const x = centerX + radius * Math.sin(angle);
const y = centerY - radius * Math.cos(angle);
return {
...btn,
left: x,
top: y,
};
});
}
};
onMounted(() => {
updateButtonPositions();
// 监听窗口大小变化,更新按钮位置
window.addEventListener("resize", updateButtonPositions);
});
onUnmounted(() => {
window.removeEventListener("resize", updateButtonPositions);
});
</script>
<template>
<div class="mechanical-arm">
<div class="top card">
<div class="controls flex align-center" style="justify-content: flex-start">
<div class="control-label">目标</div>
<el-select
v-model="form.arm"
placeholder="选择目标"
style="width: 100px; margin-right: 20px"
>
<el-option
v-for="item in armList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button @click="handleResets('zero')" style="margin-right: 10px"
>零位姿态</el-button
>
<el-button @click="handleResets('init')">初始位置</el-button>
<el-button
type="danger"
plain
:loading="clearingFault"
:disabled="!robotId || !deviceId"
@click="handleClearFault"
>
<el-icon><CircleClose /></el-icon>清除故障
</el-button>
</div>
<div class="pose-info flex align-center">
<div class="pose-item">X: {{ form.x.toFixed(6) }} m</div>
<div class="pose-item">Y: {{ form.y.toFixed(6) }} m</div>
<div class="pose-item">Z: {{ form.z.toFixed(6) }} m</div>
<div class="pose-item">RX: {{ form.rx.toFixed(6) }} deg</div>
<div class="pose-item">RY: {{ form.ry.toFixed(6) }} deg</div>
<div class="pose-item">RZ: {{ form.rz.toFixed(6) }} deg</div>
</div>
</div>
<div class="bottom">
<div class="left">
<UrdfViewer
ref="urdfViewerRef"
:base-path="'/public/models/'"
:urdf-path="'dual_arm.urdf'"
:model-color="'#8a8ae9'"
@computedChange="computedChange"
/>
</div>
<div class="right">
<div class="top">
<div class="secondary-title">
<div class="label">左手</div>
<div class="label">右手</div>
</div>
<div class="jointControl-container">
<JointControl
:jointList="leftJointList"
@updateJointAngle="updateJointAngle"
/>
<JointControl
:jointList="rightJointList"
@updateJointAngle="updateJointAngle"
/>
</div>
</div>
<div class="bottom">
<div class="left card" style="position: relative">
<div class="coordinate-system" ref="coordinateSystem">
<div class="control-buttons">
<button
v-for="(pos, index) in buttonPositions"
:key="index"
class="pos-btn"
:data-target="pos.target"
:style="{ left: `${pos.left}px`, top: `${pos.top}px` }"
>
{{ pos.label }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.mechanical-arm {
display: flex;
flex: 1;
overflow: hidden;
flex-direction: column;
box-sizing: border-box;
.card {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
}
.controls {
gap: 10px;
padding: 10px;
}
.top {
.pose-info {
justify-content: space-around;
padding: 10px 0;
.pose-item {
font-size: 16px;
user-select: none;
}
}
}
.bottom {
height: calc(100% - 120px);
display: flex;
gap: 12px;
.left {
flex: 2;
height: 100%;
}
.right {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
.top {
flex: 1;
border: 1px solid #e4e7ed;
padding: 0 8px;
overflow: hidden;
.container-title {
font-size: 16px;
text-align: center;
}
.secondary-title {
display: flex;
justify-content: space-around;
padding: 10px;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.jointControl-container {
display: flex;
gap: 10px;
height: calc(100% - 75px);
overflow: auto;
}
}
.bottom {
flex: 1;
.card {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
height: 100%;
display: flex;
flex-direction: column;
.coordinate-system {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
position: relative;
background-color: #fff;
background-image: url("@/assets/images/arm.png");
background-size: contain;
background-position: center;
background-repeat: no-repeat;
.control-buttons {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
.pos-btn {
position: absolute;
width: 40px;
height: 40px;
background: #4076ff;
color: white;
border: none;
cursor: pointer;
font-size: 16px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%); /* 按钮中心对齐坐标 */
}
}
}
}
}
}
}
}
</style>

View File

@ -1,233 +0,0 @@
<template>
<div class="microphone-container">
<div class="title">远程麦克风</div>
<div class="audiosList" v-if="audiosList.length > 0">
<div class="speaker-container">
<div class="speaker-title">选择扬声器</div>
<el-select v-model="speaker" size="small">
<el-option
v-for="item in speakerOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</div>
<div class="audioItem" v-for="item in audiosList">
<div>{{ item }}</div>
<div>
<el-button
circle
size="small"
:icon="VideoPlay"
@click="handlePlay(item)"
></el-button>
</div>
</div>
</div>
<div class="btn-container">
<el-tooltip
class="box-item"
effect="dark"
content="点击开始录音"
placement="top-start"
v-if="recordStatus === 'end'"
>
<el-icon class="btn" :size="40" @click="startRecording"><Microphone /></el-icon>
</el-tooltip>
<el-tooltip
class="box-item"
effect="dark"
content="点击暂停录音"
placement="top-start"
v-if="recordStatus === 'recording'"
>
<el-icon class="btn" :size="40" @click="pauseRecording"><VideoPause /></el-icon>
</el-tooltip>
<el-tooltip
class="box-item"
effect="dark"
content="点击恢复录音"
placement="top-start"
v-if="recordStatus === 'pause'"
>
<el-icon class="btn" :size="40" @click="recoverRecording"><Mic /></el-icon>
</el-tooltip>
<el-tooltip
class="box-item"
effect="dark"
content="点击结束录音"
placement="top-start"
v-if="recordStatus !== 'end'"
>
<el-icon class="btn" :size="40" @click="stopRecording"><Mute /></el-icon>
</el-tooltip>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { Microphone, VideoPause, Mute, Mic, VideoPlay } from "@element-plus/icons-vue";
import {
startMicrophoneApi,
pauseMicrophoneApi,
resumeMicrophoneApi,
stopMicrophoneApi,
playSpeaker
} from '@/api/device/microphone'
import { useRoute } from 'vue-router'
import { ElMessage } from "element-plus";
import { getRobotDevicesByRobotId } from '@/api/resource/robot'
const route = useRoute()
const robotId = route.query.robotId
const deviceId = route.query.deviceId
const recordStatus = ref('end');
const isRecording = ref(false)
const list = []
const audiosList = ref([])
const speaker = ref('')
const speakerOptions = ref([])
onMounted(async () => {
if (!robotId) return
const response = await getRobotDevicesByRobotId(String(robotId), { deviceKind: 13 })
speakerOptions.value = (response.data || []).map(item => item.deviceId)
speaker.value = speakerOptions.value[0] || ''
})
/**
* 开始录音
*/
const startRecording = async () => {
const fileName = new Date().getTime() + '.wav'
isRecording.value = true
const res = await startMicrophoneApi({
robotId,
deviceId,
filePath: `/home/share/record/audios/${robotId}_${deviceId}_${fileName}`
})
if (res.code === 200) {
recordStatus.value = 'recording'
list.push(`${robotId}_${deviceId}_${fileName}`)
}
};
/**
* 暂停录音
*/
const pauseRecording = async () => {
const res = await pauseMicrophoneApi({
robotId,
deviceId
})
if (res.code === 200) {
recordStatus.value = 'pause'
}
};
/**
* 恢复录音
*/
const recoverRecording = async () => {
const res = await resumeMicrophoneApi({
robotId,
deviceId
})
if (res.code === 200) {
recordStatus.value = 'recording'
}
}
/**
* 结束录音
*/
const stopRecording = async () => {
const res = await stopMicrophoneApi({
robotId,
deviceId
})
if (res.code === 200) {
recordStatus.value = 'end'
audiosList.value.unshift(list[list.length - 1])
}
isRecording.value = false
};
/**
* 播放录音
*/
const handlePlay = async (audioPath) => {
const res = await playSpeaker({
robotId,
deviceId: speaker.value,
audioPath: `/home/share/record/audios/${audioPath}`
})
if (res.code === 200) {
ElMessage.success('播放成功')
}
}
</script>
<style lang="scss" scoped>
.microphone-container {
display: flex;
flex: 1;
flex-direction: column;
max-width: 800px;
width: 100%;
height: calc(100vh - 125px);
background-color: #fff;
border-radius: 20px;
margin: auto;
.audiosList {
margin: 20px;
max-height: 280px;
overflow-y: auto;
.speaker-container {
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 12px;
.speaker-title {
width: 80px;
}
.el-select {
width: 200px;
}
}
.audioItem {
display: flex;
align-items: center;
gap: 20px;
}
}
.title {
padding-top: 20px;
font-size: 20px;
text-align: center;
color: #333;
}
.btn-container {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: center;
.btn {
margin-right: 10px;
cursor: pointer;
}
}
}
</style>

View File

@ -1,76 +0,0 @@
<script setup>
import ControlCard from "../ControlCard/index.vue"
const data = reactive({
form: {},
rules: {}
})
const {form, rules} = toRefs(data)
</script>
<template>
<div class="motor">
<el-row :gutter="16">
<el-col :span="12">
<ControlCard title="电机">
<div class="pic">
电机pic
<svg t="1747108210294" class="arrow" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="23587" width="32" height="32"><path d="M481.940645 156.242581C209.094194 383.504516 254.018065 660.645161 313.806452 778.570323l117.925161 234.859354 152.609032-76.634838-117.925161-234.859355c-4.624516-8.91871-100.748387-224.619355 132.129032-418.188387l33.032258 70.028387c8.91871 18.167742 19.489032 27.747097 39.969032 23.122581a36.996129 36.996129 0 0 0 27.086452-33.032259l66.064516-340.56258L423.803871 51.530323a39.308387 39.308387 0 0 0-36.005161 25.765161 37.987097 37.987097 0 0 0 19.489032 39.308387l74.652903 39.63871" fill="#409eff" p-id="23588"></path></svg>
<svg t="1747108469463" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="28512" width="32" height="32"><path d="M533.312 1015.104l-3.968 3.392a30.144 30.144 0 0 1-47.488-24.64v-210.816H70.272A70.272 70.272 0 0 1 0 712.768V311.232l0.384-7.68c3.84-35.2 33.664-62.592 69.888-62.592h411.584V30.144c0-26.816 32.448-40.256 51.456-21.248l481.92 481.792a30.08 30.08 0 0 1 0 42.624l-481.92 481.792z" fill="#409eff" p-id="28513"></path></svg>
<svg t="1747126605366" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1461" width="32" height="32"><path d="M311.432533 448.170667H163.84L510.293333 3.413333 856.746667 448.170667h-147.592534L760.832 1006.933333H259.754667z" fill="#0F62FE" p-id="1462"></path></svg>
<svg t="1747126646064" class="icon" viewBox="0 0 2105 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1623" width="32" height="32"><path d="M1368.744385 1015.123641l-5.916434 3.413328a64.853225 64.853225 0 0 1-46.819478 2.047997c-14.961753-5.119991-24.405293-15.473752-24.405292-26.737734v-210.772982H105.414935c-27.989287 0-54.783909-7.395543-74.52432-20.593743C11.093315 749.225418 0 731.419225 0 712.759701V311.238592l0.568888-7.679987c5.745768-35.214164 50.517249-62.577673 104.846047-62.577673h1186.188246V30.151061c0-26.794622 48.696808-40.277266 77.198093-21.21952l722.829018 481.791197c8.47643 5.631991 13.255089 13.311978 13.255089 21.276409 0 7.964431-4.778659 15.701307-13.255089 21.333297L1368.744385 1015.123641z" fill="#D6E4FE" p-id="1624"></path></svg>
<svg t="1747126605366" class="turn-up" viewBox="0 0 1024 1024" version="1.1"
xmlns="http://www.w3.org/2000/svg" p-id="1461" width="32" height="32">
<path
d="M311.432533 448.170667H263.84L510.293333 3.413333 856.746667 448.170667h-247.592534L760.832 1006.933333H159.754667z"
fill="#0F62FE" p-id="1462"></path>
</svg>
</div>
</ControlCard>
</el-col>
<el-col :span="12">
<ControlCard title="操作">
<div class="control">
<el-form :model="form" :rules="rules" label-width="100px">
<el-form-item label="速度">
<el-input v-model="form.speed" placeholder="请输入速度"></el-input>
</el-form-item>
<el-form-item label="位置">
<el-input v-model="form.position" placeholder="请输入位置"></el-input>
</el-form-item>
<el-form-item label="状态">
<el-input v-model="form.status" placeholder="请输入状态"></el-input>
</el-form-item>
<el-form-item label="运行模式">
<el-input v-model="form.mode" placeholder="请输入运行模式"></el-input>
</el-form-item>
</el-form>
</div>
</ControlCard>
</el-col>
</el-row>
</div>
</template>
<style scoped lang="scss">
.motor {
padding: 10px;
width: 100vw;
height: 100vh;
.arrow{
width: 100px;
height: 100px;
}
.el-row {
height: 100%;
.control-card {
height: 100%;
}
}
}
</style>

View File

@ -1,278 +0,0 @@
<template>
<div class="audio-player">
<div class="title">远程扬声器</div>
<div class="audio-list">
<div
v-for="(audio, index) in audioList"
:key="index"
class="audio-item"
:class="{ 'active': index === currentIndex }"
@click="playAudio(index)"
>
{{ audio.name }}
</div>
</div>
<div class="controls">
<el-button @click="playPrevious" :disabled="currentIndex === 0" circle size="small">
<svg :class="`icon ${currentIndex === 0 ? 'disabled-icon' : ''}`" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="20">
<path d="M362.3 512l445-332.3v664.5L362.3 512zM216.7 179.7h80v664.5h-80V179.7z" fill="" />
</svg>
</el-button>
<el-button @click="togglePlay" circle>
<svg v-if="isPlaying" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<path d="M512 1024C228.266667 1024 0 795.733333 0 512S228.266667 0 512 0s512 228.266667 512 512-228.266667 512-512 512z m0-42.666667c260.266667 0 469.333333-209.066667 469.333333-469.333333S772.266667 42.666667 512 42.666667 42.666667 251.733333 42.666667 512s209.066667 469.333333 469.333333 469.333333z m-106.666667-682.666666c12.8 0 21.333333 8.533333 21.333334 21.333333v384c0 12.8-8.533333 21.333333-21.333334 21.333333s-21.333333-8.533333-21.333333-21.333333V320c0-12.8 8.533333-21.333333 21.333333-21.333333z m213.333334 0c12.8 0 21.333333 8.533333 21.333333 21.333333v384c0 12.8-8.533333 21.333333-21.333333 21.333333s-21.333333-8.533333-21.333334-21.333333V320c0-12.8 8.533333-21.333333 21.333334-21.333333z" fill="#666666" fill-opacity=".9" />
</svg>
<svg v-else class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<path d="M512 0C230.4 0 0 230.4 0 512s230.4 512 512 512 512-230.4 512-512S793.6 0 512 0z m0 981.333333C253.866667 981.333333 42.666667 770.133333 42.666667 512S253.866667 42.666667 512 42.666667s469.333333 211.2 469.333333 469.333333-211.2 469.333333-469.333333 469.333333z" fill="#666666" />
<path d="M672 441.6l-170.666667-113.066667c-57.6-38.4-106.666667-12.8-106.666666 57.6v256c0 70.4 46.933333 96 106.666666 57.6l170.666667-113.066666c57.6-42.666667 57.6-106.666667 0-145.066667z" fill="#666666" />
</svg>
</el-button>
<el-button @click="playNext" :disabled="currentIndex === audioList.length - 1" circle size="small">
<svg :class="`icon ${currentIndex === audioList.length - 1 ? 'disabled-icon' : ''}`" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="20">
<path d="M216.7 844.3V179.7l445 332.3-445 332.3z m590.6 0h-80V179.7h80v664.6z" fill="" />
</svg>
</el-button>
<div class="volume-control">
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M448 938.666667a21.333333 21.333333 0 0 1-15.093333-6.246667L225.833333 725.333333H53.333333a53.393333 53.393333 0 0 1-53.333333-53.333333V352a53.393333 53.393333 0 0 1 53.333333-53.333333h172.5l207.08-207.086667A21.333333 21.333333 0 0 1 469.333333 106.666667v810.666666a21.333333 21.333333 0 0 1-21.333333 21.333334zM53.333333 341.333333a10.666667 10.666667 0 0 0-10.666666 10.666667v320a10.666667 10.666667 0 0 0 10.666666 10.666667h181.333334a21.333333 21.333333 0 0 1 15.086666 6.246666L426.666667 865.833333V158.166667L249.753333 335.086667A21.333333 21.333333 0 0 1 234.666667 341.333333z m750.48 586.553334a21.333333 21.333333 0 0 1-12.726666-38.466667 474.853333 474.853333 0 0 0 52.78-45.553333c182.993333-182.993333 182.993333-480.74 0-663.733334a474.246667 474.246667 0 0 0-52.78-45.553333 21.333333 21.333333 0 0 1 25.42-34.273333 518.346667 518.346667 0 0 1 57.533333 49.653333 511.606667 511.606667 0 0 1 0 724.08 519.026667 519.026667 0 0 1-57.54 49.653333 21.22 21.22 0 0 1-12.686667 4.193334z m-86.213333-149.333334a21.333333 21.333333 0 0 1-13.733333-37.666666c6.666667-5.586667 13.146667-11.553333 19.333333-17.726667C779.6 666.78 810.666667 591.78 810.666667 512s-31.066667-154.78-87.48-211.186667c-6.173333-6.173333-12.666667-12.14-19.333334-17.726666a21.333333 21.333333 0 1 1 27.446667-32.666667 346.585333 346.585333 0 0 1 22.046667 20.213333 341.066667 341.066667 0 0 1 0 482.72 346.585333 346.585333 0 0 1-22.046667 20.213334 21.24 21.24 0 0 1-13.7 5.013333zM629.333333 625.72a21.333333 21.333333 0 0 1-16.733333-34.546667 127.366667 127.366667 0 0 0 0-158.346666 21.333333 21.333333 0 0 1 33.486667-26.433334 170.733333 170.733333 0 0 1 0 211.213334A21.333333 21.333333 0 0 1 629.333333 625.72z" fill="#5C5C66" />
</svg>
<input
type="range"
min="0"
max="100"
step="1"
v-model.number="volume"
@input="debouncedSetVolume"
/>
<span>{{ Math.round(volume) }}%</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';
import {
pause,
play,
resume,
status,
stop,
getVolume,
setVolume as setVolumeApi,
getAll
} from '@/api/device/speaker';
const audioList = ref([
{ name: 'Track 1', path: '/audio/track1.mp3' },
{ name: 'Track 2', path: '/audio/track2.mp3' },
{ name: 'Track 3', path: '/audio/track3.mp3' },
]);
const currentIndex = ref(0);
const isPlaying = ref(false);
const volume = ref(0);
const route = useRoute();
const robotId = ref(String(route.query.robotId || ''));
const deviceId = ref(String(route.query.deviceId || ''));
const getCommonParams = () => ({
robotId: robotId.value,
deviceId: deviceId.value
});
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
};
let pollingTimeout;
const checkStatus = async () => {
try {
const response = await status(getCommonParams());
isPlaying.value = response.data.state.isRunning && !response.data.state.isPaused;
if (isPlaying.value && !response.data.state.isPaused) {
// 完成后再次调度
pollingTimeout = setTimeout(checkStatus, 1000); // 5秒间隔
}
} catch (error) {
console.error('状态检查失败:', error);
// 即使出错,也继续轮询
// pollingTimeout = setTimeout(checkStatus, 1000);
}
};
onMounted(async () => {
try {
const allResponse = await getAll();
audioList.value = allResponse.data.map(item => ({
name: item.split('.')[0],
path: `/home/share/assets/upload/${item}`
}));
await initialize();
// 启动轮询
checkStatus();
} catch (error) {
console.error('初始化失败:', error);
}
});
onUnmounted(async () => {
try {
await stop(getCommonParams());
if (pollingTimeout) {
clearTimeout(pollingTimeout); // 清除轮询
}
} catch (error) {
console.error('清理失败:', error);
}
});
const initialize = async () => {
try {
const volumeResponse = await getVolume({ ...getCommonParams() });
volume.value = volumeResponse.data || 0;
} catch (error) {
console.error('初始化状态失败:', error);
}
};
const playAudio = async (index) => {
currentIndex.value = index;
isPlaying.value = true;
try {
await play({ ...getCommonParams(), audioPath: audioList.value[index].path });
checkStatus();
} catch (error) {
console.error('播放失败:', error);
isPlaying.value = false;
}
};
const playPrevious = async () => {
if (currentIndex.value > 0) {
await playAudio(currentIndex.value - 1);
}
};
const playNext = async () => {
if (currentIndex.value < audioList.value.length - 1) {
await playAudio(currentIndex.value + 1);
} else {
await playAudio(0);
}
};
const togglePlay = async () => {
try {
if (isPlaying.value) {
await pause(getCommonParams());
} else {
await resume(getCommonParams());
}
isPlaying.value = !isPlaying.value;
checkStatus();
} catch (error) {
console.error('操作失败:', error);
}
};
const setVolume = async () => {
try {
await setVolumeApi({ ...getCommonParams(), volume: volume.value });
} catch (error) {
console.error('音量设置失败:', error);
}
};
const debouncedSetVolume = debounce(setVolume, 500);
</script>
<style scoped>
.audio-player {
display: flex;
flex: 1;
flex-direction: column;
max-width: 800px;
width: 100%;
height: calc(100vh - 125px);
background-color: #fff;
border-radius: 20px;
margin: auto;
}
.title {
padding-top: 20px;
font-size: 20px;
text-align: center;
color: #333;
}
.audio-list {
margin: 20px 0;
max-height: calc(100% - 130px);
overflow-y: auto;
border-bottom: 1px solid #ddd;
border-top: 1px solid #ddd;
}
.audio-item {
padding: 10px;
cursor: pointer;
}
.audio-item:hover,
.audio-item.active {
background-color: #e0e0e0;
}
.controls {
display: flex;
justify-content: space-around;
align-items: center;
margin-top: 20px;
}
button {
padding: 8px 16px;
background-color: rgba(0, 0, 0, 0);
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
button:disabled {
background-color: rgba(0, 0, 0, 0);
cursor: not-allowed;
}
button:hover:not(:disabled) {
background-color: #eee;
}
.volume-control {
display: flex;
align-items: center;
gap: 5px;
}
input[type="range"] {
width: 100px;
}
.icon {
vertical-align: middle;
}
.disabled-icon {
fill: #ddd;
}
</style>

View File

@ -1,108 +0,0 @@
<template>
<div class="voice-container" @click="togglePlay">
<div class="voice-btn" :class="{ playing: isPlaying }">
<span class="duration">{{ formattedDuration }}"</span>
<div class="wave">
<span v-for="n in 3" :key="n"
:style="{ height: isPlaying ? getRandomHeight() : '0px' }"></span>
</div>
</div>
<audio ref="audioEl"
:src="url"
@play="handlePlay"
@pause="handlePause"
@ended="handleEnd"
@loadedmetadata="updateDuration"></audio>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const props = defineProps({
url: { type: String, required: true },
recordingTime: { type: Number },
})
const audioEl = ref(null)
const isPlaying = ref(false)
const duration = ref(0)
let animationFrame
const formattedDuration = computed(() => Math.ceil(duration.value))
const getRandomHeight = () => `${Math.random() * 12 + 4}px`
const togglePlay = () => {
isPlaying.value ? audioEl.value.pause() : audioEl.value.play()
}
const handlePlay = () => {
isPlaying.value = true
animateWave()
}
const handlePause = () => {
isPlaying.value = false
cancelAnimationFrame(animationFrame)
}
const handleEnd = () => {
isPlaying.value = false
cancelAnimationFrame(animationFrame)
}
const updateDuration = () => {
duration.value = audioEl.value.duration === Infinity ? props.recordingTime : audioEl.value.duration
}
const animateWave = () => {
if (!isPlaying.value) return
animationFrame = requestAnimationFrame(animateWave)
}
// onMounted(() => {
// audioEl.value.src = props.src
// })
onUnmounted(() => {
cancelAnimationFrame(animationFrame)
})
</script>
<style scoped>
.voice-container {
display: inline-block;
cursor: pointer;
width: 100px;
}
.voice-btn {
display: flex;
align-items: center;
padding: 8px 12px;
background: #e6e6e6;
border-radius: 18px;
transition: all 0.3s;
}
.voice-btn.playing {
background: #d3e3fd;
}
.duration {
margin: 0 12px;
font-size: 18px;
color: #666;
}
.wave {
display: flex;
align-items: flex-end;
height: 20px;
gap: 3px;
}
.wave span {
display: inline-block;
width: 4px;
background: #07C160;
transition: height 0.3s ease;
}
</style>

View File

@ -1,699 +0,0 @@
<template>
<div class="container">
<div class="chat-container">
<div class="message-list" v-if="messages.length > 0" ref="messageRef">
<div
v-for="(msg, index) in messages"
:key="index"
class="message"
:class="{
'user-message': msg.role === 'user',
'ai-message': msg.role === 'assistant',
}"
>
<div class="message-box">
<div v-if="msg.role === 'assistant'" class="picture"></div>
<div class="message-container">
<div v-for="item in msg.list" class="messageContent">
<div class="content">{{ item.content }}</div>
<div class="audio" v-if="item.url">
<AudioPlayer :url="item.url" />
</div>
</div>
</div>
<!-- <div v-if="msg.role === 'user'" class="picture"></div> -->
</div>
</div>
<div v-if="isQuery" style="margin-left: 60px">
{{ queryText }}<el-icon><Loading /></el-icon>
</div>
</div>
<div class="welcome" v-else>
<div>
<div class="name">{{ `我是${aiName}助手,很高兴见到你` }}</div>
<div class="function">我可以帮你合成音色,请把你的任务交给我吧~</div>
</div>
</div>
<div class="input-area">
<el-input
v-model="inputMessage"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
resize="none"
placeholder="Shift+Enter换行"
@keyup.native="handleKeyUp"
@keyup.enter.native="handleEnter"
>
</el-input>
<div class="bottom-box">
<div class="select-box">
<el-select v-model="timbreValue" size="small" style="width: 120px">
<el-option
v-for="item in timbreOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<el-select
v-if="aiType === 'knowledge'"
v-model="langValue"
size="small"
style="width: 100px; margin-left: 10px"
>
<el-option
v-for="item in langOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="dialectValue"
:disabled="langValue !== '中文'"
size="small"
style="width: 100px; margin-left: 10px"
>
<el-option
v-for="item in dialectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-if="aiType === 'knowledge'"
v-model="toneValue"
size="small"
style="width: 100px; margin-left: 10px"
>
<el-option
v-for="item in toneOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input-number style="width: 100px; margin-left: 10px" v-if="aiType === 'knowledge'" size="small" v-model="countValue" :min="1" :step="1" step-strictly />
</div>
<div class="send-message">
<el-button
:icon="Position"
size="small"
type="primary"
circle
:disabled="inputMessage === ''"
@click="sendMessage"
/>
</div>
</div>
</div>
</div>
<div class="addSound" @click="openDialog">
<el-icon :size="40"><CirclePlus /></el-icon>
</div>
<el-dialog
v-model="dialogVisible"
title="新增音色"
width="500"
:before-close="handleClose"
>
<el-form
ref="ruleFormRef"
style="max-width: 600px"
:model="ruleForm"
:rules="rules"
label-width="auto"
>
<el-form-item label="名称" prop="name">
<el-input v-model="ruleForm.name" />
</el-form-item>
</el-form>
<SoundRecording ref="soundRecordingRef" />
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="submit">提交</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from "vue";
import { Position, CirclePlus } from "@element-plus/icons-vue";
import SoundRecording from "./soundRecording.vue";
import { ElMessage } from "element-plus";
import { createTimbre, getTts, createCorpus, getSynthesize, createAdvancedCorpus, getAdvancedSynthesize } from '@/api/tts';
import { ConcurrentRequestController } from "@/utils/requestController";
import AudioPlayer from "./AudioPlayer.vue";
const props = defineProps({
aiName: {
type: String,
default: '语音助手'
},
aiType: {
type: String,
default: 'basis'
}
})
const messageRef = ref(null);
const inputMessage = ref("");
const messages = ref([]);
const isQuery = ref(false);
const queryText = ref("正在思考中");
const messageIndex = ref(-1)
const controller = new ConcurrentRequestController(3); // 最大并发数为3
const cancelIndex = ref(0)
const sendMessage = async () => {
if (inputMessage.value.trim()) {
isQuery.value = true;
messages.value.push({ role: "user", list: [{ content: inputMessage.value }] });
await nextTick();
messageRef.value.scrollTop = messageRef.value.scrollHeight;
const str = inputMessage.value;
inputMessage.value = "";
if (controller.getStatus().queueLength > 0) {
controller.cancelAll();
cancelIndex.value = messageIndex.value
messages.value[cancelIndex.value] = {
role: "assistant",
list: [{ content: '已取消查询' }]
}
}
messageIndex.value += 2
getMessage(str, messageIndex.value);
}
};
const getMessage = (str, index) => {
if (props.aiType === 'basis') {
basisAiOperate(str, index)
} else if (props.aiType === 'knowledge') {
knowledgeAiOperate(str, index)
}
};
// 基础ai(语音合成)发送消息后的操作
const basisAiOperate = async (str, index) => {
try {
const res = await createCorpus({
convertWords: str,
dialect: dialectValue.value,
timbre: timbreValue.value,
});
if (res.code === 200) {
const msg = JSON.parse(res?.msg || "[]");
if (msg.length === 0) {
serviceError("未查询到相关服务");
} else {
queryText.value = "语音合成中";
getBasisAudio(msg, index);
}
} else {
serviceError("服务器繁忙,请稍后重试");
}
} catch (err) {
serviceError("服务器繁忙,请稍后重试");
}
}
const getBasisAudio = async (arr, index) => {
messages.value[index] = {
role: "assistant",
list: []
}
await basisApiController(arr, index);
isQuery.value = false;
};
async function basisApiController(arr, messageIndex) {
// 模拟10个需要发送的请求
const requests = arr.map((item, index) => {
return (signal) =>
getSynthesize({
dialect: dialectValue.value,
text: item,
timbre: timbreValue.value,
}, {
signal: signal
})
.then(async (data) => {
if (cancelIndex.value === messageIndex) {
return
}
console.log(`请求 ${index} 完成`);
const url = URL.createObjectURL(data);
messages.value[messageIndex].list.push({
content: item,
url,
})
await nextTick();
messageRef.value.scrollTop = messageRef.value.scrollHeight;
return data;
})
.catch(async (error) => {
if (cancelIndex.value === messageIndex) {
return
}
console.error(`请求 ${index} 失败:`, error);
messages.value[messageIndex].list.push({
content: item + " ,语音转换失败"
})
await nextTick();
messageRef.value.scrollTop = messageRef.value.scrollHeight;
throw error;
});
});
// 发送所有请求
const results = await Promise.allSettled(
requests.map((request) => controller.addRequest(request))
);
// 处理结果
const successfulResults = results
.filter((result) => result.status === "fulfilled")
.map((result) => result.value);
console.log("所有请求已完成", successfulResults);
return successfulResults;
}
// 语音合成(知识库)发送消息后的操作
const knowledgeAiOperate = async (str, index) => {
try {
const params = {
convertWords: str,
dialect: langValue.value === '中文' ? dialectValue.value : '',
timbre: timbreValue.value,
language: langValue.value,
tone : toneValue.value,
count: countValue.value
}
const res = await createAdvancedCorpus(params);
if (res.code === 200) {
const msg = JSON.parse(res?.msg || "[]");
if (msg.length === 0) {
serviceError("未查询到相关服务");
} else {
queryText.value = "语音合成中";
getKnowledgeAudio(msg, index, params);
}
} else {
serviceError("服务器繁忙,请稍后重试");
}
} catch (err) {
serviceError("服务器繁忙,请稍后重试");
}
}
const getKnowledgeAudio = async (arr, index, params) => {
messages.value[index] = {
role: "assistant",
list: []
}
const data = await knowledgeApiController(arr, index, params);
console.log('data', data)
isQuery.value = false;
}
async function knowledgeApiController(arr, messageIndex, params) {
// 模拟10个需要发送的请求
const requests = arr.map((item, index) => {
return (signal) =>
getAdvancedSynthesize({
dialect: params.dialect,
text: item,
timbre: params.timbre,
tone: params.tone,
language: params.language
}, {
signal: signal
})
.then(async (data) => {
if (cancelIndex.value === messageIndex) {
return
}
console.log(`请求 ${index} 完成`);
const url = URL.createObjectURL(data);
messages.value[messageIndex].list.push({
content: item,
url,
})
await nextTick();
messageRef.value.scrollTop = messageRef.value.scrollHeight;
return data;
})
.catch(async (error) => {
if (cancelIndex.value === messageIndex) {
return
}
console.error(`请求 ${index} 失败:`, error);
messages.value[messageIndex].list.push({
content: item + " ,语音转换失败"
})
await nextTick();
messageRef.value.scrollTop = messageRef.value.scrollHeight;
throw error;
});
});
// 发送所有请求
const results = await Promise.allSettled(
requests.map((request) => controller.addRequest(request))
);
// 处理结果
const successfulResults = results
.filter((result) => result.status === "fulfilled")
.map((result) => result.value);
console.log("所有请求已完成", successfulResults);
return successfulResults;
}
const serviceError = (content) => {
isQuery.value = false;
messages.value.push({
role: "assistant",
list: [{content}]
});
};
const handleKeyUp = (event) => {
// 阻止shift+enter触发的回车键事件冒泡
if (event.key === "Enter" && event.shiftKey) {
event.stopPropagation();
}
};
const handleEnter = (event) => {
// 只处理没有按住shift键的回车键事件
if (!event.shiftKey) {
sendMessage();
}
};
const timbreValue = ref("");
const timbreOptions = ref([]);
const dialectValue = ref("普通话");
const dialectOptions = [
{
label: "普通话",
value: "普通话",
},
{
label: "粤语",
value: "粤语",
},
{
label: "四川话",
value: "四川话",
},
{
label: "上海话",
value: "上海话",
},
{
label: "南京话",
value: "南京话",
},
{
label: "天津话",
value: "天津话",
},
{
label: "武汉话",
value: "武汉话",
},
];
const toneValue = ref('中性')
const toneOptions = [
{
label: '中性',
value: '中性'
},
{
label: "高兴",
value: "高兴",
},
{
label: "悲伤",
value: "悲伤",
},
{
label: "愤怒",
value: "愤怒",
},
// {
// label: "冷漠",
// value: "冷漠",
// }
]
const langValue = ref('中文')
const langOptions = [
{
label: "中文",
value: "中文",
},
{
label: "英文",
value: "英文",
},
{
label: "日文",
value: "日文",
},
{
label: "韩语",
value: "韩语",
}
]
const countValue = ref(1)
const getTimbre = async () => {
const req = await getTts();
if (req.code === 200) {
timbreOptions.value = req.data
timbreValue.value = req.data[0]
}
};
getTimbre();
const dialogVisible = ref(false);
const openDialog = () => {
dialogVisible.value = true;
};
const ruleFormRef = ref();
const ruleForm = reactive({
name: "",
});
const rules = reactive({
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
});
const soundRecordingRef = ref(null);
const submit = () => {
ruleFormRef.value.validate(async (valid) => {
if (valid) {
const globalBlob = soundRecordingRef.value.getWav();
// 直接创建新Blob并指定WAV类型
if (globalBlob) {
const file = new File([globalBlob], `${ruleForm.name}.m4a`, {
type: "audio/m4a",
});
const formData = new FormData();
formData.append("file", file);
formData.append("language_instruction", ruleForm.name);
const res = await createTimbre(formData);
if (res.code === 200) {
ElMessage.success("操作成功");
handleClose();
getTimbre();
} else {
ElMessage.success(res?.msg || "操作失败");
}
} else {
ElMessage.error("请先录音");
}
} else {
console.log("error submit!");
}
});
};
const handleClose = () => {
resetForm();
dialogVisible.value = false;
};
const resetForm = () => {
ruleFormRef.value.resetFields();
soundRecordingRef.value.reset();
};
</script>
<style lang="scss" scoped>
.container {
width: 100%;
display: flex;
justify-content: center;
.chat-container {
display: flex;
flex-direction: column;
width: 800px;
height: 100%;
.message-list {
flex: 1;
overflow-y: auto;
padding: 20px;
position: relative;
.message {
display: flex;
overflow-wrap: break-word;
margin: 10px;
position: relative;
align-items: center;
}
.message-box {
display: flex;
// flex-direction: column;
// align-items: end;
}
.user-message {
justify-content: right;
.content {
border-radius: 8px;
background-color: #ddd9ff;
white-space: pre-wrap;
padding: 10px;
max-width: 500px;
}
.picture {
width: 40px;
height: 40px;
background-image: url("./img/user.png");
background-repeat: no-repeat;
background-size: 100% 100%;
margin-bottom: 10px;
margin-left: 10px;
}
}
.ai-message {
.audio {
margin-left: 20px;
audio::-webkit-media-controls {
display: none; /* Chrome, Safari, Opera */
}
}
.message-container {
background-color: #eff6ff;
border-radius: 12px;
.messageContent {
display: flex;
align-items: center;
padding: 10px;
max-width: 500px;
}
}
.picture {
width: 40px;
height: 40px;
background-image: url("./img/user.png");
background-repeat: no-repeat;
background-size: 100% 100%;
margin-right: 10px;
}
}
}
.welcome {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
.name {
font-size: 40px;
}
.function {
margin-top: 10px;
font-size: 20px;
}
}
:deep(.input-area) {
padding: 20px;
height: 140px;
border: 1px solid #ddd9ff;
border-radius: 20px;
.el-textarea__inner {
box-shadow: none;
&:focus {
box-shadow: none;
}
&:hover {
box-shadow: none;
}
}
.bottom-box {
margin-top: 8px;
display: flex;
align-items: center;
justify-content: space-between;
.el-select__wrapper {
border-radius: 10px;
}
}
}
.input-area:has(.el-textarea__inner:focus) {
box-shadow: 0 0 0 1px #ddd9ff inset;
}
.input-area:has(.el-textarea__inner:hover) {
box-shadow: 0 0 0 1px #ddd9ff inset;
}
}
.addSound {
margin-left: 8px;
cursor: pointer;
}
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,65 +0,0 @@
<template>
<div class="app-container">
<el-container class="box-container">
<el-aside class="aside-container" v-if="showAside">
<el-menu class="ai-menu" :default-active="activeMenu" @select="menuSelect">
<el-menu-item index="1">
<!-- <el-icon><icon-menu /></el-icon> -->
<template #title>语音合成(知识库)</template>
</el-menu-item>
<el-menu-item index="2">
<!-- <el-icon><document /></el-icon> -->
<template #title>语音合成</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-main class="main-content">
<!-- 这里知识库和基础库弄反了,临时页面,也懒得去纠正了 -->
<Chat v-show="activeMenu === '1'" aiName="语音合成(知识库)" aiType="basis" />
<Chat v-show="activeMenu === '2'" aiName="语音合成" aiType="knowledge" />
</el-main>
</el-container>
</div>
</template>
<script setup>
import { ref } from "vue";
import Chat from "./chat.vue";
const activeMenu = ref('2');
const showAside = ref(false)
const menuSelect = (e) => {
activeMenu.value = e
};
</script>
<style lang="scss" scoped>
.app-container {
height: 100vh;
.box-container {
height: 100%;
.aside-container {
width: 300px;
height: 100%;
margin-bottom: 0;
background: #fbfaff;
.ai-menu {
background: #fbfaff;
border-right: 0;
}
}
.main-content {
padding: 0;
height: 100%;
display: flex;
justify-content: center;
flex: 1;
}
}
}
</style>

View File

@ -1,115 +0,0 @@
<template>
<div class="container">
<div class="recording">
<el-tooltip
class="box-item"
effect="dark"
content="点击开始录音"
placement="top-start"
v-if="!isRecording"
>
<el-icon class="start" :size="40" @click="startRecording" ><Microphone /></el-icon>
</el-tooltip>
<el-tooltip
class="box-item"
effect="dark"
content="点击结束录音"
placement="top-start"
v-if="isRecording"
>
<el-icon class="stop" :size="40" @click="stopRecording"><VideoPause /></el-icon>
</el-tooltip>
</div>
<div class="audio" v-if="audioUrl">
<!-- <audio :src="audioUrl" controls /> -->
<AudioPlayer :url="audioUrl" :recordingTime="recordingTime"/>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Microphone, VideoPause } from "@element-plus/icons-vue";
import AudioPlayer from './AudioPlayer.vue';
const isRecording = ref(false)
const audioChunks = ref([])
const audioUrl = ref(null)
let mediaRecorder = null
let globalBlob = null
const recordingTime = ref(0)
let startTime
// 启动录音
const startRecording = async () => {
audioUrl.value = null
globalBlob = null
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder = new MediaRecorder(stream)
mediaRecorder.onstart = () => {
startTime = Date.now(); // 记录开始时间
}
mediaRecorder.ondataavailable = e => audioChunks.value.push(e.data)
mediaRecorder.start()
isRecording.value = true
} catch (err) {
console.error('麦克风权限获取失败:', err)
}
}
// 停止录音
const stopRecording = () => {
mediaRecorder.stop()
mediaRecorder.onstop = () => {
recordingTime.value = Math.round((Date.now() - startTime) / 1000);
const blob = new Blob(audioChunks.value, { type: 'audio/m4a' })
globalBlob = blob
audioUrl.value = URL.createObjectURL(blob)
audioChunks.value = []
isRecording.value = false
}
}
// 转换逻辑参考
const getWav = () => {
if (globalBlob) {
return globalBlob
}
return null
}
const reset = () => {
globalBlob = null
audioUrl.value = ''
audioChunks.value = []
isRecording.value = false
}
defineExpose({
getWav,
reset
})
</script>
<style lang="scss" scoped>
.container {
display: flex;
justify-content: space-evenly !important;
align-items: center;
.recording {
.start {
cursor: pointer;
}
.stop {
color: #f80303;
cursor: pointer;
}
}
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,512 +0,0 @@
<template>
<div class="touch-interaction-wrap">
<div class="title">触控交互</div>
<div class="chat-warp scrollable">
<template v-if="chatList.length>0">
<div class="item" v-for="item in chatList" :class="{'is-me': item.isMe}">
<!-- 我发的-->
<template v-if="item.isMe">
<el-image
v-if="item.meImgUrl"
style="width: 100px; height: 100px;cursor: pointer;border-radius: 8px"
:src="item.meImgUrl"
@click="maskImg(false,[],item.meImgUrl)"
fit="contain"
/>
<div class="content" v-if="item.meContent">
{{ item.meContent }}
</div>
<!-- <div class="avatar">-->
<!-- <img :src="MyAvatar" alt="" style="width: 40px;height: 40px">-->
<!-- </div>-->
</template>
<!-- 回复的-->
<template v-else>
<div class="avatar">
<img :src="MyAvatar" alt="" style="width: 40px;height: 40px">
</div>
<div class="content" v-if="item.aiContent">
{{ item.aiContent }}
<el-icon v-if="item.isLoading">
<RefreshRight/>
</el-icon>
</div>
<el-image
@click="maskImg(true,item.aiPoint,item.aiImgUrl)"
v-if="item.aiImgUrl"
style="width: 100px; height: 100px;cursor: pointer;border-radius: 8px"
:src="item.aiImgUrl"
fit="contain"
/>
</template>
</div>
</template>
<template v-else>
<div class="empty">
<div class="flex align-center">
<img :src="MyAvatar" alt="" style="width: 40px;height: 40px;margin-right: 10px">
<div class="content">我是你的触控交互助手,很高兴见到你!</div>
</div>
<div class="desc">请把你需要识别的图片交给我吧~</div>
</div>
</template>
</div>
<img id="sourceImage" :src="currentCanvasUrl" alt="Source Image" style="display: none;">
<canvas id="canvas" v-show="openPre"></canvas>
<div class="actions">
<el-upload
:auto-upload="false"
v-model:file-list="fileList"
list-type="picture-card"
:on-preview="handlePictureCardPreview"
:on-remove="handleRemove"
:limit="1"
>
<template #trigger>
<el-button type="primary">select file</el-button>
</template>
</el-upload>
<el-input :disabled="enableInput" type="textarea"
:autosize="{ minRows: 5, maxRows: 7 }"
placeholder="输入你的问题并上传图片"
v-model="chatVal"
>
</el-input>
<div class="btn-wrap">
<input type="file" id="hiddenFileInput" style="display: none;" accept="image/*"/>
<el-tooltip
effect="dark"
content="上传附件"
placement="top-start"
>
<!-- 隐藏的文件输入元素 -->
<div class="attachment flex align-center justify-content-center" @click="handleAttachmentClick">
<el-button circle :disabled="enableInput">
<template #icon>
<el-icon :size="20">
<Paperclip/>
</el-icon>
</template>
</el-button>
</div>
</el-tooltip>
<el-tooltip
effect="dark"
:content="`${chatVal!==''&&fileList.length!==0?'发送':'请输入你的问题并上传图片'}`"
placement="top-start"
>
<div class="submit flex align-center justify-content-center" @click="sendText">
<el-button type="primary" circle :disabled="!enableInput&&chatVal===''||fileList.length===0">
<template #icon>
<el-icon :size="20">
<Top/>
</el-icon>
</template>
</el-button>
</div>
</el-tooltip>
</div>
</div>
</div>
<el-dialog v-model="dialogVisible" :show-close="false">
<img :src="dialogImageUrl" alt="Preview Image" style="height: 50vh;max-width: 50vw"/>
</el-dialog>
</template>
<script setup>
import {Paperclip, Plus, RefreshRight, Top} from "@element-plus/icons-vue";
import {ElMessage} from 'element-plus'
import {coordinateMarking} from "@/api/touch_interaction.js";
import AiAvatar from "./ai.png"
import MyAvatar from "./zsj.png"
const item = {
isMe: false,
meImgUrl: "",
meContent: "",
isLoading: false,
aiImgUrl: "",
aiContent: "",
aiPoint: []
}
const chatList = ref([])//会话列表
const fileList = ref([])//文件列表
const chatVal = ref("") //文本
const enableInput = ref(false) //允许上传和输入
// name: file.name,
// url: e.target.result,
// fileObj: file
const currentMaskFile = ref(null)
const currentCanvasUrl = ref(null)
const dialogVisible = ref(false) //文件预览
const dialogImageUrl = ref("") //文件预览的url
const markFormData = new FormData()//带上传的formData
const openPre = ref(false)
const addMeContent = (content) => {
chatList.value.push({
isMe: true,
meImgUrl: "",
meContent: content,
isLoading: false,
aiImgUrl: "",
aiContent: "",
aiPoint: []
})
}
const addMeUrl = (url) => {
chatList.value.push({
isMe: true,
meImgUrl: url,
meContent: "",
isLoading: false,
aiImgUrl: "",
aiContent: "",
aiPoint: []
})
}
const addAiContent = (content, loading = false) => {
chatList.value.push({
isMe: false,
meImgUrl: "",
meContent: "",
isLoading: loading,
aiImgUrl: "",
aiContent: content,
aiPoint: []
})
}
const addAiUrl = (url, point) => {
chatList.value.push({
isMe: false,
meImgUrl: "",
meContent: "",
isLoading: false,
aiImgUrl: url,
aiContent: "",
aiPoint: point
})
}
const sendText = () => {
enableInput.value = true
if (!chatVal.value) {
ElMessage({
message: '请输入内容',
type: 'warning',
})
return
}
currentMaskFile.value = fileList.value[0]
markFormData.append("words", chatVal.value)
markFormData.append("image", fileList.value[0]['fileObj'])
addMeUrl(fileList.value[0]['url'])
addMeContent(chatVal.value)
addAiDynamicContent("正在思考中...", true)
chatVal.value = ""
fileList.value = []
scrollEnd();
coordinateMarking(markFormData).then((res) => {
const msg = JSON.parse(res.msg)
if (Array.isArray(msg)) {
chatList.value.pop()
addAiContent(res.msg) // 坐标数据
addAiUrl(currentMaskFile.value['url'], msg)
} else {
addAiContent(msg)
}
}).catch((err) => {
console.log(err)
chatList.value.pop()
addAiDynamicContent("识别超时,请稍后重试")
})
.finally(() => {
chatList.value[chatList.value.length - 1]['content'] = ""
markFormData.delete("image")
markFormData.delete("words")
const newInput = document.createElement('input');
const input = document.getElementById("hiddenFileInput");
newInput.type = 'file';
newInput.id = 'hiddenFileInput';
newInput.accept = "image/*"
newInput.style.display = 'none';
input.parentNode.replaceChild(newInput, input);
scrollEnd();
enableInput.value = false
})
};
const handleAttachmentClick = () => {
const input = document.getElementById("hiddenFileInput");
input.click();
// 移除所有现有的change事件监听器
input.removeEventListener("change", handleFileChange);
input.addEventListener("change", handleFileChange)
};
const handleFileChange = (event) => {
const file = event.target.files[0];
if (file) {
// 创建FileReader对象
const reader = new FileReader();
// 读取完成后触发的事件处理程序
reader.onload = e => {
// 更新ref
fileList.value = [{
name: file.name,
url: e.target.result,
fileObj: file
}];
};
// 以DataURL的形式读取文件内容
reader.readAsDataURL(file);
}
}
const handlePictureCardPreview = (uploadFile) => {
dialogVisible.value = true
dialogImageUrl.value = fileList.value[0]['url']
}
const maskImg = (draw, [x1, y1, x2, y2], url) => {
openPre.value = true
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const sourceImage = document.getElementById('sourceImage');
sourceImage.src = url
canvas.addEventListener('click', () => {
openPre.value = false
})
// 设置canvas大小与图片大小相同
canvas.width = sourceImage.width;
canvas.height = sourceImage.height;
console.log(x1, y1, x2, y2, url)
// 绘制图片到canvas
ctx.drawImage(sourceImage, 0, 0);
if (draw) {
// 定义坐标和矩形大小
const x = x1; // 矩形左上角x坐标
const y = y1; // 矩形左上角y坐标
const width = Math.abs(x2 - x1); // 矩形宽度绝对值
const height = Math.abs(y2 - y1); // 矩形高度
// 绘制矩形
ctx.strokeStyle = 'red'; // 矩形边框颜色
ctx.lineWidth = 10; // 矩形边框宽度
ctx.strokeRect(x, y, width, height); // 绘制矩形
}
}
//删除还未上传的图片
const handleRemove = (uploadFile, uploadFiles) => {
fileList.value = []
}
//滚动条滚动到最底部
const scrollEnd = () => {
setTimeout(() => {
const box = document.querySelector(".chat-warp");
if (box) {
box.scrollTo({top: box.scrollHeight, behavior: "smooth"});
}
}, 100);
};
watch(
() => chatList.value,
() => {
scrollEnd();
}
);
const addAiDynamicContent = (content, loading = false) => {
chatList.value.push({
isMe: false,
meImgUrl: "",
meContent: "",
isLoading: false,
aiImgUrl: "",
aiContent: "",
aiPoint: []
})
const words = content.split("")
for (let i = 0; i < words.length; i++) {
setTimeout(() => {
chatList.value[chatList.value.length - 1]['aiContent'] += words[i]
}, i * 200 + Math.random() * 100)
}
if (loading) {
chatList.value[chatList.value.length - 1]['isLoading'] = true
}
}
</script>
<style scoped lang="scss">
.touch-interaction-wrap {
display: flex;
flex-direction: column;
align-items: center;
.add {
position: fixed;
top: 0;
left: 0;
}
.empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.content {
font-size: 24px;
}
.desc {
font-size: 14px;
color: #404040;
margin-top: 8px;
}
}
.title {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: 800px;
height: 46px;
margin-top: 10px;
font-size: 16px;
color: #262626;
font-weight: 600;
border-radius: 12px;
box-shadow: inset 0 0 0 1px rgb(229, 229, 229);
}
.chat-warp {
width: 800px;
height: calc(100vh - 340px); //220 56
overflow-y: auto;
padding-top: 20px;
.item {
display: flex;
align-items: center;
margin-bottom: 16px;
padding-right: 10px;
img {
width: 28px;
height: 28px;
margin-right: 20px;
}
.content {
font-size: 16px;
line-height: 28px;
color: #262626;
padding: 8px 20px;
box-sizing: border-box;
white-space: pre-wrap;
word-break: break-word;
background-color: #eff6ff;
border-radius: 14px;
max-width: calc(100% - 48px);
display: flex;
align-items: center;
i {
margin-left: 2px;
animation: spin 1.5s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
&.is-me {
justify-content: flex-end;
.content {
margin-left: 0;
}
img {
margin-left: 20px;
}
}
}
}
.actions {
width: 800px;
position: relative;
:deep(.el-textarea__inner ) {
padding: 10px 20px;
background-color: rgb(243, 244, 246);
border-radius: 24px;
box-shadow: 0 0 0 .5px #dce0e9;
color: rgb(64, 64, 64);
font-size: 16px;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
:deep(.el-upload--picture-card ) {
display: none;
}
:deep(.el-upload-list__item ) {
height: 80px;
width: auto;
}
.btn-wrap {
position: absolute;
right: 20px;
bottom: 10px;
display: flex;
.attachment {
margin-right: 10px;
}
.submit {
}
}
}
#canvas {
position: fixed;
z-index: 99999999;
width: 100vw;
height: 100vh;
}
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,59 +0,0 @@
export const conf = {
/**
* 基础功能配置
* */
autoplay: false, // 是否自动播放,不自动播放,浏览器有限制规则
autoplayMuted: false, // 是否自动播放(静音播放)
videoInit: true, // 是否默认初始化video,默认初始化,默认true
playsinline: true, // 是否启用内联播放模式,仅移动端生效
defaultPlaybackRate: 1, // 默认播放速度(可选:0.5/0.75/1/1.5/2等)
volume: 0.72, // 播放音量(可选:0 ~ 1)
loop: false, // 是否循环播放,默认不循环播放
startTime: 0, // 点播模式下,初始起播时间
videoAttributes: {}, // video扩展属性,暂且不配置
lang: 'zh-cn', // 播放器初始显示语言,设置为中文
fluid: true, // 是否流式布局(宽高优先于流失布局,默认16:9)注掉上方宽高看效果
fitVideoSize: 'fixed', // 保持容器宽/高,不做适配,按照容器来
videoFillMode: 'auto', // 宽高不够自动底色填充(fill拉伸填充等...)
seekedStatus: 'play', // 跳转后继续播放
// 播放器进度条故事点信息数组
progressDot: [
{
id: 0, // 唯一标识,用于删除的时候索引
time: 30, // 展示的时间点,例子为在播放到10s钟的时候展示
text: '进度条信息提示...', // hover的时候展示文案,可以为空
duration: 1, // 展示时间跨度,单位为s
style: { // 指定样式
backgroundColor: 'white'
}
},
],
thumbnail: null, // 进度条预览图配置,普通业务用不到
marginControls: false, // 是否开启画面和控制栏分离模式,不开启空间多一些
domEventType: 'default', // 响应的事件类型,不用指定,用默认的即可
/**
* 交互功能配置(一般使用默认即可)
* */
/**
* 插件配置,根据需求自选
* */
icons: {}, // 使用默认的icon图标
i18n: [], // 使用默认的中文
// 自定义一些颜色
commonStyle: {
progressColor: 'green', // 整个进度条颜色
playedColor: 'chocolate', // 已播放的进度条颜色
volumeColor: 'pink', // 音量大小竖向滑块颜色
},
controls: true, // 是否使用底部控制栏,默认使用
miniprogress: true, // 是否使用mini进度条(当底部控制栏隐藏时生效)
screenShot: false, // 关闭截图功能
rotate: true, // 是否使用视频旋转插件,默认不使用
download: true, // 是否使用下载按钮,一般不用,一般自定义控制
pip: true, // 使用使用画中画模式,默认不用
mini: true, // 是否使用小屏幕控件
cssFullscreen: true, // 是否使用网页样式全屏按钮开关
playbackRate: [0.5, 1, 1.5, 2, 3], //传入倍速可选数组
// playbackRate: true, //false,禁用倍速播放(即控制栏不显示)
keyShortcut: false, // 是否开启快捷键模式
}

View File

@ -1,62 +0,0 @@
<template>
<div class="player_container">
<div ref="xgPlayerRef"></div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import Player, { Events } from 'xgplayer'; // 引入西瓜视频模块
import 'xgplayer/dist/index.min.css'; // 引入西瓜视频样式
const { videoUrl } = defineProps({
videoUrl: {
type: String,
default: ''
}
})
const xgPlayerRef = ref()
import { conf } from "./config"; // 配置文件单独拎出来一个js文件
onMounted(() => { init() })
let player = null // 实例
const init = () => {
player = new Player({
el: xgPlayerRef.value,
// width: 600,
// height: 400, // 视频宽高尺寸
isLive: false,
url: videoUrl, // 视频源
// poster: "http://ashuai.work/static/img/avantar.png", // 视频封面
...conf,
});
player.on(Events.PLAY, (ev) => {
console.log('-播放开始-', ev);
})
player.on(Events.PAUSE, (ev) => {
console.log('-播放结束-', ev);
})
player.on('loadedmetadata', (ev) => {
console.log('-媒体数据加载好了-', ev);
})
player.on(Events.SEEKED, (ev) => {
console.log('-跳着播放-', ev);
})
}
</script>
<style lang="scss" scoped>
.player_container {
width: 100%;
height: 100%;
.xgplayer {
height: 100% !important;
}
}
</style>

View File

@ -1,24 +0,0 @@
<template>
<div class="runtime-group">
<span>{{ data.text || data.properties?.name || "分组" }}</span>
</div>
</template>
<script setup>
defineProps({ data: Object });
</script>
<style scoped lang="scss">
.runtime-group {
width: 100%;
height: 100%;
padding: 14px 18px;
box-sizing: border-box;
border: 1px dashed #91a4be;
border-radius: 8px;
background: rgba(234, 241, 250, 0.45);
color: #40536d;
font-size: 14px;
font-weight: 600;
}
</style>

View File

@ -1,47 +0,0 @@
<template>
<div class="runtime-node">
<Handle
:id="`${id}_3`"
type="target"
:position="Position.Left"
:connectable="false"
class="runtime-handle runtime-handle--target"
/>
<CustomFlow :properties="properties" :text="data.text" />
<Handle
:id="`${id}_1`"
type="source"
:position="Position.Right"
:connectable="false"
class="runtime-handle runtime-handle--source"
/>
</div>
</template>
<script setup>
import { computed } from "vue";
import { Handle, Position } from "@vue-flow/core";
import CustomFlow from "./customFlow.vue";
const props = defineProps({ id: String, data: Object });
const properties = computed(() => props.data.properties || {});
</script>
<style scoped lang="scss">
.runtime-node {
width: 100%;
height: 100%;
position: relative;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(20, 33, 61, 0.12);
}
.runtime-handle {
width: 12px;
height: 12px;
border: 3px solid #fff;
background: #2f80ed;
&--target { left: -6px; }
&--source { right: -6px; }
}
</style>

View File

@ -1,238 +0,0 @@
<template>
<div class="custom_element" :class="props.properties.state">
<div class="custom_element__header">
<div class="custom_element__title">
{{ props.text || "--" }}
</div>
<el-button v-if="showDetailTypeList.includes(props.properties.type)" v-popover="popoverRef" class="custom_element__btn" link @click="openPop"
>详情</el-button
>
</div>
<div class="nodeType">节点类型: {{ props.properties.type }}</div>
<div class="nodeState">
<div>状态:{{ stateMap[props.properties.state] }}</div>
</div>
<el-popover
ref="popoverRef"
virtual-triggering
persistent
placement="right-start"
:visible="popoverVisible"
popper-class="popover__container"
>
<template #default>
<div class="popover__header">
<div>{{ props.text || "--" }}</div>
<div class="closeBtn" @click="popoverVisible = !popoverVisible">
<el-icon><CircleClose /></el-icon>
</div>
</div>
<div class="popover__context">
<div class="container">
<div class="title">输入参数</div>
<el-input
v-if="getTypeByParams(props.properties.input) === 0"
v-model="props.properties.input"
:readonly="true"
:autosize="{ minRows: 2, maxRows: 6 }"
type="textarea"
/>
<div v-if="getTypeByParams(props.properties.input) === 2">
<el-image
style="width: 200px; height: 100px"
:src="getUrlByParams(props.properties.input)"
:zoom-rate="2"
:max-scale="7"
:min-scale="0.2"
:preview-src-list="[getUrlByParams(props.properties.input)]"
show-progress
:initial-index="4"
fit="cover"
/>
</div>
</div>
<div class="container">
<div class="title">输出参数</div>
<el-input
v-if="getTypeByParams(props.properties.output) === 0"
v-model="props.properties.output"
:readonly="true"
:autosize="{ minRows: 2, maxRows: 6 }"
type="textarea"
/>
<div v-if="getTypeByParams(props.properties.output) === 2" class="imageContainer">
<el-image
v-for="item in getUrlByParams(props.properties.output)"
style="width: 60px; height: 60px"
:src="item"
:preview-src-list="getUrlByParams(props.properties.output)"
show-progress
fit="fill"
/>
</div>
<div v-if="getTypeByParams(props.properties.output) === 4" class="videoContainer">
<IPlayer v-for="item in getUrlByParams(props.properties.output)" :videoUrl="item" />
</div>
</div>
</div>
</template>
</el-popover>
</div>
</template>
<script setup>
import { watch } from "vue";
import { CircleClose } from '@element-plus/icons-vue'
import IPlayer from './IPlayer/index.vue'
const props = defineProps({
model: Object,
graphModel: Object,
properties: Object,
text: String,
});
const stateMap = {
success: "已执行",
unexecuted: "未执行",
running: "执行中",
failed: "异常",
paused: "暂停",
stopped: "停止"
};
const showDetailTypeList = ['inject', 'startCamera', 'getImage', 'stopCamera', 'process', 'end', 'stopVideo']
const popoverVisible = ref(false);
const popoverRef = ref();
const openPop = (e) => {
popoverVisible.value = true;
};
const getTypeByParams = (str) => {
let type = 0;
try {
const data = JSON.parse(str);
type = data?.type || 0;
} catch {
type = 0;
}
return type;
};
const propertiesTypeMap = {
'2': 'imageUrl',
'4': 'videoUrl',
'default': ''
}
const getUrlByParams = (str) => {
let url = [];
try {
const data = JSON.parse(str);
url = data?.[propertiesTypeMap[data?.type || 'default']] || 0;
} catch {
url = [];
}
return url;
};
watch(
() => props.properties.nowTime,
() => {
popoverVisible.value = false;
}
);
</script>
<style lang="scss" scoped>
.custom_element {
width: 100%;
height: 100%;
padding: 10px;
border-radius: 10px;
box-sizing: border-box;
color: #fff;
&__header {
display: flex;
justify-content: space-between;
}
&__title {
font-size: 20px;
}
&__btn {
color: skyblue;
}
}
.running, .paused, .stopped {
background-color: #8bbf86;
}
.unexecuted {
background-color: #ddd;
}
.success {
background-color: green;
}
.failed {
background-color: rgb(128, 41, 0);
}
.nodeType {
margin: 8px 0;
}
.nodeState {
margin: 8px 0;
}
</style>
<style lang="scss">
.popover__container {
width: 300px !important;
.popover__header {
display: flex;
justify-content: space-between;
.closeBtn {
cursor: pointer;
}
}
.popover__context {
.container {
display: flex;
max-height: 200px;
overflow-y: auto;
margin-bottom: 10px;
.title {
width: 80px;
}
.imageContainer {
width: 260px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: stretch;
max-height: 600px;
overflow-y: auto;
}
.videoContainer {
width: 260px;
max-height: 600px;
overflow-y: auto;
}
}
}
}
</style>

View File

@ -1,205 +0,0 @@
<template>
<div class="runtime-flow-page">
<div class="runtime-toolbar">
<div class="instance-state">
实例状态:<span :class="instStatus">{{ stateMap[instStatus] || instStatus }}</span>
</div>
<el-button @click="centerView">居中</el-button>
<el-button @click="fitGraph">适应屏幕</el-button>
<el-button v-if="['RUNNING', 'PAUSED'].includes(instStatus)" type="danger" plain @click="stopFlow">停止</el-button>
<el-button v-if="instStatus === 'RUNNING'" type="warning" plain @click="pauseFlow">暂停</el-button>
<el-button v-if="instStatus === 'PAUSED'" type="success" plain @click="restartFlow">恢复</el-button>
</div>
<VueFlow
id="runtime-flow"
class="runtime-canvas"
:nodes-draggable="false"
:nodes-connectable="false"
:edges-updatable="false"
:elements-selectable="true"
:delete-key-code="null"
:min-zoom="0.1"
:max-zoom="3"
:zoom-on-double-click="false"
@init="loadFlow"
@pane-click="closeNodeDetails"
>
<template #node-runtime-node="nodeProps">
<RuntimeNode v-bind="nodeProps" />
</template>
<template #node-runtime-group="nodeProps">
<RuntimeGroup v-bind="nodeProps" />
</template>
<Background color="#d9dee8" :gap="24" :size="1" />
<Controls position="bottom-right" :show-interactive="false" />
</VueFlow>
</div>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import { VueFlow, useVueFlow } from "@vue-flow/core";
import { Background } from "@vue-flow/background";
import { Controls } from "@vue-flow/controls";
import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
import "@vue-flow/controls/dist/style.css";
import RuntimeNode from "./RuntimeNode.vue";
import RuntimeGroup from "./RuntimeGroup.vue";
import { convertNodeRedToVueFlow, updateDerivedNodeStates } from "@/utils/convertNodeRedToVueFlow";
import { getFlowRuntimeLog, pauseInst, resumeInst, stopInst } from '@/api/test/log';
import { ElMessage } from "element-plus";
const runtimeFlow = useVueFlow("runtime-flow");
const queryParams = new URLSearchParams(window.location.search);
const instId = queryParams.get("instId") || "";
const taskId = queryParams.get("taskId") || "";
const itemId = queryParams.get("itemId") || "03e0842c2fd7ab7575636a473aeefc89";
const instStatus = ref("RUNNING");
const stateMap = {
SUCCESS: "已完成",
RUNNING: "执行中",
FAILED: "失败",
PAUSED: "已暂停",
STOPPED: "已终止",
};
let pollTimer = null;
let initialized = false;
const query = () => ({ instId, taskId, itemId });
const getProperties = (id) => runtimeFlow.findNode(id)?.data?.properties || {};
const setProperties = (id, patch) => {
const node = runtimeFlow.findNode(id);
if (!node) return;
runtimeFlow.updateNodeData(id, {
...node.data,
properties: { ...node.data.properties, ...patch },
}, { replace: true });
};
const schedulePoll = () => {
clearTimeout(pollTimer);
pollTimer = window.setTimeout(getLog, 2000);
};
const getLog = async () => {
const res = await getFlowRuntimeLog(query());
if (res.code !== 200) return;
const records = (res.data || []).filter((item) => item.nodeType);
if (!records.length) {
if (["RUNNING", "PAUSED"].includes(instStatus.value)) schedulePoll();
return;
}
const states = new Map();
records.forEach((item) => {
if (["inject", "start"].includes(item.nodeType)) {
states.set(item.nodeId, { state: "success", nodeType: item.nodeType, input: item.params });
return;
}
const current = states.get(item.nodeId) || { nodeType: item.nodeType };
if (item.paramsType === "INPUT") {
current.state = item.instStatus.toLowerCase();
current.input = item.instStatus === "FAILED" ? item.params : item.message;
} else {
current.state = item.instStatus === "RUNNING" ? "success" : item.instStatus.toLowerCase();
current.output = item.instStatus === "FAILED" ? item.message : item.params;
}
states.set(item.nodeId, current);
});
states.forEach((value, id) => setProperties(id, value));
updateDerivedNodeStates(runtimeFlow.getNodes.value, getProperties, setProperties);
const last = records.at(-1);
instStatus.value = last.instStatus || instStatus.value;
if (last.nodeType !== "end" && !["FAILED", "STOPPED", "SUCCESS"].includes(last.instStatus)) schedulePoll();
};
const loadFlow = async () => {
if (initialized) return;
initialized = true;
const { nodes, edges } = await convertNodeRedToVueFlow(itemId);
runtimeFlow.setNodes(nodes);
runtimeFlow.setEdges(edges);
await nextTick();
await runtimeFlow.fitView({ padding: 0.16, duration: 300 });
await getLog();
};
const getInstDetail = async () => {
const res = await getFlowRuntimeLog(query());
const last = res.code === 200 ? (res.data || []).at(-1) : null;
if (last?.instStatus) instStatus.value = last.instStatus;
};
const runControl = async (request, successMessage) => {
const res = await request(instId);
if (res.code === 200) {
ElMessage.success(successMessage);
await getInstDetail();
if (["RUNNING", "PAUSED"].includes(instStatus.value)) schedulePoll();
} else {
ElMessage.error(res.msg);
}
};
const stopFlow = () => runControl(stopInst, "停止成功");
const pauseFlow = () => runControl(pauseInst, "暂停成功");
const restartFlow = () => runControl(resumeInst, "恢复成功");
const fitGraph = () => runtimeFlow.fitView({ padding: 0.16, duration: 300 });
const centerView = () => runtimeFlow.fitView({ padding: 0.3, duration: 300 });
const closeNodeDetails = () => {
const nowTime = Date.now();
runtimeFlow.getNodes.value.forEach((node) => setProperties(node.id, { nowTime }));
};
onMounted(getInstDetail);
onUnmounted(() => clearTimeout(pollTimer));
</script>
<style scoped lang="scss">
.runtime-flow-page {
width: 100%;
height: 100%;
min-height: 620px;
position: relative;
overflow: hidden;
background: #f5f7fa;
}
.runtime-toolbar {
position: absolute;
z-index: 10;
top: 16px;
right: 16px;
min-height: 48px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid #e1e6ee;
border-radius: 8px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 8px 24px rgba(20, 33, 61, 0.1);
:deep(.el-button + .el-button) { margin-left: 0; }
}
.instance-state {
margin-right: 8px;
color: #5a6472;
font-size: 14px;
font-weight: 600;
.RUNNING, .PAUSED { color: #d48806; }
.SUCCESS { color: #2b8a3e; }
.FAILED, .STOPPED { color: #c92a2a; }
}
.runtime-canvas {
width: 100%;
height: 100%;
}
</style>

View File

@ -1,59 +0,0 @@
import {controlPageList} from "@/enum/index.js";
import {ElMessage} from "element-plus";
import router from "@/router";
export const intoControlPage = (path, params) => {
if (!controlPageList.includes(path)) {
ElMessage.warning('该页面不在控制台页面中,无法打开');
return
}
// const fullPath = `${window.location.origin}${path}/${params}`;
// // 使用 window.open 在新标签页中打开
// window.open(fullPath, '_blank');
const fullPath = `${path}/${params}`; // 构造路由路径
router.push(fullPath); // 使用路由跳转
}
// 定义需要转换的属性列表
const propertiesToConvert = [
"workModule",
"posStep",
"postureStep",
"jointStep",
"joint1",
"joint2",
"joint3",
"joint4",
"joint5",
"joint6",
"x",
"y",
"z",
"rx",
"ry",
"rz"
];
export const formatNumber = (data) => {
const res = JSON.parse(JSON.stringify(data));
// 遍历属性列表并转换值
propertiesToConvert.forEach(property => {
if (data[property] !== null) {
res[property] = Number(data[property]);
}
});
return res
}
//对象中存在数字类型属性时,转换为字符串类型
export const convertNumbersToStrings = (obj) => {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const value = obj[key];
// 检查值是否不是 null 且是数字类型
if (value !== null && typeof value === 'number') {
obj[key] = String(value); // 转换为字符串
}
}
}
return obj;
}

View File

@ -1,89 +0,0 @@
<template>
<div
v-if="visible"
class="context-menu"
:style="{ top: position.y + 'px', left: position.x + 'px' }"
>
<div
v-for="item in menuItems"
:key="item.key"
class="menu-item"
@click="handleClick(item)"
>
{{ item.label }}
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue';
const props = defineProps({
visible: {
type: Boolean,
default: false
},
position: {
type: Object,
default: () => ({ x: 0, y: 0 })
},
menuItems: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['close', 'select']);
watch(() => props.visible, (newVal) => {
if (newVal) {
document.addEventListener('click', closeMenu);
} else {
document.removeEventListener('click', closeMenu);
}
});
const handleClick = (item) => {
emit('select', item);
closeMenu();
};
const closeMenu = () => {
emit('close');
};
onMounted(() => {
// 初始监听(如果visible初始为true)
if (props.visible) {
document.addEventListener('click', closeMenu);
}
});
onUnmounted(() => {
document.removeEventListener('click', closeMenu);
});
</script>
<style scoped>
.context-menu {
position: fixed; /* 使用 fixed 定位,不受父容器 scroll 影响 */
z-index: 9999;
background-color: #fff;
border: 1px solid #eee;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
border-radius: 4px;
padding: 5px 0;
}
.menu-item {
padding: 8px 15px;
cursor: pointer;
font-size: 14px;
color: #606266;
white-space: nowrap;
}
.menu-item:hover {
background-color: #f5f7fa;
}
</style>

View File

@ -1,547 +0,0 @@
<template>
<div
class="tech-button-with-line"
:style="buttonStyle"
ref="techButtonContainer"
@mouseover="isHovering = true"
@mouseleave="isHovering = false"
>
<!-- 新增一个包裹层,用于应用 clip-path -->
<div class="button-shape-clipper" :style="clipperStyle">
<div class="button-content-wrapper">
<slot name="button-content">
<span class="button-text">{{ buttonName }}</span>
</slot>
</div>
</div>
<!-- SVG 部分保持原有结构,但内部样式和滤镜会改变 -->
<svg
v-if="svgReady"
class="line-svg"
:width="svgWidth"
:height="svgHeight"
:style="{
top: `0px`,
left: `0px`,
}"
>
<defs>
<!-- 霓虹光效滤镜 -->
<!-- stdDeviation 控制模糊程度,值越大越模糊,光效越柔和 -->
<filter id="neon-filter" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" :stdDeviation="props.neonGlowIntensity" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- 扫描线效果渐变 -->
<!-- 动态调整扫描线颜色 -->
<linearGradient id="scan-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:rgba(255,255,255,0);stop-opacity:1" />
<stop offset="50%" :style="`stop-color:${props.lineHoverColor};stop-opacity:0.8`" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0);stop-opacity:1" />
</linearGradient>
</defs>
<!-- 主线条,应用霓虹光效和扫描线 -->
<polyline
:points="linePoints"
stroke="url(#scan-gradient)"
:stroke-width="props.lineStrokeWidth"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
filter="url(#neon-filter)"
class="main-line"
/>
<!-- 可选:一条细微的基线,用于对比或增加层次 -->
<polyline
:points="baseLinePoints"
:stroke="props.lineColor"
stroke-width="1"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
class="base-line"
/>
</svg>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch, defineProps, onUnmounted } from 'vue';
// --- Props ---
// 保持原有 props,并增加一些用于样式控制的 props
const props = defineProps({
totalButtons: { type: Number, required: true, default: 1 },
buttonIndex: { type: Number, required: true },
buttonPosition: { type: String, required: true, validator: (value) => ['left', 'right'].includes(value) },
buttonOffsetX: { type: Number, default: 350 },
endX: { type: Number, required: true },
endY: { type: Number, required: true },
buttonName: { type: String, default: '科技按钮' },
buttonWidth: { type: [Number, String], default: 150 },
buttonHeight: { type: [Number, String], default: 40 },
linePadding: { type: Number, default: 20 },
// --- 仅用于样式的 Props ---
buttonGradient: { type: Array, default: () => ['#00f0ff', '#00d0ff'] }, // 按钮背景渐变色
buttonHoverGradient: { type: Array, default: () => ['#00d0ff', '#00f0ff'] }, // 悬停时的渐变色
buttonBorderColor: { type: String, default: 'rgba(0, 255, 255, 0.6)' },
lineColor: { type: String, default: 'rgba(0, 150, 200, 0.4)' }, // 细线基础颜色
lineHoverColor: { type: String, default: 'rgba(0, 255, 255, 1)' }, // 扫描线高亮颜色
lineStrokeWidth: { type: Number, default: 3 },
neonGlowIntensity: { type: Number, default: 3 }, // 霓虹光效强度
scanLineSpeed: { type: Number, default: 8 }, // 扫描线动画速度
animationDuration: { type: Number, default: 0.4 }, // 基础动画时长
hoverScale: { type: Number, default: 1.03 }, // 悬停时的放大比例
// --- New Props for Flowing Light Effect ---
flowLineColor: { type: String, default: 'rgba(0, 255, 255, 0.8)' }, // 流光颜色
flowLineAnimationSpeed: { type: Number, default: 7 }, // 流光动画速度
});
// --- Refs ---
const techButtonContainer = ref(null);
const svgReady = ref(false);
const parentContainerRef = ref(null);
const parentHeight = ref(0);
const parentWidth = ref(0);
const svgWidth = ref(0);
const svgHeight = ref(0);
const svgLeftOffset = ref(0);
const svgTopOffset = ref(0);
const isHovering = ref(false); // 追踪鼠标是否悬停
// --- Computed Properties ---
// 计算父容器的水平中心线 (保持不变)
const autoCenterX = computed(() => parentWidth.value / 2);
// 计算按钮在父容器中的 X 坐标(全局坐标)(保持不变)
const calculatedButtonX = computed(() => {
if (!parentWidth.value) return 0;
if (props.buttonPosition === 'left') {
return autoCenterX.value - props.buttonOffsetX;
} else if (props.buttonPosition === 'right') {
return autoCenterX.value + props.buttonOffsetX;
}
return 0;
});
// 计算按钮在父容器中的 Y 坐标(全局坐标)(保持不变)
const calculatedButtonY = computed(() => {
if (!parentHeight.value || props.totalButtons <= 0) return 0;
const buttonSlotHeight = parentHeight.value / props.totalButtons;
const centerY = (props.buttonIndex - 1) * buttonSlotHeight + buttonSlotHeight / 2;
return centerY;
});
// 获取按钮的实际宽度和高度 (保持不变)
const actualButtonWidth = computed(() => {
if (techButtonContainer.value) {
return techButtonContainer.value.offsetWidth;
}
return typeof props.buttonWidth === 'number' ? props.buttonWidth : 150;
});
const actualButtonHeight = computed(() => {
if (techButtonContainer.value) {
return techButtonContainer.value.offsetHeight;
}
return typeof props.buttonHeight === 'number' ? props.buttonHeight : 40;
});
// 线的终点相对于按钮中心点的偏移 (SVG 内部坐标系) (保持不变)
const relativeEndX = computed(() => props.endX - calculatedButtonX.value + actualButtonWidth.value / 2);
const relativeEndY = computed(() => props.endY - calculatedButtonY.value + actualButtonHeight.value / 2);
// 按钮的样式
const buttonStyle = computed(() => {
const buttonWidthPx = typeof props.buttonWidth === 'number' ? `${props.buttonWidth}px` : props.buttonWidth;
const buttonHeightPx = typeof props.buttonHeight === 'number' ? `${props.buttonHeight}px` : props.buttonHeight;
const currentGradient = isHovering.value ? props.buttonHoverGradient : props.buttonGradient;
const style = {
position: 'absolute',
left: `${calculatedButtonX.value}px`,
top: `${calculatedButtonY.value}px`,
transform: 'translate(-50%, -50%)', // 居中对齐
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
width: buttonWidthPx,
height: buttonHeightPx,
borderRadius: '15px', // 强制平直四角
// 更硬朗的背景,减少模糊,增加纹理感
background: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`,
// 模拟金属/磨砂质感,但更锐利
boxShadow: `
inset 0 0 0 2px ${props.buttonBorderColor}, /* 内部边框 */
0 0 5px rgba(0, 150, 200, 0.5), /* 较弱的主光晕 */
0 0 12px rgba(0, 200, 255, 0.6), /* 第二层光晕 */
0 3px 10px rgba(0, 0, 0, 0.4) /* 底部阴影 */
`,
color: '#ffffff',
fontSize: '14px',
fontWeight: 'bold', // 更粗的字体
letterSpacing: '0.7px',
textShadow: '0 0 5px rgba(0, 150, 200, 0.9)', // 文本的锐利光效
transition: `all ${props.animationDuration}s ease-in-out`,
overflow: 'visible', // 允许流光伪元素溢出
zIndex: 1,
};
if (isHovering.value) {
style.transform = `translate(-50%, -50%) scale(${props.hoverScale})`;
style.boxShadow = `
inset 0 0 0 3px ${props.buttonBorderColor}, /* 悬停时内部边框变宽 */
0 0 8px rgba(0, 200, 255, 0.7),
0 0 18px rgba(0, 230, 255, 0.8),
0 5px 15px rgba(0, 0, 0, 0.5) /* 底部阴影增强 */
`;
}
return style;
});
// 按钮形状裁剪器的样式
const clipperStyle = computed(() => {
const currentGradient = isHovering.value ? props.buttonHoverGradient : props.buttonGradient;
const style = {
position: 'relative', // 相对于 techButtonContainer 定位
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
borderRadius: '0px', // **clipper 的形状由 clip-path 定义**
background: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`,
boxShadow: `
inset 0 0 0 2px ${props.buttonBorderColor}, /* 内部边框 */
0 0 5px rgba(0, 150, 200, 0.5), /* 较弱的主光晕 */
0 0 12px rgba(0, 200, 255, 0.6), /* 第二层光晕 */
0 3px 10px rgba(0, 0, 0, 0.4) /* 底部阴影 */
`,
color: '#ffffff',
fontSize: '14px',
fontWeight: 'bold',
letterSpacing: '0.7px',
textShadow: '0 0 5px rgba(0, 150, 200, 0.9)', // 文本的锐利光效
transition: `all ${props.animationDuration}s ease-in-out`,
overflow: 'hidden', // **clipper 内部的内容会被 clip-path 裁剪**
clipPath: `polygon(
6px 0, /* 左上角内缩进5px */
calc(100% - 6px) 0, /* 右上角内缩进5px */
100% 6px, /* 右上角外延5px */
100% calc(100% - 6px), /* 右下角内缩进5px */
calc(100% - 6px) 100%,
6px 100%, /* 左下角内缩进5px */
0 calc(100% - 6px),
0 6px, /* 左下角外延5px */
6px 0px /* 左上角内缩进5px(重复起点,闭合多边形) */
)`,
zIndex: 1, // 确保clipper在流光之上
};
if (isHovering.value) {
style.boxShadow = `
inset 0 0 0 3px ${props.buttonBorderColor}, /* 悬停时内部边框变宽 */
0 0 8px rgba(0, 200, 255, 0.7),
0 0 18px rgba(0, 230, 255, 0.8),
0 5px 15px rgba(0, 0, 0, 0.5) /* 底部阴影增强 */
`;
}
return style;
});
// 线的三个关键点 (相对于 SVG 内部坐标系 (0,0) 是按钮中心)
// --- 保持原有逻辑 ---
const linePoints = computed(() => {
if (!svgReady.value) return '';
const startPointX = (props.buttonPosition === 'left' ? props.buttonWidth : 0);
const startPointY = props.buttonHeight/2;
const endPointX = relativeEndX.value;
const endPointY = relativeEndY.value;
// L 形连接点:先垂直,再水平。
const middlePointY = endPointY;
// 调整连接点的 X 坐标,使其更具科技感,可以稍微偏离 Y 轴
// 这里的 +10 和 -10 是示例,可以根据实际效果调整
const adjustedMiddlePointX = startPointX + (props.buttonPosition === 'left' ? 100 : -100);
return `${startPointX},${startPointY} ${adjustedMiddlePointX},${middlePointY} ${endPointX},${endPointY}`;
});
// 细微基线的点 (保持原有逻辑)
const baseLinePoints = computed(() => {
if (!svgReady.value) return '';
const startPointX = (props.buttonPosition === 'left' ? props.buttonWidth : 0);
const startPointY = props.buttonHeight/2;
const endPointX = relativeEndX.value;
const endPointY = relativeEndY.value;
const adjustedMiddlePointX = startPointX + (props.buttonPosition === 'left' ? 100 : -100);
return `${startPointX},${startPointY} ${adjustedMiddlePointX},${endPointY} ${endPointX},${endPointY}`;
});
// --- Methods --- (保持不变)
const updateSvgDimensions = async () => {
if (!techButtonContainer.value) {
svgReady.value = false;
return;
}
await nextTick();
if (!parentContainerRef.value || parentContainerRef.value.offsetHeight === 0 || parentContainerRef.value.offsetWidth === 0) {
svgReady.value = false;
return;
}
parentHeight.value = parentContainerRef.value.offsetHeight;
parentWidth.value = parentContainerRef.value.offsetWidth;
if (parentHeight.value <= 0 || parentWidth.value <= 0 || props.totalButtons <= 0) {
svgReady.value = false;
return;
}
let minX = Math.min(0, 0, relativeEndX.value);
let maxX = Math.max(0, 0, relativeEndX.value);
let minY = Math.min(0, relativeEndY.value, relativeEndY.value);
let maxY = Math.max(0, relativeEndY.value, relativeEndY.value);
minX -= props.linePadding;
maxX += props.linePadding;
minY -= props.linePadding;
maxY += props.linePadding;
svgWidth.value = maxX - minX;
svgHeight.value = maxY - minY;
svgLeftOffset.value = minX;
svgTopOffset.value = minY;
svgReady.value = true;
};
// --- Resize Observer --- (保持不变)
let resizeObserver = null;
const setupResizeObserver = () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
if (parentContainerRef.value) {
resizeObserver = new ResizeObserver(() => {
const newHeight = parentContainerRef.value.offsetHeight;
const newWidth = parentContainerRef.value.offsetWidth;
if (newHeight !== parentHeight.value || newWidth !== parentWidth.value) {
parentHeight.value = newHeight;
parentWidth.value = newWidth;
debouncedUpdate(updateSvgDimensions)();
}
});
resizeObserver.observe(parentContainerRef.value);
} else {
console.error("TechButtonWithLine: Could not find parent element to observe.");
}
};
// --- Lifecycle Hooks --- (保持不变)
onMounted(() => {
if (techButtonContainer.value && techButtonContainer.value.parentElement) {
parentContainerRef.value = techButtonContainer.value.parentElement.parentElement;
setupResizeObserver();
} else {
console.error("TechButtonWithLine: Could not find techButtonContainer or its parent.");
}
updateSvgDimensions();
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
});
// --- Watchers --- (保持不变)
const debouncedUpdate = (fn, delay = 10) => {
let timer = null;
return () => {
clearTimeout(timer);
timer = setTimeout(fn, delay);
};
};
watch(
[
() => parentHeight.value,
() => parentWidth.value,
() => props.totalButtons,
() => props.buttonIndex,
() => props.buttonPosition,
() => props.endX,
() => props.endY,
() => props.buttonOffsetX,
() => props.linePadding,
() => props.buttonWidth,
() => props.buttonHeight,
// 监听用于样式变化的 props
() => props.buttonGradient,
() => props.buttonHoverGradient,
() => props.lineColor,
() => props.lineHoverColor,
() => props.lineStrokeWidth,
() => props.neonGlowIntensity,
() => props.scanLineSpeed,
() => props.animationDuration,
() => props.hoverScale,
() => props.flowLineColor,
() => props.flowLineAnimationSpeed,
],
debouncedUpdate(updateSvgDimensions),
{ immediate: true, deep: true }
);
</script>
<style scoped>
/* --- Base Styles for the Button Container --- */
.tech-button-with-line {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
overflow: visible; /* 允许 SVG 滤镜和动画效果溢出 */
z-index: 1;
border-radius: 8px; /* 科技感圆角 */
/* 基础过渡效果,用于缩放和阴影 */
/* transition: transform v-bind('`${props.animationDuration}s`') ease-in-out, box-shadow v-bind('`${props.animationDuration}s`') ease-in-out, background v-bind('`${props.animationDuration}s`') ease-in-out; */
}
.button-shape-clipper {
position: relative; /* 相对于 techButtonContainer,以填充其空间 */
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 0px; /* **clipper 的形状由 clip-path 定义** */
overflow: hidden; /* **clipper 内部的内容会被 clip-path 裁剪** */
transition: all v-bind('`${props.animationDuration}s`') ease-in-out; /* 过渡背景和阴影 */
z-index: 1; /* 确保 clipper 在流光之上 */
}
.button-content-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none; /* 让鼠标事件穿透到按钮本身 */
}
.button-text {
font-family: 'Orbitron', sans-serif; /* 科技感字体 */
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
/* color: v-bind('props.buttonBorderColor'); 文本颜色与边框颜色呼应 */
color: #fff;
}
/* --- SVG Line Styles --- */
.line-svg {
position: absolute;
pointer-events: none; /* SVG 不捕获鼠标事件 */
overflow: visible; /* 允许滤镜和渐变效果溢出 SVG bounds */
transition: all v-bind('`${props.animationDuration}s`') ease-in-out;
z-index: 0; /* 放在按钮后面 */
}
/* 主线条的动画 */
.main-line {
animation: scanline v-bind('`${props.scanLineSpeed}s`') linear infinite;
/* 调整 stroke-dasharray 来控制扫描线的长度和间隔 */
stroke-dasharray: 15px, 80px; /* 15px 显示, 100px 隐藏 */
stroke-dashoffset: 0; /* 初始位置 */
}
/* 鼠标悬停时,主线条颜色变亮,动画速度可能加快 */
.tech-button-with-line:hover .main-line {
stroke: v-bind('props.lineHoverColor'); /* 悬停时线条颜色 */
animation-duration: v-bind('`${props.scanLineSpeed / 1.5}s`'); /* 悬停时速度加快 */
stroke-dasharray: 20px, 120px; /* 悬停时线条显示部分变长 */
}
/* 细微基线样式 */
.base-line {
opacity: 0.6;
transition: all v-bind('`${props.animationDuration}s`') ease-in-out;
}
.tech-button-with-line:hover .base-line {
opacity: 0.9; /* 悬停时基线也变亮 */
}
/* --- Flowing Light Border Effect --- */
/* 使用 ::before 伪元素实现四周的流光 */
.tech-button-with-line::before {
content: '';
position: absolute;
top: -4px; /* 稍向外延伸 */
left: -4px;
right: -4px;
bottom: -4px;
/* 关键:创建动态的流光渐变 */
/* 渐变方向是从左到右,颜色从透明到高亮再到透明 */
background: linear-gradient(
90deg,
transparent,
v-bind('props.flowLineColor'),
transparent
);
border-radius: 0px; /* **与按钮一致的平直四角** */
opacity: 0.8; /* 初始可见度 */
filter: blur(5px); /* 轻微模糊,模拟光晕 */
z-index: -1; /* 放在按钮内容后面 */
animation: flowLight v-bind('`${props.flowLineAnimationSpeed}s`') linear infinite;
}
/* 悬停时,流光效果可以增强 */
.tech-button-with-line:hover::before {
opacity: 1;
filter: blur(8px); /* 悬停时模糊度增加 */
}
/* --- Keyframes for Animations --- */
@keyframes scanline {
0% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -1000; /* 确保足够长以扫过整个线段 */
}
}
/* 流光动画 */
@keyframes flowLight {
0% { background-position: -100% 0; } /* 从左侧完全移出 */
50% { background-position: 100% 0; } /* 移动到右侧完全移出 */
100% { background-position: -100% 0; } /* 回到初始状态,循环 */
}
</style>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -1,224 +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 })
// 后端状态枚举统一映射为 Element Plus tag 的文案和类型。
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' }
})
// 后端以毫秒返回耗时,短耗时保留 ms,较长耗时转秒,避免标题区域过长。
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`
}
// JSON Viewer 只在确实有内容时展示,避免空对象/空数组占据节点详情空间。
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>

View File

@ -1,203 +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>

View File

@ -1,339 +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>

View File

@ -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>

View File

@ -1,162 +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
})
// 只有子流程参数面板需要当前流程 ID,用于后端过滤不可引用的发布版本。
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>

View File

@ -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>

View File

@ -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>

View File

@ -1,180 +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 })
// 后端状态枚举统一映射为 Element Plus tag 的文案和类型。
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
})
// 后端以毫秒返回耗时,短耗时保留 ms,较长耗时转秒,避免标题区域过长。
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`
}
// JSON Viewer 只在确实有内容时展示,避免空对象/空数组占据节点详情空间。
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>

View File

@ -1,909 +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 {}
}
}
// 统一合并新版 paramsOut 和旧版 actualResult/extraInfo,后写入的 extraInfo 拥有最高展示优先级。
const outputOf = (log) => ({
...parseObject(log?.paramsOut),
...parseObject(log?.actualResult),
...parseObject(log?.extraInfo),
})
// _executionContext 保存后端补充的运行上下文,例如循环层级、事件类型和任务内出现次数。
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 []
}
// 历史 NODE_STARTED 记录入库时还没有写入循环路径,通过同一节点后续终态事件反推,
// 这样旧任务日志也能被折叠进对应轮次,而不需要修改数据库历史数据。
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 || ''}`
}
// 面向用户的日志在节点运行中保留开始事件,终态事件到达后替换开始事件;
// 开始、结束、循环和子流程这类结构节点没有业务信息,非失败场景下隐藏。
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(' - ')
|| '节点执行记录'
/**
* 提取循环折叠标题上的分析判定。非判定型日志返回 null,避免普通节点影响“分析通过”摘要。
*/
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' }
}
/**
* 把扁平日志列表转换成“循环标题 + 可展开子日志”的展示序列。
* 这一步只改变展示结构,不修改 props.list 中的原始日志对象。
*/
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)
}
// 用户模式只保留最有业务含义的一行摘要,避免把完整 JSON 暴露给非调试场景。
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>

View File

@ -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() }, { immediate: true })
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>

View File

@ -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>

View File

@ -1,597 +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 { 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>

View File

@ -1,500 +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 { 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>

View File

@ -1,520 +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 { 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>

View File

@ -1,171 +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) || []
}
/**
* 根据级联引用路径找到源字段定义,用于继承类型、子字段和默认说明。
* 路径格式为 [节点ID, input/output, 字段名, 子字段名...]。
*/
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()
}
/**
* 校验流程输出契约并写回结束节点。
* nodeParams 保存运行时取值引用,outputParams 保存对外暴露的输出字段说明。
*/
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>

View File

@ -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 { 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>

View File

@ -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>

View File

@ -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,
}
}
// getUpstreamInput 根据当前节点 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>

View File

@ -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>

View File

@ -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>

View File

@ -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 { 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>

View File

@ -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>

View File

@ -1,308 +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) || []
}
/**
* 从节点已有配置恢复子流程选择和输入/输出契约。
* nodeParams 存储的是输入引用,outputParams 存储的是子流程对外暴露的输出定义。
*/
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
}
}
/**
* 把子流程需要的机器人角色和设备用途合并到当前流程开始节点。
* 同 roleKey 的资源取更严格的 required/quantity,设备用途类型冲突时直接阻断保存。
*/
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>

View File

@ -1,414 +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>
<div v-else class="reference-field">
<el-select
v-model="item.type"
class="value-type"
:disabled="!requiresComparison(item.condition)"
@change="resetCompareValue(item)"
>
<el-option label="输入" value="input" />
<el-option label="引用" value="quote" />
</el-select>
<el-cascader
v-model="item.quote"
:disabled="!requiresComparison(item.condition)"
:options="quoteOptions"
:show-all-levels="false"
placeholder="请选择"
@visible-change="(visible) => refreshQuoteOptions(visible, item, 'quote')"
/>
</div>
</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] || "";
};
/**
* 将当前分支配置写回画布节点。
* 即使表单校验失败也要写回,这样关闭抽屉后用户刚填的错误配置仍会保留在节点上。
*/
const persistConditions = () => {
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,
});
};
/**
* 校验并保存条件。校验失败时仍关闭抽屉,但向父组件发 save-error,
* 由 ParamsDrawer 标记画布节点为错误状态,并让试运行阶段继续拦截。
*/
const validateAndSave = async () => {
const valid = await formRef.value?.validate().then(() => true).catch(() => false);
try {
persistConditions();
emit(valid ? "save-success" : "save-error");
} 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;
}
</style>

View File

@ -1,51 +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) || []
}
// Cascader 渲染已保存路径前需要先存在完整 option 树,否则回显会丢失末级字段。
refreshQuoteOptions()
/**
* 参数类型在手输和引用之间切换时,清空另一侧的值,避免保存出互相冲突的 input/quote。
*/
const handleTypeChange = (property) => {
if (!property) return
if (property.type === 'input') {
property.quote = []
} else {
property.input = ''
refreshQuoteOptions()
}
}
/**
* 级联选择器只把路径写入表单项,quoteType 由路径第二级 input/output 推导。
*/
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,
}
}

View File

@ -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 };
};

View File

@ -1,898 +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',
}
],
},
]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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>

View File

@ -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>

Some files were not shown because too many files have changed in this diff Show More