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

# Conflicts:
#	.env.development
#	src/views/flow/components/ParamsDrawer.vue
#	src/views/flow/components/params/ImageAnalysisParams.vue
#	src/views/flow/components/params/MediaAnalysisParams.vue
#	src/views/flow/config.js
#	src/views/flow/vue-flow/FlowNode.vue
#	src/views/resource/device/index.vue
This commit is contained in:
lixiaolong 2026-09-16 11:42:33 +08:00
commit f18af176f5
198 changed files with 159 additions and 44877 deletions

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,217 +0,0 @@
<template>
<div class="loop-results">
<div class="loop-results__summary">
<span>{{ history.length }} 次循环</span>
<el-tag v-if="analysisSummary" :type="analysisSummary.type" size="small">
{{ analysisSummary.label }}
</el-tag>
</div>
<el-empty v-if="!history.length" description="暂无循环结果" :image-size="56" />
<el-collapse v-else v-model="expandedIterations" class="loop-results__iterations">
<el-collapse-item v-for="iteration in history" :key="iteration.iteration" :name="iteration.iteration">
<template #title>
<div class="loop-results__iteration-title">
<strong>第 {{ iteration.iteration }} 次</strong>
<el-tag :type="statusMeta(iteration.status).type" size="small">
{{ statusMeta(iteration.status).label }}
</el-tag>
<el-tag v-if="analysisMeta(iteration.analysisStatus)" :type="analysisMeta(iteration.analysisStatus).type" size="small">
{{ analysisMeta(iteration.analysisStatus).label }}
</el-tag>
<span>{{ formatDuration(iteration.durationMs) }}</span>
</div>
</template>
<div class="loop-results__nodes">
<details v-for="node in iteration.nodes || []" :key="`${iteration.iteration}:${node.nodeId}`" class="loop-results__node">
<summary>
<span class="loop-results__node-name">{{ node.nodeName }}</span>
<el-tag :type="statusMeta(node.status).type" size="small">
{{ statusMeta(node.status).label }}
</el-tag>
<el-tag v-if="node.passed !== null && node.passed !== undefined" :type="node.passed ? 'success' : 'danger'" size="small">
{{ node.passed ? '通过' : '未通过' }}
</el-tag>
<span class="loop-results__duration">{{ formatDuration(node.durationMs) }}</span>
</summary>
<div v-if="node.error" class="loop-results__error">{{ node.error }}</div>
<div v-if="hasValue(node.input)" class="loop-results__data">
<div class="loop-results__label">输入</div>
<JsonViewer :value="node.input" copyable boxed sort theme="light" />
</div>
<div v-if="hasValue(node.output)" class="loop-results__data">
<div class="loop-results__label">输出</div>
<JsonViewer :value="node.output" copyable boxed sort theme="light" />
</div>
</details>
</div>
</el-collapse-item>
</el-collapse>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({
history: { type: Array, default: () => [] },
})
const expandedIterations = ref([])
let initialized = false
watch(() => props.history, (history) => {
if (initialized || !history?.length) return
const active = history.find(item => String(item.status || '').toUpperCase() === 'RUNNING')
expandedIterations.value = [active?.iteration || history[history.length - 1].iteration]
initialized = true
}, { immediate: true, deep: true })
const statusMeta = (status) => {
const value = String(status || '').toUpperCase()
if (value === 'SUCCESS') return { label: '执行完成', type: 'success' }
if (value === 'FAILED') return { label: '执行失败', type: 'danger' }
if (value === 'STOPPED') return { label: '已停止', type: 'info' }
if (value === 'PAUSED') return { label: '已暂停', type: 'warning' }
return { label: '执行中', type: 'warning' }
}
const analysisMeta = (status) => {
if (status === 'PASSED') return { label: '分析通过', type: 'success' }
if (status === 'FAILED') return { label: '分析未通过', type: 'danger' }
if (status === 'RUNNING') return { label: '分析中', type: 'warning' }
return null
}
const analysisSummary = computed(() => {
const statuses = props.history.map(item => item.analysisStatus).filter(Boolean)
if (!statuses.length) return null
if (statuses.includes('FAILED')) return { label: '存在未通过', type: 'danger' }
if (statuses.includes('RUNNING')) return { label: '分析中', type: 'warning' }
return { label: '全部通过', type: 'success' }
})
const formatDuration = (value) => {
const duration = Number(value)
if (!Number.isFinite(duration) || duration < 0) return '-'
if (duration < 1000) return `${duration} ms`
return `${(duration / 1000).toFixed(2)} s`
}
const hasValue = (value) => {
if (value === null || value === undefined || value === '') return false
if (Array.isArray(value)) return value.length > 0
if (typeof value === 'object') return Object.keys(value).length > 0
return true
}
</script>
<style scoped lang="scss">
.loop-results__summary {
display: flex;
min-height: 32px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 2px 8px;
color: #344054;
font-size: 13px;
font-weight: 600;
}
.loop-results__iterations {
border-top: 0;
}
.loop-results__iteration-title {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 8px;
padding-right: 8px;
}
.loop-results__iteration-title > span:last-child {
margin-left: auto;
color: #909399;
font-size: 12px;
white-space: nowrap;
}
.loop-results__nodes {
display: grid;
gap: 6px;
padding: 4px 0 10px;
}
.loop-results__node {
border-bottom: 1px solid #ebeef5;
}
.loop-results__node summary {
display: flex;
min-height: 34px;
align-items: center;
gap: 7px;
padding: 3px 4px;
cursor: pointer;
list-style: none;
}
.loop-results__node summary::-webkit-details-marker {
display: none;
}
.loop-results__node summary::before {
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid #909399;
content: '';
transition: transform 0.16s ease;
}
.loop-results__node[open] summary::before {
transform: rotate(90deg);
}
.loop-results__node-name {
min-width: 0;
overflow: hidden;
color: #303133;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.loop-results__duration {
margin-left: auto;
color: #909399;
font-size: 12px;
white-space: nowrap;
}
.loop-results__error {
margin: 4px 4px 8px 18px;
padding: 7px 9px;
border-left: 3px solid var(--el-color-danger);
background: var(--el-color-danger-light-9);
color: var(--el-color-danger-dark-2);
line-height: 18px;
overflow-wrap: anywhere;
}
.loop-results__data {
margin: 6px 4px 10px 18px;
}
.loop-results__label {
margin-bottom: 5px;
color: #667085;
font-size: 12px;
font-weight: 600;
}
</style>

View File

