feat: 完善机器人相关界面
This commit is contained in:
parent
e749e8ba95
commit
d681b46b3e
44
src/api/device/robot.js
Normal file
44
src/api/device/robot.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
// 查询机器人配置列表
|
||||||
|
export function listRobot(query) {
|
||||||
|
return request({
|
||||||
|
url: '/device/robot/list',
|
||||||
|
method: 'get',
|
||||||
|
params: query
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询机器人配置详细
|
||||||
|
export function getRobot(id) {
|
||||||
|
return request({
|
||||||
|
url: '/device/robot/' + id,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增机器人配置
|
||||||
|
export function addRobot(data) {
|
||||||
|
return request({
|
||||||
|
url: '/device/robot',
|
||||||
|
method: 'post',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改机器人配置
|
||||||
|
export function updateRobot(data) {
|
||||||
|
return request({
|
||||||
|
url: '/device/robot',
|
||||||
|
method: 'put',
|
||||||
|
data: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除机器人配置
|
||||||
|
export function delRobot(id) {
|
||||||
|
return request({
|
||||||
|
url: '/device/robot/' + id,
|
||||||
|
method: 'delete'
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -17,14 +17,14 @@
|
|||||||
<div v-if="form.stereoModule && form.rgbCamera" class="stream-container">
|
<div v-if="form.stereoModule && form.rgbCamera" class="stream-container">
|
||||||
<div class="stream-item">
|
<div class="stream-item">
|
||||||
<div class="stream-wrapper">
|
<div class="stream-wrapper">
|
||||||
<video ref="depthVideo" width="640" height="480" autoplay muted playsinline></video>
|
<video ref="depthVideo" autoplay muted playsinline></video>
|
||||||
<video ref="depthVideoBuffer" width="640" height="480" autoplay muted playsinline style="display: none;"></video>
|
<video ref="depthVideoBuffer" autoplay muted playsinline style="display: none;"></video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stream-item">
|
<div class="stream-item">
|
||||||
<div class="stream-wrapper">
|
<div class="stream-wrapper">
|
||||||
<video ref="colorVideo" width="640" height="480" autoplay muted playsinline></video>
|
<video ref="colorVideo" autoplay muted playsinline></video>
|
||||||
<video ref="colorVideoBuffer" width="640" height="480" autoplay muted playsinline style="display: none;"></video>
|
<video ref="colorVideoBuffer" autoplay muted playsinline style="display: none;"></video>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -52,6 +52,18 @@ import { getRegister } from "@/api/device/register";
|
|||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { debounce } from 'lodash'; // 引入 lodash 的防抖函数
|
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 socket = inject('ws');
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const data = reactive({
|
const data = reactive({
|
||||||
@ -189,11 +201,36 @@ watch(() => data.form.rgbCamera, async (newVal) => {
|
|||||||
subscribeDebounced(newVal, data.terminalId, 'getRGBImageStream');
|
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(() => {
|
onMounted(() => {
|
||||||
getRegister(route.path.split("/")[3]).then(res => {
|
// getRegister(route.path.split("/")[3]).then(res => {
|
||||||
data.cameraId = res.data.deviceCode;
|
// data.cameraId = res.data.deviceCode;
|
||||||
data.terminalId = res.data.idDeDeviceTerminalConfig;
|
// data.terminalId = res.data.idDeDeviceTerminalConfig;
|
||||||
});
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@ -17,12 +17,30 @@ import { inject } from 'vue';
|
|||||||
const emit = defineEmits(['update:bottomSeriesData']);
|
const emit = defineEmits(['update:bottomSeriesData']);
|
||||||
const socket = inject('ws');
|
const socket = inject('ws');
|
||||||
const handCanvas = ref(null);
|
const handCanvas = ref(null);
|
||||||
const terminalId = ref('25449ff3fcc7b27da5f69462c5efcec2'); // 待您修改
|
const terminalId = ref(''); // 待您修改
|
||||||
const deviceId = ref('hand1'); // 待您修改
|
const deviceId = ref(''); // 待您修改
|
||||||
const frameData = ref({ count: 0, lastTime: 0 });
|
const frameData = ref({ count: 0, lastTime: 0 });
|
||||||
const isHandSeries = ref(false);
|
const isHandSeries = ref(false);
|
||||||
const containerRef = ref(null)
|
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 = [
|
const rectangle = [
|
||||||
{ x: 24, y: 305, width: 21, height: 20, xCount: 3, yCount: 3 }, // PINKY.TIP
|
{ x: 24, y: 305, width: 21, height: 20, xCount: 3, yCount: 3 }, // PINKY.TIP
|
||||||
|
|||||||
@ -35,6 +35,26 @@ const handImage = ref(null);
|
|||||||
const containerRef = ref(null); // 新增:引用父容器
|
const containerRef = ref(null); // 新增:引用父容器
|
||||||
const defaultHandImageInfo = { width: 891, height: 981 };
|
const defaultHandImageInfo = { width: 891, height: 981 };
|
||||||
const imageAspectRatio = defaultHandImageInfo.width / defaultHandImageInfo.height;
|
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([
|
const sliders = ref([
|
||||||
{ value: 0, defalutHeight: 466, height: 0, x: 120, y: 140, vertical: true, style: {} }, // 小拇指
|
{ value: 0, defalutHeight: 466, height: 0, x: 120, y: 140, vertical: true, style: {} }, // 小拇指
|
||||||
@ -46,8 +66,9 @@ const sliders = ref([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
function updateSeriesData(value, i) {
|
function updateSeriesData(value, i) {
|
||||||
|
console.log(deviceId.value, terminalId.value)
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
setDexHandAngle({ deviceId: 'hand1', terminalId: '25449ff3fcc7b27da5f69462c5efcec2', value: value / 100, id: i }).then(() => {
|
setDexHandAngle({ deviceId: deviceId.value, terminalId: terminalId.value, value: value / 100, id: i }).then(() => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
updateToSeriesData();
|
updateToSeriesData();
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
@ -110,7 +131,7 @@ const updateSliderPositions = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateToSeriesData = () => {
|
const updateToSeriesData = () => {
|
||||||
status({ deviceId: 'hand1', terminalId: '25449ff3fcc7b27da5f69462c5efcec2' }).then((res) => {
|
status({ deviceId: deviceId.value, terminalId: terminalId.value }).then((res) => {
|
||||||
const newTopSeriesData = res.data.handsList.map(item => item.force);
|
const newTopSeriesData = res.data.handsList.map(item => item.force);
|
||||||
emit('update:topSeriesData', newTopSeriesData);
|
emit('update:topSeriesData', newTopSeriesData);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,10 +3,12 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="left-panel">
|
<div class="left-panel">
|
||||||
<div class="hand-container">
|
<div class="hand-container">
|
||||||
<LeftTopHand @update:topSeriesData="handleUpdateTopSeriesData" />
|
<!-- 确保这里使用的是 index.vue 中响应式的 deviceId ref -->
|
||||||
|
<LeftTopHand :initialDeviceId="deviceId" :initialTerminalId="terminalId" @update:topSeriesData="handleUpdateTopSeriesData" />
|
||||||
</div>
|
</div>
|
||||||
<div class="hand-container">
|
<div class="hand-container">
|
||||||
<LeftBottomHand @update:bottomSeriesData="handleUpdateBottomSeriesData" />
|
<!-- 确保这里使用的是 index.vue 中响应式的 terminalId ref -->
|
||||||
|
<LeftBottomHand :initialDeviceId="deviceId" :initialTerminalId="terminalId" @update:bottomSeriesData="handleUpdateBottomSeriesData" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="right-panel">
|
<div class="right-panel">
|
||||||
@ -17,12 +19,28 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref, watch } from 'vue'; // 引入 watch
|
||||||
|
// 假设你的 router 实例是这样获取的
|
||||||
|
import { useRoute } from 'vue-router'; // 确保你已经安装并配置了 vue-router
|
||||||
import RightTopCharts from './RightTopCharts.vue';
|
import RightTopCharts from './RightTopCharts.vue';
|
||||||
import LeftTopHand from './LeftTopHand.vue';
|
import LeftTopHand from './LeftTopHand.vue';
|
||||||
import LeftBottomHand from './LeftBottomHand.vue';
|
import LeftBottomHand from './LeftBottomHand.vue';
|
||||||
import RightBottomCharts from './RightBottomCharts.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 topSeriesData = ref([0, 0, 0, 0, 0, 0]);
|
||||||
const bottomSeriesData = ref([0, 0, 0, 0, 0, 0]);
|
const bottomSeriesData = ref([0, 0, 0, 0, 0, 0]);
|
||||||
const maxData = ref({
|
const maxData = ref({
|
||||||
@ -42,6 +60,10 @@ const avgData = ref({
|
|||||||
palm: { touch: 0 }
|
palm: { touch: 0 }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 声明组件内部使用的响应式变量
|
||||||
|
const deviceId = ref("");
|
||||||
|
const terminalId = ref("");
|
||||||
|
|
||||||
const handleUpdateTopSeriesData = (newData) => {
|
const handleUpdateTopSeriesData = (newData) => {
|
||||||
topSeriesData.value = newData;
|
topSeriesData.value = newData;
|
||||||
};
|
};
|
||||||
@ -50,6 +72,55 @@ const handleUpdateBottomSeriesData = ({ maxData: newMaxData, avgData: newAvgData
|
|||||||
maxData.value = newMaxData;
|
maxData.value = newMaxData;
|
||||||
avgData.value = newAvgData;
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
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>
|
||||||
@ -1,29 +1,43 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="robot-interface" class="robot-container">
|
<div id="robot-interface" class="robot-container">
|
||||||
<div class="tree-panel" :style="{ width: isTreeCollapsed ? '0px' : '240px' }">
|
<div class="tree-panel" :style="{ width: isTreeCollapsed ? '0px' : '240px' }">
|
||||||
<el-tree v-if="!isTreeCollapsed" :data="treeData" :props="treeProps" :default-expanded-keys="['robot-node-config']" @node-click="handleNodeClick" />
|
<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>
|
||||||
<div class="robot-panel">
|
<div class="robot-panel">
|
||||||
<div id="robot-bg" ref="robotBg" :style="{ backgroundImage: `url(${robotImage})`, width: bgSize.width + 'px', height: bgSize.height + 'px' }">
|
<div id="robot-bg" ref="robotBg" :style="{ backgroundImage: `url(${robotImage})`, width: bgSize.width + 'px', height: bgSize.height + 'px' }">
|
||||||
<div
|
<TechButtonWithLine
|
||||||
v-for="area in adjustedAreas"
|
v-for="(area,index) in adjustedAreas"
|
||||||
:key="area.id"
|
:key="area.id"
|
||||||
class="clickable-area"
|
:total-buttons="5"
|
||||||
:style="{
|
:button-index="area.index"
|
||||||
position: 'absolute',
|
:button-position="area.position"
|
||||||
top: area.y + 'px',
|
:end-x="area.x"
|
||||||
left: area.x + 'px',
|
:end-y="area.y"
|
||||||
width: area.width + 'px',
|
:button-name="area.label"
|
||||||
height: area.height + 'px'
|
:button-width="120"
|
||||||
}"
|
:button-height="50"
|
||||||
@click="openPopup(area)"
|
@click="openPopup(area)"
|
||||||
>
|
/>
|
||||||
<span class="clickable-text">{{ area.label }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="toggle-button" @click="isTreeCollapsed = !isTreeCollapsed">
|
<div class="toggle-button" @click="isTreeCollapsed = !isTreeCollapsed">
|
||||||
{{ isTreeCollapsed ? '>' : '<' }}
|
{{ isTreeCollapsed ? '>' : '<' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="(popup, index) in popups"
|
v-for="(popup, index) in popups"
|
||||||
@ -47,7 +61,7 @@
|
|||||||
<span class="control-icon close-icon" @click="closePopup(index)">×</span>
|
<span class="control-icon close-icon" @click="closePopup(index)">×</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<component :is="popup.component" v-bind="popup.props" />
|
<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 top" @mousedown="startResizing($event, index, 'top')"></div>
|
||||||
<div class="resize-edge bottom" @mousedown="startResizing($event, index, 'bottom')"></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 left" @mousedown="startResizing($event, index, 'left')"></div>
|
||||||
@ -57,25 +71,82 @@
|
|||||||
<div class="resize-corner bottom-left" @mousedown="startResizing($event, index, 'bottom-left')"></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 class="resize-corner bottom-right" @mousedown="startResizing($event, index, 'bottom-right')"></div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed, onUnmounted, watch } from 'vue';
|
import { ref, onMounted, computed, onUnmounted, watch, nextTick } from 'vue';
|
||||||
import HandControl from '../register/components/DexHand/index.vue';
|
import HandControl from '../register/components/DexHand/index.vue';
|
||||||
import CameraView from '../register/components/Camera/index.vue';
|
import CameraView from '../register/components/Camera/index.vue';
|
||||||
import MusicPlayer from '../register/components/Speaker/index.vue';
|
import MusicPlayer from '../register/components/Speaker/index.vue';
|
||||||
import MechanicalArm from '../register/components/MechanicalArm/index.vue';
|
import MechanicalArm from '../register/components/MechanicalArm/index.vue';
|
||||||
import image from '@/assets/images/robot.png';
|
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 = {
|
const componentsMap = {
|
||||||
HandControl,
|
HandControl,
|
||||||
CameraView,
|
CameraView,
|
||||||
MusicPlayer,
|
MusicPlayer,
|
||||||
MechanicalArm
|
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 robotImage = image;
|
||||||
const robotBg = ref(null);
|
const robotBg = ref(null);
|
||||||
const isTreeCollapsed = ref(false);
|
const isTreeCollapsed = ref(false);
|
||||||
@ -91,53 +162,209 @@ const bgSize = ref({ width: 0, height: 0 });
|
|||||||
|
|
||||||
// 原始坐标和尺寸(基于图片原始大小)
|
// 原始坐标和尺寸(基于图片原始大小)
|
||||||
const originalAreas = ref([
|
const originalAreas = ref([
|
||||||
{ id: 'hand-left', x: 15, y: 505, width: 110, height: 50, component: HandControl, label: '左手' },
|
{ id: 'camera', index: 1, position: 'left', x: 250, y: 134, component: CameraView, label: '摄像头', deviceId: '', configId: '' },
|
||||||
{ id: 'hand-right', x: 300, y: 505, width: 110, height: 50, component: HandControl, label: '右手' },
|
{ id: 'mouth', index: 1, position: 'right', x: 299, y: 220, component: MusicPlayer, label: '扬声器', deviceId: '', configId: '' },
|
||||||
{ id: 'arm-left', x: 0, y: 150, width: 220, height: 50, component: MechanicalArm, label: '左机械臂' },
|
{ id: 'arm-left', index: 2, position: 'left', x: 100, y: 650, component: MechanicalArm, label: '左机械臂', deviceId: '', configId: '' },
|
||||||
{ id: 'arm-right', x: 200, y: 150, width: 220, height: 50, component: MechanicalArm, label: '右机械臂' },
|
{ id: 'arm-right', index: 2, position: 'right', x: 480, y: 650, component: MechanicalArm, label: '右机械臂', deviceId: '', configId: '' },
|
||||||
{ id: 'camera', x: 140, y: 50, width: 160, height: 50, component: CameraView, label: '摄像头' },
|
{ id: 'hand-left', index: 3, position: 'left', x: 75, y: 885, component: HandControl, label: '左手', deviceId: '', configId: '' },
|
||||||
{ id: 'mouth', x: 140, y: 100, width: 160, height: 50, component: MusicPlayer, label: '扬声器' }
|
{ id: 'hand-right', index: 3, position: 'right', x: 520, y: 885, component: HandControl, label: '右手', deviceId: '', configId: '' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 树数据
|
// 树数据
|
||||||
const treeData = ref([
|
const treeData = ref([
|
||||||
{
|
|
||||||
id: 'robot-node-config',
|
|
||||||
label: '机器人节点配置',
|
|
||||||
children: [
|
|
||||||
{ id: 'node-1', label: '节点1', children: [{ id: 'node-1-1', label: '子节点1-1' }, { id: 'node-1-2', label: '子节点1-2' }] },
|
|
||||||
{ id: 'node-2', label: '节点2', children: [{ id: 'node-2-1', label: '子节点2-1' }, { id: 'node-2-2', label: '子节点2-2' }] },
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const treeProps = { children: 'children', label: 'label' };
|
/**
|
||||||
|
* 将扁平化的节点数据转换为树形结构。
|
||||||
|
* 假设 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) => {
|
const handleNodeClick = (data) => {
|
||||||
console.log('点击节点', data.label);
|
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(() => {
|
const adjustedAreas = computed(() => {
|
||||||
if (!imageDimensions.value.width || !imageDimensions.value.height || !robotBg.value) return [];
|
if (!imageDimensions.value.width || !imageDimensions.value.height || !robotBg.value) return [];
|
||||||
const containerWidth = containerSize.value.width - (isTreeCollapsed.value ? 0 : 240); // 仅减去树宽度
|
const treePanelWidth = isTreeCollapsed.value ? 0 : 240;
|
||||||
const containerHeight = containerSize.value.height; // 无垂直padding
|
// 确保 robot-panel 内部的 content 区域是可用的
|
||||||
const scaleX = containerWidth / imageDimensions.value.width;
|
const contentContainerWidth = containerSize.value.width - treePanelWidth;
|
||||||
const scaleY = containerHeight / imageDimensions.value.height;
|
const containerHeight = containerSize.value.height;
|
||||||
const scale = Math.min(scaleX, scaleY, 1); // 防止放大
|
|
||||||
const scaledWidth = imageDimensions.value.width * scale; // 图片宽
|
|
||||||
const scaledHeight = imageDimensions.value.height * scale;
|
|
||||||
|
|
||||||
// const offsetX = (containerWidth - scaledWidth) / 2;
|
// 仅当机器人背景图存在时,才计算缩放比例
|
||||||
// const offsetY = (containerHeight - scaledHeight) / 2;
|
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 };
|
bgSize.value = { width: scaledWidth, height: scaledHeight };
|
||||||
|
|
||||||
|
// 计算中心对齐的偏移量
|
||||||
|
const offsetX = (robotPanelContentWidth - scaledWidth) / 2;
|
||||||
|
const offsetY = (robotPanelContentHeight - scaledHeight) / 2;
|
||||||
|
|
||||||
|
|
||||||
return originalAreas.value.map(area => ({
|
return originalAreas.value.map(area => ({
|
||||||
...area,
|
...area,
|
||||||
x: area.x * scale,
|
x: area.x * scale + offsetX, // 应用缩放和中心偏移
|
||||||
y: area.y * scale,
|
y: area.y * scale + offsetY, // 应用缩放和中心偏移
|
||||||
width: area.width * scale,
|
width: area.width * scale,
|
||||||
height: area.height * scale
|
height: area.height * scale
|
||||||
}));
|
}));
|
||||||
@ -148,16 +375,22 @@ const openPopup = (area) => {
|
|||||||
const existingPopup = popups.value.find(p => p.id === area.id);
|
const existingPopup = popups.value.find(p => p.id === area.id);
|
||||||
if (existingPopup) {
|
if (existingPopup) {
|
||||||
console.log('已打开ID为', area.id, '的弹窗');
|
console.log('已打开ID为', area.id, '的弹窗');
|
||||||
|
// 如果弹窗已存在,可以考虑将其置顶
|
||||||
|
existingPopup.zIndex = currentZIndex.value++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const treeWidth = isTreeCollapsed.value ? 0 : 240;
|
const treeWidth = isTreeCollapsed.value ? 0 : 240;
|
||||||
|
// 弹窗的 left 偏移需要考虑 tree-panel 的宽度,以及 robot-panel 的 padding
|
||||||
const leftOffset = treeWidth + 20; // tree宽度 + robot-panel padding-left
|
const leftOffset = treeWidth + 20; // tree宽度 + robot-panel padding-left
|
||||||
|
|
||||||
const popup = {
|
const popup = {
|
||||||
id: area.id,
|
id: area.id,
|
||||||
component: area.component,
|
component: area.component,
|
||||||
top: `${area.y}px`,
|
top: `${area.y}px`, // area.y 已经是居中后的 Y 坐标
|
||||||
left: `${area.x + leftOffset}px`,
|
left: `${area.x + leftOffset}px`, // area.x 已经是居中后的 X 坐标,再加上 treePanel宽度和robot-panel的padding
|
||||||
zIndex: currentZIndex.value++,
|
zIndex: currentZIndex.value++,
|
||||||
|
deviceId: area.deviceId,
|
||||||
|
configId: area.configId,
|
||||||
width: 640,
|
width: 640,
|
||||||
height: 480,
|
height: 480,
|
||||||
originalWidth: 640,
|
originalWidth: 640,
|
||||||
@ -165,7 +398,7 @@ const openPopup = (area) => {
|
|||||||
originalTop: `${area.y}px`,
|
originalTop: `${area.y}px`,
|
||||||
originalLeft: `${area.x + leftOffset}px`,
|
originalLeft: `${area.x + leftOffset}px`,
|
||||||
isMaximized: false,
|
isMaximized: false,
|
||||||
props: { terminalId: 'your-terminal-id', cameraId: 'your-camera-id' }
|
props: { terminalId: 'your-terminal-id', cameraId: 'your-camera-id' } // 示例props
|
||||||
};
|
};
|
||||||
popups.value.push(popup);
|
popups.value.push(popup);
|
||||||
isResizing.value.push(false);
|
isResizing.value.push(false);
|
||||||
@ -200,10 +433,14 @@ const maximizePopup = (index) => {
|
|||||||
popup.originalHeight = popup.height;
|
popup.originalHeight = popup.height;
|
||||||
popup.originalTop = popup.top;
|
popup.originalTop = popup.top;
|
||||||
popup.originalLeft = popup.left;
|
popup.originalLeft = popup.left;
|
||||||
popup.width = window.innerWidth;
|
// 最大化时,需要考虑右侧的区域,而不是整个窗口
|
||||||
popup.height = window.innerHeight;
|
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.top = '0px';
|
||||||
popup.left = '0px';
|
popup.left = `${treeWidth}px`; // 紧贴 tree panel
|
||||||
popup.isMaximized = true;
|
popup.isMaximized = true;
|
||||||
console.log('最大化弹窗', popup.id, '到尺寸:', popup.width, 'x', popup.height, '位置:', popup.top, popup.left);
|
console.log('最大化弹窗', popup.id, '到尺寸:', popup.width, 'x', popup.height, '位置:', popup.top, popup.left);
|
||||||
}
|
}
|
||||||
@ -211,6 +448,7 @@ const maximizePopup = (index) => {
|
|||||||
|
|
||||||
// 拖动相关逻辑
|
// 拖动相关逻辑
|
||||||
const startDragging = (e, index) => {
|
const startDragging = (e, index) => {
|
||||||
|
e.stopPropagation(); // 阻止事件冒泡到其他可点击区域
|
||||||
isDragging.value = true;
|
isDragging.value = true;
|
||||||
dragIndex.value = index;
|
dragIndex.value = index;
|
||||||
const popup = popups.value[index];
|
const popup = popups.value[index];
|
||||||
@ -225,8 +463,21 @@ const dragHandler = (e) => {
|
|||||||
if (isDragging.value && dragIndex.value !== null) {
|
if (isDragging.value && dragIndex.value !== null) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const popup = popups.value[dragIndex.value];
|
const popup = popups.value[dragIndex.value];
|
||||||
const newTop = Math.max(0, e.pageY - popup.startY);
|
// 限制拖动范围,防止移出可视区域
|
||||||
const newLeft = e.pageX - popup.startX;
|
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.top = `${newTop}px`;
|
||||||
popup.left = `${newLeft}px`;
|
popup.left = `${newLeft}px`;
|
||||||
});
|
});
|
||||||
@ -242,6 +493,7 @@ const stopDragging = () => {
|
|||||||
|
|
||||||
// 调整大小相关逻辑
|
// 调整大小相关逻辑
|
||||||
const startResizing = (e, index, edge) => {
|
const startResizing = (e, index, edge) => {
|
||||||
|
e.stopPropagation(); // 阻止事件冒泡
|
||||||
const popup = popups.value[index];
|
const popup = popups.value[index];
|
||||||
resizeStart.value = {
|
resizeStart.value = {
|
||||||
index,
|
index,
|
||||||
@ -254,6 +506,7 @@ const startResizing = (e, index, edge) => {
|
|||||||
edge
|
edge
|
||||||
};
|
};
|
||||||
isResizing.value[index] = true;
|
isResizing.value[index] = true;
|
||||||
|
popup.zIndex = currentZIndex.value++; // 调整大小的时候也提高 z-index
|
||||||
document.addEventListener('mousemove', handleResize);
|
document.addEventListener('mousemove', handleResize);
|
||||||
document.addEventListener('mouseup', stopResizingGlobal);
|
document.addEventListener('mouseup', stopResizingGlobal);
|
||||||
};
|
};
|
||||||
@ -267,19 +520,21 @@ const handleResize = (e) => {
|
|||||||
const minWidth = 200;
|
const minWidth = 200;
|
||||||
const minHeight = 150;
|
const minHeight = 150;
|
||||||
|
|
||||||
|
const treeWidth = isTreeCollapsed.value ? 0 : 240;
|
||||||
|
|
||||||
switch (resizeStart.value.edge) {
|
switch (resizeStart.value.edge) {
|
||||||
case 'top':
|
case 'top':
|
||||||
const newHeight = Math.max(minHeight, resizeStart.value.originalHeight - diffY);
|
const newHeightT = Math.max(minHeight, resizeStart.value.originalHeight - diffY);
|
||||||
popup.height = newHeight;
|
popup.height = newHeightT;
|
||||||
popup.top = `${Math.max(0, resizeStart.value.originalTop + (resizeStart.value.originalHeight - newHeight))}px`;
|
popup.top = `${Math.max(0, resizeStart.value.originalTop + (resizeStart.value.originalHeight - newHeightT))}px`;
|
||||||
break;
|
break;
|
||||||
case 'bottom':
|
case 'bottom':
|
||||||
popup.height = Math.max(minHeight, resizeStart.value.originalHeight + diffY);
|
popup.height = Math.max(minHeight, resizeStart.value.originalHeight + diffY);
|
||||||
break;
|
break;
|
||||||
case 'left':
|
case 'left':
|
||||||
const newWidth = Math.max(minWidth, resizeStart.value.originalWidth - diffX);
|
const newWidthL = Math.max(minWidth, resizeStart.value.originalWidth - diffX);
|
||||||
popup.width = newWidth;
|
popup.width = newWidthL;
|
||||||
popup.left = `${resizeStart.value.originalLeft + (resizeStart.value.originalWidth - newWidth)}px`;
|
popup.left = `${Math.max(treeWidth, resizeStart.value.originalLeft + (resizeStart.value.originalWidth - newWidthL))}px`;
|
||||||
break;
|
break;
|
||||||
case 'right':
|
case 'right':
|
||||||
popup.width = Math.max(minWidth, resizeStart.value.originalWidth + diffX);
|
popup.width = Math.max(minWidth, resizeStart.value.originalWidth + diffX);
|
||||||
@ -289,7 +544,7 @@ const handleResize = (e) => {
|
|||||||
const newHeightTL = Math.max(minHeight, resizeStart.value.originalHeight - diffY);
|
const newHeightTL = Math.max(minHeight, resizeStart.value.originalHeight - diffY);
|
||||||
popup.width = newWidthTL;
|
popup.width = newWidthTL;
|
||||||
popup.height = newHeightTL;
|
popup.height = newHeightTL;
|
||||||
popup.left = `${resizeStart.value.originalLeft + (resizeStart.value.originalWidth - newWidthTL)}px`;
|
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`;
|
popup.top = `${Math.max(0, resizeStart.value.originalTop + (resizeStart.value.originalHeight - newHeightTL))}px`;
|
||||||
break;
|
break;
|
||||||
case 'top-right':
|
case 'top-right':
|
||||||
@ -304,13 +559,23 @@ const handleResize = (e) => {
|
|||||||
const newHeightBL = Math.max(minHeight, resizeStart.value.originalHeight + diffY);
|
const newHeightBL = Math.max(minHeight, resizeStart.value.originalHeight + diffY);
|
||||||
popup.width = newWidthBL;
|
popup.width = newWidthBL;
|
||||||
popup.height = newHeightBL;
|
popup.height = newHeightBL;
|
||||||
popup.left = `${resizeStart.value.originalLeft + (resizeStart.value.originalWidth - newWidthBL)}px`;
|
popup.left = `${Math.max(treeWidth, resizeStart.value.originalLeft + (resizeStart.value.originalWidth - newWidthBL))}px`;
|
||||||
break;
|
break;
|
||||||
case 'bottom-right':
|
case 'bottom-right':
|
||||||
popup.width = Math.max(minWidth, resizeStart.value.originalWidth + diffX);
|
popup.width = Math.max(minWidth, resizeStart.value.originalWidth + diffX);
|
||||||
popup.height = Math.max(minHeight, resizeStart.value.originalHeight + diffY);
|
popup.height = Math.max(minHeight, resizeStart.value.originalHeight + diffY);
|
||||||
break;
|
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`; // 限制底部
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -334,15 +599,14 @@ const debounce = (fn, delay) => {
|
|||||||
|
|
||||||
// 更新容器和图片尺寸
|
// 更新容器和图片尺寸
|
||||||
const updateDimensions = debounce(() => {
|
const updateDimensions = debounce(() => {
|
||||||
console.log('图片',robotBg.value.width,robotBg.value.offsetWidth, robotBg.value.offsetHeight)
|
|
||||||
if (robotBg.value && robotBg.value.parentElement) {
|
if (robotBg.value && robotBg.value.parentElement) {
|
||||||
containerSize.value = {
|
containerSize.value = {
|
||||||
width: robotBg.value.parentElement.offsetWidth - 40, // 左右padding 20*2
|
width: robotBg.value.parentElement.offsetWidth - 40, // 左右padding 20*2
|
||||||
height: robotBg.value.parentElement.offsetHeight // 无垂直padding
|
height: robotBg.value.parentElement.offsetHeight // 无垂直padding
|
||||||
};
|
};
|
||||||
console.log('更新容器尺寸:', containerSize.value.width, 'x', containerSize.value.height);
|
// console.log('更新容器尺寸:', containerSize.value.width, 'x', containerSize.value.height);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 50);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@ -351,19 +615,41 @@ onMounted(() => {
|
|||||||
imageDimensions.value = { width: img.width, height: img.height };
|
imageDimensions.value = { width: img.width, height: img.height };
|
||||||
console.log('图片尺寸:', imageDimensions.value.width, 'x', imageDimensions.value.height);
|
console.log('图片尺寸:', imageDimensions.value.width, 'x', imageDimensions.value.height);
|
||||||
if (robotBg.value) {
|
if (robotBg.value) {
|
||||||
const observer = new ResizeObserver(updateDimensions);
|
// 监听 robot-panel 的父元素(通常是 #app 或 main-container)的尺寸变化
|
||||||
observer.observe(robotBg.value.parentElement);
|
const containerElement = robotBg.value.parentElement;
|
||||||
watch(() => isTreeCollapsed.value, updateDimensions, { immediate: true });
|
if (containerElement) {
|
||||||
updateDimensions(); // 初始更新
|
const observer = new ResizeObserver(updateDimensions);
|
||||||
onUnmounted(() => observer.disconnect());
|
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(() => {
|
// onUnmounted 已经包含在 onMounted 的回调中
|
||||||
document.removeEventListener('mousemove', handleResize);
|
|
||||||
document.removeEventListener('mouseup', stopResizingGlobal);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -375,18 +661,22 @@ onUnmounted(() => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
height: calc(100vh - 120px); /* 示例高度,请根据实际布局调整 */
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-panel {
|
.tree-panel {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-right: 1px solid #ddd;
|
border-right: 1px solid #ddd;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
height: auto;
|
height: 100%; /* 填充父容器高度 */
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
|
background-color: #f8f8f8; /* 示例背景色 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-panel:deep(.el-tree) {
|
.tree-panel:deep(.el-tree) {
|
||||||
padding: 10px !important;
|
padding: 10px !important;
|
||||||
|
background: transparent; /* 确保 treePanel 的背景色生效 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.robot-panel {
|
.robot-panel {
|
||||||
@ -395,7 +685,7 @@ onUnmounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0 20px;
|
padding: 0 20px; /* 左右内边距 */
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,12 +695,16 @@ onUnmounted(() => {
|
|||||||
background-position: center;
|
background-position: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative; /* 添加 relative 定位 */
|
/* position: relative; 添加 relative 定位 */
|
||||||
|
/* 初始设置一个尺寸,或者由 JS 动态计算 */
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
/* box-sizing: border-box; 包含 padding */
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-button {
|
.toggle-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 20px;
|
left: 20px; /* 紧贴 robot-panel 的左边距 */
|
||||||
top: 10px;
|
top: 10px;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@ -422,6 +716,7 @@ onUnmounted(() => {
|
|||||||
background-color: #e0e0e0;
|
background-color: #e0e0e0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
z-index: 2001;
|
z-index: 2001;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle-button:hover {
|
.toggle-button:hover {
|
||||||
@ -431,6 +726,8 @@ onUnmounted(() => {
|
|||||||
.clickable-area {
|
.clickable-area {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
/* background: rgba(255, 0, 0, 0.2); */ /* 用于调试区域 */
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickable-text {
|
.clickable-text {
|
||||||
@ -441,6 +738,9 @@ onUnmounted(() => {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
text-shadow: 0 0 5px #000;
|
text-shadow: 0 0 5px #000;
|
||||||
animation: blink 1.5s infinite;
|
animation: blink 1.5s infinite;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: bold;
|
||||||
|
pointer-events: none; /* 防止文本本身捕获点击事件 */
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
@ -454,10 +754,11 @@ onUnmounted(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: white;
|
background: white;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
overflow: auto;
|
overflow: hidden; /* 整体 overflow hidden,内容由子组件处理 */
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: width 0.2s, height 0.2s, top 0.2s, left 0.2s;
|
transition: width 0.2s, height 0.2s, top 0.2s, left 0.2s;
|
||||||
border: 1px solid #eee;
|
border: 1px solid #eee;
|
||||||
|
position: absolute; /* 确保 popup 是绝对定位 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup.dragging {
|
.popup.dragging {
|
||||||
@ -474,6 +775,7 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
border-bottom: 1px solid #eee; /* 增加分隔线 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-title {
|
.popup-title {
|
||||||
@ -485,8 +787,9 @@ onUnmounted(() => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
right: 60px;
|
right: 60px; /* 为右侧按钮留出空间 */
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
user-select: none; /* 标题不可选 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.popup-controls {
|
.popup-controls {
|
||||||
@ -502,6 +805,10 @@ onUnmounted(() => {
|
|||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: #f0f0f0;
|
background: #f0f0f0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-icon:hover {
|
.control-icon:hover {
|
||||||
@ -516,10 +823,11 @@ onUnmounted(() => {
|
|||||||
background: #ff6666;
|
background: #ff6666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-edge {
|
.resize-edge, .resize-corner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top { top: 0; left: 0; right: 0; height: 10px; cursor: ns-resize; }
|
.top { top: 0; left: 0; right: 0; height: 10px; cursor: ns-resize; }
|
||||||
@ -528,10 +836,10 @@ onUnmounted(() => {
|
|||||||
.right { top: 0; bottom: 0; right: 0; width: 5px; cursor: ew-resize; }
|
.right { top: 0; bottom: 0; right: 0; width: 5px; cursor: ew-resize; }
|
||||||
|
|
||||||
.resize-corner {
|
.resize-corner {
|
||||||
position: absolute;
|
width: 12px;
|
||||||
width: 10px;
|
height: 12px;
|
||||||
height: 10px;
|
background: rgba(0, 0, 0, 0.05); /* 增加一些可见性 */
|
||||||
background: rgba(0, 0, 0, 0.1);
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-left { top: 0; left: 0; cursor: nwse-resize; }
|
.top-left { top: 0; left: 0; cursor: nwse-resize; }
|
||||||
@ -542,4 +850,25 @@ onUnmounted(() => {
|
|||||||
.popup.resizing {
|
.popup.resizing {
|
||||||
transition: none;
|
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>
|
</style>
|
||||||
@ -36,7 +36,7 @@ export default defineConfig(({mode, command}) => {
|
|||||||
//赵 http://10.148.108.58:13080
|
//赵 http://10.148.108.58:13080
|
||||||
// dev http://10.148.20.34:13080
|
// dev http://10.148.20.34:13080
|
||||||
// target: command === 'build' ? VITE_API_URL : 'http://10.148.20.34:13080',
|
// target: command === 'build' ? VITE_API_URL : 'http://10.148.20.34:13080',
|
||||||
target: command === 'build' ? VITE_API_URL : 'http://192.168.0.10:13080',
|
target: command === 'build' ? VITE_API_URL : 'http://127.0.0.1:13080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (p) => p.replace(/^\/dev-api/, '')
|
rewrite: (p) => p.replace(/^\/dev-api/, '')
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user