Merge branch 'dev' of http://192.168.1.100:18088/smart_bench/cmvr-iot-ui into dev
This commit is contained in:
commit
bacb76d58e
BIN
src/assets/images/robot.png
Normal file
BIN
src/assets/images/robot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 311 KiB |
@ -3,7 +3,7 @@
|
|||||||
<el-row type="flex" class="height-full">
|
<el-row type="flex" class="height-full">
|
||||||
<el-col :span="4" class="control-panel" style="border-right: 10px solid rgb(40, 50, 60);">
|
<el-col :span="4" class="control-panel" style="border-right: 10px solid rgb(40, 50, 60);">
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<el-form :model="form" label-width="80px">
|
<el-form :model="form">
|
||||||
<el-form-item label="深度图像">
|
<el-form-item label="深度图像">
|
||||||
<el-switch v-model="form.stereoModule"></el-switch>
|
<el-switch v-model="form.stereoModule"></el-switch>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@ -17,21 +17,25 @@
|
|||||||
<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">
|
||||||
<img ref="depthImage" width="640" height="480" alt="Depth Image" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div class="stream-item">
|
<div class="stream-item">
|
||||||
<div class="stream-wrapper">
|
<div class="stream-wrapper">
|
||||||
<img ref="colorImage" width="640" height="480" alt="Color Image" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="single-stream-container">
|
<div v-else class="single-stream-container">
|
||||||
<div v-if="form.stereoModule" class="stream-wrapper">
|
<div v-if="form.stereoModule" class="stream-wrapper">
|
||||||
<img ref="depthImage" width="640" height="480" alt="Depth Image" />
|
<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>
|
||||||
<div v-if="form.rgbCamera" class="stream-wrapper">
|
<div v-if="form.rgbCamera" class="stream-wrapper">
|
||||||
<img ref="colorImage" width="640" height="480" alt="Color Image" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
@ -46,6 +50,7 @@
|
|||||||
import { inject, reactive, toRefs, watch, onMounted, onUnmounted, ref, nextTick } from 'vue';
|
import { inject, reactive, toRefs, watch, onMounted, onUnmounted, ref, nextTick } from 'vue';
|
||||||
import { getRegister } from "@/api/device/register";
|
import { getRegister } from "@/api/device/register";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
import { debounce } from 'lodash'; // 引入 lodash 的防抖函数
|
||||||
|
|
||||||
const socket = inject('ws');
|
const socket = inject('ws');
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -53,46 +58,49 @@ const data = reactive({
|
|||||||
form: {
|
form: {
|
||||||
stereoModule: false,
|
stereoModule: false,
|
||||||
rgbCamera: false,
|
rgbCamera: false,
|
||||||
|
|
||||||
},
|
},
|
||||||
terminalId: null,
|
terminalId: null,
|
||||||
cameraId: null,
|
cameraId: null,
|
||||||
type: 'camera',
|
type: 'camera',
|
||||||
callbacks: {}
|
callbacks: {},
|
||||||
});
|
});
|
||||||
const { form } = toRefs(data);
|
const { form } = toRefs(data);
|
||||||
const depthImage = ref(null);
|
const depthVideo = ref(null);
|
||||||
const colorImage = ref(null);
|
const depthVideoBuffer = ref(null);
|
||||||
|
const colorVideo = ref(null);
|
||||||
|
const colorVideoBuffer = ref(null);
|
||||||
const blobUrls = ref([]);
|
const blobUrls = ref([]);
|
||||||
const frameData = reactive({
|
const frameData = reactive({
|
||||||
getRGBImageStream: { count: 0, lastTime: 0 },
|
getRGBImageStream: { count: 0, lastTime: 0 },
|
||||||
getDepthImageStream: { count: 0, lastTime: 0 }
|
getDepthImageStream: { count: 0, lastTime: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderImage(imageRef, data, channel, imageType) {
|
function renderVideo(mainRef, bufferRef, data, channel, imageType) {
|
||||||
const image = imageRef.value; // 动态获取最新 ref
|
const mainVideo = mainRef.value;
|
||||||
if (!image) {
|
const bufferVideo = bufferRef.value;
|
||||||
console.error(`图像元素未找到 (${imageType}): 通道: ${channel}`);
|
if (!mainVideo || !bufferVideo) {
|
||||||
|
console.error(`视频元素未找到 (${imageType}): 通道: ${channel}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
try {
|
try {
|
||||||
if (typeof data === 'string') {
|
if (typeof data === 'string') {
|
||||||
if (data.startsWith('blob:')) {
|
const newSrc = data.startsWith('blob:') ? data : `data:video/mp4;base64,${data}`;
|
||||||
if (image.src && image.src.startsWith('blob:')) {
|
|
||||||
URL.revokeObjectURL(image.src);
|
// 先加载到缓冲视频元素
|
||||||
blobUrls.value = blobUrls.value.filter(url => url !== image.src);
|
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);
|
||||||
}
|
}
|
||||||
image.src = data;
|
mainVideo.src = bufferVideo.src;
|
||||||
blobUrls.value.push(data);
|
blobUrls.value.push(newSrc);
|
||||||
} else {
|
bufferVideo.src = ''; // 清空缓冲视频源
|
||||||
if (image.src && image.src.startsWith('blob:')) {
|
};
|
||||||
URL.revokeObjectURL(image.src);
|
|
||||||
blobUrls.value = blobUrls.value.filter(url => url !== image.src);
|
|
||||||
}
|
|
||||||
image.src = `data:image/jpeg;base64,${data}`;
|
|
||||||
}
|
|
||||||
console.log(`更新 image.src (${imageType}): ${image.src.substring(0, 50)}, 通道: ${channel}`);
|
|
||||||
frameData[imageType].count++;
|
frameData[imageType].count++;
|
||||||
const currentTime = performance.now();
|
const currentTime = performance.now();
|
||||||
if (frameData[imageType].lastTime && currentTime - frameData[imageType].lastTime >= 1000) {
|
if (frameData[imageType].lastTime && currentTime - frameData[imageType].lastTime >= 1000) {
|
||||||
@ -103,23 +111,23 @@ function renderImage(imageRef, data, channel, imageType) {
|
|||||||
} else if (!frameData[imageType].lastTime) {
|
} else if (!frameData[imageType].lastTime) {
|
||||||
frameData[imageType].lastTime = currentTime;
|
frameData[imageType].lastTime = currentTime;
|
||||||
}
|
}
|
||||||
console.log(`图像渲染耗时 (${imageType}): ${performance.now() - start}ms, 通道: ${channel}`);
|
console.log(`视频渲染耗时 (${imageType}): ${performance.now() - start}ms, 通道: ${channel}`);
|
||||||
if (performance.memory) {
|
if (performance.memory) {
|
||||||
console.log('当前内存使用:', {
|
console.log('当前内存使用:', {
|
||||||
usedJSHeapSize: (performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(2) + ' MB',
|
usedJSHeapSize: (performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(2) + ' MB',
|
||||||
totalJSHeapSize: (performance.memory.totalJSHeapSize / 1024 / 1024).toFixed(2) + ' MB'
|
totalJSHeapSize: (performance.memory.totalJSHeapSize / 1024 / 1024).toFixed(2) + ' MB',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(`未知 payload 类型 (${imageType}):`, typeof data, '通道:', channel);
|
console.error(`未知 payload 类型 (${imageType}):`, typeof data, '通道:', channel);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`图像渲染失败 (${imageType}):`, err, '通道:', channel);
|
console.error(`视频渲染失败 (${imageType}):`, err, '通道:', channel);
|
||||||
console.error('payload 数据:', data);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sunbcribe = async (sub, terminalId, method) => {
|
// 防抖订阅函数
|
||||||
|
const subscribeDebounced = debounce(async (sub, terminalId, method) => {
|
||||||
if (!socket || !terminalId || !data.cameraId) {
|
if (!socket || !terminalId || !data.cameraId) {
|
||||||
console.warn(`订阅失败: socket=${!!socket}, terminalId=${terminalId}, cameraId=${data.cameraId}, method=${method}`);
|
console.warn(`订阅失败: socket=${!!socket}, terminalId=${terminalId}, cameraId=${data.cameraId}, method=${method}`);
|
||||||
return;
|
return;
|
||||||
@ -129,14 +137,15 @@ const sunbcribe = async (sub, terminalId, method) => {
|
|||||||
socket.send({
|
socket.send({
|
||||||
type: 'channel_subscription',
|
type: 'channel_subscription',
|
||||||
action: sub ? 'subscribe' : 'unsubscribe',
|
action: sub ? 'subscribe' : 'unsubscribe',
|
||||||
channel
|
channel,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sub) {
|
if (sub) {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const imageRef = method === 'getRGBImageStream' ? colorImage : depthImage;
|
const mainRef = method === 'getRGBImageStream' ? colorVideo : depthVideo;
|
||||||
if (!imageRef.value) {
|
const bufferRef = method === 'getRGBImageStream' ? colorVideoBuffer : depthVideoBuffer;
|
||||||
console.error(`图像元素未找到: ${method}, 通道: ${channel}`);
|
if (!mainRef.value || !bufferRef.value) {
|
||||||
|
console.error(`视频元素未找到: ${method}, 通道: ${channel}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,11 +156,12 @@ const sunbcribe = async (sub, terminalId, method) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const callback = (data) => {
|
const callback = (data) => {
|
||||||
if(!(data == '500')) {
|
if (data !== '500') {
|
||||||
requestAnimationFrame(() => renderImage(imageRef, data, channel, method));
|
requestAnimationFrame(() => renderVideo(mainRef, bufferRef, data, channel, method));
|
||||||
} else {
|
} else {
|
||||||
sunbcribe(false, terminalId, method)
|
// 错误处理:取消订阅后重新订阅
|
||||||
sunbcribe(true, terminalId, method)
|
subscribeDebounced(false, terminalId, method);
|
||||||
|
subscribeDebounced(true, terminalId, method);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
socket.on(channel, callback);
|
socket.on(channel, callback);
|
||||||
@ -164,36 +174,19 @@ const sunbcribe = async (sub, terminalId, method) => {
|
|||||||
console.log('清理回调:', channel);
|
console.log('清理回调:', channel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}, 300); // 300ms 防抖延迟
|
||||||
|
|
||||||
// 监听 ref 变化,重新订阅
|
|
||||||
watch(depthImage, async (newVal, oldVal) => {
|
|
||||||
if (newVal !== oldVal && data.form.stereoModule && data.terminalId && data.cameraId) {
|
|
||||||
console.log('depthImage ref 变化,重新订阅 stereoModule');
|
|
||||||
await sunbcribe(false, data.terminalId, 'getDepthImageStream');
|
|
||||||
await sunbcribe(true, data.terminalId, 'getDepthImageStream');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(colorImage, async (newVal, oldVal) => {
|
|
||||||
if (newVal !== oldVal && data.form.rgbCamera && data.terminalId && data.cameraId) {
|
|
||||||
console.log('colorImage ref 变化,重新订阅 rgbCamera');
|
|
||||||
await sunbcribe(false, data.terminalId, 'getRGBImageStream');
|
|
||||||
await sunbcribe(true, data.terminalId, 'getRGBImageStream');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
// 监听开关变化
|
||||||
watch(() => data.form.stereoModule, async (newVal) => {
|
watch(() => data.form.stereoModule, async (newVal) => {
|
||||||
if (!data.terminalId || !data.cameraId) return;
|
if (!data.terminalId || !data.cameraId) return;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
await sunbcribe(newVal, data.terminalId, 'getDepthImageStream');
|
subscribeDebounced(newVal, data.terminalId, 'getDepthImageStream');
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(() => data.form.rgbCamera, async (newVal) => {
|
watch(() => data.form.rgbCamera, async (newVal) => {
|
||||||
if (!data.terminalId || !data.cameraId) return;
|
if (!data.terminalId || !data.cameraId) return;
|
||||||
await nextTick();
|
await nextTick();
|
||||||
await sunbcribe(newVal, data.terminalId, 'getRGBImageStream');
|
subscribeDebounced(newVal, data.terminalId, 'getRGBImageStream');
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@ -205,9 +198,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (socket && data.terminalId && data.cameraId) {
|
if (socket && data.terminalId && data.cameraId) {
|
||||||
socket.off(`edgeCameraServiceImpl/getRGBImageStream/${data.terminalId}/${data.cameraId}`);
|
const channels = [
|
||||||
socket.off(`edgeCameraServiceImpl/getDepthImageStream//${data.terminalId}/${data.cameraId}`);
|
`edgeCameraServiceImpl/getRGBImageStream/${data.terminalId}/${data.cameraId}`,
|
||||||
Object.keys(data.callbacks).forEach(channel => {
|
`edgeCameraServiceImpl/getDepthImageStream/${data.terminalId}/${data.cameraId}`,
|
||||||
|
];
|
||||||
|
channels.forEach(channel => {
|
||||||
socket.off(channel, data.callbacks[channel]);
|
socket.off(channel, data.callbacks[channel]);
|
||||||
console.log('卸载时清理回调:', channel);
|
console.log('卸载时清理回调:', channel);
|
||||||
});
|
});
|
||||||
@ -223,13 +218,10 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
// height: calc(100vh - 124px);
|
|
||||||
background-color: #000;
|
background-color: #000;
|
||||||
|
|
||||||
.height-full {
|
.height-full {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-panel {
|
.control-panel {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
.controls {
|
.controls {
|
||||||
@ -242,20 +234,17 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-panel {
|
.video-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.single-stream-container {
|
.single-stream-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
||||||
.stream-wrapper {
|
.stream-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -263,27 +252,25 @@ onUnmounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
img {
|
video {
|
||||||
width: auto;
|
width: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
transition: opacity 0.1s ease-in-out; /* 平滑过渡 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream-container {
|
.stream-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
|
||||||
.stream-item {
|
.stream-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.stream-wrapper {
|
.stream-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -291,11 +278,12 @@ onUnmounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
img {
|
video {
|
||||||
width: auto;
|
width: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
|
transition: opacity 0.1s ease-in-out; /* 平滑过渡 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,8 +60,8 @@ const handleUpdateBottomSeriesData = ({ maxData, avgData }) => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(100vh - 125px);
|
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -371,11 +371,6 @@ function handleControl(row) {
|
|||||||
path: fullPath,
|
path: fullPath,
|
||||||
}); // 使用路由跳转
|
}); // 使用路由跳转
|
||||||
// intoControlPage(`/${row.deviceModel}`, `${row.id}`)
|
// intoControlPage(`/${row.deviceModel}`, `${row.id}`)
|
||||||
console.log('route', route)
|
|
||||||
const { name } = route
|
|
||||||
if (name) {
|
|
||||||
useTagsViewStore().addView(route)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChange(row) {
|
function handleChange(row) {
|
||||||
|
|||||||
533
src/views/device/robot/index.vue
Normal file
533
src/views/device/robot/index.vue
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
<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-expanded-keys="['robot-node-config']" @node-click="handleNodeClick" />
|
||||||
|
</div>
|
||||||
|
<div class="robot-panel">
|
||||||
|
<div id="robot-bg" ref="robotBg" :style="{ backgroundImage: `url(${robotImage})` }">
|
||||||
|
<div
|
||||||
|
v-for="area in adjustedAreas"
|
||||||
|
:key="area.id"
|
||||||
|
class="clickable-area"
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
top: area.y + 'px',
|
||||||
|
left: area.x + 'px',
|
||||||
|
width: area.width + 'px',
|
||||||
|
height: area.height + 'px'
|
||||||
|
}"
|
||||||
|
@click="openPopup(area)"
|
||||||
|
>
|
||||||
|
<span class="clickable-text">{{ area.label }}</span>
|
||||||
|
</div>
|
||||||
|
</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" />
|
||||||
|
<!-- 边缘拖动区域 -->
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed, onUnmounted, watch } 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 image from '@/assets/images/robot.png';
|
||||||
|
|
||||||
|
// 组件映射
|
||||||
|
const componentsMap = {
|
||||||
|
HandControl,
|
||||||
|
CameraView,
|
||||||
|
MusicPlayer
|
||||||
|
};
|
||||||
|
|
||||||
|
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); // 提高初始 z-index
|
||||||
|
const dragIndex = ref(null); // 跟踪当前拖动的索引
|
||||||
|
|
||||||
|
const resizeStart = ref({ index: null, startX: 0, startY: 0, originalWidth: 0, originalHeight: 0, originalTop: 0, originalLeft: 0, edge: '' });
|
||||||
|
|
||||||
|
// 树数据
|
||||||
|
const treeData = ref([
|
||||||
|
{
|
||||||
|
id: 'robot-node-config', // 添加唯一 ID 用于默认展开
|
||||||
|
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'
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNodeClick = (data) => {
|
||||||
|
console.log('点击节点', data.label);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 原始坐标和尺寸(基于图片真实尺寸),添加 label 用于显示文字
|
||||||
|
const originalAreas = ref([
|
||||||
|
{ id: 'hand-left', x: 50, y: 200, width: 50, height: 100, component: HandControl, label: '左手' },
|
||||||
|
{ id: 'hand-right', x: 400, y: 200, width: 50, height: 100, component: HandControl, label: '右手' },
|
||||||
|
{ id: 'camera', x: 225, y: 100, width: 50, height: 50, component: CameraView, label: '摄像头' },
|
||||||
|
{ id: 'mouth', x: 225, y: 300, width: 50, height: 50, component: MusicPlayer, label: '扬声器' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const imageDimensions = ref({ width: 0, height: 0 });
|
||||||
|
|
||||||
|
const adjustedAreas = computed(() => {
|
||||||
|
if (!imageDimensions.value.width || !imageDimensions.value.height || !robotBg.value) return [];
|
||||||
|
const containerWidth = robotBg.value.parentElement.offsetWidth - 40 - (isTreeCollapsed.value ? 0 : 240); // 减去外层 padding 和 tree-panel 宽度
|
||||||
|
console.log(containerWidth)
|
||||||
|
const containerHeight = robotBg.value.parentElement.offsetHeight - 40; // 减去外层 padding
|
||||||
|
const scaleX = containerWidth / imageDimensions.value.width;
|
||||||
|
const scaleY = containerHeight / imageDimensions.value.height;
|
||||||
|
const scale = Math.min(scaleX, scaleY); // 取最小缩放比例以适配
|
||||||
|
const offsetX = (containerWidth - (imageDimensions.value.width * scale)) / 2;
|
||||||
|
const offsetY = (containerHeight - (imageDimensions.value.height * scale)) / 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
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const openPopup = (area) => {
|
||||||
|
// 检查是否已存在相同 id 的窗口
|
||||||
|
const existingPopup = popups.value.find(p => p.id === area.id);
|
||||||
|
if (existingPopup) {
|
||||||
|
console.log('Popup with id', area.id, 'is already open');
|
||||||
|
return; // 如果已打开,阻止重复打开
|
||||||
|
}
|
||||||
|
const popup = {
|
||||||
|
id: area.id,
|
||||||
|
component: area.component, // 使用组件引用
|
||||||
|
top: area.y + 'px',
|
||||||
|
left: area.x + 'px',
|
||||||
|
zIndex: currentZIndex.value++,
|
||||||
|
width: 640, // 默认宽度
|
||||||
|
height: 480, // 默认高度
|
||||||
|
originalWidth: 640, // 初始化原始宽度
|
||||||
|
originalHeight: 480, // 初始化原始高度
|
||||||
|
originalTop: area.y + 'px', // 初始化原始顶部位置
|
||||||
|
originalLeft: area.x + 'px', // 初始化原始左侧位置
|
||||||
|
isMaximized: false,
|
||||||
|
props: { terminalId: 'your-terminal-id', cameraId: 'your-camera-id' } // 替换为实际值
|
||||||
|
};
|
||||||
|
popups.value.push(popup);
|
||||||
|
isResizing.value.push(false);
|
||||||
|
console.log('Opened popup with id', area.id, 'initial size:', popup.width, 'x', popup.height, 'position:', popup.top, popup.left);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePopup = (index) => {
|
||||||
|
popups.value.splice(index, 1);
|
||||||
|
isResizing.value.splice(index, 1);
|
||||||
|
console.log('Closed popup at index', 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('Minimized popup', popup.id, 'to size:', popup.width, 'x', popup.height, 'position:', 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; // 保存当前左侧位置
|
||||||
|
popup.width = window.innerWidth; // 全屏宽度
|
||||||
|
popup.height = window.innerHeight; // 全屏高度
|
||||||
|
popup.top = '0px'; // 从顶部开始
|
||||||
|
popup.left = '0px'; // 从左侧开始
|
||||||
|
popup.isMaximized = true;
|
||||||
|
console.log('Maximized popup', popup.id, 'to size:', popup.width, 'x', popup.height, 'position:', popup.top, popup.left);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDragging = (e, index) => {
|
||||||
|
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);
|
||||||
|
console.log('Start dragging', index, 'ClientY:', e.clientY, 'Handle height:', e.currentTarget.offsetHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragHandler = (e) => {
|
||||||
|
if (isDragging.value && dragIndex.value !== null) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const popup = popups.value[dragIndex.value];
|
||||||
|
const newTop = Math.max(0, e.pageY - popup.startY); // 限制顶部不小于 0
|
||||||
|
const newLeft = e.pageX - popup.startX; // 左侧无限制
|
||||||
|
popup.top = `${newTop}px`;
|
||||||
|
popup.left = `${newLeft}px`;
|
||||||
|
console.log('Dragging', dragIndex.value, 'New position:', popup.left, popup.top);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDragging = () => {
|
||||||
|
isDragging.value = false;
|
||||||
|
dragIndex.value = null;
|
||||||
|
document.removeEventListener('mousemove', dragHandler);
|
||||||
|
document.removeEventListener('mouseup', stopDragging);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startResizing = (e, index, edge) => {
|
||||||
|
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;
|
||||||
|
document.addEventListener('mousemove', handleResize);
|
||||||
|
document.addEventListener('mouseup', stopResizingGlobal);
|
||||||
|
console.log('Start resizing', edge);
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
switch (resizeStart.value.edge) {
|
||||||
|
case 'top':
|
||||||
|
const newHeight = Math.max(minHeight, resizeStart.value.originalHeight - diffY);
|
||||||
|
popup.height = newHeight;
|
||||||
|
popup.top = `${Math.max(0, resizeStart.value.originalTop + (resizeStart.value.originalHeight - newHeight))}px`; // 限制顶部
|
||||||
|
console.log('Resizing top', 'newHeight:', newHeight, 'newTop:', popup.top);
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
popup.height = Math.max(minHeight, resizeStart.value.originalHeight + diffY);
|
||||||
|
console.log('Resizing bottom', 'newHeight:', popup.height);
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
const newWidth = Math.max(minWidth, resizeStart.value.originalWidth - diffX);
|
||||||
|
popup.width = newWidth;
|
||||||
|
popup.left = `${resizeStart.value.originalLeft + (resizeStart.value.originalWidth - newWidth)}px`;
|
||||||
|
console.log('Resizing left', 'newWidth:', newWidth, 'newLeft:', popup.left);
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
popup.width = Math.max(minWidth, resizeStart.value.originalWidth + diffX);
|
||||||
|
console.log('Resizing right', 'newWidth:', popup.width);
|
||||||
|
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 = `${resizeStart.value.originalLeft + (resizeStart.value.originalWidth - newWidthTL)}px`;
|
||||||
|
popup.top = `${Math.max(0, resizeStart.value.originalTop + (resizeStart.value.originalHeight - newHeightTL))}px`; // 限制顶部
|
||||||
|
console.log('Resizing top-left', 'newWidth:', newWidthTL, 'newHeight:', newHeightTL, 'newTop:', popup.top);
|
||||||
|
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`; // 限制顶部
|
||||||
|
console.log('Resizing top-right', 'newWidth:', newWidthTR, 'newHeight:', newHeightTR, 'newTop:', popup.top);
|
||||||
|
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 = `${resizeStart.value.originalLeft + (resizeStart.value.originalWidth - newWidthBL)}px`;
|
||||||
|
console.log('Resizing bottom-left', 'newWidth:', newWidthBL, 'newHeight:', newHeightBL, 'newLeft:', popup.left);
|
||||||
|
break;
|
||||||
|
case 'bottom-right':
|
||||||
|
popup.width = Math.max(minWidth, resizeStart.value.originalWidth + diffX);
|
||||||
|
popup.height = Math.max(minHeight, resizeStart.value.originalHeight + diffY);
|
||||||
|
console.log('Resizing bottom-right', 'newWidth:', popup.width, 'newHeight:', popup.height);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = robotImage;
|
||||||
|
img.onload = () => {
|
||||||
|
imageDimensions.value = { width: img.width, height: img.height };
|
||||||
|
if (robotBg.value) {
|
||||||
|
watch(() => [robotBg.value.parentElement.offsetWidth, robotBg.value.parentElement.offsetHeight, isTreeCollapsed.value], ([newWidth, newHeight]) => {
|
||||||
|
const containerWidth = newWidth - 40 - (isTreeCollapsed.value ? 0 : 240); // 减去外层 padding 和 tree-panel 宽度
|
||||||
|
const containerHeight = newHeight - 40; // 减去外层 padding
|
||||||
|
const scale = Math.min(containerWidth / img.width, containerHeight / img.height);
|
||||||
|
robotBg.value.style.minWidth = `${img.width * scale}px`;
|
||||||
|
robotBg.value.style.minHeight = `${img.height * scale}px`;
|
||||||
|
}, { immediate: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousemove', handleResize);
|
||||||
|
document.removeEventListener('mouseup', stopResizingGlobal);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.robot-container {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
display: flex; /* 保持与外层一致 */
|
||||||
|
overflow: hidden; /* 防止遮挡弹窗 */
|
||||||
|
padding: 0; /* 外层已处理 padding */
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-panel {
|
||||||
|
flex-shrink: 0; /* 防止收缩 */
|
||||||
|
/* padding: 10px; */
|
||||||
|
border-right: 1px solid #ddd;
|
||||||
|
overflow-y: auto; /* 垂直滚动 */
|
||||||
|
height: auto; /* 动态高度 */
|
||||||
|
transition: width 0.3s ease; /* 平滑过渡 */
|
||||||
|
|
||||||
|
}
|
||||||
|
.tree-panel:deep(.el-tree) {
|
||||||
|
padding: 10px !important;
|
||||||
|
}
|
||||||
|
.robot-panel {
|
||||||
|
flex-grow: 1; /* 填满剩余空间 */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center; /* 水平居中 */
|
||||||
|
align-items: center; /* 垂直居中 */
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 20px; /* 补偿外层 padding */
|
||||||
|
position: relative; /* 确保按钮定位 */
|
||||||
|
}
|
||||||
|
|
||||||
|
#robot-bg {
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
min-width: 0; /* 防止收缩 */
|
||||||
|
min-height: 0; /* 防止收缩 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button {
|
||||||
|
position: absolute;
|
||||||
|
left: 20px; /* 贴近左边边 */
|
||||||
|
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; /* 确保高于其他元素 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button:hover {
|
||||||
|
background-color: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clickable-area {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: white;
|
||||||
|
user-select: none;
|
||||||
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: width 0.2s, height 0.2s, top 0.2s, left 0.2s; /* 平滑过渡 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; /* 标题居中 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; /* 居中文本 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-icon:hover {
|
||||||
|
background: #d0d0d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon:hover {
|
||||||
|
background: #ff6666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 边缘拖动区域 */
|
||||||
|
.resize-edge {
|
||||||
|
position: absolute;
|
||||||
|
background: transparent;
|
||||||
|
z-index: 2; /* 提高拖动区域优先级 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.top { top: 0; left: 0; right: 0; height: 10px; cursor: ns-resize; } /* 增加高度以覆盖 drag-handle */
|
||||||
|
.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 {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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; /* 缩放时禁用过渡 */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in New Issue
Block a user