Compare commits

..

No commits in common. "cd0e173914c35f60e9b8041de8069cca642349c6" and "bc0f9a9eced19105d0755d8b474b69ee4bb7afd7" have entirely different histories.

53 changed files with 3042 additions and 680 deletions

View File

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

45
src/api/device/profile.js Normal file
View File

@ -0,0 +1,45 @@
import request from '@/utils/request'
// 查询设备配置信息列表
export function listProfile(query) {
return request({
url: '/device/profile/list',
method: 'get',
params: query
})
}
// 查询设备配置信息详细
export function getProfile(query) {
return request({
url: '/device/profile/parameterInfo/',
method: 'get',
params: query
})
}
// 新增设备配置信息
export function addProfile(data) {
return request({
url: '/device/profile',
method: 'post',
data: data
})
}
// 修改设备配置信息
export function updateProfile(data) {
return request({
url: '/device/profile',
method: 'put',
data: data
})
}
// 删除设备配置信息
export function delProfile(id) {
return request({
url: '/device/profile/' + id,
method: 'delete'
})
}

146
src/api/device/register.js Normal file
View File

@ -0,0 +1,146 @@
import request from '@/utils/request'
// 查询设备注册列表
export function listRegister(query) {
return request({
url: '/device/register/list',
method: 'get',
params: query
})
}
// 查询设备注册详细
export function getRegister(id) {
return request({
url: '/device/register/' + id,
method: 'get'
})
}
// 新增设备注册
export function addRegister(data) {
return request({
url: '/device/register',
method: 'post',
data: data
})
}
// 修改设备注册
export function updateRegister(data) {
return request({
url: '/device/register',
method: 'put',
data: data
})
}
// 删除设备注册
export function delRegister(id) {
return request({
url: '/device/register/' + id,
method: 'delete'
})
}
// 设备示教
export function deviceTeach(id) {
return request({
url: '/device/profile/' + id,
method: 'get',
})
}
// 设备示教参数修改
export function deviceUpdateProfile(data) {
return request({
url: '/device/profile/updateProfile',
method: 'put',
data: data,
})
}
// 新增音色
export function createTimbre(data) {
return request({
url: '/show/tts/create',
method: 'post',
headers: {
'Content-Type': 'multipart/form-data',
Authorization: 'eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImFiNmEyY2Y0LWM3MjQtNDgyMy1hM2NiLTk4N2NhZWNmMzZiNCJ9.oq-JVRbQuq_ujQ2V0h2jXC3woHPdMQKhnYJ8ixRm-C6Ez4GqJWyHYgmnkw5dbDyhWGdIUH02uv6XT8UiboHD4g',
isToken: false
},
data: data,
})
}
// 获取音色
export function getTts() {
return request({
url: '/show/tts/query',
headers: {
Authorization: 'eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImFiNmEyY2Y0LWM3MjQtNDgyMy1hM2NiLTk4N2NhZWNmMzZiNCJ9.oq-JVRbQuq_ujQ2V0h2jXC3woHPdMQKhnYJ8ixRm-C6Ez4GqJWyHYgmnkw5dbDyhWGdIUH02uv6XT8UiboHD4g',
isToken: false
},
method: 'get',
})
}
// 生产语料
export function createCorpus(data) {
return request({
url: '/show/tts/corpus',
headers: {
Authorization: 'eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImFiNmEyY2Y0LWM3MjQtNDgyMy1hM2NiLTk4N2NhZWNmMzZiNCJ9.oq-JVRbQuq_ujQ2V0h2jXC3woHPdMQKhnYJ8ixRm-C6Ez4GqJWyHYgmnkw5dbDyhWGdIUH02uv6XT8UiboHD4g',
isToken: false
},
method: 'post',
data: data
})
}
export function getSynthesize(data, config = {}) {
return request({
url: '/show/tts/synthesize',
headers: {
Authorization: 'eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImFiNmEyY2Y0LWM3MjQtNDgyMy1hM2NiLTk4N2NhZWNmMzZiNCJ9.oq-JVRbQuq_ujQ2V0h2jXC3woHPdMQKhnYJ8ixRm-C6Ez4GqJWyHYgmnkw5dbDyhWGdIUH02uv6XT8UiboHD4g',
isToken: false
},
responseType: 'blob',
method: 'post',
data: data
}, {
...config
})
}
export function createAdvancedCorpus(data) {
return request({
url: '/show/tts/corpus/advanced',
headers: {
Authorization: 'eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImFiNmEyY2Y0LWM3MjQtNDgyMy1hM2NiLTk4N2NhZWNmMzZiNCJ9.oq-JVRbQuq_ujQ2V0h2jXC3woHPdMQKhnYJ8ixRm-C6Ez4GqJWyHYgmnkw5dbDyhWGdIUH02uv6XT8UiboHD4g',
isToken: false
},
method: 'post',
data: data
})
}
export function getAdvancedSynthesize(data, config = {}) {
return request({
url: '/show/tts/synthesize/advanced',
headers: {
Authorization: 'eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImFiNmEyY2Y0LWM3MjQtNDgyMy1hM2NiLTk4N2NhZWNmMzZiNCJ9.oq-JVRbQuq_ujQ2V0h2jXC3woHPdMQKhnYJ8ixRm-C6Ez4GqJWyHYgmnkw5dbDyhWGdIUH02uv6XT8UiboHD4g',
isToken: false
},
responseType: 'blob',
method: 'post',
data: data
}, {
...config
})
}
export const getFlowById = (id) => {
return request.get(import.meta.env.VITE_NODE_RED_URL + '/flow/' + id, {})
}

44
src/api/device/robot.js Normal file
View File

@ -0,0 +1,44 @@
import request from '@/utils/request'
// 查询机器人配置列表
export function listRobot(query) {
return request({
url: '/device/robot/list',
method: 'get',
params: query
})
}
// 查询机器人配置详细
export function getRobot(id) {
return request({
url: '/device/robot/' + id,
method: 'get'
})
}
// 新增机器人配置
export function addRobot(data) {
return request({
url: '/device/robot',
method: 'post',
data: data
})
}
// 修改机器人配置
export function updateRobot(data) {
return request({
url: '/device/robot',
method: 'put',
data: data
})
}
// 删除机器人配置
export function delRobot(id) {
return request({
url: '/device/robot/' + id,
method: 'delete'
})
}

View File

@ -0,0 +1,86 @@
import request from '@/utils/request'
// 查询设备终端配置列表
export function listTerminal(query) {
return request({
url: '/device/terminal/list',
method: 'get',
params: query
})
}
// 查询设备终端配置详细
export function getTerminal(id) {
return request({
url: '/device/terminal/' + id,
method: 'get'
})
}
// 新增设备终端配置
export function addTerminal(data) {
return request({
url: '/device/terminal',
method: 'post',
data: data
})
}
// 修改设备终端配置
export function updateTerminal(data) {
return request({
url: '/device/terminal',
method: 'put',
data: data
})
}
// 删除设备终端配置
export function delTerminal(id) {
return request({
url: '/device/terminal/' + id,
method: 'delete'
})
}
// 获取实例信息
export function getInstInfo(id) {
return request({
url: '/test/inst/context/' + id,
method: 'get'
})
}
// 暂停任务
export function pauseInst(id) {
return request({
url: `/test/inst/pause/${id}`,
method: 'post'
})
}
// 恢复任务
export function resumeInst(id) {
return request({
url: `/test/inst/resume/${id}`,
method: 'post'
})
}
// 恢复任务
export function stopInst(id) {
return request({
url: `/test/inst/stop/${id}`,
method: 'post'
})
}
export function getFlowParamsApi(taskId) {
return request({
url: '/test/orchestration',
method: 'get',
params: {
taskId
}
})
}

View File

@ -1,5 +0,0 @@
import request from '@/utils/request'
export function getNodeRedFlowById(id) {
return request.get(`${import.meta.env.VITE_NODE_RED_URL}/flow/${id}`, {})
}

33
src/api/inspection/map.js Normal file
View File

@ -0,0 +1,33 @@
import request from '@/utils/request'
export function getMapList(params) {
return request({
url: '/inspection/map/list',
method: 'get',
params: params
})
}
export function addMap(data) {
return request({
url: '/inspection/map',
method: 'post',
data: data
})
}
export function updateMap(data) {
return request({
url: '/inspection/map',
method: 'put',
data: data
})
}
export function deleteMap(id) {
return request({
url: `/inspection/map/${id}`,
method: 'delete'
})
}

126
src/api/inspection/robot.js Normal file
View File

@ -0,0 +1,126 @@
import request from '@/utils/request'
export function getRobotList(params) {
return request({
url: '/inspection/robot/list',
method: 'get',
params: params
})
}
export function addRobot(data) {
return request({
url: '/inspection/robot',
method: 'post',
data: data
})
}
export function updateRobot(data) {
return request({
url: '/inspection/robot',
method: 'put',
data: data
})
}
export function deleteRobot(id) {
return request({
url: `/inspection/robot/${id}`,
method: 'delete'
})
}
export function getRobotDevices(id) {
return request({
url: `/inspection/robot/${id}/devices`,
method: 'get'
})
}
export function getRobotDevicesByRobotId(robotId, params = {}) {
return request({
url: '/inspection/robot/devices',
method: 'get',
params: {
robotId,
onlineOnly: true,
...params
}
})
}
/**
* 获取机器人地图
* @param {*} robotId
* @returns
*/
export function getMapByRobot(robotId) {
return request({
url: `/inspection/robot/${robotId}/maps`,
method: 'get'
})
}
/**
* 获取地图JSON数据
* @param {*} params
* @returns
*/
export function getMapJson(params) {
return request({
url: `/inspection/robot/${params.robotId}/download-map`,
method: 'get',
params
})
}
/**
* 获取机器人位置
* @param {*} robotId
* @returns
*/
export function getRobotLocation(robotId) {
return request({
url: `/inspection/robot/${robotId}/location`,
method: 'get'
})
}
/**
* 获取机器人状态
* @param {*} robotId
* @returns
*/
export function getRobotStatus(id) {
return request({
url: `/inspection/robot/${id}/sync-status`,
method: 'post'
})
}
/**
* 机器人运动控制
* @param {*} data
* @returns
*/
export function moveRobot(data) {
return request({
url: '/api/agv/setVelocity',
method: 'post',
data: data
})
}
/**
* 机器人停止运动
* @param {*} data
* @returns
*/
export function stopRobot(data) {
return request({
url: '/api/agv/stopVelocityControl',
method: 'post',
data: data
})
}

View File

@ -1,13 +0,0 @@
import request from '@/utils/request'
export function listResourceDevices(params) {
return request({ url: '/resource/devices', method: 'get', params })
}
export function getResourceDevice(id) {
return request({ url: `/resource/devices/${encodeURIComponent(id)}`, method: 'get' })
}
export function updateResourceDevice(id, data) {
return request({ url: `/resource/devices/${encodeURIComponent(id)}`, method: 'put', data })
}

View File

@ -1,24 +0,0 @@
import request from '@/utils/request'
export function listResourceMaps(params) {
return request({ url: '/resource/maps', method: 'get', params })
}
export function listRobotMaps(robotId) {
return request({
url: `/resource/maps/robot/${encodeURIComponent(robotId)}`,
method: 'get',
})
}
export function addResourceMap(data) {
return request({ url: '/resource/maps', method: 'post', data })
}
export function updateResourceMap(data) {
return request({ url: '/resource/maps', method: 'put', data })
}
export function deleteResourceMap(ids) {
return request({ url: `/resource/maps/${ids}`, method: 'delete' })
}

View File

@ -1,77 +0,0 @@
import request from '@/utils/request'
export function listResourceRobots(params) {
return request({ url: '/resource/robots', method: 'get', params })
}
export function getResourceRobot(robotId) {
return request({ url: `/resource/robots/${encodeURIComponent(robotId)}`, method: 'get' })
}
export function listRobotDevices(robotId, params = {}) {
return request({
url: `/resource/robots/${encodeURIComponent(robotId)}/devices`,
method: 'get',
params,
})
}
export const getRobotList = listResourceRobots
export function addRobot(data) {
return request({ url: '/resource/robots', method: 'post', data })
}
export function updateRobot(data) {
return request({ url: '/resource/robots', method: 'put', data })
}
export function deleteRobot(id) {
return request({ url: `/resource/robots/${id}`, method: 'delete' })
}
export function getRobotDevices(id) {
return request({ url: `/resource/robots/id/${id}/devices`, method: 'get' })
}
export function getRobotDevicesByRobotId(robotId, params = {}) {
return request({
url: `/resource/robots/${encodeURIComponent(robotId)}/devices`,
method: 'get',
params: { onlineOnly: true, ...params },
})
}
export function getMapByRobot(robotRef) {
return request({
url: `/resource/robots/${encodeURIComponent(robotRef)}/remote-maps`,
method: 'get',
})
}
export function getMapJson(params) {
return request({
url: `/resource/robots/${encodeURIComponent(params.robotId)}/download-map`,
method: 'get',
params,
})
}
export function getRobotLocation(robotRef) {
return request({
url: `/resource/robots/${encodeURIComponent(robotRef)}/location`,
method: 'get',
})
}
export function getRobotStatus(id) {
return request({ url: `/resource/robots/id/${id}`, method: 'get' })
}
export function moveRobot(data) {
return request({ url: '/api/agv/setVelocity', method: 'post', data })
}
export function stopRobot(data) {
return request({ url: '/api/agv/stopVelocityControl', method: 'post', data })
}

View File

@ -1,29 +0,0 @@
import request from '@/utils/request'
export function listResourceTerminals(params) {
return request({ url: '/resource/terminals', method: 'get', params })
}
export function addResourceTerminal(data) {
return request({ url: '/resource/terminals', method: 'post', data })
}
export function updateResourceTerminal(data) {
return request({ url: '/resource/terminals', method: 'put', data })
}
export function getResourceTerminal(id) {
return request({ url: `/resource/terminals/${encodeURIComponent(id)}`, method: 'get' })
}
export function deleteResourceTerminals(ids) {
return request({ url: `/resource/terminals/${ids}`, method: 'delete' })
}
export function listTerminalRobots(id) {
return request({ url: `/resource/terminals/${encodeURIComponent(id)}/robots`, method: 'get' })
}
export function updateTerminalRobots(id, robotIds) {
return request({ url: `/resource/terminals/${encodeURIComponent(id)}/robots`, method: 'put', data: robotIds })
}

View File

@ -78,15 +78,3 @@ export function getFlowRuntimeLog(query) {
})
}
export function pauseInst(id) {
return request({ url: `/test/inst/pause/${id}`, method: 'post' })
}
export function resumeInst(id) {
return request({ url: `/test/inst/resume/${id}`, method: 'post' })
}
export function stopInst(id) {
return request({ url: `/test/inst/stop/${id}`, method: 'post' })
}

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,9 +1,11 @@
<template>
<section class="app-main">
<router-view v-slot="{ Component, route }">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="tagsViewStore.cachedViews">
<component v-if="!route.meta.link" :is="Component" :key="route.path"/>
</keep-alive>
</transition>
</router-view>
<iframe-toggle />
<copyright />

View File

