Compare commits

19 Commits

Author SHA1 Message Date
7332ff5f1e 修复:app/stream 填充默认值 default/001,不再用 cameraName 覆盖 2026-03-19 11:49:08 +08:00
a0d4e6d05d 优化:告警趋势图从堆叠面积改为独立折线图
去掉 stack + areaStyle,每种告警类型独立一条折线,
都从 Y 轴 0 开始,更直观易读。
2026-03-19 11:42:50 +08:00
8bce09a17a 功能:摄像头配置页面适配 cameraName
- 表格列:app+stream 改为摄像头名称
- 编辑弹窗:去掉应用名+流ID,改为摄像头名称(必填)
- app/stream 由后端自动处理,前端不再暴露
- Camera 类型新增 cameraName 字段
2026-03-19 11:42:17 +08:00
617cdc8c15 功能:告警列表摄像头搜索改为下拉选择+输入搜索
- 新增 getCameraOptions API(从 WVP 获取摄像头列表)
- 搜索框改为 ApiSelect 组件,支持下拉选择和输入前缀匹配
- 选中后用 cameraCode 查询后端
2026-03-19 11:36:56 +08:00
4c287ca690 性能:看板改为单次请求加载,从5个并发请求优化为1个
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:31:21 +08:00
852be25413 功能:侧边栏菜单「告警汇总」重命名为「告警看板」
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:20:59 +08:00
d2a77079af 功能:告警数据看板替换原告警汇总页
- 5 个 KPI 卡片(今日告警/待处理/已处理/平均响应/在线设备)
- 告警趋势面积图(7天/30天切换,按类型堆叠)
- 告警类型分布环形图(中心显示总数)
- 设备告警 Top10 横向条形图
- 告警级别分布柱状图
- 24小时时段分布图(高亮高峰时段)
- 最近告警滚动列表
- 60秒自动刷新

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 17:11:02 +08:00
58db3c7eb4 功能:算法绑定页增加告警等级选择器
支持在 ROI 算法绑定中配置告警等级(紧急/重要/普通/轻微),
等级保存在 params JSON 中,各算法有默认等级。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:32:58 +08:00
67085ffcfc 功能:告警级别调整为四级(轻微/普通/重要/紧急)并更新颜色
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:32:57 +08:00
8d5f2f138d 功能:隐藏 ROI 菜单项,ROI 配置已整合到摄像头管理中
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:32:33 +08:00
72b97453d3 修复:ROI Canvas 响应式对齐,精确覆盖图片内容区域
通过计算 object-fit: contain 下图片的真实渲染区域,让 Canvas 精确覆盖
图片内容而非整个容器(含黑边),解决不同屏幕/缩放下 ROI 偏移问题。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:30:33 +08:00
694c5c7af1 功能:边缘节点页面重写为卡片列表
- VxeGrid 表格改为 Card 卡片布局
- 新增 getDeviceList() 调用 WVP list 接口
- 每张卡片展示:设备ID、状态Tag、最后心跳、运行时长、摄像头数、配置版本
- data.ts 精简为状态配置和 formatUptime 工具函数

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 17:47:31 +08:00
4cd07c3fef 功能:前端支持车辆违停和车辆拥堵算法
- data.ts: ALERT_TYPE_OPTIONS新增车辆违停/拥堵选项
- AlgorithmParamEditor.vue: 新增违停和拥堵参数中英文名称及说明映射
- RoiAlgorithmBind.vue: 新增车辆算法帧率映射(1帧/秒)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 16:54:50 +08:00
0aa45be41f 修复:状态栏位置 + 导入顺序 + 移除多余token
1. 状态栏位置修正
   - 正确位置:应用名 -> 流ID -> 拉流地址 -> 状态 -> ROI
   - 之前错误地放在了流ID和拉流地址之间

2. 修复导入顺序
   - useAppConfig应在script setup顶部导入
   - 避免apiURL未正确初始化的问题

3. 状态检测优化
   - /snap/image已免认证,移除access-token参数
   - 简化HEAD请求逻辑

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:50:24 +08:00
0e73aa2b8d 修复:状态栏位置调整 + HEAD请求检测截图状态
1. 状态栏位置调整
   - 列顺序改为:应用名 -> 流ID -> 状态 -> 拉流地址 -> ROI

2. 状态检测优化
   - 使用 HEAD 请求替代 GET,避免下载完整图片
   - 使用 fetch API 直接发送 HEAD 请求到 /snap/image
   - 大幅提升状态检测性能

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:29:47 +08:00
fae585f5e9 功能:摄像头状态指示器 + 截图缓存优化 + 截图预热
1. 摄像头列表状态指示器(绿/红点)
   - 新增状态列显示摄像头截图可用性
   - 绿色=截图正常,红色=截图失败,灰色=加载中
   - 使用 /snap/image 代理端点检测状态

2. 截图缓存优化(getSnapUrl 重写)
   - 非强制模式:返回稳定URL(无时间戳),浏览器缓存5分钟
   - 强制模式:先触发边缘截图,再返回带时间戳URL破缓存
   - 使用 /snap/image 代理端点,避免COS预签名URL过期问题

3. 截图预热(新增摄像头时)
   - 添加摄像头成功后自动触发首次截图
   - 预热失败不影响主流程(非阻塞)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 16:02:41 +08:00
eab4337a77 功能:ROI绑定算法时校验RTSP地址有效性
截图加载失败时阻止算法绑定并提示"RTSP拉流地址无效",
通过Canvas截图加载状态逐层传递到算法绑定组件实现校验。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 15:25:07 +08:00
d8e1ae5dab refactor: 摄像头管理页面移除状态栏、拉流和导出按钮
- 删除"状态"列(拉流中/未拉流)
- 删除操作栏中"拉流"和"导出"按钮,保留ROI配置、编辑、删除
- 删除搜索栏中"拉流状态"筛选
- 清理无用import和方法(toggleStream/handleExport/startCamera/stopCamera/exportConfig)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 11:20:01 +08:00
f68b4e8b23 文档:创建 CLAUDE.md 开发指南
新增内容:
- 项目概述和生产部署信息(aiot-web-antd 容器)
- 常用命令(安装、开发、构建、测试、Docker 部署)
- 项目结构说明(Turborepo monorepo 架构)
- 关键页面路由(告警管理、设备管理、ROI 配置)
- 环境配置说明(开发和生产环境)
- API 集成指南(芋道网关、WebSocket)
- 开发工作流(添加页面、共享组件、API 接口)
- 常见问题排查
- Git 提交规范

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-05 16:29:17 +08:00
16 changed files with 1438 additions and 342 deletions

297
CLAUDE.md Normal file
View File

