FPGA_WebLab/src/views/VideoStreamView.vue

606 lines
17 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="min-h-screen bg-base-100">
<div class="container mx-auto p-6 space-y-6">
<!-- 控制面板 -->
<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-3 gap-4">
<!-- 服务状态 -->
<div class="stats shadow">
<div class="stat">
<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">
<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">
<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">
<div
tabindex="0"
role="button"
class="text-xs underline cursor-help"
>
查看客户端
</div>
<ul
tabindex="0"
class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52"
>
<li
v-for="(client, index) in statusInfo.clientEndpoints"
:key="index"
class="text-xs"
>
<a>{{ 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" />
<RefreshCw 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"
style="aspect-ratio: 4/3"
>
<!-- 视频播放器 - 使用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="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>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import {
Settings,
Video,
Users,
RefreshCw,
TestTube,
Play,
Square,
ExternalLink,
Camera,
Copy,
FileText,
AlertTriangle,
MoreHorizontal,
} from "lucide-vue-next";
import { VideoStreamClient, CameraConfigRequest } 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 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 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 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();
});
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;
}
</style>