@ -39,9 +39,6 @@ const usePermissionStore = defineStore(
const sdata = JSON.parse(JSON.stringify(res.data))
const rdata = JSON.parse(JSON.stringify(res.data))
const defaultData = JSON.parse(JSON.stringify(res.data))
normalizeRouteNames(sdata)
normalizeRouteNames(rdata)
normalizeRouteNames(defaultData)
const sidebarRoutes = filterAsyncRouter(sdata)
const rewriteRoutes = filterAsyncRouter(rdata, false, true)
const defaultRoutes = filterAsyncRouter(defaultData)
@ -73,13 +70,7 @@ function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {
} else if (route.component === 'InnerLink') {
route.component = InnerLink
} else {
const componentPath = route.component
const component = loadView(componentPath)
if (!component) {
console.error(`[dynamic-route] component not found: ${componentPath}, menu=${route.meta?.title || route.name || route.path}`)
return false
}
route.component = component
route.component = loadView(route.component)
}
}
if (route.children != null && route.children && route.children.length) {
@ -92,28 +83,6 @@ function filterAsyncRouter(asyncRouterMap, lastRouter = false, type = false) {
})
}
// Route names are global identifiers in Vue Router. Older menu data can contain
// duplicate names under different parents, which makes later routes replace earlier ones.
function normalizeRouteNames(routes, usedNames = new Set(), parentPath = '') {
routes.forEach(route => {
const fullPath = `${parentPath}/${route.path || ''}`.replace(/\/+/g, '/')
if (route.name) {
const originalName = String(route.name)
let uniqueName = originalName
if (usedNames.has(uniqueName)) {
const suffix = fullPath.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/g, '') || 'route'
uniqueName = `${originalName}_${suffix}`
let sequence = 2
while (usedNames.has(uniqueName)) uniqueName = `${originalName}_${suffix}_${sequence++}`
console.warn(`[dynamic-route] duplicate name "${originalName}" renamed to "${uniqueName}"`)
route.name = uniqueName
}
usedNames.add(uniqueName)
}
if (route.children?.length) normalizeRouteNames(route.children, usedNames, fullPath)
})
}
function filterChildren(childrenMap, lastRouter = false) {
var children = []
childrenMap.forEach(el => {

View File

@ -1,4 +1,4 @@
import { getNodeRedFlowById as getFlowById } from '@/api/flow/nodeRed';
import { getFlowById } from "@/api/device/register";
const loadNodeRedData = async (id) => {
const data = await getFlowById(id);

View File

@ -1,4 +1,4 @@
import { getTaskConfig } from '@/api/test/config'
import { getFlowParamsApi } from '@/api/device/terminal'
import { getDetect } from '@/api/test/detect'
import {
getResourceRoles,
@ -90,13 +90,13 @@ export const loadFlowItemScopedRuntimeRoles = async (items = []) => {
export const loadTaskRuntimeRoles = async (taskId) => {
if (!taskId) throw new Error('任务没有关联流程编排')
const response = await getTaskConfig({ taskId })
const response = await getFlowParamsApi(taskId)
return loadFlowItemRuntimeRoles(response.rows || [])
}
export const loadTaskScopedRuntimeRoles = async (taskId) => {
if (!taskId) throw new Error('任务没有关联流程编排')
const response = await getTaskConfig({ taskId })
const response = await getFlowParamsApi(taskId)
return loadFlowItemScopedRuntimeRoles(response.rows || [])
}

View File

@ -156,12 +156,9 @@ export const normalizeFlowResourceSchema = (sourceGraph) => {
const startProperties = start.properties || (start.properties = {})
const legacyRobotId = resolveLegacyRobotId(startProperties)
const hasExplicitRoles = Array.isArray(startProperties.resourceRoles)
const hasRobotNodes = graph.nodes.some(isRobotNode)
const roles = hasExplicitRoles
const roles = Array.isArray(startProperties.resourceRoles) && startProperties.resourceRoles.length
? startProperties.resourceRoles
: (hasRobotNodes || legacyRobotId ? [createDefaultRobotRole(legacyRobotId)] : [])
if (hasRobotNodes && roles.length === 0) roles.push(createDefaultRobotRole(legacyRobotId))
: [createDefaultRobotRole(legacyRobotId)]
roles.forEach((role, index) => {
role.roleKey ||= index === 0 ? DEFAULT_ROLE_KEY : createResourceKey('robot_role')
@ -170,7 +167,6 @@ export const normalizeFlowResourceSchema = (sourceGraph) => {
role.required = role.required !== false
role.quantity ||= { min: 1, max: 1 }
role.deviceSlots = Array.isArray(role.deviceSlots) ? role.deviceSlots : []
role.deviceSlots.forEach((slot) => delete slot.requiredCapabilities)
})
startProperties.schemaVersion = FLOW_SCHEMA_VERSION

View File

@ -0,0 +1,7 @@
<script setup>
import RobotConsole from '@/views/device/robotManger/index.vue'
</script>
<template>
<RobotConsole />
</template>

View File

@ -0,0 +1,508 @@
<template>
<div class="app-container">
<TableSearch
:queryParams="queryParams"
:showSearch="showSearch"
queryRef="queryRef"
@refresh="resetQuery"
@search="handleQuery"
>
<template #one>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item :inline="true" label="设备类型" prop="deviceModel">
<el-select
v-model="queryParams.deviceModel"
:disabled="edit"
placeholder="请选择设备类型"
@change="handleQuery"
>
<el-option
v-for="item in controlModuleList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item :inline="true" label="设备id" prop="idDeDeviceRegistration">
<el-input
v-model="queryParams.idDeDeviceRegistration"
clearable
placeholder="请输入设备id"
style="width:100%"
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
<el-form-item label="备注" prop="remark">
<el-input
v-model="queryParams.remark"
clearable
placeholder="请输入参数名称"
style="width:100%"
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
</template>
</TableSearch>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-hasPermi="['device:profile:add']"
icon="Plus"
plain
type="primary"
@click="handleAdd"
>新增
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-hasPermi="['device:profile:edit']"
:disabled="single"
icon="Edit"
plain
type="success"
@click="handleUpdate"
>修改
</el-button>
</el-col>
<!-- <el-col :span="1.5">-->
<!-- <el-button-->
<!-- v-hasPermi="['device:profile:remove']"-->
<!-- :disabled="multiple"-->
<!-- icon="Delete"-->
<!-- plain-->
<!-- type="danger"-->
<!-- @click="handleDelete"-->
<!-- >删除-->
<!-- </el-button>-->
<!-- </el-col>-->
<!-- <el-col :span="1.5">-->
<!-- <el-button-->
<!-- type="warning"-->
<!-- plain-->
<!-- icon="Download"-->
<!-- @click="handleExport"-->
<!-- v-hasPermi="['device:profile:export']"-->
<!-- >导出-->
<!-- </el-button>-->
<!-- </el-col>-->
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="profileList" @selection-change="handleSelectionChange">
<!-- <el-table-column align="center" type="selection" width="55"/>-->
<el-table-column type="expand">
<template #default="props">
<el-table :data="props.row.parameters" :height="240">
<el-table-column align="center" label="参数名" prop="paramName" show-overflow-tooltip/>
<el-table-column align="center" label="参数值" prop="defaultValue" show-overflow-tooltip/>
<el-table-column align="center" label="参数类型" prop="paramType" show-overflow-tooltip/>
<el-table-column align="center" class-name="small-padding fixed-width" label="操作" show-overflow-tooltip>
<template #default="scope">
<el-button v-hasPermi="['device:profile:edit']"
:disabled="scope.row.paramName==='idDeDeviceRegistration' || scope.row.paramName==='deviceModel'"
icon="Edit" link
type="primary"
@click="handleOpenSingleParamsDialog(scope.row)"
>修改
</el-button>
<!-- <el-button v-hasPermi="['device:profile:remove']" icon="Delete" link type="primary"-->
<!-- @click="handleDelete(scope.row)">删除-->
<!-- </el-button>-->
</template>
</el-table-column>
</el-table>
</template>
</el-table-column>
<el-table-column align="center" label="设备id" prop="idDeDeviceRegistration" show-overflow-tooltip/>
<el-table-column align="center" label="设备名称" prop="deviceName" show-overflow-tooltip/>
<el-table-column align="center" label="设备类型" prop="deviceModel">
<template #default="scope">
{{ controlModuleList.find(item => item.value === scope.row.deviceModel)?.label }}
</template>
</el-table-column>
<el-table-column align="center" label="终端名称" prop="terminalName" show-overflow-tooltip/>
<el-table-column align="center" label="备注" prop="remark" show-overflow-tooltip/>
<el-table-column align="center" class-name="small-padding fixed-width" label="操作">
<template #default="scope">
<el-button v-hasPermi="['device:profile:edit']" icon="Edit" link type="primary"
@click="handleUpdate(scope.row)">修改
</el-button>
<el-button v-hasPermi="['device:profile:remove']" icon="Delete" link type="primary"
@click="handleDelete(scope.row)">删除
</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
v-model:limit="queryParams.pageSize"
v-model:page="queryParams.pageNum"
:total="total"
@pagination="getList"
/>
<!-- 添加或修改设备配置信息对话框 -->
<el-drawer v-model="open" :title="title" append-to-body size="580" @close="()=>edit=false"
@open="getDeviceList">
<el-form ref="profileRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="设备id" prop="idDeDeviceRegistration">
<el-select
v-model="form.idDeDeviceRegistration"
:disabled="edit"
placeholder="请选择设备类型"
@change="handleDeviceModelChange"
>
<el-option
v-for="item in deviceList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="设备类型" prop="deviceModel">
<el-select
v-model="form.deviceModel"
disabled
placeholder="请选择设备类型"
>
<el-option
v-for="item in controlModuleList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark"
:rows="2" placeholder="请输入备注" type="textarea"/>
</el-form-item>
<template v-if="form?.deviceModel==='mechanical_arm'">
<el-form-item v-for="item in deviceParamsMap['mechanical_arm']" :label="item.label" :prop="item.key" :rules="[
{ required: true, message: item.label + '为必填项', trigger: 'blur' }
]">
<template v-if="item.formType==='select'">
<el-select v-model="form[item.key]">
<el-option v-for="option in item.options" :key="option.value" :label="option.label"
:value="option.value">
</el-option>
</el-select>
</template>
<template v-if="item.formType==='input_number'">
<el-input-number v-model="form[item.key]" style="width:100%"/>
</template>
</el-form-item>
</template>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</template>
</el-drawer>
<!-- 添加或修改任务流程编排对话框 -->
<el-dialog v-model="editSingleParamsDialog" :align-center="true" :title="selectSingleParams.paramName+' 修改'"
width="500px"
>
<template
v-if="!['remark'].includes(selectSingleParams.paramName)">
<el-input-number v-model="selectSingleParams.defaultValue" style="width:100%"/>
</template>
<template v-else>
<el-input v-model="selectSingleParams.defaultValue"
:rows="2" placeholder="请输入备注" type="textarea"/>
</template>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="handleEditParams">修 改</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script name="Profile" setup>
import {addProfile, delProfile, getProfile, listProfile, updateProfile} from "@/api/device/profile"
import TableSearch from "@/components/TableSearch/index.vue"
import {controlModuleList, deviceParamsMap} from "@/enum/index.js";
import {convertNumbersToStrings} from "@/views/device/register/components/index.js";
import {listRegister} from "@/api/device/register.js";
const {proxy} = getCurrentInstance()
const profileList = ref([])
const open = ref(false)
const loading = ref(true)
const showSearch = ref(true)
const ids = ref([])
const single = ref(true)
const multiple = ref(true)
const total = ref(0)
const title = ref("")
const data = reactive({
form: {},
queryParams: {
pageNum: 1,
pageSize: 10,
remark: null,
idDeDeviceRegistration: null,
paramName: null,
paramType: null
},
rules: {
idDeDeviceRegistration: [
{required: true, message: '请输入设备id', trigger: 'blur'}
],
deviceModel: [
{required: true, message: '请输入设备类型', trigger: 'blur'}
],
}
})
const {queryParams, form, rules} = toRefs(data)
/** 查询设备配置信息列表 */
function getList() {
loading.value = true
listProfile(queryParams.value).then(response => {
profileList.value = response.rows?.sort((a, b) => {
if (a.remark < b.remark) {
return -1; // 如果 a.remark 小于 b.remark,返回负数,表示 a 应该排在 b 前面
} else if (a.remark > b.remark) {
return 1; // 如果 a.remark 大于 b.remark,返回正数,表示 a 应该排在 b 后面
} else {
return 0; // 如果 a.remark 等于 b.remark,返回 0,表示顺序不变
}
})
total.value = response.total
loading.value = false
})
}
// 取消按钮
function cancel() {
open.value = false
edit.value = false
editSingleParamsDialog.value = false
reset()
}
// 表单重置
function reset() {
form.value = {
// id: null,
// createBy: null,
// createTime: null,
// updateBy: null,
// updateTime: null,
// remark: null,
// idDeDeviceRegistration: null,
// paramName: null,
// paramType: null
}
proxy.resetForm("profileRef")
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1
getList()
}
/** 重置按钮操作 */
function resetQuery() {
proxy.resetForm("queryRef")
handleQuery()
}
// 多选框选中数据
function handleSelectionChange(selection) {
ids.value = selection.map(item => item.id)
single.value = selection.length != 1
multiple.value = !selection.length
}
/** 新增按钮操作 */
function handleAdd() {
reset()
open.value = true
title.value = "添加设备配置信息"
}
const formatData = (obj) => {
//遍历对象
let res = []
for (let key in obj) {
if (edit.value) {
res.push({
remark: obj.remark,
idDeDeviceRegistration: obj.idDeDeviceRegistration,
paramName: key,
deviceModel: obj.deviceModel,
id: idParamsMap.value[key],
paramType: "float",
defaultValue: obj[key]
})
} else {
res.push({
remark: obj.remark,
idDeDeviceRegistration: obj.idDeDeviceRegistration,
paramName: key,
deviceModel: obj.deviceModel,
paramType: "float",
defaultValue: obj[key]
})
}
}
return res
}
const formInit = (arr) => {
const res = {}
res['remark'] = arr[0]['remark']
res['idDeDeviceRegistration'] = arr[0]['idDeDeviceRegistration']
res['paramType'] = arr[0]['paramType']
res['deviceModel'] = arr[0]['deviceModel']
for (let i = 0; i < arr.length; i++) {
res[arr[i]['paramName']] = arr[i]['defaultValue']
}
for (let key in res) {
if (!["remark", "idDeDeviceRegistration", "deviceModel", "workModule", "paramType"].includes(key)) {
res[key] = Number(res[key])
} else {
res[key] = res[key]
}
}
return res
}
const edit = ref(false)
const idParamsMap = ref({})
const formatIdParamMap = (arr) => {
const map = {}
for (let i = 0; i < arr.length; i++) {
map[arr[i]['paramName']] = arr[i]['id']
}
return map
}
/** 修改按钮操作 */
function handleUpdate(row) {
reset()
// const _id = row.id || ids.value
getProfile({id: row.idDeDeviceRegistration, remark: row.remark}).then(response => {
idParamsMap.value = formatIdParamMap(response.data)
form.value = formInit(response.data)
console.log(form.value, 'form.value')
edit.value = true
open.value = true
title.value = "修改设备配置信息"
})
}
/** 提交按钮 */
function submitForm() {
proxy.$refs["profileRef"].validate(valid => {
if (valid) {
const temp = convertNumbersToStrings(form.value)
const res = formatData(temp)
if (edit.value) {
updateProfile(res).then(response => {
proxy.$modal.msgSuccess("修改成功")
open.value = false
edit.value = false
getList()
})
} else {
addProfile(res).then(response => {
proxy.$modal.msgSuccess("新增成功")
open.value = false
getList()
})
}
}
})
}
/** 删除按钮操作 */
function handleDelete(row) {
const _ids = row?.parameters?.map(item => item.id)
proxy.$modal.confirm('是否确认删除该配置信息?').then(function () {
return delProfile(_ids)
}).then(() => {
getList()
proxy.$modal.msgSuccess("删除成功")
}).catch(() => {
})
}
/** 导出按钮操作 */
function handleExport() {
proxy.download('device/profile/export', {
...queryParams.value
}, `profile_${new Date().getTime()}.xlsx`)
}
getList()
const deviceList = ref([])
const getDeviceList = () => {
listRegister({pageSize: 10000, pageNum: 1}).then(response => {
deviceList.value = response.rows?.map(item => ({
label: `${item.deviceName} ( ${item.deviceCode} )`,
value: item.id,
deviceModel: item.deviceModel,
}))
})
}
getDeviceList()
const handleDeviceModelChange = () => {
form.value.deviceModel = deviceList.value.find(item => item.value === form.value.idDeDeviceRegistration)["deviceModel"]
}
const editSingleParamsDialog = ref(false)
const selectSingleParams = ref({})
const handleOpenSingleParamsDialog = (row) => {
editSingleParamsDialog.value = true
selectSingleParams.value = JSON.parse(JSON.stringify(row))
}
const handleEditParams = () => {
console.log(selectSingleParams.value)
updateProfile([{
id: selectSingleParams.value.id,
defaultValue: String(selectSingleParams.value.defaultValue)
}]).then(response => {
proxy.$modal.msgSuccess("修改成功")
editSingleParamsDialog.value = false
getList()
})
}
</script>

View File

@ -38,7 +38,7 @@
<script setup>
import { inject, reactive, toRefs, watch, onUnmounted, ref, nextTick } from 'vue';
import { getResourceDevice } from '@/api/resource/device';
import { getRegister } from "@/api/device/register";
import { getRgbStreamUrl } from "@/api/device/camera";
import { useRoute } from "vue-router";
@ -446,9 +446,9 @@ watch(
return;
}
try {
const response = await getResourceDevice(deviceId);
data.cameraId = response.data.deviceId;
data.robotId = props.initialRobotId || response.data.robotId;
const response = await getRegister(deviceId);
data.cameraId = response.data.deviceCode;
data.robotId = props.initialRobotId || response.data.idDeDeviceTerminalConfig;
if (data.form.stereoModule) await setSubscription(true, 'getDepthImageStream');
if (data.form.rgbCamera) await startDirectRgbStream();
} catch (error) {

View File

@ -26,7 +26,7 @@ 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';
import { getRegister } from "@/api/device/register";
// 1. 定义 props
const props = defineProps({
@ -83,9 +83,9 @@ const fetchRegisterData = (currentDeviceId) => {
return;
}
if (currentDeviceId) {
getResourceDevice(currentDeviceId).then(res => {
deviceId.value = res.data.deviceId;
robotId.value = res.data.robotId;
getRegister(currentDeviceId).then(res => {
deviceId.value = res.data.deviceCode; // 再次更新,确保与 API 返回一致
robotId.value = res.data.idDeDeviceTerminalConfig;
}).catch(error => {
console.error("Error fetching register:", error);
// 可以根据需要重置 deviceId 和 robotId

View File

@ -77,7 +77,7 @@ import {
} from '@/api/device/microphone'
import { useRoute } from 'vue-router'
import { ElMessage } from "element-plus";
import { getRobotDevicesByRobotId } from '@/api/resource/robot'
import { getRobotDevicesByRobotId } from '@/api/inspection/robot'
const route = useRoute()

View File

@ -148,7 +148,7 @@ 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 { createTimbre, getTts, createCorpus, getSynthesize, createAdvancedCorpus, getAdvancedSynthesize } from "@/api/device/register";
import { ConcurrentRequestController } from "@/utils/requestController";
import AudioPlayer from "./AudioPlayer.vue";

View File

@ -48,7 +48,8 @@ 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 { getFlowRuntimeLog } from "@/api/test/log";
import { pauseInst, resumeInst, stopInst } from "@/api/device/terminal";
import { ElMessage } from "element-plus";
const runtimeFlow = useVueFlow("runtime-flow");

View File

@ -0,0 +1,184 @@
<template>
<div class="app-container robot-device-page">
<section class="page-heading">
<div>
<h2>机器人设备</h2>
<p>设备由机器人在线上报,切换机器人后自动刷新。</p>
</div>
<div class="robot-selector">
<span>当前机器人</span>
<el-select v-model="selectedRobotId" filterable placeholder="请选择机器人"
:loading="robotLoading" @change="loadDevices">
<el-option v-for="robot in robotOptions" :key="robot.value"
:label="robot.label" :value="robot.value" />
</el-select>
<el-button :icon="Refresh" circle title="刷新设备" :loading="loading" @click="loadDevices" />
</div>
</section>
<section class="status-strip">
<div><strong>{{ devices.length }}</strong><span>设备总数</span></div>
<div><strong class="online">{{ onlineCount }}</strong><span>在线设备</span></div>
<div><strong class="fault">{{ faultCount }}</strong><span>故障设备</span></div>
<div class="filters">
<el-input v-model="keyword" clearable :prefix-icon="Search" placeholder="搜索名称或设备 ID" />
<el-select v-model="deviceKind" clearable placeholder="全部类型">
<el-option v-for="item in deviceKinds" :key="item.value"
:label="item.label" :value="item.value" />
</el-select>
</div>
</section>
<el-table v-loading="loading" :data="filteredDevices" height="calc(100vh - 285px)"
empty-text="当前机器人尚未上报设备">
<el-table-column label="设备" min-width="220">
<template #default="{ row }">
<div class="device-name">
<span class="device-icon"><el-icon><Cpu /></el-icon></span>
<div><b>{{ row.deviceName || row.deviceId }}</b><small>{{ row.deviceId }}</small></div>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="130">
<template #default="{ row }">{{ kindLabel(row.deviceKind, row.typeName) }}</template>
</el-table-column>
<el-table-column label="运行状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="row.onlineStatus === '1' ? 'success' : 'info'" effect="light">
{{ row.onlineStatus === '1' ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="健康状态" width="120" align="center">
<template #default="{ row }">
<el-tag :type="isFault(row) ? 'danger' : 'success'" effect="plain">
{{ isFault(row) ? '故障' : '正常' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="errorMessage" label="状态信息" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.errorMessage || '运行正常' }}</template>
</el-table-column>
<el-table-column prop="lastSeenTime" label="最后上报" width="180" />
<el-table-column label="操作" width="100" fixed="right" align="center">
<template #default="{ row }">
<el-button v-if="controlRoute(row.deviceKind)" link type="primary"
:disabled="row.onlineStatus !== '1'" @click="openControl(row)">示教</el-button>
<span v-else class="muted">-</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Cpu, Refresh, Search } from '@element-plus/icons-vue'
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
const router = useRouter()
const robotLoading = ref(false)
const loading = ref(false)
const selectedRobotId = ref('')
const robotOptions = ref([])
const devices = ref([])
const keyword = ref('')
const deviceKind = ref()
const deviceKinds = [
{ value: 1, label: '移动底盘' }, { value: 2, label: '机械臂' },
{ value: 3, label: '电池' }, { value: 4, label: '仿生头' },
{ value: 5, label: '摄像头' }, { value: 6, label: 'CAN 总线' },
{ value: 7, label: '灵巧手' }, { value: 8, label: '夹爪' },
{ value: 9, label: '麦克风' }, { value: 10, label: '电机' },
{ value: 11, label: '电机系统' }, { value: 12, label: '机器人控制器' },
{ value: 13, label: '扬声器' }
]
const kindLabel = (kind, fallback) => deviceKinds.find(item => item.value === kind)?.label || fallback || '未知设备'
const isFault = (device) => device.hasError === '1' || Number(device.healthStatus) === 3
const onlineCount = computed(() => devices.value.filter(item => item.onlineStatus === '1').length)
const faultCount = computed(() => devices.value.filter(isFault).length)
const filteredDevices = computed(() => {
const search = keyword.value.trim().toLowerCase()
return devices.value.filter(item => {
const matchesKind = deviceKind.value == null || item.deviceKind === deviceKind.value
const matchesSearch = !search || `${item.deviceName || ''} ${item.deviceId || ''}`.toLowerCase().includes(search)
return matchesKind && matchesSearch
})
})
const loadRobots = async () => {
robotLoading.value = true
try {
const response = await getRobotList({ pageNum: 1, pageSize: 1000 })
robotOptions.value = (response.rows || []).map(robot => ({
value: robot.robotId,
label: `${robot.robotName || robot.robotId} (${robot.robotId})`,
online: robot.connectStatus === '1'
})).filter(item => item.value)
const preferred = robotOptions.value.find(item => item.online) || robotOptions.value[0]
selectedRobotId.value = preferred?.value || ''
} finally {
robotLoading.value = false
}
}
const loadDevices = async () => {
devices.value = []
if (!selectedRobotId.value) return
loading.value = true
try {
const response = await getRobotDevicesByRobotId(selectedRobotId.value, { onlineOnly: false })
devices.value = Array.isArray(response.data) ? response.data : []
} finally {
loading.value = false
}
}
const controlRoute = (kind) => ({
2: 'mechanical_arm', 4: 'head', 5: 'camera', 7: 'hand', 9: 'microphone', 13: 'speaker'
})[kind]
const openControl = (device) => {
router.push({
path: `/device/${controlRoute(device.deviceKind)}/${device.id}`,
query: { robotId: selectedRobotId.value, deviceId: device.deviceId }
})
}
onMounted(async () => {
await loadRobots()
await loadDevices()
})
</script>
<style lang="scss" scoped>
.robot-device-page { min-height: 100%; background: #f5f7fa; }
.page-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 4px 0 20px; }
.page-heading h2 { margin: 0 0 5px; color: #1f2937; font-size: 22px; letter-spacing: 0; }
.page-heading p { margin: 0; color: #8492a6; font-size: 13px; }
.robot-selector { display: flex; align-items: center; gap: 10px; color: #606266; font-size: 13px; }
.robot-selector .el-select { width: 330px; }
.status-strip { display: flex; align-items: center; gap: 34px; margin-bottom: 14px; padding: 14px 18px; border: 1px solid #e4e7ed; border-radius: 6px; background: #fff; }
.status-strip > div:not(.filters) { display: grid; min-width: 74px; }
.status-strip strong { color: #303133; font-size: 24px; line-height: 28px; }
.status-strip strong.online { color: #16a34a; }
.status-strip strong.fault { color: #dc2626; }
.status-strip span { color: #909399; font-size: 12px; }
.filters { display: flex; flex: 1; justify-content: flex-end; gap: 10px; }
.filters .el-input { width: 240px; }
.filters .el-select { width: 150px; }
.device-name { display: flex; align-items: center; gap: 12px; }
.device-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 6px; background: #ecf5ff; color: #409eff; }
.device-name div { display: grid; min-width: 0; }
.device-name b { color: #303133; font-weight: 600; }
.device-name small { overflow: hidden; color: #909399; text-overflow: ellipsis; }
.muted { color: #c0c4cc; }
@media (max-width: 900px) {
.page-heading, .status-strip { align-items: stretch; flex-direction: column; }
.robot-selector, .filters { justify-content: flex-start; flex-wrap: wrap; }
.robot-selector .el-select, .filters .el-input { width: 100%; }
}
</style>

View File

@ -0,0 +1,874 @@
<template>
<div id="robot-interface" class="robot-container">
<div class="tree-panel" :style="{ width: isTreeCollapsed ? '0px' : '240px' }">
<el-tree
v-if="!isTreeCollapsed"
:data="treeData"
:props="treeProps"
default-expand-all
@node-click="handleNodeClick"
@node-contextmenu="handleNodeContextMenu"
/>
<!-- 右键菜单组件 -->
<ContextMenu
:visible="contextMenu.visible"
:position="contextMenu.position"
:menu-items="contextMenu.items"
@close="contextMenu.visible = false"
@select="handleMenuItemSelect"
/>
</div>
<div class="robot-panel">
<div id="robot-bg" ref="robotBg" :style="{ backgroundImage: `url(${robotImage})`, width: bgSize.width + 'px', height: bgSize.height + 'px' }">
<TechButtonWithLine
v-for="(area,index) in adjustedAreas"
:key="area.id"
:total-buttons="5"
:button-index="area.index"
:button-position="area.position"
:end-x="area.x"
:end-y="area.y"
:button-name="area.label"
:button-width="120"
:button-height="50"
@click="openPopup(area)"
/>
</div>
<div class="toggle-button" @click="isTreeCollapsed = !isTreeCollapsed">
{{ isTreeCollapsed ? '>' : '<' }}
</div>
</div>
<div
v-for="(popup, index) in popups"
:key="popup.id"
class="popup"
:style="{
position: 'absolute',
top: popup.top,
left: popup.left,
zIndex: popup.zIndex,
width: popup.width + 'px',
height: popup.height + 'px'
}"
:class="{ resizing: isResizing[index], dragging: isDragging && dragIndex === index }"
>
<div class="drag-handle" @mousedown="startDragging($event, index)">
<span class="popup-title">{{ popup.id.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) }}</span>
<div class="popup-controls">
<span class="control-icon" @click="minimizePopup(index)">⧓</span>
<span class="control-icon" @click="maximizePopup(index)">□</span>
<span class="control-icon close-icon" @click="closePopup(index)">×</span>
</div>
</div>
<component :is="popup.component" v-bind="popup.props" :initialDeviceId = "popup.deviceId" :initialRobotId = "popup.configId" />
<div class="resize-edge top" @mousedown="startResizing($event, index, 'top')"></div>
<div class="resize-edge bottom" @mousedown="startResizing($event, index, 'bottom')"></div>
<div class="resize-edge left" @mousedown="startResizing($event, index, 'left')"></div>
<div class="resize-edge right" @mousedown="startResizing($event, index, 'right')"></div>
<div class="resize-corner top-left" @mousedown="startResizing($event, index, 'top-left')"></div>
<div class="resize-corner top-right" @mousedown="startResizing($event, index, 'top-right')"></div>
<div class="resize-corner bottom-left" @mousedown="startResizing($event, index, 'bottom-left')"></div>
<div class="resize-corner bottom-right" @mousedown="startResizing($event, index, 'bottom-right')"></div>
</div>
<!-- 添加你现有的配置编辑对话框 -->
<el-dialog :title="dialogTitle" v-model="dialogFormVisible" width="500px" append-to-body>
<el-form ref="robotConfigFormRef" :model="dialogForm" :rules="dialogRules" label-width="80px">
<el-form-item label="配置类型" prop="configType">
<el-select v-model="dialogForm.configType" placeholder="请选择配置类型,区分是设备还是终端">
<el-option
v-for="dict in de_robot_config_type"
:key="dict.value"
:label="dict.label"
:value="dict.value"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="配置ID" prop="configId">
<el-select v-model="dialogForm.configId" placeholder="请选择配置信息" v-if="dialogForm.configType == 1">
<el-option
v-for="dict in terminalConfigList"
:key="dict.id"
:label="dict.name"
:value="dict.id"
></el-option>
</el-select>
<el-select v-model="dialogForm.configId" placeholder="请选择配置信息" v-else>
<el-option
v-for="dict in registerList"
:key="dict.id"
:label="dict.deviceName || dict.deviceCode"
:value="dict.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="节点名称" prop="nodeName">
<el-input v-model="dialogForm.nodeName" placeholder="请输入节点名称,名称可以随意取,不强制唯一" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitDialogForm">确 定</el-button>
<el-button @click="cancelDialog">取 消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, computed, onUnmounted, watch, nextTick } from 'vue';
import HandControl from '../register/components/DexHand/index.vue';
import CameraView from '../register/components/Camera/index.vue';
import MusicPlayer from '../register/components/Speaker/index.vue';
import MechanicalArm from '../register/components/MechanicalArm/index.vue';
import image from '@/assets/images/robot.png';
import { listRobot, getRobot, updateRobot } from '@/api/device/robot';
import { listTerminal } from '@/api/device/terminal'
import { listRegister } from '@/api/device/register'
import ContextMenu from './ContextMenu.vue'; // 导入右键菜单组件
import TechButtonWithLine from './TechButtonWithLine.vue';
// --- Existing Code ---
// 组件映射
const componentsMap = {
HandControl,
CameraView,
MusicPlayer,
MechanicalArm,
TechButtonWithLine
};
const { proxy } = getCurrentInstance()
const { de_robot_config_type } = proxy.useDict("de_robot_config_type")
const terminalConfigList = ref([])
const registerList = ref([])
const robotImage = image;
const robotBg = ref(null);
const isTreeCollapsed = ref(false);
const popups = ref([]);
const isDragging = ref(false);
const isResizing = ref([]);
const currentZIndex = ref(2000);
const dragIndex = ref(null);
const resizeStart = ref({ index: null, startX: 0, startY: 0, originalWidth: 0, originalHeight: 0, originalTop: 0, originalLeft: 0, edge: '' });
const imageDimensions = ref({ width: 0, height: 0 });
const containerSize = ref({ width: 0, height: 0 });
const bgSize = ref({ width: 0, height: 0 });
// 原始坐标和尺寸(基于图片原始大小)
const originalAreas = ref([
{ id: 'camera', index: 1, position: 'left', x: 250, y: 134, component: CameraView, label: '摄像头', deviceId: '', configId: '' },
{ id: 'mouth', index: 1, position: 'right', x: 299, y: 220, component: MusicPlayer, label: '扬声器', deviceId: '', configId: '' },
{ id: 'arm-left', index: 2, position: 'left', x: 100, y: 650, component: MechanicalArm, label: '左机械臂', deviceId: '', configId: '' },
{ id: 'arm-right', index: 2, position: 'right', x: 480, y: 650, component: MechanicalArm, label: '右机械臂', deviceId: '', configId: '' },
{ id: 'hand-left', index: 3, position: 'left', x: 75, y: 885, component: HandControl, label: '左手', deviceId: '', configId: '' },
{ id: 'hand-right', index: 3, position: 'right', x: 520, y: 885, component: HandControl, label: '右手', deviceId: '', configId: '' },
]);
// 树数据
const treeData = ref([
]);
/**
* 将扁平化的节点数据转换为树形结构。
* 假设 id === 'root' 的节点是顶级节点,并且保留所有原始数据。
*
* @param {Array<Object>} flatData - 扁平化的节点数据数组。
* @returns {Array<Object>} 转换后的树形结构数据。
*/
function convertFlatDataToTree(flatData) {
const nodeMap = new Map();
const rootNodes = [];
// 1. 将所有节点存入 Map,并初始化 children 数组
flatData.forEach(item => {
// 复制 item,避免直接修改原始数据,并添加 children 属性
// 保留所有原始数据字段
nodeMap.set(item.id, { ...item, children: [] });
});
// 2. 遍历 Map,根据 pid 构建树形结构
nodeMap.forEach(node => {
if (node.pid === 'root') {
// 如果 pid 是 'root',则该节点是顶级节点
rootNodes.push(node);
} else {
// 查找父节点
const parent = nodeMap.get(node.pid);
if (parent) {
// 将当前节点添加到父节点的 children 数组中
parent.children.push(node);
} else {
// 如果找不到父节点,可以选择抛出错误或忽略该节点
console.warn(`节点 ${node.id} 的父节点 ${node.pid} 不存在,该节点将被忽略。`);
}
}
});
// 3. 最终返回的就是识别出的顶级节点列表
// 这些顶级节点已经通过上面的逻辑构建了各自的子树
return rootNodes;
}
const treeProps = { children: 'children', label: 'nodeName' };
const getAll = () => {
listRobot().then(res => {
treeData.value = convertFlatDataToTree(res.data)
const configId = treeData.value[0].configId;
for (let item of treeData.value[0].children) {
const areaInfo = originalAreas.value.filter(area => area.label == item.nodeName)[0];
areaInfo.deviceId = item.configId;
areaInfo.configId = configId;
}
});
};
const handleNodeClick = (data) => {
console.log('点击节点', data.label);
};
// --- New Code for Right-Click Menu ---
const contextMenu = ref({
visible: false,
position: { x: 0, y: 0 },
items: [],
currentNode: null // 存储当前右键点击的节点
});
const handleNodeContextMenu = (event, data, node, component) => {
// 阻止默认右键菜单
event.preventDefault();
// 阻止事件冒泡到父元素(比如 el-tree 本身)
event.stopPropagation();
contextMenu.value.position = { x: event.clientX, y: event.clientY };
contextMenu.value.currentNode = data; // 存储当前节点
contextMenu.value.items = [
{ key: 'edit', label: '编辑' },
// 可以添加其他菜单项,例如 '添加子节点', '删除' 等
// { key: 'add', label: '添加子节点' },
// { key: 'delete', label: '删除' }
];
contextMenu.value.visible = true;
};
const handleMenuItemSelect = (item) => {
if (item.key === 'edit') {
openDialogForEdit(contextMenu.value.currentNode);
}
// 根据item.key处理其他菜单项
// else if (item.key === 'add') {
// console.log('添加子节点 to:', contextMenu.value.currentNode);
// // 实现添加子节点的逻辑
// }
// else if (item.key === 'delete') {
// console.log('删除节点:', contextMenu.value.currentNode);
// // 实现删除节点的逻辑
// }
};
// --- Integration with Existing Dialog ---
const dialogFormVisible = ref(false);
const dialogTitle = ref('');
const robotConfigFormRef = ref(null);
const dialogForm = ref({
configId: '',
configType: '',
pid: '',
nodeName: ''
});
const robotOptions = ref([]); // 用于el-tree-select的数据
const openDialogForEdit = (nodeData) => {
console.log('编辑节点:', nodeData.id);
dialogTitle.value = '编辑机器人配置';
getRobot(nodeData.id).then(res => {
dialogForm.value = {
...res.data,
};
dialogFormVisible.value = true;
// 确保 robotOptions 有数据,以便 tree-select 可以渲染
// 这里假设 treeData 结构能直接作为 robotOptions
robotOptions.value = treeData.value[0].children; // 假设第一个子节点是可选择的父节点,或根据实际情况调整
nextTick(() => {
robotConfigFormRef.value?.clearValidate(); // 清除之前的校验
});
})
};
// 提交对话框表单
const submitDialogForm = () => {
robotConfigFormRef.value.validate(async (valid) => {
if (valid) {
// 假设你有一个方法来更新 treeData
await updateRobot(dialogForm.value);
dialogFormVisible.value = false;
getAll();
// Optionally show a success message
ElMessage.success('配置更新成功');
} else {
console.log('表单校验失败');
}
});
};
// 取消对话框
const cancelDialog = () => {
dialogFormVisible.value = false;
};
// --- Existing Code (adjusted for clarity and potential issues) ---
// 计算调整后的区域
const adjustedAreas = computed(() => {
if (!imageDimensions.value.width || !imageDimensions.value.height || !robotBg.value) return [];
const treePanelWidth = isTreeCollapsed.value ? 0 : 240;
// 确保 robot-panel 内部的 content 区域是可用的
const contentContainerWidth = containerSize.value.width - treePanelWidth;
const containerHeight = containerSize.value.height;
// 仅当机器人背景图存在时,才计算缩放比例
if (!robotBg.value || !robotBg.value.parentElement) {
return [];
}
// 获取 robot-panel 的实际可用尺寸
const robotPanel = robotBg.value.parentElement;
const robotPanelRect = robotPanel.getBoundingClientRect();
const robotPanelContentWidth = robotPanelRect.width;
const robotPanelContentHeight = robotPanelRect.height;
const scaleX = robotPanelContentWidth / imageDimensions.value.width;
const scaleY = robotPanelContentHeight / imageDimensions.value.height;
const scale = Math.min(scaleX, scaleY, 1); // 防止放大,如果图片比容器小,就按100%显示
const scaledWidth = imageDimensions.value.width * scale;
const scaledHeight = imageDimensions.value.height * scale;
// 更新背景图片尺寸
bgSize.value = { width: scaledWidth, height: scaledHeight };
// 计算中心对齐的偏移量
const offsetX = (robotPanelContentWidth - scaledWidth) / 2;
const offsetY = (robotPanelContentHeight - scaledHeight) / 2;
return originalAreas.value.map(area => ({
...area,
x: area.x * scale + offsetX, // 应用缩放和中心偏移
y: area.y * scale + offsetY, // 应用缩放和中心偏移
width: area.width * scale,
height: area.height * scale
}));
});
// 打开弹窗,考虑树宽度和padding偏移
const openPopup = (area) => {
const existingPopup = popups.value.find(p => p.id === area.id);
if (existingPopup) {
console.log('已打开ID为', area.id, '的弹窗');
// 如果弹窗已存在,可以考虑将其置顶
existingPopup.zIndex = currentZIndex.value++;
return;
}
const treeWidth = isTreeCollapsed.value ? 0 : 240;
// 弹窗的 left 偏移需要考虑 tree-panel 的宽度,以及 robot-panel 的 padding
const leftOffset = treeWidth + 20; // tree宽度 + robot-panel padding-left
const popup = {
id: area.id,
component: area.component,
top: `${area.y}px`, // area.y 已经是居中后的 Y 坐标
left: `${area.x + leftOffset}px`, // area.x 已经是居中后的 X 坐标,再加上 treePanel宽度和robot-panel的padding
zIndex: currentZIndex.value++,
deviceId: area.deviceId,
configId: area.configId,
width: 640,
height: 480,
originalWidth: 640,
originalHeight: 480,
originalTop: `${area.y}px`,
originalLeft: `${area.x + leftOffset}px`,
isMaximized: false,
props: { robotId: area.robotId || '', cameraId: area.deviceId || '' }
};
popups.value.push(popup);
isResizing.value.push(false);
console.log('打开弹窗ID', area.id, '初始尺寸:', popup.width, 'x', popup.height, '位置:', popup.top, popup.left);
};
// 关闭弹窗
const closePopup = (index) => {
popups.value.splice(index, 1);
isResizing.value.splice(index, 1);
console.log('关闭弹窗索引', index);
};
// 最小化弹窗
const minimizePopup = (index) => {
const popup = popups.value[index];
if (popup.isMaximized) {
popup.width = popup.originalWidth;
popup.height = popup.originalHeight;
popup.top = popup.originalTop;
popup.left = popup.originalLeft;
popup.isMaximized = false;
console.log('最小化弹窗', popup.id, '到尺寸:', popup.width, 'x', popup.height, '位置:', popup.top, popup.left);
}
};
// 最大化弹窗
const maximizePopup = (index) => {
const popup = popups.value[index];
if (!popup.isMaximized) {
popup.originalWidth = popup.width;
popup.originalHeight = popup.height;
popup.originalTop = popup.top;
popup.originalLeft = popup.left;
// 最大化时,需要考虑右侧的区域,而不是整个窗口
const treeWidth = isTreeCollapsed.value ? 0 : 240;
const availableWidth = window.innerWidth - treeWidth - 240;
const availableHeight = window.innerHeight - 120;
popup.width = availableWidth;
popup.height = availableHeight;
popup.top = '0px';
popup.left = `${treeWidth}px`; // 紧贴 tree panel
popup.isMaximized = true;
console.log('最大化弹窗', popup.id, '到尺寸:', popup.width, 'x', popup.height, '位置:', popup.top, popup.left);
}
};
// 拖动相关逻辑
const startDragging = (e, index) => {
e.stopPropagation(); // 阻止事件冒泡到其他可点击区域
isDragging.value = true;
dragIndex.value = index;
const popup = popups.value[index];
popup.startX = e.pageX - parseInt(popup.left);
popup.startY = e.pageY - parseInt(popup.top);
popup.zIndex = currentZIndex.value++;
document.addEventListener('mousemove', dragHandler);
document.addEventListener('mouseup', stopDragging);
};
const dragHandler = (e) => {
if (isDragging.value && dragIndex.value !== null) {
requestAnimationFrame(() => {
const popup = popups.value[dragIndex.value];
// 限制拖动范围,防止移出可视区域
const treeWidth = isTreeCollapsed.value ? 0 : 240;
const maxX = window.innerWidth - popup.width - treeWidth; // 考虑 tree panel
const maxY = window.innerHeight - popup.height;
let newTop = e.pageY - popup.startY;
let newLeft = e.pageX - popup.startX;
// 限制在可视窗口内
newTop = Math.max(0, newTop);
newLeft = Math.max(treeWidth, newLeft); // 限制左侧不能移入 tree panel 区域
newTop = Math.min(maxY, newTop);
newLeft = Math.min(maxX, newLeft);
popup.top = `${newTop}px`;
popup.left = `${newLeft}px`;
});
}
};
const stopDragging = () => {
isDragging.value = false;
dragIndex.value = null;
document.removeEventListener('mousemove', dragHandler);
document.removeEventListener('mouseup', stopDragging);
};
// 调整大小相关逻辑
const startResizing = (e, index, edge) => {
e.stopPropagation(); // 阻止事件冒泡
const popup = popups.value[index];
resizeStart.value = {
index,
startX: e.pageX,
startY: e.pageY,
originalWidth: popup.width,
originalHeight: popup.height,
originalTop: parseInt(popup.top) || 0,
originalLeft: parseInt(popup.left) || 0,
edge
};
isResizing.value[index] = true;
popup.zIndex = currentZIndex.value++; // 调整大小的时候也提高 z-index
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResizingGlobal);
};
const handleResize = (e) => {
if (resizeStart.value.index !== null) {
const index = resizeStart.value.index;
const popup = popups.value[index];
const diffX = e.pageX - resizeStart.value.startX;
const diffY = e.pageY - resizeStart.value.startY;
const minWidth = 200;
const minHeight = 150;
const treeWidth = isTreeCollapsed.value ? 0 : 240;
switch (resizeStart.value.edge) {
case 'top':
const newHeightT = Math.max(minHeight, resizeStart.value.originalHeight - diffY);
popup.height = newHeightT;
popup.top = `${Math.max(0, resizeStart.value.originalTop + (resizeStart.value.originalHeight - newHeightT))}px`;
break;
case 'bottom':
popup.height = Math.max(minHeight, resizeStart.value.originalHeight + diffY);
break;
case 'left':
const newWidthL = Math.max(minWidth, resizeStart.value.originalWidth - diffX);
popup.width = newWidthL;
popup.left = `${Math.max(treeWidth, resizeStart.value.originalLeft + (resizeStart.value.originalWidth - newWidthL))}px`;
break;
case 'right':
popup.width = Math.max(minWidth, resizeStart.value.originalWidth + diffX);
break;
case 'top-left':
const newWidthTL = Math.max(minWidth, resizeStart.value.originalWidth - diffX);
const newHeightTL = Math.max(minHeight, resizeStart.value.originalHeight - diffY);
popup.width = newWidthTL;
popup.height = newHeightTL;
popup.left = `${Math.max(treeWidth, resizeStart.value.originalLeft + (resizeStart.value.originalWidth - newWidthTL))}px`;
popup.top = `${Math.max(0, resizeStart.value.originalTop + (resizeStart.value.originalHeight - newHeightTL))}px`;
break;
case 'top-right':
const newWidthTR = Math.max(minWidth, resizeStart.value.originalWidth + diffX);
const newHeightTR = Math.max(minHeight, resizeStart.value.originalHeight - diffY);
popup.width = newWidthTR;
popup.height = newHeightTR;
popup.top = `${Math.max(0, resizeStart.value.originalTop + (resizeStart.value.originalHeight - newHeightTR))}px`;
break;
case 'bottom-left':
const newWidthBL = Math.max(minWidth, resizeStart.value.originalWidth - diffX);
const newHeightBL = Math.max(minHeight, resizeStart.value.originalHeight + diffY);
popup.width = newWidthBL;
popup.height = newHeightBL;
popup.left = `${Math.max(treeWidth, resizeStart.value.originalLeft + (resizeStart.value.originalWidth - newWidthBL))}px`;
break;
case 'bottom-right':
popup.width = Math.max(minWidth, resizeStart.value.originalWidth + diffX);
popup.height = Math.max(minHeight, resizeStart.value.originalHeight + diffY);
break;
}
// 限制弹窗不超出容器
const treePanelWidth = isTreeCollapsed.value ? 0 : 240;
const containerRect = robotBg.value.parentElement.getBoundingClientRect();
const maxLeft = containerRect.width - popup.width - treePanelWidth; // 考虑 robot-panel padding
const maxTop = containerRect.height - popup.height;
popup.left = `${Math.max(treePanelWidth, parseInt(popup.left))}px`; // 限制左侧不进入 tree panel
popup.top = `${Math.max(0, parseInt(popup.top))}px`; // 限制顶部
popup.left = `${Math.min(maxLeft + treePanelWidth, parseInt(popup.left))}px`; // 限制右侧
popup.top = `${Math.min(maxTop, parseInt(popup.top))}px`; // 限制底部
}
};
const stopResizingGlobal = () => {
if (resizeStart.value.index !== null) {
isResizing.value[resizeStart.value.index] = false;
resizeStart.value = { index: null, startX: 0, startY: 0, originalWidth: 0, originalHeight: 0, originalTop: 0, originalLeft: 0, edge: '' };
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResizingGlobal);
}
};
// 防抖函数
const debounce = (fn, delay) => {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
};
// 更新容器和图片尺寸
const updateDimensions = debounce(() => {
if (robotBg.value && robotBg.value.parentElement) {
containerSize.value = {
width: robotBg.value.parentElement.offsetWidth - 40, // 左右padding 20*2
height: robotBg.value.parentElement.offsetHeight // 无垂直padding
};
// console.log('更新容器尺寸:', containerSize.value.width, 'x', containerSize.value.height);
}
}, 50);
onMounted(() => {
const img = new Image();
img.src = robotImage;
img.onload = () => {
imageDimensions.value = { width: img.width, height: img.height };
console.log('图片尺寸:', imageDimensions.value.width, 'x', imageDimensions.value.height);
if (robotBg.value) {
// 监听 robot-panel 的父元素(通常是 #app 或 main-container)的尺寸变化
const containerElement = robotBg.value.parentElement;
if (containerElement) {
const observer = new ResizeObserver(updateDimensions);
observer.observe(containerElement);
watch(() => isTreeCollapsed.value, updateDimensions, { immediate: true });
updateDimensions(); // 初始更新
// 在 unmounted 时断开观察
onUnmounted(() => {
observer.disconnect();
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResizingGlobal);
document.removeEventListener('mousemove', dragHandler); // 确保拖动监听也被移除
document.removeEventListener('mouseup', stopDragging);
});
}
}
};
getAll();
listRegister().then((res) => {
registerList.value = res.rows;
});
listTerminal().then((res) => {
terminalConfigList.value = res.rows;
});
// 初始化 robotOptions
// const rootNode = treeData.value.find(item => item.id === 'robot-node-config');
// if (rootNode && rootNode.children) {
// robotOptions.value = rootNode.children.filter(node => node.pid === 'root'); // 假设 'root' 是顶级父节点的 pid
// } else {
// robotOptions.value = [];
// }
});
// onUnmounted 已经包含在 onMounted 的回调中
</script>
<style scoped>
.robot-container {
width: 100%;
position: relative;
display: flex;
overflow: hidden;
padding: 0;
background-color: #fff;
border-radius: 10px;
height: calc(100vh - 120px); /* 示例高度,请根据实际布局调整 */
box-sizing: border-box;
}
.tree-panel {
flex-shrink: 0;
border-right: 1px solid #ddd;
overflow-y: auto;
height: 100%; /* 填充父容器高度 */
transition: width 0.3s ease;
background-color: #f8f8f8; /* 示例背景色 */
}
.tree-panel:deep(.el-tree) {
padding: 10px !important;
background: transparent; /* 确保 treePanel 的背景色生效 */
}
.robot-panel {
flex-grow: 1;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
padding: 0 20px; /* 左右内边距 */
position: relative;
}
#robot-bg {
background-size: contain;
background-repeat: no-repeat;
background-position: center;
min-width: 0;
min-height: 0;
/* position: relative; 添加 relative 定位 */
/* 初始设置一个尺寸,或者由 JS 动态计算 */
width: 100%;
height: 100%;
/* box-sizing: border-box; 包含 padding */
}
.toggle-button {
position: absolute;
left: 20px; /* 紧贴 robot-panel 的左边距 */
top: 10px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 24px;
background-color: #e0e0e0;
border-radius: 4px;
z-index: 2001;
box-shadow: 0 2px 4px rgba(0,0,0,.1);
}
.toggle-button:hover {
background-color: #d0d0d0;
}
.clickable-area {
cursor: pointer;
position: absolute;
/* background: rgba(255, 0, 0, 0.2); */ /* 用于调试区域 */
box-sizing: border-box;
}
.clickable-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
text-shadow: 0 0 5px #000;
animation: blink 1.5s infinite;
font-size: 14px;
font-weight: bold;
pointer-events: none; /* 防止文本本身捕获点击事件 */
}
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 1; }
}
.popup {
display: flex;
flex-direction: column;
background: white;
user-select: none;
overflow: hidden; /* 整体 overflow hidden,内容由子组件处理 */
box-sizing: border-box;
transition: width 0.2s, height 0.2s, top 0.2s, left 0.2s;
border: 1px solid #eee;
position: absolute; /* 确保 popup 是绝对定位 */
}
.popup.dragging {
transition: none;
opacity: 0.8;
}
.drag-handle {
height: 30px;
background: #e0e0e0;
cursor: move;
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
border-bottom: 1px solid #eee; /* 增加分隔线 */
}
.popup-title {
font-size: 16px;
font-weight: bold;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
position: absolute;
left: 10px;
right: 60px; /* 为右侧按钮留出空间 */
text-align: center;
user-select: none; /* 标题不可选 */
}
.popup-controls {
position: absolute;
right: 5px;
display: flex;
gap: 5px;
}
.control-icon {
cursor: pointer;
font-size: 18px;
padding: 4px 8px;
background: #f0f0f0;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.control-icon:hover {
background: #d0d0d0;
}
.close-icon {
color: #ff4444;
}
.close-icon:hover {
background: #ff6666;
}
.resize-edge, .resize-corner {
position: absolute;
background: transparent;
z-index: 2;
box-sizing: border-box;
}
.top { top: 0; left: 0; right: 0; height: 10px; cursor: ns-resize; }
.bottom { bottom: 0; left: 0; right: 0; height: 5px; cursor: ns-resize; }
.left { top: 0; bottom: 0; left: 0; width: 5px; cursor: ew-resize; }
.right { top: 0; bottom: 0; right: 0; width: 5px; cursor: ew-resize; }
.resize-corner {
width: 12px;
height: 12px;
background: rgba(0, 0, 0, 0.05); /* 增加一些可见性 */
border-radius: 2px;
}
.top-left { top: 0; left: 0; cursor: nwse-resize; }
.top-right { top: 0; right: 0; cursor: nesw-resize; }
.bottom-left { bottom: 0; left: 0; cursor: nesw-resize; }
.bottom-right { bottom: 0; right: 0; cursor: nwse-resize; }
.popup.resizing {
transition: none;
}
/* dialog 样式,与你原有的保持一致 */
.dialog-footer {
display: flex;
justify-content: flex-end;
padding-top: 10px;
border-top: 1px solid #eee;
}
.el-dialog {
display: flex;
flex-direction: column;
margin: 0 !important;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.el-dialog .el-dialog__body {
flex: 1;
overflow: auto;
}
</style>

View File

@ -0,0 +1,316 @@
<template>
<div class="app-container">
<div ref="topContainerRef">
<TableSearch
:queryParams="queryParams"
queryRef="queryRef"
:showSearch="showSearch"
@search="handleQuery"
@refresh="resetQuery"
label-width="80px"
>
<template #one>
<el-col :xs="24" :sm="24" :md="12" :lg="6" :xl="6" :xxl="6">
<el-form-item label="终端名称" prop="name">
<el-input
v-model="queryParams.name"
placeholder="请输入终端名称"
style="width:100%"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="24" :md="12" :lg="6" :xl="6" :xxl="6">
<el-form-item label="端口号" prop="port">
<el-input
v-model="queryParams.port"
placeholder="请输入端口号"
style="width:100%"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
</el-col>
</template>
</TableSearch>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="Plus"
@click="handleAdd"
v-hasPermi="['device:terminal:add']"
>新增
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="Edit"
:disabled="single"
@click="handleUpdate"
v-hasPermi="['device:terminal:edit']"
>修改
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="danger"
plain
icon="Delete"
:disabled="multiple"
@click="handleDelete"
v-hasPermi="['device:terminal:remove']"
>删除
</el-button>
</el-col>
<!-- <el-col :span="1.5">-->
<!-- <el-button-->
<!-- type="warning"-->
<!-- plain-->
<!-- icon="Download"-->
<!-- @click="handleExport"-->
<!-- v-hasPermi="['device:terminal:export']"-->
<!-- >导出-->
<!-- </el-button>-->
<!-- </el-col>-->
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
</div>
<div :style="containerHeight">
<el-table height="100%" v-loading="loading" :data="terminalList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center"/>
<el-table-column show-overflow-tooltip label="id" align="center" prop="id"/>
<el-table-column label="终端名称" align="center" prop="name" show-overflow-tooltip/>
<el-table-column label="主机" align="center" prop="host" show-overflow-tooltip/>
<el-table-column label="端口号" align="center" prop="port" show-overflow-tooltip/>
<el-table-column label="说明" align="center" prop="description" show-overflow-tooltip/>
<el-table-column label="备注" align="center" prop="remake" show-overflow-tooltip/>
<el-table-column label="状态" align="center" prop="status">
<template #default="scope">
<el-switch
v-model="scope.row.status"
inline-prompt
active-text="启用"
inactive-text="停用"
active-value="0"
inactive-value="1"
@change="handleChange(scope.row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope">
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)"
v-hasPermi="['device:terminal:edit']">修改
</el-button>
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)"
v-hasPermi="['device:terminal:remove']">删除
</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total>0"
:total="total"
v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize"
@pagination="getList"
/>
</div>
<!-- 添加或修改设备终端配置对话框 -->
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
<el-form ref="terminalRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="终端名称" prop="name">
<el-input v-model="form.name" placeholder="请输入终端名称"/>
</el-form-item>
<el-form-item label="主机" prop="host">
<el-input v-model="form.host" placeholder="请输入主机"/>
</el-form-item>
<el-form-item label="端口号" prop="port">
<el-input v-model="form.port" placeholder="请输入端口号"/>
</el-form-item>
<el-form-item label="说明" prop="description">
<el-input v-model="form.description" placeholder="请输入说明"/>
</el-form-item>
<el-form-item label="备注" prop="remake">
<el-input :rows="2"
type="textarea" v-model="form.remake" placeholder="请输入备注"/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name="Terminal">
import {listTerminal, getTerminal, delTerminal, addTerminal, updateTerminal} from "@/api/device/terminal"
import TableSearch from "@/components/TableSearch/index.vue"
import { useContainerHeight } from "@/hooks/tableHeight";
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const {proxy} = getCurrentInstance()
const terminalList = ref([])
const open = ref(false)
const loading = ref(true)
const showSearch = ref(true)
const ids = ref([])
const single = ref(true)
const multiple = ref(true)
const total = ref(0)
const title = ref("")
const data = reactive({
form: {},
queryParams: {
pageNum: 1,
pageSize: 10,
remake: null,
name: null,
host: null,
port: null,
status: null,
description: null
},
rules: {}
})
const {queryParams, form, rules} = toRefs(data)
/** 查询设备终端配置列表 */
function getList() {
loading.value = true
listTerminal(queryParams.value).then(response => {
terminalList.value = response.rows
total.value = response.total
loading.value = false
})
}
// 取消按钮
function cancel() {
open.value = false
reset()
}
// 表单重置
function reset() {
form.value = {
// id: null,
// createBy: null,
// createTime: null,
// updateBy: null,
// updateTime: null,
// remake: null,
// name: null,
// host: null,
// port: null,
// status: null,
// description: null
}
proxy.resetForm("terminalRef")
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1
getList()
}
/** 重置按钮操作 */
function resetQuery() {
proxy.resetForm("queryRef")
handleQuery()
}
// 多选框选中数据
function handleSelectionChange(selection) {
ids.value = selection.map(item => item.id)
single.value = selection.length != 1
multiple.value = !selection.length
}
/** 新增按钮操作 */
function handleAdd() {
reset()
open.value = true
title.value = "添加设备终端配置"
}
/** 修改按钮操作 */
function handleUpdate(row) {
reset()
const _id = row.id || ids.value
getTerminal(_id).then(response => {
form.value = response.data
open.value = true
title.value = "修改设备终端配置"
})
}
/** 提交按钮 */
function submitForm() {
proxy.$refs["terminalRef"].validate(valid => {
if (valid) {
if (form.value.id != null) {
updateTerminal(form.value).then(response => {
proxy.$modal.msgSuccess("修改成功")
open.value = false
getList()
})
} else {
addTerminal(form.value).then(response => {
proxy.$modal.msgSuccess("新增成功")
open.value = false
getList()
})
}
}
})
}
/** 删除按钮操作 */
function handleDelete(row) {
const _ids = row.id || ids.value
proxy.$modal.confirm('是否确认删除设备终端配置编号为"' + _ids + '"的数据项?').then(function () {
return delTerminal(_ids)
}).then(() => {
getList()
proxy.$modal.msgSuccess("删除成功")
}).catch(() => {
})
}
/** 导出按钮操作 */
function handleExport() {
proxy.download('device/terminal/export', {
...queryParams.value
}, `terminal_${new Date().getTime()}.xlsx`)
}
function handleChange(row) {
updateTerminal({status: row.status, id: row.id}).then(response => {
proxy.$modal.msgSuccess("修改成功")
getList()
})
}
getList()
</script>

View File

@ -58,7 +58,7 @@
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 { getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { DEVICE_KIND } from '@/utils/robotDevice'
import { copyActionQueueStep, getActionQueueStepName, moveActionQueueStep, toActionQueueExecutionSteps } from '@/utils/actionQueue'

View File

@ -54,17 +54,18 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { getRobotList, getRobotDevicesByRobotId } from '@/api/resource/robot'
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { toDeviceOptions } from '@/utils/robotDevice'
/**
* roles 来自流程起点的 resourceRoles,描述本次任务需要哪些机器人角色和设备用途。
* modelValue 是父组件缓存或回显的 runtimeAssignments,本组件只维护一份可编辑副本并通过 update:modelValue 同步出去。
* requiredMapId 用于有 AGV 用途时筛出位于同一地图的机器人。
* robotQuery 用于叠加业务筛选条件;requiredMapId 用于有 AGV 用途时筛出位于同一地图的机器人。
*/
const props = defineProps({
roles: { type: Array, default: () => [] },
modelValue: { type: Array, default: () => [] },
robotQuery: { type: Object, default: () => ({}) },
requiredMapId: { type: [String, Number], default: '' },
})
const emit = defineEmits(['update:modelValue'])
@ -107,6 +108,7 @@ const loadRobots = async () => {
robotLoadError.value = ''
try {
const response = await getRobotList({
...props.robotQuery,
pageNum: 1,
pageSize: 1000,
connectStatus: '1',
@ -161,11 +163,6 @@ 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))
}
/**
* 在不修改当前表单的前提下,为一个角色预演设备选择。
*
@ -177,7 +174,10 @@ const planRoleDevices = (role, robotId, devices, committedUses, preferredDevices
const selectedDevices = {}
for (const slot of role.deviceSlots || []) {
// 先按设备类型筛选,deviceKind 为空表示该用途接受任意设备。
const matchingDevices = devices.filter((device) => deviceMatchesSlot(device, slot))
const matchingDevices = devices.filter((device) => (
device?.deviceId
&& (slot.deviceKind == null || Number(device.deviceKind) === Number(slot.deviceKind))
))
const preferredId = preferredDevices[slot.slotKey]
// 自动分配时优先保留用户已有选择;若该设备不可用,再按列表顺序寻找替代设备。
const orderedDevices = preferredId
@ -265,7 +265,7 @@ const optionsOf = (role, slot) => {
.map((otherSlot) => member.devices[otherSlot.slotKey])
.filter(Boolean))
return toDeviceOptions((devicesByRole[role.roleKey] || []).filter((device) => (
deviceMatchesSlot(device, slot)
(slot.deviceKind == null || Number(device.deviceKind) === Number(slot.deviceKind))
&& (!unavailableDeviceIds.has(device.deviceId) || member.devices[slot.slotKey] === device.deviceId)
)))
}
@ -383,7 +383,7 @@ const validate = () => {
}
// 角色定义、机器人筛选条件或任务地图变化后,都需要重新计算候选机器人和设备分配。
watch([() => props.roles, () => props.requiredMapId], initialize, { immediate: true, deep: true })
watch([() => props.roles, () => props.robotQuery, () => props.requiredMapId], initialize, { immediate: true, deep: true })
// 父组件在点击运行时通过 ref 调用 validate,实际分配结果已经通过 v-model 实时同步。
defineExpose({ validate })

View File

@ -91,7 +91,7 @@
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 { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { emitter } from '@/utils/eventBus'
import { formatTableData, getStartNodeFormData } from '@/utils/flow'
import { toDeviceOptions } from '@/utils/robotDevice'

View File

@ -147,7 +147,7 @@ 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 { getRobotDevicesByRobotId, getRobotList } from '@/api/inspection/robot'
import {
createDeviceSlot,
createResourceKey,

View File

@ -126,7 +126,7 @@ import FormItemRecursive from '../FormItemRecursive.vue'
import RobotExecutionTarget from './RobotExecutionTarget.vue'
import { useQuote } from './useQuote.js'
import { getArmStatus, getArmJointState, getAgvStatus, getAgvStations } from '@/api/flow/flow'
import { getRobotList, getRobotDevicesByRobotId } from '@/api/resource/robot'
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { ElMessage } from 'element-plus'
import { getDeviceKindForAction, toDeviceOptions } from '@/utils/robotDevice'
import { actionRequiresDevice } from '@/utils/flowResources'

View File

@ -147,7 +147,7 @@ const deleteTopLevelItem = (fullPath, propPath) => {
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.resourceRoles = JSON.parse(JSON.stringify(start?.properties?.resourceRoles || [createDefaultRobotRole()]))
formData.inputParams = JSON.parse(JSON.stringify(start?.properties?.inputParams || []))
}
@ -220,10 +220,9 @@ defineExpose({ validateAndSave })
}
.device-row {
grid-template-columns: minmax(0, 1fr) 132px minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) 132px auto;
margin-bottom: 8px;
}
@media (max-width: 720px) { .device-row { grid-template-columns: 1fr; } }
.empty-tip {
margin-bottom: 6px;

View File

@ -414,7 +414,7 @@ import {
getAgvStations,
} from "@/api/flow/flow";
import { getDetect } from "@/api/test/detect";
import { getRobotList, getRobotDevicesByRobotId } from '@/api/resource/robot'
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { restoreOperationalState } from '@/api/device/system'
import { getDeviceKindForAction, toDeviceOptions } from '@/utils/robotDevice'
import { sanitizeFlowResourceDefinition } from '@/utils/flowResources'
@ -981,7 +981,7 @@ const initFlowData = {
name: "Start",
action: "start",
schemaVersion: 2,
resourceRoles: [],
resourceRoles: [createDefaultRobotRole()],
inputParams: [],
},
},

View File

@ -179,7 +179,7 @@ import {
Refresh,
SetUp,
} from '@element-plus/icons-vue'
import { listResourceDevices } from '@/api/resource/device'
import { listRegister } from '@/api/device/register'
import { getTaskList } from '@/api/inspection/task'
import { getRunTaskList } from '@/api/inspection/runTask'
import { listConfig } from '@/api/test/config'
@ -216,13 +216,13 @@ const metrics = [
const features = [
{
title: '资源中心',
description: '管理机器人、设备与边缘终端',
title: '设备接入',
description: '配置机器人与终端设备',
icon: SetUp,
color: '#2563eb',
background: '#eaf2ff',
menuTitles: ['资源中心', '设备', '机器人'],
fallback: '/resource/device',
menuTitles: ['设备注册', '设备配置管理', '设备管理'],
fallback: '/device/register',
},
{
title: '流程编排',
@ -321,7 +321,7 @@ async function loadDashboardData() {
loading.value = true
const requests = [
createMetricRequest('devices', 'resource:device:list', () => listResourceDevices({ pageNum: 1, pageSize: 1, onlineOnly: false })),
createMetricRequest('devices', 'device:register:list', () => listRegister({ pageNum: 1, pageSize: 1 })),
createMetricRequest('workflows', 'test:config:list', () => listConfig({ pageNum: 1, pageSize: 1 })),
createMetricRequest('inspections', 'inspection:task:list', () => getTaskList({ pageNum: 1, pageSize: 1 })),
createMetricRequest('instances', 'test:inst:list', () => listInst({ pageNum: 1, pageSize: 5 })),

View File

@ -62,7 +62,7 @@
</template>
<script setup>
import { moveRobot, stopRobot } from '@/api/resource/robot.js'
import { moveRobot, stopRobot } from '@/api/inspection/robot.js'
const props = defineProps({
robot: {

View File

@ -129,7 +129,7 @@ import SvgIcon from "@/components/SvgIcon";
import UrdfViewer from '../UrdfView.vue'
import DirectionControl from './DirectionControl.vue'
import RoboticArm from "./RoboticArm.vue";
import { getRobotList, getRobotDevicesByRobotId } from '@/api/resource/robot'
import { getRobotList, getRobotDevicesByRobotId } from '@/api/inspection/robot'
import { getJointStateApi, moveJApi, torqueOnApi, speedJApi, stopMotionApi, clearFault } from '@/api/inspection/cockpit'
import { Remove, CirclePlus } from '@element-plus/icons-vue'
import VoiceConversation from "./VoiceConversation.vue";

View File

@ -25,7 +25,7 @@ import {
buildOccupancyGridImage,
MobileRobot
} from '../canvasUtils';
import { getMapJson } from '@/api/resource/robot'
import { getMapJson } from '@/api/inspection/robot'
import { useInspectionStore } from "@/store/modules/inspection";
import { ElMessage } from 'element-plus';

View File

@ -84,7 +84,7 @@
import SvgIcon from "@/components/SvgIcon";
import MapCanvas from "./MapCanvas.vue";
import { onMounted, onUnmounted } from "vue";
import { getRobotList } from '@/api/resource/robot'
import { getRobotList } from '@/api/inspection/robot'
import { getRunTaskList } from '@/api/inspection/runTask'
import { agvRuntimeStatusClass, agvRuntimeStatusLabel } from '@/utils/agvRuntimeStatus'

View File

@ -41,7 +41,7 @@
<script setup>
import SvgIcon from "@/components/SvgIcon";
import IPlayer from "@/components/IPlayer/index.vue";
import { getRobotList } from '@/api/resource/robot'
import { getRobotList } from '@/api/inspection/robot'
import { agvRuntimeStatusClass, agvRuntimeStatusLabel } from '@/utils/agvRuntimeStatus'
const videoUrl = ref('https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8')

View File

@ -0,0 +1,599 @@
<template>
<div class="app-container">
<div ref="topContainerRef">
<el-form :model="queryParams" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="地图名称" prop="mapName">
<el-input v-model="queryParams.mapName" placeholder="请输入地图名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="地图描述" prop="mapDesc">
<el-input v-model="queryParams.mapDesc" placeholder="请输入地图描述" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd">新增</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
</div>
<div :style="containerHeight">
<el-table v-loading="loading" height="100%" style="width: 100%" :data="tableDataList">
<el-table-column label="地图名称" align="center" prop="mapName" />
<el-table-column label="地图描述" align="center" prop="mapDesc" min-width="160" show-overflow-tooltip />
<el-table-column label="创建人" align="center" prop="createBy" />
<el-table-column label="创建时间" align="center" prop="createTime" />
<el-table-column label="操作" class-name="small-padding fixed-width" width="200" fixed="right">
<template #default="scope">
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize" @pagination="getList" />
</div>
<!-- 添加或修改语料库对话框 -->
<el-dialog :title="title" v-model="open" width="580px" append-to-body>
<el-form ref="mapRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="地图名称" prop="mapName">
<el-input v-model="form.mapName" placeholder="请输入地图名称" />
</el-form-item>
<el-form-item label="地图描述" prop="mapDesc">
<el-input v-model="form.mapDesc" type="textarea" placeholder="请输入地图描述" />
</el-form-item>
<el-form-item label="选择机器人地图">
<el-col :span="11">
<el-form-item prop="mapSourceRobotId">
<el-select v-model="form.mapSourceRobotId" placeholder="请选择已连接AGV的机器人" clearable
filterable :loading="robotLoading" no-data-text="暂无已连接AGV的机器人" style="width: 100%"
@change="robotChange">
<el-option v-for="robot in robotList" :key="robot.value" :label="robot.label" :value="robot.value" />
</el-select>
</el-form-item>
</el-col>
<el-col class="text-center" :span="2">
<span class="text-gray-500">-</span>
</el-col>
<el-col :span="11">
<el-form-item prop="mapSourceName">
<el-select v-model="form.mapSourceName" placeholder="请选择地图" clearable style="width: 100%"
@change="mapChange">
<el-option v-for="map in mapList" :key="map.value" :label="map.label" :value="map.value" />
</el-select>
</el-form-item>
</el-col>
</el-form-item>
<div class="map-container">
<canvas id="mapCanvas"></canvas>
</div>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="submitForm">确 定</el-button>
<el-button @click="cancel">取 消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup name="Map">
import { useContainerHeight } from "@/hooks/tableHeight";
import { addMap, updateMap, deleteMap, getMapList } from "../../../api/inspection/map";
import { getRobotList, getMapByRobot, getMapJson, getRobotDevicesByRobotId } from "../../../api/inspection/robot";
import { onMounted } from "vue";
import {
worldToScreen,
screenToWorld,
drawGrid,
drawGridlines,
drawAdvancedAreaList,
drawAdvancedLineList,
drawNormalLines,
drawAdvancedCurveList,
drawAdvancedPointList,
drawMapBoundary,
buildOccupancyGridImage,
MobileRobot
} from '../cockpit/canvasUtils';
import { de } from "element-plus/es/locale/index.mjs";
import { ElMessage, ElMessageBox } from "element-plus";
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);
const mapRef = ref();
const isNew = ref(true);
const tableDataList = ref([]);
const open = ref(false);
const loading = ref(true);
const showSearch = ref(true);
const total = ref(0);
const title = ref("");
const data = reactive({
form: {
mapName: "",
mapDesc: "",
mapSourceRobotId: "",
mapSourceName: "",
},
queryParams: {
pageNum: 1,
pageSize: 10,
mapDesc: null,
mapName: null,
},
rules: {
mapName: [
{ required: true, message: "请输入地图名称", trigger: "change" },
],
mapDesc: [
{ required: true, message: "请输入地图描述", trigger: "blur" },
]
},
});
const { queryParams, form, rules } = toRefs(data);
/** 查询列表 */
function getList() {
loading.value = true;
getMapList(queryParams.value).then((response) => {
tableDataList.value = response.rows;
total.value = response.total;
loading.value = false;
}).catch(() => {
loading.value = false;
}).finally(() => {
loading.value = false;
});
}
// 取消按钮
function cancel() {
open.value = false;
reset();
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
window.removeEventListener('resize', resizeCanvasToContainer)
canvasCtx.clearRect(0, 0, applicationState.canvasWidthPx, applicationState.canvasHeightPx);
canvasCtx = null
}
// 表单重置
function reset() {
mapRef.value.resetFields();
form.value = {
mapName: "",
mapDesc: "",
mapSourceRobotId: "",
mapSourceName: "",
}
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
queryParams.value.mapName = null;
queryParams.value.mapDesc = null;
handleQuery();
}
/** 新增按钮操作 */
function handleAdd() {
getRobotArr();
open.value = true;
isNew.value = true;
title.value = "添加地图";
setTimeout(() => {
init();
}, 300);
}
/** 修改按钮操作 */
function handleUpdate(row) {
getRobotArr();
form.value = { ...row };
isNew.value = false;
open.value = true;
title.value = "修改地图";
getMapArr(row.mapSourceRobotId)
setTimeout(() => {
init();
if (form.value.mapSourceName) {
mapChange()
}
}, 300);
}
/** 提交按钮 */
function submitForm() {
mapRef.value.validate((valid) => {
if (valid) {
if (isNew.value) {
// 调用新增接口
addMap(form.value).then(() => {
ElMessage.success("地图添加成功!");
getList();
reset();
open.value = false;
});
} else {
// 调用修改接口
updateMap(form.value).then(() => {
ElMessage.success("地图修改成功!");
getList();
reset();
open.value = false;
});
}
}
});
}
/** 删除按钮操作 */
function handleDelete(row) {
ElMessageBox.confirm('确定要删除该地图吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
deleteMap(row.id).then(() => {
ElMessage.success("地图删除成功!");
getList();
});
}).catch(() => {
// 用户取消删除
});
}
function parseSmapJson(rawJson) {
// ── 解析 header ──
const header = rawJson.header || rawJson; // 兼容 header 嵌套或平铺
const minimumPos = header.minPos || { x: 0, y: 0 }; // 地图左下角(世界坐标)
const maximumPos = header.maxPos || { x: 1, y: 1 }; // 地图右上角(世界坐标)
const resolution = header.resolution || 0.05; // 每像素代表的世界长度(米)
// ── 计算栅格尺寸 ──
const worldWidth = maximumPos.x - minimumPos.x; // 地图世界宽度(米)
const worldHeight = maximumPos.y - minimumPos.y; // 地图世界高度(米)
const gridWidthPx = Math.ceil(worldWidth / resolution) + 1; // 栅格像素宽度
const gridHeightPx = Math.ceil(worldHeight / resolution) + 1;// 栅格像素高度
// ── 构建统一数据结构 ──
const parsedMap = {
name: header.mapName || 'Untitled', // 地图名称
version: header.version || '', // 版本号
mapType: header.mapType || '', // 地图类型(如 "2D-Map")
resolution: resolution, // 分辨率(米/像素)
originX: minimumPos.x, // 地图左下角 X
originY: minimumPos.y, // 地图左下角 Y
maxX: maximumPos.x, // 地图右上角 X
maxY: maximumPos.y, // 地图右上角 Y
gridWidth: gridWidthPx, // 栅格宽度(像素)
gridHeight: gridHeightPx, // 栅格高度(像素)
// normalPosList: 自由空间点,直接拷贝
normalPositions: rawJson.normalPosList || [],
// normalLineList: 直接拷贝,每项含 {startPos:{x,y}, endPos:{x,y}}
normalLines: rawJson.normalLineList || [],
// advancedPointList: 直接拷贝,每项含 {className, instanceName, pos:{x,y}, dir, desc, property:[]}
advancedPoints: rawJson.advancedPointList || [],
// advancedLineList: 直接拷贝,每项含 {className, instanceName, line:{startPos, endPos}}
advancedLines: rawJson.advancedLineList || [],
// advancedCurveList: 直接拷贝,每项含 {className, instanceName, controlPos1, controlPos2, startPos, endPos}
advancedCurves: rawJson.advancedCurveList || [],
// advancedAreaList: 直接拷贝,每项含 {className, instanceName, dir, posGroup:[], attribute:{}}
advancedAreas: rawJson.advancedAreaList || [],
};
return parsedMap;
}
const applicationState = {
parsedMap: null, // 解析后的完整地图数据
gridOffscreen: null, // 栅格图像(离屏Canvas)
hoveredElement: null, // 当前悬停的元素 {kind, index}
animationTimeSeconds: 0, // 全局动画时间戳(秒),用于脉冲动画效果
camera: { // 相机参数
centerX: 0, // 相机中心的世界坐标 X
centerY: 0, // 相机中心的世界坐标 Y
pixelsPerMeter: 20, // 缩放:每米对应的屏幕像素数
},
canvasWidthPx: 0, // Canvas CSS 逻辑宽度(像素)
canvasHeightPx: 0, // Canvas CSS 逻辑高度(像素)
devicePixelRatio: 1, // 设备像素比(高DPI屏幕 > 1)
mouseState: { // 鼠标交互状态
screenX: 0, // 鼠标在 Canvas 上的像素 X
screenY: 0, // 鼠标在 Canvas 上的像素 Y
worldX: 0, // 鼠标对应的世界坐标 X
worldY: 0, // 鼠标对应的世界坐标 Y
isDragging: false, // 是否正在拖拽平移
},
isMapLoaded: false, // 地图加载完成标志,
layerVisibility: { // 各图层是否可见
grid: true, // 占据栅格底图
edges: true, // 墙壁边缘发光
gridlines: true, // 1米网格辅助线
normalLines: true, // normalLineList
advLines: true, // advancedLineList
curves: true, // advancedCurveList
points: true, // advancedPointList
areas: true, // advancedAreaList
}
};
let mapCanvas; // 主渲染画布 DOM 元素
let canvasCtx; // 2D 渲染上下文
const init = () => {
mapCanvas = document.getElementById('mapCanvas');
canvasCtx = mapCanvas.getContext('2d');
}
/**
* 根据容器尺寸和设备像素比调整 Canvas 大小
* 在窗口 resize 时自动调用
*/
function resizeCanvasToContainer() {
const containerRect = mapCanvas.parentElement.getBoundingClientRect(); // 获取容器实际尺寸
devicePixelRatio = window.devicePixelRatio || 1; // 获取设备像素比
applicationState.canvasWidthPx = containerRect.width; // 更新逻辑宽度
applicationState.canvasHeightPx = containerRect.height; // 逻辑高度 = 容器高度 - 状态栏高度
mapCanvas.width = applicationState.canvasWidthPx * devicePixelRatio; // Canvas 物理像素宽
mapCanvas.height = applicationState.canvasHeightPx * devicePixelRatio; // Canvas 物理像素高
mapCanvas.style.width = applicationState.canvasWidthPx + 'px'; // CSS 显示宽度
mapCanvas.style.height = applicationState.canvasHeightPx + 'px'; // CSS 显示高度
}
async function loadMapIntoApplication(parsedMap) {
await new Promise(resolve => setTimeout(resolve, 30)); // 短暂等待让 UI 有机会刷新
// 保存地图数据到全局状态
applicationState.parsedMap = parsedMap;
// 构建栅格图像(带边缘高亮)
applicationState.gridOffscreen = buildOccupancyGridImage(parsedMap, applicationState.layerVisibility.edges);
// ── 设置相机初始位置(地图中心)──
applicationState.camera.centerX = (parsedMap.originX + parsedMap.maxX) / 2; // 水平居中
applicationState.camera.centerY = (parsedMap.originY + parsedMap.maxY) / 2; // 垂直居中
// 计算合适的缩放级别,使地图完整显示在画布内
const worldWidthMeters = parsedMap.maxX - parsedMap.originX; // 地图世界宽度
const worldHeightMeters = parsedMap.maxY - parsedMap.originY; // 地图世界高度
const fitScaleX = applicationState.canvasWidthPx / worldWidthMeters; // 水平方向恰好填满的缩放
const fitScaleY = applicationState.canvasHeightPx / worldHeightMeters; // 垂直方向恰好填满的缩放
applicationState.camera.pixelsPerMeter = Math.max(1, Math.max(fitScaleX, fitScaleY)); // 取较小值并留12%边距
// 标记加载完成并更新UI
applicationState.isMapLoaded = true;
}
const bindEvent = () => {
// ── 鼠标移动:更新坐标、拖拽平移、悬停检测 ──
mapCanvas.addEventListener('mousemove', (mouseEvent) => {
const canvasRect = mapCanvas.getBoundingClientRect(); // Canvas 在页面中的位置
applicationState.mouseState.screenX = mouseEvent.clientX - canvasRect.left; // 鼠标在 Canvas 上的 X
applicationState.mouseState.screenY = mouseEvent.clientY - canvasRect.top; // 鼠标在 Canvas 上的 Y
// 反算世界坐标
const worldPos = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY, applicationState);
applicationState.mouseState.worldX = worldPos.x;
applicationState.mouseState.worldY = worldPos.y;
// 如果正在拖拽,平移相机
if (applicationState.mouseState.isDragging) {
applicationState.camera.centerX -= mouseEvent.movementX / applicationState.camera.pixelsPerMeter; // 水平平移
applicationState.camera.centerY += mouseEvent.movementY / applicationState.camera.pixelsPerMeter; // 垂直平移(Y翻转)
}
});
// ── 鼠标按下:开始拖拽 ──
mapCanvas.addEventListener('mousedown', () => {
applicationState.mouseState.isDragging = true; // 标记拖拽开始
});
// ── 鼠标抬起:结束拖拽 ──
mapCanvas.addEventListener('mouseup', () => {
applicationState.mouseState.isDragging = false; // 标记拖拽结束
});
// ── 鼠标离开画布:结束拖拽并隐藏 Tooltip ──
mapCanvas.addEventListener('mouseleave', () => {
applicationState.mouseState.isDragging = false;
});
// ── 滚轮:缩放(向鼠标位置缩放)──
mapCanvas.addEventListener('wheel', (wheelEvent) => {
wheelEvent.preventDefault(); // 阻止页面滚动
const zoomFactor = wheelEvent.deltaY > 0 ? 0.88 : 1.12; // 向下滚动=缩小,向上滚动=放大
const mouseWorldBefore = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY, applicationState); // 缩放前鼠标世界坐标
applicationState.camera.pixelsPerMeter = Math.max(0.5, Math.min(2000, applicationState.camera.pixelsPerMeter * zoomFactor)); // 应用缩放并钳制范围
const mouseWorldAfter = screenToWorld(applicationState.mouseState.screenX, applicationState.mouseState.screenY, applicationState); // 缩放后鼠标世界坐标
// 调整相机中心,使鼠标指向的世界坐标保持不变
applicationState.camera.centerX += mouseWorldBefore.x - mouseWorldAfter.x;
applicationState.camera.centerY += mouseWorldBefore.y - mouseWorldAfter.y;
}, { passive: false }); // passive:false 允许 preventDefault
}
/**
* 加载 smap JSON 文件
*/
const loadUserFile = async () => {
try {
const res = await getMapJson({
robotId: form.value.mapSourceRobotId,
mapName: form.value.mapSourceName
});
if (res.code === 200) {
const rawJsonObject = JSON.parse(res.data); // 解析 JSON
const parsedMap = parseSmapJson(rawJsonObject); // 转换为内部结构
await loadMapIntoApplication(parsedMap); // 加载到应用
}
} catch (parseError) {
alert('Parse error: ' + parseError.message); // 显示错误信息
}
}
// 声明一个变量保存 requestAnimationFrame 的 ID
let animationId = null;
function renderFrame(timestamp) {
// 更新动画时间
applicationState.animationTimeSeconds = (timestamp || 0) / 1000;
// 设置 Canvas 变换矩阵(考虑设备像素比)
canvasCtx.setTransform(applicationState.devicePixelRatio, 0, 0, applicationState.devicePixelRatio, 0, 0);
canvasCtx.clearRect(0, 0, applicationState.canvasWidthPx, applicationState.canvasHeightPx); // 清空画布
// 如果地图未加载,跳过绘制
if (!applicationState.isMapLoaded) {
animationId = requestAnimationFrame(renderFrame); // 继续请求下一帧
return;
}
const map = applicationState.parsedMap; // 当前地图数据
const layers = applicationState.layerVisibility; // 图层可见性
// 图层1: 占据栅格底图
if (layers.grid && applicationState.gridOffscreen) {
drawGrid(canvasCtx, map, applicationState);
}
// 图层2: 网格辅助线(每1米一条)
if (layers.gridlines) {
drawGridlines(canvasCtx, applicationState)
}
// 图层3: advancedAreaList(半透明多边形区域)
if (layers.areas) {
drawAdvancedAreaList(canvasCtx, map, applicationState)
}
// 图层4: normalLineList(基础直线,青色 #00e5ff)
if (layers.normalLines) {
drawNormalLines(canvasCtx, map, applicationState);
}
// 图层5: advancedLineList(高级线,如 ForbiddenLine)
if (layers.advLines) {
drawAdvancedLineList(canvasCtx, map, applicationState);
}
// 图层6: advancedCurveList(贝塞尔曲线路径,酸绿色 #76ff03)
if (layers.curves) {
drawAdvancedCurveList(canvasCtx, map, applicationState);
}
// 图层7: advancedPointList(高级点标记,菱形)
if (layers.points) {
drawAdvancedPointList(canvasCtx, map, applicationState);
}
// 地图边界虚线框
drawMapBoundary(canvasCtx, map, applicationState);
// 请求下一帧渲染
animationId = requestAnimationFrame(renderFrame);
}
const robotList = ref([])
const mapList = ref([])
const robotLoading = ref(false)
const getRobotArr = async () => {
robotLoading.value = true
try {
const response = await getRobotList({
pageNum: 1,
pageSize: 10000,
connectStatus: '1'
})
const candidates = (response.rows || []).filter(item => item.robotId)
const resolved = await Promise.allSettled(candidates.map(async item => {
const deviceResponse = await getRobotDevicesByRobotId(item.robotId, { deviceKind: 1 })
const agvDevices = Array.isArray(deviceResponse.data) ? deviceResponse.data : []
if (!agvDevices.length) return null
const deviceIds = agvDevices.map(device => device.deviceId).join(' / ')
return {
label: `${item.robotName || item.robotId} (${item.robotId}) · AGV: ${deviceIds}`,
value: item.id
}
}))
robotList.value = resolved
.filter(result => result.status === 'fulfilled' && result.value)
.map(result => result.value)
} finally {
robotLoading.value = false
}
}
const getMapArr = async (robotId) => {
const res = await getMapByRobot(robotId)
if (res.code === 200) {
mapList.value = res.data.map(item => {
return { label: item, value: item }
})
}
}
const robotChange = (value) => {
form.value.mapSourceRobotId = value; // 更新选中的机器人ID
form.value.mapSourceName = ""; // 清空地图选择
if (canvasCtx) {
canvasCtx.clearRect(0, 0, applicationState.canvasWidthPx, applicationState.canvasHeightPx); // 清空画布
}
getMapArr(value);
}
const mapChange = () => {
loadUserFile();
if (animationId) {
cancelAnimationFrame(animationId);
}
animationId = requestAnimationFrame(renderFrame);
window.addEventListener('resize', resizeCanvasToContainer); // 监听窗口大小变化
resizeCanvasToContainer();
setTimeout(() => {
bindEvent()
}, 100)
}
onMounted(() => {
getList();
getRobotArr();
});
</script>
<style lang="scss" scoped>
.map-container {
width: 100%;
height: 300px;
border: 1px solid #ccc;
}
</style>

View File

@ -243,7 +243,7 @@ import {
getPointList,
updatePoint,
} from "@/api/inspection/point";
import { listResourceMaps as getMapList } from "@/api/resource/map";
import { getMapList } from "@/api/inspection/map";
import TableSearch from "@/components/TableSearch/index.vue";
import { listGroup } from "@/api/system/group.js";
import { appendParamsToPath } from "@/utils/fn.js";

View File

@ -177,15 +177,14 @@
<el-table-column label="错误" prop="errorMessage" min-width="180" show-overflow-tooltip />
</el-table>
</el-drawer>
</div>
</template>
<script setup name="ResourceRobot">
import { computed, getCurrentInstance, onActivated, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
<script setup name="InspectionRobot">
import { computed, getCurrentInstance, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { addRobot, deleteRobot, getRobotDevices, getRobotList, getRobotStatus, updateRobot } from '@/api/resource/robot'
import { listResourceMaps as getMapList } from '@/api/resource/map'
import { addRobot, deleteRobot, getRobotDevices, getRobotList, getRobotStatus, updateRobot } from '@/api/inspection/robot'
import { getMapList } from '@/api/inspection/map'
import { agvRuntimeStatusLabel, agvRuntimeStatusType } from '@/utils/agvRuntimeStatus'
const { proxy } = getCurrentInstance()
@ -226,22 +225,12 @@ const filteredDevices = computed(() => devices.value.filter(item => {
async function getList(showLoading = true) {
if (showLoading) loading.value = true
try {
const res = await getRobotList(queryParams)
robotList.value = res.rows || []
total.value = res.total || 0
} finally { if (showLoading) loading.value = false }
try { const res = await getRobotList(queryParams); robotList.value = res.rows || []; total.value = res.total || 0 } finally { if (showLoading) loading.value = false }
}
function handleQuery() { queryParams.pageNum = 1; getList() }
function resetQuery() { Object.assign(queryParams, { pageNum: 1, robotId: '', robotName: '', connectStatus: '' }); getList() }
function handleAdd() {
Object.assign(form, emptyForm())
formOpen.value = true
}
async function handleUpdate(row) {
Object.assign(form, emptyForm(), row)
formOpen.value = true
}
function handleAdd() { Object.assign(form, emptyForm()); formOpen.value = true }
function handleUpdate(row) { Object.assign(form, emptyForm(), row); formOpen.value = true }
function submitForm() {
robotRef.value.validate(async valid => {
if (!valid) return
@ -312,8 +301,6 @@ onMounted(() => {
mapList.value = (res.rows || []).map(item => ({ label: item.mapName, value: item.id }))
})
})
onActivated(() => getList(false))
onBeforeUnmount(() => {
clearInterval(deviceRefreshTimer)
clearInterval(listRefreshTimer)
@ -331,9 +318,7 @@ onBeforeUnmount(() => {
.battery-cell :deep(.el-progress__text) { min-width: 34px; font-size: 12px !important; }
.position-text { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12px; }
.fault-tag { margin-left: 6px; }
.scope-tag { margin: 2px 4px 2px 0; }
.robot-form { margin-top: 22px; }
.form-tip { width: 100%; color: var(--el-text-color-secondary); font-size: 12px; line-height: 18px; }
.device-toolbar { display: grid; grid-template-columns: minmax(220px, 1fr) 150px auto; gap: 10px; margin-bottom: 16px; }
@media (max-width: 720px) { .device-toolbar { grid-template-columns: 1fr; } }
</style>

View File

@ -185,7 +185,7 @@ import { debounce } from 'lodash'; // 引入 lodash 的防抖函数
import { fetchAllPagedRows } from '@/utils/pagedRequest'
import RuntimeAssignmentsEditor from '@/views/flow/components/RuntimeAssignmentsEditor.vue'
import RuntimeParamsEditor from '@/views/flow/components/RuntimeParamsEditor.vue'
import { getTaskConfig } from '@/api/test/config.js'
import { getFlowParamsApi } from '@/api/device/terminal.js'
import {
createRuntimeAssignments,
firstAssignedRobotId,
@ -430,7 +430,7 @@ const startTask = async (task) => {
return
}
try {
const paramsResponse = await getTaskConfig({ taskId: taskDefinition.taskConfigId })
const paramsResponse = await getFlowParamsApi(taskDefinition.taskConfigId)
const paramItems = paramsResponse.rows || []
const roles = await loadFlowItemScopedRuntimeRoles(paramItems)
runtimeAssignments.value = createRuntimeAssignments(roles, task.robotIdentity)

View File

@ -176,7 +176,7 @@ import { useContainerHeight } from "@/hooks/tableHeight";
import { ElMessage, ElMessageBox } from "element-plus";
import draggable from "vuedraggable";
import { reactive, ref } from "vue";
import { listResourceMaps as getMapList } from "@/api/resource/map";
import { getMapList } from "@/api/inspection/map";
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);

View File

@ -173,7 +173,7 @@
<div class="start-dialog-content">
<div class="start-dialog-tip">手机和车辆沿用任务配置,这里只安排执行流程的机器人和设备。</div>
<div class="start-section-title">机器人和设备</div>
<RuntimeAssignmentsEditor ref="runtimeEditorRef" v-model="runtimeAssignments" :roles="runtimeRoles"/>
<RuntimeAssignmentsEditor ref="runtimeEditorRef" v-model="runtimeAssignments" :roles="runtimeRoles" />
<div class="start-section-title start-section-title--params">运行参数</div>
<RuntimeParamsEditor ref="runtimeParamsEditorRef" :items="runtimeParamItems" />
</div>
@ -211,7 +211,7 @@ import { formatDate } from "@/utils/index";
import { debounce } from 'lodash'; // 引入 lodash 的防抖函数
import RuntimeAssignmentsEditor from '@/views/flow/components/RuntimeAssignmentsEditor.vue'
import RuntimeParamsEditor from '@/views/flow/components/RuntimeParamsEditor.vue'
import { getTaskConfig } from '@/api/test/config.js'
import { getFlowParamsApi } from '@/api/device/terminal.js'
import {
createRuntimeAssignments,
firstAssignedRobotId,
@ -476,7 +476,7 @@ const startTask = async (task) => {
return
}
try {
const paramsResponse = await getTaskConfig({ taskId: taskDefinition.taskConfigId })
const paramsResponse = await getFlowParamsApi(taskDefinition.taskConfigId)
const paramItems = paramsResponse.rows || []
const roles = await loadFlowItemScopedRuntimeRoles(paramItems)
runtimeAssignments.value = createRuntimeAssignments(roles)

View File

@ -162,7 +162,7 @@ import { useContainerHeight } from "@/hooks/tableHeight";
import { ElMessage, ElMessageBox } from "element-plus";
import draggable from "vuedraggable";
import { reactive, ref } from "vue";
import { listResourceMaps as getMapList } from "@/api/resource/map";
import { getMapList } from "@/api/inspection/map";
const topContainerRef = ref();
const containerHeight = useContainerHeight(topContainerRef);

View File

@ -1,104 +0,0 @@
<template>
<div class="app-container">
<el-form :inline="true" :model="query">
<el-form-item label="机器人ID"><el-input v-model="query.robotId" clearable @keyup.enter="search" /></el-form-item>
<el-form-item label="设备用途"><el-input v-model="query.deviceRole" clearable @keyup.enter="search" /></el-form-item>
<el-form-item label="在线状态">
<el-select v-model="onlineFilter" clearable style="width: 120px">
<el-option label="在线" value="online" /><el-option label="离线" value="offline" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" icon="Search" @click="search">查询</el-button><el-button icon="Refresh" @click="reset">重置</el-button></el-form-item>
</el-form>
<el-table v-loading="loading" :data="rows" row-key="id">
<el-table-column label="机器人" prop="robotId" min-width="200" show-overflow-tooltip />
<el-table-column label="设备" min-width="210">
<template #default="{ row }"><strong>{{ row.deviceName || row.deviceId }}</strong><div class="secondary">{{ row.deviceId }}</div></template>
</el-table-column>
<el-table-column label="类型" width="120"><template #default="{ row }">{{ kindName(row.deviceKind) }}</template></el-table-column>
<el-table-column label="用途" prop="deviceRole" min-width="150"><template #default="{ row }">{{ row.deviceRole || '未设置' }}</template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.onlineStatus === '1' ? 'success' : 'info'">{{ row.onlineStatus === '1' ? '在线' : '离线' }}</el-tag></template></el-table-column>
<el-table-column label="启用" width="90"><template #default="{ row }">{{ row.enabled === '0' ? '否' : '是' }}</template></el-table-column>
<el-table-column label="健康" width="100"><template #default="{ row }"><el-tag :type="row.hasError === '1' ? 'danger' : 'success'">{{ row.hasError === '1' ? '故障' : '正常' }}</el-tag></template></el-table-column>
<el-table-column label="最后发现" prop="lastSeenTime" width="175" />
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button v-if="controlPath(row)" link type="primary" icon="VideoPlay" :disabled="row.onlineStatus !== '1'" @click="openTeach(row)">示教</el-button>
<el-button link type="primary" icon="Setting" @click="openConfig(row)">配置</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" v-model:page="query.pageNum" v-model:limit="query.pageSize" @pagination="load" />
<el-drawer v-model="configOpen" title="设备配置" size="min(720px, 94vw)" destroy-on-close>
<el-form :model="active" label-width="90px">
<el-form-item label="设备ID"><span class="identity">{{ active.deviceId }}</span></el-form-item>
<el-form-item label="设备名称"><el-input v-model="active.deviceName" /></el-form-item>
<el-form-item label="设备用途"><el-input v-model="active.deviceRole" placeholder="例如:前摄像头、左机械臂、环境麦克风" /></el-form-item>
<el-form-item label="启用"><el-switch v-model="active.enabled" active-value="1" inactive-value="0" /></el-form-item>
<el-form-item label="备注"><el-input v-model="active.remark" type="textarea" /></el-form-item>
</el-form>
<template #footer><el-button @click="configOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveConfig">保存</el-button></template>
</el-drawer>
</div>
</template>
<script setup name="ResourceDevice">
import { onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { listResourceDevices, updateResourceDevice } from '@/api/resource/device'
const kindNames = { 1: '移动底盘', 2: '机械臂', 3: '电池', 4: '仿生头', 5: '摄像头', 6: 'CAN总线', 7: '灵巧手', 8: '夹爪', 9: '麦克风', 10: '电机', 11: '电机系统', 12: '机器人本体', 13: '扬声器' }
const router = useRouter()
const loading = ref(false), saving = ref(false), rows = ref([]), total = ref(0), configOpen = ref(false), onlineFilter = ref('')
const query = reactive({ pageNum: 1, pageSize: 20, robotId: '', deviceRole: '', onlineOnly: false })
const active = reactive({})
const kindName = value => kindNames[value] || `未知(${value ?? '-'})`
const controlPath = device => ({
2: 'mechanical_arm', 4: 'head', 5: 'camera', 7: 'hand', 9: 'microphone', 13: 'speaker',
})[device.deviceKind]
function openTeach(device) {
const path = controlPath(device)
if (!path) return
router.push({
path: `/device/${path}/${device.id}`,
query: { robotId: device.robotId, deviceId: device.deviceId },
})
}
async function load() {
loading.value = true
try {
const params = { ...query, onlineOnly: onlineFilter.value === 'online' }
const res = await listResourceDevices(params)
const data = res.rows || res.data || []
rows.value = onlineFilter.value === 'offline' ? data.filter(item => item.onlineStatus !== '1') : data
total.value = onlineFilter.value === 'offline' ? rows.value.length : (res.total ?? rows.value.length)
} finally { loading.value = false }
}
function search() { query.pageNum = 1; load() }
function reset() { Object.assign(query, { pageNum: 1, robotId: '', deviceRole: '', onlineOnly: false }); onlineFilter.value = ''; load() }
function openConfig(row) {
Object.assign(active, JSON.parse(JSON.stringify(row)))
configOpen.value = true
}
async function saveConfig() {
saving.value = true
try {
await updateResourceDevice(active.id, active)
ElMessage.success('设备配置已保存')
configOpen.value = false
load()
} finally { saving.value = false }
}
onMounted(load)
onActivated(load)
</script>
<style scoped>
.secondary { color: var(--el-text-color-secondary); font-size: 12px; margin-top: 3px; }
.identity { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }
</style>

View File

@ -1,189 +0,0 @@
<template>
<div class="app-container resource-map-page">
<el-form v-show="showSearch" :model="query" inline>
<el-form-item label="地图名称">
<el-input v-model="query.mapName" clearable placeholder="请输入地图名称" @keyup.enter="loadMaps" />
</el-form-item>
<el-form-item label="地图类型">
<el-input v-model="query.mapType" clearable placeholder="例如 2D" @keyup.enter="loadMaps" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" clearable placeholder="全部" style="width: 120px">
<el-option label="启用" value="0" />
<el-option label="停用" value="1" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">查询</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="openMapAdd">新增地图</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="loadMaps" />
</el-row>
<el-table v-loading="mapLoading" :data="maps">
<el-table-column label="地图名称" prop="mapName" min-width="160" />
<el-table-column label="版本" prop="mapVersion" width="110" />
<el-table-column label="类型" prop="mapType" width="110" />
<el-table-column label="坐标系" prop="coordinateSystem" width="130" />
<el-table-column label="来源" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ sourceLabel(row) }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === '0' ? 'success' : 'info'">
{{ row.status === '0' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="描述" prop="mapDesc" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" icon="Edit" @click="openMapEdit(row)">编辑</el-button>
<el-button link type="danger" icon="Delete" @click="removeMap(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="mapTotal > 0"
v-model:page="query.pageNum"
v-model:limit="query.pageSize"
:total="mapTotal"
@pagination="loadMaps"
/>
<el-dialog v-model="mapFormOpen" :title="mapForm.id ? '编辑地图' : '新增地图'" width="680px" append-to-body>
<el-form ref="mapFormRef" :model="mapForm" :rules="mapRules" label-width="100px">
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="地图名称" prop="mapName"><el-input v-model="mapForm.mapName" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="地图版本"><el-input v-model="mapForm.mapVersion" placeholder="例如 v1" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="地图类型"><el-input v-model="mapForm.mapType" placeholder="例如 2D" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="坐标系"><el-input v-model="mapForm.coordinateSystem" placeholder="例如 map" /></el-form-item></el-col>
<el-col :span="12">
<el-form-item label="来源类型">
<el-select v-model="mapForm.sourceType" style="width: 100%">
<el-option label="机器人" value="ROBOT" />
<el-option label="上传" value="UPLOAD" />
<el-option label="其他" value="OTHER" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12"><el-form-item label="状态"><el-radio-group v-model="mapForm.status"><el-radio value="0">启用</el-radio><el-radio value="1">停用</el-radio></el-radio-group></el-form-item></el-col>
<el-col v-if="mapForm.sourceType === 'ROBOT'" :span="12">
<el-form-item label="来源机器人">
<el-select v-model="mapForm.sourceRobotId" filterable clearable style="width: 100%">
<el-option v-for="robot in robots" :key="robot.robotId" :label="robot.robotName || robot.robotId" :value="robot.robotId" />
</el-select>
</el-form-item>
</el-col>
<el-col v-if="mapForm.sourceType === 'ROBOT'" :span="12"><el-form-item label="原地图名称"><el-input v-model="mapForm.sourceMapName" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="地图文件"><el-input v-model="mapForm.mapFilePath" placeholder="文件地址或对象存储地址" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="地图描述"><el-input v-model="mapForm.mapDesc" type="textarea" :rows="3" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="备注"><el-input v-model="mapForm.remark" type="textarea" :rows="2" /></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="mapFormOpen = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveMap">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="ResourceMap">
import { onActivated, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listResourceRobots } from '@/api/resource/robot'
import { addResourceMap, deleteResourceMap, listResourceMaps, updateResourceMap } from '@/api/resource/map'
const showSearch = ref(true)
const mapLoading = ref(false)
const saving = ref(false)
const maps = ref([])
const robots = ref([])
const mapTotal = ref(0)
const mapFormOpen = ref(false)
const mapFormRef = ref()
const query = reactive({ pageNum: 1, pageSize: 10, mapName: '', mapType: '', status: '' })
const emptyMap = () => ({
id: '', mapName: '', mapVersion: '', mapType: '2D', coordinateSystem: 'map',
sourceType: 'ROBOT', sourceRobotId: '', sourceMapName: '', mapFilePath: '',
status: '0', mapDesc: '', remark: '',
})
const mapForm = reactive(emptyMap())
const mapRules = { mapName: [{ required: true, message: '请输入地图名称', trigger: 'blur' }] }
function sourceLabel(row) {
if (row.sourceType === 'ROBOT') return [row.sourceRobotId, row.sourceMapName].filter(Boolean).join(' / ') || '机器人'
return row.sourceType || '-'
}
async function loadMaps() {
mapLoading.value = true
try {
const res = await listResourceMaps(query)
maps.value = res.rows || []
mapTotal.value = res.total || 0
} finally {
mapLoading.value = false
}
}
async function loadRobots() {
const res = await listResourceRobots({ pageNum: 1, pageSize: 10000 })
robots.value = res.rows || []
}
function handleQuery() {
query.pageNum = 1
loadMaps()
}
function resetQuery() {
Object.assign(query, { pageNum: 1, pageSize: query.pageSize, mapName: '', mapType: '', status: '' })
loadMaps()
}
function openMapAdd() {
Object.assign(mapForm, emptyMap())
mapFormOpen.value = true
}
function openMapEdit(row) {
Object.assign(mapForm, emptyMap(), JSON.parse(JSON.stringify(row)))
mapFormOpen.value = true
}
async function saveMap() {
await mapFormRef.value.validate()
saving.value = true
try {
if (mapForm.id) await updateResourceMap(mapForm)
else await addResourceMap(mapForm)
ElMessage.success('地图已保存')
mapFormOpen.value = false
await loadMaps()
} finally {
saving.value = false
}
}
async function removeMap(row) {
await ElMessageBox.confirm(`确定删除地图“${row.mapName}”吗?`, '提示', { type: 'warning' })
await deleteResourceMap(row.id)
ElMessage.success('地图已删除')
await loadMaps()
}
async function refresh() {
await Promise.all([loadMaps(), loadRobots()])
}
onMounted(refresh)
onActivated(refresh)
</script>

View File

@ -1,75 +0,0 @@
<template>
<div class="app-container">
<el-form :inline="true" :model="query">
<el-form-item label="终端"><el-input v-model="query.terminalName" clearable @keyup.enter="search" /></el-form-item>
<el-form-item label="连接状态"><el-select v-model="query.connectStatus" clearable style="width: 120px"><el-option label="在线" value="1" /><el-option label="离线" value="0" /></el-select></el-form-item>
<el-form-item><el-button type="primary" icon="Search" @click="search">查询</el-button><el-button icon="Refresh" @click="reset">重置</el-button></el-form-item>
</el-form>
<el-row class="mb8"><el-button type="primary" icon="Plus" @click="openAdd">登记终端</el-button></el-row>
<el-table v-loading="loading" :data="rows" row-key="id">
<el-table-column label="终端" min-width="210"><template #default="{ row }"><strong>{{ row.terminalName || row.terminalCode }}</strong><div class="secondary">{{ row.terminalCode }}</div></template></el-table-column>
<el-table-column label="当前地址" min-width="180"><template #default="{ row }">{{ endpoint(row) }}</template></el-table-column>
<el-table-column label="连接" width="95"><template #default="{ row }"><el-tag :type="row.connectStatus === '1' ? 'success' : 'info'">{{ row.connectStatus === '1' ? '在线' : '离线' }}</el-tag></template></el-table-column>
<el-table-column label="软件版本" prop="softwareVersion" min-width="130" />
<el-table-column label="绑定机器人" min-width="220"><template #default="{ row }"><span v-if="row.robotCount">{{ row.robotIdsCsv }}</span><span v-else class="secondary">未绑定</span></template></el-table-column>
<el-table-column label="最后心跳" prop="lastHeartbeatTime" width="180" />
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" icon="Link" @click="openBindings(row)">绑定</el-button><el-button link type="primary" icon="Edit" @click="openEdit(row)">编辑</el-button><el-button link type="danger" icon="Delete" :disabled="row.connectStatus === '1'" @click="remove(row)">删除</el-button></template></el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" v-model:page="query.pageNum" v-model:limit="query.pageSize" @pagination="load" />
<el-dialog v-model="formOpen" :title="form.id ? '编辑边缘终端' : '登记边缘终端'" width="540px">
<el-alert v-if="!form.id" title="终端编码应与QUIC节点ID一致;连接地址和在线状态由终端自动上报。" type="info" :closable="false" show-icon />
<el-form :model="form" label-width="95px" class="terminal-form">
<el-form-item label="终端编码" required><el-input v-model="form.terminalCode" :disabled="Boolean(form.id)" /></el-form-item>
<el-form-item label="终端名称" required><el-input v-model="form.terminalName" /></el-form-item>
<el-form-item label="启用状态"><el-switch v-model="form.status" active-value="0" inactive-value="1" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" /></el-form-item>
</el-form>
<template #footer><el-button @click="formOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
<el-dialog v-model="bindingOpen" title="绑定机器人" width="620px">
<el-alert title="一台机器人同一时间只归属一个在线边缘终端;重新绑定会自动解除旧终端关系。" type="info" :closable="false" show-icon />
<el-select v-model="selectedRobotIds" multiple filterable collapse-tags collapse-tags-tooltip placeholder="选择机器人" class="binding-select">
<el-option v-for="robot in robots" :key="robot.robotId" :label="`${robot.robotName || robot.robotId} (${robot.robotId})`" :value="robot.robotId" />
</el-select>
<template #footer><el-button @click="bindingOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveBindings">保存绑定</el-button></template>
</el-dialog>
</div>
</template>
<script setup name="ResourceTerminal">
import { onActivated, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listResourceTerminals, addResourceTerminal, updateResourceTerminal, deleteResourceTerminals, listTerminalRobots, updateTerminalRobots } from '@/api/resource/terminal'
import { listResourceRobots } from '@/api/resource/robot'
const loading = ref(false), saving = ref(false), rows = ref([]), total = ref(0), formOpen = ref(false), bindingOpen = ref(false), robots = ref([]), selectedRobotIds = ref([]), activeTerminal = ref(null)
const query = reactive({ pageNum: 1, pageSize: 20, terminalName: '', connectStatus: '' })
const empty = () => ({ id: '', terminalCode: '', terminalName: '', status: '0', remark: '' })
const form = reactive(empty())
const endpoint = row => row.ipAddress ? `${row.ipAddress}${row.port ? `:${row.port}` : ''}` : '-'
async function load() { loading.value = true; try { const res = await listResourceTerminals(query); rows.value = res.rows || []; total.value = res.total || 0 } finally { loading.value = false } }
function search() { query.pageNum = 1; load() }
function reset() { Object.assign(query, { pageNum: 1, terminalName: '', connectStatus: '' }); load() }
function openAdd() { Object.assign(form, empty()); formOpen.value = true }
function openEdit(row) { Object.assign(form, empty(), row); formOpen.value = true }
async function save() { if (!form.terminalCode.trim() || !form.terminalName.trim()) return ElMessage.warning('请输入终端编码和名称'); saving.value = true; try { if (form.id) await updateResourceTerminal(form); else await addResourceTerminal(form); ElMessage.success('终端已保存'); formOpen.value = false; load() } finally { saving.value = false } }
async function remove(row) { await ElMessageBox.confirm(`确定删除终端“${row.terminalName || row.terminalCode}”吗?`, '提示', { type: 'warning' }); await deleteResourceTerminals(row.id); ElMessage.success('已删除'); load() }
async function openBindings(row) {
activeTerminal.value = row
const [robotRes, bindingRes] = await Promise.all([listResourceRobots({ pageNum: 1, pageSize: 10000 }), listTerminalRobots(row.id)])
robots.value = robotRes.rows || []
selectedRobotIds.value = bindingRes.data || []
bindingOpen.value = true
}
async function saveBindings() { saving.value = true; try { await updateTerminalRobots(activeTerminal.value.id, selectedRobotIds.value); ElMessage.success('机器人绑定已保存'); bindingOpen.value = false; load() } finally { saving.value = false } }
onMounted(load)
onActivated(load)
</script>
<style scoped>
.secondary { color: var(--el-text-color-secondary); font-size: 12px; margin-top: 3px; }
.terminal-form { margin-top: 20px; }
.binding-select { width: 100%; margin-top: 20px; }
</style>

View File

@ -66,7 +66,7 @@ import { ref, watch, onUnmounted } from "vue";
import { addValueProperty, formatTableData } from "@/utils/flow";
import { emitter } from "@/utils/eventBus";
import { ElMessage } from "element-plus";
import { getTaskConfig } from "@/api/test/config.js";
import { getFlowParamsApi } from "@/api/device/terminal.js";
import { taskExecute } from "@/api/test/config"
import RuntimeAssignmentsEditor from '@/views/flow/components/RuntimeAssignmentsEditor.vue'
import {
@ -99,7 +99,7 @@ const handleClose = () => {
};
const getFlowParams = async () => {
const res = await getTaskConfig({ taskId: props.currentRow.id });
const res = await getFlowParamsApi(props.currentRow.id);
if (res.code === 200) {
const data = parseConfig(res.rows)
formData.value.tableData = data