@@ -0,0 +1,297 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
IoT 设备管理前端,基于 Vue 3 + Vben Admin 框架构建的 Turborepo monorepo。包含 4 个 UI 框架变体Ant Design、Element Plus、Naive UI、TDesign共享核心代码库。
**生产部署信息:**
- **容器名称:** `aiot-web-antd`(生产使用 Ant Design 版本)
- **端口映射:** 9090:80
- **部署位置:** 腾讯云服务器 `/opt/vsp-platform`
- **后端 API** http://服务器IP:48080芋道微服务网关
- **访问地址:** http://服务器IP:9090
- **默认账号:** admin / admin123
## 常用命令
### 安装依赖
```bash
# 需要 pnpm 10.0.0+ 和 Node.js 20.19.0+
pnpm install
```
### 本地开发
```bash
# 运行 Ant Design 版本(生产使用)
pnpm dev:antd
# 运行其他版本
pnpm dev:ele # Element Plus
pnpm dev:naive # Naive UI
pnpm dev:tdesign # TDesign
# 开发服务器默认端口5666
# 访问http://localhost:5666
```
### 构建
```bash
# 构建 Ant Design 版本
pnpm build:antd
# 构建所有版本
pnpm build
# 构建其他版本
pnpm build:ele
pnpm build:naive
pnpm build:tdesign
```
### 代码质量
```bash
# 代码检查
pnpm lint
# 代码格式化
pnpm format
# TypeScript 类型检查
pnpm check:type
# 检查循环依赖
pnpm check:circular
```
### 测试
```bash
# 单元测试Vitest
pnpm test:unit
# E2E 测试Playwright
pnpm test:e2e
```
### 清理
```bash
# 清理构建产物
pnpm clean
# 清理并重新安装依赖
pnpm reinstall
```
### Docker 部署(生产环境)
```bash
# 构建 Ant Design 版本镜像
cd apps/web-antd
docker build -t aiot-web-antd:latest .
# 运行容器
docker run -d \
--name aiot-web-antd \
-p 9090:80 \
-e VITE_BASE_URL=http://服务器IP:48080 \
aiot-web-antd:latest
# 查看日志
docker logs -f aiot-web-antd
# 重启容器
docker restart aiot-web-antd
```
## 项目结构
### 目录组织
```
iot-device-management-frontend/
├── apps/ # 应用层4 个 UI 框架变体)
│ ├── web-antd/ # Ant Design 版本(生产使用)
│ ├── web-ele/ # Element Plus 版本
│ ├── web-naive/ # Naive UI 版本
│ └── web-tdesign/ # TDesign 版本
├── packages/ # 共享包
│ ├── @core/ # 核心库(框架无关)
│ │ ├── base/ # 基础库design tokens, icons, shared
│ │ ├── composables/ # Vue composables
│ │ ├── preferences/ # 设置管理
│ │ └── ui-kit/ # UI 组件form, layout, menu, popup, tabs
│ ├── effects/ # 功能包
│ │ ├── access/ # 访问控制
│ │ ├── hooks/ # React-like hooks
│ │ ├── layouts/ # 布局组件
│ │ └── plugins/ # 插件echarts, vxe-table等
│ └── constants/ # 共享常量
├── internal/ # 内部工具
│ ├── lint-configs/ # ESLint、Prettier、Stylelint 配置
│ ├── tsconfig/ # TypeScript 配置
│ ├── vite-config/ # Vite 配置
│ └── tailwind-config/ # Tailwind CSS 配置
└── docs/ # 文档站点
```
### 核心概念
**Workspace 架构:**
- 使用 pnpm workspaces 管理 monorepo
- 使用 Turborepo 进行构建优化
- 所有 apps 共享 `@core``effects`
- 修改 `@core` 包自动反映到所有 appsVite HMR
**包命名规范:**
- `@vben/` — Vben Admin 框架包
- `@core/` — 项目核心包
- `workspace:*` — workspace 内部依赖
## 关键页面路由
### AIoT 告警管理
- `/aiot/alarm/event` — 告警事件列表
- 分页、筛选、统计
- 实时 WebSocket 推送
- 告警处理(确认、忽略、派单)
### 边缘设备管理
- `/aiot/edge/device` — 边缘设备列表
- 设备状态监控
- 告警数统计
### 摄像头与 ROI 管理
- `/aiot/camera` — 摄像头管理
- 摄像头列表、添加、编辑
- 与 WVP 平台同步
- `/aiot/roi` — ROI 区域配置
- ROI 绘制polygon
- 算法绑定配置
## 环境配置
### 开发环境apps/web-antd/.env.development
```bash
VITE_PORT=5666 # 开发服务器端口
VITE_BASE_URL=http://127.0.0.1:48080 # 后端 API 地址
VITE_GLOB_API_URL=/admin-api # API 路径前缀
VITE_DEVTOOLS=false # Vue DevTools
VITE_APP_DEFAULT_USERNAME=admin # 默认用户名
VITE_APP_DEFAULT_PASSWORD=admin123 # 默认密码
```
### 生产环境apps/web-antd/.env.production
```bash
VITE_BASE_URL=http://服务器IP:48080
VITE_GLOB_API_URL=/admin-api
```
## API 集成
### 后端接口(通过 aiot-gateway 48080
**告警事件:**
- `GET /admin-api/aiot/alarm/event/page` — 分页查询
- `GET /admin-api/aiot/alarm/event/get` — 获取详情
- `PUT /admin-api/aiot/alarm/event/handle` — 处理告警
- `GET /admin-api/aiot/alarm/event/statistics` — 统计
**边缘设备:**
- `GET /admin-api/aiot/edge/device/page` — 分页查询
- `GET /admin-api/aiot/edge/device/statistics` — 统计
**WebSocket直连 vsp-service 8000**
- `ws://服务器IP:8000/ws/alerts` — 实时告警推送
### API 调用示例
```typescript
// 使用 @vben/request 统一请求工具
import { requestClient } from '@vben/request';
// 查询告警列表
const response = await requestClient.get('/admin-api/aiot/alarm/event/page', {
params: { pageNo: 1, pageSize: 20 }
});
// 处理告警
await requestClient.put('/admin-api/aiot/alarm/event/handle', {
id: alarmId,
handleStatus: 'CONFIRMED',
handleRemark: '已确认'
});
```
## 开发工作流
### 添加新页面
1.`apps/web-antd/src/views/` 下创建页面组件
2.`apps/web-antd/src/router/routes/modules/` 添加路由配置
3. 如果是共享组件,放到 `packages/@core/ui-kit/``packages/effects/common-ui/`
### 修改共享组件
1. 修改 `packages/@core/ui-kit/` 下的组件
2. Vite HMR 自动刷新所有使用该组件的 apps
3. 无需重新构建
### 添加 API 接口
1.`apps/web-antd/src/api/` 创建 API 文件
2. 使用 `requestClient` 发起请求
3. 定义 TypeScript 类型
### 国际化i18n
- 语言文件:`apps/web-antd/src/locales/langs/`
- 中文:`zh-CN/`
- 英文:`en-US/`
## 常见问题
### pnpm install 失败
```bash
# 清理并重装
pnpm clean
rm -rf node_modules pnpm-lock.yaml
pnpm install
```
### 构建失败:循环依赖
```bash
# 检查循环依赖
pnpm check:circular
```
### 类型错误
```bash
# 运行类型检查
pnpm check:type
```
### WebSocket 连接失败
检查 vsp-service 是否运行:
```bash
docker logs vsp-service
```
## Git 提交规范
在修改代码后,使用中文提交信息:
```bash
git add .
git commit -m "功能添加XXX功能
详细说明...
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>"
```
**不要立即 push**,等待用户指示再推送到远程。

View File

