feat: 使用SignalR实时发送示波器数据,并美化示波器界面

This commit is contained in:
2025-08-19 12:55:18 +08:00
parent 1b5b0e28e3
commit 7e53b805ae
13 changed files with 1664 additions and 347 deletions

View File

@@ -1,14 +1,35 @@
import { autoResetRef, createInjectionState } from "@vueuse/core";
import { shallowRef, reactive, ref, computed } from "vue";
import { Mutex } from "async-mutex";
import {
OscilloscopeFullConfig,
OscilloscopeDataResponse,
OscilloscopeApiClient,
} from "@/APIClient";
autoResetRef,
createInjectionState,
watchDebounced,
} from "@vueuse/core";
import {
shallowRef,
reactive,
ref,
computed,
onMounted,
onUnmounted,
watchEffect,
} from "vue";
import { Mutex } from "async-mutex";
import { OscilloscopeApiClient } from "@/APIClient";
import { AuthManager } from "@/utils/AuthManager";
import { useAlertStore } from "@/components/Alert";
import { useRequiredInjection } from "@/utils/Common";
import type { HubConnection } from "@microsoft/signalr";
import type {
IOscilloscopeHub,
IOscilloscopeReceiver,
} from "@/utils/signalR/TypedSignalR.Client/server.Hubs";
import {
getHubProxyFactory,
getReceiverRegister,
} from "@/utils/signalR/TypedSignalR.Client";
import type {
OscilloscopeDataResponse,
OscilloscopeFullConfig,
} from "@/utils/signalR/server.Hubs";
export type OscilloscopeDataType = {
x: number[];
@@ -22,41 +43,103 @@ export type OscilloscopeDataType = {
};
// 默认配置
const DEFAULT_CONFIG: OscilloscopeFullConfig = new OscilloscopeFullConfig({
const DEFAULT_CONFIG: OscilloscopeFullConfig = {
captureEnabled: false,
triggerLevel: 128,
triggerRisingEdge: true,
horizontalShift: 0,
decimationRate: 50,
autoRefreshRAM: false,
});
captureFrequency: 100,
};
// 采样频率常量(后端返回)
const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
() => {
const oscData = shallowRef<OscilloscopeDataType>();
// Global Store
const alert = useRequiredInjection(useAlertStore);
// Data
const oscData = shallowRef<OscilloscopeDataType>();
const clearOscilloscopeData = () => {
oscData.value = undefined;
};
// SignalR Hub
const oscilloscopeHub = shallowRef<{
connection: HubConnection;
proxy: IOscilloscopeHub;
} | null>(null);
const oscilloscopeReceiver: IOscilloscopeReceiver = {
onDataReceived: async (data) => {
analyzeOscilloscopeData(data);
},
};
onMounted(() => {
initHub();
});
onUnmounted(() => {
clearHub();
});
function initHub() {
if (oscilloscopeHub.value) return;
const connection = AuthManager.createHubConnection("OscilloscopeHub");
const proxy =
getHubProxyFactory("IOscilloscopeHub").createHubProxy(connection);
getReceiverRegister("IOscilloscopeReceiver").register(
connection,
oscilloscopeReceiver,
);
connection.start();
oscilloscopeHub.value = { connection, proxy };
}
function clearHub() {
if (!oscilloscopeHub.value) return;
oscilloscopeHub.value.connection.stop();
oscilloscopeHub.value = null;
}
function reinitializeHub() {
clearHub();
initHub();
}
function getHubProxy() {
if (!oscilloscopeHub.value) throw new Error("Hub not initialized");
return oscilloscopeHub.value.proxy;
}
// 互斥锁
const operationMutex = new Mutex();
// 状态
const isApplying = ref(false);
const isCapturing = ref(false);
const isAutoApplying = ref(false);
// 配置
const config = reactive<OscilloscopeFullConfig>(
new OscilloscopeFullConfig({ ...DEFAULT_CONFIG }),
);
const config = reactive<OscilloscopeFullConfig>({ ...DEFAULT_CONFIG });
watchDebounced(
config,
() => {
if (!isAutoApplying.value) return;
// 采样点数(由后端数据决定)
const sampleCount = ref(0);
// 采样周期ns由adFrequency计算
const samplePeriodNs = computed(() =>
oscData.value?.adFrequency
? 1_000_000_000 / oscData.value.adFrequency
: 200,
if (
!isApplying.value ||
!isCapturing.value ||
!operationMutex.isLocked()
) {
applyConfiguration();
}
},
{ debounce: 200, maxWait: 1000 },
);
// 应用配置
@@ -68,14 +151,18 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
const release = await operationMutex.acquire();
isApplying.value = true;
try {
const client = AuthManager.createClient(OscilloscopeApiClient);
const success = await client.initialize({ ...config });
const proxy = getHubProxy();
const success = await proxy.initialize(config);
if (success) {
alert.success("示波器配置已应用", 2000);
} else {
throw new Error("应用失败");
}
} catch (error) {
if (error instanceof Error && error.message === "Hub not initialized")
reinitializeHub();
alert.error("应用配置失败", 3000);
} finally {
isApplying.value = false;
@@ -89,68 +176,55 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
alert.info("配置已重置", 2000);
};
const clearOscilloscopeData = () => {
oscData.value = undefined;
// 采样点数(由后端数据决定)
const sampleCount = ref(0);
// 采样周期ns由adFrequency计算
const samplePeriodNs = computed(() =>
oscData.value?.adFrequency
? 1_000_000_000 / oscData.value.adFrequency
: 200,
);
const analyzeOscilloscopeData = (resp: OscilloscopeDataResponse) => {
// 解析波形数据
const binaryString = atob(resp.waveformData);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
sampleCount.value = bytes.length;
// 构建时间轴
const x = Array.from(
{ length: bytes.length },
(_, i) => (i * samplePeriodNs.value) / 1000, // us
);
const y = Array.from(bytes);
oscData.value = {
x,
y,
xUnit: "us",
yUnit: "V",
adFrequency: resp.aDFrequency,
adVpp: resp.aDVpp,
adMax: resp.aDMax,
adMin: resp.aDMin,
};
};
// 获取数据
const getOscilloscopeData = async () => {
try {
const client = AuthManager.createClient(OscilloscopeApiClient);
const resp: OscilloscopeDataResponse = await client.getData();
// 解析波形数据
const binaryString = atob(resp.waveformData);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
sampleCount.value = bytes.length;
// 构建时间轴
const x = Array.from(
{ length: bytes.length },
(_, i) => (i * samplePeriodNs.value) / 1000, // us
);
const y = Array.from(bytes);
oscData.value = {
x,
y,
xUnit: "us",
yUnit: "V",
adFrequency: resp.adFrequency,
adVpp: resp.adVpp,
adMax: resp.adMax,
adMin: resp.adMin,
};
const proxy = getHubProxy();
const resp = await proxy.getData();
analyzeOscilloscopeData(resp);
} catch (error) {
alert.error("获取示波器数据失败", 3000);
}
};
// 定时器引用
let refreshIntervalId: number | undefined;
// 刷新间隔(毫秒),可根据需要调整
const refreshIntervalMs = ref(1000);
// 定时刷新函数
const startAutoRefresh = () => {
if (refreshIntervalId !== undefined) return;
refreshIntervalId = window.setInterval(async () => {
await refreshRAM();
await getOscilloscopeData();
}, refreshIntervalMs.value);
};
const stopAutoRefresh = () => {
if (refreshIntervalId !== undefined) {
clearInterval(refreshIntervalId);
refreshIntervalId = undefined;
isCapturing.value = false;
}
};
// 启动捕获
const startCapture = async () => {
if (operationMutex.isLocked()) {
@@ -160,17 +234,13 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
isCapturing.value = true;
const release = await operationMutex.acquire();
try {
const client = AuthManager.createClient(OscilloscopeApiClient);
const started = await client.startCapture();
const proxy = getHubProxy();
const started = await proxy.startCapture();
if (!started) throw new Error("无法启动捕获");
alert.info("开始捕获...", 2000);
// 启动定时刷新
startAutoRefresh();
} catch (error) {
alert.error("捕获失败", 3000);
isCapturing.value = false;
stopAutoRefresh();
} finally {
release();
}
@@ -183,11 +253,10 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
return;
}
isCapturing.value = false;
stopAutoRefresh();
const release = await operationMutex.acquire();
try {
const client = AuthManager.createClient(OscilloscopeApiClient);
const stopped = await client.stopCapture();
const proxy = getHubProxy();
const stopped = await proxy.stopCapture();
if (!stopped) throw new Error("无法停止捕获");
alert.info("捕获已停止", 2000);
} catch (error) {
@@ -197,6 +266,14 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
}
};
const toggleCapture = async () => {
if (isCapturing.value) {
await stopCapture();
} else {
await startCapture();
}
};
// 更新触发参数
const updateTrigger = async (level: number, risingEdge: boolean) => {
const client = AuthManager.createClient(OscilloscopeApiClient);
@@ -279,9 +356,9 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
config,
isApplying,
isCapturing,
isAutoApplying,
sampleCount,
samplePeriodNs,
refreshIntervalMs,
applyConfiguration,
resetConfiguration,
@@ -289,6 +366,7 @@ const [useProvideOscilloscope, useOscilloscopeState] = createInjectionState(
getOscilloscopeData,
startCapture,
stopCapture,
toggleCapture,
updateTrigger,
updateSampling,
refreshRAM,

View File

@@ -1,36 +1,93 @@
<template>
<div class="w-full h-100 flex flex-col">
<!-- 原有内容 -->
<v-chart v-if="hasData" class="w-full h-full" :option="option" autoresize />
<div v-else class="w-full h-full flex flex-col gap-4 items-center justify-center text-gray-500">
<span> 暂无数据 </span>
<!-- 采集控制按钮 -->
<div class="flex justify-center items-center mb-2">
<div
class="waveform-container w-full h-full relative overflow-hidden rounded-lg"
>
<!-- 波形图表 -->
<v-chart
v-if="hasData"
class="w-full h-full transition-all duration-500 ease-in-out"
:option="option"
autoresize
/>
<!-- 无数据状态 -->
<div
v-else
class="w-full h-full flex flex-col items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50 dark:from-slate-900 dark:to-slate-800"
>
<!-- 动画图标 -->
<div class="relative mb-6">
<div
class="w-24 h-24 rounded-full border-4 border-blue-200 dark:border-blue-800 animate-pulse"
></div>
<div class="absolute inset-0 flex items-center justify-center">
<Activity class="w-12 h-12 text-blue-500 animate-bounce" />
</div>
<!-- 扫描线效果 -->
<div
class="absolute inset-0 rounded-full border-2 border-transparent border-t-blue-500 animate-spin"
></div>
</div>
<!-- 状态文本 -->
<div class="text-center space-y-2 mb-8">
<h3 class="text-xl font-semibold text-slate-700 dark:text-slate-300">
等待信号输入
</h3>
<p class="text-slate-500 dark:text-slate-400">
请启动数据采集以显示波形
</p>
</div>
<!-- 快速启动按钮 -->
<div class="flex justify-center items-center">
<button
class="group relative px-8 py-3 bg-gradient-to-r text-white font-medium rounded-lg shadow-lg hover:shadow-xl transform hover:scale-105 transition-all duration-200 ease-in-out focus:outline-none focus:ring-4 active:scale-95"
class="group relative px-8 py-4 bg-gradient-to-r text-white font-semibold rounded-xl shadow-xl hover:shadow-2xl transform hover:scale-110 transition-all duration-300 ease-out focus:outline-none focus:ring-4 active:scale-95 overflow-hidden"
:class="{
'from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 focus:ring-blue-300':
'from-emerald-500 via-blue-500 to-purple-600 hover:from-emerald-600 hover:via-blue-600 hover:to-purple-700 focus:ring-blue-300':
!oscManager.isCapturing.value,
'from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 focus:ring-red-300':
'from-red-500 via-pink-500 to-red-600 hover:from-red-600 hover:via-pink-600 hover:to-red-700 focus:ring-red-300':
oscManager.isCapturing.value,
}" @click="
}"
@click="
oscManager.isCapturing.value
? oscManager.stopCapture()
: oscManager.startCapture()
">
<span class="flex items-center gap-2">
"
>
<!-- 背景动画效果 -->
<div
class="absolute inset-0 bg-white opacity-0 group-hover:opacity-20 transition-opacity duration-300"
></div>
<!-- 按钮内容 -->
<span class="relative flex items-center gap-3">
<template v-if="oscManager.isCapturing.value">
<Square class="w-5 h-5" />
<Square class="w-6 h-6 animate-pulse" />
停止采集
</template>
<template v-else>
<Play class="w-5 h-5" />
<Play class="w-6 h-6 group-hover:animate-pulse" />
开始采集
</template>
</span>
<!-- 光晕效果 -->
<div
class="absolute inset-0 rounded-xl bg-gradient-to-r from-transparent via-white to-transparent opacity-0 group-hover:opacity-30 transform -skew-x-12 translate-x-full group-hover:translate-x-[-200%] transition-transform duration-700"
></div>
</button>
</div>
</div>
<!-- 数据采集状态指示器 -->
<div
v-if="hasData && oscManager.isCapturing.value"
class="absolute top-4 right-4 flex items-center gap-2 bg-red-500/90 text-white px-3 py-1 rounded-full text-sm font-medium backdrop-blur-sm"
>
<div class="w-2 h-2 bg-white rounded-full animate-pulse"></div>
采集中
</div>
</div>
</template>
@@ -61,7 +118,7 @@ import type {
GridComponentOption,
} from "echarts/components";
import { useRequiredInjection } from "@/utils/Common";
import { Play, Square } from "lucide-vue-next";
import { Play, Square, Activity } from "lucide-vue-next";
use([
TooltipComponent,
@@ -113,12 +170,23 @@ const option = computed((): EChartsOption => {
? (oscData.value.y as number[][])
: [oscData.value.y as number[]];
// 预定义的通道颜色
const channelColors = [
"#3B82F6", // blue-500
"#EF4444", // red-500
"#10B981", // emerald-500
"#F59E0B", // amber-500
"#8B5CF6", // violet-500
"#06B6D4", // cyan-500
];
forEach(yChannels, (yData, index) => {
if (!oscData.value || !yData) return;
const seriesData = oscData.value.x.map((xValue, i) => [
xValue,
yData && yData[i] !== undefined ? yData[i] : 0,
]);
series.push({
type: "line",
name: `通道 ${index + 1}`,
@@ -126,41 +194,82 @@ const option = computed((): EChartsOption => {
smooth: false,
symbol: "none",
lineStyle: {
width: 2,
width: 2.5,
color: channelColors[index % channelColors.length],
shadowColor: channelColors[index % channelColors.length],
shadowBlur: isCapturing ? 0 : 4,
shadowOffsetY: 2,
},
// 关闭系列动画
itemStyle: {
color: channelColors[index % channelColors.length],
},
// 动画配置
animation: !isCapturing,
animationDuration: isCapturing ? 0 : 1000,
animationDuration: isCapturing ? 0 : 1200,
animationEasing: isCapturing ? "linear" : "cubicOut",
animationDelay: index * 100, // 错开动画时间
});
});
return {
backgroundColor: "transparent",
grid: {
left: "10%",
right: "10%",
top: "15%",
bottom: "25%",
left: "8%",
right: "5%",
top: "12%",
bottom: "20%",
borderWidth: 1,
borderColor: "#E2E8F0",
backgroundColor: "rgba(248, 250, 252, 0.8)",
},
tooltip: {
trigger: "axis",
backgroundColor: "rgba(255, 255, 255, 0.95)",
borderColor: "#E2E8F0",
borderWidth: 1,
textStyle: {
color: "#334155",
fontSize: 12,
},
formatter: (params: any) => {
if (!oscData.value) return "";
let result = `时间: ${params[0].data[0].toFixed(2)} ${oscData.value.xUnit}<br/>`;
let result = `<div style="font-weight: 600; margin-bottom: 4px;">时间: ${params[0].data[0].toFixed(2)} ${oscData.value.xUnit}</div>`;
params.forEach((param: any) => {
result += `${param.seriesName}: ${param.data[1].toFixed(3)} ${oscData.value?.yUnit ?? ""}<br/>`;
result += `<div style="color: ${param.color};">● ${param.seriesName}: ${param.data[1].toFixed(3)} ${oscData.value?.yUnit ?? ""}</div>`;
});
return result;
},
},
legend: {
top: "5%",
top: "2%",
left: "center",
textStyle: {
color: "#64748B",
fontSize: 12,
fontWeight: 500,
},
itemGap: 20,
data: series.map((s) => s.name) as string[],
},
toolbox: {
right: "2%",
top: "2%",
feature: {
restore: {},
saveAsImage: {},
restore: {
title: "重置缩放",
},
saveAsImage: {
title: "保存图片",
name: `oscilloscope_${new Date().toISOString().slice(0, 19)}`,
},
},
iconStyle: {
borderColor: "#64748B",
},
emphasis: {
iconStyle: {
borderColor: "#3B82F6",
},
},
},
dataZoom: [
@@ -168,47 +277,275 @@ const option = computed((): EChartsOption => {
type: "inside",
start: 0,
end: 100,
filterMode: "weakFilter",
},
{
start: 0,
end: 100,
height: 25,
bottom: "8%",
borderColor: "#E2E8F0",
fillerColor: "rgba(59, 130, 246, 0.1)",
handleStyle: {
color: "#3B82F6",
borderColor: "#1E40AF",
},
textStyle: {
color: "#64748B",
fontSize: 11,
},
},
],
xAxis: {
type: "value",
name: oscData.value ? `时间 (${oscData.value.xUnit})` : "时间",
nameLocation: "middle",
nameGap: 30,
nameGap: 35,
nameTextStyle: {
color: "#64748B",
fontSize: 12,
fontWeight: 500,
},
axisLine: {
show: true,
lineStyle: {
color: "#CBD5E1",
width: 1.5,
},
},
axisTick: {
show: true,
lineStyle: {
color: "#E2E8F0",
},
},
axisLabel: {
color: "#64748B",
fontSize: 11,
},
splitLine: {
show: false,
show: true,
lineStyle: {
color: "#F1F5F9",
type: "dashed",
},
},
},
yAxis: {
type: "value",
name: oscData.value ? `电压 (${oscData.value.yUnit})` : "电压",
nameLocation: "middle",
nameGap: 40,
nameGap: 50,
nameTextStyle: {
color: "#64748B",
fontSize: 12,
fontWeight: 500,
},
axisLine: {
show: true,
lineStyle: {
color: "#CBD5E1",
width: 1.5,
},
},
axisTick: {
show: true,
lineStyle: {
color: "#E2E8F0",
},
},
axisLabel: {
color: "#64748B",
fontSize: 11,
},
splitLine: {
show: false,
show: true,
lineStyle: {
color: "#F1F5F9",
type: "dashed",
},
},
},
// 全局动画开关
animation: !isCapturing,
animationDuration: isCapturing ? 0 : 1000,
animationDuration: isCapturing ? 0 : 1200,
animationEasing: isCapturing ? "linear" : "cubicOut",
series: series,
};
});
</script>
<style scoped lang="postcss">
@import "@/assets/main.css";
/* 波形容器样式 */
.waveform-container {
background: linear-gradient(
135deg,
rgba(248, 250, 252, 0.8) 0%,
rgba(241, 245, 249, 0.8) 100%
);
border: 1px solid rgba(226, 232, 240, 0.5);
position: relative;
}
.waveform-container::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
45deg,
transparent 48%,
rgba(59, 130, 246, 0.05) 50%,
transparent 52%
);
pointer-events: none;
z-index: 1;
}
/* 无数据状态的背景动画 */
.waveform-container:not(:has(canvas)) {
background: linear-gradient(
135deg,
rgba(248, 250, 252, 1) 0%,
rgba(239, 246, 255, 1) 25%,
rgba(219, 234, 254, 1) 50%,
rgba(239, 246, 255, 1) 75%,
rgba(248, 250, 252, 1) 100%
);
background-size: 200% 200%;
animation: gradient-shift 8s ease-in-out infinite;
}
@keyframes gradient-shift {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
/* 深色模式支持 */
@media (prefers-color-scheme: dark) {
.waveform-container {
background: linear-gradient(
135deg,
rgba(15, 23, 42, 0.8) 0%,
rgba(30, 41, 59, 0.8) 100%
);
border-color: rgba(71, 85, 105, 0.5);
}
.waveform-container:not(:has(canvas)) {
background: linear-gradient(
135deg,
rgba(15, 23, 42, 1) 0%,
rgba(30, 41, 59, 1) 25%,
rgba(51, 65, 85, 1) 50%,
rgba(30, 41, 59, 1) 75%,
rgba(15, 23, 42, 1) 100%
);
}
}
/* 按钮光晕效果增强 */
button {
position: relative;
overflow: hidden;
}
button::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transform: translate(-50%, -50%);
transition:
width 0.6s,
height 0.6s;
}
button:active::after {
width: 300px;
height: 300px;
}
/* 扫描线动画优化 */
@keyframes scan-line {
0% {
transform: rotate(0deg) scale(1);
opacity: 1;
}
50% {
transform: rotate(180deg) scale(1.1);
opacity: 0.7;
}
100% {
transform: rotate(360deg) scale(1);
opacity: 1;
}
}
.animate-spin {
animation: scan-line 3s linear infinite;
}
/* 状态指示器增强 */
.absolute.top-4.right-4 {
backdrop-filter: blur(8px);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.25);
animation: float 2s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-2px);
}
}
/* 图表容器增强 */
.w-full.h-full.transition-all {
border-radius: 8px;
overflow: hidden;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.05);
}
/* 响应式调整 */
@media (max-width: 768px) {
.waveform-container {
min-height: 300px;
}
button {
padding: 12px 20px;
font-size: 14px;
}
.absolute.top-4.right-4 {
top: 8px;
right: 8px;
font-size: 12px;
padding: 4px 8px;
}
}
/* 平滑过渡效果 */
* {
transition: all 0.2s ease-in-out;
}
/* 焦点样式 */
button:focus-visible {
outline: 2px solid rgba(59, 130, 246, 0.5);
outline-offset: 2px;
}
</style>