NextGB, web demo powerd by vue

This commit is contained in:
chenhaibo
2025-02-03 16:27:46 +08:00
parent 0b7126b12b
commit c80247286e
113 changed files with 16731 additions and 9944 deletions

379
html/NextGB/src/App.vue Normal file
View 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>

View 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

View 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
}

View 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}`)
}
}

View 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
}
}

View 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
}

View 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
}
}

View 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
}
// 媒体服务器类型

View 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;
}

View 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

View 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View 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>

View 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>

View 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
View 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
View 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')

View 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

View 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 = []
}

View 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
View File

@ -0,0 +1,7 @@
declare global {
interface Document {
_vue_app_is_switching_route?: boolean
}
}
export {}

View File

@ -0,0 +1,6 @@
export interface LayoutConfig {
cols: number
rows: number
size: number
label: string
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>