@ -1,202 +0,0 @@
<!--
* 文件说明:节点运行状态面板,展示耗时、输入、输出和错误。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<div v-if="showState !== 'NORMAL'" class="node-state" :class="`node-state--${showState.toLowerCase()}`">
<div class="node-state__summary">
<el-icon class="node-state__icon">
<SuccessFilled v-if="props.state === 'SUCCESS'" />
<CircleCloseFilled v-else-if="['FAILED', 'STOPPED'].includes(props.state)" />
<VideoPause v-else-if="props.state === 'PAUSED'" />
<Loading v-else />
</el-icon>
<span class="node-state__text">{{ stateText }}</span>
<span class="node-state__runtime">{{ props.runtimes }} ms</span>
</div>
<el-popover
ref="popoverRef"
trigger="click"
placement="right-start"
:width="props.popoverWidth"
:teleported="true"
:popper-class="props.popperClass"
@show="popoverVisible = true"
@hide="popoverVisible = false"
>
<template #reference>
<button type="button" class="node-state__result nodrag nopan" @mousedown.stop @click.stop>
<el-icon><View /></el-icon>
<span>{{ popoverVisible ? "收起结果" : "查看结果" }}</span>
</button>
</template>
<div class="result-section">
<div class="result-section__title">输入</div>
<div class="result-section__content"><slot name="input" /></div>
</div>
<div v-if="slots.output" class="result-section result-section--output">
<div class="result-section__title">输出</div>
<div class="result-section__content"><slot name="output" /></div>
</div>
</el-popover>
</div>
</template>
<script setup>
import { computed, ref, useSlots, watch } from "vue";
import { CircleCloseFilled, Loading, SuccessFilled, VideoPause, View } from "@element-plus/icons-vue";
const props = defineProps({
state: { type: String, default: "NORMAL" },
runtimes: { type: Number, default: 0 },
popoverWidth: { type: Number, default: 480 },
popperClass: { type: String, default: "flow-result-popover" },
});
const slots = useSlots();
const popoverRef = ref();
const popoverVisible = ref(false);
const showState = ref("NORMAL");
const stateText = computed(() => ({
SUCCESS: "执行成功",
RUNNING: "执行中",
FAILED: "执行失败",
PAUSED: "已暂停",
STOPPED: "已停止",
NORMAL: "",
}[props.state] || props.state));
watch(() => props.state, (value) => {
showState.value = value;
}, { immediate: true });
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => {
showState.value = "NORMAL";
popoverVisible.value = false;
popoverRef.value?.hide?.();
};
defineExpose({ closePopover });
</script>
<style lang="scss" scoped>
.node-state {
display: flex;
min-height: 34px;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: -4px -4px 10px;
padding: 5px 7px 5px 9px;
border: 1px solid #dbe3ee;
border-radius: 6px;
background: #f7f9fc;
color: #526071;
font-size: 12px;
}
.node-state__summary {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.node-state__icon { font-size: 16px; }
.node-state__text { font-weight: 600; white-space: nowrap; }
.node-state__runtime {
padding-left: 6px;
border-left: 1px solid currentColor;
opacity: 0.72;
white-space: nowrap;
}
.node-state__result {
display: flex;
height: 26px;
flex: 0 0 auto;
align-items: center;
gap: 4px;
padding: 0 7px;
border: 0;
border-radius: 4px;
background: rgba(255, 255, 255, 0.72);
color: inherit;
cursor: pointer;
font-size: 11px;
font-weight: 600;
}
.node-state--success {
border-color: #c8ead5;
background: #effaf3;
color: #1e7d49;
}
.node-state--failed,
.node-state--stopped {
border-color: #f1cecb;
background: #fff3f2;
color: #c33b32;
}
.node-state--paused {
border-color: #d9dee7;
background: #f3f5f8;
color: #657084;
}
.node-state--running {
border-color: #cbdcf8;
background: #f0f6ff;
color: #2f6fca;
.node-state__icon { animation: rotate-animation 1.2s linear infinite; }
}
@keyframes rotate-animation {
to { transform: rotate(360deg); }
}
</style>
<style lang="scss">
.flow-result-popover {
max-width: min(480px, calc(100vw - 32px));
padding: 0 !important;
overflow: hidden;
.result-section {
padding: 14px 16px;
}
.result-section--output { border-top: 1px solid #e8ebf0; }
.result-section__title {
margin-bottom: 8px;
color: #344054;
font-size: 13px;
font-weight: 650;
}
.result-section__content {
max-height: 280px;
overflow: auto;
color: #475467;
font-size: 12px;
}
}
.flow-loop-result-popover {
max-width: min(720px, calc(100vw - 32px));
.result-section__content {
max-height: 520px;
}
}
</style>

View File

@ -1,338 +0,0 @@
<!--
* 文件说明:节点标题栏,负责重命名、删除和单节点执行。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<div class="node-title">
<div class="node-title__main">
<div class="node-title__identity">
<div class="node-title__icon">
<img v-if="props.icon" :src="props.icon" alt="" />
<el-icon v-else><Operation /></el-icon>
</div>
<div class="node-title__text">
<div v-if="!isEditing" class="node-title__name-row">
<span class="node-title__name" :title="localName">{{ localName }}</span>
<span v-if="typeLabel" class="node-title__type">{{ typeLabel }}</span>
<el-tooltip content="重命名" placement="top">
<button
type="button"
class="node-icon-button node-title__edit nodrag nopan"
aria-label="重命名节点"
@mousedown.stop
@click.stop="startEditing"
>
<el-icon><EditPen /></el-icon>
</button>
</el-tooltip>
</div>
<div v-else class="node-title__editor nodrag nopan" @mousedown.stop @click.stop>
<el-input
ref="nameInputRef"
v-model="localName"
maxlength="40"
@keydown.enter.prevent="confirmNodeName"
@keydown.esc.prevent="cancelNodeName"
/>
<button type="button" class="node-icon-button is-confirm" aria-label="确认名称" @click="confirmNodeName">
<el-icon><Select /></el-icon>
</button>
<button type="button" class="node-icon-button" aria-label="取消重命名" @click="cancelNodeName">
<el-icon><CloseBold /></el-icon>
</button>
</div>
</div>
</div>
<div class="node-title__actions nodrag nopan" @mousedown.stop>
<el-tooltip v-if="canExecute" content="执行当前节点" placement="top">
<button type="button" class="node-icon-button is-run" aria-label="执行当前节点" @click.stop="execute">
<el-icon><VideoPlay /></el-icon>
</button>
</el-tooltip>
<el-tooltip :content="isSelectArea ? '解散分组' : '删除节点'" placement="top">
<button
type="button"
class="node-icon-button is-danger"
:aria-label="isSelectArea ? '解散分组' : '删除节点'"
@click.stop="deleteNode"
>
<el-icon><FolderRemove v-if="isSelectArea" /><Delete v-else /></el-icon>
</button>
</el-tooltip>
</div>
</div>
<div v-if="props.nodeDesc" class="node-title__description" :title="props.nodeDesc">
{{ props.nodeDesc }}
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { CloseBold, Delete, EditPen, FolderRemove, Operation, Select, VideoPlay } from "@element-plus/icons-vue";
import { ElMessageBox } from "element-plus";
import { emitter } from "@/utils/eventBus";
const props = defineProps({
icon: { type: String, default: "" },
nodeId: { type: String, default: "" },
nodeProperties: { type: Object, default: () => ({}) },
nodeDesc: { type: String, default: "" },
nodeName: { type: String, default: "" },
nodeType: { type: String, default: "" },
});
const emits = defineEmits(["setNodeName"]);
const isEditing = ref(false);
const localName = ref("");
const nameInputRef = ref();
const isSelectArea = computed(() => props.nodeType === "selectArea");
const flowContextActions = new Set([
"NONE",
"START",
"END",
"SUB_START",
"SUB_END",
"START_LOOP",
"SUB_FLOW",
"STOP_LOOP",
"BRANCH",
"GET_CURRENT_OBJECT",
]);
const canExecute = computed(() => {
if (isSelectArea.value) return false;
const action = String(props.nodeProperties?.action || "").toUpperCase();
return Boolean(action) && !flowContextActions.has(action);
});
const typeLabel = computed(() => {
if (!props.nodeType || ["NONE", "selectArea"].includes(props.nodeType)) return "";
return { EDGE: "设备", LLM: "模型", AE: "评估" }[props.nodeType] || props.nodeType;
});
/**
* 处理 sourceName 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const sourceName = () => props.nodeProperties?.name || props.nodeName || "未命名节点";
watch(sourceName, (value) => {
if (!isEditing.value) localName.value = value;
});
/**
* 处理 startEditing 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const startEditing = async () => {
isEditing.value = true;
await nextTick();
nameInputRef.value?.focus?.();
nameInputRef.value?.select?.();
};
/**
* 确认 confirmNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const confirmNodeName = () => {
const name = localName.value.trim();
if (!name) return;
localName.value = name;
emits("setNodeName", name);
isEditing.value = false;
};
/**
* 处理 cancelNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const cancelNodeName = () => {
localName.value = sourceName();
isEditing.value = false;
};
/**
* 删除 deleteNode 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const deleteNode = async () => {
try {
await ElMessageBox.confirm(
isSelectArea.value
? "解散后内部节点和连线会保留,是否继续?"
: "删除后相关连线也会一并移除,是否继续?",
isSelectArea.value ? "解散分组" : "删除节点",
{
confirmButtonText: isSelectArea.value ? "解散" : "删除",
cancelButtonText: "取消",
confirmButtonClass: "el-button--danger",
type: "warning",
});
if (isSelectArea.value) window.lf?.ungroup(props.nodeId);
else window.lf?.deleteNode(props.nodeId);
emitter.emit("deleteNode", props.nodeId);
} catch (error) {
// Cancelled by the user.
}
};
/**
* 执行 execute 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const execute = () => {
document.dispatchEvent(new CustomEvent("singleNodeExecution", {
bubbles: false,
cancelable: false,
// 携带节点 ID,供单节点执行弹窗按节点恢复上次选择的机器人。
detail: { ...props.nodeProperties, nodeId: props.nodeId },
}));
};
onMounted(() => {
localName.value = sourceName();
});
</script>
<style lang="scss" scoped>
.node-title { min-width: 0; }
.node-title__main {
display: flex;
min-height: 38px;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.node-title__identity {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 10px;
}
.node-title__icon {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 36px;
place-items: center;
border-radius: 7px;
background: #f1f5f9;
color: var(--node-accent, #475569);
img {
width: 22px;
height: 22px;
object-fit: contain;
}
}
.node-title__text { min-width: 0; flex: 1; }
.node-title__name-row {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.node-title__name {
overflow: hidden;
color: #1f2937;
font-size: 15px;
font-weight: 650;
line-height: 22px;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-title__type {
flex: 0 0 auto;
padding: 1px 6px;
border: 1px solid #dfe4ec;
border-radius: 4px;
background: #f8fafc;
color: #697386;
font-size: 10px;
font-weight: 600;
}
.node-title__edit {
width: 24px;
height: 24px;
flex: 0 0 24px;
opacity: 0;
}
.node-title__actions {
display: flex;
flex: 0 0 auto;
gap: 4px;
opacity: 0;
transition: opacity 0.16s ease;
}
.node-title:hover,
.node-title:focus-within {
.node-title__actions,
.node-title__edit { opacity: 1; }
}
.node-icon-button {
display: grid;
width: 28px;
height: 28px;
padding: 0;
place-items: center;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: #7b8494;
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
&:hover {
border-color: #dfe4ec;
background: #f8fafc;
color: #344054;
}
&.is-run:hover {
border-color: #b7dfc7;
background: #effaf3;
color: #168447;
}
&.is-danger:hover {
border-color: #f2c5c2;
background: #fff3f2;
color: #d93025;
}
&.is-confirm { color: #168447; }
}
.node-title__editor {
display: grid;
grid-template-columns: minmax(100px, 1fr) 28px 28px;
gap: 4px;
align-items: center;
}
.node-title__description {
display: -webkit-box;
overflow: hidden;
margin-top: 8px;
color: #7a8494;
font-size: 12px;
line-height: 18px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@media (hover: none) {
.node-title__actions,
.node-title__edit { opacity: 1; }
}
</style>

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,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,173 +0,0 @@
<template>
<div class="subflow-results">
<div class="subflow-results__summary">
<div>
<strong>{{ data.name || '子流程' }}</strong>
<span v-if="data.versionNo">V{{ data.versionNo }}</span>
</div>
<div class="subflow-results__summary-status">
<el-tag v-if="analysisMeta" :type="analysisMeta.type" size="small">{{ analysisMeta.label }}</el-tag>
<el-tag :type="statusMeta(data.status).type" size="small">{{ statusMeta(data.status).label }}</el-tag>
<span>{{ formatDuration(data.durationMs) }}</span>
</div>
</div>
<el-empty v-if="!executions.length" description="暂无内部节点日志" :image-size="56" />
<el-collapse v-else v-model="expandedExecutions" class="subflow-results__groups">
<el-collapse-item v-for="execution in executions" :key="execution.key" :name="execution.key">
<template #title>
<div class="subflow-results__group-title">
<strong>{{ execution.label }}</strong>
<el-tag :type="statusMeta(execution.status).type" size="small">
{{ statusMeta(execution.status).label }}
</el-tag>
<span>{{ execution.nodes?.length || 0 }} 个节点</span>
</div>
</template>
<div class="subflow-results__nodes">
<details v-for="(node, index) in execution.nodes || []" :key="`${execution.key}:${node.nodeId}:${index}`">
<summary>
<span class="subflow-results__order">{{ index + 1 }}</span>
<span class="subflow-results__node-name">{{ node.nodeName }}</span>
<span v-if="node.actionLabel && node.actionLabel !== node.nodeName" class="subflow-results__action">
{{ node.actionLabel }}
</span>
<el-tag :type="statusMeta(node.status).type" size="small">{{ statusMeta(node.status).label }}</el-tag>
<el-tag v-if="node.passed !== null && node.passed !== undefined" :type="node.passed ? 'success' : 'danger'" size="small">
{{ node.passed ? '通过' : '未通过' }}
</el-tag>
<span class="subflow-results__duration">{{ formatDuration(node.durationMs) }}</span>
</summary>
<div v-if="node.error" class="subflow-results__error">{{ node.error }}</div>
<div v-if="hasValue(node.input)" class="subflow-results__data">
<div class="subflow-results__label">输入</div>
<JsonViewer :value="node.input" copyable boxed sort theme="light" />
</div>
<div v-if="hasValue(node.output)" class="subflow-results__data">
<div class="subflow-results__label">输出</div>
<JsonViewer :value="node.output" copyable boxed sort theme="light" />
</div>
</details>
</div>
</el-collapse-item>
</el-collapse>
<details v-if="hasValue(data.output)" class="subflow-results__final-output">
<summary>子流程最终输出</summary>
<JsonViewer :value="data.output" copyable boxed sort theme="light" />
</details>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
const props = defineProps({ data: { type: Object, default: () => ({}) } })
const executions = computed(() => Array.isArray(props.data.executions) ? props.data.executions : [])
const expandedExecutions = ref([])
let initialized = false
watch(executions, (groups) => {
if (initialized || !groups.length) return
const active = groups.find((group) => String(group.status || '').toUpperCase() === 'RUNNING')
expandedExecutions.value = [active?.key || groups[groups.length - 1].key]
initialized = true
}, { immediate: true })
const statusMeta = (status) => {
const value = String(status || '').toUpperCase()
if (value === 'SUCCESS') return { label: '执行完成', type: 'success' }
if (value === 'FAILED') return { label: '执行失败', type: 'danger' }
if (value === 'STOPPED') return { label: '已停止', type: 'info' }
if (value === 'PAUSED') return { label: '已暂停', type: 'warning' }
return { label: '执行中', type: 'warning' }
}
const analysisMeta = computed(() => {
if (props.data.analysisStatus === 'PASSED') return { label: '分析通过', type: 'success' }
if (props.data.analysisStatus === 'FAILED') return { label: '分析未通过', type: 'danger' }
if (props.data.analysisStatus === 'RUNNING') return { label: '分析中', type: 'warning' }
return null
})
const formatDuration = (value) => {
const duration = Number(value)
if (!Number.isFinite(duration) || duration < 0) return '-'
if (duration < 1000) return `${duration} ms`
return `${(duration / 1000).toFixed(2)} s`
}
const hasValue = (value) => {
if (value === null || value === undefined || value === '') return false
if (Array.isArray(value)) return value.length > 0
if (typeof value === 'object') return Object.keys(value).length > 0
return true
}
</script>
<style scoped lang="scss">
.subflow-results__summary,
.subflow-results__summary-status,
.subflow-results__group-title,
.subflow-results__nodes summary {
display: flex;
align-items: center;
}
.subflow-results__summary {
justify-content: space-between;
gap: 12px;
padding: 2px 2px 12px;
color: #344054;
}
.subflow-results__summary > div,
.subflow-results__summary-status { display: flex; align-items: center; gap: 8px; }
.subflow-results__summary span { color: #667085; font-size: 12px; }
.subflow-results__groups { border-top: 0; }
.subflow-results__group-title {
min-width: 0;
flex: 1;
gap: 8px;
padding-right: 8px;
}
.subflow-results__group-title > span:last-child {
margin-left: auto;
color: #909399;
font-size: 12px;
}
.subflow-results__nodes { display: grid; gap: 5px; padding-bottom: 10px; }
.subflow-results__nodes details { border-bottom: 1px solid #ebeef5; }
.subflow-results__nodes summary {
min-height: 36px;
gap: 7px;
padding: 3px 4px;
cursor: pointer;
list-style: none;
}
.subflow-results__nodes summary::-webkit-details-marker { display: none; }
.subflow-results__order { width: 20px; color: #98a2b3; font-size: 11px; text-align: center; }
.subflow-results__node-name { min-width: 0; overflow: hidden; color: #303133; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.subflow-results__action { color: #98a2b3; font-size: 11px; white-space: nowrap; }
.subflow-results__duration { margin-left: auto; color: #909399; font-size: 12px; white-space: nowrap; }
.subflow-results__error {
margin: 4px 4px 8px 31px;
padding: 7px 9px;
border-left: 3px solid var(--el-color-danger);
background: var(--el-color-danger-light-9);
color: var(--el-color-danger-dark-2);
overflow-wrap: anywhere;
}
.subflow-results__data { margin: 6px 4px 10px 31px; }
.subflow-results__label { margin-bottom: 5px; color: #667085; font-size: 12px; font-weight: 600; }
.subflow-results__final-output { margin-top: 10px; padding-top: 10px; border-top: 1px solid #ebeef5; }
.subflow-results__final-output summary { margin-bottom: 8px; color: #344054; cursor: pointer; font-size: 12px; font-weight: 600; }
</style>

View File

@ -1,889 +0,0 @@
<template>
<div class="task-log" :style="{ height: `${height}px` }">
<!-- 独立滚动层负责监听用户位置;外层仅提供边框和悬浮按钮的定位上下文。 -->
<div ref="messageContainer" class="task-log__scroll" @scroll.passive="handleScroll">
<!-- 每条日志由固定宽度的时间列和自适应内容列组成;没有后端 ID 时使用数组下标兜底。 -->
<template v-for="(log, index) in displayLogs" :key="log.__displayKey || log.id || index">
<button
v-if="log.__iterationHeader"
type="button"
class="task-log__iteration-header"
@click="toggleIteration(log.__iterationKey)"
>
<el-icon :class="{ 'is-expanded': isIterationExpanded(log.__iterationKey) }"><ArrowRight /></el-icon>
<span class="task-log__iteration-title">{{ log.__iterationLabel }}</span>
<el-tag :type="log.__statusType" size="small">{{ log.__statusLabel }}</el-tag>
<el-tag v-if="log.__analysisLabel" :type="log.__analysisType" size="small">
{{ log.__analysisLabel }}
</el-tag>
<span class="task-log__iteration-count">{{ log.__count }} 条日志</span>
</button>
<div v-else class="task-log__item" :class="{ 'is-iteration-child': iterationPathOf(log).length }">
<div class="task-log__time">{{ formatDate(logTimeOf(log)) }}</div>
<div class="task-log__body">
<div class="task-log__content">{{ logContentOf(log) }}</div>
<div v-if="displayErrorOf(log)" class="task-log__error">
<strong>失败原因</strong>
<span>{{ displayErrorOf(log) }}</span>
</div>
<!-- 只有 analysisOf 能确认是音频或视频分析日志时,才展示结构化分析结果。 -->
<div v-if="analysisOf(log)" class="task-log__analysis">
<!-- 标题行展示分析类型、执行状态、模型名称和耗时;缺失字段不会留下空占位。 -->
<div class="task-log__analysis-heading">
<el-tag :type="analysisOf(log).tagType" size="small">
{{ analysisOf(log).title }} · {{ analysisOf(log).statusLabel }}
</el-tag>
<span v-if="isDebugView && analysisOf(log).modelName" class="task-log__analysis-meta">
模型:{{ analysisOf(log).modelName }}
</span>
<span v-if="analysisOf(log).timingMs !== null" class="task-log__analysis-meta">
耗时:{{ analysisOf(log).timingMs }}ms
</span>
</div>
<!-- 常用结果采用四列摘要,便于快速比较;详细扩展字段放到下方 JSON 区域。 -->
<div class="task-log__analysis-grid">
<div v-if="analysisOf(log).targetLabel" class="task-log__analysis-field">
<span>目标声音</span>
<strong>{{ analysisOf(log).targetLabel }}</strong>
</div>
<div v-if="analysisOf(log).label" class="task-log__analysis-field">
<span>{{ analysisOf(log).isAudio ? '实际声音' : '识别结果' }}</span>
<strong>{{ analysisOf(log).label }}</strong>
</div>
<div v-if="analysisOf(log).matchLabel" class="task-log__analysis-field">
<span>匹配状态</span>
<strong>{{ analysisOf(log).matchLabel }}</strong>
</div>
<div v-if="analysisOf(log).confidence !== null" class="task-log__analysis-field">
<span>置信度</span>
<strong>{{ analysisOf(log).confidence }}%</strong>
</div>
<div v-if="analysisOf(log).passedLabel" class="task-log__analysis-field">
<span>判断结果</span>
<strong>{{ analysisOf(log).passedLabel }}</strong>
</div>
</div>
<div v-if="analysisOf(log).summary" class="task-log__analysis-summary">
{{ analysisOf(log).summary }}
</div>
<!-- 关键事件可能来自模型输出数组,analysisOf 已限制最多展示前 20 条。 -->
<div v-if="analysisOf(log).events.length" class="task-log__analysis-events">
<div class="task-log__analysis-label">关键事件</div>
<div v-for="(event, eventIndex) in analysisOf(log).events" :key="eventIndex" class="task-log__analysis-event">
{{ event }}
</div>
</div>
<!-- 未被结构化消费的字段以格式化 JSON 展示,保留排查模型输出的能力。 -->
<pre v-if="isDebugView && analysisOf(log).details" class="task-log__analysis-details">{{ analysisOf(log).details }}</pre>
</div>
<pre v-if="isDebugView && genericOutputOf(log)" class="task-log__output">{{ genericOutputOf(log) }}</pre>
<div v-else-if="!isDebugView && businessSummaryOf(log)" class="task-log__business-summary">
{{ businessSummaryOf(log) }}
</div>
<!-- 同一日志可同时携带多种媒体;mediaOf 负责从新旧字段提取并按“类型+URL”去重。 -->
<div v-if="mediaOf(log).length" class="task-log__media">
<template v-for="media in mediaOf(log)" :key="`${media.type}:${media.url}`">
<el-image
v-if="media.type === 'image'"
class="task-log__image"
:src="media.url"
:preview-src-list="imageUrlsOf(log)"
preview-teleported
fit="cover"
/>
<!-- 视频和音频只预加载元数据,避免日志列表初次渲染时下载完整媒体文件。 -->
<video v-else-if="media.type === 'video'" class="task-log__video" :src="media.url" controls preload="metadata" />
<audio v-else class="task-log__audio" :src="media.url" controls preload="metadata" />
</template>
</div>
</div>
</div>
</template>
</div>
<!-- 用户离开底部后显示悬浮入口;点击后回到最新日志并恢复自动跟随。 -->
<el-tooltip v-if="!isAtBottom" content="跳到最新日志" placement="left">
<el-button
class="task-log__jump-latest"
type="primary"
:icon="Bottom"
circle
aria-label="跳到最新日志"
@click.stop="scrollToBottom"
/>
</el-tooltip>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { ArrowRight, Bottom } from '@element-plus/icons-vue'
import { formatDate } from '@/utils/index'
/**
* list: 任务或流程节点产生的日志数组。组件只负责展示,不修改日志对象。
* height: 日志容器固定高度;内容超出后在容器内滚动,默认 150px。
*/
const props = defineProps({
list: { type: Array, default: () => [] },
height: { type: Number, default: 150 },
mode: { type: String, default: 'debug' },
})
const isDebugView = computed(() => props.mode !== 'user')
// 保存日志滚动容器 DOM,用于首次渲染和日志更新后滚动到最新一条。
const messageContainer = ref(null)
// 使用少量像素容差吸收浏览器对子像素和缩放比例的取整误差,避免视觉上已到底部却仍显示按钮。
const BOTTOM_THRESHOLD = 8
// 初始状态视为位于底部;组件挂载后会根据真实滚动尺寸完成第一次定位。
const isAtBottom = ref(true)
// 以日志对象本身作为弱引用键缓存媒体解析结果;日志对象回收后缓存会自动释放,不产生长期内存占用。
const mediaCache = new WeakMap()
/**
* 将扩展结果安全转换为普通对象。
* 后端历史数据可能返回 JSON 字符串,新版接口可能直接返回对象;空值、基础类型或非法 JSON 均降级为空对象,
* 避免一条格式异常的日志阻断整个列表渲染。
*/
const parseObject = (value) => {
if (!value) return {}
if (typeof value === 'object') return value
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
const outputOf = (log) => ({
...parseObject(log?.paramsOut),
...parseObject(log?.actualResult),
...parseObject(log?.extraInfo),
})
const executionContextOf = (log) => outputOf(log)._executionContext || {}
const rawIterationPathOf = (log) => {
const values = [log?.iterations, executionContextOf(log).iterations, log?.iteration]
for (const value of values) {
const path = Array.isArray(value)
? value.filter(item => item !== null && item !== '')
: value === null || value === undefined || value === ''
? []
: String(value).split(/[-.,/]/).map(item => item.trim()).filter(Boolean)
if (path.length) return path
}
return []
}
// Historical NODE_STARTED records were persisted before the loop path was copied
// into the event. Pair them with the next terminal event of the same node so old
// task logs can be folded without rewriting database rows.
const inferredIterationPaths = computed(() => {
const result = new WeakMap()
const pendingStarts = new Map()
;(Array.isArray(props.list) ? props.list : []).forEach((log) => {
const eventType = String(executionContextOf(log).eventType || '').toUpperCase()
const nodeKey = String(log?.nodeId || log?.nodeName || log?.executionStep || '')
if (!nodeKey) return
if (eventType === 'NODE_STARTED') {
if (!rawIterationPathOf(log).length) {
if (!pendingStarts.has(nodeKey)) pendingStarts.set(nodeKey, [])
pendingStarts.get(nodeKey).push(log)
}
return
}
if (!['NODE_COMPLETED', 'NODE_FAILED'].includes(eventType)) return
const pending = pendingStarts.get(nodeKey)
if (!pending?.length) return
const startedLog = pending.shift()
const path = rawIterationPathOf(log)
if (path.length) result.set(startedLog, path)
})
return result
})
const iterationPathOf = (log) => {
const path = rawIterationPathOf(log)
return path.length ? path : inferredIterationPaths.value.get(log) || []
}
const executionKeyOf = (log) => {
const context = executionContextOf(log)
const nodeKey = log?.nodeId || log?.executionStep || log?.nodeName || log?.logContent || ''
return `${nodeKey}:${iterationPathOf(log).join('.')}:${context.itemOccurrence || ''}`
}
// User-facing task logs keep an unmatched start event while a node is running, then
// replace it with the terminal event. Structural wrapper nodes do not add business value.
const visibleLogs = computed(() => {
const source = Array.isArray(props.list) ? props.list : []
if (isDebugView.value) return source
const terminalKeys = new Set(source
.filter((log) => ['NODE_COMPLETED', 'NODE_FAILED'].includes(
String(executionContextOf(log).eventType || '').toUpperCase(),
))
.map(executionKeyOf))
const structuralTypes = new Set(['START', 'END', 'SUB_START', 'SUB_END', 'SUB_FLOW', 'LOOP'])
return source.filter((log) => {
const context = executionContextOf(log)
const eventType = String(context.eventType || '').toUpperCase()
const nodeType = String(context.nodeType || log?.nodeType || '').toUpperCase()
if (eventType === 'NODE_STARTED' && terminalKeys.has(executionKeyOf(log))) return false
if (structuralTypes.has(nodeType) && !errorOf(log)) return false
return true
})
})
const logTimeOf = (log) => log?.logTime || log?.createTime || log?.startTime || log?.endTime
const logContentOf = (log) => log?.logContent || log?.message
|| [log?.action || log?.nodeType, log?.status].filter(Boolean).join(' - ')
|| '节点执行记录'
const analysisPassedOf = (log) => {
const output = outputOf(log)
const analysisType = String(output.analysisType || '').toUpperCase()
const profileCode = String(output.profileCode || '').toLowerCase()
const isDecisionAnalysis = analysisType.includes('AUDIO') || analysisType.includes('VIDEO')
|| profileCode.includes('audio') || profileCode.includes('video')
|| [3, 4].includes(Number(log?.logType))
if (!isDecisionAnalysis) return null
if (['FAILED', 'ERROR'].includes(String(output.analysisStatus || '').toUpperCase())) return false
if (output.passed === undefined || output.passed === null) return null
return output.passed === true || String(output.passed).toLowerCase() === 'true'
}
const expandedIterations = ref(new Set())
const isIterationExpanded = key => expandedIterations.value.has(key)
const toggleIteration = (key) => {
const next = new Set(expandedIterations.value)
next.has(key) ? next.delete(key) : next.add(key)
expandedIterations.value = next
}
const statusOfIteration = (logs) => {
const statuses = logs.map(log => String(log?.status ?? log?.logStatus ?? '').toUpperCase())
const contexts = logs.map(log => String(executionContextOf(log).eventType || '').toUpperCase())
if (statuses.some(status => ['2', 'FAILED', 'FAILURE', 'ERROR'].includes(status))
|| contexts.includes('NODE_FAILED')) {
return { label: '执行失败', type: 'danger' }
}
const latestEventByNode = new Map()
logs.forEach((log, index) => {
const eventType = String(executionContextOf(log).eventType || '').toUpperCase()
if (!eventType) return
const nodeKey = String(log?.nodeId || log?.nodeName || log?.executionStep || index)
latestEventByNode.set(nodeKey, eventType)
})
if ([...latestEventByNode.values()].some(eventType => eventType === 'NODE_STARTED')) {
return { label: '执行中', type: 'warning' }
}
if (latestEventByNode.size) return { label: '执行完成', type: 'success' }
if (statuses.some(status => ['RUNNING', '1'].includes(status))) {
return { label: '执行中', type: 'warning' }
}
return { label: '执行完成', type: 'success' }
}
const displayLogs = computed(() => {
const source = visibleLogs.value
const groups = new Map()
source.forEach((log) => {
const path = iterationPathOf(log)
if (!path.length) return
const key = path.join('.')
if (!groups.has(key)) groups.set(key, [])
groups.get(key).push(log)
})
const emitted = new Set()
const result = []
source.forEach((log, sourceIndex) => {
const path = iterationPathOf(log)
if (!path.length) {
result.push(log)
return
}
const key = path.join('.')
if (emitted.has(key)) return
emitted.add(key)
const logs = groups.get(key) || []
const status = statusOfIteration(logs)
const decisions = logs.map(analysisPassedOf).filter(value => value !== null)
const analysisPassed = decisions.length ? decisions.every(Boolean) : null
result.push({
__iterationHeader: true,
__displayKey: `iteration:${key}:${sourceIndex}`,
__iterationKey: key,
__iterationLabel: `第 ${path.join('.')} 次循环`,
__count: logs.length,
__statusLabel: status.label,
__statusType: status.type,
__analysisLabel: analysisPassed === null ? '' : analysisPassed ? '分析通过' : '分析未通过',
__analysisType: analysisPassed ? 'success' : 'danger',
})
if (isIterationExpanded(key)) result.push(...logs)
})
return result
})
const genericOutputOf = (log) => {
if (analysisOf(log)) return ''
const output = { ...outputOf(log) }
delete output._executionContext
delete output.errorMessage
if (!Object.keys(output).length) return ''
return JSON.stringify(output, null, 2)
}
const businessSummaryOf = (log) => {
if (analysisOf(log) || mediaOf(log).length) return ''
const output = { ...outputOf(log) }
delete output._executionContext
if (typeof output.result === 'string' && output.result.trim()) return output.result.trim()
if (['number', 'boolean'].includes(typeof output.result)) return `结果:${output.result}`
if (Array.isArray(output.coordinates) && output.coordinates.length) {
return `坐标:${output.coordinates.join(', ')}`
}
if (Number.isFinite(Number(output.completedSteps))) return `已完成 ${output.completedSteps} 个步骤`
if (output.success === true) return '操作成功'
return ''
}
/**
* 把一个或多个媒体 URL 追加到结果数组。
* seen 使用“媒体类型:URL”作为去重键,因此同一 URL 以图片和视频两种类型出现时仍会分别保留;
* 非字符串、空白字符串和已经添加过的条目会被忽略。
*/
const addMedia = (result, seen, type, value) => {
const values = Array.isArray(value) ? value : [value]
values.forEach((item) => {
const url = typeof item === 'string' ? item.trim() : ''
const key = `${type}:${url}`
if (url && !seen.has(key)) {
seen.add(key)
result.push({ type, url })
}
})
}
// 分析结果的计算相对复杂,使用 WeakMap 保证模板多次调用 analysisOf(log) 时只解析一次。
const analysisCache = new WeakMap()
const errorCache = new WeakMap()
const errorOf = (log) => {
if (!log || typeof log !== 'object') return ''
if (errorCache.has(log)) return errorCache.get(log)
const actualResult = parseObject(log.actualResult)
const extraInfo = parseObject(log.extraInfo)
const paramsOut = parseObject(log.paramsOut)
const status = String(log.status ?? log.logStatus ?? '').trim().toLowerCase()
const failed = ['2', 'failed', 'failure', 'error'].includes(status)
const candidates = [
log.errorMessage,
log.errorStack,
actualResult.errorMessage,
actualResult.error,
extraInfo.errorMessage,
extraInfo.error,
paramsOut.errorMessage,
paramsOut.error,
failed ? log.message : '',
]
const result = candidates.find(value => typeof value === 'string' && value.trim())?.trim() || ''
errorCache.set(log, result)
return result
}
const displayErrorOf = (log) => {
const error = errorOf(log)
if (!error || isDebugView.value) return error
return error
.split(/\r?\n/)[0]
.replace(/^(?:[\w$.]+(?:Exception|Error):\s*)+/, '')
.replace(/^执行异常[::]\s*/, '')
.trim()
}
/**
* 把日志中的音视频分析结果转换成模板直接使用的展示模型。
* 返回 null 表示该日志不是可展示的音视频分析结果;否则返回状态、模型、置信度、结论和扩展详情等字段。
*/
const analysisOf = (log) => {
// 防御非对象输入,同时优先读取缓存,避免模板中多个字段重复做 JSON.parse 和格式化工作。
if (!log || typeof log !== 'object') return null
if (analysisCache.has(log)) return analysisCache.get(log)
// extraInfo 后展开,因此同名字段会覆盖 actualResult;这是为了让后续补充/修正的信息优先显示。
const output = {
...parseObject(log.paramsOut),
...parseObject(log.actualResult),
...parseObject(log.extraInfo),
}
// 新日志通过 analysisType/profileCode 标识媒体类型,logType=3/4 用于兼容旧版视频/音频日志。
const analysisType = String(output.analysisType || '').toUpperCase()
const profileCode = String(output.profileCode || '').toLowerCase()
const isAudio = analysisType.includes('AUDIO') || profileCode.includes('audio') || Number(log.logType) === 4
const isVideo = analysisType.includes('VIDEO') || profileCode.includes('video') || Number(log.logType) === 3
// 图片分析没有历史 logType 枚举,只依赖新协议中的 analysisType/profileCode 识别。
const isImage = analysisType.includes('IMAGE') || profileCode.includes('image')
// 仅有媒体类型还不足以展示分析面板,至少要存在一个分析状态或业务结果字段。
const hasResult = Boolean(
output.analysisStatus
|| output.analysisType
|| output.profileCode
|| output.label
|| output.labelName
|| output.summary
|| output.conclusion
|| output.resultText
|| output.result !== undefined
|| output.passed !== undefined
)
// 非分析日志也缓存 null,避免之后每次渲染都重复执行同一套识别逻辑。
if (!hasResult || (!isAudio && !isVideo && !isImage)) {
analysisCache.set(log, null)
return null
}
const status = String(output.analysisStatus || '').toUpperCase()
const matched = output.matched
const passed = output.passed
const confidenceValue = Number(output.confidence)
// 置信度兼容 0~1 和 0~100 两种协议,统一限制到 0~100 并保留一位小数。
// 非数字值返回 null,模板据此隐藏置信度字段,而不是展示 NaN。
const confidence = Number.isFinite(confidenceValue)
? Math.max(0, Math.min(100, confidenceValue <= 1 ? confidenceValue * 100 : confidenceValue)).toFixed(1)
: null
// 常见成功/失败枚举转换为中文;未知状态保留原值,完全缺失时使用中性的“已返回结果”。
const statusLabel = status === 'SUCCEEDED' || status === 'SUCCESS'
|| status === 'COMPLETED'
? '分析完成'
: status === 'FAILED' || status === 'ERROR'
? '分析失败'
: status || '已返回结果'
// 复制一份原始输出用于生成扩展详情,后续删除操作不会修改日志本身或合并后的 output。
const detailSource = { ...output }
// _executionContext 只用于前端把日志归属到任务中的检测项和执行次数,属于内部路由元数据。
// 它与已经结构化展示的分析字段一起从“详情”中移除,避免用户看到重复且难以理解的底层上下文。
;[
'analysisStatus', 'analysisType', 'profileCode', 'analysisModel', 'analysisTimingMs',
'mediaUrl', 'mediaUrls', 'audioUrl', 'videoUrl', 'imageUrl', 'label', 'labelName', 'matched',
'confidence', 'passed', 'summary', 'conclusion', 'events', '_executionContext',
'expectedLabel', 'expectedLabelName', 'decisionReason', 'resultText', 'result',
].forEach((key) => delete detailSource[key])
// 所有已知字段删除后还有剩余内容时,才生成详情 JSON,避免显示空对象。
const hasDetails = Object.keys(detailSource).length > 0
// 统一生成展示模型,模板不需要了解各版本日志字段和状态枚举的差异。
const result = {
title: isAudio ? '声音类型检测' : isImage ? '通用图片分析' : '视频分析',
statusLabel,
// 分析失败或业务判断未通过使用红色;仅匹配失败使用警告色,其余使用成功色。
tagType: status === 'FAILED' || status === 'ERROR' || passed === false
? 'danger'
: matched === false ? 'warning' : 'success',
modelName: typeof output.analysisModel === 'string'
? output.analysisModel
: output.analysisModel?.name || '',
timingMs: Number.isFinite(Number(output.analysisTimingMs)) ? Number(output.analysisTimingMs) : null,
label: output.labelName || output.label || '',
targetLabel: output.expectedLabelName || output.expectedLabel || '',
matchLabel: isAudio ? '' : matched === true ? '匹配' : matched === false ? '未匹配' : '',
confidence,
passedLabel: passed === true ? '通过' : passed === false ? '未通过' : '',
// 图片分析由提示词决定输出形态,优先展示模型文本或字符串结果,避免把枚举型原因当摘要。
summary: output.summary || output.conclusion || output.resultText
|| (typeof output.result === 'string' ? output.result : '')
|| (/^[A-Z_]+$/.test(String(output.decisionReason || '')) ? '' : output.decisionReason)
|| '',
isAudio,
events: Array.isArray(output.events)
? output.events.slice(0, 20).map((event) => typeof event === 'string' ? event : JSON.stringify(event))
: [],
details: hasDetails ? JSON.stringify(detailSource, null, 2) : '',
}
// 正常分析结果也写入缓存,后续模板读取状态、模型、摘要等字段时直接复用同一对象。
analysisCache.set(log, result)
return result
}
/**
* 从日志的新旧字段中提取所有可播放/预览的媒体资源。
* 返回值格式统一为 { type, url },模板无需关心 URL 原来位于 actualResult、extraInfo 还是日志顶层。
*/
const mediaOf = (log) => {
if (!log || typeof log !== 'object') return []
if (mediaCache.has(log)) return mediaCache.get(log)
const result = []
const seen = new Set()
// 与分析结果相同,extraInfo 中的同名字段优先于 actualResult。
const output = {
...parseObject(log.paramsOut),
...parseObject(log.actualResult),
...parseObject(log.extraInfo),
}
// 优先读取类型明确的新字段,它们不需要额外推断媒体类型。
addMedia(result, seen, 'image', output.imageUrl)
addMedia(result, seen, 'video', output.videoUrl)
addMedia(result, seen, 'audio', output.audioUrl)
const analysisType = String(output.analysisType || '').toUpperCase()
const profileCode = String(output.profileCode || '').toLowerCase()
// mediaUrl 本身没有类型,必须结合分析类型或 profileCode 后才能作为视频/音频加入。
if (analysisType.includes('VIDEO') || profileCode.includes('video')) addMedia(result, seen, 'video', output.mediaUrl)
if (analysisType.includes('AUDIO') || profileCode.includes('audio')) addMedia(result, seen, 'audio', output.mediaUrl)
if (analysisType.includes('IMAGE') || profileCode.includes('image')) {
addMedia(result, seen, 'image', output.mediaUrls)
addMedia(result, seen, 'image', output.mediaUrl)
}
addMedia(result, seen, 'image', log.screenshotUrl)
addMedia(result, seen, 'video', log.videoUrl)
// 旧版日志把媒体统一放在顶层 mediaUrl,并通过 logType=2/3/4 区分图片、视频和音频。
const legacyType = Number(log.logType) === 2 ? 'image'
: Number(log.logType) === 3 ? 'video'
: Number(log.logType) === 4 ? 'audio' : ''
if (legacyType) addMedia(result, seen, legacyType, log.mediaUrl)
// 即使没有找到媒体也缓存空数组,避免无媒体日志在每次渲染时重复扫描全部字段。
mediaCache.set(log, result)
return result
}
// Element Plus 图片预览需要同一条日志下的纯图片 URL 数组,视频和音频不会进入预览列表。
const imageUrlsOf = (log) => mediaOf(log)
.filter((item) => item.type === 'image')
.map((item) => item.url)
/**
* 根据滚动层的剩余距离更新“是否位于底部”。
* 用户拖动滚动条、滚轮滚动以及程序修改 scrollTop 都会触发 scroll 事件,因此只需要维护一个状态源。
*/
const updateBottomState = () => {
const container = messageContainer.value
if (!container) {
isAtBottom.value = true
return
}
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight
isAtBottom.value = distanceToBottom <= BOTTOM_THRESHOLD
}
// 滚动事件只做轻量尺寸计算,不写入 scrollTop,避免干扰用户正在进行的历史日志浏览。
const handleScroll = () => updateBottomState()
/**
* 将滚动位置移动到最新日志,并恢复自动跟随状态。
* nextTick 确保新日志已经渲染,scrollHeight 是更新后的完整内容高度。
*/
const scrollToBottom = async () => {
await nextTick()
const container = messageContainer.value
if (!container) return
container.scrollTop = container.scrollHeight
isAtBottom.value = true
}
/**
* 日志变化时先读取更新前的滚动状态:
* - 原本在底部,说明用户正在追踪最新日志,DOM 更新后继续滚到底部;
* - 原本不在底部,说明用户正在查看历史内容,只更新按钮状态,不改变其 scrollTop。
* deep 监听同时覆盖追加日志和现有日志内容更新。
*/
watch(() => props.list, () => {
if (isAtBottom.value) {
scrollToBottom()
} else {
nextTick(updateBottomState)
}
}, { deep: true })
// 首次进入日志视图仍默认展示最新一条,此时尚不存在用户主动选择的历史位置。
onMounted(scrollToBottom)
</script>
<style scoped lang="scss">
/* 外层容器固定高度并裁剪内容,同时作为“跳到最新日志”悬浮按钮的定位上下文。 */
.task-log {
position: relative;
box-sizing: border-box;
margin-top: 12px;
overflow: hidden;
background: #fff;
border: 1px solid #eee;
border-radius: 8px;
}
/* 只有内部区域负责滚动,悬浮按钮不会随日志内容一起移出可视区域。 */
.task-log__scroll {
box-sizing: border-box;
height: 100%;
padding: 12px;
overflow-y: auto;
}
/* 固定在滚动视口右下角,不参与日志排版;稳定尺寸避免按钮出现时挤压或移动内容。 */
.task-log__jump-latest {
position: absolute;
right: 16px;
bottom: 16px;
z-index: 2;
width: 36px;
height: 36px;
box-shadow: 0 4px 12px rgb(0 0 0 / 18%);
}
/* 桌面端使用“时间 + 内容”两列布局;内容列 minmax(0, 1fr) 允许长文本正确收缩。 */
.task-log__item {
display: grid;
grid-template-columns: 145px minmax(0, 1fr);
gap: 12px;
align-items: start;
margin-bottom: 10px;
}
.task-log__iteration-header {
display: flex;
width: 100%;
min-height: 38px;
gap: 8px;
align-items: center;
margin: 8px 0;
padding: 7px 10px;
border: 1px solid #dcdfe6;
border-radius: 6px;
background: #f7f8fa;
color: #303133;
cursor: pointer;
text-align: left;
}
.task-log__iteration-header .el-icon {
flex: 0 0 auto;
transition: transform 0.18s ease;
}
.task-log__iteration-header .el-icon.is-expanded {
transform: rotate(90deg);
}
.task-log__iteration-title {
min-width: 0;
font-size: 14px;
font-weight: 600;
}
.task-log__iteration-count {
margin-left: auto;
color: #909399;
font-size: 12px;
white-space: nowrap;
}
.task-log__item.is-iteration-child {
margin-left: 18px;
padding-left: 10px;
border-left: 2px solid #e4e7ed;
}
.task-log__time {
color: #999;
font-size: 13px;
white-space: nowrap;
}
.task-log__body {
min-width: 0;
}
.task-log__content {
color: #333;
font-size: 14px;
line-height: 20px;
}
.task-log__error {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 8px;
margin-top: 7px;
padding: 8px 10px;
border-left: 3px solid var(--el-color-danger);
background: var(--el-color-danger-light-9);
color: var(--el-color-danger-dark-2);
font-size: 13px;
line-height: 19px;
}
.task-log__error span {
min-width: 0;
overflow-wrap: anywhere;
}
/* 分析结果使用左侧强调线与普通日志正文区分,但保持在同一条日志的内容列中。 */
.task-log__analysis {
margin-top: 9px;
padding: 10px 12px;
border-left: 3px solid #5b8ff9;
background: #f6f8fc;
color: #344054;
}
.task-log__analysis-heading {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.task-log__analysis-meta,
.task-log__analysis-field span,
.task-log__analysis-label {
color: #667085;
font-size: 12px;
}
/* 四列摘要分别承载识别结果、匹配状态、置信度和判断结果。 */
.task-log__analysis-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-top: 9px;
}
.task-log__analysis-field {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.task-log__analysis-field strong {
overflow: hidden;
color: #1f2937;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-log__analysis-summary {
margin-top: 9px;
color: #344054;
line-height: 20px;
}
.task-log__analysis-events {
margin-top: 9px;
}
.task-log__analysis-event {
margin-top: 3px;
color: #475467;
font-size: 12px;
line-height: 18px;
}
/* 原始扩展详情限制最大高度并允许内部滚动,长 JSON 不会无限撑高单条日志。 */
.task-log__analysis-details {
max-height: 180px;
margin: 9px 0 0;
overflow: auto;
padding: 8px;
background: #fff;
color: #475467;
font-size: 11px;
line-height: 16px;
white-space: pre-wrap;
word-break: break-word;
}
.task-log__output {
max-height: 180px;
margin: 8px 0 0;
overflow: auto;
padding: 8px 10px;
border-left: 3px solid #67c23a;
background: #f5f7fa;
color: #475467;
font-size: 12px;
line-height: 18px;
white-space: pre-wrap;
word-break: break-word;
}
.task-log__business-summary {
margin-top: 8px;
padding: 8px 10px;
border-left: 3px solid #67c23a;
background: #f3faf0;
color: #3f5f39;
line-height: 20px;
overflow-wrap: anywhere;
}
/* 媒体资源允许自动换行;每种播放器使用稳定尺寸,资源加载后不会造成明显布局跳动。 */
.task-log__media {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.task-log__image {
width: 96px;
height: 72px;
border-radius: 4px;
}
.task-log__video {
width: min(320px, 100%);
max-height: 220px;
background: #111;
border-radius: 4px;
}
.task-log__audio {
width: min(340px, 100%);
height: 40px;
}
/* 窄屏改为上下布局,并把分析摘要缩减为两列,保证字段名称和结果仍有可读宽度。 */
@media (max-width: 640px) {
.task-log__item {
grid-template-columns: 1fr;
gap: 3px;
}
.task-log__analysis-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

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() })
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,598 +0,0 @@
<!--
* 文件说明:通用节点参数面板,配置输入输出及机器人动作调试。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<RobotExecutionTarget
v-if="isRobotNode"
:data="props.data"
v-model="formData.executionTarget"
/>
<el-collapse v-model="activeNames">
<!-- 输入参数 -->
<el-collapse-item name="1" icon-position="left">
<template #title>
输入参数
<el-button :circle="true" size="small" @click="(e) => addFormItem(e, 'nodeParams')" class="addFormItem">
<el-icon :size="14">
<Plus />
</el-icon>
</el-button>
</template>
<div class="form__container">
<el-button type="primary" v-if="hasDeviceDebugAction" size="small" @click="openRobotForm(props.data.properties.action)">{{ robotActionButtonText }}</el-button>
<el-form :inline="true" :model="formData" :rules="rules" ref="dynamicFormRef" label-position="top"
label-width="auto">
<div v-for="(property, index) in formData.nodeParams" :key="index">
<el-row>
<el-form-item :label="index === 0 ? '参数名' : ''" :prop="`nodeParams.${index}.name`"
:rules="[{ required: true, message: '请输入参数名', trigger: 'blur' }]">
<el-input :disabled="property?.disabled || false" class="param-name"
v-model="property.name" placeholder="请输入" clearable />
</el-form-item>
<el-form-item :label="index === 0 ? '参数值' : ''" :prop="`nodeParams.${index}.type`">
<el-select v-model="property.type" class="param-type" :disabled="property?.forceSelect || false" @change="handleTypeChange(property)">
<el-option label="引用" value="quote" />
<el-option label="输入" value="input" />
</el-select>
<el-form-item v-if="property.type === 'input'"
:rules="testRule(property)"
:prop="`nodeParams.${index}.input`">
<el-input-number class="param-value" v-if="property.componentType === 'number'"
v-model="property.input" :min="property.min ?? 0" :max="property.max || Infinity"
:controls="property?.controls || true" :step-strictly="true" :step="property.step || 1" placeholder="请输入" clearable />
<el-select v-model="property.input" class="param-value"
v-else-if="property.componentType === 'select'">
<el-option v-for="item in property.selectOptions" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
<el-input class="param-value" v-else v-model="property.input" placeholder="请输入"
clearable />
</el-form-item>
<el-form-item v-if="property.type === 'quote'"
:rules="[{ required: true, message: '请选择参数值', trigger: 'blur' }]"
:prop="`nodeParams.${index}.quote`">
<el-cascader :ref="(el) => { if (el) cascaderRefs[index] = el }"
v-model="property.quote" :props="{ checkStrictly: true }" :options="quoteOptions"
placeholder="请选择"
@visible-change="(visible) => visibleChange(visible, index, property.quote)"
@change="(value) => cascaderChange(value, property)" />
</el-form-item>
</el-form-item>
<el-button :icon="Minus" circle size="small" :disabled="property?.disabled || false"
@click="handleDelete(index)" class="deleteBtn" />
</el-row>
</div>
</el-form>
</div>
</el-collapse-item>
<!-- 输出参数 -->
<el-collapse-item name="2" icon-position="left">
<template #title>
输出参数
<el-button :circle="true" size="small" @click="(e) => addFormItem(e, 'outputParams')"
class="addFormItem">
<el-icon :size="14">
<Plus />
</el-icon>
</el-button>
</template>
<div class="form__container">
<el-form :inline="true" :model="formData" label-position="top" label-width="auto" :rules="outputRules"
ref="outputFormRef">
<FormItemRecursive formType="output" :current-list="formData.outputParams" prop-path="outputParams"
:depth="0" :is-first-level="true" :endDepth="2" :parent-path="[]"
@delete-item="(path) => deleteTopLevelItem(path, 'outputParams')" />
</el-form>
</div>
</el-collapse-item>
</el-collapse>
<el-dialog
v-model="dialogVisible"
:title="robotDialogTitle"
width="460"
>
<div class="robot-device-tip">设备来自机器人最近一次在线上报,单个匹配设备会自动锁定。</div>
<el-form ref="robotFormRef" :model="robotForm" :rules="robotFormRules" label-width="90px">
<el-form-item label="机器人" prop="robotId">
<el-select v-model="robotForm.robotId" filterable placeholder="请选择在线机器人"
style="width: 100%" @change="loadRobotDevices">
<el-option v-for="item in robotOptions" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="执行设备" prop="deviceId">
<el-select v-model="robotForm.deviceId" filterable :loading="deviceLoading"
:disabled="deviceOptions.length === 1" placeholder="请先选择机器人" style="width: 100%">
<el-option v-for="item in deviceOptions" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
<div v-if="deviceOptions.length === 1" class="device-auto-hint">已自动匹配并锁定</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="stationListLoading" @click="submitRobotForm">确认</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, ref, reactive, watch, nextTick } from 'vue'
import { Plus, Minus } from '@element-plus/icons-vue'
import FormItemRecursive from '../FormItemRecursive.vue'
import RobotExecutionTarget from './RobotExecutionTarget.vue'
import { getInput } from '@/utils/flow'
import { useQuote } from './useQuote.js'
import { getArmStatus, getArmJointState, getAgvStatus, getAgvStations } from '@/api/flow/flow'
import { getRobotList, getRobotDevicesByRobotId } from '@/api/resource/robot'
import { ElMessage } from 'element-plus'
import { getDeviceKindForAction, toDeviceOptions } from '@/utils/robotDevice'
import { actionRequiresDevice } from '@/utils/flowResources'
const props = defineProps({
data: Object
})
const emit = defineEmits(['save-success', 'save-error'])
const formData = reactive({
nodeParams: [],
outputParams: [],
executionTarget: {}
})
const rules = reactive({})
const outputRules = reactive({})
const dynamicFormRef = ref()
const outputFormRef = ref()
const activeNames = ref(['1', '2'])
// 使用共享的 quote 逻辑
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
// 添加表单项
/**
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addFormItem = (e, type) => {
e.stopPropagation()
const obj = {
name: '',
type: type === 'nodeParams' ? 'input' : 'string',
required: false,
children: [],
input: ''
}
if (!formData[type]) formData[type] = []
formData[type].push(obj)
}
// 删除输入参数(非递归)
/**
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} index 目标项索引
*/
const handleDelete = (index) => {
formData.nodeParams.splice(index, 1)
}
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
/**
* 删除 deleteTopLevelItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} fullPath 字段完整路径
* @param {*} propPath 字段校验路径
*/
const deleteTopLevelItem = (fullPath, propPath) => {
let currentLevel = formData[propPath]
for (let i = 0; i < fullPath.length - 1; i++) {
currentLevel = currentLevel[fullPath[i]].children
}
const lastIndex = fullPath[fullPath.length - 1]
currentLevel.splice(lastIndex, 1)
}
// 初始化数据
/**
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const initData = () => {
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || []))
formData.nodeParams = nodeParams;
formData.outputParams = outputParams;
formData.executionTarget = JSON.parse(JSON.stringify(props.data.properties.executionTarget || {}));
}
watch(
() => props.data,
() => {
initData()
},
{ immediate: true, deep: true }
)
// 保存验证
/**
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const validateAndSave = async () => {
let inputValid = true
let outputValid = true
if (dynamicFormRef.value) {
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
}
if (outputFormRef.value) {
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
}
if (isRobotNode.value && !formData.executionTarget.roleKey) inputValid = false
if (isRobotNode.value
&& actionRequiresDevice(props.data.properties.action)
&& !formData.executionTarget.deviceSlotKey) {
inputValid = false
ElMessage.warning('请选择该节点使用的设备用途')
}
// 非机器人节点不应携带机器人执行目标,避免后端将空对象误判为无效角色引用。
const properties = {
...props.data.properties,
...formData
}
if (!isRobotNode.value) delete properties.executionTarget
lf.setProperties(props.data.id, properties)
if (inputValid && outputValid) {
emit('save-success')
} else {
emit('save-error')
}
}
const dialogVisible = ref(false)
const robotFormRef = ref(null)
const robotFormRules = reactive({
robotId: [{ required: true, message: '请选择机器人', trigger: 'change' }],
deviceId: [{ required: true, message: '设备ID不能为空', trigger: 'blur' }]
})
const robotForm = reactive({
deviceId: '',
robotId: ''
})
const robotOptions = ref([])
const deviceOptions = ref([])
const deviceLoading = ref(false)
/**
* 加载 loadRobotOptions 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const loadRobotOptions = async () => {
const response = await getRobotList({ pageNum: 1, pageSize: 1000, connectStatus: '1' })
robotOptions.value = (response.rows || []).map(robot => ({
value: robot.robotId,
label: `${robot.robotName || robot.robotId} (${robot.robotId})`
}))
}
/**
* 加载 loadRobotDevices 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} robotId 机器人 ID
*/
const loadRobotDevices = async (robotId) => {
robotForm.deviceId = ''
deviceOptions.value = []
if (!robotId) return
deviceLoading.value = true
try {
const response = await getRobotDevicesByRobotId(robotId, {
deviceKind: getDeviceKindForAction(robotFormAction.value)
})
const devices = Array.isArray(response.data) ? response.data : []
deviceOptions.value = toDeviceOptions(devices)
if (devices.length > 0) robotForm.deviceId = devices[0].deviceId
if (devices.length === 0) ElMessage.warning('当前机器人没有匹配的在线设备')
} finally {
deviceLoading.value = false
}
}
/**
* 测试 testRule 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} property 参数属性
*/
const testRule = (property) => {
if (property?.name === 'stationId' && props.data.properties.action === 'AGV_MOVE_TO_STATION') {
return [
{ required: true, message: '请从站点列表中选择站点', trigger: 'change' },
{ validator: () => {
const selectedFromList = property.selectOptions?.some(option => option.value === property.input)
if (selectedFromList) return Promise.resolve()
return Promise.reject(new Error('请重新加载并选择站点'))
}, trigger: 'change' }
]
}
if (property?.name === 'acceleration') {
return [
{ required: true, message: '请输入参数值', trigger: 'blur' },
{ validator: () => {
if (formData.nodeParams.find(param => param.name === 'acceleration')?.input > formData.nodeParams.find(param => param.name === 'velocity')?.input) {
return Promise.resolve()
} else {
return Promise.reject(new Error('加速度必须大于速度'))
}
}, trigger: 'change' }
]
}
if (property?.name === 'velocity') {
return [
{ required: true, message: '请输入参数值', trigger: 'blur' },
{ validator: () => {
if (formData.nodeParams.find(param => param.name === 'acceleration')?.input > formData.nodeParams.find(param => param.name === 'velocity')?.input) {
return Promise.resolve()
} else {
return Promise.reject(new Error('加速度必须大于速度'))
}
}, trigger: 'change' }
]
}
return [{ required: property?.required ?? true, message: '请输入参数值', trigger: 'blur' }]
}
const robotFormAction = ref()
const stationListLoading = ref(false)
const POSITION_ACTIONS = ['AGV_MOVE_TO_POINT', 'ARM_MOVE_TO_POINT', 'ARM_MOVE_TO_J']
const isRobotNode = computed(() => props.data.properties?.nodeType === 'EDGE')
const hasDeviceDebugAction = computed(() => (
POSITION_ACTIONS.includes(props.data.properties.action)
|| props.data.properties.action === 'AGV_MOVE_TO_STATION'
))
const robotActionButtonText = computed(() => {
if (props.data.properties.action === 'AGV_MOVE_TO_STATION') return '选择机器人并加载站点'
if (POSITION_ACTIONS.includes(props.data.properties.action)) return '选择机器人并获取位置'
return '选择机器人设备'
})
const robotDialogTitle = computed(() => {
if (robotFormAction.value === 'AGV_MOVE_TO_STATION') return '选择AGV并加载站点'
if (POSITION_ACTIONS.includes(robotFormAction.value)) return '选择机器人并读取当前位置'
return '选择机器人设备'
})
/**
* 处理 submitRobotForm 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const submitRobotForm = () => {
if (robotFormAction.value === 'AGV_MOVE_TO_POINT') {
robotFormRef.value.validate(async (valid) => {
if (valid) {
try {
const response = await getAgvStatus({ deviceId: robotForm.deviceId, robotId: robotForm.robotId })
if (response && response.data) {
dialogVisible.value = false
const { x, y, theta } = response.data.pose
const testMap = {
x: x,
y: y,
theta: theta
}
// 将获取到的位置信息设置到 formData 中
formData.nodeParams.forEach(param => {
if (testMap.hasOwnProperty(param.name)) {
param.input = testMap[param.name]
}
})
}
} catch (error) {
console.error('获取位置信息失败:', error)
}
}
})
} else if (robotFormAction.value === 'AGV_MOVE_TO_STATION') {
robotFormRef.value.validate(async (valid) => {
if (valid) {
stationListLoading.value = true
try {
const response = await getAgvStations({
deviceId: robotForm.deviceId,
robotId: robotForm.robotId
})
const stationOptions = toStationOptions(response?.data)
if (stationOptions.length === 0) {
throw new Error('AGV未返回可用站点')
}
const stationParam = formData.nodeParams.find(param => param.name === 'stationId')
if (stationParam) {
stationParam.selectOptions = stationOptions
if (!stationOptions.some(item => item.value === stationParam.input)) {
stationParam.input = ''
}
}
dialogVisible.value = false
ElMessage.success(`已加载${stationOptions.length}个AGV站点`)
} catch (error) {
console.error('加载AGV站点列表失败:', error)
ElMessage.error(error?.message || '加载AGV站点列表失败')
} finally {
stationListLoading.value = false
}
}
})
} else if (robotFormAction.value === 'ARM_MOVE_TO_POINT') {
robotFormRef.value.validate(async (valid) => {
if (valid) {
try {
const response = await getArmStatus({ deviceId: robotForm.deviceId, robotId: robotForm.robotId })
if (response && response.data) {
dialogVisible.value = false
const { x, y, z, rx, ry, rz } = response.data
const testMap = {
x: x,
y: y,
z: z,
rx: rx,
ry: ry,
rz: rz,
}
// 将获取到的位置信息设置到 formData 中
formData.nodeParams.forEach(param => {
if (testMap.hasOwnProperty(param.name)) {
param.input = testMap[param.name]
}
})
}
} catch (error) {
console.error('获取位置信息失败:', error)
}
}
})
} else if (robotFormAction.value === 'ARM_MOVE_TO_J') {
robotFormRef.value.validate(async (valid) => {
if (valid) {
try {
const response = await getArmJointState({
deviceId: robotForm.deviceId,
robotId: robotForm.robotId
})
if (response && response.data) {
const target = response.data.position || response.data.positions
if (!Array.isArray(target) || target.length === 0) {
throw new Error('机械臂未返回有效的关节位置')
}
dialogVisible.value = false
const targetParam = formData.nodeParams.find(param => param.name === 'target')
if (targetParam) targetParam.input = JSON.stringify(target)
}
} catch (error) {
console.error('获取机械臂关节位置失败:', error)
}
}
})
}
}
/**
* 处理 toStationOptions 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} stations 站点集合
*/
const toStationOptions = (stations) => (Array.isArray(stations) ? stations : [])
.filter(station => station?.id !== undefined && station?.id !== null && String(station.id).trim())
.map(station => {
const id = String(station.id).trim()
const description = station.description ? String(station.description).trim() : ''
return {
value: id,
label: description ? `${id} - ${description}` : id
}
})
/**
* 打开 openRobotForm 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} action 节点动作类型
*/
const openRobotForm = async (action) => {
dialogVisible.value = true
robotFormAction.value = action
robotForm.deviceId = ''
await loadRobotOptions()
if (robotForm.robotId) await loadRobotDevices(robotForm.robotId)
}
defineExpose({ validateAndSave })
</script>
<style lang="scss" scoped>
.form__container {
margin: 12px 0;
:deep(.el-row) {
align-items: end;
.el-form-item {
margin-right: 12px;
}
.param-name {
width: 160px;
}
.param-type {
width: 80px;
}
.param-value {
width: 160px;
}
}
.sub-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.gSon-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.deleteBtn {
margin-bottom: 22px;
cursor: pointer;
}
}
.robot-device-tip {
margin: -4px 0 18px;
padding: 10px 12px;
border-left: 3px solid var(--el-color-primary);
background: var(--el-fill-color-light);
color: var(--el-text-color-regular);
font-size: 13px;
line-height: 20px;
}
.device-auto-hint {
color: var(--el-color-success);
font-size: 12px;
}
</style>

View File

@ -1,501 +0,0 @@
<!--
* 文件说明:代码节点参数面板,编辑并校验脚本和输入输出字段。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<el-collapse v-model="activeNames">
<el-collapse v-model="activeNames">
<el-collapse-item name="1" icon-position="left">
<template #title>
输入参数
<el-tooltip
class="box-item"
effect="dark"
content="这里输入的变量可以被下方的代码引用。"
placement="top"
>
<el-icon class="header-icon" style="margin-left: 6px">
<info-filled />
</el-icon>
</el-tooltip>
</template>
<div class="form__container">
<el-form
:inline="true"
:model="formData"
:rules="rules"
ref="dynamicFormRef"
label-position="top"
label-width="auto"
>
<div v-for="(property, index) in formData.nodeParams" :key="index">
<el-row v-if="property.name !== 'code'">
<el-form-item
:label="index === 0 ? '参数名' : ''"
:prop="`nodeParams.${index}.name`"
:rules="[
{ required: true, message: '请输入参数名', trigger: 'blur' },
]"
>
<el-input
:disabled="property?.disabled || false"
class="param-name"
v-model="property.name"
placeholder="请输入"
clearable
/>
</el-form-item>
<el-form-item
:label="index === 0 ? '参数值' : ''"
:prop="`nodeParams.${index}.type`"
>
<el-select
v-model="property.type"
class="param-type"
@change="handleTypeChange(property)"
>
<el-option label="引用" value="quote" />
<el-option label="输入" value="input" />
</el-select>
<el-form-item
v-if="property.type === 'input'"
:rules="[
{
required: property?.required ?? true,
message: '请输入参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.input`"
>
<el-input-number
class="param-value"
v-if="property.componentType === 'number'"
v-model="property.input"
:min="0"
:max="property.max || Infinity"
:controls="false"
:step-strictly="true"
placeholder="请输入"
clearable
/>
<el-select
v-model="property.input"
class="param-value"
v-else-if="property.componentType === 'select'"
>
<el-option
v-for="item in property.selectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
class="param-value"
v-else
v-model="property.input"
placeholder="请输入"
clearable
/>
</el-form-item>
<el-form-item
v-if="property.type === 'quote'"
:rules="[
{
required: true,
message: '请选择参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.quote`"
>
<el-cascader
:ref="
(el) => {
if (el) cascaderRefs[index] = el;
}
"
v-model="property.quote"
:props="{ checkStrictly: true }"
:options="quoteOptions"
placeholder="请选择"
@visible-change="
(visible) => visibleChange(visible, index, property.quote)
"
@change="(value) => cascaderChange(value, property)"
/>
</el-form-item>
</el-form-item>
<el-button
:icon="Minus"
circle
size="small"
@click="handleDelete(index)"
:disabled="property?.disabled || false"
class="deleteBtn"
/>
</el-row>
</div>
</el-form>
</div>
</el-collapse-item>
<el-collapse-item title="代码" name="2" icon-position="left">
<div class="code__container">
<div ref="codeRef" style="width: 100%; height: 100%"></div>
</div>
</el-collapse-item>
<el-collapse-item name="3" icon-position="left">
<template #title>
输出参数
<el-tooltip
class="box-item"
effect="dark"
content="这里定义的输出变量可以被后续节点引用。"
placement="top"
>
<el-icon class="header-icon" style="margin-left: 6px">
<info-filled />
</el-icon>
</el-tooltip>
<el-button
:circle="true"
size="small"
@click="(e) => addFormItem(e, 'outputParams')"
class="addFormItem"
>
<el-icon :size="14"><Plus /></el-icon>
</el-button>
</template>
<div class="form__container">
<el-form
:inline="true"
:model="formData"
label-position="top"
label-width="auto"
:rules="outputRules"
ref="outputFormRef"
>
<FormItemRecursive
formType="output"
:current-list="formData.outputParams"
prop-path="outputParams"
:depth="0"
:is-first-level="true"
:endDepth="2"
:parent-path="[]"
@delete-item="(path) => deleteTopLevelItem(path, 'outputParams')"
/>
</el-form>
</div>
</el-collapse-item>
</el-collapse>
</el-collapse>
</template>
<script setup>
import { ref, reactive, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
import { Plus, Minus } from '@element-plus/icons-vue'
import FormItemRecursive from '../FormItemRecursive.vue'
import * as monaco from 'monaco-editor';
import { getInput } from '@/utils/flow'
import { useQuote } from './useQuote.js'
const props = defineProps({
data: Object
})
const emit = defineEmits(['save-success', 'save-error'])
const formData = reactive({
nodeParams: [],
outputParams: []
})
const codeRef = ref()
let editorInstance
/**
* 判断 hasErrors 对应的数据或交互;作用范围仅限当前组件或模块。
*/
function hasErrors() {
const model = editorInstance.getModel();
if (!model) return false;
// 获取当前模型的所有标记
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
// 检查是否存在错误级别标记
return markers.some(marker => marker.severity === monaco.MarkerSeverity.Error);
}
/**
* 初始化 initEditor 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} type 目标类型
*/
const initEditor = (type) => {
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2020,
allowNonTsExtensions: true, // 允许非 ts 扩展名(.js)
checkJs: true, // 对 .js 文件进行类型检查
strict: true, // 启用所有严格检查
noImplicitAny: false, // 禁止隐式 any
noUnusedLocals: true, // 未使用的局部变量报错(可选)
noUnusedParameters: true, // 未使用的参数报错(可选)
});
// 确保语义验证开启(默认就是开启的,但可以显式设置)
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false, // 开启语义检查
noSyntaxValidation: false, // 开启语法检查
});
editorInstance = monaco.editor.create(codeRef.value, {
value: ['{}'].join('\n'),
language: type === 'code' ? 'javascript' : 'json',
fontSize: 14,
lineNumbers: 'on',
roundedSelection: true,
scrollBeyondLastLine: false,
formatOnPaste: true,
formatOnType: true,
quickSuggestions: true,
suggestOnTriggerCharacters: true,
// 自动完成
suggest: {
showWords: true,
showFunctions: true,
showVariables: true,
showClasses: true,
showModules: true,
},
});
}
// 设置代码值
/**
* 设置 setValue 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} value 待处理的新值
*/
const setValue = (value) => {
if (editorInstance) {
editorInstance.setValue(value)
}
}
// 销毁编辑器
/**
* 销毁 disposeEditor 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const disposeEditor = () => {
if (editorInstance) {
editorInstance.dispose()
editorInstance = null
}
}
const rules = reactive({})
const outputRules = reactive({})
const dynamicFormRef = ref()
const outputFormRef = ref()
const activeNames = ref(['1', '2'])
// 使用共享的 quote 逻辑
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
// 添加表单项
/**
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addFormItem = (e, type) => {
e.stopPropagation()
const obj = {
name: '',
type: type === 'nodeParams' ? 'input' : 'string',
required: false,
children: [],
input: ''
}
if (!formData[type]) formData[type] = []
formData[type].push(obj)
}
// 删除输入参数(非递归)
/**
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} index 目标项索引
*/
const handleDelete = (index) => {
formData.nodeParams.splice(index, 1)
}
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
/**
* 删除 deleteTopLevelItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} fullPath 字段完整路径
* @param {*} propPath 字段校验路径
*/
const deleteTopLevelItem = (fullPath, propPath) => {
let currentLevel = formData[propPath]
for (let i = 0; i < fullPath.length - 1; i++) {
currentLevel = currentLevel[fullPath[i]].children
}
const lastIndex = fullPath[fullPath.length - 1]
currentLevel.splice(lastIndex, 1)
}
// 初始化数据
/**
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const initData = () => {
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || []))
formData.nodeParams = nodeParams;
formData.outputParams = outputParams;
const codeParams = props.data.properties.nodeParams.find(item => item.name === 'code')
if (codeParams) {
setTimeout(() => {
if (editorInstance) {
setValue(codeParams.input)
}
}, 100)
}
}
// 保存验证
/**
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const validateAndSave = async () => {
const targetObj = formData.nodeParams.find(item => item.name === 'code');
if (targetObj) {
// 存在:修改该对象的 input 属性
targetObj.input = editorInstance.getValue();
} else {
formData.nodeParams.push(
{ name: "code", type: "input", input: editorInstance.getValue() }
)
}
let inputValid = true
let outputValid = true
if (dynamicFormRef.value) {
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
}
if (outputFormRef.value) {
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
}
// 保存到 lf
lf.setProperties(props.data.id, {
...props.data.properties,
...formData
})
if (hasErrors()) {
emit('save-error')
return
}
if (inputValid && outputValid) {
emit('save-success')
} else {
emit('save-error')
}
}
onMounted(() => {
nextTick(() => {
initEditor('code')
initData()
})
})
defineExpose({ validateAndSave })
onBeforeUnmount(() => {
disposeEditor()
})
</script>
<style lang="scss" scoped>
.form__container {
margin: 12px 0;
:deep(.el-row) {
align-items: end;
.el-form-item {
margin-right: 12px;
}
.param-name {
width: 160px;
}
.param-type {
width: 80px;
}
.param-value {
width: 160px;
}
}
.sub-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.gSon-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.deleteBtn {
margin-bottom: 22px;
cursor: pointer;
}
}
.code__container {
display: flex;
position: relative;
text-align: initial;
width: 100%;
height: 300px;
margin: 12px 0;
}
</style>

View File

@ -1,521 +0,0 @@
<!--
* 文件说明:通用设备参数面板,选择设备并配置脚本和参数。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<RobotExecutionTarget :data="props.data" v-model="formData.executionTarget" />
<el-collapse v-model="activeNames">
<el-collapse v-model="activeNames">
<el-collapse-item name="1" icon-position="left">
<template #title>
输入参数
<el-tooltip
class="box-item"
effect="dark"
content="这里输入的变量可以被下方的代码引用。"
placement="top"
>
<el-icon class="header-icon" style="margin-left: 6px">
<info-filled />
</el-icon>
</el-tooltip>
</template>
<div class="form__container">
<el-form
:inline="true"
:model="formData"
:rules="rules"
ref="dynamicFormRef"
label-position="top"
label-width="auto"
>
<div v-for="(property, index) in formData.nodeParams" :key="index">
<el-row v-if="property.name !== 'requestJson'">
<el-form-item
:label="index === 0 ? '参数名' : ''"
:prop="`nodeParams.${index}.name`"
:rules="[
{ required: true, message: '请输入参数名', trigger: 'blur' },
]"
>
<el-input
:disabled="property?.disabled || false"
class="param-name"
v-model="property.name"
placeholder="请输入"
clearable
/>
</el-form-item>
<el-form-item
:label="index === 0 ? '参数值' : ''"
:prop="`nodeParams.${index}.type`"
>
<el-select
v-model="property.type"
class="param-type"
@change="handleTypeChange(property)"
>
<el-option label="引用" value="quote" />
<el-option label="输入" value="input" />
</el-select>
<el-form-item
v-if="property.type === 'input'"
:rules="[
{
required: property?.required ?? true,
message: '请输入参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.input`"
>
<el-input-number
class="param-value"
v-if="property.componentType === 'number'"
v-model="property.input"
:min="0"
:max="property.max || Infinity"
:controls="false"
:step-strictly="true"
placeholder="请输入"
clearable
/>
<el-select
v-model="property.input"
class="param-value"
v-else-if="property.componentType === 'select'"
:disabled="property.deviceLocked"
no-data-text="请先选择机器人设备"
>
<el-option
v-for="item in property.selectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
class="param-value"
v-else
v-model="property.input"
placeholder="请输入"
clearable
/>
</el-form-item>
<el-form-item
v-if="property.type === 'quote'"
:rules="[
{
required: true,
message: '请选择参数值',
trigger: 'blur',
},
]"
:prop="`nodeParams.${index}.quote`"
>
<el-cascader
:ref="
(el) => {
if (el) cascaderRefs[index] = el;
}
"
v-model="property.quote"
:props="{ checkStrictly: true }"
:options="quoteOptions"
placeholder="请选择"
@visible-change="
(visible) => visibleChange(visible, index, property.quote)
"
@change="(value) => cascaderChange(value, property)"
/>
</el-form-item>
</el-form-item>
<el-button
:icon="Minus"
circle
size="small"
@click="handleDelete(index)"
:disabled="property?.disabled || false"
class="deleteBtn"
/>
</el-row>
</div>
</el-form>
</div>
</el-collapse-item>
<el-collapse-item title="JSON" name="2" icon-position="left">
<div class="code__container">
<div ref="codeRef" style="width: 100%; height: 100%"></div>
</div>
</el-collapse-item>
<el-collapse-item name="3" icon-position="left">
<template #title>
输出参数
<el-tooltip
class="box-item"
effect="dark"
content="这里定义的输出变量可以被后续节点引用。"
placement="top"
>
<el-icon class="header-icon" style="margin-left: 6px">
<info-filled />
</el-icon>
</el-tooltip>
<el-button
:circle="true"
size="small"
@click="(e) => addFormItem(e, 'outputParams')"
class="addFormItem"
>
<el-icon :size="14"><Plus /></el-icon>
</el-button>
</template>
<div class="form__container">
<el-form
:inline="true"
:model="formData"
label-position="top"
label-width="auto"
:rules="outputRules"
ref="outputFormRef"
>
<FormItemRecursive
formType="output"
:current-list="formData.outputParams"
prop-path="outputParams"
:depth="0"
:is-first-level="true"
:endDepth="2"
:parent-path="[]"
@delete-item="(path) => deleteTopLevelItem(path, 'outputParams')"
/>
</el-form>
</div>
</el-collapse-item>
</el-collapse>
</el-collapse>
</template>
<script setup>
import { ref, reactive, watch, nextTick, onBeforeUnmount, onMounted } from 'vue'
import { Plus, Minus } from '@element-plus/icons-vue'
import FormItemRecursive from '../FormItemRecursive.vue'
import RobotExecutionTarget from './RobotExecutionTarget.vue'
import * as monaco from 'monaco-editor';
import { getInput } from '@/utils/flow'
import { useQuote } from './useQuote.js'
import { ElMessage } from 'element-plus'
const props = defineProps({
data: Object
})
const emit = defineEmits(['save-success', 'save-error'])
const formData = reactive({
nodeParams: [],
outputParams: [],
executionTarget: {}
})
const codeRef = ref()
let editorInstance
/**
* 判断 hasErrors 对应的数据或交互;作用范围仅限当前组件或模块。
*/
function hasErrors() {
const model = editorInstance.getModel();
if (!model) return false;
// 获取当前模型的所有标记
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
// 检查是否存在错误级别标记
return markers.some(marker => marker.severity === monaco.MarkerSeverity.Error);
}
/**
* 初始化 initEditor 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} type 目标类型
*/
const initEditor = (type) => {
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2020,
allowNonTsExtensions: true, // 允许非 ts 扩展名(.js)
checkJs: true, // 对 .js 文件进行类型检查
strict: true, // 启用所有严格检查
noImplicitAny: false, // 禁止隐式 any
noUnusedLocals: true, // 未使用的局部变量报错(可选)
noUnusedParameters: true, // 未使用的参数报错(可选)
});
// 确保语义验证开启(默认就是开启的,但可以显式设置)
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false, // 开启语义检查
noSyntaxValidation: false, // 开启语法检查
});
editorInstance = monaco.editor.create(codeRef.value, {
value: ['{}'].join('\n'),
language: type === 'code' ? 'javascript' : 'json',
fontSize: 14,
lineNumbers: 'on',
roundedSelection: true,
scrollBeyondLastLine: false,
formatOnPaste: true,
formatOnType: true,
quickSuggestions: true,
suggestOnTriggerCharacters: true,
// 自动完成
suggest: {
showWords: true,
showFunctions: true,
showVariables: true,
showClasses: true,
showModules: true,
},
});
}
// 设置代码值
/**
* 设置 setValue 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} value 待处理的新值
*/
const setValue = (value) => {
if (editorInstance) {
editorInstance.setValue(value)
}
}
// 销毁编辑器
/**
* 销毁 disposeEditor 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const disposeEditor = () => {
if (editorInstance) {
editorInstance.dispose()
editorInstance = null
}
}
const rules = reactive({})
const outputRules = reactive({})
const dynamicFormRef = ref()
const outputFormRef = ref()
const activeNames = ref(['1', '2'])
// 使用共享的 quote 逻辑
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
// 添加表单项
/**
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addFormItem = (e, type) => {
e.stopPropagation()
const obj = {
name: '',
type: type === 'nodeParams' ? 'input' : 'string',
required: false,
children: [],
input: ''
}
if (!formData[type]) formData[type] = []
formData[type].push(obj)
}
// 删除输入参数(非递归)
/**
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} index 目标项索引
*/
const handleDelete = (index) => {
formData.nodeParams.splice(index, 1)
}
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
/**
* 删除 deleteTopLevelItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} fullPath 字段完整路径
* @param {*} propPath 字段校验路径
*/
const deleteTopLevelItem = (fullPath, propPath) => {
let currentLevel = formData[propPath]
for (let i = 0; i < fullPath.length - 1; i++) {
currentLevel = currentLevel[fullPath[i]].children
}
const lastIndex = fullPath[fullPath.length - 1]
currentLevel.splice(lastIndex, 1)
}
// 初始化数据
/**
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const initData = () => {
const properties = props.data?.properties || {}
const nodeParams = JSON.parse(JSON.stringify(properties.nodeParams || []))
const outputParams = JSON.parse(JSON.stringify(properties.outputParams || []))
formData.nodeParams = nodeParams;
formData.outputParams = outputParams;
formData.executionTarget = JSON.parse(JSON.stringify(properties.executionTarget || {}));
const codeParams = nodeParams.find(item => item.name === 'requestJson')
if (codeParams) {
nextTick(() => {
if (editorInstance) {
setValue(codeParams.input)
}
})
}
}
watch(
() => props.data,
initData,
{ immediate: true, deep: true }
)
// 保存验证
/**
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const validateAndSave = async () => {
const targetObj = formData.nodeParams.find(item => item.name === 'requestJson');
if (targetObj) {
// 存在:修改该对象的 input 属性
targetObj.input = editorInstance.getValue();
} else {
formData.nodeParams.push(
{ name: "requestJson", type: "input", input: editorInstance.getValue() }
)
}
let inputValid = true
let outputValid = true
if (dynamicFormRef.value) {
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
}
if (outputFormRef.value) {
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
}
if (!formData.executionTarget.roleKey || !formData.executionTarget.deviceSlotKey) {
inputValid = false
ElMessage.warning('请选择执行角色和设备用途')
}
// 保存到 lf
lf.setProperties(props.data.id, {
...props.data.properties,
...formData
})
if (hasErrors()) {
emit('save-error')
return
}
if (inputValid && outputValid) {
emit('save-success')
} else {
emit('save-error')
}
}
onMounted(() => {
nextTick(() => {
initEditor('requestJson')
const codeParams = formData.nodeParams.find(item => item.name === 'requestJson')
if (codeParams) setValue(codeParams.input)
})
})
defineExpose({ validateAndSave })
onBeforeUnmount(() => {
disposeEditor()
})
</script>
<style lang="scss" scoped>
.form__container {
margin: 12px 0;
:deep(.el-row) {
align-items: end;
.el-form-item {
margin-right: 12px;
}
.param-name {
width: 160px;
}
.param-type {
width: 80px;
}
.param-value {
width: 160px;
}
}
.sub-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.gSon-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.deleteBtn {
margin-bottom: 22px;
cursor: pointer;
}
}
.code__container {
display: flex;
position: relative;
text-align: initial;
width: 100%;
height: 300px;
margin: 12px 0;
}
</style>

View File

@ -1,158 +0,0 @@
<!-- End node output contract and value references. -->
<template>
<div class="end-params">
<div class="section-heading">
<div>
<div class="section-title">流程输出</div>
<div class="section-note">输出值来自开始参数或前面节点的输出。</div>
</div>
<el-button type="primary" plain :icon="Plus" @click="addOutput">添加输出</el-button>
</div>
<el-empty v-if="!outputs.length" description="当前流程没有输出" :image-size="72" />
<div v-for="(item, index) in outputs" :key="item.key" class="output-row">
<el-form-item label="输出名称" required>
<el-input v-model="item.name" maxlength="40" placeholder="例如:result" />
</el-form-item>
<el-form-item label="引用来源" required>
<el-cascader
v-model="item.quote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择开始参数或节点输出"
style="width: 100%"
@visible-change="refreshQuoteOptions"
@change="(value) => changeQuote(item, value)"
/>
</el-form-item>
<div class="output-row__meta">
<el-tag effect="plain" type="info">{{ item.valueType || '自动识别' }}</el-tag>
<el-input v-model="item.desc" placeholder="输出说明(可选)" />
<el-button circle :icon="Delete" aria-label="删除输出" @click="outputs.splice(index, 1)" />
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Delete, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getUpstreamInput } from '@/utils/flow'
const props = defineProps({ data: Object })
const emit = defineEmits(['save-success', 'save-error'])
const outputs = ref([])
const quoteOptions = ref([])
const clone = (value) => JSON.parse(JSON.stringify(value ?? null))
const key = () => `output_${Date.now()}_${Math.random().toString(16).slice(2)}`
const refreshQuoteOptions = (visible = true) => {
if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || []
}
const findDefinition = (quote) => {
if (!Array.isArray(quote) || quote.length < 3) return null
const graph = globalThis.lf.getGraphData()
const source = graph.nodes.find((node) => node.id === quote[0])
let definitions = quote[1] === 'input'
? [...(source?.properties?.inputParams || []), ...(source?.properties?.nodeParams || [])]
: source?.properties?.outputParams || []
let definition = null
quote.slice(2).forEach((name) => {
definition = definitions.find((item) => item.name === name)
definitions = definition?.children || []
})
return definition
}
const changeQuote = (item, value) => {
item.quoteType = value?.[1] || ''
const definition = findDefinition(value)
if (!definition) return
item.valueType = definition.type || 'string'
item.children = clone(definition.children || [])
if (!item.name) item.name = definition.name
if (!item.desc) item.desc = definition.desc || ''
}
const addOutput = () => outputs.value.push({
key: key(),
name: '',
valueType: 'string',
quote: [],
quoteType: '',
desc: '',
children: [],
})
const init = () => {
const paramsByName = new Map((props.data.properties?.nodeParams || []).map((item) => [item.name, item]))
outputs.value = (props.data.properties?.outputParams || []).map((output) => {
const param = paramsByName.get(output.name) || {}
return {
key: key(),
name: output.name || '',
valueType: output.type || 'string',
quote: Array.isArray(param.quote) ? [...param.quote] : [],
quoteType: param.quoteType || param.quote?.[1] || '',
desc: output.desc || '',
children: clone(output.children || []),
}
})
refreshQuoteOptions()
}
const validateAndSave = () => {
const names = outputs.value.map((item) => item.name.trim())
if (names.some((name) => !name)) {
ElMessage.warning('请填写流程输出名称')
emit('save-error')
return
}
if (new Set(names).size !== names.length) {
ElMessage.warning('流程输出名称不能重复')
emit('save-error')
return
}
if (outputs.value.some((item) => !Array.isArray(item.quote) || item.quote.length < 3)) {
ElMessage.warning('每个流程输出都必须选择引用来源')
emit('save-error')
return
}
const nodeParams = outputs.value.map((item) => ({
name: item.name.trim(),
type: 'quote',
input: '',
quote: [...item.quote],
quoteType: item.quoteType || item.quote[1],
required: true,
}))
const outputParams = outputs.value.map((item) => ({
name: item.name.trim(),
type: item.valueType || 'string',
desc: item.desc || '',
children: clone(item.children || []),
}))
globalThis.lf.setProperties(props.data.id, {
...props.data.properties,
nodeParams,
outputParams,
outputType: 'json',
})
emit('save-success')
}
onMounted(init)
defineExpose({ validateAndSave })
</script>
<style scoped lang="scss">
.end-params { padding-top: 14px; }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.section-title { color: #1f2937; font-size: 14px; font-weight: 650; }
.section-note { margin-top: 4px; color: #7a8494; font-size: 12px; }
.output-row { margin-bottom: 12px; padding: 14px; border: 1px solid #e4e8ef; border-radius: 6px; background: #fff; }
.output-row__meta { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 10px; }
</style>

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 { getInput, transformHttpNodeData } from '@/utils/flow'
import { useQuote } from './useQuote.js'
const props = defineProps({
data: Object
})
const emit = defineEmits(['save-success', 'save-error'])
const formData = reactive({
nodeParams: [],
outputParams: []
})
const codeRef = ref()
let editorInstance
/**
* 判断 hasErrors 对应的数据或交互;作用范围仅限当前组件或模块。
*/
function hasErrors() {
const model = editorInstance.getModel();
if (!model) return false;
// 获取当前模型的所有标记
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
// 检查是否存在错误级别标记
return markers.some(marker => marker.severity === monaco.MarkerSeverity.Error);
}
/**
* 初始化 initEditor 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} type 目标类型
*/
const initEditor = (type) => {
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ES2020,
allowNonTsExtensions: true, // 允许非 ts 扩展名(.js)
checkJs: true, // 对 .js 文件进行类型检查
strict: true, // 启用所有严格检查
noImplicitAny: false, // 禁止隐式 any
noUnusedLocals: true, // 未使用的局部变量报错(可选)
noUnusedParameters: true, // 未使用的参数报错(可选)
});
// 确保语义验证开启(默认就是开启的,但可以显式设置)
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false, // 开启语义检查
noSyntaxValidation: false, // 开启语法检查
});
editorInstance = monaco.editor.create(codeRef.value, {
value: ['{}'].join('\n'),
language: 'json',
fontSize: 14,
lineNumbers: 'on',
roundedSelection: true,
scrollBeyondLastLine: false,
formatOnPaste: true,
formatOnType: true,
quickSuggestions: true,
suggestOnTriggerCharacters: true,
// 自动完成
suggest: {
showWords: true,
showFunctions: true,
showVariables: true,
showClasses: true,
showModules: true,
},
});
}
// 设置代码值
/**
* 设置 setValue 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} value 待处理的新值
*/
const setValue = (value) => {
if (editorInstance) {
editorInstance.setValue(value)
}
}
// 销毁编辑器
/**
* 销毁 disposeEditor 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const disposeEditor = () => {
if (editorInstance) {
editorInstance.dispose()
editorInstance = null
}
}
const rules = reactive({})
const dynamicFormRef = ref()
const activeNames = ref(['1', '2', '3', '4'])
// 使用共享的 quote 逻辑
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
// 添加表单项
/**
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addFormItem = (e, type) => {
e.stopPropagation()
const obj = {
name: '',
type: type === 'nodeParams' ? 'input' : 'string',
required: false,
children: [],
input: ''
}
if (!formData[type]) formData[type] = []
formData[type].push(obj)
}
// 删除输入参数(非递归)
/**
* 处理 handleDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} index 目标项索引
*/
const handleDelete = (index) => {
formData.nodeParams.splice(index, 1)
}
// 删除顶层输出参数(递归删除通过 FormItemRecursive 的 emit 处理)
/**
* 删除 deleteTopLevelItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} fullPath 字段完整路径
* @param {*} propPath 字段校验路径
*/
const deleteTopLevelItem = (fullPath, propPath) => {
let currentLevel = formData[propPath]
for (let i = 0; i < fullPath.length - 1; i++) {
currentLevel = currentLevel[fullPath[i]].children
}
const lastIndex = fullPath[fullPath.length - 1]
currentLevel.splice(lastIndex, 1)
}
// HTTP 节点数据
const httpNodeData = ref({
body: {
bodyType: 'json',
json: '{}',
formData: []
}
});
/**
* 新增 addHttpNodeFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addHttpNodeFormItem = (e, type) => {
e.stopPropagation();
if (type === 'body') {
if (!httpNodeData.value.body.formData) {
httpNodeData.value.body.formData = [];
}
httpNodeData.value.body.formData.push({ name: "", type: "input", input: ""});
} else {
if (!httpNodeData.value[type]) {
httpNodeData.value[type] = [];
}
httpNodeData.value[type].push({ name: "", type: "input", input: ""});
}
};
/**
* 处理 handleHttpNodeDelete 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} type 目标类型
* @param {*} index 目标项索引
*/
const handleHttpNodeDelete = (type, index) => {
if (type === 'body') {
httpNodeData.value.body.formData.splice(index, 1);
} else {
httpNodeData.value[type].splice(index, 1);
}
};
// 初始化数据
/**
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const initData = () => {
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
const transformedData = transformHttpNodeData(nodeParams);
httpNodeData.value = transformedData;
if (httpNodeData.value.body.type === 'json') {
nextTick(() => {
initEditor('http');
});
setTimeout(() => {
if (editorInstance) {
setValue(httpNodeData.value.body.json || '{}')
}
}, 200)
}
}
// 保存验证
/**
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const validateAndSave = async () => {
// 分区校验动态输入、请求头、查询参数和请求体;JSON 请求体还需通过编辑器语法检查。
// 全部分区有效后才一次性写回节点属性,防止半成品配置覆盖画布中的可用数据。
let inputValid = true
if (dynamicFormRef.value) {
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
}
const nodeParams = []
nodeParams.push({ name: "config", type: "input", input: "", children: httpNodeData.value.config, disabled: true, required: true })
if (httpNodeData.value?.headers?.length > 0) {
nodeParams.push({ name: "headers", type: "input", input: "", children: httpNodeData.value.headers, disabled: true, required: true })
}
if (httpNodeData.value?.params?.length > 0) {
nodeParams.push({ name: "params", type: "input", input: "", children: httpNodeData.value.params, disabled: true, required: true })
}
const bodyObj = {
name: "body", type: "input", input: "", children: [
{ name: 'bodyType', type: "input", input: httpNodeData.value.body.type, disabled: true },
{ name: 'json', type: "input", input: httpNodeData.value.body.type === 'json' ? editorInstance.getValue() : '' },
{ name: 'formData', type: "input", input: "", children: httpNodeData.value.body.formData }
]
}
nodeParams.push(bodyObj)
// 保存到 lf
lf.setProperties(props.data.id, {
...props.data.properties,
nodeParams
})
if (httpNodeData.value.body.type === 'json') {
if (hasErrors()) {
emit('save-error')
return
}
}
if (inputValid) {
emit('save-success')
} else {
emit('save-error')
}
}
onMounted(() => {
nextTick(() => {
initData()
})
})
defineExpose({ validateAndSave })
onBeforeUnmount(() => {
disposeEditor()
})
</script>
<style lang="scss" scoped>
.http-form {
width: 100%;
.el-collapse {
width: 100%;
}
}
.form__container {
margin: 12px 0;
:deep(.el-row) {
align-items: end;
.el-form-item {
margin-right: 12px;
}
.param-name {
width: 160px;
}
.param-type {
width: 80px;
}
.param-value {
width: 160px;
}
}
.sub-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.gSon-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.deleteBtn {
margin-bottom: 22px;
cursor: pointer;
}
}
.code__container {
display: flex;
position: relative;
text-align: initial;
width: 100%;
height: 300px;
margin: 12px 0;
}
</style>

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 { getInput, transformSdAgentNodeData } from '@/utils/flow'
import { useQuote } from './useQuote.js'
const props = defineProps({
data: Object
})
const emit = defineEmits(['save-success', 'save-error'])
const formData = reactive({
nodeParams: [],
outputParams: []
})
const rules = reactive({})
const outputRules = reactive({})
const dynamicFormRef = ref()
const outputFormRef = ref()
const activeNames = ref(['1', '2'])
// 使用共享的 quote 逻辑
const { quoteOptions, cascaderRefs, handleTypeChange, cascaderChange, visibleChange } = useQuote(props.data.id)
// 添加表单项
/**
* 新增 addFormItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
* @param {*} type 目标类型
*/
const addFormItem = (e, type) => {
e.stopPropagation()
const obj = {
name: '',
type: type === 'nodeParams' ? 'input' : 'string',
required: false,
children: [],
input: ''
}
if (!formData[type]) formData[type] = []
formData[type].push(obj)
}
// 商道智能体
const sdAgentNodeData = ref({})
/**
* 处理 voiceOptions 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const voiceOptions = () => {
return [{
value: 'x4_xiaoyan',
label: 'x4_xiaoyan'
}]
}
/**
* 处理 handleTtsChange 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} val 变更值
*/
const handleTtsChange = (val) => {
if (val) {
sdAgentNodeData.value.tts = []
if (!sdAgentNodeData.value.tts || sdAgentNodeData.value.tts.length === 0) {
sdAgentNodeData.value.tts = [
{ name: 'url', type: "input", input: "", disabled: true },
{ name: 'text', type: "input", input: "", disabled: true },
{ name: 'speed', type: "input", input: "", max: 100, disabled: true },
{ name: 'voice', type: "input", input: "", componentType: 'select', selectOptions: voiceOptions(), disabled: true },
{ name: 'volume', type: "input", input: "", componentType: 'number', max: 100, disabled: true }
]
}
}
}
/**
* 新增 addSdAgentItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} e 触发操作的浏览器事件
*/
const addSdAgentItem = (e) => {
e.stopPropagation();
sdAgentNodeData.value.outputParams.push({ name: "", type: "string", input: "" });
}
/**
* 删除 deleteSdAgentItem 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} fullPath 字段完整路径
* @param {*} propPath 字段校验路径
*/
const deleteSdAgentItem = (fullPath, propPath) => {
// 从顶层数据开始查找
let currentLevel = sdAgentNodeData.value[propPath];
// 遍历路径(除最后一个索引,因为最后一个是要删除的项)
for (let i = 0; i < fullPath.length - 1; i++) {
const index = fullPath[i];
// 进入下一层级
currentLevel = currentLevel[index].children;
}
// 最后一个索引是当前层级要删除的项
const lastIndex = fullPath[fullPath.length - 1];
currentLevel.splice(lastIndex, 1);
}
// 初始化数据
/**
* 初始化 initData 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const initData = () => {
const nodeParams = JSON.parse(JSON.stringify(props.data.properties.nodeParams || []))
const outputParams = JSON.parse(JSON.stringify(props.data.properties.outputParams || []))
const transformedData = transformSdAgentNodeData(nodeParams);
sdAgentNodeData.value = {
...transformedData,
outputParams
};
}
watch(
() => props.data,
() => {
initData()
},
{ immediate: true, deep: true }
)
// 保存验证
/**
* 校验 validateAndSave 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const validateAndSave = async () => {
let inputValid = true
let outputValid = true
if (dynamicFormRef.value) {
await dynamicFormRef.value.validate((valid) => { if (!valid) inputValid = false })
}
if (outputFormRef.value) {
await outputFormRef.value.validate((valid) => { if (!valid) outputValid = false })
}
const nodeParams = []
nodeParams.push({ name: "config", type: "input", input: "", children: sdAgentNodeData.value.config, required: true })
nodeParams.push({ name: "invokeTts", type: "input", input: sdAgentNodeData.value.invokeTts })
if (sdAgentNodeData.value.invokeTts) {
nodeParams.push({ name: "tts", type: "input", input: "", children: sdAgentNodeData.value.tts })
}
lf.setProperties(props.data.id, {
...props.data.properties,
nodeParams,
outputParams: sdAgentNodeData.value.outputParams
});
if (inputValid && outputValid) {
emit('save-success')
} else {
emit('save-error')
}
}
defineExpose({ validateAndSave })
</script>
<style lang="scss" scoped>
.http-form {
width: 100%;
.el-collapse {
width: 100%;
}
}
.form__container {
margin: 12px 0;
:deep(.el-row) {
align-items: end;
.el-form-item {
margin-right: 12px;
}
.param-name {
width: 160px;
}
.param-type {
width: 80px;
}
.param-value {
width: 160px;
}
}
.sub-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.gSon-properties {
margin-left: 10px;
.zw {
margin-left: 15px;
&::before {
content: "";
position: absolute;
left: 0;
top: -4px;
width: 10px;
border-left: 1px solid gray;
border-bottom: 1px solid gray;
border-bottom-left-radius: 4px;
background-color: transparent;
height: 24px;
}
}
}
.deleteBtn {
margin-bottom: 22px;
cursor: pointer;
}
}
</style>

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,291 +0,0 @@
<!-- Published sub-flow selector and synchronized contract editor. -->
<template>
<div class="subflow-params">
<el-form label-position="top">
<el-form-item label="已发布工作流" required>
<el-select
v-model="selectedVersionId"
filterable
:loading="loading"
placeholder="选择同一项目的已发布工作流"
style="width: 100%"
@change="selectVersion"
>
<el-option
v-for="item in options"
:key="item.versionId"
:label="`${item.detectName} · V${item.versionNo}`"
:value="item.versionId"
/>
</el-select>
</el-form-item>
</el-form>
<el-alert
v-if="latestOption && selection.versionId && latestOption.versionId !== selection.versionId"
type="warning"
:closable="false"
show-icon
class="version-alert"
>
<template #title>
已发布 V{{ latestOption.versionNo }},当前节点仍使用 V{{ selection.versionNo }}
</template>
<el-button size="small" type="warning" plain @click="applyOption(latestOption)">同步最新发布版本</el-button>
</el-alert>
<el-alert
v-else-if="optionsLoaded && selection.versionId && !selectedOptionAvailable"
title="当前子流程已不可用或会形成循环引用,请重新选择"
type="error"
:closable="false"
show-icon
class="version-alert"
/>
<template v-if="selection.versionId">
<div class="contract-header">
<div>
<div class="contract-title">{{ selection.detectName }}</div>
<div class="contract-meta">{{ projectLabel }} · 发布版本 V{{ selection.versionNo }}</div>
</div>
</div>
<div class="contract-section">
<div class="contract-section__title">子流程输入</div>
<div class="contract-section__note">所有输入必须引用当前流程的开始参数或前面节点输出。</div>
<el-empty v-if="!inputs.length" description="子流程没有输入参数" :image-size="64" />
<div v-for="item in inputs" :key="item.name" class="contract-row">
<div class="contract-row__name">
<span>{{ item.name }}</span>
<el-tag size="small" effect="plain" type="info">{{ item.dataType }}</el-tag>
</div>
<el-cascader
v-model="item.quote"
:options="quoteOptions"
:props="{ checkStrictly: true }"
placeholder="选择引用来源"
style="width: 100%"
@visible-change="refreshQuoteOptions"
@change="(value) => { item.quoteType = value?.[1] || '' }"
/>
</div>
</div>
<div class="contract-section">
<div class="contract-section__title">子流程输出</div>
<el-empty v-if="!outputs.length" description="子流程没有输出参数" :image-size="64" />
<div v-for="item in outputs" :key="item.name" class="output-contract">
<span>{{ item.name }}</span>
<el-tag size="small" effect="plain" type="info">{{ item.type }}</el-tag>
<span class="output-contract__desc">{{ item.desc || '无说明' }}</span>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { flowSubFlowOptions } from '@/api/flow/flow'
import { getUpstreamInput } from '@/utils/flow'
import {
FLOW_NODE_RESOURCE_OWNER,
pruneUnusedNodeResources,
} from '@/utils/flowResources'
const props = defineProps({ data: Object, flowId: String })
const emit = defineEmits(['save-success', 'save-error'])
const loading = ref(false)
const optionsLoaded = ref(false)
const options = ref([])
const selectedVersionId = ref('')
const inputs = ref([])
const outputs = ref([])
const quoteOptions = ref([])
const selection = reactive({ itemId: '', detectName: '', projectCode: '', versionId: '', versionNo: null, resourceRoles: [] })
const clone = (value) => JSON.parse(JSON.stringify(value ?? null))
const latestOption = computed(() => options.value.find((item) => item.itemId === selection.itemId))
const selectedOptionAvailable = computed(() => options.value.some((item) => item.versionId === selection.versionId))
const projectLabel = computed(() => selection.projectCode === 'AIMA' ? '爱玛' : '长安')
const refreshQuoteOptions = (visible = true) => {
if (visible) quoteOptions.value = getUpstreamInput(props.data.id) || []
}
const restore = () => {
const stored = props.data.properties?.subFlow || {}
Object.assign(selection, {
itemId: stored.itemId || '',
detectName: stored.detectName || '',
projectCode: stored.projectCode || '',
versionId: stored.versionId || '',
versionNo: stored.versionNo ?? null,
resourceRoles: clone(stored.resourceRoles || []),
})
selectedVersionId.value = selection.versionId
const outputByName = new Map((props.data.properties?.outputParams || []).map((item) => [item.name, item]))
inputs.value = (props.data.properties?.nodeParams || []).map((item) => ({
name: item.name,
dataType: item.dataType || 'string',
quote: Array.isArray(item.quote) ? [...item.quote] : [],
quoteType: item.quoteType || item.quote?.[1] || '',
required: true,
}))
outputs.value = [...outputByName.values()].map(clone)
refreshQuoteOptions()
}
const applyOption = (option) => {
if (!option) return
const existing = new Map(inputs.value.map((item) => [item.name, item]))
Object.assign(selection, {
itemId: option.itemId,
detectName: option.detectName,
projectCode: option.projectCode,
versionId: option.versionId,
versionNo: option.versionNo,
resourceRoles: clone(option.resourceRoles || []),
})
selectedVersionId.value = option.versionId
inputs.value = (option.inputParams || []).map((item) => ({
name: item.name,
dataType: item.type || 'string',
quote: Array.isArray(existing.get(item.name)?.quote) ? [...existing.get(item.name).quote] : [],
quoteType: existing.get(item.name)?.quoteType || '',
required: true,
}))
outputs.value = clone(option.outputParams || [])
}
const selectVersion = (versionId) => applyOption(options.value.find((item) => item.versionId === versionId))
const loadOptions = async () => {
if (!props.flowId) return
loading.value = true
try {
const response = await flowSubFlowOptions(props.flowId)
options.value = response.data || []
if (!selection.versionId && options.value.length === 1) applyOption(options.value[0])
} finally {
loading.value = false
optionsLoaded.value = true
}
}
const mergeResourceRoles = () => {
const graph = globalThis.lf.getGraphData()
const start = graph.nodes.find((node) => node.type === 'start')
if (!start) throw new Error('当前流程缺少开始节点')
const roles = clone(start.properties?.resourceRoles || [])
for (const childRole of selection.resourceRoles || []) {
let parentRole = roles.find((role) => role.roleKey === childRole.roleKey)
if (!parentRole) {
const addedRole = clone(childRole)
addedRole.managedBy = FLOW_NODE_RESOURCE_OWNER
;(addedRole.deviceSlots || []).forEach((slot) => { slot.managedBy = FLOW_NODE_RESOURCE_OWNER })
roles.push(addedRole)
continue
}
parentRole.required = parentRole.required !== false || childRole.required !== false
parentRole.quantity ||= { min: 1, max: 1 }
parentRole.quantity.min = Math.max(Number(parentRole.quantity.min || 0), Number(childRole.quantity?.min || 0))
parentRole.quantity.max = Math.max(Number(parentRole.quantity.max || 0), Number(childRole.quantity?.max || 0))
parentRole.deviceSlots ||= []
for (const childSlot of childRole.deviceSlots || []) {
const parentSlot = parentRole.deviceSlots.find((slot) => slot.slotKey === childSlot.slotKey)
if (!parentSlot) {
parentRole.deviceSlots.push({
...clone(childSlot),
managedBy: FLOW_NODE_RESOURCE_OWNER,
})
} else if (Number(parentSlot.deviceKind) !== Number(childSlot.deviceKind)) {
throw new Error(`机器人角色“${childRole.displayName}”的设备用途“${childSlot.displayName}”类型冲突`)
} else {
parentSlot.required = parentSlot.required !== false || childSlot.required !== false
parentSlot.allowReuse = parentSlot.allowReuse === true && childSlot.allowReuse === true
}
}
}
return { graph, start, roles }
}
const validateAndSave = () => {
if (!selection.versionId) {
ElMessage.warning('请选择已发布工作流')
emit('save-error')
return
}
if (optionsLoaded.value && !selectedOptionAvailable.value) {
ElMessage.warning('当前子流程已不可用或会形成循环引用,请重新选择')
emit('save-error')
return
}
if (inputs.value.some((item) => !Array.isArray(item.quote) || item.quote.length < 3)) {
ElMessage.warning('子流程的所有输入参数都必须选择引用来源')
emit('save-error')
return
}
let resources
try {
resources = mergeResourceRoles()
} catch (error) {
ElMessage.error(error.message)
emit('save-error')
return
}
const nodeParams = inputs.value.map((item) => ({
name: item.name,
type: 'quote',
input: '',
quote: [...item.quote],
quoteType: item.quoteType || item.quote[1],
dataType: item.dataType,
required: true,
disabled: true,
}))
const nodeProperties = {
...props.data.properties,
subFlow: clone(selection),
nodeParams,
outputParams: clone(outputs.value),
outputType: 'json',
}
const prospectiveGraph = clone(resources.graph)
const prospectiveStart = prospectiveGraph.nodes.find((node) => node.id === resources.start.id)
const prospectiveNode = prospectiveGraph.nodes.find((node) => node.id === props.data.id)
prospectiveStart.properties = { ...prospectiveStart.properties, resourceRoles: resources.roles }
prospectiveNode.properties = nodeProperties
const cleanup = pruneUnusedNodeResources(prospectiveGraph)
globalThis.lf.setProperties(resources.start.id, {
...resources.start.properties,
resourceRoles: cleanup.start.properties.resourceRoles,
})
globalThis.lf.setProperties(props.data.id, nodeProperties)
emit('save-success')
}
onMounted(async () => {
restore()
await loadOptions()
})
defineExpose({ validateAndSave })
</script>
<style scoped lang="scss">
.subflow-params { padding-top: 14px; }
.version-alert { margin-bottom: 16px; }
.version-alert :deep(.el-alert__content) { width: 100%; }
.version-alert .el-button { margin-top: 8px; }
.contract-header { margin-bottom: 16px; padding: 14px; border: 1px solid #dce5ef; border-radius: 6px; background: #f8fafc; }
.contract-title { color: #1f2937; font-size: 15px; font-weight: 650; }
.contract-meta { margin-top: 4px; color: #697386; font-size: 12px; }
.contract-section { margin-top: 18px; }
.contract-section__title { color: #344054; font-size: 13px; font-weight: 650; }
.contract-section__note { margin: 4px 0 10px; color: #7a8494; font-size: 12px; }
.contract-row { margin-top: 10px; padding: 12px; border: 1px solid #e4e8ef; border-radius: 6px; }
.contract-row__name { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; color: #344054; font-size: 13px; font-weight: 600; }
.output-contract { display: grid; grid-template-columns: minmax(100px, auto) auto minmax(0, 1fr); align-items: center; gap: 10px; min-height: 38px; border-bottom: 1px solid #edf0f4; color: #344054; font-size: 13px; }
.output-contract__desc { overflow: hidden; color: #7a8494; text-overflow: ellipsis; white-space: nowrap; }
</style>

View File

@ -1,413 +0,0 @@
<!--
* 文件说明:分支节点专用参数组件,独立负责条件组的新增、删除、引用选择、校验和保存。
* 作用范围:仅用于 branch 类型节点,不与通用节点参数表单共享业务逻辑。
-->
<template>
<div class="switch-params">
<div class="switch-params__header">
<div>
<div class="switch-params__title">分支条件</div>
<div class="switch-params__description">条件按从上到下的顺序匹配,每个条件对应节点上的一个输出锚点。</div>
</div>
<el-button type="primary" :icon="Plus" @click="addCondition">添加条件</el-button>
</div>
<el-form ref="formRef" :model="formData" label-position="top">
<div v-for="(condition, conditionIndex) in formData.nodeParams" :key="condition.id" class="condition-card">
<div class="condition-card__header">
<div class="condition-card__identity">
<span>{{ conditionIndex === 0 ? "If" : "Else If" }}</span>
<el-input
v-model="condition.label"
maxlength="20"
show-word-limit
placeholder="请输入条件名称"
class="condition-name"
/>
</div>
<el-button
circle
plain
type="danger"
:icon="Minus"
:disabled="formData.nodeParams.length === 1"
@click="removeCondition(conditionIndex)"
/>
</div>
<el-form-item label="条件关系">
<el-radio-group v-model="condition.withOr">
<el-radio-button value="and">AND</el-radio-button>
<el-radio-button value="or">OR</el-radio-button>
</el-radio-group>
</el-form-item>
<div v-for="(item, itemIndex) in condition.list" :key="itemIndex" class="condition-row">
<div class="condition-row__fields">
<el-form-item
label="引用变量"
:prop="`nodeParams.${conditionIndex}.list.${itemIndex}.name${item.nameType === 'quote' ? 'Quote' : ''}`"
:rules="requiredRule(item.nameType === 'quote' ? '请选择引用变量' : '请输入变量值')"
>
<el-input v-if="item.nameType === 'input'" v-model="item.name" placeholder="请输入变量值">
<template #prepend>
<el-select v-model="item.nameType" class="value-type" @change="resetNameValue(item)">
<el-option label="输入" value="input" />
<el-option label="引用" value="quote" />
</el-select>
</template>
</el-input>
<div v-else class="reference-field">
<el-select v-model="item.nameType" class="value-type" @change="resetNameValue(item)">
<el-option label="输入" value="input" />
<el-option label="引用" value="quote" />
</el-select>
<el-cascader
v-model="item.nameQuote"
:options="quoteOptions"
:show-all-levels="false"
placeholder="请选择"
@visible-change="(visible) => refreshQuoteOptions(visible, item, 'nameQuote')"
/>
</div>
</el-form-item>
<el-form-item
label="判断方式"
:prop="`nodeParams.${conditionIndex}.list.${itemIndex}.condition`"
:rules="requiredRule('请选择判断方式')"
>
<el-select v-model="item.condition">
<el-option v-for="option in conditionOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item
label="比较值"
:prop="`nodeParams.${conditionIndex}.list.${itemIndex}.${item.type === 'quote' ? 'quote' : 'input'}`"
:rules="requiresComparison(item.condition) ? requiredRule(item.type === 'quote' ? '请选择比较变量' : '请输入比较值') : []"
>
<el-input
v-if="item.type === 'input'"
v-model="item.input"
:disabled="!requiresComparison(item.condition)"
placeholder="请输入比较值"
>
<template #prepend>
<el-select v-model="item.type" class="value-type" @change="resetCompareValue(item)">
<el-option label="输入" value="input" />
<el-option label="引用" value="quote" />
</el-select>
</template>
</el-input>
<el-input v-else model-value="" readonly :disabled="!requiresComparison(item.condition)" placeholder="请选择比较变量">
<template #prepend>
<el-select v-model="item.type" class="value-type" @change="resetCompareValue(item)">
<el-option label="输入" value="input" />
<el-option label="引用" value="quote" />
</el-select>
</template>
<template #append>
<el-cascader
v-model="item.quote"
:disabled="!requiresComparison(item.condition)"
:options="quoteOptions"
:show-all-levels="false"
@visible-change="(visible) => refreshQuoteOptions(visible, item, 'quote')"
/>
</template>
</el-input>
</el-form-item>
</div>
<div class="condition-row__actions">
<el-button circle :icon="Plus" @click="addConditionItem(conditionIndex)" />
<el-button
circle
:icon="Minus"
:disabled="condition.list.length === 1"
@click="removeConditionItem(conditionIndex, itemIndex)"
/>
</div>
</div>
</div>
</el-form>
<div class="else-tip"><strong>Else</strong><span>所有条件均不匹配时,从 Else 锚点继续执行。</span></div>
</div>
</template>
<script setup>
import { reactive, ref, watch } from "vue";
import { Plus, Minus } from "@element-plus/icons-vue";
import { v4 as randomUUID } from "uuid";
import { getUpstreamInput, removeSwitchEdge } from "@/utils/flow";
const props = defineProps({ data: Object });
const emit = defineEmits(["save-success", "save-error"]);
const formRef = ref(null);
const quoteOptions = ref([]);
const originalConditionIds = ref([]);
const formData = reactive({ nodeParams: [] });
const conditionOptions = [
{ label: "等于", value: "equal" },
{ label: "不等于", value: "notEqualTo" },
{ label: "为空", value: "null" },
{ label: "不为空", value: "notNull" },
{ label: "包含", value: "include" },
{ label: "不包含", value: "notInclude" },
{ label: "大于", value: "greaterThan" },
{ label: "大于等于", value: "greaterThanOrEqual" },
{ label: "小于", value: "lessThan" },
{ label: "小于等于", value: "lessThanOrEqual" },
];
/** 创建一条与原有数据结构兼容的判断项。 */
const createConditionItem = () => ({
name: "",
nameQuote: "",
nameType: "input",
condition: "",
type: "input",
input: "",
quote: "",
});
/** 创建具有稳定锚点 ID 的条件组。 */
const createCondition = (label = "") => ({
id: randomUUID().replace(/-/g, ""),
label,
withOr: "and",
list: [createConditionItem()],
});
/**
* 将旧版本可能保存的点号字符串统一转换为级联组件需要的路径数组。
* @param {string|string[]} value 已保存的引用值
* @returns {string[]} 级联选择路径
*/
const normalizeQuotePath = (value) => {
if (Array.isArray(value)) return value;
if (typeof value === "string" && value) return value.split(".").filter(Boolean);
return [];
};
/** 深拷贝已保存条件,确保取消编辑时不会污染节点属性。 */
const initData = () => {
// 必须先提供选项树再赋已有值,否则级联组件无法将节点 ID 路径解析成可见标签。
quoteOptions.value = getUpstreamInput(props.data.id) || [];
const source = props.data?.properties?.nodeParams || props.data?.properties?.conditions || [];
formData.nodeParams = JSON.parse(JSON.stringify(source));
if (!formData.nodeParams.length) formData.nodeParams.push(createCondition());
formData.nodeParams.forEach((condition, index) => {
if (!condition.label) condition.label = `条件 ${index + 1}`;
condition.list?.forEach((item) => {
if (item.nameType === "quote") item.nameQuote = normalizeQuotePath(item.nameQuote);
if (item.type === "quote") item.quote = normalizeQuotePath(item.quote);
});
});
originalConditionIds.value = formData.nodeParams.map((condition) => condition.id);
};
/** 返回 Element Plus 表单必填规则。 */
const requiredRule = (message) => [{ required: true, message, trigger: ["blur", "change"] }];
/** 判断当前运算符是否需要比较值。 */
const requiresComparison = (condition) => !["null", "notNull"].includes(condition);
/** 新增一个分支条件,保存后将生成同 ID 的节点锚点。 */
const addCondition = () => {
formData.nodeParams.push(createCondition(`条件 ${formData.nodeParams.length + 1}`));
};
/** 删除条件;实际连线清理延迟到保存成功时执行。 */
const removeCondition = (index) => {
if (formData.nodeParams.length > 1) formData.nodeParams.splice(index, 1);
};
/** 向指定条件组追加判断项。 */
const addConditionItem = (conditionIndex) => {
formData.nodeParams[conditionIndex].list.push(createConditionItem());
};
/** 删除指定判断项,同时保证每个条件至少保留一项。 */
const removeConditionItem = (conditionIndex, itemIndex) => {
const list = formData.nodeParams[conditionIndex].list;
if (list.length > 1) list.splice(itemIndex, 1);
};
/** 切换左值类型时清空另一种类型遗留的数据。 */
const resetNameValue = (item) => {
if (item.nameType === "input") item.nameQuote = "";
else item.name = "";
};
/** 切换比较值类型时清空另一种类型遗留的数据。 */
const resetCompareValue = (item) => {
if (item.type === "input") item.quote = "";
else item.input = "";
};
/**
* 打开引用选择器时获取当前节点可引用的上游输出,并保留当前选值。
* @param {boolean} visible 选择器是否打开
* @param {object} item 当前判断项
* @param {"nameQuote"|"quote"} field 当前引用字段
*/
const refreshQuoteOptions = (visible, item, field) => {
if (!visible) return;
quoteOptions.value = getUpstreamInput(props.data.id) || [];
item[field] = item[field] || "";
};
/**
* 校验并保存条件。先清理已删除条件的连线,再写入条件和锚点集合。
* 新条件锚点的位置由 switch.vue 渲染摘要后自动测量并补齐。
*/
const validateAndSave = async () => {
const valid = await formRef.value?.validate().then(() => true).catch(() => false);
if (!valid) return;
try {
const conditions = JSON.parse(JSON.stringify(formData.nodeParams));
const activeIds = new Set(conditions.map((condition) => condition.id));
const removedIds = originalConditionIds.value.filter((id) => !activeIds.has(id));
removedIds.forEach((id) => removeSwitchEdge(props.data.id, id));
const properties = window.lf.getProperties(props.data.id);
const anchor = (properties.anchor || []).filter((item) => (
item.id === `${props.data.id}_else` || activeIds.has(item.id)
));
window.lf.setProperties(props.data.id, {
...properties,
nodeParams: conditions,
anchor,
});
emit("save-success");
} catch {
emit("save-error");
}
};
watch(() => props.data, initData, { immediate: true, deep: true });
defineExpose({ validateAndSave });
</script>
<style lang="scss" scoped>
.switch-params {
padding-top: 16px;
}
.switch-params__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.switch-params__title {
color: #1f2937;
font-size: 16px;
font-weight: 650;
}
.switch-params__description {
margin-top: 4px;
color: #7a8494;
font-size: 12px;
line-height: 1.6;
}
.condition-card {
margin-bottom: 14px;
padding: 14px;
border: 1px solid #e4e8ef;
border-radius: 8px;
background: #f8fafc;
}
.condition-card__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
color: #3978c5;
font-size: 13px;
font-weight: 650;
}
.condition-card__identity {
display: flex;
align-items: center;
gap: 10px;
}
.condition-name {
width: 240px;
}
.condition-row {
display: flex;
align-items: flex-end;
gap: 8px;
padding-top: 10px;
border-top: 1px dashed #dfe4ec;
}
.condition-row + .condition-row {
margin-top: 10px;
}
.condition-row__fields {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(120px, 0.8fr) minmax(0, 1.4fr);
flex: 1;
gap: 10px;
}
.condition-row__actions {
display: flex;
padding-bottom: 18px;
}
.value-type {
width: 74px;
}
.reference-field {
display: grid;
grid-template-columns: 74px minmax(0, 1fr);
width: 100%;
gap: 8px;
}
.else-tip {
display: flex;
gap: 12px;
padding: 12px 14px;
border: 1px solid #f0d9a7;
border-radius: 8px;
background: #fffaf0;
color: #7a8494;
font-size: 12px;
strong {
color: #b7791f;
}
}
:deep(.el-form-item) {
margin-bottom: 18px;
}
:deep(.el-input-group__append) {
width: 120px;
padding: 0;
}
:deep(.el-input-group__append .el-cascader) {
width: 100%;
}
</style>

