feat: 完善智慧停车综合管理平台源码

This commit is contained in:
admin
2026-10-10 17:20:00 +08:00
commit 9c9079c998
170 changed files with 27985 additions and 0 deletions
+17
View File
@@ -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
+37
View File
@@ -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
+30
View File
@@ -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/
+21
View File
@@ -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.
+333
View File
@@ -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 鉴权。
![登录页](docs/screenshots/login.png)
### 1.1 项目概述大屏
平台内置炫酷的**项目概述大屏**:顶部动态标题与实时时钟,五大核心指标卡片(摄像头总数、设备在线率、
报警事件、报警规则、系统用户),近 7 天报警趋势折线图、设备状态环形分布图及最新报警实时滚动,
全部数据来自后端实时聚合,10 秒自动刷新,适配大屏投放。
![项目概述大屏](docs/screenshots/overview.png)
### 2. 园区态势总览
- 园区停车核心指标看板:总车位、占用/空闲、异常事件、设备在线率;
- 基于本地地图的摄像头点位分布与状态渲染,支持点位点击联动。
![园区态势](docs/screenshots/campus-state.png)
### 3. 视频监控大屏
- 科技感数据大屏:多路摄像头实时画面、轮播、实时指标与报警滚动;
- 支持 HLS(hls.js)低延迟直播,适配主流分辨率。
![监控大屏](docs/screenshots/monitoring-screen.png)
### 4. 异常报警事件管理
- 报警事件列表、级别筛选、处置状态流转,事件全程可追溯。
![异常报警](docs/screenshots/alarms.png)
### 5. AI 智能对话助手
- 标准 Chat Completions 流式对话,默认接入 TokenFree 的 `kimi-k2.6` 模型;
- 支持停车运营、设备状态、异常分析等问答,接口配置抽屉内置使用教程。
![AI 对话](docs/screenshots/ai-chat.png)
![AI 接口配置教程](docs/screenshots/ai-settings.png)
### 6. 个人中心
- 用户信息维护、密码修改等自助功能。
![个人信息](docs/screenshots/user-info.png)
### 7. 系统管理(管理员)
- **园区地图管理**:自定义地图数据上传、切分与点位编辑。
![地图管理](docs/screenshots/map-management.png)
- **摄像头管理**:摄像头档案、RTSP/RTMP 拉流地址、点位与启用状态维护。
![摄像头管理](docs/screenshots/camera-management.png)
- **报警规则管理**:按摄像头配置检测目标、触发条件与规则启停。
![报警规则](docs/screenshots/alarm-rule-management.png)
- **用户管理**:账号、角色(管理员/操作员)与状态管理。
![用户管理](docs/screenshots/user-management.png)
---
## 二、解决的行业痛点
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/)
+15
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
__pycache__
+141
View File
@@ -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
+163
View File
@@ -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
+51
View File
@@ -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
+213
View File
@@ -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
BIN
View File
Binary file not shown.
+25
View File
@@ -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',
},
};
+35
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+86
View File
@@ -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;
}
}
}
+84
View File
@@ -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"
}
}
+5601
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+8
View File
@@ -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
+51
View File
@@ -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,
},
};
}
}
+327
View File
@@ -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;
},
);
+43
View File
@@ -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;
}
}
+16
View File
@@ -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 })));
}
}
+12
View File
@@ -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);
}
}
+38
View File
@@ -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 {}
+44
View File
@@ -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();
});
});
+50
View File
@@ -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);
}
}
+456
View File
@@ -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 {}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+22
View File
@@ -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
}
}
+112
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 874 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

+24
View File
@@ -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
+39
View File
@@ -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/**"
]
}
+9
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
registry=https://registry.npmmirror.com
strict-peer-dependencies=false
+3
View File
@@ -0,0 +1,3 @@
node_modules
.umi
.umi-production
+8
View File
@@ -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"]
}
+134
View File
@@ -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' }],
});
+490
View File
@@ -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;
+45
View File
@@ -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"
}
}
+11055
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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

+4
View File
@@ -0,0 +1,4 @@
import { defineApp } from 'umi';
import './global.less';
export default defineApp({});
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Some files were not shown because too many files have changed in this diff Show More