@@ -66,12 +66,47 @@ export namespace AiotAlarmApi {
export interface AlertStatistics {
total?: number;
todayCount?: number;
yesterdayCount?: number;
pendingCount?: number;
handledCount?: number;
avgResponseMinutes?: number | null;
byType?: Record<string, number>;
byStatus?: Record<string, number>;
byLevel?: Record<string, number>;
}
/** 告警趋势项 */
export interface TrendItem {
date: string;
total: number;
leave_post?: number;
intrusion?: number;
illegal_parking?: number;
vehicle_congestion?: number;
[key: string]: number | string | undefined;
}
/** 设备告警排行项 */
export interface DeviceTopItem {
deviceId: string;
deviceName: string;
count: number;
}
/** 时段分布项 */
export interface HourDistItem {
hour: number;
count: number;
}
/** 看板聚合数据 */
export interface DashboardData {
statistics: AlertStatistics;
trend: TrendItem[];
deviceTop: DeviceTopItem[];
hourDistribution: HourDistItem[];
recentAlerts: Alert[];
}
}
// ==================== 告警管理 API ====================
@@ -107,6 +142,14 @@ export function deleteAlert(id: number | string) {
return requestClient.delete(`/aiot/alarm/alert/delete?id=${id}`);
}
/** 获取看板聚合数据(单次请求) */
export function getAlertDashboard(trendDays: number = 7) {
return requestClient.get<AiotAlarmApi.DashboardData>(
'/aiot/alarm/alert/dashboard',
{ params: { trendDays } },
);
}
/** 获取告警统计 */
export function getAlertStatistics(startTime?: string, endTime?: string) {
return requestClient.get<AiotAlarmApi.AlertStatistics>(
@@ -115,6 +158,37 @@ export function getAlertStatistics(startTime?: string, endTime?: string) {
);
}
/** 获取告警趋势 */
export function getAlertTrend(days: number = 7) {
return requestClient.get<AiotAlarmApi.TrendItem[]>(
'/aiot/alarm/alert/trend',
{ params: { days } },
);
}
/** 获取设备告警排行 */
export function getAlertDeviceTop(limit: number = 10, days: number = 7) {
return requestClient.get<AiotAlarmApi.DeviceTopItem[]>(
'/aiot/alarm/alert/device-top',
{ params: { limit, days } },
);
}
/** 获取24小时告警分布 */
export function getAlertHourDistribution(days: number = 7) {
return requestClient.get<AiotAlarmApi.HourDistItem[]>(
'/aiot/alarm/alert/hour-distribution',
{ params: { days } },
);
}
/** 获取最近告警列表 */
export function getRecentAlerts(pageSize: number = 10) {
return requestClient.get<any>('/aiot/alarm/alert/page', {
params: { pageNo: 1, pageSize },
});
}
// ==================== 摄像头告警汇总 API ====================
/** 以摄像头维度获取告警汇总 */

View File

@@ -30,6 +30,7 @@ export namespace AiotDeviceApi {
app?: string;
stream?: string;
cameraCode?: string; // 摄像头唯一编码
cameraName?: string; // 摄像头名称(用户自定义)
srcUrl?: string;
timeout?: number;
rtspType?: string; // '0'=TCP, '1'=UDP, '2'=Multicast
@@ -147,6 +148,13 @@ export function getMediaServerList() {
);
}
/** 摄像头选项列表(用于下拉搜索选择) */
export function getCameraOptions() {
return wvpRequestClient.get<
{ cameraCode: string; cameraName: string }[]
>('/aiot/device/camera/options');
}
// ==================== ROI 区域管理 ====================
/** ROI 列表(分页) */
@@ -189,19 +197,33 @@ export function deleteRoi(roiId: string) {
/**
* 获取摄像头截图 URL
*
* /snap 端点会自动处理缓存逻辑:
* - 有 Redis 缓存时直接 302 重定向到 COS presigned URL
* - 无缓存时触发 Edge 截图,等待完成后重定向(首次较慢)
* - force=true 时强制触发 Edge 截新图
* 非 force 模式:直接返回 /snap/image 代理 URL无时间戳浏览器自动缓存
* force 模式:先触发边缘端截图,再返回带时间戳的代理 URL 破缓存
*/
export async function getSnapUrl(cameraCode: string, force = false): Promise<string> {
const token = await getWvpToken();
if (force) {
// 强制刷新:先触发边缘端截图(等待完成)
try {
await wvpRequestClient.get('/aiot/device/roi/snap', {
params: { cameraCode, force: true },
});
} catch {
// 截图请求可能超时,但 COS 上可能已有图片,继续返回代理 URL
}
// 加时间戳破浏览器缓存
return (
`${apiURL}/aiot/device/roi/snap/image` +
`?cameraCode=${encodeURIComponent(cameraCode)}` +
`&access-token=${encodeURIComponent(token)}` +
`&t=${Date.now()}`
);
}
// 非 force使用代理端点不加时间戳浏览器自动缓存
return (
`${apiURL}/aiot/device/roi/snap` +
`${apiURL}/aiot/device/roi/snap/image` +
`?cameraCode=${encodeURIComponent(cameraCode)}` +
`&access-token=${encodeURIComponent(token)}` +
(force ? `&force=true` : '') +
`&t=${Date.now()}`
`&access-token=${encodeURIComponent(token)}`
);
}

View File

@@ -31,26 +31,34 @@ export namespace AiotEdgeApi {
}
// ==================== 边缘设备 API ====================
// 数据源WVP 数据库 wvp_ai_edge_device 表
// 路径经 vite proxy: /admin-api/aiot/device/* → rewrite → /api/ai/* → WVP:18080
/** 获取全部边缘设备列表 */
export function getDeviceList() {
return wvpRequestClient.get<AiotEdgeApi.Device[]>(
'/aiot/device/device/list',
);
}
/** 分页查询边缘设备列表 */
export function getDevicePage(params: PageParam) {
return wvpRequestClient.get<PageResult<AiotEdgeApi.Device>>(
'/api/ai/device/page',
'/aiot/device/device/page',
{ params },
);
}
/** 获取设备详情 */
export function getDevice(deviceId: string) {
return wvpRequestClient.get<AiotEdgeApi.Device>(
'/api/ai/device/get',
{ params: { deviceId } },
);
return wvpRequestClient.get<AiotEdgeApi.Device>('/aiot/device/device/get', {
params: { deviceId },
});
}
/** 获取设备统计 */
export function getDeviceStatistics() {
return wvpRequestClient.get<AiotEdgeApi.DeviceStatistics>(
'/api/ai/device/statistics',
'/aiot/device/device/statistics',
);
}

View File

@@ -137,12 +137,48 @@ export const useAuthStore = defineStore('auth', () => {
// userStore
userStore.setUserInfo(authPermissionInfo.user);
userStore.setUserRoles(authPermissionInfo.roles);
// accessStore
accessStore.setAccessMenus(authPermissionInfo.menus);
// accessStore - 隐藏不需要的菜单项 + 重命名菜单
const processedMenus = renameMenuItems(hideMenuItems(authPermissionInfo.menus));
accessStore.setAccessMenus(processedMenus);
accessStore.setAccessCodes(authPermissionInfo.permissions);
return authPermissionInfo;
}
/** 递归标记菜单为隐藏visible=false保留路由可直接访问 */
function hideMenuItems(menus: any[]): any[] {
// 需要从侧边栏隐藏的菜单路径ROI 区域配置已整合到摄像头管理中)
const hiddenPaths = new Set(['roi']);
const hiddenNameKeywords = ['ROI', 'roi'];
return menus.map((menu) => {
const shouldHide =
hiddenPaths.has(menu.path) ||
(menu.name &&
hiddenNameKeywords.some((kw: string) => menu.name.includes(kw)));
return {
...menu,
visible: shouldHide ? false : menu.visible,
children: menu.children ? hideMenuItems(menu.children) : menu.children,
};
});
}
/** 递归重命名菜单项 */
function renameMenuItems(menus: any[]): any[] {
const renameMap: Record<string, string> = {
'摄像头告警汇总': '告警看板',
'告警汇总': '告警看板',
};
return menus.map((menu) => {
const newName = menu.name && renameMap[menu.name];
return {
...menu,
name: newName || menu.name,
meta: newName && menu.meta ? { ...menu.meta, title: newName } : menu.meta,
children: menu.children ? renameMenuItems(menu.children) : menu.children,
};
});
}
function $reset() {
loginLoading.value = false;
}

View File

@@ -7,6 +7,8 @@ import { getRangePickerDefaultProps } from '#/utils';
export const ALERT_TYPE_OPTIONS = [
{ label: '离岗检测', value: 'leave_post' },
{ label: '周界入侵', value: 'intrusion' },
{ label: '车辆违停', value: 'illegal_parking' },
{ label: '车辆拥堵', value: 'vehicle_congestion' },
];
/** 告警状态选项 */
@@ -19,9 +21,10 @@ export const ALERT_STATUS_OPTIONS = [
/** 告警级别选项 */
export const ALERT_LEVEL_OPTIONS = [
{ label: '', value: 'low' },
{ label: '', value: 'medium' },
{ label: '', value: 'high' },
{ label: '轻微', value: 'low' },
{ label: '普通', value: 'medium' },
{ label: '重要', value: 'high' },
{ label: '紧急', value: 'critical' },
];
/** 列表的搜索表单 */
@@ -30,10 +33,21 @@ export function useGridFormSchema(): VbenFormSchema[] {
{
fieldName: 'cameraId',
label: '摄像头',
component: 'Input',
component: 'ApiSelect',
componentProps: {
placeholder: '请输入摄像头ID',
api: async () => {
const { getCameraOptions } = await import('#/api/aiot/device');
const list = await getCameraOptions();
return list.map((item: { cameraCode: string; cameraName: string }) => ({
label: item.cameraName,
value: item.cameraCode,
}));
},
placeholder: '请选择摄像头',
allowClear: true,
showSearch: true,
filterOption: (input: string, option: { label: string }) =>
option.label.toLowerCase().includes(input.toLowerCase()),
},
},
{

View File

@@ -93,8 +93,9 @@ function getLevelText(level?: string) {
function getLevelColor(level?: string) {
const colorMap: Record<string, string> = {
low: 'green',
medium: 'orange',
high: 'red',
medium: 'blue',
high: 'orange',
critical: 'red',
};
return level ? colorMap[level] || 'default' : 'default';
}

View File

@@ -0,0 +1,202 @@
import type { AiotAlarmApi } from '#/api/aiot/alarm';
const TYPE_NAMES: Record<string, string> = {
leave_post: '离岗检测',
intrusion: '周界入侵',
illegal_parking: '车辆违停',
vehicle_congestion: '车辆拥堵',
};
const TYPE_COLORS: Record<string, string> = {
leave_post: '#1890ff',
intrusion: '#f5222d',
illegal_parking: '#fa8c16',
vehicle_congestion: '#722ed1',
};
const LEVEL_NAMES: Record<number, string> = {
0: '紧急',
1: '重要',
2: '普通',
3: '轻微',
};
const LEVEL_COLORS: Record<number, string> = {
0: '#f5222d',
1: '#fa8c16',
2: '#1890ff',
3: '#8c8c8c',
};
/** 告警趋势折线图每种类型独立一条线都从0开始 */
export function getTrendChartOptions(data: AiotAlarmApi.TrendItem[]): any {
const dates = data.map((d) => d.date.slice(5)); // MM-DD
const types = Object.keys(TYPE_NAMES);
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'line' },
},
legend: {
data: types.map((t) => TYPE_NAMES[t]),
bottom: 0,
},
grid: { left: '3%', right: '4%', bottom: '12%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: dates,
},
yAxis: { type: 'value', minInterval: 1 },
series: types.map((type) => ({
name: TYPE_NAMES[type],
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
emphasis: { focus: 'series' },
itemStyle: { color: TYPE_COLORS[type] },
lineStyle: { width: 2 },
data: data.map((d) => (d[type] as number) || 0),
})),
};
}
/** 告警类型环形图 */
export function getTypePieChartOptions(
byType: Record<string, number>,
): any {
const data = Object.entries(byType)
.map(([type, count]) => ({
name: TYPE_NAMES[type] || type,
value: count,
itemStyle: { color: TYPE_COLORS[type] },
}))
.filter((d) => d.value > 0);
const total = data.reduce((s, d) => s + d.value, 0);
return {
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 0, left: 'center' },
graphic: {
type: 'text',
left: 'center',
top: '42%',
style: {
text: `${total}`,
fontSize: 28,
fontWeight: 'bold',
fill: '#333',
textAlign: 'center',
},
},
series: [
{
type: 'pie',
radius: ['45%', '70%'],
center: ['50%', '48%'],
avoidLabelOverlap: false,
label: { show: false },
data,
},
],
};
}
/** 设备告警 Top10 横向条形图 */
export function getDeviceTopChartOptions(
data: AiotAlarmApi.DeviceTopItem[],
): any {
const sorted = [...data].reverse(); // 最多的在上面
return {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '8%', bottom: '3%', top: '3%', containLabel: true },
xAxis: { type: 'value', minInterval: 1 },
yAxis: {
type: 'category',
data: sorted.map((d) => {
const name = d.deviceName || d.deviceId;
return name.length > 12 ? `${name.slice(0, 12)}...` : name;
}),
axisLabel: { fontSize: 11 },
},
series: [
{
type: 'bar',
data: sorted.map((d) => d.count),
itemStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 1, y2: 0,
colorStops: [
{ offset: 0, color: '#1890ff' },
{ offset: 1, color: '#36cfc9' },
],
},
borderRadius: [0, 4, 4, 0],
},
barMaxWidth: 20,
label: { show: true, position: 'right', fontSize: 11 },
},
],
};
}
/** 告警级别分布柱状图 */
export function getLevelBarChartOptions(
byLevel: Record<string, number>,
): any {
const levels = [0, 1, 2, 3];
return {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: levels.map((l) => LEVEL_NAMES[l]),
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{
type: 'bar',
data: levels.map((l) => ({
value: byLevel[l] || 0,
itemStyle: { color: LEVEL_COLORS[l], borderRadius: [4, 4, 0, 0] },
})),
barMaxWidth: 40,
label: { show: true, position: 'top', fontSize: 11 },
},
],
};
}
/** 24小时时段分布柱状图 */
export function getHourDistChartOptions(
data: AiotAlarmApi.HourDistItem[],
): any {
const counts = data.map((d) => d.count);
const maxCount = Math.max(...counts, 1);
return {
tooltip: { trigger: 'axis', formatter: '{b}时: {c}次' },
grid: { left: '3%', right: '4%', bottom: '3%', top: '8%', containLabel: true },
xAxis: {
type: 'category',
data: data.map((d) => `${d.hour}`),
axisLabel: { formatter: '{value}时', fontSize: 10 },
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{
type: 'bar',
data: counts.map((c) => ({
value: c,
itemStyle: {
color: c >= maxCount * 0.8 ? '#f5222d' : c >= maxCount * 0.5 ? '#fa8c16' : '#1890ff',
borderRadius: [3, 3, 0, 0],
},
})),
barMaxWidth: 16,
},
],
};
}