View File

@ -1,43 +0,0 @@
import { ref } from 'vue'
import { getUpstreamInput } from '@/utils/flow'
export function useQuote(nodeId) {
const quoteOptions = ref([])
const cascaderRefs = ref([])
const refreshQuoteOptions = () => {
quoteOptions.value = getUpstreamInput(nodeId) || []
}
// The option tree must exist before Cascader can render a persisted path.
refreshQuoteOptions()
const handleTypeChange = (property) => {
if (!property) return
if (property.type === 'input') {
property.quote = []
} else {
property.input = ''
refreshQuoteOptions()
}
}
const cascaderChange = (value, property) => {
if (!property) return
property.quote = Array.isArray(value) ? [...value] : []
property.quoteType = value?.[1] === 'input' ? 'input' : 'output'
}
const visibleChange = (visible) => {
if (visible) refreshQuoteOptions()
}
return {
quoteOptions,
cascaderRefs,
handleTypeChange,
cascaderChange,
visibleChange,
refreshQuoteOptions,
}
}

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

@ -15,19 +15,6 @@ import dialogueSvg from './icon/dialogue.svg'
import audioSvg from './icon/audio.svg'
import touchSvg from './icon/touch.svg'
import expressionSvg from './icon/expression.svg'
import { DEFAULT_CHAT_SYSTEM_PROMPT } from './chatPrompt.js'
const pointOutputChildren = () => [
{ name: 'x', type: 'number', desc: 'X坐标' },
{ name: 'y', type: 'number', desc: 'Y坐标' },
{ name: 'z', type: 'number', desc: 'Z坐标' },
]
const poseOutputChildren = () => [
...pointOutputChildren(),
{ name: 'rx', type: 'number', desc: 'RX旋转(弧度)' },
{ name: 'ry', type: 'number', desc: 'RY旋转(弧度)' },
{ name: 'rz', type: 'number', desc: 'RZ旋转(弧度)' },
]
/**
* 处理与 http method options 相关的状态或数据,该影响范围仅限当前模块。
@ -321,46 +308,6 @@ function handler(params) {
{
collapseTitle: "机器人",
nodeList: [
{
icon: cameraSvg,
name: "视觉定位执行",
type: "visionLocate",
desc: "同帧获取RGBD图像,分析目标并计算工具TCP和机械臂法兰位姿",
action: 'VISION_LOCATE_TARGET',
nodeType: 'EDGE',
nodeParams: [
{ name: "cameraDeviceId", type: "input", input: "", required: true },
{ name: "armDeviceId", type: "input", input: "", required: true },
{ name: "toolCode", type: "input", input: "", required: true },
{ name: "profileCode", type: "input", input: "common.image_analysis.v1", required: true },
{ name: "prompt", type: "input", input: "定位目标中心点", required: true },
{ name: "analysisMode", type: "input", input: "ACCURATE" },
{ name: "mountType", type: "input", input: "EYE_IN_HAND", componentType: "select", selectOptions: [
{ value: "EYE_IN_HAND", label: "末端相机" }, { value: "EYE_TO_HAND", label: "固定相机" }
] },
{ name: "approachDistanceMm", type: "input", componentType: "number", input: 10, min: 1, max: 20 },
{ name: "pressDistanceMm", type: "input", componentType: "number", input: 0, min: 0, max: 20, step: 0.1 },
{ name: "normalSampleRadius", type: "input", componentType: "number", input: 12, min: 3, max: 40 },
{ name: "confidenceThreshold", type: "input", componentType: "number", input: 0.7, min: 0, max: 1, step: 0.01 },
{ name: "minDepth", type: "input", componentType: "number", input: 0.05, min: 0.01, step: 0.05 },
{ name: "maxDepth", type: "input", componentType: "number", input: 5, min: 0.1, step: 0.1 },
{ name: "maxMovement", type: "input", componentType: "number", input: 0.5, min: 0.01, step: 0.01 },
{ name: "autoMove", type: "input", componentType: "switch", input: false, disabled: true }
],
outputType: 'json',
outputParams: [
{ name: 'objectPoint', type: 'object', children: pointOutputChildren() },
{ name: 'surfaceNormal', type: 'object', children: pointOutputChildren() },
{ name: 'approachTcpPose', type: 'object', children: poseOutputChildren() },
{ name: 'contactTcpPose', type: 'object', children: poseOutputChildren() },
{ name: 'approachFlangePose', type: 'object', children: poseOutputChildren() },
{ name: 'contactFlangePose', type: 'object', children: poseOutputChildren() },
{ name: 'pixel', type: 'object', children: [] },
{ name: 'depth', type: 'number' },
{ name: 'depthUnitScale', type: 'number' },
{ name: 'intrinsics', type: 'object', children: [] }
]
},
{
icon: videoSvg,
name: "AGV控制",
@ -947,25 +894,4 @@ function handler(params) {
}
],
},
{
collapseTitle: "闲聊",
nodeList: [{
icon: dialogueSvg,
name: "闲聊",
type: "chat",
desc: "输入文本并调用大模型进行自然语言闲聊,只返回文本",
action: 'LLM_CHAT',
nodeType: "LLM",
outputType: 'json',
nodeParams: [
{ name: "text", type: "input", input: "", required: true },
{ name: "promptMode", type: "input", input: "CUSTOM", componentType: "select", selectOptions: [
{ value: "FIXED", label: "固定提示词(推荐)" }, { value: "CUSTOM", label: "自定义提示词" }
] },
{ name: "prompt", type: "input", input: DEFAULT_CHAT_SYSTEM_PROMPT, required: true },
{ name: "invokeTts", type: "input", input: false, componentType: "switch" },
],
outputParams: [{ name: 'text', type: 'string', desc: '大模型返回的闲聊文本', disabled: true }]
}]
},
]

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>

View File

@ -1,169 +0,0 @@
<!--
* 文件说明:通用服务节点并响应运行状态。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<div class="node__container" :class="props.model.id">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
<template #input>
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template>
<template #output>
<div v-if="nodeOperatingStatus != 'FAILED'">
<JsonViewer
v-if="!['img', 'video', 'audio'].includes(props.properties.outputType)"
:value="outputJsonData"
copyable
boxed
sort
theme="light"
/>
<el-image
v-if="props.properties.outputType === 'img'"
v-for="item in outputJsonData.imageUrl"
style="width: 60px; height: 60px"
:src="item"
:preview-src-list="outputJsonData.imageUrl"
:preview-teleported="true"
show-progress
fit="fill"
/>
<IPlayer
v-if="props.properties.outputType === 'video'"
v-for="item in outputJsonData.videoUrl"
:videoUrl="item"
/>
<audio
v-for="item in audioUrls"
:key="item"
class="node-audio-player"
:src="item"
controls
preload="metadata"
/>
</div>
<div v-else>{{ errorInfoData }}</div>
</template>
</NodeState>
<NodeTitle
:icon="props.properties.icon"
:nodeId="props.model.id"
:nodeProperties="props.properties"
:nodeType="props.properties.nodeType || 'NONE'"
:nodeName="props.properties.name"
:nodeDesc="props.properties.desc"
@setNodeName="setNodeName"
/>
<!-- @zoom="zoom" -->
</div>
</template>
<script setup>
import { computed, ref, onMounted } from "vue";
import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css";
import { useFlowEvents } from "../../composables/useFlowEvents";
import IPlayer from "@/components/IPlayer/index.vue";
const { onFlowEvent } = useFlowEvents();
const props = defineProps({
model: Object,
properties: Object,
});
const emits = defineEmits(["contentChange"]);
/**
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} name 名称或事件名称
*/
const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, {
...properties,
name
});
emits("contentChange");
}
const nodeOperatingStatus = ref("NORMAL");
const runtimes = ref(0);
const inputJsonData = ref({});
const outputJsonData = ref({});
const errorInfoData = ref('');
const audioUrls = computed(() => {
const value = outputJsonData.value?.audioUrl;
if (Array.isArray(value)) return value.filter(Boolean);
if (typeof value === "string" && value) return [value];
return [];
});
const nodeStateRef = ref(null);
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => {
if (nodeStateRef.value) {
nodeStateRef.value.closePopover();
}
}
onMounted(() => {
onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) {
runtimes.value = data.endTime - data.startTime;
}
let inputData = {};
let output = {};
errorInfoData.value = data?.message || ''
try {
inputData = JSON.parse(data.paramsIn) || {};
output = JSON.parse(data.paramsOut) || {};
} catch (error) {
inputData = data.paramsIn || {};
output = data.paramsOut || {};
}
inputJsonData.value = inputData;
outputJsonData.value = output;
emits("contentChange");
}
});
onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {};
outputJsonData.value = {};
emits("contentChange");
}
});
onFlowEvent("setProperties", (data) => {
if (data.id === props.model.id) {
emits("contentChange");
}
});
});
defineExpose({ closePopover })
</script>
<style lang="scss" scoped>
.node__container {
width: 100%;
height: auto;
}
.node-audio-player {
width: min(320px, 100%);
height: 36px;
}
</style>

