Compare commits

...

3 Commits

Author SHA1 Message Date
4b170fa95a Merge branch 'feature/unified-resource-center' of http://192.168.28.10:18088/SmartBench/CMVR-IOT-UI into feature/unified-resource-center 2026-08-25 17:00:24 +08:00
59a020da32 fix(env): 更新API和WebSocket URL为正确的开发环境地址 2026-08-25 16:54:26 +08:00
e64af95cd1 refactor: 移除VueFlow组件及相关逻辑
- 从VueFlow目录中删除了RuntimeNode.vue、customFlow.vue和index.vue组件。
- 从robotManger目录中移除了ContextMenu.vue和TechButtonWithLine.vue组件。
- 通过移除控制页面导航逻辑和数字转换工具,对index.js进行了整理。
2026-08-25 15:16:09 +08:00
41 changed files with 4 additions and 6795 deletions

View File

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

View File

@ -1,26 +0,0 @@
import request from '@/utils/request'
// 移动机械臂
export function moveJ(data) {
return request({
url: '/api/humanoid/moveJ',
method: 'post',
data: data
})
}
// 获取机械臂状态
export function status(data) {
return request({
url: '/api/humanoid/status',
method: 'post',
data: data
})
}
export function clearFault(params) {
return request({
url: '/api/arm/clearFault',
method: 'get',
params
})
}

View File

@ -1,9 +0,0 @@
import request from '@/utils/request'
export function getRgbStreamUrl(robotId, deviceId) {
return request({
url: '/api/edge/camera/rgb-stream-url',
method: 'get',
params: { robotId, deviceId }
})
}

View File

@ -1,19 +0,0 @@
import request from '@/utils/request'
// 获取灵巧手状态
export function status(query) {
return request({
url: '/api/dexHand/status',
method: 'get',
params: query
})
}
// 设置灵巧手弯曲角度
export function setDexHandAngle(query) {
return request({
url: '/api/dexHand/angle',
method: 'get',
params: query
})
}

View File

@ -1,46 +0,0 @@
import request from '@/utils/request'
// 开始录音
export function startMicrophoneApi(data) {
return request({
url: '/api/Microphone/start',
method: 'post',
data: data
})
}
// 暂停录音
export function pauseMicrophoneApi(data) {
return request({
url: '/api/Microphone/pause',
method: 'post',
data: data
})
}
// 恢复录音
export function resumeMicrophoneApi(data) {
return request({
url: '/api/Microphone/resume',
method: 'post',
data: data
})
}
// 停止录音
export function stopMicrophoneApi(data) {
return request({
url: '/api/Microphone/stop',
method: 'post',
data: data
})
}
// 播放音频
export function playSpeaker(data) {
return request({
url: '/api/Speaker/play',
method: 'post',
data: data
})
}

View File

@ -1,72 +0,0 @@
import request from '@/utils/request'
// 暂停播放
export function pause(data) {
return request({
url: '/api/Speaker/pause',
method: 'post',
data: data
})
}
// 播放音频
export function play(data) {
return request({
url: '/api/Speaker/play',
method: 'post',
data: data
})
}
// 恢复播放
export function resume(data) {
return request({
url: '/api/Speaker/resume',
method: 'post',
data: data
})
}
// 获取扬声器状态
export function status(query) {
return request({
url: '/api/Speaker/status',
method: 'get',
params: query
})
}
// 停止播放
export function stop(data) {
return request({
url: '/api/Speaker/stop',
method: 'post',
data: data
})
}
// 获取音量
export function getVolume(query) {
return request({
url: '/api/Speaker/volume',
method: 'get',
params: query
})
}
// 设置音量
export function setVolume(data) {
return request({
url: '/api/Speaker/volume',
method: 'post',
data: data
})
}
// 设置音量
export function getAll() {
return request({
url: '/api/Speaker/getAll',
method: 'get',
})
}

View File

@ -1,14 +0,0 @@
import request from "@/utils/request.js";
export function coordinateMarking(data) {
return request({
url: '/show/touch/coordinates',
method: 'post',
headers: {
isToken: false,
'Content-Type': 'multipart/form-data',
'Authorization':'Bearer eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImIzY2QwYjFjLWNlNjgtNDEwNy1hYzA1LTdmYTBlNmQ4NzJmNiJ9.2N9E9IDrFeCe-yRIBly60N0kYYmCscdrhyd8KHmhw2dxLi7B_A1i8bcWRTtx9YryFB3AhkvZkGauMx0CdZ0TDg'
},
data: data
})
}

View File

@ -1,30 +0,0 @@
import request from '@/utils/request'
const serviceHeaders = {
Authorization: 'eyJhbGciOiJIUzUxMiJ9.eyJsb2dpbl91c2VyX2tleSI6ImFiNmEyY2Y0LWM3MjQtNDgyMy1hM2NiLTk4N2NhZWNmMzZiNCJ9.oq-JVRbQuq_ujQ2V0h2jXC3woHPdMQKhnYJ8ixRm-C6Ez4GqJWyHYgmnkw5dbDyhWGdIUH02uv6XT8UiboHD4g',
isToken: false,
}
export function createTimbre(data) {
return request({ url: '/show/tts/create', method: 'post', headers: { ...serviceHeaders, 'Content-Type': 'multipart/form-data' }, data })
}
export function getTts() {
return request({ url: '/show/tts/query', method: 'get', headers: serviceHeaders })
}
export function createCorpus(data) {
return request({ url: '/show/tts/corpus', method: 'post', headers: serviceHeaders, data })
}
export function getSynthesize(data, config = {}) {
return request({ url: '/show/tts/synthesize', method: 'post', headers: serviceHeaders, responseType: 'blob', data }, config)
}
export function createAdvancedCorpus(data) {
return request({ url: '/show/tts/corpus/advanced', method: 'post', headers: serviceHeaders, data })
}
export function getAdvancedSynthesize(data, config = {}) {
return request({ url: '/show/tts/synthesize/advanced', method: 'post', headers: serviceHeaders, responseType: 'blob', data }, config)
}

View File

