feat: aima
This commit is contained in:
parent
b6b368abc2
commit
c4cff2587c
@ -7,7 +7,7 @@ VITE_APP_ENV = 'development'
|
|||||||
# 招商车研物联网平台/开发环境
|
# 招商车研物联网平台/开发环境
|
||||||
VITE_APP_BASE_API = '/dev-api'
|
VITE_APP_BASE_API = '/dev-api'
|
||||||
|
|
||||||
VITE_API_URL = http://192.168.28.10:13080
|
VITE_API_URL = http://192.168.0.201:13080
|
||||||
VITE_WS_URL = ws://192.168.0.201:13080/ws
|
VITE_WS_URL = ws://192.168.0.201:13080/ws
|
||||||
|
|
||||||
# explanation
|
# explanation
|
||||||
|
|||||||
32
src/api/intelligenceTest/car.js
Normal file
32
src/api/intelligenceTest/car.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function addVehicle(data) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/vehicle',
|
||||||
|
method: 'post',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVehicleList(params) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/vehicle/list',
|
||||||
|
method: 'get',
|
||||||
|
params: params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateVehicle(data) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/vehicle',
|
||||||
|
method: 'put',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteVehicle(ids) {
|
||||||
|
return request({
|
||||||
|
url: `/aima/vehicle/${ids}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
32
src/api/intelligenceTest/mobilePhone.js
Normal file
32
src/api/intelligenceTest/mobilePhone.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function addPhone(data) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/phone',
|
||||||
|
method: 'post',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPhoneList(params) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/phone/list',
|
||||||
|
method: 'get',
|
||||||
|
params: params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePhone(data) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/phone',
|
||||||
|
method: 'put',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePhone(ids) {
|
||||||
|
return request({
|
||||||
|
url: `/aima/phone/${ids}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
69
src/api/intelligenceTest/runningTask.js
Normal file
69
src/api/intelligenceTest/runningTask.js
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
import { method } from 'lodash'
|
||||||
|
|
||||||
|
export function addTaskinstance(data) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/taskinstance',
|
||||||
|
method: 'post',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskinstanceList(params) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/taskinstance/list',
|
||||||
|
method: 'get',
|
||||||
|
params: params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTaskinstance(data) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/taskinstance',
|
||||||
|
method: 'put',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTaskinstance(ids) {
|
||||||
|
return request({
|
||||||
|
url: `/aima/taskinstance/{ids}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startTaskinstance(id) {
|
||||||
|
return request({
|
||||||
|
url: `/aima/taskinstance/start/${id}`,
|
||||||
|
method: 'put'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pauseTaskinstance(id) {
|
||||||
|
return request({
|
||||||
|
url: `/aima/taskinstance/pause/${id}`,
|
||||||
|
method: 'put'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopTaskinstance(id) {
|
||||||
|
return request({
|
||||||
|
url: `/aima/taskinstance/stop/${id}`,
|
||||||
|
method: 'put'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeTaskinstance(id) {
|
||||||
|
return request({
|
||||||
|
url: `/aima/taskinstance/resume/${id}`,
|
||||||
|
method: 'put'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskLogListApi(params) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/testlog/list',
|
||||||
|
method: 'get',
|
||||||
|
params: params
|
||||||
|
})
|
||||||
|
}
|
||||||
47
src/api/intelligenceTest/taskManage.js
Normal file
47
src/api/intelligenceTest/taskManage.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function addTask(data) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/task',
|
||||||
|
method: 'post',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskList(params) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/task/list',
|
||||||
|
method: 'get',
|
||||||
|
params: params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTask(data) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/task',
|
||||||
|
method: 'put',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTask(ids) {
|
||||||
|
return request({
|
||||||
|
url: `/aima/task/${ids}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTestCaseByTaskId(taskId) {
|
||||||
|
return request({
|
||||||
|
url: `/aima/task/testCases/${taskId}`,
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindTastCase(taskId, ids) {
|
||||||
|
return request({
|
||||||
|
url: `/aima/task/bindTestCases/${taskId}`,
|
||||||
|
method: 'post',
|
||||||
|
data: ids
|
||||||
|
})
|
||||||
|
}
|
||||||
32
src/api/intelligenceTest/testCase.js
Normal file
32
src/api/intelligenceTest/testCase.js
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function addTestCase(data) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/testcase',
|
||||||
|
method: 'post',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTestCaseList(params) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/testcase/list',
|
||||||
|
method: 'get',
|
||||||
|
params: params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTestCase(data) {
|
||||||
|
return request({
|
||||||
|
url: '/aima/testcase',
|
||||||
|
method: 'put',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTestCase(ids) {
|
||||||
|
return request({
|
||||||
|
url: `/aima/testcase/${ids}`,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -58,75 +58,6 @@ export const constantRoutes = [
|
|||||||
component: () => import("@/views/error/401"),
|
component: () => import("@/views/error/401"),
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/device",
|
|
||||||
component: Layout,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "mechanical_arm/:id",
|
|
||||||
component: () =>
|
|
||||||
import("@/views/device/register/components/MechanicalArm/index.vue"),
|
|
||||||
name: "机械臂示教",
|
|
||||||
meta: { title: "机械臂详情" },
|
|
||||||
hidden: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "camera/:id",
|
|
||||||
component: () =>
|
|
||||||
import("@/views/device/register/components/Camera/index.vue"),
|
|
||||||
name: "摄像头示教",
|
|
||||||
meta: { title: "摄像头详情" },
|
|
||||||
hidden: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "head/:id",
|
|
||||||
component: () =>
|
|
||||||
import("@/views/device/register/components/Head/index.vue"),
|
|
||||||
name: "人工头示教",
|
|
||||||
meta: { title: "人工头详情" },
|
|
||||||
hidden: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "speaker/:id",
|
|
||||||
component: () =>
|
|
||||||
import("@/views/device/register/components/Speaker/index.vue"),
|
|
||||||
name: "扬声器示教",
|
|
||||||
meta: { title: "扬声器详情" },
|
|
||||||
hidden: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "hand/:id",
|
|
||||||
component: () =>
|
|
||||||
import("@/views/device/register/components/DexHand/index.vue"),
|
|
||||||
name: "灵巧手示教",
|
|
||||||
meta: { title: "灵巧手详情" },
|
|
||||||
hidden: true,
|
|
||||||
},{
|
|
||||||
path: "microphone/:id",
|
|
||||||
component: () =>
|
|
||||||
import("@/views/device/register/components/Microphone/index.vue"),
|
|
||||||
name: "麦克风示教",
|
|
||||||
meta: { title: "麦克风详情" },
|
|
||||||
hidden: true,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
//语音合成
|
|
||||||
path: "/speech_synthesis",
|
|
||||||
component: () =>
|
|
||||||
import("@/views/device/register/components/SpeechSynthesis/index.vue"),
|
|
||||||
meta: { title: "语音合成", noLogin: true },
|
|
||||||
hidden: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
//触摸交互
|
|
||||||
path: "/touch_interaction",
|
|
||||||
component: () =>
|
|
||||||
import("@/views/device/register/components/TouchInteraction/index.vue"),
|
|
||||||
meta: { title: "触摸交互", noLogin: true },
|
|
||||||
hidden: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
//cockpit
|
//cockpit
|
||||||
path: "/inspection/cockpit",
|
path: "/inspection/cockpit",
|
||||||
@ -135,14 +66,6 @@ export const constantRoutes = [
|
|||||||
meta: { title: "logicFlow", noLogin: true },
|
meta: { title: "logicFlow", noLogin: true },
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
//logicFlow
|
|
||||||
path: "/logic_flow",
|
|
||||||
component: () =>
|
|
||||||
import("@/views/device/register/components/LogicFlow/index.vue"),
|
|
||||||
meta: { title: "logicFlow", noLogin: true },
|
|
||||||
hidden: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
//logicFlow
|
//logicFlow
|
||||||
path: "/flow",
|
path: "/flow",
|
||||||
@ -150,13 +73,6 @@ export const constantRoutes = [
|
|||||||
meta: { title: "flow", noLogin: true },
|
meta: { title: "flow", noLogin: true },
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
//电机路由
|
|
||||||
path: "/Motor/:id",
|
|
||||||
component: () =>
|
|
||||||
import("@/views/device/register/components/Motor/index.vue"),
|
|
||||||
hidden: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
component: Layout,
|
component: Layout,
|
||||||
@ -216,76 +132,6 @@ export const dynamicRoutes = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/vi/plan",
|
|
||||||
component: Layout,
|
|
||||||
hidden: true,
|
|
||||||
permissions: ["vi:testProject:list"],
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "index/:id",
|
|
||||||
component: () => import("@/views/vi/testProject/plan/index"),
|
|
||||||
name: "Plan",
|
|
||||||
meta: { title: "方案管理", activeMenu: "/vi/testProject" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/is/plan",
|
|
||||||
component: Layout,
|
|
||||||
hidden: true,
|
|
||||||
permissions: ["is:project:list"],
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "index/:id",
|
|
||||||
component: () => import("@/views/is/project/plan/index"),
|
|
||||||
name: "ProjectPlan",
|
|
||||||
meta: { title: "项目方案", activeMenu: "/is/plan" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/is/evaluate",
|
|
||||||
component: Layout,
|
|
||||||
hidden: true,
|
|
||||||
permissions: ["is:project:list"],
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "index/:id",
|
|
||||||
component: () => import("@/views/is/project/evaluate/index"),
|
|
||||||
name: "isProjectEvaluate",
|
|
||||||
meta: { title: "触控评价", activeMenu: "/is/evaluate" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/vi/evaluate",
|
|
||||||
component: Layout,
|
|
||||||
hidden: true,
|
|
||||||
permissions: ["is:project:list"],
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "index/:id",
|
|
||||||
component: () => import("@/views/vi/testProject/evaluate/index"),
|
|
||||||
name: "viProjectEvaluate",
|
|
||||||
meta: { title: "语音评价", activeMenu: "/vi/evaluate" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/monitor/job-log",
|
|
||||||
component: Layout,
|
|
||||||
hidden: true,
|
|
||||||
permissions: ["monitor:job:list"],
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "index/:jobId(\\d+)",
|
|
||||||
component: () => import("@/views/monitor/job/log"),
|
|
||||||
name: "JobLog",
|
|
||||||
meta: { title: "调度日志", activeMenu: "/monitor/job" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/tool/gen-edit",
|
path: "/tool/gen-edit",
|
||||||
component: Layout,
|
component: Layout,
|
||||||
|
|||||||
508
src/views/device/profile/index.vue
Normal file
508
src/views/device/profile/index.vue
Normal 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>
|
||||||
330
src/views/device/register/components/Camera/index.vue
Normal file
330
src/views/device/register/components/Camera/index.vue
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
<template>
|
||||||
|
<div class="realsense-viewer">
|
||||||
|
<el-row type="flex" class="height-full">
|
||||||
|
<el-col :span="4" class="control-panel" style="border-right: 10px solid rgb(40, 50, 60);">
|
||||||
|
<div class="controls">
|
||||||
|
<el-form :model="form">
|
||||||
|
<el-form-item label="深度图像">
|
||||||
|
<el-switch v-model="form.stereoModule"></el-switch>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="彩色图像">
|
||||||
|
<el-switch v-model="form.rgbCamera"></el-switch>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="20" class="video-panel" v-if="form.stereoModule || form.rgbCamera">
|
||||||
|
<div v-if="form.stereoModule && form.rgbCamera" class="stream-container">
|
||||||
|
<div class="stream-item">
|
||||||
|
<div class="stream-wrapper">
|
||||||
|
<video ref="depthVideo" autoplay muted playsinline></video>
|
||||||
|
<video ref="depthVideoBuffer" autoplay muted playsinline style="display: none;"></video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stream-item">
|
||||||
|
<div class="stream-wrapper">
|
||||||
|
<video ref="colorVideo" autoplay muted playsinline></video>
|
||||||
|
<video ref="colorVideoBuffer" autoplay muted playsinline style="display: none;"></video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="single-stream-container">
|
||||||
|
<div v-if="form.stereoModule" class="stream-wrapper">
|
||||||
|
<video ref="depthVideo" width="640" height="480" autoplay muted playsinline></video>
|
||||||
|
<video ref="depthVideoBuffer" width="640" height="480" autoplay muted playsinline style="display: none;"></video>
|
||||||
|
</div>
|
||||||
|
<div v-if="form.rgbCamera" class="stream-wrapper">
|
||||||
|
<video ref="colorVideo" width="640" height="480" autoplay muted playsinline></video>
|
||||||
|
<video ref="colorVideoBuffer" width="640" height="480" autoplay muted playsinline style="display: none;"></video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col v-else :span="20" style="align-self: center;">
|
||||||
|
<el-empty description="请选择模式" />
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { inject, reactive, toRefs, watch, onMounted, onUnmounted, ref, nextTick } from 'vue';
|
||||||
|
import { getRegister } from "@/api/device/register";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { debounce } from 'lodash'; // 引入 lodash 的防抖函数
|
||||||
|
|
||||||
|
// 1. 定义 props
|
||||||
|
const props = defineProps({
|
||||||
|
initialDeviceId: { // 示例 prop,用于从弹窗接收cameraId
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
initialTerminalId: { // 示例 prop,用于从弹窗接收 terminalId
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const socket = inject('ws');
|
||||||
|
const route = useRoute();
|
||||||
|
const data = reactive({
|
||||||
|
form: {
|
||||||
|
stereoModule: false,
|
||||||
|
rgbCamera: false,
|
||||||
|
},
|
||||||
|
terminalId: null,
|
||||||
|
cameraId: null,
|
||||||
|
type: 'camera',
|
||||||
|
callbacks: {},
|
||||||
|
});
|
||||||
|
const { form } = toRefs(data);
|
||||||
|
const depthVideo = ref(null);
|
||||||
|
const depthVideoBuffer = ref(null);
|
||||||
|
const colorVideo = ref(null);
|
||||||
|
const colorVideoBuffer = ref(null);
|
||||||
|
const blobUrls = ref([]);
|
||||||
|
const frameData = reactive({
|
||||||
|
getRGBImageStream: { count: 0, lastTime: 0 },
|
||||||
|
getDepthImageStream: { count: 0, lastTime: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderVideo(mainRef, bufferRef, data, channel, imageType) {
|
||||||
|
const mainVideo = mainRef.value;
|
||||||
|
const bufferVideo = bufferRef.value;
|
||||||
|
if (!mainVideo || !bufferVideo) {
|
||||||
|
console.error(`视频元素未找到 (${imageType}): 通道: ${channel}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
const newSrc = data.startsWith('blob:') ? data : `data:video/mp4;base64,${data}`;
|
||||||
|
|
||||||
|
// 先加载到缓冲视频元素
|
||||||
|
bufferVideo.src = newSrc;
|
||||||
|
bufferVideo.onloadeddata = () => {
|
||||||
|
// 缓冲视频加载完成后,切换到主视频
|
||||||
|
if (mainVideo.src && mainVideo.src.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(mainVideo.src);
|
||||||
|
blobUrls.value = blobUrls.value.filter(url => url !== mainVideo.src);
|
||||||
|
}
|
||||||
|
mainVideo.src = bufferVideo.src;
|
||||||
|
blobUrls.value.push(newSrc);
|
||||||
|
bufferVideo.src = ''; // 清空缓冲视频源
|
||||||
|
};
|
||||||
|
|
||||||
|
frameData[imageType].count++;
|
||||||
|
const currentTime = performance.now();
|
||||||
|
if (frameData[imageType].lastTime && currentTime - frameData[imageType].lastTime >= 1000) {
|
||||||
|
const fps = frameData[imageType].count * 1000 / (currentTime - frameData[imageType].lastTime);
|
||||||
|
console.log(`渲染帧率 (${imageType}): ${fps.toFixed(2)} fps, 通道: ${channel}`);
|
||||||
|
frameData[imageType].count = 0;
|
||||||
|
frameData[imageType].lastTime = currentTime;
|
||||||
|
} else if (!frameData[imageType].lastTime) {
|
||||||
|
frameData[imageType].lastTime = currentTime;
|
||||||
|
}
|
||||||
|
console.log(`视频渲染耗时 (${imageType}): ${performance.now() - start}ms, 通道: ${channel}`);
|
||||||
|
if (performance.memory) {
|
||||||
|
console.log('当前内存使用:', {
|
||||||
|
usedJSHeapSize: (performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(2) + ' MB',
|
||||||
|
totalJSHeapSize: (performance.memory.totalJSHeapSize / 1024 / 1024).toFixed(2) + ' MB',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error(`未知 payload 类型 (${imageType}):`, typeof data, '通道:', channel);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`视频渲染失败 (${imageType}):`, err, '通道:', channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防抖订阅函数
|
||||||
|
const subscribeDebounced = debounce(async (sub, terminalId, method) => {
|
||||||
|
if (!socket || !terminalId || !data.cameraId) {
|
||||||
|
console.warn(`订阅失败: socket=${!!socket}, terminalId=${terminalId}, cameraId=${data.cameraId}, method=${method}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const channel = `edgeCameraServiceImpl/${method}/${terminalId}/${data.cameraId}`;
|
||||||
|
console.log(sub ? '订阅' : '取消订阅', channel);
|
||||||
|
socket.send({
|
||||||
|
type: 'channel_subscription',
|
||||||
|
action: sub ? 'subscribe' : 'unsubscribe',
|
||||||
|
channel,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sub) {
|
||||||
|
await nextTick();
|
||||||
|
const mainRef = method === 'getRGBImageStream' ? colorVideo : depthVideo;
|
||||||
|
const bufferRef = method === 'getRGBImageStream' ? colorVideoBuffer : depthVideoBuffer;
|
||||||
|
if (!mainRef.value || !bufferRef.value) {
|
||||||
|
console.error(`视频元素未找到: ${method}, 通道: ${channel}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理旧回调
|
||||||
|
if (data.callbacks[channel]) {
|
||||||
|
socket.off(channel, data.callbacks[channel]);
|
||||||
|
console.log('清理旧回调:', channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const callback = (data) => {
|
||||||
|
if (data !== '500') {
|
||||||
|
requestAnimationFrame(() => renderVideo(mainRef, bufferRef, data, channel, method));
|
||||||
|
} else {
|
||||||
|
// 错误处理:取消订阅后重新订阅
|
||||||
|
subscribeDebounced(false, terminalId, method);
|
||||||
|
subscribeDebounced(true, terminalId, method);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.on(channel, callback);
|
||||||
|
data.callbacks[channel] = callback;
|
||||||
|
console.log('注册新回调:', channel);
|
||||||
|
} else {
|
||||||
|
if (data.callbacks[channel]) {
|
||||||
|
socket.off(channel, data.callbacks[channel]);
|
||||||
|
delete data.callbacks[channel];
|
||||||
|
console.log('清理回调:', channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300); // 300ms 防抖延迟
|
||||||
|
|
||||||
|
// 监听开关变化
|
||||||
|
watch(() => data.form.stereoModule, async (newVal) => {
|
||||||
|
if (!data.terminalId || !data.cameraId) return;
|
||||||
|
await nextTick();
|
||||||
|
subscribeDebounced(newVal, data.terminalId, 'getDepthImageStream');
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => data.form.rgbCamera, async (newVal) => {
|
||||||
|
if (!data.terminalId || !data.cameraId) return;
|
||||||
|
await nextTick();
|
||||||
|
subscribeDebounced(newVal, data.terminalId, 'getRGBImageStream');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 使用 watchEffect 来响应 props 和路由的变化
|
||||||
|
watchEffect(() => {
|
||||||
|
let deviceId = null;
|
||||||
|
// 优先使用 props 中的值(如果弹窗传入了)
|
||||||
|
if (props.initialDeviceId) {
|
||||||
|
deviceId = props.initialDeviceId;
|
||||||
|
}
|
||||||
|
// 如果 props 没有传入,再从路由获取(仅当cameraId和terminalId都还没设置时)
|
||||||
|
// 注意:这里需要根据你实际从路由获取参数的逻辑来调整
|
||||||
|
// 假设 route.path.split("/")[3] 是cameraId
|
||||||
|
if (!props.initialDeviceId && route.path.split("/")[3]) {
|
||||||
|
deviceId = route.path.split("/")[3];
|
||||||
|
// 调用 getRegister 并设置 cameraId
|
||||||
|
// 注意:这里的 getRegister 函数需要能够处理从路由获取的参数
|
||||||
|
|
||||||
|
}
|
||||||
|
getRegister(deviceId).then(res => {
|
||||||
|
data.cameraId = res.data.deviceCode;
|
||||||
|
data.terminalId = res.data.idDeDeviceTerminalConfig;
|
||||||
|
}).catch(error => {
|
||||||
|
console.error("Error fetching register from route:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// getRegister(route.path.split("/")[3]).then(res => {
|
||||||
|
// data.cameraId = res.data.deviceCode;
|
||||||
|
// data.terminalId = res.data.idDeDeviceTerminalConfig;
|
||||||
|
// });
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (socket && data.terminalId && data.cameraId) {
|
||||||
|
const channels = [
|
||||||
|
`edgeCameraServiceImpl/getRGBImageStream/${data.terminalId}/${data.cameraId}`,
|
||||||
|
`edgeCameraServiceImpl/getDepthImageStream/${data.terminalId}/${data.cameraId}`,
|
||||||
|
];
|
||||||
|
channels.forEach(channel => {
|
||||||
|
socket.off(channel, data.callbacks[channel]);
|
||||||
|
console.log('卸载时清理回调:', channel);
|
||||||
|
});
|
||||||
|
data.callbacks = {};
|
||||||
|
}
|
||||||
|
blobUrls.value.forEach(url => URL.revokeObjectURL(url));
|
||||||
|
blobUrls.value = [];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.realsense-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
background-color: #000;
|
||||||
|
.height-full {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.control-panel {
|
||||||
|
padding: 10px;
|
||||||
|
.controls {
|
||||||
|
padding: 10px;
|
||||||
|
background-color: rgb(50, 50, 50);
|
||||||
|
border-radius: 10px;
|
||||||
|
.el-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.video-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
.single-stream-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
.stream-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
video {
|
||||||
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
transition: opacity 0.1s ease-in-out; /* 平滑过渡 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stream-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
.stream-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
.stream-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
video {
|
||||||
|
width: auto;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
transition: opacity 0.1s ease-in-out; /* 平滑过渡 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
src/views/device/register/components/ControlCard/index.vue
Normal file
36
src/views/device/register/components/ControlCard/index.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="control-card">
|
||||||
|
<div class="title" v-if="props.title">{{ props.title }}</div>
|
||||||
|
<div class="body">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.control-card {
|
||||||
|
@include br6;
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
height: calc(100% - 20px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
431
src/views/device/register/components/DexHand/LeftBottomHand.vue
Normal file
431
src/views/device/register/components/DexHand/LeftBottomHand.vue
Normal file
@ -0,0 +1,431 @@
|
|||||||
|
<template>
|
||||||
|
<div class="left-bottom-charts">
|
||||||
|
<el-tooltip :content="!isHandSeries ? '开启触觉流': '关闭触觉流'" placement="top">
|
||||||
|
<el-switch
|
||||||
|
v-model="isHandSeries"
|
||||||
|
@change="handleSwitchChange"
|
||||||
|
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #999; position: absolute;"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
|
<canvas ref="handCanvas" class="hand-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup >
|
||||||
|
import handImage from '@/assets/images/hand2.png';
|
||||||
|
import { inject } from 'vue';
|
||||||
|
const emit = defineEmits(['update:bottomSeriesData']);
|
||||||
|
const socket = inject('ws');
|
||||||
|
const handCanvas = ref(null);
|
||||||
|
const terminalId = ref(''); // 待您修改
|
||||||
|
const deviceId = ref(''); // 待您修改
|
||||||
|
const frameData = ref({ count: 0, lastTime: 0 });
|
||||||
|
const isHandSeries = ref(false);
|
||||||
|
const containerRef = ref(null)
|
||||||
|
|
||||||
|
// 1. 定义 props
|
||||||
|
const props = defineProps({
|
||||||
|
initialDeviceId: { // 示例 prop,用于从弹窗接收cameraId
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
initialTerminalId: { // 示例 prop,用于从弹窗接收 terminalId
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 使用 watchEffect 来响应 props 和路由的变化
|
||||||
|
watchEffect(() => {
|
||||||
|
terminalId.value = props.initialTerminalId;
|
||||||
|
deviceId.value = props.initialDeviceId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 矩形定义
|
||||||
|
const rectangle = [
|
||||||
|
{ x: 24, y: 305, width: 21, height: 20, xCount: 3, yCount: 3 }, // PINKY.TIP
|
||||||
|
{ x: 17, y: 240, width: 42, height: 57, xCount: 8, yCount: 12 }, // PINKY.FINGER
|
||||||
|
{ x: 25, y: 152, width: 42, height: 51, xCount: 8, yCount: 10 }, // PINKY.PAD
|
||||||
|
{ x: 88, y: 339, width: 23, height: 22, xCount: 3, yCount: 3 }, // RING.TIP
|
||||||
|
{ x: 75, y: 276, width: 43, height: 56, xCount: 8, yCount: 12 }, // RING.FINGER
|
||||||
|
{ x: 88, y: 152, width: 44, height: 51, xCount: 8, yCount: 10 }, // RING.PAD
|
||||||
|
{ x: 153, y: 354, width: 22, height: 21, xCount: 3, yCount: 3 }, // MIDDLE_FINGER.TIP
|
||||||
|
{ x: 146, y: 283, width: 44, height: 56, xCount: 8, yCount: 12 }, // MIDDLE_FINGER.FINGER
|
||||||
|
{ x: 146, y: 152, width: 43, height: 51, xCount: 8, yCount: 10 }, // MIDDLE_FINGER.PAD
|
||||||
|
{ x: 224, y: 332, width: 23, height: 22, xCount: 3, yCount: 3 }, // INDEX.TIP
|
||||||
|
{ x: 209, y: 267, width: 44, height: 58, xCount: 8, yCount: 12 }, // INDEX.FINGER
|
||||||
|
{ x: 203, y: 152, width: 44, height: 52, xCount: 8, yCount: 10 }, // INDEX.PAD
|
||||||
|
{ x: 290, y: 225, width: 21, height: 21, xCount: 3, yCount: 3 }, // THUMB.TIP
|
||||||
|
{ x: 282, y: 161, width: 44, height: 57, xCount: 8, yCount: 12 }, // THUMB.FINGER
|
||||||
|
{ x: 289, y: 111, width: 22, height: 20, xCount: 3, yCount: 3 }, // THUMB.THUMB_MIDDLE
|
||||||
|
{ x: 269, y: 24, width: 57, height: 57, xCount: 8, yCount: 12 }, // THUMB.PAD
|
||||||
|
{ x: 53, y: 11, width: 188, height: 107, xCount: 14, yCount: 8 } // PALM.PALM_PAD
|
||||||
|
];
|
||||||
|
|
||||||
|
// 模拟的矩形数据(17 个二维数组)
|
||||||
|
const rectangleData = ref([]);
|
||||||
|
|
||||||
|
// 初始化矩形数据
|
||||||
|
const initializeRectangleData = () => {
|
||||||
|
rectangleData.value = rectangle.map(({ xCount, yCount }) =>
|
||||||
|
Array(yCount)
|
||||||
|
.fill()
|
||||||
|
.map(() => Array(xCount).fill().map(() => Math.floor(Math.random() * 0)))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 防抖函数
|
||||||
|
const debounce = (func, delay) => {
|
||||||
|
let timeoutId;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => func.apply(null, args), delay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化 Canvas
|
||||||
|
const initCanvas = () => {
|
||||||
|
const canvas = handCanvas.value;
|
||||||
|
if (!canvas) {
|
||||||
|
console.error('Canvas element is not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
canvas.width = canvas.parentElement.offsetWidth;
|
||||||
|
canvas.height = canvas.parentElement.offsetHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
drawImage(ctx, img);
|
||||||
|
drawRectangles(ctx, img);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to get 2D context');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => console.error('Failed to load image:', handImage);
|
||||||
|
img.src = handImage;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 绘制图片
|
||||||
|
const drawImage = (ctx, img) => {
|
||||||
|
const { width: canvasWidth, height: canvasHeight } = ctx.canvas;
|
||||||
|
const aspectRatio = img.width / img.height;
|
||||||
|
|
||||||
|
let drawWidth, drawHeight;
|
||||||
|
if (canvasWidth / canvasHeight > aspectRatio) {
|
||||||
|
drawHeight = canvasHeight;
|
||||||
|
drawWidth = drawHeight * aspectRatio;
|
||||||
|
} else {
|
||||||
|
drawWidth = canvasWidth;
|
||||||
|
drawHeight = drawWidth / aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const x = (canvasWidth - drawWidth) / 2;
|
||||||
|
const y = canvasHeight - drawHeight;
|
||||||
|
|
||||||
|
ctx.drawImage(img, x, y, drawWidth, drawHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 绘制矩形
|
||||||
|
const drawRectangles = (ctx, img) => {
|
||||||
|
const { width: canvasWidth, height: canvasHeight } = ctx.canvas;
|
||||||
|
const aspectRatio = img.width / img.height;
|
||||||
|
|
||||||
|
let drawWidth, drawHeight;
|
||||||
|
if (canvasWidth / canvasHeight > aspectRatio) {
|
||||||
|
drawHeight = canvasHeight;
|
||||||
|
drawWidth = drawHeight * aspectRatio;
|
||||||
|
} else {
|
||||||
|
drawWidth = canvasWidth;
|
||||||
|
drawHeight = drawWidth / aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xOffset = Math.round((canvasWidth - drawWidth) / 2);
|
||||||
|
const yOffset = Math.round(canvasHeight - drawHeight);
|
||||||
|
|
||||||
|
const scaleX = drawWidth / img.width;
|
||||||
|
const scaleY = drawHeight / img.height;
|
||||||
|
|
||||||
|
rectangle.forEach((rect, index) => {
|
||||||
|
const x = Math.round(xOffset + rect.x * scaleX);
|
||||||
|
const y = Math.round(yOffset + (img.height - rect.y - rect.height) * scaleY);
|
||||||
|
const rectWidth = Math.round(rect.width * scaleX);
|
||||||
|
const rectHeight = Math.round(rect.height * scaleY);
|
||||||
|
const smallWidth = Math.round(rectWidth / rect.xCount);
|
||||||
|
const smallHeight = Math.round(rectHeight / rect.yCount);
|
||||||
|
|
||||||
|
const data = rectangleData.value[index];
|
||||||
|
if (!data) {
|
||||||
|
console.warn(`No data for rectangle at index ${index}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < rect.xCount; i++) {
|
||||||
|
for (let j = 0; j < rect.yCount; j++) {
|
||||||
|
const smallX = x + i * smallWidth;
|
||||||
|
const smallY = y + j * smallHeight;
|
||||||
|
const value = data[j][i];
|
||||||
|
// 非常淡橙色 (#FFF5E1, RGB: 255, 245, 225) 到深红 (#800000, RGB: 128, 0, 0)
|
||||||
|
const r = 128 + Math.floor(((255 - 128) * (4096 - value) + (128 - 0) * value) / 4096);
|
||||||
|
const g = Math.floor((245 * (4096 - value) + 0 * value) / 4096);
|
||||||
|
const b = Math.floor((225 * (4096 - value) + 0 * value) / 4096);
|
||||||
|
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
|
||||||
|
ctx.fillRect(smallX, smallY, smallWidth, smallHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新 Canvas
|
||||||
|
const updateCanvas = () => {
|
||||||
|
const canvas = handCanvas.value;
|
||||||
|
if (!canvas) {
|
||||||
|
console.error('Canvas element is not available during update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
canvas.width = canvas.parentElement.offsetWidth;
|
||||||
|
canvas.height = canvas.parentElement.offsetHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
drawImage(ctx, img);
|
||||||
|
drawRectangles(ctx, img);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => console.error('Failed to load image:', handImage);
|
||||||
|
img.src = handImage;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 传感器到图表的映射
|
||||||
|
const sensorMap = [
|
||||||
|
{ fingerType: 'PINKY', partType: 'TIP', key: 'littleFinger.end' },
|
||||||
|
{ fingerType: 'PINKY', partType: 'FINGER', key: 'littleFinger.tip' },
|
||||||
|
{ fingerType: 'PINKY', partType: 'PAD', key: 'littleFinger.middle' },
|
||||||
|
{ fingerType: 'RING', partType: 'TIP', key: 'ringFinger.end' },
|
||||||
|
{ fingerType: 'RING', partType: 'FINGER', key: 'ringFinger.tip' },
|
||||||
|
{ fingerType: 'RING', partType: 'PAD', key: 'ringFinger.middle' },
|
||||||
|
{ fingerType: 'MIDDLE_FINGER', partType: 'TIP', key: 'middleFinger.end' },
|
||||||
|
{ fingerType: 'MIDDLE_FINGER', partType: 'FINGER', key: 'middleFinger.tip' },
|
||||||
|
{ fingerType: 'MIDDLE_FINGER', partType: 'PAD', key: 'middleFinger.middle' },
|
||||||
|
{ fingerType: 'INDEX', partType: 'TIP', key: 'indexFinger.end' },
|
||||||
|
{ fingerType: 'INDEX', partType: 'FINGER', key: 'indexFinger.tip' },
|
||||||
|
{ fingerType: 'INDEX', partType: 'PAD', key: 'indexFinger.middle' },
|
||||||
|
{ fingerType: 'THUMB', partType: 'TIP', key: 'thumb.end' },
|
||||||
|
{ fingerType: 'THUMB', partType: 'FINGER', key: 'thumb.tip' },
|
||||||
|
{ fingerType: 'THUMB', partType: 'THUMB_MIDDLE', key: 'thumb.middle' },
|
||||||
|
{ fingerType: 'THUMB', partType: 'PAD', key: 'thumb.palm' },
|
||||||
|
{ fingerType: 'PALM', partType: 'PALM_PAD', key: 'palm.touch' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// WebSocket 订阅
|
||||||
|
const subscribeSensorData = async (sub) => {
|
||||||
|
if (!socket || !terminalId.value || !deviceId.value) {
|
||||||
|
console.warn(`订阅失败: socket=${!!socket}, terminalId=${terminalId.value}, deviceId=${deviceId.value}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const channel = `edgeDexHandServiceImpl/getSensorDataStream/${terminalId.value}/${deviceId.value}`;
|
||||||
|
console.log(sub ? '订阅' : '取消订阅', channel);
|
||||||
|
socket.send({
|
||||||
|
type: 'channel_subscription',
|
||||||
|
action: sub ? 'subscribe' : 'unsubscribe',
|
||||||
|
channel
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sub) {
|
||||||
|
await nextTick();
|
||||||
|
if (data.callback) {
|
||||||
|
socket.off(channel, data.callback);
|
||||||
|
console.log('清理旧回调:', channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const callback = (playLoad) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
// 解析 playLoad
|
||||||
|
const sensorData = typeof playLoad === 'string' ? JSON.parse(playLoad) : playLoad;
|
||||||
|
if (!Array.isArray(sensorData) || sensorData.length !== 17) {
|
||||||
|
console.error('无效的 payload 格式: 期望 17 个传感器对象', sensorData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为 rectangleData 和计算 maxData, avgData
|
||||||
|
const newRectangleData = sensorData.map((sensor, index) => {
|
||||||
|
const { xCount, yCount } = rectangle[index];
|
||||||
|
const { rows, cols, dataList } = sensor;
|
||||||
|
|
||||||
|
// 验证尺寸
|
||||||
|
if (rows !== yCount || cols !== xCount || !Array.isArray(dataList) || dataList.length !== rows) {
|
||||||
|
console.error(`传感器 ${sensor.sensorName} 数据尺寸不匹配: 期望 ${yCount}x${xCount}, 实际 ${rows}x${cols}`);
|
||||||
|
return rectangleData.value[index] || Array(yCount).fill().map(() => Array(xCount).fill(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取 valuesList
|
||||||
|
return dataList.map(row => {
|
||||||
|
if (!row || !Array.isArray(row.valuesList) || row.valuesList.length !== cols) {
|
||||||
|
console.warn(`传感器 ${sensor.sensorName} 行数据无效`);
|
||||||
|
return Array(xCount).fill(0);
|
||||||
|
}
|
||||||
|
return row.valuesList;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算 maxData 和 avgData
|
||||||
|
const newMaxData = {
|
||||||
|
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
|
||||||
|
indexFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
middleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
ringFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
littleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
palm: { touch: 0 }
|
||||||
|
};
|
||||||
|
const newAvgData = {
|
||||||
|
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
|
||||||
|
indexFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
middleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
ringFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
littleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
palm: { touch: 0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
sensorData.forEach((sensor, index) => {
|
||||||
|
const { dataList, fingerType, partType } = sensor;
|
||||||
|
const map = sensorMap[index];
|
||||||
|
if (!map || map.fingerType !== fingerType || map.partType !== partType) {
|
||||||
|
console.warn(`传感器 ${sensor.sensorName} 映射不匹配: 期望 ${map.fingerType}.${map.partType}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算最大值和平均值
|
||||||
|
let maxValue = 0;
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
dataList.forEach(row => {
|
||||||
|
row.valuesList.forEach(value => {
|
||||||
|
maxValue = Math.max(maxValue, value);
|
||||||
|
if (value > 0) {
|
||||||
|
sum += value;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const avgValue = count > 0 ? sum / count : 0;
|
||||||
|
|
||||||
|
// 更新 maxData 和 avgData
|
||||||
|
const [finger, part] = map.key.split('.');
|
||||||
|
newMaxData[finger][part] = Math.floor(maxValue);
|
||||||
|
newAvgData[finger][part] = Math.floor(avgValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新 rectangleData 和 maxData, avgData
|
||||||
|
rectangleData.value = newRectangleData;
|
||||||
|
emit('update:topSeriesData', {maxData: newMaxData, avgData: newAvgData});
|
||||||
|
|
||||||
|
// 更新 Canvas
|
||||||
|
updateCanvas();
|
||||||
|
|
||||||
|
// 帧率和性能监控
|
||||||
|
frameData.value.count++;
|
||||||
|
const currentTime = performance.now();
|
||||||
|
if (frameData.value.lastTime && currentTime - frameData.value.lastTime >= 1000) {
|
||||||
|
const fps = frameData.value.count * 1000 / (currentTime - frameData.value.lastTime);
|
||||||
|
console.log(`渲染帧率: ${fps.toFixed(2)} fps, 通道: ${channel}`);
|
||||||
|
frameData.value.count = 0;
|
||||||
|
frameData.value.lastTime = currentTime;
|
||||||
|
} else if (!frameData.value.lastTime) {
|
||||||
|
frameData.value.lastTime = currentTime;
|
||||||
|
}
|
||||||
|
console.log(`数据更新耗时: ${(performance.now() - start).toFixed(2)}ms, 通道: ${channel}`);
|
||||||
|
if (performance.memory) {
|
||||||
|
console.log('当前内存使用:', {
|
||||||
|
usedJSHeapSize: (performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(2) + ' MB',
|
||||||
|
totalJSHeapSize: (performance.memory.totalJSHeapSize / 1024 / 1024).toFixed(2) + ' MB'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('数据更新失败:', err, '通道:', channel);
|
||||||
|
console.error('payload 数据:', playLoad);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
socket.on(channel, callback);
|
||||||
|
data.callback = callback;
|
||||||
|
console.log('注册新回调:', channel);
|
||||||
|
} else {
|
||||||
|
if (data.callback) {
|
||||||
|
socket.off(channel, data.callback);
|
||||||
|
delete data.callback;
|
||||||
|
console.log('清理回调:', channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 数据对象
|
||||||
|
const data = reactive({
|
||||||
|
callback: null
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const handleSwitchChange = (val) => {
|
||||||
|
subscribeSensorData(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生命周期钩子
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
initializeRectangleData(); // 初始化数据
|
||||||
|
initCanvas(); // 初始化 Canvas
|
||||||
|
|
||||||
|
// 使用 ResizeObserver 监听父容器尺寸变化
|
||||||
|
const container = containerRef.value || handCanvas.value?.parentElement;
|
||||||
|
if (container) {
|
||||||
|
const resizeObserver = new ResizeObserver(debounce(updateCanvas, 10));
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
// 保存 ResizeObserver 实例以便清理
|
||||||
|
data.resizeObserver = resizeObserver;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to find container for ResizeObserver');
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedResize = debounce(updateCanvas, 10);
|
||||||
|
window.addEventListener('resize', debouncedResize);
|
||||||
|
data.debouncedResize = debouncedResize; // 保存以便清理
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 清理 ResizeObserver
|
||||||
|
if (data.resizeObserver) {
|
||||||
|
data.resizeObserver.disconnect();
|
||||||
|
delete data.resizeObserver;
|
||||||
|
}
|
||||||
|
// 清理 window 的 resize 事件
|
||||||
|
if (data.debouncedResize) {
|
||||||
|
window.removeEventListener('resize', data.debouncedResize);
|
||||||
|
delete data.debouncedResize;
|
||||||
|
}
|
||||||
|
// 清理 WebSocket 订阅
|
||||||
|
if (isHandSeries.value) {
|
||||||
|
subscribeSensorData(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.left-bottom-charts {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.hand-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
270
src/views/device/register/components/DexHand/LeftTopHand.vue
Normal file
270
src/views/device/register/components/DexHand/LeftTopHand.vue
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
<template>
|
||||||
|
<div class="left-top-hand" v-loading="loading">
|
||||||
|
<el-button
|
||||||
|
class="copy-button"
|
||||||
|
type="button"
|
||||||
|
:icon="isCopied ? 'Check' : 'CopyDocument'"
|
||||||
|
:disabled="isCopied"
|
||||||
|
@click="copyParams"
|
||||||
|
aria-label="复制当前参数"
|
||||||
|
>
|
||||||
|
{{ isCopied ? '完成' : '复制' }}
|
||||||
|
</el-button>
|
||||||
|
<img src="@/assets/images/hand.png" ref="handImage" class="hand-image" />
|
||||||
|
<div v-for="(slider, index) in sliders" :key="index" class="slider-container" :style="slider.style">
|
||||||
|
<span :class="`slider-title slider-title-${index}`">{{ slider.value }}</span>
|
||||||
|
<el-slider
|
||||||
|
v-model="slider.value"
|
||||||
|
:vertical="slider.vertical"
|
||||||
|
:height="slider.height + 'px'"
|
||||||
|
@change="updateSeriesData(slider.value, index)"
|
||||||
|
:class="`slider-${index}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { status, setDexHandAngle } from '@/api/device/dexHand';
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:topSeriesData']);
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const handImage = ref(null);
|
||||||
|
const containerRef = ref(null); // 新增:引用父容器
|
||||||
|
const defaultHandImageInfo = { width: 891, height: 981 };
|
||||||
|
const imageAspectRatio = defaultHandImageInfo.width / defaultHandImageInfo.height;
|
||||||
|
const terminalId = ref('');
|
||||||
|
const deviceId = ref('');
|
||||||
|
// 1. 定义 props
|
||||||
|
const props = defineProps({
|
||||||
|
initialDeviceId: { // 示例 prop,用于从弹窗接收cameraId
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
initialTerminalId: { // 示例 prop,用于从弹窗接收 terminalId
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 使用 watchEffect 来响应 props 和路由的变化
|
||||||
|
watchEffect(() => {
|
||||||
|
terminalId.value = props.initialTerminalId;
|
||||||
|
deviceId.value = props.initialDeviceId;
|
||||||
|
console.log(deviceId.value, terminalId.value,props)
|
||||||
|
});
|
||||||
|
|
||||||
|
const sliders = ref([
|
||||||
|
{ value: 0, defalutHeight: 466, height: 0, x: 120, y: 140, vertical: true, style: {} }, // 小拇指
|
||||||
|
{ value: 0, defalutHeight: 466, height: 0, x: 230, y: 140, vertical: true, style: {} }, // 无名指
|
||||||
|
{ value: 0, defalutHeight: 466, height: 0, x: 373, y: 140, vertical: true, style: {} }, // 中指
|
||||||
|
{ value: 0, defalutHeight: 466, height: 0, x: 468, y: 140, vertical: true, style: {} }, // 食指
|
||||||
|
{ value: 0, defalutHeight: 346, height: 0, x: 608, y: 360, vertical: true, style: {} }, // 大拇指弯曲
|
||||||
|
{ value: 0, defalutHeight: 633, height: 0, x: 85, y: 723, vertical: false, style: {} }, // 大拇指旋转
|
||||||
|
]);
|
||||||
|
|
||||||
|
function updateSeriesData(value, i) {
|
||||||
|
console.log(deviceId.value, terminalId.value)
|
||||||
|
loading.value = true;
|
||||||
|
setDexHandAngle({ deviceId: deviceId.value, terminalId: terminalId.value, value: value / 100, id: i }).then(() => {
|
||||||
|
loading.value = false;
|
||||||
|
updateToSeriesData();
|
||||||
|
}).catch(() => {
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCopied = ref(false);
|
||||||
|
|
||||||
|
const copyParams = () => {
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(sliders.value))
|
||||||
|
.then(() => {
|
||||||
|
isCopied.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
isCopied.value = false;
|
||||||
|
}, 3000);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('复制失败:', err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const debounce = (func, delay) => {
|
||||||
|
let timeoutId;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => func.apply(null, args), delay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSliderPositions = () => {
|
||||||
|
const img = handImage.value;
|
||||||
|
if (img) {
|
||||||
|
const { width, height } = img.getBoundingClientRect();
|
||||||
|
console.log('Window resized - New dimensions:', width, height);
|
||||||
|
const widthRatio = width / height;
|
||||||
|
let realWidth = 0;
|
||||||
|
let realHeight = 0;
|
||||||
|
if (widthRatio > imageAspectRatio) {
|
||||||
|
realHeight = height;
|
||||||
|
realWidth = realHeight * imageAspectRatio;
|
||||||
|
} else {
|
||||||
|
realWidth = width;
|
||||||
|
realHeight = realWidth / imageAspectRatio;
|
||||||
|
}
|
||||||
|
const realImageAspectRatio = realWidth / defaultHandImageInfo.width;
|
||||||
|
console.log('Real dimensions:', realWidth, realHeight, realImageAspectRatio);
|
||||||
|
|
||||||
|
sliders.value.forEach((slider, index) => {
|
||||||
|
slider.height = slider.defalutHeight * realImageAspectRatio;
|
||||||
|
slider.style = {
|
||||||
|
top: (height / 2 - realHeight / 2) + slider.y * realImageAspectRatio - 16 + 'px',
|
||||||
|
left: (width / 2 - realWidth / 2) + slider.x * realImageAspectRatio - 16 + 'px',
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 10,
|
||||||
|
width: slider.height + 'px',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateToSeriesData = () => {
|
||||||
|
status({ deviceId: deviceId.value, terminalId: terminalId.value }).then((res) => {
|
||||||
|
const newTopSeriesData = res.data.handsList.map(item => item.force);
|
||||||
|
emit('update:topSeriesData', newTopSeriesData);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const data = ref({
|
||||||
|
debouncedResize: null,
|
||||||
|
resizeObserver: null,
|
||||||
|
});
|
||||||
|
onMounted(() => {
|
||||||
|
updateSliderPositions();
|
||||||
|
// 使用 ResizeObserver 监听父容器尺寸变化
|
||||||
|
const container = containerRef.value || handImage.value?.parentElement;
|
||||||
|
if (container) {
|
||||||
|
const resizeObserver = new ResizeObserver(debounce(updateSliderPositions, 10));
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
data.value.resizeObserver = resizeObserver;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to find container for ResizeObserver');
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedResize = debounce(updateSliderPositions, 0);
|
||||||
|
window.addEventListener('resize', debouncedResize);
|
||||||
|
data.value.debouncedResize = debouncedResize;
|
||||||
|
updateToSeriesData();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 清理 ResizeObserver
|
||||||
|
if (data.value.resizeObserver) {
|
||||||
|
data.value.resizeObserver.disconnect();
|
||||||
|
data.value.resizeObserver = null;
|
||||||
|
}
|
||||||
|
// 清理 window 的 resize 事件
|
||||||
|
if (data.value.debouncedResize) {
|
||||||
|
window.removeEventListener('resize', data.value.debouncedResize);
|
||||||
|
data.value.debouncedResize = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.left-top-hand {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hand-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-container {
|
||||||
|
position: absolute;
|
||||||
|
zIndex: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-container ::v-deep(.el-slider__button) {
|
||||||
|
width: 25px !important;
|
||||||
|
height: 12px !important;
|
||||||
|
background-color: #409eff !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-5 ::v-deep(.el-slider__button) {
|
||||||
|
width: 12px !important;
|
||||||
|
height: 25px !important;
|
||||||
|
background-color: #409eff !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-container .el-slider__runway {
|
||||||
|
background-color: #007bff;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-title {
|
||||||
|
color: #409eff;
|
||||||
|
position: absolute;
|
||||||
|
bottom: -20px;
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-title-5 {
|
||||||
|
bottom: 8px;
|
||||||
|
left: -30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
width: 100%;
|
||||||
|
height: 50%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.slider-container {
|
||||||
|
width: 5vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
position: absolute;
|
||||||
|
background-color: #FFFfff;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 0;
|
||||||
|
color: #646362;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:hover {
|
||||||
|
background-color: #FFF5E1;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:active {
|
||||||
|
background-color: #FFDAB9;
|
||||||
|
transform: translateY(0);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:focus {
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:disabled {
|
||||||
|
background-color: #F0E8E0;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,229 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="containerRef" style="width: 100%;height: 100%; margin: 0 auto;">
|
||||||
|
<div class="tabs">
|
||||||
|
<div
|
||||||
|
v-for="(tab, index) in tabs"
|
||||||
|
:key="index"
|
||||||
|
class="tab"
|
||||||
|
:class="{ active: activeTab === tab.value }"
|
||||||
|
@click="switchTab(tab.value)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref="chart" style="width: 100%; height: 100%;"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue';
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
|
||||||
|
// 解构 props
|
||||||
|
const props = defineProps({
|
||||||
|
maxData: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
default: () => ({
|
||||||
|
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
|
||||||
|
indexFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
middleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
ringFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
littleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
palm: { touch: 0 }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
avgData: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
default: () => ({
|
||||||
|
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
|
||||||
|
indexFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
middleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
ringFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
littleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
palm: { touch: 0 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const chart = ref(null);
|
||||||
|
const containerRef = ref(null); // 新增:引用父容器
|
||||||
|
const activeTab = ref('avg'); // 默认选中平均值
|
||||||
|
|
||||||
|
// Tab 配置
|
||||||
|
const tabs = [
|
||||||
|
{ label: '最大值', value: 'max' },
|
||||||
|
{ label: '平均值', value: 'avg' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let myChart;
|
||||||
|
|
||||||
|
// 防抖函数
|
||||||
|
const debounce = (func, delay) => {
|
||||||
|
let timeoutId;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => func.apply(null, args), delay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const initChart = () => {
|
||||||
|
if (!chart.value) {
|
||||||
|
console.error('Chart element is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
myChart = echarts.init(chart.value);
|
||||||
|
const option = {
|
||||||
|
title: { text: '手指触觉数据' },
|
||||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||||
|
legend: { data: ['指端', '指尖', '指中', '指腹', '手掌触觉'] },
|
||||||
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: ['小拇指', '无名指', '中指', '食指', '大拇指', '手掌'],
|
||||||
|
splitArea: { show: true }
|
||||||
|
},
|
||||||
|
yAxis: [{ type: 'value', name: '弯曲应力', max: 4000,interval: 1000 }],
|
||||||
|
series: [
|
||||||
|
{ name: '指端', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#FF5733' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] },
|
||||||
|
{ name: '指尖', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#33FF57' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] },
|
||||||
|
{ name: '指中', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#5733FF' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] },
|
||||||
|
{ name: '指腹', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#FF33A1' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] },
|
||||||
|
{ name: '手掌触觉', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#FFD700' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
myChart.setOption(option);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取每个部分的数据,处理空值
|
||||||
|
const getPartData = (data, part) => {
|
||||||
|
return [
|
||||||
|
data.littleFinger[part] || null,
|
||||||
|
data.ringFinger[part] || null,
|
||||||
|
data.middleFinger[part] || null,
|
||||||
|
data.indexFinger[part] || null,
|
||||||
|
data.thumb[part] || null,
|
||||||
|
data.palm[part] || null
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateChart = (data) => {
|
||||||
|
const seriesData = {
|
||||||
|
end: getPartData(data, 'end'),
|
||||||
|
tip: getPartData(data, 'tip'),
|
||||||
|
middle: getPartData(data, 'middle'),
|
||||||
|
palm: getPartData(data, 'palm'),
|
||||||
|
touch: getPartData(data, 'touch')
|
||||||
|
};
|
||||||
|
myChart.setOption({
|
||||||
|
series: [
|
||||||
|
{ name: '指端', data: seriesData.end },
|
||||||
|
{ name: '指尖', data: seriesData.tip },
|
||||||
|
{ name: '指中', data: seriesData.middle },
|
||||||
|
{ name: '指腹', data: seriesData.palm },
|
||||||
|
{ name: '手掌触觉', data: seriesData.touch }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchTab = (tab) => {
|
||||||
|
activeTab.value = tab;
|
||||||
|
updateChart(activeTab.value === 'max' ? props.maxData : props.avgData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 监听 props 变化,更新图表
|
||||||
|
watch([() => props.maxData, () => props.avgData, activeTab], () => {
|
||||||
|
updateChart(activeTab.value === 'max' ? props.maxData : props.avgData);
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = ref({
|
||||||
|
resizeObserver: null,
|
||||||
|
debouncedResize: null,
|
||||||
|
});
|
||||||
|
onMounted(() => {
|
||||||
|
initChart();
|
||||||
|
updateChart(activeTab.value === 'max' ? props.maxData : props.avgData);
|
||||||
|
|
||||||
|
// 使用 ResizeObserver 监听父容器尺寸变化
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (container) {
|
||||||
|
const resizeObserver = new ResizeObserver(debounce(() => {
|
||||||
|
if (myChart) {
|
||||||
|
console.log('Container resized:', container.offsetWidth, container.offsetHeight);
|
||||||
|
myChart.resize();
|
||||||
|
}
|
||||||
|
}, 10));
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
data.value.resizeObserver = resizeObserver;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to find container for ResizeObserver');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留 window 的 resize 事件监听
|
||||||
|
const debouncedResize = debounce(() => {
|
||||||
|
if (myChart) {
|
||||||
|
myChart.resize();
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
window.addEventListener('resize', debouncedResize);
|
||||||
|
data.value.debouncedResize = debouncedResize;
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 清理图表实例
|
||||||
|
if (myChart) {
|
||||||
|
myChart.dispose();
|
||||||
|
myChart = null;
|
||||||
|
}
|
||||||
|
// 清理 ResizeObserver
|
||||||
|
if (data.value.resizeObserver) {
|
||||||
|
data.value.resizeObserver.disconnect();
|
||||||
|
data.value.resizeObserver = null;
|
||||||
|
}
|
||||||
|
// 清理 window 的 resize 事件
|
||||||
|
if (data.value.debouncedResize) {
|
||||||
|
window.removeEventListener('resize', data.value.debouncedResize);
|
||||||
|
data.value.debouncedResize = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
position: absolute;
|
||||||
|
right: 50%;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0);
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin: 0 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
border-bottom: 3px solid #007bff;
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
162
src/views/device/register/components/DexHand/RightTopCharts.vue
Normal file
162
src/views/device/register/components/DexHand/RightTopCharts.vue
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<div class="right-chart-panel" ref="containerRef">
|
||||||
|
<div ref="topChart" class="chart"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
topSeriesData: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [0, 0, 0, 0, 0, 0],
|
||||||
|
validator: (value) => value.length === 6 && value.every((v) => typeof v === 'number'),
|
||||||
|
},
|
||||||
|
maxData: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
default: () => ({
|
||||||
|
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
|
||||||
|
indexFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
middleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
ringFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
littleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
palm: { touch: 0 }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
avgData: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
default: () => ({
|
||||||
|
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
|
||||||
|
indexFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
middleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
ringFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
littleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
palm: { touch: 0 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const topChart = ref(null);
|
||||||
|
const containerRef = ref(null); // 新增:引用父容器
|
||||||
|
let chart1Instance = null;
|
||||||
|
|
||||||
|
// 防抖函数
|
||||||
|
const debounce = (func, delay) => {
|
||||||
|
let timeoutId;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => func.apply(null, args), delay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const topChartData = ref({
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: ['小拇指', '无名指', '中指', '食指', '大拇指弯曲', '大拇指旋转'],
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
name: '受力',
|
||||||
|
type: 'value',
|
||||||
|
min: 0,
|
||||||
|
max: 10000,
|
||||||
|
interval: 2500,
|
||||||
|
axisLine: { show: true, lineStyle: { color: '#1f77b4' } },
|
||||||
|
axisLabel: { formatter: '{value} g' },
|
||||||
|
axisTick: { show: true },
|
||||||
|
},
|
||||||
|
series: [{ data: props.topSeriesData, type: 'bar' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = ref({
|
||||||
|
resizeObserver: null,
|
||||||
|
debouncedResize: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
if (!topChart.value) {
|
||||||
|
console.error('Chart element is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chart1Instance = echarts.init(topChart.value);
|
||||||
|
chart1Instance.setOption(topChartData.value);
|
||||||
|
|
||||||
|
// 使用 ResizeObserver 监听父容器尺寸变化
|
||||||
|
const container = containerRef.value;
|
||||||
|
if (container) {
|
||||||
|
const resizeObserver = new ResizeObserver(debounce(() => {
|
||||||
|
if (chart1Instance) {
|
||||||
|
console.log('Container resized:', container.offsetWidth, container.offsetHeight);
|
||||||
|
chart1Instance.resize();
|
||||||
|
}
|
||||||
|
}, 10));
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
data.value.resizeObserver = resizeObserver;
|
||||||
|
} else {
|
||||||
|
console.error('Failed to find container for ResizeObserver');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保留 window 的 resize 事件监听
|
||||||
|
const debouncedResize = debounce(() => {
|
||||||
|
if (chart1Instance) {
|
||||||
|
chart1Instance.resize();
|
||||||
|
}
|
||||||
|
}, 10);
|
||||||
|
window.addEventListener('resize', debouncedResize);
|
||||||
|
data.value.debouncedResize = debouncedResize;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('图表初始化失败:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 清理图表实例
|
||||||
|
if (chart1Instance) {
|
||||||
|
chart1Instance.dispose();
|
||||||
|
chart1Instance = null;
|
||||||
|
}
|
||||||
|
// 清理 ResizeObserver
|
||||||
|
if (data.value.resizeObserver) {
|
||||||
|
data.value.resizeObserver.disconnect();
|
||||||
|
data.value.resizeObserver = null;
|
||||||
|
}
|
||||||
|
// 清理 window 的 resize 事件
|
||||||
|
if (data.value.debouncedResize) {
|
||||||
|
window.removeEventListener('resize', data.value.debouncedResize);
|
||||||
|
data.value.debouncedResize = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听 props.topSeriesData
|
||||||
|
watch(
|
||||||
|
() => props.topSeriesData,
|
||||||
|
(newTopData) => {
|
||||||
|
if (chart1Instance) {
|
||||||
|
chart1Instance.setOption({ series: [{ data: newTopData, type: 'bar' }] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.right-chart-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
height: 50%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.chart {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
margin: 10px 0;
|
||||||
|
min-height: 200px; /* 确保图表有足够的高度 */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
158
src/views/device/register/components/DexHand/index.vue
Normal file
158
src/views/device/register/components/DexHand/index.vue
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
<!-- index.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<div class="left-panel">
|
||||||
|
<div class="hand-container">
|
||||||
|
<!-- 确保这里使用的是 index.vue 中响应式的 deviceId ref -->
|
||||||
|
<LeftTopHand :initialDeviceId="deviceId" :initialTerminalId="terminalId" @update:topSeriesData="handleUpdateTopSeriesData" />
|
||||||
|
</div>
|
||||||
|
<div class="hand-container">
|
||||||
|
<!-- 确保这里使用的是 index.vue 中响应式的 terminalId ref -->
|
||||||
|
<LeftBottomHand :initialDeviceId="deviceId" :initialTerminalId="terminalId" @update:bottomSeriesData="handleUpdateBottomSeriesData" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right-panel">
|
||||||
|
<RightTopCharts :top-series-data="topSeriesData" style="height: 50%;" />
|
||||||
|
<RightBottomCharts :max-data="maxData" :avg-data="avgData" style="height: 50%;" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'; // 引入 watch
|
||||||
|
// 假设你的 router 实例是这样获取的
|
||||||
|
import { useRoute } from 'vue-router'; // 确保你已经安装并配置了 vue-router
|
||||||
|
import RightTopCharts from './RightTopCharts.vue';
|
||||||
|
import LeftTopHand from './LeftTopHand.vue';
|
||||||
|
import LeftBottomHand from './LeftBottomHand.vue';
|
||||||
|
import RightBottomCharts from './RightBottomCharts.vue';
|
||||||
|
import { getRegister } from "@/api/device/register";
|
||||||
|
|
||||||
|
// 1. 定义 props
|
||||||
|
const props = defineProps({
|
||||||
|
initialDeviceId: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
initialTerminalId: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 响应式数据
|
||||||
|
const topSeriesData = ref([0, 0, 0, 0, 0, 0]);
|
||||||
|
const bottomSeriesData = ref([0, 0, 0, 0, 0, 0]);
|
||||||
|
const maxData = ref({
|
||||||
|
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
|
||||||
|
indexFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
middleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
ringFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
littleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
palm: { touch: 0 }
|
||||||
|
});
|
||||||
|
const avgData = ref({
|
||||||
|
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
|
||||||
|
indexFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
middleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
ringFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
littleFinger: { end: 0, tip: 0, middle: 0 },
|
||||||
|
palm: { touch: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 声明组件内部使用的响应式变量
|
||||||
|
const deviceId = ref("");
|
||||||
|
const terminalId = ref("");
|
||||||
|
|
||||||
|
const handleUpdateTopSeriesData = (newData) => {
|
||||||
|
topSeriesData.value = newData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateBottomSeriesData = ({ maxData: newMaxData, avgData: newAvgData }) => {
|
||||||
|
maxData.value = newMaxData;
|
||||||
|
avgData.value = newAvgData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取路由实例
|
||||||
|
const route = useRoute();
|
||||||
|
// 统一处理获取 register 数据的方法
|
||||||
|
const fetchRegisterData = (currentDeviceId) => {
|
||||||
|
if (currentDeviceId) {
|
||||||
|
getRegister(currentDeviceId).then(res => {
|
||||||
|
deviceId.value = res.data.deviceCode; // 再次更新,确保与 API 返回一致
|
||||||
|
terminalId.value = res.data.idDeDeviceTerminalConfig;
|
||||||
|
}).catch(error => {
|
||||||
|
console.error("Error fetching register:", error);
|
||||||
|
// 可以根据需要重置 deviceId 和 terminalId
|
||||||
|
deviceId.value = "";
|
||||||
|
terminalId.value = "";
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 如果 deviceId 为空,可以考虑重置 terminalId
|
||||||
|
terminalId.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 使用 watch 侦听 props 和路由的变化
|
||||||
|
// 当 initialDeviceId prop 变化时,更新 deviceId ref
|
||||||
|
watch(() => props.initialDeviceId, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
deviceId.value = newVal;
|
||||||
|
// 当 props.initialDeviceId 确定后,再调用 getRegister
|
||||||
|
fetchRegisterData(deviceId.value);
|
||||||
|
}
|
||||||
|
}, { immediate: true }); // immediate: true 会在组件挂载时立即执行一次 watch
|
||||||
|
|
||||||
|
// 如果 props.initialDeviceId 为空,则尝试从路由获取
|
||||||
|
// 注意:这里假设 route.path.split("/")[3] 是cameraId
|
||||||
|
// 如果你的路由结构不同,请调整这里的逻辑
|
||||||
|
watch(() => route.path, (newPath) => {
|
||||||
|
const routeDeviceId = newPath.split("/")[3];
|
||||||
|
if (!deviceId.value && routeDeviceId) { // 只有当 deviceId 尚未从 props 或其他地方设置时才从路由获取
|
||||||
|
deviceId.value = routeDeviceId;
|
||||||
|
fetchRegisterData(deviceId.value);
|
||||||
|
}
|
||||||
|
}, { immediate: true }); // immediate: true 确保在组件挂载时也检查路由
|
||||||
|
|
||||||
|
|
||||||
|
// 如果需要 initialTerminalId prop 也影响 terminalId ref,可以添加一个 watch
|
||||||
|
watch(() => props.initialTerminalId, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
terminalId.value = newVal;
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex: 1; /* 占据父容器所有可用空间 */
|
||||||
|
width: 100%;
|
||||||
|
height: 100%; /* 占满父容器高度 */
|
||||||
|
min-height: 0; /* 防止 flex 子项最小高度问题 */
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden; /* 防止内容溢出 */
|
||||||
|
box-sizing: border-box; /* 包含 padding 和 border */
|
||||||
|
padding: 20px; /* 与父容器一致,避免双重 padding */
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel,
|
||||||
|
.right-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%; /* 占满 container 高度 */
|
||||||
|
padding: 0; /* 移除子面板内边距,交给 container 处理 */
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hand-container {
|
||||||
|
position: relative;
|
||||||
|
height: 50%; /* 每个 hand-container 占左面板的 50% 高度 */
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
314
src/views/device/register/components/Head/index.vue
Normal file
314
src/views/device/register/components/Head/index.vue
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
<template>
|
||||||
|
<div class="head-control">
|
||||||
|
<h2 class="title">人工头舵机控制</h2>
|
||||||
|
<div class="main-content">
|
||||||
|
<!-- 左侧:10个Yaw预设输入框 -->
|
||||||
|
<div class="preset-panel left-panel">
|
||||||
|
<h3>Yaw 预设位(左右转头)</h3>
|
||||||
|
<div v-for="(preset, index) in leftPresets" :key="index" class="preset-item">
|
||||||
|
<span class="label">位 {{ index + 1 }}</span>
|
||||||
|
<el-input-number
|
||||||
|
v-model="preset.value"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
:precision="2"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
@change="onPresetChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" size="small" @click="applyYawFromPresets">应用当前 Yaw</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 中央图片 + 20个控制点 -->
|
||||||
|
<div class="image-container">
|
||||||
|
<img
|
||||||
|
src="https://cdn.head-acoustics.com/_processed_/3/5/csm_HMS_V_frontal_seitlich_67c86d4fad.jpg"
|
||||||
|
alt="人工头"
|
||||||
|
class="head-image"
|
||||||
|
/>
|
||||||
|
<!-- 20 个控制点 -->
|
||||||
|
<div
|
||||||
|
v-for="(point, index) in points"
|
||||||
|
:key="index"
|
||||||
|
class="control-point"
|
||||||
|
:style="{ left: point.x + '%', top: point.y + '%' }"
|
||||||
|
:class="{ active: point.active }"
|
||||||
|
@click="applyPreset(index)"
|
||||||
|
:title="`点 ${index + 1}: Yaw=${point.yaw.toFixed(2)}, Pitch=${point.pitch.toFixed(2)}`"
|
||||||
|
>
|
||||||
|
{{ index + 1 }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:10个Pitch预设输入框 -->
|
||||||
|
<div class="preset-panel right-panel">
|
||||||
|
<h3>Pitch 预设位(上下点头)</h3>
|
||||||
|
<div v-for="(preset, index) in rightPresets" :key="index" class="preset-item">
|
||||||
|
<span class="label">位 {{ index + 1 }}</span>
|
||||||
|
<el-input-number
|
||||||
|
v-model="preset.value"
|
||||||
|
:min="0"
|
||||||
|
:max="1"
|
||||||
|
:step="0.01"
|
||||||
|
:precision="2"
|
||||||
|
size="small"
|
||||||
|
controls-position="right"
|
||||||
|
@change="onPresetChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" size="small" @click="applyPitchFromPresets">应用当前 Pitch</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 当前值显示与下发 -->
|
||||||
|
<div class="current-display">
|
||||||
|
<div class="current-item">
|
||||||
|
<span>当前 Yaw:</span>
|
||||||
|
<span class="value">{{ servo1.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="current-item">
|
||||||
|
<span>当前 Pitch:</span>
|
||||||
|
<span class="value">{{ servo2.toFixed(2) }}</span>
|
||||||
|
</div>
|
||||||
|
<el-button type="primary" @click="sendServoValues">立即下发舵机值</el-button>
|
||||||
|
<el-button @click="resetToCenter">复位到中心 (0.50, 0.50)</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
// import { setServo } from '@/api/device/head'; // 假设接口
|
||||||
|
|
||||||
|
const terminalId = ref('25449ff3fcc7b27da5f69462c5efcec2');
|
||||||
|
const deviceId = ref('head1');
|
||||||
|
|
||||||
|
const servo1 = ref(0.5); // 当前 Yaw
|
||||||
|
const servo2 = ref(0.5); // 当前 Pitch
|
||||||
|
|
||||||
|
// 左侧10个Yaw预设(对应左侧10个点)
|
||||||
|
const leftPresets = ref([
|
||||||
|
{ value: 0.10 }, { value: 0.15 }, { value: 0.08 }, { value: 0.12 }, { value: 0.18 },
|
||||||
|
{ value: 0.05 }, { value: 0.10 }, { value: 0.15 }, { value: 0.08 }, { value: 0.12 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 右侧10个Pitch预设(对应右侧10个点,但Pitch值可独立调整)
|
||||||
|
const rightPresets = ref([
|
||||||
|
{ value: 0.20 }, { value: 0.30 }, { value: 0.40 }, { value: 0.50 }, { value: 0.60 },
|
||||||
|
{ value: 0.70 }, { value: 0.80 }, { value: 0.90 }, { value: 0.95 }, { value: 1.00 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 当预设值改变时,自动同步到对应的点(可选)
|
||||||
|
const onPresetChange = () => {
|
||||||
|
// 可选:同步到 points 数组的 yaw/pitch
|
||||||
|
leftPresets.value.forEach((p, i) => {
|
||||||
|
points.value[i].yaw = p.value;
|
||||||
|
});
|
||||||
|
rightPresets.value.forEach((p, i) => {
|
||||||
|
points.value[i + 10].pitch = p.value;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 点击图片上的点 → 应用对应预设,并同步到输入框
|
||||||
|
const applyPreset = (index) => {
|
||||||
|
points.value.forEach(p => p.active = false);
|
||||||
|
points.value[index].active = true;
|
||||||
|
|
||||||
|
const yaw = points.value[index].yaw;
|
||||||
|
const pitch = points.value[index].pitch;
|
||||||
|
|
||||||
|
servo1.value = yaw;
|
||||||
|
servo2.value = pitch;
|
||||||
|
|
||||||
|
// 同步到左侧/右侧输入框(高亮当前对应的预设)
|
||||||
|
if (index < 10) {
|
||||||
|
leftPresets.value.forEach((p, i) => p.value = i === index ? yaw : p.value);
|
||||||
|
} else {
|
||||||
|
rightPresets.value.forEach((p, i) => p.value = i === (index - 10) ? pitch : p.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendServoValues();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从左侧预设应用当前选中的Yaw(假设用户先在输入框选中一个,这里用最后一个修改的近似,或添加选中逻辑)
|
||||||
|
let lastLeftIndex = null;
|
||||||
|
let lastRightIndex = null;
|
||||||
|
|
||||||
|
// 简化:提供按钮直接应用当前所有输入框的平均或手动选择。这里提供“应用当前Yaw”按钮,使用第一个非默认的值或手动。
|
||||||
|
// 为简化,这里按钮应用“第一个输入框的值”作为Yaw,你可以改为添加选中高亮。
|
||||||
|
const applyYawFromPresets = () => {
|
||||||
|
servo1.value = leftPresets.value[0].value; // 示例:使用第一个,你可改为选中机制
|
||||||
|
sendServoValues();
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPitchFromPresets = () => {
|
||||||
|
servo2.value = rightPresets.value[0].value; // 示例:使用第一个
|
||||||
|
sendServoValues();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendServoValues = async () => {
|
||||||
|
try {
|
||||||
|
// await setServo({
|
||||||
|
// terminalId: terminalId.value,
|
||||||
|
// deviceId: deviceId.value,
|
||||||
|
// servo1: servo1.value,
|
||||||
|
// servo2: servo2.value,
|
||||||
|
// });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('舵机下发失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetToCenter = () => {
|
||||||
|
servo1.value = 0.5;
|
||||||
|
servo2.value = 0.5;
|
||||||
|
points.value.forEach(p => p.active = false);
|
||||||
|
sendServoValues();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 20个点坐标(已调整适应新图片)
|
||||||
|
const points = ref([
|
||||||
|
// 左侧10点
|
||||||
|
{ x: 18, y: 20, yaw: 0.10, pitch: 0.20, active: false },
|
||||||
|
{ x: 22, y: 30, yaw: 0.15, pitch: 0.30, active: false },
|
||||||
|
{ x: 15, y: 40, yaw: 0.08, pitch: 0.40, active: false },
|
||||||
|
{ x: 20, y: 50, yaw: 0.12, pitch: 0.50, active: false },
|
||||||
|
{ x: 25, y: 60, yaw: 0.18, pitch: 0.60, active: false },
|
||||||
|
{ x: 12, y: 70, yaw: 0.05, pitch: 0.70, active: false },
|
||||||
|
{ x: 18, y: 80, yaw: 0.10, pitch: 0.80, active: false },
|
||||||
|
{ x: 22, y: 90, yaw: 0.15, pitch: 0.90, active: false },
|
||||||
|
{ x: 15, y: 95, yaw: 0.08, pitch: 0.95, active: false },
|
||||||
|
{ x: 20, y: 98, yaw: 0.12, pitch: 1.00, active: false },
|
||||||
|
|
||||||
|
// 右侧10点(Yaw偏大)
|
||||||
|
{ x: 82, y: 20, yaw: 0.90, pitch: 0.20, active: false },
|
||||||
|
{ x: 78, y: 30, yaw: 0.85, pitch: 0.30, active: false },
|
||||||
|
{ x: 85, y: 40, yaw: 0.92, pitch: 0.40, active: false },
|
||||||
|
{ x: 80, y: 50, yaw: 0.88, pitch: 0.50, active: false },
|
||||||
|
{ x: 75, y: 60, yaw: 0.82, pitch: 0.60, active: false },
|
||||||
|
{ x: 88, y: 70, yaw: 0.95, pitch: 0.70, active: false },
|
||||||
|
{ x: 82, y: 80, yaw: 0.90, pitch: 0.80, active: false },
|
||||||
|
{ x: 78, y: 90, yaw: 0.85, pitch: 0.90, active: false },
|
||||||
|
{ x: 85, y: 95, yaw: 0.92, pitch: 0.95, active: false },
|
||||||
|
{ x: 80, y: 98, yaw: 0.88, pitch: 1.00, active: false },
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head-control {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
height: calc(100vh - 125px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-panel {
|
||||||
|
width: 220px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-panel h3 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
width: 50px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-container {
|
||||||
|
position: relative;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-image {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-point {
|
||||||
|
position: absolute;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(64, 158, 255, 0.8);
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 3px solid #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-point:hover {
|
||||||
|
background: #409eff;
|
||||||
|
transform: translate(-50%, -50%) scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-point.active {
|
||||||
|
background: #f56c6c;
|
||||||
|
transform: translate(-50%, -50%) scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-display {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 40px;
|
||||||
|
margin-top: 30px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #409eff;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
103
src/views/device/register/components/LogicFlow/CustomGroup.js
Normal file
103
src/views/device/register/components/LogicFlow/CustomGroup.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
|
||||||
|
import { GroupNodeModel, GroupNode } from '@logicflow/extension'
|
||||||
|
import { recursiveFilter } from "@/utils/convertNodeRedToLogicFlow";
|
||||||
|
|
||||||
|
class CustomGroupModel extends GroupNodeModel {
|
||||||
|
initNodeData(data) {
|
||||||
|
super.initNodeData(data);
|
||||||
|
this.children = new Set(data.children || [])
|
||||||
|
this.zIndex = 0;
|
||||||
|
|
||||||
|
this.width = data.w + 80; // 增加内边距
|
||||||
|
this.height = data.h + 80;
|
||||||
|
this.x = data.x;
|
||||||
|
this.y = data.y;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.updateSize()
|
||||||
|
const nodes = this.graphModel.nodes;
|
||||||
|
const arr = recursiveFilter(nodes, this.id)
|
||||||
|
arr.forEach((item) => {
|
||||||
|
item.updateSize()
|
||||||
|
})
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttributes() {
|
||||||
|
this.text.editable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTextStyle() {
|
||||||
|
const style = super.getTextStyle();
|
||||||
|
style.fill = this.properties.style?.color || '#333';
|
||||||
|
style.fontSize = 16;
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSize() {
|
||||||
|
const children = []
|
||||||
|
this.children.forEach((id) => {
|
||||||
|
children.push(this.graphModel.getNodeModelById(id))
|
||||||
|
});
|
||||||
|
if (children.length === 0) return;
|
||||||
|
|
||||||
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||||
|
children.forEach((node) => {
|
||||||
|
minX = Math.min(minX, node.x - node.width / 2);
|
||||||
|
maxX = Math.max(maxX, node.x + node.width / 2);
|
||||||
|
minY = Math.min(minY, node.y - node.height / 2);
|
||||||
|
maxY = Math.max(maxY, node.y + node.height / 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.width = maxX - minX + 80; // 增加内边距
|
||||||
|
this.height = maxY - minY + 80;
|
||||||
|
this.x = (minX + maxX) / 2;
|
||||||
|
this.y = (minY + maxY) / 2;
|
||||||
|
this.setTextCoordinate(this.width, this.height, this.x, this.y)
|
||||||
|
}
|
||||||
|
|
||||||
|
setTextCoordinate(width, height, x, y) {
|
||||||
|
const strategies = {
|
||||||
|
"n": () => {
|
||||||
|
this.text.x = x
|
||||||
|
this.text.y = y - height / 2 + 20
|
||||||
|
},
|
||||||
|
"ne": () => {
|
||||||
|
this.text.x = x + width / 2 - 20
|
||||||
|
this.text.y = y - height / 2 + 20
|
||||||
|
},
|
||||||
|
"sw": () => {
|
||||||
|
this.text.x = x - width / 2 + 20
|
||||||
|
this.text.y = y + height / 2 - 20
|
||||||
|
},
|
||||||
|
"s": () => {
|
||||||
|
this.text.x = x
|
||||||
|
this.text.y = y + height / 2 - 20
|
||||||
|
},
|
||||||
|
"se": () => {
|
||||||
|
this.text.x = x + width / 2 - 20
|
||||||
|
this.text.y = y + height / 2 - 20
|
||||||
|
},
|
||||||
|
"default": () => {
|
||||||
|
this.text.x = x - width / 2 + 20
|
||||||
|
this.text.y = y - height / 2 + 20
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const labelPosition = this.properties.style?.['label-position'] || 'default'
|
||||||
|
const action = strategies[labelPosition]
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild(id) {
|
||||||
|
this.children.delete(id) // 现在可安全调用
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出方法注册
|
||||||
|
export function registerCustomGroup(lf) {
|
||||||
|
lf.register({
|
||||||
|
type: "customGroup",
|
||||||
|
view: GroupNode,
|
||||||
|
model: CustomGroupModel
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
export const conf = {
|
||||||
|
/**
|
||||||
|
* 基础功能配置
|
||||||
|
* */
|
||||||
|
autoplay: false, // 是否自动播放,不自动播放,浏览器有限制规则
|
||||||
|
autoplayMuted: false, // 是否自动播放(静音播放)
|
||||||
|
videoInit: true, // 是否默认初始化video,默认初始化,默认true
|
||||||
|
playsinline: true, // 是否启用内联播放模式,仅移动端生效
|
||||||
|
defaultPlaybackRate: 1, // 默认播放速度(可选:0.5/0.75/1/1.5/2等)
|
||||||
|
volume: 0.72, // 播放音量(可选:0 ~ 1)
|
||||||
|
loop: false, // 是否循环播放,默认不循环播放
|
||||||
|
startTime: 0, // 点播模式下,初始起播时间
|
||||||
|
videoAttributes: {}, // video扩展属性,暂且不配置
|
||||||
|
lang: 'zh-cn', // 播放器初始显示语言,设置为中文
|
||||||
|
fluid: true, // 是否流式布局(宽高优先于流失布局,默认16:9)注掉上方宽高看效果
|
||||||
|
fitVideoSize: 'fixed', // 保持容器宽/高,不做适配,按照容器来
|
||||||
|
videoFillMode: 'auto', // 宽高不够自动底色填充(fill拉伸填充等...)
|
||||||
|
seekedStatus: 'play', // 跳转后继续播放
|
||||||
|
// 播放器进度条故事点信息数组
|
||||||
|
progressDot: [
|
||||||
|
{
|
||||||
|
id: 0, // 唯一标识,用于删除的时候索引
|
||||||
|
time: 30, // 展示的时间点,例子为在播放到10s钟的时候展示
|
||||||
|
text: '进度条信息提示...', // hover的时候展示文案,可以为空
|
||||||
|
duration: 1, // 展示时间跨度,单位为s
|
||||||
|
style: { // 指定样式
|
||||||
|
backgroundColor: 'white'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
thumbnail: null, // 进度条预览图配置,普通业务用不到
|
||||||
|
marginControls: false, // 是否开启画面和控制栏分离模式,不开启空间多一些
|
||||||
|
domEventType: 'default', // 响应的事件类型,不用指定,用默认的即可
|
||||||
|
/**
|
||||||
|
* 交互功能配置(一般使用默认即可)
|
||||||
|
* */
|
||||||
|
/**
|
||||||
|
* 插件配置,根据需求自选
|
||||||
|
* */
|
||||||
|
icons: {}, // 使用默认的icon图标
|
||||||
|
i18n: [], // 使用默认的中文
|
||||||
|
// 自定义一些颜色
|
||||||
|
commonStyle: {
|
||||||
|
progressColor: 'green', // 整个进度条颜色
|
||||||
|
playedColor: 'chocolate', // 已播放的进度条颜色
|
||||||
|
volumeColor: 'pink', // 音量大小竖向滑块颜色
|
||||||
|
},
|
||||||
|
controls: true, // 是否使用底部控制栏,默认使用
|
||||||
|
miniprogress: true, // 是否使用mini进度条(当底部控制栏隐藏时生效)
|
||||||
|
screenShot: false, // 关闭截图功能
|
||||||
|
rotate: true, // 是否使用视频旋转插件,默认不使用
|
||||||
|
download: true, // 是否使用下载按钮,一般不用,一般自定义控制
|
||||||
|
pip: true, // 使用使用画中画模式,默认不用
|
||||||
|
mini: true, // 是否使用小屏幕控件
|
||||||
|
cssFullscreen: true, // 是否使用网页样式全屏按钮开关
|
||||||
|
playbackRate: [0.5, 1, 1.5, 2, 3], //传入倍速可选数组
|
||||||
|
// playbackRate: true, //false,禁用倍速播放(即控制栏不显示)
|
||||||
|
keyShortcut: false, // 是否开启快捷键模式
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div class="player_container">
|
||||||
|
<div ref="xgPlayerRef"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import Player, { Events } from 'xgplayer'; // 引入西瓜视频模块
|
||||||
|
import 'xgplayer/dist/index.min.css'; // 引入西瓜视频样式
|
||||||
|
|
||||||
|
const { videoUrl } = defineProps({
|
||||||
|
videoUrl: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const xgPlayerRef = ref()
|
||||||
|
|
||||||
|
import { conf } from "./config"; // 配置文件单独拎出来一个js文件
|
||||||
|
|
||||||
|
onMounted(() => { init() })
|
||||||
|
|
||||||
|
let player = null // 实例
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
player = new Player({
|
||||||
|
el: xgPlayerRef.value,
|
||||||
|
// width: 600,
|
||||||
|
// height: 400, // 视频宽高尺寸
|
||||||
|
isLive: false,
|
||||||
|
url: videoUrl, // 视频源
|
||||||
|
// poster: "http://ashuai.work/static/img/avantar.png", // 视频封面
|
||||||
|
...conf,
|
||||||
|
});
|
||||||
|
player.on(Events.PLAY, (ev) => {
|
||||||
|
console.log('-播放开始-', ev);
|
||||||
|
})
|
||||||
|
player.on(Events.PAUSE, (ev) => {
|
||||||
|
console.log('-播放结束-', ev);
|
||||||
|
})
|
||||||
|
player.on('loadedmetadata', (ev) => {
|
||||||
|
console.log('-媒体数据加载好了-', ev);
|
||||||
|
})
|
||||||
|
player.on(Events.SEEKED, (ev) => {
|
||||||
|
console.log('-跳着播放-', ev);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.player_container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.xgplayer {
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
238
src/views/device/register/components/LogicFlow/customFlow.vue
Normal file
238
src/views/device/register/components/LogicFlow/customFlow.vue
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
<template>
|
||||||
|
<div class="custom_element" :class="props.properties.state">
|
||||||
|
<div class="custom_element__header">
|
||||||
|
<div class="custom_element__title">
|
||||||
|
{{ props.text || "--" }}
|
||||||
|
</div>
|
||||||
|
<el-button v-if="showDetailTypeList.includes(props.properties.type)" v-popover="popoverRef" class="custom_element__btn" link @click="openPop"
|
||||||
|
>详情</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="nodeType">节点类型: {{ props.properties.type }}</div>
|
||||||
|
<div class="nodeState">
|
||||||
|
<div>状态:{{ stateMap[props.properties.state] }}</div>
|
||||||
|
</div>
|
||||||
|
<el-popover
|
||||||
|
ref="popoverRef"
|
||||||
|
virtual-triggering
|
||||||
|
persistent
|
||||||
|
placement="right-start"
|
||||||
|
:visible="popoverVisible"
|
||||||
|
popper-class="popover__container"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<div class="popover__header">
|
||||||
|
<div>{{ props.text || "--" }}</div>
|
||||||
|
<div class="closeBtn" @click="popoverVisible = !popoverVisible">
|
||||||
|
<el-icon><CircleClose /></el-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="popover__context">
|
||||||
|
<div class="container">
|
||||||
|
<div class="title">输入参数</div>
|
||||||
|
<el-input
|
||||||
|
v-if="getTypeByParams(props.properties.input) === 0"
|
||||||
|
v-model="props.properties.input"
|
||||||
|
:readonly="true"
|
||||||
|
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||||
|
type="textarea"
|
||||||
|
/>
|
||||||
|
<div v-if="getTypeByParams(props.properties.input) === 2">
|
||||||
|
<el-image
|
||||||
|
style="width: 200px; height: 100px"
|
||||||
|
:src="getUrlByParams(props.properties.input)"
|
||||||
|
:zoom-rate="2"
|
||||||
|
:max-scale="7"
|
||||||
|
:min-scale="0.2"
|
||||||
|
:preview-src-list="[getUrlByParams(props.properties.input)]"
|
||||||
|
show-progress
|
||||||
|
:initial-index="4"
|
||||||
|
fit="cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="title">输出参数</div>
|
||||||
|
<el-input
|
||||||
|
v-if="getTypeByParams(props.properties.output) === 0"
|
||||||
|
v-model="props.properties.output"
|
||||||
|
:readonly="true"
|
||||||
|
:autosize="{ minRows: 2, maxRows: 6 }"
|
||||||
|
type="textarea"
|
||||||
|
/>
|
||||||
|
<div v-if="getTypeByParams(props.properties.output) === 2" class="imageContainer">
|
||||||
|
<el-image
|
||||||
|
v-for="item in getUrlByParams(props.properties.output)"
|
||||||
|
style="width: 60px; height: 60px"
|
||||||
|
:src="item"
|
||||||
|
:preview-src-list="getUrlByParams(props.properties.output)"
|
||||||
|
show-progress
|
||||||
|
fit="fill"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="getTypeByParams(props.properties.output) === 4" class="videoContainer">
|
||||||
|
<IPlayer v-for="item in getUrlByParams(props.properties.output)" :videoUrl="item" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-popover>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { watch } from "vue";
|
||||||
|
import { CircleClose } from '@element-plus/icons-vue'
|
||||||
|
import IPlayer from './IPlayer/index.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
model: Object,
|
||||||
|
graphModel: Object,
|
||||||
|
properties: Object,
|
||||||
|
text: String,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stateMap = {
|
||||||
|
success: "已执行",
|
||||||
|
unexecuted: "未执行",
|
||||||
|
running: "执行中",
|
||||||
|
failed: "异常",
|
||||||
|
paused: "暂停",
|
||||||
|
stopped: "停止"
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDetailTypeList = ['inject', 'startCamera', 'getImage', 'stopCamera', 'process', 'end', 'stopVideo']
|
||||||
|
|
||||||
|
const popoverVisible = ref(false);
|
||||||
|
const popoverRef = ref();
|
||||||
|
|
||||||
|
const openPop = (e) => {
|
||||||
|
popoverVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeByParams = (str) => {
|
||||||
|
let type = 0;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(str);
|
||||||
|
type = data?.type || 0;
|
||||||
|
} catch {
|
||||||
|
type = 0;
|
||||||
|
}
|
||||||
|
return type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const propertiesTypeMap = {
|
||||||
|
'2': 'imageUrl',
|
||||||
|
'4': 'videoUrl',
|
||||||
|
'default': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUrlByParams = (str) => {
|
||||||
|
let url = [];
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(str);
|
||||||
|
url = data?.[propertiesTypeMap[data?.type || 'default']] || 0;
|
||||||
|
} catch {
|
||||||
|
url = [];
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.properties.nowTime,
|
||||||
|
() => {
|
||||||
|
popoverVisible.value = false;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.custom_element {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
color: skyblue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.running, .paused, .stopped {
|
||||||
|
background-color: #8bbf86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unexecuted {
|
||||||
|
background-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
background-color: green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.failed {
|
||||||
|
background-color: rgb(128, 41, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeType {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodeState {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style lang="scss">
|
||||||
|
.popover__container {
|
||||||
|
width: 300px !important;
|
||||||
|
|
||||||
|
.popover__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover__context {
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageContainer {
|
||||||
|
width: 260px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: stretch;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.videoContainer {
|
||||||
|
width: 260px;
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
134
src/views/device/register/components/LogicFlow/customFlowNode.js
Normal file
134
src/views/device/register/components/LogicFlow/customFlowNode.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
// 导入 HtmlNode 及其模型,为后续继承做准备
|
||||||
|
import { HtmlNode, HtmlNodeModel } from '@logicflow/core';
|
||||||
|
// 导入 Vue 相关方法,用于渲染组件
|
||||||
|
import { createApp, h } from 'vue';
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
// 导入 Vue 组件
|
||||||
|
import CustomFlow from './customFlow.vue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定义一个 元素的 HTML 节点类,继承自 HtmlNode
|
||||||
|
* 该类负责在 HTML 中渲染 元素,并处理其交互逻辑
|
||||||
|
*/
|
||||||
|
class CustomFlowHtmlNode extends HtmlNode {
|
||||||
|
isMounted; // 标记组件是否已挂载
|
||||||
|
r; // 渲染函数
|
||||||
|
app; // Vue 应用实例
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造函数
|
||||||
|
* @param props 传递给节点的属性,包括模型、图模型等
|
||||||
|
*/
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.isMounted = false;
|
||||||
|
// 创建 元素的渲染函数
|
||||||
|
this.r = h(CustomFlow, {
|
||||||
|
model: props.model,
|
||||||
|
graphModel: props.graphModel,
|
||||||
|
properties: {
|
||||||
|
...props.model.getProperties(),
|
||||||
|
},
|
||||||
|
text: props.model.text.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建 Vue 应用实例,并指定渲染函数
|
||||||
|
this.app = createApp({
|
||||||
|
render: () => this.r
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 HTML 内容设置到指定的根元素上
|
||||||
|
* @param rootEl 根元素
|
||||||
|
*/
|
||||||
|
setHtml(rootEl) {
|
||||||
|
if (!this.isMounted) {
|
||||||
|
this.isMounted = true;
|
||||||
|
const node = document.createElement('div');
|
||||||
|
node.style.width = '100%'
|
||||||
|
node.style.height = '100%'
|
||||||
|
rootEl.appendChild(node);
|
||||||
|
this.app.use(ElementPlus) // 关键:单独注册ElementPlus
|
||||||
|
this.app.mount(node);
|
||||||
|
} else {
|
||||||
|
this.r.component.props.properties = this.props.model.getProperties();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取节点文本内容
|
||||||
|
* 对于元素,返回 null,因为其内容由特定组件渲染
|
||||||
|
* @returns {null}
|
||||||
|
*/
|
||||||
|
getText() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定义一个元素的 HTML 模型类,继承自 HtmlNodeModel
|
||||||
|
* 该类主要设置节点的属性和样式
|
||||||
|
*/
|
||||||
|
class CustomFlowHtmlModel extends HtmlNodeModel {
|
||||||
|
initNodeData(data) {
|
||||||
|
super.initNodeData(data);
|
||||||
|
// 仅允许从底部锚点连出
|
||||||
|
this.sourceRules.push({
|
||||||
|
message: '只能从输出锚点连线',
|
||||||
|
validate: (sourceNode, targetNode, sourceAnchor) =>
|
||||||
|
// sourceAnchor.name === 'right'
|
||||||
|
sourceAnchor.properties.connectionType === 'source'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 仅允许连接到顶部锚点
|
||||||
|
this.targetRules.push({
|
||||||
|
message: '只能连接到输入锚点',
|
||||||
|
validate: (sourceNode, targetNode, targetAnchor) =>
|
||||||
|
// targetAnchor.name === 'left'
|
||||||
|
targetAnchor.properties.connectionType === 'target'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置节点属性
|
||||||
|
* 包括宽度、高度、文本编辑属性等
|
||||||
|
*/
|
||||||
|
setAttributes() {
|
||||||
|
this.width = 200;
|
||||||
|
this.height = 100;
|
||||||
|
this.text.editable = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义节点只有左右两个锚点. 锚点位置通过中心点和宽度算出来。
|
||||||
|
getDefaultAnchor() {
|
||||||
|
let _a = this, x = _a.x, y = _a.y, width = _a.width, height = _a.height;
|
||||||
|
return [
|
||||||
|
// { x: x, y: y - height / 2, id: "".concat(this.id, "_0") },
|
||||||
|
{ x: x + width / 2, y: y, name: 'left', id: "".concat(this.id, "_1"), properties: { connectionType: 'target' } },
|
||||||
|
// { x: x, y: y + height / 2, id: "".concat(this.id, "_2") },
|
||||||
|
{ x: x - width / 2, y: y, name: 'right', id: "".concat(this.id, "_3"), properties: { connectionType: 'source' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取节点轮廓样式
|
||||||
|
* 覆盖父类方法,设置 stroke 属性为 none,以适应特定的视觉效果
|
||||||
|
* @returns {object} 节点轮廓样式
|
||||||
|
*/
|
||||||
|
getOutlineStyle() {
|
||||||
|
const style = super.getOutlineStyle();
|
||||||
|
style.stroke = 'none';
|
||||||
|
style.hover.stroke = 'none';
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出方法注册
|
||||||
|
export function registerCustomNode(lf) {
|
||||||
|
lf.register({
|
||||||
|
type: "CustomNode",
|
||||||
|
view: CustomFlowHtmlNode,
|
||||||
|
model: CustomFlowHtmlModel
|
||||||
|
})
|
||||||
|
}
|
||||||
294
src/views/device/register/components/LogicFlow/index.vue
Normal file
294
src/views/device/register/components/LogicFlow/index.vue
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flow_container">
|
||||||
|
<div class="btn_container">
|
||||||
|
<div class="instState">实例状态:<span :class="instStatus">{{ stateMap[instStatus]}}</span></div>
|
||||||
|
<el-button key=" translateCenter" type="primary" @click="() => lfRef.translateCenter()">居中</el-button>
|
||||||
|
<el-button key="fitView" type="primary" @click="() => lfRef.fitView()">适应屏幕</el-button>
|
||||||
|
<el-button v-if="['RUNNING','PAUSED'].includes(instStatus)" type="primary" @click="stopFlow">停止</el-button>
|
||||||
|
<el-button v-if="instStatus === 'RUNNING'" type="primary" @click="pauseFlow">暂停</el-button>
|
||||||
|
<el-button v-if="instStatus === 'PAUSED'" type="primary" @click="restartFlow">恢复</el-button>
|
||||||
|
</div>
|
||||||
|
<div ref="containerRef" class="containerRef"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import LogicFlow from "@logicflow/core";
|
||||||
|
import { Group } from "@logicflow/extension";
|
||||||
|
import "@logicflow/core/es/index.css";
|
||||||
|
import { convertNodeRedToLogicFlow, recursiveFilter, changeOtherNodeState } from '@/utils/convertNodeRedToLogicFlow'
|
||||||
|
import { registerCustomNode } from "./customFlowNode";
|
||||||
|
import { registerCustomGroup } from "./CustomGroup";
|
||||||
|
import { getLogicFlow } from '@/api/test/log'
|
||||||
|
import { pauseInst, resumeInst, stopInst } from '@/api/device/terminal'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const containerRef = ref(null);
|
||||||
|
const lfRef = ref(null);
|
||||||
|
// 在新窗口的脚本中
|
||||||
|
const queryParams = new URLSearchParams(window.location.search);
|
||||||
|
const instId = queryParams.get('instId') || '';
|
||||||
|
const taskId = queryParams.get('taskId') || '';
|
||||||
|
const itemId = queryParams.get('itemId') || '03e0842c2fd7ab7575636a473aeefc89';
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "custom-node-1",
|
||||||
|
text: "node-1",
|
||||||
|
type: "CustomNode",
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'custom-node-2',
|
||||||
|
type: 'CustomNode',
|
||||||
|
x: 300,
|
||||||
|
y: 300,
|
||||||
|
text: 'node-2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// 边
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
type: 'polyline',
|
||||||
|
sourceNodeId: 'custom-node-1',
|
||||||
|
targetNodeId: 'custom-node-2',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataNodes = ref([])
|
||||||
|
|
||||||
|
const loadNodeRedData = async () => {
|
||||||
|
const {nodes, edges} = await convertNodeRedToLogicFlow(itemId)
|
||||||
|
lfRef.value.render({
|
||||||
|
nodes,
|
||||||
|
edges
|
||||||
|
})
|
||||||
|
dataNodes.value = nodes
|
||||||
|
setTimeout(() => {
|
||||||
|
lfRef.value.fitView()
|
||||||
|
}, 20)
|
||||||
|
getLog()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLog = async () => {
|
||||||
|
const res = await getLogicFlow({
|
||||||
|
instId,
|
||||||
|
taskId,
|
||||||
|
itemId
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
const arr = res.data.filter(item => {
|
||||||
|
return item.nodeType
|
||||||
|
})
|
||||||
|
const map = new Map()
|
||||||
|
arr.forEach(item => {
|
||||||
|
if (['inject', 'start'].includes(item.nodeType)) { // , 'end'
|
||||||
|
map.set(item.nodeId, {
|
||||||
|
state: 'success',
|
||||||
|
nodeType: item.nodeType,
|
||||||
|
input: item.params
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let obj
|
||||||
|
if (map.has(item.nodeId)) {
|
||||||
|
obj = map.get(item.nodeId)
|
||||||
|
} else {
|
||||||
|
obj = {
|
||||||
|
nodeType: item.nodeType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.paramsType === 'INPUT') {
|
||||||
|
obj.state = item.instStatus.toLowerCase(),
|
||||||
|
obj.input = item.instStatus === 'FAILED' ? item.params : item.message
|
||||||
|
} else {
|
||||||
|
obj.state = item.instStatus.toLowerCase() === 'running' ? 'success' : item.instStatus.toLowerCase(),
|
||||||
|
obj.output = item.instStatus === 'FAILED' ? item.message : item.params
|
||||||
|
}
|
||||||
|
map.set(item.nodeId, obj)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [key, value] of map) {
|
||||||
|
lfRef.value.setProperties(key, value)
|
||||||
|
}
|
||||||
|
changeOtherNodeState(dataNodes.value, lfRef.value)
|
||||||
|
if (arr[arr.length - 1].nodeType !== 'end' && arr[arr.length - 1].instStatus !== 'FAILED') {
|
||||||
|
setTimeout(() => {
|
||||||
|
getLog()
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopFlow = async () => {
|
||||||
|
const res = await stopInst(instId)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('停止成功')
|
||||||
|
getInstDetail()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pauseFlow = async () => {
|
||||||
|
const res = await pauseInst(instId)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('暂停成功')
|
||||||
|
getInstDetail()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restartFlow = async () => {
|
||||||
|
const res = await resumeInst(instId)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('恢复成功')
|
||||||
|
getInstDetail()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const instStatus = ref('RUNNING')
|
||||||
|
const stateMap = {
|
||||||
|
SUCCESS: "已完成",
|
||||||
|
RUNNING: "执行中",
|
||||||
|
FAILED: "失败",
|
||||||
|
PAUSED: "已暂停",
|
||||||
|
STOPPED: "已终止"
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInstDetail = async () => {
|
||||||
|
const res = await getLogicFlow({
|
||||||
|
instId,
|
||||||
|
taskId,
|
||||||
|
itemId
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
const item = res.data[res.data.length - 1]
|
||||||
|
instStatus.value = item.instStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (containerRef.value) {
|
||||||
|
const lf = new LogicFlow({
|
||||||
|
container: containerRef.value,
|
||||||
|
isSilentMode: true,
|
||||||
|
adjustNodePosition: true,
|
||||||
|
stopZoomGraph: false,
|
||||||
|
stopScrollGraph: false,
|
||||||
|
stopMoveGraph: false,
|
||||||
|
autoExpand: true,
|
||||||
|
adjustEdgeStartAndEnd: true,
|
||||||
|
allowRotate: false,
|
||||||
|
edgeTextEdit: false,
|
||||||
|
keyboard: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
partial: true,
|
||||||
|
background: {
|
||||||
|
color: "#FFFFFF",
|
||||||
|
},
|
||||||
|
grid: true,
|
||||||
|
edgeTextDraggable: false,
|
||||||
|
nodeTextEdit: false, //节点是否可编辑。false不可编辑
|
||||||
|
textEdit: false, //是否开启文本编辑
|
||||||
|
style: {
|
||||||
|
inputText: {
|
||||||
|
background: "black",
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
idGenerator(type) {
|
||||||
|
return type + "_" + Math.random();
|
||||||
|
},
|
||||||
|
plugins: [Group],
|
||||||
|
group: {
|
||||||
|
foldable: true, // 启用折叠功能
|
||||||
|
foldSize: 30, // 折叠后显示的图标尺寸
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册自定义节点
|
||||||
|
registerCustomNode(lf)
|
||||||
|
registerCustomGroup(lf)
|
||||||
|
// 渲染数据 data
|
||||||
|
lf.render(data);
|
||||||
|
|
||||||
|
lf.on('node:dragstart', (data, e) => {
|
||||||
|
lfRef.value.setProperties(data.data.id, {
|
||||||
|
nowTime: new Date().getTime()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听子节点拖动事件
|
||||||
|
lf.on("node:mousemove", ({ data, e }) => {
|
||||||
|
const { nodes } = lf.getGraphData();
|
||||||
|
const arr = recursiveFilter(nodes, data.id)
|
||||||
|
arr.forEach(item => {
|
||||||
|
lf.getNodeModelById(item.id).updateSize()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听子节点拖动事件
|
||||||
|
lf.on("blank:drop", () => {
|
||||||
|
getLog()
|
||||||
|
});
|
||||||
|
|
||||||
|
lfRef.value = lf;
|
||||||
|
}
|
||||||
|
loadNodeRedData()
|
||||||
|
getInstDetail()
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.flow_container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.btn_container {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: end;
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
.instState {
|
||||||
|
margin-right: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
.RUNNING, .PAUSED, .STOPPED {
|
||||||
|
color: #8bbf86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.SUCCESS {
|
||||||
|
color: green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.FAILED {
|
||||||
|
color: rgb(128, 41, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.containerRef) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.lf-graph {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<div class="joint-control" v-for="joint in jointList" :key="joint.name">
|
||||||
|
<div class="joint-header">
|
||||||
|
<span class="joint-name">{{ joint.displayName }}</span>
|
||||||
|
<span class="joint-type">{{ joint.type.toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="joint-info">
|
||||||
|
<span class="angle-display">{{ formatAngle(joint.value) }}</span>
|
||||||
|
<span class="limits">[{{ formatAngle(joint.min) }}, {{ formatAngle(joint.max) }}]</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-container">
|
||||||
|
<el-slider size="small" v-model="joint.value" :min="joint.min" :max="joint.max" :step="joint.step" @input="(value) => updateJointAngle(joint.name, value)"/>
|
||||||
|
<div class="slider-labels">
|
||||||
|
<span>{{ formatAngle(joint.min) }}</span>
|
||||||
|
<span>{{ formatAngle(joint.max) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
jointList: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits(['updateJointAngle'])
|
||||||
|
|
||||||
|
const updateJointAngle = (name, value) => {
|
||||||
|
emits('updateJointAngle', { name, value })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角度格式化
|
||||||
|
*/
|
||||||
|
const formatAngle = (radians) => {
|
||||||
|
const degrees = (radians * 180 / Math.PI).toFixed(1)
|
||||||
|
return `${degrees}°`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.joint-control {
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border: 1px solid #cfcfcf;
|
||||||
|
|
||||||
|
.joint-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
.joint-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.joint-type {
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(52, 152, 219, 0.3);
|
||||||
|
color: #3498db;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.joint-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
.angle-display {
|
||||||
|
color: #f1c40f;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.limits {
|
||||||
|
color: #95a5a6;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-container {
|
||||||
|
padding: 0 5px;
|
||||||
|
|
||||||
|
.joint-slider {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
background: #34495e;
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ecf0f1;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid #3498db;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #7f8c8d;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,394 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 模板部分保持不变 -->
|
||||||
|
<div class="urdf-viewer-container">
|
||||||
|
|
||||||
|
<div ref="viewerContainer" class="urdf-viewer"></div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="loading-overlay">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p>正在解析URDF并加载模型...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount, watch, reactive, computed } from 'vue'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||||||
|
import URDFLoader from 'urdf-loader'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
basePath: { type: String, required: true },
|
||||||
|
urdfPath: { type: String, required: true },
|
||||||
|
modelColor: { type: String, default: '#cbcbcb' },
|
||||||
|
showJointLimits: { type: Boolean, default: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emits = defineEmits(['computedChange'])
|
||||||
|
|
||||||
|
const viewerContainer = ref(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
// Three.js 核心对象
|
||||||
|
let scene = null
|
||||||
|
let camera = null
|
||||||
|
let renderer = null
|
||||||
|
let controls = null
|
||||||
|
let robot = null
|
||||||
|
|
||||||
|
// 关节控制相关状态
|
||||||
|
const jointControls = reactive([])
|
||||||
|
const availableJoints = ref({})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从URDF XML中提取关节信息(使用浏览器原生DOMParser)
|
||||||
|
*/
|
||||||
|
const extractJointInfoFromURDF = (urdfXml) => {
|
||||||
|
try {
|
||||||
|
const joints = []
|
||||||
|
const parser = new DOMParser()
|
||||||
|
const xmlDoc = parser.parseFromString(urdfXml, 'text/xml')
|
||||||
|
|
||||||
|
// 检查解析错误
|
||||||
|
const parseError = xmlDoc.getElementsByTagName('parsererror')
|
||||||
|
if (parseError.length > 0) {
|
||||||
|
throw new Error('XML解析错误: ' + parseError[0].textContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有joint元素
|
||||||
|
const jointElements = xmlDoc.getElementsByTagName('joint')
|
||||||
|
|
||||||
|
for (let i = 0; i < jointElements.length; i++) {
|
||||||
|
const jointElement = jointElements[i]
|
||||||
|
const name = jointElement.getAttribute('name')
|
||||||
|
const type = jointElement.getAttribute('type')
|
||||||
|
|
||||||
|
// 只处理可动关节
|
||||||
|
if (type && ['revolute', 'continuous', 'prismatic'].includes(type)) {
|
||||||
|
const jointInfo = {
|
||||||
|
name: name,
|
||||||
|
displayName: name.replace(/_/g, ' '),
|
||||||
|
type: type,
|
||||||
|
value: 0,
|
||||||
|
step: 0.01
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取关节限制
|
||||||
|
const limitElements = jointElement.getElementsByTagName('limit')
|
||||||
|
if (limitElements.length > 0) {
|
||||||
|
const limit = limitElements[0]
|
||||||
|
const lower = limit.getAttribute('lower')
|
||||||
|
const upper = limit.getAttribute('upper')
|
||||||
|
const effort = limit.getAttribute('effort')
|
||||||
|
const velocity = limit.getAttribute('velocity')
|
||||||
|
|
||||||
|
// 处理旋转关节
|
||||||
|
if (type === 'revolute' || type === 'continuous') {
|
||||||
|
jointInfo.min = lower !== null ? parseFloat(lower) :
|
||||||
|
(type === 'revolute' ? -Math.PI : -Math.PI)
|
||||||
|
jointInfo.max = upper !== null ? parseFloat(upper) :
|
||||||
|
(type === 'revolute' ? Math.PI : Math.PI)
|
||||||
|
}
|
||||||
|
// 处理平移关节
|
||||||
|
else if (type === 'prismatic') {
|
||||||
|
jointInfo.min = lower !== null ? parseFloat(lower) : -1.0
|
||||||
|
jointInfo.max = upper !== null ? parseFloat(upper) : 1.0
|
||||||
|
jointInfo.step = 0.001
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effort) jointInfo.maxEffort = parseFloat(effort)
|
||||||
|
if (velocity) jointInfo.maxVelocity = parseFloat(velocity)
|
||||||
|
} else {
|
||||||
|
// 如果没有限制,设置默认范围
|
||||||
|
if (type === 'continuous') {
|
||||||
|
jointInfo.min = -Math.PI
|
||||||
|
jointInfo.max = Math.PI
|
||||||
|
} else if (type === 'revolute') {
|
||||||
|
jointInfo.min = -Math.PI / 2
|
||||||
|
jointInfo.max = Math.PI / 2
|
||||||
|
} else if (type === 'prismatic') {
|
||||||
|
jointInfo.min = -0.5
|
||||||
|
jointInfo.max = 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取关节轴
|
||||||
|
const axisElements = jointElement.getElementsByTagName('axis')
|
||||||
|
if (axisElements.length > 0) {
|
||||||
|
const axis = axisElements[0].getAttribute('xyz')
|
||||||
|
if (axis) {
|
||||||
|
const axisValues = axis.split(' ').map(Number)
|
||||||
|
jointInfo.axis = new THREE.Vector3(...axisValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取关节原点
|
||||||
|
const originElements = jointElement.getElementsByTagName('origin')
|
||||||
|
if (originElements.length > 0) {
|
||||||
|
const origin = originElements[0]
|
||||||
|
const xyz = origin.getAttribute('xyz')
|
||||||
|
const rpy = origin.getAttribute('rpy')
|
||||||
|
|
||||||
|
if (xyz) jointInfo.origin = xyz.split(' ').map(Number)
|
||||||
|
if (rpy) jointInfo.orientation = rpy.split(' ').map(Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
joints.push(jointInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按关节名称排序
|
||||||
|
joints.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
|
||||||
|
console.log('从URDF提取的关节信息:', joints)
|
||||||
|
return joints
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析URDF失败:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化Three.js场景
|
||||||
|
*/
|
||||||
|
const initScene = () => {
|
||||||
|
const container = viewerContainer.value
|
||||||
|
|
||||||
|
scene = new THREE.Scene()
|
||||||
|
scene.background = new THREE.Color(0xf0f0f0)
|
||||||
|
|
||||||
|
camera = new THREE.PerspectiveCamera(
|
||||||
|
75,
|
||||||
|
container.clientWidth / container.clientHeight,
|
||||||
|
0.01,
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
camera.position.set(2, 2, 3)
|
||||||
|
|
||||||
|
renderer = new THREE.WebGLRenderer({ antialias: true })
|
||||||
|
renderer.setSize(container.clientWidth, container.clientHeight)
|
||||||
|
container.appendChild(renderer.domElement)
|
||||||
|
|
||||||
|
// 光源
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
|
||||||
|
scene.add(ambientLight)
|
||||||
|
|
||||||
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
|
||||||
|
directionalLight.position.set(10, 20, 10)
|
||||||
|
scene.add(directionalLight)
|
||||||
|
|
||||||
|
controls = new OrbitControls(camera, renderer.domElement)
|
||||||
|
controls.enableDamping = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载URDF机器人模型
|
||||||
|
*/
|
||||||
|
const loadRobot = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 清除现有模型
|
||||||
|
if (robot) {
|
||||||
|
scene.remove(robot)
|
||||||
|
jointControls.length = 0
|
||||||
|
availableJoints.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 加载URDF文件内容
|
||||||
|
const urdfUrl = `${props.basePath}${props.urdfPath}`
|
||||||
|
const response = await fetch(urdfUrl)
|
||||||
|
const urdfXml = await response.text()
|
||||||
|
|
||||||
|
// 2. 解析URDF提取关节信息(同步调用)
|
||||||
|
const extractedJoints = extractJointInfoFromURDF(urdfXml)
|
||||||
|
|
||||||
|
// 3. 使用URDFLoader加载模型
|
||||||
|
const manager = new THREE.LoadingManager()
|
||||||
|
const loader = new URDFLoader(manager)
|
||||||
|
|
||||||
|
loader.packages = { '': props.basePath }
|
||||||
|
|
||||||
|
robot = await new Promise((resolve, reject) => {
|
||||||
|
loader.load(urdfUrl, resolve, undefined, reject)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. 坐标轴修正
|
||||||
|
robot.rotation.x = -Math.PI / 2
|
||||||
|
robot.rotation.z = Math.PI / 2
|
||||||
|
robot.updateMatrixWorld(true)
|
||||||
|
|
||||||
|
// 应用颜色
|
||||||
|
robot.traverse((child) => {
|
||||||
|
if (child.isMesh && child.material) {
|
||||||
|
child.material.color.set(props.modelColor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
scene.add(robot)
|
||||||
|
|
||||||
|
// 5. 初始化关节控制系统
|
||||||
|
availableJoints.value = robot.joints
|
||||||
|
|
||||||
|
// 将提取的关节信息与加载的关节匹配
|
||||||
|
extractedJoints.forEach(jointInfo => {
|
||||||
|
if (availableJoints.value[jointInfo.name]) {
|
||||||
|
// 获取当前关节值
|
||||||
|
const currentValue = availableJoints.value[jointInfo.name].jointValue || 0
|
||||||
|
|
||||||
|
// 确保初始值在限制范围内
|
||||||
|
let initialValue = currentValue
|
||||||
|
if (props.showJointLimits) {
|
||||||
|
initialValue = Math.max(jointInfo.min, Math.min(jointInfo.max, currentValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到控制列表
|
||||||
|
jointControls.push({
|
||||||
|
...jointInfo,
|
||||||
|
value: initialValue
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置初始关节位置
|
||||||
|
availableJoints.value[jointInfo.name].setJointValue(initialValue)
|
||||||
|
} else {
|
||||||
|
console.warn(`关节 ${jointInfo.name} 在URDF模型中不存在或不可控`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fitCameraToObject(robot)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载URDF模型失败:', error)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新关节角度
|
||||||
|
*/
|
||||||
|
const updateJointAngle = (jointName, angle) => {
|
||||||
|
if (availableJoints.value[jointName]) {
|
||||||
|
try {
|
||||||
|
// 应用限制(如果启用)
|
||||||
|
let clampedAngle = angle
|
||||||
|
if (props.showJointLimits) {
|
||||||
|
const jointInfo = jointControls.find(j => j.name === jointName)
|
||||||
|
if (jointInfo) {
|
||||||
|
clampedAngle = Math.max(jointInfo.min, Math.min(jointInfo.max, angle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用关节角度
|
||||||
|
availableJoints.value[jointName].setJointValue(clampedAngle)
|
||||||
|
|
||||||
|
// 更新UI中的值
|
||||||
|
const jointControl = jointControls.find(j => j.name === jointName)
|
||||||
|
if (jointControl) {
|
||||||
|
jointControl.value = clampedAngle
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`设置关节 ${jointName} 角度失败:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调整相机视角
|
||||||
|
*/
|
||||||
|
const fitCameraToObject = (object) => {
|
||||||
|
const boundingBox = new THREE.Box3().setFromObject(object)
|
||||||
|
const center = boundingBox.getCenter(new THREE.Vector3())
|
||||||
|
const size = boundingBox.getSize(new THREE.Vector3())
|
||||||
|
|
||||||
|
const maxSize = Math.max(size.x, size.y, size.z)
|
||||||
|
const fitHeightDistance = maxSize / (2 * Math.atan((Math.PI * camera.fov) / 360))
|
||||||
|
const fitWidthDistance = fitHeightDistance / camera.aspect
|
||||||
|
const distance = 1.5 * Math.max(fitHeightDistance, fitWidthDistance)
|
||||||
|
|
||||||
|
const direction = new THREE.Vector3(0, 1, 1).normalize()
|
||||||
|
controls.target.copy(center)
|
||||||
|
camera.position.copy(center).add(direction.multiplyScalar(distance))
|
||||||
|
controls.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动画循环
|
||||||
|
*/
|
||||||
|
const animate = () => {
|
||||||
|
requestAnimationFrame(animate)
|
||||||
|
controls.update()
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 窗口大小变化处理
|
||||||
|
*/
|
||||||
|
const onWindowResize = () => {
|
||||||
|
const container = viewerContainer.value
|
||||||
|
camera.aspect = container.clientWidth / container.clientHeight
|
||||||
|
camera.updateProjectionMatrix()
|
||||||
|
renderer.setSize(container.clientWidth, container.clientHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理资源
|
||||||
|
*/
|
||||||
|
const dispose = () => {
|
||||||
|
window.removeEventListener('resize', onWindowResize)
|
||||||
|
controls?.dispose()
|
||||||
|
renderer?.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听属性变化
|
||||||
|
watch(
|
||||||
|
() => [props.basePath, props.urdfPath],
|
||||||
|
() => {
|
||||||
|
loadRobot()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(jointControls, (newVal) => {
|
||||||
|
emits('computedChange', newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// 生命周期
|
||||||
|
onMounted(() => {
|
||||||
|
initScene()
|
||||||
|
loadRobot()
|
||||||
|
window.addEventListener('resize', onWindowResize)
|
||||||
|
animate()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
updateJointAngle
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 样式部分保持不变 */
|
||||||
|
.urdf-viewer-container {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.urdf-viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.loading-overlay p {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #ecf0f1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
350
src/views/device/register/components/MechanicalArm/index.vue
Normal file
350
src/views/device/register/components/MechanicalArm/index.vue
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactive, toRefs, onUnmounted, ref, onMounted } from "vue";
|
||||||
|
import UrdfViewer from "./components/UrdfViewer.vue";
|
||||||
|
import JointControl from "./components/JointControl.vue";
|
||||||
|
|
||||||
|
const data = reactive({
|
||||||
|
form: {
|
||||||
|
x: 11,
|
||||||
|
y: 11,
|
||||||
|
z: 11,
|
||||||
|
rx: 11,
|
||||||
|
ry: 11,
|
||||||
|
rz: 11,
|
||||||
|
arm: 1,
|
||||||
|
},
|
||||||
|
armList: [
|
||||||
|
{ label: "目标1", value: 1 },
|
||||||
|
{ label: "目标2", value: 2 },
|
||||||
|
{ label: "目标3", value: 3 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { form, armList } = toRefs(data);
|
||||||
|
|
||||||
|
const leftJointList = ref(null);
|
||||||
|
const rightJointList = ref(null);
|
||||||
|
const computedChange = (val) => {
|
||||||
|
leftJointList.value = val.filter((item) => {
|
||||||
|
return item.name[0] === "L";
|
||||||
|
});
|
||||||
|
rightJointList.value = val.filter((item) => {
|
||||||
|
return item.name[0] === "R";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const urdfViewerRef = ref(null);
|
||||||
|
|
||||||
|
const updateJointAngle = ({ name, value }) => {
|
||||||
|
if (urdfViewerRef.value) {
|
||||||
|
urdfViewerRef.value.updateJointAngle(name, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const coordinateSystem = ref(null); // 用于获取容器元素
|
||||||
|
const buttonPositions = ref([]); // 存储按钮的动态位置
|
||||||
|
|
||||||
|
const buttonDirections = [
|
||||||
|
{ target: "z_plus", direction: "top", label: "Z+" }, // left
|
||||||
|
{ target: "rz_plus", direction: "top-right", label: "RZ+" },
|
||||||
|
{ target: "x_plus", direction: "right", label: "X+" }, // bottom
|
||||||
|
{ target: "ry_plus", direction: "bottom-right", label: "RY+" },
|
||||||
|
{ target: "y_minus", direction: "bottom", label: "Y-" },
|
||||||
|
{ target: "ry_minus", direction: "bottom-left", label: "RY-" },
|
||||||
|
{ target: "z_minus", direction: "bottom", label: "Z-" },
|
||||||
|
{ target: "rx_minus", direction: "bottom-left", label: "RX-" },
|
||||||
|
{ target: "x_minus", direction: "left", label: "X-" },
|
||||||
|
{ target: "rx_plus", direction: "top-left", label: "RX+" },
|
||||||
|
{ target: "y_plus", direction: "left", label: "Y+" }, // right
|
||||||
|
{ target: "rz_minus", direction: "top-left", label: "RZ-" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 默认图片尺寸
|
||||||
|
const ORIGINAL_IMAGE_WIDTH = 1025;
|
||||||
|
const ORIGINAL_IMAGE_HEIGHT = 817;
|
||||||
|
|
||||||
|
const updateButtonPositions = () => {
|
||||||
|
if (coordinateSystem.value) {
|
||||||
|
const containerRect = coordinateSystem.value.getBoundingClientRect();
|
||||||
|
const containerWidth = containerRect.width;
|
||||||
|
const containerHeight = containerRect.height;
|
||||||
|
|
||||||
|
// 计算图片的实际显示尺寸(考虑 background-size: contain)
|
||||||
|
const imageAspectRatio = ORIGINAL_IMAGE_WIDTH / ORIGINAL_IMAGE_HEIGHT;
|
||||||
|
const containerAspectRatio = containerWidth / containerHeight;
|
||||||
|
|
||||||
|
let imageWidth, imageHeight, imageLeft, imageTop;
|
||||||
|
if (containerAspectRatio > imageAspectRatio) {
|
||||||
|
imageHeight = containerHeight;
|
||||||
|
imageWidth = imageHeight * imageAspectRatio;
|
||||||
|
imageLeft = (containerWidth - imageWidth) / 2;
|
||||||
|
imageTop = 0;
|
||||||
|
} else {
|
||||||
|
imageWidth = containerWidth;
|
||||||
|
imageHeight = imageWidth / imageAspectRatio;
|
||||||
|
imageLeft = 0;
|
||||||
|
imageTop = (containerHeight - imageHeight) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算图片中心坐标
|
||||||
|
const centerX = imageLeft + imageWidth / 2;
|
||||||
|
const centerY = imageTop + imageHeight / 2;
|
||||||
|
|
||||||
|
// 计算圆的半径(根据图片尺寸的较小值比例确定,可调整比例系数)
|
||||||
|
const radiusScale = 0.85; // 半径系数,0-1之间,越大半径越大
|
||||||
|
const radius = Math.min(imageWidth, imageHeight) * radiusScale / 2;
|
||||||
|
|
||||||
|
// 定义方位对应的角度(弧度制),top为0度,顺时针增加
|
||||||
|
const directionAngles = {
|
||||||
|
"top": 0,
|
||||||
|
"top-right": Math.PI / 6, // 30度
|
||||||
|
"right": Math.PI / 3, // 60度
|
||||||
|
"bottom-right": Math.PI / 2, // 90度
|
||||||
|
"bottom": 2 * Math.PI / 3, // 120度
|
||||||
|
"bottom-left": 5 * Math.PI / 6, // 150度
|
||||||
|
"left": Math.PI, // 180度
|
||||||
|
"top-left": 7 * Math.PI / 6, // 210度
|
||||||
|
};
|
||||||
|
|
||||||
|
// 调整角度以均匀分布12个按钮(360/12=30度,即Math.PI/6弧度)
|
||||||
|
const totalButtons = buttonDirections.length;
|
||||||
|
const angleStep = 2 * Math.PI / totalButtons;
|
||||||
|
|
||||||
|
// 更新按钮位置
|
||||||
|
buttonPositions.value = buttonDirections.map((btn, index) => {
|
||||||
|
// 计算每个按钮的角度(从top开始顺时针排列)
|
||||||
|
const angle = index * angleStep;
|
||||||
|
// 计算按钮的坐标
|
||||||
|
const x = centerX + radius * Math.sin(angle);
|
||||||
|
const y = centerY - radius * Math.cos(angle);
|
||||||
|
return {
|
||||||
|
...btn,
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateButtonPositions();
|
||||||
|
|
||||||
|
// 监听窗口大小变化,更新按钮位置
|
||||||
|
window.addEventListener("resize", updateButtonPositions);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("resize", updateButtonPositions);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mechanical-arm">
|
||||||
|
<div class="top card">
|
||||||
|
<div class="controls flex align-center" style="justify-content: flex-start">
|
||||||
|
<div class="control-label">目标</div>
|
||||||
|
<el-select
|
||||||
|
v-model="form.arm"
|
||||||
|
placeholder="选择目标"
|
||||||
|
style="width: 100px; margin-right: 20px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in armList"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button @click="handleResets('zero')" style="margin-right: 10px"
|
||||||
|
>零位姿态</el-button
|
||||||
|
>
|
||||||
|
<el-button @click="handleResets('init')">初始位置</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="pose-info flex align-center">
|
||||||
|
<div class="pose-item">X: {{ form.x.toFixed(6) }} m</div>
|
||||||
|
<div class="pose-item">Y: {{ form.y.toFixed(6) }} m</div>
|
||||||
|
<div class="pose-item">Z: {{ form.z.toFixed(6) }} m</div>
|
||||||
|
<div class="pose-item">RX: {{ form.rx.toFixed(6) }} deg</div>
|
||||||
|
<div class="pose-item">RY: {{ form.ry.toFixed(6) }} deg</div>
|
||||||
|
<div class="pose-item">RZ: {{ form.rz.toFixed(6) }} deg</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bottom">
|
||||||
|
<div class="left">
|
||||||
|
<UrdfViewer
|
||||||
|
ref="urdfViewerRef"
|
||||||
|
:base-path="'/public/models/'"
|
||||||
|
:urdf-path="'dual_arm.urdf'"
|
||||||
|
:model-color="'#8a8ae9'"
|
||||||
|
@computedChange="computedChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<div class="top">
|
||||||
|
<div class="secondary-title">
|
||||||
|
<div class="label">左手</div>
|
||||||
|
<div class="label">右手</div>
|
||||||
|
</div>
|
||||||
|
<div class="jointControl-container">
|
||||||
|
<JointControl
|
||||||
|
:jointList="leftJointList"
|
||||||
|
@updateJointAngle="updateJointAngle"
|
||||||
|
/>
|
||||||
|
<JointControl
|
||||||
|
:jointList="rightJointList"
|
||||||
|
@updateJointAngle="updateJointAngle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bottom">
|
||||||
|
<div class="left card" style="position: relative">
|
||||||
|
<div class="coordinate-system" ref="coordinateSystem">
|
||||||
|
<div class="control-buttons">
|
||||||
|
<button
|
||||||
|
v-for="(pos, index) in buttonPositions"
|
||||||
|
:key="index"
|
||||||
|
class="pos-btn"
|
||||||
|
:data-target="pos.target"
|
||||||
|
:style="{ left: `${pos.left}px`, top: `${pos.top}px` }"
|
||||||
|
>
|
||||||
|
{{ pos.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.mechanical-arm {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-direction: column;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
.pose-info {
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 10px 0;
|
||||||
|
|
||||||
|
.pose-item {
|
||||||
|
font-size: 16px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
height: calc(100% - 120px);
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.left {
|
||||||
|
flex: 2;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.top {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
padding: 0 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.container-title {
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-title {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jointControl-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
height: calc(100% - 75px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.coordinate-system {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
background-color: #fff;
|
||||||
|
background-image: url("@/assets/images/arm.png");
|
||||||
|
background-size: contain;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
|
||||||
|
.control-buttons {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
.pos-btn {
|
||||||
|
position: absolute;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: #4076ff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transform: translate(-50%, -50%); /* 按钮中心对齐坐标 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
225
src/views/device/register/components/Microphone/index.vue
Normal file
225
src/views/device/register/components/Microphone/index.vue
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
<template>
|
||||||
|
<div class="microphone-container">
|
||||||
|
<div class="title">远程麦克风</div>
|
||||||
|
<div class="audiosList" v-if="audiosList.length > 0">
|
||||||
|
<div class="speaker-container">
|
||||||
|
<div class="speaker-title">选择扬声器</div>
|
||||||
|
<el-select v-model="speaker" size="small">
|
||||||
|
<el-option
|
||||||
|
v-for="item in speakerOptions"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="audioItem" v-for="item in audiosList">
|
||||||
|
<div>{{ item }}</div>
|
||||||
|
<div>
|
||||||
|
<el-button
|
||||||
|
circle
|
||||||
|
size="small"
|
||||||
|
:icon="VideoPlay"
|
||||||
|
@click="handlePlay(item)"
|
||||||
|
></el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-container">
|
||||||
|
<el-tooltip
|
||||||
|
class="box-item"
|
||||||
|
effect="dark"
|
||||||
|
content="点击开始录音"
|
||||||
|
placement="top-start"
|
||||||
|
v-if="recordStatus === 'end'"
|
||||||
|
>
|
||||||
|
<el-icon class="btn" :size="40" @click="startRecording"><Microphone /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip
|
||||||
|
class="box-item"
|
||||||
|
effect="dark"
|
||||||
|
content="点击暂停录音"
|
||||||
|
placement="top-start"
|
||||||
|
v-if="recordStatus === 'recording'"
|
||||||
|
>
|
||||||
|
<el-icon class="btn" :size="40" @click="pauseRecording"><VideoPause /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip
|
||||||
|
class="box-item"
|
||||||
|
effect="dark"
|
||||||
|
content="点击恢复录音"
|
||||||
|
placement="top-start"
|
||||||
|
v-if="recordStatus === 'pause'"
|
||||||
|
>
|
||||||
|
<el-icon class="btn" :size="40" @click="recoverRecording"><Mic /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip
|
||||||
|
class="box-item"
|
||||||
|
effect="dark"
|
||||||
|
content="点击结束录音"
|
||||||
|
placement="top-start"
|
||||||
|
v-if="recordStatus !== 'end'"
|
||||||
|
>
|
||||||
|
<el-icon class="btn" :size="40" @click="stopRecording"><Mute /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { Microphone, VideoPause, Mute, Mic, VideoPlay } from "@element-plus/icons-vue";
|
||||||
|
import {
|
||||||
|
startMicrophoneApi,
|
||||||
|
pauseMicrophoneApi,
|
||||||
|
resumeMicrophoneApi,
|
||||||
|
stopMicrophoneApi,
|
||||||
|
playSpeaker
|
||||||
|
} from '@/api/device/microphone'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
|
||||||
|
const terminalId = route.query.terminalId
|
||||||
|
const deviceId = route.query.deviceId
|
||||||
|
|
||||||
|
const recordStatus = ref('end');
|
||||||
|
const isRecording = ref(false)
|
||||||
|
const list = []
|
||||||
|
const audiosList = ref([])
|
||||||
|
const speaker = ref('spk1')
|
||||||
|
const speakerOptions = ref(['spk1'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始录音
|
||||||
|
*/
|
||||||
|
const startRecording = async () => {
|
||||||
|
const fileName = new Date().getTime() + '.wav'
|
||||||
|
isRecording.value = true
|
||||||
|
const res = await startMicrophoneApi({
|
||||||
|
terminalId,
|
||||||
|
deviceId,
|
||||||
|
filePath: `/home/share/record/audios/${terminalId}_${deviceId}_${fileName}`
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
recordStatus.value = 'recording'
|
||||||
|
list.push(`${terminalId}_${deviceId}_${fileName}`)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 暂停录音
|
||||||
|
*/
|
||||||
|
const pauseRecording = async () => {
|
||||||
|
const res = await pauseMicrophoneApi({
|
||||||
|
terminalId,
|
||||||
|
deviceId
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
recordStatus.value = 'pause'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复录音
|
||||||
|
*/
|
||||||
|
const recoverRecording = async () => {
|
||||||
|
const res = await resumeMicrophoneApi({
|
||||||
|
terminalId,
|
||||||
|
deviceId
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
recordStatus.value = 'recording'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结束录音
|
||||||
|
*/
|
||||||
|
const stopRecording = async () => {
|
||||||
|
const res = await stopMicrophoneApi({
|
||||||
|
terminalId,
|
||||||
|
deviceId
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
recordStatus.value = 'end'
|
||||||
|
audiosList.value.unshift(list[list.length - 1])
|
||||||
|
}
|
||||||
|
isRecording.value = false
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 播放录音
|
||||||
|
*/
|
||||||
|
const handlePlay = async (audioPath) => {
|
||||||
|
const res = await playSpeaker({
|
||||||
|
terminalId,
|
||||||
|
deviceId: speaker.value,
|
||||||
|
audioPath: `/home/share/record/audios/${audioPath}`
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success('播放成功')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.microphone-container {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - 125px);
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin: auto;
|
||||||
|
|
||||||
|
.audiosList {
|
||||||
|
margin: 20px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.speaker-container {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.speaker-title {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-select {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.audioItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
padding-top: 20px;
|
||||||
|
font-size: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
margin-right: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
76
src/views/device/register/components/Motor/index.vue
Normal file
76
src/views/device/register/components/Motor/index.vue
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
<script setup>
|
||||||
|
import ControlCard from "../ControlCard/index.vue"
|
||||||
|
|
||||||
|
const data = reactive({
|
||||||
|
form: {},
|
||||||
|
rules: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const {form, rules} = toRefs(data)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="motor">
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="12">
|
||||||
|
<ControlCard title="电机">
|
||||||
|
<div class="pic">
|
||||||
|
电机pic
|
||||||
|
<svg t="1747108210294" class="arrow" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="23587" width="32" height="32"><path d="M481.940645 156.242581C209.094194 383.504516 254.018065 660.645161 313.806452 778.570323l117.925161 234.859354 152.609032-76.634838-117.925161-234.859355c-4.624516-8.91871-100.748387-224.619355 132.129032-418.188387l33.032258 70.028387c8.91871 18.167742 19.489032 27.747097 39.969032 23.122581a36.996129 36.996129 0 0 0 27.086452-33.032259l66.064516-340.56258L423.803871 51.530323a39.308387 39.308387 0 0 0-36.005161 25.765161 37.987097 37.987097 0 0 0 19.489032 39.308387l74.652903 39.63871" fill="#409eff" p-id="23588"></path></svg>
|
||||||
|
<svg t="1747108469463" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="28512" width="32" height="32"><path d="M533.312 1015.104l-3.968 3.392a30.144 30.144 0 0 1-47.488-24.64v-210.816H70.272A70.272 70.272 0 0 1 0 712.768V311.232l0.384-7.68c3.84-35.2 33.664-62.592 69.888-62.592h411.584V30.144c0-26.816 32.448-40.256 51.456-21.248l481.92 481.792a30.08 30.08 0 0 1 0 42.624l-481.92 481.792z" fill="#409eff" p-id="28513"></path></svg>
|
||||||
|
<svg t="1747126605366" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1461" width="32" height="32"><path d="M311.432533 448.170667H163.84L510.293333 3.413333 856.746667 448.170667h-147.592534L760.832 1006.933333H259.754667z" fill="#0F62FE" p-id="1462"></path></svg>
|
||||||
|
<svg t="1747126646064" class="icon" viewBox="0 0 2105 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1623" width="32" height="32"><path d="M1368.744385 1015.123641l-5.916434 3.413328a64.853225 64.853225 0 0 1-46.819478 2.047997c-14.961753-5.119991-24.405293-15.473752-24.405292-26.737734v-210.772982H105.414935c-27.989287 0-54.783909-7.395543-74.52432-20.593743C11.093315 749.225418 0 731.419225 0 712.759701V311.238592l0.568888-7.679987c5.745768-35.214164 50.517249-62.577673 104.846047-62.577673h1186.188246V30.151061c0-26.794622 48.696808-40.277266 77.198093-21.21952l722.829018 481.791197c8.47643 5.631991 13.255089 13.311978 13.255089 21.276409 0 7.964431-4.778659 15.701307-13.255089 21.333297L1368.744385 1015.123641z" fill="#D6E4FE" p-id="1624"></path></svg>
|
||||||
|
|
||||||
|
<svg t="1747126605366" class="turn-up" viewBox="0 0 1024 1024" version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" p-id="1461" width="32" height="32">
|
||||||
|
<path
|
||||||
|
d="M311.432533 448.170667H263.84L510.293333 3.413333 856.746667 448.170667h-247.592534L760.832 1006.933333H159.754667z"
|
||||||
|
fill="#0F62FE" p-id="1462"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</ControlCard>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<ControlCard title="操作">
|
||||||
|
<div class="control">
|
||||||
|
<el-form :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="速度">
|
||||||
|
<el-input v-model="form.speed" placeholder="请输入速度"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="位置">
|
||||||
|
<el-input v-model="form.position" placeholder="请输入位置"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-input v-model="form.status" placeholder="请输入状态"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="运行模式">
|
||||||
|
<el-input v-model="form.mode" placeholder="请输入运行模式"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</ControlCard>
|
||||||
|
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.motor {
|
||||||
|
padding: 10px;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
.arrow{
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
.el-row {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.control-card {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
</style>
|
||||||
276
src/views/device/register/components/Speaker/index.vue
Normal file
276
src/views/device/register/components/Speaker/index.vue
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
<template>
|
||||||
|
<div class="audio-player">
|
||||||
|
<div class="title">远程扬声器</div>
|
||||||
|
<div class="audio-list">
|
||||||
|
<div
|
||||||
|
v-for="(audio, index) in audioList"
|
||||||
|
:key="index"
|
||||||
|
class="audio-item"
|
||||||
|
:class="{ 'active': index === currentIndex }"
|
||||||
|
@click="playAudio(index)"
|
||||||
|
>
|
||||||
|
{{ audio.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<el-button @click="playPrevious" :disabled="currentIndex === 0" circle size="small">
|
||||||
|
<svg :class="`icon ${currentIndex === 0 ? 'disabled-icon' : ''}`" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="20">
|
||||||
|
<path d="M362.3 512l445-332.3v664.5L362.3 512zM216.7 179.7h80v664.5h-80V179.7z" fill="" />
|
||||||
|
</svg>
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="togglePlay" circle>
|
||||||
|
<svg v-if="isPlaying" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32">
|
||||||
|
<path d="M512 1024C228.266667 1024 0 795.733333 0 512S228.266667 0 512 0s512 228.266667 512 512-228.266667 512-512 512z m0-42.666667c260.266667 0 469.333333-209.066667 469.333333-469.333333S772.266667 42.666667 512 42.666667 42.666667 251.733333 42.666667 512s209.066667 469.333333 469.333333 469.333333z m-106.666667-682.666666c12.8 0 21.333333 8.533333 21.333334 21.333333v384c0 12.8-8.533333 21.333333-21.333334 21.333333s-21.333333-8.533333-21.333333-21.333333V320c0-12.8 8.533333-21.333333 21.333333-21.333333z m213.333334 0c12.8 0 21.333333 8.533333 21.333333 21.333333v384c0 12.8-8.533333 21.333333-21.333333 21.333333s-21.333333-8.533333-21.333334-21.333333V320c0-12.8 8.533333-21.333333 21.333334-21.333333z" fill="#666666" fill-opacity=".9" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32">
|
||||||
|
<path d="M512 0C230.4 0 0 230.4 0 512s230.4 512 512 512 512-230.4 512-512S793.6 0 512 0z m0 981.333333C253.866667 981.333333 42.666667 770.133333 42.666667 512S253.866667 42.666667 512 42.666667s469.333333 211.2 469.333333 469.333333-211.2 469.333333-469.333333 469.333333z" fill="#666666" />
|
||||||
|
<path d="M672 441.6l-170.666667-113.066667c-57.6-38.4-106.666667-12.8-106.666666 57.6v256c0 70.4 46.933333 96 106.666666 57.6l170.666667-113.066666c57.6-42.666667 57.6-106.666667 0-145.066667z" fill="#666666" />
|
||||||
|
</svg>
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="playNext" :disabled="currentIndex === audioList.length - 1" circle size="small">
|
||||||
|
<svg :class="`icon ${currentIndex === audioList.length - 1 ? 'disabled-icon' : ''}`" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="20">
|
||||||
|
<path d="M216.7 844.3V179.7l445 332.3-445 332.3z m590.6 0h-80V179.7h80v664.6z" fill="" />
|
||||||
|
</svg>
|
||||||
|
</el-button>
|
||||||
|
<div class="volume-control">
|
||||||
|
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
|
||||||
|
<path d="M448 938.666667a21.333333 21.333333 0 0 1-15.093333-6.246667L225.833333 725.333333H53.333333a53.393333 53.393333 0 0 1-53.333333-53.333333V352a53.393333 53.393333 0 0 1 53.333333-53.333333h172.5l207.08-207.086667A21.333333 21.333333 0 0 1 469.333333 106.666667v810.666666a21.333333 21.333333 0 0 1-21.333333 21.333334zM53.333333 341.333333a10.666667 10.666667 0 0 0-10.666666 10.666667v320a10.666667 10.666667 0 0 0 10.666666 10.666667h181.333334a21.333333 21.333333 0 0 1 15.086666 6.246666L426.666667 865.833333V158.166667L249.753333 335.086667A21.333333 21.333333 0 0 1 234.666667 341.333333z m750.48 586.553334a21.333333 21.333333 0 0 1-12.726666-38.466667 474.853333 474.853333 0 0 0 52.78-45.553333c182.993333-182.993333 182.993333-480.74 0-663.733334a474.246667 474.246667 0 0 0-52.78-45.553333 21.333333 21.333333 0 0 1 25.42-34.273333 518.346667 518.346667 0 0 1 57.533333 49.653333 511.606667 511.606667 0 0 1 0 724.08 519.026667 519.026667 0 0 1-57.54 49.653333 21.22 21.22 0 0 1-12.686667 4.193334z m-86.213333-149.333334a21.333333 21.333333 0 0 1-13.733333-37.666666c6.666667-5.586667 13.146667-11.553333 19.333333-17.726667C779.6 666.78 810.666667 591.78 810.666667 512s-31.066667-154.78-87.48-211.186667c-6.173333-6.173333-12.666667-12.14-19.333334-17.726666a21.333333 21.333333 0 1 1 27.446667-32.666667 346.585333 346.585333 0 0 1 22.046667 20.213333 341.066667 341.066667 0 0 1 0 482.72 346.585333 346.585333 0 0 1-22.046667 20.213334 21.24 21.24 0 0 1-13.7 5.013333zM629.333333 625.72a21.333333 21.333333 0 0 1-16.733333-34.546667 127.366667 127.366667 0 0 0 0-158.346666 21.333333 21.333333 0 0 1 33.486667-26.433334 170.733333 170.733333 0 0 1 0 211.213334A21.333333 21.333333 0 0 1 629.333333 625.72z" fill="#5C5C66" />
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
v-model.number="volume"
|
||||||
|
@input="debouncedSetVolume"
|
||||||
|
/>
|
||||||
|
<span>{{ Math.round(volume) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
import {
|
||||||
|
pause,
|
||||||
|
play,
|
||||||
|
resume,
|
||||||
|
status,
|
||||||
|
stop,
|
||||||
|
getVolume,
|
||||||
|
setVolume as setVolumeApi,
|
||||||
|
getAll
|
||||||
|
} from '@/api/device/speaker';
|
||||||
|
|
||||||
|
const audioList = ref([
|
||||||
|
{ name: 'Track 1', path: '/audio/track1.mp3' },
|
||||||
|
{ name: 'Track 2', path: '/audio/track2.mp3' },
|
||||||
|
{ name: 'Track 3', path: '/audio/track3.mp3' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
const isPlaying = ref(false);
|
||||||
|
const volume = ref(0);
|
||||||
|
|
||||||
|
const terminalId = ref('25449ff3fcc7b27da5f69462c5efcec2');
|
||||||
|
const deviceId = ref('spk1');
|
||||||
|
|
||||||
|
const getCommonParams = () => ({
|
||||||
|
terminalId: terminalId.value,
|
||||||
|
deviceId: deviceId.value
|
||||||
|
});
|
||||||
|
|
||||||
|
const debounce = (func, delay) => {
|
||||||
|
let timeoutId;
|
||||||
|
return (...args) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => func.apply(null, args), delay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
let pollingTimeout;
|
||||||
|
|
||||||
|
const checkStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await status(getCommonParams());
|
||||||
|
isPlaying.value = response.data.state.isRunning && !response.data.state.isPaused;
|
||||||
|
if (isPlaying.value && !response.data.state.isPaused) {
|
||||||
|
// 完成后再次调度
|
||||||
|
pollingTimeout = setTimeout(checkStatus, 1000); // 5秒间隔
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('状态检查失败:', error);
|
||||||
|
// 即使出错,也继续轮询
|
||||||
|
// pollingTimeout = setTimeout(checkStatus, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const allResponse = await getAll();
|
||||||
|
audioList.value = allResponse.data.map(item => ({
|
||||||
|
name: item.split('.')[0],
|
||||||
|
path: `/home/share/assets/upload/${item}`
|
||||||
|
}));
|
||||||
|
await initialize();
|
||||||
|
// 启动轮询
|
||||||
|
checkStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('初始化失败:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(async () => {
|
||||||
|
try {
|
||||||
|
await stop(getCommonParams());
|
||||||
|
if (pollingTimeout) {
|
||||||
|
clearTimeout(pollingTimeout); // 清除轮询
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清理失败:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialize = async () => {
|
||||||
|
try {
|
||||||
|
const volumeResponse = await getVolume({ ...getCommonParams() });
|
||||||
|
volume.value = volumeResponse.data || 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('初始化状态失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const playAudio = async (index) => {
|
||||||
|
currentIndex.value = index;
|
||||||
|
isPlaying.value = true;
|
||||||
|
try {
|
||||||
|
await play({ ...getCommonParams(), audioPath: audioList.value[index].path });
|
||||||
|
checkStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('播放失败:', error);
|
||||||
|
isPlaying.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const playPrevious = async () => {
|
||||||
|
if (currentIndex.value > 0) {
|
||||||
|
await playAudio(currentIndex.value - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const playNext = async () => {
|
||||||
|
if (currentIndex.value < audioList.value.length - 1) {
|
||||||
|
await playAudio(currentIndex.value + 1);
|
||||||
|
} else {
|
||||||
|
await playAudio(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlay = async () => {
|
||||||
|
try {
|
||||||
|
if (isPlaying.value) {
|
||||||
|
await pause(getCommonParams());
|
||||||
|
} else {
|
||||||
|
await resume(getCommonParams());
|
||||||
|
}
|
||||||
|
isPlaying.value = !isPlaying.value;
|
||||||
|
checkStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('操作失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVolume = async () => {
|
||||||
|
try {
|
||||||
|
await setVolumeApi({ ...getCommonParams(), volume: volume.value });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('音量设置失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const debouncedSetVolume = debounce(setVolume, 500);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.audio-player {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 800px;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - 125px);
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
padding-top: 20px;
|
||||||
|
font-size: 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-list {
|
||||||
|
margin: 20px 0;
|
||||||
|
max-height: calc(100% - 130px);
|
||||||
|
overflow-y: auto;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-item {
|
||||||
|
padding: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-item:hover,
|
||||||
|
.audio-item.active {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background-color: rgba(0, 0, 0, 0);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background-color: rgba(0, 0, 0, 0);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled-icon {
|
||||||
|
fill: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="voice-container" @click="togglePlay">
|
||||||
|
<div class="voice-btn" :class="{ playing: isPlaying }">
|
||||||
|
<span class="duration">{{ formattedDuration }}"</span>
|
||||||
|
<div class="wave">
|
||||||
|
<span v-for="n in 3" :key="n"
|
||||||
|
:style="{ height: isPlaying ? getRandomHeight() : '0px' }"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<audio ref="audioEl"
|
||||||
|
:src="url"
|
||||||
|
@play="handlePlay"
|
||||||
|
@pause="handlePause"
|
||||||
|
@ended="handleEnd"
|
||||||
|
@loadedmetadata="updateDuration"></audio>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
url: { type: String, required: true },
|
||||||
|
recordingTime: { type: Number },
|
||||||
|
})
|
||||||
|
|
||||||
|
const audioEl = ref(null)
|
||||||
|
const isPlaying = ref(false)
|
||||||
|
const duration = ref(0)
|
||||||
|
let animationFrame
|
||||||
|
|
||||||
|
const formattedDuration = computed(() => Math.ceil(duration.value))
|
||||||
|
|
||||||
|
const getRandomHeight = () => `${Math.random() * 12 + 4}px`
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
isPlaying.value ? audioEl.value.pause() : audioEl.value.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
isPlaying.value = true
|
||||||
|
animateWave()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePause = () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
cancelAnimationFrame(animationFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEnd = () => {
|
||||||
|
isPlaying.value = false
|
||||||
|
cancelAnimationFrame(animationFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateDuration = () => {
|
||||||
|
duration.value = audioEl.value.duration === Infinity ? props.recordingTime : audioEl.value.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
const animateWave = () => {
|
||||||
|
if (!isPlaying.value) return
|
||||||
|
animationFrame = requestAnimationFrame(animateWave)
|
||||||
|
}
|
||||||
|
|
||||||
|
// onMounted(() => {
|
||||||
|
// audioEl.value.src = props.src
|
||||||
|
// })
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cancelAnimationFrame(animationFrame)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.voice-container {
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
.voice-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #e6e6e6;
|
||||||
|
border-radius: 18px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.voice-btn.playing {
|
||||||
|
background: #d3e3fd;
|
||||||
|
}
|
||||||
|
.duration {
|
||||||
|
margin: 0 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.wave {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
height: 20px;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.wave span {
|
||||||
|
display: inline-block;
|
||||||
|
width: 4px;
|
||||||
|
background: #07C160;
|
||||||
|
transition: height 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
699
src/views/device/register/components/SpeechSynthesis/chat.vue
Normal file
699
src/views/device/register/components/SpeechSynthesis/chat.vue
Normal file
@ -0,0 +1,699 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<div class="chat-container">
|
||||||
|
<div class="message-list" v-if="messages.length > 0" ref="messageRef">
|
||||||
|
<div
|
||||||
|
v-for="(msg, index) in messages"
|
||||||
|
:key="index"
|
||||||
|
class="message"
|
||||||
|
:class="{
|
||||||
|
'user-message': msg.role === 'user',
|
||||||
|
'ai-message': msg.role === 'assistant',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="message-box">
|
||||||
|
<div v-if="msg.role === 'assistant'" class="picture"></div>
|
||||||
|
<div class="message-container">
|
||||||
|
<div v-for="item in msg.list" class="messageContent">
|
||||||
|
<div class="content">{{ item.content }}</div>
|
||||||
|
<div class="audio" v-if="item.url">
|
||||||
|
<AudioPlayer :url="item.url" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- <div v-if="msg.role === 'user'" class="picture"></div> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isQuery" style="margin-left: 60px">
|
||||||
|
{{ queryText }}<el-icon><Loading /></el-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="welcome" v-else>
|
||||||
|
<div>
|
||||||
|
<div class="name">{{ `我是${aiName}助手,很高兴见到你` }}</div>
|
||||||
|
<div class="function">我可以帮你合成音色,请把你的任务交给我吧~</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-area">
|
||||||
|
<el-input
|
||||||
|
v-model="inputMessage"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 3, maxRows: 6 }"
|
||||||
|
resize="none"
|
||||||
|
placeholder="Shift+Enter换行"
|
||||||
|
@keyup.native="handleKeyUp"
|
||||||
|
@keyup.enter.native="handleEnter"
|
||||||
|
>
|
||||||
|
</el-input>
|
||||||
|
<div class="bottom-box">
|
||||||
|
<div class="select-box">
|
||||||
|
<el-select v-model="timbreValue" size="small" style="width: 120px">
|
||||||
|
<el-option
|
||||||
|
v-for="item in timbreOptions"
|
||||||
|
:key="item"
|
||||||
|
:label="item"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-if="aiType === 'knowledge'"
|
||||||
|
v-model="langValue"
|
||||||
|
size="small"
|
||||||
|
style="width: 100px; margin-left: 10px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in langOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-select
|
||||||
|
v-model="dialectValue"
|
||||||
|
:disabled="langValue !== '中文'"
|
||||||
|
size="small"
|
||||||
|
style="width: 100px; margin-left: 10px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in dialectOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-if="aiType === 'knowledge'"
|
||||||
|
v-model="toneValue"
|
||||||
|
size="small"
|
||||||
|
style="width: 100px; margin-left: 10px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in toneOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
|
||||||
|
<el-input-number style="width: 100px; margin-left: 10px" v-if="aiType === 'knowledge'" size="small" v-model="countValue" :min="1" :step="1" step-strictly />
|
||||||
|
</div>
|
||||||
|
<div class="send-message">
|
||||||
|
<el-button
|
||||||
|
:icon="Position"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
circle
|
||||||
|
:disabled="inputMessage === ''"
|
||||||
|
@click="sendMessage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="addSound" @click="openDialog">
|
||||||
|
<el-icon :size="40"><CirclePlus /></el-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
title="新增音色"
|
||||||
|
width="500"
|
||||||
|
:before-close="handleClose"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="ruleFormRef"
|
||||||
|
style="max-width: 600px"
|
||||||
|
:model="ruleForm"
|
||||||
|
:rules="rules"
|
||||||
|
label-width="auto"
|
||||||
|
>
|
||||||
|
<el-form-item label="名称" prop="name">
|
||||||
|
<el-input v-model="ruleForm.name" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<SoundRecording ref="soundRecordingRef" />
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="handleClose">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submit">提交</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { Position, CirclePlus } from "@element-plus/icons-vue";
|
||||||
|
import SoundRecording from "./soundRecording.vue";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { createTimbre, getTts, createCorpus, getSynthesize, createAdvancedCorpus, getAdvancedSynthesize } from "@/api/device/register";
|
||||||
|
import { ConcurrentRequestController } from "@/utils/requestController";
|
||||||
|
import AudioPlayer from "./AudioPlayer.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
aiName: {
|
||||||
|
type: String,
|
||||||
|
default: '语音助手'
|
||||||
|
},
|
||||||
|
aiType: {
|
||||||
|
type: String,
|
||||||
|
default: 'basis'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const messageRef = ref(null);
|
||||||
|
const inputMessage = ref("");
|
||||||
|
const messages = ref([]);
|
||||||
|
|
||||||
|
const isQuery = ref(false);
|
||||||
|
const queryText = ref("正在思考中");
|
||||||
|
const messageIndex = ref(-1)
|
||||||
|
const controller = new ConcurrentRequestController(3); // 最大并发数为3
|
||||||
|
const cancelIndex = ref(0)
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (inputMessage.value.trim()) {
|
||||||
|
isQuery.value = true;
|
||||||
|
messages.value.push({ role: "user", list: [{ content: inputMessage.value }] });
|
||||||
|
await nextTick();
|
||||||
|
messageRef.value.scrollTop = messageRef.value.scrollHeight;
|
||||||
|
const str = inputMessage.value;
|
||||||
|
inputMessage.value = "";
|
||||||
|
if (controller.getStatus().queueLength > 0) {
|
||||||
|
controller.cancelAll();
|
||||||
|
cancelIndex.value = messageIndex.value
|
||||||
|
messages.value[cancelIndex.value] = {
|
||||||
|
role: "assistant",
|
||||||
|
list: [{ content: '已取消查询' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messageIndex.value += 2
|
||||||
|
getMessage(str, messageIndex.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessage = (str, index) => {
|
||||||
|
if (props.aiType === 'basis') {
|
||||||
|
basisAiOperate(str, index)
|
||||||
|
} else if (props.aiType === 'knowledge') {
|
||||||
|
knowledgeAiOperate(str, index)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 基础ai(语音合成)发送消息后的操作
|
||||||
|
const basisAiOperate = async (str, index) => {
|
||||||
|
try {
|
||||||
|
const res = await createCorpus({
|
||||||
|
convertWords: str,
|
||||||
|
dialect: dialectValue.value,
|
||||||
|
timbre: timbreValue.value,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
const msg = JSON.parse(res?.msg || "[]");
|
||||||
|
if (msg.length === 0) {
|
||||||
|
serviceError("未查询到相关服务");
|
||||||
|
} else {
|
||||||
|
queryText.value = "语音合成中";
|
||||||
|
getBasisAudio(msg, index);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serviceError("服务器繁忙,请稍后重试");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
serviceError("服务器繁忙,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBasisAudio = async (arr, index) => {
|
||||||
|
messages.value[index] = {
|
||||||
|
role: "assistant",
|
||||||
|
list: []
|
||||||
|
}
|
||||||
|
await basisApiController(arr, index);
|
||||||
|
isQuery.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function basisApiController(arr, messageIndex) {
|
||||||
|
// 模拟10个需要发送的请求
|
||||||
|
const requests = arr.map((item, index) => {
|
||||||
|
return (signal) =>
|
||||||
|
getSynthesize({
|
||||||
|
dialect: dialectValue.value,
|
||||||
|
text: item,
|
||||||
|
timbre: timbreValue.value,
|
||||||
|
}, {
|
||||||
|
signal: signal
|
||||||
|
})
|
||||||
|
.then(async (data) => {
|
||||||
|
if (cancelIndex.value === messageIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`请求 ${index} 完成`);
|
||||||
|
const url = URL.createObjectURL(data);
|
||||||
|
messages.value[messageIndex].list.push({
|
||||||
|
content: item,
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
await nextTick();
|
||||||
|
messageRef.value.scrollTop = messageRef.value.scrollHeight;
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.catch(async (error) => {
|
||||||
|
if (cancelIndex.value === messageIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error(`请求 ${index} 失败:`, error);
|
||||||
|
messages.value[messageIndex].list.push({
|
||||||
|
content: item + " ,语音转换失败"
|
||||||
|
})
|
||||||
|
await nextTick();
|
||||||
|
messageRef.value.scrollTop = messageRef.value.scrollHeight;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送所有请求
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
requests.map((request) => controller.addRequest(request))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理结果
|
||||||
|
const successfulResults = results
|
||||||
|
.filter((result) => result.status === "fulfilled")
|
||||||
|
.map((result) => result.value);
|
||||||
|
|
||||||
|
console.log("所有请求已完成", successfulResults);
|
||||||
|
return successfulResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 语音合成(知识库)发送消息后的操作
|
||||||
|
const knowledgeAiOperate = async (str, index) => {
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
convertWords: str,
|
||||||
|
dialect: langValue.value === '中文' ? dialectValue.value : '',
|
||||||
|
timbre: timbreValue.value,
|
||||||
|
language: langValue.value,
|
||||||
|
tone : toneValue.value,
|
||||||
|
count: countValue.value
|
||||||
|
}
|
||||||
|
const res = await createAdvancedCorpus(params);
|
||||||
|
if (res.code === 200) {
|
||||||
|
const msg = JSON.parse(res?.msg || "[]");
|
||||||
|
if (msg.length === 0) {
|
||||||
|
serviceError("未查询到相关服务");
|
||||||
|
} else {
|
||||||
|
queryText.value = "语音合成中";
|
||||||
|
getKnowledgeAudio(msg, index, params);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serviceError("服务器繁忙,请稍后重试");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
serviceError("服务器繁忙,请稍后重试");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getKnowledgeAudio = async (arr, index, params) => {
|
||||||
|
messages.value[index] = {
|
||||||
|
role: "assistant",
|
||||||
|
list: []
|
||||||
|
}
|
||||||
|
const data = await knowledgeApiController(arr, index, params);
|
||||||
|
console.log('data', data)
|
||||||
|
isQuery.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function knowledgeApiController(arr, messageIndex, params) {
|
||||||
|
// 模拟10个需要发送的请求
|
||||||
|
const requests = arr.map((item, index) => {
|
||||||
|
return (signal) =>
|
||||||
|
getAdvancedSynthesize({
|
||||||
|
dialect: params.dialect,
|
||||||
|
text: item,
|
||||||
|
timbre: params.timbre,
|
||||||
|
tone: params.tone,
|
||||||
|
language: params.language
|
||||||
|
}, {
|
||||||
|
signal: signal
|
||||||
|
})
|
||||||
|
.then(async (data) => {
|
||||||
|
if (cancelIndex.value === messageIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`请求 ${index} 完成`);
|
||||||
|
const url = URL.createObjectURL(data);
|
||||||
|
messages.value[messageIndex].list.push({
|
||||||
|
content: item,
|
||||||
|
url,
|
||||||
|
})
|
||||||
|
await nextTick();
|
||||||
|
messageRef.value.scrollTop = messageRef.value.scrollHeight;
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.catch(async (error) => {
|
||||||
|
if (cancelIndex.value === messageIndex) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error(`请求 ${index} 失败:`, error);
|
||||||
|
messages.value[messageIndex].list.push({
|
||||||
|
content: item + " ,语音转换失败"
|
||||||
|
})
|
||||||
|
await nextTick();
|
||||||
|
messageRef.value.scrollTop = messageRef.value.scrollHeight;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送所有请求
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
requests.map((request) => controller.addRequest(request))
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理结果
|
||||||
|
const successfulResults = results
|
||||||
|
.filter((result) => result.status === "fulfilled")
|
||||||
|
.map((result) => result.value);
|
||||||
|
|
||||||
|
console.log("所有请求已完成", successfulResults);
|
||||||
|
return successfulResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceError = (content) => {
|
||||||
|
isQuery.value = false;
|
||||||
|
messages.value.push({
|
||||||
|
role: "assistant",
|
||||||
|
list: [{content}]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyUp = (event) => {
|
||||||
|
// 阻止shift+enter触发的回车键事件冒泡
|
||||||
|
if (event.key === "Enter" && event.shiftKey) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnter = (event) => {
|
||||||
|
// 只处理没有按住shift键的回车键事件
|
||||||
|
if (!event.shiftKey) {
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timbreValue = ref("");
|
||||||
|
const timbreOptions = ref([]);
|
||||||
|
|
||||||
|
const dialectValue = ref("普通话");
|
||||||
|
const dialectOptions = [
|
||||||
|
{
|
||||||
|
label: "普通话",
|
||||||
|
value: "普通话",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "粤语",
|
||||||
|
value: "粤语",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "四川话",
|
||||||
|
value: "四川话",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "上海话",
|
||||||
|
value: "上海话",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "南京话",
|
||||||
|
value: "南京话",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "天津话",
|
||||||
|
value: "天津话",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "武汉话",
|
||||||
|
value: "武汉话",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const toneValue = ref('中性')
|
||||||
|
const toneOptions = [
|
||||||
|
{
|
||||||
|
label: '中性',
|
||||||
|
value: '中性'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "高兴",
|
||||||
|
value: "高兴",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "悲伤",
|
||||||
|
value: "悲伤",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "愤怒",
|
||||||
|
value: "愤怒",
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// label: "冷漠",
|
||||||
|
// value: "冷漠",
|
||||||
|
// }
|
||||||
|
]
|
||||||
|
|
||||||
|
const langValue = ref('中文')
|
||||||
|
const langOptions = [
|
||||||
|
{
|
||||||
|
label: "中文",
|
||||||
|
value: "中文",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "英文",
|
||||||
|
value: "英文",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "日文",
|
||||||
|
value: "日文",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "韩语",
|
||||||
|
value: "韩语",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const countValue = ref(1)
|
||||||
|
|
||||||
|
const getTimbre = async () => {
|
||||||
|
const req = await getTts();
|
||||||
|
if (req.code === 200) {
|
||||||
|
timbreOptions.value = req.data
|
||||||
|
timbreValue.value = req.data[0]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
getTimbre();
|
||||||
|
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
|
||||||
|
const openDialog = () => {
|
||||||
|
dialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ruleFormRef = ref();
|
||||||
|
const ruleForm = reactive({
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = reactive({
|
||||||
|
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const soundRecordingRef = ref(null);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
ruleFormRef.value.validate(async (valid) => {
|
||||||
|
if (valid) {
|
||||||
|
const globalBlob = soundRecordingRef.value.getWav();
|
||||||
|
// 直接创建新Blob并指定WAV类型
|
||||||
|
if (globalBlob) {
|
||||||
|
const file = new File([globalBlob], `${ruleForm.name}.m4a`, {
|
||||||
|
type: "audio/m4a",
|
||||||
|
});
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("language_instruction", ruleForm.name);
|
||||||
|
|
||||||
|
const res = await createTimbre(formData);
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success("操作成功");
|
||||||
|
handleClose();
|
||||||
|
getTimbre();
|
||||||
|
} else {
|
||||||
|
ElMessage.success(res?.msg || "操作失败");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ElMessage.error("请先录音");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("error submit!");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
resetForm();
|
||||||
|
dialogVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
ruleFormRef.value.resetFields();
|
||||||
|
soundRecordingRef.value.reset();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.chat-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 800px;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
margin: 10px;
|
||||||
|
position: relative;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-box {
|
||||||
|
display: flex;
|
||||||
|
// flex-direction: column;
|
||||||
|
// align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message {
|
||||||
|
justify-content: right;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #ddd9ff;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
padding: 10px;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picture {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background-image: url("./img/user.png");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-message {
|
||||||
|
.audio {
|
||||||
|
margin-left: 20px;
|
||||||
|
|
||||||
|
audio::-webkit-media-controls {
|
||||||
|
display: none; /* Chrome, Safari, Opera */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-container {
|
||||||
|
background-color: #eff6ff;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
.messageContent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.picture {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background-image: url("./img/user.png");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 100% 100%;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.function {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.input-area) {
|
||||||
|
padding: 20px;
|
||||||
|
height: 140px;
|
||||||
|
border: 1px solid #ddd9ff;
|
||||||
|
border-radius: 20px;
|
||||||
|
|
||||||
|
.el-textarea__inner {
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-box {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.el-select__wrapper {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area:has(.el-textarea__inner:focus) {
|
||||||
|
box-shadow: 0 0 0 1px #ddd9ff inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area:has(.el-textarea__inner:hover) {
|
||||||
|
box-shadow: 0 0 0 1px #ddd9ff inset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.addSound {
|
||||||
|
margin-left: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
src/views/device/register/components/SpeechSynthesis/img/ai.png
Normal file
BIN
src/views/device/register/components/SpeechSynthesis/img/ai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<el-container class="box-container">
|
||||||
|
<el-aside class="aside-container" v-if="showAside">
|
||||||
|
<el-menu class="ai-menu" :default-active="activeMenu" @select="menuSelect">
|
||||||
|
<el-menu-item index="1">
|
||||||
|
<!-- <el-icon><icon-menu /></el-icon> -->
|
||||||
|
<template #title>语音合成(知识库)</template>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="2">
|
||||||
|
<!-- <el-icon><document /></el-icon> -->
|
||||||
|
<template #title>语音合成</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</el-aside>
|
||||||
|
<el-main class="main-content">
|
||||||
|
<!-- 这里知识库和基础库弄反了,临时页面,也懒得去纠正了 -->
|
||||||
|
<Chat v-show="activeMenu === '1'" aiName="语音合成(知识库)" aiType="basis" />
|
||||||
|
<Chat v-show="activeMenu === '2'" aiName="语音合成" aiType="knowledge" />
|
||||||
|
</el-main>
|
||||||
|
</el-container>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import Chat from "./chat.vue";
|
||||||
|
|
||||||
|
const activeMenu = ref('2');
|
||||||
|
const showAside = ref(false)
|
||||||
|
|
||||||
|
const menuSelect = (e) => {
|
||||||
|
activeMenu.value = e
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.app-container {
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
.box-container {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.aside-container {
|
||||||
|
width: 300px;
|
||||||
|
height: 100%;
|
||||||
|
margin-bottom: 0;
|
||||||
|
background: #fbfaff;
|
||||||
|
|
||||||
|
.ai-menu {
|
||||||
|
background: #fbfaff;
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<div class="recording">
|
||||||
|
<el-tooltip
|
||||||
|
class="box-item"
|
||||||
|
effect="dark"
|
||||||
|
content="点击开始录音"
|
||||||
|
placement="top-start"
|
||||||
|
v-if="!isRecording"
|
||||||
|
>
|
||||||
|
<el-icon class="start" :size="40" @click="startRecording" ><Microphone /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip
|
||||||
|
class="box-item"
|
||||||
|
effect="dark"
|
||||||
|
content="点击结束录音"
|
||||||
|
placement="top-start"
|
||||||
|
v-if="isRecording"
|
||||||
|
>
|
||||||
|
<el-icon class="stop" :size="40" @click="stopRecording"><VideoPause /></el-icon>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
<div class="audio" v-if="audioUrl">
|
||||||
|
<!-- <audio :src="audioUrl" controls /> -->
|
||||||
|
<AudioPlayer :url="audioUrl" :recordingTime="recordingTime"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Microphone, VideoPause } from "@element-plus/icons-vue";
|
||||||
|
import AudioPlayer from './AudioPlayer.vue';
|
||||||
|
|
||||||
|
const isRecording = ref(false)
|
||||||
|
const audioChunks = ref([])
|
||||||
|
const audioUrl = ref(null)
|
||||||
|
let mediaRecorder = null
|
||||||
|
let globalBlob = null
|
||||||
|
const recordingTime = ref(0)
|
||||||
|
let startTime
|
||||||
|
|
||||||
|
// 启动录音
|
||||||
|
const startRecording = async () => {
|
||||||
|
audioUrl.value = null
|
||||||
|
globalBlob = null
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
mediaRecorder = new MediaRecorder(stream)
|
||||||
|
mediaRecorder.onstart = () => {
|
||||||
|
startTime = Date.now(); // 记录开始时间
|
||||||
|
}
|
||||||
|
mediaRecorder.ondataavailable = e => audioChunks.value.push(e.data)
|
||||||
|
mediaRecorder.start()
|
||||||
|
isRecording.value = true
|
||||||
|
} catch (err) {
|
||||||
|
console.error('麦克风权限获取失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止录音
|
||||||
|
const stopRecording = () => {
|
||||||
|
mediaRecorder.stop()
|
||||||
|
mediaRecorder.onstop = () => {
|
||||||
|
recordingTime.value = Math.round((Date.now() - startTime) / 1000);
|
||||||
|
const blob = new Blob(audioChunks.value, { type: 'audio/m4a' })
|
||||||
|
globalBlob = blob
|
||||||
|
audioUrl.value = URL.createObjectURL(blob)
|
||||||
|
audioChunks.value = []
|
||||||
|
isRecording.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换逻辑参考
|
||||||
|
const getWav = () => {
|
||||||
|
if (globalBlob) {
|
||||||
|
return globalBlob
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
globalBlob = null
|
||||||
|
audioUrl.value = ''
|
||||||
|
audioChunks.value = []
|
||||||
|
isRecording.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getWav,
|
||||||
|
reset
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-evenly !important;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.recording {
|
||||||
|
.start {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop {
|
||||||
|
color: #f80303;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
BIN
src/views/device/register/components/TouchInteraction/ai.png
Normal file
BIN
src/views/device/register/components/TouchInteraction/ai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
512
src/views/device/register/components/TouchInteraction/index.vue
Normal file
512
src/views/device/register/components/TouchInteraction/index.vue
Normal file
@ -0,0 +1,512 @@
|
|||||||
|
<template>
|
||||||
|
<div class="touch-interaction-wrap">
|
||||||
|
<div class="title">触控交互</div>
|
||||||
|
<div class="chat-warp scrollable">
|
||||||
|
<template v-if="chatList.length>0">
|
||||||
|
<div class="item" v-for="item in chatList" :class="{'is-me': item.isMe}">
|
||||||
|
|
||||||
|
<!-- 我发的-->
|
||||||
|
<template v-if="item.isMe">
|
||||||
|
<el-image
|
||||||
|
v-if="item.meImgUrl"
|
||||||
|
style="width: 100px; height: 100px;cursor: pointer;border-radius: 8px"
|
||||||
|
:src="item.meImgUrl"
|
||||||
|
@click="maskImg(false,[],item.meImgUrl)"
|
||||||
|
fit="contain"
|
||||||
|
/>
|
||||||
|
<div class="content" v-if="item.meContent">
|
||||||
|
{{ item.meContent }}
|
||||||
|
</div>
|
||||||
|
<!-- <div class="avatar">-->
|
||||||
|
<!-- <img :src="MyAvatar" alt="" style="width: 40px;height: 40px">-->
|
||||||
|
<!-- </div>-->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 回复的-->
|
||||||
|
<template v-else>
|
||||||
|
<div class="avatar">
|
||||||
|
<img :src="MyAvatar" alt="" style="width: 40px;height: 40px">
|
||||||
|
</div>
|
||||||
|
<div class="content" v-if="item.aiContent">
|
||||||
|
{{ item.aiContent }}
|
||||||
|
<el-icon v-if="item.isLoading">
|
||||||
|
<RefreshRight/>
|
||||||
|
</el-icon>
|
||||||
|
</div>
|
||||||
|
<el-image
|
||||||
|
@click="maskImg(true,item.aiPoint,item.aiImgUrl)"
|
||||||
|
v-if="item.aiImgUrl"
|
||||||
|
style="width: 100px; height: 100px;cursor: pointer;border-radius: 8px"
|
||||||
|
:src="item.aiImgUrl"
|
||||||
|
fit="contain"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="empty">
|
||||||
|
<div class="flex align-center">
|
||||||
|
<img :src="MyAvatar" alt="" style="width: 40px;height: 40px;margin-right: 10px">
|
||||||
|
<div class="content">我是你的触控交互助手,很高兴见到你!</div>
|
||||||
|
</div>
|
||||||
|
<div class="desc">请把你需要识别的图片交给我吧~</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img id="sourceImage" :src="currentCanvasUrl" alt="Source Image" style="display: none;">
|
||||||
|
<canvas id="canvas" v-show="openPre"></canvas>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<el-upload
|
||||||
|
:auto-upload="false"
|
||||||
|
v-model:file-list="fileList"
|
||||||
|
list-type="picture-card"
|
||||||
|
:on-preview="handlePictureCardPreview"
|
||||||
|
:on-remove="handleRemove"
|
||||||
|
:limit="1"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<el-button type="primary">select file</el-button>
|
||||||
|
</template>
|
||||||
|
</el-upload>
|
||||||
|
<el-input :disabled="enableInput" type="textarea"
|
||||||
|
:autosize="{ minRows: 5, maxRows: 7 }"
|
||||||
|
placeholder="输入你的问题并上传图片"
|
||||||
|
v-model="chatVal"
|
||||||
|
>
|
||||||
|
</el-input>
|
||||||
|
<div class="btn-wrap">
|
||||||
|
<input type="file" id="hiddenFileInput" style="display: none;" accept="image/*"/>
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
content="上传附件"
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<!-- 隐藏的文件输入元素 -->
|
||||||
|
<div class="attachment flex align-center justify-content-center" @click="handleAttachmentClick">
|
||||||
|
<el-button circle :disabled="enableInput">
|
||||||
|
<template #icon>
|
||||||
|
<el-icon :size="20">
|
||||||
|
<Paperclip/>
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-tooltip>
|
||||||
|
|
||||||
|
<el-tooltip
|
||||||
|
effect="dark"
|
||||||
|
:content="`${chatVal!==''&&fileList.length!==0?'发送':'请输入你的问题并上传图片'}`"
|
||||||
|
placement="top-start"
|
||||||
|
>
|
||||||
|
<div class="submit flex align-center justify-content-center" @click="sendText">
|
||||||
|
<el-button type="primary" circle :disabled="!enableInput&&chatVal===''||fileList.length===0">
|
||||||
|
<template #icon>
|
||||||
|
<el-icon :size="20">
|
||||||
|
<Top/>
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-dialog v-model="dialogVisible" :show-close="false">
|
||||||
|
<img :src="dialogImageUrl" alt="Preview Image" style="height: 50vh;max-width: 50vw"/>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {Paperclip, Plus, RefreshRight, Top} from "@element-plus/icons-vue";
|
||||||
|
import {ElMessage} from 'element-plus'
|
||||||
|
import {coordinateMarking} from "@/api/touch_interaction.js";
|
||||||
|
import AiAvatar from "./ai.png"
|
||||||
|
import MyAvatar from "./zsj.png"
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
isMe: false,
|
||||||
|
meImgUrl: "",
|
||||||
|
meContent: "",
|
||||||
|
isLoading: false,
|
||||||
|
aiImgUrl: "",
|
||||||
|
aiContent: "",
|
||||||
|
aiPoint: []
|
||||||
|
}
|
||||||
|
const chatList = ref([])//会话列表
|
||||||
|
const fileList = ref([])//文件列表
|
||||||
|
const chatVal = ref("") //文本
|
||||||
|
const enableInput = ref(false) //允许上传和输入
|
||||||
|
|
||||||
|
// name: file.name,
|
||||||
|
// url: e.target.result,
|
||||||
|
// fileObj: file
|
||||||
|
const currentMaskFile = ref(null)
|
||||||
|
const currentCanvasUrl = ref(null)
|
||||||
|
|
||||||
|
const dialogVisible = ref(false) //文件预览
|
||||||
|
const dialogImageUrl = ref("") //文件预览的url
|
||||||
|
|
||||||
|
const markFormData = new FormData()//带上传的formData
|
||||||
|
|
||||||
|
const openPre = ref(false)
|
||||||
|
|
||||||
|
const addMeContent = (content) => {
|
||||||
|
chatList.value.push({
|
||||||
|
isMe: true,
|
||||||
|
meImgUrl: "",
|
||||||
|
meContent: content,
|
||||||
|
isLoading: false,
|
||||||
|
aiImgUrl: "",
|
||||||
|
aiContent: "",
|
||||||
|
aiPoint: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const addMeUrl = (url) => {
|
||||||
|
chatList.value.push({
|
||||||
|
isMe: true,
|
||||||
|
meImgUrl: url,
|
||||||
|
meContent: "",
|
||||||
|
isLoading: false,
|
||||||
|
aiImgUrl: "",
|
||||||
|
aiContent: "",
|
||||||
|
aiPoint: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const addAiContent = (content, loading = false) => {
|
||||||
|
chatList.value.push({
|
||||||
|
isMe: false,
|
||||||
|
meImgUrl: "",
|
||||||
|
meContent: "",
|
||||||
|
isLoading: loading,
|
||||||
|
aiImgUrl: "",
|
||||||
|
aiContent: content,
|
||||||
|
aiPoint: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const addAiUrl = (url, point) => {
|
||||||
|
chatList.value.push({
|
||||||
|
isMe: false,
|
||||||
|
meImgUrl: "",
|
||||||
|
meContent: "",
|
||||||
|
isLoading: false,
|
||||||
|
aiImgUrl: url,
|
||||||
|
aiContent: "",
|
||||||
|
aiPoint: point
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const sendText = () => {
|
||||||
|
enableInput.value = true
|
||||||
|
if (!chatVal.value) {
|
||||||
|
ElMessage({
|
||||||
|
message: '请输入内容',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentMaskFile.value = fileList.value[0]
|
||||||
|
markFormData.append("words", chatVal.value)
|
||||||
|
markFormData.append("image", fileList.value[0]['fileObj'])
|
||||||
|
addMeUrl(fileList.value[0]['url'])
|
||||||
|
addMeContent(chatVal.value)
|
||||||
|
addAiDynamicContent("正在思考中...", true)
|
||||||
|
chatVal.value = ""
|
||||||
|
fileList.value = []
|
||||||
|
scrollEnd();
|
||||||
|
coordinateMarking(markFormData).then((res) => {
|
||||||
|
const msg = JSON.parse(res.msg)
|
||||||
|
if (Array.isArray(msg)) {
|
||||||
|
chatList.value.pop()
|
||||||
|
addAiContent(res.msg) // 坐标数据
|
||||||
|
addAiUrl(currentMaskFile.value['url'], msg)
|
||||||
|
} else {
|
||||||
|
addAiContent(msg)
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
console.log(err)
|
||||||
|
chatList.value.pop()
|
||||||
|
addAiDynamicContent("识别超时,请稍后重试")
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
chatList.value[chatList.value.length - 1]['content'] = ""
|
||||||
|
markFormData.delete("image")
|
||||||
|
markFormData.delete("words")
|
||||||
|
const newInput = document.createElement('input');
|
||||||
|
const input = document.getElementById("hiddenFileInput");
|
||||||
|
newInput.type = 'file';
|
||||||
|
newInput.id = 'hiddenFileInput';
|
||||||
|
newInput.accept = "image/*"
|
||||||
|
newInput.style.display = 'none';
|
||||||
|
input.parentNode.replaceChild(newInput, input);
|
||||||
|
scrollEnd();
|
||||||
|
enableInput.value = false
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttachmentClick = () => {
|
||||||
|
const input = document.getElementById("hiddenFileInput");
|
||||||
|
input.click();
|
||||||
|
// 移除所有现有的change事件监听器
|
||||||
|
input.removeEventListener("change", handleFileChange);
|
||||||
|
input.addEventListener("change", handleFileChange)
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
// 创建FileReader对象
|
||||||
|
const reader = new FileReader();
|
||||||
|
// 读取完成后触发的事件处理程序
|
||||||
|
reader.onload = e => {
|
||||||
|
// 更新ref
|
||||||
|
fileList.value = [{
|
||||||
|
name: file.name,
|
||||||
|
url: e.target.result,
|
||||||
|
fileObj: file
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
// 以DataURL的形式读取文件内容
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePictureCardPreview = (uploadFile) => {
|
||||||
|
dialogVisible.value = true
|
||||||
|
dialogImageUrl.value = fileList.value[0]['url']
|
||||||
|
}
|
||||||
|
|
||||||
|
const maskImg = (draw, [x1, y1, x2, y2], url) => {
|
||||||
|
openPre.value = true
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const sourceImage = document.getElementById('sourceImage');
|
||||||
|
sourceImage.src = url
|
||||||
|
canvas.addEventListener('click', () => {
|
||||||
|
openPre.value = false
|
||||||
|
})
|
||||||
|
// 设置canvas大小与图片大小相同
|
||||||
|
canvas.width = sourceImage.width;
|
||||||
|
canvas.height = sourceImage.height;
|
||||||
|
console.log(x1, y1, x2, y2, url)
|
||||||
|
// 绘制图片到canvas
|
||||||
|
ctx.drawImage(sourceImage, 0, 0);
|
||||||
|
|
||||||
|
if (draw) {
|
||||||
|
// 定义坐标和矩形大小
|
||||||
|
const x = x1; // 矩形左上角x坐标
|
||||||
|
const y = y1; // 矩形左上角y坐标
|
||||||
|
const width = Math.abs(x2 - x1); // 矩形宽度绝对值
|
||||||
|
const height = Math.abs(y2 - y1); // 矩形高度
|
||||||
|
|
||||||
|
// 绘制矩形
|
||||||
|
ctx.strokeStyle = 'red'; // 矩形边框颜色
|
||||||
|
ctx.lineWidth = 10; // 矩形边框宽度
|
||||||
|
ctx.strokeRect(x, y, width, height); // 绘制矩形
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//删除还未上传的图片
|
||||||
|
const handleRemove = (uploadFile, uploadFiles) => {
|
||||||
|
fileList.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
//滚动条滚动到最底部
|
||||||
|
const scrollEnd = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const box = document.querySelector(".chat-warp");
|
||||||
|
if (box) {
|
||||||
|
box.scrollTo({top: box.scrollHeight, behavior: "smooth"});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
watch(
|
||||||
|
() => chatList.value,
|
||||||
|
() => {
|
||||||
|
scrollEnd();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const addAiDynamicContent = (content, loading = false) => {
|
||||||
|
chatList.value.push({
|
||||||
|
isMe: false,
|
||||||
|
meImgUrl: "",
|
||||||
|
meContent: "",
|
||||||
|
isLoading: false,
|
||||||
|
aiImgUrl: "",
|
||||||
|
aiContent: "",
|
||||||
|
aiPoint: []
|
||||||
|
})
|
||||||
|
const words = content.split("")
|
||||||
|
for (let i = 0; i < words.length; i++) {
|
||||||
|
setTimeout(() => {
|
||||||
|
chatList.value[chatList.value.length - 1]['aiContent'] += words[i]
|
||||||
|
}, i * 200 + Math.random() * 100)
|
||||||
|
}
|
||||||
|
if (loading) {
|
||||||
|
chatList.value[chatList.value.length - 1]['isLoading'] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.touch-interaction-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.add {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #404040;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 800px;
|
||||||
|
height: 46px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #262626;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: inset 0 0 0 1px rgb(229, 229, 229);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-warp {
|
||||||
|
width: 800px;
|
||||||
|
height: calc(100vh - 340px); //220 56
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-top: 20px;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-right: 10px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 28px;
|
||||||
|
color: #262626;
|
||||||
|
padding: 8px 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
background-color: #eff6ff;
|
||||||
|
border-radius: 14px;
|
||||||
|
max-width: calc(100% - 48px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
i {
|
||||||
|
margin-left: 2px;
|
||||||
|
animation: spin 1.5s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-me {
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
width: 800px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
:deep(.el-textarea__inner ) {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background-color: rgb(243, 244, 246);
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 0 0 .5px #dce0e9;
|
||||||
|
color: rgb(64, 64, 64);
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-upload--picture-card ) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-upload-list__item ) {
|
||||||
|
height: 80px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-wrap {
|
||||||
|
position: absolute;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.attachment {
|
||||||
|
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#canvas {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 99999999;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
src/views/device/register/components/TouchInteraction/zsj.png
Normal file
BIN
src/views/device/register/components/TouchInteraction/zsj.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
59
src/views/device/register/components/index.js
Normal file
59
src/views/device/register/components/index.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import {controlPageList} from "@/enum/index.js";
|
||||||
|
import {ElMessage} from "element-plus";
|
||||||
|
import router from "@/router";
|
||||||
|
|
||||||
|
export const intoControlPage = (path, params) => {
|
||||||
|
if (!controlPageList.includes(path)) {
|
||||||
|
ElMessage.warning('该页面不在控制台页面中,无法打开');
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// const fullPath = `${window.location.origin}${path}/${params}`;
|
||||||
|
// // 使用 window.open 在新标签页中打开
|
||||||
|
// window.open(fullPath, '_blank');
|
||||||
|
const fullPath = `${path}/${params}`; // 构造路由路径
|
||||||
|
router.push(fullPath); // 使用路由跳转
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义需要转换的属性列表
|
||||||
|
const propertiesToConvert = [
|
||||||
|
"workModule",
|
||||||
|
"posStep",
|
||||||
|
"postureStep",
|
||||||
|
"jointStep",
|
||||||
|
"joint1",
|
||||||
|
"joint2",
|
||||||
|
"joint3",
|
||||||
|
"joint4",
|
||||||
|
"joint5",
|
||||||
|
"joint6",
|
||||||
|
"x",
|
||||||
|
"y",
|
||||||
|
"z",
|
||||||
|
"rx",
|
||||||
|
"ry",
|
||||||
|
"rz"
|
||||||
|
];
|
||||||
|
export const formatNumber = (data) => {
|
||||||
|
const res = JSON.parse(JSON.stringify(data));
|
||||||
|
// 遍历属性列表并转换值
|
||||||
|
propertiesToConvert.forEach(property => {
|
||||||
|
if (data[property] !== null) {
|
||||||
|
res[property] = Number(data[property]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
//对象中存在数字类型属性时,转换为字符串类型
|
||||||
|
export const convertNumbersToStrings = (obj) => {
|
||||||
|
for (const key in obj) {
|
||||||
|
if (obj.hasOwnProperty(key)) {
|
||||||
|
const value = obj[key];
|
||||||
|
// 检查值是否不是 null 且是数字类型
|
||||||
|
if (value !== null && typeof value === 'number') {
|
||||||
|
obj[key] = String(value); // 转换为字符串
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
414
src/views/device/register/index.vue
Normal file
414
src/views/device/register/index.vue
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<div ref="topContainerRef">
|
||||||
|
<TableSearch
|
||||||
|
:queryParams="queryParams"
|
||||||
|
:showSearch="showSearch"
|
||||||
|
label-width="80px"
|
||||||
|
queryRef="queryRef"
|
||||||
|
@refresh="resetQuery"
|
||||||
|
@search="handleQuery"
|
||||||
|
>
|
||||||
|
<template #one>
|
||||||
|
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||||
|
<el-form-item label="设备编号" prop="deviceCode">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.deviceCode"
|
||||||
|
clearable
|
||||||
|
placeholder="请输入设备编号"
|
||||||
|
style="width:100%"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||||
|
<el-form-item label="设备名称" prop="deviceName">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.deviceName"
|
||||||
|
clearable
|
||||||
|
placeholder="请输入设备名称"
|
||||||
|
style="width:100%"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||||
|
<el-form-item label="设备类型" prop="deviceModel">
|
||||||
|
<el-select
|
||||||
|
v-model="queryParams.deviceModel"
|
||||||
|
placeholder="请输入设备类型"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in controlModuleList"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</template>
|
||||||
|
</TableSearch>
|
||||||
|
|
||||||
|
<el-row :gutter="10" class="mb8">
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
v-hasPermi="['device:register:add']"
|
||||||
|
icon="Plus"
|
||||||
|
plain
|
||||||
|
type="primary"
|
||||||
|
@click="handleAdd"
|
||||||
|
>新增
|
||||||
|
</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
v-hasPermi="['device:register:edit']"
|
||||||
|
:disabled="single"
|
||||||
|
icon="Edit"
|
||||||
|
plain
|
||||||
|
type="success"
|
||||||
|
@click="handleUpdate"
|
||||||
|
>修改
|
||||||
|
</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
v-hasPermi="['device:register: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:register: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="registerList" @selection-change="handleSelectionChange">
|
||||||
|
<el-table-column align="center" type="selection" width="55" />
|
||||||
|
<el-table-column align="center" label="id" prop="id" show-overflow-tooltip/>
|
||||||
|
<el-table-column align="center" label="设备终端配置id" prop="idDeDeviceTerminalConfig" show-overflow-tooltip
|
||||||
|
width="120"/>
|
||||||
|
<el-table-column align="center" label="设备编号" prop="deviceCode" show-overflow-tooltip/>
|
||||||
|
<el-table-column align="center" label="设备名称" prop="deviceName" show-overflow-tooltip/>
|
||||||
|
<!-- <el-table-column label="设备规格" align="center" prop="deviceSpec"/>-->
|
||||||
|
<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 label="设备厂家" align="center" prop="factory"/>-->
|
||||||
|
<!-- <el-table-column label="设备图片" align="center" prop="images"/>-->
|
||||||
|
<!-- <el-table-column show-overflow-tooltip label="备注" align="center" prop="remake"/>-->
|
||||||
|
<el-table-column align="center" label="设备状态" prop="status">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-switch
|
||||||
|
v-model="scope.row.status"
|
||||||
|
active-text="启用"
|
||||||
|
active-value="0"
|
||||||
|
inactive-text="停用"
|
||||||
|
inactive-value="1"
|
||||||
|
inline-prompt
|
||||||
|
@change="handleChange(scope.row)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column align="center" class-name="small-padding fixed-width" label="操作" width="200">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button v-hasPermi="['device:register:edit']" icon="Edit" link type="primary"
|
||||||
|
@click="handleUpdate(scope.row)">修改
|
||||||
|
</el-button>
|
||||||
|
<el-button v-hasPermi="['device:register:deploy']" icon="Cpu" link type="primary"
|
||||||
|
@click="handleControl(scope.row)">示教
|
||||||
|
</el-button>
|
||||||
|
<el-button v-hasPermi="['device:register: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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加或修改设备注册对话框 -->
|
||||||
|
<el-dialog v-model="open" :title="title" append-to-body width="500px">
|
||||||
|
<el-form ref="registerRef" :model="form" :rules="rules" label-width="110px">
|
||||||
|
<el-form-item label="设备终端配置id" prop="idDeDeviceTerminalConfig">
|
||||||
|
<el-input v-model="form.idDeDeviceTerminalConfig"
|
||||||
|
placeholder="请输入设备终端配置id"/>
|
||||||
|
<!-- <el-input v-model="form.idDeDeviceTerminalConfig" :disabled="form.idDeDeviceTerminalConfig!== ''"
|
||||||
|
placeholder="请输入设备终端配置id"/> -->
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="设备编号" prop="deviceCode">
|
||||||
|
<el-input v-model="form.deviceCode" placeholder="请输入设备编号"/>
|
||||||
|
<!-- <el-input v-model="form.deviceCode" :disabled="form.deviceCode!== ''" placeholder="请输入设备编号"/> -->
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="设备名称" prop="deviceName">
|
||||||
|
<el-input v-model="form.deviceName" placeholder="请输入设备名称"/>
|
||||||
|
</el-form-item>
|
||||||
|
<!-- <el-form-item label="设备规格" prop="deviceSpec">-->
|
||||||
|
<!-- <el-input v-model="form.deviceSpec" placeholder="请输入设备规格"/>-->
|
||||||
|
<!-- </el-form-item>-->
|
||||||
|
|
||||||
|
<el-form-item label="设备类型" prop="deviceModel">
|
||||||
|
<el-select
|
||||||
|
v-model="form.deviceModel"
|
||||||
|
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="所属分组">
|
||||||
|
<el-select v-model="form.groupId" placeholder="请选择所属分组">
|
||||||
|
<el-option v-for="group in groupList" :key="group.value" :label="group.label"
|
||||||
|
:value="group.value"></el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<!-- <el-form-item label="设备厂家" prop="factory">-->
|
||||||
|
<!-- <el-input v-model="form.factory" placeholder="请输入设备厂家"/>-->
|
||||||
|
<!-- </el-form-item>-->
|
||||||
|
<!-- <el-form-item label="设备图片" prop="images">-->
|
||||||
|
<!-- <el-input v-model="form.images" 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 name="Register" setup>
|
||||||
|
import {addRegister, delRegister, getRegister, listRegister, updateRegister} from "@/api/device/register"
|
||||||
|
import TableSearch from "@/components/TableSearch/index.vue"
|
||||||
|
import {useRouter} from "vue-router"
|
||||||
|
import {intoControlPage} from "@/views/device/register/components/index.js";
|
||||||
|
import {controlModuleList} from "@/enum/index.js";
|
||||||
|
import {listGroup} from "@/api/system/group.js";
|
||||||
|
import useTagsViewStore from '@/store/modules/tagsView'
|
||||||
|
|
||||||
|
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||||
|
|
||||||
|
const topContainerRef = ref();
|
||||||
|
const containerHeight = useContainerHeight(topContainerRef);
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
|
||||||
|
const {proxy} = getCurrentInstance()
|
||||||
|
|
||||||
|
const registerList = 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,
|
||||||
|
deviceCode: null,
|
||||||
|
deviceName: null,
|
||||||
|
deviceSpec: null,
|
||||||
|
deviceModel: null,
|
||||||
|
factory: null,
|
||||||
|
status: null
|
||||||
|
},
|
||||||
|
rules: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
const {queryParams, form, rules} = toRefs(data)
|
||||||
|
|
||||||
|
/** 查询设备注册列表 */
|
||||||
|
function getList() {
|
||||||
|
loading.value = true
|
||||||
|
listRegister(queryParams.value).then(response => {
|
||||||
|
registerList.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,
|
||||||
|
// idDeDeviceTerminalConfig: null,
|
||||||
|
// deviceCode: null,
|
||||||
|
// deviceName: null,
|
||||||
|
// deviceSpec: null,
|
||||||
|
// deviceModel: null,
|
||||||
|
// factory: null,
|
||||||
|
// images: null,
|
||||||
|
// status: null
|
||||||
|
}
|
||||||
|
proxy.resetForm("registerRef")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索按钮操作 */
|
||||||
|
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
|
||||||
|
getRegister(_id).then(response => {
|
||||||
|
form.value = response.data
|
||||||
|
open.value = true
|
||||||
|
title.value = "修改设备注册"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交按钮 */
|
||||||
|
function submitForm() {
|
||||||
|
proxy.$refs["registerRef"].validate(valid => {
|
||||||
|
if (valid) {
|
||||||
|
if (form.value.id != null) {
|
||||||
|
updateRegister(form.value).then(response => {
|
||||||
|
proxy.$modal.msgSuccess("修改成功")
|
||||||
|
open.value = false
|
||||||
|
getList()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
addRegister(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 delRegister(_ids)
|
||||||
|
}).then(() => {
|
||||||
|
getList()
|
||||||
|
proxy.$modal.msgSuccess("删除成功")
|
||||||
|
}).catch(() => {
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出按钮操作 */
|
||||||
|
function handleExport() {
|
||||||
|
proxy.download('device/register/export', {
|
||||||
|
...queryParams.value
|
||||||
|
}, `register_${new Date().getTime()}.xlsx`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 控制按钮操作 */
|
||||||
|
function handleControl(row) {
|
||||||
|
const fullPath = `${row.deviceModel}/${row.id}`; // 构造路由路径
|
||||||
|
router.push({
|
||||||
|
path: fullPath,
|
||||||
|
query: {
|
||||||
|
terminalId: row.idDeDeviceTerminalConfig,
|
||||||
|
deviceId: row.deviceCode
|
||||||
|
}
|
||||||
|
}); // 使用路由跳转
|
||||||
|
// intoControlPage(`/${row.deviceModel}`, `${row.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(row) {
|
||||||
|
updateRegister({status: row.status, id: row.id}).then(response => {
|
||||||
|
proxy.$modal.msgSuccess("修改成功")
|
||||||
|
getList()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getList()
|
||||||
|
|
||||||
|
|
||||||
|
const groupList = ref([])
|
||||||
|
|
||||||
|
/** 获取系统分组列表(默认传1查询检测项列表) */
|
||||||
|
function handleGetSystemGroup(row) {
|
||||||
|
listGroup({type: 3}).then(response => {
|
||||||
|
groupList.value = response.rows?.map((item) => {
|
||||||
|
return {
|
||||||
|
label: item.groupName,
|
||||||
|
value: String(item.groupId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGetSystemGroup()
|
||||||
|
</script>
|
||||||
89
src/views/device/robot/ContextMenu.vue
Normal file
89
src/views/device/robot/ContextMenu.vue
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="context-menu"
|
||||||
|
:style="{ top: position.y + 'px', left: position.x + 'px' }"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="item in menuItems"
|
||||||
|
:key="item.key"
|
||||||
|
class="menu-item"
|
||||||
|
@click="handleClick(item)"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch, onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({ x: 0, y: 0 })
|
||||||
|
},
|
||||||
|
menuItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'select']);
|
||||||
|
|
||||||
|
watch(() => props.visible, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
document.addEventListener('click', closeMenu);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', closeMenu);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClick = (item) => {
|
||||||
|
emit('select', item);
|
||||||
|
closeMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 初始监听(如果visible初始为true)
|
||||||
|
if (props.visible) {
|
||||||
|
document.addEventListener('click', closeMenu);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', closeMenu);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.context-menu {
|
||||||
|
position: fixed; /* 使用 fixed 定位,不受父容器 scroll 影响 */
|
||||||
|
z-index: 9999;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
padding: 8px 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #606266;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:hover {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
547
src/views/device/robot/TechButtonWithLine.vue
Normal file
547
src/views/device/robot/TechButtonWithLine.vue
Normal file
@ -0,0 +1,547 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="tech-button-with-line"
|
||||||
|
:style="buttonStyle"
|
||||||
|
ref="techButtonContainer"
|
||||||
|
@mouseover="isHovering = true"
|
||||||
|
@mouseleave="isHovering = false"
|
||||||
|
>
|
||||||
|
<!-- 新增一个包裹层,用于应用 clip-path -->
|
||||||
|
<div class="button-shape-clipper" :style="clipperStyle">
|
||||||
|
<div class="button-content-wrapper">
|
||||||
|
<slot name="button-content">
|
||||||
|
<span class="button-text">{{ buttonName }}</span>
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- SVG 部分保持原有结构,但内部样式和滤镜会改变 -->
|
||||||
|
<svg
|
||||||
|
v-if="svgReady"
|
||||||
|
class="line-svg"
|
||||||
|
:width="svgWidth"
|
||||||
|
:height="svgHeight"
|
||||||
|
:style="{
|
||||||
|
top: `0px`,
|
||||||
|
left: `0px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<!-- 霓虹光效滤镜 -->
|
||||||
|
<!-- stdDeviation 控制模糊程度,值越大越模糊,光效越柔和 -->
|
||||||
|
<filter id="neon-filter" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur in="SourceGraphic" :stdDeviation="props.neonGlowIntensity" result="blur"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
<!-- 扫描线效果渐变 -->
|
||||||
|
<!-- 动态调整扫描线颜色 -->
|
||||||
|
<linearGradient id="scan-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:rgba(255,255,255,0);stop-opacity:1" />
|
||||||
|
<stop offset="50%" :style="`stop-color:${props.lineHoverColor};stop-opacity:0.8`" />
|
||||||
|
<stop offset="100%" style="stop-color:rgba(255,255,255,0);stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- 主线条,应用霓虹光效和扫描线 -->
|
||||||
|
<polyline
|
||||||
|
:points="linePoints"
|
||||||
|
stroke="url(#scan-gradient)"
|
||||||
|
:stroke-width="props.lineStrokeWidth"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
filter="url(#neon-filter)"
|
||||||
|
class="main-line"
|
||||||
|
/>
|
||||||
|
<!-- 可选:一条细微的基线,用于对比或增加层次 -->
|
||||||
|
<polyline
|
||||||
|
:points="baseLinePoints"
|
||||||
|
:stroke="props.lineColor"
|
||||||
|
stroke-width="1"
|
||||||
|
fill="none"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="base-line"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, nextTick, watch, defineProps, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
// --- Props ---
|
||||||
|
// 保持原有 props,并增加一些用于样式控制的 props
|
||||||
|
const props = defineProps({
|
||||||
|
totalButtons: { type: Number, required: true, default: 1 },
|
||||||
|
buttonIndex: { type: Number, required: true },
|
||||||
|
buttonPosition: { type: String, required: true, validator: (value) => ['left', 'right'].includes(value) },
|
||||||
|
buttonOffsetX: { type: Number, default: 350 },
|
||||||
|
|
||||||
|
endX: { type: Number, required: true },
|
||||||
|
endY: { type: Number, required: true },
|
||||||
|
buttonName: { type: String, default: '科技按钮' },
|
||||||
|
buttonWidth: { type: [Number, String], default: 150 },
|
||||||
|
buttonHeight: { type: [Number, String], default: 40 },
|
||||||
|
linePadding: { type: Number, default: 20 },
|
||||||
|
|
||||||
|
// --- 仅用于样式的 Props ---
|
||||||
|
buttonGradient: { type: Array, default: () => ['#00f0ff', '#00d0ff'] }, // 按钮背景渐变色
|
||||||
|
buttonHoverGradient: { type: Array, default: () => ['#00d0ff', '#00f0ff'] }, // 悬停时的渐变色
|
||||||
|
buttonBorderColor: { type: String, default: 'rgba(0, 255, 255, 0.6)' },
|
||||||
|
lineColor: { type: String, default: 'rgba(0, 150, 200, 0.4)' }, // 细线基础颜色
|
||||||
|
lineHoverColor: { type: String, default: 'rgba(0, 255, 255, 1)' }, // 扫描线高亮颜色
|
||||||
|
lineStrokeWidth: { type: Number, default: 3 },
|
||||||
|
neonGlowIntensity: { type: Number, default: 3 }, // 霓虹光效强度
|
||||||
|
scanLineSpeed: { type: Number, default: 8 }, // 扫描线动画速度
|
||||||
|
animationDuration: { type: Number, default: 0.4 }, // 基础动画时长
|
||||||
|
hoverScale: { type: Number, default: 1.03 }, // 悬停时的放大比例
|
||||||
|
// --- New Props for Flowing Light Effect ---
|
||||||
|
flowLineColor: { type: String, default: 'rgba(0, 255, 255, 0.8)' }, // 流光颜色
|
||||||
|
flowLineAnimationSpeed: { type: Number, default: 7 }, // 流光动画速度
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Refs ---
|
||||||
|
const techButtonContainer = ref(null);
|
||||||
|
const svgReady = ref(false);
|
||||||
|
|
||||||
|
const parentContainerRef = ref(null);
|
||||||
|
const parentHeight = ref(0);
|
||||||
|
const parentWidth = ref(0);
|
||||||
|
|
||||||
|
const svgWidth = ref(0);
|
||||||
|
const svgHeight = ref(0);
|
||||||
|
const svgLeftOffset = ref(0);
|
||||||
|
const svgTopOffset = ref(0);
|
||||||
|
|
||||||
|
const isHovering = ref(false); // 追踪鼠标是否悬停
|
||||||
|
|
||||||
|
// --- Computed Properties ---
|
||||||
|
|
||||||
|
// 计算父容器的水平中心线 (保持不变)
|
||||||
|
const autoCenterX = computed(() => parentWidth.value / 2);
|
||||||
|
|
||||||
|
// 计算按钮在父容器中的 X 坐标(全局坐标)(保持不变)
|
||||||
|
const calculatedButtonX = computed(() => {
|
||||||
|
if (!parentWidth.value) return 0;
|
||||||
|
if (props.buttonPosition === 'left') {
|
||||||
|
return autoCenterX.value - props.buttonOffsetX;
|
||||||
|
} else if (props.buttonPosition === 'right') {
|
||||||
|
return autoCenterX.value + props.buttonOffsetX;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算按钮在父容器中的 Y 坐标(全局坐标)(保持不变)
|
||||||
|
const calculatedButtonY = computed(() => {
|
||||||
|
if (!parentHeight.value || props.totalButtons <= 0) return 0;
|
||||||
|
const buttonSlotHeight = parentHeight.value / props.totalButtons;
|
||||||
|
const centerY = (props.buttonIndex - 1) * buttonSlotHeight + buttonSlotHeight / 2;
|
||||||
|
return centerY;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取按钮的实际宽度和高度 (保持不变)
|
||||||
|
const actualButtonWidth = computed(() => {
|
||||||
|
if (techButtonContainer.value) {
|
||||||
|
return techButtonContainer.value.offsetWidth;
|
||||||
|
}
|
||||||
|
return typeof props.buttonWidth === 'number' ? props.buttonWidth : 150;
|
||||||
|
});
|
||||||
|
const actualButtonHeight = computed(() => {
|
||||||
|
if (techButtonContainer.value) {
|
||||||
|
return techButtonContainer.value.offsetHeight;
|
||||||
|
}
|
||||||
|
return typeof props.buttonHeight === 'number' ? props.buttonHeight : 40;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 线的终点相对于按钮中心点的偏移 (SVG 内部坐标系) (保持不变)
|
||||||
|
const relativeEndX = computed(() => props.endX - calculatedButtonX.value + actualButtonWidth.value / 2);
|
||||||
|
const relativeEndY = computed(() => props.endY - calculatedButtonY.value + actualButtonHeight.value / 2);
|
||||||
|
|
||||||
|
// 按钮的样式
|
||||||
|
const buttonStyle = computed(() => {
|
||||||
|
const buttonWidthPx = typeof props.buttonWidth === 'number' ? `${props.buttonWidth}px` : props.buttonWidth;
|
||||||
|
const buttonHeightPx = typeof props.buttonHeight === 'number' ? `${props.buttonHeight}px` : props.buttonHeight;
|
||||||
|
|
||||||
|
const currentGradient = isHovering.value ? props.buttonHoverGradient : props.buttonGradient;
|
||||||
|
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${calculatedButtonX.value}px`,
|
||||||
|
top: `${calculatedButtonY.value}px`,
|
||||||
|
transform: 'translate(-50%, -50%)', // 居中对齐
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
width: buttonWidthPx,
|
||||||
|
height: buttonHeightPx,
|
||||||
|
borderRadius: '15px', // 强制平直四角
|
||||||
|
// 更硬朗的背景,减少模糊,增加纹理感
|
||||||
|
background: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`,
|
||||||
|
// 模拟金属/磨砂质感,但更锐利
|
||||||
|
boxShadow: `
|
||||||
|
inset 0 0 0 2px ${props.buttonBorderColor}, /* 内部边框 */
|
||||||
|
0 0 5px rgba(0, 150, 200, 0.5), /* 较弱的主光晕 */
|
||||||
|
0 0 12px rgba(0, 200, 255, 0.6), /* 第二层光晕 */
|
||||||
|
0 3px 10px rgba(0, 0, 0, 0.4) /* 底部阴影 */
|
||||||
|
`,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold', // 更粗的字体
|
||||||
|
letterSpacing: '0.7px',
|
||||||
|
textShadow: '0 0 5px rgba(0, 150, 200, 0.9)', // 文本的锐利光效
|
||||||
|
transition: `all ${props.animationDuration}s ease-in-out`,
|
||||||
|
overflow: 'visible', // 允许流光伪元素溢出
|
||||||
|
zIndex: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isHovering.value) {
|
||||||
|
style.transform = `translate(-50%, -50%) scale(${props.hoverScale})`;
|
||||||
|
style.boxShadow = `
|
||||||
|
inset 0 0 0 3px ${props.buttonBorderColor}, /* 悬停时内部边框变宽 */
|
||||||
|
0 0 8px rgba(0, 200, 255, 0.7),
|
||||||
|
0 0 18px rgba(0, 230, 255, 0.8),
|
||||||
|
0 5px 15px rgba(0, 0, 0, 0.5) /* 底部阴影增强 */
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按钮形状裁剪器的样式
|
||||||
|
const clipperStyle = computed(() => {
|
||||||
|
const currentGradient = isHovering.value ? props.buttonHoverGradient : props.buttonGradient;
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
position: 'relative', // 相对于 techButtonContainer 定位
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
borderRadius: '0px', // **clipper 的形状由 clip-path 定义**
|
||||||
|
background: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`,
|
||||||
|
boxShadow: `
|
||||||
|
inset 0 0 0 2px ${props.buttonBorderColor}, /* 内部边框 */
|
||||||
|
0 0 5px rgba(0, 150, 200, 0.5), /* 较弱的主光晕 */
|
||||||
|
0 0 12px rgba(0, 200, 255, 0.6), /* 第二层光晕 */
|
||||||
|
0 3px 10px rgba(0, 0, 0, 0.4) /* 底部阴影 */
|
||||||
|
`,
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
letterSpacing: '0.7px',
|
||||||
|
textShadow: '0 0 5px rgba(0, 150, 200, 0.9)', // 文本的锐利光效
|
||||||
|
transition: `all ${props.animationDuration}s ease-in-out`,
|
||||||
|
overflow: 'hidden', // **clipper 内部的内容会被 clip-path 裁剪**
|
||||||
|
clipPath: `polygon(
|
||||||
|
6px 0, /* 左上角内缩进5px */
|
||||||
|
calc(100% - 6px) 0, /* 右上角内缩进5px */
|
||||||
|
100% 6px, /* 右上角外延5px */
|
||||||
|
100% calc(100% - 6px), /* 右下角内缩进5px */
|
||||||
|
calc(100% - 6px) 100%,
|
||||||
|
6px 100%, /* 左下角内缩进5px */
|
||||||
|
0 calc(100% - 6px),
|
||||||
|
0 6px, /* 左下角外延5px */
|
||||||
|
6px 0px /* 左上角内缩进5px(重复起点,闭合多边形) */
|
||||||
|
)`,
|
||||||
|
zIndex: 1, // 确保clipper在流光之上
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isHovering.value) {
|
||||||
|
style.boxShadow = `
|
||||||
|
inset 0 0 0 3px ${props.buttonBorderColor}, /* 悬停时内部边框变宽 */
|
||||||
|
0 0 8px rgba(0, 200, 255, 0.7),
|
||||||
|
0 0 18px rgba(0, 230, 255, 0.8),
|
||||||
|
0 5px 15px rgba(0, 0, 0, 0.5) /* 底部阴影增强 */
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 线的三个关键点 (相对于 SVG 内部坐标系 (0,0) 是按钮中心)
|
||||||
|
// --- 保持原有逻辑 ---
|
||||||
|
const linePoints = computed(() => {
|
||||||
|
if (!svgReady.value) return '';
|
||||||
|
|
||||||
|
const startPointX = (props.buttonPosition === 'left' ? props.buttonWidth : 0);
|
||||||
|
const startPointY = props.buttonHeight/2;
|
||||||
|
|
||||||
|
const endPointX = relativeEndX.value;
|
||||||
|
const endPointY = relativeEndY.value;
|
||||||
|
|
||||||
|
// L 形连接点:先垂直,再水平。
|
||||||
|
const middlePointY = endPointY;
|
||||||
|
|
||||||
|
// 调整连接点的 X 坐标,使其更具科技感,可以稍微偏离 Y 轴
|
||||||
|
// 这里的 +10 和 -10 是示例,可以根据实际效果调整
|
||||||
|
const adjustedMiddlePointX = startPointX + (props.buttonPosition === 'left' ? 100 : -100);
|
||||||
|
|
||||||
|
return `${startPointX},${startPointY} ${adjustedMiddlePointX},${middlePointY} ${endPointX},${endPointY}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 细微基线的点 (保持原有逻辑)
|
||||||
|
const baseLinePoints = computed(() => {
|
||||||
|
if (!svgReady.value) return '';
|
||||||
|
const startPointX = (props.buttonPosition === 'left' ? props.buttonWidth : 0);
|
||||||
|
const startPointY = props.buttonHeight/2;
|
||||||
|
const endPointX = relativeEndX.value;
|
||||||
|
const endPointY = relativeEndY.value;
|
||||||
|
const adjustedMiddlePointX = startPointX + (props.buttonPosition === 'left' ? 100 : -100);
|
||||||
|
return `${startPointX},${startPointY} ${adjustedMiddlePointX},${endPointY} ${endPointX},${endPointY}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Methods --- (保持不变)
|
||||||
|
const updateSvgDimensions = async () => {
|
||||||
|
if (!techButtonContainer.value) {
|
||||||
|
svgReady.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
if (!parentContainerRef.value || parentContainerRef.value.offsetHeight === 0 || parentContainerRef.value.offsetWidth === 0) {
|
||||||
|
svgReady.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parentHeight.value = parentContainerRef.value.offsetHeight;
|
||||||
|
parentWidth.value = parentContainerRef.value.offsetWidth;
|
||||||
|
|
||||||
|
if (parentHeight.value <= 0 || parentWidth.value <= 0 || props.totalButtons <= 0) {
|
||||||
|
svgReady.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let minX = Math.min(0, 0, relativeEndX.value);
|
||||||
|
let maxX = Math.max(0, 0, relativeEndX.value);
|
||||||
|
let minY = Math.min(0, relativeEndY.value, relativeEndY.value);
|
||||||
|
let maxY = Math.max(0, relativeEndY.value, relativeEndY.value);
|
||||||
|
|
||||||
|
minX -= props.linePadding;
|
||||||
|
maxX += props.linePadding;
|
||||||
|
minY -= props.linePadding;
|
||||||
|
maxY += props.linePadding;
|
||||||
|
|
||||||
|
svgWidth.value = maxX - minX;
|
||||||
|
svgHeight.value = maxY - minY;
|
||||||
|
|
||||||
|
svgLeftOffset.value = minX;
|
||||||
|
svgTopOffset.value = minY;
|
||||||
|
|
||||||
|
svgReady.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Resize Observer --- (保持不变)
|
||||||
|
let resizeObserver = null;
|
||||||
|
const setupResizeObserver = () => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
if (parentContainerRef.value) {
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
const newHeight = parentContainerRef.value.offsetHeight;
|
||||||
|
const newWidth = parentContainerRef.value.offsetWidth;
|
||||||
|
if (newHeight !== parentHeight.value || newWidth !== parentWidth.value) {
|
||||||
|
parentHeight.value = newHeight;
|
||||||
|
parentWidth.value = newWidth;
|
||||||
|
debouncedUpdate(updateSvgDimensions)();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(parentContainerRef.value);
|
||||||
|
} else {
|
||||||
|
console.error("TechButtonWithLine: Could not find parent element to observe.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Lifecycle Hooks --- (保持不变)
|
||||||
|
onMounted(() => {
|
||||||
|
if (techButtonContainer.value && techButtonContainer.value.parentElement) {
|
||||||
|
parentContainerRef.value = techButtonContainer.value.parentElement.parentElement;
|
||||||
|
setupResizeObserver();
|
||||||
|
} else {
|
||||||
|
console.error("TechButtonWithLine: Could not find techButtonContainer or its parent.");
|
||||||
|
}
|
||||||
|
updateSvgDimensions();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (resizeObserver) {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Watchers --- (保持不变)
|
||||||
|
const debouncedUpdate = (fn, delay = 10) => {
|
||||||
|
let timer = null;
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(fn, delay);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[
|
||||||
|
() => parentHeight.value,
|
||||||
|
() => parentWidth.value,
|
||||||
|
() => props.totalButtons,
|
||||||
|
() => props.buttonIndex,
|
||||||
|
() => props.buttonPosition,
|
||||||
|
() => props.endX,
|
||||||
|
() => props.endY,
|
||||||
|
() => props.buttonOffsetX,
|
||||||
|
() => props.linePadding,
|
||||||
|
() => props.buttonWidth,
|
||||||
|
() => props.buttonHeight,
|
||||||
|
// 监听用于样式变化的 props
|
||||||
|
() => props.buttonGradient,
|
||||||
|
() => props.buttonHoverGradient,
|
||||||
|
() => props.lineColor,
|
||||||
|
() => props.lineHoverColor,
|
||||||
|
() => props.lineStrokeWidth,
|
||||||
|
() => props.neonGlowIntensity,
|
||||||
|
() => props.scanLineSpeed,
|
||||||
|
() => props.animationDuration,
|
||||||
|
() => props.hoverScale,
|
||||||
|
() => props.flowLineColor,
|
||||||
|
() => props.flowLineAnimationSpeed,
|
||||||
|
],
|
||||||
|
debouncedUpdate(updateSvgDimensions),
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* --- Base Styles for the Button Container --- */
|
||||||
|
.tech-button-with-line {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: visible; /* 允许 SVG 滤镜和动画效果溢出 */
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 8px; /* 科技感圆角 */
|
||||||
|
/* 基础过渡效果,用于缩放和阴影 */
|
||||||
|
/* transition: transform v-bind('`${props.animationDuration}s`') ease-in-out, box-shadow v-bind('`${props.animationDuration}s`') ease-in-out, background v-bind('`${props.animationDuration}s`') ease-in-out; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-shape-clipper {
|
||||||
|
position: relative; /* 相对于 techButtonContainer,以填充其空间 */
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 0px; /* **clipper 的形状由 clip-path 定义** */
|
||||||
|
overflow: hidden; /* **clipper 内部的内容会被 clip-path 裁剪** */
|
||||||
|
transition: all v-bind('`${props.animationDuration}s`') ease-in-out; /* 过渡背景和阴影 */
|
||||||
|
z-index: 1; /* 确保 clipper 在流光之上 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-content-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none; /* 让鼠标事件穿透到按钮本身 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-text {
|
||||||
|
font-family: 'Orbitron', sans-serif; /* 科技感字体 */
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
/* color: v-bind('props.buttonBorderColor'); 文本颜色与边框颜色呼应 */
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- SVG Line Styles --- */
|
||||||
|
.line-svg {
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none; /* SVG 不捕获鼠标事件 */
|
||||||
|
overflow: visible; /* 允许滤镜和渐变效果溢出 SVG bounds */
|
||||||
|
transition: all v-bind('`${props.animationDuration}s`') ease-in-out;
|
||||||
|
z-index: 0; /* 放在按钮后面 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主线条的动画 */
|
||||||
|
.main-line {
|
||||||
|
animation: scanline v-bind('`${props.scanLineSpeed}s`') linear infinite;
|
||||||
|
/* 调整 stroke-dasharray 来控制扫描线的长度和间隔 */
|
||||||
|
stroke-dasharray: 15px, 80px; /* 15px 显示, 100px 隐藏 */
|
||||||
|
stroke-dashoffset: 0; /* 初始位置 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 鼠标悬停时,主线条颜色变亮,动画速度可能加快 */
|
||||||
|
.tech-button-with-line:hover .main-line {
|
||||||
|
stroke: v-bind('props.lineHoverColor'); /* 悬停时线条颜色 */
|
||||||
|
animation-duration: v-bind('`${props.scanLineSpeed / 1.5}s`'); /* 悬停时速度加快 */
|
||||||
|
stroke-dasharray: 20px, 120px; /* 悬停时线条显示部分变长 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 细微基线样式 */
|
||||||
|
.base-line {
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: all v-bind('`${props.animationDuration}s`') ease-in-out;
|
||||||
|
}
|
||||||
|
.tech-button-with-line:hover .base-line {
|
||||||
|
opacity: 0.9; /* 悬停时基线也变亮 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Flowing Light Border Effect --- */
|
||||||
|
/* 使用 ::before 伪元素实现四周的流光 */
|
||||||
|
.tech-button-with-line::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4px; /* 稍向外延伸 */
|
||||||
|
left: -4px;
|
||||||
|
right: -4px;
|
||||||
|
bottom: -4px;
|
||||||
|
/* 关键:创建动态的流光渐变 */
|
||||||
|
/* 渐变方向是从左到右,颜色从透明到高亮再到透明 */
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
v-bind('props.flowLineColor'),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
border-radius: 0px; /* **与按钮一致的平直四角** */
|
||||||
|
opacity: 0.8; /* 初始可见度 */
|
||||||
|
filter: blur(5px); /* 轻微模糊,模拟光晕 */
|
||||||
|
z-index: -1; /* 放在按钮内容后面 */
|
||||||
|
animation: flowLight v-bind('`${props.flowLineAnimationSpeed}s`') linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬停时,流光效果可以增强 */
|
||||||
|
.tech-button-with-line:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(8px); /* 悬停时模糊度增加 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Keyframes for Animations --- */
|
||||||
|
@keyframes scanline {
|
||||||
|
0% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: -1000; /* 确保足够长以扫过整个线段 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 流光动画 */
|
||||||
|
@keyframes flowLight {
|
||||||
|
0% { background-position: -100% 0; } /* 从左侧完全移出 */
|
||||||
|
50% { background-position: 100% 0; } /* 移动到右侧完全移出 */
|
||||||
|
100% { background-position: -100% 0; } /* 回到初始状态,循环 */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
874
src/views/device/robot/index.vue
Normal file
874
src/views/device/robot/index.vue
Normal 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" :initialTerminalId = "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: { terminalId: 'your-terminal-id', cameraId: 'your-camera-id' } // 示例props
|
||||||
|
};
|
||||||
|
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>
|
||||||
316
src/views/device/terminal/index.vue
Normal file
316
src/views/device/terminal/index.vue
Normal 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>
|
||||||
117
src/views/home/components/BarChart.vue
Normal file
117
src/views/home/components/BarChart.vue
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 给外层容器固定宽高基准 -->
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div ref="chartDom" class="chartDom"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
import { watch, defineProps, onMounted, ref, onBeforeUnmount } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '各测试类型分布'
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
type: Array,
|
||||||
|
default: () => ['已完成', '运行中', '暂停中', '异常']
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: Array,
|
||||||
|
default: () => ['功能测试', '稳定性测试', '兼容性测试', '性能测试', '回归测试']
|
||||||
|
},
|
||||||
|
series: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 提升到全局作用域,watch可访问
|
||||||
|
let myChart = null;
|
||||||
|
let option = null;
|
||||||
|
const chartDom =ref(null)
|
||||||
|
const initChart = () => {
|
||||||
|
if (!chartDom.value) return;
|
||||||
|
// 避免重复实例
|
||||||
|
if (myChart) myChart.dispose();
|
||||||
|
myChart = echarts.init(chartDom.value);
|
||||||
|
|
||||||
|
option = {
|
||||||
|
title: {
|
||||||
|
text: props.title
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis'
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: props.legend,
|
||||||
|
right: '8%'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '3%',
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
toolbox: {
|
||||||
|
feature: {
|
||||||
|
saveAsImage: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: props.xAxis
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value'
|
||||||
|
},
|
||||||
|
series: props.series
|
||||||
|
};
|
||||||
|
// 必须渲染配置
|
||||||
|
myChart.setOption(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听props变化,深度监听
|
||||||
|
watch(() => props, () => {
|
||||||
|
if (!myChart || !option) return;
|
||||||
|
// 更新数据
|
||||||
|
option.title.text = props.title;
|
||||||
|
option.legend.data = props.legend;
|
||||||
|
option.xAxis.data = props.xAxis;
|
||||||
|
option.series = props.series;
|
||||||
|
myChart.setOption(option);
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 窗口自适应
|
||||||
|
const resizeChart = () => {
|
||||||
|
myChart && myChart.resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initChart()
|
||||||
|
window.addEventListener('resize', resizeChart)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 销毁释放资源
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', resizeChart)
|
||||||
|
if (myChart) myChart.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
.chartDom {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
118
src/views/home/components/LineChart.vue
Normal file
118
src/views/home/components/LineChart.vue
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 给外层容器固定宽高基准 -->
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div ref="chartDom" class="chartDom"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import * as echarts from 'echarts';
|
||||||
|
import { watch, defineProps, onMounted, ref, onBeforeUnmount } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '近7日测试执行趋势'
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
type: Array,
|
||||||
|
default: () => ['执行任务数', '通过率']
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: Array,
|
||||||
|
default: () => ['06-10', '06-11', '06-12', '06-13', '06-14', '06-15', '06-16']
|
||||||
|
},
|
||||||
|
series: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 提升到全局作用域,watch可访问
|
||||||
|
let myChart = null;
|
||||||
|
let option = null;
|
||||||
|
const chartDom =ref(null)
|
||||||
|
const initChart = () => {
|
||||||
|
if (!chartDom.value) return;
|
||||||
|
// 避免重复实例
|
||||||
|
if (myChart) myChart.dispose();
|
||||||
|
myChart = echarts.init(chartDom.value);
|
||||||
|
|
||||||
|
option = {
|
||||||
|
title: {
|
||||||
|
text: props.title
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis'
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: props.legend,
|
||||||
|
right: '8%'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
left: '3%',
|
||||||
|
right: '4%',
|
||||||
|
bottom: '3%',
|
||||||
|
containLabel: true
|
||||||
|
},
|
||||||
|
toolbox: {
|
||||||
|
feature: {
|
||||||
|
saveAsImage: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
boundaryGap: false,
|
||||||
|
data: props.xAxis
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value'
|
||||||
|
},
|
||||||
|
series: props.series
|
||||||
|
};
|
||||||
|
// 必须渲染配置
|
||||||
|
myChart.setOption(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听props变化,深度监听
|
||||||
|
watch(() => props, () => {
|
||||||
|
if (!myChart || !option) return;
|
||||||
|
// 更新数据
|
||||||
|
option.title.text = props.title;
|
||||||
|
option.legend.data = props.legend;
|
||||||
|
option.xAxis.data = props.xAxis;
|
||||||
|
option.series = props.series;
|
||||||
|
myChart.setOption(option);
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// 窗口自适应
|
||||||
|
const resizeChart = () => {
|
||||||
|
myChart && myChart.resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initChart()
|
||||||
|
window.addEventListener('resize', resizeChart)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 销毁释放资源
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', resizeChart)
|
||||||
|
if (myChart) myChart.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
.chartDom {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
307
src/views/home/index.vue
Normal file
307
src/views/home/index.vue
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home_page">
|
||||||
|
<div class="car_container">
|
||||||
|
<div class="car_item">
|
||||||
|
<div>图标</div>
|
||||||
|
<div>
|
||||||
|
<div class="title">测试任务总数</div>
|
||||||
|
<div class="number">1248</div>
|
||||||
|
<div class="compare">较昨日 +128 ↑</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="car_item">
|
||||||
|
<div>图标</div>
|
||||||
|
<div>
|
||||||
|
<div class="title">今日执行任务</div>
|
||||||
|
<div class="number">86</div>
|
||||||
|
<div class="compare">较昨日 +16 ↑</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="car_item">
|
||||||
|
<div>图标</div>
|
||||||
|
<div>
|
||||||
|
<div class="title">在线设备数</div>
|
||||||
|
<div class="number">128</div>
|
||||||
|
<div class="compare">较昨日 +8 ↑</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="car_item">
|
||||||
|
<div>图标</div>
|
||||||
|
<div>
|
||||||
|
<div class="title">异常告警数</div>
|
||||||
|
<div class="number">23</div>
|
||||||
|
<div class="compare">较昨日 -5 ↓</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="car_item">
|
||||||
|
<div>图标</div>
|
||||||
|
<div>
|
||||||
|
<div class="title">测试通过率</div>
|
||||||
|
<div class="number">96.37%</div>
|
||||||
|
<div class="compare">较昨日 +1.28% ↑</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="car_item">
|
||||||
|
<div>图标</div>
|
||||||
|
<div>
|
||||||
|
<div class="title">本周执行时长</div>
|
||||||
|
<div class="number">268.5h</div>
|
||||||
|
<div class="compare">较昨日 +23.6h ↑</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart_container">
|
||||||
|
<div class="lineChart-box">
|
||||||
|
<LineChart :series="lineSeries" />
|
||||||
|
</div>
|
||||||
|
<div class="barChart-box"><BarChart :series="barSeries" /></div>
|
||||||
|
<div class="info-box">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="item-title">待处理事项</div>
|
||||||
|
<div class="item-context">
|
||||||
|
<div>待审核用例</div>
|
||||||
|
<div>待处理异常</div>
|
||||||
|
<div>待分配任务</div>
|
||||||
|
<div>待生成报告</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="item-title">告警信息</div>
|
||||||
|
<div class="item-context">
|
||||||
|
<div class="item" v-for="alarm in alarmList">
|
||||||
|
<div class="status-box">
|
||||||
|
<div class="status" :class="alarm.status">{{ statusList[alarm.status] }}</div>
|
||||||
|
<div class="context">{{ alarm.context }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="time">{{ alarm.time }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table_container">
|
||||||
|
<div>最近测试任务</div>
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="tableList"
|
||||||
|
row-key="id"
|
||||||
|
>
|
||||||
|
<el-table-column prop="deptName" label="任务名称"></el-table-column>
|
||||||
|
<el-table-column prop="orderNum" label="执行对象"></el-table-column>
|
||||||
|
<el-table-column prop="orderNum" label="测试类型"></el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<dict-tag :options="sys_normal_disable" :value="scope.row.status" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="orderNum" label="合格率"></el-table-column>
|
||||||
|
<el-table-column label="创建时间" align="center" prop="createTime" width="200">
|
||||||
|
<template #default="scope">
|
||||||
|
<span>{{ parseTime(scope.row.createTime) }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
||||||
|
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import LineChart from './components/LineChart.vue';
|
||||||
|
import BarChart from './components/BarChart.vue';
|
||||||
|
|
||||||
|
const lineSeries = ref([
|
||||||
|
{
|
||||||
|
name: '执行任务数',
|
||||||
|
type: 'line',
|
||||||
|
data: [120, 122, 101, 114, 90, 130, 110]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '通过率',
|
||||||
|
type: 'line',
|
||||||
|
data: [96.5, 97.3, 92.8, 95.8, 98.7, 96.8, 97.9]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const barSeries = ref([
|
||||||
|
{
|
||||||
|
name: '已完成',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'Ad',
|
||||||
|
emphasis: {
|
||||||
|
focus: 'series'
|
||||||
|
},
|
||||||
|
data: [30, 32, 30, 34, 39, 30, 30]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '运行中',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'Ad',
|
||||||
|
emphasis: {
|
||||||
|
focus: 'series'
|
||||||
|
},
|
||||||
|
data: [27, 32, 30, 34, 39, 30, 30]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '暂停中',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'Ad',
|
||||||
|
emphasis: {
|
||||||
|
focus: 'series'
|
||||||
|
},
|
||||||
|
data: [10, 32, 30, 34, 39, 30, 30]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '异常',
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'Ad',
|
||||||
|
emphasis: {
|
||||||
|
focus: 'series'
|
||||||
|
},
|
||||||
|
data: [5, 32, 30, 34, 39, 30, 30]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const statusList = {
|
||||||
|
'urgent': '紧急',
|
||||||
|
'important': '重要',
|
||||||
|
'ordinary': '一般'
|
||||||
|
}
|
||||||
|
|
||||||
|
const alarmList = ref([
|
||||||
|
{
|
||||||
|
status: 'urgent',
|
||||||
|
context: '手机设备AIMA-TEST-015离线',
|
||||||
|
time: '10:32'
|
||||||
|
}, {
|
||||||
|
status: 'important',
|
||||||
|
context: '测试任务AIMA-00123执行异常',
|
||||||
|
time: '10:15'
|
||||||
|
}, {
|
||||||
|
status: 'ordinary',
|
||||||
|
context: '存储空间使用率超过80%',
|
||||||
|
time: '10:15'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const tableList = ref([])
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.home_page {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.car_container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
.car_item {
|
||||||
|
flex: 1;
|
||||||
|
box-shadow: 0 0 0 1px #dddddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 100px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart_container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 20px;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
.lineChart-box {
|
||||||
|
flex: 1;
|
||||||
|
height: 300px;
|
||||||
|
box-shadow: 0 0 0 1px #dddddd;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barChart-box {
|
||||||
|
flex: 1;
|
||||||
|
height: 300px;
|
||||||
|
box-shadow: 0 0 0 1px #dddddd;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-box {
|
||||||
|
flex: 1;
|
||||||
|
height: 300px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
box-shadow: 0 0 0 1px #dddddd;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-context {
|
||||||
|
padding: 0 20px;
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.status-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.urgent {
|
||||||
|
background: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.important {
|
||||||
|
background: yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ordinary {
|
||||||
|
background: blue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.table_container {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,40 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container home">
|
<div class="app-container home">
|
||||||
<div class="title">
|
<Home />
|
||||||
<p>招商车研AI测评机器人</p>
|
|
||||||
<p>模拟测试平台</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup name="Index">
|
<script setup name="Index">
|
||||||
|
import Home from '@/views/home/index'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
font-weight: bold;
|
|
||||||
gap: 5px;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 60px;
|
|
||||||
text-align: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.title::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
background-image: url("../assets/images/index-background.jpg");
|
|
||||||
height: 100%;
|
|
||||||
background-size: cover;
|
|
||||||
opacity: 0.3; /* 只在这层做透明 */
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
304
src/views/intelligenceTest/car/index.vue
Normal file
304
src/views/intelligenceTest/car/index.vue
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<div ref="topContainerRef">
|
||||||
|
<TableSearch
|
||||||
|
:queryParams="queryParams"
|
||||||
|
:showSearch="showSearch"
|
||||||
|
label-width="90px"
|
||||||
|
queryRef="queryRef"
|
||||||
|
@refresh="resetQuery"
|
||||||
|
@search="handleQuery"
|
||||||
|
>
|
||||||
|
<template #one>
|
||||||
|
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||||
|
<el-form-item label="车辆名称" prop="vehicleName">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.vehicleName"
|
||||||
|
placeholder="请输入车辆名称"
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||||
|
<el-form-item label="车型" prop="vehicleModel">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.vehicleModel"
|
||||||
|
placeholder="请输入车型"
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</template>
|
||||||
|
</TableSearch>
|
||||||
|
|
||||||
|
<el-row :gutter="10" class="mb8">
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
icon="Plus"
|
||||||
|
@click="handleAdd"
|
||||||
|
>新增</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
icon="Edit"
|
||||||
|
:disabled="single"
|
||||||
|
@click="handleUpdate"
|
||||||
|
>修改</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
icon="Delete"
|
||||||
|
:disabled="multiple"
|
||||||
|
@click="handleDelete"
|
||||||
|
>删除</el-button>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :style="containerHeight">
|
||||||
|
<!-- 表格数据 -->
|
||||||
|
<el-table height="100%" v-loading="loading" :data="tableDataList" @selection-change="handleSelectionChange">
|
||||||
|
<el-table-column type="selection" width="55" align="center" />
|
||||||
|
<el-table-column label="车辆名称" prop="vehicleName" />
|
||||||
|
<el-table-column label="车型" prop="vehicleModel" />
|
||||||
|
<el-table-column label="车架号" prop="vin" />
|
||||||
|
<el-table-column label="颜色" prop="color"/>
|
||||||
|
<el-table-column label="状态" align="center" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-switch
|
||||||
|
v-model="scope.row.status"
|
||||||
|
active-value="0"
|
||||||
|
inactive-value="1"
|
||||||
|
@change="handleStatusChange(scope.row)"
|
||||||
|
></el-switch>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" width="180"/>
|
||||||
|
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tooltip content="修改" placement="top">
|
||||||
|
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip content="删除" placement="top">
|
||||||
|
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-show="total > 0"
|
||||||
|
:total="total"
|
||||||
|
v-model:page="queryParams.pageNum"
|
||||||
|
v-model:limit="queryParams.pageSize"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加或修改配置对话框 -->
|
||||||
|
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
|
||||||
|
<el-form ref="carRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="车辆名称" prop="vehicleName">
|
||||||
|
<el-input v-model="form.vehicleName" placeholder="请输入车辆名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="车型" prop="vehicleModel">
|
||||||
|
<el-input v-model="form.vehicleModel" placeholder="请输入车型" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="车架号" prop="vin">
|
||||||
|
<el-input v-model="form.vin" placeholder="请输入车架号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="颜色" prop="vin">
|
||||||
|
<el-input v-model="form.color" placeholder="请输入颜色" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="生产日期" prop="productionDate">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.productionDate"
|
||||||
|
type="date"
|
||||||
|
style="width: 100%;"
|
||||||
|
placeholder="Pick a day"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||||
|
<el-button @click="cancel">取 消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup name="Role">
|
||||||
|
import TableSearch from "@/components/TableSearch/index.vue";
|
||||||
|
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||||
|
import { addVehicle, getVehicleList, updateVehicle, deleteVehicle } from "@/api/intelligenceTest/car.js"
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const topContainerRef = ref();
|
||||||
|
const containerHeight = useContainerHeight(topContainerRef);
|
||||||
|
|
||||||
|
const tableDataList = ref([]);
|
||||||
|
const open = ref(false);
|
||||||
|
const loading = ref(true);
|
||||||
|
const showSearch = ref(true);
|
||||||
|
const ids = ref([]);
|
||||||
|
const single = ref(true);
|
||||||
|
const multiple = ref(true);
|
||||||
|
const total = ref(0);
|
||||||
|
const title = ref("");
|
||||||
|
|
||||||
|
|
||||||
|
const data = reactive({
|
||||||
|
form: {},
|
||||||
|
queryParams: {
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
vehicleName: undefined,
|
||||||
|
vehicleModel: undefined
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
vehicleName: [{ required: true, message: "车辆名称不能为空", trigger: "blur" }],
|
||||||
|
vehicleModel: [{ required: true, message: "车型不能为空", trigger: "blur" }],
|
||||||
|
vin: [{ required: true, message: "车架号不能为空", trigger: "blur" }],
|
||||||
|
color: [{ required: true, message: "颜色不能为空", trigger: "blur" }],
|
||||||
|
productionDate: [{ required: true, message: "生产日期不能为空", trigger: "blur" }]
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { queryParams, form, rules } = toRefs(data);
|
||||||
|
|
||||||
|
/** 查询列表 */
|
||||||
|
const getList = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
const res = await getVehicleList(queryParams.value)
|
||||||
|
if (res.code === 200) {
|
||||||
|
tableDataList.value = res.rows;
|
||||||
|
total.value = res.total;
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索按钮操作 */
|
||||||
|
function handleQuery() {
|
||||||
|
queryParams.value.pageNum = 1;
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置按钮操作 */
|
||||||
|
function resetQuery() {
|
||||||
|
handleQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除按钮操作 */
|
||||||
|
function handleDelete(row) {
|
||||||
|
const deleteIds = row.id || ids.value;
|
||||||
|
ElMessageBox.confirm(`确定要删除编号为${deleteIds}的车辆吗?`, "提示", {
|
||||||
|
confirmButtonText: "确定",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
type: "warning",
|
||||||
|
}).then(() => {
|
||||||
|
deleteVehicle(deleteIds).then(() => {
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
getList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 多选框选中数据 */
|
||||||
|
function handleSelectionChange(selection) {
|
||||||
|
ids.value = selection.map(item => item.id);
|
||||||
|
single.value = selection.length != 1;
|
||||||
|
multiple.value = !selection.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态修改 */
|
||||||
|
function handleStatusChange(row) {
|
||||||
|
let text = row.status === "0" ? "启用" : "停用";
|
||||||
|
ElMessageBox.confirm('确认要"' + text + '""' + row.vehicleName + '"车辆吗?', "提示", {
|
||||||
|
confirmButtonText: "确定",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
type: "warning",
|
||||||
|
}).then(() => {
|
||||||
|
updateVehicle({
|
||||||
|
...row,
|
||||||
|
}).then(() => {
|
||||||
|
ElMessage.success(text + "成功");
|
||||||
|
getList();
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
row.status = row.status === "0" ? "1" : "0";
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
carRef.value.resetFields()
|
||||||
|
};
|
||||||
|
|
||||||
|
const carRef = ref()
|
||||||
|
const isNew = ref(true)
|
||||||
|
|
||||||
|
/** 添加 */
|
||||||
|
function handleAdd() {
|
||||||
|
open.value = true;
|
||||||
|
isNew.value = true
|
||||||
|
title.value = "添加";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改 */
|
||||||
|
function handleUpdate(row) {
|
||||||
|
form.value = { ...row };
|
||||||
|
open.value = true;
|
||||||
|
isNew.value = false
|
||||||
|
title.value = "修改";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交按钮 */
|
||||||
|
function submitForm() {
|
||||||
|
carRef.value.validate((valid) => {
|
||||||
|
if (valid) {
|
||||||
|
if (isNew.value) {
|
||||||
|
addVehicle(form.value).then(() => {
|
||||||
|
ElMessage.success("添加成功");
|
||||||
|
getList();
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateVehicle(form.value).then(() => {
|
||||||
|
ElMessage.success("修改成功");
|
||||||
|
getList();
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消按钮 */
|
||||||
|
function cancel() {
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
getList();
|
||||||
|
</script>
|
||||||
321
src/views/intelligenceTest/mobilePhone/index.vue
Normal file
321
src/views/intelligenceTest/mobilePhone/index.vue
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<div ref="topContainerRef">
|
||||||
|
<TableSearch
|
||||||
|
:queryParams="queryParams"
|
||||||
|
:showSearch="showSearch"
|
||||||
|
label-width="90px"
|
||||||
|
queryRef="queryRef"
|
||||||
|
@refresh="resetQuery"
|
||||||
|
@search="handleQuery"
|
||||||
|
>
|
||||||
|
<template #one>
|
||||||
|
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||||
|
<el-form-item label="手机名称" prop="phoneName">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.phoneName"
|
||||||
|
placeholder="请输入手机名称"
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||||
|
<el-form-item label="手机型号" prop="phoneModel">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.phoneModel"
|
||||||
|
placeholder="请输入手机型号"
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||||
|
<el-form-item label="厂商" prop="manufacturer">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.manufacturer"
|
||||||
|
placeholder="请输入厂商"
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</template>
|
||||||
|
</TableSearch>
|
||||||
|
|
||||||
|
<el-row :gutter="10" class="mb8">
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
icon="Plus"
|
||||||
|
@click="handleAdd"
|
||||||
|
>新增</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
icon="Edit"
|
||||||
|
:disabled="single"
|
||||||
|
@click="handleUpdate"
|
||||||
|
>修改</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
icon="Delete"
|
||||||
|
:disabled="multiple"
|
||||||
|
@click="handleDelete"
|
||||||
|
>删除</el-button>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :style="containerHeight">
|
||||||
|
<!-- 表格数据 -->
|
||||||
|
<el-table height="100%" v-loading="loading" :data="tableDataList" @selection-change="handleSelectionChange">
|
||||||
|
<el-table-column type="selection" width="55" align="center" />
|
||||||
|
<el-table-column label="手机名称" prop="phoneName" :show-overflow-tooltip="true" width="180"/>
|
||||||
|
<el-table-column label="手机型号" prop="phoneModel" />
|
||||||
|
<el-table-column label="系统版本" prop="osVersion" />
|
||||||
|
<el-table-column label="操作系统" prop="osSystem" width="100" />
|
||||||
|
<el-table-column label="厂商" align="center" prop="manufacturer" />
|
||||||
|
<el-table-column label="额外参数" align="center" prop="specifications" :show-overflow-tooltip="true" width="180"/>
|
||||||
|
<el-table-column label="状态" align="center" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-switch
|
||||||
|
v-model="scope.row.status"
|
||||||
|
active-value="0"
|
||||||
|
inactive-value="1"
|
||||||
|
@change="handleStatusChange(scope.row)"
|
||||||
|
></el-switch>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" width="180"/>
|
||||||
|
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tooltip content="修改" placement="top">
|
||||||
|
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip content="删除" placement="top">
|
||||||
|
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-show="total > 0"
|
||||||
|
:total="total"
|
||||||
|
v-model:page="queryParams.pageNum"
|
||||||
|
v-model:limit="queryParams.pageSize"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加或修改配置对话框 -->
|
||||||
|
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
|
||||||
|
<el-form ref="phoneRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="手机名称" prop="phoneName">
|
||||||
|
<el-input v-model="form.phoneName" placeholder="请输入手机名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="手机型号" prop="phoneModel">
|
||||||
|
<el-input v-model="form.phoneModel" placeholder="请输入手机型号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="操作系统" prop="osSystem">
|
||||||
|
<el-select v-model="form.osSystem">
|
||||||
|
<el-option label="Android" value="Android" />
|
||||||
|
<el-option label="iOS" value="iOS" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="系统版本" prop="osVersion">
|
||||||
|
<el-input v-model="form.osVersion" placeholder="请输入系统版本" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="厂商" prop="manufacturer">
|
||||||
|
<el-input v-model="form.manufacturer" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="规格参数" prop="specifications">
|
||||||
|
<el-input v-model="form.specifications" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="form.remark" type="textarea" placeholder="请输入内容"></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||||
|
<el-button @click="cancel">取 消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup name="Role">
|
||||||
|
import TableSearch from "@/components/TableSearch/index.vue";
|
||||||
|
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||||
|
import { addPhone, getPhoneList, updatePhone, deletePhone } from "@/api/intelligenceTest/mobilePhone.js"
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
const topContainerRef = ref();
|
||||||
|
const containerHeight = useContainerHeight(topContainerRef);
|
||||||
|
|
||||||
|
const tableDataList = ref([]);
|
||||||
|
const open = ref(false);
|
||||||
|
const loading = ref(true);
|
||||||
|
const showSearch = ref(true);
|
||||||
|
const ids = ref([]);
|
||||||
|
const single = ref(true);
|
||||||
|
const multiple = ref(true);
|
||||||
|
const total = ref(0);
|
||||||
|
const title = ref("");
|
||||||
|
|
||||||
|
|
||||||
|
const data = reactive({
|
||||||
|
form: {},
|
||||||
|
queryParams: {
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
phoneName: undefined,
|
||||||
|
phoneModel: undefined,
|
||||||
|
manufacturer: undefined
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
phoneName: [{ required: true, message: "手机名称不能为空", trigger: "blur" }],
|
||||||
|
phoneModel: [{ required: true, message: "手机型号不能为空", trigger: "blur" }],
|
||||||
|
manufacturer: [{ required: true, message: "厂商不能为空", trigger: "blur" }],
|
||||||
|
osSystem: [{ required: true, message: "操作系统不能为空", trigger: "blur" }],
|
||||||
|
osVersion: [{ required: true, message: "系统版本不能为空", trigger: "blur" }]
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { queryParams, form, rules } = toRefs(data);
|
||||||
|
|
||||||
|
/** 查询列表 */
|
||||||
|
const getList = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
const res = await getPhoneList(queryParams.value)
|
||||||
|
if (res.code === 200) {
|
||||||
|
tableDataList.value = res.rows;
|
||||||
|
total.value = res.total;
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索按钮操作 */
|
||||||
|
function handleQuery() {
|
||||||
|
queryParams.value.pageNum = 1;
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置按钮操作 */
|
||||||
|
function resetQuery() {
|
||||||
|
handleQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除按钮操作 */
|
||||||
|
function handleDelete(row) {
|
||||||
|
const deleteIds = row.id || ids.value;
|
||||||
|
ElMessageBox.confirm(`确定要删除编号为${deleteIds}的手机吗?`, "提示", {
|
||||||
|
confirmButtonText: "确定",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
type: "warning",
|
||||||
|
}).then(() => {
|
||||||
|
deletePhone(deleteIds).then(() => {
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
getList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 多选框选中数据 */
|
||||||
|
function handleSelectionChange(selection) {
|
||||||
|
ids.value = selection.map(item => item.id);
|
||||||
|
single.value = selection.length != 1;
|
||||||
|
multiple.value = !selection.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态修改 */
|
||||||
|
function handleStatusChange(row) {
|
||||||
|
let text = row.status === "0" ? "启用" : "停用";
|
||||||
|
ElMessageBox.confirm('确认要"' + text + '""' + row.phoneName + '"手机吗?', "提示", {
|
||||||
|
confirmButtonText: "确定",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
type: "warning",
|
||||||
|
}).then(() => {
|
||||||
|
updatePhone({
|
||||||
|
...row,
|
||||||
|
}).then(() => {
|
||||||
|
ElMessage.success(text + "成功");
|
||||||
|
getList();
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
row.status = row.status === "0" ? "1" : "0";
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
phoneRef.value.resetFields()
|
||||||
|
};
|
||||||
|
|
||||||
|
const phoneRef = ref()
|
||||||
|
const isNew = ref(true)
|
||||||
|
|
||||||
|
/** 添加 */
|
||||||
|
function handleAdd() {
|
||||||
|
open.value = true;
|
||||||
|
isNew.value = true
|
||||||
|
title.value = "添加";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改 */
|
||||||
|
function handleUpdate(row) {
|
||||||
|
form.value = { ...row };
|
||||||
|
open.value = true;
|
||||||
|
isNew.value = false
|
||||||
|
title.value = "修改";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** 提交按钮 */
|
||||||
|
function submitForm() {
|
||||||
|
phoneRef.value.validate((valid) => {
|
||||||
|
if (valid) {
|
||||||
|
if (isNew.value) {
|
||||||
|
addPhone(form.value).then(() => {
|
||||||
|
ElMessage.success("添加成功");
|
||||||
|
getList();
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
deleteVehicle(form.value).then(() => {
|
||||||
|
ElMessage.success("修改成功");
|
||||||
|
getList();
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消按钮 */
|
||||||
|
function cancel() {
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
getList();
|
||||||
|
</script>
|
||||||
647
src/views/intelligenceTest/runningTask/index.vue
Normal file
647
src/views/intelligenceTest/runningTask/index.vue
Normal file
@ -0,0 +1,647 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<div class="left">
|
||||||
|
<div class="header">
|
||||||
|
<div>任务队列</div>
|
||||||
|
<el-button type="primary" @click="addTask">添加任务</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="taskList-container">
|
||||||
|
<div class="task-item" v-for="task in runTaskList">
|
||||||
|
<div class="task-content">
|
||||||
|
<div class="task-title">{{ task.taskName }}</div>
|
||||||
|
<div class="task-actions">
|
||||||
|
<SvgIcon class="icon" v-if="task.status == '1'" name="pause" :size="24" color="#FFB300"
|
||||||
|
@click="pauseTask(task)" />
|
||||||
|
<SvgIcon class="icon" v-if="task.status == '3' || task.status == '5'" name="start"
|
||||||
|
:size="24" color="#00FF88" @click="startTask(task)" />
|
||||||
|
<SvgIcon class="icon" v-if="task.status != '5'" name="stop" :size="24" color="#d81e06"
|
||||||
|
@click="stopTask(task)" />
|
||||||
|
<SvgIcon class="icon" v-if="task.status == '5'" name="remove" :size="24" color="#FFB300"
|
||||||
|
@click="removeTask(task)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="robot">手机:{{ task.phoneName }}</div>
|
||||||
|
<div class="robot">车辆:{{ task.vehicleName }}</div>
|
||||||
|
<el-progress :color="customColorMethod"
|
||||||
|
:percentage="parseFloat(taskRunningInfo[task.id]?.progress?.toFixed(2)) || 0" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right" ref="topContainerRef">
|
||||||
|
<div class="header">
|
||||||
|
<div class="label" :class="{ active: activePanel === 'current' }" @click="activePanel = 'current'">当前任务
|
||||||
|
</div>
|
||||||
|
<div class="label" :class="{ active: activePanel === 'history' }" @click="activePanel = 'history'">历史记录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="context-container">
|
||||||
|
<div v-if="activePanel === 'current'">
|
||||||
|
<!-- 当前任务内容 -->
|
||||||
|
<div class="task" v-for="runTask in runingTaskList">
|
||||||
|
<div class="task-header">
|
||||||
|
<div class="header-container">
|
||||||
|
<div class="task-info">
|
||||||
|
<div class="title">{{ runTask.taskName }}</div>
|
||||||
|
<div class="id">ID: {{ runTask.taskId }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="robot">
|
||||||
|
<div>手机:{{ runTask.phoneName }}</div>
|
||||||
|
<div>车辆:{{ runTask.vehicleName }}</div>
|
||||||
|
<div>开始时间: {{ formatDate(runTask.startTime) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<el-tag :type="runTask.status === '3' ? 'primary' : 'warning'">
|
||||||
|
{{ taskStatusMap[runTask.status] || '未知状态' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-label">执行进度</div>
|
||||||
|
<el-progress :color="customColorMethod"
|
||||||
|
:percentage="parseFloat(taskRunningInfo[runTask.id]?.progress?.toFixed(2)) || 0" />
|
||||||
|
<taskSteps :list="pointList_runningTask[runTask.taskId]"
|
||||||
|
:activeStep="taskRunningInfo[runTask.id]?.activeStep || 0" />
|
||||||
|
<div class="run-log-container">
|
||||||
|
<div>执行日志</div>
|
||||||
|
<taskLog :list="taskLogList[runTask.id]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="activePanel === 'history'">
|
||||||
|
<div :style="containerHeight">
|
||||||
|
<el-table v-loading="loading" height="100%" :data="tableDataList">
|
||||||
|
<el-table-column align="center" label="任务名称" prop="taskName" show-overflow-tooltip />
|
||||||
|
<el-table-column align="center" label="任务状态" prop="status">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'">
|
||||||
|
{{ taskStatusMap[scope.row.status] || '未知状态' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column align="center" label="任务类型" prop="taskType">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.taskType === '1' ? 'primary' : 'success'">
|
||||||
|
{{ scope.row.taskType === '1' ? '巡检任务' : '讲解任务' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column align="center" label="机器人" prop="robotName" show-overflow-tooltip />
|
||||||
|
<el-table-column align="center" label="开始时间" prop="startTime">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ formatDate(scope.row.startTime) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column align="center" label="结束时间" prop="endTime">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ formatDate(scope.row.endTime) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column align="center" class-name="small-padding fixed-width" label="操作"
|
||||||
|
width="260">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button icon="Edit" link type="primary" @click="handleOpenLog(scope.row)">
|
||||||
|
日志
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination v-show="total > 0" v-model:limit="queryParams.pageSize"
|
||||||
|
v-model:page="queryParams.pageNum" :total="total" @pagination="getHistoryList" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 添加任务弹窗 -->
|
||||||
|
<el-dialog title="添加任务" v-model="open" width="500px" append-to-body>
|
||||||
|
<el-form ref="runTaskRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="选择任务" prop="taskId">
|
||||||
|
<el-select v-model="form.taskId" placeholder="请选择任务" clearable style="width: 100%">
|
||||||
|
<el-option v-for="task in taskList" :key="task.id" :label="task.taskName" :value="task.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="选择手机" prop="phoneId">
|
||||||
|
<el-select v-model="form.phoneId" placeholder="请选择手机" clearable style="width: 100%">
|
||||||
|
<el-option v-for="phone in phoneList" :key="phone.id" :label="phone.phoneName"
|
||||||
|
:value="phone.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="选择车辆" prop="vehicleId">
|
||||||
|
<el-select v-model="form.vehicleId" placeholder="请选择车辆" clearable style="width: 100%">
|
||||||
|
<el-option v-for="vehicle in carList" :key="vehicle.id" :label="vehicle.vehicleName"
|
||||||
|
:value="vehicle.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input type="textarea" v-model="form.remark" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button type="primary" @click="confirmTaskDialog">确 定</el-button>
|
||||||
|
<el-button @click="cancelTaskDialog">取 消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog title="任务执行日志" v-model="logDialogVisible" width="500px" append-to-body>
|
||||||
|
<taskLog :list="logDialogData" :height="300" />
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import SvgIcon from "@/components/SvgIcon";
|
||||||
|
import taskSteps from "./task-steps.vue";
|
||||||
|
import taskLog from "./task-log.vue";
|
||||||
|
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||||
|
import { getTaskList, getTestCaseByTaskId } from "@/api/intelligenceTest/taskManage";
|
||||||
|
import { getPhoneList } from "@/api/intelligenceTest/mobilePhone";
|
||||||
|
import { getVehicleList } from "@/api/intelligenceTest/car";
|
||||||
|
import { onMounted, onUnmounted } from "vue";
|
||||||
|
import {
|
||||||
|
addTaskinstance,
|
||||||
|
getTaskinstanceList,
|
||||||
|
startTaskinstance,
|
||||||
|
pauseTaskinstance,
|
||||||
|
stopTaskinstance,
|
||||||
|
resumeTaskinstance,
|
||||||
|
deleteTaskinstance,
|
||||||
|
getTaskLogListApi
|
||||||
|
} from "@/api/intelligenceTest/runningTask";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import { formatDate } from "@/utils/index";
|
||||||
|
import { debounce } from 'lodash'; // 引入 lodash 的防抖函数
|
||||||
|
|
||||||
|
const topContainerRef = ref();
|
||||||
|
const containerHeight = useContainerHeight(topContainerRef);
|
||||||
|
const socket = inject('ws');
|
||||||
|
|
||||||
|
const taskStatusMap = {
|
||||||
|
'0': '已完成',
|
||||||
|
'1': '执行中',
|
||||||
|
'2': '失败',
|
||||||
|
'3': '已暂停',
|
||||||
|
'4': '已终止',
|
||||||
|
'5': '未执行',
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
taskId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules = reactive({
|
||||||
|
taskId: [{ required: true, message: '请选择任务', trigger: 'change' }],
|
||||||
|
phoneId: [{ required: true, message: '请选择手机', trigger: 'change' }],
|
||||||
|
vehicleId: [{ required: true, message: '请选择车辆', trigger: 'change' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskList = ref([]);
|
||||||
|
const phoneList = ref([]);
|
||||||
|
|
||||||
|
const runTaskList = ref([]);
|
||||||
|
|
||||||
|
const getTaskData = () => {
|
||||||
|
getTaskList({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10000,
|
||||||
|
}).then((res) => {
|
||||||
|
taskList.value = res.rows;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPhoneData = (mapId = null) => {
|
||||||
|
getPhoneList({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10000
|
||||||
|
}).then((res) => {
|
||||||
|
phoneList.value = res.rows;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const carList = ref([])
|
||||||
|
|
||||||
|
const getCarData = (mapId = null) => {
|
||||||
|
getVehicleList({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10000
|
||||||
|
}).then((res) => {
|
||||||
|
carList.value = res.rows;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
|
||||||
|
const addTask = () => {
|
||||||
|
open.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runTaskRef = ref();
|
||||||
|
/**
|
||||||
|
* 确定添加执行任务
|
||||||
|
*/
|
||||||
|
const confirmTaskDialog = () => {
|
||||||
|
runTaskRef.value.validate((valid) => {
|
||||||
|
if (valid) {
|
||||||
|
addTaskinstance(form).then(() => {
|
||||||
|
ElMessage.success("任务添加成功");
|
||||||
|
getList();
|
||||||
|
open.value = false;
|
||||||
|
runTaskRef.value.resetFields();
|
||||||
|
}).catch(() => {
|
||||||
|
ElMessage.error("任务添加失败");
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelTaskDialog = () => {
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const runingTaskList = ref([])
|
||||||
|
/**
|
||||||
|
* 获取任务队列
|
||||||
|
*/
|
||||||
|
const getList = () => {
|
||||||
|
getTaskinstanceList({
|
||||||
|
statusList: ['1', '3', '5'],
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code === 200) {
|
||||||
|
runTaskList.value = res.rows;
|
||||||
|
runingTaskList.value = res.rows.filter(item => item.status !== '5')
|
||||||
|
for (let i = 0; i < runTaskList.value.length; i++) {
|
||||||
|
getCaseByTaskId(runTaskList.value[i].taskId)
|
||||||
|
getTaskLogList(runTaskList.value[i].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointList_runningTask = ref({})
|
||||||
|
/**
|
||||||
|
* 获取正在执行任务的用例列表
|
||||||
|
* @param taskId
|
||||||
|
*/
|
||||||
|
const getCaseByTaskId = async (taskId) => {
|
||||||
|
const res = await getTestCaseByTaskId(taskId)
|
||||||
|
if (res.code === 200) {
|
||||||
|
pointList_runningTask.value[taskId] = res.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskLogList = ref({})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取正在执行任务的执行日志
|
||||||
|
* @param taskInstanceId
|
||||||
|
*/
|
||||||
|
const getTaskLogList = async (taskInstanceId) => {
|
||||||
|
const res = await getTaskLogListApi({ taskInstanceId })
|
||||||
|
if (res.code === 200) {
|
||||||
|
taskLogList.value[taskInstanceId] = res.rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义进度条值不同区间所显示的颜色
|
||||||
|
* @param percentage
|
||||||
|
*/
|
||||||
|
const customColorMethod = (percentage) => {
|
||||||
|
if (percentage < 30) {
|
||||||
|
return '#6f7ad3'
|
||||||
|
}
|
||||||
|
if (percentage < 70) {
|
||||||
|
return '#1989fa'
|
||||||
|
}
|
||||||
|
return '#67c23a'
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePanel = ref('current');
|
||||||
|
|
||||||
|
const tableDataList = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const total = ref(0);
|
||||||
|
|
||||||
|
const queryParams = reactive({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始执行任务
|
||||||
|
* @param task
|
||||||
|
*/
|
||||||
|
const startTask = async (task) => {
|
||||||
|
if (task.status === '3') {
|
||||||
|
let res = await resumeTaskinstance(task.id);
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success("任务已恢复");
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let res = await startTaskinstance(task.id);
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success("任务已开始");
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 暂停执行任务
|
||||||
|
* @param task
|
||||||
|
*/
|
||||||
|
const pauseTask = async (task) => {
|
||||||
|
let res = await pauseTaskinstance(task.id);
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success("任务已暂停");
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止执行任务
|
||||||
|
* @param task
|
||||||
|
*/
|
||||||
|
const stopTask = async (task) => {
|
||||||
|
let res = await stopTaskinstance(task.id);
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success("任务已停止");
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除执行任务
|
||||||
|
* @param task
|
||||||
|
*/
|
||||||
|
const removeTask = async (task) => {
|
||||||
|
ElMessageBox.confirm('确定要删除该任务吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
}).then(async () => {
|
||||||
|
let res = await deleteRunTask(task.id);
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success("任务已删除");
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
// 取消删除
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取历史执行任务
|
||||||
|
*/
|
||||||
|
const getHistoryList = () => {
|
||||||
|
loading.value = true;
|
||||||
|
getTaskinstanceList({
|
||||||
|
statusList: ['0', '2', '4'],
|
||||||
|
pageNum: queryParams.pageNum,
|
||||||
|
pageSize: queryParams.pageSize,
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.code === 200) {
|
||||||
|
tableDataList.value = res.rows;
|
||||||
|
total.value = res.total;
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}).catch(() => {
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskRunningInfo = ref({})
|
||||||
|
|
||||||
|
const socketChannelCallbacks = ref({})
|
||||||
|
const channel = 'AimaTaskInstance';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅websocket
|
||||||
|
*/
|
||||||
|
const subscribeDebounced = debounce(async (sub) => {
|
||||||
|
if (!socket) {
|
||||||
|
console.warn(`执行任务订阅失败: socket=${!!socket}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.send({
|
||||||
|
type: 'channel_subscription',
|
||||||
|
action: sub ? 'subscribe' : 'unsubscribe',
|
||||||
|
channel,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sub) {
|
||||||
|
await nextTick();
|
||||||
|
// 清理旧回调
|
||||||
|
if (socketChannelCallbacks.value[channel]) {
|
||||||
|
socket.off(channel, socketChannelCallbacks.value[channel]);
|
||||||
|
console.log('清理旧回调:', channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const callback = (data) => {
|
||||||
|
console.log('data', data)
|
||||||
|
if (data !== '500') {
|
||||||
|
if (data.status !== 0) {
|
||||||
|
let activeStep = pointList_runningTask.value[data.taskId].findIndex(item => item.detectItemId === data.itemId) || 0
|
||||||
|
if (data.progress == 100) {
|
||||||
|
activeStep += 1
|
||||||
|
}
|
||||||
|
taskRunningInfo.value[data.taskInstanceId] = {
|
||||||
|
...data,
|
||||||
|
activeStep
|
||||||
|
}
|
||||||
|
if (!taskLogList.value[data.taskInstanceId]) {
|
||||||
|
taskLogList.value[data.taskInstanceId] = []
|
||||||
|
}
|
||||||
|
taskLogList.value[data.taskInstanceId].push({
|
||||||
|
...data
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
delete taskRunningInfo.value[data.taskInstanceId]
|
||||||
|
delete taskLogList.value[data.taskInstanceId]
|
||||||
|
getList()
|
||||||
|
getHistoryList()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 错误处理:取消订阅后重新订阅
|
||||||
|
subscribeDebounced(false);
|
||||||
|
subscribeDebounced(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.on(channel, callback);
|
||||||
|
socketChannelCallbacks.value[channel] = callback;
|
||||||
|
} else {
|
||||||
|
if (socketChannelCallbacks.value[channel]) {
|
||||||
|
socket.off(channel, socketChannelCallbacks.value[channel]);
|
||||||
|
delete socketChannelCallbacks.value[channel];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
const logDialogVisible = ref(false)
|
||||||
|
const logDialogData = ref([])
|
||||||
|
const handleOpenLog = async (row) => {
|
||||||
|
logDialogVisible.value = true
|
||||||
|
const res = await getTaskLogListApi({ taskInstanceId: row.id })
|
||||||
|
if (res.code === 200) {
|
||||||
|
logDialogData.value = res.rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
getTaskData();
|
||||||
|
getPhoneData();
|
||||||
|
getCarData()
|
||||||
|
getList();
|
||||||
|
getHistoryList();
|
||||||
|
subscribeDebounced(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
subscribeDebounced(false)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.left {
|
||||||
|
width: 25%;
|
||||||
|
height: 100%;
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskList-container {
|
||||||
|
margin-top: 16px;
|
||||||
|
height: calc(100% - 100px);
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
padding: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
box-shadow: inset 0 0 0 1px #eee;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.task-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.task-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: inset 0 0 0 1px #eee;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
color: #409eff;
|
||||||
|
box-shadow: inset 0 0 0 1px #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-container {
|
||||||
|
height: calc(100% - 40px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task {
|
||||||
|
padding: 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.task-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.task-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 50px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.progress-label {
|
||||||
|
margin-top: 12px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
71
src/views/intelligenceTest/runningTask/task-log.vue
Normal file
71
src/views/intelligenceTest/runningTask/task-log.vue
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div class="log-container" :style="{ 'height': height + 'px' }" ref="messageContainer">
|
||||||
|
<div class="log" v-for="log, index in list">
|
||||||
|
<div class="time">{{ formatDate(log.logTime) }}</div>
|
||||||
|
<div class="context">{{ log.logContent }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { formatDate } from "@/utils/index";
|
||||||
|
import { watch } from "vue";
|
||||||
|
const props = defineProps({
|
||||||
|
list: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 150
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const messageContainer = ref(null)
|
||||||
|
|
||||||
|
const SCROLL_DELAY = 100
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (messageContainer.value) {
|
||||||
|
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
|
||||||
|
}
|
||||||
|
}, SCROLL_DELAY)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.list, () => {
|
||||||
|
scrollToBottom()
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
scrollToBottom()
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.log-container {
|
||||||
|
margin-top: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
.log {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.time {
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context {
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
88
src/views/intelligenceTest/runningTask/task-steps.vue
Normal file
88
src/views/intelligenceTest/runningTask/task-steps.vue
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<el-scrollbar ref="scrollbarRef" style="width: 100%;" class="step-scrollbar">
|
||||||
|
<div class="step-wrapper" ref="stepWrapperRef">
|
||||||
|
<el-steps :active="activeStep" finish-status="success">
|
||||||
|
<el-step v-for="item in list" :key="value" :title="item.caseName" :description="item.remark" />
|
||||||
|
</el-steps>
|
||||||
|
</div>
|
||||||
|
</el-scrollbar>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
list: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
activeStep: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const scrollbarRef = ref(null)
|
||||||
|
const stepWrapperRef = ref(null)
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动到当前激活的步骤
|
||||||
|
*/
|
||||||
|
const scrollToActiveStep = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
// 获取 el-scrollbar 的可视区域宽度
|
||||||
|
const scrollbarEl = scrollbarRef.value?.$el
|
||||||
|
const wrapEl = scrollbarEl?.querySelector('.el-scrollbar__wrap')
|
||||||
|
if (!wrapEl) return
|
||||||
|
|
||||||
|
const viewWidth = wrapEl.clientWidth
|
||||||
|
|
||||||
|
// 获取所有 step 元素
|
||||||
|
const steps = stepWrapperRef.value?.querySelectorAll('.el-step')
|
||||||
|
if (!steps || !steps[props.activeStep]) return
|
||||||
|
|
||||||
|
// 计算激活 step 的位置
|
||||||
|
const activeEl = steps[props.activeStep]
|
||||||
|
const stepLeft = activeEl.offsetLeft
|
||||||
|
const stepWidth = activeEl.offsetWidth
|
||||||
|
|
||||||
|
// 让激活 step 居中显示
|
||||||
|
const scrollLeft = stepLeft - viewWidth / 2 + stepWidth / 2
|
||||||
|
console.log('滚动位置:', scrollLeft)
|
||||||
|
// 使用 el-scrollbar 提供的方法滚动
|
||||||
|
scrollbarRef.value.setScrollLeft(Math.max(0, scrollLeft))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollToActiveStep()
|
||||||
|
}, 1000)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.step-scrollbar {
|
||||||
|
margin-top: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.step-wrapper {
|
||||||
|
display: inline-block;
|
||||||
|
/* 让wrapper宽度由内容决定 */
|
||||||
|
width: 100%;
|
||||||
|
/* 至少和容器一样宽 */
|
||||||
|
white-space: nowrap;
|
||||||
|
/* 防止 flex 项换行 */
|
||||||
|
|
||||||
|
margin: 10px 0 20px 0;
|
||||||
|
|
||||||
|
:deep(.el-steps) {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
width: calc(100% - 150px);
|
||||||
|
|
||||||
|
.el-step {
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
/* 禁止步骤项被压缩 */
|
||||||
|
min-width: 150px;
|
||||||
|
/* 每个步骤的最小宽度,按需调整 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
357
src/views/intelligenceTest/taskManage/index.vue
Normal file
357
src/views/intelligenceTest/taskManage/index.vue
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<div ref="topContainerRef">
|
||||||
|
<TableSearch :queryParams="queryParams" :showSearch="showSearch" label-width="90px" queryRef="queryRef"
|
||||||
|
@refresh="resetQuery" @search="handleQuery">
|
||||||
|
<template #one>
|
||||||
|
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||||
|
<el-form-item label="任务名称" prop="taskName">
|
||||||
|
<el-input v-model="queryParams.taskName" clearable placeholder="请输入任务名称" style="width: 100%"
|
||||||
|
@keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||||
|
<el-form-item label="任务类型" prop="taskType">
|
||||||
|
<el-select v-model="queryParams.taskType" clearable placeholder="请选择任务类型"
|
||||||
|
style="width: 100%">
|
||||||
|
<el-option label="常规测试" value="1" />
|
||||||
|
<el-option label="回归测试" value="2" />
|
||||||
|
<el-option label="专项测试" value="3" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</template>
|
||||||
|
</TableSearch>
|
||||||
|
|
||||||
|
<el-row :gutter="10" class="mb8">
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button icon="Plus" plain type="primary" @click="handleAdd">新增
|
||||||
|
</el-button>
|
||||||
|
</el-col>
|
||||||
|
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :style="containerHeight">
|
||||||
|
<el-table v-loading="loading" height="100%" :data="tableDataList">
|
||||||
|
<el-table-column align="center" label="id" prop="id" show-overflow-tooltip />
|
||||||
|
<el-table-column align="center" label="任务名称" prop="taskName" show-overflow-tooltip />
|
||||||
|
<el-table-column align="center" label="任务描述" prop="remark" show-overflow-tooltip />
|
||||||
|
<el-table-column align="center" label="任务类型" prop="taskType">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.taskType === '1' ? 'primary' : 'success'">
|
||||||
|
{{ scope.row.taskType === '1' ? '巡检任务' : '讲解任务' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column align="center" label="创建人" prop="createBy" show-overflow-tooltip />
|
||||||
|
<el-table-column align="center" label="创建时间" prop="createTime" show-overflow-tooltip />
|
||||||
|
<el-table-column align="center" class-name="small-padding fixed-width" label="操作" width="260">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button icon="Edit" link type="primary" @click="handleUpdate(scope.row)">
|
||||||
|
修改
|
||||||
|
</el-button>
|
||||||
|
<el-button icon="Cpu" link type="primary" @click="handleBindCase(scope.row)">绑定用例
|
||||||
|
</el-button>
|
||||||
|
<el-button icon="Delete" link type="primary" @click="handleDelete(scope.row)">删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination v-show="total > 0" v-model:limit="queryParams.pageSize" v-model:page="queryParams.pageNum"
|
||||||
|
:total="total" @pagination="getList" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<el-dialog v-model="open" :title="title" append-to-body width="500px">
|
||||||
|
<el-form ref="taskRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="任务名称" prop="taskName">
|
||||||
|
<el-input v-model="form.taskName" placeholder="请输入任务名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="任务类型" prop="taskType">
|
||||||
|
<el-select v-model="form.taskType" clearable placeholder="请选择任务类型" style="width: 100%">
|
||||||
|
<el-option label="常规测试" value="1" />
|
||||||
|
<el-option label="回归测试" value="2" />
|
||||||
|
<el-option label="专项测试" value="3" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input v-model="form.remark" :rows="2" placeholder="请输入备注" type="textarea" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||||
|
<el-button @click="cancel">取 消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 点位绑定弹窗 -->
|
||||||
|
<el-dialog v-model="dialogVisible" title="测试用例绑定" width="800px">
|
||||||
|
<el-row>
|
||||||
|
<TableSearch :queryParams="caseQueryParams" :showSearch="showSearch" label-width="90px" @refresh="resetBindCaseQuery" @search="handleBindCaseQuery">
|
||||||
|
<template #one>
|
||||||
|
<el-col :lg="12" :md="12" :sm="24" :xl="12" :xs="24" :xxl="12">
|
||||||
|
<el-form-item label="用例名称" prop="caseName">
|
||||||
|
<el-input v-model="caseQueryParams.caseName" clearable placeholder="请输入用例名称"
|
||||||
|
style="width: 100%" @keyup.enter="handleBindCaseQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</template>
|
||||||
|
</TableSearch>
|
||||||
|
</el-row>
|
||||||
|
<el-row :gutter="24">
|
||||||
|
<el-col :span="12">
|
||||||
|
<div style="height: 55vh">
|
||||||
|
<div class="task-title">待选择用例</div>
|
||||||
|
|
||||||
|
<draggable :animation="340" :forceFallback="true" :list="taskLeftList" class="draggable-group"
|
||||||
|
ghostClass="dragClass" group="people" itemKey="name">
|
||||||
|
<template #item="{ element, index }">
|
||||||
|
<div class="list-group-item">
|
||||||
|
<el-icon>
|
||||||
|
<Tools />
|
||||||
|
</el-icon>
|
||||||
|
{{ element.caseName }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="12">
|
||||||
|
<div style="height: 55vh">
|
||||||
|
<div class="task-title">用例排序</div>
|
||||||
|
<draggable :animation="340" :forceFallback="true" :list="taskRightList" class="draggable-group"
|
||||||
|
ghostClass="dragClass" group="people" itemKey="name">
|
||||||
|
<template #item="{ element, index }">
|
||||||
|
<div class="list-group-item">
|
||||||
|
{{ index + 1 }} 、{{ element.caseName }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</div>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
<div class="custom-block">
|
||||||
|
<div class="mb5">1、将待选择用例拖拽至用例排序中</div>
|
||||||
|
<div>2、用例排序中可拖拽自定义其顺序</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button type="primary" @click="bindCase">确 定</el-button>
|
||||||
|
<el-button @click="cancelCase">取 消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script name="Detect" setup>
|
||||||
|
import { addTask, updateTask, getTaskList, deleteTask, getTestCaseByTaskId, bindTastCase} from "../../../api/intelligenceTest/taskManage";
|
||||||
|
import { getTestCaseList } from "../../../api/intelligenceTest/testCase.js";
|
||||||
|
import TableSearch from "@/components/TableSearch/index.vue";
|
||||||
|
|
||||||
|
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||||
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
|
import draggable from "vuedraggable";
|
||||||
|
import { reactive, ref } from "vue";
|
||||||
|
import { getMapList } from "@/api/inspection/map";
|
||||||
|
|
||||||
|
const topContainerRef = ref();
|
||||||
|
const containerHeight = useContainerHeight(topContainerRef);
|
||||||
|
|
||||||
|
const { proxy } = getCurrentInstance();
|
||||||
|
|
||||||
|
const tableDataList = ref([]);
|
||||||
|
const open = ref(false);
|
||||||
|
const dialogVisible = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const showSearch = ref(true);
|
||||||
|
|
||||||
|
const total = ref(0);
|
||||||
|
const title = ref("");
|
||||||
|
|
||||||
|
const data = reactive({
|
||||||
|
form: {},
|
||||||
|
queryParams: {
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
taskName: null,
|
||||||
|
taskType: null,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
taskName: [
|
||||||
|
{ required: true, message: "任务名称不能为空", trigger: "blur" },
|
||||||
|
],
|
||||||
|
taskType: [
|
||||||
|
{ required: true, message: "请选择任务类型", trigger: "blur" },
|
||||||
|
],
|
||||||
|
mapId: [
|
||||||
|
{ required: true, message: "请选择所属地图", trigger: "blur" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { queryParams, form, rules } = toRefs(data);
|
||||||
|
|
||||||
|
/** 查询任务列表 */
|
||||||
|
function getList() {
|
||||||
|
loading.value = false;
|
||||||
|
getTaskList(queryParams.value).then((res) => {
|
||||||
|
tableDataList.value = res.rows;
|
||||||
|
total.value = res.total;
|
||||||
|
loading.value = false;
|
||||||
|
}).finally(() => {
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消按钮
|
||||||
|
function cancel() {
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单重置
|
||||||
|
function reset() {
|
||||||
|
taskRef.value.resetFields();
|
||||||
|
form.value = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索按钮操作 */
|
||||||
|
function handleQuery() {
|
||||||
|
queryParams.value.pageNum = 1;
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置按钮操作 */
|
||||||
|
function resetQuery() {
|
||||||
|
handleQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增按钮操作 */
|
||||||
|
function handleAdd() {
|
||||||
|
isNew.value = true;
|
||||||
|
open.value = true;
|
||||||
|
title.value = "添加任务";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改按钮操作 */
|
||||||
|
function handleUpdate(row) {
|
||||||
|
form.value = { ...row };
|
||||||
|
isNew.value = false;
|
||||||
|
open.value = true;
|
||||||
|
title.value = "修改任务";
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskRef = ref();
|
||||||
|
const isNew = ref(true);
|
||||||
|
/** 提交按钮 */
|
||||||
|
function submitForm() {
|
||||||
|
taskRef.value.validate((valid) => {
|
||||||
|
if (valid) {
|
||||||
|
if (isNew.value) {
|
||||||
|
addTask(form.value).then(() => {
|
||||||
|
ElMessage.success("添加成功");
|
||||||
|
cancel();
|
||||||
|
getList();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateTask(form.value).then(() => {
|
||||||
|
ElMessage.success("修改成功");
|
||||||
|
cancel();
|
||||||
|
getList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除按钮操作 */
|
||||||
|
function handleDelete(row) {
|
||||||
|
ElMessageBox.confirm("确定要删除该任务吗?", "提示", {
|
||||||
|
confirmButtonText: "确定",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
type: "warning",
|
||||||
|
}).then(() => {
|
||||||
|
deleteTask(row.id).then(() => {
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
getList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const caseQueryParams = reactive({
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 1000000,
|
||||||
|
caseName: null
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 搜索按钮操作 */
|
||||||
|
function handleBindCaseQuery() {
|
||||||
|
queryParams.value.pageNum = 1;
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置按钮操作 */
|
||||||
|
function resetBindCaseQuery() {
|
||||||
|
handleBindCaseQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = ref(null);
|
||||||
|
const handleBindCase = async (row) => {
|
||||||
|
dialogVisible.value = true;
|
||||||
|
taskId.value = row.id;
|
||||||
|
|
||||||
|
getbindCaseByTaskId(row.id)
|
||||||
|
getTestCaseListData()
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 查询点位列表 */
|
||||||
|
const getTestCaseListData = async () => {
|
||||||
|
const res = await getTestCaseList(caseQueryParams)
|
||||||
|
if (res.code === 200) {
|
||||||
|
let ids = []
|
||||||
|
if (taskRightList.value) {
|
||||||
|
ids = taskRightList.value.map(item => item.id)
|
||||||
|
}
|
||||||
|
taskLeftList.value = res.rows.filter(item => {
|
||||||
|
return !ids.includes(item.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getbindCaseByTaskId = async (taskId) => {
|
||||||
|
const res = await getTestCaseByTaskId(taskId)
|
||||||
|
if (res.code === 200) {
|
||||||
|
taskRightList.value = res.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskLeftList = ref([])
|
||||||
|
const taskRightList = ref([])
|
||||||
|
|
||||||
|
const bindCase = async () => {
|
||||||
|
if (taskRightList.value.length === 0) {
|
||||||
|
ElMessage.warning("请选择用例")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ids = taskRightList.value.map(item => item.id)
|
||||||
|
const res = await bindTastCase(taskId.value, ids)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success("绑定用例成功")
|
||||||
|
cancelCase()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelCase = () => {
|
||||||
|
dialogVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getList();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
330
src/views/intelligenceTest/testCase/index.vue
Normal file
330
src/views/intelligenceTest/testCase/index.vue
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<div ref="topContainerRef">
|
||||||
|
<TableSearch
|
||||||
|
:queryParams="queryParams"
|
||||||
|
:showSearch="showSearch"
|
||||||
|
label-width="90px"
|
||||||
|
queryRef="queryRef"
|
||||||
|
@refresh="resetQuery"
|
||||||
|
@search="handleQuery"
|
||||||
|
>
|
||||||
|
<template #one>
|
||||||
|
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||||
|
<el-form-item label="用例编码" prop="caseCode">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.caseCode"
|
||||||
|
placeholder="请输入用例编码"
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :lg="6" :md="12" :sm="24" :xl="6" :xs="24" :xxl="6">
|
||||||
|
<el-form-item label="测试步骤" prop="testSteps">
|
||||||
|
<el-input
|
||||||
|
v-model="queryParams.testSteps"
|
||||||
|
placeholder="请输入测试步骤"
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@keyup.enter="handleQuery"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</template>
|
||||||
|
</TableSearch>
|
||||||
|
|
||||||
|
<el-row :gutter="10" class="mb8">
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
icon="Plus"
|
||||||
|
@click="handleAdd"
|
||||||
|
>新增</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
icon="Edit"
|
||||||
|
:disabled="single"
|
||||||
|
@click="handleUpdate"
|
||||||
|
>修改</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5">
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
icon="Delete"
|
||||||
|
:disabled="multiple"
|
||||||
|
@click="handleDelete"
|
||||||
|
>删除</el-button>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :style="containerHeight">
|
||||||
|
<!-- 表格数据 -->
|
||||||
|
<el-table height="100%" v-loading="loading" :data="tableDataList" @selection-change="handleSelectionChange">
|
||||||
|
<el-table-column type="selection" width="55" align="center" />
|
||||||
|
<el-table-column label="用例名称" prop="caseName" />
|
||||||
|
<el-table-column label="预期结果" prop="expectedResult" :show-overflow-tooltip="true" width="200" />
|
||||||
|
<el-table-column label="前置条件" prop="precondition" show-overflow-tooltip="true" width="200" />
|
||||||
|
<el-table-column label="测试步骤" align="center" prop="testSteps" show-overflow-tooltip="true" width="200"/>
|
||||||
|
<el-table-column label="测试描述" align="center" prop="testDescription" show-overflow-tooltip="true" width="200" />
|
||||||
|
<el-table-column label="状态" align="center" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-switch
|
||||||
|
v-model="scope.row.enabled"
|
||||||
|
active-value="0"
|
||||||
|
inactive-value="1"
|
||||||
|
@change="handleStatusChange(scope.row)"
|
||||||
|
></el-switch>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column align="center" label="配置状态" prop="configStatus">
|
||||||
|
<template #default="scope">
|
||||||
|
<TableTags
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
label: '已配置',
|
||||||
|
value: '0',
|
||||||
|
type: 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '未配置',
|
||||||
|
value: '1',
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:value="scope.row.configStatus"
|
||||||
|
></TableTags>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tooltip content="修改" placement="top">
|
||||||
|
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-button
|
||||||
|
icon="Cpu"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="handleDeploy(scope.row)"
|
||||||
|
>配置
|
||||||
|
</el-button>
|
||||||
|
<el-tooltip content="删除" placement="top">
|
||||||
|
<el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-show="total > 0"
|
||||||
|
:total="total"
|
||||||
|
v-model:page="queryParams.pageNum"
|
||||||
|
v-model:limit="queryParams.pageSize"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加或修改配置对话框 -->
|
||||||
|
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
|
||||||
|
<el-form ref="testCaseRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="用例名称" prop="caseName">
|
||||||
|
<el-input v-model="form.caseName" placeholder="请输入用例名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="预期结果" prop="expectedResult">
|
||||||
|
<el-input type="textarea" v-model="form.expectedResult" placeholder="请输入预期结果" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="前置条件" prop="precondition">
|
||||||
|
<el-input type="textarea" v-model="form.precondition" placeholder="请输入前置条件" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="测试步骤" prop="testSteps">
|
||||||
|
<el-input type="textarea" v-model="form.testSteps" placeholder="请输入测试步骤" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="测试描述" prop="testDescription">
|
||||||
|
<el-input type="textarea" v-model="form.testDescription" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||||
|
<el-button @click="cancel">取 消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup name="Role">
|
||||||
|
import TableSearch from "@/components/TableSearch/index.vue";
|
||||||
|
import { useContainerHeight } from "@/hooks/tableHeight";
|
||||||
|
import { addTestCase, getTestCaseList, updateTestCase, deleteTestCase } from "@/api/intelligenceTest/testCase.js"
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { appendParamsToPath } from "@/utils/fn.js";
|
||||||
|
import TableTags from "@/components/TableTags/index.vue";
|
||||||
|
|
||||||
|
const topContainerRef = ref();
|
||||||
|
const containerHeight = useContainerHeight(topContainerRef);
|
||||||
|
|
||||||
|
const tableDataList = ref([]);
|
||||||
|
const open = ref(false);
|
||||||
|
const loading = ref(true);
|
||||||
|
const showSearch = ref(true);
|
||||||
|
const ids = ref([]);
|
||||||
|
const single = ref(true);
|
||||||
|
const multiple = ref(true);
|
||||||
|
const total = ref(0);
|
||||||
|
const title = ref("");
|
||||||
|
|
||||||
|
|
||||||
|
const data = reactive({
|
||||||
|
form: {},
|
||||||
|
queryParams: {
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
caseName: undefined,
|
||||||
|
testSteps: undefined
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
caseName: [{ required: true, message: "用例名称不能为空", trigger: "blur" }],
|
||||||
|
expectedResult: [{ required: true, message: "预期结果不能为空", trigger: "blur" }],
|
||||||
|
testSteps: [{ required: true, message: "测试步骤为空", trigger: "blur" }],
|
||||||
|
testDescription: [{ required: true, message: "测试描述不能为空", trigger: "blur" }]
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { queryParams, form, rules } = toRefs(data);
|
||||||
|
|
||||||
|
/** 查询列表 */
|
||||||
|
const getList = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
const res = await getTestCaseList(queryParams.value)
|
||||||
|
if (res.code === 200) {
|
||||||
|
tableDataList.value = res.rows;
|
||||||
|
total.value = res.total;
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 搜索按钮操作 */
|
||||||
|
function handleQuery() {
|
||||||
|
queryParams.value.pageNum = 1;
|
||||||
|
getList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置按钮操作 */
|
||||||
|
function resetQuery() {
|
||||||
|
handleQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除按钮操作 */
|
||||||
|
function handleDelete(row) {
|
||||||
|
const deleteIds = row.id || ids.value;
|
||||||
|
ElMessageBox.confirm(`确定要删除编号为${deleteIds}的测试用例吗?`, "提示", {
|
||||||
|
confirmButtonText: "确定",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
type: "warning",
|
||||||
|
}).then(() => {
|
||||||
|
deleteTestCase(deleteIds).then(() => {
|
||||||
|
ElMessage.success("删除成功");
|
||||||
|
getList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** 多选框选中数据 */
|
||||||
|
function handleSelectionChange(selection) {
|
||||||
|
ids.value = selection.map(item => item.id);
|
||||||
|
single.value = selection.length != 1;
|
||||||
|
multiple.value = !selection.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态修改 */
|
||||||
|
function handleStatusChange(row) {
|
||||||
|
let text = row.enabled === "0" ? "启用" : "停用";
|
||||||
|
ElMessageBox.confirm('确认要"' + text + '""' + row.caseName + '"测试用例吗?', "提示", {
|
||||||
|
confirmButtonText: "确定",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
type: "warning",
|
||||||
|
}).then(() => {
|
||||||
|
updateTestCase({
|
||||||
|
...row,
|
||||||
|
}).then(() => {
|
||||||
|
ElMessage.success(text + "成功");
|
||||||
|
getList();
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
row.enabled = row.enabled === "0" ? "1" : "0";
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
testCaseRef.value.resetFields()
|
||||||
|
};
|
||||||
|
|
||||||
|
const testCaseRef = ref()
|
||||||
|
const isNew = ref(true)
|
||||||
|
|
||||||
|
/** 添加 */
|
||||||
|
function handleAdd() {
|
||||||
|
open.value = true;
|
||||||
|
isNew.value = true
|
||||||
|
title.value = "添加";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改 */
|
||||||
|
function handleUpdate(row) {
|
||||||
|
form.value = { ...row };
|
||||||
|
open.value = true;
|
||||||
|
isNew.value = false
|
||||||
|
title.value = "修改";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交按钮 */
|
||||||
|
function submitForm() {
|
||||||
|
testCaseRef.value.validate((valid) => {
|
||||||
|
if (valid) {
|
||||||
|
if (isNew.value) {
|
||||||
|
addTestCase(form.value).then(() => {
|
||||||
|
ElMessage.success("添加成功");
|
||||||
|
getList();
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateTestCase(form.value).then(() => {
|
||||||
|
ElMessage.success("修改成功");
|
||||||
|
getList();
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消按钮 */
|
||||||
|
function cancel() {
|
||||||
|
open.value = false;
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 配置按钮操作 */
|
||||||
|
function handleDeploy(row) {
|
||||||
|
const params = { itemId: row.detectItemId, name: row.caseName, state: row.status };
|
||||||
|
const fullPath = appendParamsToPath("/flow", params);
|
||||||
|
const p = window.open(fullPath, "_blank");
|
||||||
|
}
|
||||||
|
|
||||||
|
getList();
|
||||||
|
</script>
|
||||||
Loading…
Reference in New Issue
Block a user