View File

@@ -1,83 +1,467 @@
<script setup lang="ts">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiotAlarmApi } from '#/api/aiot/alarm';
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
import { Badge, Tag } from 'ant-design-vue';
import {
Badge,
Card,
Col,
Empty,
List,
ListItem,
ListItemMeta,
Row,
Segmented,
Spin,
Statistic,
Tag,
} from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCameraAlertSummary } from '#/api/aiot/alarm';
import {
getAlertDashboard,
getAlertTrend,
} from '#/api/aiot/alarm';
import { useCameraSummaryColumns } from '../list/data';
import {
getDeviceTopChartOptions,
getHourDistChartOptions,
getLevelBarChartOptions,
getTrendChartOptions,
getTypePieChartOptions,
} from './chart-options';
defineOptions({ name: 'AiotAlarmSummary' });
defineOptions({ name: 'AiotAlarmDashboard' });
const router = useRouter();
const loading = ref(true);
/** 跳转到该摄像头的告警列表 */
function handleViewAlerts(row: AiotAlarmApi.CameraAlertSummary) {
router.push({
path: '/aiot/alarm/list',
query: { cameraId: row.cameraId },
});
// 数据
const stats = ref<AiotAlarmApi.AlertStatistics>({});
const trendData = ref<AiotAlarmApi.TrendItem[]>([]);
const deviceTopData = ref<AiotAlarmApi.DeviceTopItem[]>([]);
const hourDistData = ref<AiotAlarmApi.HourDistItem[]>([]);
const recentAlerts = ref<AiotAlarmApi.Alert[]>([]);
// 趋势天数切换
const trendDays = ref(7);
const trendDaysOptions = [
{ label: '近7天', value: 7 },
{ label: '近30天', value: 30 },
];
// 图表 ref
const trendChartRef = ref();
const typePieChartRef = ref();
const deviceTopChartRef = ref();
const levelBarChartRef = ref();
const hourDistChartRef = ref();
const { renderEcharts: renderTrend } = useEcharts(trendChartRef);
const { renderEcharts: renderTypePie } = useEcharts(typePieChartRef);
const { renderEcharts: renderDeviceTop } = useEcharts(deviceTopChartRef);
const { renderEcharts: renderLevelBar } = useEcharts(levelBarChartRef);
const { renderEcharts: renderHourDist } = useEcharts(hourDistChartRef);
// 自动刷新
let refreshTimer: ReturnType<typeof setInterval> | null = null;
// 告警类型映射
const TYPE_NAMES: Record<string, string> = {
leave_post: '离岗检测',
intrusion: '周界入侵',
illegal_parking: '车辆违停',
vehicle_congestion: '车辆拥堵',
};
const TYPE_COLORS: Record<string, string> = {
leave_post: 'blue',
intrusion: 'red',
illegal_parking: 'orange',
vehicle_congestion: 'purple',
};
const STATUS_NAMES: Record<string, string> = {
pending: '待处理',
handled: '已处理',
ignored: '已忽略',
};
/** 加载全部数据(单次请求) */
async function loadAll() {
loading.value = true;
try {
const data = await getAlertDashboard(trendDays.value);
stats.value = data.statistics;
trendData.value = data.trend;
deviceTopData.value = data.deviceTop;
hourDistData.value = data.hourDistribution;
recentAlerts.value = data.recentAlerts || [];
} finally {
loading.value = false;
await nextTick();
await nextTick();
renderCharts();
}
}
const [Grid] = useVbenVxeGrid({
gridOptions: {
columns: useCameraSummaryColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }) => {
return await getCameraAlertSummary({
pageNo: page.currentPage,
pageSize: page.pageSize,
});
},
},
},
rowConfig: {
keyField: 'cameraId',
isHover: true,
},
toolbarConfig: {
refresh: true,
},
} as VxeTableGridOptions<AiotAlarmApi.CameraAlertSummary>,
/** 切换趋势天数 */
async function onTrendDaysChange(val: number) {
trendDays.value = val;
try {
trendData.value = await getAlertTrend(val);
await nextTick();
renderTrend(getTrendChartOptions(trendData.value));
} catch {
/* ignore */
}
}
/** 渲染所有图表 */
function renderCharts() {
if (trendData.value.length > 0) {
renderTrend(getTrendChartOptions(trendData.value));
}
if (stats.value.byType && Object.keys(stats.value.byType).length > 0) {
renderTypePie(getTypePieChartOptions(stats.value.byType));
}
if (deviceTopData.value.length > 0) {
renderDeviceTop(getDeviceTopChartOptions(deviceTopData.value));
}
if (stats.value.byLevel && Object.keys(stats.value.byLevel).length > 0) {
renderLevelBar(getLevelBarChartOptions(stats.value.byLevel));
}
if (hourDistData.value.length > 0) {
renderHourDist(getHourDistChartOptions(hourDistData.value));
}
}
/** 计算同比变化 */
function getDayChange(): { value: number; type: 'up' | 'down' | 'flat' } {
const today = stats.value.todayCount ?? 0;
const yesterday = stats.value.yesterdayCount ?? 0;
if (yesterday === 0) return { value: 0, type: 'flat' };
const pct = Math.round(((today - yesterday) / yesterday) * 100);
return {
value: Math.abs(pct),
type: pct > 0 ? 'up' : pct < 0 ? 'down' : 'flat',
};
}
/** 格式化时间为简短格式 */
function formatTime(time?: string) {
if (!time) return '';
const d = new Date(time);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** 跳转到告警列表 */
function goToAlertList() {
router.push('/aiot/alarm/list');
}
onMounted(() => {
loadAll();
refreshTimer = setInterval(loadAll, 60_000);
});
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer);
});
</script>
<template>
<Page auto-content-height>
<Grid table-title="摄像头告警汇总">
<!-- 待处理数量列 -->
<template #pendingCount="{ row }">
<Badge
v-if="row.pendingCount > 0"
:count="row.pendingCount"
:overflow-count="99"
:number-style="{ backgroundColor: '#faad14' }"
/>
<span v-else class="text-gray-400">0</span>
</template>
<div class="dashboard-container">
<!-- KPI 卡片 -->
<Row :gutter="[16, 16]">
<Col :xs="24" :sm="12" :md="8" :lg="5">
<Card size="small" :bordered="false" class="kpi-card">
<Statistic
title="今日告警"
:value="stats.todayCount ?? 0"
:value-style="{ color: '#1890ff', fontWeight: 'bold', fontSize: '28px' }"
/>
<div class="kpi-footer">
<template v-if="getDayChange().type === 'up'">
<span class="change-up">&#9650; {{ getDayChange().value }}%</span>
<span class="change-label">较昨日</span>
</template>
<template v-else-if="getDayChange().type === 'down'">
<span class="change-down">&#9660; {{ getDayChange().value }}%</span>
<span class="change-label">较昨日</span>
</template>
<template v-else>
<span class="change-flat">与昨日持平</span>
</template>
</div>
</Card>
</Col>
<Col :xs="12" :sm="12" :md="8" :lg="5">
<Card size="small" :bordered="false" class="kpi-card">
<Statistic
title="待处理"
:value="stats.pendingCount ?? 0"
:value-style="{ color: '#faad14', fontWeight: 'bold', fontSize: '28px' }"
/>
<div class="kpi-footer">
<span class="change-label">需及时处理</span>
</div>
</Card>
</Col>
<Col :xs="12" :sm="12" :md="8" :lg="5">
<Card size="small" :bordered="false" class="kpi-card">
<Statistic
title="已处理"
:value="stats.handledCount ?? 0"
:value-style="{ color: '#52c41a', fontWeight: 'bold', fontSize: '28px' }"
/>
<div class="kpi-footer">
<span class="change-label">
总计 {{ stats.total ?? 0 }}
</span>
</div>
</Card>
</Col>
<Col :xs="12" :sm="12" :md="8" :lg="5">
<Card size="small" :bordered="false" class="kpi-card">
<Statistic
title="平均响应"
:value="stats.avgResponseMinutes ?? '-'"
suffix="分钟"
:value-style="{ color: '#722ed1', fontWeight: 'bold', fontSize: '28px' }"
/>
<div class="kpi-footer">
<span class="change-label">告警到处理</span>
</div>
</Card>
</Col>
<Col :xs="12" :sm="12" :md="8" :lg="4">
<Card size="small" :bordered="false" class="kpi-card">
<Statistic
title="在线设备"
:value="deviceTopData.length"
:value-style="{ color: '#13c2c2', fontWeight: 'bold', fontSize: '28px' }"
/>
<div class="kpi-footer">
<span class="change-label">活跃摄像头</span>
</div>
</Card>
</Col>
</Row>
<!-- 操作列 -->
<template #actions="{ row }">
<TableAction
:actions="[
{
label: '查看告警',
type: 'link',
icon: ACTION_ICON.VIEW,
onClick: handleViewAlerts.bind(null, row),
},
]"
/>
</template>
</Grid>
<!-- 第一行图表趋势 + 类型 -->
<Row :gutter="[16, 16]" class="mt-4">
<Col :xs="24" :lg="14">
<Card size="small" :bordered="false">
<template #title>
<div class="chart-title-bar">
<span>告警趋势</span>
<Segmented
:value="trendDays"
:options="trendDaysOptions"
size="small"
@change="onTrendDaysChange"
/>
</div>
</template>
<Spin :spinning="loading">
<div v-if="trendData.length > 0">
<EchartsUI ref="trendChartRef" class="h-[320px] w-full" />
</div>
<Empty v-else class="h-[320px]" description="暂无数据" />
</Spin>
</Card>
</Col>
<Col :xs="24" :lg="10">
<Card size="small" :bordered="false" title="告警类型分布">
<Spin :spinning="loading">
<div
v-if="
stats.byType && Object.keys(stats.byType).length > 0
"
>
<EchartsUI ref="typePieChartRef" class="h-[320px] w-full" />
</div>
<Empty v-else class="h-[320px]" description="暂无数据" />
</Spin>
</Card>
</Col>
</Row>
<!-- 第二行图表设备Top + 级别 -->
<Row :gutter="[16, 16]" class="mt-4">
<Col :xs="24" :lg="14">
<Card size="small" :bordered="false" title="设备告警 Top10近7天">
<Spin :spinning="loading">
<div v-if="deviceTopData.length > 0">
<EchartsUI
ref="deviceTopChartRef"
class="h-[320px] w-full"
/>
</div>
<Empty v-else class="h-[320px]" description="暂无数据" />
</Spin>
</Card>
</Col>
<Col :xs="24" :lg="10">
<Card size="small" :bordered="false" title="告警级别分布">
<Spin :spinning="loading">
<div
v-if="
stats.byLevel && Object.keys(stats.byLevel).length > 0
"
>
<EchartsUI ref="levelBarChartRef" class="h-[320px] w-full" />
</div>
<Empty v-else class="h-[320px]" description="暂无数据" />
</Spin>
</Card>
</Col>
</Row>
<!-- 第三行:时段分布 + 最近告警 -->
<Row :gutter="[16, 16]" class="mt-4">
<Col :xs="24" :lg="14">
<Card size="small" :bordered="false" title="24小时时段分布近7天">
<Spin :spinning="loading">
<div v-if="hourDistData.length > 0">
<EchartsUI ref="hourDistChartRef" class="h-[280px] w-full" />
</div>
<Empty v-else class="h-[280px]" description="暂无数据" />
</Spin>
</Card>
</Col>
<Col :xs="24" :lg="10">
<Card size="small" :bordered="false">
<template #title>
<div class="chart-title-bar">
<span>最近告警</span>
<a class="view-all-link" @click="goToAlertList">查看全部</a>
</div>
</template>
<div class="recent-alerts-list">
<List
:data-source="recentAlerts"
:loading="loading"
size="small"
>
<template #renderItem="{ item }">
<ListItem>
<ListItemMeta>
<template #title>
<div class="alert-item-title">
<Tag
:color="TYPE_COLORS[item.alertType] || 'default'"
size="small"
>
{{ TYPE_NAMES[item.alertType] || item.alertType }}
</Tag>
<span class="alert-device-name">
{{ item.cameraName || item.cameraId }}
</span>
</div>
</template>
<template #description>
<div class="alert-item-desc">
<span>{{ formatTime(item.triggerTime) }}</span>
<Badge
:status="
item.status === 'pending'
? 'warning'
: item.status === 'handled'
? 'success'
: 'default'
"
:text="STATUS_NAMES[item.status] || item.status"
/>
</div>
</template>
</ListItemMeta>
</ListItem>
</template>
</List>
</div>
</Card>
</Col>
</Row>
</div>
</Page>
</template>
<style scoped>
.dashboard-container {
padding: 4px;
}
.kpi-card {
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
border-radius: 8px;
}
.kpi-footer {
margin-top: 8px;
font-size: 12px;
}
.change-up {
color: #f5222d;
font-weight: 500;
margin-right: 4px;
}
.change-down {
color: #52c41a;
font-weight: 500;
margin-right: 4px;
}
.change-flat {
color: #8c8c8c;
}
.change-label {
color: #8c8c8c;
}
.chart-title-bar {
display: flex;
align-items: center;
justify-content: space-between;
}
.view-all-link {
font-size: 13px;
cursor: pointer;
color: #1890ff;
}
.recent-alerts-list {
max-height: 280px;
overflow-y: auto;
}
.alert-item-title {
display: flex;
align-items: center;
gap: 8px;
}
.alert-device-name {
font-size: 13px;
color: #333;
}
.alert-item-desc {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #8c8c8c;
}
</style>

