feat: 添加告警详情H5页面,企微卡片点击可查看详情并操作
- 新增 H5 告警详情页面(截图+信息+3个操作按钮) - 企微卡片"查看详情"跳转到 H5 页面 - 操作按钮改为:前往处理/已处理/误报忽略 - 新增 alarm_detail API 供 H5 页面获取告警+VLM分析数据 - 挂载 /static 目录提供 H5 页面访问
This commit is contained in:
@@ -111,6 +111,11 @@ _uploads_dir = Path("uploads")
|
||||
_uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=str(_uploads_dir)), name="uploads")
|
||||
|
||||
# H5 告警详情页面(企微卡片"查看详情"跳转目标)
|
||||
_static_dir = Path(__file__).parent / "static"
|
||||
if _static_dir.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")
|
||||
|
||||
|
||||
def get_alert_svc():
|
||||
return alert_service
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"""
|
||||
企微回调路由
|
||||
|
||||
处理安保人员在企微卡片上的操作(确认处理/已处理完成/误报忽略)。
|
||||
处理安保人员在企微卡片上的操作(前往处理/已处理/误报忽略)。
|
||||
提供告警详情接口供 H5 页面使用。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
from app.yudao_compat import YudaoResponse
|
||||
from app.models import get_session, AlarmEvent, AlarmLlmAnalysis
|
||||
from app.services.alarm_event_service import get_alarm_event_service, AlarmEventService
|
||||
from app.utils.logger import logger
|
||||
|
||||
@@ -24,16 +26,55 @@ class AlarmActionRequest(BaseModel):
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/alarm_detail")
|
||||
async def get_alarm_detail(alarm_id: str = Query(..., description="告警ID")):
|
||||
"""
|
||||
告警详情接口(供 H5 页面使用,无认证)
|
||||
|
||||
返回告警基本信息 + VLM 分析描述
|
||||
"""
|
||||
db = get_session()
|
||||
try:
|
||||
alarm = db.query(AlarmEvent).filter(AlarmEvent.alarm_id == alarm_id).first()
|
||||
if not alarm:
|
||||
return YudaoResponse.error(404, "告警不存在")
|
||||
|
||||
# 查 VLM 分析结果
|
||||
vlm_desc = ""
|
||||
analysis = db.query(AlarmLlmAnalysis).filter(
|
||||
AlarmLlmAnalysis.alarm_id == alarm_id
|
||||
).order_by(AlarmLlmAnalysis.id.desc()).first()
|
||||
if analysis:
|
||||
vlm_desc = analysis.summary or ""
|
||||
|
||||
return YudaoResponse.success({
|
||||
"alarm_id": alarm.alarm_id,
|
||||
"alarm_type": alarm.alarm_type,
|
||||
"device_id": alarm.device_id,
|
||||
"scene_id": alarm.scene_id,
|
||||
"event_time": alarm.event_time.strftime('%Y-%m-%d %H:%M:%S') if alarm.event_time else "",
|
||||
"alarm_level": alarm.alarm_level,
|
||||
"alarm_status": alarm.alarm_status,
|
||||
"handle_status": alarm.handle_status,
|
||||
"snapshot_url": alarm.snapshot_url or "",
|
||||
"handler": alarm.handler or "",
|
||||
"handle_remark": alarm.handle_remark or "",
|
||||
"vlm_description": vlm_desc,
|
||||
})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/callback/alarm_action")
|
||||
async def alarm_action_callback(
|
||||
req: AlarmActionRequest,
|
||||
service: AlarmEventService = Depends(get_alarm_event_service),
|
||||
):
|
||||
"""
|
||||
企微告警操作回调(无认证,由企微服务端调用)
|
||||
企微告警操作回调(无认证,由 H5 页面调用)
|
||||
|
||||
action:
|
||||
- confirm: 确认处理 → handle_status=HANDLING
|
||||
- confirm: 前往处理 → handle_status=HANDLING
|
||||
- complete: 已处理完成 → handle_status=DONE, alarm_status=CLOSED
|
||||
- ignore: 误报忽略 → alarm_status=FALSE, handle_status=DONE
|
||||
"""
|
||||
@@ -41,17 +82,17 @@ async def alarm_action_callback(
|
||||
"confirm": {
|
||||
"alarm_status": "CONFIRMED",
|
||||
"handle_status": "HANDLING",
|
||||
"remark": "企微确认处理",
|
||||
"remark": "前往处理",
|
||||
},
|
||||
"complete": {
|
||||
"alarm_status": "CLOSED",
|
||||
"handle_status": "DONE",
|
||||
"remark": "企微手动结单",
|
||||
"remark": "手动结单",
|
||||
},
|
||||
"ignore": {
|
||||
"alarm_status": "FALSE",
|
||||
"handle_status": "DONE",
|
||||
"remark": "企微标记误报",
|
||||
"remark": "标记误报",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.models import (
|
||||
AlarmEvent, AlarmLlmAnalysis,
|
||||
CameraAreaBinding, AreaPersonBinding, NotifyArea,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.services.vlm_service import get_vlm_service
|
||||
from app.services.wechat_service import get_wechat_service
|
||||
|
||||
@@ -94,6 +95,7 @@ async def process_alarm_notification(alarm_data: Dict):
|
||||
snapshot_url=snapshot_url,
|
||||
event_time=event_time_str,
|
||||
alarm_level=alarm_level,
|
||||
service_base_url=f"http://{settings.app.host}:{settings.app.port}",
|
||||
)
|
||||
|
||||
logger.info(f"告警通知完成: {alarm_id} → {len(persons)} 人")
|
||||
|
||||
@@ -75,6 +75,7 @@ class WeChatService:
|
||||
snapshot_url: str,
|
||||
event_time: str,
|
||||
alarm_level: int = 2,
|
||||
service_base_url: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
发送告警文本卡片
|
||||
@@ -120,6 +121,9 @@ class WeChatService:
|
||||
f"<div class=\"normal\">{description}</div>"
|
||||
)
|
||||
|
||||
# H5 详情页 URL(企微卡片"查看详情"跳转目标)
|
||||
detail_url = f"{service_base_url}/static/alarm_detail.html?alarm_id={alarm_id}" if service_base_url else snapshot_url or "https://work.weixin.qq.com"
|
||||
|
||||
msg = {
|
||||
"touser": "|".join(user_ids),
|
||||
"msgtype": "textcard",
|
||||
@@ -127,7 +131,7 @@ class WeChatService:
|
||||
"textcard": {
|
||||
"title": f"【{level_name}】{type_name}告警",
|
||||
"description": content,
|
||||
"url": snapshot_url or "https://work.weixin.qq.com",
|
||||
"url": detail_url,
|
||||
"btntxt": "查看详情",
|
||||
},
|
||||
}
|
||||
|
||||
189
app/static/alarm_detail.html
Normal file
189
app/static/alarm_detail.html
Normal file
@@ -0,0 +1,189 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>告警详情</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif; background: #f5f5f5; color: #333; }
|
||||
.container { max-width: 420px; margin: 0 auto; padding: 12px; }
|
||||
|
||||
/* 截图区 */
|
||||
.snapshot { width: 100%; border-radius: 8px; overflow: hidden; background: #000; margin-bottom: 12px; }
|
||||
.snapshot img { width: 100%; display: block; }
|
||||
.snapshot .no-img { padding: 60px 0; text-align: center; color: #999; font-size: 14px; }
|
||||
|
||||
/* 告警信息 */
|
||||
.info-card { background: #fff; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
|
||||
.info-card .level-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; color: #fff; margin-bottom: 8px; }
|
||||
.level-1 { background: #1890ff; }
|
||||
.level-2 { background: #faad14; }
|
||||
.level-3 { background: #fa541c; }
|
||||
.level-4 { background: #f5222d; }
|
||||
.info-card .title { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
|
||||
.info-row { display: flex; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #f0f0f0; }
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-row .label { color: #999; width: 70px; flex-shrink: 0; }
|
||||
.info-row .value { color: #333; flex: 1; }
|
||||
|
||||
/* VLM 描述 */
|
||||
.vlm-desc { background: #f0f7ff; border-left: 3px solid #1890ff; padding: 10px 12px; border-radius: 0 6px 6px 0; margin-bottom: 12px; font-size: 13px; color: #555; }
|
||||
.vlm-desc .tag { font-size: 11px; color: #1890ff; margin-bottom: 4px; }
|
||||
|
||||
/* 状态 */
|
||||
.status-bar { background: #fff; border-radius: 8px; padding: 12px 16px; margin-bottom: 12px; text-align: center; }
|
||||
.status-bar .status { font-size: 14px; font-weight: 500; }
|
||||
.status-new { color: #fa541c; }
|
||||
.status-handling { color: #faad14; }
|
||||
.status-done { color: #52c41a; }
|
||||
.status-false { color: #999; }
|
||||
|
||||
/* 按钮组 */
|
||||
.actions { display: flex; gap: 10px; padding: 0 0 20px; }
|
||||
.actions .btn { flex: 1; padding: 12px 0; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: opacity 0.2s; }
|
||||
.actions .btn:active { opacity: 0.7; }
|
||||
.btn-go { background: #1890ff; color: #fff; }
|
||||
.btn-done { background: #52c41a; color: #fff; }
|
||||
.btn-false { background: #f0f0f0; color: #666; }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* 已操作提示 */
|
||||
.done-msg { text-align: center; padding: 16px; background: #f6ffed; border-radius: 8px; margin-bottom: 12px; color: #52c41a; font-size: 14px; }
|
||||
.toast { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(0,0,0,0.75); color: #fff; padding: 12px 24px; border-radius: 8px; font-size: 14px; display: none; z-index: 100; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container" id="app">
|
||||
<div class="snapshot" id="snapshot-area"></div>
|
||||
<div class="info-card" id="info-card"></div>
|
||||
<div id="vlm-area"></div>
|
||||
<div class="status-bar" id="status-bar"></div>
|
||||
<div id="action-area"></div>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const alarmId = params.get('alarm_id');
|
||||
const baseUrl = location.origin;
|
||||
|
||||
// 告警类型映射
|
||||
const typeNames = { leave_post: '人员离岗', intrusion: '周界入侵' };
|
||||
const levelNames = { 1: '提醒', 2: '一般', 3: '严重', 4: '紧急' };
|
||||
const statusNames = { NEW: '待处理', CONFIRMED: '已确认', HANDLING: '处理中', CLOSED: '已结单', FALSE: '误报', RESOLVED: '自动结束' };
|
||||
const handleNames = { UNHANDLED: '未处理', HANDLING: '处理中', DONE: '已完成' };
|
||||
|
||||
let alarmData = null;
|
||||
|
||||
async function loadAlarm() {
|
||||
if (!alarmId) {
|
||||
document.getElementById('app').innerHTML = '<div style="text-align:center;padding:60px 0;color:#999;">缺少告警ID参数</div>';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(baseUrl + '/api/wechat/alarm_detail?alarm_id=' + encodeURIComponent(alarmId));
|
||||
const json = await resp.json();
|
||||
if (json.code !== 0) throw new Error(json.msg || '加载失败');
|
||||
alarmData = json.data;
|
||||
render();
|
||||
} catch (e) {
|
||||
document.getElementById('app').innerHTML = '<div style="text-align:center;padding:60px 0;color:#f00;">加载失败: ' + e.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
const d = alarmData;
|
||||
|
||||
// 截图
|
||||
const snapArea = document.getElementById('snapshot-area');
|
||||
if (d.snapshot_url) {
|
||||
snapArea.innerHTML = '<img src="' + d.snapshot_url + '" alt="告警截图" onerror="this.parentElement.innerHTML=\'<div class=no-img>截图加载失败</div>\'">';
|
||||
} else {
|
||||
snapArea.innerHTML = '<div class="no-img">暂无截图</div>';
|
||||
}
|
||||
|
||||
// 信息卡片
|
||||
const typeName = typeNames[d.alarm_type] || d.alarm_type;
|
||||
const levelName = levelNames[d.alarm_level] || '一般';
|
||||
const levelClass = 'level-' + (d.alarm_level || 2);
|
||||
|
||||
document.getElementById('info-card').innerHTML =
|
||||
'<span class="level-badge ' + levelClass + '">' + levelName + '</span>' +
|
||||
'<div class="title">' + typeName + '告警</div>' +
|
||||
'<div class="info-row"><span class="label">告警ID</span><span class="value">' + (d.alarm_id || '').slice(-12) + '</span></div>' +
|
||||
'<div class="info-row"><span class="label">摄像头</span><span class="value">' + (d.device_id || '-') + '</span></div>' +
|
||||
'<div class="info-row"><span class="label">区域</span><span class="value">' + (d.scene_id || '-') + '</span></div>' +
|
||||
'<div class="info-row"><span class="label">时间</span><span class="value">' + (d.event_time || '-') + '</span></div>';
|
||||
|
||||
// VLM 描述
|
||||
const vlmArea = document.getElementById('vlm-area');
|
||||
if (d.vlm_description) {
|
||||
vlmArea.innerHTML = '<div class="vlm-desc"><div class="tag">AI 分析</div>' + d.vlm_description + '</div>';
|
||||
} else {
|
||||
vlmArea.innerHTML = '';
|
||||
}
|
||||
|
||||
// 状态
|
||||
const alarmStatus = d.alarm_status || 'NEW';
|
||||
const handleStatus = d.handle_status || 'UNHANDLED';
|
||||
const statusClass = alarmStatus === 'FALSE' ? 'status-false' : handleStatus === 'DONE' ? 'status-done' : handleStatus === 'HANDLING' ? 'status-handling' : 'status-new';
|
||||
document.getElementById('status-bar').innerHTML =
|
||||
'<div class="status ' + statusClass + '">' + (statusNames[alarmStatus] || alarmStatus) + ' / ' + (handleNames[handleStatus] || handleStatus) + '</div>';
|
||||
|
||||
// 操作按钮(已处理完的不显示)
|
||||
const actionArea = document.getElementById('action-area');
|
||||
if (handleStatus === 'DONE' || alarmStatus === 'FALSE' || alarmStatus === 'CLOSED' || alarmStatus === 'RESOLVED') {
|
||||
const handler = d.handler || '';
|
||||
const remark = d.handle_remark || '';
|
||||
actionArea.innerHTML = '<div class="done-msg">' + (remark || '已处理') + (handler ? '<br><span style="font-size:12px;color:#999;">操作人: ' + handler + '</span>' : '') + '</div>';
|
||||
} else {
|
||||
actionArea.innerHTML =
|
||||
'<div class="actions">' +
|
||||
'<button class="btn btn-go" onclick="doAction(\'confirm\')">前往处理</button>' +
|
||||
'<button class="btn btn-done" onclick="doAction(\'complete\')">已处理</button>' +
|
||||
'<button class="btn btn-false" onclick="doAction(\'ignore\')">误报忽略</button>' +
|
||||
'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function doAction(action) {
|
||||
const actionNames = { confirm: '前往处理', complete: '已处理', ignore: '误报忽略' };
|
||||
if (!confirm('确定执行【' + actionNames[action] + '】?')) return;
|
||||
|
||||
// 禁用所有按钮
|
||||
document.querySelectorAll('.btn').forEach(b => b.disabled = true);
|
||||
|
||||
try {
|
||||
const resp = await fetch(baseUrl + '/api/wechat/callback/alarm_action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
alarm_id: alarmId,
|
||||
action: action,
|
||||
operator_uid: 'wechat_user',
|
||||
remark: null,
|
||||
}),
|
||||
});
|
||||
const json = await resp.json();
|
||||
if (json.code !== 0) throw new Error(json.msg || '操作失败');
|
||||
showToast('操作成功');
|
||||
// 重新加载
|
||||
setTimeout(loadAlarm, 800);
|
||||
} catch (e) {
|
||||
showToast('操作失败: ' + e.message);
|
||||
document.querySelectorAll('.btn').forEach(b => b.disabled = false);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.style.display = 'block';
|
||||
setTimeout(() => t.style.display = 'none', 2000);
|
||||
}
|
||||
|
||||
loadAlarm();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user