@ -1,7 +1,7 @@
import { createWebHistory, createRouter } from "vue-router"; import { createWebHistory, createRouter } from "vue-router";
/* Layout */ /* Layout */
import Layout from "@/layout"; import Layout from "@/layout";
import { resolveCommonView } from '@cmvr/iot-common' import { createDeviceRoutes, resolveCommonView } from '@cmvr/iot-common'
const commonView = (view) => { const commonView = (view) => {
const component = resolveCommonView(view) const component = resolveCommonView(view)
@ -66,83 +66,7 @@ export const constantRoutes = [
component: commonView('error/401'), component: commonView('error/401'),
hidden: true, hidden: true,
}, },
{ ...createDeviceRoutes(Layout),
path: "/device",
component: Layout,
children: [
{
path: "mechanical_arm/:id",
component: () =>
import("@/views/device/register/components/MechanicalArm/index.vue"),
name: "机械臂示教",
meta: { title: "机械臂详情" },
hidden: true,
},
{
path: "camera/:id",
component: () =>
import("@/views/device/register/components/Camera/index.vue"),
name: "摄像头示教",
meta: { title: "摄像头详情" },
hidden: true,
},
{
path: "head/:id",
component: () =>
import("@/views/device/register/components/Head/index.vue"),
name: "人工头示教",
meta: { title: "人工头详情" },
hidden: true,
},
{
path: "speaker/:id",
component: () =>
import("@/views/device/register/components/Speaker/index.vue"),
name: "扬声器示教",
meta: { title: "扬声器详情" },
hidden: true,
},
{
path: "hand/:id",
component: () =>
import("@/views/device/register/components/DexHand/index.vue"),
name: "灵巧手示教",
meta: { title: "灵巧手详情" },
hidden: true,
},{
path: "microphone/:id",
component: () =>
import("@/views/device/register/components/Microphone/index.vue"),
name: "麦克风示教",
meta: { title: "麦克风详情" },
hidden: true,
}
],
},
{
//语音合成
path: "/speech_synthesis",
component: () =>
import("@/views/device/register/components/SpeechSynthesis/index.vue"),
meta: { title: "语音合成", noLogin: true },
hidden: true,
},
{
//触摸交互
path: "/touch_interaction",
component: () =>
import("@/views/device/register/components/TouchInteraction/index.vue"),
meta: { title: "触摸交互", noLogin: true },
hidden: true,
},
{
// 流程运行状态
path: "/logic_flow",
component: () =>
import("@/views/device/register/components/VueFlow/index.vue"),
meta: { title: "流程运行状态", noLogin: true },
hidden: true,
},
{ {
// 流程编排 // 流程编排
path: "/flow", path: "/flow",
@ -150,13 +74,6 @@ export const constantRoutes = [
meta: { title: "流程编排", noLogin: true }, meta: { title: "流程编排", noLogin: true },
hidden: true, hidden: true,
}, },
{
//电机路由
path: "/Motor/:id",
component: () =>
import("@/views/device/register/components/Motor/index.vue"),
hidden: true,
},
{ {
path: "", path: "",
component: Layout, component: Layout,

View File

@ -1,553 +0,0 @@
<template>
<div class="realsense-viewer">
<el-row type="flex" class="height-full">
<el-col :span="4" class="control-panel" style="border-right: 10px solid rgb(40, 50, 60);">
<div class="controls">
<el-form :model="form">
<el-form-item label="深度图像">
<el-switch v-model="form.stereoModule"></el-switch>
</el-form-item>
<el-form-item label="彩色图像">
<el-switch v-model="form.rgbCamera"></el-switch>
</el-form-item>
</el-form>
</div>
</el-col>
<el-col :span="20" class="video-panel" v-if="form.stereoModule || form.rgbCamera">
<div class="stream-container" :class="{ dual: form.stereoModule && form.rgbCamera }">
<div v-show="form.stereoModule" class="stream-item">
<div class="stream-wrapper">
<video ref="depthVideo" autoplay muted playsinline></video>
<span class="stream-state">{{ statusText(streamStatus.getDepthImageStream) }}</span>
</div>
</div>
<div v-show="form.rgbCamera" class="stream-item">
<div class="stream-wrapper">
<video ref="colorVideo" autoplay muted playsinline></video>
<span class="stream-state">{{ statusText(streamStatus.getRGBImageStream) }}</span>
</div>
</div>
</div>
</el-col>
<el-col v-else :span="20" style="align-self: center;">
<el-empty description="请选择模式" />
</el-col>
</el-row>
</div>
</template>
<script setup>
import { inject, reactive, toRefs, watch, onUnmounted, ref, nextTick } from 'vue';
import { getResourceDevice } from '@/api/resource/device';
import { getRgbStreamUrl } from "@/api/device/camera";
import { useRoute } from "vue-router";
const props = defineProps({
initialDeviceId: {
type: String,
default: null,
},
initialRobotId: {
type: String,
default: null,
},
});
const socket = inject('ws');
const route = useRoute();
const data = reactive({
form: {
stereoModule: false,
rgbCamera: false,
},
robotId: null,
cameraId: null,
type: 'camera',
callbacks: {},
});
const { form } = toRefs(data);
const depthVideo = ref(null);
const colorVideo = ref(null);
const streamStatus = reactive({
getRGBImageStream: 'idle',
getDepthImageStream: 'idle',
});
const mediaPlayers = new Map();
const retryTimers = new Map();
let rgbStreamGeneration = 0;
let rgbStreamRetryTimer = null;
let rgbStreamAbortController = null;
const MAX_QUEUE_BYTES = 2 * 1024 * 1024;
const STARTUP_BUFFER_SECONDS = 0.35;
const TARGET_LIVE_LATENCY_SECONDS = 0.45;
const MAX_PLAYED_BUFFER_SECONDS = 2;
const PLAYBACK_SYNC_INTERVAL_MS = 200;
const statusText = (status) => ({
connecting: '连接中',
live: '实时',
retrying: '重连中',
error: '播放异常',
}[status] || '未连接');
function getVideoRef(method) {
return method === 'getRGBImageStream' ? colorVideo : depthVideo;
}
async function startDirectRgbStream() {
if (!data.robotId || !data.cameraId || !colorVideo.value) return;
rgbStreamAbortController?.abort();
rgbStreamAbortController = null;
const generation = ++rgbStreamGeneration;
clearTimeout(rgbStreamRetryTimer);
rgbStreamRetryTimer = null;
streamStatus.getRGBImageStream = 'connecting';
try {
const response = await getRgbStreamUrl(data.robotId, data.cameraId);
if (generation !== rgbStreamGeneration || !data.form.rgbCamera) return;
const relativeUrl = response.data;
const baseUrl = import.meta.env.VITE_APP_BASE_API.replace(/\/$/, '');
const video = colorVideo.value;
if (!video) return;
const player = createMediaPlayer(
'getRGBImageStream',
video,
'video/mp4; codecs="avc1.42C01E"'
);
rgbStreamAbortController = new AbortController();
consumeDirectRgbStream(
`${baseUrl}${relativeUrl}`,
generation,
player,
rgbStreamAbortController.signal
);
} catch (error) {
if (generation === rgbStreamGeneration && data.form.rgbCamera) {
console.error('Failed to open the direct RGB video stream:', error);
scheduleDirectRgbRestart();
}
}
}
async function consumeDirectRgbStream(url, generation, player, signal) {
try {
const response = await fetch(url, { signal, cache: 'no-store' });
if (!response.ok || !response.body) {
throw new Error(`RGB video stream request failed: HTTP ${response.status}`);
}
const reader = response.body.getReader();
while (generation === rgbStreamGeneration && data.form.rgbCamera) {
const { done, value } = await reader.read();
if (done) break;
if (!value?.byteLength || player.destroyed) continue;
const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
player.queue.push(chunk);
player.queueBytes += chunk.byteLength;
if (player.queueBytes > MAX_QUEUE_BYTES) {
throw new Error(`RGB video stream queue exceeded ${MAX_QUEUE_BYTES} bytes`);
}
pumpMediaQueue(player);
}
if (!signal.aborted && generation === rgbStreamGeneration && data.form.rgbCamera) {
scheduleDirectRgbRestart();
}
} catch (error) {
if (error?.name !== 'AbortError' && generation === rgbStreamGeneration && data.form.rgbCamera) {
console.error('Direct RGB video stream interrupted:', error);
scheduleDirectRgbRestart();
}
}
}
function stopDirectRgbStream() {
rgbStreamGeneration++;
clearTimeout(rgbStreamRetryTimer);
rgbStreamRetryTimer = null;
rgbStreamAbortController?.abort();
rgbStreamAbortController = null;
destroyMediaPlayer('getRGBImageStream');
streamStatus.getRGBImageStream = 'idle';
}
function scheduleDirectRgbRestart() {
if (rgbStreamRetryTimer || !data.form.rgbCamera) return;
rgbStreamAbortController?.abort();
rgbStreamAbortController = null;
destroyMediaPlayer('getRGBImageStream');
streamStatus.getRGBImageStream = 'retrying';
rgbStreamRetryTimer = setTimeout(() => {
rgbStreamRetryTimer = null;
startDirectRgbStream();
}, 1000);
}
function createMediaPlayer(method, video, mimeType) {
if (!window.MediaSource || !MediaSource.isTypeSupported(mimeType)) {
streamStatus[method] = 'error';
throw new Error(`浏览器不支持媒体格式: ${mimeType}`);
}
destroyMediaPlayer(method);
const mediaSource = new MediaSource();
const objectUrl = URL.createObjectURL(mediaSource);
const state = {
method,
video,
mimeType,
mediaSource,
sourceBuffer: null,
objectUrl,
queue: [],
queueBytes: 0,
lastSequence: 0,
started: false,
syncTimer: null,
destroyed: false,
};
mediaPlayers.set(method, state);
video.src = objectUrl;
mediaSource.addEventListener('sourceopen', () => {
if (state.destroyed || state.sourceBuffer) return;
try {
const sourceBuffer = mediaSource.addSourceBuffer(mimeType);
state.sourceBuffer = sourceBuffer;
sourceBuffer.addEventListener('updateend', () => pumpMediaQueue(state));
sourceBuffer.addEventListener('error', () => restartMediaStream(method));
state.syncTimer = window.setInterval(() => maintainLivePlayback(state), PLAYBACK_SYNC_INTERVAL_MS);
pumpMediaQueue(state);
} catch (error) {
console.error('创建视频缓冲区失败:', error);
restartMediaStream(method);
}
}, { once: true });
return state;
}
function appendMediaPacket(method, packet) {
const video = getVideoRef(method).value;
if (!video) return;
let state = mediaPlayers.get(method);
if (!state || state.mimeType !== packet.mimeType || state.video !== video) {
state = createMediaPlayer(method, video, packet.mimeType);
}
if (state.lastSequence && packet.sequence !== state.lastSequence + 1) {
console.warn(`视频片段序号中断: ${state.lastSequence} -> ${packet.sequence}`);
scheduleRestart(method);
return;
}
state.lastSequence = packet.sequence;
state.queue.push(packet.data);
state.queueBytes += packet.data.byteLength;
if (state.queueBytes > MAX_QUEUE_BYTES) {
console.warn(`视频缓冲积压超过${MAX_QUEUE_BYTES / 1024 / 1024}MB,重新建立实时流`);
scheduleRestart(method);
return;
}
pumpMediaQueue(state);
}
function pumpMediaQueue(state) {
const sourceBuffer = state.sourceBuffer;
if (state.destroyed || !sourceBuffer || sourceBuffer.updating) return;
try {
maintainLivePlayback(state);
const buffered = sourceBuffer.buffered;
if (buffered.length > 0 && state.started) {
const oldestBufferedTime = buffered.start(0);
const removeEnd = state.video.currentTime - 1;
if (removeEnd - oldestBufferedTime > MAX_PLAYED_BUFFER_SECONDS) {
sourceBuffer.remove(oldestBufferedTime, removeEnd);
return;
}
}
if (state.queue.length === 0) return;
const chunk = state.queue.shift();
state.queueBytes -= chunk.byteLength;
sourceBuffer.appendBuffer(chunk);
streamStatus[state.method] = 'live';
} catch (error) {
console.error('追加视频片段失败:', error);
restartMediaStream(state.method);
}
}
function restartMediaStream(method) {
if (method === 'getRGBImageStream') {
scheduleDirectRgbRestart();
} else {
scheduleRestart(method);
}
}
function maintainLivePlayback(state) {
const { sourceBuffer, video } = state;
if (state.destroyed || !sourceBuffer || sourceBuffer.updating || sourceBuffer.buffered.length === 0) return;
const buffered = sourceBuffer.buffered;
const rangeIndex = buffered.length - 1;
const liveStart = buffered.start(rangeIndex);
const liveEnd = buffered.end(rangeIndex);
const bufferedDuration = liveEnd - liveStart;
if (!state.started) {
if (bufferedDuration < STARTUP_BUFFER_SECONDS) return;
video.currentTime = Math.max(liveStart, liveEnd - TARGET_LIVE_LATENCY_SECONDS);
video.playbackRate = 1;
state.started = true;
video.play().catch(() => {});
return;
}
if (video.paused) {
video.play().catch(() => {});
}
if (video.seeking) return;
const latency = liveEnd - video.currentTime;
let playbackRate = 1;
if (latency > 1.4) {
playbackRate = 1.2;
} else if (latency > 1) {
playbackRate = 1.12;
} else if (latency > 0.7) {
playbackRate = 1.05;
} else if (latency < 0.28) {
playbackRate = 0.98;
}
if (Math.abs(video.playbackRate - playbackRate) > 0.001) {
video.playbackRate = playbackRate;
}
}
function destroyMediaPlayer(method) {
const state = mediaPlayers.get(method);
if (!state) return;
state.destroyed = true;
state.queue = [];
state.queueBytes = 0;
if (state.syncTimer) {
clearInterval(state.syncTimer);
state.syncTimer = null;
}
const video = state.video;
if (video) {
video.playbackRate = 1;
video.pause();
video.removeAttribute('src');
video.load();
}
URL.revokeObjectURL(state.objectUrl);
mediaPlayers.delete(method);
}
function channelFor(method) {
return `edgeCameraServiceImpl/${method}/${data.robotId}/${data.cameraId}`;
}
async function setSubscription(enabled, method, force = false) {
if (!socket || !data.robotId || !data.cameraId) return;
await nextTick();
const channel = channelFor(method);
if (enabled) {
if (data.callbacks[channel] && !force) return;
streamStatus[method] = 'connecting';
if (!data.callbacks[channel]) {
const callback = (payload) => {
if (payload?.media) {
appendMediaPacket(method, payload);
} else if (payload === '500') {
scheduleRestart(method);
}
};
// 先注册前端回调,再通知后端启动,避免丢失初始化片段。
socket.on(channel, callback);
data.callbacks[channel] = callback;
}
} else {
clearTimeout(retryTimers.get(method));
retryTimers.delete(method);
}
socket.send({
type: 'channel_subscription',
action: enabled ? 'subscribe' : 'unsubscribe',
channel,
});
if (!enabled) {
if (data.callbacks[channel]) {
socket.off(channel, data.callbacks[channel]);
delete data.callbacks[channel];
}
destroyMediaPlayer(method);
streamStatus[method] = 'idle';
}
}
function scheduleRestart(method) {
if (retryTimers.has(method) || !data.form[method === 'getRGBImageStream' ? 'rgbCamera' : 'stereoModule']) return;
streamStatus[method] = 'retrying';
destroyMediaPlayer(method);
const timer = setTimeout(async () => {
retryTimers.delete(method);
await setSubscription(false, method);
await new Promise(resolve => setTimeout(resolve, 500));
if (data.form[method === 'getRGBImageStream' ? 'rgbCamera' : 'stereoModule']) {
await setSubscription(true, method);
}
}, 1000);
retryTimers.set(method, timer);
}
function handleSocketOpen() {
if (data.form.stereoModule && data.robotId && data.cameraId) {
destroyMediaPlayer('getDepthImageStream');
setSubscription(true, 'getDepthImageStream', true);
}
}
function handleSocketClose() {
destroyMediaPlayer('getDepthImageStream');
if (data.form.stereoModule) {
streamStatus.getDepthImageStream = 'retrying';
}
}
socket?.on('open', handleSocketOpen);
socket?.on('close', handleSocketClose);
watch(() => data.form.stereoModule, async (newVal) => {
if (!data.robotId || !data.cameraId) return;
await setSubscription(newVal, 'getDepthImageStream');
});
watch(() => data.form.rgbCamera, async (newVal) => {
if (!data.robotId || !data.cameraId) return;
if (newVal) {
await nextTick();
await startDirectRgbStream();
} else {
stopDirectRgbStream();
}
});
watch(
() => props.initialDeviceId || route.path.split('/')[3],
async (deviceId) => {
if (!deviceId) return;
const queryRobotId = props.initialRobotId || route.query.robotId;
const queryDeviceId = props.initialDeviceId || route.query.deviceId;
if (queryRobotId && queryDeviceId) {
data.cameraId = String(queryDeviceId);
data.robotId = String(queryRobotId);
if (data.form.stereoModule) await setSubscription(true, 'getDepthImageStream');
if (data.form.rgbCamera) await startDirectRgbStream();
return;
}
try {
const response = await getResourceDevice(deviceId);
data.cameraId = response.data.deviceId;
data.robotId = props.initialRobotId || response.data.robotId;
if (data.form.stereoModule) await setSubscription(true, 'getDepthImageStream');
if (data.form.rgbCamera) await startDirectRgbStream();
} catch (error) {
console.error('获取摄像头注册信息失败:', error);
}
},
{ immediate: true }
);
onUnmounted(() => {
socket?.off('open', handleSocketOpen);
socket?.off('close', handleSocketClose);
stopDirectRgbStream();
['getDepthImageStream'].forEach(method => {
clearTimeout(retryTimers.get(method));
if (data.robotId && data.cameraId) {
const channel = channelFor(method);
socket?.send({ type: 'channel_subscription', action: 'unsubscribe', channel });
if (data.callbacks[channel]) socket.off(channel, data.callbacks[channel]);
}
destroyMediaPlayer(method);
});
data.callbacks = {};
});
</script>
<style scoped lang="scss">
.realsense-viewer {
display: flex;
flex-direction: column;
flex: 1;
background-color: #000;
.height-full {
height: 100%;
}
.control-panel {
padding: 10px;
.controls {
padding: 10px;
background-color: rgb(50, 50, 50);
border-radius: 10px;
.el-form {
display: flex;
flex-direction: column;
}
}
}
.video-panel {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
.stream-container {
display: flex;
flex-direction: column;
flex-grow: 1;
min-height: 0;
&.dual {
.stream-item {
min-height: 50%;
}
}
.stream-item {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.stream-wrapper {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
.stream-state {
position: absolute;
top: 12px;
right: 12px;
padding: 4px 9px;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
background: rgba(0, 0, 0, 0.58);
color: rgba(255, 255, 255, 0.9);
font-size: 12px;
line-height: 18px;
pointer-events: none;
}
}
}
}
}
}
</style>

View File

@ -1,36 +0,0 @@
<script setup>
const props = defineProps({
title: {
type: String,
}
})
</script>
<template>
<div class="control-card">
<div class="title" v-if="props.title">{{ props.title }}</div>
<div class="body">
<slot></slot>
</div>
</div>
</template>
<style scoped lang="scss">
.control-card {
@include br6;
padding: 10px;
.title {
color: #333;
font-size: 14px;
margin-bottom: 10px;
font-weight: 900;
}
.body {
height: calc(100% - 20px);
overflow-y: auto;
}
}
</style>

View File

@ -1,431 +0,0 @@
<template>
<div class="left-bottom-charts">
<el-tooltip :content="!isHandSeries ? '开启触觉流': '关闭触觉流'" placement="top">
<el-switch
v-model="isHandSeries"
@change="handleSwitchChange"
style="--el-switch-on-color: #13ce66; --el-switch-off-color: #999; position: absolute;"
/>
</el-tooltip>
<canvas ref="handCanvas" class="hand-canvas"></canvas>
</div>
</template>
<script setup >
import handImage from '@/assets/images/hand2.png';
import { inject } from 'vue';
const emit = defineEmits(['update:bottomSeriesData']);
const socket = inject('ws');
const handCanvas = ref(null);
const robotId = ref(''); // 待您修改
const deviceId = ref(''); // 待您修改
const frameData = ref({ count: 0, lastTime: 0 });
const isHandSeries = ref(false);
const containerRef = ref(null)
// 1. 定义 props
const props = defineProps({
initialDeviceId: { // 示例 prop,用于从弹窗接收cameraId
type: String,
default: null,
},
initialRobotId: { // 示例 prop,用于从弹窗接收 robotId
type: String,
default: null,
},
});
// 2. 使用 watchEffect 来响应 props 和路由的变化
watchEffect(() => {
robotId.value = props.initialRobotId;
deviceId.value = props.initialDeviceId;
});
// 矩形定义
const rectangle = [
{ x: 24, y: 305, width: 21, height: 20, xCount: 3, yCount: 3 }, // PINKY.TIP
{ x: 17, y: 240, width: 42, height: 57, xCount: 8, yCount: 12 }, // PINKY.FINGER
{ x: 25, y: 152, width: 42, height: 51, xCount: 8, yCount: 10 }, // PINKY.PAD
{ x: 88, y: 339, width: 23, height: 22, xCount: 3, yCount: 3 }, // RING.TIP
{ x: 75, y: 276, width: 43, height: 56, xCount: 8, yCount: 12 }, // RING.FINGER
{ x: 88, y: 152, width: 44, height: 51, xCount: 8, yCount: 10 }, // RING.PAD
{ x: 153, y: 354, width: 22, height: 21, xCount: 3, yCount: 3 }, // MIDDLE_FINGER.TIP
{ x: 146, y: 283, width: 44, height: 56, xCount: 8, yCount: 12 }, // MIDDLE_FINGER.FINGER
{ x: 146, y: 152, width: 43, height: 51, xCount: 8, yCount: 10 }, // MIDDLE_FINGER.PAD
{ x: 224, y: 332, width: 23, height: 22, xCount: 3, yCount: 3 }, // INDEX.TIP
{ x: 209, y: 267, width: 44, height: 58, xCount: 8, yCount: 12 }, // INDEX.FINGER
{ x: 203, y: 152, width: 44, height: 52, xCount: 8, yCount: 10 }, // INDEX.PAD
{ x: 290, y: 225, width: 21, height: 21, xCount: 3, yCount: 3 }, // THUMB.TIP
{ x: 282, y: 161, width: 44, height: 57, xCount: 8, yCount: 12 }, // THUMB.FINGER
{ x: 289, y: 111, width: 22, height: 20, xCount: 3, yCount: 3 }, // THUMB.THUMB_MIDDLE
{ x: 269, y: 24, width: 57, height: 57, xCount: 8, yCount: 12 }, // THUMB.PAD
{ x: 53, y: 11, width: 188, height: 107, xCount: 14, yCount: 8 } // PALM.PALM_PAD
];
// 模拟的矩形数据(17 个二维数组)
const rectangleData = ref([]);
// 初始化矩形数据
const initializeRectangleData = () => {
rectangleData.value = rectangle.map(({ xCount, yCount }) =>
Array(yCount)
.fill()
.map(() => Array(xCount).fill().map(() => Math.floor(Math.random() * 0)))
);
};
// 防抖函数
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
};
// 初始化 Canvas
const initCanvas = () => {
const canvas = handCanvas.value;
if (!canvas) {
console.error('Canvas element is not available');
return;
}
const img = new Image();
img.onload = () => {
canvas.width = canvas.parentElement.offsetWidth;
canvas.height = canvas.parentElement.offsetHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
drawImage(ctx, img);
drawRectangles(ctx, img);
} else {
console.error('Failed to get 2D context');
}
};
img.onerror = () => console.error('Failed to load image:', handImage);
img.src = handImage;
};
// 绘制图片
const drawImage = (ctx, img) => {
const { width: canvasWidth, height: canvasHeight } = ctx.canvas;
const aspectRatio = img.width / img.height;
let drawWidth, drawHeight;
if (canvasWidth / canvasHeight > aspectRatio) {
drawHeight = canvasHeight;
drawWidth = drawHeight * aspectRatio;
} else {
drawWidth = canvasWidth;
drawHeight = drawWidth / aspectRatio;
}
const x = (canvasWidth - drawWidth) / 2;
const y = canvasHeight - drawHeight;
ctx.drawImage(img, x, y, drawWidth, drawHeight);
};
// 绘制矩形
const drawRectangles = (ctx, img) => {
const { width: canvasWidth, height: canvasHeight } = ctx.canvas;
const aspectRatio = img.width / img.height;
let drawWidth, drawHeight;
if (canvasWidth / canvasHeight > aspectRatio) {
drawHeight = canvasHeight;
drawWidth = drawHeight * aspectRatio;
} else {
drawWidth = canvasWidth;
drawHeight = drawWidth / aspectRatio;
}
const xOffset = Math.round((canvasWidth - drawWidth) / 2);
const yOffset = Math.round(canvasHeight - drawHeight);
const scaleX = drawWidth / img.width;
const scaleY = drawHeight / img.height;
rectangle.forEach((rect, index) => {
const x = Math.round(xOffset + rect.x * scaleX);
const y = Math.round(yOffset + (img.height - rect.y - rect.height) * scaleY);
const rectWidth = Math.round(rect.width * scaleX);
const rectHeight = Math.round(rect.height * scaleY);
const smallWidth = Math.round(rectWidth / rect.xCount);
const smallHeight = Math.round(rectHeight / rect.yCount);
const data = rectangleData.value[index];
if (!data) {
console.warn(`No data for rectangle at index ${index}`);
return;
}
for (let i = 0; i < rect.xCount; i++) {
for (let j = 0; j < rect.yCount; j++) {
const smallX = x + i * smallWidth;
const smallY = y + j * smallHeight;
const value = data[j][i];
// 非常淡橙色 (#FFF5E1, RGB: 255, 245, 225) 到深红 (#800000, RGB: 128, 0, 0)
const r = 128 + Math.floor(((255 - 128) * (4096 - value) + (128 - 0) * value) / 4096);
const g = Math.floor((245 * (4096 - value) + 0 * value) / 4096);
const b = Math.floor((225 * (4096 - value) + 0 * value) / 4096);
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
ctx.fillRect(smallX, smallY, smallWidth, smallHeight);
}
}
});
};
// 更新 Canvas
const updateCanvas = () => {
const canvas = handCanvas.value;
if (!canvas) {
console.error('Canvas element is not available during update');
return;
}
const img = new Image();
img.onload = () => {
canvas.width = canvas.parentElement.offsetWidth;
canvas.height = canvas.parentElement.offsetHeight;
const ctx = canvas.getContext('2d');
if (ctx) {
drawImage(ctx, img);
drawRectangles(ctx, img);
}
};
img.onerror = () => console.error('Failed to load image:', handImage);
img.src = handImage;
};
// 传感器到图表的映射
const sensorMap = [
{ fingerType: 'PINKY', partType: 'TIP', key: 'littleFinger.end' },
{ fingerType: 'PINKY', partType: 'FINGER', key: 'littleFinger.tip' },
{ fingerType: 'PINKY', partType: 'PAD', key: 'littleFinger.middle' },
{ fingerType: 'RING', partType: 'TIP', key: 'ringFinger.end' },
{ fingerType: 'RING', partType: 'FINGER', key: 'ringFinger.tip' },
{ fingerType: 'RING', partType: 'PAD', key: 'ringFinger.middle' },
{ fingerType: 'MIDDLE_FINGER', partType: 'TIP', key: 'middleFinger.end' },
{ fingerType: 'MIDDLE_FINGER', partType: 'FINGER', key: 'middleFinger.tip' },
{ fingerType: 'MIDDLE_FINGER', partType: 'PAD', key: 'middleFinger.middle' },
{ fingerType: 'INDEX', partType: 'TIP', key: 'indexFinger.end' },
{ fingerType: 'INDEX', partType: 'FINGER', key: 'indexFinger.tip' },
{ fingerType: 'INDEX', partType: 'PAD', key: 'indexFinger.middle' },
{ fingerType: 'THUMB', partType: 'TIP', key: 'thumb.end' },
{ fingerType: 'THUMB', partType: 'FINGER', key: 'thumb.tip' },
{ fingerType: 'THUMB', partType: 'THUMB_MIDDLE', key: 'thumb.middle' },
{ fingerType: 'THUMB', partType: 'PAD', key: 'thumb.palm' },
{ fingerType: 'PALM', partType: 'PALM_PAD', key: 'palm.touch' }
];
// WebSocket 订阅
const subscribeSensorData = async (sub) => {
if (!socket || !robotId.value || !deviceId.value) {
console.warn(`订阅失败: socket=${!!socket}, robotId=${robotId.value}, deviceId=${deviceId.value}`);
return;
}
const channel = `edgeDexHandServiceImpl/getSensorDataStream/${robotId.value}/${deviceId.value}`;
console.log(sub ? '订阅' : '取消订阅', channel);
socket.send({
type: 'channel_subscription',
action: sub ? 'subscribe' : 'unsubscribe',
channel
});
if (sub) {
await nextTick();
if (data.callback) {
socket.off(channel, data.callback);
console.log('清理旧回调:', channel);
}
const callback = (playLoad) => {
requestAnimationFrame(() => {
const start = performance.now();
try {
// 解析 playLoad
const sensorData = typeof playLoad === 'string' ? JSON.parse(playLoad) : playLoad;
if (!Array.isArray(sensorData) || sensorData.length !== 17) {
console.error('无效的 payload 格式: 期望 17 个传感器对象', sensorData);
return;
}
// 转换为 rectangleData 和计算 maxData, avgData
const newRectangleData = sensorData.map((sensor, index) => {
const { xCount, yCount } = rectangle[index];
const { rows, cols, dataList } = sensor;
// 验证尺寸
if (rows !== yCount || cols !== xCount || !Array.isArray(dataList) || dataList.length !== rows) {
console.error(`传感器 ${sensor.sensorName} 数据尺寸不匹配: 期望 ${yCount}x${xCount}, 实际 ${rows}x${cols}`);
return rectangleData.value[index] || Array(yCount).fill().map(() => Array(xCount).fill(0));
}
// 提取 valuesList
return dataList.map(row => {
if (!row || !Array.isArray(row.valuesList) || row.valuesList.length !== cols) {
console.warn(`传感器 ${sensor.sensorName} 行数据无效`);
return Array(xCount).fill(0);
}
return row.valuesList;
});
});
// 计算 maxData 和 avgData
const newMaxData = {
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
};
const newAvgData = {
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
};
sensorData.forEach((sensor, index) => {
const { dataList, fingerType, partType } = sensor;
const map = sensorMap[index];
if (!map || map.fingerType !== fingerType || map.partType !== partType) {
console.warn(`传感器 ${sensor.sensorName} 映射不匹配: 期望 ${map.fingerType}.${map.partType}`);
return;
}
// 计算最大值和平均值
let maxValue = 0;
let sum = 0;
let count = 0;
dataList.forEach(row => {
row.valuesList.forEach(value => {
maxValue = Math.max(maxValue, value);
if (value > 0) {
sum += value;
count++;
}
});
});
const avgValue = count > 0 ? sum / count : 0;
// 更新 maxData 和 avgData
const [finger, part] = map.key.split('.');
newMaxData[finger][part] = Math.floor(maxValue);
newAvgData[finger][part] = Math.floor(avgValue);
});
// 更新 rectangleData 和 maxData, avgData
rectangleData.value = newRectangleData;
emit('update:topSeriesData', {maxData: newMaxData, avgData: newAvgData});
// 更新 Canvas
updateCanvas();
// 帧率和性能监控
frameData.value.count++;
const currentTime = performance.now();
if (frameData.value.lastTime && currentTime - frameData.value.lastTime >= 1000) {
const fps = frameData.value.count * 1000 / (currentTime - frameData.value.lastTime);
console.log(`渲染帧率: ${fps.toFixed(2)} fps, 通道: ${channel}`);
frameData.value.count = 0;
frameData.value.lastTime = currentTime;
} else if (!frameData.value.lastTime) {
frameData.value.lastTime = currentTime;
}
console.log(`数据更新耗时: ${(performance.now() - start).toFixed(2)}ms, 通道: ${channel}`);
if (performance.memory) {
console.log('当前内存使用:', {
usedJSHeapSize: (performance.memory.usedJSHeapSize / 1024 / 1024).toFixed(2) + ' MB',
totalJSHeapSize: (performance.memory.totalJSHeapSize / 1024 / 1024).toFixed(2) + ' MB'
});
}
} catch (err) {
console.error('数据更新失败:', err, '通道:', channel);
console.error('payload 数据:', playLoad);
}
});
};
socket.on(channel, callback);
data.callback = callback;
console.log('注册新回调:', channel);
} else {
if (data.callback) {
socket.off(channel, data.callback);
delete data.callback;
console.log('清理回调:', channel);
}
}
};
// 数据对象
const data = reactive({
callback: null
});
const handleSwitchChange = (val) => {
subscribeSensorData(val)
}
// 生命周期钩子
onMounted(async () => {
await nextTick();
initializeRectangleData(); // 初始化数据
initCanvas(); // 初始化 Canvas
// 使用 ResizeObserver 监听父容器尺寸变化
const container = containerRef.value || handCanvas.value?.parentElement;
if (container) {
const resizeObserver = new ResizeObserver(debounce(updateCanvas, 10));
resizeObserver.observe(container);
// 保存 ResizeObserver 实例以便清理
data.resizeObserver = resizeObserver;
} else {
console.error('Failed to find container for ResizeObserver');
}
const debouncedResize = debounce(updateCanvas, 10);
window.addEventListener('resize', debouncedResize);
data.debouncedResize = debouncedResize; // 保存以便清理
});
onUnmounted(() => {
// 清理 ResizeObserver
if (data.resizeObserver) {
data.resizeObserver.disconnect();
delete data.resizeObserver;
}
// 清理 window 的 resize 事件
if (data.debouncedResize) {
window.removeEventListener('resize', data.debouncedResize);
delete data.debouncedResize;
}
// 清理 WebSocket 订阅
if (isHandSeries.value) {
subscribeSensorData(false);
}
});
</script>
<style>
.left-bottom-charts {
height: 100%;
}
.hand-canvas {
width: 100%;
height: 100%;
object-fit: contain;
}
</style>

View File

@ -1,270 +0,0 @@
<template>
<div class="left-top-hand" v-loading="loading">
<el-button
class="copy-button"
type="button"
:icon="isCopied ? 'Check' : 'CopyDocument'"
:disabled="isCopied"
@click="copyParams"
aria-label="复制当前参数"
>
{{ isCopied ? '完成' : '复制' }}
</el-button>
<img src="@/assets/images/hand.png" ref="handImage" class="hand-image" />
<div v-for="(slider, index) in sliders" :key="index" class="slider-container" :style="slider.style">
<span :class="`slider-title slider-title-${index}`">{{ slider.value }}</span>
<el-slider
v-model="slider.value"
:vertical="slider.vertical"
:height="slider.height + 'px'"
@change="updateSeriesData(slider.value, index)"
:class="`slider-${index}`"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { status, setDexHandAngle } from '@/api/device/dexHand';
const emit = defineEmits(['update:topSeriesData']);
const loading = ref(false);
const handImage = ref(null);
const containerRef = ref(null); // 新增:引用父容器
const defaultHandImageInfo = { width: 891, height: 981 };
const imageAspectRatio = defaultHandImageInfo.width / defaultHandImageInfo.height;
const robotId = ref('');
const deviceId = ref('');
// 1. 定义 props
const props = defineProps({
initialDeviceId: { // 示例 prop,用于从弹窗接收cameraId
type: String,
default: null,
},
initialRobotId: { // 示例 prop,用于从弹窗接收 robotId
type: String,
default: null,
},
});
// 2. 使用 watchEffect 来响应 props 和路由的变化
watchEffect(() => {
robotId.value = props.initialRobotId;
deviceId.value = props.initialDeviceId;
console.log(deviceId.value, robotId.value,props)
});
const sliders = ref([
{ value: 0, defalutHeight: 466, height: 0, x: 120, y: 140, vertical: true, style: {} }, // 小拇指
{ value: 0, defalutHeight: 466, height: 0, x: 230, y: 140, vertical: true, style: {} }, // 无名指
{ value: 0, defalutHeight: 466, height: 0, x: 373, y: 140, vertical: true, style: {} }, // 中指
{ value: 0, defalutHeight: 466, height: 0, x: 468, y: 140, vertical: true, style: {} }, // 食指
{ value: 0, defalutHeight: 346, height: 0, x: 608, y: 360, vertical: true, style: {} }, // 大拇指弯曲
{ value: 0, defalutHeight: 633, height: 0, x: 85, y: 723, vertical: false, style: {} }, // 大拇指旋转
]);
function updateSeriesData(value, i) {
console.log(deviceId.value, robotId.value)
loading.value = true;
setDexHandAngle({ deviceId: deviceId.value, robotId: robotId.value, value: value / 100, id: i }).then(() => {
loading.value = false;
updateToSeriesData();
}).catch(() => {
loading.value = false;
});
}
const isCopied = ref(false);
const copyParams = () => {
navigator.clipboard.writeText(JSON.stringify(sliders.value))
.then(() => {
isCopied.value = true;
setTimeout(() => {
isCopied.value = false;
}, 3000);
})
.catch(err => {
console.error('复制失败:', err);
});
};
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
};
const updateSliderPositions = () => {
const img = handImage.value;
if (img) {
const { width, height } = img.getBoundingClientRect();
console.log('Window resized - New dimensions:', width, height);
const widthRatio = width / height;
let realWidth = 0;
let realHeight = 0;
if (widthRatio > imageAspectRatio) {
realHeight = height;
realWidth = realHeight * imageAspectRatio;
} else {
realWidth = width;
realHeight = realWidth / imageAspectRatio;
}
const realImageAspectRatio = realWidth / defaultHandImageInfo.width;
console.log('Real dimensions:', realWidth, realHeight, realImageAspectRatio);
sliders.value.forEach((slider, index) => {
slider.height = slider.defalutHeight * realImageAspectRatio;
slider.style = {
top: (height / 2 - realHeight / 2) + slider.y * realImageAspectRatio - 16 + 'px',
left: (width / 2 - realWidth / 2) + slider.x * realImageAspectRatio - 16 + 'px',
position: 'absolute',
zIndex: 10,
width: slider.height + 'px',
};
});
}
};
const updateToSeriesData = () => {
status({ deviceId: deviceId.value, robotId: robotId.value }).then((res) => {
const newTopSeriesData = res.data.handsList.map(item => item.force);
emit('update:topSeriesData', newTopSeriesData);
});
};
const data = ref({
debouncedResize: null,
resizeObserver: null,
});
onMounted(() => {
updateSliderPositions();
// 使用 ResizeObserver 监听父容器尺寸变化
const container = containerRef.value || handImage.value?.parentElement;
if (container) {
const resizeObserver = new ResizeObserver(debounce(updateSliderPositions, 10));
resizeObserver.observe(container);
data.value.resizeObserver = resizeObserver;
} else {
console.error('Failed to find container for ResizeObserver');
}
const debouncedResize = debounce(updateSliderPositions, 0);
window.addEventListener('resize', debouncedResize);
data.value.debouncedResize = debouncedResize;
updateToSeriesData();
});
onUnmounted(() => {
// 清理 ResizeObserver
if (data.value.resizeObserver) {
data.value.resizeObserver.disconnect();
data.value.resizeObserver = null;
}
// 清理 window 的 resize 事件
if (data.value.debouncedResize) {
window.removeEventListener('resize', data.value.debouncedResize);
data.value.debouncedResize = null;
}
});
</script>
<style scoped>
.left-top-hand {
position: relative;
height: 100%;
}
.hand-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.slider-container {
position: absolute;
z-index: 10;
}
.slider-container ::v-deep(.el-slider__button) {
width: 25px !important;
height: 12px !important;
background-color: #409eff !important;
border-radius: 0 !important;
}
.slider-5 ::v-deep(.el-slider__button) {
width: 12px !important;
height: 25px !important;
background-color: #409eff !important;
border-radius: 0 !important;
}
.slider-container .el-slider__runway {
background-color: #007bff;
border-radius: 2px;
}
.slider-title {
color: #409eff;
position: absolute;
bottom: -20px;
left: 10px;
}
.slider-title-5 {
bottom: 8px;
left: -30px;
}
.image {
width: 100%;
height: 50%;
object-fit: contain;
}
@media (max-width: 768px) {
.slider-container {
width: 5vw;
}
}
.copy-button {
position: absolute;
background-color: #FFFfff;
padding: 5px 10px;
border-radius: 5px;
border: 0;
color: #646362;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
outline: none;
}
.copy-button:hover {
background-color: #FFF5E1;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.copy-button:active {
background-color: #FFDAB9;
transform: translateY(0);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.copy-button:focus {
box-shadow: 0 0 0 3px rgba(255, 165, 0, 0.3);
}
.copy-button:disabled {
background-color: #F0E8E0;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
</style>

View File

@ -1,229 +0,0 @@
<template>
<div ref="containerRef" style="width: 100%;height: 100%; margin: 0 auto;">
<div class="tabs">
<div
v-for="(tab, index) in tabs"
:key="index"
class="tab"
:class="{ active: activeTab === tab.value }"
@click="switchTab(tab.value)"
>
{{ tab.label }}
</div>
</div>
<div ref="chart" style="width: 100%; height: 100%;"></div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import * as echarts from 'echarts';
// 解构 props
const props = defineProps({
maxData: {
type: Object,
required: true,
default: () => ({
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
})
},
avgData: {
type: Object,
required: true,
default: () => ({
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
})
}
});
const chart = ref(null);
const containerRef = ref(null); // 新增:引用父容器
const activeTab = ref('avg'); // 默认选中平均值
// Tab 配置
const tabs = [
{ label: '最大值', value: 'max' },
{ label: '平均值', value: 'avg' }
];
let myChart;
// 防抖函数
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
};
const initChart = () => {
if (!chart.value) {
console.error('Chart element is null');
return;
}
myChart = echarts.init(chart.value);
const option = {
title: { text: '手指触觉数据' },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { data: ['指端', '指尖', '指中', '指腹', '手掌触觉'] },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
data: ['小拇指', '无名指', '中指', '食指', '大拇指', '手掌'],
splitArea: { show: true }
},
yAxis: [{ type: 'value', name: '弯曲应力', max: 4000,interval: 1000 }],
series: [
{ name: '指端', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#FF5733' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] },
{ name: '指尖', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#33FF57' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] },
{ name: '指中', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#5733FF' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] },
{ name: '指腹', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#FF33A1' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] },
{ name: '手掌触觉', type: 'bar', barGap: '1%', barCategoryGap: '10%', itemStyle: { color: '#FFD700' }, label: { show: true, position: 'top' }, emphasis: { focus: 'series' }, data: [] }
]
};
myChart.setOption(option);
};
// 获取每个部分的数据,处理空值
const getPartData = (data, part) => {
return [
data.littleFinger[part] || null,
data.ringFinger[part] || null,
data.middleFinger[part] || null,
data.indexFinger[part] || null,
data.thumb[part] || null,
data.palm[part] || null
];
};
const updateChart = (data) => {
const seriesData = {
end: getPartData(data, 'end'),
tip: getPartData(data, 'tip'),
middle: getPartData(data, 'middle'),
palm: getPartData(data, 'palm'),
touch: getPartData(data, 'touch')
};
myChart.setOption({
series: [
{ name: '指端', data: seriesData.end },
{ name: '指尖', data: seriesData.tip },
{ name: '指中', data: seriesData.middle },
{ name: '指腹', data: seriesData.palm },
{ name: '手掌触觉', data: seriesData.touch }
]
});
};
const switchTab = (tab) => {
activeTab.value = tab;
updateChart(activeTab.value === 'max' ? props.maxData : props.avgData);
};
// 监听 props 变化,更新图表
watch([() => props.maxData, () => props.avgData, activeTab], () => {
updateChart(activeTab.value === 'max' ? props.maxData : props.avgData);
});
const data = ref({
resizeObserver: null,
debouncedResize: null,
});
onMounted(() => {
initChart();
updateChart(activeTab.value === 'max' ? props.maxData : props.avgData);
// 使用 ResizeObserver 监听父容器尺寸变化
const container = containerRef.value;
if (container) {
const resizeObserver = new ResizeObserver(debounce(() => {
if (myChart) {
console.log('Container resized:', container.offsetWidth, container.offsetHeight);
myChart.resize();
}
}, 10));
resizeObserver.observe(container);
data.value.resizeObserver = resizeObserver;
} else {
console.error('Failed to find container for ResizeObserver');
}
// 保留 window 的 resize 事件监听
const debouncedResize = debounce(() => {
if (myChart) {
myChart.resize();
}
}, 10);
window.addEventListener('resize', debouncedResize);
data.value.debouncedResize = debouncedResize;
});
onUnmounted(() => {
// 清理图表实例
if (myChart) {
myChart.dispose();
myChart = null;
}
// 清理 ResizeObserver
if (data.value.resizeObserver) {
data.value.resizeObserver.disconnect();
data.value.resizeObserver = null;
}
// 清理 window 的 resize 事件
if (data.value.debouncedResize) {
window.removeEventListener('resize', data.value.debouncedResize);
data.value.debouncedResize = null;
}
});
</script>
<style scoped>
.container {
display: flex;
width: 100%;
margin: 0 auto;
flex-direction: column;
}
.tabs {
position: absolute;
right: 50%;
justify-content: center;
background: rgba(0, 0, 0, 0);
border-radius: 10px 10px 0 0;
padding: 5px 0;
}
.tab {
padding: 10px 20px;
margin: 0 10px;
cursor: pointer;
font-size: 16px;
color: #333;
border-bottom: 3px solid transparent;
transition: all 0.3s ease;
}
.tab:hover {
background-color: #f5f5f5;
border-radius: 5px;
}
.tab.active {
border-bottom: 3px solid #007bff;
color: #007bff;
}
</style>

View File

@ -1,162 +0,0 @@
<template>
<div class="right-chart-panel" ref="containerRef">
<div ref="topChart" class="chart"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import * as echarts from 'echarts';
const props = defineProps({
topSeriesData: {
type: Array,
default: () => [0, 0, 0, 0, 0, 0],
validator: (value) => value.length === 6 && value.every((v) => typeof v === 'number'),
},
maxData: {
type: Object,
required: true,
default: () => ({
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
})
},
avgData: {
type: Object,
required: true,
default: () => ({
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
})
}
});
const topChart = ref(null);
const containerRef = ref(null); // 新增:引用父容器
let chart1Instance = null;
// 防抖函数
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
};
const topChartData = ref({
xAxis: {
type: 'category',
data: ['小拇指', '无名指', '中指', '食指', '大拇指弯曲', '大拇指旋转'],
},
yAxis: {
name: '受力',
type: 'value',
min: 0,
max: 10000,
interval: 2500,
axisLine: { show: true, lineStyle: { color: '#1f77b4' } },
axisLabel: { formatter: '{value} g' },
axisTick: { show: true },
},
series: [{ data: props.topSeriesData, type: 'bar' }],
});
const data = ref({
resizeObserver: null,
debouncedResize: null,
});
onMounted(() => {
try {
if (!topChart.value) {
console.error('Chart element is null');
return;
}
chart1Instance = echarts.init(topChart.value);
chart1Instance.setOption(topChartData.value);
// 使用 ResizeObserver 监听父容器尺寸变化
const container = containerRef.value;
if (container) {
const resizeObserver = new ResizeObserver(debounce(() => {
if (chart1Instance) {
console.log('Container resized:', container.offsetWidth, container.offsetHeight);
chart1Instance.resize();
}
}, 10));
resizeObserver.observe(container);
data.value.resizeObserver = resizeObserver;
} else {
console.error('Failed to find container for ResizeObserver');
}
// 保留 window 的 resize 事件监听
const debouncedResize = debounce(() => {
if (chart1Instance) {
chart1Instance.resize();
}
}, 10);
window.addEventListener('resize', debouncedResize);
data.value.debouncedResize = debouncedResize;
} catch (error) {
console.error('图表初始化失败:', error);
}
});
onUnmounted(() => {
// 清理图表实例
if (chart1Instance) {
chart1Instance.dispose();
chart1Instance = null;
}
// 清理 ResizeObserver
if (data.value.resizeObserver) {
data.value.resizeObserver.disconnect();
data.value.resizeObserver = null;
}
// 清理 window 的 resize 事件
if (data.value.debouncedResize) {
window.removeEventListener('resize', data.value.debouncedResize);
data.value.debouncedResize = null;
}
});
// 监听 props.topSeriesData
watch(
() => props.topSeriesData,
(newTopData) => {
if (chart1Instance) {
chart1Instance.setOption({ series: [{ data: newTopData, type: 'bar' }] });
}
},
{ immediate: true }
);
</script>
<style scoped>
.right-chart-panel {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
height: 50%;
padding: 10px;
}
.chart {
flex: 1;
height: 100%;
margin: 10px 0;
min-height: 200px; /* 确保图表有足够的高度 */
}
</style>

View File

@ -1,168 +0,0 @@
<!-- index.vue -->
<template>
<div class="container">
<div class="left-panel">
<div class="hand-container">
<!-- 确保这里使用的是 index.vue 中响应式的 deviceId ref -->
<LeftTopHand :initialDeviceId="deviceId" :initialRobotId="robotId" @update:topSeriesData="handleUpdateTopSeriesData" />
</div>
<div class="hand-container">
<!-- 确保这里使用的是 index.vue 中响应式的 robotId ref -->
<LeftBottomHand :initialDeviceId="deviceId" :initialRobotId="robotId" @update:bottomSeriesData="handleUpdateBottomSeriesData" />
</div>
</div>
<div class="right-panel">
<RightTopCharts :top-series-data="topSeriesData" style="height: 50%;" />
<RightBottomCharts :max-data="maxData" :avg-data="avgData" style="height: 50%;" />
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'; // 引入 watch
// 假设你的 router 实例是这样获取的
import { useRoute } from 'vue-router'; // 确保你已经安装并配置了 vue-router
import RightTopCharts from './RightTopCharts.vue';
import LeftTopHand from './LeftTopHand.vue';
import LeftBottomHand from './LeftBottomHand.vue';
import RightBottomCharts from './RightBottomCharts.vue';
import { getResourceDevice } from '@/api/resource/device';
// 1. 定义 props
const props = defineProps({
initialDeviceId: {
type: String,
default: null,
},
initialRobotId: {
type: String,
default: null,
},
});
// 响应式数据
const topSeriesData = ref([0, 0, 0, 0, 0, 0]);
const bottomSeriesData = ref([0, 0, 0, 0, 0, 0]);
const maxData = ref({
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
});
const avgData = ref({
thumb: { end: 0, tip: 0, middle: 0, palm: 0 },
indexFinger: { end: 0, tip: 0, middle: 0 },
middleFinger: { end: 0, tip: 0, middle: 0 },
ringFinger: { end: 0, tip: 0, middle: 0 },
littleFinger: { end: 0, tip: 0, middle: 0 },
palm: { touch: 0 }
});
// 声明组件内部使用的响应式变量
const deviceId = ref("");
const robotId = ref("");
const handleUpdateTopSeriesData = (newData) => {
topSeriesData.value = newData;
};
const handleUpdateBottomSeriesData = ({ maxData: newMaxData, avgData: newAvgData }) => {
maxData.value = newMaxData;
avgData.value = newAvgData;
};
// 获取路由实例
const route = useRoute();
// 统一处理获取 register 数据的方法
const fetchRegisterData = (currentDeviceId) => {
if (route.query.robotId && route.query.deviceId) {
robotId.value = String(route.query.robotId);
deviceId.value = String(route.query.deviceId);
return;
}
if (currentDeviceId) {
getResourceDevice(currentDeviceId).then(res => {
deviceId.value = res.data.deviceId;
robotId.value = res.data.robotId;
}).catch(error => {
console.error("Error fetching register:", error);
// 可以根据需要重置 deviceId 和 robotId
deviceId.value = "";
robotId.value = "";
});
} else {
// 如果 deviceId 为空,可以考虑重置 robotId
robotId.value = "";
}
};
// 2. 使用 watch 侦听 props 和路由的变化
// 当 initialDeviceId prop 变化时,更新 deviceId ref
watch(() => props.initialDeviceId, (newVal) => {
if (newVal) {
deviceId.value = newVal;
// 当 props.initialDeviceId 确定后,再调用 getRegister
fetchRegisterData(deviceId.value);
}
}, { immediate: true }); // immediate: true 会在组件挂载时立即执行一次 watch
// 如果 props.initialDeviceId 为空,则尝试从路由获取
// 注意:这里假设 route.path.split("/")[3] 是cameraId
// 如果你的路由结构不同,请调整这里的逻辑
watch(() => route.path, (newPath) => {
if (route.query.robotId && route.query.deviceId) {
robotId.value = String(route.query.robotId);
deviceId.value = String(route.query.deviceId);
return;
}
const routeDeviceId = newPath.split("/")[3];
if (!deviceId.value && routeDeviceId) { // 只有当 deviceId 尚未从 props 或其他地方设置时才从路由获取
deviceId.value = routeDeviceId;
fetchRegisterData(deviceId.value);
}
}, { immediate: true }); // immediate: true 确保在组件挂载时也检查路由
// 如果需要 initialRobotId prop 也影响 robotId ref,可以添加一个 watch
watch(() => props.initialRobotId, (newVal) => {
if (newVal) {
robotId.value = newVal;
}
}, { immediate: true });
</script>
<style scoped>
.container {
display: flex;
flex: 1; /* 占据父容器所有可用空间 */
width: 100%;
height: 100%; /* 占满父容器高度 */
min-height: 0; /* 防止 flex 子项最小高度问题 */
background-color: #fff;
border-radius: 20px;
overflow: hidden; /* 防止内容溢出 */
box-sizing: border-box; /* 包含 padding 和 border */
padding: 20px; /* 与父容器一致,避免双重 padding */
}
.left-panel,
.right-panel {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 50%;
height: 100%; /* 占满 container 高度 */
padding: 0; /* 移除子面板内边距,交给 container 处理 */
box-sizing: border-box;
overflow: hidden;
}
.hand-container {
position: relative;
height: 50%; /* 每个 hand-container 占左面板的 50% 高度 */
width: 100%;
overflow: hidden;
}
</style>

View File

@ -1,316 +0,0 @@
<template>
<div class="head-control">
<h2 class="title">人工头舵机控制</h2>
<div class="main-content">
<!-- 左侧:10个Yaw预设输入框 -->
<div class="preset-panel left-panel">
<h3>Yaw 预设位(左右转头)</h3>
<div v-for="(preset, index) in leftPresets" :key="index" class="preset-item">
<span class="label">位 {{ index + 1 }}</span>
<el-input-number
v-model="preset.value"
:min="0"
:max="1"
:step="0.01"
:precision="2"
size="small"
controls-position="right"
@change="onPresetChange"
/>
</div>
<el-button type="primary" size="small" @click="applyYawFromPresets">应用当前 Yaw</el-button>
</div>
<!-- 中央图片 + 20个控制点 -->
<div class="image-container">
<img
src="https://cdn.head-acoustics.com/_processed_/3/5/csm_HMS_V_frontal_seitlich_67c86d4fad.jpg"
alt="人工头"
class="head-image"
/>
<!-- 20 个控制点 -->
<div
v-for="(point, index) in points"
:key="index"
class="control-point"
:style="{ left: point.x + '%', top: point.y + '%' }"
:class="{ active: point.active }"
@click="applyPreset(index)"
:title="`点 ${index + 1}: Yaw=${point.yaw.toFixed(2)}, Pitch=${point.pitch.toFixed(2)}`"
>
{{ index + 1 }}
</div>
</div>
<!-- 右侧:10个Pitch预设输入框 -->
<div class="preset-panel right-panel">
<h3>Pitch 预设位(上下点头)</h3>
<div v-for="(preset, index) in rightPresets" :key="index" class="preset-item">
<span class="label">位 {{ index + 1 }}</span>
<el-input-number
v-model="preset.value"
:min="0"
:max="1"
:step="0.01"
:precision="2"
size="small"
controls-position="right"
@change="onPresetChange"
/>
</div>
<el-button type="primary" size="small" @click="applyPitchFromPresets">应用当前 Pitch</el-button>
</div>
</div>
<!-- 当前值显示与下发 -->
<div class="current-display">
<div class="current-item">
<span>当前 Yaw:</span>
<span class="value">{{ servo1.toFixed(2) }}</span>
</div>
<div class="current-item">
<span>当前 Pitch:</span>
<span class="value">{{ servo2.toFixed(2) }}</span>
</div>
<el-button type="primary" @click="sendServoValues">立即下发舵机值</el-button>
<el-button @click="resetToCenter">复位到中心 (0.50, 0.50)</el-button>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRoute } from 'vue-router';
// import { setServo } from '@/api/device/head'; // 假设接口
const route = useRoute();
const robotId = ref(String(route.query.robotId || ''));
const deviceId = ref(String(route.query.deviceId || ''));
const servo1 = ref(0.5); // 当前 Yaw
const servo2 = ref(0.5); // 当前 Pitch
// 左侧10个Yaw预设(对应左侧10个点)
const leftPresets = ref([
{ value: 0.10 }, { value: 0.15 }, { value: 0.08 }, { value: 0.12 }, { value: 0.18 },
{ value: 0.05 }, { value: 0.10 }, { value: 0.15 }, { value: 0.08 }, { value: 0.12 }
]);
// 右侧10个Pitch预设(对应右侧10个点,但Pitch值可独立调整)
const rightPresets = ref([
{ value: 0.20 }, { value: 0.30 }, { value: 0.40 }, { value: 0.50 }, { value: 0.60 },
{ value: 0.70 }, { value: 0.80 }, { value: 0.90 }, { value: 0.95 }, { value: 1.00 }
]);
// 当预设值改变时,自动同步到对应的点(可选)
const onPresetChange = () => {
// 可选:同步到 points 数组的 yaw/pitch
leftPresets.value.forEach((p, i) => {
points.value[i].yaw = p.value;
});
rightPresets.value.forEach((p, i) => {
points.value[i + 10].pitch = p.value;
});
};
// 点击图片上的点 → 应用对应预设,并同步到输入框
const applyPreset = (index) => {
points.value.forEach(p => p.active = false);
points.value[index].active = true;
const yaw = points.value[index].yaw;
const pitch = points.value[index].pitch;
servo1.value = yaw;
servo2.value = pitch;
// 同步到左侧/右侧输入框(高亮当前对应的预设)
if (index < 10) {
leftPresets.value.forEach((p, i) => p.value = i === index ? yaw : p.value);
} else {
rightPresets.value.forEach((p, i) => p.value = i === (index - 10) ? pitch : p.value);
}
sendServoValues();
};
// 从左侧预设应用当前选中的Yaw(假设用户先在输入框选中一个,这里用最后一个修改的近似,或添加选中逻辑)
let lastLeftIndex = null;
let lastRightIndex = null;
// 简化:提供按钮直接应用当前所有输入框的平均或手动选择。这里提供“应用当前Yaw”按钮,使用第一个非默认的值或手动。
// 为简化,这里按钮应用“第一个输入框的值”作为Yaw,你可以改为添加选中高亮。
const applyYawFromPresets = () => {
servo1.value = leftPresets.value[0].value; // 示例:使用第一个,你可改为选中机制
sendServoValues();
};
const applyPitchFromPresets = () => {
servo2.value = rightPresets.value[0].value; // 示例:使用第一个
sendServoValues();
};
const sendServoValues = async () => {
try {
// await setServo({
// robotId: robotId.value,
// deviceId: deviceId.value,
// servo1: servo1.value,
// servo2: servo2.value,
// });
} catch (error) {
console.error('舵机下发失败:', error);
}
};
const resetToCenter = () => {
servo1.value = 0.5;
servo2.value = 0.5;
points.value.forEach(p => p.active = false);
sendServoValues();
};
// 20个点坐标(已调整适应新图片)
const points = ref([
// 左侧10点
{ x: 18, y: 20, yaw: 0.10, pitch: 0.20, active: false },
{ x: 22, y: 30, yaw: 0.15, pitch: 0.30, active: false },
{ x: 15, y: 40, yaw: 0.08, pitch: 0.40, active: false },
{ x: 20, y: 50, yaw: 0.12, pitch: 0.50, active: false },
{ x: 25, y: 60, yaw: 0.18, pitch: 0.60, active: false },
{ x: 12, y: 70, yaw: 0.05, pitch: 0.70, active: false },
{ x: 18, y: 80, yaw: 0.10, pitch: 0.80, active: false },
{ x: 22, y: 90, yaw: 0.15, pitch: 0.90, active: false },
{ x: 15, y: 95, yaw: 0.08, pitch: 0.95, active: false },
{ x: 20, y: 98, yaw: 0.12, pitch: 1.00, active: false },
// 右侧10点(Yaw偏大)
{ x: 82, y: 20, yaw: 0.90, pitch: 0.20, active: false },
{ x: 78, y: 30, yaw: 0.85, pitch: 0.30, active: false },
{ x: 85, y: 40, yaw: 0.92, pitch: 0.40, active: false },
{ x: 80, y: 50, yaw: 0.88, pitch: 0.50, active: false },
{ x: 75, y: 60, yaw: 0.82, pitch: 0.60, active: false },
{ x: 88, y: 70, yaw: 0.95, pitch: 0.70, active: false },
{ x: 82, y: 80, yaw: 0.90, pitch: 0.80, active: false },
{ x: 78, y: 90, yaw: 0.85, pitch: 0.90, active: false },
{ x: 85, y: 95, yaw: 0.92, pitch: 0.95, active: false },
{ x: 80, y: 98, yaw: 0.88, pitch: 1.00, active: false },
]);
</script>
<style scoped>
.head-control {
padding: 20px;
max-width: 1400px;
margin: 0 auto;
background: #fff;
border-radius: 20px;
height: calc(100vh - 125px);
display: flex;
flex-direction: column;
}
.title {
text-align: center;
margin-bottom: 20px;
font-size: 24px;
}
.main-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 40px;
}
.preset-panel {
width: 220px;
background: #f5f7fa;
padding: 20px;
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.preset-panel h3 {
margin: 0 0 10px 0;
text-align: center;
font-size: 16px;
}
.preset-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.label {
width: 50px;
font-size: 14px;
}
.image-container {
position: relative;
max-width: 600px;
}
.head-image {
width: 100%;
height: auto;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.control-point {
position: absolute;
width: 40px;
height: 40px;
background: rgba(64, 158, 255, 0.8);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
cursor: pointer;
transform: translate(-50%, -50%);
transition: all 0.2s;
border: 3px solid #fff;
}
.control-point:hover {
background: #409eff;
transform: translate(-50%, -50%) scale(1.2);
}
.control-point.active {
background: #f56c6c;
transform: translate(-50%, -50%) scale(1.4);
}
.current-display {
display: flex;
justify-content: center;
align-items: center;
gap: 40px;
margin-top: 30px;
flex-wrap: wrap;
}
.current-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 18px;
}
.value {
font-weight: bold;
color: #409eff;
min-width: 60px;
}
</style>

View File

@ -1,133 +0,0 @@
<template>
<div class="container">
<div class="joint-control" v-for="joint in jointList" :key="joint.name">
<div class="joint-header">
<span class="joint-name">{{ joint.displayName }}</span>
<span class="joint-type">{{ joint.type.toUpperCase() }}</span>
</div>
<div class="joint-info">
<span class="angle-display">{{ formatAngle(joint.value) }}</span>
<span class="limits">[{{ formatAngle(joint.min) }}, {{ formatAngle(joint.max) }}]</span>
</div>
<div class="slider-container">
<el-slider size="small" v-model="joint.value" :min="joint.min" :max="joint.max" :step="joint.step" @input="(value) => updateJointAngle(joint.name, value)"/>
<div class="slider-labels">
<span>{{ formatAngle(joint.min) }}</span>
<span>{{ formatAngle(joint.max) }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
jointList: {
type: Array,
default: () => []
}
})
const emits = defineEmits(['updateJointAngle'])
const updateJointAngle = (name, value) => {
emits('updateJointAngle', { name, value })
}
/**
* 角度格式化
*/
const formatAngle = (radians) => {
const degrees = (radians * 180 / Math.PI).toFixed(1)
return `${degrees}°`
}
</script>
<style lang="scss" scoped>
.container {
width: 100%;
height: 100%;
.joint-control {
border-radius: 6px;
padding: 15px;
margin-bottom: 15px;
border: 1px solid #cfcfcf;
.joint-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
.joint-name {
font-weight: 600;
font-size: 14px;
}
.joint-type {
font-size: 11px;
background: rgba(52, 152, 219, 0.3);
color: #3498db;
padding: 3px 8px;
border-radius: 12px;
font-weight: 500;
}
}
.joint-info {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 13px;
.angle-display {
color: #f1c40f;
font-weight: 600;
font-size: 14px;
}
.limits {
color: #95a5a6;
font-size: 11px;
font-family: monospace;
}
}
.slider-container {
padding: 0 5px;
.joint-slider {
width: 100%;
height: 8px;
-webkit-appearance: none;
background: #34495e;
border-radius: 4px;
outline: none;
margin-bottom: 8px;
cursor: pointer;
&::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #ecf0f1;
cursor: pointer;
border: 2px solid #3498db;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
transition: all 0.2s;
}
}
.slider-labels {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #7f8c8d;
margin-top: 5px;
}
}
}
}
</style>

