NextGB, web demo powerd by vue
This commit is contained in:
9
.vscode/launch.json
vendored
9
.vscode/launch.json
vendored
@ -5,13 +5,14 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Launch Package",
|
"name": "Launch Binary",
|
||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"mode": "exec",
|
||||||
"program": "${workspaceFolder}/main",
|
"program": "${workspaceFolder}/bin/srs-sip",
|
||||||
|
"cwd": "${workspaceFolder}/bin",
|
||||||
"env": {},
|
"env": {},
|
||||||
"args": ["-sip-port", "5080", "-media-addr", "127.0.0.1:1985"]
|
"args": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
74
Dockerfile
Normal file
74
Dockerfile
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# 引入SRS
|
||||||
|
FROM ossrs/srs:v6.0.155 AS srs
|
||||||
|
|
||||||
|
# 前端构建阶段
|
||||||
|
FROM node:20-slim AS frontend-builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY html/NextGB/package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY html/NextGB/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 后端构建阶段
|
||||||
|
FROM golang:1.23 AS backend-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/srs-sip main/main.go
|
||||||
|
|
||||||
|
# 最终运行阶段
|
||||||
|
FROM ubuntu:22.04
|
||||||
|
WORKDIR /usr/local
|
||||||
|
|
||||||
|
# 设置时区
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y ca-certificates tzdata supervisor && \
|
||||||
|
ln -fs /usr/share/zoneinfo/$TZ /etc/localtime && \
|
||||||
|
dpkg-reconfigure -f noninteractive tzdata && \
|
||||||
|
apt-get clean && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# 复制SRS
|
||||||
|
COPY --from=srs /usr/local/srs /usr/local/srs
|
||||||
|
COPY conf/srs.conf /usr/local/srs/conf/
|
||||||
|
|
||||||
|
# 复制前端构建产物到html目录
|
||||||
|
COPY --from=frontend-builder /app/frontend/dist /usr/local/srs-sip/html
|
||||||
|
|
||||||
|
# 复制后端构建产物
|
||||||
|
COPY --from=backend-builder /app/srs-sip /usr/local/srs-sip/
|
||||||
|
COPY conf/config.yaml /usr/local/srs-sip/
|
||||||
|
|
||||||
|
# 创建supervisor配置
|
||||||
|
RUN mkdir -p /etc/supervisor/conf.d
|
||||||
|
RUN echo "[supervisord]\n\
|
||||||
|
nodaemon=true\n\
|
||||||
|
user=root\n\
|
||||||
|
logfile=/dev/stdout\n\
|
||||||
|
logfile_maxbytes=0\n\
|
||||||
|
\n\
|
||||||
|
[program:srs]\n\
|
||||||
|
command=/usr/local/srs/objs/srs -c /usr/local/srs/conf/srs.conf\n\
|
||||||
|
directory=/usr/local/srs\n\
|
||||||
|
autostart=true\n\
|
||||||
|
autorestart=true\n\
|
||||||
|
stdout_logfile=/dev/stdout\n\
|
||||||
|
stdout_logfile_maxbytes=0\n\
|
||||||
|
stderr_logfile=/dev/stderr\n\
|
||||||
|
stderr_logfile_maxbytes=0\n\
|
||||||
|
\n\
|
||||||
|
[program:srs-sip]\n\
|
||||||
|
command=/usr/local/srs-sip/srs-sip\n\
|
||||||
|
directory=/usr/local/srs-sip\n\
|
||||||
|
autostart=true\n\
|
||||||
|
autorestart=true\n\
|
||||||
|
stdout_logfile=/dev/stdout\n\
|
||||||
|
stdout_logfile_maxbytes=0\n\
|
||||||
|
stderr_logfile=/dev/stderr\n\
|
||||||
|
stderr_logfile_maxbytes=0" > /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
|
||||||
|
EXPOSE 1935 5060 8025 9000 5060/udp 8000/udp
|
||||||
|
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||||
16
Makefile
16
Makefile
@ -2,6 +2,7 @@ GOCMD=go
|
|||||||
GOBUILD=$(GOCMD) build
|
GOBUILD=$(GOCMD) build
|
||||||
BINARY_NAME=bin/srs-sip
|
BINARY_NAME=bin/srs-sip
|
||||||
MAIN_PATH=main/main.go
|
MAIN_PATH=main/main.go
|
||||||
|
VUE_DIR=html/NextGB
|
||||||
|
|
||||||
default: build
|
default: build
|
||||||
|
|
||||||
@ -10,6 +11,8 @@ build:
|
|||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(BINARY_NAME)
|
rm -f $(BINARY_NAME)
|
||||||
|
rm -rf $(VUE_DIR)/dist
|
||||||
|
rm -rf $(VUE_DIR)/node_modules
|
||||||
|
|
||||||
run:
|
run:
|
||||||
$(GOBUILD) -o $(BINARY_NAME) $(MAIN_PATH)
|
$(GOBUILD) -o $(BINARY_NAME) $(MAIN_PATH)
|
||||||
@ -19,4 +22,15 @@ install:
|
|||||||
$(GOBUILD) -o $(BINARY_NAME) $(MAIN_PATH)
|
$(GOBUILD) -o $(BINARY_NAME) $(MAIN_PATH)
|
||||||
mv $(BINARY_NAME) /usr/local/bin
|
mv $(BINARY_NAME) /usr/local/bin
|
||||||
|
|
||||||
.PHONY: clean
|
vue-install:
|
||||||
|
cd $(VUE_DIR) && npm install
|
||||||
|
|
||||||
|
vue-build:
|
||||||
|
cd $(VUE_DIR) && npm run build
|
||||||
|
|
||||||
|
vue-dev:
|
||||||
|
cd $(VUE_DIR) && npm run dev
|
||||||
|
|
||||||
|
all: build vue-build
|
||||||
|
|
||||||
|
.PHONY: clean vue-install vue-build vue-dev all
|
||||||
|
|||||||
23
README.md
23
README.md
@ -3,34 +3,31 @@
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Pre-requisites:
|
Pre-requisites:
|
||||||
- Go 1.20+ is installed
|
- Go 1.23+
|
||||||
- GOPATH/bin is in your PATH
|
- Node 20+
|
||||||
|
|
||||||
Then run
|
Then run
|
||||||
```
|
```
|
||||||
git clone https://github.com/ossrs/srs-sip
|
git clone https://github.com/ossrs/srs-sip
|
||||||
cd srs-sip
|
cd srs-sip
|
||||||
./bootstrap.sh
|
./build.sh
|
||||||
mage
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If you are on a Unix-like system, you can also run the following command.
|
If on Windows
|
||||||
```
|
```
|
||||||
make
|
./build.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the program:
|
Run the program:
|
||||||
|
|
||||||
```
|
```
|
||||||
./bin/srs-sip -sip-port 5060 -media-addr 127.0.0.1:1985 -api-port 2020 -http-server-port 8888
|
./bin/srs-sip
|
||||||
```
|
```
|
||||||
|
|
||||||
- `sip-port` : the SIP port, this program listen on, for device register with gb28181
|
Use docker
|
||||||
- `media-addr` : the API address for SRS, typically on port 1985, used to send HTTP requests to "/gb/v1/publish"
|
```
|
||||||
- `api-port`: The API server port, used to send HTTP requests, for example "/srs-sip/v1/channels"
|
docker run -id -p 1985:1985 -p 2025:2025 -p 5060:5060 -p 8025:8025 -p 9000:9000 -p 5060:5060/udp -p 8000:8000/udp --name srs-sip --env CANDIDATE=your_ip xiaoniu008/srs-sip:alpha
|
||||||
- `http-server-port`: The demo web server.
|
```
|
||||||
|
|
||||||
Access http://localhost:8888 in web browser.
|
|
||||||
|
|
||||||
## Sequence
|
## Sequence
|
||||||
|
|
||||||
|
|||||||
49
bootstrap.sh
49
bootstrap.sh
@ -1,49 +0,0 @@
|
|||||||
add_gopath_to_path() {
|
|
||||||
GOPATH=$(go env GOPATH)
|
|
||||||
# Check if GOPATH is set
|
|
||||||
if [ -z "$GOPATH" ]; then
|
|
||||||
echo "GOPATH is not set."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if $GOPATH/bin is already in ~/.bashrc
|
|
||||||
if grep -q "$GOPATH/bin" ~/.bashrc; then
|
|
||||||
echo "$GOPATH/bin is already in PATH."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Add $GOPATH/bin to PATH
|
|
||||||
echo "export PATH=\$PATH:$GOPATH/bin" >> ~/.bashrc
|
|
||||||
source ~/.bashrc
|
|
||||||
|
|
||||||
echo "$GOPATH/bin has been added to PATH."
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if ! command -v mage &> /dev/null
|
|
||||||
then
|
|
||||||
pushd /tmp
|
|
||||||
|
|
||||||
OS_IS_LINUX=$(uname -s |grep -q Linux && echo YES)
|
|
||||||
if [ "$OS_IS_LINUX" == "YES" ]; then
|
|
||||||
add_gopath_to_path
|
|
||||||
if [ $? -eq 1 ]; then
|
|
||||||
echo "error: Failed to add $GOPATH/bin to PATH."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
git clone https://github.com/magefile/mage
|
|
||||||
cd mage
|
|
||||||
go run bootstrap.go
|
|
||||||
rm -rf /tmp/mage
|
|
||||||
popd
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! command -v mage &> /dev/null
|
|
||||||
then
|
|
||||||
echo "error: Ensure `go env GOPATH`/bin is in your \$PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
go mod download
|
|
||||||
137
build.bat
Normal file
137
build.bat
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
set BINARY_NAME=bin\srs-sip.exe
|
||||||
|
set MAIN_PATH=main\main.go
|
||||||
|
set VUE_DIR=html\NextGB
|
||||||
|
set CONFIG_FILE=conf\config.yaml
|
||||||
|
|
||||||
|
if "%1"=="" goto all
|
||||||
|
if "%1"=="build" goto build
|
||||||
|
if "%1"=="clean" goto clean
|
||||||
|
if "%1"=="run" goto run
|
||||||
|
if "%1"=="vue-install" goto vue-install
|
||||||
|
if "%1"=="vue-build" goto vue-build
|
||||||
|
if "%1"=="vue-dev" goto vue-dev
|
||||||
|
if "%1"=="all" goto all
|
||||||
|
|
||||||
|
:build
|
||||||
|
echo Building Go binary...
|
||||||
|
if not exist "bin" mkdir bin
|
||||||
|
go build -o %BINARY_NAME% %MAIN_PATH%
|
||||||
|
|
||||||
|
echo Copying config file...
|
||||||
|
if exist "%CONFIG_FILE%" (
|
||||||
|
mkdir "bin\%~dp0%CONFIG_FILE%" 2>nul
|
||||||
|
xcopy /s /i /y "%CONFIG_FILE%" "bin\%~dp0%CONFIG_FILE%\"
|
||||||
|
echo Config file copied to bin\%~dp0%CONFIG_FILE%
|
||||||
|
) else (
|
||||||
|
echo Warning: %CONFIG_FILE% not found
|
||||||
|
)
|
||||||
|
goto :eof
|
||||||
|
|
||||||
|
:clean
|
||||||
|
echo Cleaning...
|
||||||
|
if exist %BINARY_NAME% del /F /Q %BINARY_NAME%
|
||||||
|
if exist %VUE_DIR%\dist rd /S /Q %VUE_DIR%\dist
|
||||||
|
if exist %VUE_DIR%\node_modules rd /S /Q %VUE_DIR%\node_modules
|
||||||
|
if exist bin\html rd /S /Q bin\html
|
||||||
|
if exist bin\%CONFIG_FILE% del /F /Q bin\%CONFIG_FILE%
|
||||||
|
goto :eof
|
||||||
|
|
||||||
|
:run
|
||||||
|
echo Running application...
|
||||||
|
go build -o %BINARY_NAME% %MAIN_PATH%
|
||||||
|
%BINARY_NAME%
|
||||||
|
goto :eof
|
||||||
|
|
||||||
|
:vue-install
|
||||||
|
echo Installing Vue dependencies...
|
||||||
|
cd %VUE_DIR%
|
||||||
|
call npm install
|
||||||
|
cd ..\..
|
||||||
|
goto :eof
|
||||||
|
|
||||||
|
:vue-build
|
||||||
|
echo Building Vue project...
|
||||||
|
if not exist "%VUE_DIR%" (
|
||||||
|
echo Error: Vue directory not found at %VUE_DIR%
|
||||||
|
goto :eof
|
||||||
|
)
|
||||||
|
|
||||||
|
rem Check Node.js version
|
||||||
|
where node >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Error: Node.js is not installed
|
||||||
|
goto :eof
|
||||||
|
)
|
||||||
|
|
||||||
|
for /f "tokens=1,2,3 delims=." %%a in ('node -v') do (
|
||||||
|
set NODE_MAJOR=%%a
|
||||||
|
)
|
||||||
|
set NODE_MAJOR=%NODE_MAJOR:~1%
|
||||||
|
if %NODE_MAJOR% LSS 17 (
|
||||||
|
echo Error: Node.js version 17 or higher is required ^(current version: %NODE_MAJOR%^)
|
||||||
|
echo Please upgrade Node.js using the official installer or nvm-windows
|
||||||
|
goto :eof
|
||||||
|
)
|
||||||
|
|
||||||
|
pushd %VUE_DIR%
|
||||||
|
echo Current directory: %CD%
|
||||||
|
if not exist "package.json" (
|
||||||
|
echo Error: package.json not found in %VUE_DIR%
|
||||||
|
popd
|
||||||
|
goto :eof
|
||||||
|
)
|
||||||
|
|
||||||
|
rem Check if node_modules exists and install dependencies if needed
|
||||||
|
if not exist "node_modules" (
|
||||||
|
echo Node modules not found, installing dependencies...
|
||||||
|
call npm install
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Error: Failed to install dependencies
|
||||||
|
popd
|
||||||
|
goto :eof
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Running npm run build...
|
||||||
|
call npm run build
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Error: Vue build failed
|
||||||
|
popd
|
||||||
|
goto :eof
|
||||||
|
)
|
||||||
|
popd
|
||||||
|
echo Vue build completed successfully
|
||||||
|
|
||||||
|
echo Copying dist files to bin directory...
|
||||||
|
if exist bin\html rd /S /Q bin\html
|
||||||
|
if not exist bin mkdir bin
|
||||||
|
if not exist "%VUE_DIR%\dist" (
|
||||||
|
echo Error: Vue dist directory not found at %VUE_DIR%\dist
|
||||||
|
goto :eof
|
||||||
|
)
|
||||||
|
robocopy "%VUE_DIR%\dist" "bin\html" /E /NFL /NDL /NJH /NJS /nc /ns /np
|
||||||
|
if errorlevel 8 (
|
||||||
|
echo Error copying files
|
||||||
|
) else (
|
||||||
|
echo Vue dist files successfully copied to bin\html
|
||||||
|
)
|
||||||
|
goto :eof
|
||||||
|
|
||||||
|
:vue-dev
|
||||||
|
echo Starting Vue development server...
|
||||||
|
cd %VUE_DIR%
|
||||||
|
call npm run dev
|
||||||
|
cd ..\..
|
||||||
|
goto :eof
|
||||||
|
|
||||||
|
:all
|
||||||
|
echo Building entire project...
|
||||||
|
call :build
|
||||||
|
call :vue-build
|
||||||
|
echo.
|
||||||
|
echo Press any key to exit...
|
||||||
|
pause>nul
|
||||||
|
goto :eof
|
||||||
167
build.sh
Executable file
167
build.sh
Executable file
@ -0,0 +1,167 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
BINARY_NAME="bin/srs-sip"
|
||||||
|
MAIN_PATH="main/main.go"
|
||||||
|
VUE_DIR="html/NextGB"
|
||||||
|
CONFIG_FILE="conf/config.yaml"
|
||||||
|
|
||||||
|
# 检测操作系统类型
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Darwin*)
|
||||||
|
echo "Mac OS X detected"
|
||||||
|
;;
|
||||||
|
Linux*)
|
||||||
|
echo "Linux detected"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown operating system"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
build() {
|
||||||
|
echo "Building Go binary..."
|
||||||
|
mkdir -p bin
|
||||||
|
go build -o ${BINARY_NAME} ${MAIN_PATH}
|
||||||
|
|
||||||
|
echo "Copying config file..."
|
||||||
|
if [ -f "${CONFIG_FILE}" ]; then
|
||||||
|
mkdir -p "bin/$(dirname ${CONFIG_FILE})"
|
||||||
|
cp -a "${CONFIG_FILE}" "bin/$(dirname ${CONFIG_FILE})/"
|
||||||
|
echo "Config file copied to bin/$(dirname ${CONFIG_FILE})/"
|
||||||
|
else
|
||||||
|
echo "Warning: ${CONFIG_FILE} not found"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
clean() {
|
||||||
|
echo "Cleaning..."
|
||||||
|
rm -rf ${BINARY_NAME}
|
||||||
|
rm -rf ${VUE_DIR}/dist
|
||||||
|
rm -rf ${VUE_DIR}/node_modules
|
||||||
|
rm -rf bin/html
|
||||||
|
rm -rf bin/${CONFIG_FILE}
|
||||||
|
}
|
||||||
|
|
||||||
|
run() {
|
||||||
|
echo "Running application..."
|
||||||
|
go build -o ${BINARY_NAME} ${MAIN_PATH}
|
||||||
|
./${BINARY_NAME}
|
||||||
|
}
|
||||||
|
|
||||||
|
vue_install() {
|
||||||
|
echo "Installing Vue dependencies..."
|
||||||
|
cd ${VUE_DIR}
|
||||||
|
npm install
|
||||||
|
cd ../..
|
||||||
|
}
|
||||||
|
|
||||||
|
vue_build() {
|
||||||
|
echo "Building Vue project..."
|
||||||
|
if [ ! -d "${VUE_DIR}" ]; then
|
||||||
|
echo "Error: Vue directory not found at ${VUE_DIR}"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check Node.js version
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo "Error: Node.js is not installed"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NODE_VERSION=$(node -v | cut -d "v" -f 2)
|
||||||
|
NODE_MAJOR_VERSION=$(echo $NODE_VERSION | cut -d "." -f 1)
|
||||||
|
if [ "$NODE_MAJOR_VERSION" -lt 17 ]; then
|
||||||
|
echo "Error: Node.js version 17 or higher is required (current version: $NODE_VERSION)"
|
||||||
|
echo "Please upgrade Node.js using your package manager or nvm"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
pushd ${VUE_DIR} > /dev/null
|
||||||
|
echo "Current directory: $(pwd)"
|
||||||
|
|
||||||
|
if [ ! -f "package.json" ]; then
|
||||||
|
echo "Error: package.json not found in ${VUE_DIR}"
|
||||||
|
popd > /dev/null
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if node_modules exists and install dependencies if needed
|
||||||
|
if [ ! -d "node_modules" ] || [ ! -f "node_modules/.package-lock.json" ]; then
|
||||||
|
echo "Node modules not found or incomplete, installing dependencies..."
|
||||||
|
npm install
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Failed to install dependencies"
|
||||||
|
popd > /dev/null
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Running npm run build..."
|
||||||
|
npm run build
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "Error: Vue build failed"
|
||||||
|
popd > /dev/null
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
popd > /dev/null
|
||||||
|
echo "Vue build completed successfully"
|
||||||
|
|
||||||
|
echo "Copying dist files to bin directory..."
|
||||||
|
rm -rf bin/html
|
||||||
|
mkdir -p bin
|
||||||
|
if [ ! -d "${VUE_DIR}/dist" ]; then
|
||||||
|
echo "Error: Vue dist directory not found at ${VUE_DIR}/dist"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
cp -r "${VUE_DIR}/dist" "bin/html"
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
echo "Vue dist files successfully copied to bin/html"
|
||||||
|
else
|
||||||
|
echo "Error copying files"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
vue_dev() {
|
||||||
|
echo "Starting Vue development server..."
|
||||||
|
cd ${VUE_DIR}
|
||||||
|
npm run dev
|
||||||
|
cd ../..
|
||||||
|
}
|
||||||
|
|
||||||
|
build_all() {
|
||||||
|
clean
|
||||||
|
build
|
||||||
|
vue_build
|
||||||
|
}
|
||||||
|
|
||||||
|
# 根据命令行参数执行相应的功能
|
||||||
|
case "$1" in
|
||||||
|
"build")
|
||||||
|
build
|
||||||
|
;;
|
||||||
|
"clean")
|
||||||
|
clean
|
||||||
|
;;
|
||||||
|
"run")
|
||||||
|
run
|
||||||
|
;;
|
||||||
|
"vue-install")
|
||||||
|
vue_install
|
||||||
|
;;
|
||||||
|
"vue-build")
|
||||||
|
vue_build
|
||||||
|
;;
|
||||||
|
"vue-dev")
|
||||||
|
vue_dev
|
||||||
|
;;
|
||||||
|
"all"|"")
|
||||||
|
build_all
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown command: $1"
|
||||||
|
echo "Usage: $0 {build|clean|run|vue-install|vue-build|vue-dev|all}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
18
conf/config.yaml
Normal file
18
conf/config.yaml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# 通用配置
|
||||||
|
common:
|
||||||
|
log-level: "info"
|
||||||
|
|
||||||
|
# GB28181配置
|
||||||
|
gb28181:
|
||||||
|
serial: "34020000002000000001"
|
||||||
|
realm: "3402000000"
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 5060
|
||||||
|
auth:
|
||||||
|
enable: false
|
||||||
|
password: "123456"
|
||||||
|
|
||||||
|
# HTTP服务配置
|
||||||
|
http:
|
||||||
|
listen: 8025
|
||||||
|
dir: ./html
|
||||||
60
conf/srs.conf
Normal file
60
conf/srs.conf
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
listen 1935;
|
||||||
|
max_connections 1000;
|
||||||
|
# For docker, please use docker logs to manage the logs of SRS.
|
||||||
|
# See https://docs.docker.com/config/containers/logging/
|
||||||
|
srs_log_tank console;
|
||||||
|
daemon off;
|
||||||
|
disable_daemon_for_docker off;
|
||||||
|
http_api {
|
||||||
|
enabled on;
|
||||||
|
listen 1985;
|
||||||
|
raw_api {
|
||||||
|
enabled on;
|
||||||
|
allow_reload on;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http_server {
|
||||||
|
enabled on;
|
||||||
|
listen 8080;
|
||||||
|
dir ./objs/nginx/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
stream_caster {
|
||||||
|
enabled on;
|
||||||
|
caster gb28181;
|
||||||
|
output rtmp://127.0.0.1/live/[stream];
|
||||||
|
listen 9000;
|
||||||
|
sip {
|
||||||
|
enabled off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rtc_server {
|
||||||
|
enabled on;
|
||||||
|
listen 8000; # UDP port
|
||||||
|
# @see https://github.com/ossrs/srs/wiki/v4_CN_WebRTC#config-candidate
|
||||||
|
candidate $CANDIDATE;
|
||||||
|
# Disable for Oryx.
|
||||||
|
use_auto_detect_network_ip off;
|
||||||
|
api_as_candidates off;
|
||||||
|
}
|
||||||
|
|
||||||
|
vhost __defaultVhost__ {
|
||||||
|
http_remux {
|
||||||
|
enabled on;
|
||||||
|
mount [vhost]/[app]/[stream].flv;
|
||||||
|
}
|
||||||
|
rtc {
|
||||||
|
enabled on;
|
||||||
|
nack on;
|
||||||
|
twcc on;
|
||||||
|
stun_timeout 30;
|
||||||
|
dtls_role passive;
|
||||||
|
# @see https://github.com/ossrs/srs/wiki/v4_CN_WebRTC#rtmp-to-rtc
|
||||||
|
rtmp_to_rtc on;
|
||||||
|
keep_bframe off;
|
||||||
|
# @see https://github.com/ossrs/srs/wiki/v4_CN_WebRTC#rtc-to-rtmp
|
||||||
|
rtc_to_rtmp on;
|
||||||
|
pli_for_rtmp 6.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
go.mod
37
go.mod
@ -1,41 +1,52 @@
|
|||||||
module github.com/ossrs/srs-sip
|
module github.com/ossrs/srs-sip
|
||||||
|
|
||||||
go 1.20
|
go 1.23
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/emiago/sipgo v0.22.1
|
||||||
github.com/gorilla/handlers v1.5.2
|
github.com/gorilla/handlers v1.5.2
|
||||||
github.com/gorilla/mux v1.8.1
|
github.com/gorilla/mux v1.8.1
|
||||||
github.com/magefile/mage v1.15.0
|
github.com/ossrs/go-oryx-lib v0.0.10
|
||||||
github.com/ossrs/go-oryx-lib v0.0.9
|
github.com/ossrs/srs-bench v0.0.0-20240708032622-848f9300df56
|
||||||
github.com/ossrs/srs-bench v0.0.0-20230906232735-aa029b492d0f
|
github.com/rs/zerolog v1.33.0
|
||||||
github.com/rs/zerolog v1.32.0
|
golang.org/x/net v0.33.0
|
||||||
golang.org/x/net v0.10.0
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.29.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/emiago/sipgo v0.22.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.3 // indirect
|
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||||
github.com/ghettovoice/gosip v0.0.0-20220929080231-de8ba881be83 // indirect
|
github.com/ghettovoice/gosip v0.0.0-20220929080231-de8ba881be83 // indirect
|
||||||
github.com/gobwas/httphead v0.1.0 // indirect
|
github.com/gobwas/httphead v0.1.0 // indirect
|
||||||
github.com/gobwas/pool v0.2.1 // indirect
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
github.com/gobwas/ws v1.3.2 // indirect
|
github.com/gobwas/ws v1.3.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/icholy/digest v0.1.22 // indirect
|
github.com/icholy/digest v0.1.22 // indirect
|
||||||
github.com/kr/text v0.2.0 // indirect
|
github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pion/randutil v0.1.0 // indirect
|
github.com/pion/randutil v0.1.0 // indirect
|
||||||
github.com/pion/rtp v1.7.13 // indirect
|
github.com/pion/rtp v1.7.13 // indirect
|
||||||
github.com/pion/webrtc/v3 v3.2.9 // indirect
|
github.com/pion/webrtc/v3 v3.2.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b // indirect
|
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.4.2 // indirect
|
||||||
github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5 // indirect
|
github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5 // indirect
|
||||||
github.com/x-cray/logrus-prefixed-formatter v0.5.2 // indirect
|
github.com/x-cray/logrus-prefixed-formatter v0.5.2 // indirect
|
||||||
github.com/yapingcat/gomedia/codec v0.0.0-20220617074658-94762898dc25 // indirect
|
github.com/yapingcat/gomedia/codec v0.0.0-20220617074658-94762898dc25 // indirect
|
||||||
github.com/yapingcat/gomedia/mpeg2 v0.0.0-20220617074658-94762898dc25 // indirect
|
github.com/yapingcat/gomedia/mpeg2 v0.0.0-20220617074658-94762898dc25 // indirect
|
||||||
golang.org/x/crypto v0.9.0 // indirect
|
golang.org/x/crypto v0.31.0 // indirect
|
||||||
golang.org/x/sys v0.19.0 // indirect
|
golang.org/x/sys v0.28.0 // indirect
|
||||||
golang.org/x/term v0.8.0 // indirect
|
golang.org/x/term v0.27.0 // indirect
|
||||||
golang.org/x/text v0.9.0 // indirect
|
golang.org/x/text v0.21.0 // indirect
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||||
|
modernc.org/libc v1.41.0 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.7.2 // indirect
|
||||||
|
modernc.org/strutil v1.2.0 // indirect
|
||||||
|
modernc.org/token v1.1.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
67
go.sum
67
go.sum
@ -1,11 +1,10 @@
|
|||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/discoviking/fsm v0.0.0-20150126104936-f4a273feecca/go.mod h1:W+3LQaEkN8qAwwcw0KC546sUEnX86GIT8CcMLZC4mG0=
|
github.com/discoviking/fsm v0.0.0-20150126104936-f4a273feecca/go.mod h1:W+3LQaEkN8qAwwcw0KC546sUEnX86GIT8CcMLZC4mG0=
|
||||||
github.com/emiago/sipgo v0.22.0 h1:GaQ51m26M9QnVBVY2aDJ/mXqq/BDfZ1A+nW7XgU/4Ts=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/emiago/sipgo v0.22.0/go.mod h1:a77FgPEEjJvfYWYfP3p53u+dNhWEMb/VGVS6guvBzx0=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/emiago/sipgo v0.22.1 h1:imidktnLwl+fUKPAUUhxQJ4E3sDDaMBvoEvUOMJaSOI=
|
github.com/emiago/sipgo v0.22.1 h1:imidktnLwl+fUKPAUUhxQJ4E3sDDaMBvoEvUOMJaSOI=
|
||||||
github.com/emiago/sipgo v0.22.1/go.mod h1:a77FgPEEjJvfYWYfP3p53u+dNhWEMb/VGVS6guvBzx0=
|
github.com/emiago/sipgo v0.22.1/go.mod h1:a77FgPEEjJvfYWYfP3p53u+dNhWEMb/VGVS6guvBzx0=
|
||||||
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
||||||
@ -38,6 +37,7 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
|
|||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
@ -45,17 +45,19 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE
|
|||||||
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/icholy/digest v0.1.22 h1:dRIwCjtAcXch57ei+F0HSb5hmprL873+q7PoVojdMzM=
|
github.com/icholy/digest v0.1.22 h1:dRIwCjtAcXch57ei+F0HSb5hmprL873+q7PoVojdMzM=
|
||||||
github.com/icholy/digest v0.1.22/go.mod h1:uLAeDdWKIWNFMH0wqbwchbTQOmJWhzSnL7zmqSPqEEc=
|
github.com/icholy/digest v0.1.22/go.mod h1:uLAeDdWKIWNFMH0wqbwchbTQOmJWhzSnL7zmqSPqEEc=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
|
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
|
||||||
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||||
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||||
@ -66,8 +68,11 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
|||||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/nxadm/tail v1.4.5/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.5/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||||
@ -83,10 +88,10 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
|
|||||||
github.com/onsi/gomega v1.10.4/go.mod h1:g/HbgYopi++010VEqkFgJHKC09uJiW9UkXvMUuKHUCQ=
|
github.com/onsi/gomega v1.10.4/go.mod h1:g/HbgYopi++010VEqkFgJHKC09uJiW9UkXvMUuKHUCQ=
|
||||||
github.com/onsi/gomega v1.17.0 h1:9Luw4uT5HTjHTN8+aNcSThgH1vdXnmdJ8xIfZ4wyTRE=
|
github.com/onsi/gomega v1.17.0 h1:9Luw4uT5HTjHTN8+aNcSThgH1vdXnmdJ8xIfZ4wyTRE=
|
||||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||||
github.com/ossrs/go-oryx-lib v0.0.9 h1:piZkzit/1hqAcXP31/mvDEDpHVjCmBMmvzF3hN8hUuQ=
|
github.com/ossrs/go-oryx-lib v0.0.10 h1:tyhe21d7UdMstxi0QGJACs2prIxWOw3eSEC8+cZHbQk=
|
||||||
github.com/ossrs/go-oryx-lib v0.0.9/go.mod h1:i2tH4TZBzAw5h+HwGrNOKvP/nmZgSQz0OEnLLdzcT/8=
|
github.com/ossrs/go-oryx-lib v0.0.10/go.mod h1:nDTZDIADYNsuwnFflruKfB5ibQvQxPO2TQIFHJZsnvQ=
|
||||||
github.com/ossrs/srs-bench v0.0.0-20230906232735-aa029b492d0f h1:qvibrAolgLiEgbwtWbUy4Ts48sfURc7+7UaGxi2euyo=
|
github.com/ossrs/srs-bench v0.0.0-20240708032622-848f9300df56 h1:ppDTLPa/5g4u+XqKQmf7urr+Kndk8KOxcsRaSqW0fJE=
|
||||||
github.com/ossrs/srs-bench v0.0.0-20230906232735-aa029b492d0f/go.mod h1:aba1nViJ8Cd37kvuyhUrZ3kY1ASxFldaA8o1pLlZO6Y=
|
github.com/ossrs/srs-bench v0.0.0-20240708032622-848f9300df56/go.mod h1:It9LsTwcB6q9HCPA5pO9ri70wZfZm4GFHpdxwydnktI=
|
||||||
github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0=
|
github.com/pion/datachannel v1.5.5/go.mod h1:iMz+lECmfdCMqFRhXhcA/219B0SQlbpoR2V118yimL0=
|
||||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||||
github.com/pion/ice/v2 v2.3.6/go.mod h1:9/TzKDRwBVAPsC+YOrKH/e3xDrubeTRACU9/sHQarsU=
|
github.com/pion/ice/v2 v2.3.6/go.mod h1:9/TzKDRwBVAPsC+YOrKH/e3xDrubeTRACU9/sHQarsU=
|
||||||
@ -116,16 +121,17 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
|||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||||
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
|
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||||
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||||
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b h1:gQZ0qzfKHQIybLANtM3mBXNUtOfsCFXeTsnBqCsx1KM=
|
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b h1:gQZ0qzfKHQIybLANtM3mBXNUtOfsCFXeTsnBqCsx1KM=
|
||||||
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
github.com/satori/go.uuid v1.2.1-0.20181028125025-b2ce2384e17b/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||||
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
|
github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw=
|
||||||
|
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
|
||||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
@ -133,7 +139,6 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
|||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
@ -157,11 +162,13 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
|||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
|
||||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
|
||||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||||
|
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
@ -177,13 +184,15 @@ golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
|||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@ -202,7 +211,6 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@ -213,8 +221,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@ -222,8 +230,9 @@ golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
|||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||||
golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols=
|
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@ -233,14 +242,16 @@ golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
|||||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@ -269,3 +280,17 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
|
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
|
||||||
gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY=
|
gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY=
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||||
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||||
|
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
|
||||||
|
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||||
|
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||||
|
modernc.org/sqlite v1.29.2 h1:xgBSyA3gemwgP31PWFfFjtBorQNYpeypGdoSDjXhrgI=
|
||||||
|
modernc.org/sqlite v1.29.2/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
6
html/NextGB/.editorconfig
Normal file
6
html/NextGB/.editorconfig
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
2
html/NextGB/.env
Normal file
2
html/NextGB/.env
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
VITE_APP_TITLE=NextGB
|
||||||
|
VITE_APP_API_BASE_URL=
|
||||||
30
html/NextGB/.gitignore
vendored
Normal file
30
html/NextGB/.gitignore
vendored
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
7
html/NextGB/.prettierrc.json
Normal file
7
html/NextGB/.prettierrc.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
8
html/NextGB/.vite/deps/_metadata.json
Normal file
8
html/NextGB/.vite/deps/_metadata.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"hash": "706f8cd6",
|
||||||
|
"configHash": "1f32e48a",
|
||||||
|
"lockfileHash": "f28932da",
|
||||||
|
"browserHash": "b779d841",
|
||||||
|
"optimized": {},
|
||||||
|
"chunks": {}
|
||||||
|
}
|
||||||
3
html/NextGB/.vite/deps/package.json
Normal file
3
html/NextGB/.vite/deps/package.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"type": "module"
|
||||||
|
}
|
||||||
9
html/NextGB/.vscode/extensions.json
vendored
Normal file
9
html/NextGB/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"vitest.explorer",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
45
html/NextGB/README.md
Normal file
45
html/NextGB/README.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# NextGB
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type-Check, Compile and Minify for Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Unit Tests with [Vitest](https://vitest.dev/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:unit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lint with [ESLint](https://eslint.org/)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
1
html/NextGB/env.d.ts
vendored
Normal file
1
html/NextGB/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
27
html/NextGB/eslint.config.js
Normal file
27
html/NextGB/eslint.config.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import vueTsEslintConfig from '@vue/eslint-config-typescript'
|
||||||
|
import pluginVitest from '@vitest/eslint-plugin'
|
||||||
|
import oxlint from 'eslint-plugin-oxlint'
|
||||||
|
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
name: 'app/files-to-lint',
|
||||||
|
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'app/files-to-ignore',
|
||||||
|
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'],
|
||||||
|
},
|
||||||
|
|
||||||
|
...pluginVue.configs['flat/essential'],
|
||||||
|
...vueTsEslintConfig(),
|
||||||
|
|
||||||
|
{
|
||||||
|
...pluginVitest.configs.recommended,
|
||||||
|
files: ['src/**/__tests__/*'],
|
||||||
|
},
|
||||||
|
oxlint.configs['flat/recommended'],
|
||||||
|
skipFormatting,
|
||||||
|
]
|
||||||
13
html/NextGB/index.html
Normal file
13
html/NextGB/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Vite App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7284
html/NextGB/package-lock.json
generated
Normal file
7284
html/NextGB/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
html/NextGB/package.json
Normal file
51
html/NextGB/package.json
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "nextgb",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test:unit": "vitest",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build",
|
||||||
|
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
|
||||||
|
"lint:eslint": "eslint . --fix",
|
||||||
|
"lint": "run-s lint:*",
|
||||||
|
"format": "prettier --write src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.1.0",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"echarts": "^5.4.3",
|
||||||
|
"element-plus": "^2.4.2",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"vue": "^3.3.8",
|
||||||
|
"vue-router": "^4.2.5",
|
||||||
|
"vue-echarts": "^6.6.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tsconfig/node22": "^22.0.0",
|
||||||
|
"@types/jsdom": "^21.1.7",
|
||||||
|
"@types/node": "^22.9.3",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"@vitest/eslint-plugin": "1.1.10",
|
||||||
|
"@vue/eslint-config-prettier": "^10.1.0",
|
||||||
|
"@vue/eslint-config-typescript": "^14.1.3",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
|
"eslint": "^9.14.0",
|
||||||
|
"eslint-plugin-oxlint": "^0.11.0",
|
||||||
|
"eslint-plugin-vue": "^9.30.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"npm-run-all2": "^7.0.1",
|
||||||
|
"oxlint": "^0.11.0",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"typescript": "~5.6.3",
|
||||||
|
"vite": "^6.0.1",
|
||||||
|
"vite-plugin-vue-devtools": "^7.6.5",
|
||||||
|
"vitest": "^2.1.5",
|
||||||
|
"vue-tsc": "^2.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
html/NextGB/public/favicon.ico
Normal file
BIN
html/NextGB/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
379
html/NextGB/src/App.vue
Normal file
379
html/NextGB/src/App.vue
Normal file
@ -0,0 +1,379 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, provide, onMounted } from 'vue'
|
||||||
|
import {
|
||||||
|
Monitor,
|
||||||
|
Setting,
|
||||||
|
Tools,
|
||||||
|
Fold,
|
||||||
|
VideoCamera,
|
||||||
|
User,
|
||||||
|
VideoPlay,
|
||||||
|
DataLine,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { useDefaultMediaServer } from '@/stores/mediaServer'
|
||||||
|
import { fetchDevicesAndChannels } from '@/stores/devices'
|
||||||
|
import { fetchMediaServers } from '@/stores/mediaServer'
|
||||||
|
|
||||||
|
const isCollapse = ref(false)
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
isCollapse.value = !isCollapse.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提供默认媒体服务器
|
||||||
|
const defaultMediaServer = useDefaultMediaServer()
|
||||||
|
provide('defaultMediaServer', defaultMediaServer)
|
||||||
|
|
||||||
|
// 初始化数据
|
||||||
|
const initializeData = async () => {
|
||||||
|
try {
|
||||||
|
// 并行获取设备列表和媒体服务器列表
|
||||||
|
await Promise.all([fetchDevicesAndChannels(), fetchMediaServers()])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('初始化数据失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initializeData()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- 左侧菜单 -->
|
||||||
|
<div class="sidebar" :class="{ 'is-collapse': isCollapse }">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="./assets/logo.svg" alt="Logo" />
|
||||||
|
<span>NextGB</span>
|
||||||
|
</div>
|
||||||
|
<el-menu :collapse="isCollapse" default-active="1" class="sidebar-menu">
|
||||||
|
<el-menu-item index="dashboard" @click="$router.push('/dashboard')">
|
||||||
|
<el-icon><DataLine /></el-icon>
|
||||||
|
<span>系统概览</span>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-menu-item index="realplay" @click="$router.push('/realplay')">
|
||||||
|
<el-icon><Monitor /></el-icon>
|
||||||
|
<span>实时监控</span>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-menu-item index="playback" @click="$router.push('/playback')">
|
||||||
|
<el-icon><VideoPlay /></el-icon>
|
||||||
|
<span>录像回放</span>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-menu-item index="media" @click="$router.push('/media')">
|
||||||
|
<el-icon><VideoCamera /></el-icon>
|
||||||
|
<span>流媒体服务</span>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-sub-menu index="device">
|
||||||
|
<template #title>
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
<span>设备管理</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="device-list" @click="$router.push('/devices')">
|
||||||
|
设备列表
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
|
||||||
|
<el-sub-menu index="system">
|
||||||
|
<template #title>
|
||||||
|
<el-icon><Tools /></el-icon>
|
||||||
|
<span>系统设置</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="settings" @click="$router.push('/settings')">基本设置</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
</el-menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧内容区 -->
|
||||||
|
<div class="main-container">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<el-button @click="toggleSidebar">
|
||||||
|
<el-icon><Fold /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<el-dropdown>
|
||||||
|
<span class="user-info">
|
||||||
|
<el-icon class="avatar-icon"><User /></el-icon>
|
||||||
|
<span>管理员</span>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item>个人信息</el-dropdown-item>
|
||||||
|
<el-dropdown-item>退出登录</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主要内容区域 -->
|
||||||
|
<div class="main-content">
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<keep-alive :include="['RealplayView', 'PlaybackView']">
|
||||||
|
<component :is="Component" />
|
||||||
|
</keep-alive>
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: hidden; /* 防止横向溢出 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 240px;
|
||||||
|
background-color: #304156;
|
||||||
|
color: #fff;
|
||||||
|
transition: width 0.3s;
|
||||||
|
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.15);
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #2b3a4d;
|
||||||
|
border-bottom: 1px solid #1f2d3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
transition: margin 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo span {
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
border-right: none !important;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义菜单样式 */
|
||||||
|
:deep(.el-menu) {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-menu-item) {
|
||||||
|
height: 50px;
|
||||||
|
line-height: 50px;
|
||||||
|
color: #bfcbd9;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #263445 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background-color: #1890ff !important;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-sub-menu__title) {
|
||||||
|
height: 50px;
|
||||||
|
line-height: 50px;
|
||||||
|
color: #bfcbd9;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #263445 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-menu--collapse) {
|
||||||
|
width: 64px;
|
||||||
|
|
||||||
|
.el-sub-menu__title span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-sub-menu__title .el-sub-menu__icon-arrow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠状态下的样式 */
|
||||||
|
.is-collapse {
|
||||||
|
width: 64px;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
padding: 0 16px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
opacity: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图标样式 */
|
||||||
|
:deep(.el-menu-item .el-icon),
|
||||||
|
:deep(.el-sub-menu__title .el-icon) {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-right: 12px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: margin-left 0.3s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.is-collapse + .main-container {
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
height: 60px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
.avatar-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 8px;
|
||||||
|
background-color: #e6e6e6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f0f2f5;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 修改二级菜单样式 */
|
||||||
|
:deep(.el-sub-menu) {
|
||||||
|
.el-menu {
|
||||||
|
background-color: #1f2d3d !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-menu-item {
|
||||||
|
padding-left: 54px !important;
|
||||||
|
height: 44px;
|
||||||
|
line-height: 44px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #001528 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background-color: #1890ff !important;
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化子菜单标题样式 */
|
||||||
|
:deep(.el-sub-menu__title) {
|
||||||
|
&:hover {
|
||||||
|
background-color: #263445 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-sub-menu__icon-arrow {
|
||||||
|
right: 15px;
|
||||||
|
margin-top: -4px;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开状态的箭头动画 */
|
||||||
|
:deep(.el-sub-menu.is-opened) {
|
||||||
|
> .el-sub-menu__title {
|
||||||
|
color: #f4f4f5;
|
||||||
|
|
||||||
|
.el-sub-menu__icon-arrow {
|
||||||
|
transform: rotateZ(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 折叠状态下的弹出菜单样式 */
|
||||||
|
:deep(.el-menu--popup) {
|
||||||
|
background-color: #1f2d3d !important;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
.el-menu-item {
|
||||||
|
height: 44px;
|
||||||
|
line-height: 44px;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 20px !important;
|
||||||
|
color: #bfcbd9;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #001528 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background-color: #1890ff !important;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 修改菜单过渡动画 */
|
||||||
|
:deep(.el-menu-item),
|
||||||
|
:deep(.el-sub-menu__title) {
|
||||||
|
transition:
|
||||||
|
background-color 0.3s,
|
||||||
|
color 0.3s,
|
||||||
|
border-color 0.3s;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
95
html/NextGB/src/api/index.ts
Normal file
95
html/NextGB/src/api/index.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type * as Types from './types'
|
||||||
|
import type { MediaServer } from '@/api/mediaserver/types'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_APP_API_BASE_URL,
|
||||||
|
timeout: 5000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 媒体服务器相关 API
|
||||||
|
export const mediaServerApi = {
|
||||||
|
// 获取媒体服务器列表
|
||||||
|
getMediaServers: () =>
|
||||||
|
api.get<Types.ApiResponse<MediaServer[]>>('/srs-sip/v1/media-servers'),
|
||||||
|
|
||||||
|
// 添加媒体服务器
|
||||||
|
addMediaServer: (data: Omit<MediaServer, 'id' | 'status' | 'created_at'>) =>
|
||||||
|
api.post<Types.ApiResponse<{ msg: string }>>('/srs-sip/v1/media-servers', data),
|
||||||
|
|
||||||
|
// 删除媒体服务器
|
||||||
|
deleteMediaServer: (id: number) =>
|
||||||
|
api.delete<Types.ApiResponse<{ msg: string }>>(`/srs-sip/v1/media-servers/${id}`),
|
||||||
|
|
||||||
|
// 设置默认媒体服务器
|
||||||
|
setDefaultMediaServer: (id: number) =>
|
||||||
|
api.post<Types.ApiResponse<{ msg: string }>>(`/srs-sip/v1/media-servers/default/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备相关 API
|
||||||
|
export const deviceApi = {
|
||||||
|
// 获取设备列表
|
||||||
|
getDevices: () => api.get<Types.ApiResponse<Types.Device[]>>('/srs-sip/v1/devices'),
|
||||||
|
|
||||||
|
// 获取设备通道
|
||||||
|
getDeviceChannels: (deviceId: string) =>
|
||||||
|
api.get<Types.ApiResponse<Types.ChannelInfo[]>>(`/srs-sip/v1/devices/${deviceId}/channels`),
|
||||||
|
|
||||||
|
// 添加 invite API
|
||||||
|
invite: (params: Types.InviteRequest) =>
|
||||||
|
api.post<Types.ApiResponse<Types.InviteResponse>>('/srs-sip/v1/invite', params),
|
||||||
|
|
||||||
|
// 停止播放
|
||||||
|
bye: (params: Types.ByeRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/bye', params),
|
||||||
|
|
||||||
|
// 暂停播放
|
||||||
|
pause: (params: Types.PauseRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/pause', params),
|
||||||
|
|
||||||
|
// 恢复播放
|
||||||
|
resume: (params: Types.ResumeRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/resume', params),
|
||||||
|
|
||||||
|
// 设置播放速度
|
||||||
|
speed: (params: Types.SpeedRequest) => api.post<Types.ApiResponse<any>>('/srs-sip/v1/speed', params),
|
||||||
|
|
||||||
|
// 云台控制
|
||||||
|
controlPTZ: (params: Types.PTZControlRequest) =>
|
||||||
|
api.post<Types.ApiResponse<any>>('/srs-sip/v1/ptz', params),
|
||||||
|
|
||||||
|
// 查询录像
|
||||||
|
queryRecord: (params: Types.RecordInfoRequest) =>
|
||||||
|
api.post<Types.ApiResponse<Types.RecordInfoResponse[]>>('/srs-sip/v1/query-record', params),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// 配置处理逻辑
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const res = response.data as Types.ApiResponse<any>
|
||||||
|
if (res.code !== 0) {
|
||||||
|
ElMessage.error('请求失败')
|
||||||
|
return Promise.reject(new Error('请求失败'))
|
||||||
|
}
|
||||||
|
response.data = res.data
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
ElMessage.error(error.response?.data?.message || '网络错误')
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
30
html/NextGB/src/api/mediaserver/base.ts
Normal file
30
html/NextGB/src/api/mediaserver/base.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import type { ClientInfo, StreamInfo, VersionInfo, RtcPlayer } from './types'
|
||||||
|
import { MediaServerType } from './types'
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 媒体服务器接口
|
||||||
|
*/
|
||||||
|
export interface IMediaServer {
|
||||||
|
type: MediaServerType
|
||||||
|
getVersion(): Promise<VersionInfo>
|
||||||
|
getStreamInfo(): Promise<StreamInfo[]>
|
||||||
|
getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]>
|
||||||
|
createRtcPlayer(): RtcPlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 媒体服务器基础实现类
|
||||||
|
*/
|
||||||
|
export abstract class BaseMediaServer implements IMediaServer {
|
||||||
|
type: MediaServerType
|
||||||
|
|
||||||
|
constructor(type: MediaServerType) {
|
||||||
|
this.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract getVersion(): Promise<VersionInfo>
|
||||||
|
abstract getStreamInfo(): Promise<StreamInfo[]>
|
||||||
|
abstract getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]>
|
||||||
|
abstract createRtcPlayer(): RtcPlayer
|
||||||
|
}
|
||||||
22
html/NextGB/src/api/mediaserver/factory.ts
Normal file
22
html/NextGB/src/api/mediaserver/factory.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import type { MediaServer } from './types'
|
||||||
|
import { MediaServerType } from './types'
|
||||||
|
import type { BaseMediaServer } from './base'
|
||||||
|
import { SRSServer } from './srs/srs'
|
||||||
|
import { ZLMServer } from './zlm/zlm'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建媒体服务器实例的工厂函数
|
||||||
|
*/
|
||||||
|
export const createMediaServer = (config: MediaServer): BaseMediaServer => {
|
||||||
|
// 统一转换为小写进行比较
|
||||||
|
const serverType = config.type.toLowerCase()
|
||||||
|
|
||||||
|
switch (serverType) {
|
||||||
|
case MediaServerType.SRS:
|
||||||
|
return new SRSServer(config.ip, config.port)
|
||||||
|
case MediaServerType.ZLM:
|
||||||
|
return new ZLMServer(config.ip, config.port, config.secret)
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported media server type: ${config.type}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
364
html/NextGB/src/api/mediaserver/srs/srs.ts
Normal file
364
html/NextGB/src/api/mediaserver/srs/srs.ts
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
import type { ClientInfo, StreamInfo, VersionInfo, RtcPlayer } from '@/api/mediaserver/types'
|
||||||
|
import { MediaServerType } from '@/api/mediaserver/types'
|
||||||
|
import { BaseMediaServer } from '@/api/mediaserver/base'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
interface SRSVersionResponse {
|
||||||
|
code: number
|
||||||
|
server: string
|
||||||
|
service: string
|
||||||
|
pid: string
|
||||||
|
data: {
|
||||||
|
major: number
|
||||||
|
minor: number
|
||||||
|
revision: number
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SRSClientsResponse {
|
||||||
|
code: number
|
||||||
|
server: string
|
||||||
|
service: string
|
||||||
|
pid: string
|
||||||
|
clients: {
|
||||||
|
id: string
|
||||||
|
vhost: string
|
||||||
|
stream: string
|
||||||
|
ip: string
|
||||||
|
pageUrl: string
|
||||||
|
swfUrl: string
|
||||||
|
tcUrl: string
|
||||||
|
url: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
publish: boolean
|
||||||
|
alive: number
|
||||||
|
send_bytes: number
|
||||||
|
recv_bytes: number
|
||||||
|
kbps: {
|
||||||
|
recv_30s: number
|
||||||
|
send_30s: number
|
||||||
|
}
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SRSStreamResponse {
|
||||||
|
code: number
|
||||||
|
server: string
|
||||||
|
service: string
|
||||||
|
pid: string
|
||||||
|
streams: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
vhost: string
|
||||||
|
app: string
|
||||||
|
tcUrl: string
|
||||||
|
url: string
|
||||||
|
live_ms: number
|
||||||
|
clients: number
|
||||||
|
frames: number
|
||||||
|
send_bytes: number
|
||||||
|
recv_bytes: number
|
||||||
|
kbps: {
|
||||||
|
recv_30s: number
|
||||||
|
send_30s: number
|
||||||
|
}
|
||||||
|
publish: {
|
||||||
|
active: boolean
|
||||||
|
cid: string
|
||||||
|
}
|
||||||
|
video?: {
|
||||||
|
codec: string
|
||||||
|
profile: string
|
||||||
|
level: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
audio?: {
|
||||||
|
codec: string
|
||||||
|
sample_rate: number
|
||||||
|
channel: number
|
||||||
|
profile: string
|
||||||
|
}
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserQuery {
|
||||||
|
[key: string]: string | undefined
|
||||||
|
schema?: string
|
||||||
|
play?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedUrl {
|
||||||
|
url: string
|
||||||
|
schema: string
|
||||||
|
server: string
|
||||||
|
port: number
|
||||||
|
vhost: string
|
||||||
|
app: string
|
||||||
|
stream: string
|
||||||
|
user_query: UserQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export class SRSServer extends BaseMediaServer {
|
||||||
|
private baseUrl: string
|
||||||
|
|
||||||
|
constructor(host: string, port: number) {
|
||||||
|
super(MediaServerType.SRS)
|
||||||
|
this.baseUrl = `http://${host}:${port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVersion(): Promise<VersionInfo> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<SRSVersionResponse>(`${this.baseUrl}/api/v1/versions`)
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: response.data.data.version,
|
||||||
|
buildDate: undefined, // SRS API 没有提供构建日期
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to get SRS version: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStreamInfo(): Promise<StreamInfo[]> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<SRSStreamResponse>(`${this.baseUrl}/api/v1/streams/`)
|
||||||
|
|
||||||
|
return response.data.streams.map((stream) => ({
|
||||||
|
id: stream.id,
|
||||||
|
name: stream.name,
|
||||||
|
vhost: stream.vhost,
|
||||||
|
url: stream.tcUrl,
|
||||||
|
clients: stream.clients - 1,
|
||||||
|
active: stream.publish.active,
|
||||||
|
send_bytes: stream.send_bytes,
|
||||||
|
recv_bytes: stream.recv_bytes,
|
||||||
|
video: stream.video
|
||||||
|
? {
|
||||||
|
codec: stream.video.codec,
|
||||||
|
width: stream.video.width,
|
||||||
|
height: stream.video.height,
|
||||||
|
fps: 0, // SRS API 没有直接提供 fps 信息
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
audio: stream.audio
|
||||||
|
? {
|
||||||
|
codec: stream.audio.codec,
|
||||||
|
sampleRate: stream.audio.sample_rate,
|
||||||
|
channels: stream.audio.channel,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to get SRS streams info: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<SRSClientsResponse>(`${this.baseUrl}/api/v1/clients/`)
|
||||||
|
let clients = response.data.clients.filter((client) => !client.publish)
|
||||||
|
|
||||||
|
// 如果指定了 stream_id,则过滤出对应的流
|
||||||
|
if (params?.stream_id) {
|
||||||
|
clients = clients.filter(client => client.stream === params.stream_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return clients.map((client) => {
|
||||||
|
console.log('Client alive value:', client.alive, typeof client.alive)
|
||||||
|
return {
|
||||||
|
id: client.id,
|
||||||
|
vhost: client.vhost,
|
||||||
|
stream: client.stream,
|
||||||
|
ip: client.ip,
|
||||||
|
url: client.url,
|
||||||
|
alive: Math.round(client.alive * 1000), // 转换为毫秒并四舍五入
|
||||||
|
type: client.type,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to get SRS clients info: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async kickClient(clientId: string) {
|
||||||
|
const response = await axios.post(`${this.baseUrl}/api/v1/clients/${clientId}/kick`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
createRtcPlayer(): RtcPlayer {
|
||||||
|
const self = {
|
||||||
|
pc: new RTCPeerConnection({
|
||||||
|
iceServers: [],
|
||||||
|
}),
|
||||||
|
|
||||||
|
async play(url: string) {
|
||||||
|
const conf = this.__internal.prepareUrl(url)
|
||||||
|
this.pc.addTransceiver('audio', { direction: 'recvonly' })
|
||||||
|
this.pc.addTransceiver('video', { direction: 'recvonly' })
|
||||||
|
|
||||||
|
const offer = await this.pc.createOffer()
|
||||||
|
await this.pc.setLocalDescription(offer)
|
||||||
|
|
||||||
|
const session = await fetch(conf.apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
api: conf.apiUrl,
|
||||||
|
streamurl: conf.streamUrl,
|
||||||
|
clientip: null,
|
||||||
|
sdp: offer.sdp,
|
||||||
|
}),
|
||||||
|
}).then((res) => res.json())
|
||||||
|
|
||||||
|
if (session.code) {
|
||||||
|
throw session
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.pc.setRemoteDescription(
|
||||||
|
new RTCSessionDescription({ type: 'answer', sdp: session.sdp }),
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
},
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
this.pc.close()
|
||||||
|
},
|
||||||
|
|
||||||
|
ontrack: null as ((event: RTCTrackEvent) => void) | null,
|
||||||
|
|
||||||
|
__internal: {
|
||||||
|
defaultPath: '/rtc/v1/play/',
|
||||||
|
|
||||||
|
prepareUrl(webrtcUrl: string) {
|
||||||
|
const urlObject = this.parse(webrtcUrl) as ParsedUrl
|
||||||
|
const schema = urlObject.user_query.schema
|
||||||
|
? urlObject.user_query.schema + ':'
|
||||||
|
: window.location.protocol
|
||||||
|
|
||||||
|
let port = urlObject.port || 1985
|
||||||
|
if (schema === 'https:') {
|
||||||
|
port = urlObject.port || 443
|
||||||
|
}
|
||||||
|
|
||||||
|
let api = urlObject.user_query.play || this.defaultPath
|
||||||
|
if (api.lastIndexOf('/') !== api.length - 1) {
|
||||||
|
api += '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiUrl = schema + '//' + urlObject.server + ':' + port + api
|
||||||
|
for (const key in urlObject.user_query) {
|
||||||
|
if (key !== 'api' && key !== 'play') {
|
||||||
|
apiUrl += '&' + key + '=' + urlObject.user_query[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apiUrl = apiUrl.replace(api + '&', api + '?')
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiUrl,
|
||||||
|
streamUrl: urlObject.url,
|
||||||
|
schema,
|
||||||
|
urlObject,
|
||||||
|
port,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
parse(url: string): ParsedUrl {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
.replace('rtmp://', 'http://')
|
||||||
|
.replace('webrtc://', 'http://')
|
||||||
|
.replace('rtc://', 'http://')
|
||||||
|
|
||||||
|
let vhost = a.hostname
|
||||||
|
let app = a.pathname.substring(1, a.pathname.lastIndexOf('/'))
|
||||||
|
const stream = a.pathname.slice(a.pathname.lastIndexOf('/') + 1)
|
||||||
|
|
||||||
|
app = app.replace('...vhost...', '?vhost=')
|
||||||
|
if (app.indexOf('?') >= 0) {
|
||||||
|
const params = app.slice(app.indexOf('?'))
|
||||||
|
app = app.slice(0, app.indexOf('?'))
|
||||||
|
|
||||||
|
if (params.indexOf('vhost=') > 0) {
|
||||||
|
vhost = params.slice(params.indexOf('vhost=') + 'vhost='.length)
|
||||||
|
if (vhost.indexOf('&') > 0) {
|
||||||
|
vhost = vhost.slice(0, vhost.indexOf('&'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a.hostname === vhost) {
|
||||||
|
const re = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/
|
||||||
|
if (re.test(a.hostname)) {
|
||||||
|
vhost = '__defaultVhost__'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let schema = 'rtmp'
|
||||||
|
if (url.indexOf('://') > 0) {
|
||||||
|
schema = url.slice(0, url.indexOf('://'))
|
||||||
|
}
|
||||||
|
|
||||||
|
let port = parseInt(a.port)
|
||||||
|
if (!port) {
|
||||||
|
if (schema === 'http') {
|
||||||
|
port = 80
|
||||||
|
} else if (schema === 'https') {
|
||||||
|
port = 443
|
||||||
|
} else if (schema === 'rtmp') {
|
||||||
|
port = 1935
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ret: ParsedUrl = {
|
||||||
|
url,
|
||||||
|
schema,
|
||||||
|
server: a.hostname,
|
||||||
|
port,
|
||||||
|
vhost,
|
||||||
|
app,
|
||||||
|
stream,
|
||||||
|
user_query: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fill_query(a.search, ret)
|
||||||
|
return ret
|
||||||
|
},
|
||||||
|
|
||||||
|
fill_query(query_string: string, obj: ParsedUrl) {
|
||||||
|
if (query_string.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query_string.indexOf('?') >= 0) {
|
||||||
|
query_string = query_string.split('?')[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
const queries = query_string.split('&')
|
||||||
|
for (const elem of queries) {
|
||||||
|
const query = elem.split('=')
|
||||||
|
obj.user_query[query[0]] = query[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.user_query.domain) {
|
||||||
|
obj.vhost = obj.user_query.domain
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pc.ontrack = (event: RTCTrackEvent) => {
|
||||||
|
if (self.ontrack) {
|
||||||
|
self.ontrack(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
81
html/NextGB/src/api/mediaserver/types.ts
Normal file
81
html/NextGB/src/api/mediaserver/types.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* 流媒体服务器类型枚举
|
||||||
|
*/
|
||||||
|
export enum MediaServerType {
|
||||||
|
ZLM = 'zlm', // ZLMediaKit
|
||||||
|
SRS = 'srs', // SRS
|
||||||
|
CUSTOM = 'custom', // 自定义服务器
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RtcPlayer {
|
||||||
|
pc: RTCPeerConnection
|
||||||
|
play(url: string): Promise<void>
|
||||||
|
close(): Promise<void>
|
||||||
|
ontrack: ((event: RTCTrackEvent) => void) | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaServer {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
ip: string
|
||||||
|
port: number
|
||||||
|
type: string
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
secret?: string
|
||||||
|
status: number
|
||||||
|
created_at: string
|
||||||
|
isDefault?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 版本信息接口
|
||||||
|
*/
|
||||||
|
export interface VersionInfo {
|
||||||
|
version: string
|
||||||
|
buildDate?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频编码信息
|
||||||
|
*/
|
||||||
|
export interface VideoCodecInfo {
|
||||||
|
codec: string // 视频编码格式,如 H264, H265
|
||||||
|
width: number // 视频宽度
|
||||||
|
height: number // 视频高度
|
||||||
|
fps: number // 帧率
|
||||||
|
bitrate?: number // 比特率 (kbps)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 音频编码信息
|
||||||
|
*/
|
||||||
|
export interface AudioCodecInfo {
|
||||||
|
codec: string // 音频编码格式,如 AAC, G711
|
||||||
|
sampleRate: number // 采样率
|
||||||
|
channels: number // 声道数
|
||||||
|
bitrate?: number // 比特率 (kbps)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamInfo {
|
||||||
|
id: string
|
||||||
|
name: string // 流名称
|
||||||
|
vhost: string // 虚拟主机
|
||||||
|
url: string // 流地址
|
||||||
|
clients: number // 客户端连接数
|
||||||
|
active: boolean // 是否活跃
|
||||||
|
video?: VideoCodecInfo // 视频编码信息
|
||||||
|
audio?: AudioCodecInfo // 音频编码信息
|
||||||
|
send_bytes?: number // 已传输字节数
|
||||||
|
recv_bytes?: number // 已接收字节数
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientInfo {
|
||||||
|
id: string
|
||||||
|
vhost: string
|
||||||
|
stream: string
|
||||||
|
ip: string
|
||||||
|
url: string
|
||||||
|
alive: number
|
||||||
|
type: string
|
||||||
|
}
|
||||||
285
html/NextGB/src/api/mediaserver/zlm/zlm.ts
Normal file
285
html/NextGB/src/api/mediaserver/zlm/zlm.ts
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
import type { ClientInfo, StreamInfo, VersionInfo, MediaServer, RtcPlayer } from '@/api/mediaserver/types'
|
||||||
|
import { MediaServerType } from '@/api/mediaserver/types'
|
||||||
|
import { BaseMediaServer } from '@/api/mediaserver/base'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
// {
|
||||||
|
// "code": 0,
|
||||||
|
// "data": {
|
||||||
|
// "branchName": "master",
|
||||||
|
// "buildTime": "2023-04-19T10:34:34",
|
||||||
|
// "commitHash": "f143898"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
interface ZLMVersionResponse {
|
||||||
|
code: number
|
||||||
|
data: {
|
||||||
|
branchName: string
|
||||||
|
buildTime: string
|
||||||
|
commitHash: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// {
|
||||||
|
// "code" : 0,
|
||||||
|
// "data" : [
|
||||||
|
// {
|
||||||
|
// "app" : "live", # 应用名
|
||||||
|
// "readerCount" : 0, # 本协议观看人数
|
||||||
|
// "totalReaderCount" : 0, # 观看总人数,包括hls/rtsp/rtmp/http-flv/ws-flv
|
||||||
|
// "schema" : "rtsp", # 协议
|
||||||
|
// "stream" : "obs", # 流id
|
||||||
|
// "originSock": { # 客户端和服务器网络信息,可能为null类型
|
||||||
|
// "identifier": "140241931428384",
|
||||||
|
// "local_ip": "127.0.0.1",
|
||||||
|
// "local_port": 1935,
|
||||||
|
// "peer_ip": "127.0.0.1",
|
||||||
|
// "peer_port": 50097
|
||||||
|
// },
|
||||||
|
// "originType": 1, # 产生源类型,包括 unknown = 0,rtmp_push=1,rtsp_push=2,rtp_push=3,pull=4,ffmpeg_pull=5,mp4_vod=6,device_chn=7
|
||||||
|
// "originTypeStr": "MediaOriginType::rtmp_push",
|
||||||
|
// "originUrl": "rtmp://127.0.0.1:1935/live/hks2", #产生源的url
|
||||||
|
// "createStamp": 1602205811, #GMT unix系统时间戳,单位秒
|
||||||
|
// "aliveSecond": 100, #存活时间,单位秒
|
||||||
|
// "bytesSpeed": 12345, #数据产生速度,单位byte/s
|
||||||
|
// "tracks" : [ # 音视频轨道
|
||||||
|
// {
|
||||||
|
// "channels" : 1, # 音频通道数
|
||||||
|
// "codec_id" : 2, # H264 = 0, H265 = 1, AAC = 2, G711A = 3, G711U = 4
|
||||||
|
// "codec_id_name" : "CodecAAC", # 编码类型名称
|
||||||
|
// "codec_type" : 1, # Video = 0, Audio = 1
|
||||||
|
// "ready" : true, # 轨道是否准备就绪
|
||||||
|
// "frames" : 1119, #累计接收帧数
|
||||||
|
// "sample_bit" : 16, # 音频采样位数
|
||||||
|
// "sample_rate" : 8000 # 音频采样率
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// "codec_id" : 0, # H264 = 0, H265 = 1, AAC = 2, G711A = 3, G711U = 4
|
||||||
|
// "codec_id_name" : "CodecH264", # 编码类型名称
|
||||||
|
// "codec_type" : 0, # Video = 0, Audio = 1
|
||||||
|
// "fps" : 59, # 视频fps
|
||||||
|
// "frames" : 1119, #累计接收帧数,不包含sei/aud/sps/pps等不能解码的帧
|
||||||
|
// "gop_interval_ms" : 1993, #gop间隔时间,单位毫秒
|
||||||
|
// "gop_size" : 60, #gop大小,单位帧数
|
||||||
|
// "key_frames" : 21, #累计接收关键帧数
|
||||||
|
// "height" : 720, # 视频高
|
||||||
|
// "ready" : true, # 轨道是否准备就绪
|
||||||
|
// "width" : 1280 # 视频宽
|
||||||
|
// }
|
||||||
|
// ],
|
||||||
|
// "vhost" : "__defaultVhost__" # 虚拟主机名
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
|
||||||
|
interface ZLMTrackInfo {
|
||||||
|
channels?: number // 音频通道数
|
||||||
|
codec_id: number // 编码器ID
|
||||||
|
codec_id_name: string // 编码器名称
|
||||||
|
codec_type: number // 编码类型 (0: Video, 1: Audio)
|
||||||
|
ready: boolean // 轨道是否就绪
|
||||||
|
frames: number // 累计接收帧数
|
||||||
|
sample_bit?: number // 音频采样位数
|
||||||
|
sample_rate?: number // 音频采样率
|
||||||
|
// 视频特有属性
|
||||||
|
fps?: number // 视频帧率
|
||||||
|
width?: number // 视频宽度
|
||||||
|
height?: number // 视频高度
|
||||||
|
gop_interval_ms?: number // GOP间隔时间
|
||||||
|
gop_size?: number // GOP大小
|
||||||
|
key_frames?: number // 关键帧数
|
||||||
|
bytesSpeed?: number // 数据速率
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZLMStreamInfo {
|
||||||
|
app: string
|
||||||
|
readerCount: number
|
||||||
|
totalReaderCount: number
|
||||||
|
schema: string
|
||||||
|
stream: string
|
||||||
|
originSock: {
|
||||||
|
identifier: string
|
||||||
|
local_ip: string
|
||||||
|
local_port: number
|
||||||
|
peer_ip: string
|
||||||
|
peer_port: number
|
||||||
|
}
|
||||||
|
originType: number
|
||||||
|
originTypeStr: string
|
||||||
|
originUrl: string
|
||||||
|
createStamp: number
|
||||||
|
aliveSecond: number
|
||||||
|
bytesSpeed: number
|
||||||
|
tracks: ZLMTrackInfo[]
|
||||||
|
vhost: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZLMStreamResponse {
|
||||||
|
code: number
|
||||||
|
data: ZLMStreamInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// {
|
||||||
|
// "code": 0,
|
||||||
|
// "data": [
|
||||||
|
// {
|
||||||
|
// "identifier": "3-309",
|
||||||
|
// "local_ip": "::",
|
||||||
|
// "local_port": 8000,
|
||||||
|
// "peer_ip": "172.18.190.159",
|
||||||
|
// "peer_port": 52996,
|
||||||
|
// "typeid": "mediakit::WebRtcSession"
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
|
||||||
|
interface ZLMClientInfo {
|
||||||
|
identifier: string
|
||||||
|
local_ip: string
|
||||||
|
local_port: number
|
||||||
|
peer_ip: string
|
||||||
|
peer_port: number
|
||||||
|
typeid: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZLMClientInfoResponse {
|
||||||
|
code: number
|
||||||
|
data: ZLMClientInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZLMRtcResponse {
|
||||||
|
code: number
|
||||||
|
id: string
|
||||||
|
sdp: string
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ZLMServer extends BaseMediaServer {
|
||||||
|
private baseUrl: string
|
||||||
|
private secret: string
|
||||||
|
|
||||||
|
constructor(host: string, port: number, secret: string = '') {
|
||||||
|
super(MediaServerType.ZLM)
|
||||||
|
this.baseUrl = `http://${host}:${port}`
|
||||||
|
this.secret = secret
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVersion(): Promise<VersionInfo> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<ZLMVersionResponse>(`${this.baseUrl}/index/api/version${this.secret ? '?secret=' + this.secret : ''}`)
|
||||||
|
return {
|
||||||
|
version: response.data.data.buildTime,
|
||||||
|
buildDate: response.data.data.buildTime,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to get ZLM version: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /index/api/getMediaList?schema=rtsp&secret=
|
||||||
|
async getStreamInfo(): Promise<StreamInfo[]> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<ZLMStreamResponse>(`${this.baseUrl}/index/api/getMediaList?schema=rtsp&secret=${this.secret}`)
|
||||||
|
return response.data.data.map((stream) => {
|
||||||
|
const videoTrack = stream.tracks.find((track) => track.codec_type === 0)
|
||||||
|
const audioTrack = stream.tracks.find((track) => track.codec_type === 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: stream.stream,
|
||||||
|
name: stream.stream,
|
||||||
|
vhost: stream.vhost,
|
||||||
|
url: stream.originUrl,
|
||||||
|
clients: stream.readerCount,
|
||||||
|
active: stream.aliveSecond > 0,
|
||||||
|
send_bytes: 0,
|
||||||
|
recv_bytes: stream.bytesSpeed,
|
||||||
|
video: videoTrack?.codec_type === 0 ? {
|
||||||
|
codec: videoTrack.codec_id_name,
|
||||||
|
width: videoTrack.width ?? 0,
|
||||||
|
height: videoTrack.height ?? 0,
|
||||||
|
fps: videoTrack.fps ?? 0,
|
||||||
|
bitrate: videoTrack.bytesSpeed ?? 0,
|
||||||
|
} : undefined,
|
||||||
|
audio: audioTrack?.codec_type === 1 ? {
|
||||||
|
codec: audioTrack.codec_id_name,
|
||||||
|
sampleRate: audioTrack.sample_rate ?? 0,
|
||||||
|
channels: audioTrack.channels ?? 0,
|
||||||
|
bitrate: audioTrack.bytesSpeed ?? 0,
|
||||||
|
} : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to get ZLM streams info: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /index/api/getMediaPlayerList?secret=035c73f7-bb6b-4889-a715-d9eb2d1925cc&schema=rtsp&vhost=defaultVhost&app=live&stream=test
|
||||||
|
async getClientInfo(params?: { stream_id?: string }): Promise<ClientInfo[]> {
|
||||||
|
try {
|
||||||
|
const streamParam = params?.stream_id ? `&stream=${params.stream_id}` : ''
|
||||||
|
const response = await axios.get<ZLMClientInfoResponse>(
|
||||||
|
`${this.baseUrl}/index/api/getMediaPlayerList?secret=${this.secret}&schema=rtsp&vhost=defaultVhost&app=rtp${streamParam}`
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.data.data.map((client) => ({
|
||||||
|
id: client.identifier,
|
||||||
|
vhost: '__defaultVhost__', // ZLM默认虚拟主机
|
||||||
|
stream: 'null',
|
||||||
|
ip: client.peer_ip,
|
||||||
|
url: `${client.local_ip}:${client.local_port}`,
|
||||||
|
alive: 1, // 在线状态
|
||||||
|
type: client.typeid.replace('mediakit::', '') // 移除 mediakit:: 前缀
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to get ZLM clients info: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createRtcPlayer(): RtcPlayer {
|
||||||
|
const self = {
|
||||||
|
pc: new RTCPeerConnection({
|
||||||
|
iceServers: [],
|
||||||
|
}),
|
||||||
|
|
||||||
|
async play(url: string): Promise<void> {
|
||||||
|
this.pc.addTransceiver('audio', { direction: 'recvonly' })
|
||||||
|
this.pc.addTransceiver('video', { direction: 'recvonly' })
|
||||||
|
|
||||||
|
const offer = await this.pc.createOffer()
|
||||||
|
await this.pc.setLocalDescription(offer)
|
||||||
|
|
||||||
|
const response = await axios.post<ZLMRtcResponse>(
|
||||||
|
url,
|
||||||
|
offer.sdp,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain;charset=utf-8'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.data.code !== 0) {
|
||||||
|
throw new Error('创建WebRTC播放器失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.pc.setRemoteDescription(
|
||||||
|
new RTCSessionDescription({ type: 'answer', sdp: response.data.sdp })
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
async close(): Promise<void> {
|
||||||
|
this.pc.close()
|
||||||
|
},
|
||||||
|
|
||||||
|
ontrack: null as ((event: RTCTrackEvent) => void) | null,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.pc.ontrack = (event: RTCTrackEvent) => {
|
||||||
|
if (self.ontrack) {
|
||||||
|
self.ontrack(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
110
html/NextGB/src/api/types.ts
Normal file
110
html/NextGB/src/api/types.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
// API 响应类型
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
code: number
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteRequest {
|
||||||
|
media_server_id: number
|
||||||
|
device_id: string
|
||||||
|
channel_id: string
|
||||||
|
sub_stream: number
|
||||||
|
play_type: number
|
||||||
|
start_time: number
|
||||||
|
end_time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteResponse {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ByeRequest {
|
||||||
|
device_id: string
|
||||||
|
channel_id: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PauseRequest {
|
||||||
|
device_id: string
|
||||||
|
channel_id: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumeRequest {
|
||||||
|
device_id: string
|
||||||
|
channel_id: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeedRequest {
|
||||||
|
device_id: string
|
||||||
|
channel_id: string
|
||||||
|
url: string
|
||||||
|
speed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通道状态类型
|
||||||
|
export type ChannelStatus = 'ON' | 'OFF'
|
||||||
|
|
||||||
|
// 通道信息类型
|
||||||
|
export interface ChannelInfo {
|
||||||
|
device_id: string
|
||||||
|
parent_id: string
|
||||||
|
name: string
|
||||||
|
manufacturer: string
|
||||||
|
model: string
|
||||||
|
owner: string
|
||||||
|
civil_code: string
|
||||||
|
address: string
|
||||||
|
port: number
|
||||||
|
parental: number
|
||||||
|
safety_way: number
|
||||||
|
register_way: number
|
||||||
|
secrecy: number
|
||||||
|
ip_address: string
|
||||||
|
status: ChannelStatus
|
||||||
|
longitude: number
|
||||||
|
latitude: number
|
||||||
|
info: {
|
||||||
|
ptz_type: number
|
||||||
|
resolution: string
|
||||||
|
download_speed: string
|
||||||
|
}
|
||||||
|
ssrc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordInfoRequest {
|
||||||
|
device_id: string
|
||||||
|
channel_id: string
|
||||||
|
start_time: number
|
||||||
|
end_time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordInfoResponse {
|
||||||
|
device_id: string
|
||||||
|
name: string
|
||||||
|
file_path: string
|
||||||
|
address: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
secrecy: number
|
||||||
|
type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Device {
|
||||||
|
device_id: string
|
||||||
|
source_addr: string
|
||||||
|
network_type: string
|
||||||
|
status: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PTZControlRequest {
|
||||||
|
device_id: string
|
||||||
|
channel_id: string
|
||||||
|
ptz: string
|
||||||
|
speed: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 媒体服务器类型
|
||||||
|
|
||||||
86
html/NextGB/src/assets/base.css
Normal file
86
html/NextGB/src/assets/base.css
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
/* color palette from <https://github.com/vuejs/theme> */
|
||||||
|
:root {
|
||||||
|
--vt-c-white: #ffffff;
|
||||||
|
--vt-c-white-soft: #f8f8f8;
|
||||||
|
--vt-c-white-mute: #f2f2f2;
|
||||||
|
|
||||||
|
--vt-c-black: #181818;
|
||||||
|
--vt-c-black-soft: #222222;
|
||||||
|
--vt-c-black-mute: #282828;
|
||||||
|
|
||||||
|
--vt-c-indigo: #2c3e50;
|
||||||
|
|
||||||
|
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||||
|
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||||
|
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||||
|
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||||
|
|
||||||
|
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||||
|
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||||
|
--vt-c-text-dark-1: var(--vt-c-white);
|
||||||
|
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* semantic color variables for this project */
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-white);
|
||||||
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-light-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-light-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
|
--color-text: var(--vt-c-text-light-1);
|
||||||
|
|
||||||
|
--section-gap: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-background: var(--vt-c-black);
|
||||||
|
--color-background-soft: var(--vt-c-black-soft);
|
||||||
|
--color-background-mute: var(--vt-c-black-mute);
|
||||||
|
|
||||||
|
--color-border: var(--vt-c-divider-dark-2);
|
||||||
|
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||||
|
|
||||||
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
|
--color-text: var(--vt-c-text-dark-2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-background);
|
||||||
|
transition:
|
||||||
|
color 0.5s,
|
||||||
|
background-color 0.5s;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family:
|
||||||
|
Inter,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
Oxygen,
|
||||||
|
Ubuntu,
|
||||||
|
Cantarell,
|
||||||
|
'Fira Sans',
|
||||||
|
'Droid Sans',
|
||||||
|
'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
1
html/NextGB/src/assets/logo.svg
Normal file
1
html/NextGB/src/assets/logo.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||||
|
After Width: | Height: | Size: 276 B |
31
html/NextGB/src/assets/main.css
Normal file
31
html/NextGB/src/assets/main.css
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
@import './base.css';
|
||||||
|
|
||||||
|
#app {
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
a,
|
||||||
|
.green {
|
||||||
|
text-decoration: none;
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
transition: 0.4s;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
a:hover {
|
||||||
|
background-color: hsla(160, 100%, 37%, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
body {
|
||||||
|
padding: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
padding: 0 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
html/NextGB/src/assets/srs-logo.ico
Normal file
BIN
html/NextGB/src/assets/srs-logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
html/NextGB/src/assets/zlm-logo.png
Normal file
BIN
html/NextGB/src/assets/zlm-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
232
html/NextGB/src/components/common/DateTimeRangePanel.vue
Normal file
232
html/NextGB/src/components/common/DateTimeRangePanel.vue
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ArrowRight, VideoCamera, Search } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
search: [{ start: string; end: string }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isCollapsed = ref(false)
|
||||||
|
const startDateTime = ref('')
|
||||||
|
const endDateTime = ref('')
|
||||||
|
|
||||||
|
const formatDateTime = (date: Date) => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShortcut = (type: string) => {
|
||||||
|
const now = new Date()
|
||||||
|
const start = new Date()
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'today': {
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'yesterday': {
|
||||||
|
start.setDate(start.getDate() - 1)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
now.setDate(now.getDate() - 1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'lastWeek': {
|
||||||
|
start.setDate(start.getDate() - 7)
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startDateTime.value = formatDateTime(start)
|
||||||
|
now.setHours(23, 59, 59)
|
||||||
|
endDateTime.value = formatDateTime(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
if (!startDateTime.value || !endDateTime.value) return
|
||||||
|
emit('search', {
|
||||||
|
start: startDateTime.value,
|
||||||
|
end: endDateTime.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="datetime-range-panel" :class="{ collapsed: isCollapsed }">
|
||||||
|
<div class="panel-header" @click="isCollapsed = !isCollapsed">
|
||||||
|
<div class="header-title">
|
||||||
|
<el-icon class="collapse-arrow" :class="{ collapsed: isCollapsed }">
|
||||||
|
<ArrowRight />
|
||||||
|
</el-icon>
|
||||||
|
<el-icon class="title-icon"><VideoCamera /></el-icon>
|
||||||
|
<span>{{ title || '时间范围' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content">
|
||||||
|
<div class="search-form">
|
||||||
|
<div class="form-item calendar-wrapper">
|
||||||
|
<div class="datetime-range">
|
||||||
|
<div class="datetime-item">
|
||||||
|
<div class="datetime-label">开始时间:</div>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="startDateTime"
|
||||||
|
type="datetime"
|
||||||
|
:editable="false"
|
||||||
|
placeholder="开始时间"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="datetime-item">
|
||||||
|
<div class="datetime-label">结束时间:</div>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="endDateTime"
|
||||||
|
type="datetime"
|
||||||
|
:editable="false"
|
||||||
|
placeholder="结束时间"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="shortcuts">
|
||||||
|
<el-button text size="small" @click="handleShortcut('today')"> 今天 </el-button>
|
||||||
|
<el-button text size="small" @click="handleShortcut('yesterday')"> 昨天 </el-button>
|
||||||
|
<el-button text size="small" @click="handleShortcut('lastWeek')"> 最近一周 </el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-if="!isCollapsed">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:disabled="!startDateTime || !endDateTime"
|
||||||
|
@click="handleSearch"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.datetime-range-panel {
|
||||||
|
background-color: var(--el-bg-color);
|
||||||
|
border-radius: var(--el-border-radius-base);
|
||||||
|
box-shadow: var(--el-box-shadow-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-arrow {
|
||||||
|
font-size: 12px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
|
||||||
|
&.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datetime-range-panel.collapsed .panel-content {
|
||||||
|
height: 0;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datetime-range {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datetime-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.datetime-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
padding-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
color: var(--el-text-color-disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-wrapper {
|
||||||
|
:deep(.el-input__wrapper) {
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__inner) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-date-editor) {
|
||||||
|
--el-date-editor-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-picker-panel) {
|
||||||
|
--el-datepicker-border-color: var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
133
html/NextGB/src/components/common/SearchBox.vue
Normal file
133
html/NextGB/src/components/common/SearchBox.vue
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Search, Refresh, Expand, List } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loading?: boolean
|
||||||
|
showViewModeSwitch?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false,
|
||||||
|
showViewModeSwitch: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const viewMode = ref<'tree' | 'list'>('tree')
|
||||||
|
const tooltipRef = ref()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:searchQuery', value: string): void
|
||||||
|
(e: 'update:viewMode', value: 'tree' | 'list'): void
|
||||||
|
(e: 'refresh'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleSearchInput = (value: string) => {
|
||||||
|
searchQuery.value = value
|
||||||
|
emit('update:searchQuery', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewModeChange = () => {
|
||||||
|
viewMode.value = viewMode.value === 'tree' ? 'list' : 'tree'
|
||||||
|
emit('update:viewMode', viewMode.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
emit('refresh')
|
||||||
|
tooltipRef.value?.hide()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="search-box">
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<el-input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="搜索设备或通道..."
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
@input="handleSearchInput"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<el-tooltip
|
||||||
|
v-if="showViewModeSwitch"
|
||||||
|
:content="viewMode === 'tree' ? '切换到列表视图' : '切换到树形视图'"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
class="action-btn"
|
||||||
|
:icon="viewMode === 'list' ? Expand : List"
|
||||||
|
size="small"
|
||||||
|
@click="handleViewModeChange"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip ref="tooltipRef" content="刷新设备列表" placement="top">
|
||||||
|
<el-button
|
||||||
|
class="action-btn refresh-btn"
|
||||||
|
:icon="Refresh"
|
||||||
|
size="small"
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleRefresh"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-box {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.el-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
border-color: transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 5px 8px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-icon) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.refresh-btn {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
766
html/NextGB/src/components/monitor/MonitorGrid.vue
Normal file
766
html/NextGB/src/components/monitor/MonitorGrid.vue
Normal file
@ -0,0 +1,766 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onBeforeUnmount, onMounted, onActivated, onDeactivated } from 'vue'
|
||||||
|
import {
|
||||||
|
VideoCamera,
|
||||||
|
Close,
|
||||||
|
Camera,
|
||||||
|
FullScreen,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import type * as Types from '@/api/types'
|
||||||
|
import { deviceApi } from '@/api'
|
||||||
|
import { createMediaServer } from '@/api/mediaserver/factory'
|
||||||
|
import { useDefaultMediaServer } from '@/stores/mediaServer'
|
||||||
|
import type { LayoutConfig } from '@/types/layout'
|
||||||
|
import { MediaServerType } from '@/api/mediaserver/types'
|
||||||
|
|
||||||
|
interface DeviceWithChannel extends Types.Device {
|
||||||
|
channelInfo?: Types.ChannelInfo
|
||||||
|
player?: any
|
||||||
|
error?: boolean
|
||||||
|
id?: string
|
||||||
|
channel?: Types.ChannelInfo
|
||||||
|
isMuted?: boolean
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
defaultMuted?: boolean
|
||||||
|
layouts: Record<string, LayoutConfig>
|
||||||
|
showBorder?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
'window-select': [data: { deviceId: string; channelId: string } | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const currentLayout = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit('update:modelValue', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedDevices = ref<(DeviceWithChannel | null)[]>([])
|
||||||
|
const isFullscreen = ref(false)
|
||||||
|
|
||||||
|
// 使用共享的默认媒体服务器
|
||||||
|
const defaultMediaServer = useDefaultMediaServer()
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const gridStyle = computed(() => {
|
||||||
|
const layout = props.layouts[currentLayout.value]
|
||||||
|
return {
|
||||||
|
gridTemplateColumns: `repeat(${layout.cols}, 1fr)`,
|
||||||
|
gridTemplateRows: `repeat(${layout.rows}, 1fr)`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxDevices = computed(() => props.layouts[currentLayout.value].size)
|
||||||
|
|
||||||
|
// 视频流控制
|
||||||
|
const startWebRTCPlay = async (url: string, index: number, device: DeviceWithChannel) => {
|
||||||
|
if (!defaultMediaServer?.value) {
|
||||||
|
throw new Error('未找到可用的媒体服务器')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目前只有SRS支持WebRTC播放
|
||||||
|
const serverType = defaultMediaServer.value.type.toLowerCase()
|
||||||
|
console.log('当前媒体服务器类型:', serverType)
|
||||||
|
|
||||||
|
const mediaServer = createMediaServer(defaultMediaServer.value)
|
||||||
|
const player = (mediaServer as any).createRtcPlayer()
|
||||||
|
device.player = player
|
||||||
|
|
||||||
|
player.ontrack = (event: RTCTrackEvent) => {
|
||||||
|
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||||
|
if (videoElement && event.streams?.[0]) {
|
||||||
|
videoElement.srcObject = event.streams[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await player.play(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startStream = async (
|
||||||
|
device: DeviceWithChannel,
|
||||||
|
index: number,
|
||||||
|
play_type: number = 0,
|
||||||
|
start_time: number = 0,
|
||||||
|
end_time: number = 0,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
device.error = false
|
||||||
|
|
||||||
|
if (!defaultMediaServer?.value) {
|
||||||
|
throw new Error('未找到可用的媒体服务器,请先在流媒体服务页面设置默认服务器')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultMediaServer.value.status === 0) {
|
||||||
|
throw new Error('默认流媒体服务器不在线,请检查服务器状态')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await deviceApi.invite({
|
||||||
|
media_server_id: defaultMediaServer.value.id,
|
||||||
|
device_id: device.channel!.parent_id,
|
||||||
|
channel_id: device.channel!.device_id,
|
||||||
|
sub_stream: 0,
|
||||||
|
play_type,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
})
|
||||||
|
|
||||||
|
const streamData = response.data as unknown as Types.InviteResponse
|
||||||
|
if (!streamData?.url) {
|
||||||
|
throw new Error('播放地址不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
device.url = streamData.url
|
||||||
|
|
||||||
|
await startWebRTCPlay(streamData.url, index, device)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('启动播放失败:', error)
|
||||||
|
device.error = true
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '启动播放失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备管理
|
||||||
|
const cleanupDevice = async (device: DeviceWithChannel) => {
|
||||||
|
if (device.player) {
|
||||||
|
try {
|
||||||
|
await device.player.close()
|
||||||
|
device.player = null
|
||||||
|
|
||||||
|
if (!device.url) return
|
||||||
|
|
||||||
|
await deviceApi.bye({
|
||||||
|
device_id: device.device_id,
|
||||||
|
channel_id: device.channel!.device_id,
|
||||||
|
url: device.url!,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('关闭播放器失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const play = async (
|
||||||
|
device: Types.Device & {
|
||||||
|
channel: Types.ChannelInfo
|
||||||
|
play_type?: number
|
||||||
|
start_time?: number
|
||||||
|
end_time?: number
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
let index = selectedDevices.value.findIndex((d) => d === null)
|
||||||
|
if (index === -1) {
|
||||||
|
if (selectedDevices.value.length >= maxDevices.value) {
|
||||||
|
ElMessage.warning('已达到最大分屏数量')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
index = selectedDevices.value.length
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deviceWithChannel: DeviceWithChannel = {
|
||||||
|
...device,
|
||||||
|
channelInfo: device.channel,
|
||||||
|
channel: device.channel,
|
||||||
|
error: false,
|
||||||
|
isMuted: props.defaultMuted,
|
||||||
|
url: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
while (selectedDevices.value.length <= index) {
|
||||||
|
selectedDevices.value.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedDevices.value[index] = deviceWithChannel
|
||||||
|
await startStream(
|
||||||
|
deviceWithChannel,
|
||||||
|
index,
|
||||||
|
device.play_type,
|
||||||
|
device.start_time,
|
||||||
|
device.end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.muted = props.defaultMuted ?? true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加设备失败:', error)
|
||||||
|
ElMessage.error('添加设备失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pause = async (index: number) => {
|
||||||
|
const device = selectedDevices.value[index]
|
||||||
|
if (!device) return
|
||||||
|
|
||||||
|
await deviceApi.pause({
|
||||||
|
device_id: device.device_id,
|
||||||
|
channel_id: device.channel!.device_id,
|
||||||
|
url: device.url!,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resume = async (index: number) => {
|
||||||
|
const device = selectedDevices.value[index]
|
||||||
|
if (!device) return
|
||||||
|
|
||||||
|
await deviceApi.resume({
|
||||||
|
device_id: device.device_id,
|
||||||
|
channel_id: device.channel!.device_id,
|
||||||
|
url: device.url!,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const speed = async (index: number, speed: number) => {
|
||||||
|
const device = selectedDevices.value[index]
|
||||||
|
if (!device) return
|
||||||
|
|
||||||
|
await deviceApi.speed({
|
||||||
|
device_id: device.device_id,
|
||||||
|
channel_id: device.channel!.device_id,
|
||||||
|
url: device.url!,
|
||||||
|
speed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = async (index: number) => {
|
||||||
|
const device = selectedDevices.value[index]
|
||||||
|
if (!device) return
|
||||||
|
|
||||||
|
await cleanupDevice(device)
|
||||||
|
|
||||||
|
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||||
|
if (videoElement?.srcObject) {
|
||||||
|
const stream = videoElement.srcObject as MediaStream
|
||||||
|
stream.getTracks().forEach((track) => track.stop())
|
||||||
|
videoElement.srcObject = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将位置设为 null 而不是删除
|
||||||
|
selectedDevices.value[index] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频控制
|
||||||
|
const handleVideoDoubleClick = (index: number) => {
|
||||||
|
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||||
|
if (!videoElement) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
videoElement.requestFullscreen()
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('视频全屏切换失败:', err)
|
||||||
|
ElMessage.error('全屏切换失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVideoError = (index: number, event: Event) => {
|
||||||
|
console.error('视频播放错误:', event)
|
||||||
|
const device = selectedDevices.value[index]
|
||||||
|
if (device) {
|
||||||
|
device.error = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const capture = async (index: number) => {
|
||||||
|
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||||
|
if (!videoElement) {
|
||||||
|
ElMessage.error('未找到视频元素')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = videoElement.videoWidth
|
||||||
|
canvas.height = videoElement.videoHeight
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('无法创建canvas上下文')
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
const device = selectedDevices.value[index]
|
||||||
|
if (!device) {
|
||||||
|
throw new Error('设备不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
|
const filename = `${device.name || 'capture'}-${timestamp}.png`
|
||||||
|
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.download = filename
|
||||||
|
link.href = canvas.toDataURL('image/png')
|
||||||
|
link.click()
|
||||||
|
|
||||||
|
ElMessage.success('抓图成功')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('抓图失败:', err)
|
||||||
|
ElMessage.error('抓图失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空所有设备
|
||||||
|
const clear = async () => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要清空所有设备吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let i = 0; i < selectedDevices.value.length; i++) {
|
||||||
|
if (selectedDevices.value[i]) {
|
||||||
|
await stop(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用 null 填充数组而不是清空
|
||||||
|
selectedDevices.value = new Array(props.layouts[currentLayout.value].size).fill(null)
|
||||||
|
|
||||||
|
ElMessage.success('已清空所有设备')
|
||||||
|
} catch (err) {
|
||||||
|
if (err !== 'cancel') {
|
||||||
|
console.error('清空设备失败:', err)
|
||||||
|
ElMessage.error('清空设备失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 布局切换处理
|
||||||
|
watch(currentLayout, async (newLayout, oldLayout) => {
|
||||||
|
const maxSize = props.layouts[newLayout].size
|
||||||
|
const activeDevices = selectedDevices.value.filter((d) => d !== null).length
|
||||||
|
|
||||||
|
if (activeDevices > maxSize) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`切换布局将移除${activeDevices - maxSize}个设备,是否继续?`,
|
||||||
|
'提示',
|
||||||
|
{
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 从后往前移除超出设备
|
||||||
|
for (let i = selectedDevices.value.length - 1; i >= 0; i--) {
|
||||||
|
if (selectedDevices.value[i] && i >= maxSize) {
|
||||||
|
await stop(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调整数组大小
|
||||||
|
selectedDevices.value.length = maxSize
|
||||||
|
|
||||||
|
ElMessage.success('布局切换成功')
|
||||||
|
} catch (err) {
|
||||||
|
if (err !== 'cancel') {
|
||||||
|
console.error('布局切换失败:', err)
|
||||||
|
ElMessage.error('布局切换失败')
|
||||||
|
}
|
||||||
|
currentLayout.value = oldLayout
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果设备数量不超过新布局,只需调整数组大小
|
||||||
|
if (selectedDevices.value.length > maxSize) {
|
||||||
|
selectedDevices.value.length = maxSize
|
||||||
|
} else {
|
||||||
|
while (selectedDevices.value.length < maxSize) {
|
||||||
|
selectedDevices.value.push(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生命周期钩子
|
||||||
|
onMounted(() => {
|
||||||
|
// 确保有初始网格
|
||||||
|
if (selectedDevices.value.length === 0) {
|
||||||
|
selectedDevices.value = new Array(props.layouts[currentLayout.value].size).fill(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
isFullscreen.value = !!document.fullscreenElement
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
// 只在组件真正销毁时清理资源
|
||||||
|
if (!(document as any)._vue_app_is_switching_route) {
|
||||||
|
selectedDevices.value.forEach((device) => {
|
||||||
|
if (device?.player) {
|
||||||
|
try {
|
||||||
|
device.player.close()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('关闭播放器失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
resume,
|
||||||
|
speed,
|
||||||
|
clear,
|
||||||
|
stop,
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleMute = (index: number) => {
|
||||||
|
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||||
|
if (!videoElement) return
|
||||||
|
|
||||||
|
const device = selectedDevices.value[index]
|
||||||
|
if (!device) return
|
||||||
|
|
||||||
|
device.isMuted = !device.isMuted
|
||||||
|
videoElement.muted = device.isMuted
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
const handleVideoClick = (index: number) => {
|
||||||
|
const device = selectedDevices.value[index]
|
||||||
|
activeIndex.value = index
|
||||||
|
|
||||||
|
if (device && device.channel) {
|
||||||
|
emit('window-select', {
|
||||||
|
deviceId: device.device_id,
|
||||||
|
channelId: device.channel.device_id,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
emit('window-select', null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加激活/停用处理
|
||||||
|
onActivated(() => {
|
||||||
|
console.log('MonitorGrid activated')
|
||||||
|
// 检查并恢复所有视频播放
|
||||||
|
selectedDevices.value.forEach((device, index) => {
|
||||||
|
if (device) {
|
||||||
|
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||||
|
if (videoElement && videoElement.paused) {
|
||||||
|
videoElement.play().catch((err) => {
|
||||||
|
console.error('恢复视频播放失败:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
console.log('MonitorGrid deactivated')
|
||||||
|
// 可以选择暂停视频播放,但不销毁资源
|
||||||
|
selectedDevices.value.forEach((device, index) => {
|
||||||
|
if (device) {
|
||||||
|
const videoElement = document.getElementById(`video-player-${index}`) as HTMLVideoElement
|
||||||
|
if (videoElement && !videoElement.paused) {
|
||||||
|
// 可以选择是否暂停视频
|
||||||
|
// videoElement.pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="monitor-grid">
|
||||||
|
<div class="grid-container" :style="gridStyle">
|
||||||
|
<div
|
||||||
|
v-for="(device, index) in selectedDevices"
|
||||||
|
:key="index"
|
||||||
|
class="grid-item"
|
||||||
|
:class="{
|
||||||
|
active: index === activeIndex,
|
||||||
|
'with-border': props.showBorder,
|
||||||
|
}"
|
||||||
|
@click="handleVideoClick(index)"
|
||||||
|
>
|
||||||
|
<template v-if="device !== null">
|
||||||
|
<video
|
||||||
|
:id="`video-player-${index}`"
|
||||||
|
class="video-player"
|
||||||
|
autoplay
|
||||||
|
:muted="device.isMuted ?? true"
|
||||||
|
@dblclick="handleVideoDoubleClick(index)"
|
||||||
|
@error="handleVideoError(index, $event)"
|
||||||
|
/>
|
||||||
|
<div class="video-overlay">
|
||||||
|
<div class="device-info">{{ device.name }} - {{ device.channel?.name ?? '' }}</div>
|
||||||
|
<div class="video-controls">
|
||||||
|
<div class="control-bar">
|
||||||
|
<el-button
|
||||||
|
class="control-btn"
|
||||||
|
@click.stop="toggleMute(index)"
|
||||||
|
:title="device.isMuted ? '取消静音' : '静音'"
|
||||||
|
>
|
||||||
|
<el-icon>
|
||||||
|
<component :is="device.isMuted ? 'Mute' : 'Microphone'" />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
class="control-btn"
|
||||||
|
@click.stop="capture(index)"
|
||||||
|
:title="'抓图'"
|
||||||
|
:disabled="device.error"
|
||||||
|
>
|
||||||
|
<el-icon><Camera /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
class="control-btn"
|
||||||
|
@click.stop="handleVideoDoubleClick(index)"
|
||||||
|
:title="'全屏'"
|
||||||
|
>
|
||||||
|
<el-icon><FullScreen /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
class="control-btn is-danger"
|
||||||
|
@click.stop="stop(index)"
|
||||||
|
:title="'关闭'"
|
||||||
|
>
|
||||||
|
<el-icon><Close /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty-slot">
|
||||||
|
<el-icon><VideoCamera /></el-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.monitor-grid {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: var(--el-box-shadow-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-container {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
gap: 0px;
|
||||||
|
padding: 0px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--el-bg-color-page);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&.is-fullscreen {
|
||||||
|
padding: 16px;
|
||||||
|
background: #000;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-item {
|
||||||
|
position: relative;
|
||||||
|
background-color: var(--el-fill-color-darker);
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&.with-border {
|
||||||
|
border: 2px solid transparent;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, transparent 30%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-item:hover .video-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 0 0 0 4px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
padding: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: #fff !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
color: var(--el-color-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-danger:hover {
|
||||||
|
color: var(--el-color-danger) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 根据布局调整按钮大小 */
|
||||||
|
.grid-container[style*='repeat(1, 1fr)'] .control-btn {
|
||||||
|
width: 32px !important;
|
||||||
|
height: 32px !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-container[style*='repeat(2, 1fr)'] .control-btn {
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
font-size: 14px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-container[style*='repeat(3, 1fr)'] .control-btn {
|
||||||
|
width: 20px !important;
|
||||||
|
height: 20px !important;
|
||||||
|
font-size: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-container[style*='repeat(4, 1fr)'] .control-btn {
|
||||||
|
width: 16px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
font-size: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-bar {
|
||||||
|
.el-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-slot {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #1a1a1a;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
opacity: 0.5;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #242424;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
opacity: 0.8;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-grid {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: var(--el-box-shadow-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-toolbar {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button-group .el-button--small) {
|
||||||
|
padding: 5px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-radio-group .el-radio-button__inner) {
|
||||||
|
padding: 5px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-radio-group) {
|
||||||
|
--el-button-bg-color: var(--el-fill-color-blank);
|
||||||
|
--el-button-hover-bg-color: var(--el-fill-color);
|
||||||
|
--el-button-active-bg-color: var(--el-color-primary);
|
||||||
|
--el-button-text-color: var(--el-text-color-regular);
|
||||||
|
--el-button-hover-text-color: var(--el-text-color-primary);
|
||||||
|
--el-button-active-text-color: #fff;
|
||||||
|
--el-button-border-color: var(--el-border-color);
|
||||||
|
--el-button-hover-border-color: var(--el-border-color-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
10
html/NextGB/src/env.d.ts
vendored
Normal file
10
html/NextGB/src/env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_APP_TITLE: string
|
||||||
|
readonly VITE_APP_API_BASE_URL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
23
html/NextGB/src/main.ts
Normal file
23
html/NextGB/src/main.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import * as ElementPlusIcons from '@element-plus/icons-vue'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// 注册所有图标
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIcons)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(ElementPlus)
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
49
html/NextGB/src/router/index.ts
Normal file
49
html/NextGB/src/router/index.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import RealplayView from '@/views/realplay/RealplayView.vue'
|
||||||
|
import SettingsView from '@/views/setting/SettingsView.vue'
|
||||||
|
import PlaybackView from '@/views/playback/PlaybackView.vue'
|
||||||
|
import MediaServerView from '@/views/mediaserver/MediaServerView.vue'
|
||||||
|
import DashboardView from '@/views/DashboardView.vue'
|
||||||
|
import DeviceListView from '@/views/DeviceListView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'dashboard',
|
||||||
|
component: DashboardView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/realplay',
|
||||||
|
name: 'realplay',
|
||||||
|
component: RealplayView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/devices',
|
||||||
|
name: 'devices',
|
||||||
|
component: DeviceListView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: SettingsView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/playback',
|
||||||
|
name: 'playback',
|
||||||
|
component: PlaybackView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/media',
|
||||||
|
name: 'media',
|
||||||
|
component: MediaServerView,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
99
html/NextGB/src/stores/devices.ts
Normal file
99
html/NextGB/src/stores/devices.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Device, ChannelInfo } from '@/api/types'
|
||||||
|
import { deviceApi } from '@/api'
|
||||||
|
|
||||||
|
// 设备列表
|
||||||
|
const devices = ref<Device[]>([])
|
||||||
|
// 通道列表
|
||||||
|
const channels = ref<ChannelInfo[]>([])
|
||||||
|
// 加载状态
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const formatDeviceData = (device: any): Device => {
|
||||||
|
return {
|
||||||
|
device_id: device.device_id,
|
||||||
|
source_addr: device.source_addr,
|
||||||
|
network_type: device.network_type,
|
||||||
|
status: 1,
|
||||||
|
name: device.device_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设备和通道列表
|
||||||
|
export const fetchDevicesAndChannels = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
// 获取设备列表
|
||||||
|
const response = await deviceApi.getDevices()
|
||||||
|
const deviceList = Array.isArray(response.data) ? response.data : []
|
||||||
|
devices.value = deviceList.map(formatDeviceData)
|
||||||
|
|
||||||
|
// 获取所有设备的通道
|
||||||
|
const allChannels: ChannelInfo[] = []
|
||||||
|
for (const device of devices.value) {
|
||||||
|
try {
|
||||||
|
const response = await deviceApi.getDeviceChannels(device.device_id)
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
// 确保每个通道都有正确的设备ID和其他必要属性
|
||||||
|
const deviceChannels = response.data.map((channel: any) => ({
|
||||||
|
...channel,
|
||||||
|
device_id: channel.device_id,
|
||||||
|
status: channel.status || 'OFF',
|
||||||
|
name: channel.name || '未命名',
|
||||||
|
parent_id: device.device_id,
|
||||||
|
info: {
|
||||||
|
...channel.info,
|
||||||
|
ptz_type: channel.info?.ptz_type || 0,
|
||||||
|
resolution: channel.info?.resolution || '',
|
||||||
|
download_speed: channel.info?.download_speed || '',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
allChannels.push(...deviceChannels)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`获取设备 ${device.device_id} 的通道失败:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
channels.value = allChannels
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取设备列表失败:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设备列表
|
||||||
|
export const useDevices = () => devices
|
||||||
|
|
||||||
|
// 获取通道列表
|
||||||
|
export const useChannels = () => channels
|
||||||
|
|
||||||
|
// 获取加载状态
|
||||||
|
export const useDevicesLoading = () => loading
|
||||||
|
|
||||||
|
// 更新设备列表
|
||||||
|
export const updateDevices = (newDevices: Device[]) => {
|
||||||
|
devices.value = newDevices
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新通道列表
|
||||||
|
export const updateChannels = (newChannels: ChannelInfo[]) => {
|
||||||
|
channels.value = newChannels
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据设备ID获取设备
|
||||||
|
export const getDeviceById = (deviceId: string) => {
|
||||||
|
return devices.value.find((device) => device.device_id === deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据设备ID获取该设备的所有通道
|
||||||
|
export const getChannelsByDeviceId = (deviceId: string) => {
|
||||||
|
return channels.value.filter((channel) => channel.device_id === deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空数据
|
||||||
|
export const clearDevicesStore = () => {
|
||||||
|
devices.value = []
|
||||||
|
channels.value = []
|
||||||
|
}
|
||||||
100
html/NextGB/src/stores/mediaServer.ts
Normal file
100
html/NextGB/src/stores/mediaServer.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { MediaServer } from '@/api/mediaserver/types'
|
||||||
|
import { mediaServerApi } from '@/api'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { createMediaServer } from '@/api/mediaserver/factory'
|
||||||
|
|
||||||
|
// 所有媒体服务器列表
|
||||||
|
const mediaServers = ref<MediaServer[]>([])
|
||||||
|
// 默认媒体服务器
|
||||||
|
const defaultMediaServer = ref<MediaServer | null>(null)
|
||||||
|
// 加载状态
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 检查服务器状态
|
||||||
|
export const checkServersStatus = async () => {
|
||||||
|
for (const server of mediaServers.value) {
|
||||||
|
try {
|
||||||
|
const mediaServer = createMediaServer(server)
|
||||||
|
await mediaServer.getVersion()
|
||||||
|
server.status = 1 // 在线
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`检查服务器 ${server.name} 状态失败:`, error)
|
||||||
|
server.status = 0 // 离线
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取媒体服务器列表
|
||||||
|
export const fetchMediaServers = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await mediaServerApi.getMediaServers()
|
||||||
|
// 确保 mediaServers 始终是数组,并将 is_default 映射为 isDefault
|
||||||
|
mediaServers.value = Array.isArray(response.data)
|
||||||
|
? response.data.map((server: any) => ({
|
||||||
|
...server,
|
||||||
|
isDefault: server.is_default,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (mediaServers.value.length > 0) {
|
||||||
|
await checkServersStatus()
|
||||||
|
// 找到默认服务器并更新 defaultMediaServer
|
||||||
|
const defaultServer = mediaServers.value.find((server: MediaServer) => server.isDefault === 1)
|
||||||
|
if (defaultServer) {
|
||||||
|
defaultMediaServer.value = defaultServer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取媒体服务器列表失败:', error)
|
||||||
|
mediaServers.value = [] // 出错时也清空列表
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置默认媒体服务器
|
||||||
|
export const setDefaultMediaServer = async (server: MediaServer) => {
|
||||||
|
try {
|
||||||
|
// 调用后端API设置默认服务器
|
||||||
|
await mediaServerApi.setDefaultMediaServer(server.id)
|
||||||
|
|
||||||
|
// 更新前端状态
|
||||||
|
mediaServers.value.forEach((s: MediaServer) => {
|
||||||
|
s.isDefault = s.id === server.id ? 1 : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新默认服务器引用
|
||||||
|
defaultMediaServer.value = mediaServers.value.find((s: MediaServer) => s.id === server.id) || null
|
||||||
|
|
||||||
|
ElMessage.success('已设为默认节点')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '设置默认节点失败')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除媒体服务器
|
||||||
|
export const deleteMediaServer = async (server: MediaServer) => {
|
||||||
|
try {
|
||||||
|
// 如果要删除的是默认服务器,先清除默认服务器状态
|
||||||
|
if (server.isDefault) {
|
||||||
|
defaultMediaServer.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
await mediaServerApi.deleteMediaServer(server.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
await fetchMediaServers()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有媒体服务器列表
|
||||||
|
export const useMediaServers = () => mediaServers
|
||||||
|
// 获取默认媒体服务器
|
||||||
|
export const useDefaultMediaServer = () => defaultMediaServer
|
||||||
|
// 获取加载状态
|
||||||
|
export const useMediaServersLoading = () => loading
|
||||||
7
html/NextGB/src/types/global.d.ts
vendored
Normal file
7
html/NextGB/src/types/global.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
declare global {
|
||||||
|
interface Document {
|
||||||
|
_vue_app_is_switching_route?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
6
html/NextGB/src/types/layout.ts
Normal file
6
html/NextGB/src/types/layout.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface LayoutConfig {
|
||||||
|
cols: number
|
||||||
|
rows: number
|
||||||
|
size: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
208
html/NextGB/src/views/DashboardView.vue
Normal file
208
html/NextGB/src/views/DashboardView.vue
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useMediaServers } from '@/stores/mediaServer'
|
||||||
|
import { useDevices } from '@/stores/devices'
|
||||||
|
import type { MediaServer } from '@/api/mediaserver/types'
|
||||||
|
import type { Device } from '@/api/types'
|
||||||
|
import { createMediaServer } from '@/api/mediaserver/factory'
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const mediaServers = useMediaServers()
|
||||||
|
const devices = useDevices()
|
||||||
|
|
||||||
|
const onlineServerCount = computed(
|
||||||
|
() => mediaServers.value.filter((server: MediaServer) => server.status === 1).length,
|
||||||
|
)
|
||||||
|
const totalServerCount = computed(() => mediaServers.value.length)
|
||||||
|
const onlineDeviceCount = computed(
|
||||||
|
() => devices.value.filter((device: Device) => device.status === 1).length,
|
||||||
|
)
|
||||||
|
const totalDeviceCount = computed(() => devices.value.length)
|
||||||
|
|
||||||
|
const totalStreams = ref(0)
|
||||||
|
const totalPlayers = ref(0)
|
||||||
|
|
||||||
|
const fetchStreamAndPlayerCount = async () => {
|
||||||
|
let streamCount = 0
|
||||||
|
let playerCount = 0
|
||||||
|
for (const server of mediaServers.value) {
|
||||||
|
if (server.status === 1) { // 只统计在线服务器
|
||||||
|
try {
|
||||||
|
const mediaServer = createMediaServer(server)
|
||||||
|
const streams = await mediaServer.getStreamInfo()
|
||||||
|
streamCount += streams.length
|
||||||
|
// 统计所有流的客户端数量
|
||||||
|
playerCount += streams.reduce((sum, stream) => sum + (stream.clients || 0), 0)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`获取服务器 ${server.name} 的流信息失败:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalStreams.value = streamCount
|
||||||
|
totalPlayers.value = playerCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每30秒更新一次统计数据
|
||||||
|
onMounted(() => {
|
||||||
|
fetchStreamAndPlayerCount()
|
||||||
|
setInterval(fetchStreamAndPlayerCount, 30000)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<h1 class="dashboard-title">系统概览</h1>
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">流媒体服务器</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="number">
|
||||||
|
<span class="online">{{ onlineServerCount }}</span>
|
||||||
|
<span class="separator">/</span>
|
||||||
|
<span class="total">{{ totalServerCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="label">在线/总数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">设备状态</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="number">
|
||||||
|
<span class="online">{{ onlineDeviceCount }}</span>
|
||||||
|
<span class="separator">/</span>
|
||||||
|
<span class="total">{{ totalDeviceCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="label">在线/总数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">流数量</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="number">
|
||||||
|
<span class="total">{{ totalStreams }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="label">总流数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dashboard-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">播放者数量</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<div class="number">
|
||||||
|
<span class="total">{{ totalPlayers }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="label">总播放数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
padding: 24px;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--el-bg-color-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--el-box-shadow-light);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--el-box-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number .online {
|
||||||
|
color: var(--el-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.number .separator {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number .total {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式调整 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number {
|
||||||
|
font-size: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number .separator {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
302
html/NextGB/src/views/DeviceListView.vue
Normal file
302
html/NextGB/src/views/DeviceListView.vue
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
useDevices,
|
||||||
|
useChannels,
|
||||||
|
fetchDevicesAndChannels,
|
||||||
|
useDevicesLoading,
|
||||||
|
} from '@/stores/devices'
|
||||||
|
import type { Device, ChannelInfo } from '@/api/types'
|
||||||
|
|
||||||
|
const devices = useDevices()
|
||||||
|
const allChannels = useChannels()
|
||||||
|
const loading = useDevicesLoading()
|
||||||
|
|
||||||
|
const deviceList = computed(() => devices.value.map(formatDeviceData))
|
||||||
|
|
||||||
|
const fetchDevices = async (showError = true) => {
|
||||||
|
try {
|
||||||
|
await fetchDevicesAndChannels()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取设备列表失败:', error)
|
||||||
|
if (showError) {
|
||||||
|
ElMessage.error('获取设备列表失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtendedDevice extends Device {
|
||||||
|
channelCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const currentDevice = ref<ExtendedDevice | null>(null)
|
||||||
|
const channels = ref<ChannelInfo[]>([])
|
||||||
|
|
||||||
|
const formatDeviceData = (device: Device): ExtendedDevice => {
|
||||||
|
return {
|
||||||
|
...device,
|
||||||
|
status: device.status || 0,
|
||||||
|
name: device.name || device.device_id,
|
||||||
|
channelCount: allChannels.value.filter(
|
||||||
|
(channel: ChannelInfo) => channel.parent_id === device.device_id,
|
||||||
|
).length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredDevices = computed(() => {
|
||||||
|
if (!searchQuery.value.trim()) return deviceList.value
|
||||||
|
|
||||||
|
const query = searchQuery.value.trim().toLowerCase()
|
||||||
|
return deviceList.value.filter((device: Device) => {
|
||||||
|
return (
|
||||||
|
device.name?.toLowerCase().includes(query) ||
|
||||||
|
device.device_id?.toLowerCase().includes(query) ||
|
||||||
|
device.source_addr?.toLowerCase().includes(query) ||
|
||||||
|
device.network_type?.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurrentChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
fetchDevices(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDeviceDetails = async (device: ExtendedDevice) => {
|
||||||
|
currentDevice.value = device
|
||||||
|
dialogVisible.value = true
|
||||||
|
channels.value = allChannels.value.filter(
|
||||||
|
(channel: ChannelInfo) => channel.parent_id === device.device_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusType = (status: number) => {
|
||||||
|
switch (status) {
|
||||||
|
case 1:
|
||||||
|
return 'success'
|
||||||
|
case 0:
|
||||||
|
return 'danger'
|
||||||
|
default:
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status: number) => {
|
||||||
|
switch (status) {
|
||||||
|
case 1:
|
||||||
|
return '在线'
|
||||||
|
case 0:
|
||||||
|
return '离线'
|
||||||
|
default:
|
||||||
|
return '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paginatedDevices = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
|
return filteredDevices.value.slice(start, start + pageSize.value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="device-list-view">
|
||||||
|
<h1>设备管理</h1>
|
||||||
|
<div class="device-list">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-input
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="搜索设备ID、名称、地址或网络类型..."
|
||||||
|
class="search-input"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="primary" @click="handleSearch">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
搜索
|
||||||
|
</el-button>
|
||||||
|
<el-button type="success" :loading="loading" @click="handleRefresh">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="paginatedDevices" border stripe>
|
||||||
|
<template #empty>
|
||||||
|
<el-empty :description="searchQuery ? '未找到匹配的设备' : '暂无设备数据'" />
|
||||||
|
</template>
|
||||||
|
<el-table-column prop="device_id" label="设备ID" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="name" label="设备名称" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="source_addr" label="地址" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="network_type" label="网络类型" min-width="100" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="getStatusType(row.status)">
|
||||||
|
{{ getStatusText(row.status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="channelCount" label="通道数量" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.channelCount || 0 }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click.stop="showDeviceDetails(row)">
|
||||||
|
查看详情
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="filteredDevices.length"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
layout="total, prev, pager, next, jumper"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="`设备详情 - ${currentDevice?.name || currentDevice?.device_id}`"
|
||||||
|
width="70%"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div class="device-details">
|
||||||
|
<div class="device-info">
|
||||||
|
<h3>设备信息</h3>
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="设备ID">
|
||||||
|
{{ currentDevice?.device_id }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="设备名称">
|
||||||
|
{{ currentDevice?.name }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="地址">
|
||||||
|
{{ currentDevice?.source_addr }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="网络类型">
|
||||||
|
{{ currentDevice?.network_type }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="状态">
|
||||||
|
<el-tag :type="currentDevice?.status === 1 ? 'success' : 'danger'">
|
||||||
|
{{ currentDevice?.status === 1 ? '在线' : '离线' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="通道数量">
|
||||||
|
{{ currentDevice?.channelCount || 0 }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="channel-list">
|
||||||
|
<h3>通道列表</h3>
|
||||||
|
<el-table :data="channels" border stripe>
|
||||||
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
|
<el-table-column prop="name" label="通道名称" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column
|
||||||
|
prop="device_id"
|
||||||
|
label="通道ID"
|
||||||
|
min-width="120"
|
||||||
|
show-overflow-tooltip
|
||||||
|
/>
|
||||||
|
<el-table-column prop="manufacturer" label="厂商" min-width="120" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.status === 'ON' ? 'success' : 'danger'">
|
||||||
|
{{ row.status === 'ON' ? '在线' : '离线' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.device-list-view {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-list {
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
height: calc(100vh - 180px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table {
|
||||||
|
flex: 1;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-info h3,
|
||||||
|
.channel-list h3 {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
739
html/NextGB/src/views/mediaserver/MediaServerCard.vue
Normal file
739
html/NextGB/src/views/mediaserver/MediaServerCard.vue
Normal file
@ -0,0 +1,739 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Delete,
|
||||||
|
View,
|
||||||
|
VideoCamera,
|
||||||
|
Microphone,
|
||||||
|
Upload,
|
||||||
|
Download,
|
||||||
|
Connection,
|
||||||
|
VideoPlay,
|
||||||
|
User,
|
||||||
|
Refresh,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import type { MediaServer } from '@/api/mediaserver/types'
|
||||||
|
import type { StreamInfo } from '@/api/mediaserver/types'
|
||||||
|
import { createMediaServer } from '@/api/mediaserver/factory'
|
||||||
|
import zlmLogo from '@/assets/zlm-logo.png'
|
||||||
|
import srsLogo from '@/assets/srs-logo.ico'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
server: MediaServer
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const version = ref<string>('获取中...')
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'delete', server: MediaServer): void
|
||||||
|
(e: 'set-default', server: MediaServer): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isDefault = computed(() => props.server.isDefault === 1)
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
emit('delete', props.server)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetDefault = () => {
|
||||||
|
emit('set-default', props.server)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const streams = ref<StreamInfo[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const expandedRowKeys = ref<string[]>([])
|
||||||
|
|
||||||
|
const handleRowExpand = (row: StreamInfo) => {
|
||||||
|
const index = expandedRowKeys.value.indexOf(row.id)
|
||||||
|
if (index > -1) {
|
||||||
|
expandedRowKeys.value.splice(index, 1)
|
||||||
|
} else {
|
||||||
|
expandedRowKeys.value.push(row.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const withLoading = async (operation: () => Promise<void>) => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await operation()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取流信息失败:', error)
|
||||||
|
ElMessage.error('获取流信息失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleView = async () => {
|
||||||
|
dialogVisible.value = true
|
||||||
|
await withLoading(fetchStreamInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchStreamInfo = async () => {
|
||||||
|
const mediaServer = createMediaServer(props.server)
|
||||||
|
const streamInfos = await mediaServer.getStreamInfo()
|
||||||
|
|
||||||
|
let clientsMap: Record<string, any[]> = {}
|
||||||
|
|
||||||
|
// 根据服务器类型使用不同的获取客户端信息逻辑
|
||||||
|
if (props.server.type === 'ZLM') {
|
||||||
|
// ZLM需要为每个流单独获取客户端信息
|
||||||
|
await Promise.all(
|
||||||
|
streamInfos.map(async (stream) => {
|
||||||
|
const clients = await mediaServer.getClientInfo({ stream_id: stream.id })
|
||||||
|
clientsMap[stream.id] = clients
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// SRS可以一次性获取所有客户端信息
|
||||||
|
const clients = await mediaServer.getClientInfo()
|
||||||
|
clientsMap = clients.reduce(
|
||||||
|
(acc: Record<string, any[]>, client) => {
|
||||||
|
if (!acc[client.stream]) {
|
||||||
|
acc[client.stream] = []
|
||||||
|
}
|
||||||
|
acc[client.stream].push(client)
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将客户端信息关联到对应的流
|
||||||
|
streams.value = streamInfos.map((stream: StreamInfo) => ({
|
||||||
|
...stream,
|
||||||
|
clients_info: clientsMap[stream.id] || [],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshStreams = () => withLoading(fetchStreamInfo)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const mediaServer = createMediaServer(props.server)
|
||||||
|
const versionInfo = await mediaServer.getVersion()
|
||||||
|
version.value = versionInfo.version
|
||||||
|
} catch (error) {
|
||||||
|
version.value = '获取失败'
|
||||||
|
console.error('获取版本信息失败:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatBytes = (bytes?: number) => {
|
||||||
|
if (!bytes) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyUrl = (url: string) => {
|
||||||
|
navigator.clipboard.writeText(url)
|
||||||
|
ElMessage.success('URL已复制到剪贴板')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (ms: number) => {
|
||||||
|
console.log('Duration input:', ms, typeof ms)
|
||||||
|
|
||||||
|
const seconds = Math.floor(ms / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}小时${minutes % 60}分`
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}分${seconds % 60}秒`
|
||||||
|
} else {
|
||||||
|
return `${seconds}秒`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDisconnect = async (stream: StreamInfo) => {
|
||||||
|
// TODO: 实现断开流的API调用
|
||||||
|
ElMessage.warning('断开流功能即将实现')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-card class="server-card" :class="{ 'is-default': isDefault }">
|
||||||
|
<div v-if="isDefault" class="default-ribbon"></div>
|
||||||
|
<div class="server-header">
|
||||||
|
<img :src="server.type === 'ZLM' ? zlmLogo : srsLogo" class="server-icon" />
|
||||||
|
<div class="server-info">
|
||||||
|
<h3>
|
||||||
|
{{ server.name }}
|
||||||
|
<el-tag size="small" type="info" class="type-tag">{{ server.type }}</el-tag>
|
||||||
|
</h3>
|
||||||
|
<div class="server-ip">{{ server.ip }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-tags">
|
||||||
|
<el-tag :type="server.status === 1 ? 'success' : 'danger'" class="status-tag">
|
||||||
|
{{ server.status === 1 ? '在线' : '离线' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag
|
||||||
|
:type="isDefault ? 'warning' : 'info'"
|
||||||
|
class="default-tag"
|
||||||
|
@click="handleSetDefault"
|
||||||
|
style="cursor: pointer"
|
||||||
|
>
|
||||||
|
{{ isDefault ? '默认节点' : '设为默认' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="server-body">
|
||||||
|
<p>版本: {{ version }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="server-footer">
|
||||||
|
<el-button-group>
|
||||||
|
<el-button type="primary" size="small" :icon="View" @click="handleView">查看</el-button>
|
||||||
|
<el-button type="danger" size="small" :icon="Delete" @click="handleDelete">删除</el-button>
|
||||||
|
</el-button-group>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="`${server.name} - 流信息`"
|
||||||
|
width="90%"
|
||||||
|
class="stream-dialog"
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div class="stream-dashboard">
|
||||||
|
<div class="dashboard-item">
|
||||||
|
<div class="dashboard-icon">
|
||||||
|
<el-icon><Connection /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-content">
|
||||||
|
<div class="dashboard-value">{{ streams.length }}</div>
|
||||||
|
<div class="dashboard-label">总流数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-divider"></div>
|
||||||
|
<div class="dashboard-item">
|
||||||
|
<div class="dashboard-icon active">
|
||||||
|
<el-icon><VideoPlay /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-content">
|
||||||
|
<div class="dashboard-value success">{{ streams.filter((s) => s.active).length }}</div>
|
||||||
|
<div class="dashboard-label">活跃流数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-divider"></div>
|
||||||
|
<div class="dashboard-item">
|
||||||
|
<div class="dashboard-icon primary">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-content">
|
||||||
|
<div class="dashboard-value primary">
|
||||||
|
{{ streams.reduce((sum, s) => sum + s.clients, 0) }}
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-label">总客户端数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="loading"
|
||||||
|
:data="streams"
|
||||||
|
style="width: 100%"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
class="stream-table"
|
||||||
|
:empty-text="loading ? '加载中...' : '暂无流数据'"
|
||||||
|
:expand-row-keys="expandedRowKeys"
|
||||||
|
row-key="id"
|
||||||
|
@row-click="(row) => handleRowExpand(row)"
|
||||||
|
>
|
||||||
|
<el-table-column prop="name" label="流名称" min-width="120" />
|
||||||
|
<el-table-column label="URL" min-width="200" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-link type="primary" :underline="false" @click="copyUrl(row.url)">
|
||||||
|
{{ row.url }}
|
||||||
|
</el-link>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="clients" label="客户端数" width="100" align="center" />
|
||||||
|
<el-table-column label="状态" width="80" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.active ? 'success' : 'danger'" size="small">
|
||||||
|
{{ row.active ? '活跃' : '断开' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="编码信息" min-width="320">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="codec-info">
|
||||||
|
<template v-if="row.video">
|
||||||
|
<el-tooltip content="视频编码信息" placement="top">
|
||||||
|
<div class="info-chip video">
|
||||||
|
<el-icon><VideoCamera /></el-icon>
|
||||||
|
<span class="codec">{{ row.video.codec }}</span>
|
||||||
|
<span class="detail">{{ row.video.width }}x{{ row.video.height }}</span>
|
||||||
|
</div>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
<template v-if="row.audio">
|
||||||
|
<el-tooltip content="音频编码信息" placement="top">
|
||||||
|
<div class="info-chip audio">
|
||||||
|
<el-icon><Microphone /></el-icon>
|
||||||
|
<span class="codec">{{ row.audio.codec }}</span>
|
||||||
|
<span class="detail">{{ row.audio.sampleRate }}Hz</span>
|
||||||
|
</div>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="传输数据" width="300">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="transfer-info">
|
||||||
|
<el-tooltip :content="server.type === 'ZLM' ? '下行速率' : '累计下行流量'" placement="top">
|
||||||
|
<div class="info-chip download">
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
<span class="value">{{ formatBytes(row.send_bytes) }}</span>
|
||||||
|
<span v-if="server.type === 'ZLM'" class="rate-unit">/s</span>
|
||||||
|
</div>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip :content="server.type === 'ZLM' ? '上行速率' : '累计上行流量'" placement="top">
|
||||||
|
<div class="info-chip upload">
|
||||||
|
<el-icon><Upload /></el-icon>
|
||||||
|
<span class="value">{{ formatBytes(row.recv_bytes) }}</span>
|
||||||
|
<span v-if="server.type === 'ZLM'" class="rate-unit">/s</span>
|
||||||
|
</div>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
:disabled="!row.active"
|
||||||
|
@click.stop="handleDisconnect(row)"
|
||||||
|
>
|
||||||
|
断开
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column type="expand" :width="0">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="expanded-details">
|
||||||
|
<el-table
|
||||||
|
:data="row.clients_info || []"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
size="small"
|
||||||
|
class="client-table"
|
||||||
|
:show-header="false"
|
||||||
|
style="width: 600px"
|
||||||
|
>
|
||||||
|
<el-table-column prop="id" width="100">
|
||||||
|
<template #default="{ row: client }">
|
||||||
|
<el-tooltip content="客户端ID" placement="top">
|
||||||
|
<span>{{ client.id }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="ip" width="110">
|
||||||
|
<template #default="{ row: client }">
|
||||||
|
<el-tooltip content="IP地址" placement="top">
|
||||||
|
<span>{{ client.ip }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="type" width="60">
|
||||||
|
<template #default="{ row: client }">
|
||||||
|
<el-tooltip content="类型" placement="top">
|
||||||
|
<span>{{ client.type }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="url" min-width="150">
|
||||||
|
<template #default="{ row: client }">
|
||||||
|
<el-tooltip content="URL" placement="top">
|
||||||
|
<el-link type="primary" :underline="false" @click.stop="copyUrl(client.url)">
|
||||||
|
{{ client.url }}
|
||||||
|
</el-link>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="alive" width="80" align="right">
|
||||||
|
<template #default="{ row: client }">
|
||||||
|
<el-tooltip content="存活时间" placement="top">
|
||||||
|
<span>{{ formatDuration(client.alive) }}</span>
|
||||||
|
</el-tooltip>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="dialogVisible = false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="refreshStreams">
|
||||||
|
<el-icon><Refresh /></el-icon>刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.server-card {
|
||||||
|
transition: all 0.3s;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-default {
|
||||||
|
border: 1px solid #e6a23c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-ribbon {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-ribbon::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 150%;
|
||||||
|
height: 24px;
|
||||||
|
background: #e6a23c;
|
||||||
|
transform: rotate(-45deg) translateX(-50%);
|
||||||
|
transform-origin: 0 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info h3 {
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-ip {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-body {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-body p {
|
||||||
|
margin: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-footer {
|
||||||
|
margin-top: 15px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tag,
|
||||||
|
.default-tag {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-tag {
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.default-tag:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-weight: normal;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-radio-group) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-dialog :deep(.el-dialog__header) {
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin-right: 0;
|
||||||
|
border-bottom: 1px solid #dcdfe6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-dialog :deep(.el-dialog__body) {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-dialog :deep(.el-dialog__footer) {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-top: 1px solid #dcdfe6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-dashboard {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-around;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f5f7fa;
|
||||||
|
color: #909399;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-icon.active {
|
||||||
|
background: #f0f9eb;
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-icon.primary {
|
||||||
|
background: #ecf5ff;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-icon :deep(.el-icon) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-value.success {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-value.primary {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 60px;
|
||||||
|
background: linear-gradient(180deg, transparent, #dcdfe6 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-table {
|
||||||
|
margin-top: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-table :deep(.el-table__header-wrapper) {
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-table :deep(.el-table__header) th {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
font-weight: 600;
|
||||||
|
height: 40px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-table :deep(.el-table__row) {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-table :deep(.el-table__cell) {
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-table :deep(.el-table__row:hover) {
|
||||||
|
background-color: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.codec-info,
|
||||||
|
.transfer-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: default;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-chip .el-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-chip.video {
|
||||||
|
background: linear-gradient(45deg, #409eff22, #409eff11);
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-chip.audio {
|
||||||
|
background: linear-gradient(45deg, #67c23a22, #67c23a11);
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-chip.download {
|
||||||
|
background: linear-gradient(45deg, #409eff22, #409eff11);
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-chip.upload {
|
||||||
|
background: linear-gradient(45deg, #e6a23d22, #e6a23d11);
|
||||||
|
color: #e6a23d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-chip .unit {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-chip .rate-unit {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-left: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-chip:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-chip .codec {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-chip .detail,
|
||||||
|
.info-chip .value {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 优化表格样式 */
|
||||||
|
.stream-table :deep(.el-table__row) {
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-table :deep(.el-table__cell) {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-details {
|
||||||
|
padding: 4px 8px 4px 48px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-table {
|
||||||
|
--el-table-border-color: #e4e7ed;
|
||||||
|
--el-table-row-hover-bg-color: #ecf5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-table :deep(.el-table__row) {
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-table :deep(.el-table__cell) {
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-table :deep(.cell) {
|
||||||
|
color: #606266;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-table :deep(.el-link) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-table :deep(.el-table__expand-column) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-table :deep(.el-table__row) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
234
html/NextGB/src/views/mediaserver/MediaServerView.vue
Normal file
234
html/NextGB/src/views/mediaserver/MediaServerView.vue
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Plus, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import MediaServerCard from './MediaServerCard.vue'
|
||||||
|
import type { MediaServer } from '@/api/mediaserver/types'
|
||||||
|
import { mediaServerApi } from '@/api'
|
||||||
|
import {
|
||||||
|
useMediaServers,
|
||||||
|
useDefaultMediaServer,
|
||||||
|
fetchMediaServers,
|
||||||
|
setDefaultMediaServer,
|
||||||
|
deleteMediaServer,
|
||||||
|
checkServersStatus,
|
||||||
|
} from '@/stores/mediaServer'
|
||||||
|
|
||||||
|
const mediaServers = useMediaServers()
|
||||||
|
|
||||||
|
// 表单校验规则
|
||||||
|
const rules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入节点名称', trigger: 'blur' },
|
||||||
|
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
ip: [
|
||||||
|
{ required: true, message: '请输入IP地址', trigger: 'blur' },
|
||||||
|
{ pattern: /^(\d{1,3}\.){3}\d{1,3}$/, message: '请输入正确的IP地址格式', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
port: [
|
||||||
|
{ required: true, message: '请输入端口号', trigger: 'blur' },
|
||||||
|
{
|
||||||
|
type: 'number' as const,
|
||||||
|
min: 1,
|
||||||
|
max: 65535,
|
||||||
|
message: '端口号范围为1-65535',
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
type: [{ required: true, message: '请选择服务器类型', trigger: 'change' }],
|
||||||
|
secret: [
|
||||||
|
{
|
||||||
|
validator: (rule: any, value: string, callback: Function) => {
|
||||||
|
if (newServer.value.type === 'ZLM' && !value) {
|
||||||
|
callback(new Error('请输入 SECRET'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: 'blur'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const formRef = ref()
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const newServer = ref<
|
||||||
|
Pick<MediaServer, 'name' | 'ip' | 'port' | 'type' | 'username' | 'password' | 'isDefault' | 'secret'>
|
||||||
|
>({
|
||||||
|
name: '',
|
||||||
|
ip: '',
|
||||||
|
port: 1985,
|
||||||
|
type: 'SRS',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
isDefault: 0,
|
||||||
|
secret: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
dialogVisible.value = true
|
||||||
|
// 重置表单
|
||||||
|
newServer.value = {
|
||||||
|
name: '',
|
||||||
|
ip: '',
|
||||||
|
port: 1985,
|
||||||
|
type: 'SRS',
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
isDefault: 0,
|
||||||
|
secret: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (server: MediaServer) => {
|
||||||
|
try {
|
||||||
|
await deleteMediaServer(server)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetDefault = (server: MediaServer) => {
|
||||||
|
setDefaultMediaServer(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
await mediaServerApi.addMediaServer({
|
||||||
|
name: newServer.value.name,
|
||||||
|
ip: newServer.value.ip,
|
||||||
|
port: newServer.value.port,
|
||||||
|
type: newServer.value.type,
|
||||||
|
username: newServer.value.username,
|
||||||
|
password: newServer.value.password,
|
||||||
|
isDefault: newServer.value.isDefault,
|
||||||
|
...(newServer.value.type === 'ZLM' ? { secret: newServer.value.secret } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
dialogVisible.value = false
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
await fetchMediaServers()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
formRef.value?.resetFields()
|
||||||
|
dialogVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载时获取数据
|
||||||
|
onMounted(() => {
|
||||||
|
// 延迟3秒后获取服务器状态
|
||||||
|
setTimeout(() => {
|
||||||
|
checkServersStatus()
|
||||||
|
}, 3000)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="media-view">
|
||||||
|
<!-- 工具栏 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button type="primary" @click="handleAdd">
|
||||||
|
<el-icon><Plus /></el-icon>新增节点
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="fetchMediaServers">
|
||||||
|
<el-icon><Refresh /></el-icon>刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 节点卡片列表 -->
|
||||||
|
<div class="server-grid">
|
||||||
|
<MediaServerCard
|
||||||
|
v-for="server in mediaServers"
|
||||||
|
:key="server.id"
|
||||||
|
:server="server"
|
||||||
|
@delete="handleDelete"
|
||||||
|
@set-default="handleSetDefault"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 优化后的添加节点对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
title="添加节点"
|
||||||
|
width="500px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<el-form ref="formRef" :model="newServer" :rules="rules" label-width="100px" status-icon>
|
||||||
|
<el-form-item label="节点名称" prop="name">
|
||||||
|
<el-input v-model="newServer.name" placeholder="请输入节点名称" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="服务器类型">
|
||||||
|
<el-radio-group v-model="newServer.type">
|
||||||
|
<el-radio value="SRS">SRS</el-radio>
|
||||||
|
<el-radio value="ZLM">ZLM</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="IP地址" prop="ip">
|
||||||
|
<el-input v-model="newServer.ip" placeholder="请输入IP地址" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="端口" prop="port">
|
||||||
|
<el-input
|
||||||
|
v-model.number="newServer.port"
|
||||||
|
placeholder="请输入端口号"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="newServer.type === 'ZLM'" label="SECRET" prop="secret">
|
||||||
|
<el-input v-model="newServer.secret" placeholder="请输入 SECRET" clearable show-password />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="设为默认">
|
||||||
|
<el-switch v-model="newServer.isDefault" active-text="是" inactive-text="否" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="handleClose">取 消</el-button>
|
||||||
|
<el-button type="primary" @click="submitForm">确 定</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.media-view {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-form-item__label) {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input-number) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 隐藏验证图标(包括错误和成功状态) */
|
||||||
|
:deep(.el-input__validateIcon) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
414
html/NextGB/src/views/playback/DeviceTree.vue
Normal file
414
html/NextGB/src/views/playback/DeviceTree.vue
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { Device, ChannelInfo } from '@/api/types'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
useDevices,
|
||||||
|
useChannels,
|
||||||
|
useDevicesLoading,
|
||||||
|
fetchDevicesAndChannels,
|
||||||
|
} from '@/stores/devices'
|
||||||
|
import SearchBox from '@/components/common/SearchBox.vue'
|
||||||
|
|
||||||
|
interface DeviceNode {
|
||||||
|
device_id: string
|
||||||
|
label: string
|
||||||
|
children?: DeviceNode[]
|
||||||
|
isChannel?: boolean
|
||||||
|
channelInfo?: ChannelInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = useDevices()
|
||||||
|
const channels = useChannels()
|
||||||
|
const loading = useDevicesLoading()
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const expandedKeys = ref<string[]>([])
|
||||||
|
const selectedChannels = ref<Set<string>>(new Set())
|
||||||
|
const viewMode = ref<'tree' | 'list'>('list')
|
||||||
|
|
||||||
|
const deviceNodes = computed(() => {
|
||||||
|
const nodes: DeviceNode[] = []
|
||||||
|
for (const device of devices.value) {
|
||||||
|
const deviceChannels = channels.value.filter(
|
||||||
|
(channel: ChannelInfo) => channel.parent_id === device.device_id,
|
||||||
|
)
|
||||||
|
const deviceNode: DeviceNode = {
|
||||||
|
device_id: device.device_id,
|
||||||
|
label: device.name || '未命名',
|
||||||
|
children: deviceChannels.map((channel: ChannelInfo) => ({
|
||||||
|
device_id: channel.device_id,
|
||||||
|
label: `${channel.name}`,
|
||||||
|
isChannel: true,
|
||||||
|
channelInfo: channel,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
nodes.push(deviceNode)
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshDevices = async () => {
|
||||||
|
try {
|
||||||
|
await fetchDevicesAndChannels()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('刷新设备列表失败')
|
||||||
|
}
|
||||||
|
tooltipRef.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(
|
||||||
|
e: 'update:selectedChannels',
|
||||||
|
channels: { device: Device | undefined; channel: ChannelInfo }[],
|
||||||
|
): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleChannelSelect = (channelId: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
// 清除之前选中的所有通道
|
||||||
|
selectedChannels.value.clear()
|
||||||
|
// 只添加当前选中的通道
|
||||||
|
selectedChannels.value.add(channelId)
|
||||||
|
} else {
|
||||||
|
selectedChannels.value.delete(channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送选中的通道信息
|
||||||
|
const selectedChannelInfos = Array.from(selectedChannels.value)
|
||||||
|
.map((id) => {
|
||||||
|
const channel = channels.value.find((c: ChannelInfo) => c.device_id === id)
|
||||||
|
if (channel) {
|
||||||
|
return {
|
||||||
|
device: devices.value.find((d: Device) => d.device_id === channel.parent_id),
|
||||||
|
channel,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
.filter((info) => info !== null) as { device: Device | undefined; channel: ChannelInfo }[]
|
||||||
|
|
||||||
|
emit('update:selectedChannels', selectedChannelInfos)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredData = computed(() => {
|
||||||
|
const nodes = deviceNodes.value
|
||||||
|
const query = searchQuery.value.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (viewMode.value === 'list') {
|
||||||
|
const allChannels = nodes.flatMap((node) =>
|
||||||
|
(node.children || []).map((channel) => ({
|
||||||
|
...channel,
|
||||||
|
parentDeviceId: node.device_id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: '所有通道',
|
||||||
|
device_id: 'root',
|
||||||
|
children: allChannels,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredChannels = allChannels.filter(
|
||||||
|
(channel) =>
|
||||||
|
channel.label.toLowerCase().includes(query) ||
|
||||||
|
channel.device_id.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: '所有通道',
|
||||||
|
device_id: 'root',
|
||||||
|
children: filteredChannels,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
expandedKeys.value = []
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedKeys.value = ['root']
|
||||||
|
|
||||||
|
return nodes.filter((node) => {
|
||||||
|
const searchNode = (item: any): boolean => {
|
||||||
|
const isMatch =
|
||||||
|
item.label?.toLowerCase().includes(query) || item.device_id?.toLowerCase().includes(query)
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
if (item.isChannel) {
|
||||||
|
const parentDevice = nodes.find((device) =>
|
||||||
|
device.children?.some((channel) => channel.device_id === item.device_id),
|
||||||
|
)
|
||||||
|
if (parentDevice) {
|
||||||
|
expandedKeys.value.push(parentDevice.device_id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expandedKeys.value.push(item.device_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.children) {
|
||||||
|
const hasMatchingChild = item.children.some(searchNode)
|
||||||
|
if (hasMatchingChild && !expandedKeys.value.includes(item.device_id)) {
|
||||||
|
expandedKeys.value.push(item.device_id)
|
||||||
|
}
|
||||||
|
return isMatch || hasMatchingChild
|
||||||
|
}
|
||||||
|
return isMatch
|
||||||
|
}
|
||||||
|
return searchNode(node)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooltipRef = ref()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="device-tree">
|
||||||
|
<SearchBox
|
||||||
|
v-model:searchQuery="searchQuery"
|
||||||
|
v-model:viewMode="viewMode"
|
||||||
|
:loading="loading"
|
||||||
|
:show-view-mode-switch="true"
|
||||||
|
@refresh="refreshDevices"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-tree
|
||||||
|
v-if="viewMode === 'tree'"
|
||||||
|
v-loading="loading"
|
||||||
|
:data="[
|
||||||
|
{
|
||||||
|
label: '所有设备',
|
||||||
|
device_id: 'root',
|
||||||
|
children: filteredData,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:props="{ children: 'children', label: 'label' }"
|
||||||
|
node-key="device_id"
|
||||||
|
:expanded-keys="expandedKeys"
|
||||||
|
default-expand-all
|
||||||
|
>
|
||||||
|
<template #default="{ node, data }">
|
||||||
|
<span class="custom-tree-node">
|
||||||
|
<el-checkbox
|
||||||
|
v-if="data.isChannel"
|
||||||
|
:model-value="selectedChannels.has(data.device_id)"
|
||||||
|
@update:model-value="
|
||||||
|
(checked) => handleChannelSelect(data.device_id, checked as boolean)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span :class="data.isChannel ? 'channel-label' : 'device-label'">
|
||||||
|
{{ data.label }}
|
||||||
|
</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="data.isChannel"
|
||||||
|
size="small"
|
||||||
|
:type="data.channelInfo?.status === 'ON' ? 'success' : 'danger'"
|
||||||
|
>
|
||||||
|
{{ data.channelInfo?.status === 'ON' ? '在线' : '离线' }}
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
|
||||||
|
<div v-else class="channel-list" v-loading="loading">
|
||||||
|
<el-tree
|
||||||
|
:data="filteredData"
|
||||||
|
:props="{ children: 'children', label: 'label' }"
|
||||||
|
node-key="device_id"
|
||||||
|
:default-expanded-keys="['root']"
|
||||||
|
>
|
||||||
|
<template #default="{ node, data }">
|
||||||
|
<span class="custom-tree-node">
|
||||||
|
<el-checkbox
|
||||||
|
v-if="data.isChannel"
|
||||||
|
:model-value="selectedChannels.has(data.device_id)"
|
||||||
|
@update:model-value="
|
||||||
|
(checked) => handleChannelSelect(data.device_id, checked as boolean)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span :class="data.isChannel ? 'channel-label' : 'device-label'">
|
||||||
|
{{ data.label }}
|
||||||
|
</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="data.isChannel"
|
||||||
|
size="small"
|
||||||
|
:type="data.channelInfo?.status === 'ON' ? 'success' : 'danger'"
|
||||||
|
>
|
||||||
|
{{ data.channelInfo?.status === 'ON' ? '在线' : '离线' }}
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.device-tree {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.el-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
border-color: transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 5px 8px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-icon) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.refresh-btn {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tree {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
:deep(.el-tree-node__content) {
|
||||||
|
height: 36px;
|
||||||
|
padding-left: 8px !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tree-node__expand-icon) {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-leaf {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 4px;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.device-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-label {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tag {
|
||||||
|
margin-left: auto;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-checkbox) {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
:deep(.el-tree) {
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
.el-tree-node__content {
|
||||||
|
height: 36px;
|
||||||
|
padding-left: 8px !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-current {
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tree-node__expand-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-leaf {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
729
html/NextGB/src/views/playback/PlaybackView.vue
Normal file
729
html/NextGB/src/views/playback/PlaybackView.vue
Normal file
@ -0,0 +1,729 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onBeforeUnmount, onActivated, onDeactivated, watch } from 'vue'
|
||||||
|
import DeviceTree from './DeviceTree.vue'
|
||||||
|
import MonitorGrid from '@/components/monitor/MonitorGrid.vue'
|
||||||
|
import DateTimeRangePanel from '@/components/common/DateTimeRangePanel.vue'
|
||||||
|
import type { Device, ChannelInfo, RecordInfoResponse } from '@/api/types'
|
||||||
|
import type { LayoutConfig } from '@/types/layout'
|
||||||
|
import { deviceApi } from '@/api'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import { VideoPlay, VideoPause, CloseBold, Microphone } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
type LayoutKey = '1'
|
||||||
|
type LayoutConfigs = Record<LayoutKey, LayoutConfig>
|
||||||
|
|
||||||
|
// 布局配置
|
||||||
|
const layouts: LayoutConfigs = {
|
||||||
|
'1': { cols: 1, rows: 1, size: 1, label: '单屏' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const monitorGridRef = ref()
|
||||||
|
const selectedChannels = ref<{ device: Device | undefined; channel: ChannelInfo }[]>([])
|
||||||
|
const volume = ref(100)
|
||||||
|
const currentLayout = ref<'1'>('1') // 固定为单屏模式
|
||||||
|
const defaultMuted = ref(true)
|
||||||
|
const activeWindow = ref<{ deviceId: string; channelId: string } | null>(null)
|
||||||
|
const isPlaying = ref(false) // 添加播放状态变量
|
||||||
|
const isFirstPlay = ref(true)
|
||||||
|
|
||||||
|
// 时间轴刻度显示控制
|
||||||
|
const timelineWidth = ref(0)
|
||||||
|
const showAllLabels = computed(() => timelineWidth.value >= 720) // 当宽度大于720px时显示所有标签
|
||||||
|
const showMediumLabels = computed(() => timelineWidth.value >= 480) // 当宽度大于480px时显示中等标签
|
||||||
|
|
||||||
|
// 时间轴光标位置
|
||||||
|
const cursorPosition = ref(0)
|
||||||
|
const cursorTime = ref('')
|
||||||
|
const isTimelineHovered = ref(false)
|
||||||
|
|
||||||
|
const getTimeFromEvent = (event: MouseEvent, element: HTMLElement) => {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
const position = ((event.clientX - rect.left) / rect.width) * 100
|
||||||
|
const normalizedPosition = Math.max(0, Math.min(100, position))
|
||||||
|
|
||||||
|
const totalMinutes = 24 * 60
|
||||||
|
const minutes = Math.floor((normalizedPosition / 100) * totalMinutes)
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const mins = minutes % 60
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: normalizedPosition,
|
||||||
|
time: dayjs().startOf('day').add(hours, 'hour').add(mins, 'minute'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimelineMouseMove = (event: MouseEvent) => {
|
||||||
|
const timeline = event.currentTarget as HTMLElement
|
||||||
|
const { position, time } = getTimeFromEvent(event, timeline)
|
||||||
|
cursorPosition.value = position
|
||||||
|
cursorTime.value = time.format('HH:mm:ss')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理时间轴鼠标进入/离开
|
||||||
|
const handleTimelineMouseEnter = () => {
|
||||||
|
isTimelineHovered.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimelineMouseLeave = () => {
|
||||||
|
isTimelineHovered.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 屏幕尺寸类型
|
||||||
|
const screenType = computed(() => {
|
||||||
|
if (timelineWidth.value >= 720) return '大屏'
|
||||||
|
if (timelineWidth.value >= 480) return '中屏'
|
||||||
|
return '小屏'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 根据屏幕宽度调整时间轴高度
|
||||||
|
const timelineHeight = computed(() => {
|
||||||
|
if (timelineWidth.value >= 720) return 60
|
||||||
|
if (timelineWidth.value >= 480) return 48
|
||||||
|
return 36
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听时间轴宽度变化
|
||||||
|
const updateTimelineWidth = () => {
|
||||||
|
const timeline = document.querySelector('.timeline-scale')
|
||||||
|
if (timeline) {
|
||||||
|
timelineWidth.value = timeline.clientWidth
|
||||||
|
console.log(`时间轴宽度: ${timelineWidth.value}px, 当前屏幕: ${screenType.value}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听窗口大小变化
|
||||||
|
onMounted(() => {
|
||||||
|
updateTimelineWidth()
|
||||||
|
window.addEventListener('resize', updateTimelineWidth)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', updateTimelineWidth)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleWindowSelect = (data: { deviceId: string; channelId: string } | null) => {
|
||||||
|
activeWindow.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordSegments = ref<RecordInfoResponse[]>([])
|
||||||
|
|
||||||
|
const handleQueryRecord = async ({ start, end }: { start: string; end: string }) => {
|
||||||
|
if (selectedChannels.value.length === 0) {
|
||||||
|
ElMessage.warning('请先选择要查询的通道')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const promises = selectedChannels.value.map(async ({ device, channel }) => {
|
||||||
|
if (!device?.device_id || !channel.device_id) return []
|
||||||
|
|
||||||
|
const response = await deviceApi.queryRecord({
|
||||||
|
device_id: device.device_id,
|
||||||
|
channel_id: channel.device_id,
|
||||||
|
start_time: dayjs(start).unix(),
|
||||||
|
end_time: dayjs(end).unix(),
|
||||||
|
})
|
||||||
|
return Array.isArray(response.data) ? response.data : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.all<RecordInfoResponse[]>(promises)
|
||||||
|
recordSegments.value = results.flat()
|
||||||
|
|
||||||
|
// 自动激活第一个选中的通道
|
||||||
|
if (selectedChannels.value.length > 0) {
|
||||||
|
const firstChannel = selectedChannels.value[0]
|
||||||
|
if (firstChannel.device?.device_id && firstChannel.channel.device_id) {
|
||||||
|
activeWindow.value = {
|
||||||
|
deviceId: firstChannel.device.device_id,
|
||||||
|
channelId: firstChannel.channel.device_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('查询录像失败:', error)
|
||||||
|
ElMessage.error('查询录像失败')
|
||||||
|
recordSegments.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStop = () => {
|
||||||
|
monitorGridRef.value?.stop(0)
|
||||||
|
isPlaying.value = false // 设置播放状态为 false
|
||||||
|
|
||||||
|
// 确保 activeWindow 始终指向第一个屏幕
|
||||||
|
if (selectedChannels.value.length > 0) {
|
||||||
|
const firstChannel = selectedChannels.value[0]
|
||||||
|
if (firstChannel.device?.device_id && firstChannel.channel.device_id) {
|
||||||
|
activeWindow.value = {
|
||||||
|
deviceId: firstChannel.device.device_id,
|
||||||
|
channelId: firstChannel.channel.device_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听 selectedChannels 变化,确保 activeWindow 始终指向第一个屏幕
|
||||||
|
watch(selectedChannels, (newChannels) => {
|
||||||
|
if (newChannels.length > 0) {
|
||||||
|
const firstChannel = newChannels[0]
|
||||||
|
if (firstChannel.device?.device_id && firstChannel.channel.device_id) {
|
||||||
|
activeWindow.value = {
|
||||||
|
deviceId: firstChannel.device.device_id,
|
||||||
|
channelId: firstChannel.channel.device_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const calculatePosition = (time: string) => {
|
||||||
|
const hour = dayjs(time).hour()
|
||||||
|
const minute = dayjs(time).minute()
|
||||||
|
return ((hour * 60 + minute) / (24 * 60)) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateWidth = (start: string, end: string) => {
|
||||||
|
const startMinutes = dayjs(start).hour() * 60 + dayjs(start).minute()
|
||||||
|
const endMinutes = dayjs(end).hour() * 60 + dayjs(end).minute()
|
||||||
|
return ((endMinutes - startMinutes) / (24 * 60)) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加激活/停用处理
|
||||||
|
onActivated(() => {
|
||||||
|
console.log('PlaybackView activated')
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
console.log('PlaybackView deactivated')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件名称(用于 keep-alive)
|
||||||
|
defineOptions({
|
||||||
|
name: 'PlaybackView',
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTimelineDoubleClick = async (event: MouseEvent) => {
|
||||||
|
if (selectedChannels.value.length === 0) {
|
||||||
|
ElMessage.warning('请先选择要播放的通道')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline = event.currentTarget as HTMLElement
|
||||||
|
const { time } = getTimeFromEvent(event, timeline)
|
||||||
|
const endTime = dayjs().endOf('day')
|
||||||
|
|
||||||
|
// 只播放第一个选中的通道
|
||||||
|
const { device, channel } = selectedChannels.value[0]
|
||||||
|
if (!device?.device_id || !channel.device_id) {
|
||||||
|
ElMessage.warning('设备信息不完整')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
monitorGridRef.value?.play({
|
||||||
|
...device,
|
||||||
|
channel: channel,
|
||||||
|
play_type: 1, // 1 表示回放
|
||||||
|
start_time: time.unix(),
|
||||||
|
end_time: endTime.unix(), // 使用当天 23:59:59 的时间戳
|
||||||
|
})
|
||||||
|
isPlaying.value = true // 设置播放状态为 true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('播放录像失败:', error)
|
||||||
|
ElMessage.error('播放录像失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理播放/暂停切换
|
||||||
|
const handlePlayPause = async () => {
|
||||||
|
if (!activeWindow.value || selectedChannels.value.length === 0) return
|
||||||
|
|
||||||
|
if (!isPlaying.value) {
|
||||||
|
// 开始播放
|
||||||
|
const { device, channel } = selectedChannels.value[0]
|
||||||
|
if (!device?.device_id || !channel.device_id) {
|
||||||
|
ElMessage.warning('设备信息不完整')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 如果有录像段,则根据是否是第一次播放来决定是调用 play 还是 resume
|
||||||
|
if (recordSegments.value.length === 0) {
|
||||||
|
ElMessage.warning('没有可播放的录像')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFirstPlay.value) {
|
||||||
|
monitorGridRef.value?.play({
|
||||||
|
...device,
|
||||||
|
channel: channel,
|
||||||
|
play_type: 1, // 1 表示回放
|
||||||
|
start_time: recordSegments.value[0].start_time,
|
||||||
|
end_time: recordSegments.value[0].end_time,
|
||||||
|
})
|
||||||
|
isFirstPlay.value = false
|
||||||
|
} else {
|
||||||
|
monitorGridRef.value?.resume(0)
|
||||||
|
}
|
||||||
|
isPlaying.value = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('播放录像失败:', error)
|
||||||
|
ElMessage.error('播放录像失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 暂停播放
|
||||||
|
try {
|
||||||
|
const { device, channel } = selectedChannels.value[0]
|
||||||
|
if (!device?.device_id || !channel.device_id) {
|
||||||
|
ElMessage.warning('设备信息不完整')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('暂停录像')
|
||||||
|
monitorGridRef.value?.pause(0)
|
||||||
|
isPlaying.value = false
|
||||||
|
} catch (error) {
|
||||||
|
console.error('暂停录像失败:', error)
|
||||||
|
ElMessage.error('暂停录像失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="playback-container">
|
||||||
|
<div class="left-panel">
|
||||||
|
<DeviceTree v-model:selectedChannels="selectedChannels" />
|
||||||
|
<DateTimeRangePanel title="录像查询" @search="handleQueryRecord" />
|
||||||
|
</div>
|
||||||
|
<div class="right-panel">
|
||||||
|
<div class="playback-panel">
|
||||||
|
<MonitorGrid
|
||||||
|
ref="monitorGridRef"
|
||||||
|
v-model="currentLayout"
|
||||||
|
:layouts="layouts"
|
||||||
|
:default-muted="defaultMuted"
|
||||||
|
:show-border="false"
|
||||||
|
@window-select="handleWindowSelect"
|
||||||
|
/>
|
||||||
|
<div class="timeline-panel" :style="{ height: `${timelineHeight}px` }">
|
||||||
|
<div class="timeline-ruler">
|
||||||
|
<div
|
||||||
|
class="timeline-scale"
|
||||||
|
@mousemove="handleTimelineMouseMove"
|
||||||
|
@mouseenter="handleTimelineMouseEnter"
|
||||||
|
@mouseleave="handleTimelineMouseLeave"
|
||||||
|
@dblclick="handleTimelineDoubleClick"
|
||||||
|
>
|
||||||
|
<div class="timeline-marks">
|
||||||
|
<div
|
||||||
|
v-for="hour in 24"
|
||||||
|
:key="hour"
|
||||||
|
class="hour-mark"
|
||||||
|
:class="{
|
||||||
|
'major-mark': (hour - 1) % 6 === 0,
|
||||||
|
'medium-mark': (hour - 1) % 3 === 0 && (hour - 1) % 6 !== 0,
|
||||||
|
'minor-mark': (hour - 1) % 3 !== 0,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
(hour - 1) % 6 === 0 ||
|
||||||
|
(showMediumLabels && (hour - 1) % 3 === 0) ||
|
||||||
|
showAllLabels
|
||||||
|
"
|
||||||
|
class="hour-label"
|
||||||
|
>
|
||||||
|
{{ (hour - 1).toString().padStart(2, '0') }}
|
||||||
|
</div>
|
||||||
|
<div class="hour-line"></div>
|
||||||
|
<div class="half-hour-mark"></div>
|
||||||
|
</div>
|
||||||
|
<div class="hour-mark major-mark" style="flex: 0 0 auto">
|
||||||
|
<div class="hour-label">24</div>
|
||||||
|
<div class="hour-line"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="timeline-cursor"
|
||||||
|
:class="{ visible: isTimelineHovered }"
|
||||||
|
:style="{ left: `${cursorPosition}%` }"
|
||||||
|
>
|
||||||
|
<div class="cursor-time" :class="{ visible: isTimelineHovered }">
|
||||||
|
{{ cursorTime }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="record-segments">
|
||||||
|
<div
|
||||||
|
v-for="(segment, index) in recordSegments"
|
||||||
|
:key="index"
|
||||||
|
class="record-segment"
|
||||||
|
:style="{
|
||||||
|
left: `${calculatePosition(segment.start_time)}%`,
|
||||||
|
width: `${calculateWidth(segment.start_time, segment.end_time)}%`,
|
||||||
|
}"
|
||||||
|
:title="`${dayjs(segment.start_time).format('HH:mm:ss')} - ${dayjs(segment.end_time).format('HH:mm:ss')}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="control-panel">
|
||||||
|
<div class="control-group">
|
||||||
|
<el-button-group>
|
||||||
|
<el-button
|
||||||
|
:icon="isPlaying ? VideoPause : VideoPlay"
|
||||||
|
:disabled="!activeWindow"
|
||||||
|
size="large"
|
||||||
|
:title="isPlaying ? '暂停' : '播放'"
|
||||||
|
@click="handlePlayPause"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
:icon="CloseBold"
|
||||||
|
:disabled="!activeWindow"
|
||||||
|
@click="handleStop"
|
||||||
|
size="large"
|
||||||
|
title="停止"
|
||||||
|
/>
|
||||||
|
</el-button-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="volume-control">
|
||||||
|
<el-icon><Microphone /></el-icon>
|
||||||
|
<el-slider
|
||||||
|
v-model="volume"
|
||||||
|
:max="100"
|
||||||
|
:min="0"
|
||||||
|
:disabled="!activeWindow"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.playback-container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel {
|
||||||
|
width: 280px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel {
|
||||||
|
flex: 1;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-panel {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-panel {
|
||||||
|
height: 60px;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 140px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
|
||||||
|
:deep(.el-icon) {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button-group {
|
||||||
|
.el-button {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
--el-button-bg-color: transparent;
|
||||||
|
--el-button-border-color: transparent;
|
||||||
|
--el-button-hover-bg-color: rgba(255, 255, 255, 0.1);
|
||||||
|
--el-button-hover-border-color: transparent;
|
||||||
|
--el-button-active-bg-color: rgba(255, 255, 255, 0.15);
|
||||||
|
--el-button-text-color: rgba(255, 255, 255, 0.85);
|
||||||
|
--el-button-disabled-text-color: rgba(255, 255, 255, 0.3);
|
||||||
|
--el-button-disabled-bg-color: transparent;
|
||||||
|
--el-button-disabled-border-color: transparent;
|
||||||
|
|
||||||
|
:deep(.el-icon) {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
--el-button-text-color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-slider) {
|
||||||
|
--el-slider-main-bg-color: var(--el-color-primary);
|
||||||
|
--el-slider-runway-bg-color: rgba(255, 255, 255, 0.15);
|
||||||
|
--el-slider-stop-bg-color: rgba(255, 255, 255, 0.2);
|
||||||
|
--el-slider-disabled-color: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
.el-slider__runway {
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-slider__button {
|
||||||
|
border: none;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-slider__bar {
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-panel {
|
||||||
|
background-color: #242424;
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
user-select: none;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-ruler {
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
padding: 0 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-marks {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-scale {
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-mark {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-label {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-line {
|
||||||
|
position: relative;
|
||||||
|
width: 1px;
|
||||||
|
height: 8px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.half-hour-mark {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
height: 6px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-cursor {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background-color: var(--el-color-warning);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 4px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(var(--el-color-warning-rgb), 0.2),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-time {
|
||||||
|
position: absolute;
|
||||||
|
top: -20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: var(--el-color-warning);
|
||||||
|
color: #000;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.major-mark {
|
||||||
|
.hour-line {
|
||||||
|
height: 16px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.4);
|
||||||
|
width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-label {
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.medium-mark {
|
||||||
|
.hour-line {
|
||||||
|
height: 12px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.25);
|
||||||
|
width: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-label {
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.minor-mark {
|
||||||
|
.hour-line {
|
||||||
|
height: 8px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.15);
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hour-label {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-ruler:hover .timeline-pointer {
|
||||||
|
box-shadow: 0 0 8px rgba(var(--el-color-primary-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-panel::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
rgba(255, 255, 255, 0.1) 20%,
|
||||||
|
rgba(255, 255, 255, 0.1) 80%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-segments {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 24px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-segment {
|
||||||
|
position: absolute;
|
||||||
|
height: 8px;
|
||||||
|
bottom: 12px;
|
||||||
|
background-color: var(--el-color-success);
|
||||||
|
opacity: 0.85;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
503
html/NextGB/src/views/realplay/DeviceTree.vue
Normal file
503
html/NextGB/src/views/realplay/DeviceTree.vue
Normal file
@ -0,0 +1,503 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { Device, ChannelInfo } from '@/api/types'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
useDevices,
|
||||||
|
useChannels,
|
||||||
|
useDevicesLoading,
|
||||||
|
fetchDevicesAndChannels,
|
||||||
|
} from '@/stores/devices'
|
||||||
|
import SearchBox from '@/components/common/SearchBox.vue'
|
||||||
|
|
||||||
|
interface DeviceNode {
|
||||||
|
device_id: string
|
||||||
|
label: string
|
||||||
|
children?: DeviceNode[]
|
||||||
|
isChannel?: boolean
|
||||||
|
channelInfo?: ChannelInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = useDevices()
|
||||||
|
const channels = useChannels()
|
||||||
|
const loading = useDevicesLoading()
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const expandedKeys = ref<string[]>([])
|
||||||
|
|
||||||
|
const deviceNodes = computed(() => {
|
||||||
|
const nodes: DeviceNode[] = []
|
||||||
|
for (const device of devices.value) {
|
||||||
|
const deviceChannels = channels.value.filter(
|
||||||
|
(channel: ChannelInfo) => channel.parent_id === device.device_id,
|
||||||
|
)
|
||||||
|
const deviceNode: DeviceNode = {
|
||||||
|
device_id: device.device_id,
|
||||||
|
label: device.name || '未命名',
|
||||||
|
children: deviceChannels.map((channel: ChannelInfo) => ({
|
||||||
|
device_id: channel.device_id,
|
||||||
|
label: `${channel.name}`,
|
||||||
|
isChannel: true,
|
||||||
|
channelInfo: channel,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
nodes.push(deviceNode)
|
||||||
|
}
|
||||||
|
return nodes
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshDevices = async () => {
|
||||||
|
try {
|
||||||
|
await fetchDevicesAndChannels()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('刷新设备列表失败')
|
||||||
|
}
|
||||||
|
tooltipRef.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'select', data: { device: Device | undefined; channel: ChannelInfo }): void
|
||||||
|
(e: 'play', data: { device: Device | undefined; channel: ChannelInfo }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleSelect = (data: DeviceNode) => {
|
||||||
|
if (data.isChannel && data.channelInfo) {
|
||||||
|
emit('select', {
|
||||||
|
device: devices.value.find((d: Device) => d.device_id === data.channelInfo?.parent_id),
|
||||||
|
channel: data.channelInfo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNodeDbClick = (data: DeviceNode) => {
|
||||||
|
if (data.isChannel && data.channelInfo) {
|
||||||
|
emit('play', {
|
||||||
|
device: devices.value.find((d: Device) => d.device_id === data.channelInfo?.parent_id),
|
||||||
|
channel: data.channelInfo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewMode = ref<'tree' | 'list'>('tree')
|
||||||
|
|
||||||
|
const filteredData = computed(() => {
|
||||||
|
const nodes = deviceNodes.value
|
||||||
|
const query = searchQuery.value.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (viewMode.value === 'list') {
|
||||||
|
const allChannels = nodes.flatMap((node) =>
|
||||||
|
(node.children || []).map((channel) => ({
|
||||||
|
...channel,
|
||||||
|
parentDeviceId: node.device_id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: '所有通道',
|
||||||
|
device_id: 'root',
|
||||||
|
children: allChannels,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredChannels = allChannels.filter(
|
||||||
|
(channel) =>
|
||||||
|
channel.label.toLowerCase().includes(query) ||
|
||||||
|
channel.device_id.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: '所有通道',
|
||||||
|
device_id: 'root',
|
||||||
|
children: filteredChannels,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
expandedKeys.value = []
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedKeys.value = ['root']
|
||||||
|
|
||||||
|
return nodes.filter((node) => {
|
||||||
|
const searchNode = (item: any): boolean => {
|
||||||
|
const isMatch =
|
||||||
|
item.label?.toLowerCase().includes(query) || item.device_id?.toLowerCase().includes(query)
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
if (item.isChannel) {
|
||||||
|
const parentDevice = nodes.find((device) =>
|
||||||
|
device.children?.some((channel) => channel.device_id === item.device_id),
|
||||||
|
)
|
||||||
|
if (parentDevice) {
|
||||||
|
expandedKeys.value.push(parentDevice.device_id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expandedKeys.value.push(item.device_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.children) {
|
||||||
|
const hasMatchingChild = item.children.some(searchNode)
|
||||||
|
if (hasMatchingChild && !expandedKeys.value.includes(item.device_id)) {
|
||||||
|
expandedKeys.value.push(item.device_id)
|
||||||
|
}
|
||||||
|
return isMatch || hasMatchingChild
|
||||||
|
}
|
||||||
|
return isMatch
|
||||||
|
}
|
||||||
|
return searchNode(node)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const tooltipRef = ref()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="device-tree">
|
||||||
|
<SearchBox
|
||||||
|
v-model:searchQuery="searchQuery"
|
||||||
|
v-model:viewMode="viewMode"
|
||||||
|
:loading="loading"
|
||||||
|
:show-view-mode-switch="true"
|
||||||
|
@refresh="refreshDevices"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-tree
|
||||||
|
v-if="viewMode === 'tree'"
|
||||||
|
v-loading="loading"
|
||||||
|
:data="[
|
||||||
|
{
|
||||||
|
label: '所有设备',
|
||||||
|
device_id: 'root',
|
||||||
|
children: filteredData,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:props="{ children: 'children', label: 'label' }"
|
||||||
|
@node-click="handleSelect"
|
||||||
|
node-key="device_id"
|
||||||
|
highlight-current
|
||||||
|
:expanded-keys="expandedKeys"
|
||||||
|
>
|
||||||
|
<template #default="{ node, data }">
|
||||||
|
<span class="custom-tree-node" @dblclick.stop="handleNodeDbClick(data)">
|
||||||
|
<span :class="data.isChannel ? 'channel-label' : 'device-label'">
|
||||||
|
{{ data.label }}
|
||||||
|
<template v-if="data.isChannel && data.channelInfo"> </template>
|
||||||
|
</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="data.isChannel"
|
||||||
|
size="small"
|
||||||
|
:type="data.channelInfo?.status === 'ON' ? 'success' : 'danger'"
|
||||||
|
>
|
||||||
|
{{ data.channelInfo?.status === 'ON' ? '在线' : '离线' }}
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
|
||||||
|
<div v-else class="channel-list" v-loading="loading">
|
||||||
|
<el-tree
|
||||||
|
:data="filteredData"
|
||||||
|
:props="{ children: 'children', label: 'label' }"
|
||||||
|
@node-click="handleSelect"
|
||||||
|
node-key="device_id"
|
||||||
|
highlight-current
|
||||||
|
:default-expanded-keys="['root']"
|
||||||
|
>
|
||||||
|
<template #default="{ node, data }">
|
||||||
|
<span class="custom-tree-node" @dblclick.stop="handleNodeDbClick(data)">
|
||||||
|
<span :class="data.isChannel ? 'channel-label' : 'device-label'">
|
||||||
|
{{ data.label }}
|
||||||
|
</span>
|
||||||
|
<el-tag
|
||||||
|
v-if="data.isChannel"
|
||||||
|
size="small"
|
||||||
|
:type="data.channelInfo?.status === 'ON' ? 'success' : 'danger'"
|
||||||
|
>
|
||||||
|
{{ data.channelInfo?.status === 'ON' ? '在线' : '离线' }}
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.device-tree {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.el-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
border-color: transparent;
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 5px 8px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-top-left-radius: 4px;
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-top-right-radius: 4px;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-icon) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-loading {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.refresh-btn {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-loading {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__wrapper) {
|
||||||
|
box-shadow: 0 0 0 1px var(--el-border-color) inset;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 0 0 1px var(--el-border-color-hover) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-focus {
|
||||||
|
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tree {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
:deep(.el-tree-node) {
|
||||||
|
&.is-expanded > .el-tree-node__children {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
height: 100%;
|
||||||
|
width: 1px;
|
||||||
|
position: absolute;
|
||||||
|
left: -12px;
|
||||||
|
top: -4px;
|
||||||
|
border-left: 1px dotted var(--el-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child::before {
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tree-node__content) {
|
||||||
|
height: 36px;
|
||||||
|
padding-left: 8px !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-current {
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tree-node__expand-icon) {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-leaf {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 4px;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.device-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-label {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tag {
|
||||||
|
margin-left: auto;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
:deep(.el-tree) {
|
||||||
|
background: transparent;
|
||||||
|
|
||||||
|
.el-tree-node__content {
|
||||||
|
height: 36px;
|
||||||
|
padding-left: 8px !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-current {
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tree-node__expand-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
|
||||||
|
&.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-leaf {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin: 2px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
min-height: 32px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-label {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tag {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
transform-origin: right;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-tree-node__content) {
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
.el-button-group {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml-2 {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tree-node {
|
||||||
|
.channel-label,
|
||||||
|
.device-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
386
html/NextGB/src/views/realplay/PtzControlPanel.vue
Normal file
386
html/NextGB/src/views/realplay/PtzControlPanel.vue
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { ArrowRight, VideoCamera } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight as ArrowRightControl,
|
||||||
|
TopLeft,
|
||||||
|
TopRight,
|
||||||
|
BottomLeft,
|
||||||
|
BottomRight,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { deviceApi } from '@/api'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
activeWindow?: {
|
||||||
|
deviceId: string
|
||||||
|
channelId: string
|
||||||
|
} | null
|
||||||
|
title?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
control: [direction: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isCollapsed = ref(false)
|
||||||
|
const speed = ref(5)
|
||||||
|
|
||||||
|
const handlePtzStart = async (direction: string) => {
|
||||||
|
if (!props.activeWindow) {
|
||||||
|
ElMessage.warning('请先选择一个视频窗口')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deviceApi.controlPTZ({
|
||||||
|
device_id: props.activeWindow.deviceId,
|
||||||
|
channel_id: props.activeWindow.channelId,
|
||||||
|
ptz: direction,
|
||||||
|
speed: speed.value.toString(),
|
||||||
|
})
|
||||||
|
emit('control', direction)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PTZ control failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePtzStop = async () => {
|
||||||
|
if (!props.activeWindow) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deviceApi.controlPTZ({
|
||||||
|
device_id: props.activeWindow.deviceId,
|
||||||
|
channel_id: props.activeWindow.channelId,
|
||||||
|
ptz: 'stop',
|
||||||
|
speed: speed.value.toString(),
|
||||||
|
})
|
||||||
|
emit('control', 'stop')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('PTZ stop failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDisabled = computed(() => !props.activeWindow)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ptz-control-panel" :class="{ collapsed: isCollapsed }">
|
||||||
|
<div class="panel-header" @click="isCollapsed = !isCollapsed">
|
||||||
|
<div class="header-title">
|
||||||
|
<el-icon class="collapse-arrow" :class="{ collapsed: isCollapsed }">
|
||||||
|
<ArrowRight />
|
||||||
|
</el-icon>
|
||||||
|
<el-icon class="title-icon"><VideoCamera /></el-icon>
|
||||||
|
<span>{{ title || '云台控制' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel-content">
|
||||||
|
<div class="control-form">
|
||||||
|
<div class="ptz-controls">
|
||||||
|
<div class="direction-controls">
|
||||||
|
<div class="direction-pad">
|
||||||
|
<el-button
|
||||||
|
class="direction-btn up"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
@mousedown="handlePtzStart('up')"
|
||||||
|
@mouseup="handlePtzStop"
|
||||||
|
@mouseleave="handlePtzStop"
|
||||||
|
>
|
||||||
|
<el-icon><ArrowUp /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
class="direction-btn right"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
@mousedown="handlePtzStart('right')"
|
||||||
|
@mouseup="handlePtzStop"
|
||||||
|
@mouseleave="handlePtzStop"
|
||||||
|
>
|
||||||
|
<el-icon><ArrowRightControl /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
class="direction-btn down"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
@mousedown="handlePtzStart('down')"
|
||||||
|
@mouseup="handlePtzStop"
|
||||||
|
@mouseleave="handlePtzStop"
|
||||||
|
>
|
||||||
|
<el-icon><ArrowDown /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
class="direction-btn left"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
@mousedown="handlePtzStart('left')"
|
||||||
|
@mouseup="handlePtzStop"
|
||||||
|
@mouseleave="handlePtzStop"
|
||||||
|
>
|
||||||
|
<el-icon><ArrowLeft /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
class="direction-btn up-left"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
@mousedown="handlePtzStart('upleft')"
|
||||||
|
@mouseup="handlePtzStop"
|
||||||
|
@mouseleave="handlePtzStop"
|
||||||
|
>
|
||||||
|
<el-icon><TopLeft /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
class="direction-btn up-right"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
@mousedown="handlePtzStart('upright')"
|
||||||
|
@mouseup="handlePtzStop"
|
||||||
|
@mouseleave="handlePtzStop"
|
||||||
|
>
|
||||||
|
<el-icon><TopRight /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
class="direction-btn down-left"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
@mousedown="handlePtzStart('downleft')"
|
||||||
|
@mouseup="handlePtzStop"
|
||||||
|
@mouseleave="handlePtzStop"
|
||||||
|
>
|
||||||
|
<el-icon><BottomLeft /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
class="direction-btn down-right"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
@mousedown="handlePtzStart('downright')"
|
||||||
|
@mouseup="handlePtzStop"
|
||||||
|
@mouseleave="handlePtzStop"
|
||||||
|
>
|
||||||
|
<el-icon><BottomRight /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<div class="direction-center"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="speed-control">
|
||||||
|
<div class="speed-value">{{ speed }}</div>
|
||||||
|
<el-slider
|
||||||
|
v-model="speed"
|
||||||
|
:min="1"
|
||||||
|
:max="10"
|
||||||
|
:step="1"
|
||||||
|
:show-tooltip="false"
|
||||||
|
:disabled="isDisabled"
|
||||||
|
vertical
|
||||||
|
height="90px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ptz-control-panel {
|
||||||
|
background-color: var(--el-bg-color);
|
||||||
|
border-radius: var(--el-border-radius-base);
|
||||||
|
box-shadow: var(--el-box-shadow-lighter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-arrow {
|
||||||
|
font-size: 12px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
|
||||||
|
&.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptz-control-panel.collapsed .panel-content {
|
||||||
|
height: 0;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-form {
|
||||||
|
padding: 16px;
|
||||||
|
transform-origin: top;
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptz-control-panel.collapsed .control-form {
|
||||||
|
transform: scaleY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptz-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-pad {
|
||||||
|
position: relative;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
grid-template-rows: repeat(3, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-btn {
|
||||||
|
--el-button-bg-color: var(--el-color-primary-light-8);
|
||||||
|
--el-button-border-color: var(--el-color-primary-light-5);
|
||||||
|
--el-button-hover-bg-color: var(--el-color-primary-light-7);
|
||||||
|
--el-button-hover-border-color: var(--el-color-primary-light-4);
|
||||||
|
--el-button-active-bg-color: var(--el-color-primary-light-5);
|
||||||
|
--el-button-active-border-color: var(--el-color-primary);
|
||||||
|
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-btn.up {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-btn.right {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-btn.down {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-btn.left {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-btn.up-left {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-btn.up-right {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-btn.down-left {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-btn.down-right {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.direction-center {
|
||||||
|
grid-column: 2;
|
||||||
|
grid-row: 2;
|
||||||
|
background-color: var(--el-color-primary-light-8);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-groups,
|
||||||
|
.control-group {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-control {
|
||||||
|
height: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-value {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-slider) {
|
||||||
|
--el-slider-button-size: 10px;
|
||||||
|
--el-slider-height: 2px;
|
||||||
|
height: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-slider.is-vertical) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
226
html/NextGB/src/views/realplay/RealplayView.vue
Normal file
226
html/NextGB/src/views/realplay/RealplayView.vue
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onActivated, onDeactivated } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { FullScreen, Setting, Delete } from '@element-plus/icons-vue'
|
||||||
|
import DeviceTree from './DeviceTree.vue'
|
||||||
|
import MonitorGrid from '@/components/monitor/MonitorGrid.vue'
|
||||||
|
import PtzControlPanel from '@/views/realplay/PtzControlPanel.vue'
|
||||||
|
import type { Device, ChannelInfo } from '@/api/types'
|
||||||
|
import type { LayoutConfig } from '@/types/layout'
|
||||||
|
|
||||||
|
// 所有可用的布局键
|
||||||
|
type LayoutKey = '1' | '4' | '9' | '16'
|
||||||
|
type LayoutConfigs = Record<LayoutKey, LayoutConfig>
|
||||||
|
|
||||||
|
// 布局配置
|
||||||
|
const layouts: LayoutConfigs = {
|
||||||
|
'1': { cols: 1, rows: 1, size: 1, label: '单屏' },
|
||||||
|
'4': { cols: 2, rows: 2, size: 4, label: '四分屏' },
|
||||||
|
'9': { cols: 3, rows: 3, size: 9, label: '九分屏' },
|
||||||
|
'16': { cols: 4, rows: 4, size: 16, label: '十六分屏' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const monitorGridRef = ref()
|
||||||
|
const selectedChannel = ref<{ device: Device | undefined; channel: ChannelInfo } | null>(null)
|
||||||
|
const activeWindow = ref<{ deviceId: string; channelId: string } | null>(null)
|
||||||
|
const currentLayout = ref<LayoutKey>('9')
|
||||||
|
const showSettings = ref(false)
|
||||||
|
const defaultMuted = ref(true)
|
||||||
|
|
||||||
|
const handleDeviceSelect = (data: { device: Device | undefined; channel: ChannelInfo }) => {
|
||||||
|
selectedChannel.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDevicePlay = (data: { device: Device | undefined; channel: ChannelInfo }) => {
|
||||||
|
if (data.channel.device_id) {
|
||||||
|
monitorGridRef.value?.play({
|
||||||
|
...data.device,
|
||||||
|
channel: data.channel,
|
||||||
|
play_type: 0,
|
||||||
|
start_time: 0,
|
||||||
|
end_time: 0,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ElMessage.warning('设备信息不完整')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWindowSelect = (data: { deviceId: string; channelId: string } | null) => {
|
||||||
|
activeWindow.value = data
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePtzControl = (direction: string) => {
|
||||||
|
if (!activeWindow.value) {
|
||||||
|
ElMessage.warning('请先选择视频窗口')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('云台控制:', direction, activeWindow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
monitorGridRef.value?.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleGridFullscreen = async () => {
|
||||||
|
const gridContainer = document.querySelector('.monitor-grid') as HTMLElement
|
||||||
|
if (!gridContainer) {
|
||||||
|
console.error('未找到视频网格容器')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
await gridContainer.requestFullscreen()
|
||||||
|
} else {
|
||||||
|
await document.exitFullscreen()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('全屏切换失败:', err)
|
||||||
|
ElMessage.error('全屏切换失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加激活/停用处理
|
||||||
|
onActivated(() => {
|
||||||
|
console.log('MonitorView activated')
|
||||||
|
// 如果需要在重新激活时执行某些操作,可以在这里添加
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
console.log('MonitorView deactivated')
|
||||||
|
// 组件被缓存,不需要清理视频资源
|
||||||
|
})
|
||||||
|
|
||||||
|
// 组件名称(用于 keep-alive)
|
||||||
|
defineOptions({
|
||||||
|
name: 'RealplayView',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="monitor-view">
|
||||||
|
<div class="monitor-layout">
|
||||||
|
<div class="left-panel">
|
||||||
|
<DeviceTree @select="handleDeviceSelect" @play="handleDevicePlay" />
|
||||||
|
<PtzControlPanel
|
||||||
|
title="云台控制"
|
||||||
|
:active-window="activeWindow"
|
||||||
|
@control="handlePtzControl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="monitor-grid-container">
|
||||||
|
<div class="grid-toolbar">
|
||||||
|
<div class="layout-controls">
|
||||||
|
<el-radio-group v-model="currentLayout" size="small">
|
||||||
|
<el-radio-button v-for="(layout, key) in layouts" :key="key" :value="key">
|
||||||
|
{{ layout.label }}
|
||||||
|
</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button-group>
|
||||||
|
<el-button size="small" @click="showSettings = true" :title="'设置'">
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
size="small"
|
||||||
|
@click="clearAll"
|
||||||
|
:title="'清空所有设备'"
|
||||||
|
>
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="toggleGridFullscreen" :title="'全屏'">
|
||||||
|
<el-icon><FullScreen /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</el-button-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MonitorGrid
|
||||||
|
ref="monitorGridRef"
|
||||||
|
v-model="currentLayout"
|
||||||
|
:layouts="layouts"
|
||||||
|
:default-muted="defaultMuted"
|
||||||
|
:show-border="true"
|
||||||
|
@window-select="handleWindowSelect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 设置对话框 -->
|
||||||
|
<el-dialog v-model="showSettings" title="设置" width="400px" destroy-on-close>
|
||||||
|
<el-form label-width="120px">
|
||||||
|
<el-form-item label="默认静音">
|
||||||
|
<el-switch v-model="defaultMuted" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<span class="dialog-footer">
|
||||||
|
<el-button @click="showSettings = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="showSettings = false">确定</el-button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.monitor-view {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-grid-container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-toolbar {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left,
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-button-group .el-button--small) {
|
||||||
|
padding: 5px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-radio-group .el-radio-button__inner) {
|
||||||
|
padding: 5px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-radio-group) {
|
||||||
|
--el-button-bg-color: var(--el-fill-color-blank);
|
||||||
|
--el-button-hover-bg-color: var(--el-fill-color);
|
||||||
|
--el-button-active-bg-color: var(--el-color-primary);
|
||||||
|
--el-button-text-color: var(--el-text-color-regular);
|
||||||
|
--el-button-hover-text-color: var(--el-text-color-primary);
|
||||||
|
--el-button-active-text-color: #fff;
|
||||||
|
--el-button-border-color: var(--el-border-color);
|
||||||
|
--el-button-hover-border-color: var(--el-border-color-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
25
html/NextGB/src/views/setting/SettingsView.vue
Normal file
25
html/NextGB/src/views/setting/SettingsView.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import SystemForm from '@/views/setting/SystemForm.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="settings-view">
|
||||||
|
<h1>系统设置</h1>
|
||||||
|
<div class="settings-content">
|
||||||
|
<SystemForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-view {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
margin-top: 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
36
html/NextGB/src/views/setting/SystemForm.vue
Normal file
36
html/NextGB/src/views/setting/SystemForm.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const formData = ref({
|
||||||
|
systemName: 'demo',
|
||||||
|
storagePath: '/data/recordings',
|
||||||
|
retention: 30,
|
||||||
|
autoCleanup: true,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-form :model="formData" label-width="120px">
|
||||||
|
<el-form-item label="系统名称">
|
||||||
|
<el-input v-model="formData.systemName" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="存储路径">
|
||||||
|
<el-input v-model="formData.storagePath" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="保留天数">
|
||||||
|
<el-input-number v-model="formData.retention" :min="1" :max="365" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="自动清理">
|
||||||
|
<el-switch v-model="formData.autoCleanup" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary">保存设置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.el-form {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
13
html/NextGB/tsconfig.app.json
Normal file
13
html/NextGB/tsconfig.app.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
html/NextGB/tsconfig.json
Normal file
14
html/NextGB/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["element-plus/global"],
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
html/NextGB/tsconfig.node.json
Normal file
19
html/NextGB/tsconfig.node.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "@tsconfig/node22/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"nightwatch.conf.*",
|
||||||
|
"playwright.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"types": ["node"]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
html/NextGB/tsconfig.vitest.json
Normal file
11
html/NextGB/tsconfig.vitest.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
"exclude": [],
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
|
||||||
|
|
||||||
|
"lib": [],
|
||||||
|
"types": ["node", "jsdom"]
|
||||||
|
}
|
||||||
|
}
|
||||||
18
html/NextGB/vite.config.ts
Normal file
18
html/NextGB/vite.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
14
html/NextGB/vitest.config.ts
Normal file
14
html/NextGB/vitest.config.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||||
|
import viteConfig from './vite.config'
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
viteConfig,
|
||||||
|
defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||||
|
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
50
magefile.go
50
magefile.go
@ -1,50 +0,0 @@
|
|||||||
//go:build mage
|
|
||||||
// +build mage
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"github.com/magefile/mage/sh"
|
|
||||||
)
|
|
||||||
|
|
||||||
var Default = Build
|
|
||||||
|
|
||||||
func Build() error {
|
|
||||||
path := "bin"
|
|
||||||
if err := os.MkdirAll(path, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
name := "srs-sip"
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
name += ".exe"
|
|
||||||
}
|
|
||||||
name = filepath.Join(path, name)
|
|
||||||
|
|
||||||
if err := sh.Run("go", "build", "-o", name, "main/main.go"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
name = "srs-sip-tools"
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
name += ".exe"
|
|
||||||
}
|
|
||||||
name = filepath.Join(path, name)
|
|
||||||
|
|
||||||
if err := sh.Run("go", "build", "-o", name, "tools/main.go"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("build done")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Clean() {
|
|
||||||
os.RemoveAll("bin")
|
|
||||||
fmt.Println("clean done")
|
|
||||||
}
|
|
||||||
58
main/main.go
58
main/main.go
@ -10,11 +10,12 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/handlers"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
"github.com/ossrs/go-oryx-lib/logger"
|
"github.com/ossrs/go-oryx-lib/logger"
|
||||||
"github.com/ossrs/srs-sip/pkg/api"
|
"github.com/ossrs/srs-sip/pkg/api"
|
||||||
"github.com/ossrs/srs-sip/pkg/config"
|
"github.com/ossrs/srs-sip/pkg/config"
|
||||||
"github.com/ossrs/srs-sip/pkg/service"
|
"github.com/ossrs/srs-sip/pkg/service"
|
||||||
"github.com/ossrs/srs-sip/pkg/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func WaitTerminationSignal(cancel context.CancelFunc) {
|
func WaitTerminationSignal(cancel context.CancelFunc) {
|
||||||
@ -28,7 +29,12 @@ func WaitTerminationSignal(cancel context.CancelFunc) {
|
|||||||
func main() {
|
func main() {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
conf := utils.Parse(ctx)
|
conf, err := config.LoadConfig("conf/config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
logger.E(nil, "load config failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sipSvr, err := service.NewService(ctx, conf)
|
sipSvr, err := service.NewService(ctx, conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Ef("create service failed. err is %v", err.Error())
|
logger.Ef("create service failed. err is %v", err.Error())
|
||||||
@ -40,32 +46,62 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 创建主路由
|
||||||
|
router := mux.NewRouter().StrictSlash(true)
|
||||||
|
|
||||||
|
// CORS配置
|
||||||
|
headers := handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization"})
|
||||||
|
methods := handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"})
|
||||||
|
origins := handlers.AllowedOrigins([]string{"*"})
|
||||||
|
|
||||||
|
// 设置API路由 - 需要在静态文件路由之前设置
|
||||||
apiSvr, err := api.NewHttpApiServer(conf, sipSvr)
|
apiSvr, err := api.NewHttpApiServer(conf, sipSvr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Ef("create http service failed. err is %v", err.Error())
|
logger.Ef("create http service failed. err is %v", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
apiSvr.Start()
|
apiSvr.Start(router)
|
||||||
|
|
||||||
var targetDir string
|
// 使用配置中指定的目录,如果不存在则尝试备选目录
|
||||||
targetDirs := []string{"./web/html", "../web/html"}
|
targetDir := conf.Http.Dir
|
||||||
for _, dir := range targetDirs {
|
if _, err := os.Stat(path.Join(targetDir, "index.html")); err != nil {
|
||||||
|
backupDirs := []string{"./html", "../web/NextGB/dist"}
|
||||||
|
for _, dir := range backupDirs {
|
||||||
if _, err := os.Stat(path.Join(dir, "index.html")); err == nil {
|
if _, err := os.Stat(path.Join(dir, "index.html")); err == nil {
|
||||||
targetDir = dir
|
targetDir = dir
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if targetDir == "" {
|
if targetDir == "" {
|
||||||
logger.Ef(ctx, "index.html not found in %v", targetDirs)
|
logger.Ef(ctx, "index.html not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 创建文件服务器
|
||||||
|
fs := http.FileServer(http.Dir(targetDir))
|
||||||
|
|
||||||
|
// 添加静态文件处理 - 使用NotFoundHandler来处理未匹配的路由
|
||||||
|
router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
logger.Tf(context.Background(), "Handling request: %s", r.URL.Path)
|
||||||
|
|
||||||
|
// 检查请求的文件是否存在
|
||||||
|
filePath := path.Join(targetDir, r.URL.Path)
|
||||||
|
_, err := os.Stat(filePath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// 如果文件不存在,返回 index.html
|
||||||
|
r.URL.Path = "/"
|
||||||
|
}
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 启动合并后的HTTP服务
|
||||||
go func() {
|
go func() {
|
||||||
c := conf.(*config.MainConfig)
|
httpPort := strconv.Itoa(conf.Http.Port)
|
||||||
httpPort := strconv.Itoa(c.HttpServerPort)
|
handler := handlers.CORS(headers, methods, origins)(router)
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: ":" + httpPort,
|
Addr: ":" + httpPort,
|
||||||
Handler: http.FileServer(http.Dir(targetDir)),
|
Handler: handler,
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: 10 * time.Second,
|
||||||
WriteTimeout: 10 * time.Second,
|
WriteTimeout: 10 * time.Second,
|
||||||
IdleTimeout: 30 * time.Second,
|
IdleTimeout: 30 * time.Second,
|
||||||
@ -77,8 +113,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
logger.Tf(ctx, "media server address is %v", conf.(*config.MainConfig).MediaAddr)
|
|
||||||
|
|
||||||
WaitTerminationSignal(cancel)
|
WaitTerminationSignal(cancel)
|
||||||
|
|
||||||
sipSvr.Stop()
|
sipSvr.Stop()
|
||||||
|
|||||||
37
package-lock.json
generated
Normal file
37
package-lock.json
generated
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "srs-sip",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"echarts": "^5.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/echarts": {
|
||||||
|
"version": "5.6.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz",
|
||||||
|
"integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0",
|
||||||
|
"zrender": "5.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/zrender": {
|
||||||
|
"version": "5.6.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz",
|
||||||
|
"integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"echarts": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,137 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
"github.com/ossrs/srs-sip/pkg/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (h *HttpApiServer) RegisterRoutes(router *mux.Router) {
|
|
||||||
|
|
||||||
apiV1Router := router.PathPrefix("/srs-sip/v1").Subrouter()
|
|
||||||
|
|
||||||
// Add Auth middleware
|
|
||||||
//apiV1Router.Use(authMiddleware)
|
|
||||||
|
|
||||||
apiV1Router.HandleFunc("/devices", h.ApiListDevices).Methods(http.MethodGet)
|
|
||||||
apiV1Router.HandleFunc("/devices/{id}/channels", h.ApiGetChannelByDeviceId).Methods(http.MethodGet)
|
|
||||||
apiV1Router.HandleFunc("/channels", h.ApiGetAllChannels).Methods(http.MethodGet)
|
|
||||||
|
|
||||||
apiV1Router.HandleFunc("/invite", h.ApiInvite).Methods(http.MethodPost)
|
|
||||||
apiV1Router.HandleFunc("/bye", h.ApiBye).Methods(http.MethodPost)
|
|
||||||
apiV1Router.HandleFunc("/ptz", h.ApiPTZControl).Methods(http.MethodPost)
|
|
||||||
|
|
||||||
apiV1Router.HandleFunc("", h.GetAPIRoutes(apiV1Router)).Methods(http.MethodGet)
|
|
||||||
|
|
||||||
router.HandleFunc("/srs-sip", h.ApiGetAPIVersion).Methods(http.MethodGet)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HttpApiServer) RespondWithJSON(w http.ResponseWriter, code int, data interface{}) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
wrapper := map[string]interface{}{
|
|
||||||
"code": code,
|
|
||||||
"data": data,
|
|
||||||
}
|
|
||||||
json.NewEncoder(w).Encode(wrapper)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HttpApiServer) RespondWithJSONSimple(w http.ResponseWriter, jsonStr string) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.Write([]byte(jsonStr))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HttpApiServer) GetAPIRoutes(router *mux.Router) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var routes []map[string]string
|
|
||||||
|
|
||||||
router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
|
|
||||||
path, err := route.GetPathTemplate()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
methods, err := route.GetMethods()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, method := range methods {
|
|
||||||
routes = append(routes, map[string]string{
|
|
||||||
"method": method,
|
|
||||||
"path": path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
h.RespondWithJSON(w, 0, routes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HttpApiServer) ApiGetAPIVersion(w http.ResponseWriter, r *http.Request) {
|
|
||||||
h.RespondWithJSONSimple(w, `{"version": "v1"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HttpApiServer) ApiListDevices(w http.ResponseWriter, r *http.Request) {
|
|
||||||
list := service.DM.GetDevices()
|
|
||||||
h.RespondWithJSON(w, 0, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HttpApiServer) ApiGetChannelByDeviceId(w http.ResponseWriter, r *http.Request) {
|
|
||||||
vars := mux.Vars(r)
|
|
||||||
id := vars["id"]
|
|
||||||
channels := service.DM.ApiGetChannelByDeviceId(id)
|
|
||||||
h.RespondWithJSON(w, 0, channels)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HttpApiServer) ApiGetAllChannels(w http.ResponseWriter, r *http.Request) {
|
|
||||||
channels := service.DM.GetAllVideoChannels()
|
|
||||||
h.RespondWithJSON(w, 0, channels)
|
|
||||||
}
|
|
||||||
|
|
||||||
// request: {"device_id": "1", "channel_id": "1", "sub_stream": 0}
|
|
||||||
// response: {"code": 0, "data": {"channel_id": "1", "url": "webrtc://"}}
|
|
||||||
func (h *HttpApiServer) ApiInvite(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Parse request
|
|
||||||
var req map[string]string
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get device and channel
|
|
||||||
deviceID := req["device_id"]
|
|
||||||
channelID := req["channel_id"]
|
|
||||||
//subStream := req["sub_stream"]
|
|
||||||
|
|
||||||
code := 0
|
|
||||||
url := ""
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
data := map[string]string{
|
|
||||||
"channel_id": channelID,
|
|
||||||
"url": url,
|
|
||||||
}
|
|
||||||
h.RespondWithJSON(w, code, data)
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := h.sipSvr.Uas.Invite(deviceID, channelID); err != nil {
|
|
||||||
code = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c, ok := h.sipSvr.Uas.GetVideoChannelStatue(channelID)
|
|
||||||
if !ok {
|
|
||||||
code = http.StatusInternalServerError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
url = "webrtc://" + h.conf.MediaAddr + "/live/" + c.Ssrc
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HttpApiServer) ApiBye(w http.ResponseWriter, r *http.Request) {
|
|
||||||
h.RespondWithJSONSimple(w, `{"msg":"Not implemented"}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HttpApiServer) ApiPTZControl(w http.ResponseWriter, r *http.Request) {
|
|
||||||
h.RespondWithJSONSimple(w, `{"msg":"Not implemented"}`)
|
|
||||||
}
|
|
||||||
@ -2,13 +2,10 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/ossrs/go-oryx-lib/logger"
|
|
||||||
|
|
||||||
"github.com/gorilla/handlers"
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/ossrs/go-oryx-lib/logger"
|
||||||
"github.com/ossrs/srs-sip/pkg/config"
|
"github.com/ossrs/srs-sip/pkg/config"
|
||||||
"github.com/ossrs/srs-sip/pkg/service"
|
"github.com/ossrs/srs-sip/pkg/service"
|
||||||
)
|
)
|
||||||
@ -25,21 +22,24 @@ func NewHttpApiServer(r0 interface{}, svr *service.Service) (*HttpApiServer, err
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HttpApiServer) Start() {
|
func (h *HttpApiServer) Start(router *mux.Router) {
|
||||||
router := mux.NewRouter().StrictSlash(true)
|
// 添加版本检查路由到主路由器
|
||||||
h.RegisterRoutes(router)
|
router.HandleFunc("/srs-sip", h.ApiGetAPIVersion).Methods(http.MethodGet)
|
||||||
|
|
||||||
headers := handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization"})
|
// 创建一个子路由,所有API都以/srs-sip/v1为前缀
|
||||||
methods := handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"})
|
apiRouter := router.PathPrefix("/srs-sip/v1").Subrouter()
|
||||||
origins := handlers.AllowedOrigins([]string{"*"})
|
|
||||||
|
|
||||||
go func() {
|
logger.Tf(context.Background(), "Registering API routes under /srs-sip/v1")
|
||||||
ctx := context.Background()
|
h.RegisterRoutes(apiRouter)
|
||||||
addr := fmt.Sprintf(":%v", h.conf.APIPort)
|
|
||||||
logger.Tf(ctx, "http api listen on %s", addr)
|
// 打印所有注册的路由,包含更详细的信息
|
||||||
err := http.ListenAndServe(addr, handlers.CORS(headers, methods, origins)(router))
|
router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
|
||||||
if err != nil {
|
pathTemplate, _ := route.GetPathTemplate()
|
||||||
panic(err)
|
pathRegexp, _ := route.GetPathRegexp()
|
||||||
}
|
methods, _ := route.GetMethods()
|
||||||
}()
|
queries, _ := route.GetQueriesTemplates()
|
||||||
|
logger.Tf(context.Background(), "Route Details: Path=%v, Regexp=%v, Methods=%v, Queries=%v",
|
||||||
|
pathTemplate, pathRegexp, methods, queries)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
284
pkg/api/controller.go
Normal file
284
pkg/api/controller.go
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/ossrs/srs-sip/pkg/models"
|
||||||
|
"github.com/ossrs/srs-sip/pkg/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *HttpApiServer) RegisterRoutes(router *mux.Router) {
|
||||||
|
// Add Auth middleware
|
||||||
|
//apiV1Router.Use(authMiddleware)
|
||||||
|
|
||||||
|
router.HandleFunc("/devices", h.ApiListDevices).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/devices/{id}/channels", h.ApiGetChannelByDeviceId).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/channels", h.ApiGetAllChannels).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
router.HandleFunc("/invite", h.ApiInvite).Methods(http.MethodPost)
|
||||||
|
router.HandleFunc("/bye", h.ApiBye).Methods(http.MethodPost)
|
||||||
|
router.HandleFunc("/ptz", h.ApiPTZControl).Methods(http.MethodPost)
|
||||||
|
router.HandleFunc("/pause", h.ApiPause).Methods(http.MethodPost)
|
||||||
|
router.HandleFunc("/resume", h.ApiResume).Methods(http.MethodPost)
|
||||||
|
router.HandleFunc("/speed", h.ApiSpeed).Methods(http.MethodPost)
|
||||||
|
|
||||||
|
router.HandleFunc("/query-record", h.ApiQueryRecord).Methods(http.MethodPost)
|
||||||
|
|
||||||
|
// 媒体服务器相关接口,查询,新增,删除,用restful风格
|
||||||
|
router.HandleFunc("/media-servers", h.ApiListMediaServers).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/media-servers", h.ApiAddMediaServer).Methods(http.MethodPost)
|
||||||
|
router.HandleFunc("/media-servers/{id}", h.ApiDeleteMediaServer).Methods(http.MethodDelete)
|
||||||
|
router.HandleFunc("/media-servers/default/{id}", h.ApiSetDefaultMediaServer).Methods(http.MethodPost)
|
||||||
|
|
||||||
|
router.HandleFunc("", h.GetAPIRoutes(router)).Methods(http.MethodGet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) RespondWithJSON(w http.ResponseWriter, code int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
wrapper := models.CommonResponse{
|
||||||
|
Code: code,
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(wrapper)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) RespondWithJSONSimple(w http.ResponseWriter, jsonStr string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(jsonStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) GetAPIRoutes(router *mux.Router) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var routes []map[string]string
|
||||||
|
|
||||||
|
router.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error {
|
||||||
|
path, err := route.GetPathTemplate()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
methods, err := route.GetMethods()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, method := range methods {
|
||||||
|
routes = append(routes, map[string]string{
|
||||||
|
"method": method,
|
||||||
|
"path": path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
h.RespondWithJSON(w, 0, routes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) ApiGetAPIVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.RespondWithJSONSimple(w, `{"version": "v1"}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) ApiListDevices(w http.ResponseWriter, r *http.Request) {
|
||||||
|
list := service.DM.GetDevices()
|
||||||
|
h.RespondWithJSON(w, 0, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) ApiGetChannelByDeviceId(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id := vars["id"]
|
||||||
|
channels := service.DM.ApiGetChannelByDeviceId(id)
|
||||||
|
h.RespondWithJSON(w, 0, channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) ApiGetAllChannels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
channels := service.DM.GetAllVideoChannels()
|
||||||
|
h.RespondWithJSON(w, 0, channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) ApiInvite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.InviteRequest
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := h.sipSvr.Uas.Invite(req)
|
||||||
|
if err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := models.InviteResponse{
|
||||||
|
ChannelID: req.ChannelID,
|
||||||
|
URL: session.URL,
|
||||||
|
}
|
||||||
|
h.RespondWithJSON(w, 0, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) ApiBye(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.ByeRequest
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.sipSvr.Uas.Bye(req); err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) ApiPause(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.PauseRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.sipSvr.Uas.Pause(req); err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) ApiResume(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.ResumeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.sipSvr.Uas.Resume(req); err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) ApiSpeed(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.SpeedRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.sipSvr.Uas.Speed(req); err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// request: {"device_id": "1", "channel_id": "1", "ptz": "up", "speed": "1}
|
||||||
|
func (h *HttpApiServer) ApiPTZControl(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.PTZControlRequest
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := 0
|
||||||
|
msg := ""
|
||||||
|
defer func() {
|
||||||
|
h.RespondWithJSON(w, code, map[string]string{"msg": msg})
|
||||||
|
}()
|
||||||
|
if err := h.sipSvr.Uas.ControlPTZ(req.DeviceID, req.ChannelID, req.PTZ, req.Speed); err != nil {
|
||||||
|
code = http.StatusInternalServerError
|
||||||
|
msg = err.Error()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg = "success"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) ApiQueryRecord(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.QueryRecordRequest
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := h.sipSvr.Uas.QueryRecord(req.DeviceID, req.ChannelID, req.StartTime, req.EndTime)
|
||||||
|
if err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.RespondWithJSON(w, 0, records)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) ApiListMediaServers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
servers, err := service.MediaDB.ListMediaServers()
|
||||||
|
if err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.RespondWithJSON(w, 0, servers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// request: {"name": "srs1", "ip": "192.168.1.100", "port": 1935, "type": "SRS", "username": "admin", "password": "123456"}
|
||||||
|
func (h *HttpApiServer) ApiAddMediaServer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req models.MediaServerRequest
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证必填字段
|
||||||
|
if req.Name == "" || req.IP == "" || req.Port == 0 || req.Type == "" {
|
||||||
|
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": "name, ip, port and type are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到数据库
|
||||||
|
if err := service.MediaDB.AddMediaServer(req.Name, req.Type, req.IP, req.Port, req.Username, req.Password, req.Secret, req.IsDefault); err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) ApiDeleteMediaServer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id, err := strconv.Atoi(vars["id"])
|
||||||
|
if err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.MediaDB.DeleteMediaServer(id); err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HttpApiServer) ApiSetDefaultMediaServer(w http.ResponseWriter, r *http.Request) {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
id, err := strconv.Atoi(vars["id"])
|
||||||
|
if err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusBadRequest, map[string]string{"msg": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.MediaDB.SetDefaultMediaServer(id); err != nil {
|
||||||
|
h.RespondWithJSON(w, http.StatusInternalServerError, map[string]string{"msg": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.RespondWithJSON(w, 0, map[string]string{"msg": "success"})
|
||||||
|
}
|
||||||
@ -3,16 +3,85 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 通用配置
|
||||||
|
type CommonConfig struct {
|
||||||
|
LogLevel string `yaml:"log-level"`
|
||||||
|
LogFile string `yaml:"log-file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GB28181配置
|
||||||
|
type GB28181AuthConfig struct {
|
||||||
|
Enable bool `yaml:"enable"`
|
||||||
|
Password string `yaml:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GB28181Config struct {
|
||||||
|
Serial string `yaml:"serial"`
|
||||||
|
Realm string `yaml:"realm"`
|
||||||
|
Host string `yaml:"host"`
|
||||||
|
Port int `yaml:"port"`
|
||||||
|
Auth GB28181AuthConfig `yaml:"auth"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP服务配置
|
||||||
|
type HttpConfig struct {
|
||||||
|
Port int `yaml:"listen"`
|
||||||
|
Dir string `yaml:"dir"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主配置结构
|
||||||
type MainConfig struct {
|
type MainConfig struct {
|
||||||
Serial string `ymal:"serial"`
|
Common CommonConfig `yaml:"common"`
|
||||||
Realm string `ymal:"realm"`
|
GB28181 GB28181Config `yaml:"gb28181"`
|
||||||
SipHost string `ymal:"sip-host"`
|
Http HttpConfig `yaml:"http"`
|
||||||
SipPort int `ymal:"sip-port"`
|
}
|
||||||
MediaAddr string `ymal:"media-addr"`
|
|
||||||
HttpServerPort int `ymal:"http-server-port"`
|
// 获取默认配置
|
||||||
APIPort int `ymal:"api-port"`
|
func DefaultConfig() *MainConfig {
|
||||||
|
return &MainConfig{
|
||||||
|
Common: CommonConfig{
|
||||||
|
LogLevel: "info",
|
||||||
|
LogFile: "app.log",
|
||||||
|
},
|
||||||
|
GB28181: GB28181Config{
|
||||||
|
Serial: "34020000002000000001",
|
||||||
|
Realm: "3402000000",
|
||||||
|
Host: "0.0.0.0",
|
||||||
|
Port: 5060,
|
||||||
|
Auth: GB28181AuthConfig{
|
||||||
|
Enable: false,
|
||||||
|
Password: "123456",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Http: HttpConfig{
|
||||||
|
Port: 8025,
|
||||||
|
Dir: "./html",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig(filename string) (*MainConfig, error) {
|
||||||
|
// 如果配置文件不存在,返回默认配置
|
||||||
|
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
||||||
|
return DefaultConfig(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read config file failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var config MainConfig
|
||||||
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse config file failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetLocalIP() (string, error) {
|
func GetLocalIP() (string, error) {
|
||||||
|
|||||||
121
pkg/db/media_server.go
Normal file
121
pkg/db/media_server.go
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/ossrs/srs-sip/pkg/models"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
instance *MediaServerDB
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaServerDB struct {
|
||||||
|
models.MediaServerResponse
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInstance 返回 MediaServerDB 的单例实例
|
||||||
|
func GetInstance(dbPath string) (*MediaServerDB, error) {
|
||||||
|
var err error
|
||||||
|
once.Do(func() {
|
||||||
|
instance, err = NewMediaServerDB(dbPath)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return instance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMediaServerDB(dbPath string) (*MediaServerDB, error) {
|
||||||
|
db, err := sql.Open("sqlite", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建媒体服务器表
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS media_servers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
username TEXT,
|
||||||
|
password TEXT,
|
||||||
|
secret TEXT,
|
||||||
|
is_default INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &MediaServerDB{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaServerDB) AddMediaServer(name, serverType, ip string, port int, username, password, secret string, isDefault int) error {
|
||||||
|
_, err := m.db.Exec(`
|
||||||
|
INSERT INTO media_servers (name, type, ip, port, username, password, secret, is_default)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, name, serverType, ip, port, username, password, secret, isDefault)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaServerDB) DeleteMediaServer(id int) error {
|
||||||
|
_, err := m.db.Exec("DELETE FROM media_servers WHERE id = ?", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaServerDB) GetMediaServer(id int) (*models.MediaServerResponse, error) {
|
||||||
|
var ms models.MediaServerResponse
|
||||||
|
err := m.db.QueryRow(`
|
||||||
|
SELECT id, name, type, ip, port, username, password, secret, is_default, created_at
|
||||||
|
FROM media_servers WHERE id = ?
|
||||||
|
`, id).Scan(&ms.ID, &ms.Name, &ms.Type, &ms.IP, &ms.Port, &ms.Username, &ms.Password, &ms.Secret, &ms.IsDefault, &ms.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaServerDB) ListMediaServers() ([]models.MediaServerResponse, error) {
|
||||||
|
rows, err := m.db.Query(`
|
||||||
|
SELECT id, name, type, ip, port, username, password, secret, is_default, created_at
|
||||||
|
FROM media_servers ORDER BY created_at DESC
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var servers []models.MediaServerResponse
|
||||||
|
for rows.Next() {
|
||||||
|
var ms models.MediaServerResponse
|
||||||
|
err := rows.Scan(&ms.ID, &ms.Name, &ms.Type, &ms.IP, &ms.Port, &ms.Username, &ms.Password, &ms.Secret, &ms.IsDefault, &ms.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
servers = append(servers, ms)
|
||||||
|
}
|
||||||
|
return servers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaServerDB) SetDefaultMediaServer(id int) error {
|
||||||
|
// 先将所有服务器设置为非默认
|
||||||
|
if _, err := m.db.Exec("UPDATE media_servers SET is_default = 0"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将指定ID的服务器设置为默认
|
||||||
|
_, err := m.db.Exec("UPDATE media_servers SET is_default = 1 WHERE id = ?", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaServerDB) Close() error {
|
||||||
|
return m.db.Close()
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package signaling
|
package media
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@ -12,10 +12,12 @@ import (
|
|||||||
"github.com/ossrs/go-oryx-lib/logger"
|
"github.com/ossrs/go-oryx-lib/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ISignaling interface {
|
type IMedia interface {
|
||||||
Publish(id, ssrc string) (int, error)
|
Publish(id, ssrc string) (int, error)
|
||||||
Unpublish(id string) error
|
Unpublish(id string) error
|
||||||
GetStreamStatus(id string) (bool, error)
|
GetStreamStatus(id string) (bool, error)
|
||||||
|
GetAddr() string
|
||||||
|
GetWebRTCAddr(id string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
// The r is HTTP API to request, like "http://localhost:1985/gb/v1/publish".
|
// The r is HTTP API to request, like "http://localhost:1985/gb/v1/publish".
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package signaling
|
package media
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@ -8,7 +8,10 @@ import (
|
|||||||
|
|
||||||
type Srs struct {
|
type Srs struct {
|
||||||
Ctx context.Context
|
Ctx context.Context
|
||||||
Addr string // The address of SRS, eg: http://localhost:1985
|
Schema string // The schema of SRS, eg: http
|
||||||
|
Addr string // The address of SRS, eg: localhost:1985
|
||||||
|
Username string // The username of SRS, eg: admin
|
||||||
|
Password string // The password of SRS, eg: 123456
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Srs) Publish(id, ssrc string) (int, error) {
|
func (s *Srs) Publish(id, ssrc string) (int, error) {
|
||||||
@ -24,7 +27,7 @@ func (s *Srs) Publish(id, ssrc string) (int, error) {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
if err := apiRequest(s.Ctx, s.Addr+"/gb/v1/publish/", req, &res); err != nil {
|
if err := apiRequest(s.Ctx, s.Schema+"://"+s.Addr+"/gb/v1/publish/", req, &res); err != nil {
|
||||||
return 0, errors.Wrapf(err, "gb/v1/publish")
|
return 0, errors.Wrapf(err, "gb/v1/publish")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,7 +81,7 @@ func (s *Srs) GetStreamStatus(id string) (bool, error) {
|
|||||||
Streams []Stream `json:"streams"`
|
Streams []Stream `json:"streams"`
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
if err := apiRequest(s.Ctx, s.Addr+"/api/v1/streams?count=99", nil, &res); err != nil {
|
if err := apiRequest(s.Ctx, s.Schema+"://"+s.Addr+"/api/v1/streams?count=99", nil, &res); err != nil {
|
||||||
return false, errors.Wrapf(err, "api/v1/stream")
|
return false, errors.Wrapf(err, "api/v1/stream")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,3 +96,11 @@ func (s *Srs) GetStreamStatus(id string) (bool, error) {
|
|||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Srs) GetAddr() string {
|
||||||
|
return s.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Srs) GetWebRTCAddr(id string) string {
|
||||||
|
return "webrtc://" + s.Addr + "/live/" + id
|
||||||
|
}
|
||||||
59
pkg/media/zlm.go
Normal file
59
pkg/media/zlm.go
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/ossrs/go-oryx-lib/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Zlm struct {
|
||||||
|
Ctx context.Context
|
||||||
|
Schema string // The schema of ZLM, eg: http
|
||||||
|
Addr string // The address of ZLM, eg: localhost:8085
|
||||||
|
Secret string // The secret of ZLM, eg: ZLMediaKit_secret
|
||||||
|
}
|
||||||
|
|
||||||
|
// /index/api/openRtpServer
|
||||||
|
// secret={{ZLMediaKit_secret}}&port=0&enable_tcp=1&stream_id=test2
|
||||||
|
func (z *Zlm) Publish(id, ssrc string) (int, error) {
|
||||||
|
|
||||||
|
res := struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
if err := apiRequest(z.Ctx, z.Schema+"://"+z.Addr+"/index/api/openRtpServer?secret="+z.Secret+"&port=0&enable_tcp=1&stream_id="+id+"&ssrc="+ssrc, nil, &res); err != nil {
|
||||||
|
return 0, errors.Wrapf(err, "gb/v1/publish")
|
||||||
|
}
|
||||||
|
return res.Port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /index/api/closeRtpServer
|
||||||
|
func (z *Zlm) Unpublish(id string) error {
|
||||||
|
res := struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
}{}
|
||||||
|
if err := apiRequest(z.Ctx, z.Schema+"://"+z.Addr+"/index/api/closeRtpServer?secret="+z.Secret+"&stream_id="+id, nil, &res); err != nil {
|
||||||
|
return errors.Wrapf(err, "gb/v1/publish")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /index/api/getMediaList
|
||||||
|
func (z *Zlm) GetStreamStatus(id string) (bool, error) {
|
||||||
|
res := struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
}{}
|
||||||
|
if err := apiRequest(z.Ctx, z.Schema+"://"+z.Addr+"/index/api/getMediaList?secret="+z.Secret+"&stream_id="+id, nil, &res); err != nil {
|
||||||
|
return false, errors.Wrapf(err, "gb/v1/publish")
|
||||||
|
}
|
||||||
|
return res.Code == 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (z *Zlm) GetAddr() string {
|
||||||
|
return z.Addr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (z *Zlm) GetWebRTCAddr(id string) string {
|
||||||
|
return "http://" + z.Addr + "/index/api/webrtc?app=rtp&stream=" + id + "&type=play"
|
||||||
|
}
|
||||||
83
pkg/models/gb28181.go
Normal file
83
pkg/models/gb28181.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "encoding/xml"
|
||||||
|
|
||||||
|
type Record struct {
|
||||||
|
DeviceID string `xml:"DeviceID" json:"device_id"`
|
||||||
|
Name string `xml:"Name" json:"name"`
|
||||||
|
FilePath string `xml:"FilePath" json:"file_path"`
|
||||||
|
Address string `xml:"Address" json:"address"`
|
||||||
|
StartTime string `xml:"StartTime" json:"start_time"`
|
||||||
|
EndTime string `xml:"EndTime" json:"end_time"`
|
||||||
|
Secrecy int `xml:"Secrecy" json:"secrecy"`
|
||||||
|
Type string `xml:"Type" json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example XML structure for channel info:
|
||||||
|
//
|
||||||
|
// <Item>
|
||||||
|
// <DeviceID>34020000001320000002</DeviceID>
|
||||||
|
// <Name>209</Name>
|
||||||
|
// <Manufacturer>UNIVIEW</Manufacturer>
|
||||||
|
// <Model>HIC6622-IR@X33-VF</Model>
|
||||||
|
// <Owner>IPC-B2202.7.11.230222</Owner>
|
||||||
|
// <CivilCode>CivilCode</CivilCode>
|
||||||
|
// <Address>Address</Address>
|
||||||
|
// <Parental>1</Parental>
|
||||||
|
// <ParentID>75015310072008100002</ParentID>
|
||||||
|
// <SafetyWay>0</SafetyWay>
|
||||||
|
// <RegisterWay>1</RegisterWay>
|
||||||
|
// <Secrecy>0</Secrecy>
|
||||||
|
// <Status>ON</Status>
|
||||||
|
// <Longitude>0.0000000</Longitude>
|
||||||
|
// <Latitude>0.0000000</Latitude>
|
||||||
|
// <Info>
|
||||||
|
// <PTZType>1</PTZType>
|
||||||
|
// <Resolution>6/4/2</Resolution>
|
||||||
|
// <DownloadSpeed>0</DownloadSpeed>
|
||||||
|
// </Info>
|
||||||
|
// </Item>
|
||||||
|
|
||||||
|
type ChannelInfo struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
ParentID string `json:"parent_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Manufacturer string `json:"manufacturer"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
CivilCode string `json:"civil_code"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Parental int `json:"parental"`
|
||||||
|
SafetyWay int `json:"safety_way"`
|
||||||
|
RegisterWay int `json:"register_way"`
|
||||||
|
Secrecy int `json:"secrecy"`
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
Status ChannelStatus `json:"status"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Info struct {
|
||||||
|
PTZType int `json:"ptz_type"`
|
||||||
|
Resolution string `json:"resolution"`
|
||||||
|
DownloadSpeed string `json:"download_speed"` // Speed levels: 1/2/4/8
|
||||||
|
} `json:"info"`
|
||||||
|
|
||||||
|
// Custom fields
|
||||||
|
Ssrc string `json:"ssrc"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChannelStatus string
|
||||||
|
|
||||||
|
type XmlMessageInfo struct {
|
||||||
|
XMLName xml.Name
|
||||||
|
CmdType string
|
||||||
|
SN int
|
||||||
|
DeviceID string
|
||||||
|
DeviceName string
|
||||||
|
Manufacturer string
|
||||||
|
Model string
|
||||||
|
Channel string
|
||||||
|
DeviceList []ChannelInfo `xml:"DeviceList>Item"`
|
||||||
|
RecordList []*Record `xml:"RecordList>Item"`
|
||||||
|
SumNum int
|
||||||
|
}
|
||||||
80
pkg/models/types.go
Normal file
80
pkg/models/types.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type BaseRequest struct {
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InviteRequest struct {
|
||||||
|
BaseRequest
|
||||||
|
MediaServerId int `json:"media_server_id"`
|
||||||
|
PlayType int `json:"play_type"` // 0: live, 1: playback, 2: download
|
||||||
|
SubStream int `json:"sub_stream"`
|
||||||
|
StartTime int64 `json:"start_time"`
|
||||||
|
EndTime int64 `json:"end_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type InviteResponse struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionRequest struct {
|
||||||
|
BaseRequest
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ByeRequest struct {
|
||||||
|
SessionRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
type PauseRequest struct {
|
||||||
|
SessionRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResumeRequest struct {
|
||||||
|
SessionRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpeedRequest struct {
|
||||||
|
SessionRequest
|
||||||
|
Speed float32 `json:"speed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PTZControlRequest struct {
|
||||||
|
BaseRequest
|
||||||
|
PTZ string `json:"ptz"`
|
||||||
|
Speed string `json:"speed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QueryRecordRequest struct {
|
||||||
|
BaseRequest
|
||||||
|
StartTime int64 `json:"start_time"`
|
||||||
|
EndTime int64 `json:"end_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaServer struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
IsDefault int `json:"is_default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaServerRequest struct {
|
||||||
|
MediaServer
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaServerResponse struct {
|
||||||
|
MediaServer
|
||||||
|
ID int `json:"id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CommonResponse struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
||||||
92
pkg/service/auth.go
Normal file
92
pkg/service/auth.go
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthInfo 存储解析后的认证信息
|
||||||
|
type AuthInfo struct {
|
||||||
|
Username string
|
||||||
|
Realm string
|
||||||
|
Nonce string
|
||||||
|
URI string
|
||||||
|
Response string
|
||||||
|
Algorithm string
|
||||||
|
Method string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateNonce 生成随机 nonce 字符串
|
||||||
|
func GenerateNonce() string {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
rand.Read(b)
|
||||||
|
return fmt.Sprintf("%x", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAuthorization 解析 SIP Authorization 头
|
||||||
|
// Authorization: Digest username="34020000001320000001",realm="3402000000",
|
||||||
|
// nonce="44010b73623249f6916a6acf7c316b8e",uri="sip:34020000002000000001@3402000000",
|
||||||
|
// response="e4ca3fdc5869fa1c544ea7af60014444",algorithm=MD5
|
||||||
|
func ParseAuthorization(auth string) *AuthInfo {
|
||||||
|
auth = strings.TrimPrefix(auth, "Digest ")
|
||||||
|
parts := strings.Split(auth, ",")
|
||||||
|
result := &AuthInfo{}
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if !strings.Contains(part, "=") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
kv := strings.SplitN(part, "=", 2)
|
||||||
|
key := strings.TrimSpace(kv[0])
|
||||||
|
value := strings.Trim(strings.TrimSpace(kv[1]), "\"")
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "username":
|
||||||
|
result.Username = value
|
||||||
|
case "realm":
|
||||||
|
result.Realm = value
|
||||||
|
case "nonce":
|
||||||
|
result.Nonce = value
|
||||||
|
case "uri":
|
||||||
|
result.URI = value
|
||||||
|
case "response":
|
||||||
|
result.Response = value
|
||||||
|
case "algorithm":
|
||||||
|
result.Algorithm = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAuth 验证 SIP 认证信息
|
||||||
|
func ValidateAuth(authInfo *AuthInfo, password string) bool {
|
||||||
|
if authInfo == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认方法为 REGISTER
|
||||||
|
method := "REGISTER"
|
||||||
|
if authInfo.Method != "" {
|
||||||
|
method = authInfo.Method
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 MD5 哈希
|
||||||
|
ha1 := md5Hex(authInfo.Username + ":" + authInfo.Realm + ":" + password)
|
||||||
|
ha2 := md5Hex(method + ":" + authInfo.URI)
|
||||||
|
correctResponse := md5Hex(ha1 + ":" + authInfo.Nonce + ":" + ha2)
|
||||||
|
|
||||||
|
return authInfo.Response == correctResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// md5Hex 计算字符串的 MD5 哈希值并返回十六进制字符串
|
||||||
|
func md5Hex(s string) string {
|
||||||
|
hash := md5.New()
|
||||||
|
hash.Write([]byte(s))
|
||||||
|
return hex.EncodeToString(hash.Sum(nil))
|
||||||
|
}
|
||||||
@ -1,64 +1,12 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/ossrs/srs-sip/pkg/utils"
|
"github.com/ossrs/srs-sip/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// <Item>
|
|
||||||
// <DeviceID>34020000001320000002</DeviceID>
|
|
||||||
// <Name>209</Name>
|
|
||||||
// <Manufacturer>UNIVIEW</Manufacturer>
|
|
||||||
// <Model>HIC6622-IR@X33-VF</Model>
|
|
||||||
// <Owner>IPC-B2202.7.11.230222</Owner>
|
|
||||||
// <CivilCode>CivilCode</CivilCode>
|
|
||||||
// <Address>Address</Address>
|
|
||||||
// <Parental>1</Parental>
|
|
||||||
// <ParentID>75015310072008100002</ParentID>
|
|
||||||
// <SafetyWay>0</SafetyWay>
|
|
||||||
// <RegisterWay>1</RegisterWay>
|
|
||||||
// <Secrecy>0</Secrecy>
|
|
||||||
// <Status>ON</Status>
|
|
||||||
// <Longitude>0.0000000</Longitude>
|
|
||||||
// <Latitude>0.0000000</Latitude>
|
|
||||||
// <Info>
|
|
||||||
// <PTZType>1</PTZType>
|
|
||||||
// <Resolution>6/4/2</Resolution>
|
|
||||||
// <DownloadSpeed>0</DownloadSpeed>
|
|
||||||
// </Info>
|
|
||||||
// </Item>
|
|
||||||
|
|
||||||
type ChannelInfo struct {
|
|
||||||
DeviceID string `json:"device_id"`
|
|
||||||
ParentID string `json:"parent_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Manufacturer string `json:"manufacturer"`
|
|
||||||
Model string `json:"model"`
|
|
||||||
Owner string `json:"owner"`
|
|
||||||
CivilCode string `json:"civil_code"`
|
|
||||||
Address string `json:"address"`
|
|
||||||
Port int `json:"port"`
|
|
||||||
Parental int `json:"parental"`
|
|
||||||
SafetyWay int `json:"safety_way"`
|
|
||||||
RegisterWay int `json:"register_way"`
|
|
||||||
Secrecy int `json:"secrecy"`
|
|
||||||
IPAddress string `json:"ip_address"`
|
|
||||||
Status ChannelStatus `json:"status"`
|
|
||||||
Longitude float64 `json:"longitude"`
|
|
||||||
Latitude float64 `json:"latitude"`
|
|
||||||
Info struct {
|
|
||||||
PTZType int `json:"ptz_type"`
|
|
||||||
Resolution string `json:"resolution"`
|
|
||||||
DownloadSpeed string `json:"download_speed"` // 1/2/4/8
|
|
||||||
} `json:"info"`
|
|
||||||
|
|
||||||
// custom fields
|
|
||||||
Ssrc string `json:"ssrc"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ChannelStatus string
|
|
||||||
|
|
||||||
type DeviceInfo struct {
|
type DeviceInfo struct {
|
||||||
DeviceID string `json:"device_id"`
|
DeviceID string `json:"device_id"`
|
||||||
SourceAddr string `json:"source_addr"`
|
SourceAddr string `json:"source_addr"`
|
||||||
@ -83,6 +31,13 @@ func GetDeviceManager() *deviceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (dm *deviceManager) AddDevice(id string, info *DeviceInfo) {
|
func (dm *deviceManager) AddDevice(id string, info *DeviceInfo) {
|
||||||
|
channel := models.ChannelInfo{
|
||||||
|
DeviceID: id,
|
||||||
|
ParentID: id,
|
||||||
|
Name: id,
|
||||||
|
Status: models.ChannelStatus("ON"),
|
||||||
|
}
|
||||||
|
info.ChannelMap.Store(channel.DeviceID, channel)
|
||||||
dm.devices.Store(id, info)
|
dm.devices.Store(id, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,41 +62,88 @@ func (dm *deviceManager) GetDevice(id string) (*DeviceInfo, bool) {
|
|||||||
return v.(*DeviceInfo), true
|
return v.(*DeviceInfo), true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dm *deviceManager) UpdateChannels(deviceID string, list ...ChannelInfo) {
|
// ChannelParser defines interface for different manufacturer's channel parsing
|
||||||
device, ok := dm.GetDevice(deviceID)
|
type ChannelParser interface {
|
||||||
if !ok {
|
ParseChannels(list ...models.ChannelInfo) ([]models.ChannelInfo, error)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, channel := range list {
|
// channelParserRegistry manages registration and lookup of manufacturer-specific parsers
|
||||||
|
type channelParserRegistry struct {
|
||||||
|
parsers map[string]ChannelParser
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
parserRegistry = &channelParserRegistry{
|
||||||
|
parsers: make(map[string]ChannelParser),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterParser registers a parser for a specific manufacturer
|
||||||
|
func (r *channelParserRegistry) RegisterParser(manufacturer string, parser ChannelParser) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.parsers[manufacturer] = parser
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetParser retrieves parser for a specific manufacturer
|
||||||
|
func (r *channelParserRegistry) GetParser(manufacturer string) (ChannelParser, bool) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
parser, ok := r.parsers[manufacturer]
|
||||||
|
return parser, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateChannels updates device channel information
|
||||||
|
func (dm *deviceManager) UpdateChannels(deviceID string, list ...models.ChannelInfo) error {
|
||||||
|
device, ok := dm.GetDevice(deviceID)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("device not found: %s", deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clear ChannelMap
|
||||||
|
device.ChannelMap.Range(func(key, value interface{}) bool {
|
||||||
|
device.ChannelMap.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
parser, ok := parserRegistry.GetParser(list[0].Manufacturer)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no parser found for manufacturer: %s", list[0].Manufacturer)
|
||||||
|
}
|
||||||
|
|
||||||
|
channels, err := parser.ParseChannels(list...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse channels: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, channel := range channels {
|
||||||
device.ChannelMap.Store(channel.DeviceID, channel)
|
device.ChannelMap.Store(channel.DeviceID, channel)
|
||||||
}
|
}
|
||||||
dm.devices.Store(deviceID, device)
|
dm.devices.Store(deviceID, device)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dm *deviceManager) ApiGetChannelByDeviceId(deviceID string) []ChannelInfo {
|
func (dm *deviceManager) ApiGetChannelByDeviceId(deviceID string) []models.ChannelInfo {
|
||||||
device, ok := dm.GetDevice(deviceID)
|
device, ok := dm.GetDevice(deviceID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
channels := make([]ChannelInfo, 0)
|
channels := make([]models.ChannelInfo, 0)
|
||||||
device.ChannelMap.Range(func(key, value interface{}) bool {
|
device.ChannelMap.Range(func(key, value interface{}) bool {
|
||||||
channels = append(channels, value.(ChannelInfo))
|
channels = append(channels, value.(models.ChannelInfo))
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return channels
|
return channels
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dm *deviceManager) GetAllVideoChannels() []ChannelInfo {
|
func (dm *deviceManager) GetAllVideoChannels() []models.ChannelInfo {
|
||||||
channels := make([]ChannelInfo, 0)
|
channels := make([]models.ChannelInfo, 0)
|
||||||
dm.devices.Range(func(key, value interface{}) bool {
|
dm.devices.Range(func(key, value interface{}) bool {
|
||||||
device := value.(*DeviceInfo)
|
device := value.(*DeviceInfo)
|
||||||
device.ChannelMap.Range(func(key, value interface{}) bool {
|
device.ChannelMap.Range(func(key, value interface{}) bool {
|
||||||
if utils.IsVideoChannel(value.(ChannelInfo).DeviceID) {
|
channels = append(channels, value.(models.ChannelInfo))
|
||||||
channels = append(channels, value.(ChannelInfo))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
@ -164,3 +166,37 @@ func (dm *deviceManager) GetDeviceInfoByChannel(channelID string) (*DeviceInfo,
|
|||||||
})
|
})
|
||||||
return device, found
|
return device, found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hikvision channel parser implementation
|
||||||
|
type HikvisionParser struct{}
|
||||||
|
|
||||||
|
func (p *HikvisionParser) ParseChannels(list ...models.ChannelInfo) ([]models.ChannelInfo, error) {
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dahua channel parser implementation
|
||||||
|
type DahuaParser struct{}
|
||||||
|
|
||||||
|
func (p *DahuaParser) ParseChannels(list ...models.ChannelInfo) ([]models.ChannelInfo, error) {
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uniview channel parser implementation
|
||||||
|
type UniviewParser struct{}
|
||||||
|
|
||||||
|
func (p *UniviewParser) ParseChannels(list ...models.ChannelInfo) ([]models.ChannelInfo, error) {
|
||||||
|
videoChannels := make([]models.ChannelInfo, 0)
|
||||||
|
for _, channel := range list {
|
||||||
|
// 只有Parental为1的通道,才是视频通道
|
||||||
|
if channel.Parental == 1 {
|
||||||
|
videoChannels = append(videoChannels, channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return videoChannels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
parserRegistry.RegisterParser("Hikvision", &HikvisionParser{})
|
||||||
|
parserRegistry.RegisterParser("DAHUA", &DahuaParser{})
|
||||||
|
parserRegistry.RegisterParser("UNIVIEW", &UniviewParser{})
|
||||||
|
}
|
||||||
|
|||||||
@ -8,21 +8,13 @@ import (
|
|||||||
|
|
||||||
"github.com/emiago/sipgo/sip"
|
"github.com/emiago/sipgo/sip"
|
||||||
"github.com/ossrs/go-oryx-lib/logger"
|
"github.com/ossrs/go-oryx-lib/logger"
|
||||||
|
"github.com/ossrs/srs-sip/pkg/models"
|
||||||
"github.com/ossrs/srs-sip/pkg/service/stack"
|
"github.com/ossrs/srs-sip/pkg/service/stack"
|
||||||
"golang.org/x/net/html/charset"
|
"golang.org/x/net/html/charset"
|
||||||
)
|
)
|
||||||
|
|
||||||
const GB28181_ID_LENGTH = 20
|
const GB28181_ID_LENGTH = 20
|
||||||
|
|
||||||
type VideoChannelStatus struct {
|
|
||||||
ID string
|
|
||||||
ParentID string
|
|
||||||
MediaHost string
|
|
||||||
MediaPort int
|
|
||||||
Ssrc string
|
|
||||||
Status string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UAS) onRegister(req *sip.Request, tx sip.ServerTransaction) {
|
func (s *UAS) onRegister(req *sip.Request, tx sip.ServerTransaction) {
|
||||||
id := req.From().Address.User
|
id := req.From().Address.User
|
||||||
if len(id) != GB28181_ID_LENGTH {
|
if len(id) != GB28181_ID_LENGTH {
|
||||||
@ -30,6 +22,27 @@ func (s *UAS) onRegister(req *sip.Request, tx sip.ServerTransaction) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.conf.GB28181.Auth.Enable {
|
||||||
|
// Check if Authorization header exists
|
||||||
|
authHeader := req.GetHeaders("Authorization")
|
||||||
|
|
||||||
|
// If no Authorization header, send 401 response to request authentication
|
||||||
|
if len(authHeader) == 0 {
|
||||||
|
nonce := GenerateNonce()
|
||||||
|
resp := stack.NewUnauthorizedResponse(req, http.StatusUnauthorized, "Unauthorized", nonce, s.conf.GB28181.Realm)
|
||||||
|
_ = tx.Respond(resp)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate Authorization
|
||||||
|
authInfo := ParseAuthorization(authHeader[0].Value())
|
||||||
|
if !ValidateAuth(authInfo, s.conf.GB28181.Auth.Password) {
|
||||||
|
logger.Ef(s.ctx, "%s auth failed, source: %s", id, req.Source())
|
||||||
|
s.respondRegister(req, http.StatusForbidden, "Auth Failed", tx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
isUnregister := false
|
isUnregister := false
|
||||||
if exps := req.GetHeaders("Expires"); len(exps) > 0 {
|
if exps := req.GetHeaders("Expires"); len(exps) > 0 {
|
||||||
exp := exps[0]
|
exp := exps[0]
|
||||||
@ -88,19 +101,7 @@ func (s *UAS) onMessage(req *sip.Request, tx sip.ServerTransaction) {
|
|||||||
|
|
||||||
//logger.Tf(s.ctx, "Received MESSAGE: %s", req.String())
|
//logger.Tf(s.ctx, "Received MESSAGE: %s", req.String())
|
||||||
|
|
||||||
temp := &struct {
|
temp := &models.XmlMessageInfo{}
|
||||||
XMLName xml.Name
|
|
||||||
CmdType string
|
|
||||||
SN int // 请求序列号,一般用于对应 request 和 response
|
|
||||||
DeviceID string
|
|
||||||
DeviceName string
|
|
||||||
Manufacturer string
|
|
||||||
Model string
|
|
||||||
Channel string
|
|
||||||
DeviceList []ChannelInfo `xml:"DeviceList>Item"`
|
|
||||||
// RecordList []*Record `xml:"RecordList>Item"`
|
|
||||||
// SumNum int
|
|
||||||
}{}
|
|
||||||
decoder := xml.NewDecoder(bytes.NewReader([]byte(req.Body())))
|
decoder := xml.NewDecoder(bytes.NewReader([]byte(req.Body())))
|
||||||
decoder.CharsetReader = charset.NewReaderLabel
|
decoder.CharsetReader = charset.NewReaderLabel
|
||||||
if err := decoder.Decode(temp); err != nil {
|
if err := decoder.Decode(temp); err != nil {
|
||||||
@ -122,6 +123,14 @@ func (s *UAS) onMessage(req *sip.Request, tx sip.ServerTransaction) {
|
|||||||
//go s.AutoInvite(temp.DeviceID, temp.DeviceList...)
|
//go s.AutoInvite(temp.DeviceID, temp.DeviceList...)
|
||||||
case "Alarm":
|
case "Alarm":
|
||||||
logger.T(s.ctx, "Alarm")
|
logger.T(s.ctx, "Alarm")
|
||||||
|
case "RecordInfo":
|
||||||
|
logger.T(s.ctx, "RecordInfo")
|
||||||
|
// 从 recordQueryResults 中获取对应通道的结果通道
|
||||||
|
if ch, ok := s.recordQueryResults.Load(temp.DeviceID); ok {
|
||||||
|
// 发送查询结果
|
||||||
|
resultChan := ch.(chan *models.XmlMessageInfo)
|
||||||
|
resultChan <- temp
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
logger.Wf(s.ctx, "Not supported CmdType: %s", temp.CmdType)
|
logger.Wf(s.ctx, "Not supported CmdType: %s", temp.CmdType)
|
||||||
response := sip.NewResponseFromRequest(req, http.StatusBadRequest, "", nil)
|
response := sip.NewResponseFromRequest(req, http.StatusBadRequest, "", nil)
|
||||||
@ -135,19 +144,3 @@ func (s *UAS) onNotify(req *sip.Request, tx sip.ServerTransaction) {
|
|||||||
logger.T(s.ctx, "Received NOTIFY request")
|
logger.T(s.ctx, "Received NOTIFY request")
|
||||||
tx.Respond(sip.NewResponseFromRequest(req, http.StatusOK, "OK", nil))
|
tx.Respond(sip.NewResponseFromRequest(req, http.StatusOK, "OK", nil))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UAS) AddVideoChannelStatue(channelID string, status VideoChannelStatus) {
|
|
||||||
s.channelsStatue.Store(channelID, status)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UAS) GetVideoChannelStatue(channelID string) (VideoChannelStatus, bool) {
|
|
||||||
v, ok := s.channelsStatue.Load(channelID)
|
|
||||||
if !ok {
|
|
||||||
return VideoChannelStatus{}, false
|
|
||||||
}
|
|
||||||
return v.(VideoChannelStatus), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UAS) RemoveVideoChannelStatue(channelID string) {
|
|
||||||
s.channelsStatue.Delete(channelID)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,48 +3,217 @@ package service
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/emiago/sipgo/sip"
|
"github.com/emiago/sipgo/sip"
|
||||||
"github.com/ossrs/go-oryx-lib/errors"
|
"github.com/ossrs/go-oryx-lib/errors"
|
||||||
"github.com/ossrs/go-oryx-lib/logger"
|
"github.com/ossrs/go-oryx-lib/logger"
|
||||||
|
"github.com/ossrs/srs-sip/pkg/media"
|
||||||
|
"github.com/ossrs/srs-sip/pkg/models"
|
||||||
"github.com/ossrs/srs-sip/pkg/service/stack"
|
"github.com/ossrs/srs-sip/pkg/service/stack"
|
||||||
"github.com/ossrs/srs-sip/pkg/utils"
|
"github.com/ossrs/srs-sip/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *UAS) AutoInvite(deviceID string, list ...ChannelInfo) {
|
type Session struct {
|
||||||
for _, c := range list {
|
ID string
|
||||||
if c.Status == "ON" && utils.IsVideoChannel(c.DeviceID) {
|
ParentID string
|
||||||
if err := s.Invite(deviceID, c.DeviceID); err != nil {
|
MediaHost string
|
||||||
logger.Ef(s.ctx, "invite error: %s", err.Error())
|
MediaPort int
|
||||||
}
|
Ssrc string
|
||||||
}
|
Status string
|
||||||
}
|
URL string
|
||||||
|
RefCount int
|
||||||
|
CSeq int
|
||||||
|
InviteReq *sip.Request
|
||||||
|
InviteRes *sip.Response
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UAS) Invite(deviceID, channelID string) error {
|
func (s *Session) NewRequest(method sip.RequestMethod, body []byte) *sip.Request {
|
||||||
if s.isPublishing(channelID) {
|
request := sip.NewRequest(method, s.InviteReq.Recipient)
|
||||||
|
|
||||||
|
request.SipVersion = s.InviteRes.SipVersion
|
||||||
|
|
||||||
|
maxForwardsHeader := sip.MaxForwardsHeader(70)
|
||||||
|
request.AppendHeader(&maxForwardsHeader)
|
||||||
|
|
||||||
|
if h := s.InviteReq.From(); h != nil {
|
||||||
|
request.AppendHeader(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h := s.InviteRes.To(); h != nil {
|
||||||
|
request.AppendHeader(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h := s.InviteReq.CallID(); h != nil {
|
||||||
|
request.AppendHeader(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h := s.InviteReq.CSeq(); h != nil {
|
||||||
|
h.SeqNo++
|
||||||
|
request.AppendHeader(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.SetSource(s.InviteReq.Source())
|
||||||
|
request.SetDestination(s.InviteReq.Destination())
|
||||||
|
request.SetTransport(s.InviteReq.Transport())
|
||||||
|
request.SetBody(body)
|
||||||
|
|
||||||
|
s.CSeq++
|
||||||
|
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) NewByeRequest() *sip.Request {
|
||||||
|
return s.NewRequest(sip.BYE, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PAUSE RTSP/1.0
|
||||||
|
// CSeq:1
|
||||||
|
// PauseTime:now
|
||||||
|
func (s *Session) NewPauseRequest() *sip.Request {
|
||||||
|
body := []byte(fmt.Sprintf(`PAUSE RTSP/1.0
|
||||||
|
CSeq: %d
|
||||||
|
PauseTime: now
|
||||||
|
`, s.CSeq))
|
||||||
|
s.CSeq++
|
||||||
|
pauseRequest := s.NewRequest(sip.INFO, body)
|
||||||
|
pauseRequest.AppendHeader(sip.NewHeader("Content-Type", "Application/MANSRTSP"))
|
||||||
|
return pauseRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLAY RTSP/1.0
|
||||||
|
// CSeq:2
|
||||||
|
// Range:npt=now
|
||||||
|
func (s *Session) NewResumeRequest() *sip.Request {
|
||||||
|
body := []byte(fmt.Sprintf(`PLAY RTSP/1.0
|
||||||
|
CSeq: %d
|
||||||
|
Range: npt=now
|
||||||
|
`, s.CSeq))
|
||||||
|
s.CSeq++
|
||||||
|
resumeRequest := s.NewRequest(sip.INFO, body)
|
||||||
|
resumeRequest.AppendHeader(sip.NewHeader("Content-Type", "Application/MANSRTSP"))
|
||||||
|
return resumeRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLAY RTSP/1.0
|
||||||
|
// CSeq:3
|
||||||
|
// Scale:2.0
|
||||||
|
func (s *Session) NewSpeedRequest(speed float32) *sip.Request {
|
||||||
|
body := []byte(fmt.Sprintf(`PLAY RTSP/1.0
|
||||||
|
CSeq: %d
|
||||||
|
Scale: %.1f
|
||||||
|
`, s.CSeq, speed))
|
||||||
|
s.CSeq++
|
||||||
|
speedRequest := s.NewRequest(sip.INFO, body)
|
||||||
|
speedRequest.AppendHeader(sip.NewHeader("Content-Type", "Application/MANSRTSP"))
|
||||||
|
return speedRequest
|
||||||
|
}
|
||||||
|
func (s *UAS) AddSession(key string, status Session) {
|
||||||
|
logger.Tf(s.ctx, "AddSession: %s, %+v", key, status)
|
||||||
|
s.Streams.Store(key, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UAS) GetSession(key string) (Session, bool) {
|
||||||
|
v, ok := s.Streams.Load(key)
|
||||||
|
if !ok {
|
||||||
|
return Session{}, false
|
||||||
|
}
|
||||||
|
return v.(Session), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UAS) GetSessionByURL(url string) (string, Session) {
|
||||||
|
var k string
|
||||||
|
var result Session
|
||||||
|
s.Streams.Range(func(key, value interface{}) bool {
|
||||||
|
stream := value.(Session)
|
||||||
|
if stream.URL == url {
|
||||||
|
k = key.(string)
|
||||||
|
result = stream
|
||||||
|
return false // break
|
||||||
|
}
|
||||||
|
return true // continue
|
||||||
|
})
|
||||||
|
return k, result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UAS) RemoveSession(key string) {
|
||||||
|
s.Streams.Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UAS) InitMediaServer(req models.InviteRequest) error {
|
||||||
|
s.mediaLock.Lock()
|
||||||
|
defer s.mediaLock.Unlock()
|
||||||
|
|
||||||
|
mediaServer, err := MediaDB.GetMediaServer(req.MediaServerId)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "get media server error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.media != nil && s.media.GetAddr() == fmt.Sprintf("%s:%d", mediaServer.IP, mediaServer.Port) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ssrc := utils.CreateSSRC(true)
|
switch mediaServer.Type {
|
||||||
|
case "SRS", "srs":
|
||||||
|
s.media = &media.Srs{
|
||||||
|
Ctx: s.ctx,
|
||||||
|
Schema: "http",
|
||||||
|
Addr: fmt.Sprintf("%s:%d", mediaServer.IP, mediaServer.Port),
|
||||||
|
Username: mediaServer.Username,
|
||||||
|
Password: mediaServer.Password,
|
||||||
|
}
|
||||||
|
case "ZLM", "zlm":
|
||||||
|
s.media = &media.Zlm{
|
||||||
|
Ctx: s.ctx,
|
||||||
|
Schema: "http",
|
||||||
|
Addr: fmt.Sprintf("%s:%d", mediaServer.IP, mediaServer.Port),
|
||||||
|
Secret: mediaServer.Secret,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.Errorf("unsupported media server type: %s", mediaServer.Type)
|
||||||
|
}
|
||||||
|
|
||||||
mediaPort, err := s.signal.Publish(ssrc, ssrc)
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UAS) Invite(req models.InviteRequest) (*Session, error) {
|
||||||
|
key := fmt.Sprintf("%d:%s:%s:%d:%d:%d:%d", req.MediaServerId, req.DeviceID, req.ChannelID, req.SubStream, req.PlayType, req.StartTime, req.EndTime)
|
||||||
|
|
||||||
|
// Check if stream already exists
|
||||||
|
if s.isPublishing(key) {
|
||||||
|
// Stream exists, increase reference count
|
||||||
|
c, _ := s.GetSession(key)
|
||||||
|
c.RefCount++
|
||||||
|
s.AddSession(key, c)
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ssrc := utils.CreateSSRC(req.PlayType == 0)
|
||||||
|
|
||||||
|
err := s.InitMediaServer(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(err, "api gb publish request error")
|
return nil, errors.Wrapf(err, "init media server error")
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaHost := strings.Split(s.conf.MediaAddr, ":")[0]
|
mediaPort, err := s.media.Publish(ssrc, ssrc)
|
||||||
if mediaHost == "" {
|
if err != nil {
|
||||||
return errors.Errorf("media host is empty")
|
return nil, errors.Wrapf(err, "api gb publish request error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mediaHost := strings.Split(s.media.GetAddr(), ":")[0]
|
||||||
|
if mediaHost == "" {
|
||||||
|
return nil, errors.Errorf("media host is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionName := utils.GetSessionName(req.PlayType)
|
||||||
|
|
||||||
sdpInfo := []string{
|
sdpInfo := []string{
|
||||||
"v=0",
|
"v=0",
|
||||||
fmt.Sprintf("o=%s 0 0 IN IP4 %s", channelID, mediaHost),
|
fmt.Sprintf("o=%s 0 0 IN IP4 %s", req.ChannelID, mediaHost),
|
||||||
"s=" + "Play",
|
"s=" + sessionName,
|
||||||
"u=" + channelID + ":0",
|
"u=" + req.ChannelID + ":0",
|
||||||
"c=IN IP4 " + mediaHost,
|
"c=IN IP4 " + mediaHost,
|
||||||
"t=0 0", // start time and end time
|
"t=" + fmt.Sprintf("%d %d", req.StartTime, req.EndTime),
|
||||||
fmt.Sprintf("m=video %d TCP/RTP/AVP 96", mediaPort),
|
fmt.Sprintf("m=video %d TCP/RTP/AVP 96", mediaPort),
|
||||||
"a=recvonly",
|
"a=recvonly",
|
||||||
"a=rtpmap:96 PS/90000",
|
"a=rtpmap:96 PS/90000",
|
||||||
@ -56,63 +225,132 @@ func (s *UAS) Invite(deviceID, channelID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: 需要考虑不同设备,通道ID相同的情况
|
// TODO: 需要考虑不同设备,通道ID相同的情况
|
||||||
d, ok := DM.GetDeviceInfoByChannel(channelID)
|
d, ok := DM.GetDeviceInfoByChannel(req.ChannelID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.Errorf("device not found by %s", channelID)
|
return nil, errors.Errorf("device not found by %s", req.ChannelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
subject := fmt.Sprintf("%s:%s,%s:0", channelID, ssrc, s.conf.Serial)
|
subject := fmt.Sprintf("%s:%s,%s:0", req.ChannelID, ssrc, s.conf.GB28181.Serial)
|
||||||
|
|
||||||
req, err := stack.NewInviteRequest([]byte(strings.Join(sdpInfo, "\r\n")), subject, stack.OutboundConfig{
|
reqInvite, err := stack.NewInviteRequest([]byte(strings.Join(sdpInfo, "\r\n")), subject, stack.OutboundConfig{
|
||||||
Via: d.SourceAddr,
|
Via: d.SourceAddr,
|
||||||
To: d.DeviceID,
|
To: d.DeviceID,
|
||||||
From: s.conf.Serial,
|
From: s.conf.GB28181.Serial,
|
||||||
Transport: d.NetworkType,
|
Transport: d.NetworkType,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(err, "build invite request error")
|
return nil, errors.Wrapf(err, "build invite request error")
|
||||||
}
|
|
||||||
tx, err := s.sipCli.TransactionRequest(s.ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrapf(err, "transaction request error")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := s.waitAnswer(tx)
|
res, err := s.handleSipTransaction(reqInvite)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(err, "wait answer error")
|
return nil, err
|
||||||
}
|
|
||||||
if res.StatusCode != 200 {
|
|
||||||
return errors.Errorf("invite response error: %s", res.String())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ack := sip.NewAckRequest(req, res, nil)
|
ack := sip.NewAckRequest(reqInvite, res, nil)
|
||||||
s.sipCli.WriteRequest(ack)
|
s.sipCli.WriteRequest(ack)
|
||||||
|
|
||||||
s.AddVideoChannelStatue(channelID, VideoChannelStatus{
|
session := Session{
|
||||||
ID: channelID,
|
ID: req.ChannelID,
|
||||||
ParentID: deviceID,
|
ParentID: req.DeviceID,
|
||||||
MediaHost: mediaHost,
|
MediaHost: mediaHost,
|
||||||
MediaPort: mediaPort,
|
MediaPort: mediaPort,
|
||||||
Ssrc: ssrc,
|
Ssrc: ssrc,
|
||||||
Status: "ON",
|
Status: "ON",
|
||||||
})
|
URL: s.media.GetWebRTCAddr(ssrc),
|
||||||
|
RefCount: 1,
|
||||||
|
InviteReq: reqInvite,
|
||||||
|
InviteRes: res,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.AddSession(key, session)
|
||||||
|
|
||||||
|
return &session, nil
|
||||||
|
}
|
||||||
|
func (s *UAS) isPublishing(key string) bool {
|
||||||
|
c, ok := s.GetSession(key)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if stream already exists
|
||||||
|
if p, err := s.media.GetStreamStatus(c.Ssrc); err != nil || !p {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UAS) Bye(req models.ByeRequest) error {
|
||||||
|
key, session := s.GetSessionByURL(req.URL)
|
||||||
|
if key == "" {
|
||||||
|
return errors.Errorf("stream not found: %s", req.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.RefCount--
|
||||||
|
if session.RefCount > 0 {
|
||||||
|
s.AddSession(key, session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if err := s.media.Unpublish(session.Ssrc); err != nil {
|
||||||
|
logger.Ef(s.ctx, "unpublish stream error: %s", err)
|
||||||
|
}
|
||||||
|
s.RemoveSession(key)
|
||||||
|
}()
|
||||||
|
|
||||||
|
reqBye := session.NewByeRequest()
|
||||||
|
_, err := s.handleSipTransaction(reqBye)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UAS) isPublishing(channelID string) bool {
|
func (s *UAS) Pause(req models.PauseRequest) error {
|
||||||
c, err := s.GetVideoChannelStatue(channelID)
|
key, session := s.GetSessionByURL(req.URL)
|
||||||
if !err {
|
if key == "" {
|
||||||
return false
|
return errors.Errorf("stream not found: %s", req.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
if p, err := s.signal.GetStreamStatus(c.Ssrc); err != nil || !p {
|
pauseRequest := session.NewPauseRequest()
|
||||||
return false
|
_, err := s.handleSipTransaction(pauseRequest)
|
||||||
}
|
if err != nil {
|
||||||
return true
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UAS) Resume(req models.ResumeRequest) error {
|
||||||
|
key, session := s.GetSessionByURL(req.URL)
|
||||||
|
if key == "" {
|
||||||
|
return errors.Errorf("stream not found: %s", req.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
resumeRequest := session.NewResumeRequest()
|
||||||
|
_, err := s.handleSipTransaction(resumeRequest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UAS) Speed(req models.SpeedRequest) error {
|
||||||
|
key, session := s.GetSessionByURL(req.URL)
|
||||||
|
if key == "" {
|
||||||
|
return errors.Errorf("stream not found: %s", req.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
speedRequest := session.NewSpeedRequest(req.Speed)
|
||||||
|
_, err := s.handleSipTransaction(speedRequest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UAS) Bye() error {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,29 +369,22 @@ func (s *UAS) Catalog(deviceID string) error {
|
|||||||
|
|
||||||
body := fmt.Sprintf(CatalogXML, s.getSN(), deviceID)
|
body := fmt.Sprintf(CatalogXML, s.getSN(), deviceID)
|
||||||
|
|
||||||
req, err := stack.NewCatelogRequest([]byte(body), stack.OutboundConfig{
|
req, err := stack.NewMessageRequest([]byte(body), stack.OutboundConfig{
|
||||||
Via: d.SourceAddr,
|
Via: d.SourceAddr,
|
||||||
To: d.DeviceID,
|
To: d.DeviceID,
|
||||||
From: s.conf.Serial,
|
From: s.conf.GB28181.Serial,
|
||||||
Transport: d.NetworkType,
|
Transport: d.NetworkType,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(err, "build catalog request error")
|
return errors.Wrapf(err, "build catalog request error")
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := s.sipCli.TransactionRequest(s.ctx, req)
|
_, err = s.handleSipTransaction(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(err, "transaction request error")
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := s.waitAnswer(tx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrapf(err, "wait answer error")
|
|
||||||
}
|
|
||||||
logger.Tf(s.ctx, "catalog response: %s", res.String())
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UAS) waitAnswer(tx sip.ClientTransaction) (*sip.Response, error) {
|
func (s *UAS) waitAnswer(tx sip.ClientTransaction) (*sip.Response, error) {
|
||||||
@ -167,3 +398,134 @@ func (s *UAS) waitAnswer(tx sip.ClientTransaction) (*sip.Response, error) {
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// <?xml version="1.0"?>
|
||||||
|
// <Control>
|
||||||
|
// <CmdType>DeviceControl</CmdType>
|
||||||
|
// <SN>474</SN>
|
||||||
|
// <DeviceID>33010602001310019325</DeviceID>
|
||||||
|
// <PTZCmd>a50f4d0190000092</PTZCmd>
|
||||||
|
// <Info>
|
||||||
|
// <ControlPriority>150</ControlPriority>
|
||||||
|
// </Info>
|
||||||
|
// </Control>
|
||||||
|
func (s *UAS) ControlPTZ(deviceID, channelID, ptz, speed string) error {
|
||||||
|
var ptzXML = `<?xml version="1.0"?>
|
||||||
|
<Control>
|
||||||
|
<CmdType>DeviceControl</CmdType>
|
||||||
|
<SN>%d</SN>
|
||||||
|
<DeviceID>%s</DeviceID>
|
||||||
|
<PTZCmd>%s</PTZCmd>
|
||||||
|
<Info>
|
||||||
|
<ControlPriority>150</ControlPriority>
|
||||||
|
</Info>
|
||||||
|
</Control>
|
||||||
|
`
|
||||||
|
|
||||||
|
// d, ok := DM.GetDevice(deviceID)
|
||||||
|
d, ok := DM.GetDeviceInfoByChannel(channelID)
|
||||||
|
if !ok {
|
||||||
|
return errors.Errorf("device %s not found", deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ptzCmd, err := toPTZCmd(ptz, speed)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "build ptz command error")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := fmt.Sprintf(ptzXML, s.getSN(), channelID, ptzCmd)
|
||||||
|
|
||||||
|
req, err := stack.NewMessageRequest([]byte(body), stack.OutboundConfig{
|
||||||
|
Via: d.SourceAddr,
|
||||||
|
To: d.DeviceID,
|
||||||
|
From: s.conf.GB28181.Serial,
|
||||||
|
Transport: d.NetworkType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrapf(err, "build ptz request error")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.handleSipTransaction(req)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryRecord 查询录像记录
|
||||||
|
func (s *UAS) QueryRecord(deviceID, channelID string, startTime, endTime int64) ([]*models.Record, error) {
|
||||||
|
var queryXML = `<?xml version="1.0"?>
|
||||||
|
<Query>
|
||||||
|
<CmdType>RecordInfo</CmdType>
|
||||||
|
<SN>%d</SN>
|
||||||
|
<DeviceID>%s</DeviceID>
|
||||||
|
<StartTime>%s</StartTime>
|
||||||
|
<EndTime>%s</EndTime>
|
||||||
|
<Secrecy>0</Secrecy>
|
||||||
|
<Type>all</Type>
|
||||||
|
</Query>
|
||||||
|
`
|
||||||
|
|
||||||
|
d, ok := DM.GetDeviceInfoByChannel(channelID)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.Errorf("device %s not found", deviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间原本是unix时间戳,需要转换为YYYY-MM-DDTHH:MM:SS
|
||||||
|
startTimeStr := time.Unix(startTime, 0).Format("2006-01-02T00:00:00")
|
||||||
|
endTimeStr := time.Unix(endTime, 0).Format("2006-01-02T15:04:05")
|
||||||
|
|
||||||
|
body := fmt.Sprintf(queryXML, s.getSN(), channelID, startTimeStr, endTimeStr)
|
||||||
|
|
||||||
|
req, err := stack.NewMessageRequest([]byte(body), stack.OutboundConfig{
|
||||||
|
Via: d.SourceAddr,
|
||||||
|
To: d.DeviceID,
|
||||||
|
From: s.conf.GB28181.Serial,
|
||||||
|
Transport: d.NetworkType,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "build query request error")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.handleSipTransaction(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建一个通道来接收录像查询结果
|
||||||
|
resultChan := make(chan *models.XmlMessageInfo, 1)
|
||||||
|
s.recordQueryResults.Store(channelID, resultChan)
|
||||||
|
defer s.recordQueryResults.Delete(channelID)
|
||||||
|
|
||||||
|
// 等待结果或超时
|
||||||
|
var allRecords []*models.Record
|
||||||
|
timeout := time.After(10 * time.Second)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-timeout:
|
||||||
|
return allRecords, errors.Errorf("query record timeout after 30s")
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return nil, errors.Errorf("context done")
|
||||||
|
case records := <-resultChan:
|
||||||
|
allRecords = append(allRecords, records.RecordList...)
|
||||||
|
logger.Tf(s.ctx, "[channel %s] 应收总数 %d, 实收总数 %d, 本次收到 %d", channelID, records.SumNum, len(allRecords), len(records.RecordList))
|
||||||
|
|
||||||
|
if len(allRecords) == records.SumNum {
|
||||||
|
return allRecords, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UAS) handleSipTransaction(req *sip.Request) (*sip.Response, error) {
|
||||||
|
tx, err := s.sipCli.TransactionRequest(s.ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "transaction request error")
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.waitAnswer(tx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "wait answer error")
|
||||||
|
}
|
||||||
|
if res.StatusCode != 200 {
|
||||||
|
return nil, errors.Errorf("response error: %s", res.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|||||||
81
pkg/service/ptz.go
Normal file
81
pkg/service/ptz.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ptzCmdMap = map[string]uint8{
|
||||||
|
"stop": 0,
|
||||||
|
"right": 1,
|
||||||
|
"left": 2,
|
||||||
|
"down": 4,
|
||||||
|
"downright": 5,
|
||||||
|
"downleft": 6,
|
||||||
|
"up": 8,
|
||||||
|
"upright": 9,
|
||||||
|
"upleft": 10,
|
||||||
|
"zoomin": 16,
|
||||||
|
"zoomout": 32,
|
||||||
|
}
|
||||||
|
|
||||||
|
ptzSpeedMap = map[string]uint8{
|
||||||
|
"1": 25,
|
||||||
|
"2": 50,
|
||||||
|
"3": 75,
|
||||||
|
"4": 100,
|
||||||
|
"5": 125,
|
||||||
|
"6": 150,
|
||||||
|
"7": 175,
|
||||||
|
"8": 200,
|
||||||
|
"9": 225,
|
||||||
|
"10": 255,
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultSpeed uint8 = 125
|
||||||
|
)
|
||||||
|
|
||||||
|
func getPTZSpeed(speed string) uint8 {
|
||||||
|
if v, ok := ptzSpeedMap[speed]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return defaultSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPTZCmd(cmdName, speed string) (string, error) {
|
||||||
|
cmdCode, ok := ptzCmdMap[cmdName]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("invalid ptz command: %q", cmdName)
|
||||||
|
}
|
||||||
|
|
||||||
|
speedValue := getPTZSpeed(speed)
|
||||||
|
|
||||||
|
var horizontalSpeed, verticalSpeed, zSpeed uint8
|
||||||
|
|
||||||
|
switch cmdName {
|
||||||
|
case "left", "right":
|
||||||
|
horizontalSpeed = speedValue
|
||||||
|
verticalSpeed = 0
|
||||||
|
case "up", "down":
|
||||||
|
verticalSpeed = speedValue
|
||||||
|
horizontalSpeed = 0
|
||||||
|
case "upleft", "upright", "downleft", "downright":
|
||||||
|
verticalSpeed = speedValue
|
||||||
|
horizontalSpeed = speedValue
|
||||||
|
case "zoomin", "zoomout":
|
||||||
|
zSpeed = speedValue << 4 // zoom速度在高4位
|
||||||
|
default:
|
||||||
|
horizontalSpeed = 0
|
||||||
|
verticalSpeed = 0
|
||||||
|
zSpeed = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := uint16(0xA5) + uint16(0x0F) + uint16(0x01) + uint16(cmdCode) + uint16(horizontalSpeed) + uint16(verticalSpeed) + uint16(zSpeed)
|
||||||
|
checksum := uint8(sum % 256)
|
||||||
|
|
||||||
|
return fmt.Sprintf("A50F01%02X%02X%02X%02X%02X",
|
||||||
|
cmdCode,
|
||||||
|
horizontalSpeed,
|
||||||
|
verticalSpeed,
|
||||||
|
zSpeed,
|
||||||
|
checksum,
|
||||||
|
), nil
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ type OutboundConfig struct {
|
|||||||
To string
|
To string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRequest(method sip.RequestMethod, body []byte, conf OutboundConfig) (*sip.Request, error) {
|
func NewRequest(method sip.RequestMethod, body []byte, conf OutboundConfig) (*sip.Request, error) {
|
||||||
if len(conf.From) != 20 || len(conf.To) != 20 {
|
if len(conf.From) != 20 || len(conf.To) != 20 {
|
||||||
return nil, errors.Errorf("From or To length is not 20")
|
return nil, errors.Errorf("From or To length is not 20")
|
||||||
}
|
}
|
||||||
@ -37,7 +37,7 @@ func newRequest(method sip.RequestMethod, body []byte, conf OutboundConfig) (*si
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewRegisterRequest(conf OutboundConfig) (*sip.Request, error) {
|
func NewRegisterRequest(conf OutboundConfig) (*sip.Request, error) {
|
||||||
req, err := newRequest(sip.REGISTER, nil, conf)
|
req, err := NewRequest(sip.REGISTER, nil, conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -47,7 +47,7 @@ func NewRegisterRequest(conf OutboundConfig) (*sip.Request, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewInviteRequest(body []byte, subject string, conf OutboundConfig) (*sip.Request, error) {
|
func NewInviteRequest(body []byte, subject string, conf OutboundConfig) (*sip.Request, error) {
|
||||||
req, err := newRequest(sip.INVITE, body, conf)
|
req, err := NewRequest(sip.INVITE, body, conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -57,8 +57,8 @@ func NewInviteRequest(body []byte, subject string, conf OutboundConfig) (*sip.Re
|
|||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCatelogRequest(body []byte, conf OutboundConfig) (*sip.Request, error) {
|
func NewMessageRequest(body []byte, conf OutboundConfig) (*sip.Request, error) {
|
||||||
req, err := newRequest(sip.MESSAGE, body, conf)
|
req, err := NewRequest(sip.MESSAGE, body, conf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package stack
|
package stack
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/emiago/sipgo/sip"
|
"github.com/emiago/sipgo/sip"
|
||||||
@ -9,7 +10,7 @@ import (
|
|||||||
const TIME_LAYOUT = "2024-01-01T00:00:00"
|
const TIME_LAYOUT = "2024-01-01T00:00:00"
|
||||||
const EXPIRES_TIME = 3600
|
const EXPIRES_TIME = 3600
|
||||||
|
|
||||||
func NewRegisterResponse(req *sip.Request, code sip.StatusCode, reason string) *sip.Response {
|
func newResponse(req *sip.Request, code sip.StatusCode, reason string) *sip.Response {
|
||||||
resp := sip.NewResponseFromRequest(req, code, reason, nil)
|
resp := sip.NewResponseFromRequest(req, code, reason, nil)
|
||||||
|
|
||||||
newTo := &sip.ToHeader{Address: resp.To().Address, Params: sip.NewParams()}
|
newTo := &sip.ToHeader{Address: resp.To().Address, Params: sip.NewParams()}
|
||||||
@ -17,9 +18,24 @@ func NewRegisterResponse(req *sip.Request, code sip.StatusCode, reason string) *
|
|||||||
|
|
||||||
resp.ReplaceHeader(newTo)
|
resp.ReplaceHeader(newTo)
|
||||||
resp.RemoveHeader("Allow")
|
resp.RemoveHeader("Allow")
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegisterResponse(req *sip.Request, code sip.StatusCode, reason string) *sip.Response {
|
||||||
|
resp := newResponse(req, code, reason)
|
||||||
|
|
||||||
expires := sip.ExpiresHeader(EXPIRES_TIME)
|
expires := sip.ExpiresHeader(EXPIRES_TIME)
|
||||||
resp.AppendHeader(&expires)
|
resp.AppendHeader(&expires)
|
||||||
resp.AppendHeader(sip.NewHeader("Date", time.Now().Format(TIME_LAYOUT)))
|
resp.AppendHeader(sip.NewHeader("Date", time.Now().Format(TIME_LAYOUT)))
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewUnauthorizedResponse(req *sip.Request, code sip.StatusCode, reason, nonce, realm string) *sip.Response {
|
||||||
|
resp := newResponse(req, code, reason)
|
||||||
|
|
||||||
|
resp.AppendHeader(sip.NewHeader("WWW-Authenticate", fmt.Sprintf(`Digest realm="%s",nonce="%s",algorithm=MD5`, realm, nonce)))
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|||||||
@ -81,7 +81,7 @@ func (c *UAC) doRegister() error {
|
|||||||
From: "34020000001110000001",
|
From: "34020000001110000001",
|
||||||
To: "34020000002000000001",
|
To: "34020000002000000001",
|
||||||
Transport: "UDP",
|
Transport: "UDP",
|
||||||
Via: fmt.Sprintf("%s:%d", c.LocalIP, c.conf.SipPort),
|
Via: fmt.Sprintf("%s:%d", c.LocalIP, c.conf.GB28181.Port),
|
||||||
})
|
})
|
||||||
tx, err := c.sipCli.TransactionRequest(c.ctx, r)
|
tx, err := c.sipCli.TransactionRequest(c.ctx, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -5,27 +5,31 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/emiago/sipgo"
|
"github.com/emiago/sipgo"
|
||||||
"github.com/emiago/sipgo/sip"
|
|
||||||
"github.com/ossrs/go-oryx-lib/logger"
|
"github.com/ossrs/go-oryx-lib/logger"
|
||||||
"github.com/ossrs/srs-sip/pkg/config"
|
"github.com/ossrs/srs-sip/pkg/config"
|
||||||
"github.com/ossrs/srs-sip/pkg/signaling"
|
"github.com/ossrs/srs-sip/pkg/db"
|
||||||
|
"github.com/ossrs/srs-sip/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UAS struct {
|
type UAS struct {
|
||||||
*Cascade
|
*Cascade
|
||||||
|
|
||||||
SN uint32
|
SN uint32
|
||||||
channelsStatue sync.Map
|
Streams sync.Map
|
||||||
signal signaling.ISignaling
|
mediaLock sync.Mutex
|
||||||
|
media media.IMedia
|
||||||
|
recordQueryResults sync.Map // channelID -> chan []Record
|
||||||
|
|
||||||
sipConnUDP *net.UDPConn
|
sipConnUDP *net.UDPConn
|
||||||
sipConnTCP *net.TCPListener
|
sipConnTCP *net.TCPListener
|
||||||
}
|
}
|
||||||
|
|
||||||
var DM = GetDeviceManager()
|
var DM = GetDeviceManager()
|
||||||
|
var MediaDB, _ = db.GetInstance("./media_servers.db")
|
||||||
|
|
||||||
func NewUas() *UAS {
|
func NewUas() *UAS {
|
||||||
return &UAS{
|
return &UAS{
|
||||||
@ -35,12 +39,6 @@ func NewUas() *UAS {
|
|||||||
|
|
||||||
func (s *UAS) Start(agent *sipgo.UserAgent, r0 interface{}) error {
|
func (s *UAS) Start(agent *sipgo.UserAgent, r0 interface{}) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
conf := r0.(*config.MainConfig)
|
|
||||||
sig := &signaling.Srs{
|
|
||||||
Ctx: ctx,
|
|
||||||
Addr: "http://" + conf.MediaAddr,
|
|
||||||
}
|
|
||||||
s.signal = sig
|
|
||||||
s.startSipServer(agent, ctx, r0)
|
s.startSipServer(agent, ctx, r0)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -86,19 +84,23 @@ func (s *UAS) startSipServer(agent *sipgo.UserAgent, ctx context.Context, r0 int
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
candidate := os.Getenv("CANDIDATE")
|
||||||
|
if candidate != "" {
|
||||||
|
MediaDB.AddMediaServer("Default", "SRS", candidate, 1985, "", "", "", 1)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *UAS) startUDP() error {
|
func (s *UAS) startUDP() error {
|
||||||
lis, err := net.ListenUDP("udp", &net.UDPAddr{
|
lis, err := net.ListenUDP("udp", &net.UDPAddr{
|
||||||
IP: net.IPv4(0, 0, 0, 0),
|
IP: net.IPv4(0, 0, 0, 0),
|
||||||
Port: s.conf.SipPort,
|
Port: s.conf.GB28181.Port,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot listen on the UDP signaling port %d: %w", s.conf.SipPort, err)
|
return fmt.Errorf("cannot listen on the UDP signaling port %d: %w", s.conf.GB28181.Port, err)
|
||||||
}
|
}
|
||||||
s.sipConnUDP = lis
|
s.sipConnUDP = lis
|
||||||
logger.Tf(s.ctx, "sip signaling listening on UDP %s:%d", lis.LocalAddr().String(), s.conf.SipPort)
|
logger.Tf(s.ctx, "sip signaling listening on UDP %s:%d", lis.LocalAddr().String(), s.conf.GB28181.Port)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := s.sipSvr.ServeUDP(lis); err != nil {
|
if err := s.sipSvr.ServeUDP(lis); err != nil {
|
||||||
@ -111,13 +113,13 @@ func (s *UAS) startUDP() error {
|
|||||||
func (s *UAS) startTCP() error {
|
func (s *UAS) startTCP() error {
|
||||||
lis, err := net.ListenTCP("tcp", &net.TCPAddr{
|
lis, err := net.ListenTCP("tcp", &net.TCPAddr{
|
||||||
IP: net.IPv4(0, 0, 0, 0),
|
IP: net.IPv4(0, 0, 0, 0),
|
||||||
Port: s.conf.SipPort,
|
Port: s.conf.GB28181.Port,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot listen on the TCP signaling port %d: %w", s.conf.SipPort, err)
|
return fmt.Errorf("cannot listen on the TCP signaling port %d: %w", s.conf.GB28181.Port, err)
|
||||||
}
|
}
|
||||||
s.sipConnTCP = lis
|
s.sipConnTCP = lis
|
||||||
logger.Tf(s.ctx, "sip signaling listening on TCP %s:%d", lis.Addr().String(), s.conf.SipPort)
|
logger.Tf(s.ctx, "sip signaling listening on TCP %s:%d", lis.Addr().String(), s.conf.GB28181.Port)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if err := s.sipSvr.ServeTCP(lis); err != nil && !errors.Is(err, net.ErrClosed) {
|
if err := s.sipSvr.ServeTCP(lis); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||||
@ -127,10 +129,6 @@ func (s *UAS) startTCP() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sipErrorResponse(tx sip.ServerTransaction, req *sip.Request) {
|
|
||||||
_ = tx.Respond(sip.NewResponseFromRequest(req, 400, "", nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *UAS) getSN() uint32 {
|
func (s *UAS) getSN() uint32 {
|
||||||
s.SN++
|
s.SN++
|
||||||
return s.SN
|
return s.SN
|
||||||
|
|||||||
@ -1,44 +1,10 @@
|
|||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"flag"
|
|
||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/ossrs/srs-sip/pkg/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Parse(ctx context.Context) interface{} {
|
|
||||||
fl := flag.NewFlagSet(os.Args[0], flag.ContinueOnError)
|
|
||||||
|
|
||||||
var conf config.MainConfig
|
|
||||||
fl.StringVar(&conf.Serial, "serial", "34020000002000000001", "The serial number")
|
|
||||||
fl.StringVar(&conf.Realm, "realm", "3402000000", "The realm")
|
|
||||||
fl.StringVar(&conf.SipHost, "sip-host", "0.0.0.0", "The SIP host")
|
|
||||||
fl.IntVar(&conf.SipPort, "sip-port", 5060, "The SIP port")
|
|
||||||
fl.StringVar(&conf.MediaAddr, "media-addr", "127.0.0.1:1985", "The api address of media server. like: 127.0.0.1:1985")
|
|
||||||
fl.IntVar(&conf.HttpServerPort, "http-server-port", 8888, "The port of http server")
|
|
||||||
fl.IntVar(&conf.APIPort, "api-port", 2020, "The port of http api server")
|
|
||||||
|
|
||||||
fl.Usage = func() {
|
|
||||||
fl.PrintDefaults()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := fl.Parse(os.Args[1:]); err == flag.ErrHelp {
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
showHelp := conf.MediaAddr == ""
|
|
||||||
if showHelp {
|
|
||||||
fl.Usage()
|
|
||||||
os.Exit(-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &conf
|
|
||||||
}
|
|
||||||
|
|
||||||
func GenRandomNumber(n int) string {
|
func GenRandomNumber(n int) string {
|
||||||
var result string
|
var result string
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
@ -64,3 +30,17 @@ func IsVideoChannel(channelID string) bool {
|
|||||||
deviceType := channelID[10:13]
|
deviceType := channelID[10:13]
|
||||||
return deviceType == "131" || deviceType == "132"
|
return deviceType == "131" || deviceType == "132"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetSessionName 根据播放类型返回会话名称
|
||||||
|
func GetSessionName(playType int) string {
|
||||||
|
switch playType {
|
||||||
|
case 1:
|
||||||
|
return "Playback"
|
||||||
|
case 2:
|
||||||
|
return "Download"
|
||||||
|
case 3:
|
||||||
|
return "Talk"
|
||||||
|
default:
|
||||||
|
return "Play"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
9
web/html/css/bootstrap.min.css
vendored
9
web/html/css/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
Binary file not shown.
|
Before Width: | Height: | Size: 783 B |
@ -1,865 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>DEMO</title>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<link rel="stylesheet" type="text/css" href="css/bootstrap.min.css"/>
|
|
||||||
<style>
|
|
||||||
body{
|
|
||||||
padding-top: 30px;
|
|
||||||
}
|
|
||||||
#my_modal_footer {
|
|
||||||
margin-top: -20px;
|
|
||||||
padding-top: 3px;
|
|
||||||
}
|
|
||||||
#main_modal {
|
|
||||||
margin-top: -60px;
|
|
||||||
}
|
|
||||||
#rtc_player_modal {
|
|
||||||
margin-top: -60px;
|
|
||||||
}
|
|
||||||
.div_play_time {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
#pb_buffer_bg {
|
|
||||||
margin-top: -4px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<img src=' '/>
|
|
||||||
<div class="navbar navbar-fixed-top">
|
|
||||||
<div class="navbar-inner">
|
|
||||||
<div class="container">
|
|
||||||
<a id="srs_index" class="brand" href="#">DEMO</a>
|
|
||||||
<div class="nav-collapse collapse">
|
|
||||||
<ul class="nav">
|
|
||||||
<li class="active" ><a id="nav_gb28181" href="srs_gb28181.html">GB28181</a></li>
|
|
||||||
<li>
|
|
||||||
<a href="https://github.com/ossrs/srs">
|
|
||||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/ossrs/srs?style=social">
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="container">
|
|
||||||
<div name="detect_flash">
|
|
||||||
<div id="main_flash_alert" class="alert alert-danger fade in hide">
|
|
||||||
<button type="button" class="close" data-dismiss="alert">×</button>
|
|
||||||
<strong><p>Usage:</p></strong>
|
|
||||||
<p>
|
|
||||||
请点击下面的图标,启用Flash
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
若没有见到这个图标,Chrome浏览器请打开
|
|
||||||
<span class="text-info">chrome://settings/content/flash</span> 并修改为"Ask first"。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div id="main_flash_hdr" class="hide">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-inline">
|
|
||||||
API地址与端口
|
|
||||||
<input type="text" id="txt_api_url" class="input-xxlarge">
|
|
||||||
<p></p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="span4" id="divSipSessionList">
|
|
||||||
<span>设备列表</span><label id="lab_sip_session"></label>
|
|
||||||
<div style="overflow:scroll; height:330px; width:310px">
|
|
||||||
<ul id="channelList"></ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="span8">
|
|
||||||
<button class="btn btn-primary" id="btn_query_channel">获取设备</button>
|
|
||||||
<button class="btn btn-primary" id="btn_sip_bye">bye</button>
|
|
||||||
<button class="btn btn-primary" id="btn_sip_querycatalog" style="display:none;">querycatalog</button>
|
|
||||||
<div id="context2">
|
|
||||||
<div>
|
|
||||||
<pre id="video" style="overflow:scroll; height:280px"></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p></p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="span4" id="divMediaChannelList">
|
|
||||||
</div>
|
|
||||||
<div class="span8">
|
|
||||||
<div id="context2">
|
|
||||||
URL:<a id="gb28181ChannelId"></a>
|
|
||||||
<div>
|
|
||||||
<textarea class="span6" id="txt_rtc_url" rows="2"></textarea>
|
|
||||||
<button class="btn btn-primary" id="btn_rtc_play">RTC播放</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div>Message</div>
|
|
||||||
<pre id="apiMessage" style="overflow:scroll; height:210px"></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="main_content" class="hide">
|
|
||||||
<div id="main_modal" class="modal hide fade">
|
|
||||||
<div class="modal-header">
|
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
|
||||||
<h3><a href="https://github.com/ossrs/srs">SrsFlvPlayer</a></h3>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div>
|
|
||||||
<video id="video_player" width="98%" autoplay controls></video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer" id="my_modal_footer">
|
|
||||||
<div>
|
|
||||||
<div class="btn-group dropup">
|
|
||||||
<button class="btn btn-primary" id="btn_ptz_up"> 上↑ </button>
|
|
||||||
<button class="btn btn-primary" id="btn_ptz_down"> 下↓ </button>
|
|
||||||
<button class="btn btn-primary" id="btn_ptz_left"> ←左 </button>
|
|
||||||
<button class="btn btn-primary" id="btn_ptz_right"> 右→ </button>
|
|
||||||
<button class="btn btn-primary" id="btn_ptz_zoomin"> 放大+ </button>
|
|
||||||
<button class="btn btn-primary" id="btn_ptz_zoomout"> 缩小- </button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="rtc_player_modal" class="modal hide fade">
|
|
||||||
<div class="modal-header">
|
|
||||||
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button>
|
|
||||||
<h3><a href="https://github.com/ossrs/srs">RtcPlayer</a></h3>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<video id="rtc_media_player" width="100%" controls autoplay ></video>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer" id="my_modal_footer">
|
|
||||||
<div>
|
|
||||||
<div class="btn-group dropup">
|
|
||||||
<button class="btn btn-primary" id="btn_ptz_up_rtc"> 上↑ </button>
|
|
||||||
<button class="btn btn-primary" id="btn_ptz_down_rtc"> 下↓ </button>
|
|
||||||
<button class="btn btn-primary" id="btn_ptz_left_rtc"> ←左 </button>
|
|
||||||
<button class="btn btn-primary" id="btn_ptz_right_rtc"> 右→ </button>
|
|
||||||
<button class="btn btn-primary" id="btn_ptz_zoomin_rtc"> 放大+ </button>
|
|
||||||
<button class="btn btn-primary" id="btn_ptz_zoomout_rtc"> 缩小- </button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<p></p>
|
|
||||||
<p><a href="https://github.com/ossrs/srs">SRS Team © 2013</a></p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
<script type="text/javascript" src="js/jquery-1.12.2.min.js"></script>
|
|
||||||
<script type="text/javascript" src="js/bootstrap.min.js"></script>
|
|
||||||
<script type="text/javascript" src="js/json2.js"></script>
|
|
||||||
<script type="text/javascript" src="js/srs.page.js"></script>
|
|
||||||
<script type="text/javascript" src="js/srs.log.js"></script>
|
|
||||||
<script type="text/javascript" src="js/srs.utility.js"></script>
|
|
||||||
<script type="text/javascript" src="js/winlin.utility.js"></script>
|
|
||||||
<script type="text/javascript">
|
|
||||||
$(function(){
|
|
||||||
$('#main_content').show();
|
|
||||||
autoLoadPage();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<script type="text/javascript">
|
|
||||||
var srs_player = null;
|
|
||||||
var url = null;
|
|
||||||
|
|
||||||
var query = parse_query_string();
|
|
||||||
var query_host = query.host.split(':');
|
|
||||||
if (query_host && query_host.length == 2) {
|
|
||||||
$("#txt_api_url").val("http://" + query_host[0] + ":2020");
|
|
||||||
} else {
|
|
||||||
$("#txt_api_url").val("http://" + query.host + ":2020");
|
|
||||||
}
|
|
||||||
var __active_dar = null;
|
|
||||||
function select_dar(dar_id, num, den) {
|
|
||||||
srs_player.set_dar(num, den);
|
|
||||||
|
|
||||||
if (__active_dar) {
|
|
||||||
__active_dar.removeClass("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
__active_dar = $(dar_id).parent();
|
|
||||||
__active_dar.addClass("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
var __active_size = null;
|
|
||||||
function select_fs_size(size_id, refer, percent) {
|
|
||||||
srs_player.set_fs(refer, percent);
|
|
||||||
|
|
||||||
if (__active_size) {
|
|
||||||
__active_size.removeClass("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
__active_size = $(size_id).parent();
|
|
||||||
__active_size.addClass("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
function select_buffer(buffer_time) {
|
|
||||||
var bt = buffer_time;
|
|
||||||
var bt_id = "#btn_bt_" + bt.toFixed(1).replace(".", "_");
|
|
||||||
select_buffer_time(bt_id, bt);
|
|
||||||
}
|
|
||||||
function select_max_buffer(max_buffer_time) {
|
|
||||||
var mbt = max_buffer_time;
|
|
||||||
var mbt_id = "#btn_mbt_" + mbt.toFixed(1).replace(".", "_");
|
|
||||||
select_max_buffer_time(mbt_id, mbt);
|
|
||||||
}
|
|
||||||
|
|
||||||
var __active_bt = null;
|
|
||||||
function select_buffer_time(bt_id, buffer_time) {
|
|
||||||
srs_player.set_bt(buffer_time);
|
|
||||||
|
|
||||||
if (__active_bt) {
|
|
||||||
__active_bt.removeClass("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
__active_bt = $(bt_id).parent();
|
|
||||||
__active_bt.addClass("active");
|
|
||||||
|
|
||||||
select_max_buffer(srs_player.max_buffer_time);
|
|
||||||
}
|
|
||||||
|
|
||||||
var __active_mbt = null;
|
|
||||||
function select_max_buffer_time(mbt_id, max_buffer_time) {
|
|
||||||
srs_player.set_mbt(max_buffer_time);
|
|
||||||
|
|
||||||
if (__active_mbt) {
|
|
||||||
__active_mbt.removeClass("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
__active_mbt = $(mbt_id).parent();
|
|
||||||
__active_mbt.addClass("active");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//格式化json显示
|
|
||||||
function syntaxHighlight(json) {
|
|
||||||
if (typeof json != 'string') {
|
|
||||||
json = JSON.stringify(json, undefined, 2);
|
|
||||||
}
|
|
||||||
json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function(match) {
|
|
||||||
var cls = 'number';
|
|
||||||
if (/^"/.test(match)) {
|
|
||||||
if (/:$/.test(match)) {
|
|
||||||
cls = 'key';
|
|
||||||
} else {
|
|
||||||
cls = 'string';
|
|
||||||
}
|
|
||||||
} else if (/true|false/.test(match)) {
|
|
||||||
cls = 'boolean';
|
|
||||||
} else if (/null/.test(match)) {
|
|
||||||
cls = 'null';
|
|
||||||
}
|
|
||||||
return '<span class="' + cls + '">' + match + '</span>';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function http_get(url){
|
|
||||||
var retdata = null;
|
|
||||||
console.log("GET", url);
|
|
||||||
$.ajax({
|
|
||||||
type : "GET",
|
|
||||||
async : false,
|
|
||||||
url : url,
|
|
||||||
contentType: "text/html",
|
|
||||||
data : "",
|
|
||||||
complete : function() {
|
|
||||||
},
|
|
||||||
error : function(ret) {
|
|
||||||
alert("GET 请求失败:" + url);
|
|
||||||
},
|
|
||||||
success : function(ret) {
|
|
||||||
console.log(ret);
|
|
||||||
retdata = ret;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return retdata;
|
|
||||||
}
|
|
||||||
|
|
||||||
function http_post(url, data){
|
|
||||||
var retdata = null;
|
|
||||||
console.log("POST", url);
|
|
||||||
$.ajax({
|
|
||||||
type : "POST",
|
|
||||||
async : false,
|
|
||||||
url : url,
|
|
||||||
contentType: "application/json",
|
|
||||||
data : JSON.stringify(data),
|
|
||||||
complete : function() {
|
|
||||||
},
|
|
||||||
error : function(ret) {
|
|
||||||
alert("POST 请求失败:" + url);
|
|
||||||
},
|
|
||||||
success : function(ret) {
|
|
||||||
console.log(ret);
|
|
||||||
retdata = ret;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return retdata;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getParentIdById(id) {
|
|
||||||
for (let parentId in devices) {
|
|
||||||
let parentDevices = devices[parentId];
|
|
||||||
for (let idx in parentDevices) {
|
|
||||||
if (parentDevices[idx].id == id) {
|
|
||||||
return parentId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// request: {"device_id": "1", "channel_id": "1", "sub_stream": 0}
|
|
||||||
// response: {"code": 0, "data": {"channel_id": "1", "url": "webrtc://"}}
|
|
||||||
function channelOnClick(chidObj){
|
|
||||||
var chId = chidObj.text;
|
|
||||||
var parentId = getParentIdById(chId);
|
|
||||||
|
|
||||||
var body = {
|
|
||||||
"device_id": parentId,
|
|
||||||
"channel_id": chId,
|
|
||||||
"sub_stream": "0"
|
|
||||||
};
|
|
||||||
url = $("#txt_api_url").val();
|
|
||||||
var apiurl = url + "/srs-sip/v1/invite";
|
|
||||||
|
|
||||||
var ret = http_post(apiurl, body);
|
|
||||||
$('#sipSessionMessage').html(syntaxHighlight(ret));
|
|
||||||
|
|
||||||
if (ret == undefined || ret.code != 0) {
|
|
||||||
alert("invite请求失败");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
play(ret.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
function play(data) {
|
|
||||||
$("#txt_rtc_url").val(data.url);
|
|
||||||
$("#btn_rtc_play").click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshGb28181ChList(data) {
|
|
||||||
$("#channelList").empty();
|
|
||||||
devices = {};
|
|
||||||
|
|
||||||
// 遍历数据,将设备信息按照parent_id进行分组
|
|
||||||
for (let idx in data) {
|
|
||||||
var device = data[idx];
|
|
||||||
var parent = device.parent_id;
|
|
||||||
var id = device.device_id;
|
|
||||||
|
|
||||||
// 如果该parent_id不存在在devices对象中,则创建一个新数组
|
|
||||||
if (!devices[parent]) {
|
|
||||||
devices[parent] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将设备信息添加到对应的parent_id数组中
|
|
||||||
devices[parent].push({ id: id, ip: device.ip_address, name: device.name});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 遍历devices对象,生成设备树
|
|
||||||
for (let parent in devices) {
|
|
||||||
var parentDevices = devices[parent];
|
|
||||||
var parentLi = "<li>" + parent + "<ul>";
|
|
||||||
|
|
||||||
// 遍历该parent_id下的设备信息
|
|
||||||
parentDevices.forEach(function(device) {
|
|
||||||
var id = device.id;
|
|
||||||
var title = device.name + "(" + device.ip + ")";
|
|
||||||
var childLi = "<li><a id='linkChannelId" + id + "' href='javascript:void(0)' title='" + title + "' onclick='channelOnClick(this)'>" + id + "</a></li>";
|
|
||||||
parentLi += childLi;
|
|
||||||
});
|
|
||||||
|
|
||||||
parentLi += "</ul></li>";
|
|
||||||
|
|
||||||
// 将生成的设备树添加到页面中
|
|
||||||
$("#channelList").append(parentLi);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Async-await-promise based SRS RTC Player.
|
|
||||||
function SrsRtcPlayerAsync() {
|
|
||||||
var self = {};
|
|
||||||
|
|
||||||
// @see https://github.com/rtcdn/rtcdn-draft
|
|
||||||
// @url The WebRTC url to play with, for example:
|
|
||||||
// webrtc://r.ossrs.net/live/livestream
|
|
||||||
// or specifies the API port:
|
|
||||||
// webrtc://r.ossrs.net:11985/live/livestream
|
|
||||||
// or autostart the play:
|
|
||||||
// webrtc://r.ossrs.net/live/livestream?autostart=true
|
|
||||||
// or change the app from live to myapp:
|
|
||||||
// webrtc://r.ossrs.net:11985/myapp/livestream
|
|
||||||
// or change the stream from livestream to mystream:
|
|
||||||
// webrtc://r.ossrs.net:11985/live/mystream
|
|
||||||
// or set the api server to myapi.domain.com:
|
|
||||||
// webrtc://myapi.domain.com/live/livestream
|
|
||||||
// or set the candidate(ip) of answer:
|
|
||||||
// webrtc://r.ossrs.net/live/livestream?eip=39.107.238.185
|
|
||||||
// or force to access https API:
|
|
||||||
// webrtc://r.ossrs.net/live/livestream?schema=https
|
|
||||||
// or use plaintext, without SRTP:
|
|
||||||
// webrtc://r.ossrs.net/live/livestream?encrypt=false
|
|
||||||
// or any other information, will pass-by in the query:
|
|
||||||
// webrtc://r.ossrs.net/live/livestream?vhost=xxx
|
|
||||||
// webrtc://r.ossrs.net/live/livestream?token=xxx
|
|
||||||
self.play = async function(url) {
|
|
||||||
var conf = self.__internal.prepareUrl(url);
|
|
||||||
self.pc.addTransceiver("audio", {direction: "recvonly"});
|
|
||||||
self.pc.addTransceiver("video", {direction: "recvonly"});
|
|
||||||
|
|
||||||
var offer = await self.pc.createOffer();
|
|
||||||
await self.pc.setLocalDescription(offer);
|
|
||||||
var session = await new Promise(function(resolve, reject) {
|
|
||||||
// @see https://github.com/rtcdn/rtcdn-draft
|
|
||||||
var data = {
|
|
||||||
api: conf.apiUrl, streamurl: conf.streamUrl, clientip: null, sdp: offer.sdp
|
|
||||||
};
|
|
||||||
console.log("Generated offer: ", data);
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
type: "POST", url: conf.apiUrl, data: JSON.stringify(data),
|
|
||||||
contentType:'application/json', dataType: 'json'
|
|
||||||
}).done(function(data) {
|
|
||||||
console.log("Got answer: ", data);
|
|
||||||
if (data.code) {
|
|
||||||
reject(data); return;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(data);
|
|
||||||
}).fail(function(reason){
|
|
||||||
reject(reason);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await self.pc.setRemoteDescription(
|
|
||||||
new RTCSessionDescription({type: 'answer', sdp: session.sdp})
|
|
||||||
);
|
|
||||||
return session;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Close the publisher.
|
|
||||||
self.close = function() {
|
|
||||||
self.pc.close();
|
|
||||||
};
|
|
||||||
|
|
||||||
// The callback when got remote stream.
|
|
||||||
self.onaddstream = function (event) {};
|
|
||||||
|
|
||||||
// Internal APIs.
|
|
||||||
self.__internal = {
|
|
||||||
defaultPath: '/rtc/v1/play/',
|
|
||||||
prepareUrl: function (webrtcUrl) {
|
|
||||||
var urlObject = self.__internal.parse(webrtcUrl);
|
|
||||||
|
|
||||||
// If user specifies the schema, use it as API schema.
|
|
||||||
var schema = urlObject.user_query.schema;
|
|
||||||
schema = schema ? schema + ':' : window.location.protocol;
|
|
||||||
|
|
||||||
var port = urlObject.port || 1985;
|
|
||||||
if (schema === 'https:') {
|
|
||||||
port = urlObject.port || 443;
|
|
||||||
}
|
|
||||||
|
|
||||||
// @see https://github.com/rtcdn/rtcdn-draft
|
|
||||||
var api = urlObject.user_query.play || self.__internal.defaultPath;
|
|
||||||
if (api.lastIndexOf('/') !== api.length - 1) {
|
|
||||||
api += '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
apiUrl = schema + '//' + urlObject.server + ':' + port + api;
|
|
||||||
for (var key in urlObject.user_query) {
|
|
||||||
if (key !== 'api' && key !== 'play') {
|
|
||||||
apiUrl += '&' + key + '=' + urlObject.user_query[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Replace /rtc/v1/play/&k=v to /rtc/v1/play/?k=v
|
|
||||||
var apiUrl = apiUrl.replace(api + '&', api + '?');
|
|
||||||
|
|
||||||
var streamUrl = urlObject.url;
|
|
||||||
|
|
||||||
return {apiUrl: apiUrl, streamUrl: streamUrl, schema: schema, urlObject: urlObject, port: port};
|
|
||||||
},
|
|
||||||
parse: function (url) {
|
|
||||||
// @see: http://stackoverflow.com/questions/10469575/how-to-use-location-object-to-parse-url-without-redirecting-the-page-in-javascri
|
|
||||||
var a = document.createElement("a");
|
|
||||||
a.href = url.replace("rtmp://", "http://")
|
|
||||||
.replace("webrtc://", "http://")
|
|
||||||
.replace("rtc://", "http://");
|
|
||||||
|
|
||||||
var vhost = a.hostname;
|
|
||||||
var app = a.pathname.substring(1, a.pathname.lastIndexOf("/"));
|
|
||||||
var stream = a.pathname.slice(a.pathname.lastIndexOf("/") + 1);
|
|
||||||
|
|
||||||
// parse the vhost in the params of app, that srs supports.
|
|
||||||
app = app.replace("...vhost...", "?vhost=");
|
|
||||||
if (app.indexOf("?") >= 0) {
|
|
||||||
var 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("&"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// when vhost equals to server, and server is ip,
|
|
||||||
// the vhost is __defaultVhost__
|
|
||||||
if (a.hostname === vhost) {
|
|
||||||
var re = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/;
|
|
||||||
if (re.test(a.hostname)) {
|
|
||||||
vhost = "__defaultVhost__";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse the schema
|
|
||||||
var schema = "rtmp";
|
|
||||||
if (url.indexOf("://") > 0) {
|
|
||||||
schema = url.slice(0, url.indexOf("://"));
|
|
||||||
}
|
|
||||||
|
|
||||||
var port = a.port;
|
|
||||||
if (!port) {
|
|
||||||
if (schema === 'http') {
|
|
||||||
port = 80;
|
|
||||||
} else if (schema === 'https') {
|
|
||||||
port = 443;
|
|
||||||
} else if (schema === 'rtmp') {
|
|
||||||
port = 1935;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var ret = {
|
|
||||||
url: url,
|
|
||||||
schema: schema,
|
|
||||||
server: a.hostname, port: port,
|
|
||||||
vhost: vhost, app: app, stream: stream
|
|
||||||
};
|
|
||||||
self.__internal.fill_query(a.search, ret);
|
|
||||||
|
|
||||||
// For webrtc API, we use 443 if page is https, or schema specified it.
|
|
||||||
if (!ret.port) {
|
|
||||||
if (schema === 'webrtc' || schema === 'rtc') {
|
|
||||||
if (ret.user_query.schema === 'https') {
|
|
||||||
ret.port = 443;
|
|
||||||
} else if (window.location.href.indexOf('https://') === 0) {
|
|
||||||
ret.port = 443;
|
|
||||||
} else {
|
|
||||||
// For WebRTC, SRS use 1985 as default API port.
|
|
||||||
ret.port = 1985;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
fill_query: function (query_string, obj) {
|
|
||||||
// pure user query object.
|
|
||||||
obj.user_query = {};
|
|
||||||
|
|
||||||
if (query_string.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// split again for angularjs.
|
|
||||||
if (query_string.indexOf("?") >= 0) {
|
|
||||||
query_string = query_string.split("?")[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
var queries = query_string.split("&");
|
|
||||||
for (var i = 0; i < queries.length; i++) {
|
|
||||||
var elem = queries[i];
|
|
||||||
|
|
||||||
var query = elem.split("=");
|
|
||||||
obj[query[0]] = query[1];
|
|
||||||
obj.user_query[query[0]] = query[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// alias domain for vhost.
|
|
||||||
if (obj.domain) {
|
|
||||||
obj.vhost = obj.domain;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
self.pc = new RTCPeerConnection(null);
|
|
||||||
self.pc.onaddstream = function (event) {
|
|
||||||
if (self.onaddstream) {
|
|
||||||
self.onaddstream(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return self;
|
|
||||||
}
|
|
||||||
|
|
||||||
var flvPlayer = null;
|
|
||||||
var hlsPlayer = null;
|
|
||||||
var devices = {};
|
|
||||||
|
|
||||||
var stopPlayers = function () {
|
|
||||||
if (flvPlayer) {
|
|
||||||
flvPlayer.destroy();
|
|
||||||
flvPlayer = null;
|
|
||||||
}
|
|
||||||
if (hlsPlayer) {
|
|
||||||
hlsPlayer.destroy();
|
|
||||||
hlsPlayer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var hide_for_error = function () {
|
|
||||||
$('#main_flash_alert').show();
|
|
||||||
$('#main_info').hide();
|
|
||||||
$('#main_tips').hide();
|
|
||||||
$('#video_player').hide();
|
|
||||||
//$('#btn_play').hide();
|
|
||||||
|
|
||||||
stopPlayers();
|
|
||||||
};
|
|
||||||
|
|
||||||
var show_for_ok = function () {
|
|
||||||
$('#main_flash_alert').hide();
|
|
||||||
$('#main_info').show();
|
|
||||||
$('#main_tips').show();
|
|
||||||
$('#video_player').show();
|
|
||||||
//$('#btn_play').show();
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/****
|
|
||||||
* The parameters for this page:
|
|
||||||
* schema, the protocol schema, rtmp or http.
|
|
||||||
* server, the ip of the url.
|
|
||||||
* port, the rtmp port of url.
|
|
||||||
* vhost, the vhost of url, can equals to server.
|
|
||||||
* app, the app of url.
|
|
||||||
* stream, the stream of url, can endwith .flv or .mp4 or nothing for RTMP.
|
|
||||||
* autostart, whether auto play the stream.
|
|
||||||
* buffer, the buffer time in seconds.
|
|
||||||
* extra params:
|
|
||||||
* shp_identify, hls+ param.
|
|
||||||
* for example:
|
|
||||||
* http://localhost:8088/players/srs_player.html?vhost=ossrs.net&app=live&stream=livestream&server=ossrs.net&port=1935&autostart=true&schema=rtmp
|
|
||||||
* http://localhost:8088/players/srs_player.html?vhost=ossrs.net&app=live&stream=livestream.flv&server=ossrs.net&port=8080&autostart=true&schema=http
|
|
||||||
*/
|
|
||||||
var autoLoadPage = function() {
|
|
||||||
var query = parse_query_string();
|
|
||||||
|
|
||||||
// get the vhost and port to set the default url.
|
|
||||||
// url set to: http://localhost:8080/live/livestream.flv
|
|
||||||
srs_init_flv("#txt_url", "#main_modal");
|
|
||||||
srs_init_flv("#txt_url", "#rtc_player_modal");
|
|
||||||
|
|
||||||
// consts for buffer and max buffer.
|
|
||||||
var bts = [0.1, 0.2, 0.3, 0.5, 0.8, 1, 2, 3, 4, 5, 6, 8, 10, 15, 20, 30];
|
|
||||||
var mbts = [0.6, 0.9, 1.2, 1.5, 2.4, 3, 6, 9, 12, 15, 18, 24, 30, 45, 60, 90];
|
|
||||||
|
|
||||||
// the play startup time.
|
|
||||||
var pst = new Date();
|
|
||||||
|
|
||||||
$("#main_modal").on("show", function(){
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#main_modal").on("hide", function(){
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
if (true) {
|
|
||||||
for (var bt of bts) {
|
|
||||||
var bt_id = "#btn_bt_" + bt.toFixed(1).replace(".", "_");
|
|
||||||
|
|
||||||
var bt_fun = function(id, v){
|
|
||||||
$(bt_id).click(function(){
|
|
||||||
select_buffer_time(id, v);
|
|
||||||
|
|
||||||
// remember the chagned buffer.
|
|
||||||
if (Number(query.buffer) != srs_player.buffer_time) {
|
|
||||||
query.buffer = srs_player.buffer_time;
|
|
||||||
apply_url_change();
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
};
|
|
||||||
bt_fun(bt_id, bt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (true) {
|
|
||||||
for (var mbt of mbts) {
|
|
||||||
var mbt_id = "#btn_mbt_" + mbt.toFixed(1).replace(".", "_");
|
|
||||||
|
|
||||||
var mbt_fun = function(id, v){
|
|
||||||
$(mbt_id).click(function(){
|
|
||||||
select_max_buffer_time(id, v);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
mbt_fun(mbt_id, mbt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (true){
|
|
||||||
var time_query = function(){
|
|
||||||
$("#btn_query_channel").click();
|
|
||||||
setTimeout(function () {$("#btn_query_channel").click()}, 1000);
|
|
||||||
};
|
|
||||||
|
|
||||||
$("#btn_sip_bye").click(function(){
|
|
||||||
var text = $("#sipSessionId").text();
|
|
||||||
if (text.indexOf("-->") != -1) {
|
|
||||||
var str = text.split("-->");
|
|
||||||
id = str[0];
|
|
||||||
var str2 = str[1].split(":")
|
|
||||||
chid = str2[1];
|
|
||||||
|
|
||||||
url = $("#txt_api_url").val();
|
|
||||||
var apiurl = url + "/srs-sip/v1/gb28181?action=sip_bye&id=" + id + "&chid="+chid;
|
|
||||||
var ret = http_get(apiurl);
|
|
||||||
$('#sipSessionMessage').html(syntaxHighlight(ret));
|
|
||||||
|
|
||||||
if (ret != undefined && ret.code == 0){
|
|
||||||
time_query();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#btn_sip_querycatalog").click(function(){
|
|
||||||
var text = $("#sipSessionId").text();
|
|
||||||
if (text.indexOf("-->") != -1) {
|
|
||||||
var str = text.split("-->");
|
|
||||||
id = str[0];
|
|
||||||
var str2 = str[1].split(":")
|
|
||||||
chid = str2[0];
|
|
||||||
|
|
||||||
url = $("#txt_api_url").val();
|
|
||||||
var apiurl = url + "/srs-sip/v1/gb28181?action=sip_query_catalog&id=" + id;
|
|
||||||
var ret = http_get(apiurl);
|
|
||||||
$('#sipSessionMessage').html(syntaxHighlight(ret));
|
|
||||||
if (ret != undefined && ret.code == 0){
|
|
||||||
time_query();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#btn_query_channel").click(function(){
|
|
||||||
url = $("#txt_api_url").val();
|
|
||||||
var apiurl = url + "/srs-sip/v1/channels/"
|
|
||||||
var ret = http_get(apiurl);
|
|
||||||
$('#apiMessage').html(syntaxHighlight(ret));
|
|
||||||
|
|
||||||
if (ret != undefined && ret.code == 0){
|
|
||||||
refreshGb28181ChList(ret.data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var call_ptz_cmd = function(cmd) {
|
|
||||||
var str = $("#gb28181ChannelId").text();
|
|
||||||
var str_array = str.split("@")
|
|
||||||
var chid = "";
|
|
||||||
var id = "";
|
|
||||||
if (str_array.length < 1){
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var speed = "136";
|
|
||||||
|
|
||||||
id = str_array[0];
|
|
||||||
chid = str_array[1];
|
|
||||||
|
|
||||||
url = $("#txt_api_url").val();
|
|
||||||
var apiurl = url + "/srs-sip/v1/gb28181?action=sip_ptz&id=" + id + "&chid="+chid+ "&ptzcmd="+cmd + "&speed=" + speed;
|
|
||||||
var ret = http_get(apiurl);
|
|
||||||
$('#apiMessage').html(syntaxHighlight(ret));
|
|
||||||
};
|
|
||||||
|
|
||||||
var ptz_cmd = ["up", "down", "right", "left", "zoomin", "zoomout"]
|
|
||||||
for (var i=0; i<ptz_cmd.length; i++){
|
|
||||||
var bt_fun = function(id, cmd){
|
|
||||||
$(bt_id).mousedown(function(){
|
|
||||||
call_ptz_cmd(cmd);
|
|
||||||
});
|
|
||||||
$(bt_id).mouseup(function(){
|
|
||||||
call_ptz_cmd("stop");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
var bt_id = "#btn_ptz_"+ptz_cmd[i]+ "_rtc";
|
|
||||||
bt_fun(bt_id, ptz_cmd[i]);
|
|
||||||
|
|
||||||
bt_id = "#btn_ptz_"+ptz_cmd[i];
|
|
||||||
bt_fun(bt_id, ptz_cmd[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
var sdk = null; // Global handler to do cleanup when replaying.
|
|
||||||
var startPlay = function() {
|
|
||||||
$('#rtc_media_player').show();
|
|
||||||
|
|
||||||
// Close PC when user replay.
|
|
||||||
if (sdk) {
|
|
||||||
sdk.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
sdk = new SrsRtcPlayerAsync();
|
|
||||||
sdk.onaddstream = function (event) {
|
|
||||||
console.log('Start play, event: ', event);
|
|
||||||
$('#rtc_media_player').prop('srcObject', event.stream);
|
|
||||||
};
|
|
||||||
|
|
||||||
// For example:
|
|
||||||
// webrtc://r.ossrs.net/live/livestream
|
|
||||||
var url = $("#txt_rtc_url").val();
|
|
||||||
sdk.play(url).then(function(session){
|
|
||||||
$('#sessionid').html(session.sessionid);
|
|
||||||
$('#simulator-drop').attr('href', session.simulator + '?drop=1&username=' + session.sessionid);
|
|
||||||
}).catch(function (reason) {
|
|
||||||
sdk.close();
|
|
||||||
$('#rtc_media_player').hide();
|
|
||||||
console.error(reason);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
$("#btn_rtc_play").click(function(){
|
|
||||||
$('#rtc_media_player').width(srs_get_player_width);
|
|
||||||
$('#rtc_media_player').height(srs_get_player_height);
|
|
||||||
$("#rtc_player_modal").modal({show: true, keyboard: false});
|
|
||||||
|
|
||||||
startPlay();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
$("#rtc_player_modal").on("hide", function(){
|
|
||||||
if (sdk) {
|
|
||||||
sdk.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
1
web/html/js/adapter-7.4.0.min.js
vendored
1
web/html/js/adapter-7.4.0.min.js
vendored
File diff suppressed because one or more lines are too long
6
web/html/js/bootstrap.min.js
vendored
6
web/html/js/bootstrap.min.js
vendored
File diff suppressed because one or more lines are too long
19
web/html/js/dash-v4.5.1.all.min.js
vendored
19
web/html/js/dash-v4.5.1.all.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
web/html/js/hls-1.4.14.min.js
vendored
2
web/html/js/hls-1.4.14.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user