FPGA_WebLab/src/views/Project/VideoStream.vue

858 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="bg-base-100 flex flex-col gap-7">
<!-- 控制面板 -->
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-primary">
<Settings class="w-6 h-6" />
控制面板
</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<!-- 服务状态 -->
<div class="stats shadow">
<div class="stat bg-base-100">
<div class="stat-figure text-primary">
<div
class="badge"
:class="
statusInfo.isRunning ? 'badge-success' : 'badge-error'
"
>
{{ statusInfo.isRunning ? "运行中" : "已停止" }}
</div>
</div>
<div class="stat-title">服务状态</div>
<div class="stat-value text-primary">HTTP</div>
<div class="stat-desc">端口: {{ statusInfo.serverPort }}</div>
</div>
</div>
<!-- 流信息 -->
<div class="stats shadow">
<div class="stat bg-base-100">
<div class="stat-figure text-secondary">
<Video class="w-8 h-8" />
</div>
<div class="stat-title">视频规格</div>
<div class="stat-value text-secondary">
{{ streamInfo.frameWidth }}×{{ streamInfo.frameHeight }}
</div>
<div class="stat-desc">{{ streamInfo.frameRate }} FPS</div>
</div>
</div>
<!-- 分辨率控制 -->
<div class="stats shadow">
<div class="stat bg-base-100">
<div class="stat-figure text-info">
<Settings class="w-8 h-8" />
</div>
<div class="stat-title">分辨率设置</div>
<div class="stat-value text-sm">
<select
class="select select-sm select-bordered max-w-xs"
v-model="selectedResolution"
@change="changeResolution"
:disabled="changingResolution"
>
<option v-for="res in supportedResolutions" :key="`${res.width}x${res.height}`" :value="res">
{{ res.width }}×{{ res.height }}
</option>
</select>
</div>
<div class="stat-desc">
<button
class="btn btn-xs btn-outline btn-info mt-1"
@click="refreshResolutions"
:disabled="loadingResolutions"
>
<RefreshCw v-if="loadingResolutions" class="animate-spin h-3 w-3" />
{{ loadingResolutions ? "刷新中..." : "刷新" }}
</button>
</div>
</div>
</div>
<!-- 连接数 -->
<div class="stats shadow">
<div class="stat bg-base-100 relative">
<div class="stat-figure text-accent">
<Users class="w-8 h-8" />
</div>
<div class="stat-title">连接数</div>
<div class="stat-value text-accent">
{{ statusInfo.connectedClients }}
</div>
<div class="stat-desc">
<div class="dropdown dropdown-hover dropdown-top">
<div
tabindex="0"
role="button"
class="text-xs underline cursor-help"
>
查看客户端
</div>
<ul
tabindex="0"
class="dropdown-content z-20 menu p-2 shadow bg-base-200 rounded-box w-64 max-h-48 overflow-y-auto"
>
<li
v-for="(client, index) in statusInfo.clientEndpoints"
:key="index"
class="text-xs"
>
<a class="break-all">{{ client }}</a>
</li>
<li
v-if="
!statusInfo.clientEndpoints ||
statusInfo.clientEndpoints.length === 0
"
>
<a class="text-xs opacity-50">无活跃连接</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="card-actions justify-end mt-4">
<button
class="btn btn-outline btn-primary"
@click="configCamera"
:dsiabled="configing"
>
<RefreshCw v-if="configing" class="animate-spin h-4 w-4 mr-2" />
<CogIcon v-else class="h-4 w-4 mr-2" />
{{ configing ? "配置中..." : "配置摄像头" }}
</button>
<button
class="btn btn-outline btn-primary"
@click="refreshStatus"
:disabled="loading"
>
<RefreshCw v-if="loading" class="animate-spin h-4 w-4 mr-2" />
<RefreshCw v-else class="h-4 w-4 mr-2" />
{{ loading ? "刷新中..." : "刷新状态" }}
</button>
<button
class="btn btn-primary"
@click="testConnection"
:disabled="testing"
>
<RefreshCw v-if="testing" class="animate-spin h-4 w-4 mr-2" />
<TestTube v-else class="h-4 w-4 mr-2" />
{{ testing ? "测试中..." : "测试连接" }}
</button>
</div>
</div>
</div>
<!-- 视频预览区域 -->
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-primary">
<Video class="w-6 h-6" />
视频预览
</h2>
<div
class="relative bg-black rounded-lg overflow-hidden cursor-pointer"
:class="[
focusAnimationClass,
{ 'cursor-not-allowed': !isPlaying || hasVideoError }
]"
style="aspect-ratio: 4/3"
@click="handleVideoClick"
>
<!-- 视频播放器 - 使用img标签直接显示MJPEG流 -->
<div
v-show="isPlaying"
class="w-full h-full flex items-center justify-center"
>
<img
:src="currentVideoSource"
alt="视频流"
class="max-w-full max-h-full object-contain"
@error="handleVideoError"
@load="handleVideoLoad"
/>
</div>
<!-- 对焦提示 -->
<div
v-if="isPlaying && !hasVideoError"
class="absolute top-4 right-4 text-white text-sm bg-black bg-opacity-50 px-2 py-1 rounded"
>
{{ isFocusing ? '对焦中...' : '点击画面对焦' }}
</div>
<!-- 错误信息显示 -->
<div
v-if="hasVideoError"
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-70"
>
<div class="card bg-error text-white shadow-lg w-full max-w-lg">
<div class="card-body">
<h3 class="card-title flex items-center gap-2">
<AlertTriangle class="h-6 w-6" />
视频流加载失败
</h3>
<p>无法连接到视频服务器,请检查以下内容:</p>
<ul class="list-disc list-inside">
<li>视频流服务是否已启动</li>
<li>网络连接是否正常</li>
<li>端口 {{ statusInfo.serverPort }} 是否可访问</li>
</ul>
<div class="card-actions justify-end mt-2">
<button
class="btn btn-sm btn-outline btn-primary"
@click="tryReconnect"
>
重试连接
</button>
</div>
</div>
</div>
</div>
<!-- 占位符 -->
<div
v-show="!isPlaying && !hasVideoError"
class="absolute inset-0 flex items-center justify-center text-white"
>
<div class="text-center">
<Video class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p class="text-lg opacity-75">{{ videoStatus }}</p>
<p class="text-sm opacity-60 mt-2">
点击"播放视频流"按钮开始查看实时视频
</p>
</div>
</div>
</div>
<!-- 视频控制 -->
<div class="flex justify-between items-center mt-4">
<div class="text-sm text-base-content/70">
流地址:
<code class="bg-base-300 px-2 py-1 rounded">{{
streamInfo.mjpegUrl
}}</code>
</div>
<div class="space-x-2">
<div class="dropdown dropdown-hover dropdown-top dropdown-end">
<div
tabindex="0"
role="button"
class="btn btn-sm btn-outline btn-accent"
>
<MoreHorizontal class="w-4 h-4 mr-1" />
更多功能
</div>
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52"
>
<li>
<a @click="openInNewTab(streamInfo.htmlUrl)">
<ExternalLink class="w-4 h-4" />
在新标签打开视频页面
</a>
</li>
<li>
<a @click="takeSnapshot">
<Camera class="w-4 h-4" />
获取并下载快照
</a>
</li>
<li>
<a @click="copyToClipboard(streamInfo.mjpegUrl)">
<Copy class="w-4 h-4" />
复制MJPEG地址
</a>
</li>
</ul>
</div>
<button
class="btn btn-success btn-sm"
@click="startStream"
:disabled="isPlaying"
>
<Play class="w-4 h-4 mr-1" />
播放视频流
</button>
<button
class="btn btn-error btn-sm"
@click="stopStream"
:disabled="!isPlaying"
>
<Square class="w-4 h-4 mr-1" />
停止视频流
</button>
</div>
</div>
</div>
</div>
<!-- 日志区域 -->
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-primary">
<FileText class="w-6 h-6" />
操作日志
</h2>
<div class="bg-base-300 rounded-lg p-4 h-48 overflow-y-auto">
<div
v-for="(log, index) in logs"
:key="index"
class="text-sm font-mono mb-1"
>
<span class="text-base-content/50"
>[{{ formatTime(log.time) }}]</span
>
<span :class="getLogClass(log.level)">{{ log.message }}</span>
</div>
<div
v-if="logs.length === 0"
class="text-base-content/50 text-center py-8"
>
暂无日志记录
</div>
</div>
<div class="card-actions justify-end mt-2">
<button class="btn btn-outline btn-sm" @click="clearLogs">
清空日志
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import {
CogIcon,
Settings,
Video,
Users,
RefreshCw,
TestTube,
Play,
Square,
ExternalLink,
Camera,
Copy,
FileText,
AlertTriangle,
MoreHorizontal,
} from "lucide-vue-next";
import { VideoStreamClient, CameraConfigRequest, ResolutionConfigRequest } from "@/APIClient";
import { useEquipments } from "@/stores/equipments";
const eqps = useEquipments();
// 状态管理
const loading = ref(false);
const configing = ref(false);
const testing = ref(false);
const isPlaying = ref(false);
const hasVideoError = ref(false);
const videoStatus = ref('点击"播放视频流"按钮开始查看实时视频');
// 对焦相关状态
const isFocusing = ref(false);
const focusAnimationClass = ref('');
// 分辨率相关状态
const changingResolution = ref(false);
const loadingResolutions = ref(false);
const selectedResolution = ref({ width: 640, height: 480 });
const supportedResolutions = ref([
{ width: 640, height: 480 },
{ width: 1280, height: 720 }
]);
// 数据
const statusInfo = ref({
isRunning: false,
serverPort: 8080,
streamUrl: "",
mjpegUrl: "",
snapshotUrl: "",
connectedClients: 0,
clientEndpoints: [] as string[],
});
const streamInfo = ref({
frameRate: 30,
frameWidth: 640,
frameHeight: 480,
format: "MJPEG",
htmlUrl: "",
mjpegUrl: "",
snapshotUrl: "",
});
const currentVideoSource = ref("");
const logs = ref<Array<{ time: Date; level: string; message: string }>>([]);
// API 客户端
const videoClient = new VideoStreamClient();
// 添加日志
const addLog = (level: string, message: string) => {
logs.value.push({
time: new Date(),
level,
message,
});
// 限制日志数量
if (logs.value.length > 100) {
logs.value.shift();
}
};
// 格式化时间
const formatTime = (time: Date) => {
return time.toLocaleTimeString();
};
// 获取日志样式
const getLogClass = (level: string) => {
switch (level) {
case "error":
return "text-error";
case "warning":
return "text-warning";
case "success":
return "text-success";
default:
return "text-base-content";
}
};
// 清空日志
const clearLogs = () => {
logs.value = [];
addLog("info", "日志已清空");
};
// 复制到剪贴板
const copyToClipboard = (text: string) => {
navigator.clipboard
.writeText(text)
.then(() => {
addLog("success", "已复制到剪贴板");
})
.catch((err) => {
addLog("error", `复制失败: ${err}`);
});
};
// 在新标签中打开视频页面
const openInNewTab = (url: string) => {
window.open(url, "_blank");
addLog("info", `已在新标签打开视频页面: ${url}`);
};
// 获取并下载快照
const takeSnapshot = async () => {
try {
addLog("info", "正在获取快照...");
// 使用当前的快照URL
const snapshotUrl = streamInfo.value.snapshotUrl;
if (!snapshotUrl) {
addLog("error", "快照URL不可用");
return;
}
// 添加时间戳防止缓存
const urlWithTimestamp = `${snapshotUrl}?t=${new Date().getTime()}`;
// 创建一个临时链接下载图片
const a = document.createElement("a");
a.href = urlWithTimestamp;
a.download = `fpga-snapshot-${new Date().toISOString().replace(/:/g, "-")}.jpg`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
addLog("success", "快照已下载");
} catch (error) {
addLog("error", `获取快照失败: ${error}`);
console.error("获取快照失败:", error);
}
};
async function configCamera() {
configing.value = true;
try {
addLog("info", "正在配置并初始化摄像头...");
const boardconfig = new CameraConfigRequest({
address: eqps.boardAddr,
port: eqps.boardPort,
});
await videoClient.configureCamera(boardconfig);
const status = await videoClient.getCameraConfig();
if (status.isConfigured) {
addLog("success", "摄像头已配置并初始化");
} else {
addLog("error", "摄像头配置失败,请检查地址和端口");
}
} catch (error) {
addLog("error", `摄像头配置失败: ${error}`);
console.error("摄像头配置失败:", error);
} finally {
configing.value = false;
}
}
// 刷新状态
const refreshStatus = async () => {
loading.value = true;
try {
addLog("info", "正在获取服务状态...");
// 使用新的API方法名称
const status = await videoClient.getStatus();
statusInfo.value = status;
const info = await videoClient.getStreamInfo();
streamInfo.value = info;
addLog("success", "服务状态获取成功");
} catch (error) {
addLog("error", `获取状态失败: ${error}`);
console.error("获取状态失败:", error);
} finally {
loading.value = false;
}
};
// 测试连接
const testConnection = async () => {
testing.value = true;
try {
addLog("info", "正在测试视频流连接...");
const result = await videoClient.testConnection();
if (result) {
addLog("success", "视频流连接测试成功");
} else {
addLog("error", "视频流连接测试失败");
}
} catch (error) {
addLog("error", `连接测试失败: ${error}`);
console.error("连接测试失败:", error);
} finally {
testing.value = false;
}
};
// 视频错误处理
const handleVideoError = () => {
if (isPlaying.value) {
hasVideoError.value = true;
addLog("error", "视频流加载失败");
}
};
// 视频加载成功处理
const handleVideoLoad = () => {
hasVideoError.value = false;
addLog("success", "视频流加载成功");
};
// 尝试重新连接
const tryReconnect = () => {
addLog("info", "尝试重新连接视频流...");
hasVideoError.value = false;
// 重新设置视频源,添加时间戳避免缓存问题
currentVideoSource.value = `${streamInfo.value.mjpegUrl}?t=${new Date().getTime()}`;
};
// 执行对焦
const performFocus = async () => {
if (isFocusing.value || !isPlaying.value) return;
try {
isFocusing.value = true;
focusAnimationClass.value = 'focus-starting';
addLog("info", "正在执行自动对焦...");
// 调用对焦API
const response = await fetch('/api/VideoStream/Focus');
const result = await response.json();
if (result.success) {
// 对焦成功动画
focusAnimationClass.value = 'focus-success';
addLog("success", "自动对焦执行成功");
// 2秒后消失
setTimeout(() => {
focusAnimationClass.value = '';
}, 2000);
} else {
// 对焦失败动画
focusAnimationClass.value = 'focus-error';
addLog("error", `自动对焦执行失败: ${result.message || '未知错误'}`);
// 2秒后消失
setTimeout(() => {
focusAnimationClass.value = '';
}, 2000);
}
} catch (error) {
// 对焦失败动画
focusAnimationClass.value = 'focus-error';
addLog("error", `自动对焦执行失败: ${error}`);
console.error("自动对焦执行失败:", error);
// 2秒后消失
setTimeout(() => {
focusAnimationClass.value = '';
}, 2000);
} finally {
// 1秒后重置对焦状态
setTimeout(() => {
isFocusing.value = false;
}, 1000);
}
};
// 处理视频点击事件
const handleVideoClick = (event: MouseEvent) => {
// 只在播放状态下才允许对焦
if (!isPlaying.value || hasVideoError.value) return;
// 防止重复点击
if (isFocusing.value) return;
performFocus();
};
// 启动视频流
const startStream = async () => {
try {
addLog("info", "正在启动视频流...");
videoStatus.value = "正在连接视频流...";
videoClient.setEnabled(true);
// 刷新状态
await refreshStatus();
// 设置视频源
currentVideoSource.value = streamInfo.value.mjpegUrl;
// 设置播放状态
isPlaying.value = true;
hasVideoError.value = false;
addLog("success", "视频流已启动");
} catch (error) {
addLog("error", `启动视频流失败: ${error}`);
videoStatus.value = "启动视频流失败";
console.error("启动视频流失败:", error);
}
};
// 分辨率相关方法
// 获取支持的分辨率列表
const refreshResolutions = async () => {
loadingResolutions.value = true;
try {
addLog("info", "正在获取支持的分辨率列表...");
const resolutions = await videoClient.getSupportedResolutions();
supportedResolutions.value = resolutions.resolutions;
console.log("支持的分辨率列表:", supportedResolutions.value);
// 获取当前分辨率
const currentRes = await videoClient.getCurrentResolution();
selectedResolution.value = currentRes;
addLog("success", "分辨率列表获取成功");
} catch (error) {
addLog("error", `获取分辨率列表失败: ${error}`);
console.error("获取分辨率列表失败:", error);
} finally {
loadingResolutions.value = false;
}
};
// 切换分辨率
const changeResolution = async () => {
if (!selectedResolution.value) return;
changingResolution.value = true;
const wasPlaying = isPlaying.value;
try {
addLog("info", `正在切换分辨率到 ${selectedResolution.value.width}×${selectedResolution.value.height}...`);
// 如果正在播放,先停止视频流
if (wasPlaying) {
stopStream();
await new Promise(resolve => setTimeout(resolve, 1000)); // 等待1秒
}
// 设置新分辨率
const resolutionRequest = new ResolutionConfigRequest({
width: selectedResolution.value.width,
height: selectedResolution.value.height
});
const success = await videoClient.setResolution(resolutionRequest);
if (success) {
// 刷新流信息
await refreshStatus();
// 如果之前在播放,重新启动视频流
if (wasPlaying) {
await new Promise(resolve => setTimeout(resolve, 500)); // 短暂延迟
await startStream();
}
addLog("success", `分辨率已切换到 ${selectedResolution.value.width}×${selectedResolution.value.height}`);
} else {
addLog("error", "分辨率切换失败");
}
} catch (error) {
addLog("error", `分辨率切换失败: ${error}`);
console.error("分辨率切换失败:", error);
} finally {
changingResolution.value = false;
}
};
// 停止视频流
const stopStream = () => {
try {
addLog("info", "正在停止视频流...");
videoClient.setEnabled(false);
// 清除视频源
currentVideoSource.value = "";
// 更新状态
isPlaying.value = false;
hasVideoError.value = false;
videoStatus.value = '点击"播放视频流"按钮开始查看实时视频';
addLog("success", "视频流已停止");
} catch (error) {
addLog("error", `停止视频流失败: ${error}`);
console.error("停止视频流失败:", error);
}
};
// 生命周期
onMounted(async () => {
addLog("info", "HTTP 视频流页面已加载");
await refreshStatus();
await refreshResolutions(); // 初始化分辨率信息
});
onUnmounted(() => {
stopStream();
});
</script>
<style scoped>
/* 自定义样式 */
.stats {
background-color: var(--b1);
color: var(--bc);
/* 添加适配文本颜色 */
}
code {
font-size: 0.75rem;
}
img {
/* 确保视频流居中显示 */
margin: 0 auto;
}
* {
transition: all 500ms ease-in-out;
}
/* 对焦动画样式 */
.focus-starting {
border: 3px solid transparent;
animation: focus-starting-animation 0.5s ease-in-out forwards;
}
.focus-success {
border: 3px solid transparent;
animation: focus-success-animation 2s ease-in-out forwards;
}
.focus-error {
border: 3px solid transparent;
animation: focus-error-animation 2s ease-in-out forwards;
}
@keyframes focus-starting-animation {
0% {
border-color: transparent;
}
100% {
border-color: #fbbf24; /* 黄色 */
box-shadow: 0 0 20px rgba(251, 191, 36, 0.5);
}
}
@keyframes focus-success-animation {
0% {
border-color: #fbbf24; /* 黄色 */
box-shadow: 0 0 20px rgba(251, 191, 36, 0.5);
}
50% {
border-color: #10b981; /* 绿色 */
box-shadow: 0 0 20px rgba(16, 185, 129, 0.5);
}
100% {
border-color: transparent;
box-shadow: none;
}
}
@keyframes focus-error-animation {
0% {
border-color: #fbbf24; /* 黄色 */
box-shadow: 0 0 20px rgba(251, 191, 36, 0.5);
}
50% {
border-color: #ef4444; /* 红色 */
box-shadow: 0 0 20px rgba(239, 68, 68, 0.5);
}
100% {
border-color: transparent;
box-shadow: none;
}
}
/* 对焦状态下的鼠标指针 */
.cursor-pointer {
cursor: pointer;
}
.cursor-not-allowed {
cursor: not-allowed;
}
</style>