View File

@@ -10,6 +10,7 @@ import type { AiotDeviceApi } from '#/api/aiot/device';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useAppConfig } from '@vben/hooks';
import { Page } from '@vben/common-ui';
import {
@@ -29,18 +30,18 @@ import {
import {
deleteCamera,
exportConfig,
getCameraList,
getMediaServerList,
getRoiByCameraId,
getSnapUrl,
pushAllConfig,
saveCamera,
startCamera,
stopCamera,
} from '#/api/aiot/device';
defineOptions({ name: 'AiotDeviceCamera' });
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const router = useRouter();
// ==================== 列表状态 ====================
@@ -48,19 +49,18 @@ const router = useRouter();
const loading = ref(false);
const cameraList = ref<AiotDeviceApi.Camera[]>([]);
const roiCounts = ref<Record<string, number>>({});
const cameraStatus = ref<Record<string, boolean | null>>({});
const page = ref(1);
const pageSize = ref(15);
const total = ref(0);
const searchQuery = ref('');
const searchPulling = ref<string | undefined>(undefined);
const columns = [
{ title: '应用名', dataIndex: 'app', width: 120 },
{ title: '流ID', dataIndex: 'stream', width: 150 },
{ title: '摄像头名称', dataIndex: 'cameraName', width: 150 },
{ title: '拉流地址', dataIndex: 'srcUrl', ellipsis: true },
{ title: '状态', key: 'pulling', width: 100 },
{ title: '状态', key: 'status', width: 60, align: 'center' as const },
{ title: 'ROI', key: 'roiCount', width: 80, align: 'center' as const },
{ title: '操作', key: 'actions', width: 340, fixed: 'right' as const },
{ title: '操作', key: 'actions', width: 240, fixed: 'right' as const },
];
// ==================== 编辑弹窗状态 ====================
@@ -72,7 +72,8 @@ const mediaServerOptions = ref<{ label: string; value: string }[]>([]);
const editForm = reactive<Partial<AiotDeviceApi.Camera>>({
id: undefined,
type: 'default',
app: 'live',
cameraName: '',
app: '',
stream: '',
srcUrl: '',
timeout: 15,
@@ -114,14 +115,11 @@ async function loadData() {
page: page.value,
count: pageSize.value,
query: searchQuery.value || undefined,
pulling:
searchPulling.value === undefined
? undefined
: searchPulling.value === 'true',
});
cameraList.value = res.list || [];
total.value = res.total || 0;
loadRoiCounts();
loadCameraStatus();
} catch {
message.error('加载摄像头列表失败');
} finally {
@@ -142,6 +140,21 @@ function loadRoiCounts() {
}
}
async function loadCameraStatus() {
for (const cam of cameraList.value) {
const cameraCode = cam.cameraCode;
if (!cameraCode) continue;
// 使用 fetch HEAD 请求检测截图是否可用(/snap/image 已免认证)
try {
const url = `${apiURL}/aiot/device/roi/snap/image?cameraCode=${encodeURIComponent(cameraCode)}`;
const res = await fetch(url, { method: 'HEAD' });
cameraStatus.value = { ...cameraStatus.value, [cameraCode]: res.ok };
} catch {
cameraStatus.value = { ...cameraStatus.value, [cameraCode]: false };
}
}
}
async function loadMediaServers() {
try {
const data = await getMediaServerList();
@@ -164,7 +177,8 @@ function resetForm() {
Object.assign(editForm, {
id: undefined,
type: 'default',
app: 'live',
cameraName: '',
app: '',
stream: '',
srcUrl: '',
timeout: 15,
@@ -225,6 +239,7 @@ function handleEdit(row: AiotDeviceApi.Camera) {
Object.assign(editForm, {
id: row.id,
type: row.type || 'default',
cameraName: row.cameraName || '',
app: row.app || '',
stream: row.stream || '',
cameraCode: row.cameraCode || '',
@@ -244,24 +259,35 @@ function handleEdit(row: AiotDeviceApi.Camera) {
}
async function handleSave() {
if (!editForm.app?.trim()) {
message.warning('请输入应用名');
return;
}
if (!editForm.stream?.trim()) {
message.warning('请输入流ID');
if (!editForm.cameraName?.trim()) {
message.warning('请输入摄像头名称');
return;
}
if (!editForm.srcUrl?.trim()) {
message.warning('请输入拉流地址');
return;
}
// app/stream 为 ZLM 内部字段,自动填充默认值即可
if (!editForm.app) editForm.app = 'default';
if (!editForm.stream) editForm.stream = '001';
saving.value = true;
try {
await saveCamera({ ...editForm });
message.success(editForm.id ? '编辑成功' : '添加成功');
editModalOpen.value = false;
loadData();
// 新增摄像头成功后自动触发首次截图预热(非阻塞)
if (!editForm.id) {
loadData().then(() => {
const saved = cameraList.value.find(
(c) => c.app === editForm.app && c.stream === editForm.stream,
);
if (saved?.cameraCode) {
getSnapUrl(saved.cameraCode, true).catch(() => {});
}
});
} else {
loadData();
}
} catch (err: any) {
message.error(err?.message || '保存失败');
} finally {
@@ -274,7 +300,7 @@ async function handleSave() {
function handleDelete(row: AiotDeviceApi.Camera) {
Modal.confirm({
title: '删除确认',
content: `确定删除摄像头 ${row.cameraCode || row.stream} `,
content: `确定删除摄像头 ${row.cameraName || row.cameraCode || row.stream} `,
okType: 'danger',
async onOk() {
try {
@@ -288,23 +314,6 @@ function handleDelete(row: AiotDeviceApi.Camera) {
});
}
// ==================== 拉流控制 ====================
async function toggleStream(row: AiotDeviceApi.Camera) {
try {
if (row.pulling) {
await stopCamera(row.id!);
message.success('已停止拉流');
} else {
await startCamera(row.id!);
message.success('开始拉流');
}
loadData();
} catch {
message.error('操作失败');
}
}
// ==================== ROI 配置跳转 ====================
function handleRoiConfig(row: AiotDeviceApi.Camera) {
@@ -317,29 +326,6 @@ function handleRoiConfig(row: AiotDeviceApi.Camera) {
});
}
// ==================== 配置导出 ====================
async function handleExport(row: AiotDeviceApi.Camera) {
const cameraCode = row.cameraCode;
if (!cameraCode) {
message.warning('摄像头编码为空,无法导出');
return;
}
try {
const data = await exportConfig(cameraCode);
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `config_${cameraCode}.json`;
a.click();
URL.revokeObjectURL(url);
} catch {
message.error('导出失败');
}
}
// ==================== 同步全局配置 ====================
const syncing = ref(false);
@@ -401,17 +387,6 @@ onMounted(() => {
allow-clear
@press-enter="loadData"
/>
<Select
v-model:value="searchPulling"
placeholder="拉流状态"
style="width: 130px"
allow-clear
:options="[
{ value: 'true', label: '拉流中' },
{ value: 'false', label: '未拉流' },
]"
@change="loadData"
/>
<Button type="primary" @click="loadData">查询</Button>
</div>
<Space>
@@ -442,10 +417,17 @@ onMounted(() => {
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'pulling'">
<Tag :color="record.pulling ? 'success' : 'default'">
{{ record.pulling ? '拉流中' : '未拉流' }}
</Tag>
<template v-if="column.key === 'status'">
<Badge
:status="
cameraStatus[record.cameraCode] === null ||
cameraStatus[record.cameraCode] === undefined
? 'default'
: cameraStatus[record.cameraCode]
? 'success'
: 'error'
"
/>
</template>
<template v-else-if="column.key === 'roiCount'">
<Badge
@@ -464,15 +446,6 @@ onMounted(() => {
ROI配置
</Button>
<Button size="small" @click="handleEdit(record)">编辑</Button>
<Button
:type="record.pulling ? 'default' : 'primary'"
:danger="record.pulling"
size="small"
@click="toggleStream(record)"
>
{{ record.pulling ? '停止' : '拉流' }}
</Button>
<Button size="small" @click="handleExport(record)">导出</Button>
<Button size="small" danger @click="handleDelete(record)">
删除
</Button>
@@ -508,26 +481,11 @@ onMounted(() => {
<Form.Item v-if="editForm.id" label="摄像头编码">
<Input :value="editForm.cameraCode" disabled />
</Form.Item>
<Form.Item label="应用名" required>
<AutoComplete
v-model:value="editForm.app"
:options="appOptions"
placeholder="选择已有场景或输入新场景名"
allow-clear
@change="autoFillStreamId"
/>
<div style="margin-top: 4px; font-size: 12px; color: #999">
场景名称同一场景下可有多个摄像头可选择已有或输入新名称
</div>
</Form.Item>
<Form.Item label="流ID" required>
<Form.Item label="摄像头名称" required>
<Input
v-model:value="editForm.stream"
placeholder="在当前场景下的唯一标识001系统会自动编号"
v-model:value="editForm.cameraName"
placeholder="请输入摄像头名称,如:大堂吧台、室外停车场"
/>
<div style="margin-top: 4px; font-size: 12px; color: #999">
在当前场景下唯一系统会根据应用名自动编号可手动修改
</div>
</Form.Item>
<Form.Item label="拉流地址" required>
<Input

View File

@@ -55,6 +55,14 @@ const paramNameMap: Record<string, string> = {
detection_interval: '检测间隔(秒)',
enable_tracking: '启用跟踪',
tracking_timeout: '跟踪超时(秒)',
// 车辆违停参数
confirm_vehicle_sec: '车辆确认时间(秒)',
parking_countdown_sec: '违停倒计时(秒)',
confirm_clear_sec: '消失确认时间(秒)',
cooldown_sec: '告警冷却期(秒)',
// 车辆拥堵参数
count_threshold: '车辆数量阈值',
confirm_congestion_sec: '拥堵确认时间(秒)',
};
// 参数说明映射
@@ -66,6 +74,14 @@ const paramDescMap: Record<string, string> = {
confirm_seconds: '持续检测到人达到该时间后触发告警,持续无人达到该时间后自动结束告警(向后兼容参数)',
confirm_intrusion_seconds: '入侵确认:持续检测到人达到该时间后触发告警',
confirm_clear_seconds: '消失确认:持续无人达到该时间后自动结束告警。期间短暂有人(<5秒)不影响倒计时',
// 车辆违停参数说明
confirm_vehicle_sec: '确认车辆停留的时间,超过该时间开始违停倒计时',
parking_countdown_sec: '确认有车后的违停等待时间,超过该时间触发违停告警',
confirm_clear_sec: '车辆离开后,持续无车达到该时间后自动结束告警',
cooldown_sec: '触发告警后,多少秒内不再重复告警',
// 车辆拥堵参数说明
count_threshold: '区域内车辆数量达到该值时判定为拥堵',
confirm_congestion_sec: '车辆数持续超过阈值达到该时间后触发拥堵告警',
};
// 获取参数的中文名称

View File

@@ -22,14 +22,32 @@ import {
import AlgorithmParamEditor from './AlgorithmParamEditor.vue';
/** 告警等级选项 */
const ALARM_LEVEL_OPTIONS = [
{ value: 0, label: '紧急', color: '#f5222d' },
{ value: 1, label: '重要', color: '#fa8c16' },
{ value: 2, label: '普通', color: '#1677ff' },
{ value: 3, label: '轻微', color: '#8c8c8c' },
];
/** 算法默认告警等级 */
const DEFAULT_ALARM_LEVELS: Record<string, number> = {
intrusion: 1,
leave_post: 2,
illegal_parking: 1,
vehicle_congestion: 2,
};
interface Props {
roiId: string;
bindings: AiotDeviceApi.RoiAlgoBinding[];
snapOk: boolean;
}
const props = withDefaults(defineProps<Props>(), {
roiId: '',
bindings: () => [],
snapOk: false,
});
const emit = defineEmits<{
@@ -70,6 +88,10 @@ async function handleBind() {
message.warning('请选择算法');
return;
}
if (!props.snapOk) {
message.error('RTSP 拉流地址无效,请先在摄像头管理中修正拉流地址');
return;
}
try {
await bindAlgo({
roiId: props.roiId,
@@ -130,9 +152,40 @@ function getAlgoFrameRate(algoCode: string): string {
const frameRates: Record<string, string> = {
leave_post: '3帧/秒',
intrusion: '1帧/秒',
illegal_parking: '1帧/秒',
vehicle_congestion: '1帧/秒',
};
return frameRates[algoCode] || '5帧/秒';
}
/** 从 params JSON 中读取告警等级 */
function getAlarmLevel(item: AiotDeviceApi.RoiAlgoBinding): number {
try {
const params = JSON.parse(item.bind.params || '{}');
if (params.alarm_level !== undefined) return params.alarm_level;
} catch { /* empty */ }
return DEFAULT_ALARM_LEVELS[item.bind.algoCode || ''] ?? 2;
}
/** 修改告警等级 */
async function onAlarmLevelChange(item: AiotDeviceApi.RoiAlgoBinding, level: number) {
try {
let params: Record<string, any> = {};
try {
params = JSON.parse(item.bind.params || '{}');
} catch { /* empty */ }
params.alarm_level = level;
await updateAlgoParams({
bindId: item.bind.bindId!,
params: JSON.stringify(params),
});
// 更新本地数据
item.bind.params = JSON.stringify(params);
message.success('告警等级已更新');
} catch {
message.error('更新告警等级失败');
}
}
</script>
<template>
@@ -186,9 +239,22 @@ function getAlgoFrameRate(algoCode: string): string {
<Tag color="green" style="font-size: 11px">
{{ getAlgoFrameRate(item.bind.algoCode) }}
</Tag>
<span style="color: #999; font-size: 12px">
{{ item.bind.algoCode }}
</span>
<Select
:value="getAlarmLevel(item)"
size="small"
style="width: 90px"
@change="(val: any) => onAlarmLevelChange(item, val as number)"
>
<Select.Option
v-for="opt in ALARM_LEVEL_OPTIONS"
:key="opt.value"
:value="opt.value"
>
<span :style="{ color: opt.color, fontWeight: 500 }">
{{ opt.label }}
</span>
</Select.Option>
</Select>
</div>
<div style="display: flex; align-items: center; gap: 8px">
<Switch

View File

@@ -21,6 +21,7 @@ const emit = defineEmits<{
'roi-drawn': [data: { roi_type: string; coordinates: string }];
'roi-selected': [roiId: string | null];
'roi-deleted': [roiId: string];
'snap-status': [ok: boolean];
}>();
const wrapper = ref<HTMLDivElement>();
@@ -77,22 +78,55 @@ onUnmounted(() => {
function onImageLoad() {
loading.value = false;
emit('snap-status', true);
nextTick(() => initCanvas());
}
function onImageError() {
loading.value = false;
errorMsg.value = '截图加载失败,请确认摄像头正在拉流';
errorMsg.value = '截图加载失败,请确认摄像头拉流地址是否有效';
emit('snap-status', false);
// 关键:截图失败也初始化 canvas使 ROI 区域可见可操作
nextTick(() => initCanvas());
}
function getImageContentRect() {
const img = wrapper.value?.querySelector('img');
if (!img || !img.naturalWidth || !wrapper.value) {
return {
x: 0,
y: 0,
w: wrapper.value?.clientWidth ?? 0,
h: wrapper.value?.clientHeight ?? 0,
};
}
const cW = wrapper.value.clientWidth;
const cH = wrapper.value.clientHeight;
const imgRatio = img.naturalWidth / img.naturalHeight;
const cRatio = cW / cH;
let rW: number;
let rH: number;
if (imgRatio > cRatio) {
rW = cW;
rH = cW / imgRatio;
} else {
rH = cH;
rW = cH * imgRatio;
}
return { x: (cW - rW) / 2, y: (cH - rH) / 2, w: rW, h: rH };
}
function initCanvas() {
if (!canvas.value || !wrapper.value) return;
canvasWidth = wrapper.value.clientWidth;
canvasHeight = wrapper.value.clientHeight;
const rect = getImageContentRect();
canvasWidth = rect.w;
canvasHeight = rect.h;
canvas.value.width = canvasWidth;
canvas.value.height = canvasHeight;
canvas.value.style.left = `${rect.x}px`;
canvas.value.style.top = `${rect.y}px`;
canvas.value.style.width = `${rect.w}px`;
canvas.value.style.height = `${rect.h}px`;
ctx = canvas.value.getContext('2d');
redraw();
}
@@ -362,10 +396,6 @@ function drawPolygonInProgress() {
.roi-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: crosshair;
}
</style>

View File

@@ -64,6 +64,7 @@ const roiList = ref<AiotDeviceApi.Roi[]>([]);
const selectedRoiId = ref<null | string>(null);
const selectedRoiBindings = ref<AiotDeviceApi.RoiAlgoBinding[]>([]);
const snapUrl = ref('');
const snapOk = ref(false);
const edgeDevices = ref<Array<{ deviceId: string }>>([]);
@@ -157,6 +158,10 @@ async function refreshSnap() {
await buildSnapUrl(true);
}
function onSnapStatus(ok: boolean) {
snapOk.value = ok;
}
// ==================== ROI 数据加载 ====================
async function loadRois() {
@@ -352,6 +357,7 @@ function handlePush() {
@roi-drawn="onRoiDrawn"
@roi-selected="onRoiSelected"
@roi-deleted="onRoiDeleted"
@snap-status="onSnapStatus"
/>
</div>
@@ -471,6 +477,7 @@ function handlePush() {
<RoiAlgorithmBind
:roi-id="selectedRoi.roiId || ''"
:bindings="selectedRoiBindings"
:snap-ok="snapOk"
@changed="loadRoiDetail"
/>
</div>

View File

@@ -1,77 +1,16 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
/** 设备状态配置 */
export const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
online: { color: 'success', label: '在线' },
offline: { color: 'default', label: '离线' },
};
/** 设备状态选项 */
export const DEVICE_STATUS_OPTIONS = [
{ label: '在线', value: 'online' },
{ label: '离线', value: 'offline' },
{ label: '异常', value: 'error' },
];
/** 边缘设备搜索表单 */
export function useEdgeGridFormSchema(): VbenFormSchema[] {
return [
{
fieldName: 'status',
label: '设备状态',
component: 'Select',
componentProps: {
options: DEVICE_STATUS_OPTIONS,
placeholder: '请选择设备状态',
allowClear: true,
},
},
];
}
/** 边缘设备列表字段 */
export function useEdgeGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'deviceId',
title: '设备ID',
minWidth: 160,
},
{
field: 'deviceName',
title: '设备名称',
minWidth: 150,
},
{
field: 'status',
title: '状态',
minWidth: 90,
slots: { default: 'status' },
},
{
field: 'lastHeartbeat',
title: '最后心跳',
minWidth: 170,
formatter: 'formatDateTime',
},
{
field: 'uptimeSeconds',
title: '运行时长',
minWidth: 100,
slots: { default: 'uptime' },
},
{
field: 'framesProcessed',
title: '处理帧数',
minWidth: 100,
slots: { default: 'frames' },
},
{
field: 'alertsGenerated',
title: '告警数',
minWidth: 90,
slots: { default: 'alerts' },
},
{
field: 'updatedAt',
title: '更新时间',
minWidth: 170,
formatter: 'formatDateTime',
},
];
/** 格式化运行时长 */
export function formatUptime(seconds?: number): string {
if (seconds == null) return '-';
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}${h}小时`;
if (h > 0) return `${h}小时 ${m}分钟`;
return `${m}分钟`;
}

View File

@@ -1,92 +1,134 @@
<script setup lang="ts">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AiotEdgeApi } from '#/api/aiot/edge';
import { ref, onMounted } from 'vue';
import { Page } from '@vben/common-ui';
import { Tag } from 'ant-design-vue';
import {
Button,
Card,
Col,
Descriptions,
DescriptionsItem,
Empty,
Row,
Spin,
Tag,
} from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getDevicePage } from '#/api/aiot/edge';
import { getDeviceList } from '#/api/aiot/edge';
import type { AiotEdgeApi } from '#/api/aiot/edge';
import { useEdgeGridColumns, useEdgeGridFormSchema } from './data';
import { formatUptime, STATUS_CONFIG } from './data';
defineOptions({ name: 'AiotEdgeNode' });
/** 获取状态颜色 */
function getStatusColor(status?: string) {
const colorMap: Record<string, string> = {
online: 'success',
offline: 'default',
error: 'error',
};
return status ? colorMap[status] || 'default' : 'default';
const loading = ref(false);
const devices = ref<AiotEdgeApi.Device[]>([]);
async function fetchDevices() {
loading.value = true;
try {
const list = await getDeviceList();
devices.value = Array.isArray(list) ? list : [];
} catch {
devices.value = [];
} finally {
loading.value = false;
}
}
/** 格式化运行时长 */
function formatUptime(seconds?: number) {
if (seconds == null) return '-';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
function formatTime(time?: string) {
if (!time) return '-';
return time;
}
const [Grid] = useVbenVxeGrid({
formOptions: {
schema: useEdgeGridFormSchema(),
},
gridOptions: {
columns: useEdgeGridColumns(),
height: 'auto',
keepSource: true,
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDevicePage({
pageNo: page.currentPage,
pageSize: page.pageSize,
status: formValues?.status,
});
},
},
},
rowConfig: {
keyField: 'id',
isHover: true,
},
toolbarConfig: {
refresh: true,
search: true,
},
} as VxeTableGridOptions<AiotEdgeApi.Device>,
});
onMounted(fetchDevices);
</script>
<template>
<Page auto-content-height>
<Grid table-title="边缘节点管理">
<!-- 状态列 -->
<template #status="{ row }">
<Tag :color="getStatusColor(row.status)">
{{ row.statusName || row.status || '-' }}
</Tag>
</template>
<div class="edge-node-page">
<div class="page-header">
<h3 class="page-title">边缘节点管理</h3>
<Button type="primary" :loading="loading" @click="fetchDevices">
刷新
</Button>
</div>
<!-- 运行时长列 -->
<template #uptime="{ row }">
<span>{{ formatUptime(row.uptimeSeconds) }}</span>
</template>
<Spin :spinning="loading">
<Empty v-if="!loading && devices.length === 0" description="暂无边缘节点数据" />
<!-- 处理帧数列 -->
<template #frames="{ row }">
<span>{{ row.framesProcessed?.toLocaleString() ?? '-' }}</span>
</template>
<Row v-else :gutter="[16, 16]">
<Col
v-for="device in devices"
:key="device.deviceId"
:xs="24"
:sm="24"
:md="12"
:lg="8"
:xl="8"
>
<Card hoverable>
<template #title>
<div class="card-title">
<span class="device-id">{{ device.deviceId || '-' }}</span>
<Tag
:color="STATUS_CONFIG[device.status || '']?.color || 'default'"
>
{{ STATUS_CONFIG[device.status || '']?.label || device.status || '未知' }}
</Tag>
</div>
</template>
<!-- 告警数列 -->
<template #alerts="{ row }">
<span>{{ row.alertsGenerated?.toLocaleString() ?? '-' }}</span>
</template>
</Grid>
<Descriptions :column="1" size="small">
<DescriptionsItem label="最后心跳">
{{ formatTime(device.lastHeartbeat) }}
</DescriptionsItem>
<DescriptionsItem label="运行时长">
{{ formatUptime(device.uptimeSeconds) }}
</DescriptionsItem>
<DescriptionsItem label="摄像头数">
{{ device.streamCount ?? '-' }}
</DescriptionsItem>
<DescriptionsItem label="配置版本">
{{ device.configVersion || '-' }}
</DescriptionsItem>
</Descriptions>
</Card>
</Col>
</Row>
</Spin>
</div>
</Page>
</template>
<style scoped>
.edge-node-page {
padding: 0;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.device-id {
font-family: monospace;
font-size: 14px;
font-weight: 600;
}
</style>