View File

@ -1,394 +0,0 @@
<template>
<!-- 模板部分保持不变 -->
<div class="urdf-viewer-container">
<div ref="viewerContainer" class="urdf-viewer"></div>
<div v-if="isLoading" class="loading-overlay">
<div class="loading-spinner"></div>
<p>正在解析URDF并加载模型...</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, reactive, computed } from 'vue'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import URDFLoader from 'urdf-loader'
const props = defineProps({
basePath: { type: String, required: true },
urdfPath: { type: String, required: true },
modelColor: { type: String, default: '#cbcbcb' },
showJointLimits: { type: Boolean, default: true }
})
const emits = defineEmits(['computedChange'])
const viewerContainer = ref(null)
const isLoading = ref(false)
// Three.js 核心对象
let scene = null
let camera = null
let renderer = null
let controls = null
let robot = null
// 关节控制相关状态
const jointControls = reactive([])
const availableJoints = ref({})
/**
* 从URDF XML中提取关节信息(使用浏览器原生DOMParser)
*/
const extractJointInfoFromURDF = (urdfXml) => {
try {
const joints = []
const parser = new DOMParser()
const xmlDoc = parser.parseFromString(urdfXml, 'text/xml')
// 检查解析错误
const parseError = xmlDoc.getElementsByTagName('parsererror')
if (parseError.length > 0) {
throw new Error('XML解析错误: ' + parseError[0].textContent)
}
// 获取所有joint元素
const jointElements = xmlDoc.getElementsByTagName('joint')
for (let i = 0; i < jointElements.length; i++) {
const jointElement = jointElements[i]
const name = jointElement.getAttribute('name')
const type = jointElement.getAttribute('type')
// 只处理可动关节
if (type && ['revolute', 'continuous', 'prismatic'].includes(type)) {
const jointInfo = {
name: name,
displayName: name.replace(/_/g, ' '),
type: type,
value: 0,
step: 0.01
}
// 提取关节限制
const limitElements = jointElement.getElementsByTagName('limit')
if (limitElements.length > 0) {
const limit = limitElements[0]
const lower = limit.getAttribute('lower')
const upper = limit.getAttribute('upper')
const effort = limit.getAttribute('effort')
const velocity = limit.getAttribute('velocity')
// 处理旋转关节
if (type === 'revolute' || type === 'continuous') {
jointInfo.min = lower !== null ? parseFloat(lower) :
(type === 'revolute' ? -Math.PI : -Math.PI)
jointInfo.max = upper !== null ? parseFloat(upper) :
(type === 'revolute' ? Math.PI : Math.PI)
}
// 处理平移关节
else if (type === 'prismatic') {
jointInfo.min = lower !== null ? parseFloat(lower) : -1.0
jointInfo.max = upper !== null ? parseFloat(upper) : 1.0
jointInfo.step = 0.001
}
if (effort) jointInfo.maxEffort = parseFloat(effort)
if (velocity) jointInfo.maxVelocity = parseFloat(velocity)
} else {
// 如果没有限制,设置默认范围
if (type === 'continuous') {
jointInfo.min = -Math.PI
jointInfo.max = Math.PI
} else if (type === 'revolute') {
jointInfo.min = -Math.PI / 2
jointInfo.max = Math.PI / 2
} else if (type === 'prismatic') {
jointInfo.min = -0.5
jointInfo.max = 0.5
}
}
// 提取关节轴
const axisElements = jointElement.getElementsByTagName('axis')
if (axisElements.length > 0) {
const axis = axisElements[0].getAttribute('xyz')
if (axis) {
const axisValues = axis.split(' ').map(Number)
jointInfo.axis = new THREE.Vector3(...axisValues)
}
}
// 提取关节原点
const originElements = jointElement.getElementsByTagName('origin')
if (originElements.length > 0) {
const origin = originElements[0]
const xyz = origin.getAttribute('xyz')
const rpy = origin.getAttribute('rpy')
if (xyz) jointInfo.origin = xyz.split(' ').map(Number)
if (rpy) jointInfo.orientation = rpy.split(' ').map(Number)
}
joints.push(jointInfo)
}
}
// 按关节名称排序
joints.sort((a, b) => a.name.localeCompare(b.name))
console.log('从URDF提取的关节信息:', joints)
return joints
} catch (error) {
console.error('解析URDF失败:', error)
return []
}
}
/**
* 初始化Three.js场景
*/
const initScene = () => {
const container = viewerContainer.value
scene = new THREE.Scene()
scene.background = new THREE.Color(0xf0f0f0)
camera = new THREE.PerspectiveCamera(
75,
container.clientWidth / container.clientHeight,
0.01,
1000
)
camera.position.set(2, 2, 3)
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(container.clientWidth, container.clientHeight)
container.appendChild(renderer.domElement)
// 光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
scene.add(ambientLight)
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
directionalLight.position.set(10, 20, 10)
scene.add(directionalLight)
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
}
/**
* 加载URDF机器人模型
*/
const loadRobot = async () => {
isLoading.value = true
try {
// 清除现有模型
if (robot) {
scene.remove(robot)
jointControls.length = 0
availableJoints.value = {}
}
// 1. 加载URDF文件内容
const urdfUrl = `${props.basePath}${props.urdfPath}`
const response = await fetch(urdfUrl)
const urdfXml = await response.text()
// 2. 解析URDF提取关节信息(同步调用)
const extractedJoints = extractJointInfoFromURDF(urdfXml)
// 3. 使用URDFLoader加载模型
const manager = new THREE.LoadingManager()
const loader = new URDFLoader(manager)
loader.packages = { '': props.basePath }
robot = await new Promise((resolve, reject) => {
loader.load(urdfUrl, resolve, undefined, reject)
})
// 4. 坐标轴修正
robot.rotation.x = -Math.PI / 2
robot.rotation.z = Math.PI / 2
robot.updateMatrixWorld(true)
// 应用颜色
robot.traverse((child) => {
if (child.isMesh && child.material) {
child.material.color.set(props.modelColor)
}
})
scene.add(robot)
// 5. 初始化关节控制系统
availableJoints.value = robot.joints
// 将提取的关节信息与加载的关节匹配
extractedJoints.forEach(jointInfo => {
if (availableJoints.value[jointInfo.name]) {
// 获取当前关节值
const currentValue = availableJoints.value[jointInfo.name].jointValue || 0
// 确保初始值在限制范围内
let initialValue = currentValue
if (props.showJointLimits) {
initialValue = Math.max(jointInfo.min, Math.min(jointInfo.max, currentValue))
}
// 添加到控制列表
jointControls.push({
...jointInfo,
value: initialValue
})
// 设置初始关节位置
availableJoints.value[jointInfo.name].setJointValue(initialValue)
} else {
console.warn(`关节 ${jointInfo.name} 在URDF模型中不存在或不可控`)
}
})
fitCameraToObject(robot)
} catch (error) {
console.error('加载URDF模型失败:', error)
} finally {
isLoading.value = false
}
}
/**
* 更新关节角度
*/
const updateJointAngle = (jointName, angle) => {
if (availableJoints.value[jointName]) {
try {
// 应用限制(如果启用)
let clampedAngle = angle
if (props.showJointLimits) {
const jointInfo = jointControls.find(j => j.name === jointName)
if (jointInfo) {
clampedAngle = Math.max(jointInfo.min, Math.min(jointInfo.max, angle))
}
}
// 应用关节角度
availableJoints.value[jointName].setJointValue(clampedAngle)
// 更新UI中的值
const jointControl = jointControls.find(j => j.name === jointName)
if (jointControl) {
jointControl.value = clampedAngle
}
} catch (error) {
console.error(`设置关节 ${jointName} 角度失败:`, error)
}
}
}
/**
* 调整相机视角
*/
const fitCameraToObject = (object) => {
const boundingBox = new THREE.Box3().setFromObject(object)
const center = boundingBox.getCenter(new THREE.Vector3())
const size = boundingBox.getSize(new THREE.Vector3())
const maxSize = Math.max(size.x, size.y, size.z)
const fitHeightDistance = maxSize / (2 * Math.atan((Math.PI * camera.fov) / 360))
const fitWidthDistance = fitHeightDistance / camera.aspect
const distance = 1.5 * Math.max(fitHeightDistance, fitWidthDistance)
const direction = new THREE.Vector3(0, 1, 1).normalize()
controls.target.copy(center)
camera.position.copy(center).add(direction.multiplyScalar(distance))
controls.update()
}
/**
* 动画循环
*/
const animate = () => {
requestAnimationFrame(animate)
controls.update()
renderer.render(scene, camera)
}
/**
* 窗口大小变化处理
*/
const onWindowResize = () => {
const container = viewerContainer.value
camera.aspect = container.clientWidth / container.clientHeight
camera.updateProjectionMatrix()
renderer.setSize(container.clientWidth, container.clientHeight)
}
/**
* 清理资源
*/
const dispose = () => {
window.removeEventListener('resize', onWindowResize)
controls?.dispose()
renderer?.dispose()
}
// 监听属性变化
watch(
() => [props.basePath, props.urdfPath],
() => {
loadRobot()
}
)
watch(jointControls, (newVal) => {
emits('computedChange', newVal)
})
// 生命周期
onMounted(() => {
initScene()
loadRobot()
window.addEventListener('resize', onWindowResize)
animate()
})
onBeforeUnmount(() => {
dispose()
})
defineExpose({
updateJointAngle
})
</script>
<style scoped>
/* 样式部分保持不变 */
.urdf-viewer-container {
height: 100%;
width: 100%;
.urdf-viewer {
width: 100%;
height: 100%;
overflow: hidden;
}
}
.loading-overlay p {
font-size: 16px;
color: #ecf0f1;
}
</style>

