feat: 使用SignalR实时发送示波器数据,并美化示波器界面
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user