Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7332ff5f1e | |||
| a0d4e6d05d | |||
| 8bce09a17a | |||
| 617cdc8c15 | |||
| 4c287ca690 | |||
| 852be25413 | |||
| d2a77079af | |||
| 58db3c7eb4 | |||
| 67085ffcfc | |||
| 8d5f2f138d | |||
| 72b97453d3 | |||
| 694c5c7af1 | |||
| 4cd07c3fef | |||
| 0aa45be41f | |||
| 0e73aa2b8d | |||
| fae585f5e9 | |||
| eab4337a77 | |||
| d8e1ae5dab | |||
| f68b4e8b23 |
297
CLAUDE.md
Normal file
297
CLAUDE.md
Normal 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` 包自动反映到所有 apps(Vite 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**,等待用户指示再推送到远程。
|
||||
@@ -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 ====================
|
||||
|
||||
/** 以摄像头维度获取告警汇总 */
|
||||
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
202
apps/web-antd/src/views/aiot/alarm/summary/chart-options.ts
Normal file
202
apps/web-antd/src/views/aiot/alarm/summary/chart-options.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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">▲ {{ getDayChange().value }}%</span>
|
||||
<span class="change-label">较昨日</span>
|
||||
</template>
|
||||
<template v-else-if="getDayChange().type === 'down'">
|
||||
<span class="change-down">▼ {{ 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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '车辆数持续超过阈值达到该时间后触发拥堵告警',
|
||||
};
|
||||
|
||||
// 获取参数的中文名称
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}分钟`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user