feat: 完善智慧停车综合管理平台源码
@@ -0,0 +1,17 @@
|
|||||||
|
# 应用访问地址:http://localhost:4010
|
||||||
|
APP_PORT=4010
|
||||||
|
RTMP_PORT=4015
|
||||||
|
MYSQL_PORT=4012
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
MYSQL_DATABASE=smart_parking
|
||||||
|
MYSQL_USER=parking
|
||||||
|
MYSQL_PASSWORD=parking123
|
||||||
|
MYSQL_ROOT_PASSWORD=root123
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=smart-parking-secret
|
||||||
|
|
||||||
|
# AI 算法端(仅在启用 docker compose --profile ai 时使用)
|
||||||
|
AI_ADMIN_USERNAME=admin
|
||||||
|
AI_ADMIN_PASSWORD=admin123
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
name: Lint Test
|
||||||
|
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '18.x'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Lint-frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: |
|
||||||
|
npm i -g pnpm
|
||||||
|
cd frontend
|
||||||
|
pnpm install
|
||||||
|
pnpm run lint
|
||||||
|
|
||||||
|
Lint-backend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: |
|
||||||
|
npm i -g pnpm
|
||||||
|
cd backend
|
||||||
|
pnpm install
|
||||||
|
pnpm run lint
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# 本地私有工作目录
|
||||||
|
.???ex/
|
||||||
|
|
||||||
|
# Node / package managers
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 He Rui
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
# 智慧停车综合管理平台(Smart Parking Platform)
|
||||||
|
|
||||||
|
> **一句话简介**:一套集园区态势感知、视频监控大屏、异常智能报警、停车运营管理与 AI 智能助手于一体的、可私有化 Docker 一键部署的智慧停车综合管理平台。
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/screenshots/login.png" width="720" alt="平台登录页"/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [一、详细功能描述](#一详细功能描述)
|
||||||
|
- [二、解决的行业痛点](#二解决的行业痛点)
|
||||||
|
- [三、核心技术优势](#三核心技术优势)
|
||||||
|
- [四、应用场景](#四应用场景)
|
||||||
|
- [五、适配目标行业与人群](#五适配目标行业与人群)
|
||||||
|
- [六、商业应用价值](#六商业应用价值)
|
||||||
|
- [七、技术架构](#七技术架构)
|
||||||
|
- [八、环境前置依赖](#八环境前置依赖)
|
||||||
|
- [九、快速开始](#九快速开始)
|
||||||
|
- [十、完整安装部署教程](#十完整安装部署教程)
|
||||||
|
- [十一、AI 对话(TokenFree)使用教程](#十一ai-对话tokenfree使用教程)
|
||||||
|
- [十二、版权与使用说明](#十二版权与使用说明)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、详细功能描述
|
||||||
|
|
||||||
|
平台围绕“一张屏、一张图、一个大脑”设计,功能逐条对应实拍截图:
|
||||||
|
|
||||||
|
### 1. 平台登录与权限体系
|
||||||
|
|
||||||
|
- 玻璃拟态登录页、动态渐变背景、发光输入框,支持账号密码登录与 JWT 鉴权。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 1.1 项目概述大屏
|
||||||
|
|
||||||
|
平台内置炫酷的**项目概述大屏**:顶部动态标题与实时时钟,五大核心指标卡片(摄像头总数、设备在线率、
|
||||||
|
报警事件、报警规则、系统用户),近 7 天报警趋势折线图、设备状态环形分布图及最新报警实时滚动,
|
||||||
|
全部数据来自后端实时聚合,10 秒自动刷新,适配大屏投放。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 2. 园区态势总览
|
||||||
|
|
||||||
|
- 园区停车核心指标看板:总车位、占用/空闲、异常事件、设备在线率;
|
||||||
|
- 基于本地地图的摄像头点位分布与状态渲染,支持点位点击联动。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 3. 视频监控大屏
|
||||||
|
|
||||||
|
- 科技感数据大屏:多路摄像头实时画面、轮播、实时指标与报警滚动;
|
||||||
|
- 支持 HLS(hls.js)低延迟直播,适配主流分辨率。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 4. 异常报警事件管理
|
||||||
|
|
||||||
|
- 报警事件列表、级别筛选、处置状态流转,事件全程可追溯。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 5. AI 智能对话助手
|
||||||
|
|
||||||
|
- 标准 Chat Completions 流式对话,默认接入 TokenFree 的 `kimi-k2.6` 模型;
|
||||||
|
- 支持停车运营、设备状态、异常分析等问答,接口配置抽屉内置使用教程。
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
### 6. 个人中心
|
||||||
|
|
||||||
|
- 用户信息维护、密码修改等自助功能。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 7. 系统管理(管理员)
|
||||||
|
|
||||||
|
- **园区地图管理**:自定义地图数据上传、切分与点位编辑。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **摄像头管理**:摄像头档案、RTSP/RTMP 拉流地址、点位与启用状态维护。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **报警规则管理**:按摄像头配置检测目标、触发条件与规则启停。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **用户管理**:账号、角色(管理员/操作员)与状态管理。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、解决的行业痛点
|
||||||
|
|
||||||
|
1. **系统割裂、信息孤岛**:传统监控、停车、报警系统各自独立,缺乏统一态势视图,运维人员需多平台切换。
|
||||||
|
2. **依赖人工值守、响应滞后**:传统监控需人工 7×24 盯屏,异常发现晚、研判慢、上报链路长。
|
||||||
|
3. **摄像头位置不可视**:不存储园区 GIS 地图与摄像头精确位置,调取和排查效率低。
|
||||||
|
4. **部署复杂、落地成本高**:传统方案环境依赖多、部署周期长,中小企业难以私有化落地。
|
||||||
|
5. **数据不沉淀、运营无依据**:缺乏停车与设备数据沉淀,管理决策缺少数据支撑。
|
||||||
|
|
||||||
|
平台以“统一面板 + 智能检测 + 一键私有化部署”系统性解决上述问题。
|
||||||
|
|
||||||
|
## 三、核心技术优势
|
||||||
|
|
||||||
|
- **全栈现代技术架构**:Umi 4 + React 18 + Ant Design 5 前端,NestJS + TypeScript 后端,模块清晰、可维护性强。
|
||||||
|
- **智能视频分析**:基于 YOLO 深度学习的实时目标检测(监控算法端),异常自动识别并联动报警。
|
||||||
|
- **低延迟流媒体**:RTMP 收流 + Nginx-RTMP/FFmpeg 转 HLS 分发,兼顾实时性与浏览器兼容性。
|
||||||
|
- **本地地图引擎**:Leaflet + 地图瓦片本地化切分与存储,支持自定义园区地图。
|
||||||
|
- **私有化一键交付**:Docker Compose 编排前后端、数据库、媒体服务,一条命令完成部署。
|
||||||
|
- **统一暗色面板设计体系**:玻璃拟态 + 霓虹光效 + 响应式布局,视觉统一、辨识度高。
|
||||||
|
- **AI 能力可插拔**:支持标准 Chat Completions 协议,可按需切换 TokenFree 上的 Kimi / Qwen / Claude 等模型。
|
||||||
|
|
||||||
|
## 四、应用场景
|
||||||
|
|
||||||
|
- 产业园区、写字楼、商业综合体的停车场与周界监控;
|
||||||
|
- 校园、医院、政务园区的车辆与人员安全管理;
|
||||||
|
- 物流园、工厂厂区的车辆出入与作业区域异常监测;
|
||||||
|
- 物业/安保指挥中心的集中监控与接处警;
|
||||||
|
- 需要私有化部署、数据不出内网的安防与停车运营场景。
|
||||||
|
|
||||||
|
## 五、适配目标行业与人群
|
||||||
|
|
||||||
|
| 维度 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 目标行业 | 智慧物业、智慧园区、智慧交通、安防安保、校园/医院后勤、工厂物流 |
|
||||||
|
| 管理决策者 | 需要全局态势、数据报表与运营决策依据的管理者 |
|
||||||
|
| 一线运维/安保 | 需要视频监控、报警处置、设备巡检与快速定位的操作人员 |
|
||||||
|
| IT/集成服务商 | 需要可快速私有化交付、二次集成与品牌化部署的集成商 |
|
||||||
|
|
||||||
|
## 六、商业应用价值
|
||||||
|
|
||||||
|
- **降本增效**:自动化异常检测替代部分人工盯屏,降低人力成本、缩短响应时间。
|
||||||
|
- **私有化资产沉淀**:数据与平台完全部署在客户侧,形成可持续运营的数字化资产。
|
||||||
|
- **快速复制交付**:标准化 Docker 交付包支持多项目快速复制,显著降低项目实施成本。
|
||||||
|
- **可扩展增值空间**:可在统一平台上扩展计费、引导、数据分析等模块,形成增值收入。
|
||||||
|
- **品牌化交付能力**:支持 Logo、品牌与主题定制,便于服务商品牌化对外输出。
|
||||||
|
|
||||||
|
## 七、技术架构
|
||||||
|
|
||||||
|
### 7.1 总体架构
|
||||||
|
|
||||||
|
平台划分为 **前端、后端、监控算法端、RTMP 媒体服务器、地图瓦片服务、关系型数据库** 六大部分:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph Client[浏览器端]
|
||||||
|
UI[Umi4 + React18 + Antd5 玻璃面板]
|
||||||
|
end
|
||||||
|
subgraph Server[服务端]
|
||||||
|
API[NestJS 后端 REST + WebSocket]
|
||||||
|
DB[(MySQL 8.0)]
|
||||||
|
RTMP[RTMP/HLS 媒体服务]
|
||||||
|
TILE[地图瓦片服务]
|
||||||
|
end
|
||||||
|
subgraph AI[监控算法端]
|
||||||
|
YOLO[YOLO 实时目标检测]
|
||||||
|
end
|
||||||
|
CAM[摄像头 RTSP/RTMP 流] --> YOLO
|
||||||
|
YOLO -->|FFmpeg 推流| RTMP
|
||||||
|
YOLO <-->|WebSocket 配置/事件上报| API
|
||||||
|
UI -->|HTTPS / HLS| API
|
||||||
|
UI -->|HLS 直播| RTMP
|
||||||
|
UI -->|瓦片请求| TILE
|
||||||
|
API --> DB
|
||||||
|
```
|
||||||
|
|
||||||
|
- **前端**:React + Umi 4,Leaflet 渲染地图,hls.js 播放直播;
|
||||||
|
- **后端**:NestJS(TypeScript),负责业务逻辑、REST API、WebSocket、数据库 IO 与算法端鉴权;
|
||||||
|
- **监控算法端**:Python + YOLO,拉流、检测、推流,并通过 WebSocket 上报事件;
|
||||||
|
- **媒体服务**:RTMP 收流并转 HLS 提供浏览器直播;
|
||||||
|
- **数据层**:MySQL 8.0 持久化用户、设备、规则、报警、地图数据。
|
||||||
|
|
||||||
|
原始架构图(项目早期版本)保留于 `docs/tech-framework.png`、`docs/deployment-framework.png`。
|
||||||
|
|
||||||
|
### 7.2 端口规划(默认)
|
||||||
|
|
||||||
|
| 服务 | 容器端口 | 宿主机默认端口 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Web 应用(HTTP/HLS) | 80 | 4010 |
|
||||||
|
| MySQL | 3306 | 4012 |
|
||||||
|
| RTMP | 1935 | 4015 |
|
||||||
|
|
||||||
|
> 端口可通过 `.env` 文件调整,详见部署章节。
|
||||||
|
|
||||||
|
## 八、环境前置依赖
|
||||||
|
|
||||||
|
### 8.1 硬件建议
|
||||||
|
|
||||||
|
| 规模 | CPU | 内存 | 磁盘 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 测试/小规模(≤16 路) | 4 核 | 8 GB | 40 GB SSD |
|
||||||
|
| 生产/中规模(16–64 路) | 8 核+ | 16 GB+ | 100 GB+ SSD |
|
||||||
|
|
||||||
|
> 监控算法端(YOLO)建议独立部署或配备 GPU 以获得更好性能。
|
||||||
|
|
||||||
|
### 8.2 软件依赖
|
||||||
|
|
||||||
|
- **Docker Engine** 20.10+
|
||||||
|
- **Docker Compose** v2+
|
||||||
|
- 开放端口:应用端口(默认 4010)、RTMP(默认 4015);数据库端口建议仅内网开放
|
||||||
|
- 现代浏览器:Chrome / Edge / Firefox 最新版本
|
||||||
|
- (可选)AI 对话:在 [TokenFree](https://www.tokenfree.com/) 申请 API Key
|
||||||
|
|
||||||
|
完整依赖与注意事项见交付包内「文档」目录下的《环境依赖清单》与《部署注意事项》。
|
||||||
|
|
||||||
|
## 九、快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 进入项目目录
|
||||||
|
cd smart-parking-platform
|
||||||
|
|
||||||
|
# 2.(可选)按需修改端口与数据库口令
|
||||||
|
# 复制并编辑环境变量文件
|
||||||
|
cp .env.example .env # Windows: copy .env.example .env
|
||||||
|
|
||||||
|
# 3. 一键启动全部服务
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# 4. 浏览器访问
|
||||||
|
# http://localhost:4010
|
||||||
|
```
|
||||||
|
|
||||||
|
默认账号(见后端初始化逻辑 `backend/src/database-seeder.ts`):
|
||||||
|
|
||||||
|
| 角色 | 账号 | 密码 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 管理员 | `admin` | `admin123` |
|
||||||
|
| 操作员 | `operator` | `123456` |
|
||||||
|
|
||||||
|
> 首次登录后请及时修改默认密码。
|
||||||
|
|
||||||
|
## 十、完整安装部署教程
|
||||||
|
|
||||||
|
### 10.1 Docker 一键部署(推荐)
|
||||||
|
|
||||||
|
1. 安装并启动 Docker(Windows 建议使用 Docker Desktop,Linux 使用官方源安装 Docker Engine + Compose 插件)。
|
||||||
|
2. 获取项目完整源码(含 `docker-compose.yml`、`.env.example`、`front-backend.Dockerfile`)。
|
||||||
|
3. 根据现场环境调整 `.env`,关键变量:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
APP_PORT=4010 # Web 访问端口
|
||||||
|
MYSQL_PORT=4012 # MySQL 宿主机端口
|
||||||
|
RTMP_PORT=4015 # RTMP 推流端口
|
||||||
|
MYSQL_ROOT_PASSWORD=<修改为强口令>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 执行 `docker compose up -d`,首次会自动构建镜像并初始化数据库、导入示例数据。
|
||||||
|
5. 使用 `docker compose ps` 查看状态,等待应用容器变为 `healthy`。
|
||||||
|
6. 浏览器访问 `http://<服务器IP>:<APP_PORT>`,使用默认账号登录。
|
||||||
|
|
||||||
|
### 10.2 本地开发/手动部署(无 Docker 时)
|
||||||
|
|
||||||
|
**后端(NestJS)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
# 配置数据库连接(环境变量或配置文件)
|
||||||
|
npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端(Umi 4)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
# 开发启动
|
||||||
|
npm run dev
|
||||||
|
# 生产构建(如本地 pnpm 与 lockfile 版本冲突,可直接使用 umi)
|
||||||
|
# ./node_modules/.bin/umi build
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
**监控算法端(Python + YOLO)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ai-end
|
||||||
|
pip install -r requirements.txt
|
||||||
|
# 配置后端地址、RTMP 地址、管理员账号
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
算法端基本流程:建立 WebSocket 连接 → 后端鉴权并下发摄像头配置与拉流地址 → 每个摄像头独立进程完成 FFmpeg 推流与规则检测 → 异常实时上报。
|
||||||
|
|
||||||
|
### 10.3 运维与排障
|
||||||
|
|
||||||
|
- 查看日志:`docker compose logs -f app`
|
||||||
|
- 重启服务:`docker compose restart app`
|
||||||
|
- 停止并清理:`docker compose down`(数据保留在数据卷,加 `-v` 会同时删除数据卷,请谨慎)
|
||||||
|
- 直播无画面:检查摄像头源是否可达、RTMP 端口是否放通、浏览器是否允许混合内容(建议统一走 HTTP/HTTPS 同源)。
|
||||||
|
|
||||||
|
## 十一、AI 对话(TokenFree)使用教程
|
||||||
|
|
||||||
|
平台「AI 对话」页面默认接入 [TokenFree](https://www.tokenfree.com/)——统一 AI API 网关,支持标准 Chat Completions 协议,默认模型 `kimi-k2.6`。
|
||||||
|
|
||||||
|
| 配置项 | 默认值 |
|
||||||
|
| --- | --- |
|
||||||
|
| API Base URL | `https://www.tokenfree.com/v1` |
|
||||||
|
| 默认模型 | `kimi-k2.6` |
|
||||||
|
| 协议 | 标准 Chat Completions(流式输出 / Models 列表) |
|
||||||
|
|
||||||
|
使用步骤:
|
||||||
|
|
||||||
|
1. 打开 <https://www.tokenfree.com/>,注册账号并完成邮箱验证后登录;
|
||||||
|
2. 进入控制台「令牌 / API Keys」页面,点击「添加令牌」,复制生成的 Key(`sk-` 开头);
|
||||||
|
3. 登录本系统,进入「AI 对话」页面,点击右上角齿轮(接口配置);
|
||||||
|
4. 将 API Key 粘贴到表单(接口地址、模型已预填,一般无需修改);
|
||||||
|
5. 点击「测试连接」验证通过后「保存」,即可开始流式对话。
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- API Key 仅保存在浏览器本地(localStorage),不会上传到本系统后端;
|
||||||
|
- 可在 Model 中填写 TokenFree 支持的其他模型(如 Qwen、Claude 等);
|
||||||
|
- 切换服务商后本地配置会自动升级版本,旧缓存不会污染新默认值。
|
||||||
|
|
||||||
|
## 十二、版权与使用说明
|
||||||
|
|
||||||
|
- 本平台的界面设计、面板风格、部署脚本、AI 对接及项目文档由交付团队完成,其使用权与商用权以合同及授权文件为准。
|
||||||
|
- 未经书面许可,不得对本平台进行复制、反向工程、转售、再次分发或用于授权之外的用途。
|
||||||
|
- 平台所接入的第三方服务(如 TokenFree)受其各自服务条款与模型许可约束,使用时请同时遵守对应条款。
|
||||||
|
- 请在遵守适用法律法规的前提下使用本平台。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 相关链接
|
||||||
|
|
||||||
|
- [开源社区 GitCC](https://www.gitcc.com/)
|
||||||
|
- [GCC AI Coding](https://www.gitvv.com/)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
FROM continuumio/miniconda3:latest
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
RUN echo $TZ > /etc/timezone && \
|
||||||
|
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
|
||||||
|
dpkg-reconfigure -f noninteractive tzdata
|
||||||
|
|
||||||
|
COPY ./ai-end /usr/share/smart-parking-platform/ai-end
|
||||||
|
|
||||||
|
WORKDIR /usr/share/smart-parking-platform/ai-end
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install ffmpeg libsm6 libxext6 -y &&\
|
||||||
|
conda env create -f environment.yml -n ai-end
|
||||||
|
|
||||||
|
CMD ["/opt/conda/envs/ai-end/bin/python3", "main.py"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
__pycache__
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
name: pytorch
|
||||||
|
channels:
|
||||||
|
- pytorch
|
||||||
|
- nvidia
|
||||||
|
- https://repo.anaconda.com/pkgs/main/
|
||||||
|
- https://repo.anaconda.com/pkgs/free/
|
||||||
|
- https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/
|
||||||
|
- https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/
|
||||||
|
- defaults
|
||||||
|
dependencies:
|
||||||
|
- _libgcc_mutex=0.1=main
|
||||||
|
- _openmp_mutex=5.1=1_gnu
|
||||||
|
- autopep8=1.6.0=pyhd3eb1b0_1
|
||||||
|
- blas=1.0=mkl
|
||||||
|
- brotlipy=0.7.0=py310h7f8727e_1002
|
||||||
|
- bzip2=1.0.8=h7b6447c_0
|
||||||
|
- ca-certificates=2023.01.10=h06a4308_0
|
||||||
|
- certifi=2022.12.7=py310h06a4308_0
|
||||||
|
- cffi=1.15.1=py310h5eee18b_3
|
||||||
|
- charset-normalizer=2.0.4=pyhd3eb1b0_0
|
||||||
|
- cryptography=39.0.1=py310h9ce1e76_0
|
||||||
|
- cuda-cudart=11.7.99=0
|
||||||
|
- cuda-cupti=11.7.101=0
|
||||||
|
- cuda-libraries=11.7.1=0
|
||||||
|
- cuda-nvrtc=11.7.99=0
|
||||||
|
- cuda-nvtx=11.7.91=0
|
||||||
|
- cuda-runtime=11.7.1=0
|
||||||
|
- ffmpeg=4.3=hf484d3e_0
|
||||||
|
- filelock=3.9.0=py310h06a4308_0
|
||||||
|
- flit-core=3.8.0=py310h06a4308_0
|
||||||
|
- freetype=2.12.1=h4a9f257_0
|
||||||
|
- giflib=5.2.1=h5eee18b_3
|
||||||
|
- gmp=6.2.1=h295c915_3
|
||||||
|
- gmpy2=2.1.2=py310heeb90bb_0
|
||||||
|
- gnutls=3.6.15=he1e5248_0
|
||||||
|
- idna=3.4=py310h06a4308_0
|
||||||
|
- intel-openmp=2021.4.0=h06a4308_3561
|
||||||
|
- jinja2=3.1.2=py310h06a4308_0
|
||||||
|
- jpeg=9e=h5eee18b_1
|
||||||
|
- lame=3.100=h7b6447c_0
|
||||||
|
- lcms2=2.12=h3be6417_0
|
||||||
|
- ld_impl_linux-64=2.38=h1181459_1
|
||||||
|
- lerc=3.0=h295c915_0
|
||||||
|
- libcublas=11.10.3.66=0
|
||||||
|
- libcufft=10.7.2.124=h4fbf590_0
|
||||||
|
- libcufile=1.6.0.25=0
|
||||||
|
- libcurand=10.3.2.56=0
|
||||||
|
- libcusolver=11.4.0.1=0
|
||||||
|
- libcusparse=11.7.4.91=0
|
||||||
|
- libdeflate=1.17=h5eee18b_0
|
||||||
|
- libffi=3.4.2=h6a678d5_6
|
||||||
|
- libgcc-ng=11.2.0=h1234567_1
|
||||||
|
- libgomp=11.2.0=h1234567_1
|
||||||
|
- libiconv=1.16=h7f8727e_2
|
||||||
|
- libidn2=2.3.2=h7f8727e_0
|
||||||
|
- libnpp=11.7.4.75=0
|
||||||
|
- libnvjpeg=11.8.0.2=0
|
||||||
|
- libpng=1.6.39=h5eee18b_0
|
||||||
|
- libstdcxx-ng=11.2.0=h1234567_1
|
||||||
|
- libtasn1=4.19.0=h5eee18b_0
|
||||||
|
- libtiff=4.5.0=h6a678d5_2
|
||||||
|
- libunistring=0.9.10=h27cfd23_0
|
||||||
|
- libuuid=1.41.5=h5eee18b_0
|
||||||
|
- libwebp=1.2.4=h11a3e52_1
|
||||||
|
- libwebp-base=1.2.4=h5eee18b_1
|
||||||
|
- lz4-c=1.9.4=h6a678d5_0
|
||||||
|
- markupsafe=2.1.1=py310h7f8727e_0
|
||||||
|
- mkl=2021.4.0=h06a4308_640
|
||||||
|
- mkl-service=2.4.0=py310h7f8727e_0
|
||||||
|
- mkl_fft=1.3.1=py310hd6ae3a3_0
|
||||||
|
- mkl_random=1.2.2=py310h00e6091_0
|
||||||
|
- mpc=1.1.0=h10f8cd9_1
|
||||||
|
- mpfr=4.0.2=hb69a4c5_1
|
||||||
|
- ncurses=6.4=h6a678d5_0
|
||||||
|
- nettle=3.7.3=hbbd107a_1
|
||||||
|
- networkx=2.8.4=py310h06a4308_1
|
||||||
|
- numpy=1.23.5=py310hd5efca6_0
|
||||||
|
- numpy-base=1.23.5=py310h8e6c178_0
|
||||||
|
- openh264=2.1.1=h4ff587b_0
|
||||||
|
- openssl=1.1.1t=h7f8727e_0
|
||||||
|
- pillow=9.4.0=py310h6a678d5_0
|
||||||
|
- pip=23.0.1=py310h06a4308_0
|
||||||
|
- pycodestyle=2.10.0=py310h06a4308_0
|
||||||
|
- pycparser=2.21=pyhd3eb1b0_0
|
||||||
|
- pyopenssl=23.0.0=py310h06a4308_0
|
||||||
|
- pysocks=1.7.1=py310h06a4308_0
|
||||||
|
- python=3.10.10=h7a1cb2a_2
|
||||||
|
- pytorch=2.0.0=py3.10_cuda11.7_cudnn8.5.0_0
|
||||||
|
- pytorch-cuda=11.7=h778d358_3
|
||||||
|
- pytorch-mutex=1.0=cuda
|
||||||
|
- readline=8.2=h5eee18b_0
|
||||||
|
- requests=2.28.1=py310h06a4308_1
|
||||||
|
- setuptools=65.6.3=py310h06a4308_0
|
||||||
|
- six=1.16.0=pyhd3eb1b0_1
|
||||||
|
- sqlite=3.41.1=h5eee18b_0
|
||||||
|
- sympy=1.11.1=py310h06a4308_0
|
||||||
|
- tk=8.6.12=h1ccaba5_0
|
||||||
|
- toml=0.10.2=pyhd3eb1b0_0
|
||||||
|
- torchaudio=2.0.0=py310_cu117
|
||||||
|
- torchtriton=2.0.0=py310
|
||||||
|
- torchvision=0.15.0=py310_cu117
|
||||||
|
- typing_extensions=4.4.0=py310h06a4308_0
|
||||||
|
- urllib3=1.26.15=py310h06a4308_0
|
||||||
|
- wheel=0.38.4=py310h06a4308_0
|
||||||
|
- xz=5.2.10=h5eee18b_1
|
||||||
|
- zlib=1.2.13=h5eee18b_0
|
||||||
|
- zstd=1.5.4=hc292b87_0
|
||||||
|
- pip:
|
||||||
|
- aiohttp==3.8.4
|
||||||
|
- aiosignal==1.3.1
|
||||||
|
- async-timeout==4.0.2
|
||||||
|
- attrs==22.2.0
|
||||||
|
- bidict==0.22.1
|
||||||
|
- contourpy==1.0.7
|
||||||
|
- cycler==0.11.0
|
||||||
|
- fonttools==4.39.3
|
||||||
|
- frozenlist==1.3.3
|
||||||
|
- kiwisolver==1.4.4
|
||||||
|
- matplotlib==3.7.1
|
||||||
|
- mpmath==1.2.1
|
||||||
|
- multidict==6.0.4
|
||||||
|
- opencv-python==4.7.0.72
|
||||||
|
- packaging==23.0
|
||||||
|
- pandas==2.0.0
|
||||||
|
- psutil==5.9.4
|
||||||
|
- pyparsing==3.0.9
|
||||||
|
- python-dateutil==2.8.2
|
||||||
|
- python-engineio==4.4.0
|
||||||
|
- python-socketio==5.8.0
|
||||||
|
- pytz==2023.3
|
||||||
|
- pyyaml==6.0
|
||||||
|
- scipy==1.10.1
|
||||||
|
- seaborn==0.12.2
|
||||||
|
- sentry-sdk==1.19.1
|
||||||
|
- thop==0.1.1-2209072238
|
||||||
|
- tqdm==4.65.0
|
||||||
|
- tzdata==2023.3
|
||||||
|
- ultralytics==8.0.72
|
||||||
|
- websocket-client==1.5.1
|
||||||
|
- yarl==1.8.2
|
||||||
|
prefix: /root/anaconda3/envs/pytorch
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
from model import YOLOModel
|
||||||
|
from wsClient import WSClient
|
||||||
|
import asyncio
|
||||||
|
import multiprocessing
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
|
||||||
|
def getOfflineCameraIDs(httpServerUrl, adminUsername, password):
|
||||||
|
__SHA256KEY = b"smart-parking-platform"
|
||||||
|
r = requests.get(
|
||||||
|
httpServerUrl + "/api/ai/getOfflineCameraList",
|
||||||
|
params={
|
||||||
|
"adminUsername": adminUsername,
|
||||||
|
"password": base64.b64encode(
|
||||||
|
hmac.new(__SHA256KEY, password.encode("utf-8"), hashlib.sha256).digest()
|
||||||
|
).decode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
res = r.json()
|
||||||
|
if not res["success"]:
|
||||||
|
raise Exception(res["message"])
|
||||||
|
|
||||||
|
return list(map(lambda x: x["cameraID"], res["data"]))
|
||||||
|
|
||||||
|
|
||||||
|
# Windows推流本机摄像头到rtmp://localhost:1515/hls/3
|
||||||
|
# ffmpeg -list_devices true -f dshow -i dummy
|
||||||
|
# ffmpeg -f dshow -i video="USB2.0 HD UVC WebCam" -vcodec libx264 -preset:v ultrafast -tune:v zerolatency -g 30 -f:v flv -c:a copy rtmp://192.169.3.3:1515/live/3
|
||||||
|
|
||||||
|
|
||||||
|
def ffmpegStreamToRtmpServer(streamUrl: str, rtmpUrl: str):
|
||||||
|
print("exec ffmpeg...")
|
||||||
|
if streamUrl.startswith("rtsp"):
|
||||||
|
os.execvp(
|
||||||
|
"/usr/bin/ffmpeg",
|
||||||
|
f"/usr/bin/ffmpeg -hide_banner -loglevel error -rtsp_transport tcp -i {streamUrl} -c:v libx264 -preset:v ultrafast -tune:v zerolatency -g 30 -f:v flv -c:a copy {rtmpUrl}".split(
|
||||||
|
" "
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
os.execvp(
|
||||||
|
"/usr/bin/ffmpeg",
|
||||||
|
f"/usr/bin/ffmpeg -hide_banner -loglevel error -i {streamUrl} -c:v libx264 -preset:v ultrafast -tune:v zerolatency -g 30 -f:v flv -c:a copy {rtmpUrl}".split(
|
||||||
|
" "
|
||||||
|
),
|
||||||
|
)
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def beginWork(ws: WSClient):
|
||||||
|
rtmpUrl = ws.rtmpServerUrl + "/" + ws.cameraID
|
||||||
|
ffmpegProcess = multiprocessing.Process(
|
||||||
|
target=ffmpegStreamToRtmpServer, args=(ws.rtspUrl, rtmpUrl), daemon=True
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
model = YOLOModel(device=ws.context.get("modelDevice", "cpu"))
|
||||||
|
|
||||||
|
ffmpegProcess.start()
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Begin to detect video for camera {ws.cameraID}, streamUrl: {ws.rtspUrl} \nIf wait too long, please check if the stream url is correct."
|
||||||
|
)
|
||||||
|
results = model.detectVideo(ws.rtspUrl, classList=[0, 2]) # 0:person, 2:car
|
||||||
|
|
||||||
|
for frameResult in results:
|
||||||
|
clsCount = model.getResultClsCount(frameResult)
|
||||||
|
|
||||||
|
await ws.trySendAlarm(
|
||||||
|
{
|
||||||
|
"algorithmType": "body",
|
||||||
|
"count": clsCount.get("person", 0),
|
||||||
|
"predictResult": frameResult,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await ws.trySendAlarm(
|
||||||
|
{
|
||||||
|
"algorithmType": "vehicle",
|
||||||
|
"count": clsCount.get("car", 0),
|
||||||
|
"predictResult": frameResult,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ffmpegProcess.is_alive():
|
||||||
|
print(f"ffmpeg process for camera {ws.cameraID} is dead")
|
||||||
|
break
|
||||||
|
await asyncio.sleep(float(ws.context.get("interval", 1)))
|
||||||
|
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
ffmpegProcess.kill()
|
||||||
|
await ws.disconnect()
|
||||||
|
print("error, kill ffmpeg process")
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
wsServerUrl="",
|
||||||
|
rtmpServerUrl="",
|
||||||
|
adminUsername="",
|
||||||
|
password="",
|
||||||
|
cameraID="",
|
||||||
|
detectionInterval=0.5,
|
||||||
|
modelDevice="cpu",
|
||||||
|
):
|
||||||
|
asyncio.run(
|
||||||
|
WSClient(
|
||||||
|
wsServerUrl,
|
||||||
|
rtmpServerUrl,
|
||||||
|
adminUsername,
|
||||||
|
password,
|
||||||
|
str(cameraID),
|
||||||
|
beginWork,
|
||||||
|
{"interval": float(detectionInterval), "modelDevice": modelDevice},
|
||||||
|
).stayConnected()
|
||||||
|
)
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
multiprocessing.freeze_support()
|
||||||
|
httpServerUrl = os.getenv("HTTP_SERVER_URL", "http://localhost")
|
||||||
|
wsServerUrl = httpServerUrl + "/ws"
|
||||||
|
rtmpServerUrl = os.getenv("RTMP_SERVER_URL", "rtmp://localhost:1515/live")
|
||||||
|
adminUsername = os.getenv("ADMIN_USERNAME", "admin123")
|
||||||
|
password = os.getenv("ADMIN_PASSWORD", "admin123")
|
||||||
|
cameraIDs = []
|
||||||
|
if os.getenv("CAMERA_IDS") is not None:
|
||||||
|
cameraIDs = os.getenv("CAMERA_IDS").split(",")
|
||||||
|
else:
|
||||||
|
cameraIDs = getOfflineCameraIDs(httpServerUrl, adminUsername, password)
|
||||||
|
|
||||||
|
detectionInterval = float(os.getenv("DETECTION_INTERVAL", 0.5))
|
||||||
|
modelDevice = os.getenv("MODEL_DEVICE", "cpu")
|
||||||
|
|
||||||
|
processes = []
|
||||||
|
# start a process for each camera
|
||||||
|
for cameraID in cameraIDs:
|
||||||
|
p = multiprocessing.Process(
|
||||||
|
target=main,
|
||||||
|
args=(
|
||||||
|
wsServerUrl,
|
||||||
|
rtmpServerUrl,
|
||||||
|
adminUsername,
|
||||||
|
password,
|
||||||
|
cameraID,
|
||||||
|
detectionInterval,
|
||||||
|
modelDevice,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
p.start()
|
||||||
|
processes.append(p)
|
||||||
|
pass
|
||||||
|
|
||||||
|
# wait for all processes to finish
|
||||||
|
for p in processes:
|
||||||
|
p.join()
|
||||||
|
|
||||||
|
pass
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
|
||||||
|
class YOLOModel:
|
||||||
|
__model = None
|
||||||
|
conf = 0.5
|
||||||
|
device = "cpu"
|
||||||
|
|
||||||
|
def __init__(self, conf=0.5, device="cpu"):
|
||||||
|
self.conf = conf
|
||||||
|
self.device = device
|
||||||
|
self.__model = YOLO(model="./yolov8n.pt", task="detect")
|
||||||
|
|
||||||
|
def detectImage(self, image, classList=[0, 2]):
|
||||||
|
result = self.__model.predict(
|
||||||
|
source=image,
|
||||||
|
verbose=False,
|
||||||
|
conf=self.conf,
|
||||||
|
line_thickness=1,
|
||||||
|
classes=classList,
|
||||||
|
device=self.device,
|
||||||
|
)[0]
|
||||||
|
result.clsCount = self.getResultClsCount(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def detectVideo(self, video, classList=[0, 2]):
|
||||||
|
result = self.__model.predict(
|
||||||
|
source=video,
|
||||||
|
verbose=False,
|
||||||
|
stream=True,
|
||||||
|
line_thickness=1,
|
||||||
|
conf=self.conf,
|
||||||
|
classes=classList,
|
||||||
|
device=self.device,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def getResultClsCount(self, result):
|
||||||
|
clsCount = dict()
|
||||||
|
detectedCls = result.boxes.cls
|
||||||
|
for clsValue in detectedCls.unique():
|
||||||
|
clsName = str(result.names[int(clsValue)])
|
||||||
|
count = int((detectedCls == clsValue).sum())
|
||||||
|
clsCount[clsName] = count
|
||||||
|
return clsCount
|
||||||
|
|
||||||
|
def getSupportedClsList(self):
|
||||||
|
return self.__model.names
|
||||||
|
|
||||||
|
|
||||||
|
pass
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
from typing import Any
|
||||||
|
import socketio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import cv2
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WSClient:
|
||||||
|
__SHA256KEY = b"smart-parking-platform"
|
||||||
|
sio = socketio.AsyncClient(reconnection=False)
|
||||||
|
wsServerUrl = None
|
||||||
|
rtmpServerUrl = None
|
||||||
|
|
||||||
|
connected = False
|
||||||
|
ready = False
|
||||||
|
|
||||||
|
async def onReady(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
username = None
|
||||||
|
password = None
|
||||||
|
cameraID = None
|
||||||
|
|
||||||
|
rtspUrl = None
|
||||||
|
alarmRules = None
|
||||||
|
"""
|
||||||
|
@type alarmRule: [{
|
||||||
|
'id': 1,
|
||||||
|
'name': '测试规则',
|
||||||
|
'enabled': True,
|
||||||
|
'algorithmType':'body',
|
||||||
|
'triggerDayOfWeek': [1, 2, 3, 4, 5],
|
||||||
|
'triggerTimeStart': '09:00:00',
|
||||||
|
'triggerTimeEnd': '18:00:00',
|
||||||
|
'triggerCountMin': 0,
|
||||||
|
'triggerCountMax': -1
|
||||||
|
}]
|
||||||
|
"""
|
||||||
|
|
||||||
|
alarmThrottle = {}
|
||||||
|
"""
|
||||||
|
@type alarmThrottle: {
|
||||||
|
[ alarmRuleID:str ]: datetime
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
throttleTime = 60 # seconds
|
||||||
|
|
||||||
|
context = {}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
wsServerUrl: str,
|
||||||
|
rtmpServerUrl: str,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
cameraID: str,
|
||||||
|
onReady: Any,
|
||||||
|
context: dict = {},
|
||||||
|
) -> None:
|
||||||
|
self.wsServerUrl = wsServerUrl
|
||||||
|
self.rtmpServerUrl = rtmpServerUrl
|
||||||
|
self.username = username
|
||||||
|
self.password = base64.b64encode(
|
||||||
|
hmac.new(
|
||||||
|
self.__SHA256KEY, password.encode("utf-8"), hashlib.sha256
|
||||||
|
).digest()
|
||||||
|
).decode("utf-8")
|
||||||
|
self.cameraID = cameraID
|
||||||
|
self.onReady = onReady
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
self.sio.on("cameraConfigChange", self.onCameraConfigChange)
|
||||||
|
|
||||||
|
self.sio.on("connect", self.onConnect)
|
||||||
|
self.sio.on("disconnect", self.onDisconnect)
|
||||||
|
self.sio.on("connect_error", self.onConnectError)
|
||||||
|
|
||||||
|
print(f"ws client for camera {cameraID} created")
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
if self.connected:
|
||||||
|
return
|
||||||
|
data = json.dumps(
|
||||||
|
{
|
||||||
|
"username": self.username,
|
||||||
|
"password": self.password,
|
||||||
|
"cameraID": self.cameraID,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await self.sio.connect(
|
||||||
|
self.wsServerUrl, socketio_path="/ws/ai/", headers={"data": data}
|
||||||
|
)
|
||||||
|
self.connected = True
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def sendMessage(self, msg):
|
||||||
|
await self.sio.emit("message", msg)
|
||||||
|
print("message sent")
|
||||||
|
pass
|
||||||
|
|
||||||
|
def matchAlarmRule(self, data):
|
||||||
|
"""
|
||||||
|
@type data: {
|
||||||
|
'algorithmType': 'body',
|
||||||
|
'count': 1,
|
||||||
|
'predictResult': Any
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
matchedRules = []
|
||||||
|
if not self.ready:
|
||||||
|
raise Exception(f"CameraID {self.cameraID} not ready or disconnected")
|
||||||
|
if self.alarmRules is None:
|
||||||
|
return matchedRules
|
||||||
|
for rule in self.alarmRules:
|
||||||
|
if (
|
||||||
|
rule["enabled"]
|
||||||
|
and rule["algorithmType"] == data["algorithmType"]
|
||||||
|
and (datetime.now().weekday() + 1) in rule["triggerDayOfWeek"]
|
||||||
|
and datetime.now().time()
|
||||||
|
>= datetime.strptime(rule["triggerTimeStart"], "%H:%M:%S").time()
|
||||||
|
and datetime.now().time()
|
||||||
|
<= datetime.strptime(rule["triggerTimeEnd"], "%H:%M:%S").time()
|
||||||
|
and data["count"] >= rule["triggerCountMin"]
|
||||||
|
and (
|
||||||
|
data["count"] <= rule["triggerCountMax"]
|
||||||
|
or rule["triggerCountMax"] == -1
|
||||||
|
)
|
||||||
|
and (
|
||||||
|
self.alarmThrottle.get(rule["id"]) is None
|
||||||
|
or (datetime.now() - self.alarmThrottle[rule["id"]]).seconds
|
||||||
|
> self.throttleTime
|
||||||
|
)
|
||||||
|
):
|
||||||
|
matchedRules.append(rule)
|
||||||
|
return matchedRules
|
||||||
|
|
||||||
|
async def trySendAlarm(self, data):
|
||||||
|
"""
|
||||||
|
@type data: {
|
||||||
|
'algorithmType': 'body',
|
||||||
|
'count': 1,
|
||||||
|
'predictResult': Any
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
rules = self.matchAlarmRule(data)
|
||||||
|
for rule in rules:
|
||||||
|
"""
|
||||||
|
AlarmData: {
|
||||||
|
picBase64: string;
|
||||||
|
alarmRuleID: number;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
await self.sio.emit(
|
||||||
|
"alarm",
|
||||||
|
{
|
||||||
|
"alarmRuleID": rule["id"],
|
||||||
|
"picBase64": "data:image/jpg;base64,"
|
||||||
|
+ base64.b64encode(
|
||||||
|
cv2.imencode(".jpg", data["predictResult"].plot())[1]
|
||||||
|
).decode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# 防止短时间内重复发送
|
||||||
|
self.alarmThrottle[rule["id"]] = datetime.now()
|
||||||
|
print(f"camera {self.cameraID} alarm sent: {rule['name']}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
await self.sio.disconnect()
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def onCameraConfigChange(self, data):
|
||||||
|
if self.rtspUrl is not None and self.rtspUrl != data["rtspUrl"]:
|
||||||
|
print(f"Camera {self.cameraID} rtsp url changed, disconnecting...")
|
||||||
|
await self.disconnect()
|
||||||
|
return
|
||||||
|
|
||||||
|
self.rtspUrl = data["rtspUrl"]
|
||||||
|
self.alarmRules = data["alarmRules"]
|
||||||
|
print(f"Camera {self.cameraID} config updated")
|
||||||
|
|
||||||
|
if self.connected and not self.ready:
|
||||||
|
self.ready = True
|
||||||
|
asyncio.get_event_loop().create_task(self.onReady(self))
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def stayConnected(self):
|
||||||
|
await self.connect()
|
||||||
|
await self.sio.wait()
|
||||||
|
pass
|
||||||
|
|
||||||
|
def onConnect(self):
|
||||||
|
print(f"connection for camera {self.cameraID} established")
|
||||||
|
pass
|
||||||
|
|
||||||
|
def onDisconnect(self):
|
||||||
|
print(f"camera {self.cameraID} disconnected from server")
|
||||||
|
self.connected = False
|
||||||
|
self.ready = False
|
||||||
|
pass
|
||||||
|
|
||||||
|
def onConnectError(self, err):
|
||||||
|
print(err)
|
||||||
|
pass
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module.exports = {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
project: 'tsconfig.json',
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||||
|
extends: [
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:prettier/recommended',
|
||||||
|
],
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
jest: true,
|
||||||
|
},
|
||||||
|
ignorePatterns: ['.eslintrc.js'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/interface-name-prefix': 'off',
|
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
/coverage
|
||||||
|
/.nyc_output
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
/.idea
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# IDE - VSCode
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
user www-data;
|
||||||
|
worker_processes auto;
|
||||||
|
pid /run/nginx.pid;
|
||||||
|
include /etc/nginx/modules-enabled/*.conf;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 768;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
access_log /var/log/nginx/access.log;
|
||||||
|
error_log /var/log/nginx/error.log;
|
||||||
|
gzip on;
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
client_max_body_size 500M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
index index.html;
|
||||||
|
root /usr/share/smart-parking-platform/frontend/dist;
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /public/ {
|
||||||
|
proxy_pass http://localhost:3000/public/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection upgrade;
|
||||||
|
proxy_set_header Accept-Encoding gzip;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:3000/api/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection upgrade;
|
||||||
|
proxy_set_header Accept-Encoding gzip;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hls {
|
||||||
|
types {
|
||||||
|
application/vnd.apple.mpegurl m3u8;
|
||||||
|
video/mp2t ts;
|
||||||
|
}
|
||||||
|
alias /dev/shm/nginx-live;
|
||||||
|
add_header Cache-Control no-cache;
|
||||||
|
add_header Access-Control-Allow-Origin *;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rtmp {
|
||||||
|
server {
|
||||||
|
listen 1515;
|
||||||
|
chunk_size 4096;
|
||||||
|
application live {
|
||||||
|
live on;
|
||||||
|
hls on;
|
||||||
|
hls_path /dev/shm/nginx-live;
|
||||||
|
hls_cleanup on;
|
||||||
|
hls_type live;
|
||||||
|
hls_fragment_naming system;
|
||||||
|
hls_fragment 2s;
|
||||||
|
hls_playlist_length 10s;
|
||||||
|
hls_fragment_slicing plain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"name": "smart-parking-backend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "tsc --noEmit && eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/cache-manager": "^1.0.0",
|
||||||
|
"@nestjs/common": "^9.4.0",
|
||||||
|
"@nestjs/config": "^2.3.1",
|
||||||
|
"@nestjs/core": "^9.4.0",
|
||||||
|
"@nestjs/jwt": "^10.0.3",
|
||||||
|
"@nestjs/platform-express": "^9.4.0",
|
||||||
|
"@nestjs/platform-socket.io": "^9.4.0",
|
||||||
|
"@nestjs/serve-static": "^3.0.1",
|
||||||
|
"@nestjs/typeorm": "^9.0.1",
|
||||||
|
"@nestjs/websockets": "^9.4.0",
|
||||||
|
"cache-manager": "^5.2.1",
|
||||||
|
"dayjs": "^1.11.7",
|
||||||
|
"fs-extra": "^11.1.1",
|
||||||
|
"mysql2": "^3.2.4",
|
||||||
|
"reflect-metadata": "^0.1.13",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"socket.io": "^4.6.1",
|
||||||
|
"typeorm": "^0.3.15"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^9.4.2",
|
||||||
|
"@nestjs/schematics": "^9.1.0",
|
||||||
|
"@nestjs/testing": "^9.4.0",
|
||||||
|
"@types/express": "^4.17.17",
|
||||||
|
"@types/fs-extra": "^11.0.1",
|
||||||
|
"@types/jest": "29.5.0",
|
||||||
|
"@types/node": "18.15.11",
|
||||||
|
"@types/supertest": "^2.0.12",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.59.1",
|
||||||
|
"@typescript-eslint/parser": "^5.59.1",
|
||||||
|
"eslint": "^8.39.0",
|
||||||
|
"eslint-config-prettier": "^8.8.0",
|
||||||
|
"eslint-plugin-prettier": "^4.2.1",
|
||||||
|
"jest": "29.5.0",
|
||||||
|
"prettier": "^2.8.8",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"supertest": "^6.3.3",
|
||||||
|
"ts-jest": "29.0.5",
|
||||||
|
"ts-loader": "^9.4.2",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tsconfig-paths": "4.2.0",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"webpack": "^5.81.0"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": "src",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "../coverage",
|
||||||
|
"testEnvironment": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
MYSQL_HOST=localhost
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_DATABASE=smart_parking
|
||||||
|
MYSQL_USER=parking
|
||||||
|
MYSQL_PASSWORD=parking123
|
||||||
|
JWT_SECRET=smart-parking-secret
|
||||||
|
JWT_EXPIRES_IN=30d
|
||||||
|
PUBLIC_DIR_ABSOLUTE_PATH=/var/www/html/public
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { ControllersModule } from './controllers/controllers.module';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||||
|
import { WsGatewaysModule } from './ws-gateways/ws-gateways.module';
|
||||||
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
|
import { DatabaseSeeder } from './database-seeder';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true, envFilePath: 'server.config.env' }),
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
type: 'mysql',
|
||||||
|
database: configService.get('MYSQL_DATABASE'),
|
||||||
|
host: configService.get('MYSQL_HOST'),
|
||||||
|
port: +configService.get('MYSQL_PORT'),
|
||||||
|
username: configService.get('MYSQL_USER'),
|
||||||
|
password: configService.get('MYSQL_PASSWORD'),
|
||||||
|
synchronize: true,
|
||||||
|
autoLoadEntities: true,
|
||||||
|
timezone: 'Z',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
CacheModule.register({ isGlobal: true, ttl: 0, max: 0 }),
|
||||||
|
ServeStaticModule.forRootAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => [
|
||||||
|
{
|
||||||
|
rootPath: configService.get('PUBLIC_DIR_ABSOLUTE_PATH'),
|
||||||
|
serveRoot: '/public',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
global: true,
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
secret: configService.get('JWT_SECRET'),
|
||||||
|
signOptions: { expiresIn: configService.get('JWT_EXPIRES_IN') },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
ControllersModule,
|
||||||
|
WsGatewaysModule,
|
||||||
|
],
|
||||||
|
providers: [DatabaseSeeder],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AdminController } from './admin.controller';
|
||||||
|
|
||||||
|
describe('AdminController', () => {
|
||||||
|
let controller: AdminController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AdminController],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<AdminController>(AdminController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
SetMetadata,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from 'src/guards/auth/auth.guard';
|
||||||
|
import { RoleGuard } from 'src/guards/role/role.guard';
|
||||||
|
import { AlarmRule } from 'src/services/alarm-rule/alarm-rule.entity';
|
||||||
|
import { AlarmRuleService } from 'src/services/alarm-rule/alarm-rule.service';
|
||||||
|
import { Camera } from 'src/services/camera/camera.entity';
|
||||||
|
import { CameraService } from 'src/services/camera/camera.service';
|
||||||
|
import { MapConfigService } from 'src/services/map-config/map-config.service';
|
||||||
|
import { UserService } from 'src/services/user/user.service';
|
||||||
|
import { UtilsService } from 'src/services/utils/utils.service';
|
||||||
|
import { FetchTypes } from 'src/types/fetchTypes';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
@SetMetadata('role', 'admin')
|
||||||
|
@UseGuards(AuthGuard, RoleGuard)
|
||||||
|
export class AdminController {
|
||||||
|
constructor(
|
||||||
|
private mapConfigService: MapConfigService,
|
||||||
|
private utilsService: UtilsService,
|
||||||
|
private cameraService: CameraService,
|
||||||
|
private alarmRuleService: AlarmRuleService,
|
||||||
|
private userService: UserService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('/api/admin/updateMapConfig')
|
||||||
|
async updateMapConfig(
|
||||||
|
@Body() body: FetchTypes['POST /api/admin/updateMapConfig']['req'],
|
||||||
|
): Promise<FetchTypes['POST /api/admin/updateMapConfig']['res']['data']> {
|
||||||
|
if (
|
||||||
|
body.layer.type === 'imageOverlay' &&
|
||||||
|
body.layer.url.startsWith('data:image')
|
||||||
|
) {
|
||||||
|
const filePath = await this.utilsService.writeBase64ImageToFile(
|
||||||
|
body.layer.url,
|
||||||
|
);
|
||||||
|
this.mapConfigService.updateConfig({
|
||||||
|
layerType: 'imageOverlay',
|
||||||
|
layerUrlOrPath: filePath,
|
||||||
|
imageLayerBounds: body.layer.bounds,
|
||||||
|
mapCenter: body.mapOptions.center,
|
||||||
|
mapZoom: body.mapOptions.zoom,
|
||||||
|
minZoom: body.mapOptions.minZoom,
|
||||||
|
maxZoom: body.mapOptions.maxZoom,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.mapConfigService.updateConfig({
|
||||||
|
layerType: 'tileLayer',
|
||||||
|
layerUrlOrPath: body.layer.url,
|
||||||
|
mapCenter: body.mapOptions.center,
|
||||||
|
mapZoom: body.mapOptions.zoom,
|
||||||
|
minZoom: body.mapOptions.minZoom,
|
||||||
|
maxZoom: body.mapOptions.maxZoom,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/api/admin/getCameraList')
|
||||||
|
async getCameraList(): Promise<
|
||||||
|
FetchTypes['GET /api/admin/getCameraList']['res']['data']
|
||||||
|
> {
|
||||||
|
const list = await this.cameraService.getList(true, true);
|
||||||
|
return list.map((camera) => ({
|
||||||
|
cameraID: camera.id,
|
||||||
|
cameraName: camera.name,
|
||||||
|
cameraStatus: this.cameraService.getCameraStatus(camera),
|
||||||
|
latlng: [Number(camera.latitude), Number(camera.longitude)],
|
||||||
|
cameraModel: camera.model,
|
||||||
|
hlsUrl: this.cameraService.getHlsUrl(camera.id),
|
||||||
|
rtspUrl: camera.rtspUrl,
|
||||||
|
alarmRules:
|
||||||
|
camera.alarmRules?.map((rule) => ({
|
||||||
|
alarmRuleID: rule.id,
|
||||||
|
alarmRuleName: rule.name,
|
||||||
|
})) || [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/api/admin/addCamera')
|
||||||
|
async addCamera(
|
||||||
|
@Body() body: FetchTypes['POST /api/admin/addCamera']['req'],
|
||||||
|
): Promise<FetchTypes['POST /api/admin/addCamera']['res']['data']> {
|
||||||
|
await this.cameraService.addCamera({
|
||||||
|
name: body.cameraName,
|
||||||
|
model: body.cameraModel,
|
||||||
|
latitude: body.latlng[0],
|
||||||
|
longitude: body.latlng[1],
|
||||||
|
rtspUrl: body.rtspUrl,
|
||||||
|
alarmRules:
|
||||||
|
body.alarmRuleIDs?.map((id) => {
|
||||||
|
const rule = new AlarmRule();
|
||||||
|
rule.id = id;
|
||||||
|
return rule;
|
||||||
|
}) || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/api/admin/updateCamera')
|
||||||
|
async updateCamera(
|
||||||
|
@Body() body: FetchTypes['POST /api/admin/updateCamera']['req'],
|
||||||
|
): Promise<FetchTypes['POST /api/admin/updateCamera']['res']['data']> {
|
||||||
|
await this.cameraService.updateCamera({
|
||||||
|
id: body.cameraID,
|
||||||
|
name: body.cameraName,
|
||||||
|
model: body.cameraModel,
|
||||||
|
latitude: body.latlng[0],
|
||||||
|
longitude: body.latlng[1],
|
||||||
|
rtspUrl: body.rtspUrl,
|
||||||
|
alarmRules: body.alarmRuleIDs.map((id) => {
|
||||||
|
const rule = new AlarmRule();
|
||||||
|
rule.id = id;
|
||||||
|
return rule;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/api/admin/deleteCamera')
|
||||||
|
async deleteCamera(
|
||||||
|
@Body() body: FetchTypes['POST /api/admin/deleteCamera']['req'],
|
||||||
|
): Promise<FetchTypes['POST /api/admin/deleteCamera']['res']['data']> {
|
||||||
|
await this.cameraService.deleteCamera(body.cameraID);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/api/admin/getAlarmRuleList')
|
||||||
|
async getAlarmRuleList(): Promise<
|
||||||
|
FetchTypes['GET /api/admin/getAlarmRuleList']['res']['data']
|
||||||
|
> {
|
||||||
|
const list = await this.alarmRuleService.getList(true);
|
||||||
|
|
||||||
|
return list.map((rule) => ({
|
||||||
|
alarmRuleID: rule.id,
|
||||||
|
alarmRuleName: rule.name,
|
||||||
|
enabled: rule.enabled,
|
||||||
|
algorithmType: rule.algorithmType,
|
||||||
|
relatedCameras:
|
||||||
|
rule.relatedCameras?.map((camera) => ({
|
||||||
|
cameraID: camera.id,
|
||||||
|
cameraName: camera.name,
|
||||||
|
})) || [],
|
||||||
|
triggerCondition: {
|
||||||
|
time: {
|
||||||
|
dayOfWeek: rule.triggerDayOfWeek,
|
||||||
|
timeRange: [rule.triggerTimeStart, rule.triggerTimeEnd],
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
min: rule.triggerCountMin,
|
||||||
|
max: rule.triggerCountMax,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/api/admin/addAlarmRule')
|
||||||
|
async addAlarmRule(
|
||||||
|
@Body() body: FetchTypes['POST /api/admin/addAlarmRule']['req'],
|
||||||
|
): Promise<FetchTypes['POST /api/admin/addAlarmRule']['res']['data']> {
|
||||||
|
await this.alarmRuleService.addRule({
|
||||||
|
name: body.alarmRuleName,
|
||||||
|
enabled: body.enabled,
|
||||||
|
algorithmType: body.algorithmType,
|
||||||
|
relatedCameras: body.relatedCameraIds.map((id) => {
|
||||||
|
const camera = new Camera();
|
||||||
|
camera.id = id;
|
||||||
|
return camera;
|
||||||
|
}),
|
||||||
|
triggerDayOfWeek: body.triggerCondition.time.dayOfWeek,
|
||||||
|
triggerTimeStart: body.triggerCondition.time.timeRange[0],
|
||||||
|
triggerTimeEnd: body.triggerCondition.time.timeRange[1],
|
||||||
|
triggerCountMin: body.triggerCondition.count.min,
|
||||||
|
triggerCountMax: body.triggerCondition.count.max,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/api/admin/updateAlarmRule')
|
||||||
|
async updateAlarmRule(
|
||||||
|
@Body() body: FetchTypes['POST /api/admin/updateAlarmRule']['req'],
|
||||||
|
): Promise<FetchTypes['POST /api/admin/updateAlarmRule']['res']['data']> {
|
||||||
|
await this.alarmRuleService.updateRule({
|
||||||
|
id: body.alarmRuleID,
|
||||||
|
name: body.alarmRuleName,
|
||||||
|
enabled: body.enabled,
|
||||||
|
algorithmType: body.algorithmType,
|
||||||
|
relatedCameras: body.relatedCameraIds.map((id) => {
|
||||||
|
const camera = new Camera();
|
||||||
|
camera.id = id;
|
||||||
|
return camera;
|
||||||
|
}),
|
||||||
|
triggerDayOfWeek: body.triggerCondition.time.dayOfWeek,
|
||||||
|
triggerTimeStart: body.triggerCondition.time.timeRange[0],
|
||||||
|
triggerTimeEnd: body.triggerCondition.time.timeRange[1],
|
||||||
|
triggerCountMin: body.triggerCondition.count.min,
|
||||||
|
triggerCountMax: body.triggerCondition.count.max,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/api/admin/deleteAlarmRule')
|
||||||
|
async deleteAlarmRule(
|
||||||
|
@Body() body: FetchTypes['POST /api/admin/deleteAlarmRule']['req'],
|
||||||
|
): Promise<FetchTypes['POST /api/admin/deleteAlarmRule']['res']['data']> {
|
||||||
|
await this.alarmRuleService.deleteRule(body.alarmRuleID);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/api/admin/getUserList')
|
||||||
|
async getUserList(): Promise<
|
||||||
|
FetchTypes['GET /api/admin/getUserList']['res']['data']
|
||||||
|
> {
|
||||||
|
const list = await this.userService.getUserList();
|
||||||
|
return list.map((user) => ({
|
||||||
|
username: user.username,
|
||||||
|
avatarURL: user.avatarFilePath
|
||||||
|
? this.utilsService.filePathToURL(user.avatarFilePath)
|
||||||
|
: '',
|
||||||
|
email: user.email ?? '',
|
||||||
|
tel: user.tel ?? '',
|
||||||
|
nickname: user.nickname,
|
||||||
|
role: user.role,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/api/admin/addUser')
|
||||||
|
async addUser(
|
||||||
|
@Body() body: FetchTypes['POST /api/admin/addUser']['req'],
|
||||||
|
): Promise<FetchTypes['POST /api/admin/addUser']['res']['data']> {
|
||||||
|
await this.userService.addUser({
|
||||||
|
username: body.username,
|
||||||
|
password: body.password,
|
||||||
|
role: body.role,
|
||||||
|
nickname: body.nickname,
|
||||||
|
email: body.email,
|
||||||
|
tel: body.tel,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/api/admin/updateUser')
|
||||||
|
async updateUser(
|
||||||
|
@Body() body: FetchTypes['POST /api/admin/updateUser']['req'],
|
||||||
|
): Promise<FetchTypes['POST /api/admin/updateUser']['res']['data']> {
|
||||||
|
await this.userService.updateUser({
|
||||||
|
username: body.username,
|
||||||
|
password: body.newPassword,
|
||||||
|
role: body.role,
|
||||||
|
nickname: body.nickname,
|
||||||
|
email: body.email,
|
||||||
|
tel: body.tel,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/api/admin/deleteUser')
|
||||||
|
async deleteUser(
|
||||||
|
@Body() body: FetchTypes['POST /api/admin/deleteUser']['req'],
|
||||||
|
): Promise<FetchTypes['POST /api/admin/deleteUser']['res']['data']> {
|
||||||
|
await this.userService.deleteUser(body.username);
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { CommonController } from './common.controller';
|
||||||
|
|
||||||
|
describe('CommonController', () => {
|
||||||
|
let controller: CommonController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [CommonController],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<CommonController>(CommonController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CameraService } from 'src/services/camera/camera.service';
|
||||||
|
|
||||||
|
import { UserService } from 'src/services/user/user.service';
|
||||||
|
import { UtilsService } from 'src/services/utils/utils.service';
|
||||||
|
import { FetchTypes } from 'src/types/fetchTypes';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class CommonController {
|
||||||
|
constructor(
|
||||||
|
private readonly userService: UserService,
|
||||||
|
private utilService: UtilsService,
|
||||||
|
private cameraService: CameraService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('/api/user/login')
|
||||||
|
async userLogin(
|
||||||
|
@Body() data: FetchTypes['POST /api/user/login']['req'],
|
||||||
|
): Promise<FetchTypes['POST /api/user/login']['res']['data']> {
|
||||||
|
const user = await this.userService.authLogin(data.username, data.password);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return {
|
||||||
|
token: await this.userService.genToken(user),
|
||||||
|
userInfo: {
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
nickname: user.nickname,
|
||||||
|
tel: user.tel ?? '',
|
||||||
|
email: user.email ?? '',
|
||||||
|
avatarURL: user.avatarFilePath
|
||||||
|
? this.utilService.filePathToURL(user.avatarFilePath)
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else throw new ForbiddenException('用户名或密码错误');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/api/ai/getOfflineCameraList')
|
||||||
|
async getOfflineCameraList(
|
||||||
|
@Query() data: FetchTypes['GET /api/ai/getOfflineCameraList']['req'],
|
||||||
|
): Promise<FetchTypes['GET /api/ai/getOfflineCameraList']['res']['data']> {
|
||||||
|
const user = await this.userService.authLogin(
|
||||||
|
data.adminUsername,
|
||||||
|
data.password,
|
||||||
|
);
|
||||||
|
if (!user || user.role !== 'admin') throw new UnauthorizedException();
|
||||||
|
|
||||||
|
const list = await this.cameraService.getOfflineList();
|
||||||
|
return list.map((camera) => ({
|
||||||
|
cameraID: camera.id,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UserController } from './user/user.controller';
|
||||||
|
import { CommonController } from './common/common.controller';
|
||||||
|
import { AdminController } from './admin/admin.controller';
|
||||||
|
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
import { ExceptionTransformerFilter } from 'src/interceptors/exception-transformer/exception-transformer.filter';
|
||||||
|
import { ResponseTransformerInterceptor } from 'src/interceptors/response-transformer/response-transformer.interceptor';
|
||||||
|
import { ServicesModule } from 'src/services/services.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ServicesModule],
|
||||||
|
controllers: [UserController, CommonController, AdminController],
|
||||||
|
providers: [
|
||||||
|
{ provide: APP_FILTER, useClass: ExceptionTransformerFilter },
|
||||||
|
{ provide: APP_INTERCEPTOR, useClass: ResponseTransformerInterceptor },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ControllersModule {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UserController } from './user.controller';
|
||||||
|
|
||||||
|
describe('UserController', () => {
|
||||||
|
let controller: UserController;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [UserController],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
controller = module.get<UserController>(UserController);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Post,
|
||||||
|
PreconditionFailedException,
|
||||||
|
Query,
|
||||||
|
SetMetadata,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { UserInfo } from 'src/decorators/userinfo/userinfo.decorator';
|
||||||
|
import { AuthGuard } from 'src/guards/auth/auth.guard';
|
||||||
|
import { RoleGuard } from 'src/guards/role/role.guard';
|
||||||
|
import { AlarmEventService } from 'src/services/alarm-event/alarm-event.service';
|
||||||
|
import { CameraService } from 'src/services/camera/camera.service';
|
||||||
|
import { MapConfigService } from 'src/services/map-config/map-config.service';
|
||||||
|
import { AlarmRuleService } from 'src/services/alarm-rule/alarm-rule.service';
|
||||||
|
import { UserService } from 'src/services/user/user.service';
|
||||||
|
import { UtilsService } from 'src/services/utils/utils.service';
|
||||||
|
import { FetchTypes } from 'src/types/fetchTypes';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
@SetMetadata('role', 'user')
|
||||||
|
@UseGuards(AuthGuard, RoleGuard)
|
||||||
|
export class UserController {
|
||||||
|
constructor(
|
||||||
|
private cameraService: CameraService,
|
||||||
|
private alarmEventService: AlarmEventService,
|
||||||
|
private userService: UserService,
|
||||||
|
private utilsService: UtilsService,
|
||||||
|
private mapConfigService: MapConfigService,
|
||||||
|
private alarmRuleService: AlarmRuleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('/api/user/getOverviewStats')
|
||||||
|
async getOverviewStats(): Promise<{
|
||||||
|
camera: { total: number; online: number; offline: number; alarm: number };
|
||||||
|
alarmEvent: { total: number; pending: number; resolved: number };
|
||||||
|
userTotal: number;
|
||||||
|
alarmRuleTotal: number;
|
||||||
|
onlineRate: number;
|
||||||
|
trend: { date: string; total: number; pending: number }[];
|
||||||
|
cameraStatusDist: { name: string; value: number }[];
|
||||||
|
latestAlarms: {
|
||||||
|
eventID: number;
|
||||||
|
alarmTime: string;
|
||||||
|
cameraName: string;
|
||||||
|
alarmRuleName: string;
|
||||||
|
resolved: boolean;
|
||||||
|
}[];
|
||||||
|
}> {
|
||||||
|
const cameras = await this.cameraService.getList(false, true);
|
||||||
|
let online = 0;
|
||||||
|
let alarm = 0;
|
||||||
|
for (const c of cameras) {
|
||||||
|
if (c.online) online++;
|
||||||
|
if (this.cameraService.getCameraStatus(c) === 'alarm') alarm++;
|
||||||
|
}
|
||||||
|
const offline = cameras.length - online;
|
||||||
|
|
||||||
|
const allEvents = await this.alarmEventService.getList(true, true);
|
||||||
|
const pending = allEvents.list.filter((e) => !e.resolved).length;
|
||||||
|
const resolved = allEvents.total - pending;
|
||||||
|
|
||||||
|
// 近 7 天报警趋势
|
||||||
|
const days: { date: string; key: string }[] = [];
|
||||||
|
const buckets = new Map<string, { total: number; pending: number }>();
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = dayjs().subtract(i, 'day');
|
||||||
|
const key = d.format('MM-DD');
|
||||||
|
days.push({ date: key, key: d.format('YYYY-MM-DD') });
|
||||||
|
buckets.set(d.format('YYYY-MM-DD'), { total: 0, pending: 0 });
|
||||||
|
}
|
||||||
|
for (const e of allEvents.list) {
|
||||||
|
const key = dayjs(e.createdAt).format('YYYY-MM-DD');
|
||||||
|
const b = buckets.get(key);
|
||||||
|
if (b) {
|
||||||
|
b.total++;
|
||||||
|
if (!e.resolved) b.pending++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const trend = days.map((d) => ({
|
||||||
|
date: d.date,
|
||||||
|
total: buckets.get(d.key)!.total,
|
||||||
|
pending: buckets.get(d.key)!.pending,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const users = await this.userService.getUserList();
|
||||||
|
const rules = await this.alarmRuleService.getList(false, false);
|
||||||
|
|
||||||
|
const latestAlarms = allEvents.list.slice(0, 8).map((e) => ({
|
||||||
|
eventID: e.id,
|
||||||
|
alarmTime: dayjs(e.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
cameraName: e.sourceCamera?.name ?? '',
|
||||||
|
alarmRuleName: e.alarmRule?.name ?? '',
|
||||||
|
resolved: e.resolved,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
camera: { total: cameras.length, online, offline, alarm },
|
||||||
|
alarmEvent: { total: allEvents.total, pending, resolved },
|
||||||
|
userTotal: users.length,
|
||||||
|
alarmRuleTotal: rules.length,
|
||||||
|
onlineRate: cameras.length ? Number(((online / cameras.length) * 100).toFixed(1)) : 0,
|
||||||
|
trend,
|
||||||
|
cameraStatusDist: [
|
||||||
|
{ name: '在线', value: online },
|
||||||
|
{ name: '报警', value: alarm },
|
||||||
|
{ name: '离线', value: offline },
|
||||||
|
],
|
||||||
|
latestAlarms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/api/user/getCampusState')
|
||||||
|
async getCampusState(): Promise<{
|
||||||
|
cameraTotal: number;
|
||||||
|
cameraOnline: number;
|
||||||
|
cameraAlarm: number;
|
||||||
|
alarmEventPending: number;
|
||||||
|
cameraList: {
|
||||||
|
cameraName: string;
|
||||||
|
cameraID: number;
|
||||||
|
cameraStatus: string;
|
||||||
|
latlng: [number, number];
|
||||||
|
}[];
|
||||||
|
}> {
|
||||||
|
const cameraList = await this.cameraService.getList(false, true);
|
||||||
|
let cameraOnline = 0,
|
||||||
|
cameraAlarm = 0;
|
||||||
|
|
||||||
|
const cameraListRes = cameraList.map((camera) => {
|
||||||
|
if (camera.online) cameraOnline++;
|
||||||
|
if (this.cameraService.getCameraStatus(camera) === 'alarm') cameraAlarm++;
|
||||||
|
return {
|
||||||
|
cameraName: camera.name,
|
||||||
|
cameraID: camera.id,
|
||||||
|
cameraStatus: this.cameraService.getCameraStatus(camera),
|
||||||
|
latlng: [Number(camera.latitude), Number(camera.longitude)] as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
cameraTotal: cameraList.length,
|
||||||
|
cameraOnline,
|
||||||
|
cameraAlarm,
|
||||||
|
alarmEventPending: await this.alarmEventService.getPendingCount(),
|
||||||
|
cameraList: cameraListRes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/api/user/getCameraInfo')
|
||||||
|
async getCameraInfo(
|
||||||
|
@Query('cameraID')
|
||||||
|
cameraID: FetchTypes['GET /api/user/getCameraInfo']['req']['cameraID'],
|
||||||
|
): Promise<FetchTypes['GET /api/user/getCameraInfo']['res']['data']> {
|
||||||
|
const camera = await this.cameraService.getById(cameraID, true, true);
|
||||||
|
if (!camera) throw new NotFoundException('Camera not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
cameraName: camera.name,
|
||||||
|
cameraID: camera.id,
|
||||||
|
cameraStatus: this.cameraService.getCameraStatus(camera),
|
||||||
|
hlsUrl: this.cameraService.getHlsUrl(camera.id),
|
||||||
|
latlng: [Number(camera.latitude), Number(camera.longitude)] as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
],
|
||||||
|
cameraModel: camera.model,
|
||||||
|
alarmRules:
|
||||||
|
camera.alarmRules?.map((rule) => ({
|
||||||
|
alarmRuleID: rule.id,
|
||||||
|
alarmRuleName: rule.name,
|
||||||
|
})) ?? [],
|
||||||
|
alarmEvents:
|
||||||
|
camera.alarmEvents?.map((event) => ({
|
||||||
|
eventID: event.id,
|
||||||
|
alarmTime: dayjs(event.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: event?.alarmRule?.id ?? 0,
|
||||||
|
alarmRuleName: event?.alarmRule?.name ?? '',
|
||||||
|
},
|
||||||
|
resolved: event.resolved,
|
||||||
|
})) ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/api/user/resolveAlarm')
|
||||||
|
async resolveAlarm(
|
||||||
|
@Body('eventID')
|
||||||
|
eventID: FetchTypes['POST /api/user/resolveAlarm']['req']['eventID'],
|
||||||
|
): Promise<FetchTypes['POST /api/user/resolveAlarm']['res']['data']> {
|
||||||
|
await this.alarmEventService.resolve(eventID);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/api/user/getAlarmEvents')
|
||||||
|
async getAlarmEvents(
|
||||||
|
@Query()
|
||||||
|
query: FetchTypes['GET /api/user/getAlarmEvents']['req'],
|
||||||
|
): Promise<FetchTypes['GET /api/user/getAlarmEvents']['res']['data']> {
|
||||||
|
const res = await this.alarmEventService.getList(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
query.current,
|
||||||
|
query.pageSize,
|
||||||
|
{
|
||||||
|
resolved:
|
||||||
|
query.resolved === 'true'
|
||||||
|
? true
|
||||||
|
: query.resolved === 'false'
|
||||||
|
? false
|
||||||
|
: undefined,
|
||||||
|
cameraID: query.cameraID,
|
||||||
|
alarmRuleName: query.alarmType,
|
||||||
|
cameraName: query.cameraName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
list:
|
||||||
|
res.list.map((event) => ({
|
||||||
|
eventID: event.id,
|
||||||
|
alarmTime: dayjs(event.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: event?.alarmRule?.id ?? 0,
|
||||||
|
alarmRuleName: event?.alarmRule?.name ?? '',
|
||||||
|
},
|
||||||
|
resolved: event.resolved,
|
||||||
|
cameraID: event?.sourceCamera?.id ?? 0,
|
||||||
|
cameraName: event?.sourceCamera?.name ?? '',
|
||||||
|
cameraLatlng: [
|
||||||
|
Number(event?.sourceCamera?.latitude),
|
||||||
|
Number(event?.sourceCamera?.longitude),
|
||||||
|
] as [number, number],
|
||||||
|
cameraModel: event?.sourceCamera?.model ?? '',
|
||||||
|
alarmPicUrl: this.utilsService.filePathToURL(event.picFilePath),
|
||||||
|
})) ?? [],
|
||||||
|
total: res.total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/api/user/getMonitList')
|
||||||
|
async getMonitList(): Promise<
|
||||||
|
FetchTypes['GET /api/user/getMonitList']['res']['data']
|
||||||
|
> {
|
||||||
|
return (await this.cameraService.getList(false, true)).map((camera) => ({
|
||||||
|
cameraID: camera.id,
|
||||||
|
cameraName: camera.name,
|
||||||
|
cameraStatus: this.cameraService.getCameraStatus(camera),
|
||||||
|
hlsUrl: this.cameraService.getHlsUrl(camera.id),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/api/user/getUserInfo')
|
||||||
|
async getUserInfo(
|
||||||
|
@UserInfo('username') username: string,
|
||||||
|
): Promise<FetchTypes['GET /api/user/getUserInfo']['res']['data']> {
|
||||||
|
const userInfo = await this.userService.getByUsername(username);
|
||||||
|
if (!userInfo) throw new NotFoundException('User not found');
|
||||||
|
return {
|
||||||
|
username: userInfo.username,
|
||||||
|
nickname: userInfo.nickname,
|
||||||
|
email: userInfo.email ?? '',
|
||||||
|
tel: userInfo.tel ?? '',
|
||||||
|
role: userInfo.role,
|
||||||
|
avatarURL: userInfo.avatarFilePath
|
||||||
|
? this.utilsService.filePathToURL(userInfo.avatarFilePath)
|
||||||
|
: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/api/user/updateUserInfo')
|
||||||
|
async updateUserInfo(
|
||||||
|
@Body() body: FetchTypes['POST /api/user/updateUserInfo']['req'],
|
||||||
|
@UserInfo('username') username: string,
|
||||||
|
): Promise<FetchTypes['POST /api/user/updateUserInfo']['res']['data']> {
|
||||||
|
if (username !== body.username || (body as any).password) {
|
||||||
|
throw new ForbiddenException('Permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.avatarURL.startsWith('data:image')) {
|
||||||
|
const avatarFilePath = await this.utilsService.writeBase64ImageToFile(
|
||||||
|
body.avatarURL,
|
||||||
|
);
|
||||||
|
await this.userService.updateUser({
|
||||||
|
username: username,
|
||||||
|
nickname: body.nickname,
|
||||||
|
email: body.email,
|
||||||
|
tel: body.tel,
|
||||||
|
avatarFilePath,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.userService.updateUser({
|
||||||
|
username: username,
|
||||||
|
nickname: body.nickname,
|
||||||
|
email: body.email,
|
||||||
|
tel: body.tel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('/api/user/updatePassword')
|
||||||
|
async updatePassword(
|
||||||
|
@Body() body: FetchTypes['POST /api/user/updatePassword']['req'],
|
||||||
|
@UserInfo('username') username: string,
|
||||||
|
): Promise<FetchTypes['POST /api/user/updatePassword']['res']['data']> {
|
||||||
|
const user = await this.userService.getByUsername(username);
|
||||||
|
if (!user) throw new NotFoundException('User not found');
|
||||||
|
|
||||||
|
if (body.oldPassword !== user.password) {
|
||||||
|
throw new PreconditionFailedException('旧密码不正确');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.userService.updateUser({
|
||||||
|
username: username,
|
||||||
|
password: body.newPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('/api/user/getMapConfig')
|
||||||
|
async getMapConfig(): Promise<
|
||||||
|
FetchTypes['GET /api/user/getMapConfig']['res']['data']
|
||||||
|
> {
|
||||||
|
const config = await this.mapConfigService.getLatestConfig();
|
||||||
|
if (!config) throw new NotFoundException('Map config not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
layer:
|
||||||
|
config.layerType === 'imageOverlay'
|
||||||
|
? {
|
||||||
|
type: 'imageOverlay',
|
||||||
|
url: this.utilsService.filePathToURL(config.layerUrlOrPath),
|
||||||
|
bounds: config.imageLayerBounds as [
|
||||||
|
[number, number],
|
||||||
|
[number, number],
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: 'tileLayer',
|
||||||
|
url: config.layerUrlOrPath,
|
||||||
|
},
|
||||||
|
mapOptions: {
|
||||||
|
center: config.mapCenter,
|
||||||
|
zoom: config.mapZoom,
|
||||||
|
minZoom: config.minZoom,
|
||||||
|
maxZoom: config.maxZoom,
|
||||||
|
attributionControl: false,
|
||||||
|
zoomControl: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import path from 'path';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { AlarmRule } from './services/alarm-rule/alarm-rule.entity';
|
||||||
|
import { User } from './services/user/user.entity';
|
||||||
|
import { Camera } from './services/camera/camera.entity';
|
||||||
|
import { AlarmEvent } from './services/alarm-event/alarm-event.entity';
|
||||||
|
import { MapConfig } from './services/map-config/map-config.entity';
|
||||||
|
|
||||||
|
const SHARED_SECRET = 'smart-parking-platform';
|
||||||
|
|
||||||
|
const hashPassword = (plain: string): string =>
|
||||||
|
crypto
|
||||||
|
.createHmac('sha256', SHARED_SECRET)
|
||||||
|
.update(plain)
|
||||||
|
.digest('base64');
|
||||||
|
|
||||||
|
interface CameraSeed {
|
||||||
|
name: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
model: string;
|
||||||
|
rtspUrl: string;
|
||||||
|
ruleNames: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12 路摄像头:覆盖园区出入口、地下车库、车位区、通道、坡道与屋顶
|
||||||
|
const CAMERA_SEEDS: CameraSeed[] = [
|
||||||
|
{
|
||||||
|
name: '东门入口摄像机',
|
||||||
|
latitude: 36.6625,
|
||||||
|
longitude: 117.0368,
|
||||||
|
model: 'HIKVISION DS-CD2T46',
|
||||||
|
rtspUrl: 'rtsp://demo-camera/east-gate',
|
||||||
|
ruleNames: ['人员异常聚集', '车辆违停检测', '高峰期车流监测'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '地下车库A区摄像机',
|
||||||
|
latitude: 36.6608,
|
||||||
|
longitude: 117.0342,
|
||||||
|
model: 'DAHUA IPC-HFW5442',
|
||||||
|
rtspUrl: 'rtsp://demo-camera/garage-a',
|
||||||
|
ruleNames: ['车辆违停检测'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '中央车位区摄像机',
|
||||||
|
latitude: 36.6614,
|
||||||
|
longitude: 117.0351,
|
||||||
|
model: 'HIKVISION DS-2CD2386',
|
||||||
|
rtspUrl: 'rtsp://demo-camera/center-lot',
|
||||||
|
ruleNames: ['车辆违停检测', '高峰期车流监测'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '西门出口摄像机',
|
||||||
|
latitude: 36.6619,
|
||||||
|
longitude: 117.0329,
|
||||||
|
model: 'UNV IPC2124SR3',
|
||||||
|
rtspUrl: 'rtsp://demo-camera/west-exit',
|
||||||
|
ruleNames: ['人员异常聚集', '高峰期车流监测'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '非机动车通道摄像机',
|
||||||
|
latitude: 36.6631,
|
||||||
|
longitude: 117.0357,
|
||||||
|
model: 'DAHUA IPC-HDW3541',
|
||||||
|
rtspUrl: 'rtsp://demo-camera/non-motor-lane',
|
||||||
|
ruleNames: ['人员异常聚集'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '南门访客入口摄像机',
|
||||||
|
latitude: 36.6602,
|
||||||
|
longitude: 117.0361,
|
||||||
|
model: 'HIKVISION DS-2CD2T86',
|
||||||
|
rtspUrl: 'rtsp://demo-camera/south-gate',
|
||||||
|
ruleNames: ['人员异常聚集', '车辆违停检测'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '地下车库B区摄像机',
|
||||||
|
latitude: 36.6605,
|
||||||
|
longitude: 117.0335,
|
||||||
|
model: 'DAHUA IPC-HFW3441',
|
||||||
|
rtspUrl: 'rtsp://demo-camera/garage-b',
|
||||||
|
ruleNames: ['车辆违停检测', '高峰期车流监测'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '车库电梯厅摄像机',
|
||||||
|
latitude: 36.661,
|
||||||
|
longitude: 117.033,
|
||||||
|
model: 'HIKVISION DS-2CD2346',
|
||||||
|
rtspUrl: 'rtsp://demo-camera/garage-lobby',
|
||||||
|
ruleNames: ['人员异常聚集'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '北侧员工通道摄像机',
|
||||||
|
latitude: 36.6636,
|
||||||
|
longitude: 117.0346,
|
||||||
|
model: 'UNV IPC3228SR3',
|
||||||
|
rtspUrl: 'rtsp://demo-camera/north-staff',
|
||||||
|
ruleNames: ['人员异常聚集'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '东侧坡道入口摄像机',
|
||||||
|
latitude: 36.662,
|
||||||
|
longitude: 117.0375,
|
||||||
|
model: 'HIKVISION DS-2CD3T46',
|
||||||
|
rtspUrl: 'rtsp://demo-camera/east-ramp',
|
||||||
|
ruleNames: ['车辆违停检测', '高峰期车流监测'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '中央收费岗亭摄像机',
|
||||||
|
latitude: 36.6616,
|
||||||
|
longitude: 117.0348,
|
||||||
|
model: 'DAHUA IPC-HFW5442',
|
||||||
|
rtspUrl: 'rtsp://demo-camera/toll-booth',
|
||||||
|
ruleNames: ['人员异常聚集', '车辆违停检测'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '屋顶停车场摄像机',
|
||||||
|
latitude: 36.6628,
|
||||||
|
longitude: 117.034,
|
||||||
|
model: 'HIKVISION DS-2CD2T87',
|
||||||
|
rtspUrl: 'rtsp://demo-camera/roof-lot',
|
||||||
|
ruleNames: ['车辆违停检测', '高峰期车流监测'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 报警抓拍种子图(源文件 → public 下固定名)
|
||||||
|
const ALARM_PIC_SEEDS: Record<string, string> = {
|
||||||
|
'alarm-crowd.jpg': 'seed-alarm-crowd.jpg',
|
||||||
|
'alarm-parking.jpg': 'seed-alarm-parking.jpg',
|
||||||
|
'alarm-peak.jpg': 'seed-alarm-peak.jpg',
|
||||||
|
'alarm-bicycle.jpg': 'seed-alarm-bicycle.jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DatabaseSeeder implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(DatabaseSeeder.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
const publicDir = this.configService.getOrThrow<string>(
|
||||||
|
'PUBLIC_DIR_ABSOLUTE_PATH',
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const userRepo = manager.getRepository(User);
|
||||||
|
const ruleRepo = manager.getRepository(AlarmRule);
|
||||||
|
const cameraRepo = manager.getRepository(Camera);
|
||||||
|
const eventRepo = manager.getRepository(AlarmEvent);
|
||||||
|
const mapRepo = manager.getRepository(MapConfig);
|
||||||
|
|
||||||
|
if ((await userRepo.count()) === 0) {
|
||||||
|
await userRepo.save([
|
||||||
|
userRepo.create({
|
||||||
|
username: 'admin',
|
||||||
|
nickname: '系统管理员',
|
||||||
|
role: 'admin',
|
||||||
|
password: hashPassword('admin123'),
|
||||||
|
email: 'admin@parking.local',
|
||||||
|
tel: '13800000001',
|
||||||
|
}),
|
||||||
|
userRepo.create({
|
||||||
|
username: 'operator',
|
||||||
|
nickname: '停车运营员',
|
||||||
|
role: 'user',
|
||||||
|
password: hashPassword('123456'),
|
||||||
|
email: 'operator@parking.local',
|
||||||
|
tel: '13800000002',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
this.logger.log('已创建默认用户');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 规则幂等补齐(按名称)
|
||||||
|
const allDays = [1, 2, 3, 4, 5, 6, 7];
|
||||||
|
const ruleDefs = [
|
||||||
|
{
|
||||||
|
name: '人员异常聚集',
|
||||||
|
enabled: true,
|
||||||
|
algorithmType: 'body' as const,
|
||||||
|
triggerDayOfWeek: allDays,
|
||||||
|
triggerTimeStart: '00:00:00',
|
||||||
|
triggerTimeEnd: '23:59:59',
|
||||||
|
triggerCountMin: 3,
|
||||||
|
triggerCountMax: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '车辆违停检测',
|
||||||
|
enabled: true,
|
||||||
|
algorithmType: 'vehicle' as const,
|
||||||
|
triggerDayOfWeek: allDays,
|
||||||
|
triggerTimeStart: '00:00:00',
|
||||||
|
triggerTimeEnd: '23:59:59',
|
||||||
|
triggerCountMin: 1,
|
||||||
|
triggerCountMax: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '高峰期车流监测',
|
||||||
|
enabled: true,
|
||||||
|
algorithmType: 'vehicle' as const,
|
||||||
|
triggerDayOfWeek: [1, 2, 3, 4, 5],
|
||||||
|
triggerTimeStart: '07:30:00',
|
||||||
|
triggerTimeEnd: '09:30:00',
|
||||||
|
triggerCountMin: 5,
|
||||||
|
triggerCountMax: -1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ruleByName = new Map<string, AlarmRule>();
|
||||||
|
for (const def of ruleDefs) {
|
||||||
|
let rule = await ruleRepo.findOne({ where: { name: def.name } });
|
||||||
|
if (!rule) {
|
||||||
|
rule = await ruleRepo.save(ruleRepo.create(def));
|
||||||
|
this.logger.log(`已创建报警规则:${def.name}`);
|
||||||
|
}
|
||||||
|
ruleByName.set(def.name, rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 摄像头幂等补齐到 12 路
|
||||||
|
for (const seed of CAMERA_SEEDS) {
|
||||||
|
let camera = await cameraRepo.findOne({
|
||||||
|
where: { name: seed.name },
|
||||||
|
relations: { alarmRules: true },
|
||||||
|
});
|
||||||
|
const alarmRules = seed.ruleNames
|
||||||
|
.map((n) => ruleByName.get(n))
|
||||||
|
.filter((r): r is AlarmRule => Boolean(r));
|
||||||
|
|
||||||
|
if (!camera) {
|
||||||
|
await cameraRepo.save(
|
||||||
|
cameraRepo.create({
|
||||||
|
name: seed.name,
|
||||||
|
latitude: seed.latitude,
|
||||||
|
longitude: seed.longitude,
|
||||||
|
model: seed.model,
|
||||||
|
rtspUrl: seed.rtspUrl,
|
||||||
|
online: false,
|
||||||
|
alarmRules,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 已存在则补齐规则关联(不覆盖在线状态)
|
||||||
|
camera.alarmRules = alarmRules;
|
||||||
|
await cameraRepo.save(camera);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.log('摄像头清单已同步(12 路)');
|
||||||
|
|
||||||
|
// 复制报警抓拍图到 public 目录
|
||||||
|
const seedAssetDir = path.join(__dirname, '..', 'seed-assets');
|
||||||
|
await fs.ensureDir(publicDir);
|
||||||
|
const publicPicPaths: string[] = [];
|
||||||
|
for (const [srcName, destName] of Object.entries(ALARM_PIC_SEEDS)) {
|
||||||
|
const dest = path.join(publicDir, destName);
|
||||||
|
try {
|
||||||
|
await fs.copy(path.join(seedAssetDir, srcName), dest, {
|
||||||
|
overwrite: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.logger.warn(`种子抓拍图缺失:${srcName}`);
|
||||||
|
}
|
||||||
|
if (await fs.pathExists(dest)) publicPicPaths.push(dest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 历史报警事件:仅在无任何事件时播种,避免重复
|
||||||
|
if ((await eventRepo.count()) === 0 && publicPicPaths.length > 0) {
|
||||||
|
const cameras = await cameraRepo.find();
|
||||||
|
const rules = [...ruleByName.values()];
|
||||||
|
const events: AlarmEvent[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
const DAY = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// 过去 7 天约 42 条事件,时间升序
|
||||||
|
for (let i = 0; i < 42; i += 1) {
|
||||||
|
const offsetMin = Math.floor((42 - i) * (7 * 24 * 60) / 42);
|
||||||
|
const createdAt = new Date(now - offsetMin * 60 * 1000);
|
||||||
|
const camera = cameras[i % cameras.length];
|
||||||
|
const rule = rules[i % rules.length];
|
||||||
|
const picFilePath =
|
||||||
|
publicPicPaths[
|
||||||
|
rule.algorithmType === 'body'
|
||||||
|
? i % 2 === 0
|
||||||
|
? 0
|
||||||
|
: 3
|
||||||
|
: i % 2 === 0
|
||||||
|
? 1
|
||||||
|
: 2
|
||||||
|
] ?? publicPicPaths[i % publicPicPaths.length];
|
||||||
|
|
||||||
|
events.push(
|
||||||
|
eventRepo.create({
|
||||||
|
resolved: i % 10 < 7, // 约 70% 已处理,保留部分未处理
|
||||||
|
picFilePath,
|
||||||
|
createdAt,
|
||||||
|
sourceCamera: camera,
|
||||||
|
alarmRule: rule,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await eventRepo.save(events);
|
||||||
|
this.logger.log('已播种过去 7 天历史报警事件 42 条');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((await mapRepo.count()) === 0) {
|
||||||
|
await mapRepo.save(
|
||||||
|
mapRepo.create({
|
||||||
|
layerType: 'tileLayer',
|
||||||
|
layerUrlOrPath: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
mapCenter: [36.6615, 117.035],
|
||||||
|
mapZoom: 17,
|
||||||
|
minZoom: 3,
|
||||||
|
maxZoom: 19,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.logger.log('已创建默认地图配置');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const UserInfo = createParamDecorator(
|
||||||
|
(data: string, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
|
||||||
|
return data ? user?.[data] : user;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import assert from 'assert';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
import { Request } from 'express';
|
||||||
|
import { User } from 'src/services/user/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private jwtService: JwtService,
|
||||||
|
@Inject(CACHE_MANAGER) private cache: Cache,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const token = this.extractTokenFromHeader(request);
|
||||||
|
if (!token) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync<User>(token);
|
||||||
|
assert(token === (await this.cache.get(payload.username)));
|
||||||
|
|
||||||
|
request['user'] = payload;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTokenFromHeader(request: Request): string | undefined {
|
||||||
|
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
return type === 'Bearer' ? token : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RoleGuard implements CanActivate {
|
||||||
|
constructor(private reflector: Reflector) {}
|
||||||
|
|
||||||
|
canActivate(
|
||||||
|
context: ExecutionContext,
|
||||||
|
): boolean | Promise<boolean> | Observable<boolean> {
|
||||||
|
const requiredRole = this.reflector.get('role', context.getClass());
|
||||||
|
const userRole = context.switchToHttp().getRequest()?.user?.role;
|
||||||
|
return userRole === 'admin' || userRole === requiredRole;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { HttpAdapterHost } from '@nestjs/core';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class ExceptionTransformerFilter implements ExceptionFilter {
|
||||||
|
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const { httpAdapter } = this.httpAdapterHost;
|
||||||
|
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
|
||||||
|
const httpStatus =
|
||||||
|
exception instanceof HttpException
|
||||||
|
? exception.getStatus()
|
||||||
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
|
Logger.error(exception);
|
||||||
|
console.error(exception);
|
||||||
|
const exceptionMessage =
|
||||||
|
exception instanceof HttpException
|
||||||
|
? exception.message
|
||||||
|
: String(exception);
|
||||||
|
|
||||||
|
const responseBody = {
|
||||||
|
success: false,
|
||||||
|
message: exceptionMessage,
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ResponseTransformerInterceptor } from './response-transformer.interceptor';
|
||||||
|
|
||||||
|
describe('ResponseTransformerInterceptor', () => {
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(new ResponseTransformerInterceptor()).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Observable, map } from 'rxjs';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ResponseTransformerInterceptor implements NestInterceptor {
|
||||||
|
intercept(_context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||||
|
return next
|
||||||
|
.handle()
|
||||||
|
.pipe(map((data) => ({ success: true, message: '', data })));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||||
|
cors: true,
|
||||||
|
});
|
||||||
|
app.useBodyParser('json', { limit: '50mb' });
|
||||||
|
await app.listen(3000);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Camera } from '../camera/camera.entity';
|
||||||
|
import { AlarmRule } from '../alarm-rule/alarm-rule.entity';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class AlarmEvent {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int' })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bool', default: false })
|
||||||
|
resolved: boolean;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
picFilePath: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn()
|
||||||
|
deletedAt: Date;
|
||||||
|
|
||||||
|
@ManyToOne(() => Camera, (camera) => camera.alarmEvents)
|
||||||
|
sourceCamera?: Camera;
|
||||||
|
|
||||||
|
@ManyToOne(() => AlarmRule, (alarmRule) => alarmRule.alarmEvents)
|
||||||
|
@JoinColumn()
|
||||||
|
alarmRule?: AlarmRule;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AlarmEventService } from './alarm-event.service';
|
||||||
|
|
||||||
|
describe('AlarmEventService', () => {
|
||||||
|
let service: AlarmEventService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [AlarmEventService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AlarmEventService>(AlarmEventService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { AlarmEvent } from './alarm-event.entity';
|
||||||
|
import { FindManyOptions, Like, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AlarmEventService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AlarmEvent)
|
||||||
|
private alarmEventRepo: Repository<AlarmEvent>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getList(
|
||||||
|
withSourceCamera = false,
|
||||||
|
withAlarmRule = false,
|
||||||
|
current?: number,
|
||||||
|
pageSize?: number,
|
||||||
|
search?: {
|
||||||
|
resolved?: boolean;
|
||||||
|
cameraName?: string;
|
||||||
|
alarmRuleName?: string;
|
||||||
|
cameraID?: number;
|
||||||
|
},
|
||||||
|
): Promise<{ total: number; list: AlarmEvent[] }> {
|
||||||
|
const findOptions: FindManyOptions<AlarmEvent> = {
|
||||||
|
relations: {
|
||||||
|
sourceCamera: withSourceCamera,
|
||||||
|
alarmRule: withAlarmRule,
|
||||||
|
},
|
||||||
|
skip: current && pageSize ? (current - 1) * pageSize : undefined,
|
||||||
|
take: current && pageSize ? pageSize : undefined,
|
||||||
|
where: {
|
||||||
|
resolved: search?.resolved,
|
||||||
|
sourceCamera: {
|
||||||
|
name: search?.cameraName ? Like(`%${search.cameraName}%`) : undefined,
|
||||||
|
id: search?.cameraID,
|
||||||
|
},
|
||||||
|
alarmRule: {
|
||||||
|
name: search?.alarmRuleName
|
||||||
|
? Like(`%${search.alarmRuleName}%`)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const total = await this.alarmEventRepo.count(findOptions);
|
||||||
|
const list = await this.alarmEventRepo.find({
|
||||||
|
...findOptions,
|
||||||
|
order: {
|
||||||
|
id: 'DESC',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { total, list };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getResolvedList() {
|
||||||
|
return await this.alarmEventRepo.find({ where: { resolved: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPenddingList() {
|
||||||
|
return await this.alarmEventRepo.find({ where: { resolved: false } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingCount() {
|
||||||
|
return await this.alarmEventRepo.count({ where: { resolved: false } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolve(id: number) {
|
||||||
|
await this.alarmEventRepo.update({ id }, { resolved: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addEvent(event: Partial<AlarmEvent>) {
|
||||||
|
return await this.alarmEventRepo.save(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinTable,
|
||||||
|
ManyToMany,
|
||||||
|
OneToMany,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { Camera } from '../camera/camera.entity';
|
||||||
|
import { AlarmEvent } from '../alarm-event/alarm-event.entity';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class AlarmRule {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int' })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
enabled: boolean;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
algorithmType: 'body' | 'vehicle';
|
||||||
|
|
||||||
|
@Column({ type: 'json' })
|
||||||
|
triggerDayOfWeek: number[];
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
triggerTimeStart: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
triggerTimeEnd: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
triggerCountMin: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
triggerCountMax: number;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn()
|
||||||
|
deletedAt: Date;
|
||||||
|
|
||||||
|
@ManyToMany(() => Camera, (camera) => camera.alarmRules)
|
||||||
|
@JoinTable()
|
||||||
|
relatedCameras?: Camera[];
|
||||||
|
|
||||||
|
@OneToMany(() => AlarmEvent, (alarmEvent) => alarmEvent.alarmRule)
|
||||||
|
alarmEvents?: AlarmEvent[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AlarmRuleService } from './alarm-rule.service';
|
||||||
|
|
||||||
|
describe('AlarmRuleService', () => {
|
||||||
|
let service: AlarmRuleService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [AlarmRuleService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AlarmRuleService>(AlarmRuleService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Inject, Injectable, forwardRef } from '@nestjs/common';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AlarmRule } from './alarm-rule.entity';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { AiEndGateway } from 'src/ws-gateways/ai-end/ai-end.gateway';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AlarmRuleService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AlarmRule) private alarmRuleRepo: Repository<AlarmRule>,
|
||||||
|
@Inject(forwardRef(() => AiEndGateway))
|
||||||
|
private aiEndGateway: AiEndGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getList(
|
||||||
|
withRelatedCameras = false,
|
||||||
|
withAlarmEvents = false,
|
||||||
|
): Promise<AlarmRule[]> {
|
||||||
|
return await this.alarmRuleRepo.find({
|
||||||
|
relations: {
|
||||||
|
relatedCameras: withRelatedCameras,
|
||||||
|
alarmEvents: withAlarmEvents,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(
|
||||||
|
ruleID: number,
|
||||||
|
withRelatedCameras = false,
|
||||||
|
withAlarmEvents = false,
|
||||||
|
): Promise<AlarmRule | null> {
|
||||||
|
return await this.alarmRuleRepo.findOne({
|
||||||
|
where: { id: ruleID },
|
||||||
|
relations: {
|
||||||
|
relatedCameras: withRelatedCameras,
|
||||||
|
alarmEvents: withAlarmEvents,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addRule(rule: Partial<AlarmRule>) {
|
||||||
|
const savedRule = await this.alarmRuleRepo.save(rule);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
savedRule.relatedCameras?.map((camera) =>
|
||||||
|
this.aiEndGateway.notifyCameraConfigChange(camera.id),
|
||||||
|
) ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRule(rule: Partial<AlarmRule>) {
|
||||||
|
if (!rule.id) return;
|
||||||
|
const originalRule = await this.getById(rule.id, true);
|
||||||
|
const savedRule = await this.alarmRuleRepo.save(rule);
|
||||||
|
|
||||||
|
const relatedCameraIds = [
|
||||||
|
...new Set(
|
||||||
|
[
|
||||||
|
...(originalRule?.relatedCameras ?? []),
|
||||||
|
...(savedRule?.relatedCameras ?? []),
|
||||||
|
].map((camera) => camera.id),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
relatedCameraIds.map((cameraId) =>
|
||||||
|
this.aiEndGateway.notifyCameraConfigChange(cameraId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRule(ruleID: number) {
|
||||||
|
const rule = await this.getById(ruleID, true);
|
||||||
|
if (!rule) return;
|
||||||
|
await this.alarmRuleRepo.softDelete({ id: ruleID });
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
rule.relatedCameras?.map((camera) =>
|
||||||
|
this.aiEndGateway.notifyCameraConfigChange(camera.id),
|
||||||
|
) ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Entity,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToMany,
|
||||||
|
OneToMany,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import { AlarmEvent } from '../alarm-event/alarm-event.entity';
|
||||||
|
import { AlarmRule } from '../alarm-rule/alarm-rule.entity';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class Camera {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int' })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Column({ type: 'bool', default: false })
|
||||||
|
online: boolean;
|
||||||
|
|
||||||
|
@Column({ default: '' })
|
||||||
|
rtspUrl: string;
|
||||||
|
|
||||||
|
@Column({ type: 'double' })
|
||||||
|
latitude: number;
|
||||||
|
|
||||||
|
@Column({ type: 'double' })
|
||||||
|
longitude: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
model: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn()
|
||||||
|
deletedAt: Date;
|
||||||
|
|
||||||
|
@OneToMany(() => AlarmEvent, (alarmEvent) => alarmEvent.sourceCamera)
|
||||||
|
@JoinColumn()
|
||||||
|
alarmEvents?: AlarmEvent[];
|
||||||
|
|
||||||
|
@ManyToMany(() => AlarmRule, (alarmRule) => alarmRule.relatedCameras)
|
||||||
|
alarmRules?: AlarmRule[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { CameraService } from './camera.service';
|
||||||
|
|
||||||
|
describe('CameraService', () => {
|
||||||
|
let service: CameraService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [CameraService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<CameraService>(CameraService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Inject, Injectable, forwardRef } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { Camera } from './camera.entity';
|
||||||
|
import { AiEndGateway } from 'src/ws-gateways/ai-end/ai-end.gateway';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CameraService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Camera) private cameraRepo: Repository<Camera>,
|
||||||
|
@Inject(forwardRef(() => AiEndGateway))
|
||||||
|
private aiEndGateway: AiEndGateway,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getList(
|
||||||
|
withAlarmRules = false,
|
||||||
|
withAlarmEvents = false,
|
||||||
|
): Promise<Camera[]> {
|
||||||
|
return await this.cameraRepo.find({
|
||||||
|
relations: { alarmRules: withAlarmRules, alarmEvents: withAlarmEvents },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOfflineList(): Promise<Camera[]> {
|
||||||
|
return await this.cameraRepo.find({
|
||||||
|
where: { online: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(
|
||||||
|
cameraID: number,
|
||||||
|
withAlarmRules = false,
|
||||||
|
withAlarmEvents = false,
|
||||||
|
): Promise<Camera | null> {
|
||||||
|
return await this.cameraRepo.findOne({
|
||||||
|
where: { id: cameraID },
|
||||||
|
relations: {
|
||||||
|
alarmRules: withAlarmRules,
|
||||||
|
alarmEvents: withAlarmEvents ? { alarmRule: true } : false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCameraStatus(camera: Camera): 'normal' | 'offline' | 'alarm' {
|
||||||
|
if (camera.online === false) return 'offline';
|
||||||
|
if (
|
||||||
|
camera.alarmEvents?.filter((event) => event.resolved === false)?.length ??
|
||||||
|
0 > 0
|
||||||
|
)
|
||||||
|
return 'alarm';
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCamera(camera: Partial<Camera>) {
|
||||||
|
const savedCamera = await this.cameraRepo.save(camera);
|
||||||
|
|
||||||
|
await this.aiEndGateway.notifyCameraConfigChange(savedCamera.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCamera(camera: Partial<Camera>) {
|
||||||
|
if (!camera.id) return;
|
||||||
|
const savedCamera = await this.cameraRepo.save(camera);
|
||||||
|
|
||||||
|
await this.aiEndGateway.notifyCameraConfigChange(savedCamera.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCamera(cameraID: number) {
|
||||||
|
await this.cameraRepo.softDelete({ id: cameraID });
|
||||||
|
|
||||||
|
await this.aiEndGateway.notifyCameraConfigChange(cameraID);
|
||||||
|
}
|
||||||
|
|
||||||
|
getHlsUrl(cameraID: number): string {
|
||||||
|
return '/hls/' + cameraID + '.m3u8';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class MapConfig {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int' })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
layerType: 'imageOverlay' | 'tileLayer';
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
layerUrlOrPath: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: 'json',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
imageLayerBounds?: [[number, number], [number, number]];
|
||||||
|
|
||||||
|
@Column({ type: 'json' })
|
||||||
|
mapCenter: [number, number];
|
||||||
|
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
mapZoom: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 0 })
|
||||||
|
minZoom: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 18 })
|
||||||
|
maxZoom: number;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn()
|
||||||
|
deletedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { MapConfigService } from './map-config.service';
|
||||||
|
|
||||||
|
describe('MapConfigService', () => {
|
||||||
|
let service: MapConfigService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [MapConfigService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<MapConfigService>(MapConfigService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { MapConfig } from './map-config.entity';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MapConfigService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(MapConfig) private mapConfigRepo: Repository<MapConfig>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getLatestConfig(): Promise<MapConfig | null> {
|
||||||
|
return (
|
||||||
|
await this.mapConfigRepo.find({
|
||||||
|
order: { id: 'DESC' },
|
||||||
|
take: 1,
|
||||||
|
})
|
||||||
|
)?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateConfig(config: Partial<MapConfig>) {
|
||||||
|
await this.mapConfigRepo.save(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Module, Provider } from '@nestjs/common';
|
||||||
|
import { UserService } from './user/user.service';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { User } from './user/user.entity';
|
||||||
|
import { EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
|
||||||
|
import { Camera } from './camera/camera.entity';
|
||||||
|
import { AlarmEvent } from './alarm-event/alarm-event.entity';
|
||||||
|
import { AlarmRule } from './alarm-rule/alarm-rule.entity';
|
||||||
|
import { CameraService } from './camera/camera.service';
|
||||||
|
import { AlarmEventService } from './alarm-event/alarm-event.service';
|
||||||
|
import { AlarmRuleService } from './alarm-rule/alarm-rule.service';
|
||||||
|
import { UtilsService } from './utils/utils.service';
|
||||||
|
import { MapConfigService } from './map-config/map-config.service';
|
||||||
|
import { MapConfig } from './map-config/map-config.entity';
|
||||||
|
|
||||||
|
const entities: EntityClassOrSchema[] = [
|
||||||
|
User,
|
||||||
|
Camera,
|
||||||
|
AlarmEvent,
|
||||||
|
AlarmRule,
|
||||||
|
MapConfig,
|
||||||
|
];
|
||||||
|
|
||||||
|
const services: Provider[] = [
|
||||||
|
UserService,
|
||||||
|
CameraService,
|
||||||
|
AlarmEventService,
|
||||||
|
AlarmRuleService,
|
||||||
|
UtilsService,
|
||||||
|
MapConfigService,
|
||||||
|
];
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature(entities)],
|
||||||
|
providers: services,
|
||||||
|
exports: services,
|
||||||
|
})
|
||||||
|
export class ServicesModule {}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
DeleteDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn({ type: 'int' })
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
username: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
nickname: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
role: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
avatarFilePath?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
tel?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
updatedAt: Date;
|
||||||
|
|
||||||
|
@DeleteDateColumn()
|
||||||
|
deletedAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
|
describe('UserService', () => {
|
||||||
|
let service: UserService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [UserService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<UserService>(UserService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { User } from './user.entity';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { Cache } from 'cache-manager';
|
||||||
|
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User) private userRepo: Repository<User>,
|
||||||
|
private jwtService: JwtService,
|
||||||
|
@Inject(CACHE_MANAGER) private cache: Cache,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getUserList(): Promise<User[]> {
|
||||||
|
return await this.userRepo.find({
|
||||||
|
select: { password: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async authLogin(username: string, password: string): Promise<User | null> {
|
||||||
|
return await this.userRepo.findOne({ where: { username, password } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async genToken(user: User): Promise<string> {
|
||||||
|
const token = await this.jwtService.signAsync({ ...user });
|
||||||
|
await this.cache.set(user.username, token);
|
||||||
|
return 'Bearer ' + token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByUsername(username: string): Promise<User | null> {
|
||||||
|
return await this.userRepo.findOne({ where: { username } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(user: Partial<User>) {
|
||||||
|
if (!user.username) return;
|
||||||
|
await this.userRepo.update({ username: user.username }, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addUser(user: Partial<User>) {
|
||||||
|
return await this.userRepo.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(username: string) {
|
||||||
|
await this.userRepo.softDelete({ username });
|
||||||
|
await this.cache.del(username);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { UtilsService } from './utils.service';
|
||||||
|
|
||||||
|
describe('UtilsService', () => {
|
||||||
|
let service: UtilsService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [UtilsService],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<UtilsService>(UtilsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UtilsService {
|
||||||
|
constructor(private configService: ConfigService) {}
|
||||||
|
|
||||||
|
private publicDirPath = this.configService.getOrThrow<string>(
|
||||||
|
'PUBLIC_DIR_ABSOLUTE_PATH',
|
||||||
|
);
|
||||||
|
|
||||||
|
private URLToPublicDir = '/public';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write image file to the public path from base64
|
||||||
|
* @param base64Image base64 file data
|
||||||
|
* @returns the written file path with the randomly generated file name
|
||||||
|
*/
|
||||||
|
async writeBase64ImageToFile(base64Image: string): Promise<string> {
|
||||||
|
const fileExt = /data:image\/(.+?);base64,/.exec(base64Image)?.[1];
|
||||||
|
if (!fileExt) throw new Error('Invalid file type');
|
||||||
|
const base64Data = base64Image.replace(/data:image\/(.+?);base64,/, '');
|
||||||
|
const dataBuffer = Buffer.from(base64Data, 'base64');
|
||||||
|
const filePath = path.join(
|
||||||
|
this.publicDirPath,
|
||||||
|
`${randomBytes(10).toString('hex')}.${fileExt}`,
|
||||||
|
);
|
||||||
|
await fs.ensureDir(this.publicDirPath);
|
||||||
|
await fs.writeFile(filePath, dataBuffer);
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
filePathToURL(filePath: string): string {
|
||||||
|
return filePath.replace(this.publicDirPath, this.URLToPublicDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/ban-types */
|
||||||
|
|
||||||
|
// Stay consistent with the frontend/src/services/serviceTypes.ts
|
||||||
|
|
||||||
|
export interface FetchTypes {
|
||||||
|
'POST /api/user/login': {
|
||||||
|
req: { username: string; password: string };
|
||||||
|
res: {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
token: string;
|
||||||
|
userInfo: {
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
role: string;
|
||||||
|
avatarURL: string;
|
||||||
|
tel: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'GET /api/user/getOverviewStats': {
|
||||||
|
req: void;
|
||||||
|
res: {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
camera: { total: number; online: number; offline: number; alarm: number };
|
||||||
|
alarmEvent: { total: number; pending: number; resolved: number };
|
||||||
|
userTotal: number;
|
||||||
|
alarmRuleTotal: number;
|
||||||
|
onlineRate: number;
|
||||||
|
trend: { date: string; total: number; pending: number }[];
|
||||||
|
cameraStatusDist: { name: string; value: number }[];
|
||||||
|
latestAlarms: {
|
||||||
|
eventID: number;
|
||||||
|
alarmTime: string;
|
||||||
|
cameraName: string;
|
||||||
|
alarmRuleName: string;
|
||||||
|
resolved: boolean;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'GET /api/user/getCampusState': {
|
||||||
|
req: void;
|
||||||
|
res: {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: {
|
||||||
|
cameraTotal: number;
|
||||||
|
cameraOnline: number;
|
||||||
|
cameraAlarm: number;
|
||||||
|
alarmEventPending: number;
|
||||||
|
cameraList: {
|
||||||
|
cameraName: string;
|
||||||
|
cameraID: number;
|
||||||
|
cameraStatus: string;
|
||||||
|
latlng: [number, number];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'GET /api/user/getCameraInfo': {
|
||||||
|
req: { cameraID: number };
|
||||||
|
res: {
|
||||||
|
data: {
|
||||||
|
cameraName: string;
|
||||||
|
cameraID: number;
|
||||||
|
cameraStatus: string;
|
||||||
|
hlsUrl: string;
|
||||||
|
latlng: [number, number];
|
||||||
|
cameraModel: string;
|
||||||
|
alarmRules: {
|
||||||
|
alarmRuleID: number;
|
||||||
|
alarmRuleName: string;
|
||||||
|
}[];
|
||||||
|
alarmEvents: {
|
||||||
|
eventID: number;
|
||||||
|
alarmTime: string;
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: number;
|
||||||
|
alarmRuleName: string;
|
||||||
|
};
|
||||||
|
resolved: boolean;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'POST /api/user/resolveAlarm': {
|
||||||
|
req: { eventID: number };
|
||||||
|
res: { success: boolean; message: string; data: {} };
|
||||||
|
};
|
||||||
|
'GET /api/user/getAlarmEvents': {
|
||||||
|
req: {
|
||||||
|
cameraID?: number;
|
||||||
|
current?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
alarmType?: string;
|
||||||
|
cameraName?: string;
|
||||||
|
resolved?: string;
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {
|
||||||
|
total: number;
|
||||||
|
list: {
|
||||||
|
eventID: number;
|
||||||
|
alarmTime: string;
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: number;
|
||||||
|
alarmRuleName: string;
|
||||||
|
};
|
||||||
|
resolved: boolean;
|
||||||
|
cameraID: number;
|
||||||
|
cameraName: string;
|
||||||
|
cameraLatlng: [number, number];
|
||||||
|
cameraModel: string;
|
||||||
|
alarmPicUrl: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'GET /api/user/getMonitList': {
|
||||||
|
req: void;
|
||||||
|
res: {
|
||||||
|
data: {
|
||||||
|
cameraName: string;
|
||||||
|
cameraID: number;
|
||||||
|
cameraStatus: string;
|
||||||
|
hlsUrl: string;
|
||||||
|
}[];
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'GET /api/user/getUserInfo': {
|
||||||
|
req: void;
|
||||||
|
res: {
|
||||||
|
data: {
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
role: string;
|
||||||
|
avatarURL: string;
|
||||||
|
tel: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'POST /api/user/updateUserInfo': {
|
||||||
|
req: {
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
avatarURL: string;
|
||||||
|
tel: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'POST /api/user/updatePassword': {
|
||||||
|
req: {
|
||||||
|
oldPassword: string;
|
||||||
|
newPassword: string;
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'GET /api/user/getMapConfig': {
|
||||||
|
req: void;
|
||||||
|
res: {
|
||||||
|
data: {
|
||||||
|
mapOptions: {
|
||||||
|
center: [number, number];
|
||||||
|
zoom: number;
|
||||||
|
minZoom: number;
|
||||||
|
maxZoom: number;
|
||||||
|
attributionControl: boolean;
|
||||||
|
zoomControl: boolean;
|
||||||
|
};
|
||||||
|
layer:
|
||||||
|
| {
|
||||||
|
type: 'imageOverlay';
|
||||||
|
url: string;
|
||||||
|
bounds: [[number, number], [number, number]];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tileLayer';
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'POST /api/admin/updateMapConfig': {
|
||||||
|
req: {
|
||||||
|
mapOptions: {
|
||||||
|
center: [number, number];
|
||||||
|
zoom: number;
|
||||||
|
minZoom: number;
|
||||||
|
maxZoom: number;
|
||||||
|
attributionControl: boolean;
|
||||||
|
zoomControl: boolean;
|
||||||
|
};
|
||||||
|
layer:
|
||||||
|
| {
|
||||||
|
type: 'imageOverlay';
|
||||||
|
url: string;
|
||||||
|
bounds: [[number, number], [number, number]];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'tileLayer';
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'GET /api/admin/getCameraList': {
|
||||||
|
req: void;
|
||||||
|
res: {
|
||||||
|
data: {
|
||||||
|
cameraName: string;
|
||||||
|
cameraID: number;
|
||||||
|
cameraStatus: string;
|
||||||
|
hlsUrl: string;
|
||||||
|
rtspUrl: string;
|
||||||
|
latlng: [number, number];
|
||||||
|
cameraModel: string;
|
||||||
|
alarmRules: {
|
||||||
|
alarmRuleID: number;
|
||||||
|
alarmRuleName: string;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'POST /api/admin/addCamera': {
|
||||||
|
req: {
|
||||||
|
cameraName: string;
|
||||||
|
latlng: [number, number];
|
||||||
|
cameraModel: string;
|
||||||
|
alarmRuleIDs: number[];
|
||||||
|
rtspUrl: string;
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'POST /api/admin/updateCamera': {
|
||||||
|
req: {
|
||||||
|
cameraID: number;
|
||||||
|
cameraName: string;
|
||||||
|
latlng: [number, number];
|
||||||
|
cameraModel: string;
|
||||||
|
alarmRuleIDs: number[];
|
||||||
|
rtspUrl: string;
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'POST /api/admin/deleteCamera': {
|
||||||
|
req: {
|
||||||
|
cameraID: number;
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'GET /api/admin/getAlarmRuleList': {
|
||||||
|
req: void;
|
||||||
|
res: {
|
||||||
|
data: {
|
||||||
|
alarmRuleID: number;
|
||||||
|
alarmRuleName: string;
|
||||||
|
relatedCameras: {
|
||||||
|
cameraName: string;
|
||||||
|
cameraID: number;
|
||||||
|
}[];
|
||||||
|
enabled: boolean;
|
||||||
|
algorithmType: 'body' | 'vehicle';
|
||||||
|
triggerCondition: {
|
||||||
|
time: {
|
||||||
|
dayOfWeek: number[];
|
||||||
|
timeRange: [string, string];
|
||||||
|
};
|
||||||
|
count: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'POST /api/admin/addAlarmRule': {
|
||||||
|
req: {
|
||||||
|
relatedCameraIds: number[];
|
||||||
|
alarmRuleName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
algorithmType: 'body' | 'vehicle';
|
||||||
|
triggerCondition: {
|
||||||
|
time: {
|
||||||
|
dayOfWeek: number[];
|
||||||
|
timeRange: [string, string];
|
||||||
|
};
|
||||||
|
count: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'POST /api/admin/updateAlarmRule': {
|
||||||
|
req: {
|
||||||
|
alarmRuleID: number;
|
||||||
|
alarmRuleName: string;
|
||||||
|
relatedCameraIds: number[];
|
||||||
|
enabled: boolean;
|
||||||
|
algorithmType: 'body' | 'vehicle';
|
||||||
|
triggerCondition: {
|
||||||
|
time: {
|
||||||
|
dayOfWeek: number[];
|
||||||
|
timeRange: [string, string];
|
||||||
|
};
|
||||||
|
count: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'POST /api/admin/deleteAlarmRule': {
|
||||||
|
req: {
|
||||||
|
alarmRuleID: number;
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'GET /api/admin/getUserList': {
|
||||||
|
req: void;
|
||||||
|
res: {
|
||||||
|
data: {
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
role: string;
|
||||||
|
avatarURL: string;
|
||||||
|
tel: string;
|
||||||
|
email: string;
|
||||||
|
}[];
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'POST /api/admin/addUser': {
|
||||||
|
req: {
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
nickname: string;
|
||||||
|
tel?: string;
|
||||||
|
email?: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'POST /api/admin/updateUser': {
|
||||||
|
req: {
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
nickname: string;
|
||||||
|
tel?: string;
|
||||||
|
email?: string;
|
||||||
|
newPassword?: string;
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'POST /api/admin/deleteUser': {
|
||||||
|
req: {
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {};
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
'GET /api/ai/getOfflineCameraList': {
|
||||||
|
req: {
|
||||||
|
adminUsername: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
res: {
|
||||||
|
data: {
|
||||||
|
cameraID: number;
|
||||||
|
}[];
|
||||||
|
message: string;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type fetchUrls = keyof FetchTypes extends `${string} ${infer Url}`
|
||||||
|
? Url
|
||||||
|
: never;
|
||||||
|
|
||||||
|
declare module '@nestjs/common' {
|
||||||
|
export const Get: (path?: fetchUrls) => MethodDecorator;
|
||||||
|
export const Post: (path?: fetchUrls) => MethodDecorator;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { AiEndGateway } from './ai-end.gateway';
|
||||||
|
|
||||||
|
describe('AiEndGateway', () => {
|
||||||
|
let gateway: AiEndGateway;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [AiEndGateway],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
gateway = module.get<AiEndGateway>(AiEndGateway);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(gateway).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { Inject, forwardRef } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
WebSocketServer,
|
||||||
|
SubscribeMessage,
|
||||||
|
WebSocketGateway,
|
||||||
|
OnGatewayConnection,
|
||||||
|
OnGatewayDisconnect,
|
||||||
|
MessageBody,
|
||||||
|
ConnectedSocket,
|
||||||
|
} from '@nestjs/websockets';
|
||||||
|
import { Server, ServerOptions, Socket } from 'socket.io';
|
||||||
|
import { AlarmEventService } from 'src/services/alarm-event/alarm-event.service';
|
||||||
|
import { AlarmRule } from 'src/services/alarm-rule/alarm-rule.entity';
|
||||||
|
import { Camera } from 'src/services/camera/camera.entity';
|
||||||
|
import { CameraService } from 'src/services/camera/camera.service';
|
||||||
|
import { UserService } from 'src/services/user/user.service';
|
||||||
|
import { UtilsService } from 'src/services/utils/utils.service';
|
||||||
|
|
||||||
|
interface ClientInfo {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
cameraID: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlarmData {
|
||||||
|
picBase64: string;
|
||||||
|
alarmRuleID: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CameraConfig {
|
||||||
|
rtspUrl: string;
|
||||||
|
alarmRules: AlarmRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@WebSocketGateway<Partial<ServerOptions>>({
|
||||||
|
path: '/ws/ai/',
|
||||||
|
cors: {},
|
||||||
|
serveClient: false,
|
||||||
|
})
|
||||||
|
export class AiEndGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||||
|
@WebSocketServer()
|
||||||
|
wsServer: Server;
|
||||||
|
constructor(
|
||||||
|
@Inject(forwardRef(() => CameraService))
|
||||||
|
private cameraService: CameraService,
|
||||||
|
@Inject(forwardRef(() => AlarmEventService))
|
||||||
|
private alarmEventService: AlarmEventService,
|
||||||
|
@Inject(forwardRef(() => UserService))
|
||||||
|
private userService: UserService,
|
||||||
|
@Inject(forwardRef(() => UtilsService))
|
||||||
|
private utilsService: UtilsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
connecetedClients: Map<string, Socket> = new Map();
|
||||||
|
|
||||||
|
@SubscribeMessage('message')
|
||||||
|
handleMessage(
|
||||||
|
@MessageBody() body: string,
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
) {
|
||||||
|
console.log('message received: ', body);
|
||||||
|
client.emit('message', 'hello from server');
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('alarm')
|
||||||
|
async handleAlarm(
|
||||||
|
@MessageBody() body: AlarmData,
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
) {
|
||||||
|
console.log('alarm received: ');
|
||||||
|
|
||||||
|
const { cameraID } = client.data as ClientInfo;
|
||||||
|
const sourceCamera = new Camera();
|
||||||
|
sourceCamera.id = parseInt(cameraID);
|
||||||
|
const alarmRule = new AlarmRule();
|
||||||
|
alarmRule.id = body.alarmRuleID;
|
||||||
|
|
||||||
|
await this.alarmEventService.addEvent({
|
||||||
|
sourceCamera,
|
||||||
|
picFilePath: await this.utilsService.writeBase64ImageToFile(
|
||||||
|
body.picBase64,
|
||||||
|
),
|
||||||
|
alarmRule,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async notifyCameraConfigChange(
|
||||||
|
cameraID: number,
|
||||||
|
cameraConfig?: CameraConfig,
|
||||||
|
) {
|
||||||
|
const client = this.connecetedClients.get(cameraID.toString());
|
||||||
|
if (!cameraConfig) {
|
||||||
|
const config = await this.cameraService.getById(cameraID, true);
|
||||||
|
if (!config) return;
|
||||||
|
cameraConfig = {
|
||||||
|
alarmRules: config?.alarmRules ?? [],
|
||||||
|
rtspUrl: config.rtspUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
client?.emit('cameraConfigChange', cameraConfig);
|
||||||
|
|
||||||
|
console.log(`notify ${cameraID} CameraConfigChange`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnectClient(cameraID: number) {
|
||||||
|
this.connecetedClients.get(cameraID.toString())?.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleConnection(@ConnectedSocket() client: Socket) {
|
||||||
|
try {
|
||||||
|
if (typeof client.client.request.headers.data !== 'string') {
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data: ClientInfo = JSON.parse(client.client.request.headers.data);
|
||||||
|
|
||||||
|
const user = await this.userService.authLogin(
|
||||||
|
data.username,
|
||||||
|
data.password,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user?.role !== 'admin') {
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.connecetedClients.has(data.cameraID)) {
|
||||||
|
this.connecetedClients.get(data.cameraID)?.disconnect();
|
||||||
|
this.connecetedClients.delete(data.cameraID);
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.data = data;
|
||||||
|
|
||||||
|
const camera = await this.cameraService.getById(
|
||||||
|
parseInt(data.cameraID),
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (!camera) {
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.cameraService.updateCamera({
|
||||||
|
id: parseInt(data.cameraID),
|
||||||
|
online: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.connecetedClients.set(data.cameraID, client);
|
||||||
|
|
||||||
|
console.log(`client ${data.cameraID} connected`);
|
||||||
|
|
||||||
|
await this.notifyCameraConfigChange(parseInt(data.cameraID));
|
||||||
|
} catch {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDisconnect(@ConnectedSocket() client: Socket) {
|
||||||
|
this.connecetedClients.delete(client.data.cameraID);
|
||||||
|
client.removeAllListeners();
|
||||||
|
|
||||||
|
const data = client.data as ClientInfo;
|
||||||
|
await this.cameraService.updateCamera({
|
||||||
|
id: parseInt(data.cameraID),
|
||||||
|
online: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`client ${data.cameraID} disconnected`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = async (ms: number) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(null);
|
||||||
|
}, ms);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { ServicesModule } from 'src/services/services.module';
|
||||||
|
import { AiEndGateway } from './ai-end/ai-end.gateway';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [ServicesModule],
|
||||||
|
providers: [AiEndGateway],
|
||||||
|
exports: [AiEndGateway],
|
||||||
|
})
|
||||||
|
export class WsGatewaysModule {}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "es2017",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
name: smart-parking-platform
|
||||||
|
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: smart-parking-mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-smart_parking}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-parking}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-parking123}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root123}
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
command:
|
||||||
|
- --character-set-server=utf8mb4
|
||||||
|
- --collation-server=utf8mb4_unicode_ci
|
||||||
|
ports:
|
||||||
|
- "${MYSQL_PORT:-4012}:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql-data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-root123}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- parking-net
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: front-backend.Dockerfile
|
||||||
|
container_name: smart-parking-app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
MYSQL_HOST: mysql
|
||||||
|
MYSQL_PORT: 3306
|
||||||
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-smart_parking}
|
||||||
|
MYSQL_USER: ${MYSQL_USER:-parking}
|
||||||
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-parking123}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-smart-parking-secret}
|
||||||
|
JWT_EXPIRES_IN: 30d
|
||||||
|
PUBLIC_DIR_ABSOLUTE_PATH: /var/www/html/public
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-4010}:80"
|
||||||
|
- "${RTMP_PORT:-4015}:1515"
|
||||||
|
volumes:
|
||||||
|
- public-data:/var/www/html/public
|
||||||
|
- hls-data:/dev/shm/nginx-live
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://localhost/"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 40s
|
||||||
|
networks:
|
||||||
|
- parking-net
|
||||||
|
|
||||||
|
device-simulator:
|
||||||
|
image: smart-parking-device-simulator:latest
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: simulator.Dockerfile
|
||||||
|
container_name: smart-parking-device-simulator
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
HTTP_SERVER_URL: http://app
|
||||||
|
RTMP_SERVER_URL: rtmp://app:1515/live
|
||||||
|
ADMIN_USERNAME: ${AI_ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD: ${AI_ADMIN_PASSWORD:-admin123}
|
||||||
|
CAMERA_IDS: ${SIM_CAMERA_IDS:-1,2,3,4,5,6,7,8,9,10,11,12}
|
||||||
|
ASSETS_DIR: /assets
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
networks:
|
||||||
|
- parking-net
|
||||||
|
|
||||||
|
ai-end:
|
||||||
|
profiles: ["ai"]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ai-end.Dockerfile
|
||||||
|
container_name: smart-parking-ai-end
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
HTTP_SERVER_URL: http://app
|
||||||
|
RTMP_SERVER_URL: rtmp://app:1515/live
|
||||||
|
ADMIN_USERNAME: ${AI_ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD: ${AI_ADMIN_PASSWORD:-admin123}
|
||||||
|
DETECTION_INTERVAL: "0.5"
|
||||||
|
MODEL_DEVICE: cpu
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
networks:
|
||||||
|
- parking-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql-data:
|
||||||
|
public-data:
|
||||||
|
hls-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
parking-net:
|
||||||
|
driver: bridge
|
||||||
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 5.1 MiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 633 KiB |
|
After Width: | Height: | Size: 519 KiB |
|
After Width: | Height: | Size: 504 KiB |
|
After Width: | Height: | Size: 425 KiB |
|
After Width: | Height: | Size: 610 KiB |
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 933 KiB |
|
After Width: | Height: | Size: 596 KiB |
|
After Width: | Height: | Size: 447 KiB |
|
After Width: | Height: | Size: 874 KiB |
|
After Width: | Height: | Size: 657 KiB |
|
After Width: | Height: | Size: 533 KiB |
|
After Width: | Height: | Size: 142 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
ENV TZ=Asia/Shanghai
|
||||||
|
RUN echo $TZ > /etc/timezone && \
|
||||||
|
ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
|
||||||
|
dpkg-reconfigure -f noninteractive tzdata
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y curl nginx libnginx-mod-rtmp && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY ./backend /usr/share/smart-parking-platform/backend
|
||||||
|
|
||||||
|
WORKDIR /usr/share/smart-parking-platform/backend
|
||||||
|
|
||||||
|
RUN npm i -g pnpm@8.15.0 && \
|
||||||
|
pnpm install --frozen-lockfile && \
|
||||||
|
pnpm run build && \
|
||||||
|
mkdir -p /var/www/html/public
|
||||||
|
|
||||||
|
COPY ./frontend/dist /usr/share/smart-parking-platform/frontend/dist
|
||||||
|
COPY ./backend/nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
EXPOSE 80 1515
|
||||||
|
|
||||||
|
CMD service nginx start && pnpm run start:prod
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react/jsx-runtime",
|
||||||
|
"standard-with-typescript",
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
|
"overrides": [],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module",
|
||||||
|
"project": "./tsconfig.json",
|
||||||
|
"tsconfigRootDir": "."
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "^18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": ["react"],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
"@typescript-eslint/no-misused-promises": "off",
|
||||||
|
"@typescript-eslint/no-floating-promises": "off",
|
||||||
|
"@typescript-eslint/strict-boolean-expressions": "off"
|
||||||
|
},
|
||||||
|
"ignorePatterns": [
|
||||||
|
"build/**",
|
||||||
|
"dist/**",
|
||||||
|
"src/assets/**",
|
||||||
|
"src/.umi/**",
|
||||||
|
"src/.umi-production/**"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/node_modules
|
||||||
|
/.env.local
|
||||||
|
/.umirc.local.ts
|
||||||
|
/config/config.local.ts
|
||||||
|
/src/.umi
|
||||||
|
/src/.umi-production
|
||||||
|
/src/.umi-test
|
||||||
|
/dist
|
||||||
|
.swc
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
registry=https://registry.npmmirror.com
|
||||||
|
strict-peer-dependencies=false
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.umi
|
||||||
|
.umi-production
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 80,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"proseWrap": "never",
|
||||||
|
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
|
||||||
|
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { defineConfig } from 'umi';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import { theme } from 'antd';
|
||||||
|
|
||||||
|
const panelDarkTheme = {
|
||||||
|
algorithm: theme.darkAlgorithm,
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#22d3ee',
|
||||||
|
colorInfo: '#38bdf8',
|
||||||
|
colorSuccess: '#4ade80',
|
||||||
|
colorWarning: '#fbbf24',
|
||||||
|
colorError: '#fb7185',
|
||||||
|
colorTextBase: '#e6f7ff',
|
||||||
|
colorBgBase: '#07142e',
|
||||||
|
borderRadius: 14,
|
||||||
|
wireframe: false,
|
||||||
|
fontFamily: 'Inter, PingFang SC, Microsoft YaHei, system-ui, sans-serif',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Layout: {
|
||||||
|
bodyBg: 'transparent',
|
||||||
|
headerBg: 'transparent',
|
||||||
|
siderBg: 'transparent',
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
colorBgContainer: 'rgba(7, 18, 39, 0.84)',
|
||||||
|
colorBorderSecondary: 'rgba(103, 232, 249, 0.16)',
|
||||||
|
headerBg: 'rgba(34, 211, 238, 0.08)',
|
||||||
|
headerFontSize: 16,
|
||||||
|
},
|
||||||
|
Table: {
|
||||||
|
colorBgContainer: 'rgba(2, 8, 23, 0.2)',
|
||||||
|
colorHeaderBg: 'rgba(34, 211, 238, 0.12)',
|
||||||
|
colorBgContainerHover: 'rgba(34, 211, 238, 0.1)',
|
||||||
|
colorText: '#e6f7ff',
|
||||||
|
headerColor: '#cffafe',
|
||||||
|
borderColor: 'rgba(103, 232, 249, 0.16)',
|
||||||
|
cellPaddingBlock: 10,
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
colorBgContainer: 'rgba(2, 8, 23, 0.48)',
|
||||||
|
colorBorder: 'rgba(103, 232, 249, 0.28)',
|
||||||
|
activeBorderColor: '#22d3ee',
|
||||||
|
colorText: '#e6f7ff',
|
||||||
|
},
|
||||||
|
InputNumber: {
|
||||||
|
colorBgContainer: 'rgba(2, 8, 23, 0.48)',
|
||||||
|
colorBorder: 'rgba(103, 232, 249, 0.28)',
|
||||||
|
activeBorderColor: '#22d3ee',
|
||||||
|
colorText: '#e6f7ff',
|
||||||
|
},
|
||||||
|
Select: {
|
||||||
|
colorBgContainer: 'rgba(2, 8, 23, 0.48)',
|
||||||
|
colorBorder: 'rgba(103, 232, 249, 0.28)',
|
||||||
|
optionSelectedBg: 'rgba(34, 211, 238, 0.18)',
|
||||||
|
colorText: '#e6f7ff',
|
||||||
|
},
|
||||||
|
DatePicker: {
|
||||||
|
colorBgContainer: 'rgba(2, 8, 23, 0.48)',
|
||||||
|
colorBorder: 'rgba(103, 232, 249, 0.28)',
|
||||||
|
colorText: '#e6f7ff',
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
primaryShadow: '0 12px 26px rgba(37, 99, 235, 0.28)',
|
||||||
|
defaultBg: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
defaultColor: '#a5f3fc',
|
||||||
|
},
|
||||||
|
Modal: {
|
||||||
|
colorBgContainer: '#081428',
|
||||||
|
headerColor: '#e6f7ff',
|
||||||
|
contentColor: '#e6f7ff',
|
||||||
|
},
|
||||||
|
Drawer: {
|
||||||
|
colorBgElevated: '#081428',
|
||||||
|
colorText: '#e6f7ff',
|
||||||
|
},
|
||||||
|
Popover: {
|
||||||
|
colorBgElevated: '#0b1b38',
|
||||||
|
colorText: '#e6f7ff',
|
||||||
|
},
|
||||||
|
Tooltip: {
|
||||||
|
colorBgSpotlight: '#0b1b38',
|
||||||
|
colorTextLightSolid: '#e6f7ff',
|
||||||
|
},
|
||||||
|
Tabs: {
|
||||||
|
itemColor: 'rgba(230, 247, 255, 0.68)',
|
||||||
|
itemSelectedColor: '#67e8f9',
|
||||||
|
inkBarColor: '#22d3ee',
|
||||||
|
},
|
||||||
|
Collapse: {
|
||||||
|
colorBgContainer: 'rgba(7, 18, 39, 0.68)',
|
||||||
|
colorBorder: 'rgba(103, 232, 249, 0.18)',
|
||||||
|
headerBg: 'rgba(34, 211, 238, 0.07)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: ['@umijs/plugins/dist/antd', '@umijs/plugins/dist/locale'],
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: '@/pages/index', layout: false },
|
||||||
|
{ path: '/login', component: '@/pages/login', layout: false },
|
||||||
|
{ path: '/overview', component: '@/pages/overview' },
|
||||||
|
{ path: '/campusState', component: '@/pages/campusState' },
|
||||||
|
{ path: '/monitScreen', component: '@/pages/monitScreen' },
|
||||||
|
{ path: '/alarms', component: '@/pages/alarms' },
|
||||||
|
{ path: '/ai-chat', component: '@/pages/aiChat' },
|
||||||
|
{ path: '/userInfo', component: '@/pages/userInfo' },
|
||||||
|
{ path: '/admin/mapManage', component: '@/pages/admin/mapManage' },
|
||||||
|
{ path: '/admin/camerasManage', component: '@/pages/admin/camerasManage' },
|
||||||
|
{
|
||||||
|
path: '/admin/alarmRulesManage',
|
||||||
|
component: '@/pages/admin/alarmRulesManage',
|
||||||
|
},
|
||||||
|
{ path: '/admin/usersManage', component: '@/pages/admin/usersManage' },
|
||||||
|
{ path: '/*', redirect: '/' },
|
||||||
|
],
|
||||||
|
clientLoader: {},
|
||||||
|
npmClient: 'pnpm',
|
||||||
|
antd: {
|
||||||
|
configProvider: {
|
||||||
|
locale: zhCN,
|
||||||
|
theme: panelDarkTheme,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
locale: { default: 'zh-CN', antd: false, baseSeparator: '-' },
|
||||||
|
publicPath: process.env.NODE_ENV === 'production' ? './' : '/',
|
||||||
|
history: { type: 'hash' },
|
||||||
|
metas: [
|
||||||
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
|
{ name: 'theme-color', content: '#07142e' },
|
||||||
|
],
|
||||||
|
links: [{ rel: 'icon', type: 'image/svg+xml', href: './favicon.svg' }],
|
||||||
|
});
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
import constants from '../src/constants';
|
||||||
|
import type ServiceTypes from '../src/services/serviceTypes';
|
||||||
|
|
||||||
|
const MockApis: { [api in keyof ServiceTypes]: ServiceTypes[api]['res'] } = {
|
||||||
|
'POST /api/user/login': {
|
||||||
|
data: {
|
||||||
|
token: '123',
|
||||||
|
userInfo: {
|
||||||
|
avatarURL:
|
||||||
|
'https://himg.bdimg.com/sys/portrait/item/pp.1.2c68ba76.fN8DC9UiXh1lKjACEyEBTg.jpg',
|
||||||
|
nickname: 'admin',
|
||||||
|
role: constants.userRole.ADMIN,
|
||||||
|
tel: '12345678901',
|
||||||
|
email: '',
|
||||||
|
username: 'admin',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'GET /api/user/getCampusState': {
|
||||||
|
data: {
|
||||||
|
cameraTotal: 20,
|
||||||
|
cameraOnline: 10,
|
||||||
|
cameraAlarm: 5,
|
||||||
|
alarmEventPending: 3,
|
||||||
|
cameraList: [
|
||||||
|
{
|
||||||
|
cameraName: '摄像头1',
|
||||||
|
cameraID: 1,
|
||||||
|
cameraStatus: 'normal',
|
||||||
|
latlng: [36.666394717516695, 117.13263524798919],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cameraName: '摄像头2',
|
||||||
|
cameraID: 2,
|
||||||
|
cameraStatus: 'offline',
|
||||||
|
latlng: [36.66553375772535, 117.13452323144844],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cameraName: '摄像头3',
|
||||||
|
cameraID: 3,
|
||||||
|
cameraStatus: 'alarm',
|
||||||
|
latlng: [36.666747708247264, 117.13250652184426],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'POST /api/user/resolveAlarm': {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
message: '',
|
||||||
|
},
|
||||||
|
'GET /api/user/getCameraInfo': {
|
||||||
|
data: {
|
||||||
|
cameraName: '摄像头1',
|
||||||
|
cameraID: 1,
|
||||||
|
cameraStatus: 'normal',
|
||||||
|
latlng: [36.666394717516695, 117.13263524798919],
|
||||||
|
cameraModel: '摄像头型号1',
|
||||||
|
alarmRules: [
|
||||||
|
{
|
||||||
|
alarmRuleID: 1,
|
||||||
|
alarmRuleName: '报警规则1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alarmRuleID: 2,
|
||||||
|
alarmRuleName: '报警规则2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alarmRuleID: 3,
|
||||||
|
alarmRuleName: '报警规则3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
|
||||||
|
alarmEvents: [
|
||||||
|
{
|
||||||
|
eventID: 1,
|
||||||
|
alarmTime: '2020-01-01 00:00:00',
|
||||||
|
alarmRule: { alarmRuleID: 1, alarmRuleName: '报警规则1' },
|
||||||
|
resolved: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventID: 2,
|
||||||
|
alarmTime: '2020-01-01 00:00:00',
|
||||||
|
alarmRule: { alarmRuleID: 1, alarmRuleName: '报警规则1' },
|
||||||
|
resolved: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'GET /api/user/getAlarmEvents': {
|
||||||
|
data: {
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
eventID: 549,
|
||||||
|
alarmTime: '2023-04-26 15:44:49',
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: 1,
|
||||||
|
alarmRuleName: '人体检测-2人以上',
|
||||||
|
},
|
||||||
|
resolved: false,
|
||||||
|
cameraID: 3,
|
||||||
|
cameraName: '本机测试摄像头',
|
||||||
|
cameraLatlng: [36.6670821627257, 117.13324069971353],
|
||||||
|
cameraModel: 'USB2.0 HD UVC WebCam',
|
||||||
|
alarmPicUrl: '/public/2141dae8fb8bea60d85b.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventID: 548,
|
||||||
|
alarmTime: '2023-04-26 15:37:20',
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: 1,
|
||||||
|
alarmRuleName: '人体检测-2人以上',
|
||||||
|
},
|
||||||
|
resolved: false,
|
||||||
|
cameraID: 3,
|
||||||
|
cameraName: '本机测试摄像头',
|
||||||
|
cameraLatlng: [36.6670821627257, 117.13324069971353],
|
||||||
|
cameraModel: 'USB2.0 HD UVC WebCam',
|
||||||
|
alarmPicUrl: '/public/aba3863a01e7236920d5.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventID: 547,
|
||||||
|
alarmTime: '2023-04-26 15:35:04',
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: 1,
|
||||||
|
alarmRuleName: '人体检测-2人以上',
|
||||||
|
},
|
||||||
|
resolved: false,
|
||||||
|
cameraID: 3,
|
||||||
|
cameraName: '本机测试摄像头',
|
||||||
|
cameraLatlng: [36.6670821627257, 117.13324069971353],
|
||||||
|
cameraModel: 'USB2.0 HD UVC WebCam',
|
||||||
|
alarmPicUrl: '/public/1f1ceed3f0c35bb87910.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventID: 546,
|
||||||
|
alarmTime: '2023-04-26 15:26:14',
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: 1,
|
||||||
|
alarmRuleName: '人体检测-2人以上',
|
||||||
|
},
|
||||||
|
resolved: false,
|
||||||
|
cameraID: 3,
|
||||||
|
cameraName: '本机测试摄像头',
|
||||||
|
cameraLatlng: [36.6670821627257, 117.13324069971353],
|
||||||
|
cameraModel: 'USB2.0 HD UVC WebCam',
|
||||||
|
alarmPicUrl: '/public/75b9aebaa9b7fc58a79b.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventID: 545,
|
||||||
|
alarmTime: '2023-04-26 14:12:00',
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: 1,
|
||||||
|
alarmRuleName: '人体检测-2人以上',
|
||||||
|
},
|
||||||
|
resolved: false,
|
||||||
|
cameraID: 3,
|
||||||
|
cameraName: '本机测试摄像头',
|
||||||
|
cameraLatlng: [36.6670821627257, 117.13324069971353],
|
||||||
|
cameraModel: 'USB2.0 HD UVC WebCam',
|
||||||
|
alarmPicUrl: '/public/ec08fa2c1ed46495370a.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventID: 544,
|
||||||
|
alarmTime: '2023-04-26 13:45:49',
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: 1,
|
||||||
|
alarmRuleName: '人体检测-2人以上',
|
||||||
|
},
|
||||||
|
resolved: false,
|
||||||
|
cameraID: 3,
|
||||||
|
cameraName: '本机测试摄像头',
|
||||||
|
cameraLatlng: [36.6670821627257, 117.13324069971353],
|
||||||
|
cameraModel: 'USB2.0 HD UVC WebCam',
|
||||||
|
alarmPicUrl: '/public/744465efbe7d7b40fa34.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventID: 543,
|
||||||
|
alarmTime: '2023-04-26 13:44:15',
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: 1,
|
||||||
|
alarmRuleName: '人体检测-2人以上',
|
||||||
|
},
|
||||||
|
resolved: false,
|
||||||
|
cameraID: 3,
|
||||||
|
cameraName: '本机测试摄像头',
|
||||||
|
cameraLatlng: [36.6670821627257, 117.13324069971353],
|
||||||
|
cameraModel: 'USB2.0 HD UVC WebCam',
|
||||||
|
alarmPicUrl: '/public/459467958020cade7923.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventID: 542,
|
||||||
|
alarmTime: '2023-04-26 13:42:49',
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: 1,
|
||||||
|
alarmRuleName: '人体检测-2人以上',
|
||||||
|
},
|
||||||
|
resolved: false,
|
||||||
|
cameraID: 3,
|
||||||
|
cameraName: '本机测试摄像头',
|
||||||
|
cameraLatlng: [36.6670821627257, 117.13324069971353],
|
||||||
|
cameraModel: 'USB2.0 HD UVC WebCam',
|
||||||
|
alarmPicUrl: '/public/d537b15a67ce7cdef3ac.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventID: 541,
|
||||||
|
alarmTime: '2023-04-26 13:41:47',
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: 1,
|
||||||
|
alarmRuleName: '人体检测-2人以上',
|
||||||
|
},
|
||||||
|
resolved: false,
|
||||||
|
cameraID: 3,
|
||||||
|
cameraName: '本机测试摄像头',
|
||||||
|
cameraLatlng: [36.6670821627257, 117.13324069971353],
|
||||||
|
cameraModel: 'USB2.0 HD UVC WebCam',
|
||||||
|
alarmPicUrl: '/public/61077f07aa8afd27e6ae.jpg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventID: 540,
|
||||||
|
alarmTime: '2023-04-26 13:40:03',
|
||||||
|
alarmRule: {
|
||||||
|
alarmRuleID: 1,
|
||||||
|
alarmRuleName: '人体检测-2人以上',
|
||||||
|
},
|
||||||
|
resolved: false,
|
||||||
|
cameraID: 3,
|
||||||
|
cameraName: '本机测试摄像头',
|
||||||
|
cameraLatlng: [36.6670821627257, 117.13324069971353],
|
||||||
|
cameraModel: 'USB2.0 HD UVC WebCam',
|
||||||
|
alarmPicUrl: '/public/668c2c59bab771870e4f.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 549,
|
||||||
|
},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'GET /api/user/getMonitList': {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
cameraName: '摄像头1',
|
||||||
|
cameraID: 1,
|
||||||
|
cameraStatus: 'normal',
|
||||||
|
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cameraName: '摄像头2',
|
||||||
|
cameraID: 2,
|
||||||
|
cameraStatus: 'normal',
|
||||||
|
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cameraName: '摄像头3',
|
||||||
|
cameraID: 3,
|
||||||
|
cameraStatus: 'normal',
|
||||||
|
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cameraName: '摄像头4',
|
||||||
|
cameraID: 4,
|
||||||
|
cameraStatus: 'normal',
|
||||||
|
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cameraName: '摄像头5',
|
||||||
|
cameraID: 5,
|
||||||
|
cameraStatus: 'normal',
|
||||||
|
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'GET /api/user/getUserInfo': {
|
||||||
|
data: {
|
||||||
|
avatarURL:
|
||||||
|
'https://himg.bdimg.com/sys/portrait/item/pp.1.2c68ba76.fN8DC9UiXh1lKjACEyEBTg.jpg',
|
||||||
|
username: 'admin',
|
||||||
|
role: constants.userRole.ADMIN,
|
||||||
|
tel: '12345678901',
|
||||||
|
email: '123@mail.com',
|
||||||
|
nickname: 'admin',
|
||||||
|
},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'POST /api/user/updateUserInfo': {
|
||||||
|
data: {},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'POST /api/user/updatePassword': {
|
||||||
|
data: {},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'GET /api/user/getMapConfig': {
|
||||||
|
data: {
|
||||||
|
mapOptions: {
|
||||||
|
attributionControl: false,
|
||||||
|
center: [36.66669, 117.13272],
|
||||||
|
zoom: 17,
|
||||||
|
minZoom: 17,
|
||||||
|
maxZoom: 17,
|
||||||
|
zoomControl: false,
|
||||||
|
},
|
||||||
|
layer: {
|
||||||
|
type: 'tileLayer',
|
||||||
|
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'POST /api/admin/updateMapConfig': {
|
||||||
|
data: {},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'GET /api/admin/getCameraList': {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
cameraName: '摄像头1',
|
||||||
|
cameraID: 1,
|
||||||
|
cameraStatus: 'normal',
|
||||||
|
latlng: [36.666394717516695, 117.13263524798919],
|
||||||
|
cameraModel: '摄像头型号1',
|
||||||
|
alarmRules: [{ alarmRuleID: 1, alarmRuleName: '报警规则1' }],
|
||||||
|
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
|
||||||
|
rtspUrl:
|
||||||
|
'rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cameraName: '摄像头2',
|
||||||
|
cameraID: 2,
|
||||||
|
cameraStatus: 'offline',
|
||||||
|
latlng: [36.66553375772535, 117.13452323144844],
|
||||||
|
cameraModel: '摄像头型号2',
|
||||||
|
alarmRules: [{ alarmRuleID: 2, alarmRuleName: '报警规则2' }],
|
||||||
|
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
|
||||||
|
rtspUrl:
|
||||||
|
'rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cameraName: '摄像头3',
|
||||||
|
cameraID: 3,
|
||||||
|
cameraStatus: 'alarm',
|
||||||
|
latlng: [36.666747708247264, 117.13250652184426],
|
||||||
|
cameraModel: '摄像头型号3',
|
||||||
|
alarmRules: [{ alarmRuleID: 3, alarmRuleName: '报警规则3' }],
|
||||||
|
hlsUrl: 'https://cdn.jwplayer.com/manifests/pZxWPRg4.m3u8',
|
||||||
|
rtspUrl:
|
||||||
|
'rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mov',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'POST /api/admin/addCamera': {
|
||||||
|
data: {},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'POST /api/admin/updateCamera': {
|
||||||
|
data: {},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'POST /api/admin/deleteCamera': {
|
||||||
|
data: {},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'GET /api/admin/getAlarmRuleList': {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
alarmRuleID: 1,
|
||||||
|
alarmRuleName: '报警规则1',
|
||||||
|
relatedCameras: [
|
||||||
|
{ cameraID: 1, cameraName: '摄像头1' },
|
||||||
|
{ cameraID: 2, cameraName: '摄像头2' },
|
||||||
|
],
|
||||||
|
algorithmType: 'body',
|
||||||
|
enabled: true,
|
||||||
|
triggerCondition: {
|
||||||
|
count: { max: 2, min: 1 },
|
||||||
|
time: { dayOfWeek: [1, 2, 3], timeRange: ['00:00:00', '23:59:59'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alarmRuleID: 2,
|
||||||
|
alarmRuleName: '报警规则2',
|
||||||
|
relatedCameras: [
|
||||||
|
{ cameraID: 1, cameraName: '摄像头1' },
|
||||||
|
{ cameraID: 2, cameraName: '摄像头2' },
|
||||||
|
],
|
||||||
|
algorithmType: 'vehicle',
|
||||||
|
enabled: true,
|
||||||
|
triggerCondition: {
|
||||||
|
count: { max: 2, min: 1 },
|
||||||
|
time: { dayOfWeek: [1, 2, 3], timeRange: ['00:00:00', '23:59:59'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
alarmRuleID: 3,
|
||||||
|
alarmRuleName: '报警规则3',
|
||||||
|
relatedCameras: [
|
||||||
|
{ cameraID: 1, cameraName: '摄像头1' },
|
||||||
|
{ cameraID: 2, cameraName: '摄像头2' },
|
||||||
|
],
|
||||||
|
algorithmType: 'body',
|
||||||
|
enabled: false,
|
||||||
|
triggerCondition: {
|
||||||
|
count: { max: 2, min: 1 },
|
||||||
|
time: { dayOfWeek: [1, 2, 3], timeRange: ['00:00:00', '23:59:59'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'POST /api/admin/addAlarmRule': {
|
||||||
|
data: {},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'POST /api/admin/updateAlarmRule': {
|
||||||
|
data: {},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'POST /api/admin/deleteAlarmRule': {
|
||||||
|
data: {},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'GET /api/admin/getUserList': {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
username: 'admin',
|
||||||
|
role: 'admin',
|
||||||
|
tel: '12345678901',
|
||||||
|
email: '123@456.com',
|
||||||
|
nickname: 'admin1',
|
||||||
|
avatarURL:
|
||||||
|
'https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: 'user',
|
||||||
|
role: 'user',
|
||||||
|
tel: '12345678901',
|
||||||
|
email: '123@456.com',
|
||||||
|
nickname: 'admin2',
|
||||||
|
avatarURL:
|
||||||
|
'https://zos.alipayobjects.com/rmsportal/ODTLcjxAfvqbxHnVXCYX.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'POST /api/admin/addUser': {
|
||||||
|
data: {},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'POST /api/admin/updateUser': {
|
||||||
|
data: {},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'POST /api/admin/deleteUser': {
|
||||||
|
data: {},
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
'GET /api/ai/getOfflineCameraList': {
|
||||||
|
data: [{ cameraID: 1 }, { cameraID: 2 }, { cameraID: 3 }],
|
||||||
|
message: '',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MockApis;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"author": "",
|
||||||
|
"scripts": {
|
||||||
|
"build": "umi build",
|
||||||
|
"dev": "umi dev",
|
||||||
|
"postinstall": "umi setup",
|
||||||
|
"lint": "tsc && eslint .",
|
||||||
|
"setup": "umi setup",
|
||||||
|
"start": "npm run dev"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.0.1",
|
||||||
|
"@umijs/plugins": "^4.0.66",
|
||||||
|
"ahooks": "^3.7.6",
|
||||||
|
"antd": "^5.4.4",
|
||||||
|
"antd-img-crop": "^4.12.2",
|
||||||
|
"crypto-js": "^4.1.1",
|
||||||
|
"dayjs": "^1.11.7",
|
||||||
|
"hls.js": "^1.4.0",
|
||||||
|
"leaflet": "^1.9.3",
|
||||||
|
"mobx": "^6.9.0",
|
||||||
|
"mobx-react": "^7.6.0",
|
||||||
|
"umi": "^4.0.66"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/crypto-js": "^4.1.1",
|
||||||
|
"@types/leaflet": "^1.9.3",
|
||||||
|
"@types/react": "^18.0.37",
|
||||||
|
"@types/react-dom": "^18.0.11",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.59.0",
|
||||||
|
"eslint": "^8.38.0",
|
||||||
|
"eslint-config-prettier": "^8.8.0",
|
||||||
|
"eslint-config-standard-with-typescript": "^34.0.1",
|
||||||
|
"eslint-plugin-import": "^2.27.5",
|
||||||
|
"eslint-plugin-n": "^15.7.0",
|
||||||
|
"eslint-plugin-promise": "^6.1.1",
|
||||||
|
"eslint-plugin-react": "^7.32.2",
|
||||||
|
"prettier": "^2.8.7",
|
||||||
|
"prettier-plugin-organize-imports": "^2.3.4",
|
||||||
|
"prettier-plugin-packagejson": "^2.4.3",
|
||||||
|
"tslib": "^2.5.0",
|
||||||
|
"typescript": "^4.9.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="12" y1="8" x2="116" y2="120" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#07142e"/>
|
||||||
|
<stop offset="0.52" stop-color="#0b2a52"/>
|
||||||
|
<stop offset="1" stop-color="#1e1b4b"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="cyan" x1="28" y1="24" x2="100" y2="104" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#67e8f9"/>
|
||||||
|
<stop offset="0.52" stop-color="#22d3ee"/>
|
||||||
|
<stop offset="1" stop-color="#8b5cf6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glow" x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feGaussianBlur stdDeviation="3.2" result="blur"/>
|
||||||
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="10" y="10" width="108" height="108" rx="28" fill="url(#bg)" stroke="#67e8f9" stroke-opacity=".45" stroke-width="2"/>
|
||||||
|
<path d="M31 46h46l20-12v60l-20-12H31a8 8 0 0 1-8-8V54a8 8 0 0 1 8-8Z" fill="#081a35" stroke="url(#cyan)" stroke-width="4" filter="url(#glow)"/>
|
||||||
|
<circle cx="55" cy="68" r="17" fill="#020617" stroke="#67e8f9" stroke-width="4"/>
|
||||||
|
<circle cx="55" cy="68" r="7" fill="#22d3ee" opacity=".9"/>
|
||||||
|
<path d="M82 35a42 42 0 0 1 0 66M91 25a56 56 0 0 1 0 86" fill="none" stroke="#8b5cf6" stroke-width="4" stroke-linecap="round" opacity=".72"/>
|
||||||
|
<path d="M25 25h18M25 25v18M103 103H85m18 0V85" stroke="#67e8f9" stroke-width="3" stroke-linecap="round" opacity=".8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
import { defineApp } from 'umi';
|
||||||
|
import './global.less';
|
||||||
|
|
||||||
|
export default defineApp({});
|
||||||
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 8.3 KiB |