NextGB, web demo powerd by vue
This commit is contained in:
379
html/NextGB/src/App.vue
Normal file
379
html/NextGB/src/App.vue
Normal file
@ -0,0 +1,379 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, provide, onMounted } from 'vue'
|
||||
import {
|
||||
Monitor,
|
||||
Setting,
|
||||
Tools,
|
||||
Fold,
|
||||
VideoCamera,
|
||||
User,
|
||||
VideoPlay,
|
||||
DataLine,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useDefaultMediaServer } from '@/stores/mediaServer'
|
||||
import { fetchDevicesAndChannels } from '@/stores/devices'
|
||||
import { fetchMediaServers } from '@/stores/mediaServer'
|
||||
|
||||
const isCollapse = ref(false)
|
||||
|
||||
const toggleSidebar = () => {
|
||||
isCollapse.value = !isCollapse.value
|
||||
}
|
||||
|
||||
// 提供默认媒体服务器
|
||||
const defaultMediaServer = useDefaultMediaServer()
|
||||
provide('defaultMediaServer', defaultMediaServer)
|
||||
|
||||
// 初始化数据
|
||||
const initializeData = async () => {
|
||||
try {
|
||||
// 并行获取设备列表和媒体服务器列表
|
||||
await Promise.all([fetchDevicesAndChannels(), fetchMediaServers()])
|
||||
} catch (error) {
|
||||
console.error('初始化数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 左侧菜单 -->
|
||||
<div class="sidebar" :class="{ 'is-collapse': isCollapse }">
|
||||
<div class="logo">
|
||||
<img src="./assets/logo.svg" alt="Logo" />
|
||||
<span>NextGB</span>
|
||||
</div>
|
||||
<el-menu :collapse="isCollapse" default-active="1" class="sidebar-menu">
|
||||
<el-menu-item index="dashboard" @click="$router.push('/dashboard')">
|
||||
<el-icon><DataLine /></el-icon>
|
||||
<span>系统概览</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="realplay" @click="$router.push('/realplay')">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>实时监控</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="playback" @click="$router.push('/playback')">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
<span>录像回放</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item index="media" @click="$router.push('/media')">
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
<span>流媒体服务</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="device">
|
||||
<template #title>
|
||||
<el-icon><Setting /></el-icon>
|
||||
<span>设备管理</span>
|
||||
</template>
|
||||
<el-menu-item index="device-list" @click="$router.push('/devices')">
|
||||
设备列表
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-sub-menu index="system">
|
||||
<template #title>
|
||||
<el-icon><Tools /></el-icon>
|
||||
<span>系统设置</span>
|
||||
</template>
|
||||
<el-menu-item index="settings" @click="$router.push('/settings')">基本设置</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
<div class="main-container">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<el-button @click="toggleSidebar">
|
||||
<el-icon><Fold /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-dropdown>
|
||||
<span class="user-info">
|
||||
<el-icon class="avatar-icon"><User /></el-icon>
|
||||
<span>管理员</span>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item>个人信息</el-dropdown-item>
|
||||
<el-dropdown-item>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="['RealplayView', 'PlaybackView']">
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow-x: hidden; /* 防止横向溢出 */
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background-color: #304156;
|
||||
color: #fff;
|
||||
transition: width 0.3s;
|
||||
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.15);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
overflow: hidden;
|
||||
background-color: #2b3a4d;
|
||||
border-bottom: 1px solid #1f2d3d;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
transition: margin 0.3s;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
margin-left: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
transition: opacity 0.3s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none !important;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 自定义菜单样式 */
|
||||
:deep(.el-menu) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
color: #bfcbd9;
|
||||
|
||||
&:hover {
|
||||
background-color: #263445 !important;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: #1890ff !important;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu__title) {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
color: #bfcbd9;
|
||||
|
||||
&:hover {
|
||||
background-color: #263445 !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-menu--collapse) {
|
||||
width: 64px;
|
||||
|
||||
.el-sub-menu__title span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-sub-menu__title .el-sub-menu__icon-arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 折叠状态下的样式 */
|
||||
.is-collapse {
|
||||
width: 64px;
|
||||
|
||||
.logo {
|
||||
padding: 0 16px;
|
||||
|
||||
img {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
opacity: 0;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 图标样式 */
|
||||
:deep(.el-menu-item .el-icon),
|
||||
:deep(.el-sub-menu__title .el-icon) {
|
||||
font-size: 18px;
|
||||
margin-right: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: margin-left 0.3s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar.is-collapse + .main-container {
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 60px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
|
||||
.avatar-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
background-color: #e6e6e6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
background: #f0f2f5;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 修改二级菜单样式 */
|
||||
:deep(.el-sub-menu) {
|
||||
.el-menu {
|
||||
background-color: #1f2d3d !important;
|
||||
}
|
||||
|
||||
.el-menu-item {
|
||||
padding-left: 54px !important;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background-color: #001528 !important;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: #1890ff !important;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 优化子菜单标题样式 */
|
||||
:deep(.el-sub-menu__title) {
|
||||
&:hover {
|
||||
background-color: #263445 !important;
|
||||
}
|
||||
|
||||
.el-sub-menu__icon-arrow {
|
||||
right: 15px;
|
||||
margin-top: -4px;
|
||||
font-size: 12px;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
/* 展开状态的箭头动画 */
|
||||
:deep(.el-sub-menu.is-opened) {
|
||||
> .el-sub-menu__title {
|
||||
color: #f4f4f5;
|
||||
|
||||
.el-sub-menu__icon-arrow {
|
||||
transform: rotateZ(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 折叠状态下的弹出菜单样式 */
|
||||
:deep(.el-menu--popup) {
|
||||
background-color: #1f2d3d !important;
|
||||
padding: 0;
|
||||
|
||||
.el-menu-item {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
font-size: 13px;
|
||||
padding: 0 20px !important;
|
||||
color: #bfcbd9;
|
||||
|
||||
&:hover {
|
||||
background-color: #001528 !important;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: #1890ff !important;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 修改菜单过渡动画 */
|
||||
:deep(.el-menu-item),
|
||||
:deep(.el-sub-menu__title) {
|
||||
transition:
|
||||
background-color 0.3s,
|
||||
color 0.3s,
|
||||
border-color 0.3s;
|
||||
}
|
||||
</style>
|
||||
95
html/NextGB/src/api/index.ts
Normal file
95
html/NextGB/src/api/index.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type * as Types from './types'
|
||||
import type { MediaServer } from '@/api/mediaserver/types'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_APP_API_BASE_URL,
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 媒体服务器相关 API
|
||||
export const mediaServerApi = {
|
||||
// 获取媒体服务器列表
|
||||
getMediaServers: () =>
|
||||
api.get<Types.ApiResponse<MediaServer[]>>('/srs-sip/v1/media-servers'),
|
||||
|
||||
// 添加媒体服务器
|
||||
addMediaServer: (data: Omit<MediaServer, 'id' | 'status' | 'created_at'>) =>
|
||||
api.post<Types.ApiResponse<{ msg: string }>>('/srs-sip/v1/media-servers', data),
|
||||
|
||||
// 删除媒体服务器
|
||||
deleteMediaServer: (id: number) =>
|
||||
api.delete<Types.ApiResponse<{ msg: string }>>(`/srs-sip/v1/media-servers/${id}`),
|
||||
|
||||
// 设置默认媒体服务器
|
||||
setDefaultMediaServer: (id: number) =>
|
||||
api.post<Types.ApiResponse<{ msg: string }>>(`/srs-sip/v1/media-servers/default/${id}`),
|
||||
}
|
||||
|
||||
// 设备相关 API
|
||||
export const deviceApi = {
|
||||
// 获取设备列表
|
||||
getDevices: () => api.get<Types.ApiResponse<Types.Device[]>>('/srs-sip/v1/devices'),
|
||||
|
||||
// 获取设备通道
|
||||
getDeviceChannels: (deviceId: string) =>
|
||||
api.get<Types.ApiResponse<Types.ChannelInfo[]>>(`/srs-sip/v1/devices/${deviceId}/channels`),
|
||||
|
||||
// 添加 invite API
|
||||
invite: (params: Types.InviteRequest) =>
|
||||
api.post<Types.ApiResponse<Types.InviteResponse>>('/srs-sip/v1/invite', params),
|
||||
|
||||
// 停止播放
|
||||
bye: (params: Types.ByeRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/bye', params),
|
||||
|
||||
// 暂停播放
|
||||
pause: (params: Types.PauseRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/pause', params),
|
||||
|
||||
// 恢复播放
|
||||
resume: (params: Types.ResumeRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/resume', params),
|
||||
|
||||
// 设置播放速度
|
||||
speed: (params: Types.SpeedRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/speed', params),
|
||||
|
||||
// 云台控制
|
||||
controlPTZ: (params: Types.PTZControlRequest) =>
|
||||
api.post<Types.ApiResponse<any>>('/srs-sip/v1/ptz', params),
|
||||
|
||||
// 查询录像
|
||||
queryRecord: (params: Types.RecordInfoRequest) =>
|
||||
api.post<Types.ApiResponse<Types.RecordInfoResponse[]>>('/srs-sip/v1/query-record', params),
|
||||
}
|
||||
|
||||
// 请求拦截器
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// 配置处理逻辑
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
const res = response.data as Types.ApiResponse<any>
|
||||
if (res.code !== 0) {
|
||||
ElMessage.error('请求失败')
|
||||
return Promise.reject(new Error('请求失败'))
|
||||
}
|
||||
response.data = res.data
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
ElMessage.error(error.response?.data?.message || '网络错误')
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default api
|
||||
30
html/NextGB/src/api/mediaserver/base.ts
Normal file
30
html/NextGB/src/api/mediaserver/base.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { ClientInfo, StreamInfo, VersionInfo, RtcPlayer } from './types'
|
||||
import { MediaServerType } from './types'
|
||||
|
||||
|
||||
/**
|
||||
* 媒体服务器接口
|
||||
*/
|
||||
export interface IMediaServer {
|
||||
type: MediaServerType
|
||||
getVersion(): Promise<VersionInfo>
|
||||
getStreamInfo(): Promise<StreamInfo[]>
|
||||
getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]>
|
||||
createRtcPlayer(): RtcPlayer
|
||||
}
|
||||
|
||||
/**
|
||||
* 媒体服务器基础实现类
|
||||
*/
|
||||
export abstract class BaseMediaServer implements IMediaServer {
|
||||
type: MediaServerType
|
||||
|
||||
constructor(type: MediaServerType) {
|
||||
this.type = type
|
||||
}
|
||||
|
||||
abstract getVersion(): Promise<VersionInfo>
|
||||
abstract getStreamInfo(): Promise<StreamInfo[]>
|
||||
abstract getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]>
|
||||
abstract createRtcPlayer(): RtcPlayer
|
||||
}
|
||||
22
html/NextGB/src/api/mediaserver/factory.ts
Normal file
22
html/NextGB/src/api/mediaserver/factory.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { MediaServer } from './types'
|
||||
import { MediaServerType } from './types'
|
||||
import type { BaseMediaServer } from './base'
|
||||
import { SRSServer } from './srs/srs'
|
||||
import { ZLMServer } from './zlm/zlm'
|
||||
|
||||
/**
|
||||
* 创建媒体服务器实例的工厂函数
|
||||
*/
|
||||
export const createMediaServer = (config: MediaServer): BaseMediaServer => {
|
||||
// 统一转换为小写进行比较
|
||||
const serverType = config.type.toLowerCase()
|
||||
|
||||
switch (serverType) {
|
||||
case MediaServerType.SRS:
|
||||
return new SRSServer(config.ip, config.port)
|
||||
case MediaServerType.ZLM:
|
||||
return new ZLMServer(config.ip, config.port, config.secret)
|
||||
default:
|
||||
throw new Error(`Unsupported media server type: ${config.type}`)
|
||||
}
|
||||
}
|
||||
364
html/NextGB/src/api/mediaserver/srs/srs.ts
Normal file
364
html/NextGB/src/api/mediaserver/srs/srs.ts
Normal file
@ -0,0 +1,364 @@
|
||||
import type { ClientInfo, StreamInfo, VersionInfo, RtcPlayer } from '@/api/mediaserver/types'
|
||||
import { MediaServerType } from '@/api/mediaserver/types'
|
||||
import { BaseMediaServer } from '@/api/mediaserver/base'
|
||||
import axios from 'axios'
|
||||
|
||||
interface SRSVersionResponse {
|
||||
code: number
|
||||
server: string
|
||||
service: string
|
||||
pid: string
|
||||
data: {
|
||||
major: number
|
||||
minor: number
|
||||
revision: number
|
||||
version: string
|
||||
}
|
||||
}
|
||||
|
||||
interface SRSClientsResponse {
|
||||
code: number
|
||||
server: string
|
||||
service: string
|
||||
pid: string
|
||||
clients: {
|
||||
id: string
|
||||
vhost: string
|
||||
stream: string
|
||||
ip: string
|
||||
pageUrl: string
|
||||
swfUrl: string
|
||||
tcUrl: string
|
||||
url: string
|
||||
name: string
|
||||
type: string
|
||||
publish: boolean
|
||||
alive: number
|
||||
send_bytes: number
|
||||
recv_bytes: number
|
||||
kbps: {
|
||||
recv_30s: number
|
||||
send_30s: number
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
interface SRSStreamResponse {
|
||||
code: number
|
||||
server: string
|
||||
service: string
|
||||
pid: string
|
||||
streams: {
|
||||
id: string
|
||||
name: string
|
||||
vhost: string
|
||||
app: string
|
||||
tcUrl: string
|
||||
url: string
|
||||
live_ms: number
|
||||
clients: number
|
||||
frames: number
|
||||
send_bytes: number
|
||||
recv_bytes: number
|
||||
kbps: {
|
||||
recv_30s: number
|
||||
send_30s: number
|
||||
}
|
||||
publish: {
|
||||
active: boolean
|
||||
cid: string
|
||||
}
|
||||
video?: {
|
||||
codec: string
|
||||
profile: string
|
||||
level: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
audio?: {
|
||||
codec: string
|
||||
sample_rate: number
|
||||
channel: number
|
||||
profile: string
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
interface UserQuery {
|
||||
[key: string]: string | undefined
|
||||
schema?: string
|
||||
play?: string
|
||||
}
|
||||
|
||||
interface ParsedUrl {
|
||||
url: string
|
||||
schema: string
|
||||
server: string
|
||||
port: number
|
||||
vhost: string
|
||||
app: string
|
||||
stream: string
|
||||
user_query: UserQuery
|
||||
}
|
||||
|
||||
|
||||
|
||||
export class SRSServer extends BaseMediaServer {
|
||||
private baseUrl: string
|
||||
|
||||
constructor(host: string, port: number) {
|
||||
super(MediaServerType.SRS)
|
||||
this.baseUrl = `http://${host}:${port}`
|
||||
}
|
||||
|
||||
async getVersion(): Promise<VersionInfo> {
|
||||
try {
|
||||
const response = await axios.get<SRSVersionResponse>(`${this.baseUrl}/api/v1/versions`)
|
||||
|
||||
return {
|
||||
version: response.data.data.version,
|
||||
buildDate: undefined, // SRS API 没有提供构建日期
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get SRS version: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async getStreamInfo(): Promise<StreamInfo[]> {
|
||||
try {
|
||||
const response = await axios.get<SRSStreamResponse>(`${this.baseUrl}/api/v1/streams/`)
|
||||
|
||||
return response.data.streams.map((stream) => ({
|
||||
id: stream.id,
|
||||
name: stream.name,
|
||||
vhost: stream.vhost,
|
||||
url: stream.tcUrl,
|
||||
clients: stream.clients - 1,
|
||||
active: stream.publish.active,
|
||||
send_bytes: stream.send_bytes,
|
||||
recv_bytes: stream.recv_bytes,
|
||||
video: stream.video
|
||||
? {
|
||||
codec: stream.video.codec,
|
||||
width: stream.video.width,
|
||||
height: stream.video.height,
|
||||
fps: 0, // SRS API 没有直接提供 fps 信息
|
||||
}
|
||||
: undefined,
|
||||
audio: stream.audio
|
||||
? {
|
||||
codec: stream.audio.codec,
|
||||
sampleRate: stream.audio.sample_rate,
|
||||
channels: stream.audio.channel,
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get SRS streams info: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]> {
|
||||
try {
|
||||
const response = await axios.get<SRSClientsResponse>(`${this.baseUrl}/api/v1/clients/`)
|
||||
let clients = response.data.clients.filter((client) => !client.publish)
|
||||
|
||||
// 如果指定了 stream_id,则过滤出对应的流
|
||||
if (params?.stream_id) {
|
||||
clients = clients.filter(client => client.stream === params.stream_id)
|
||||
}
|
||||
|
||||
return clients.map((client) => {
|
||||
console.log('Client alive value:', client.alive, typeof client.alive)
|
||||
return {
|
||||
id: client.id,
|
||||
vhost: client.vhost,
|
||||
stream: client.stream,
|
||||
ip: client.ip,
|
||||
url: client.url,
|
||||
alive: Math.round(client.alive * 1000), // 转换为毫秒并四舍五入
|
||||
type: client.type,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get SRS clients info: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
async kickClient(clientId: string) {
|
||||
const response = await axios.post(`${this.baseUrl}/api/v1/clients/${clientId}/kick`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
createRtcPlayer(): RtcPlayer {
|
||||
const self = {
|
||||
pc: new RTCPeerConnection({
|
||||
iceServers: [],
|
||||
}),
|
||||
|
||||
async play(url: string) {
|
||||
const conf = this.__internal.prepareUrl(url)
|
||||
this.pc.addTransceiver('audio', { direction: 'recvonly' })
|
||||
this.pc.addTransceiver('video', { direction: 'recvonly' })
|
||||
|
||||
const offer = await this.pc.createOffer()
|
||||
await this.pc.setLocalDescription(offer)
|
||||
|
||||
const session = await fetch(conf.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api: conf.apiUrl,
|
||||
streamurl: conf.streamUrl,
|
||||
clientip: null,
|
||||
sdp: offer.sdp,
|
||||
}),
|
||||
}).then((res) => res.json())
|
||||
|
||||
if (session.code) {
|
||||
throw session
|
||||
}
|
||||
|
||||
await this.pc.setRemoteDescription(
|
||||
new RTCSessionDescription({ type: 'answer', sdp: session.sdp }),
|
||||
)
|
||||
return session
|
||||
},
|
||||
|
||||
async close() {
|
||||
this.pc.close()
|
||||
},
|
||||
|
||||
ontrack: null as ((event: RTCTrackEvent) => void) | null,
|
||||
|
||||
__internal: {
|
||||
defaultPath: '/rtc/v1/play/',
|
||||
|
||||
prepareUrl(webrtcUrl: string) {
|
||||
const urlObject = this.parse(webrtcUrl) as ParsedUrl
|
||||
const schema = urlObject.user_query.schema
|
||||
? urlObject.user_query.schema + ':'
|
||||
: window.location.protocol
|
||||
|
||||
let port = urlObject.port || 1985
|
||||
if (schema === 'https:') {
|
||||
port = urlObject.port || 443
|
||||
}
|
||||
|
||||
let api = urlObject.user_query.play || this.defaultPath
|
||||
if (api.lastIndexOf('/') !== api.length - 1) {
|
||||
api += '/'
|
||||
}
|
||||
|
||||
let apiUrl = schema + '//' + urlObject.server + ':' + port + api
|
||||
for (const key in urlObject.user_query) {
|
||||
if (key !== 'api' && key !== 'play') {
|
||||
apiUrl += '&' + key + '=' + urlObject.user_query[key]
|
||||
}
|
||||
}
|
||||
apiUrl = apiUrl.replace(api + '&', api + '?')
|
||||
|
||||
return {
|
||||
apiUrl,
|
||||
streamUrl: urlObject.url,
|
||||
schema,
|
||||
urlObject,
|
||||
port,
|
||||
}
|
||||
},
|
||||
|
||||
parse(url: string): ParsedUrl {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
.replace('rtmp://', 'http://')
|
||||
.replace('webrtc://', 'http://')
|
||||
.replace('rtc://', 'http://')
|
||||
|
||||
let vhost = a.hostname
|
||||
let app = a.pathname.substring(1, a.pathname.lastIndexOf('/'))
|
||||
const stream = a.pathname.slice(a.pathname.lastIndexOf('/') + 1)
|
||||
|
||||
app = app.replace('...vhost...', '?vhost=')
|
||||
if (app.indexOf('?') >= 0) {
|
||||
const params = app.slice(app.indexOf('?'))
|
||||
app = app.slice(0, app.indexOf('?'))
|
||||
|
||||
if (params.indexOf('vhost=') > 0) {
|
||||
vhost = params.slice(params.indexOf('vhost=') + 'vhost='.length)
|
||||
if (vhost.indexOf('&') > 0) {
|
||||
vhost = vhost.slice(0, vhost.indexOf('&'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (a.hostname === vhost) {
|
||||
const re = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/
|
||||
if (re.test(a.hostname)) {
|
||||
vhost = '__defaultVhost__'
|
||||
}
|
||||
}
|
||||
|
||||
let schema = 'rtmp'
|
||||
if (url.indexOf('://') > 0) {
|
||||
schema = url.slice(0, url.indexOf('://'))
|
||||
}
|
||||
|
||||
let port = parseInt(a.port)
|
||||
if (!port) {
|
||||
if (schema === 'http') {
|
||||
port = 80
|
||||
} else if (schema === 'https') {
|
||||
port = 443
|
||||
} else if (schema === 'rtmp') {
|
||||
port = 1935
|
||||
}
|
||||
}
|
||||
|
||||
const ret: ParsedUrl = {
|
||||
url,
|
||||
schema,
|
||||
server: a.hostname,
|
||||
port,
|
||||
vhost,
|
||||
app,
|
||||
stream,
|
||||
user_query: {},
|
||||
}
|
||||
|
||||
this.fill_query(a.search, ret)
|
||||
return ret
|
||||
},
|
||||
|
||||
fill_query(query_string: string, obj: ParsedUrl) {
|
||||
if (query_string.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (query_string.indexOf('?') >= 0) {
|
||||
query_string = query_string.split('?')[1]
|
||||
}
|
||||
|
||||
const queries = query_string.split('&')
|
||||
for (const elem of queries) {
|
||||
const query = elem.split('=')
|
||||
obj.user_query[query[0]] = query[1]
|
||||
}
|
||||
|
||||
if (obj.user_query.domain) {
|
||||
obj.vhost = obj.user_query.domain
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
self.pc.ontrack = (event: RTCTrackEvent) => {
|
||||
if (self.ontrack) {
|
||||
self.ontrack(event)
|
||||
}
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
}
|
||||
81
html/NextGB/src/api/mediaserver/types.ts
Normal file
81
html/NextGB/src/api/mediaserver/types.ts
Normal file
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 流媒体服务器类型枚举
|
||||
*/
|
||||
export enum MediaServerType {
|
||||
ZLM = 'zlm', // ZLMediaKit
|
||||
SRS = 'srs', // SRS
|
||||
CUSTOM = 'custom', // 自定义服务器
|
||||
}
|
||||
|
||||
export interface RtcPlayer {
|
||||
pc: RTCPeerConnection
|
||||
play(url: string): Promise<void>
|
||||
close(): Promise<void>
|
||||
ontrack: ((event: RTCTrackEvent) => void) | null
|
||||
}
|
||||
|
||||
export interface MediaServer {
|
||||
id: number
|
||||
name: string
|
||||
ip: string
|
||||
port: number
|
||||
type: string
|
||||
username?: string
|
||||
password?: string
|
||||
secret?: string
|
||||
status: number
|
||||
created_at: string
|
||||
isDefault?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本信息接口
|
||||
*/
|
||||
export interface VersionInfo {
|
||||
version: string
|
||||
buildDate?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频编码信息
|
||||
*/
|
||||
export interface VideoCodecInfo {
|
||||
codec: string // 视频编码格式,如 H264, H265
|
||||
width: number // 视频宽度
|
||||
height: number // 视频高度
|
||||
fps: number // 帧率
|
||||
bitrate?: number // 比特率 (kbps)
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频编码信息
|
||||
*/
|
||||
export interface AudioCodecInfo {
|
||||
codec: string // 音频编码格式,如 AAC, G711
|
||||
sampleRate: number // 采样率
|
||||
channels: number // 声道数
|
||||
bitrate?: number // 比特率 (kbps)
|
||||
}
|
||||
|
||||
export interface StreamInfo {
|
||||
id: string
|
||||
name: string // 流名称
|
||||
vhost: string // 虚拟主机
|
||||
url: string // 流地址
|
||||
clients: number // 客户端连接数
|
||||
active: boolean // 是否活跃
|
||||
video?: VideoCodecInfo // 视频编码信息
|
||||
audio?: AudioCodecInfo // 音频编码信息
|
||||
send_bytes?: number // 已传输字节数
|
||||
recv_bytes?: number // 已接收字节数
|
||||
}
|
||||
|
||||
export interface ClientInfo {
|
||||
id: string
|
||||
vhost: string
|
||||
stream: string
|
||||
ip: string
|
||||
url: string
|
||||
alive: number
|
||||
type: string
|
||||
}
|
||||
285
html/NextGB/src/api/mediaserver/zlm/zlm.ts
Normal file
285
html/NextGB/src/api/mediaserver/zlm/zlm.ts
Normal file
@ -0,0 +1,285 @@
|
||||
import type { ClientInfo, StreamInfo, VersionInfo, MediaServer, RtcPlayer } from '@/api/mediaserver/types'
|
||||
import { MediaServerType } from '@/api/mediaserver/types'
|
||||
import { BaseMediaServer } from '@/api/mediaserver/base'
|
||||
import axios from 'axios'
|
||||
|
||||
// {
|
||||
// "code": 0,
|
||||
// "data": {
|
||||
// "branchName": "master",
|
||||
// "buildTime": "2023-04-19T10:34:34",
|
||||
// "commitHash": "f143898"
|
||||
// }
|
||||
// }
|
||||
interface ZLMVersionResponse {
|
||||
code: number
|
||||
data: {
|
||||
branchName: string
|
||||
buildTime: string
|
||||
commitHash: string
|
||||
}
|
||||
}
|
||||
|
||||
// {
|
||||
// "code" : 0,
|
||||
// "data" : [
|
||||
// {
|
||||
// "app" : "live", # 应用名
|
||||
// "readerCount" : 0, # 本协议观看人数
|
||||
// "totalReaderCount" : 0, # 观看总人数,包括hls/rtsp/rtmp/http-flv/ws-flv
|
||||
// "schema" : "rtsp", # 协议
|
||||
// "stream" : "obs", # 流id
|
||||
// "originSock": { # 客户端和服务器网络信息,可能为null类型
|
||||
// "identifier": "140241931428384",
|
||||
// "local_ip": "127.0.0.1",
|
||||
// "local_port": 1935,
|
||||
// "peer_ip": "127.0.0.1",
|
||||
// "peer_port": 50097
|
||||
// },
|
||||
// "originType": 1, # 产生源类型,包括 unknown = 0,rtmp_push=1,rtsp_push=2,rtp_push=3,pull=4,ffmpeg_pull=5,mp4_vod=6,device_chn=7
|
||||
// "originTypeStr": "MediaOriginType::rtmp_push",
|
||||
// "originUrl": "rtmp://127.0.0.1:1935/live/hks2", #产生源的url
|
||||
// "createStamp": 1602205811, #GMT unix系统时间戳,单位秒
|
||||
// "aliveSecond": 100, #存活时间,单位秒
|
||||
// "bytesSpeed": 12345, #数据产生速度,单位byte/s
|
||||
// "tracks" : [ # 音视频轨道
|
||||
// {
|
||||
// "channels" : 1, # 音频通道数
|
||||
// "codec_id" : 2, # H264 = 0, H265 = 1, AAC = 2, G711A = 3, G711U = 4
|
||||
// "codec_id_name" : "CodecAAC", # 编码类型名称
|
||||
// "codec_type" : 1, # Video = 0, Audio = 1
|
||||
// "ready" : true, # 轨道是否准备就绪
|
||||
// "frames" : 1119, #累计接收帧数
|
||||
// "sample_bit" : 16, # 音频采样位数
|
||||
// "sample_rate" : 8000 # 音频采样率
|
||||
// },
|
||||
// {
|
||||
// "codec_id" : 0, # H264 = 0, H265 = 1, AAC = 2, G711A = 3, G711U = 4
|
||||
// "codec_id_name" : "CodecH264", # 编码类型名称
|
||||
// "codec_type" : 0, # Video = 0, Audio = 1
|
||||
// "fps" : 59, # 视频fps
|
||||
// "frames" : 1119, #累计接收帧数,不包含sei/aud/sps/pps等不能解码的帧
|
||||
// "gop_interval_ms" : 1993, #gop间隔时间,单位毫秒
|
||||
// "gop_size" : 60, #gop大小,单位帧数
|
||||
// "key_frames" : 21, #累计接收关键帧数
|
||||
// "height" : 720, # 视频高
|
||||
// "ready" : true, # 轨道是否准备就绪
|
||||
// "width" : 1280 # 视频宽
|
||||
// }
|
||||
// ],
|
||||
// "vhost" : "__defaultVhost__" # 虚拟主机名
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
|
||||
interface ZLMTrackInfo {
|
||||
channels?: number // 音频通道数
|
||||
codec_id: number // 编码器ID
|
||||
codec_id_name: string // 编码器名称
|
||||
codec_type: number // 编码类型 (0: Video, 1: Audio)
|
||||
ready: boolean // 轨道是否就绪
|
||||
frames: number // 累计接收帧数
|
||||
sample_bit?: number // 音频采样位数
|
||||
sample_rate?: number // 音频采样率
|
||||
// 视频特有属性
|
||||
fps?: number // 视频帧率
|
||||
width?: number // 视频宽度
|
||||
height?: number // 视频高度
|
||||
gop_interval_ms?: number // GOP间隔时间
|
||||
gop_size?: number // GOP大小
|
||||
key_frames?: number // 关键帧数
|
||||
bytesSpeed?: number // 数据速率
|
||||
}
|
||||
|
||||
interface ZLMStreamInfo {
|
||||
app: string
|
||||
readerCount: number
|
||||
totalReaderCount: number
|
||||
schema: string
|
||||
stream: string
|
||||
originSock: {
|
||||
identifier: string
|
||||
local_ip: string
|
||||
local_port: number
|
||||
peer_ip: string
|
||||
peer_port: number
|
||||
}
|
||||
originType: number
|
||||
originTypeStr: string
|
||||
originUrl: string
|
||||
createStamp: number
|
||||
aliveSecond: number
|
||||
bytesSpeed: number
|
||||
tracks: ZLMTrackInfo[]
|
||||
vhost: string
|
||||
}
|
||||
|
||||
interface ZLMStreamResponse {
|
||||
code: number
|
||||
data: ZLMStreamInfo[]
|
||||
}
|
||||
|
||||
// {
|
||||
// "code": 0,
|
||||
// "data": [
|
||||
// {
|
||||
// "identifier": "3-309",
|
||||
// "local_ip": "::",
|
||||
// "local_port": 8000,
|
||||
// "peer_ip": "172.18.190.159",
|
||||
// "peer_port": 52996,
|
||||
// "typeid": "mediakit::WebRtcSession"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
|
||||
interface ZLMClientInfo {
|
||||
identifier: string
|
||||
local_ip: string
|
||||
local_port: number
|
||||
peer_ip: string
|
||||
peer_port: number
|
||||
typeid: string
|
||||
}
|
||||
|
||||
interface ZLMClientInfoResponse {
|
||||
code: number
|
||||
data: ZLMClientInfo[]
|
||||
}
|
||||
|
||||
interface ZLMRtcResponse {
|
||||
code: number
|
||||
id: string
|
||||
sdp: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export class ZLMServer extends BaseMediaServer {
|
||||
private baseUrl: string
|
||||
private secret: string
|
||||
|
||||
constructor(host: string, port: number, secret: string = '') {
|
||||
super(MediaServerType.ZLM)
|
||||
this.baseUrl = `http://${host}:${port}`
|
||||
this.secret = secret
|
||||
}
|
||||
|
||||
async getVersion(): Promise<VersionInfo> {
|
||||
try {
|
||||
const response = await axios.get<ZLMVersionResponse>(`${this.baseUrl}/index/api/version${this.secret ? '?secret=' + this.secret : ''}`)
|
||||
return {
|
||||
version: response.data.data.buildTime,
|
||||
buildDate: response.data.data.buildTime,
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get ZLM version: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// /index/api/getMediaList?schema=rtsp&secret=
|
||||
async getStreamInfo(): Promise<StreamInfo[]> {
|
||||
try {
|
||||
const response = await axios.get<ZLMStreamResponse>(`${this.baseUrl}/index/api/getMediaList?schema=rtsp&secret=${this.secret}`)
|
||||
return response.data.data.map((stream) => {
|
||||
const videoTrack = stream.tracks.find((track) => track.codec_type === 0)
|
||||
const audioTrack = stream.tracks.find((track) => track.codec_type === 1)
|
||||
|
||||
return {
|
||||
id: stream.stream,
|
||||
name: stream.stream,
|
||||
vhost: stream.vhost,
|
||||
url: stream.originUrl,
|
||||
clients: stream.readerCount,
|
||||
active: stream.aliveSecond > 0,
|
||||
send_bytes: 0,
|
||||
recv_bytes: stream.bytesSpeed,
|
||||
video: videoTrack?.codec_type === 0 ? {
|
||||
codec: videoTrack.codec_id_name,
|
||||
width: videoTrack.width ?? 0,
|
||||
height: videoTrack.height ?? 0,
|
||||
fps: videoTrack.fps ?? 0,
|
||||
bitrate: videoTrack.bytesSpeed ?? 0,
|
||||
} : undefined,
|
||||
audio: audioTrack?.codec_type === 1 ? {
|
||||
codec: audioTrack.codec_id_name,
|
||||
sampleRate: audioTrack.sample_rate ?? 0,
|
||||
channels: audioTrack.channels ?? 0,
|
||||
bitrate: audioTrack.bytesSpeed ?? 0,
|
||||
} : undefined,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get ZLM streams info: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// /index/api/getMediaPlayerList?secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc&schema=rtsp&vhost=defaultVhost&app=live&stream=test
|
||||
async getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]> {
|
||||
try {
|
||||
const streamParam = params?.stream_id ? `&stream=${params.stream_id}` : ''
|
||||
const response = await axios.get<ZLMClientInfoResponse>(
|
||||
`${this.baseUrl}/index/api/getMediaPlayerList?secret=${this.secret}&schema=rtsp&vhost=defaultVhost&app=rtp${streamParam}`
|
||||
)
|
||||
|
||||
return response.data.data.map((client) => ({
|
||||
id: client.identifier,
|
||||
vhost: '__defaultVhost__', // ZLM默认虚拟主机
|
||||
stream: 'null',
|
||||
ip: client.peer_ip,
|
||||
url: `${client.local_ip}:${client.local_port}`,
|
||||
alive: 1, // 在线状态
|
||||
type: client.typeid.replace('mediakit::', '') // 移除 mediakit:: 前缀
|
||||
}))
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get ZLM clients info: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
createRtcPlayer(): RtcPlayer {
|
||||
const self = {
|
||||
pc: new RTCPeerConnection({
|
||||
iceServers: [],
|
||||
}),
|
||||
|
||||
async play(url: string): Promise<void> {
|
||||
this.pc.addTransceiver('audio', { direction: 'recvonly' })
|
||||
this.pc.addTransceiver('video', { direction: 'recvonly' })
|
||||
|
||||
const offer = await this.pc.createOffer()
|
||||
await this.pc.setLocalDescription(offer)
|
||||
|
||||
const response = await axios.post<ZLMRtcResponse>(
|
||||
url,
|
||||
offer.sdp,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'text/plain;charset=utf-8'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (response.data.code !== 0) {
|
||||
throw new Error('创建WebRTC播放器失败')
|
||||
}
|
||||
|
||||
await this.pc.setRemoteDescription(
|
||||
new RTCSessionDescription({ type: 'answer', sdp: response.data.sdp })
|
||||
)
|
||||
},
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.pc.close()
|
||||
},
|
||||
|
||||
ontrack: null as ((event: RTCTrackEvent) => void) | null,
|
||||
}
|
||||
|
||||
self.pc.ontrack = (event: RTCTrackEvent) => {
|
||||
if (self.ontrack) {
|
||||
self.ontrack(event)
|
||||
}
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
}
|
||||
110
html/NextGB/src/api/types.ts
Normal file
110
html/NextGB/src/api/types.ts
Normal file
@ -0,0 +1,110 @@
|
||||
// API 响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface InviteRequest {
|
||||
media_server_id: number
|
||||
device_id: string
|
||||
channel_id: string
|
||||
sub_stream: number
|
||||
play_type: number
|
||||
start_time: number
|
||||
end_time: number
|
||||
}
|
||||
|
||||
export interface InviteResponse {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ByeRequest {
|
||||
device_id: string
|
||||
channel_id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface PauseRequest {
|
||||
device_id: string
|
||||
channel_id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface ResumeRequest {
|
||||
device_id: string
|
||||
channel_id: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface SpeedRequest {
|
||||
device_id: string
|
||||
channel_id: string
|
||||
url: string
|
||||
speed: number
|
||||
}
|
||||
|
||||
// 通道状态类型
|
||||
export type ChannelStatus = 'ON' | 'OFF'
|
||||
|
||||
// 通道信息类型
|
||||
export interface ChannelInfo {
|
||||
device_id: string
|
||||
parent_id: string
|
||||
name: string
|
||||
manufacturer: string
|
||||
model: string
|
||||
owner: string
|
||||
civil_code: string
|
||||
address: string
|
||||
port: number
|
||||
parental: number
|
||||
safety_way: number
|
||||
register_way: number
|
||||
secrecy: number
|
||||
ip_address: string
|
||||
status: ChannelStatus
|
||||
longitude: number
|
||||
latitude: number
|
||||
info: {
|
||||
ptz_type: number
|
||||
resolution: string
|
||||
download_speed: string
|
||||
}
|
||||
ssrc: string
|
||||
}
|
||||
|
||||
export interface RecordInfoRequest {
|
||||
device_id: string
|
||||
channel_id: string
|
||||
start_time: number
|
||||
end_time: number
|
||||
}
|
||||
|
||||
export interface RecordInfoResponse {
|
||||
device_id: string
|
||||
name: string
|
||||
file_path: string
|
||||
address: string
|
||||
start_time: string
|
||||
end_time: string
|
||||
secrecy: number
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
device_id: string
|
||||
source_addr: string
|
||||
network_type: string
|
||||
status: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface PTZControlRequest {
|
||||
device_id: string
|
||||
channel_id: string
|
||||
ptz: string
|
||||
speed: string
|
||||
}
|
||||
|
||||
// 媒体服务器类型
|
||||
|
||||
86
html/NextGB/src/assets/base.css
Normal file
86
html/NextGB/src/assets/base.css
Normal file
@ -0,0 +1,86 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
1
html/NextGB/src/assets/logo.svg
Normal file
1
html/NextGB/src/assets/logo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
31
html/NextGB/src/assets/main.css
Normal file
31
html/NextGB/src/assets/main.css
Normal file
@ -0,0 +1,31 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
padding: 0 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
padding: 0 0;
|
||||
}
|
||||
}
|
||||
BIN
html/NextGB/src/assets/srs-logo.ico
Normal file
BIN
html/NextGB/src/assets/srs-logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
html/NextGB/src/assets/zlm-logo.png
Normal file
BIN
html/NextGB/src/assets/zlm-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
232
html/NextGB/src/components/common/DateTimeRangePanel.vue
Normal file
232
html/NextGB/src/components/common/DateTimeRangePanel.vue
Normal file
@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ArrowRight, VideoCamera, Search } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: [{ start: string; end: string }]
|
||||
}>()
|
||||
|
||||
const isCollapsed = ref(false)
|
||||
const startDateTime = ref('')
|
||||
const endDateTime = ref('')
|
||||
|
||||
const formatDateTime = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
const handleShortcut = (type: string) => {
|
||||
const now = new Date()
|
||||
const start = new Date()
|
||||
|
||||
switch (type) {
|
||||
case 'today': {
|
||||
start.setHours(0, 0, 0, 0)
|
||||
break
|
||||
}
|
||||
case 'yesterday': {
|
||||
start.setDate(start.getDate() - 1)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
now.setDate(now.getDate() - 1)
|
||||
break
|
||||
}
|
||||
case 'lastWeek': {
|
||||
start.setDate(start.getDate() - 7)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
startDateTime.value = formatDateTime(start)
|
||||
now.setHours(23, 59, 59)
|
||||
endDateTime.value = formatDateTime(now)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!startDateTime.value || !endDateTime.value) return
|
||||
emit('search', {
|
||||
start: startDateTime.value,
|
||||
end: endDateTime.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="datetime-range-panel" :class="{ collapsed: isCollapsed }">
|
||||
<div class="panel-header" @click="isCollapsed = !isCollapsed">
|
||||
<div class="header-title">
|
||||
<el-icon class="collapse-arrow" :class="{ collapsed: isCollapsed }">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
<el-icon class="title-icon"><VideoCamera /></el-icon>
|
||||
<span>{{ title || '时间范围' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="search-form">
|
||||
<div class="form-item calendar-wrapper">
|
||||
<div class="datetime-range">
|
||||
<div class="datetime-item">
|
||||
<div class="datetime-label">开始时间:</div>
|
||||
<el-date-picker
|
||||
v-model="startDateTime"
|
||||
type="datetime"
|
||||
:editable="false"
|
||||
placeholder="开始时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
<div class="datetime-item">
|
||||
<div class="datetime-label">结束时间:</div>
|
||||
<el-date-picker
|
||||
v-model="endDateTime"
|
||||
type="datetime"
|
||||
:editable="false"
|
||||
placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shortcuts">
|
||||
<el-button text size="small" @click="handleShortcut('today')"> 今天 </el-button>
|
||||
<el-button text size="small" @click="handleShortcut('yesterday')"> 昨天 </el-button>
|
||||
<el-button text size="small" @click="handleShortcut('lastWeek')"> 最近一周 </el-button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="!isCollapsed">
|
||||
<el-button
|
||||
type="primary"
|
||||
:disabled="!startDateTime || !endDateTime"
|
||||
@click="handleSearch"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon><Search /></el-icon>
|
||||
查询
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.datetime-range-panel {
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.collapse-arrow {
|
||||
font-size: 12px;
|
||||
transition: transform 0.2s ease;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
&.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 14px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.datetime-range-panel.collapsed .panel-content {
|
||||
height: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.datetime-range {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.datetime-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.datetime-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 0 4px;
|
||||
|
||||
:deep(.el-button) {
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
|
||||
&.is-disabled {
|
||||
color: var(--el-text-color-disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-wrapper {
|
||||
:deep(.el-input__wrapper) {
|
||||
padding: 0 8px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-date-editor) {
|
||||
--el-date-editor-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.search-form {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-picker-panel) {
|
||||
--el-datepicker-border-color: var(--el-border-color-lighter);
|
||||
}
|
||||
</style>
|
||||
133
html/NextGB/src/components/common/SearchBox.vue
Normal file
133
html/NextGB/src/components/common/SearchBox.vue
Normal file
@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Search, Refresh, Expand, List } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
interface Props {
|
||||
loading?: boolean
|
||||
showViewModeSwitch?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
showViewModeSwitch: false,
|
||||
})
|
||||
|
||||
const searchQuery = ref('')
|
||||
const viewMode = ref<'tree' | 'list'>('tree')
|
||||
const tooltipRef = ref()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:searchQuery', value: string): void
|
||||
(e: 'update:viewMode', value: 'tree' | 'list'): void
|
||||
(e: 'refresh'): void
|
||||
}>()
|
||||
|
||||
const handleSearchInput = (value: string) => {
|
||||
searchQuery.value = value
|
||||
emit('update:searchQuery', value)
|
||||
}
|
||||
|
||||
const handleViewModeChange = () => {
|
||||
viewMode.value = viewMode.value === 'tree' ? 'list' : 'tree'
|
||||
emit('update:viewMode', viewMode.value)
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
emit('refresh')
|
||||
tooltipRef.value?.hide()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="search-box">
|
||||
<div class="search-wrapper">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索设备或通道..."
|
||||
clearable
|
||||
size="small"
|
||||
@input="handleSearchInput"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="action-buttons">
|
||||
<el-tooltip
|
||||
v-if="showViewModeSwitch"
|
||||
:content="viewMode === 'tree' ? '切换到列表视图' : '切换到树形视图'"
|
||||
placement="top"
|
||||
>
|
||||
<el-button
|
||||
class="action-btn"
|
||||
:icon="viewMode === 'list' ? Expand : List"
|
||||
size="small"
|
||||
@click="handleViewModeChange"
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip ref="tooltipRef" content="刷新设备列表" placement="top">
|
||||
<el-button
|
||||
class="action-btn refresh-btn"
|
||||
:icon="Refresh"
|
||||
size="small"
|
||||
:loading="loading"
|
||||
@click="handleRefresh"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-box {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-left: auto;
|
||||
|
||||
.action-btn {
|
||||
color: var(--el-text-color-regular);
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
padding: 5px 8px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:deep(.el-icon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&.refresh-btn {
|
||||
color: var(--el-color-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
766
html/NextGB/src/components/monitor/MonitorGrid.vue
Normal file
766
html/NextGB/src/components/monitor/MonitorGrid.vue
Normal file
@ -0,0 +1,766 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount, onMounted, onActivated, onDeactivated } from 'vue'
|
||||
import {
|
||||
VideoCamera,
|
||||
Close,
|
||||
Camera,
|
||||
FullScreen,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type * as Types from '@/api/types'
|
||||
import { deviceApi } from '@/api'
|
||||
import { createMediaServer } from '@/api/mediaserver/factory'
|
||||
import { useDefaultMediaServer } from '@/stores/mediaServer'
|
||||
import type { LayoutConfig } from '@/types/layout'
|
||||
import { MediaServerType } from '@/api/mediaserver/types'
|
||||
|
||||
interface DeviceWithChannel extends Types.Device {
|
||||
channelInfo?: Types.ChannelInfo
|
||||
player?: any
|
||||
error?: boolean
|
||||
id?: string
|
||||
channel?: Types.ChannelInfo
|
||||
isMuted?: boolean
|
||||
url?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
defaultMuted?: boolean
|
||||
layouts: Record<string, LayoutConfig>
|
||||
showBorder?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'window-select': [data: { deviceId: string; channelId: string } | null]
|
||||
}>()
|
||||
|
||||
const currentLayout = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const selectedDevices = ref<(DeviceWithChannel | null)[]>([])
|
||||
const isFullscreen = ref(false)
|
||||
|
||||
// 使用共享的默认媒体服务器
|
||||
const defaultMediaServer = useDefaultMediaServer()
|
||||
|
||||
// 计算属性
|
||||
const gridStyle = computed(() => {
|
||||
const layout = props.layouts[currentLayout.value]
|
||||
return {
|
||||
gridTemplateColumns: `repeat(${layout.cols}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${layout.rows}, 1fr)`,
|
||||
}
|
||||
})
|
||||
|
||||
const maxDevices = computed(() => props.layouts[currentLayout.value].size)
|
||||
|
||||
// 视频流控制
|
||||
const startWebRTCPlay = async (url: string, index: number, device: DeviceWithChannel) => {
|
||||
if (!defaultMediaServer?.value) {
|
||||
throw new Error('未找到可用的媒体服务器')
|
||||
}
|
||||
|
||||
// 目前只有SRS支持WebRTC播放
|
||||
const serverType = defaultMediaServer.value.type.toLowerCase()
|
||||
console.log('当前媒体服务器类型:', serverType)
|
||||
|
||||
const mediaServer = createMediaServer(defaultMediaServer.value)
|
||||
const player = (mediaServer as any).createRtcPlayer()
|
||||
device.player = player
|
||||
|
||||
player.ontrack = (event: RTCTrackEvent) => {
|
||||
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||
if (videoElement && event.streams?.[0]) {
|
||||
videoElement.srcObject = event.streams[0]
|
||||
}
|
||||
}
|
||||
|
||||
await player.play(url)
|
||||
}
|
||||
|
||||
const startStream = async (
|
||||
device: DeviceWithChannel,
|
||||
index: number,
|
||||
play_type: number = 0,
|
||||
start_time: number = 0,
|
||||
end_time: number = 0,
|
||||
) => {
|
||||
try {
|
||||
device.error = false
|
||||
|
||||
if (!defaultMediaServer?.value) {
|
||||
throw new Error('未找到可用的媒体服务器,请先在流媒体服务页面设置默认服务器')
|
||||
}
|
||||
|
||||
if (defaultMediaServer.value.status === 0) {
|
||||
throw new Error('默认流媒体服务器不在线,请检查服务器状态')
|
||||
}
|
||||
|
||||
const response = await deviceApi.invite({
|
||||
media_server_id: defaultMediaServer.value.id,
|
||||
device_id: device.channel!.parent_id,
|
||||
channel_id: device.channel!.device_id,
|
||||
sub_stream: 0,
|
||||
play_type,
|
||||
start_time,
|
||||
end_time,
|
||||
})
|
||||
|
||||
const streamData = response.data as unknown as Types.InviteResponse
|
||||
if (!streamData?.url) {
|
||||
throw new Error('播放地址不存在')
|
||||
}
|
||||
|
||||
device.url = streamData.url
|
||||
|
||||
await startWebRTCPlay(streamData.url, index, device)
|
||||
} catch (error) {
|
||||
console.error('启动播放失败:', error)
|
||||
device.error = true
|
||||
ElMessage.error(error instanceof Error ? error.message : '启动播放失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 设备管理
|
||||
const cleanupDevice = async (device: DeviceWithChannel) => {
|
||||
if (device.player) {
|
||||
try {
|
||||
await device.player.close()
|
||||
device.player = null
|
||||
|
||||
if (!device.url) return
|
||||
|
||||
await deviceApi.bye({
|
||||
device_id: device.device_id,
|
||||
channel_id: device.channel!.device_id,
|
||||
url: device.url!,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('关闭播放器失败:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const play = async (
|
||||
device: Types.Device & {
|
||||
channel: Types.ChannelInfo
|
||||
play_type?: number
|
||||
start_time?: number
|
||||
end_time?: number
|
||||
},
|
||||
) => {
|
||||
let index = selectedDevices.value.findIndex((d) => d === null)
|
||||
if (index === -1) {
|
||||
if (selectedDevices.value.length >= maxDevices.value) {
|
||||
ElMessage.warning('已达到最大分屏数量')
|
||||
return
|
||||
}
|
||||
index = selectedDevices.value.length
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceWithChannel: DeviceWithChannel = {
|
||||
...device,
|
||||
channelInfo: device.channel,
|
||||
channel: device.channel,
|
||||
error: false,
|
||||
isMuted: props.defaultMuted,
|
||||
url: '',
|
||||
}
|
||||
|
||||
while (selectedDevices.value.length <= index) {
|
||||
selectedDevices.value.push(null)
|
||||
}
|
||||
|
||||
selectedDevices.value[index] = deviceWithChannel
|
||||
await startStream(
|
||||
deviceWithChannel,
|
||||
index,
|
||||
device.play_type,
|
||||
device.start_time,
|
||||
device.end_time,
|
||||
)
|
||||
|
||||
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||
if (videoElement) {
|
||||
videoElement.muted = props.defaultMuted ?? true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加设备失败:', error)
|
||||
ElMessage.error('添加设备失败')
|
||||
}
|
||||
}
|
||||
|
||||
const pause = async (index: number) => {
|
||||
const device = selectedDevices.value[index]
|
||||
if (!device) return
|
||||
|
||||
await deviceApi.pause({
|
||||
device_id: device.device_id,
|
||||
channel_id: device.channel!.device_id,
|
||||
url: device.url!,
|
||||
})
|
||||
}
|
||||
|
||||
const resume = async (index: number) => {
|
||||
const device = selectedDevices.value[index]
|
||||
if (!device) return
|
||||
|
||||
await deviceApi.resume({
|
||||
device_id: device.device_id,
|
||||
channel_id: device.channel!.device_id,
|
||||
url: device.url!,
|
||||
})
|
||||
}
|
||||
|
||||
const speed = async (index: number, speed: number) => {
|
||||
const device = selectedDevices.value[index]
|
||||
if (!device) return
|
||||
|
||||
await deviceApi.speed({
|
||||
device_id: device.device_id,
|
||||
channel_id: device.channel!.device_id,
|
||||
url: device.url!,
|
||||
speed,
|
||||
})
|
||||
}
|
||||
|
||||
const stop = async (index: number) => {
|
||||
const device = selectedDevices.value[index]
|
||||
if (!device) return
|
||||
|
||||
await cleanupDevice(device)
|
||||
|
||||
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||
if (videoElement?.srcObject) {
|
||||
const stream = videoElement.srcObject as MediaStream
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
videoElement.srcObject = null
|
||||
}
|
||||
|
||||
// 将位置设为 null 而不是删除
|
||||
selectedDevices.value[index] = null
|
||||
}
|
||||
|
||||
// 视频控制
|
||||
const handleVideoDoubleClick = (index: number) => {
|
||||
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||
if (!videoElement) return
|
||||
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
videoElement.requestFullscreen()
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('视频全屏切换失败:', err)
|
||||
ElMessage.error('全屏切换失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleVideoError = (index: number, event: Event) => {
|
||||
console.error('视频播放错误:', event)
|
||||
const device = selectedDevices.value[index]
|
||||
if (device) {
|
||||
device.error = true
|
||||
}
|
||||
}
|
||||
|
||||
const capture = async (index: number) => {
|
||||
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||
if (!videoElement) {
|
||||
ElMessage.error('未找到视频元素')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = videoElement.videoWidth
|
||||
canvas.height = videoElement.videoHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
throw new Error('无法创建canvas上下文')
|
||||
}
|
||||
|
||||
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height)
|
||||
|
||||
const device = selectedDevices.value[index]
|
||||
if (!device) {
|
||||
throw new Error('设备不存在')
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const filename = `${device.name || 'capture'}-${timestamp}.png`
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.download = filename
|
||||
link.href = canvas.toDataURL('image/png')
|
||||
link.click()
|
||||
|
||||
ElMessage.success('抓图成功')
|
||||
} catch (err) {
|
||||
console.error('抓图失败:', err)
|
||||
ElMessage.error('抓图失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 清空所有设备
|
||||
const clear = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要清空所有设备吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
for (let i = 0; i < selectedDevices.value.length; i++) {
|
||||
if (selectedDevices.value[i]) {
|
||||
await stop(i)
|
||||
}
|
||||
}
|
||||
|
||||
// 用 null 填充数组而不是清空
|
||||
selectedDevices.value = new Array(props.layouts[currentLayout.value].size).fill(null)
|
||||
|
||||
ElMessage.success('已清空所有设备')
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
console.error('清空设备失败:', err)
|
||||
ElMessage.error('清空设备失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 布局切换处理
|
||||
watch(currentLayout, async (newLayout, oldLayout) => {
|
||||
const maxSize = props.layouts[newLayout].size
|
||||
const activeDevices = selectedDevices.value.filter((d) => d !== null).length
|
||||
|
||||
if (activeDevices > maxSize) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`切换布局将移除${activeDevices - maxSize}个设备,是否继续?`,
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
},
|
||||
)
|
||||
|
||||
// 从后往前移除超出设备
|
||||
for (let i = selectedDevices.value.length - 1; i >= 0; i--) {
|
||||
if (selectedDevices.value[i] && i >= maxSize) {
|
||||
await stop(i)
|
||||
}
|
||||
}
|
||||
|
||||
// 调整数组大小
|
||||
selectedDevices.value.length = maxSize
|
||||
|
||||
ElMessage.success('布局切换成功')
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') {
|
||||
console.error('布局切换失败:', err)
|
||||
ElMessage.error('布局切换失败')
|
||||
}
|
||||
currentLayout.value = oldLayout
|
||||
}
|
||||
} else {
|
||||
// 如果设备数量不超过新布局,只需调整数组大小
|
||||
if (selectedDevices.value.length > maxSize) {
|
||||
selectedDevices.value.length = maxSize
|
||||
} else {
|
||||
while (selectedDevices.value.length < maxSize) {
|
||||
selectedDevices.value.push(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
// 确保有初始网格
|
||||
if (selectedDevices.value.length === 0) {
|
||||
selectedDevices.value = new Array(props.layouts[currentLayout.value].size).fill(null)
|
||||
}
|
||||
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
isFullscreen.value = !!document.fullscreenElement
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// 只在组件真正销毁时清理资源
|
||||
if (!(document as any)._vue_app_is_switching_route) {
|
||||
selectedDevices.value.forEach((device) => {
|
||||
if (device?.player) {
|
||||
try {
|
||||
device.player.close()
|
||||
} catch (err) {
|
||||
console.error('关闭播放器失败:', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
play,
|
||||
pause,
|
||||
resume,
|
||||
speed,
|
||||
clear,
|
||||
stop,
|
||||
})
|
||||
|
||||
const toggleMute = (index: number) => {
|
||||
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||
if (!videoElement) return
|
||||
|
||||
const device = selectedDevices.value[index]
|
||||
if (!device) return
|
||||
|
||||
device.isMuted = !device.isMuted
|
||||
videoElement.muted = device.isMuted
|
||||
}
|
||||
|
||||
const activeIndex = ref<number | null>(null)
|
||||
|
||||
const handleVideoClick = (index: number) => {
|
||||
const device = selectedDevices.value[index]
|
||||
activeIndex.value = index
|
||||
|
||||
if (device && device.channel) {
|
||||
emit('window-select', {
|
||||
deviceId: device.device_id,
|
||||
channelId: device.channel.device_id,
|
||||
})
|
||||
} else {
|
||||
emit('window-select', null)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加激活/停用处理
|
||||
onActivated(() => {
|
||||
console.log('MonitorGrid activated')
|
||||
// 检查并恢复所有视频播放
|
||||
selectedDevices.value.forEach((device, index) => {
|
||||
if (device) {
|
||||
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||
if (videoElement && videoElement.paused) {
|
||||
videoElement.play().catch((err) => {
|
||||
console.error('恢复视频播放失败:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
console.log('MonitorGrid deactivated')
|
||||
// 可以选择暂停视频播放,但不销毁资源
|
||||
selectedDevices.value.forEach((device, index) => {
|
||||
if (device) {
|
||||
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||
if (videoElement && !videoElement.paused) {
|
||||
// 可以选择是否暂停视频
|
||||
// videoElement.pause()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="monitor-grid">
|
||||
<div class="grid-container" :style="gridStyle">
|
||||
<div
|
||||
v-for="(device, index) in selectedDevices"
|
||||
:key="index"
|
||||
class="grid-item"
|
||||
:class="{
|
||||
active: index === activeIndex,
|
||||
'with-border': props.showBorder,
|
||||
}"
|
||||
@click="handleVideoClick(index)"
|
||||
>
|
||||
<template v-if="device !== null">
|
||||
<video
|
||||
:id="`video-player-${index}`"
|
||||
class="video-player"
|
||||
autoplay
|
||||
:muted="device.isMuted ?? true"
|
||||
@dblclick="handleVideoDoubleClick(index)"
|
||||
@error="handleVideoError(index, $event)"
|
||||
/>
|
||||
<div class="video-overlay">
|
||||
<div class="device-info">{{ device.name }} - {{ device.channel?.name ?? '' }}</div>
|
||||
<div class="video-controls">
|
||||
<div class="control-bar">
|
||||
<el-button
|
||||
class="control-btn"
|
||||
@click.stop="toggleMute(index)"
|
||||
:title="device.isMuted ? '取消静音' : '静音'"
|
||||
>
|
||||
<el-icon>
|
||||
<component :is="device.isMuted ? 'Mute' : 'Microphone'" />
|
||||
</el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
class="control-btn"
|
||||
@click.stop="capture(index)"
|
||||
:title="'抓图'"
|
||||
:disabled="device.error"
|
||||
>
|
||||
<el-icon><Camera /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
class="control-btn"
|
||||
@click.stop="handleVideoDoubleClick(index)"
|
||||
:title="'全屏'"
|
||||
>
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
class="control-btn is-danger"
|
||||
@click.stop="stop(index)"
|
||||
:title="'关闭'"
|
||||
>
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty-slot">
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.monitor-grid {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
.grid-container {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 0px;
|
||||
padding: 0px;
|
||||
height: 100%;
|
||||
background: var(--el-bg-color-page);
|
||||
border-radius: 4px;
|
||||
|
||||
&.is-fullscreen {
|
||||
padding: 16px;
|
||||
background: #000;
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
position: relative;
|
||||
background-color: var(--el-fill-color-darker);
|
||||
transition: border-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.with-border {
|
||||
border: 2px solid transparent;
|
||||
|
||||
&.active {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.video-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, transparent 30%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.grid-item:hover .video-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.device-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 2px;
|
||||
border-radius: 0 0 0 4px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
color: #fff !important;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1) !important;
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
|
||||
&.is-danger:hover {
|
||||
color: var(--el-color-danger) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 根据布局调整按钮大小 */
|
||||
.grid-container[style*='repeat(1, 1fr)'] .control-btn {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.grid-container[style*='repeat(2, 1fr)'] .control-btn {
|
||||
width: 28px !important;
|
||||
height: 28px !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.grid-container[style*='repeat(3, 1fr)'] .control-btn {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.grid-container[style*='repeat(4, 1fr)'] .control-btn {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
font-size: 8px !important;
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
.el-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-slot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1a1a1a;
|
||||
|
||||
.el-icon {
|
||||
font-size: 24px;
|
||||
opacity: 0.5;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #242424;
|
||||
|
||||
.el-icon {
|
||||
opacity: 0.8;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.monitor-grid {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
.grid-toolbar {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
:deep(.el-button-group .el-button--small) {
|
||||
padding: 5px 11px;
|
||||
}
|
||||
|
||||
:deep(.el-radio-group .el-radio-button__inner) {
|
||||
padding: 5px 15px;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.video-container:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
:deep(.el-radio-group) {
|
||||
--el-button-bg-color: var(--el-fill-color-blank);
|
||||
--el-button-hover-bg-color: var(--el-fill-color);
|
||||
--el-button-active-bg-color: var(--el-color-primary);
|
||||
--el-button-text-color: var(--el-text-color-regular);
|
||||
--el-button-hover-text-color: var(--el-text-color-primary);
|
||||
--el-button-active-text-color: #fff;
|
||||
--el-button-border-color: var(--el-border-color);
|
||||
--el-button-hover-border-color: var(--el-border-color-hover);
|
||||
}
|
||||
</style>
|
||||
10
html/NextGB/src/env.d.ts
vendored
Normal file
10
html/NextGB/src/env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly VITE_APP_API_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
23
html/NextGB/src/main.ts
Normal file
23
html/NextGB/src/main.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import * as ElementPlusIcons from '@element-plus/icons-vue'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册所有图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIcons)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(ElementPlus)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
49
html/NextGB/src/router/index.ts
Normal file
49
html/NextGB/src/router/index.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import RealplayView from '@/views/realplay/RealplayView.vue'
|
||||
import SettingsView from '@/views/setting/SettingsView.vue'
|
||||
import PlaybackView from '@/views/playback/PlaybackView.vue'
|
||||
import MediaServerView from '@/views/mediaserver/MediaServerView.vue'
|
||||
import DashboardView from '@/views/DashboardView.vue'
|
||||
import DeviceListView from '@/views/DeviceListView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard',
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
component: DashboardView,
|
||||
},
|
||||
{
|
||||
path: '/realplay',
|
||||
name: 'realplay',
|
||||
component: RealplayView,
|
||||
},
|
||||
{
|
||||
path: '/devices',
|
||||
name: 'devices',
|
||||
component: DeviceListView,
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: SettingsView,
|
||||
},
|
||||
{
|
||||
path: '/playback',
|
||||
name: 'playback',
|
||||
component: PlaybackView,
|
||||
},
|
||||
{
|
||||
path: '/media',
|
||||
name: 'media',
|
||||
component: MediaServerView,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
99
html/NextGB/src/stores/devices.ts
Normal file
99
html/NextGB/src/stores/devices.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { ref } from 'vue'
|
||||
import type { Device, ChannelInfo } from '@/api/types'
|
||||
import { deviceApi } from '@/api'
|
||||
|
||||
// 设备列表
|
||||
const devices = ref<Device[]>([])
|
||||
// 通道列表
|
||||
const channels = ref<ChannelInfo[]>([])
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
const formatDeviceData = (device: any): Device => {
|
||||
return {
|
||||
device_id: device.device_id,
|
||||
source_addr: device.source_addr,
|
||||
network_type: device.network_type,
|
||||
status: 1,
|
||||
name: device.device_id,
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设备和通道列表
|
||||
export const fetchDevicesAndChannels = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
// 获取设备列表
|
||||
const response = await deviceApi.getDevices()
|
||||
const deviceList = Array.isArray(response.data) ? response.data : []
|
||||
devices.value = deviceList.map(formatDeviceData)
|
||||
|
||||
// 获取所有设备的通道
|
||||
const allChannels: ChannelInfo[] = []
|
||||
for (const device of devices.value) {
|
||||
try {
|
||||
const response = await deviceApi.getDeviceChannels(device.device_id)
|
||||
if (Array.isArray(response.data)) {
|
||||
// 确保每个通道都有正确的设备ID和其他必要属性
|
||||
const deviceChannels = response.data.map((channel: any) => ({
|
||||
...channel,
|
||||
device_id: channel.device_id,
|
||||
status: channel.status || 'OFF',
|
||||
name: channel.name || '未命名',
|
||||
parent_id: device.device_id,
|
||||
info: {
|
||||
...channel.info,
|
||||
ptz_type: channel.info?.ptz_type || 0,
|
||||
resolution: channel.info?.resolution || '',
|
||||
download_speed: channel.info?.download_speed || '',
|
||||
},
|
||||
}))
|
||||
allChannels.push(...deviceChannels)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`获取设备 ${device.device_id} 的通道失败:`, error)
|
||||
}
|
||||
}
|
||||
channels.value = allChannels
|
||||
} catch (error) {
|
||||
console.error('获取设备列表失败:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设备列表
|
||||
export const useDevices = () => devices
|
||||
|
||||
// 获取通道列表
|
||||
export const useChannels = () => channels
|
||||
|
||||
// 获取加载状态
|
||||
export const useDevicesLoading = () => loading
|
||||
|
||||
// 更新设备列表
|
||||
export const updateDevices = (newDevices: Device[]) => {
|
||||
devices.value = newDevices
|
||||
}
|
||||
|
||||
// 更新通道列表
|
||||
export const updateChannels = (newChannels: ChannelInfo[]) => {
|
||||
channels.value = newChannels
|
||||
}
|
||||
|
||||
// 根据设备ID获取设备
|
||||
export const getDeviceById = (deviceId: string) => {
|
||||
return devices.value.find((device) => device.device_id === deviceId)
|
||||
}
|
||||
|
||||
// 根据设备ID获取该设备的所有通道
|
||||
export const getChannelsByDeviceId = (deviceId: string) => {
|
||||
return channels.value.filter((channel) => channel.device_id === deviceId)
|
||||
}
|
||||
|
||||
// 清空数据
|
||||
export const clearDevicesStore = () => {
|
||||
devices.value = []
|
||||
channels.value = []
|
||||
}
|
||||
100
html/NextGB/src/stores/mediaServer.ts
Normal file
100
html/NextGB/src/stores/mediaServer.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { ref } from 'vue'
|
||||
import type { MediaServer } from '@/api/mediaserver/types'
|
||||
import { mediaServerApi } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { createMediaServer } from '@/api/mediaserver/factory'
|
||||
|
||||
// 所有媒体服务器列表
|
||||
const mediaServers = ref<MediaServer[]>([])
|
||||
// 默认媒体服务器
|
||||
const defaultMediaServer = ref<MediaServer | null>(null)
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 检查服务器状态
|
||||
export const checkServersStatus = async () => {
|
||||
for (const server of mediaServers.value) {
|
||||
try {
|
||||
const mediaServer = createMediaServer(server)
|
||||
await mediaServer.getVersion()
|
||||
server.status = 1 // 在线
|
||||
} catch (error) {
|
||||
console.error(`检查服务器 ${server.name} 状态失败:`, error)
|
||||
server.status = 0 // 离线
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取媒体服务器列表
|
||||
export const fetchMediaServers = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await mediaServerApi.getMediaServers()
|
||||
// 确保 mediaServers 始终是数组,并将 is_default 映射为 isDefault
|
||||
mediaServers.value = Array.isArray(response.data)
|
||||
? response.data.map((server: any) => ({
|
||||
...server,
|
||||
isDefault: server.is_default,
|
||||
}))
|
||||
: []
|
||||
|
||||
if (mediaServers.value.length > 0) {
|
||||
await checkServersStatus()
|
||||
// 找到默认服务器并更新 defaultMediaServer
|
||||
const defaultServer = mediaServers.value.find((server: MediaServer) => server.isDefault === 1)
|
||||
if (defaultServer) {
|
||||
defaultMediaServer.value = defaultServer
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取媒体服务器列表失败:', error)
|
||||
mediaServers.value = [] // 出错时也清空列表
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 设置默认媒体服务器
|
||||
export const setDefaultMediaServer = async (server: MediaServer) => {
|
||||
try {
|
||||
// 调用后端API设置默认服务器
|
||||
await mediaServerApi.setDefaultMediaServer(server.id)
|
||||
|
||||
// 更新前端状态
|
||||
mediaServers.value.forEach((s: MediaServer) => {
|
||||
s.isDefault = s.id === server.id ? 1 : 0
|
||||
})
|
||||
|
||||
// 更新默认服务器引用
|
||||
defaultMediaServer.value = mediaServers.value.find((s: MediaServer) => s.id === server.id) || null
|
||||
|
||||
ElMessage.success('已设为默认节点')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '设置默认节点失败')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 删除媒体服务器
|
||||
export const deleteMediaServer = async (server: MediaServer) => {
|
||||
try {
|
||||
// 如果要删除的是默认服务器,先清除默认服务器状态
|
||||
if (server.isDefault) {
|
||||
defaultMediaServer.value = null
|
||||
}
|
||||
|
||||
await mediaServerApi.deleteMediaServer(server.id)
|
||||
ElMessage.success('删除成功')
|
||||
await fetchMediaServers()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有媒体服务器列表
|
||||
export const useMediaServers = () => mediaServers
|
||||
// 获取默认媒体服务器
|
||||
export const useDefaultMediaServer = () => defaultMediaServer
|
||||
// 获取加载状态
|
||||
export const useMediaServersLoading = () => loading
|
||||
7
html/NextGB/src/types/global.d.ts
vendored
Normal file
7
html/NextGB/src/types/global.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
declare global {
|
||||
interface Document {
|
||||
_vue_app_is_switching_route?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
6
html/NextGB/src/types/layout.ts
Normal file
6
html/NextGB/src/types/layout.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface LayoutConfig {
|
||||
cols: number
|
||||
rows: number
|
||||
size: number
|
||||
label: string
|
||||
}
|
||||
208
html/NextGB/src/views/DashboardView.vue
Normal file
208
html/NextGB/src/views/DashboardView.vue
Normal file
@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useMediaServers } from '@/stores/mediaServer'
|
||||
import { useDevices } from '@/stores/devices'
|
||||
import type { MediaServer } from '@/api/mediaserver/types'
|
||||
import type { Device } from '@/api/types'
|
||||
import { createMediaServer } from '@/api/mediaserver/factory'
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const mediaServers = useMediaServers()
|
||||
const devices = useDevices()
|
||||
|
||||
const onlineServerCount = computed(
|
||||
() => mediaServers.value.filter((server: MediaServer) => server.status === 1).length,
|
||||
)
|
||||
const totalServerCount = computed(() => mediaServers.value.length)
|
||||
const onlineDeviceCount = computed(
|
||||
() => devices.value.filter((device: Device) => device.status === 1).length,
|
||||
)
|
||||
const totalDeviceCount = computed(() => devices.value.length)
|
||||
|
||||
const totalStreams = ref(0)
|
||||
const totalPlayers = ref(0)
|
||||
|
||||
const fetchStreamAndPlayerCount = async () => {
|
||||
let streamCount = 0
|
||||
let playerCount = 0
|
||||
for (const server of mediaServers.value) {
|
||||
if (server.status === 1) { // 只统计在线服务器
|
||||
try {
|
||||
const mediaServer = createMediaServer(server)
|
||||
const streams = await mediaServer.getStreamInfo()
|
||||
streamCount += streams.length
|
||||
// 统计所有流的客户端数量
|
||||
playerCount += streams.reduce((sum, stream) => sum + (stream.clients || 0), 0)
|
||||
} catch (error) {
|
||||
console.error(`获取服务器 ${server.name} 的流信息失败:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
totalStreams.value = streamCount
|
||||
totalPlayers.value = playerCount
|
||||
}
|
||||
|
||||
// 每30秒更新一次统计数据
|
||||
onMounted(() => {
|
||||
fetchStreamAndPlayerCount()
|
||||
setInterval(fetchStreamAndPlayerCount, 30000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<h1 class="dashboard-title">系统概览</h1>
|
||||
<div class="dashboard-grid">
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">流媒体服务器</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="number">
|
||||
<span class="online">{{ onlineServerCount }}</span>
|
||||
<span class="separator">/</span>
|
||||
<span class="total">{{ totalServerCount }}</span>
|
||||
</div>
|
||||
<div class="label">在线/总数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">设备状态</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="number">
|
||||
<span class="online">{{ onlineDeviceCount }}</span>
|
||||
<span class="separator">/</span>
|
||||
<span class="total">{{ totalDeviceCount }}</span>
|
||||
</div>
|
||||
<div class="label">在线/总数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">流数量</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="number">
|
||||
<span class="total">{{ totalStreams }}</span>
|
||||
</div>
|
||||
<div class="label">总流数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">播放者数量</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="number">
|
||||
<span class="total">{{ totalPlayers }}</span>
|
||||
</div>
|
||||
<div class="label">总播放数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
background-color: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
margin: 0 0 24px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dashboard-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--el-box-shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.number {
|
||||
font-size: 48px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.number .online {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.number .separator {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 36px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.number .total {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.number {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.number .separator {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
302
html/NextGB/src/views/DeviceListView.vue
Normal file
302
html/NextGB/src/views/DeviceListView.vue
Normal file
@ -0,0 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
useDevices,
|
||||
useChannels,
|
||||
fetchDevicesAndChannels,
|
||||
useDevicesLoading,
|
||||
} from '@/stores/devices'
|
||||
import type { Device, ChannelInfo } from '@/api/types'
|
||||
|
||||
const devices = useDevices()
|
||||
const allChannels = useChannels()
|
||||
const loading = useDevicesLoading()
|
||||
|
||||
const deviceList = computed(() => devices.value.map(formatDeviceData))
|
||||
|
||||
const fetchDevices = async (showError = true) => {
|
||||
try {
|
||||
await fetchDevicesAndChannels()
|
||||
} catch (error) {
|
||||
console.error('获取设备列表失败:', error)
|
||||
if (showError) {
|
||||
ElMessage.error('获取设备列表失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ExtendedDevice extends Device {
|
||||
channelCount?: number
|
||||
}
|
||||
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const currentDevice = ref<ExtendedDevice | null>(null)
|
||||
const channels = ref<ChannelInfo[]>([])
|
||||
|
||||
const formatDeviceData = (device: Device): ExtendedDevice => {
|
||||
return {
|
||||
...device,
|
||||
status: device.status || 0,
|
||||
name: device.name || device.device_id,
|
||||
channelCount: allChannels.value.filter(
|
||||
(channel: ChannelInfo) => channel.parent_id === device.device_id,
|
||||
).length,
|
||||
}
|
||||
}
|
||||
|
||||
const filteredDevices = computed(() => {
|
||||
if (!searchQuery.value.trim()) return deviceList.value
|
||||
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
return deviceList.value.filter((device: Device) => {
|
||||
return (
|
||||
device.name?.toLowerCase().includes(query) ||
|
||||
device.device_id?.toLowerCase().includes(query) ||
|
||||
device.source_addr?.toLowerCase().includes(query) ||
|
||||
device.network_type?.toLowerCase().includes(query)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchDevices(true)
|
||||
}
|
||||
|
||||
const showDeviceDetails = async (device: ExtendedDevice) => {
|
||||
currentDevice.value = device
|
||||
dialogVisible.value = true
|
||||
channels.value = allChannels.value.filter(
|
||||
(channel: ChannelInfo) => channel.parent_id === device.device_id,
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusType = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return 'success'
|
||||
case 0:
|
||||
return 'danger'
|
||||
default:
|
||||
return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return '在线'
|
||||
case 0:
|
||||
return '离线'
|
||||
default:
|
||||
return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
const paginatedDevices = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize.value
|
||||
return filteredDevices.value.slice(start, start + pageSize.value)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="device-list-view">
|
||||
<h1>设备管理</h1>
|
||||
<div class="device-list">
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索设备ID、名称、地址或网络类型..."
|
||||
class="search-input"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button type="success" :loading="loading" @click="handleRefresh">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="paginatedDevices" border stripe>
|
||||
<template #empty>
|
||||
<el-empty :description="searchQuery ? '未找到匹配的设备' : '暂无设备数据'" />
|
||||
</template>
|
||||
<el-table-column prop="device_id" label="设备ID" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="name" label="设备名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="source_addr" label="地址" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="network_type" label="网络类型" min-width="100" />
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="channelCount" label="通道数量" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.channelCount || 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click.stop="showDeviceDetails(row)">
|
||||
查看详情
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:total="filteredDevices.length"
|
||||
@current-change="handleCurrentChange"
|
||||
layout="total, prev, pager, next, jumper"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="`设备详情 - ${currentDevice?.name || currentDevice?.device_id}`"
|
||||
width="70%"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="device-details">
|
||||
<div class="device-info">
|
||||
<h3>设备信息</h3>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="设备ID">
|
||||
{{ currentDevice?.device_id }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="设备名称">
|
||||
{{ currentDevice?.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="地址">
|
||||
{{ currentDevice?.source_addr }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="网络类型">
|
||||
{{ currentDevice?.network_type }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="currentDevice?.status === 1 ? 'success' : 'danger'">
|
||||
{{ currentDevice?.status === 1 ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="通道数量">
|
||||
{{ currentDevice?.channelCount || 0 }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="channel-list">
|
||||
<h3>通道列表</h3>
|
||||
<el-table :data="channels" border stripe>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="name" label="通道名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column
|
||||
prop="device_id"
|
||||
label="通道ID"
|
||||
min-width="120"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column prop="manufacturer" label="厂商" min-width="120" />
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'ON' ? 'success' : 'danger'">
|
||||
{{ row.status === 'ON' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.device-list-view {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.device-list {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
height: calc(100vh - 180px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
flex: 1;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.device-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.device-info h3,
|
||||
.channel-list h3 {
|
||||
margin-bottom: 15px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.channel-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.channel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
739
html/NextGB/src/views/mediaserver/MediaServerCard.vue
Normal file
739
html/NextGB/src/views/mediaserver/MediaServerCard.vue
Normal file
@ -0,0 +1,739 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Delete,
|
||||
View,
|
||||
VideoCamera,
|
||||
Microphone,
|
||||
Upload,
|
||||
Download,
|
||||
Connection,
|
||||
VideoPlay,
|
||||
User,
|
||||
Refresh,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { MediaServer } from '@/api/mediaserver/types'
|
||||
import type { StreamInfo } from '@/api/mediaserver/types'
|
||||
import { createMediaServer } from '@/api/mediaserver/factory'
|
||||
import zlmLogo from '@/assets/zlm-logo.png'
|
||||
import srsLogo from '@/assets/srs-logo.ico'
|
||||
|
||||
const props = defineProps<{
|
||||
server: MediaServer
|
||||
}>()
|
||||
|
||||
const version = ref<string>('获取中...')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete', server: MediaServer): void
|
||||
(e: 'set-default', server: MediaServer): void
|
||||
}>()
|
||||
|
||||
const isDefault = computed(() => props.server.isDefault === 1)
|
||||
|
||||
const handleDelete = () => {
|
||||
emit('delete', props.server)
|
||||
}
|
||||
|
||||
const handleSetDefault = () => {
|
||||
emit('set-default', props.server)
|
||||
}
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const streams = ref<StreamInfo[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const expandedRowKeys = ref<string[]>([])
|
||||
|
||||
const handleRowExpand = (row: StreamInfo) => {
|
||||
const index = expandedRowKeys.value.indexOf(row.id)
|
||||
if (index > -1) {
|
||||
expandedRowKeys.value.splice(index, 1)
|
||||
} else {
|
||||
expandedRowKeys.value.push(row.id)
|
||||
}
|
||||
}
|
||||
|
||||
const withLoading = async (operation: () => Promise<void>) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await operation()
|
||||
} catch (error) {
|
||||
console.error('获取流信息失败:', error)
|
||||
ElMessage.error('获取流信息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleView = async () => {
|
||||
dialogVisible.value = true
|
||||
await withLoading(fetchStreamInfo)
|
||||
}
|
||||
|
||||
const fetchStreamInfo = async () => {
|
||||
const mediaServer = createMediaServer(props.server)
|
||||
const streamInfos = await mediaServer.getStreamInfo()
|
||||
|
||||
let clientsMap: Record<string, any[]> = {}
|
||||
|
||||
// 根据服务器类型使用不同的获取客户端信息逻辑
|
||||
if (props.server.type === 'ZLM') {
|
||||
// ZLM需要为每个流单独获取客户端信息
|
||||
await Promise.all(
|
||||
streamInfos.map(async (stream) => {
|
||||
const clients = await mediaServer.getClientInfo({ stream_id: stream.id })
|
||||
clientsMap[stream.id] = clients
|
||||
})
|
||||
)
|
||||
} else {
|
||||
// SRS可以一次性获取所有客户端信息
|
||||
const clients = await mediaServer.getClientInfo()
|
||||
clientsMap = clients.reduce(
|
||||
(acc: Record<string, any[]>, client) => {
|
||||
if (!acc[client.stream]) {
|
||||
acc[client.stream] = []
|
||||
}
|
||||
acc[client.stream].push(client)
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
// 将客户端信息关联到对应的流
|
||||
streams.value = streamInfos.map((stream: StreamInfo) => ({
|
||||
...stream,
|
||||
clients_info: clientsMap[stream.id] || [],
|
||||
}))
|
||||
}
|
||||
|
||||
const refreshStreams = () => withLoading(fetchStreamInfo)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const mediaServer = createMediaServer(props.server)
|
||||
const versionInfo = await mediaServer.getVersion()
|
||||
version.value = versionInfo.version
|
||||
} catch (error) {
|
||||
version.value = '获取失败'
|
||||
console.error('获取版本信息失败:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const formatBytes = (bytes?: number) => {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const copyUrl = (url: string) => {
|
||||
navigator.clipboard.writeText(url)
|
||||
ElMessage.success('URL已复制到剪贴板')
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
console.log('Duration input:', ms, typeof ms)
|
||||
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}小时${minutes % 60}分`
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}分${seconds % 60}秒`
|
||||
} else {
|
||||
return `${seconds}秒`
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnect = async (stream: StreamInfo) => {
|
||||
// TODO: 实现断开流的API调用
|
||||
ElMessage.warning('断开流功能即将实现')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-card class="server-card" :class="{ 'is-default': isDefault }">
|
||||
<div v-if="isDefault" class="default-ribbon"></div>
|
||||
<div class="server-header">
|
||||
<img :src="server.type === 'ZLM' ? zlmLogo : srsLogo" class="server-icon" />
|
||||
<div class="server-info">
|
||||
<h3>
|
||||
{{ server.name }}
|
||||
<el-tag size="small" type="info" class="type-tag">{{ server.type }}</el-tag>
|
||||
</h3>
|
||||
<div class="server-ip">{{ server.ip }}</div>
|
||||
</div>
|
||||
<div class="status-tags">
|
||||
<el-tag :type="server.status === 1 ? 'success' : 'danger'" class="status-tag">
|
||||
{{ server.status === 1 ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
:type="isDefault ? 'warning' : 'info'"
|
||||
class="default-tag"
|
||||
@click="handleSetDefault"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
{{ isDefault ? '默认节点' : '设为默认' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="server-body">
|
||||
<p>版本: {{ version }}</p>
|
||||
</div>
|
||||
<div class="server-footer">
|
||||
<el-button-group>
|
||||
<el-button type="primary" size="small" :icon="View" @click="handleView">查看</el-button>
|
||||
<el-button type="danger" size="small" :icon="Delete" @click="handleDelete">删除</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="`${server.name} - 流信息`"
|
||||
width="90%"
|
||||
class="stream-dialog"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="stream-dashboard">
|
||||
<div class="dashboard-item">
|
||||
<div class="dashboard-icon">
|
||||
<el-icon><Connection /></el-icon>
|
||||
</div>
|
||||
<div class="dashboard-content">
|
||||
<div class="dashboard-value">{{ streams.length }}</div>
|
||||
<div class="dashboard-label">总流数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-divider"></div>
|
||||
<div class="dashboard-item">
|
||||
<div class="dashboard-icon active">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
</div>
|
||||
<div class="dashboard-content">
|
||||
<div class="dashboard-value success">{{ streams.filter((s) => s.active).length }}</div>
|
||||
<div class="dashboard-label">活跃流数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-divider"></div>
|
||||
<div class="dashboard-item">
|
||||
<div class="dashboard-icon primary">
|
||||
<el-icon><User /></el-icon>
|
||||
</div>
|
||||
<div class="dashboard-content">
|
||||
<div class="dashboard-value primary">
|
||||
{{ streams.reduce((sum, s) => sum + s.clients, 0) }}
|
||||
</div>
|
||||
<div class="dashboard-label">总客户端数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="streams"
|
||||
style="width: 100%"
|
||||
border
|
||||
stripe
|
||||
class="stream-table"
|
||||
:empty-text="loading ? '加载中...' : '暂无流数据'"
|
||||
:expand-row-keys="expandedRowKeys"
|
||||
row-key="id"
|
||||
@row-click="(row) => handleRowExpand(row)"
|
||||
>
|
||||
<el-table-column prop="name" label="流名称" min-width="120" />
|
||||
<el-table-column label="URL" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="copyUrl(row.url)">
|
||||
{{ row.url }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="clients" label="客户端数" width="100" align="center" />
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.active ? 'success' : 'danger'" size="small">
|
||||
{{ row.active ? '活跃' : '断开' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="编码信息" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div class="codec-info">
|
||||
<template v-if="row.video">
|
||||
<el-tooltip content="视频编码信息" placement="top">
|
||||
<div class="info-chip video">
|
||||
<el-icon><VideoCamera /></el-icon>
|
||||
<span class="codec">{{ row.video.codec }}</span>
|
||||
<span class="detail">{{ row.video.width }}x{{ row.video.height }}</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-if="row.audio">
|
||||
<el-tooltip content="音频编码信息" placement="top">
|
||||
<div class="info-chip audio">
|
||||
<el-icon><Microphone /></el-icon>
|
||||
<span class="codec">{{ row.audio.codec }}</span>
|
||||
<span class="detail">{{ row.audio.sampleRate }}Hz</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="传输数据" width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="transfer-info">
|
||||
<el-tooltip :content="server.type === 'ZLM' ? '下行速率' : '累计下行流量'" placement="top">
|
||||
<div class="info-chip download">
|
||||
<el-icon><Download /></el-icon>
|
||||
<span class="value">{{ formatBytes(row.send_bytes) }}</span>
|
||||
<span v-if="server.type === 'ZLM'" class="rate-unit">/s</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
<el-tooltip :content="server.type === 'ZLM' ? '上行速率' : '累计上行流量'" placement="top">
|
||||
<div class="info-chip upload">
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span class="value">{{ formatBytes(row.recv_bytes) }}</span>
|
||||
<span v-if="server.type === 'ZLM'" class="rate-unit">/s</span>
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
:disabled="!row.active"
|
||||
@click.stop="handleDisconnect(row)"
|
||||
>
|
||||
断开
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column type="expand" :width="0">
|
||||
<template #default="{ row }">
|
||||
<div class="expanded-details">
|
||||
<el-table
|
||||
:data="row.clients_info || []"
|
||||
border
|
||||
stripe
|
||||
size="small"
|
||||
class="client-table"
|
||||
:show-header="false"
|
||||
style="width: 600px"
|
||||
>
|
||||
<el-table-column prop="id" width="100">
|
||||
<template #default="{ row: client }">
|
||||
<el-tooltip content="客户端ID" placement="top">
|
||||
<span>{{ client.id }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip" width="110">
|
||||
<template #default="{ row: client }">
|
||||
<el-tooltip content="IP地址" placement="top">
|
||||
<span>{{ client.ip }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="type" width="60">
|
||||
<template #default="{ row: client }">
|
||||
<el-tooltip content="类型" placement="top">
|
||||
<span>{{ client.type }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="url" min-width="150">
|
||||
<template #default="{ row: client }">
|
||||
<el-tooltip content="URL" placement="top">
|
||||
<el-link type="primary" :underline="false" @click.stop="copyUrl(client.url)">
|
||||
{{ client.url }}
|
||||
</el-link>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="alive" width="80" align="right">
|
||||
<template #default="{ row: client }">
|
||||
<el-tooltip content="存活时间" placement="top">
|
||||
<span>{{ formatDuration(client.alive) }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="refreshStreams">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.server-card {
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.is-default {
|
||||
border: 1px solid #e6a23c;
|
||||
}
|
||||
|
||||
.default-ribbon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.default-ribbon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 150%;
|
||||
height: 24px;
|
||||
background: #e6a23c;
|
||||
transform: rotate(-45deg) translateX(-50%);
|
||||
transform-origin: 0 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.server-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.server-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
object-position: left;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.server-info h3 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.server-ip {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.server-body {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.server-body p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.server-footer {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.status-tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.status-tag,
|
||||
.default-tag {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.default-tag {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.default-tag:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
margin-left: 8px;
|
||||
font-weight: normal;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
:deep(.el-radio-group) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stream-dialog :deep(.el-dialog__header) {
|
||||
padding: 20px 24px;
|
||||
margin-right: 0;
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.stream-dialog :deep(.el-dialog__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.stream-dialog :deep(.el-dialog__footer) {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.stream-dashboard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dashboard-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.dashboard-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: #f5f7fa;
|
||||
color: #909399;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.dashboard-icon.active {
|
||||
background: #f0f9eb;
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.dashboard-icon.primary {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.dashboard-icon :deep(.el-icon) {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dashboard-value.success {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.dashboard-value.primary {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.dashboard-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.dashboard-divider {
|
||||
width: 1px;
|
||||
height: 60px;
|
||||
background: linear-gradient(180deg, transparent, #dcdfe6 50%, transparent);
|
||||
}
|
||||
|
||||
.stream-table {
|
||||
margin-top: 16px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.stream-table :deep(.el-table__header-wrapper) {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.stream-table :deep(.el-table__header) th {
|
||||
background-color: #f5f7fa;
|
||||
font-weight: 600;
|
||||
height: 40px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.stream-table :deep(.el-table__row) {
|
||||
transition: all 0.3s ease;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.stream-table :deep(.el-table__cell) {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.stream-table :deep(.el-table__row:hover) {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.codec-info,
|
||||
.transfer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.info-chip .el-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.info-chip.video {
|
||||
background: linear-gradient(45deg, #409eff22, #409eff11);
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.info-chip.audio {
|
||||
background: linear-gradient(45deg, #67c23a22, #67c23a11);
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.info-chip.download {
|
||||
background: linear-gradient(45deg, #409eff22, #409eff11);
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.info-chip.upload {
|
||||
background: linear-gradient(45deg, #e6a23d22, #e6a23d11);
|
||||
color: #e6a23d;
|
||||
}
|
||||
|
||||
.info-chip .unit {
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.info-chip .rate-unit {
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.info-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.info-chip .codec {
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.info-chip .detail,
|
||||
.info-chip .value {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* 优化表格样式 */
|
||||
.stream-table :deep(.el-table__row) {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.stream-table :deep(.el-table__cell) {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.expanded-details {
|
||||
padding: 4px 8px 4px 48px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.client-table {
|
||||
--el-table-border-color: #e4e7ed;
|
||||
--el-table-row-hover-bg-color: #ecf5ff;
|
||||
}
|
||||
|
||||
.client-table :deep(.el-table__row) {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.client-table :deep(.el-table__cell) {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.client-table :deep(.cell) {
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.client-table :deep(.el-link) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stream-table :deep(.el-table__expand-column) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stream-table :deep(.el-table__row) {
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
234
html/NextGB/src/views/mediaserver/MediaServerView.vue
Normal file
234
html/NextGB/src/views/mediaserver/MediaServerView.vue
Normal file
@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import MediaServerCard from './MediaServerCard.vue'
|
||||
import type { MediaServer } from '@/api/mediaserver/types'
|
||||
import { mediaServerApi } from '@/api'
|
||||
import {
|
||||
useMediaServers,
|
||||
useDefaultMediaServer,
|
||||
fetchMediaServers,
|
||||
setDefaultMediaServer,
|
||||
deleteMediaServer,
|
||||
checkServersStatus,
|
||||
} from '@/stores/mediaServer'
|
||||
|
||||
const mediaServers = useMediaServers()
|
||||
|
||||
// 表单校验规则
|
||||
const rules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入节点名称', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
|
||||
],
|
||||
ip: [
|
||||
{ required: true, message: '请输入IP地址', trigger: 'blur' },
|
||||
{ pattern: /^(\d{1,3}\.){3}\d{1,3}$/, message: '请输入正确的IP地址格式', trigger: 'blur' },
|
||||
],
|
||||
port: [
|
||||
{ required: true, message: '请输入端口号', trigger: 'blur' },
|
||||
{
|
||||
type: 'number' as const,
|
||||
min: 1,
|
||||
max: 65535,
|
||||
message: '端口号范围为1-65535',
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
type: [{ required: true, message: '请选择服务器类型', trigger: 'change' }],
|
||||
secret: [
|
||||
{
|
||||
validator: (rule: any, value: string, callback: Function) => {
|
||||
if (newServer.value.type === 'ZLM' && !value) {
|
||||
callback(new Error('请输入 SECRET'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
const formRef = ref()
|
||||
const dialogVisible = ref(false)
|
||||
const newServer = ref<
|
||||
Pick<MediaServer, 'name' | 'ip' | 'port' | 'type' | 'username' | 'password' | 'isDefault' | 'secret'>
|
||||
>({
|
||||
name: '',
|
||||
ip: '',
|
||||
port: 1985,
|
||||
type: 'SRS',
|
||||
username: '',
|
||||
password: '',
|
||||
isDefault: 0,
|
||||
secret: '',
|
||||
})
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogVisible.value = true
|
||||
// 重置表单
|
||||
newServer.value = {
|
||||
name: '',
|
||||
ip: '',
|
||||
port: 1985,
|
||||
type: 'SRS',
|
||||
username: '',
|
||||
password: '',
|
||||
isDefault: 0,
|
||||
secret: '',
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (server: MediaServer) => {
|
||||
try {
|
||||
await deleteMediaServer(server)
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetDefault = (server: MediaServer) => {
|
||||
setDefaultMediaServer(server)
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
await mediaServerApi.addMediaServer({
|
||||
name: newServer.value.name,
|
||||
ip: newServer.value.ip,
|
||||
port: newServer.value.port,
|
||||
type: newServer.value.type,
|
||||
username: newServer.value.username,
|
||||
password: newServer.value.password,
|
||||
isDefault: newServer.value.isDefault,
|
||||
...(newServer.value.type === 'ZLM' ? { secret: newServer.value.secret } : {}),
|
||||
})
|
||||
|
||||
dialogVisible.value = false
|
||||
ElMessage.success('添加成功')
|
||||
await fetchMediaServers()
|
||||
} catch (error) {
|
||||
console.error('添加失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
formRef.value?.resetFields()
|
||||
dialogVisible.value = false
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
// 延迟3秒后获取服务器状态
|
||||
setTimeout(() => {
|
||||
checkServersStatus()
|
||||
}, 3000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="media-view">
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>新增节点
|
||||
</el-button>
|
||||
<el-button @click="fetchMediaServers">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 节点卡片列表 -->
|
||||
<div class="server-grid">
|
||||
<MediaServerCard
|
||||
v-for="server in mediaServers"
|
||||
:key="server.id"
|
||||
:server="server"
|
||||
@delete="handleDelete"
|
||||
@set-default="handleSetDefault"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 优化后的添加节点对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
title="添加节点"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form ref="formRef" :model="newServer" :rules="rules" label-width="100px" status-icon>
|
||||
<el-form-item label="节点名称" prop="name">
|
||||
<el-input v-model="newServer.name" placeholder="请输入节点名称" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="服务器类型">
|
||||
<el-radio-group v-model="newServer.type">
|
||||
<el-radio value="SRS">SRS</el-radio>
|
||||
<el-radio value="ZLM">ZLM</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="IP地址" prop="ip">
|
||||
<el-input v-model="newServer.ip" placeholder="请输入IP地址" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="端口" prop="port">
|
||||
<el-input
|
||||
v-model.number="newServer.port"
|
||||
placeholder="请输入端口号"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="newServer.type === 'ZLM'" label="SECRET" prop="secret">
|
||||
<el-input v-model="newServer.secret" placeholder="请输入 SECRET" clearable show-password />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="设为默认">
|
||||
<el-switch v-model="newServer.isDefault" active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取 消</el-button>
|
||||
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.media-view {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.server-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.el-input-number) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 隐藏验证图标(包括错误和成功状态) */
|
||||
:deep(.el-input__validateIcon) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
414
html/NextGB/src/views/playback/DeviceTree.vue
Normal file
414
html/NextGB/src/views/playback/DeviceTree.vue
Normal file
@ -0,0 +1,414 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Device, ChannelInfo } from '@/api/types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
useDevices,
|
||||
useChannels,
|
||||
useDevicesLoading,
|
||||
fetchDevicesAndChannels,
|
||||
} from '@/stores/devices'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
|
||||
interface DeviceNode {
|
||||
device_id: string
|
||||
label: string
|
||||
children?: DeviceNode[]
|
||||
isChannel?: boolean
|
||||
channelInfo?: ChannelInfo
|
||||
}
|
||||
|
||||
const devices = useDevices()
|
||||
const channels = useChannels()
|
||||
const loading = useDevicesLoading()
|
||||
const searchQuery = ref('')
|
||||
const expandedKeys = ref<string[]>([])
|
||||
const selectedChannels = ref<Set<string>>(new Set())
|
||||
const viewMode = ref<'tree' | 'list'>('list')
|
||||
|
||||
const deviceNodes = computed(() => {
|
||||
const nodes: DeviceNode[] = []
|
||||
for (const device of devices.value) {
|
||||
const deviceChannels = channels.value.filter(
|
||||
(channel: ChannelInfo) => channel.parent_id === device.device_id,
|
||||
)
|
||||
const deviceNode: DeviceNode = {
|
||||
device_id: device.device_id,
|
||||
label: device.name || '未命名',
|
||||
children: deviceChannels.map((channel: ChannelInfo) => ({
|
||||
device_id: channel.device_id,
|
||||
label: `${channel.name}`,
|
||||
isChannel: true,
|
||||
channelInfo: channel,
|
||||
})),
|
||||
}
|
||||
nodes.push(deviceNode)
|
||||
}
|
||||
return nodes
|
||||
})
|
||||
|
||||
const refreshDevices = async () => {
|
||||
try {
|
||||
await fetchDevicesAndChannels()
|
||||
} catch (error) {
|
||||
ElMessage.error('刷新设备列表失败')
|
||||
}
|
||||
tooltipRef.value?.hide()
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'update:selectedChannels',
|
||||
channels: { device: Device | undefined; channel: ChannelInfo }[],
|
||||
): void
|
||||
}>()
|
||||
|
||||
const handleChannelSelect = (channelId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
// 清除之前选中的所有通道
|
||||
selectedChannels.value.clear()
|
||||
// 只添加当前选中的通道
|
||||
selectedChannels.value.add(channelId)
|
||||
} else {
|
||||
selectedChannels.value.delete(channelId)
|
||||
}
|
||||
|
||||
// 发送选中的通道信息
|
||||
const selectedChannelInfos = Array.from(selectedChannels.value)
|
||||
.map((id) => {
|
||||
const channel = channels.value.find((c: ChannelInfo) => c.device_id === id)
|
||||
if (channel) {
|
||||
return {
|
||||
device: devices.value.find((d: Device) => d.device_id === channel.parent_id),
|
||||
channel,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((info) => info !== null) as { device: Device | undefined; channel: ChannelInfo }[]
|
||||
|
||||
emit('update:selectedChannels', selectedChannelInfos)
|
||||
}
|
||||
|
||||
const filteredData = computed(() => {
|
||||
const nodes = deviceNodes.value
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
|
||||
if (viewMode.value === 'list') {
|
||||
const allChannels = nodes.flatMap((node) =>
|
||||
(node.children || []).map((channel) => ({
|
||||
...channel,
|
||||
parentDeviceId: node.device_id,
|
||||
})),
|
||||
)
|
||||
|
||||
if (!query) {
|
||||
return [
|
||||
{
|
||||
label: '所有通道',
|
||||
device_id: 'root',
|
||||
children: allChannels,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const filteredChannels = allChannels.filter(
|
||||
(channel) =>
|
||||
channel.label.toLowerCase().includes(query) ||
|
||||
channel.device_id.toLowerCase().includes(query),
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
label: '所有通道',
|
||||
device_id: 'root',
|
||||
children: filteredChannels,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
expandedKeys.value = []
|
||||
return nodes
|
||||
}
|
||||
|
||||
expandedKeys.value = ['root']
|
||||
|
||||
return nodes.filter((node) => {
|
||||
const searchNode = (item: any): boolean => {
|
||||
const isMatch =
|
||||
item.label?.toLowerCase().includes(query) || item.device_id?.toLowerCase().includes(query)
|
||||
|
||||
if (isMatch) {
|
||||
if (item.isChannel) {
|
||||
const parentDevice = nodes.find((device) =>
|
||||
device.children?.some((channel) => channel.device_id === item.device_id),
|
||||
)
|
||||
if (parentDevice) {
|
||||
expandedKeys.value.push(parentDevice.device_id)
|
||||
}
|
||||
} else {
|
||||
expandedKeys.value.push(item.device_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.children) {
|
||||
const hasMatchingChild = item.children.some(searchNode)
|
||||
if (hasMatchingChild && !expandedKeys.value.includes(item.device_id)) {
|
||||
expandedKeys.value.push(item.device_id)
|
||||
}
|
||||
return isMatch || hasMatchingChild
|
||||
}
|
||||
return isMatch
|
||||
}
|
||||
return searchNode(node)
|
||||
})
|
||||
})
|
||||
|
||||
const tooltipRef = ref()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="device-tree">
|
||||
<SearchBox
|
||||
v-model:searchQuery="searchQuery"
|
||||
v-model:viewMode="viewMode"
|
||||
:loading="loading"
|
||||
:show-view-mode-switch="true"
|
||||
@refresh="refreshDevices"
|
||||
/>
|
||||
|
||||
<el-tree
|
||||
v-if="viewMode === 'tree'"
|
||||
v-loading="loading"
|
||||
:data="[
|
||||
{
|
||||
label: '所有设备',
|
||||
device_id: 'root',
|
||||
children: filteredData,
|
||||
},
|
||||
]"
|
||||
:props="{ children: 'children', label: 'label' }"
|
||||
node-key="device_id"
|
||||
:expanded-keys="expandedKeys"
|
||||
default-expand-all
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="custom-tree-node">
|
||||
<el-checkbox
|
||||
v-if="data.isChannel"
|
||||
:model-value="selectedChannels.has(data.device_id)"
|
||||
@update:model-value="
|
||||
(checked) => handleChannelSelect(data.device_id, checked as boolean)
|
||||
"
|
||||
/>
|
||||
<span :class="data.isChannel ? 'channel-label' : 'device-label'">
|
||||
{{ data.label }}
|
||||
</span>
|
||||
<el-tag
|
||||
v-if="data.isChannel"
|
||||
size="small"
|
||||
:type="data.channelInfo?.status === 'ON' ? 'success' : 'danger'"
|
||||
>
|
||||
{{ data.channelInfo?.status === 'ON' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
|
||||
<div v-else class="channel-list" v-loading="loading">
|
||||
<el-tree
|
||||
:data="filteredData"
|
||||
:props="{ children: 'children', label: 'label' }"
|
||||
node-key="device_id"
|
||||
:default-expanded-keys="['root']"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="custom-tree-node">
|
||||
<el-checkbox
|
||||
v-if="data.isChannel"
|
||||
:model-value="selectedChannels.has(data.device_id)"
|
||||
@update:model-value="
|
||||
(checked) => handleChannelSelect(data.device_id, checked as boolean)
|
||||
"
|
||||
/>
|
||||
<span :class="data.isChannel ? 'channel-label' : 'device-label'">
|
||||
{{ data.label }}
|
||||
</span>
|
||||
<el-tag
|
||||
v-if="data.isChannel"
|
||||
size="small"
|
||||
:type="data.channelInfo?.status === 'ON' ? 'success' : 'danger'"
|
||||
>
|
||||
{{ data.channelInfo?.status === 'ON' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.device-tree {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-left: auto;
|
||||
|
||||
.action-btn {
|
||||
color: var(--el-text-color-regular);
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
padding: 5px 8px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:deep(.el-icon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&.refresh-btn {
|
||||
color: var(--el-color-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-tree {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
border-radius: 4px;
|
||||
|
||||
:deep(.el-tree-node__content) {
|
||||
height: 36px;
|
||||
padding-left: 8px !important;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tree-node__expand-icon) {
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&.is-leaf {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 0 4px;
|
||||
user-select: none;
|
||||
|
||||
.device-label {
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.channel-label {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
margin-left: auto;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.el-checkbox) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
|
||||
:deep(.el-tree) {
|
||||
background: transparent;
|
||||
|
||||
.el-tree-node__content {
|
||||
height: 36px;
|
||||
padding-left: 8px !important;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.is-current {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.el-tree-node__expand-icon {
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&.is-leaf {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
729
html/NextGB/src/views/playback/PlaybackView.vue
Normal file
729
html/NextGB/src/views/playback/PlaybackView.vue
Normal file
@ -0,0 +1,729 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch } from 'vue'
|
||||
import DeviceTree from './DeviceTree.vue'
|
||||
import MonitorGrid from '@/components/monitor/MonitorGrid.vue'
|
||||
import DateTimeRangePanel from '@/components/common/DateTimeRangePanel.vue'
|
||||
import type { Device, ChannelInfo, RecordInfoResponse } from '@/api/types'
|
||||
import type { LayoutConfig } from '@/types/layout'
|
||||
import { deviceApi } from '@/api'
|
||||
import dayjs from 'dayjs'
|
||||
import { VideoPlay, VideoPause, CloseBold, Microphone } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
type LayoutKey = '1'
|
||||
type LayoutConfigs = Record<LayoutKey, LayoutConfig>
|
||||
|
||||
// 布局配置
|
||||
const layouts: LayoutConfigs = {
|
||||
'1': { cols: 1, rows: 1, size: 1, label: '单屏' },
|
||||
} as const
|
||||
|
||||
const monitorGridRef = ref()
|
||||
const selectedChannels = ref<{ device: Device | undefined; channel: ChannelInfo }[]>([])
|
||||
const volume = ref(100)
|
||||
const currentLayout = ref<'1'>('1') // 固定为单屏模式
|
||||
const defaultMuted = ref(true)
|
||||
const activeWindow = ref<{ deviceId: string; channelId: string } | null>(null)
|
||||
const isPlaying = ref(false) // 添加播放状态变量
|
||||
const isFirstPlay = ref(true)
|
||||
|
||||
// 时间轴刻度显示控制
|
||||
const timelineWidth = ref(0)
|
||||
const showAllLabels = computed(() => timelineWidth.value >= 720) // 当宽度大于720px时显示所有标签
|
||||
const showMediumLabels = computed(() => timelineWidth.value >= 480) // 当宽度大于480px时显示中等标签
|
||||
|
||||
// 时间轴光标位置
|
||||
const cursorPosition = ref(0)
|
||||
const cursorTime = ref('')
|
||||
const isTimelineHovered = ref(false)
|
||||
|
||||
const getTimeFromEvent = (event: MouseEvent, element: HTMLElement) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const position = ((event.clientX - rect.left) / rect.width) * 100
|
||||
const normalizedPosition = Math.max(0, Math.min(100, position))
|
||||
|
||||
const totalMinutes = 24 * 60
|
||||
const minutes = Math.floor((normalizedPosition / 100) * totalMinutes)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = minutes % 60
|
||||
|
||||
return {
|
||||
position: normalizedPosition,
|
||||
time: dayjs().startOf('day').add(hours, 'hour').add(mins, 'minute'),
|
||||
}
|
||||
}
|
||||
|
||||
const handleTimelineMouseMove = (event: MouseEvent) => {
|
||||
const timeline = event.currentTarget as HTMLElement
|
||||
const { position, time } = getTimeFromEvent(event, timeline)
|
||||
cursorPosition.value = position
|
||||
cursorTime.value = time.format('HH:mm:ss')
|
||||
}
|
||||
|
||||
// 处理时间轴鼠标进入/离开
|
||||
const handleTimelineMouseEnter = () => {
|
||||
isTimelineHovered.value = true
|
||||
}
|
||||
|
||||
const handleTimelineMouseLeave = () => {
|
||||
isTimelineHovered.value = false
|
||||
}
|
||||
|
||||
// 屏幕尺寸类型
|
||||
const screenType = computed(() => {
|
||||
if (timelineWidth.value >= 720) return '大屏'
|
||||
if (timelineWidth.value >= 480) return '中屏'
|
||||
return '小屏'
|
||||
})
|
||||
|
||||
// 根据屏幕宽度调整时间轴高度
|
||||
const timelineHeight = computed(() => {
|
||||
if (timelineWidth.value >= 720) return 60
|
||||
if (timelineWidth.value >= 480) return 48
|
||||
return 36
|
||||
})
|
||||
|
||||
// 监听时间轴宽度变化
|
||||
const updateTimelineWidth = () => {
|
||||
const timeline = document.querySelector('.timeline-scale')
|
||||
if (timeline) {
|
||||
timelineWidth.value = timeline.clientWidth
|
||||
console.log(`时间轴宽度: ${timelineWidth.value}px, 当前屏幕: ${screenType.value}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听窗口大小变化
|
||||
onMounted(() => {
|
||||
updateTimelineWidth()
|
||||
window.addEventListener('resize', updateTimelineWidth)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateTimelineWidth)
|
||||
})
|
||||
|
||||
const handleWindowSelect = (data: { deviceId: string; channelId: string } | null) => {
|
||||
activeWindow.value = data
|
||||
}
|
||||
|
||||
const recordSegments = ref<RecordInfoResponse[]>([])
|
||||
|
||||
const handleQueryRecord = async ({ start, end }: { start: string; end: string }) => {
|
||||
if (selectedChannels.value.length === 0) {
|
||||
ElMessage.warning('请先选择要查询的通道')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const promises = selectedChannels.value.map(async ({ device, channel }) => {
|
||||
if (!device?.device_id || !channel.device_id) return []
|
||||
|
||||
const response = await deviceApi.queryRecord({
|
||||
device_id: device.device_id,
|
||||
channel_id: channel.device_id,
|
||||
start_time: dayjs(start).unix(),
|
||||
end_time: dayjs(end).unix(),
|
||||
})
|
||||
return Array.isArray(response.data) ? response.data : []
|
||||
})
|
||||
|
||||
const results = await Promise.all<RecordInfoResponse[]>(promises)
|
||||
recordSegments.value = results.flat()
|
||||
|
||||
// 自动激活第一个选中的通道
|
||||
if (selectedChannels.value.length > 0) {
|
||||
const firstChannel = selectedChannels.value[0]
|
||||
if (firstChannel.device?.device_id && firstChannel.channel.device_id) {
|
||||
activeWindow.value = {
|
||||
deviceId: firstChannel.device.device_id,
|
||||
channelId: firstChannel.channel.device_id
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('查询录像失败:', error)
|
||||
ElMessage.error('查询录像失败')
|
||||
recordSegments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = () => {
|
||||
monitorGridRef.value?.stop(0)
|
||||
isPlaying.value = false // 设置播放状态为 false
|
||||
|
||||
// 确保 activeWindow 始终指向第一个屏幕
|
||||
if (selectedChannels.value.length > 0) {
|
||||
const firstChannel = selectedChannels.value[0]
|
||||
if (firstChannel.device?.device_id && firstChannel.channel.device_id) {
|
||||
activeWindow.value = {
|
||||
deviceId: firstChannel.device.device_id,
|
||||
channelId: firstChannel.channel.device_id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 selectedChannels 变化,确保 activeWindow 始终指向第一个屏幕
|
||||
watch(selectedChannels, (newChannels) => {
|
||||
if (newChannels.length > 0) {
|
||||
const firstChannel = newChannels[0]
|
||||
if (firstChannel.device?.device_id && firstChannel.channel.device_id) {
|
||||
activeWindow.value = {
|
||||
deviceId: firstChannel.device.device_id,
|
||||
channelId: firstChannel.channel.device_id
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const calculatePosition = (time: string) => {
|
||||
const hour = dayjs(time).hour()
|
||||
const minute = dayjs(time).minute()
|
||||
return ((hour * 60 + minute) / (24 * 60)) * 100
|
||||
}
|
||||
|
||||
const calculateWidth = (start: string, end: string) => {
|
||||
const startMinutes = dayjs(start).hour() * 60 + dayjs(start).minute()
|
||||
const endMinutes = dayjs(end).hour() * 60 + dayjs(end).minute()
|
||||
return ((endMinutes - startMinutes) / (24 * 60)) * 100
|
||||
}
|
||||
|
||||
// 添加激活/停用处理
|
||||
onActivated(() => {
|
||||
console.log('PlaybackView activated')
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
console.log('PlaybackView deactivated')
|
||||
})
|
||||
|
||||
// 组件名称(用于 keep-alive)
|
||||
defineOptions({
|
||||
name: 'PlaybackView',
|
||||
})
|
||||
|
||||
const handleTimelineDoubleClick = async (event: MouseEvent) => {
|
||||
if (selectedChannels.value.length === 0) {
|
||||
ElMessage.warning('请先选择要播放的通道')
|
||||
return
|
||||
}
|
||||
|
||||
const timeline = event.currentTarget as HTMLElement
|
||||
const { time } = getTimeFromEvent(event, timeline)
|
||||
const endTime = dayjs().endOf('day')
|
||||
|
||||
// 只播放第一个选中的通道
|
||||
const { device, channel } = selectedChannels.value[0]
|
||||
if (!device?.device_id || !channel.device_id) {
|
||||
ElMessage.warning('设备信息不完整')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
monitorGridRef.value?.play({
|
||||
...device,
|
||||
channel: channel,
|
||||
play_type: 1, // 1 表示回放
|
||||
start_time: time.unix(),
|
||||
end_time: endTime.unix(), // 使用当天 23:59:59 的时间戳
|
||||
})
|
||||
isPlaying.value = true // 设置播放状态为 true
|
||||
} catch (error) {
|
||||
console.error('播放录像失败:', error)
|
||||
ElMessage.error('播放录像失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理播放/暂停切换
|
||||
const handlePlayPause = async () => {
|
||||
if (!activeWindow.value || selectedChannels.value.length === 0) return
|
||||
|
||||
if (!isPlaying.value) {
|
||||
// 开始播放
|
||||
const { device, channel } = selectedChannels.value[0]
|
||||
if (!device?.device_id || !channel.device_id) {
|
||||
ElMessage.warning('设备信息不完整')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 如果有录像段,则根据是否是第一次播放来决定是调用 play 还是 resume
|
||||
if (recordSegments.value.length === 0) {
|
||||
ElMessage.warning('没有可播放的录像')
|
||||
return
|
||||
}
|
||||
|
||||
if (isFirstPlay.value) {
|
||||
monitorGridRef.value?.play({
|
||||
...device,
|
||||
channel: channel,
|
||||
play_type: 1, // 1 表示回放
|
||||
start_time: recordSegments.value[0].start_time,
|
||||
end_time: recordSegments.value[0].end_time,
|
||||
})
|
||||
isFirstPlay.value = false
|
||||
} else {
|
||||
monitorGridRef.value?.resume(0)
|
||||
}
|
||||
isPlaying.value = true
|
||||
} catch (error) {
|
||||
console.error('播放录像失败:', error)
|
||||
ElMessage.error('播放录像失败')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 暂停播放
|
||||
try {
|
||||
const { device, channel } = selectedChannels.value[0]
|
||||
if (!device?.device_id || !channel.device_id) {
|
||||
ElMessage.warning('设备信息不完整')
|
||||
return
|
||||
}
|
||||
console.log('暂停录像')
|
||||
monitorGridRef.value?.pause(0)
|
||||
isPlaying.value = false
|
||||
} catch (error) {
|
||||
console.error('暂停录像失败:', error)
|
||||
ElMessage.error('暂停录像失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="playback-container">
|
||||
<div class="left-panel">
|
||||
<DeviceTree v-model:selectedChannels="selectedChannels" />
|
||||
<DateTimeRangePanel title="录像查询" @search="handleQueryRecord" />
|
||||
</div>
|
||||
<div class="right-panel">
|
||||
<div class="playback-panel">
|
||||
<MonitorGrid
|
||||
ref="monitorGridRef"
|
||||
v-model="currentLayout"
|
||||
:layouts="layouts"
|
||||
:default-muted="defaultMuted"
|
||||
:show-border="false"
|
||||
@window-select="handleWindowSelect"
|
||||
/>
|
||||
<div class="timeline-panel" :style="{ height: `${timelineHeight}px` }">
|
||||
<div class="timeline-ruler">
|
||||
<div
|
||||
class="timeline-scale"
|
||||
@mousemove="handleTimelineMouseMove"
|
||||
@mouseenter="handleTimelineMouseEnter"
|
||||
@mouseleave="handleTimelineMouseLeave"
|
||||
@dblclick="handleTimelineDoubleClick"
|
||||
>
|
||||
<div class="timeline-marks">
|
||||
<div
|
||||
v-for="hour in 24"
|
||||
:key="hour"
|
||||
class="hour-mark"
|
||||
:class="{
|
||||
'major-mark': (hour - 1) % 6 === 0,
|
||||
'medium-mark': (hour - 1) % 3 === 0 && (hour - 1) % 6 !== 0,
|
||||
'minor-mark': (hour - 1) % 3 !== 0,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
(hour - 1) % 6 === 0 ||
|
||||
(showMediumLabels && (hour - 1) % 3 === 0) ||
|
||||
showAllLabels
|
||||
"
|
||||
class="hour-label"
|
||||
>
|
||||
{{ (hour - 1).toString().padStart(2, '0') }}
|
||||
</div>
|
||||
<div class="hour-line"></div>
|
||||
<div class="half-hour-mark"></div>
|
||||
</div>
|
||||
<div class="hour-mark major-mark" style="flex: 0 0 auto">
|
||||
<div class="hour-label">24</div>
|
||||
<div class="hour-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="timeline-cursor"
|
||||
:class="{ visible: isTimelineHovered }"
|
||||
:style="{ left: `${cursorPosition}%` }"
|
||||
>
|
||||
<div class="cursor-time" :class="{ visible: isTimelineHovered }">
|
||||
{{ cursorTime }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="record-segments">
|
||||
<div
|
||||
v-for="(segment, index) in recordSegments"
|
||||
:key="index"
|
||||
class="record-segment"
|
||||
:style="{
|
||||
left: `${calculatePosition(segment.start_time)}%`,
|
||||
width: `${calculateWidth(segment.start_time, segment.end_time)}%`,
|
||||
}"
|
||||
:title="`${dayjs(segment.start_time).format('HH:mm:ss')} - ${dayjs(segment.end_time).format('HH:mm:ss')}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-panel">
|
||||
<div class="control-group">
|
||||
<el-button-group>
|
||||
<el-button
|
||||
:icon="isPlaying ? VideoPause : VideoPlay"
|
||||
:disabled="!activeWindow"
|
||||
size="large"
|
||||
:title="isPlaying ? '暂停' : '播放'"
|
||||
@click="handlePlayPause"
|
||||
/>
|
||||
<el-button
|
||||
:icon="CloseBold"
|
||||
:disabled="!activeWindow"
|
||||
@click="handleStop"
|
||||
size="large"
|
||||
title="停止"
|
||||
/>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<div class="volume-control">
|
||||
<el-icon><Microphone /></el-icon>
|
||||
<el-slider
|
||||
v-model="volume"
|
||||
:max="100"
|
||||
:min="0"
|
||||
:disabled="!activeWindow"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.playback-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
flex: 1;
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playback-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
height: 60px;
|
||||
background-color: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 140px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
|
||||
:deep(.el-icon) {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-button-group {
|
||||
.el-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
--el-button-bg-color: transparent;
|
||||
--el-button-border-color: transparent;
|
||||
--el-button-hover-bg-color: rgba(255, 255, 255, 0.1);
|
||||
--el-button-hover-border-color: transparent;
|
||||
--el-button-active-bg-color: rgba(255, 255, 255, 0.15);
|
||||
--el-button-text-color: rgba(255, 255, 255, 0.85);
|
||||
--el-button-disabled-text-color: rgba(255, 255, 255, 0.3);
|
||||
--el-button-disabled-bg-color: transparent;
|
||||
--el-button-disabled-border-color: transparent;
|
||||
|
||||
:deep(.el-icon) {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
--el-button-text-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-slider) {
|
||||
--el-slider-main-bg-color: var(--el-color-primary);
|
||||
--el-slider-runway-bg-color: rgba(255, 255, 255, 0.15);
|
||||
--el-slider-stop-bg-color: rgba(255, 255, 255, 0.2);
|
||||
--el-slider-disabled-color: rgba(255, 255, 255, 0.1);
|
||||
|
||||
.el-slider__runway {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.el-slider__button {
|
||||
border: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
}
|
||||
|
||||
.el-slider__bar {
|
||||
height: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-panel {
|
||||
background-color: #242424;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.timeline-ruler {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.timeline-marks {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.timeline-scale {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hour-mark {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.hour-label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hour-line {
|
||||
position: relative;
|
||||
width: 1px;
|
||||
height: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.half-hour-mark {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
height: 6px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.timeline-cursor {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: var(--el-color-warning);
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 2;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
transform: translateX(-50%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(var(--el-color-warning-rgb), 0.2),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.cursor-time {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--el-color-warning);
|
||||
color: #000;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.major-mark {
|
||||
.hour-line {
|
||||
height: 16px;
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.hour-label {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.medium-mark {
|
||||
.hour-line {
|
||||
height: 12px;
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
width: 1.5px;
|
||||
}
|
||||
|
||||
.hour-label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.minor-mark {
|
||||
.hour-line {
|
||||
height: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.hour-label {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 10px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-ruler:hover .timeline-pointer {
|
||||
box-shadow: 0 0 8px rgba(var(--el-color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.timeline-panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.1) 20%,
|
||||
rgba(255, 255, 255, 0.1) 80%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.record-segments {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 24px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.record-segment {
|
||||
position: absolute;
|
||||
height: 8px;
|
||||
bottom: 12px;
|
||||
background-color: var(--el-color-success);
|
||||
opacity: 0.85;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
|
||||
& + & {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
503
html/NextGB/src/views/realplay/DeviceTree.vue
Normal file
503
html/NextGB/src/views/realplay/DeviceTree.vue
Normal file
@ -0,0 +1,503 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { Device, ChannelInfo } from '@/api/types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
useDevices,
|
||||
useChannels,
|
||||
useDevicesLoading,
|
||||
fetchDevicesAndChannels,
|
||||
} from '@/stores/devices'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
|
||||
interface DeviceNode {
|
||||
device_id: string
|
||||
label: string
|
||||
children?: DeviceNode[]
|
||||
isChannel?: boolean
|
||||
channelInfo?: ChannelInfo
|
||||
}
|
||||
|
||||
const devices = useDevices()
|
||||
const channels = useChannels()
|
||||
const loading = useDevicesLoading()
|
||||
const searchQuery = ref('')
|
||||
const expandedKeys = ref<string[]>([])
|
||||
|
||||
const deviceNodes = computed(() => {
|
||||
const nodes: DeviceNode[] = []
|
||||
for (const device of devices.value) {
|
||||
const deviceChannels = channels.value.filter(
|
||||
(channel: ChannelInfo) => channel.parent_id === device.device_id,
|
||||
)
|
||||
const deviceNode: DeviceNode = {
|
||||
device_id: device.device_id,
|
||||
label: device.name || '未命名',
|
||||
children: deviceChannels.map((channel: ChannelInfo) => ({
|
||||
device_id: channel.device_id,
|
||||
label: `${channel.name}`,
|
||||
isChannel: true,
|
||||
channelInfo: channel,
|
||||
})),
|
||||
}
|
||||
nodes.push(deviceNode)
|
||||
}
|
||||
return nodes
|
||||
})
|
||||
|
||||
const refreshDevices = async () => {
|
||||
try {
|
||||
await fetchDevicesAndChannels()
|
||||
} catch (error) {
|
||||
ElMessage.error('刷新设备列表失败')
|
||||
}
|
||||
tooltipRef.value?.hide()
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', data: { device: Device | undefined; channel: ChannelInfo }): void
|
||||
(e: 'play', data: { device: Device | undefined; channel: ChannelInfo }): void
|
||||
}>()
|
||||
|
||||
const handleSelect = (data: DeviceNode) => {
|
||||
if (data.isChannel && data.channelInfo) {
|
||||
emit('select', {
|
||||
device: devices.value.find((d: Device) => d.device_id === data.channelInfo?.parent_id),
|
||||
channel: data.channelInfo,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleNodeDbClick = (data: DeviceNode) => {
|
||||
if (data.isChannel && data.channelInfo) {
|
||||
emit('play', {
|
||||
device: devices.value.find((d: Device) => d.device_id === data.channelInfo?.parent_id),
|
||||
channel: data.channelInfo,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const viewMode = ref<'tree' | 'list'>('tree')
|
||||
|
||||
const filteredData = computed(() => {
|
||||
const nodes = deviceNodes.value
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
|
||||
if (viewMode.value === 'list') {
|
||||
const allChannels = nodes.flatMap((node) =>
|
||||
(node.children || []).map((channel) => ({
|
||||
...channel,
|
||||
parentDeviceId: node.device_id,
|
||||
})),
|
||||
)
|
||||
|
||||
if (!query) {
|
||||
return [
|
||||
{
|
||||
label: '所有通道',
|
||||
device_id: 'root',
|
||||
children: allChannels,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const filteredChannels = allChannels.filter(
|
||||
(channel) =>
|
||||
channel.label.toLowerCase().includes(query) ||
|
||||
channel.device_id.toLowerCase().includes(query),
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
label: '所有通道',
|
||||
device_id: 'root',
|
||||
children: filteredChannels,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
expandedKeys.value = []
|
||||
return nodes
|
||||
}
|
||||
|
||||
expandedKeys.value = ['root']
|
||||
|
||||
return nodes.filter((node) => {
|
||||
const searchNode = (item: any): boolean => {
|
||||
const isMatch =
|
||||
item.label?.toLowerCase().includes(query) || item.device_id?.toLowerCase().includes(query)
|
||||
|
||||
if (isMatch) {
|
||||
if (item.isChannel) {
|
||||
const parentDevice = nodes.find((device) =>
|
||||
device.children?.some((channel) => channel.device_id === item.device_id),
|
||||
)
|
||||
if (parentDevice) {
|
||||
expandedKeys.value.push(parentDevice.device_id)
|
||||
}
|
||||
} else {
|
||||
expandedKeys.value.push(item.device_id)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.children) {
|
||||
const hasMatchingChild = item.children.some(searchNode)
|
||||
if (hasMatchingChild && !expandedKeys.value.includes(item.device_id)) {
|
||||
expandedKeys.value.push(item.device_id)
|
||||
}
|
||||
return isMatch || hasMatchingChild
|
||||
}
|
||||
return isMatch
|
||||
}
|
||||
return searchNode(node)
|
||||
})
|
||||
})
|
||||
|
||||
const tooltipRef = ref()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="device-tree">
|
||||
<SearchBox
|
||||
v-model:searchQuery="searchQuery"
|
||||
v-model:viewMode="viewMode"
|
||||
:loading="loading"
|
||||
:show-view-mode-switch="true"
|
||||
@refresh="refreshDevices"
|
||||
/>
|
||||
|
||||
<el-tree
|
||||
v-if="viewMode === 'tree'"
|
||||
v-loading="loading"
|
||||
:data="[
|
||||
{
|
||||
label: '所有设备',
|
||||
device_id: 'root',
|
||||
children: filteredData,
|
||||
},
|
||||
]"
|
||||
:props="{ children: 'children', label: 'label' }"
|
||||
@node-click="handleSelect"
|
||||
node-key="device_id"
|
||||
highlight-current
|
||||
:expanded-keys="expandedKeys"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="custom-tree-node" @dblclick.stop="handleNodeDbClick(data)">
|
||||
<span :class="data.isChannel ? 'channel-label' : 'device-label'">
|
||||
{{ data.label }}
|
||||
<template v-if="data.isChannel && data.channelInfo"> </template>
|
||||
</span>
|
||||
<el-tag
|
||||
v-if="data.isChannel"
|
||||
size="small"
|
||||
:type="data.channelInfo?.status === 'ON' ? 'success' : 'danger'"
|
||||
>
|
||||
{{ data.channelInfo?.status === 'ON' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
|
||||
<div v-else class="channel-list" v-loading="loading">
|
||||
<el-tree
|
||||
:data="filteredData"
|
||||
:props="{ children: 'children', label: 'label' }"
|
||||
@node-click="handleSelect"
|
||||
node-key="device_id"
|
||||
highlight-current
|
||||
:default-expanded-keys="['root']"
|
||||
>
|
||||
<template #default="{ node, data }">
|
||||
<span class="custom-tree-node" @dblclick.stop="handleNodeDbClick(data)">
|
||||
<span :class="data.isChannel ? 'channel-label' : 'device-label'">
|
||||
{{ data.label }}
|
||||
</span>
|
||||
<el-tag
|
||||
v-if="data.isChannel"
|
||||
size="small"
|
||||
:type="data.channelInfo?.status === 'ON' ? 'success' : 'danger'"
|
||||
>
|
||||
{{ data.channelInfo?.status === 'ON' ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.device-tree {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.el-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-left: auto;
|
||||
|
||||
.action-btn {
|
||||
color: var(--el-text-color-regular);
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
padding: 5px 8px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
border-radius: 0;
|
||||
margin-left: 0;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:deep(.el-icon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&.is-loading {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.refresh-btn {
|
||||
color: var(--el-color-primary);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
&.is-loading {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px var(--el-border-color) inset;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 1px var(--el-border-color-hover) inset;
|
||||
}
|
||||
|
||||
&.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-tree {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
border-radius: 4px;
|
||||
|
||||
:deep(.el-tree-node) {
|
||||
&.is-expanded > .el-tree-node__children {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
position: absolute;
|
||||
left: -12px;
|
||||
top: -4px;
|
||||
border-left: 1px dotted var(--el-border-color);
|
||||
}
|
||||
|
||||
&:last-child::before {
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tree-node__content) {
|
||||
height: 36px;
|
||||
padding-left: 8px !important;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.is-current {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tree-node__expand-icon) {
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&.is-leaf {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 0 4px;
|
||||
user-select: none;
|
||||
|
||||
.device-label {
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.channel-label {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
margin-left: auto;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.channel-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
|
||||
:deep(.el-tree) {
|
||||
background: transparent;
|
||||
|
||||
.el-tree-node__content {
|
||||
height: 36px;
|
||||
padding-left: 8px !important;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.is-current {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.el-tree-node__expand-icon {
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-secondary);
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&.is-leaf {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.channel-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
margin: 2px 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 32px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.channel-label {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
transition: all 0.2s ease;
|
||||
transform-origin: right;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-tree-node__content) {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
.el-button-group {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.custom-tree-node {
|
||||
.channel-label,
|
||||
.device-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
386
html/NextGB/src/views/realplay/PtzControlPanel.vue
Normal file
386
html/NextGB/src/views/realplay/PtzControlPanel.vue
Normal file
@ -0,0 +1,386 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ArrowRight, VideoCamera } from '@element-plus/icons-vue'
|
||||
import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight as ArrowRightControl,
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { deviceApi } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const props = defineProps<{
|
||||
activeWindow?: {
|
||||
deviceId: string
|
||||
channelId: string
|
||||
} | null
|
||||
title?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
control: [direction: string]
|
||||
}>()
|
||||
|
||||
const isCollapsed = ref(false)
|
||||
const speed = ref(5)
|
||||
|
||||
const handlePtzStart = async (direction: string) => {
|
||||
if (!props.activeWindow) {
|
||||
ElMessage.warning('请先选择一个视频窗口')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deviceApi.controlPTZ({
|
||||
device_id: props.activeWindow.deviceId,
|
||||
channel_id: props.activeWindow.channelId,
|
||||
ptz: direction,
|
||||
speed: speed.value.toString(),
|
||||
})
|
||||
emit('control', direction)
|
||||
} catch (error) {
|
||||
console.error('PTZ control failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePtzStop = async () => {
|
||||
if (!props.activeWindow) return
|
||||
|
||||
try {
|
||||
await deviceApi.controlPTZ({
|
||||
device_id: props.activeWindow.deviceId,
|
||||
channel_id: props.activeWindow.channelId,
|
||||
ptz: 'stop',
|
||||
speed: speed.value.toString(),
|
||||
})
|
||||
emit('control', 'stop')
|
||||
} catch (error) {
|
||||
console.error('PTZ stop failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = computed(() => !props.activeWindow)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ptz-control-panel" :class="{ collapsed: isCollapsed }">
|
||||
<div class="panel-header" @click="isCollapsed = !isCollapsed">
|
||||
<div class="header-title">
|
||||
<el-icon class="collapse-arrow" :class="{ collapsed: isCollapsed }">
|
||||
<ArrowRight />
|
||||
</el-icon>
|
||||
<el-icon class="title-icon"><VideoCamera /></el-icon>
|
||||
<span>{{ title || '云台控制' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<div class="control-form">
|
||||
<div class="ptz-controls">
|
||||
<div class="direction-controls">
|
||||
<div class="direction-pad">
|
||||
<el-button
|
||||
class="direction-btn up"
|
||||
:disabled="isDisabled"
|
||||
@mousedown="handlePtzStart('up')"
|
||||
@mouseup="handlePtzStop"
|
||||
@mouseleave="handlePtzStop"
|
||||
>
|
||||
<el-icon><ArrowUp /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
class="direction-btn right"
|
||||
:disabled="isDisabled"
|
||||
@mousedown="handlePtzStart('right')"
|
||||
@mouseup="handlePtzStop"
|
||||
@mouseleave="handlePtzStop"
|
||||
>
|
||||
<el-icon><ArrowRightControl /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
class="direction-btn down"
|
||||
:disabled="isDisabled"
|
||||
@mousedown="handlePtzStart('down')"
|
||||
@mouseup="handlePtzStop"
|
||||
@mouseleave="handlePtzStop"
|
||||
>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
class="direction-btn left"
|
||||
:disabled="isDisabled"
|
||||
@mousedown="handlePtzStart('left')"
|
||||
@mouseup="handlePtzStop"
|
||||
@mouseleave="handlePtzStop"
|
||||
>
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
class="direction-btn up-left"
|
||||
:disabled="isDisabled"
|
||||
@mousedown="handlePtzStart('upleft')"
|
||||
@mouseup="handlePtzStop"
|
||||
@mouseleave="handlePtzStop"
|
||||
>
|
||||
<el-icon><TopLeft /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
class="direction-btn up-right"
|
||||
:disabled="isDisabled"
|
||||
@mousedown="handlePtzStart('upright')"
|
||||
@mouseup="handlePtzStop"
|
||||
@mouseleave="handlePtzStop"
|
||||
>
|
||||
<el-icon><TopRight /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
class="direction-btn down-left"
|
||||
:disabled="isDisabled"
|
||||
@mousedown="handlePtzStart('downleft')"
|
||||
@mouseup="handlePtzStop"
|
||||
@mouseleave="handlePtzStop"
|
||||
>
|
||||
<el-icon><BottomLeft /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
class="direction-btn down-right"
|
||||
:disabled="isDisabled"
|
||||
@mousedown="handlePtzStart('downright')"
|
||||
@mouseup="handlePtzStop"
|
||||
@mouseleave="handlePtzStop"
|
||||
>
|
||||
<el-icon><BottomRight /></el-icon>
|
||||
</el-button>
|
||||
<div class="direction-center"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="speed-control">
|
||||
<div class="speed-value">{{ speed }}</div>
|
||||
<el-slider
|
||||
v-model="speed"
|
||||
:min="1"
|
||||
:max="10"
|
||||
:step="1"
|
||||
:show-tooltip="false"
|
||||
:disabled="isDisabled"
|
||||
vertical
|
||||
height="90px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ptz-control-panel {
|
||||
background-color: var(--el-bg-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.collapse-arrow {
|
||||
font-size: 12px;
|
||||
transition: transform 0.2s ease;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
&.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
font-size: 14px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.ptz-control-panel.collapsed .panel-content {
|
||||
height: 0;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.control-form {
|
||||
padding: 16px;
|
||||
transform-origin: top;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ptz-control-panel.collapsed .control-form {
|
||||
transform: scaleY(0);
|
||||
}
|
||||
|
||||
.ptz-controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.direction-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.direction-pad {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.direction-btn {
|
||||
--el-button-bg-color: var(--el-color-primary-light-8);
|
||||
--el-button-border-color: var(--el-color-primary-light-5);
|
||||
--el-button-hover-bg-color: var(--el-color-primary-light-7);
|
||||
--el-button-hover-border-color: var(--el-color-primary-light-4);
|
||||
--el-button-active-bg-color: var(--el-color-primary-light-5);
|
||||
--el-button-active-border-color: var(--el-color-primary);
|
||||
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.el-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.direction-btn.up {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.direction-btn.right {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.direction-btn.down {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.direction-btn.left {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.direction-btn.up-left {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.direction-btn.up-right {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.direction-btn.down-left {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.direction-btn.down-right {
|
||||
grid-column: 3;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.direction-center {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
background-color: var(--el-color-primary-light-8);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.control-groups,
|
||||
.control-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.speed-control {
|
||||
height: 120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin-right: 0;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.speed-value {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-radius: 2px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
:deep(.el-slider) {
|
||||
--el-slider-button-size: 10px;
|
||||
--el-slider-height: 2px;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
:deep(.el-slider.is-vertical) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
226
html/NextGB/src/views/realplay/RealplayView.vue
Normal file
226
html/NextGB/src/views/realplay/RealplayView.vue
Normal file
@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onActivated, onDeactivated } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { FullScreen, Setting, Delete } from '@element-plus/icons-vue'
|
||||
import DeviceTree from './DeviceTree.vue'
|
||||
import MonitorGrid from '@/components/monitor/MonitorGrid.vue'
|
||||
import PtzControlPanel from '@/views/realplay/PtzControlPanel.vue'
|
||||
import type { Device, ChannelInfo } from '@/api/types'
|
||||
import type { LayoutConfig } from '@/types/layout'
|
||||
|
||||
// 所有可用的布局键
|
||||
type LayoutKey = '1' | '4' | '9' | '16'
|
||||
type LayoutConfigs = Record<LayoutKey, LayoutConfig>
|
||||
|
||||
// 布局配置
|
||||
const layouts: LayoutConfigs = {
|
||||
'1': { cols: 1, rows: 1, size: 1, label: '单屏' },
|
||||
'4': { cols: 2, rows: 2, size: 4, label: '四分屏' },
|
||||
'9': { cols: 3, rows: 3, size: 9, label: '九分屏' },
|
||||
'16': { cols: 4, rows: 4, size: 16, label: '十六分屏' },
|
||||
} as const
|
||||
|
||||
const monitorGridRef = ref()
|
||||
const selectedChannel = ref<{ device: Device | undefined; channel: ChannelInfo } | null>(null)
|
||||
const activeWindow = ref<{ deviceId: string; channelId: string } | null>(null)
|
||||
const currentLayout = ref<LayoutKey>('9')
|
||||
const showSettings = ref(false)
|
||||
const defaultMuted = ref(true)
|
||||
|
||||
const handleDeviceSelect = (data: { device: Device | undefined; channel: ChannelInfo }) => {
|
||||
selectedChannel.value = data
|
||||
}
|
||||
|
||||
const handleDevicePlay = (data: { device: Device | undefined; channel: ChannelInfo }) => {
|
||||
if (data.channel.device_id) {
|
||||
monitorGridRef.value?.play({
|
||||
...data.device,
|
||||
channel: data.channel,
|
||||
play_type: 0,
|
||||
start_time: 0,
|
||||
end_time: 0,
|
||||
})
|
||||
} else {
|
||||
ElMessage.warning('设备信息不完整')
|
||||
}
|
||||
}
|
||||
|
||||
const handleWindowSelect = (data: { deviceId: string; channelId: string } | null) => {
|
||||
activeWindow.value = data
|
||||
}
|
||||
|
||||
const handlePtzControl = (direction: string) => {
|
||||
if (!activeWindow.value) {
|
||||
ElMessage.warning('请先选择视频窗口')
|
||||
return
|
||||
}
|
||||
console.log('云台控制:', direction, activeWindow.value)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
monitorGridRef.value?.clear()
|
||||
}
|
||||
|
||||
const toggleGridFullscreen = async () => {
|
||||
const gridContainer = document.querySelector('.monitor-grid') as HTMLElement
|
||||
if (!gridContainer) {
|
||||
console.error('未找到视频网格容器')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await gridContainer.requestFullscreen()
|
||||
} else {
|
||||
await document.exitFullscreen()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('全屏切换失败:', err)
|
||||
ElMessage.error('全屏切换失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 添加激活/停用处理
|
||||
onActivated(() => {
|
||||
console.log('MonitorView activated')
|
||||
// 如果需要在重新激活时执行某些操作,可以在这里添加
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
console.log('MonitorView deactivated')
|
||||
// 组件被缓存,不需要清理视频资源
|
||||
})
|
||||
|
||||
// 组件名称(用于 keep-alive)
|
||||
defineOptions({
|
||||
name: 'RealplayView',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="monitor-view">
|
||||
<div class="monitor-layout">
|
||||
<div class="left-panel">
|
||||
<DeviceTree @select="handleDeviceSelect" @play="handleDevicePlay" />
|
||||
<PtzControlPanel
|
||||
title="云台控制"
|
||||
:active-window="activeWindow"
|
||||
@control="handlePtzControl"
|
||||
/>
|
||||
</div>
|
||||
<div class="monitor-grid-container">
|
||||
<div class="grid-toolbar">
|
||||
<div class="layout-controls">
|
||||
<el-radio-group v-model="currentLayout" size="small">
|
||||
<el-radio-button v-for="(layout, key) in layouts" :key="key" :value="key">
|
||||
{{ layout.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<el-button-group>
|
||||
<el-button size="small" @click="showSettings = true" :title="'设置'">
|
||||
<el-icon><Setting /></el-icon>
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
size="small"
|
||||
@click="clearAll"
|
||||
:title="'清空所有设备'"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
<el-button size="small" @click="toggleGridFullscreen" :title="'全屏'">
|
||||
<el-icon><FullScreen /></el-icon>
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</div>
|
||||
<MonitorGrid
|
||||
ref="monitorGridRef"
|
||||
v-model="currentLayout"
|
||||
:layouts="layouts"
|
||||
:default-muted="defaultMuted"
|
||||
:show-border="true"
|
||||
@window-select="handleWindowSelect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置对话框 -->
|
||||
<el-dialog v-model="showSettings" title="设置" width="400px" destroy-on-close>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="默认静音">
|
||||
<el-switch v-model="defaultMuted" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="showSettings = false">取消</el-button>
|
||||
<el-button type="primary" @click="showSettings = false">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.monitor-view {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.monitor-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.monitor-grid-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.grid-toolbar {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-button-group .el-button--small) {
|
||||
padding: 5px 11px;
|
||||
}
|
||||
|
||||
:deep(.el-radio-group .el-radio-button__inner) {
|
||||
padding: 5px 15px;
|
||||
}
|
||||
|
||||
:deep(.el-radio-group) {
|
||||
--el-button-bg-color: var(--el-fill-color-blank);
|
||||
--el-button-hover-bg-color: var(--el-fill-color);
|
||||
--el-button-active-bg-color: var(--el-color-primary);
|
||||
--el-button-text-color: var(--el-text-color-regular);
|
||||
--el-button-hover-text-color: var(--el-text-color-primary);
|
||||
--el-button-active-text-color: #fff;
|
||||
--el-button-border-color: var(--el-border-color);
|
||||
--el-button-hover-border-color: var(--el-border-color-hover);
|
||||
}
|
||||
</style>
|
||||
25
html/NextGB/src/views/setting/SettingsView.vue
Normal file
25
html/NextGB/src/views/setting/SettingsView.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import SystemForm from '@/views/setting/SystemForm.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-view">
|
||||
<h1>系统设置</h1>
|
||||
<div class="settings-content">
|
||||
<SystemForm />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-view {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
margin-top: 20px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
36
html/NextGB/src/views/setting/SystemForm.vue
Normal file
36
html/NextGB/src/views/setting/SystemForm.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const formData = ref({
|
||||
systemName: 'demo',
|
||||
storagePath: '/data/recordings',
|
||||
retention: 30,
|
||||
autoCleanup: true,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-form :model="formData" label-width="120px">
|
||||
<el-form-item label="系统名称">
|
||||
<el-input v-model="formData.systemName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="存储路径">
|
||||
<el-input v-model="formData.storagePath" />
|
||||
</el-form-item>
|
||||
<el-form-item label="保留天数">
|
||||
<el-input-number v-model="formData.retention" :min="1" :max="365" />
|
||||
</el-form-item>
|
||||
<el-form-item label="自动清理">
|
||||
<el-switch v-model="formData.autoCleanup" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary">保存设置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.el-form {
|
||||
max-width: 600px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user