View File

@ -1,101 +0,0 @@
<!--
* 文件说明:流程入口节点并响应运行状态。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<div class="node__container" :class="props.model.id">
<keep-alive>
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
<template #input>
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template>
<!-- <template #output>
<JsonViewer :value="outputJsonData" copyable boxed sort theme="light" />
</template> -->
</NodeState>
</keep-alive>
<div class="terminal-node">
<div class="terminal-node__icon"><img src="../../icon/start.svg" alt="" /></div>
<div class="terminal-node__content">
<div class="terminal-node__title">流程开始</div>
<div class="terminal-node__description">接收流程输入并启动工作流</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import NodeState from "../../components/NodeState.vue";
import "vue3-json-viewer/dist/index.css";
import { useFlowEvents } from "../../composables/useFlowEvents";
const { onFlowEvent } = useFlowEvents();
const props = defineProps({
model: Object,
properties: Object,
});
const emits = defineEmits(["contentChange"]);
const nodeOperatingStatus = ref("NORMAL");
const runtimes = ref(0);
const inputJsonData = ref({});
const outputJsonData = ref({});
const nodeStateRef = ref(null);
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => {
if (nodeStateRef.value) {
nodeStateRef.value.closePopover();
}
}
onMounted(() => {
onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) {
runtimes.value = data.endTime - data.startTime;
}
let inputData = {};
let output = {};
try {
inputData = JSON.parse(data.paramsIn) || {};
output = JSON.parse(data.paramsOut) || {};
} catch (error) {
inputData = data.paramsIn || {};
output = data.paramsOut || {};
}
inputJsonData.value = inputData;
outputJsonData.value = output;
emits("contentChange");
}
});
onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {};
outputJsonData.value = {};
emits("contentChange");
}
});
onFlowEvent("setProperties", (data) => {
if (data.id === props.model.id) {
emits("contentChange");
}
});
});
defineExpose({ closePopover })
</script>