View File

@ -1,384 +0,0 @@
<script setup>
import { computed, reactive, toRefs, onUnmounted, ref, onMounted } from "vue";
import { useRoute } from "vue-router";
import { CircleClose } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { clearFault as clearArmFault } from "@/api/device/arm";
import UrdfViewer from "./components/UrdfViewer.vue";
import JointControl from "./components/JointControl.vue";
const route = useRoute();
const robotId = computed(() => String(route.query.robotId || ""));
const deviceId = computed(() => String(route.query.deviceId || ""));
const clearingFault = ref(false);
const handleClearFault = async () => {
if (!robotId.value || !deviceId.value) {
ElMessage.warning("缺少机器人或机械臂设备信息");
return;
}
clearingFault.value = true;
try {
await clearArmFault({ robotId: robotId.value, deviceId: deviceId.value });
ElMessage.success("机械臂故障状态已清除");
} catch (error) {
console.error("清除机械臂故障失败:", error);
} finally {
clearingFault.value = false;
}
};
const data = reactive({
form: {
x: 11,
y: 11,
z: 11,
rx: 11,
ry: 11,
rz: 11,
arm: 1,
},
armList: [
{ label: "目标1", value: 1 },
{ label: "目标2", value: 2 },
{ label: "目标3", value: 3 },
],
});
const { form, armList } = toRefs(data);
const leftJointList = ref(null);
const rightJointList = ref(null);
const computedChange = (val) => {
leftJointList.value = val.filter((item) => {
return item.name[0] === "L";
});
rightJointList.value = val.filter((item) => {
return item.name[0] === "R";
});
};
const urdfViewerRef = ref(null);
const updateJointAngle = ({ name, value }) => {
if (urdfViewerRef.value) {
urdfViewerRef.value.updateJointAngle(name, value);
}
};
const coordinateSystem = ref(null); // 用于获取容器元素
const buttonPositions = ref([]); // 存储按钮的动态位置
const buttonDirections = [
{ target: "z_plus", direction: "top", label: "Z+" }, // left
{ target: "rz_plus", direction: "top-right", label: "RZ+" },
{ target: "x_plus", direction: "right", label: "X+" }, // bottom
{ target: "ry_plus", direction: "bottom-right", label: "RY+" },
{ target: "y_minus", direction: "bottom", label: "Y-" },
{ target: "ry_minus", direction: "bottom-left", label: "RY-" },
{ target: "z_minus", direction: "bottom", label: "Z-" },
{ target: "rx_minus", direction: "bottom-left", label: "RX-" },
{ target: "x_minus", direction: "left", label: "X-" },
{ target: "rx_plus", direction: "top-left", label: "RX+" },
{ target: "y_plus", direction: "left", label: "Y+" }, // right
{ target: "rz_minus", direction: "top-left", label: "RZ-" },
];
// 默认图片尺寸
const ORIGINAL_IMAGE_WIDTH = 1025;
const ORIGINAL_IMAGE_HEIGHT = 817;
const updateButtonPositions = () => {
if (coordinateSystem.value) {
const containerRect = coordinateSystem.value.getBoundingClientRect();
const containerWidth = containerRect.width;
const containerHeight = containerRect.height;
// 计算图片的实际显示尺寸(考虑 background-size: contain)
const imageAspectRatio = ORIGINAL_IMAGE_WIDTH / ORIGINAL_IMAGE_HEIGHT;
const containerAspectRatio = containerWidth / containerHeight;
let imageWidth, imageHeight, imageLeft, imageTop;
if (containerAspectRatio > imageAspectRatio) {
imageHeight = containerHeight;
imageWidth = imageHeight * imageAspectRatio;
imageLeft = (containerWidth - imageWidth) / 2;
imageTop = 0;
} else {
imageWidth = containerWidth;
imageHeight = imageWidth / imageAspectRatio;
imageLeft = 0;
imageTop = (containerHeight - imageHeight) / 2;
}
// 计算图片中心坐标
const centerX = imageLeft + imageWidth / 2;
const centerY = imageTop + imageHeight / 2;
// 计算圆的半径(根据图片尺寸的较小值比例确定,可调整比例系数)
const radiusScale = 0.85; // 半径系数,0-1之间,越大半径越大
const radius = Math.min(imageWidth, imageHeight) * radiusScale / 2;
// 定义方位对应的角度(弧度制),top为0度,顺时针增加
const directionAngles = {
"top": 0,
"top-right": Math.PI / 6, // 30度
"right": Math.PI / 3, // 60度
"bottom-right": Math.PI / 2, // 90度
"bottom": 2 * Math.PI / 3, // 120度
"bottom-left": 5 * Math.PI / 6, // 150度
"left": Math.PI, // 180度
"top-left": 7 * Math.PI / 6, // 210度
};
// 调整角度以均匀分布12个按钮(360/12=30度,即Math.PI/6弧度)
const totalButtons = buttonDirections.length;
const angleStep = 2 * Math.PI / totalButtons;
// 更新按钮位置
buttonPositions.value = buttonDirections.map((btn, index) => {
// 计算每个按钮的角度(从top开始顺时针排列)
const angle = index * angleStep;
// 计算按钮的坐标
const x = centerX + radius * Math.sin(angle);
const y = centerY - radius * Math.cos(angle);
return {
...btn,
left: x,
top: y,
};
});
}
};
onMounted(() => {
updateButtonPositions();
// 监听窗口大小变化,更新按钮位置
window.addEventListener("resize", updateButtonPositions);
});
onUnmounted(() => {
window.removeEventListener("resize", updateButtonPositions);
});
</script>
<template>
<div class="mechanical-arm">
<div class="top card">
<div class="controls flex align-center" style="justify-content: flex-start">
<div class="control-label">目标</div>
<el-select
v-model="form.arm"
placeholder="选择目标"
style="width: 100px; margin-right: 20px"
>
<el-option
v-for="item in armList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-button @click="handleResets('zero')" style="margin-right: 10px"
>零位姿态</el-button
>
<el-button @click="handleResets('init')">初始位置</el-button>
<el-button
type="danger"
plain
:loading="clearingFault"
:disabled="!robotId || !deviceId"
@click="handleClearFault"
>
<el-icon><CircleClose /></el-icon>清除故障
</el-button>
</div>
<div class="pose-info flex align-center">
<div class="pose-item">X: {{ form.x.toFixed(6) }} m</div>
<div class="pose-item">Y: {{ form.y.toFixed(6) }} m</div>
<div class="pose-item">Z: {{ form.z.toFixed(6) }} m</div>
<div class="pose-item">RX: {{ form.rx.toFixed(6) }} deg</div>
<div class="pose-item">RY: {{ form.ry.toFixed(6) }} deg</div>
<div class="pose-item">RZ: {{ form.rz.toFixed(6) }} deg</div>
</div>
</div>
<div class="bottom">
<div class="left">
<UrdfViewer
ref="urdfViewerRef"
:base-path="'/public/models/'"
:urdf-path="'dual_arm.urdf'"
:model-color="'#8a8ae9'"
@computedChange="computedChange"
/>
</div>
<div class="right">
<div class="top">
<div class="secondary-title">
<div class="label">左手</div>
<div class="label">右手</div>
</div>
<div class="jointControl-container">
<JointControl
:jointList="leftJointList"
@updateJointAngle="updateJointAngle"
/>
<JointControl
:jointList="rightJointList"
@updateJointAngle="updateJointAngle"
/>
</div>
</div>
<div class="bottom">
<div class="left card" style="position: relative">
<div class="coordinate-system" ref="coordinateSystem">
<div class="control-buttons">
<button
v-for="(pos, index) in buttonPositions"
:key="index"
class="pos-btn"
:data-target="pos.target"
:style="{ left: `${pos.left}px`, top: `${pos.top}px` }"
>
{{ pos.label }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.mechanical-arm {
display: flex;
flex: 1;
overflow: hidden;
flex-direction: column;
box-sizing: border-box;
.card {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
}
.controls {
gap: 10px;
padding: 10px;
}
.top {
.pose-info {
justify-content: space-around;
padding: 10px 0;
.pose-item {
font-size: 16px;
user-select: none;
}
}
}
.bottom {
height: calc(100% - 120px);
display: flex;
gap: 12px;
.left {
flex: 2;
height: 100%;
}
.right {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
.top {
flex: 1;
border: 1px solid #e4e7ed;
padding: 0 8px;
overflow: hidden;
.container-title {
font-size: 16px;
text-align: center;
}
.secondary-title {
display: flex;
justify-content: space-around;
padding: 10px;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.jointControl-container {
display: flex;
gap: 10px;
height: calc(100% - 75px);
overflow: auto;
}
}
.bottom {
flex: 1;
.card {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
height: 100%;
display: flex;
flex-direction: column;
.coordinate-system {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
position: relative;
background-color: #fff;
background-image: url("@/assets/images/arm.png");
background-size: contain;
background-position: center;
background-repeat: no-repeat;
.control-buttons {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
.pos-btn {
position: absolute;
width: 40px;
height: 40px;
background: #4076ff;
color: white;
border: none;
cursor: pointer;
font-size: 16px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%); /* 按钮中心对齐坐标 */
}
}
}
}
}
}
}
}
</style>

View File

@ -1,233 +0,0 @@
<template>
<div class="microphone-container">
<div class="title">远程麦克风</div>
<div class="audiosList" v-if="audiosList.length > 0">
<div class="speaker-container">
<div class="speaker-title">选择扬声器</div>
<el-select v-model="speaker" size="small">
<el-option
v-for="item in speakerOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</div>
<div class="audioItem" v-for="item in audiosList">
<div>{{ item }}</div>
<div>
<el-button
circle
size="small"
:icon="VideoPlay"
@click="handlePlay(item)"
></el-button>
</div>
</div>
</div>
<div class="btn-container">
<el-tooltip
class="box-item"
effect="dark"
content="点击开始录音"
placement="top-start"
v-if="recordStatus === 'end'"
>
<el-icon class="btn" :size="40" @click="startRecording"><Microphone /></el-icon>
</el-tooltip>
<el-tooltip
class="box-item"
effect="dark"
content="点击暂停录音"
placement="top-start"
v-if="recordStatus === 'recording'"
>
<el-icon class="btn" :size="40" @click="pauseRecording"><VideoPause /></el-icon>
</el-tooltip>
<el-tooltip
class="box-item"
effect="dark"
content="点击恢复录音"
placement="top-start"
v-if="recordStatus === 'pause'"
>
<el-icon class="btn" :size="40" @click="recoverRecording"><Mic /></el-icon>
</el-tooltip>
<el-tooltip
class="box-item"
effect="dark"
content="点击结束录音"
placement="top-start"
v-if="recordStatus !== 'end'"
>
<el-icon class="btn" :size="40" @click="stopRecording"><Mute /></el-icon>
</el-tooltip>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { Microphone, VideoPause, Mute, Mic, VideoPlay } from "@element-plus/icons-vue";
import {
startMicrophoneApi,
pauseMicrophoneApi,
resumeMicrophoneApi,
stopMicrophoneApi,
playSpeaker
} from '@/api/device/microphone'
import { useRoute } from 'vue-router'
import { ElMessage } from "element-plus";
import { getRobotDevicesByRobotId } from '@/api/resource/robot'
const route = useRoute()
const robotId = route.query.robotId
const deviceId = route.query.deviceId
const recordStatus = ref('end');
const isRecording = ref(false)
const list = []
const audiosList = ref([])
const speaker = ref('')
const speakerOptions = ref([])
onMounted(async () => {
if (!robotId) return
const response = await getRobotDevicesByRobotId(String(robotId), { deviceKind: 13 })
speakerOptions.value = (response.data || []).map(item => item.deviceId)
speaker.value = speakerOptions.value[0] || ''
})
/**
* 开始录音
*/
const startRecording = async () => {
const fileName = new Date().getTime() + '.wav'
isRecording.value = true
const res = await startMicrophoneApi({
robotId,
deviceId,
filePath: `/home/share/record/audios/${robotId}_${deviceId}_${fileName}`
})
if (res.code === 200) {
recordStatus.value = 'recording'
list.push(`${robotId}_${deviceId}_${fileName}`)
}
};
/**
* 暂停录音
*/
const pauseRecording = async () => {
const res = await pauseMicrophoneApi({
robotId,
deviceId
})
if (res.code === 200) {
recordStatus.value = 'pause'
}
};
/**
* 恢复录音
*/
const recoverRecording = async () => {
const res = await resumeMicrophoneApi({
robotId,
deviceId
})
if (res.code === 200) {
recordStatus.value = 'recording'
}
}
/**
* 结束录音
*/
const stopRecording = async () => {
const res = await stopMicrophoneApi({
robotId,
deviceId
})
if (res.code === 200) {
recordStatus.value = 'end'
audiosList.value.unshift(list[list.length - 1])
}
isRecording.value = false
};
/**
* 播放录音
*/
const handlePlay = async (audioPath) => {
const res = await playSpeaker({
robotId,
deviceId: speaker.value,
audioPath: `/home/share/record/audios/${audioPath}`
})
if (res.code === 200) {
ElMessage.success('播放成功')
}
}
</script>
<style lang="scss" scoped>
.microphone-container {
display: flex;
flex: 1;
flex-direction: column;
max-width: 800px;
width: 100%;
height: calc(100vh - 125px);
background-color: #fff;
border-radius: 20px;
margin: auto;
.audiosList {
margin: 20px;
max-height: 280px;
overflow-y: auto;
.speaker-container {
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 12px;
.speaker-title {
width: 80px;
}
.el-select {
width: 200px;
}
}
.audioItem {
display: flex;
align-items: center;
gap: 20px;
}
}
.title {
padding-top: 20px;
font-size: 20px;
text-align: center;
color: #333;
}
.btn-container {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: center;
.btn {
margin-right: 10px;
cursor: pointer;
}
}
}
</style>

View File

@ -1,76 +0,0 @@
<script setup>
import ControlCard from "../ControlCard/index.vue"
const data = reactive({
form: {},
rules: {}
})
const {form, rules} = toRefs(data)
</script>
<template>
<div class="motor">
<el-row :gutter="16">
<el-col :span="12">
<ControlCard title="电机">
<div class="pic">
电机pic
<svg t="1747108210294" class="arrow" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="23587" width="32" height="32"><path d="M481.940645 156.242581C209.094194 383.504516 254.018065 660.645161 313.806452 778.570323l117.925161 234.859354 152.609032-76.634838-117.925161-234.859355c-4.624516-8.91871-100.748387-224.619355 132.129032-418.188387l33.032258 70.028387c8.91871 18.167742 19.489032 27.747097 39.969032 23.122581a36.996129 36.996129 0 0 0 27.086452-33.032259l66.064516-340.56258L423.803871 51.530323a39.308387 39.308387 0 0 0-36.005161 25.765161 37.987097 37.987097 0 0 0 19.489032 39.308387l74.652903 39.63871" fill="#409eff" p-id="23588"></path></svg>
<svg t="1747108469463" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="28512" width="32" height="32"><path d="M533.312 1015.104l-3.968 3.392a30.144 30.144 0 0 1-47.488-24.64v-210.816H70.272A70.272 70.272 0 0 1 0 712.768V311.232l0.384-7.68c3.84-35.2 33.664-62.592 69.888-62.592h411.584V30.144c0-26.816 32.448-40.256 51.456-21.248l481.92 481.792a30.08 30.08 0 0 1 0 42.624l-481.92 481.792z" fill="#409eff" p-id="28513"></path></svg>
<svg t="1747126605366" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1461" width="32" height="32"><path d="M311.432533 448.170667H163.84L510.293333 3.413333 856.746667 448.170667h-147.592534L760.832 1006.933333H259.754667z" fill="#0F62FE" p-id="1462"></path></svg>
<svg t="1747126646064" class="icon" viewBox="0 0 2105 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1623" width="32" height="32"><path d="M1368.744385 1015.123641l-5.916434 3.413328a64.853225 64.853225 0 0 1-46.819478 2.047997c-14.961753-5.119991-24.405293-15.473752-24.405292-26.737734v-210.772982H105.414935c-27.989287 0-54.783909-7.395543-74.52432-20.593743C11.093315 749.225418 0 731.419225 0 712.759701V311.238592l0.568888-7.679987c5.745768-35.214164 50.517249-62.577673 104.846047-62.577673h1186.188246V30.151061c0-26.794622 48.696808-40.277266 77.198093-21.21952l722.829018 481.791197c8.47643 5.631991 13.255089 13.311978 13.255089 21.276409 0 7.964431-4.778659 15.701307-13.255089 21.333297L1368.744385 1015.123641z" fill="#D6E4FE" p-id="1624"></path></svg>
<svg t="1747126605366" class="turn-up" viewBox="0 0 1024 1024" version="1.1"
xmlns="http://www.w3.org/2000/svg" p-id="1461" width="32" height="32">
<path
d="M311.432533 448.170667H263.84L510.293333 3.413333 856.746667 448.170667h-247.592534L760.832 1006.933333H159.754667z"
fill="#0F62FE" p-id="1462"></path>
</svg>
</div>
</ControlCard>
</el-col>
<el-col :span="12">
<ControlCard title="操作">
<div class="control">
<el-form :model="form" :rules="rules" label-width="100px">
<el-form-item label="速度">
<el-input v-model="form.speed" placeholder="请输入速度"></el-input>
</el-form-item>
<el-form-item label="位置">
<el-input v-model="form.position" placeholder="请输入位置"></el-input>
</el-form-item>
<el-form-item label="状态">
<el-input v-model="form.status" placeholder="请输入状态"></el-input>
</el-form-item>
<el-form-item label="运行模式">
<el-input v-model="form.mode" placeholder="请输入运行模式"></el-input>
</el-form-item>
</el-form>
</div>
</ControlCard>
</el-col>
</el-row>
</div>
</template>
<style scoped lang="scss">
.motor {
padding: 10px;
width: 100vw;
height: 100vh;
.arrow{
width: 100px;
height: 100px;
}
.el-row {
height: 100%;
.control-card {
height: 100%;
}
}
}
</style>

View File

@ -1,278 +0,0 @@
<template>
<div class="audio-player">
<div class="title">远程扬声器</div>
<div class="audio-list">
<div
v-for="(audio, index) in audioList"
:key="index"
class="audio-item"
:class="{ 'active': index === currentIndex }"
@click="playAudio(index)"
>
{{ audio.name }}
</div>
</div>
<div class="controls">
<el-button @click="playPrevious" :disabled="currentIndex === 0" circle size="small">
<svg :class="`icon ${currentIndex === 0 ? 'disabled-icon' : ''}`" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="20">
<path d="M362.3 512l445-332.3v664.5L362.3 512zM216.7 179.7h80v664.5h-80V179.7z" fill="" />
</svg>
</el-button>
<el-button @click="togglePlay" circle>
<svg v-if="isPlaying" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<path d="M512 1024C228.266667 1024 0 795.733333 0 512S228.266667 0 512 0s512 228.266667 512 512-228.266667 512-512 512z m0-42.666667c260.266667 0 469.333333-209.066667 469.333333-469.333333S772.266667 42.666667 512 42.666667 42.666667 251.733333 42.666667 512s209.066667 469.333333 469.333333 469.333333z m-106.666667-682.666666c12.8 0 21.333333 8.533333 21.333334 21.333333v384c0 12.8-8.533333 21.333333-21.333334 21.333333s-21.333333-8.533333-21.333333-21.333333V320c0-12.8 8.533333-21.333333 21.333333-21.333333z m213.333334 0c12.8 0 21.333333 8.533333 21.333333 21.333333v384c0 12.8-8.533333 21.333333-21.333333 21.333333s-21.333333-8.533333-21.333334-21.333333V320c0-12.8 8.533333-21.333333 21.333334-21.333333z" fill="#666666" fill-opacity=".9" />
</svg>
<svg v-else class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<path d="M512 0C230.4 0 0 230.4 0 512s230.4 512 512 512 512-230.4 512-512S793.6 0 512 0z m0 981.333333C253.866667 981.333333 42.666667 770.133333 42.666667 512S253.866667 42.666667 512 42.666667s469.333333 211.2 469.333333 469.333333-211.2 469.333333-469.333333 469.333333z" fill="#666666" />
<path d="M672 441.6l-170.666667-113.066667c-57.6-38.4-106.666667-12.8-106.666666 57.6v256c0 70.4 46.933333 96 106.666666 57.6l170.666667-113.066666c57.6-42.666667 57.6-106.666667 0-145.066667z" fill="#666666" />
</svg>
</el-button>
<el-button @click="playNext" :disabled="currentIndex === audioList.length - 1" circle size="small">
<svg :class="`icon ${currentIndex === audioList.length - 1 ? 'disabled-icon' : ''}`" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="20">
<path d="M216.7 844.3V179.7l445 332.3-445 332.3z m590.6 0h-80V179.7h80v664.6z" fill="" />
</svg>
</el-button>
<div class="volume-control">
<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="24" height="24">
<path d="M448 938.666667a21.333333 21.333333 0 0 1-15.093333-6.246667L225.833333 725.333333H53.333333a53.393333 53.393333 0 0 1-53.333333-53.333333V352a53.393333 53.393333 0 0 1 53.333333-53.333333h172.5l207.08-207.086667A21.333333 21.333333 0 0 1 469.333333 106.666667v810.666666a21.333333 21.333333 0 0 1-21.333333 21.333334zM53.333333 341.333333a10.666667 10.666667 0 0 0-10.666666 10.666667v320a10.666667 10.666667 0 0 0 10.666666 10.666667h181.333334a21.333333 21.333333 0 0 1 15.086666 6.246666L426.666667 865.833333V158.166667L249.753333 335.086667A21.333333 21.333333 0 0 1 234.666667 341.333333z m750.48 586.553334a21.333333 21.333333 0 0 1-12.726666-38.466667 474.853333 474.853333 0 0 0 52.78-45.553333c182.993333-182.993333 182.993333-480.74 0-663.733334a474.246667 474.246667 0 0 0-52.78-45.553333 21.333333 21.333333 0 0 1 25.42-34.273333 518.346667 518.346667 0 0 1 57.533333 49.653333 511.606667 511.606667 0 0 1 0 724.08 519.026667 519.026667 0 0 1-57.54 49.653333 21.22 21.22 0 0 1-12.686667 4.193334z m-86.213333-149.333334a21.333333 21.333333 0 0 1-13.733333-37.666666c6.666667-5.586667 13.146667-11.553333 19.333333-17.726667C779.6 666.78 810.666667 591.78 810.666667 512s-31.066667-154.78-87.48-211.186667c-6.173333-6.173333-12.666667-12.14-19.333334-17.726666a21.333333 21.333333 0 1 1 27.446667-32.666667 346.585333 346.585333 0 0 1 22.046667 20.213333 341.066667 341.066667 0 0 1 0 482.72 346.585333 346.585333 0 0 1-22.046667 20.213334 21.24 21.24 0 0 1-13.7 5.013333zM629.333333 625.72a21.333333 21.333333 0 0 1-16.733333-34.546667 127.366667 127.366667 0 0 0 0-158.346666 21.333333 21.333333 0 0 1 33.486667-26.433334 170.733333 170.733333 0 0 1 0 211.213334A21.333333 21.333333 0 0 1 629.333333 625.72z" fill="#5C5C66" />
</svg>
<input
type="range"
min="0"
max="100"
step="1"
v-model.number="volume"
@input="debouncedSetVolume"
/>
<span>{{ Math.round(volume) }}%</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';
import {
pause,
play,
resume,
status,
stop,
getVolume,
setVolume as setVolumeApi,
getAll
} from '@/api/device/speaker';
const audioList = ref([
{ name: 'Track 1', path: '/audio/track1.mp3' },
{ name: 'Track 2', path: '/audio/track2.mp3' },
{ name: 'Track 3', path: '/audio/track3.mp3' },
]);
const currentIndex = ref(0);
const isPlaying = ref(false);
const volume = ref(0);
const route = useRoute();
const robotId = ref(String(route.query.robotId || ''));
const deviceId = ref(String(route.query.deviceId || ''));
const getCommonParams = () => ({
robotId: robotId.value,
deviceId: deviceId.value
});
const debounce = (func, delay) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func.apply(null, args), delay);
};
};
let pollingTimeout;
const checkStatus = async () => {
try {
const response = await status(getCommonParams());
isPlaying.value = response.data.state.isRunning && !response.data.state.isPaused;
if (isPlaying.value && !response.data.state.isPaused) {
// 完成后再次调度
pollingTimeout = setTimeout(checkStatus, 1000); // 5秒间隔
}
} catch (error) {
console.error('状态检查失败:', error);
// 即使出错,也继续轮询
// pollingTimeout = setTimeout(checkStatus, 1000);
}
};
onMounted(async () => {
try {
const allResponse = await getAll();
audioList.value = allResponse.data.map(item => ({
name: item.split('.')[0],
path: `/home/share/assets/upload/${item}`
}));
await initialize();
// 启动轮询
checkStatus();
} catch (error) {
console.error('初始化失败:', error);
}
});
onUnmounted(async () => {
try {
await stop(getCommonParams());
if (pollingTimeout) {
clearTimeout(pollingTimeout); // 清除轮询
}
} catch (error) {
console.error('清理失败:', error);
}
});
const initialize = async () => {
try {
const volumeResponse = await getVolume({ ...getCommonParams() });
volume.value = volumeResponse.data || 0;
} catch (error) {
console.error('初始化状态失败:', error);
}
};
const playAudio = async (index) => {
currentIndex.value = index;
isPlaying.value = true;
try {
await play({ ...getCommonParams(), audioPath: audioList.value[index].path });
checkStatus();
} catch (error) {
console.error('播放失败:', error);
isPlaying.value = false;
}
};
const playPrevious = async () => {
if (currentIndex.value > 0) {
await playAudio(currentIndex.value - 1);
}
};
const playNext = async () => {
if (currentIndex.value < audioList.value.length - 1) {
await playAudio(currentIndex.value + 1);
} else {
await playAudio(0);
}
};
const togglePlay = async () => {
try {
if (isPlaying.value) {
await pause(getCommonParams());
} else {
await resume(getCommonParams());
}
isPlaying.value = !isPlaying.value;
checkStatus();
} catch (error) {
console.error('操作失败:', error);
}
};
const setVolume = async () => {
try {
await setVolumeApi({ ...getCommonParams(), volume: volume.value });
} catch (error) {
console.error('音量设置失败:', error);
}
};
const debouncedSetVolume = debounce(setVolume, 500);
</script>
<style scoped>
.audio-player {
display: flex;
flex: 1;
flex-direction: column;
max-width: 800px;
width: 100%;
height: calc(100vh - 125px);
background-color: #fff;
border-radius: 20px;
margin: auto;
}
.title {
padding-top: 20px;
font-size: 20px;
text-align: center;
color: #333;
}
.audio-list {
margin: 20px 0;
max-height: calc(100% - 130px);
overflow-y: auto;
border-bottom: 1px solid #ddd;
border-top: 1px solid #ddd;
}
.audio-item {
padding: 10px;
cursor: pointer;
}
.audio-item:hover,
.audio-item.active {
background-color: #e0e0e0;
}
.controls {
display: flex;
justify-content: space-around;
align-items: center;
margin-top: 20px;
}
button {
padding: 8px 16px;
background-color: rgba(0, 0, 0, 0);
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
button:disabled {
background-color: rgba(0, 0, 0, 0);
cursor: not-allowed;
}
button:hover:not(:disabled) {
background-color: #eee;
}
.volume-control {
display: flex;
align-items: center;
gap: 5px;
}
input[type="range"] {
width: 100px;
}
.icon {
vertical-align: middle;
}
.disabled-icon {
fill: #ddd;
}
</style>

View File

@ -1,108 +0,0 @@
<template>
<div class="voice-container" @click="togglePlay">
<div class="voice-btn" :class="{ playing: isPlaying }">
<span class="duration">{{ formattedDuration }}"</span>
<div class="wave">
<span v-for="n in 3" :key="n"
:style="{ height: isPlaying ? getRandomHeight() : '0px' }"></span>
</div>
</div>
<audio ref="audioEl"
:src="url"
@play="handlePlay"
@pause="handlePause"
@ended="handleEnd"
@loadedmetadata="updateDuration"></audio>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const props = defineProps({
url: { type: String, required: true },
recordingTime: { type: Number },
})
const audioEl = ref(null)
const isPlaying = ref(false)
const duration = ref(0)
let animationFrame
const formattedDuration = computed(() => Math.ceil(duration.value))
const getRandomHeight = () => `${Math.random() * 12 + 4}px`
const togglePlay = () => {
isPlaying.value ? audioEl.value.pause() : audioEl.value.play()
}
const handlePlay = () => {
isPlaying.value = true
animateWave()
}
const handlePause = () => {
isPlaying.value = false
cancelAnimationFrame(animationFrame)
}
const handleEnd = () => {
isPlaying.value = false
cancelAnimationFrame(animationFrame)
}
const updateDuration = () => {
duration.value = audioEl.value.duration === Infinity ? props.recordingTime : audioEl.value.duration
}
const animateWave = () => {
if (!isPlaying.value) return
animationFrame = requestAnimationFrame(animateWave)
}
// onMounted(() => {
// audioEl.value.src = props.src
// })
onUnmounted(() => {
cancelAnimationFrame(animationFrame)
})
</script>
<style scoped>
.voice-container {
display: inline-block;
cursor: pointer;
width: 100px;
}
.voice-btn {
display: flex;
align-items: center;
padding: 8px 12px;
background: #e6e6e6;
border-radius: 18px;
transition: all 0.3s;
}
.voice-btn.playing {
background: #d3e3fd;
}
.duration {
margin: 0 12px;
font-size: 18px;
color: #666;
}
.wave {
display: flex;
align-items: flex-end;
height: 20px;
gap: 3px;
}
.wave span {
display: inline-block;
width: 4px;
background: #07C160;
transition: height 0.3s ease;
}
</style>

View File

@ -1,699 +0,0 @@
<template>
<div class="container">
<div class="chat-container">
<div class="message-list" v-if="messages.length > 0" ref="messageRef">
<div
v-for="(msg, index) in messages"
:key="index"
class="message"
:class="{
'user-message': msg.role === 'user',
'ai-message': msg.role === 'assistant',
}"
>
<div class="message-box">
<div v-if="msg.role === 'assistant'" class="picture"></div>
<div class="message-container">
<div v-for="item in msg.list" class="messageContent">
<div class="content">{{ item.content }}</div>
<div class="audio" v-if="item.url">
<AudioPlayer :url="item.url" />
</div>
</div>
</div>
<!-- <div v-if="msg.role === 'user'" class="picture"></div> -->
</div>
</div>
<div v-if="isQuery" style="margin-left: 60px">
{{ queryText }}<el-icon><Loading /></el-icon>
</div>
</div>
<div class="welcome" v-else>
<div>
<div class="name">{{ `我是${aiName}助手,很高兴见到你` }}</div>
<div class="function">我可以帮你合成音色,请把你的任务交给我吧~</div>
</div>
</div>
<div class="input-area">
<el-input
v-model="inputMessage"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
resize="none"
placeholder="Shift+Enter换行"
@keyup.native="handleKeyUp"
@keyup.enter.native="handleEnter"
>
</el-input>
<div class="bottom-box">
<div class="select-box">
<el-select v-model="timbreValue" size="small" style="width: 120px">
<el-option
v-for="item in timbreOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
<el-select
v-if="aiType === 'knowledge'"
v-model="langValue"
size="small"
style="width: 100px; margin-left: 10px"
>
<el-option
v-for="item in langOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="dialectValue"
:disabled="langValue !== '中文'"
size="small"
style="width: 100px; margin-left: 10px"
>
<el-option
v-for="item in dialectOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-if="aiType === 'knowledge'"
v-model="toneValue"
size="small"
style="width: 100px; margin-left: 10px"
>
<el-option
v-for="item in toneOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input-number style="width: 100px; margin-left: 10px" v-if="aiType === 'knowledge'" size="small" v-model="countValue" :min="1" :step="1" step-strictly />
</div>
<div class="send-message">
<el-button
:icon="Position"
size="small"
type="primary"
circle
:disabled="inputMessage === ''"
@click="sendMessage"
/>
</div>
</div>
</div>
</div>
<div class="addSound" @click="openDialog">
<el-icon :size="40"><CirclePlus /></el-icon>
</div>
<el-dialog
v-model="dialogVisible"
title="新增音色"
width="500"
:before-close="handleClose"
>
<el-form
ref="ruleFormRef"
style="max-width: 600px"
:model="ruleForm"
:rules="rules"
label-width="auto"
>
<el-form-item label="名称" prop="name">
<el-input v-model="ruleForm.name" />
</el-form-item>
</el-form>
<SoundRecording ref="soundRecordingRef" />
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="submit">提交</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from "vue";
import { Position, CirclePlus } from "@element-plus/icons-vue";
import SoundRecording from "./soundRecording.vue";
import { ElMessage } from "element-plus";
import { createTimbre, getTts, createCorpus, getSynthesize, createAdvancedCorpus, getAdvancedSynthesize } from '@/api/tts';
import { ConcurrentRequestController } from "@/utils/requestController";
import AudioPlayer from "./AudioPlayer.vue";
const props = defineProps({
aiName: {
type: String,
default: '语音助手'
},
aiType: {
type: String,
default: 'basis'
}
})
const messageRef = ref(null);
const inputMessage = ref("");
const messages = ref([]);
const isQuery = ref(false);
const queryText = ref("正在思考中");
const messageIndex = ref(-1)
const controller = new ConcurrentRequestController(3); // 最大并发数为3
const cancelIndex = ref(0)
const sendMessage = async () => {
if (inputMessage.value.trim()) {
isQuery.value = true;
messages.value.push({ role: "user", list: [{ content: inputMessage.value }] });
await nextTick();
messageRef.value.scrollTop = messageRef.value.scrollHeight;
const str = inputMessage.value;
inputMessage.value = "";
if (controller.getStatus().queueLength > 0) {
controller.cancelAll();
cancelIndex.value = messageIndex.value
messages.value[cancelIndex.value] = {
role: "assistant",
list: [{ content: '已取消查询' }]
}
}
messageIndex.value += 2
getMessage(str, messageIndex.value);
}
};
const getMessage = (str, index) => {
if (props.aiType === 'basis') {
basisAiOperate(str, index)
} else if (props.aiType === 'knowledge') {
knowledgeAiOperate(str, index)
}
};
// 基础ai(语音合成)发送消息后的操作
const basisAiOperate = async (str, index) => {
try {
const res = await createCorpus({
convertWords: str,
dialect: dialectValue.value,
timbre: timbreValue.value,
});
if (res.code === 200) {
const msg = JSON.parse(res?.msg || "[]");
if (msg.length === 0) {
serviceError("未查询到相关服务");
} else {
queryText.value = "语音合成中";
getBasisAudio(msg, index);
}
} else {
serviceError("服务器繁忙,请稍后重试");
}
} catch (err) {
serviceError("服务器繁忙,请稍后重试");
}
}
const getBasisAudio = async (arr, index) => {
messages.value[index] = {
role: "assistant",
list: []
}
await basisApiController(arr, index);
isQuery.value = false;
};
async function basisApiController(arr, messageIndex) {
// 模拟10个需要发送的请求
const requests = arr.map((item, index) => {
return (signal) =>
getSynthesize({
dialect: dialectValue.value,
text: item,
timbre: timbreValue.value,
}, {
signal: signal
})
.then(async (data) => {
if (cancelIndex.value === messageIndex) {
return
}
console.log(`请求 ${index} 完成`);
const url = URL.createObjectURL(data);
messages.value[messageIndex].list.push({
content: item,
url,
})
await nextTick();
messageRef.value.scrollTop = messageRef.value.scrollHeight;
return data;
})
.catch(async (error) => {
if (cancelIndex.value === messageIndex) {
return
}
console.error(`请求 ${index} 失败:`, error);
messages.value[messageIndex].list.push({
content: item + " ,语音转换失败"
})
await nextTick();
messageRef.value.scrollTop = messageRef.value.scrollHeight;
throw error;
});
});
// 发送所有请求
const results = await Promise.allSettled(
requests.map((request) => controller.addRequest(request))
);
// 处理结果
const successfulResults = results
.filter((result) => result.status === "fulfilled")
.map((result) => result.value);
console.log("所有请求已完成", successfulResults);
return successfulResults;
}
// 语音合成(知识库)发送消息后的操作
const knowledgeAiOperate = async (str, index) => {
try {
const params = {
convertWords: str,
dialect: langValue.value === '中文' ? dialectValue.value : '',
timbre: timbreValue.value,
language: langValue.value,
tone : toneValue.value,
count: countValue.value
}
const res = await createAdvancedCorpus(params);
if (res.code === 200) {
const msg = JSON.parse(res?.msg || "[]");
if (msg.length === 0) {
serviceError("未查询到相关服务");
} else {
queryText.value = "语音合成中";
getKnowledgeAudio(msg, index, params);
}
} else {
serviceError("服务器繁忙,请稍后重试");
}
} catch (err) {
serviceError("服务器繁忙,请稍后重试");
}
}
const getKnowledgeAudio = async (arr, index, params) => {
messages.value[index] = {
role: "assistant",
list: []
}
const data = await knowledgeApiController(arr, index, params);
console.log('data', data)
isQuery.value = false;
}
async function knowledgeApiController(arr, messageIndex, params) {
// 模拟10个需要发送的请求
const requests = arr.map((item, index) => {
return (signal) =>
getAdvancedSynthesize({
dialect: params.dialect,
text: item,
timbre: params.timbre,
tone: params.tone,
language: params.language
}, {
signal: signal
})
.then(async (data) => {
if (cancelIndex.value === messageIndex) {
return
}
console.log(`请求 ${index} 完成`);
const url = URL.createObjectURL(data);
messages.value[messageIndex].list.push({
content: item,
url,
})
await nextTick();
messageRef.value.scrollTop = messageRef.value.scrollHeight;
return data;
})
.catch(async (error) => {
if (cancelIndex.value === messageIndex) {
return
}
console.error(`请求 ${index} 失败:`, error);
messages.value[messageIndex].list.push({
content: item + " ,语音转换失败"
})
await nextTick();
messageRef.value.scrollTop = messageRef.value.scrollHeight;
throw error;
});
});
// 发送所有请求
const results = await Promise.allSettled(
requests.map((request) => controller.addRequest(request))
);
// 处理结果
const successfulResults = results
.filter((result) => result.status === "fulfilled")
.map((result) => result.value);
console.log("所有请求已完成", successfulResults);
return successfulResults;
}
const serviceError = (content) => {
isQuery.value = false;
messages.value.push({
role: "assistant",
list: [{content}]
});
};
const handleKeyUp = (event) => {
// 阻止shift+enter触发的回车键事件冒泡
if (event.key === "Enter" && event.shiftKey) {
event.stopPropagation();
}
};
const handleEnter = (event) => {
// 只处理没有按住shift键的回车键事件
if (!event.shiftKey) {
sendMessage();
}
};
const timbreValue = ref("");
const timbreOptions = ref([]);
const dialectValue = ref("普通话");
const dialectOptions = [
{
label: "普通话",
value: "普通话",
},
{
label: "粤语",
value: "粤语",
},
{
label: "四川话",
value: "四川话",
},
{
label: "上海话",
value: "上海话",
},
{
label: "南京话",
value: "南京话",
},
{
label: "天津话",
value: "天津话",
},
{
label: "武汉话",
value: "武汉话",
},
];
const toneValue = ref('中性')
const toneOptions = [
{
label: '中性',
value: '中性'
},
{
label: "高兴",
value: "高兴",
},
{
label: "悲伤",
value: "悲伤",
},
{
label: "愤怒",
value: "愤怒",
},
// {
// label: "冷漠",
// value: "冷漠",
// }
]
const langValue = ref('中文')
const langOptions = [
{
label: "中文",
value: "中文",
},
{
label: "英文",
value: "英文",
},
{
label: "日文",
value: "日文",
},
{
label: "韩语",
value: "韩语",
}
]
const countValue = ref(1)
const getTimbre = async () => {
const req = await getTts();
if (req.code === 200) {
timbreOptions.value = req.data
timbreValue.value = req.data[0]
}
};
getTimbre();
const dialogVisible = ref(false);
const openDialog = () => {
dialogVisible.value = true;
};
const ruleFormRef = ref();
const ruleForm = reactive({
name: "",
});
const rules = reactive({
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
});
const soundRecordingRef = ref(null);
const submit = () => {
ruleFormRef.value.validate(async (valid) => {
if (valid) {
const globalBlob = soundRecordingRef.value.getWav();
// 直接创建新Blob并指定WAV类型
if (globalBlob) {
const file = new File([globalBlob], `${ruleForm.name}.m4a`, {
type: "audio/m4a",
});
const formData = new FormData();
formData.append("file", file);
formData.append("language_instruction", ruleForm.name);
const res = await createTimbre(formData);
if (res.code === 200) {
ElMessage.success("操作成功");
handleClose();
getTimbre();
} else {
ElMessage.success(res?.msg || "操作失败");
}
} else {
ElMessage.error("请先录音");
}
} else {
console.log("error submit!");
}
});
};
const handleClose = () => {
resetForm();
dialogVisible.value = false;
};
const resetForm = () => {
ruleFormRef.value.resetFields();
soundRecordingRef.value.reset();
};
</script>
<style lang="scss" scoped>
.container {
width: 100%;
display: flex;
justify-content: center;
.chat-container {
display: flex;
flex-direction: column;
width: 800px;
height: 100%;
.message-list {
flex: 1;
overflow-y: auto;
padding: 20px;
position: relative;
.message {
display: flex;
overflow-wrap: break-word;
margin: 10px;
position: relative;
align-items: center;
}
.message-box {
display: flex;
// flex-direction: column;
// align-items: end;
}
.user-message {
justify-content: right;
.content {
border-radius: 8px;
background-color: #ddd9ff;
white-space: pre-wrap;
padding: 10px;
max-width: 500px;
}
.picture {
width: 40px;
height: 40px;
background-image: url("./img/user.png");
background-repeat: no-repeat;
background-size: 100% 100%;
margin-bottom: 10px;
margin-left: 10px;
}
}
.ai-message {
.audio {
margin-left: 20px;
audio::-webkit-media-controls {
display: none; /* Chrome, Safari, Opera */
}
}
.message-container {
background-color: #eff6ff;
border-radius: 12px;
.messageContent {
display: flex;
align-items: center;
padding: 10px;
max-width: 500px;
}
}
.picture {
width: 40px;
height: 40px;
background-image: url("./img/user.png");
background-repeat: no-repeat;
background-size: 100% 100%;
margin-right: 10px;
}
}
}
.welcome {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
.name {
font-size: 40px;
}
.function {
margin-top: 10px;
font-size: 20px;
}
}
:deep(.input-area) {
padding: 20px;
height: 140px;
border: 1px solid #ddd9ff;
border-radius: 20px;
.el-textarea__inner {
box-shadow: none;
&:focus {
box-shadow: none;
}
&:hover {
box-shadow: none;
}
}
.bottom-box {
margin-top: 8px;
display: flex;
align-items: center;
justify-content: space-between;
.el-select__wrapper {
border-radius: 10px;
}
}
}
.input-area:has(.el-textarea__inner:focus) {
box-shadow: 0 0 0 1px #ddd9ff inset;
}
.input-area:has(.el-textarea__inner:hover) {
box-shadow: 0 0 0 1px #ddd9ff inset;
}
}
.addSound {
margin-left: 8px;
cursor: pointer;
}
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,65 +0,0 @@
<template>
<div class="app-container">
<el-container class="box-container">
<el-aside class="aside-container" v-if="showAside">
<el-menu class="ai-menu" :default-active="activeMenu" @select="menuSelect">
<el-menu-item index="1">
<!-- <el-icon><icon-menu /></el-icon> -->
<template #title>语音合成(知识库)</template>
</el-menu-item>
<el-menu-item index="2">
<!-- <el-icon><document /></el-icon> -->
<template #title>语音合成</template>
</el-menu-item>
</el-menu>
</el-aside>
<el-main class="main-content">
<!-- 这里知识库和基础库弄反了,临时页面,也懒得去纠正了 -->
<Chat v-show="activeMenu === '1'" aiName="语音合成(知识库)" aiType="basis" />
<Chat v-show="activeMenu === '2'" aiName="语音合成" aiType="knowledge" />
</el-main>
</el-container>
</div>
</template>
<script setup>
import { ref } from "vue";
import Chat from "./chat.vue";
const activeMenu = ref('2');
const showAside = ref(false)
const menuSelect = (e) => {
activeMenu.value = e
};
</script>
<style lang="scss" scoped>
.app-container {
height: 100vh;
.box-container {
height: 100%;
.aside-container {
width: 300px;
height: 100%;
margin-bottom: 0;
background: #fbfaff;
.ai-menu {
background: #fbfaff;
border-right: 0;
}
}
.main-content {
padding: 0;
height: 100%;
display: flex;
justify-content: center;
flex: 1;
}
}
}
</style>

View File

@ -1,115 +0,0 @@
<template>
<div class="container">
<div class="recording">
<el-tooltip
class="box-item"
effect="dark"
content="点击开始录音"
placement="top-start"
v-if="!isRecording"
>
<el-icon class="start" :size="40" @click="startRecording" ><Microphone /></el-icon>
</el-tooltip>
<el-tooltip
class="box-item"
effect="dark"
content="点击结束录音"
placement="top-start"
v-if="isRecording"
>
<el-icon class="stop" :size="40" @click="stopRecording"><VideoPause /></el-icon>
</el-tooltip>
</div>
<div class="audio" v-if="audioUrl">
<!-- <audio :src="audioUrl" controls /> -->
<AudioPlayer :url="audioUrl" :recordingTime="recordingTime"/>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Microphone, VideoPause } from "@element-plus/icons-vue";
import AudioPlayer from './AudioPlayer.vue';
const isRecording = ref(false)
const audioChunks = ref([])
const audioUrl = ref(null)
let mediaRecorder = null
let globalBlob = null
const recordingTime = ref(0)
let startTime
// 启动录音
const startRecording = async () => {
audioUrl.value = null
globalBlob = null
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder = new MediaRecorder(stream)
mediaRecorder.onstart = () => {
startTime = Date.now(); // 记录开始时间
}
mediaRecorder.ondataavailable = e => audioChunks.value.push(e.data)
mediaRecorder.start()
isRecording.value = true
} catch (err) {
console.error('麦克风权限获取失败:', err)
}
}
// 停止录音
const stopRecording = () => {
mediaRecorder.stop()
mediaRecorder.onstop = () => {
recordingTime.value = Math.round((Date.now() - startTime) / 1000);
const blob = new Blob(audioChunks.value, { type: 'audio/m4a' })
globalBlob = blob
audioUrl.value = URL.createObjectURL(blob)
audioChunks.value = []
isRecording.value = false
}
}
// 转换逻辑参考
const getWav = () => {
if (globalBlob) {
return globalBlob
}
return null
}
const reset = () => {
globalBlob = null
audioUrl.value = ''
audioChunks.value = []
isRecording.value = false
}
defineExpose({
getWav,
reset
})
</script>
<style lang="scss" scoped>
.container {
display: flex;
justify-content: space-evenly !important;
align-items: center;
.recording {
.start {
cursor: pointer;
}
.stop {
color: #f80303;
cursor: pointer;
}
}
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,512 +0,0 @@
<template>
<div class="touch-interaction-wrap">
<div class="title">触控交互</div>
<div class="chat-warp scrollable">
<template v-if="chatList.length>0">
<div class="item" v-for="item in chatList" :class="{'is-me': item.isMe}">
<!-- 我发的-->
<template v-if="item.isMe">
<el-image
v-if="item.meImgUrl"
style="width: 100px; height: 100px;cursor: pointer;border-radius: 8px"
:src="item.meImgUrl"
@click="maskImg(false,[],item.meImgUrl)"
fit="contain"
/>
<div class="content" v-if="item.meContent">
{{ item.meContent }}
</div>
<!-- <div class="avatar">-->
<!-- <img :src="MyAvatar" alt="" style="width: 40px;height: 40px">-->
<!-- </div>-->
</template>
<!-- 回复的-->
<template v-else>
<div class="avatar">
<img :src="MyAvatar" alt="" style="width: 40px;height: 40px">
</div>
<div class="content" v-if="item.aiContent">
{{ item.aiContent }}
<el-icon v-if="item.isLoading">
<RefreshRight/>
</el-icon>
</div>
<el-image
@click="maskImg(true,item.aiPoint,item.aiImgUrl)"
v-if="item.aiImgUrl"
style="width: 100px; height: 100px;cursor: pointer;border-radius: 8px"
:src="item.aiImgUrl"
fit="contain"
/>
</template>
</div>
</template>
<template v-else>
<div class="empty">
<div class="flex align-center">
<img :src="MyAvatar" alt="" style="width: 40px;height: 40px;margin-right: 10px">
<div class="content">我是你的触控交互助手,很高兴见到你!</div>
</div>
<div class="desc">请把你需要识别的图片交给我吧~</div>
</div>
</template>
</div>
<img id="sourceImage" :src="currentCanvasUrl" alt="Source Image" style="display: none;">
<canvas id="canvas" v-show="openPre"></canvas>
<div class="actions">
<el-upload
:auto-upload="false"
v-model:file-list="fileList"
list-type="picture-card"
:on-preview="handlePictureCardPreview"
:on-remove="handleRemove"
:limit="1"
>
<template #trigger>
<el-button type="primary">select file</el-button>
</template>
</el-upload>
<el-input :disabled="enableInput" type="textarea"
:autosize="{ minRows: 5, maxRows: 7 }"
placeholder="输入你的问题并上传图片"
v-model="chatVal"
>
</el-input>
<div class="btn-wrap">
<input type="file" id="hiddenFileInput" style="display: none;" accept="image/*"/>
<el-tooltip
effect="dark"
content="上传附件"
placement="top-start"
>
<!-- 隐藏的文件输入元素 -->
<div class="attachment flex align-center justify-content-center" @click="handleAttachmentClick">
<el-button circle :disabled="enableInput">
<template #icon>
<el-icon :size="20">
<Paperclip/>
</el-icon>
</template>
</el-button>
</div>
</el-tooltip>
<el-tooltip
effect="dark"
:content="`${chatVal!==''&&fileList.length!==0?'发送':'请输入你的问题并上传图片'}`"
placement="top-start"
>
<div class="submit flex align-center justify-content-center" @click="sendText">
<el-button type="primary" circle :disabled="!enableInput&&chatVal===''||fileList.length===0">
<template #icon>
<el-icon :size="20">
<Top/>
</el-icon>
</template>
</el-button>
</div>
</el-tooltip>
</div>
</div>
</div>
<el-dialog v-model="dialogVisible" :show-close="false">
<img :src="dialogImageUrl" alt="Preview Image" style="height: 50vh;max-width: 50vw"/>
</el-dialog>
</template>
<script setup>
import {Paperclip, Plus, RefreshRight, Top} from "@element-plus/icons-vue";
import {ElMessage} from 'element-plus'
import {coordinateMarking} from "@/api/touch_interaction.js";
import AiAvatar from "./ai.png"
import MyAvatar from "./zsj.png"
const item = {
isMe: false,
meImgUrl: "",
meContent: "",
isLoading: false,
aiImgUrl: "",
aiContent: "",
aiPoint: []
}
const chatList = ref([])//会话列表
const fileList = ref([])//文件列表
const chatVal = ref("") //文本
const enableInput = ref(false) //允许上传和输入
// name: file.name,
// url: e.target.result,
// fileObj: file
const currentMaskFile = ref(null)
const currentCanvasUrl = ref(null)
const dialogVisible = ref(false) //文件预览
const dialogImageUrl = ref("") //文件预览的url
const markFormData = new FormData()//带上传的formData
const openPre = ref(false)
const addMeContent = (content) => {
chatList.value.push({
isMe: true,
meImgUrl: "",
meContent: content,
isLoading: false,
aiImgUrl: "",
aiContent: "",
aiPoint: []
})
}
const addMeUrl = (url) => {
chatList.value.push({
isMe: true,
meImgUrl: url,
meContent: "",
isLoading: false,
aiImgUrl: "",
aiContent: "",
aiPoint: []
})
}
const addAiContent = (content, loading = false) => {
chatList.value.push({
isMe: false,
meImgUrl: "",
meContent: "",
isLoading: loading,
aiImgUrl: "",
aiContent: content,
aiPoint: []
})
}
const addAiUrl = (url, point) => {
chatList.value.push({
isMe: false,
meImgUrl: "",
meContent: "",
isLoading: false,
aiImgUrl: url,
aiContent: "",
aiPoint: point
})
}
const sendText = () => {
enableInput.value = true
if (!chatVal.value) {
ElMessage({
message: '请输入内容',
type: 'warning',
})
return
}
currentMaskFile.value = fileList.value[0]
markFormData.append("words", chatVal.value)
markFormData.append("image", fileList.value[0]['fileObj'])
addMeUrl(fileList.value[0]['url'])
addMeContent(chatVal.value)
addAiDynamicContent("正在思考中...", true)
chatVal.value = ""
fileList.value = []
scrollEnd();
coordinateMarking(markFormData).then((res) => {
const msg = JSON.parse(res.msg)
if (Array.isArray(msg)) {
chatList.value.pop()
addAiContent(res.msg) // 坐标数据
addAiUrl(currentMaskFile.value['url'], msg)
} else {
addAiContent(msg)
}
}).catch((err) => {
console.log(err)
chatList.value.pop()
addAiDynamicContent("识别超时,请稍后重试")
})
.finally(() => {
chatList.value[chatList.value.length - 1]['content'] = ""
markFormData.delete("image")
markFormData.delete("words")
const newInput = document.createElement('input');
const input = document.getElementById("hiddenFileInput");
newInput.type = 'file';
newInput.id = 'hiddenFileInput';
newInput.accept = "image/*"
newInput.style.display = 'none';
input.parentNode.replaceChild(newInput, input);
scrollEnd();
enableInput.value = false
})
};
const handleAttachmentClick = () => {
const input = document.getElementById("hiddenFileInput");
input.click();
// 移除所有现有的change事件监听器
input.removeEventListener("change", handleFileChange);
input.addEventListener("change", handleFileChange)
};
const handleFileChange = (event) => {
const file = event.target.files[0];
if (file) {
// 创建FileReader对象
const reader = new FileReader();
// 读取完成后触发的事件处理程序
reader.onload = e => {
// 更新ref
fileList.value = [{
name: file.name,
url: e.target.result,
fileObj: file
}];
};
// 以DataURL的形式读取文件内容
reader.readAsDataURL(file);
}
}
const handlePictureCardPreview = (uploadFile) => {
dialogVisible.value = true
dialogImageUrl.value = fileList.value[0]['url']
}
const maskImg = (draw, [x1, y1, x2, y2], url) => {
openPre.value = true
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const sourceImage = document.getElementById('sourceImage');
sourceImage.src = url
canvas.addEventListener('click', () => {
openPre.value = false
})
// 设置canvas大小与图片大小相同
canvas.width = sourceImage.width;
canvas.height = sourceImage.height;
console.log(x1, y1, x2, y2, url)
// 绘制图片到canvas
ctx.drawImage(sourceImage, 0, 0);
if (draw) {
// 定义坐标和矩形大小
const x = x1; // 矩形左上角x坐标
const y = y1; // 矩形左上角y坐标
const width = Math.abs(x2 - x1); // 矩形宽度绝对值
const height = Math.abs(y2 - y1); // 矩形高度
// 绘制矩形
ctx.strokeStyle = 'red'; // 矩形边框颜色
ctx.lineWidth = 10; // 矩形边框宽度
ctx.strokeRect(x, y, width, height); // 绘制矩形
}
}
//删除还未上传的图片
const handleRemove = (uploadFile, uploadFiles) => {
fileList.value = []
}
//滚动条滚动到最底部
const scrollEnd = () => {
setTimeout(() => {
const box = document.querySelector(".chat-warp");
if (box) {
box.scrollTo({top: box.scrollHeight, behavior: "smooth"});
}
}, 100);
};
watch(
() => chatList.value,
() => {
scrollEnd();
}
);
const addAiDynamicContent = (content, loading = false) => {
chatList.value.push({
isMe: false,
meImgUrl: "",
meContent: "",
isLoading: false,
aiImgUrl: "",
aiContent: "",
aiPoint: []
})
const words = content.split("")
for (let i = 0; i < words.length; i++) {
setTimeout(() => {
chatList.value[chatList.value.length - 1]['aiContent'] += words[i]
}, i * 200 + Math.random() * 100)
}
if (loading) {
chatList.value[chatList.value.length - 1]['isLoading'] = true
}
}
</script>
<style scoped lang="scss">
.touch-interaction-wrap {
display: flex;
flex-direction: column;
align-items: center;
.add {
position: fixed;
top: 0;
left: 0;
}
.empty {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.content {
font-size: 24px;
}
.desc {
font-size: 14px;
color: #404040;
margin-top: 8px;
}
}
.title {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: 800px;
height: 46px;
margin-top: 10px;
font-size: 16px;
color: #262626;
font-weight: 600;
border-radius: 12px;
box-shadow: inset 0 0 0 1px rgb(229, 229, 229);
}
.chat-warp {
width: 800px;
height: calc(100vh - 340px); //220 56
overflow-y: auto;
padding-top: 20px;
.item {
display: flex;
align-items: center;
margin-bottom: 16px;
padding-right: 10px;
img {
width: 28px;
height: 28px;
margin-right: 20px;
}
.content {
font-size: 16px;
line-height: 28px;
color: #262626;
padding: 8px 20px;
box-sizing: border-box;
white-space: pre-wrap;
word-break: break-word;
background-color: #eff6ff;
border-radius: 14px;
max-width: calc(100% - 48px);
display: flex;
align-items: center;
i {
margin-left: 2px;
animation: spin 1.5s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
&.is-me {
justify-content: flex-end;
.content {
margin-left: 0;
}
img {
margin-left: 20px;
}
}
}
}
.actions {
width: 800px;
position: relative;
:deep(.el-textarea__inner ) {
padding: 10px 20px;
background-color: rgb(243, 244, 246);
border-radius: 24px;
box-shadow: 0 0 0 .5px #dce0e9;
color: rgb(64, 64, 64);
font-size: 16px;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
:deep(.el-upload--picture-card ) {
display: none;
}
:deep(.el-upload-list__item ) {
height: 80px;
width: auto;
}
.btn-wrap {
position: absolute;
right: 20px;
bottom: 10px;
display: flex;
.attachment {
margin-right: 10px;
}
.submit {
}
}
}
#canvas {
position: fixed;
z-index: 99999999;
width: 100vw;
height: 100vh;
}
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,59 +0,0 @@
export const conf = {
/**
* 基础功能配置
* */
autoplay: false, // 是否自动播放,不自动播放,浏览器有限制规则
autoplayMuted: false, // 是否自动播放(静音播放)
videoInit: true, // 是否默认初始化video,默认初始化,默认true
playsinline: true, // 是否启用内联播放模式,仅移动端生效
defaultPlaybackRate: 1, // 默认播放速度(可选:0.5/0.75/1/1.5/2等)
volume: 0.72, // 播放音量(可选:0 ~ 1)
loop: false, // 是否循环播放,默认不循环播放
startTime: 0, // 点播模式下,初始起播时间
videoAttributes: {}, // video扩展属性,暂且不配置
lang: 'zh-cn', // 播放器初始显示语言,设置为中文
fluid: true, // 是否流式布局(宽高优先于流失布局,默认16:9)注掉上方宽高看效果
fitVideoSize: 'fixed', // 保持容器宽/高,不做适配,按照容器来
videoFillMode: 'auto', // 宽高不够自动底色填充(fill拉伸填充等...)
seekedStatus: 'play', // 跳转后继续播放
// 播放器进度条故事点信息数组
progressDot: [
{
id: 0, // 唯一标识,用于删除的时候索引
time: 30, // 展示的时间点,例子为在播放到10s钟的时候展示
text: '进度条信息提示...', // hover的时候展示文案,可以为空
duration: 1, // 展示时间跨度,单位为s
style: { // 指定样式
backgroundColor: 'white'
}
},
],
thumbnail: null, // 进度条预览图配置,普通业务用不到
marginControls: false, // 是否开启画面和控制栏分离模式,不开启空间多一些
domEventType: 'default', // 响应的事件类型,不用指定,用默认的即可
/**
* 交互功能配置(一般使用默认即可)
* */
/**
* 插件配置,根据需求自选
* */
icons: {}, // 使用默认的icon图标
i18n: [], // 使用默认的中文
// 自定义一些颜色
commonStyle: {
progressColor: 'green', // 整个进度条颜色
playedColor: 'chocolate', // 已播放的进度条颜色
volumeColor: 'pink', // 音量大小竖向滑块颜色
},
controls: true, // 是否使用底部控制栏,默认使用
miniprogress: true, // 是否使用mini进度条(当底部控制栏隐藏时生效)
screenShot: false, // 关闭截图功能
rotate: true, // 是否使用视频旋转插件,默认不使用
download: true, // 是否使用下载按钮,一般不用,一般自定义控制
pip: true, // 使用使用画中画模式,默认不用
mini: true, // 是否使用小屏幕控件
cssFullscreen: true, // 是否使用网页样式全屏按钮开关
playbackRate: [0.5, 1, 1.5, 2, 3], //传入倍速可选数组
// playbackRate: true, //false,禁用倍速播放(即控制栏不显示)
keyShortcut: false, // 是否开启快捷键模式
}

View File

@ -1,62 +0,0 @@
<template>
<div class="player_container">
<div ref="xgPlayerRef"></div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import Player, { Events } from 'xgplayer'; // 引入西瓜视频模块
import 'xgplayer/dist/index.min.css'; // 引入西瓜视频样式
const { videoUrl } = defineProps({
videoUrl: {
type: String,
default: ''
}
})
const xgPlayerRef = ref()
import { conf } from "./config"; // 配置文件单独拎出来一个js文件
onMounted(() => { init() })
let player = null // 实例
const init = () => {
player = new Player({
el: xgPlayerRef.value,
// width: 600,
// height: 400, // 视频宽高尺寸
isLive: false,
url: videoUrl, // 视频源
// poster: "http://ashuai.work/static/img/avantar.png", // 视频封面
...conf,
});
player.on(Events.PLAY, (ev) => {
console.log('-播放开始-', ev);
})
player.on(Events.PAUSE, (ev) => {
console.log('-播放结束-', ev);
})
player.on('loadedmetadata', (ev) => {
console.log('-媒体数据加载好了-', ev);
})
player.on(Events.SEEKED, (ev) => {
console.log('-跳着播放-', ev);
})
}
</script>
<style lang="scss" scoped>
.player_container {
width: 100%;
height: 100%;
.xgplayer {
height: 100% !important;
}
}
</style>

View File

@ -1,24 +0,0 @@
<template>
<div class="runtime-group">
<span>{{ data.text || data.properties?.name || "分组" }}</span>
</div>
</template>
<script setup>
defineProps({ data: Object });
</script>
<style scoped lang="scss">
.runtime-group {
width: 100%;
height: 100%;
padding: 14px 18px;
box-sizing: border-box;
border: 1px dashed #91a4be;
border-radius: 8px;
background: rgba(234, 241, 250, 0.45);
color: #40536d;
font-size: 14px;
font-weight: 600;
}
</style>

View File

@ -1,47 +0,0 @@
<template>
<div class="runtime-node">
<Handle
:id="`${id}_3`"
type="target"
:position="Position.Left"
:connectable="false"
class="runtime-handle runtime-handle--target"
/>
<CustomFlow :properties="properties" :text="data.text" />
<Handle
:id="`${id}_1`"
type="source"
:position="Position.Right"
:connectable="false"
class="runtime-handle runtime-handle--source"
/>
</div>
</template>
<script setup>
import { computed } from "vue";
import { Handle, Position } from "@vue-flow/core";
import CustomFlow from "./customFlow.vue";
const props = defineProps({ id: String, data: Object });
const properties = computed(() => props.data.properties || {});
</script>
<style scoped lang="scss">
.runtime-node {
width: 100%;
height: 100%;
position: relative;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(20, 33, 61, 0.12);
}
.runtime-handle {
width: 12px;
height: 12px;
border: 3px solid #fff;
background: #2f80ed;
&--target { left: -6px; }
&--source { right: -6px; }
}
</style>

View File

@ -1,238 +0,0 @@
<template>
<div class="custom_element" :class="props.properties.state">
<div class="custom_element__header">
<div class="custom_element__title">
{{ props.text || "--" }}
</div>
<el-button v-if="showDetailTypeList.includes(props.properties.type)" v-popover="popoverRef" class="custom_element__btn" link @click="openPop"
>详情</el-button
>
</div>
<div class="nodeType">节点类型: {{ props.properties.type }}</div>
<div class="nodeState">
<div>状态:{{ stateMap[props.properties.state] }}</div>
</div>
<el-popover
ref="popoverRef"
virtual-triggering
persistent
placement="right-start"
:visible="popoverVisible"
popper-class="popover__container"
>
<template #default>
<div class="popover__header">
<div>{{ props.text || "--" }}</div>
<div class="closeBtn" @click="popoverVisible = !popoverVisible">
<el-icon><CircleClose /></el-icon>
</div>
</div>
<div class="popover__context">
<div class="container">
<div class="title">输入参数</div>
<el-input
v-if="getTypeByParams(props.properties.input) === 0"
v-model="props.properties.input"
:readonly="true"
:autosize="{ minRows: 2, maxRows: 6 }"
type="textarea"
/>
<div v-if="getTypeByParams(props.properties.input) === 2">
<el-image
style="width: 200px; height: 100px"
:src="getUrlByParams(props.properties.input)"
:zoom-rate="2"
:max-scale="7"
:min-scale="0.2"
:preview-src-list="[getUrlByParams(props.properties.input)]"
show-progress
:initial-index="4"
fit="cover"
/>
</div>
</div>
<div class="container">
<div class="title">输出参数</div>
<el-input
v-if="getTypeByParams(props.properties.output) === 0"
v-model="props.properties.output"
:readonly="true"
:autosize="{ minRows: 2, maxRows: 6 }"
type="textarea"
/>
<div v-if="getTypeByParams(props.properties.output) === 2" class="imageContainer">
<el-image
v-for="item in getUrlByParams(props.properties.output)"
style="width: 60px; height: 60px"
:src="item"
:preview-src-list="getUrlByParams(props.properties.output)"
show-progress
fit="fill"
/>
</div>
<div v-if="getTypeByParams(props.properties.output) === 4" class="videoContainer">
<IPlayer v-for="item in getUrlByParams(props.properties.output)" :videoUrl="item" />
</div>
</div>
</div>
</template>
</el-popover>
</div>
</template>
<script setup>
import { watch } from "vue";
import { CircleClose } from '@element-plus/icons-vue'
import IPlayer from './IPlayer/index.vue'
const props = defineProps({
model: Object,
graphModel: Object,
properties: Object,
text: String,
});
const stateMap = {
success: "已执行",
unexecuted: "未执行",
running: "执行中",
failed: "异常",
paused: "暂停",
stopped: "停止"
};
const showDetailTypeList = ['inject', 'startCamera', 'getImage', 'stopCamera', 'process', 'end', 'stopVideo']
const popoverVisible = ref(false);
const popoverRef = ref();
const openPop = (e) => {
popoverVisible.value = true;
};
const getTypeByParams = (str) => {
let type = 0;
try {
const data = JSON.parse(str);
type = data?.type || 0;
} catch {
type = 0;
}
return type;
};
const propertiesTypeMap = {
'2': 'imageUrl',
'4': 'videoUrl',
'default': ''
}
const getUrlByParams = (str) => {
let url = [];
try {
const data = JSON.parse(str);
url = data?.[propertiesTypeMap[data?.type || 'default']] || 0;
} catch {
url = [];
}
return url;
};
watch(
() => props.properties.nowTime,
() => {
popoverVisible.value = false;
}
);
</script>
<style lang="scss" scoped>
.custom_element {
width: 100%;
height: 100%;
padding: 10px;
border-radius: 10px;
box-sizing: border-box;
color: #fff;
&__header {
display: flex;
justify-content: space-between;
}
&__title {
font-size: 20px;
}
&__btn {
color: skyblue;
}
}
.running, .paused, .stopped {
background-color: #8bbf86;
}
.unexecuted {
background-color: #ddd;
}
.success {
background-color: green;
}
.failed {
background-color: rgb(128, 41, 0);
}
.nodeType {
margin: 8px 0;
}
.nodeState {
margin: 8px 0;
}
</style>
<style lang="scss">
.popover__container {
width: 300px !important;
.popover__header {
display: flex;
justify-content: space-between;
.closeBtn {
cursor: pointer;
}
}
.popover__context {
.container {
display: flex;
max-height: 200px;
overflow-y: auto;
margin-bottom: 10px;
.title {
width: 80px;
}
.imageContainer {
width: 260px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: stretch;
max-height: 600px;
overflow-y: auto;
}
.videoContainer {
width: 260px;
max-height: 600px;
overflow-y: auto;
}
}
}
}
</style>

View File

@ -1,205 +0,0 @@
<template>
<div class="runtime-flow-page">
<div class="runtime-toolbar">
<div class="instance-state">
实例状态:<span :class="instStatus">{{ stateMap[instStatus] || instStatus }}</span>
</div>
<el-button @click="centerView">居中</el-button>
<el-button @click="fitGraph">适应屏幕</el-button>
<el-button v-if="['RUNNING', 'PAUSED'].includes(instStatus)" type="danger" plain @click="stopFlow">停止</el-button>
<el-button v-if="instStatus === 'RUNNING'" type="warning" plain @click="pauseFlow">暂停</el-button>
<el-button v-if="instStatus === 'PAUSED'" type="success" plain @click="restartFlow">恢复</el-button>
</div>
<VueFlow
id="runtime-flow"
class="runtime-canvas"
:nodes-draggable="false"
:nodes-connectable="false"
:edges-updatable="false"
:elements-selectable="true"
:delete-key-code="null"
:min-zoom="0.1"
:max-zoom="3"
:zoom-on-double-click="false"
@init="loadFlow"
@pane-click="closeNodeDetails"
>
<template #node-runtime-node="nodeProps">
<RuntimeNode v-bind="nodeProps" />
</template>
<template #node-runtime-group="nodeProps">
<RuntimeGroup v-bind="nodeProps" />
</template>
<Background color="#d9dee8" :gap="24" :size="1" />
<Controls position="bottom-right" :show-interactive="false" />
</VueFlow>
</div>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import { VueFlow, useVueFlow } from "@vue-flow/core";
import { Background } from "@vue-flow/background";
import { Controls } from "@vue-flow/controls";
import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
import "@vue-flow/controls/dist/style.css";
import RuntimeNode from "./RuntimeNode.vue";
import RuntimeGroup from "./RuntimeGroup.vue";
import { convertNodeRedToVueFlow, updateDerivedNodeStates } from "@/utils/convertNodeRedToVueFlow";
import { getFlowRuntimeLog, pauseInst, resumeInst, stopInst } from '@/api/test/log';
import { ElMessage } from "element-plus";
const runtimeFlow = useVueFlow("runtime-flow");
const queryParams = new URLSearchParams(window.location.search);
const instId = queryParams.get("instId") || "";
const taskId = queryParams.get("taskId") || "";
const itemId = queryParams.get("itemId") || "03e0842c2fd7ab7575636a473aeefc89";
const instStatus = ref("RUNNING");
const stateMap = {
SUCCESS: "已完成",
RUNNING: "执行中",
FAILED: "失败",
PAUSED: "已暂停",
STOPPED: "已终止",
};
let pollTimer = null;
let initialized = false;
const query = () => ({ instId, taskId, itemId });
const getProperties = (id) => runtimeFlow.findNode(id)?.data?.properties || {};
const setProperties = (id, patch) => {
const node = runtimeFlow.findNode(id);
if (!node) return;
runtimeFlow.updateNodeData(id, {
...node.data,
properties: { ...node.data.properties, ...patch },
}, { replace: true });
};
const schedulePoll = () => {
clearTimeout(pollTimer);
pollTimer = window.setTimeout(getLog, 2000);
};
const getLog = async () => {
const res = await getFlowRuntimeLog(query());
if (res.code !== 200) return;
const records = (res.data || []).filter((item) => item.nodeType);
if (!records.length) {
if (["RUNNING", "PAUSED"].includes(instStatus.value)) schedulePoll();
return;
}
const states = new Map();
records.forEach((item) => {
if (["inject", "start"].includes(item.nodeType)) {
states.set(item.nodeId, { state: "success", nodeType: item.nodeType, input: item.params });
return;
}
const current = states.get(item.nodeId) || { nodeType: item.nodeType };
if (item.paramsType === "INPUT") {
current.state = item.instStatus.toLowerCase();
current.input = item.instStatus === "FAILED" ? item.params : item.message;
} else {
current.state = item.instStatus === "RUNNING" ? "success" : item.instStatus.toLowerCase();
current.output = item.instStatus === "FAILED" ? item.message : item.params;
}
states.set(item.nodeId, current);
});
states.forEach((value, id) => setProperties(id, value));
updateDerivedNodeStates(runtimeFlow.getNodes.value, getProperties, setProperties);
const last = records.at(-1);
instStatus.value = last.instStatus || instStatus.value;
if (last.nodeType !== "end" && !["FAILED", "STOPPED", "SUCCESS"].includes(last.instStatus)) schedulePoll();
};
const loadFlow = async () => {
if (initialized) return;
initialized = true;
const { nodes, edges } = await convertNodeRedToVueFlow(itemId);
runtimeFlow.setNodes(nodes);
runtimeFlow.setEdges(edges);
await nextTick();
await runtimeFlow.fitView({ padding: 0.16, duration: 300 });
await getLog();
};
const getInstDetail = async () => {
const res = await getFlowRuntimeLog(query());
const last = res.code === 200 ? (res.data || []).at(-1) : null;
if (last?.instStatus) instStatus.value = last.instStatus;
};
const runControl = async (request, successMessage) => {
const res = await request(instId);
if (res.code === 200) {
ElMessage.success(successMessage);
await getInstDetail();
if (["RUNNING", "PAUSED"].includes(instStatus.value)) schedulePoll();
} else {
ElMessage.error(res.msg);
}
};
const stopFlow = () => runControl(stopInst, "停止成功");
const pauseFlow = () => runControl(pauseInst, "暂停成功");
const restartFlow = () => runControl(resumeInst, "恢复成功");
const fitGraph = () => runtimeFlow.fitView({ padding: 0.16, duration: 300 });
const centerView = () => runtimeFlow.fitView({ padding: 0.3, duration: 300 });
const closeNodeDetails = () => {
const nowTime = Date.now();
runtimeFlow.getNodes.value.forEach((node) => setProperties(node.id, { nowTime }));
};
onMounted(getInstDetail);
onUnmounted(() => clearTimeout(pollTimer));
</script>
<style scoped lang="scss">
.runtime-flow-page {
width: 100%;
height: 100%;
min-height: 620px;
position: relative;
overflow: hidden;
background: #f5f7fa;
}
.runtime-toolbar {
position: absolute;
z-index: 10;
top: 16px;
right: 16px;
min-height: 48px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid #e1e6ee;
border-radius: 8px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 8px 24px rgba(20, 33, 61, 0.1);
:deep(.el-button + .el-button) { margin-left: 0; }
}
.instance-state {
margin-right: 8px;
color: #5a6472;
font-size: 14px;
font-weight: 600;
.RUNNING, .PAUSED { color: #d48806; }
.SUCCESS { color: #2b8a3e; }
.FAILED, .STOPPED { color: #c92a2a; }
}
.runtime-canvas {
width: 100%;
height: 100%;
}
</style>

View File

@ -1,59 +0,0 @@
import {controlPageList} from "@/enum/index.js";
import {ElMessage} from "element-plus";
import router from "@/router";
export const intoControlPage = (path, params) => {
if (!controlPageList.includes(path)) {
ElMessage.warning('该页面不在控制台页面中,无法打开');
return
}
// const fullPath = `${window.location.origin}${path}/${params}`;
// // 使用 window.open 在新标签页中打开
// window.open(fullPath, '_blank');
const fullPath = `${path}/${params}`; // 构造路由路径
router.push(fullPath); // 使用路由跳转
}
// 定义需要转换的属性列表
const propertiesToConvert = [
"workModule",
"posStep",
"postureStep",
"jointStep",
"joint1",
"joint2",
"joint3",
"joint4",
"joint5",
"joint6",
"x",
"y",
"z",
"rx",
"ry",
"rz"
];
export const formatNumber = (data) => {
const res = JSON.parse(JSON.stringify(data));
// 遍历属性列表并转换值
propertiesToConvert.forEach(property => {
if (data[property] !== null) {
res[property] = Number(data[property]);
}
});
return res
}
//对象中存在数字类型属性时,转换为字符串类型
export const convertNumbersToStrings = (obj) => {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
const value = obj[key];
// 检查值是否不是 null 且是数字类型
if (value !== null && typeof value === 'number') {
obj[key] = String(value); // 转换为字符串
}
}
}
return obj;
}

View File

@ -1,89 +0,0 @@
<template>
<div
v-if="visible"
class="context-menu"
:style="{ top: position.y + 'px', left: position.x + 'px' }"
>
<div
v-for="item in menuItems"
:key="item.key"
class="menu-item"
@click="handleClick(item)"
>
{{ item.label }}
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue';
const props = defineProps({
visible: {
type: Boolean,
default: false
},
position: {
type: Object,
default: () => ({ x: 0, y: 0 })
},
menuItems: {
type: Array,
default: () => []
}
});
const emit = defineEmits(['close', 'select']);
watch(() => props.visible, (newVal) => {
if (newVal) {
document.addEventListener('click', closeMenu);
} else {
document.removeEventListener('click', closeMenu);
}
});
const handleClick = (item) => {
emit('select', item);
closeMenu();
};
const closeMenu = () => {
emit('close');
};
onMounted(() => {
// 初始监听(如果visible初始为true)
if (props.visible) {
document.addEventListener('click', closeMenu);
}
});
onUnmounted(() => {
document.removeEventListener('click', closeMenu);
});
</script>
<style scoped>
.context-menu {
position: fixed; /* 使用 fixed 定位,不受父容器 scroll 影响 */
z-index: 9999;
background-color: #fff;
border: 1px solid #eee;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
border-radius: 4px;
padding: 5px 0;
}
.menu-item {
padding: 8px 15px;
cursor: pointer;
font-size: 14px;
color: #606266;
white-space: nowrap;
}
.menu-item:hover {
background-color: #f5f7fa;
}
</style>

View File

@ -1,547 +0,0 @@
<template>
<div
class="tech-button-with-line"
:style="buttonStyle"
ref="techButtonContainer"
@mouseover="isHovering = true"
@mouseleave="isHovering = false"
>
<!-- 新增一个包裹层,用于应用 clip-path -->
<div class="button-shape-clipper" :style="clipperStyle">
<div class="button-content-wrapper">
<slot name="button-content">
<span class="button-text">{{ buttonName }}</span>
</slot>
</div>
</div>
<!-- SVG 部分保持原有结构,但内部样式和滤镜会改变 -->
<svg
v-if="svgReady"
class="line-svg"
:width="svgWidth"
:height="svgHeight"
:style="{
top: `0px`,
left: `0px`,
}"
>
<defs>
<!-- 霓虹光效滤镜 -->
<!-- stdDeviation 控制模糊程度,值越大越模糊,光效越柔和 -->
<filter id="neon-filter" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur in="SourceGraphic" :stdDeviation="props.neonGlowIntensity" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<!-- 扫描线效果渐变 -->
<!-- 动态调整扫描线颜色 -->
<linearGradient id="scan-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:rgba(255,255,255,0);stop-opacity:1" />
<stop offset="50%" :style="`stop-color:${props.lineHoverColor};stop-opacity:0.8`" />
<stop offset="100%" style="stop-color:rgba(255,255,255,0);stop-opacity:1" />
</linearGradient>
</defs>
<!-- 主线条,应用霓虹光效和扫描线 -->
<polyline
:points="linePoints"
stroke="url(#scan-gradient)"
:stroke-width="props.lineStrokeWidth"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
filter="url(#neon-filter)"
class="main-line"
/>
<!-- 可选:一条细微的基线,用于对比或增加层次 -->
<polyline
:points="baseLinePoints"
:stroke="props.lineColor"
stroke-width="1"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
class="base-line"
/>
</svg>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch, defineProps, onUnmounted } from 'vue';
// --- Props ---
// 保持原有 props,并增加一些用于样式控制的 props
const props = defineProps({
totalButtons: { type: Number, required: true, default: 1 },
buttonIndex: { type: Number, required: true },
buttonPosition: { type: String, required: true, validator: (value) => ['left', 'right'].includes(value) },
buttonOffsetX: { type: Number, default: 350 },
endX: { type: Number, required: true },
endY: { type: Number, required: true },
buttonName: { type: String, default: '科技按钮' },
buttonWidth: { type: [Number, String], default: 150 },
buttonHeight: { type: [Number, String], default: 40 },
linePadding: { type: Number, default: 20 },
// --- 仅用于样式的 Props ---
buttonGradient: { type: Array, default: () => ['#00f0ff', '#00d0ff'] }, // 按钮背景渐变色
buttonHoverGradient: { type: Array, default: () => ['#00d0ff', '#00f0ff'] }, // 悬停时的渐变色
buttonBorderColor: { type: String, default: 'rgba(0, 255, 255, 0.6)' },
lineColor: { type: String, default: 'rgba(0, 150, 200, 0.4)' }, // 细线基础颜色
lineHoverColor: { type: String, default: 'rgba(0, 255, 255, 1)' }, // 扫描线高亮颜色
lineStrokeWidth: { type: Number, default: 3 },
neonGlowIntensity: { type: Number, default: 3 }, // 霓虹光效强度
scanLineSpeed: { type: Number, default: 8 }, // 扫描线动画速度
animationDuration: { type: Number, default: 0.4 }, // 基础动画时长
hoverScale: { type: Number, default: 1.03 }, // 悬停时的放大比例
// --- New Props for Flowing Light Effect ---
flowLineColor: { type: String, default: 'rgba(0, 255, 255, 0.8)' }, // 流光颜色
flowLineAnimationSpeed: { type: Number, default: 7 }, // 流光动画速度
});
// --- Refs ---
const techButtonContainer = ref(null);
const svgReady = ref(false);
const parentContainerRef = ref(null);
const parentHeight = ref(0);
const parentWidth = ref(0);
const svgWidth = ref(0);
const svgHeight = ref(0);
const svgLeftOffset = ref(0);
const svgTopOffset = ref(0);
const isHovering = ref(false); // 追踪鼠标是否悬停
// --- Computed Properties ---
// 计算父容器的水平中心线 (保持不变)
const autoCenterX = computed(() => parentWidth.value / 2);
// 计算按钮在父容器中的 X 坐标(全局坐标)(保持不变)
const calculatedButtonX = computed(() => {
if (!parentWidth.value) return 0;
if (props.buttonPosition === 'left') {
return autoCenterX.value - props.buttonOffsetX;
} else if (props.buttonPosition === 'right') {
return autoCenterX.value + props.buttonOffsetX;
}
return 0;
});
// 计算按钮在父容器中的 Y 坐标(全局坐标)(保持不变)
const calculatedButtonY = computed(() => {
if (!parentHeight.value || props.totalButtons <= 0) return 0;
const buttonSlotHeight = parentHeight.value / props.totalButtons;
const centerY = (props.buttonIndex - 1) * buttonSlotHeight + buttonSlotHeight / 2;
return centerY;
});
// 获取按钮的实际宽度和高度 (保持不变)
const actualButtonWidth = computed(() => {
if (techButtonContainer.value) {
return techButtonContainer.value.offsetWidth;
}
return typeof props.buttonWidth === 'number' ? props.buttonWidth : 150;
});
const actualButtonHeight = computed(() => {
if (techButtonContainer.value) {
return techButtonContainer.value.offsetHeight;
}
return typeof props.buttonHeight === 'number' ? props.buttonHeight : 40;
});
// 线的终点相对于按钮中心点的偏移 (SVG 内部坐标系) (保持不变)
const relativeEndX = computed(() => props.endX - calculatedButtonX.value + actualButtonWidth.value / 2);
const relativeEndY = computed(() => props.endY - calculatedButtonY.value + actualButtonHeight.value / 2);
// 按钮的样式
const buttonStyle = computed(() => {
const buttonWidthPx = typeof props.buttonWidth === 'number' ? `${props.buttonWidth}px` : props.buttonWidth;
const buttonHeightPx = typeof props.buttonHeight === 'number' ? `${props.buttonHeight}px` : props.buttonHeight;
const currentGradient = isHovering.value ? props.buttonHoverGradient : props.buttonGradient;
const style = {
position: 'absolute',
left: `${calculatedButtonX.value}px`,
top: `${calculatedButtonY.value}px`,
transform: 'translate(-50%, -50%)', // 居中对齐
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
width: buttonWidthPx,
height: buttonHeightPx,
borderRadius: '15px', // 强制平直四角
// 更硬朗的背景,减少模糊,增加纹理感
background: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`,
// 模拟金属/磨砂质感,但更锐利
boxShadow: `
inset 0 0 0 2px ${props.buttonBorderColor}, /* 内部边框 */
0 0 5px rgba(0, 150, 200, 0.5), /* 较弱的主光晕 */
0 0 12px rgba(0, 200, 255, 0.6), /* 第二层光晕 */
0 3px 10px rgba(0, 0, 0, 0.4) /* 底部阴影 */
`,
color: '#ffffff',
fontSize: '14px',
fontWeight: 'bold', // 更粗的字体
letterSpacing: '0.7px',
textShadow: '0 0 5px rgba(0, 150, 200, 0.9)', // 文本的锐利光效
transition: `all ${props.animationDuration}s ease-in-out`,
overflow: 'visible', // 允许流光伪元素溢出
zIndex: 1,
};
if (isHovering.value) {
style.transform = `translate(-50%, -50%) scale(${props.hoverScale})`;
style.boxShadow = `
inset 0 0 0 3px ${props.buttonBorderColor}, /* 悬停时内部边框变宽 */
0 0 8px rgba(0, 200, 255, 0.7),
0 0 18px rgba(0, 230, 255, 0.8),
0 5px 15px rgba(0, 0, 0, 0.5) /* 底部阴影增强 */
`;
}
return style;
});
// 按钮形状裁剪器的样式
const clipperStyle = computed(() => {
const currentGradient = isHovering.value ? props.buttonHoverGradient : props.buttonGradient;
const style = {
position: 'relative', // 相对于 techButtonContainer 定位
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
borderRadius: '0px', // **clipper 的形状由 clip-path 定义**
background: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`,
boxShadow: `
inset 0 0 0 2px ${props.buttonBorderColor}, /* 内部边框 */
0 0 5px rgba(0, 150, 200, 0.5), /* 较弱的主光晕 */
0 0 12px rgba(0, 200, 255, 0.6), /* 第二层光晕 */
0 3px 10px rgba(0, 0, 0, 0.4) /* 底部阴影 */
`,
color: '#ffffff',
fontSize: '14px',
fontWeight: 'bold',
letterSpacing: '0.7px',
textShadow: '0 0 5px rgba(0, 150, 200, 0.9)', // 文本的锐利光效
transition: `all ${props.animationDuration}s ease-in-out`,
overflow: 'hidden', // **clipper 内部的内容会被 clip-path 裁剪**
clipPath: `polygon(
6px 0, /* 左上角内缩进5px */
calc(100% - 6px) 0, /* 右上角内缩进5px */
100% 6px, /* 右上角外延5px */
100% calc(100% - 6px), /* 右下角内缩进5px */
calc(100% - 6px) 100%,
6px 100%, /* 左下角内缩进5px */
0 calc(100% - 6px),
0 6px, /* 左下角外延5px */
6px 0px /* 左上角内缩进5px(重复起点,闭合多边形) */
)`,
zIndex: 1, // 确保clipper在流光之上
};
if (isHovering.value) {
style.boxShadow = `
inset 0 0 0 3px ${props.buttonBorderColor}, /* 悬停时内部边框变宽 */
0 0 8px rgba(0, 200, 255, 0.7),
0 0 18px rgba(0, 230, 255, 0.8),
0 5px 15px rgba(0, 0, 0, 0.5) /* 底部阴影增强 */
`;
}
return style;
});
// 线的三个关键点 (相对于 SVG 内部坐标系 (0,0) 是按钮中心)
// --- 保持原有逻辑 ---
const linePoints = computed(() => {
if (!svgReady.value) return '';
const startPointX = (props.buttonPosition === 'left' ? props.buttonWidth : 0);
const startPointY = props.buttonHeight/2;
const endPointX = relativeEndX.value;
const endPointY = relativeEndY.value;
// L 形连接点:先垂直,再水平。
const middlePointY = endPointY;
// 调整连接点的 X 坐标,使其更具科技感,可以稍微偏离 Y 轴
// 这里的 +10 和 -10 是示例,可以根据实际效果调整
const adjustedMiddlePointX = startPointX + (props.buttonPosition === 'left' ? 100 : -100);
return `${startPointX},${startPointY} ${adjustedMiddlePointX},${middlePointY} ${endPointX},${endPointY}`;
});
// 细微基线的点 (保持原有逻辑)
const baseLinePoints = computed(() => {
if (!svgReady.value) return '';
const startPointX = (props.buttonPosition === 'left' ? props.buttonWidth : 0);
const startPointY = props.buttonHeight/2;
const endPointX = relativeEndX.value;
const endPointY = relativeEndY.value;
const adjustedMiddlePointX = startPointX + (props.buttonPosition === 'left' ? 100 : -100);
return `${startPointX},${startPointY} ${adjustedMiddlePointX},${endPointY} ${endPointX},${endPointY}`;
});
// --- Methods --- (保持不变)
const updateSvgDimensions = async () => {
if (!techButtonContainer.value) {
svgReady.value = false;
return;
}
await nextTick();
if (!parentContainerRef.value || parentContainerRef.value.offsetHeight === 0 || parentContainerRef.value.offsetWidth === 0) {
svgReady.value = false;
return;
}
parentHeight.value = parentContainerRef.value.offsetHeight;
parentWidth.value = parentContainerRef.value.offsetWidth;
if (parentHeight.value <= 0 || parentWidth.value <= 0 || props.totalButtons <= 0) {
svgReady.value = false;
return;
}
let minX = Math.min(0, 0, relativeEndX.value);
let maxX = Math.max(0, 0, relativeEndX.value);
let minY = Math.min(0, relativeEndY.value, relativeEndY.value);
let maxY = Math.max(0, relativeEndY.value, relativeEndY.value);
minX -= props.linePadding;
maxX += props.linePadding;
minY -= props.linePadding;
maxY += props.linePadding;
svgWidth.value = maxX - minX;
svgHeight.value = maxY - minY;
svgLeftOffset.value = minX;
svgTopOffset.value = minY;
svgReady.value = true;
};
// --- Resize Observer --- (保持不变)
let resizeObserver = null;
const setupResizeObserver = () => {
if (resizeObserver) {
resizeObserver.disconnect();
}
if (parentContainerRef.value) {
resizeObserver = new ResizeObserver(() => {
const newHeight = parentContainerRef.value.offsetHeight;
const newWidth = parentContainerRef.value.offsetWidth;
if (newHeight !== parentHeight.value || newWidth !== parentWidth.value) {
parentHeight.value = newHeight;
parentWidth.value = newWidth;
debouncedUpdate(updateSvgDimensions)();
}
});
resizeObserver.observe(parentContainerRef.value);
} else {
console.error("TechButtonWithLine: Could not find parent element to observe.");
}
};
// --- Lifecycle Hooks --- (保持不变)
onMounted(() => {
if (techButtonContainer.value && techButtonContainer.value.parentElement) {
parentContainerRef.value = techButtonContainer.value.parentElement.parentElement;
setupResizeObserver();
} else {
console.error("TechButtonWithLine: Could not find techButtonContainer or its parent.");
}
updateSvgDimensions();
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
});
// --- Watchers --- (保持不变)
const debouncedUpdate = (fn, delay = 10) => {
let timer = null;
return () => {
clearTimeout(timer);
timer = setTimeout(fn, delay);
};
};
watch(
[
() => parentHeight.value,
() => parentWidth.value,
() => props.totalButtons,
() => props.buttonIndex,
() => props.buttonPosition,
() => props.endX,
() => props.endY,
() => props.buttonOffsetX,
() => props.linePadding,
() => props.buttonWidth,
() => props.buttonHeight,
// 监听用于样式变化的 props
() => props.buttonGradient,
() => props.buttonHoverGradient,
() => props.lineColor,
() => props.lineHoverColor,
() => props.lineStrokeWidth,
() => props.neonGlowIntensity,
() => props.scanLineSpeed,
() => props.animationDuration,
() => props.hoverScale,
() => props.flowLineColor,
() => props.flowLineAnimationSpeed,
],
debouncedUpdate(updateSvgDimensions),
{ immediate: true, deep: true }
);
</script>
<style scoped>
/* --- Base Styles for the Button Container --- */
.tech-button-with-line {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
overflow: visible; /* 允许 SVG 滤镜和动画效果溢出 */
z-index: 1;
border-radius: 8px; /* 科技感圆角 */
/* 基础过渡效果,用于缩放和阴影 */
/* transition: transform v-bind('`${props.animationDuration}s`') ease-in-out, box-shadow v-bind('`${props.animationDuration}s`') ease-in-out, background v-bind('`${props.animationDuration}s`') ease-in-out; */
}
.button-shape-clipper {
position: relative; /* 相对于 techButtonContainer,以填充其空间 */
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 0px; /* **clipper 的形状由 clip-path 定义** */
overflow: hidden; /* **clipper 内部的内容会被 clip-path 裁剪** */
transition: all v-bind('`${props.animationDuration}s`') ease-in-out; /* 过渡背景和阴影 */
z-index: 1; /* 确保 clipper 在流光之上 */
}
.button-content-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none; /* 让鼠标事件穿透到按钮本身 */
}
.button-text {
font-family: 'Orbitron', sans-serif; /* 科技感字体 */
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
/* color: v-bind('props.buttonBorderColor'); 文本颜色与边框颜色呼应 */
color: #fff;
}
/* --- SVG Line Styles --- */
.line-svg {
position: absolute;
pointer-events: none; /* SVG 不捕获鼠标事件 */
overflow: visible; /* 允许滤镜和渐变效果溢出 SVG bounds */
transition: all v-bind('`${props.animationDuration}s`') ease-in-out;
z-index: 0; /* 放在按钮后面 */
}
/* 主线条的动画 */
.main-line {
animation: scanline v-bind('`${props.scanLineSpeed}s`') linear infinite;
/* 调整 stroke-dasharray 来控制扫描线的长度和间隔 */
stroke-dasharray: 15px, 80px; /* 15px 显示, 100px 隐藏 */
stroke-dashoffset: 0; /* 初始位置 */
}
/* 鼠标悬停时,主线条颜色变亮,动画速度可能加快 */
.tech-button-with-line:hover .main-line {
stroke: v-bind('props.lineHoverColor'); /* 悬停时线条颜色 */
animation-duration: v-bind('`${props.scanLineSpeed / 1.5}s`'); /* 悬停时速度加快 */
stroke-dasharray: 20px, 120px; /* 悬停时线条显示部分变长 */
}
/* 细微基线样式 */
.base-line {
opacity: 0.6;
transition: all v-bind('`${props.animationDuration}s`') ease-in-out;
}
.tech-button-with-line:hover .base-line {
opacity: 0.9; /* 悬停时基线也变亮 */
}
/* --- Flowing Light Border Effect --- */
/* 使用 ::before 伪元素实现四周的流光 */
.tech-button-with-line::before {
content: '';
position: absolute;
top: -4px; /* 稍向外延伸 */
left: -4px;
right: -4px;
bottom: -4px;
/* 关键:创建动态的流光渐变 */
/* 渐变方向是从左到右,颜色从透明到高亮再到透明 */
background: linear-gradient(
90deg,
transparent,
v-bind('props.flowLineColor'),
transparent
);
border-radius: 0px; /* **与按钮一致的平直四角** */
opacity: 0.8; /* 初始可见度 */
filter: blur(5px); /* 轻微模糊,模拟光晕 */
z-index: -1; /* 放在按钮内容后面 */
animation: flowLight v-bind('`${props.flowLineAnimationSpeed}s`') linear infinite;
}
/* 悬停时,流光效果可以增强 */
.tech-button-with-line:hover::before {
opacity: 1;
filter: blur(8px); /* 悬停时模糊度增加 */
}
/* --- Keyframes for Animations --- */
@keyframes scanline {
0% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -1000; /* 确保足够长以扫过整个线段 */
}
}
/* 流光动画 */
@keyframes flowLight {
0% { background-position: -100% 0; } /* 从左侧完全移出 */
50% { background-position: 100% 0; } /* 移动到右侧完全移出 */
100% { background-position: -100% 0; } /* 回到初始状态,循环 */
}
</style>