View File

@ -1,25 +0,0 @@
<template>
<div class="action-queue-node">
<ServiceNode ref="serviceNodeRef" v-bind="props" @content-change="$emit('contentChange')" />
<div class="action-queue-node__summary">{{ stepCount }} 个步骤</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import ServiceNode from '../common/serviceNode.vue'
const props = defineProps({ model: Object, properties: Object })
defineEmits(['contentChange'])
const serviceNodeRef = ref()
const stepCount = computed(() => {
const steps = props.properties?.nodeParams?.find((item) => item.name === 'steps')?.input
return Array.isArray(steps) ? steps.length : 0
})
const closePopover = () => serviceNodeRef.value?.closePopover?.()
defineExpose({ closePopover })
</script>
<style scoped>
.action-queue-node__summary { margin-top: 7px; color: #667085; font-size: 11px; line-height: 16px; }
</style>

View File

@ -1,146 +0,0 @@
<!--
* 文件说明:代码执行节点并展示运行状态。
* 作用范围:仅服务于流程设计器模块。
-->
<template>
<div class="node__container" :class="props.model.id">
<NodeState :state="nodeOperatingStatus" :runtimes="runtimes" ref="nodeStateRef">
<template #input>
<JsonViewer :value="inputJsonData" copyable boxed sort theme="light" />
</template>
<template #output>
<div v-if="nodeOperatingStatus != 'FAILED'">
<JsonViewer
v-if="!['img', 'video'].includes(props.properties.outputType)"
:value="outputJsonData"
copyable
boxed
sort
theme="light"
/>
<el-image
v-if="props.properties.outputType === 'img'"
v-for="item in outputJsonData.imageUrl"
style="width: 60px; height: 60px"
:src="item"
:preview-src-list="outputJsonData.imageUrl"
:preview-teleported="true"
show-progress
fit="fill"
/>
<IPlayer
v-if="props.properties.outputType === 'video'"
v-for="item in outputJsonData.videoUrl"
:videoUrl="item"
/>
</div>
<div v-else>{{ errorInfoData }}</div>
</template>
</NodeState>
<NodeTitle
:icon="props.properties.icon"
:nodeId="props.model.id"
:nodeProperties="props.properties"
:nodeType="props.properties.nodeType || 'NONE'"
:nodeName="props.properties.name"
:nodeDesc="props.properties.desc"
@setNodeName="setNodeName"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import NodeTitle from "../../components/NodeTitle.vue";
import NodeState from "../../components/NodeState.vue";
import { useFlowEvents } from "../../composables/useFlowEvents";
import IPlayer from "@/components/IPlayer/index.vue";
const { onFlowEvent } = useFlowEvents();
const props = defineProps({
model: Object,
properties: Object,
});
const emits = defineEmits(["contentChange"]);
/**
* 设置 setNodeName 对应的数据或交互;作用范围仅限当前组件或模块。
* @param {*} name 名称或事件名称
*/
const setNodeName = (name) => {
const properties = lf.getProperties(props.model.id);
lf.setProperties(props.model.id, {
...properties,
name
});
emits("contentChange");
}
const nodeOperatingStatus = ref("NORMAL");
const runtimes = ref(0);
const inputJsonData = ref({});
const outputJsonData = ref({});
const errorInfoData = ref('');
const nodeStateRef = ref(null);
/**
* 关闭 closePopover 对应的数据或交互;作用范围仅限当前组件或模块。
*/
const closePopover = () => {
if (nodeStateRef.value) {
nodeStateRef.value.closePopover();
}
}
onMounted(() => {
onFlowEvent("changeNodeState", (data) => {
if (data.nodeId === props.model.id) {
nodeOperatingStatus.value = data.status;
if (data.endTime && data.startTime) {
runtimes.value = data.endTime - data.startTime;
}
let inputData = {};
let output = {};
errorInfoData.value = data?.message || ''
try {
inputData = JSON.parse(data.paramsIn) || {};
output = JSON.parse(data.paramsOut) || {};
} catch (error) {
inputData = data.paramsIn || {};
output = data.paramsOut || {};
}
inputJsonData.value = inputData;
outputJsonData.value = output;
emits("contentChange");
}
});
onFlowEvent("contentChange", (data) => {
if (data.id === props.model.id) {
nodeOperatingStatus.value = "NORMAL";
inputJsonData.value = {};
outputJsonData.value = {};
emits("contentChange");
}
});
onFlowEvent("setProperties", (data) => {
if (data.id === props.model.id) {
emits("contentChange");
}
});
})
defineExpose({ closePopover })
</script>
<style lang="scss" scoped>
.node__container {
width: 100%;
height: auto;
}
</style>

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