video_monitor/templates/app/alarm/index.html
2026-08-30 22:23:12 +08:00

366 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "app/base.html" %}
{% load static %}
{% block title %}{{ T.nav_alarm|default:'报警管理' }} - {{ settings.name|default:"Monitor" }}{% endblock %}
{% block nav_alarm %}active{% endblock %}
{% block page_title %}{{ T.nav_alarm|default:'报警管理' }}{% endblock %}
{% block extra_head %}
<style>
.alarm-page{max-width:1280px;margin:0 auto;}
.alarm-card{background:#fff;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.05);overflow:hidden;}
.alarm-head{display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:linear-gradient(135deg,#fef2f2 0%,#fff5f5 100%);border-bottom:1px solid #fee2e2;gap:10px;flex-wrap:wrap;}
.alarm-head h3{margin:0;font-size:15px;font-weight:600;color:#991b1b;display:flex;align-items:center;gap:8px;}
.alarm-head h3 .dot{width:8px;height:8px;border-radius:50%;background:#dc2626;box-shadow:0 0 0 3px rgba(220,38,38,.15);animation:alarmPulse 1.5s infinite;}
@keyframes alarmPulse{0%,100%{box-shadow:0 0 0 3px rgba(220,38,38,.15);}50%{box-shadow:0 0 0 6px rgba(220,38,38,.05);}}
.alarm-head .tools{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}
.alarm-head select{padding:6px 10px;font-size:12px;border:1px solid #fca5a5;border-radius:6px;background:#fff;}
.btn{padding:7px 14px;font-size:12px;border-radius:6px;border:1px solid var(--c-border,#dcdfe6);background:#fff;cursor:pointer;}
.btn:hover{color:#b91c1c;border-color:#fca5a5;background:#fef2f2;}
.alarm-auto{font-size:11px;color:#9ca3af;}
.alarm-auto-refresh{display:flex;align-items:center;gap:6px;font-size:12px;color:#6b7280;white-space:nowrap;}
.alarm-auto-refresh label{display:flex;align-items:center;gap:4px;cursor:pointer;user-select:none;}
.alarm-auto-refresh input[type="number"]{width:46px;padding:5px 6px;font-size:12px;border:1px solid #fca5a5;border-radius:6px;text-align:center;}
.alarm-auto-refresh input[type="checkbox"]{width:14px;height:14px;cursor:pointer;}
.alarm-auto-refresh .countdown{min-width:32px;font-variant-numeric:tabular-nums;color:#b91c1c;font-weight:600;}
.alarm-body{padding:16px 20px;min-height:200px;}
.alarm-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px;}
.alarm-item{border:1px solid #e5e7eb;border-radius:10px;overflow:hidden;background:#fff;transition:.15s;display:flex;flex-direction:column;}
.alarm-item:hover{border-color:#dc2626;box-shadow:0 6px 16px rgba(220,38,38,.15);transform:translateY(-2px);}
.alarm-item .thumb{position:relative;background:#000;aspect-ratio:16/9;overflow:hidden;cursor:zoom-in;}
.alarm-item .thumb img{width:100%;height:100%;object-fit:cover;display:block;}
.alarm-item .thumb .noimg{display:flex;align-items:center;justify-content:center;height:100%;color:#6b7280;font-size:11px;background:#f3f4f6;}
.alarm-item .tag{position:absolute;top:6px;left:6px;font-size:10px;font-weight:700;padding:2px 8px;border-radius:4px;background:rgba(220,38,38,.92);color:#fff;}
.alarm-item .tag.loiter{background:rgba(217,119,6,.92);}
.alarm-item .tag.motion{background:rgba(99,102,241,.85);}
.alarm-item .alarm-id{position:absolute;top:6px;right:6px;font-size:10px;font-weight:700;padding:2px 8px;border-radius:4px;background:rgba(15,23,42,.72);color:#fff;font-variant-numeric:tabular-nums;letter-spacing:.02em;}
.alarm-item .zoom{position:absolute;bottom:4px;right:4px;font-size:12px;color:#fff;background:rgba(0,0,0,.5);border-radius:3px;padding:0 5px;line-height:18px;}
.alarm-item .info{padding:9px 11px;display:flex;flex-direction:column;gap:4px;flex:1;}
.alarm-item .info .lbl{font-size:13px;font-weight:600;color:#1f2937;}
.alarm-item .info .meta{font-size:11px;color:#6b7280;display:flex;justify-content:space-between;gap:6px;}
.alarm-item .info .reason{font-size:11px;color:#4b5563;line-height:1.45;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}
.alarm-item .info .algo{font-size:11px;color:#7c3aed;font-weight:600;}
.alarm-item .info .zone{font-size:11px;color:#0369a1;}
.alarm-item .row-bot{display:flex;align-items:center;justify-content:space-between;padding:6px 11px;border-top:1px solid #f3f4f6;background:#fafafa;}
.alarm-item .row-bot .chk{display:flex;align-items:center;gap:6px;font-size:11px;color:#6b7280;cursor:pointer;}
.alarm-item .row-bot .chk input{cursor:pointer;width:14px;height:14px;}
.alarm-item .row-bot .del{border:none;background:transparent;color:#9ca3af;cursor:pointer;font-size:14px;padding:2px 6px;border-radius:4px;transition:.15s;}
.alarm-item .row-bot .del:hover{color:#dc2626;background:#fef2f2;}
.alarm-empty{padding:48px 20px;text-align:center;color:#9ca3af;font-size:13px;}
.alarm-empty .ic{font-size:32px;display:block;margin-bottom:8px;opacity:.4;}
.alarm-loading{padding:48px 20px;text-align:center;color:#9ca3af;font-size:13px;}
/* 大图预览 */
.snap-overlay{position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:10000;display:flex;align-items:center;justify-content:center;padding:24px;cursor:zoom-out;}
.snap-overlay img{max-width:94%;max-height:94%;border-radius:8px;box-shadow:0 8px 40px rgba(0,0,0,.5);}
.snap-overlay .cap{position:absolute;bottom:24px;left:50%;transform:translateX(-50%);color:#fff;font-size:12px;background:rgba(0,0,0,.5);padding:6px 14px;border-radius:20px;}
</style>
{% endblock %}
{% block content %}
<div class="alarm-page">
<div class="alarm-card">
<div class="alarm-head">
<h3><span class="dot"></span>{{ T.nav_alarm|default:'报警管理' }}</h3>
<div class="tools">
<select id="fStream" onchange="onAlarmFilterChange()">
<option value="">{{ T.zone_all_cameras|default:'全部摄像头' }}</option>
</select>
<select id="fType" onchange="onAlarmFilterChange()">
<option value="">{{ T.alarm_all_types|default:'全部报警' }}</option>
<option value="entered_zone">{{ T.ev_entered_zone|default:'进入区域' }}</option>
<option value="loiter">{{ T.ev_loiter|default:'滞留告警' }}</option>
<option value="line_count">{{ T.ev_line_count|default:'越线计数告警' }}</option>
<option value="line_cross">{{ T.ev_line_cross|default:'越线检测' }}</option>
</select>
<button class="btn" onclick="loadAlarms()">{{ T.btn_refresh_symbol|default:'↻ 刷新' }}</button>
<div class="alarm-auto-refresh">
<label><input type="checkbox" id="autoRefreshOn" checked onchange="onAutoRefreshToggle()">{{ T.alarm_auto_refresh|default:'自动刷新' }}</label>
<input type="number" id="autoRefreshSec" min="3" max="300" value="10" title="{{ T.alarm_auto_refresh_sec_hint|default:'刷新间隔(秒)' }}" onchange="onAutoRefreshSecChange()" onblur="onAutoRefreshSecChange()">
<span>{{ T.alarm_auto_refresh_unit|default:'秒' }}</span>
<span class="countdown" id="autoRefreshCountdown">10s</span>
</div>
<button class="btn" onclick="toggleSelectAll()" id="btnSelectAll">{{ T.timeline_select_all|default:'全选' }}</button>
<button class="btn" onclick="batchDel()">{{ T.timeline_batch_del|default:'批量删除' }}</button>
<button class="btn" onclick="clearAlarms()" style="color:#b91c1c;border-color:#fca5a5;background:#fef2f2;">{{ T.timeline_clear_alarms|default:'清空报警' }}</button>
</div>
</div>
<div class="alarm-body" id="alarmBody">
<div class="alarm-empty"><span class="ic"></span>{{ T.alarm_empty_hint|default:'暂无报警,启动分析后将自动捕获报警画面' }}</div>
</div>
<div class="card-body" style="padding-top:0">
<div class="pagination" id="pagination"></div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
var g_streams = [];
var g_selectedIds = new Set();
var g_currentPage = 1;
var g_pageSize = 12;
var ALARM_TYPES = ['entered_zone', 'loiter', 'line_count', 'line_cross'];
var g_autoRefreshTimer = null;
var g_autoRefreshCountdown = 10;
var g_autoRefreshSec = 10;
var AUTO_REFRESH_KEY = 'monitor_alarm_auto_refresh_sec';
function formatAlarmTime(ts){
if(!ts) return '';
var s = String(ts).replace('T', ' ').trim();
s = s.replace(/\.\d+$/, '');
if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s)){
return s.substring(0, 19);
}
if(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(s)){
return s + ':00';
}
return s.length >= 19 ? s.substring(0, 19) : s;
}
function loadAutoRefreshPref(){
try{
var v = parseInt(localStorage.getItem(AUTO_REFRESH_KEY), 10);
if(!isNaN(v) && v >= 3 && v <= 300) g_autoRefreshSec = v;
}catch(e){}
var inp = document.getElementById('autoRefreshSec');
if(inp) inp.value = g_autoRefreshSec;
}
function saveAutoRefreshPref(){
try{ localStorage.setItem(AUTO_REFRESH_KEY, String(g_autoRefreshSec)); }catch(e){}
}
function updateCountdownDisplay(){
var el = document.getElementById('autoRefreshCountdown');
if(!el) return;
var on = document.getElementById('autoRefreshOn') && document.getElementById('autoRefreshOn').checked;
el.textContent = on ? (g_autoRefreshCountdown + 's') : '—';
}
function resetAutoRefreshCountdown(){
g_autoRefreshCountdown = g_autoRefreshSec;
updateCountdownDisplay();
}
function tickAutoRefresh(){
var on = document.getElementById('autoRefreshOn');
if(!on || !on.checked) return;
g_autoRefreshCountdown--;
if(g_autoRefreshCountdown <= 0){
loadAlarms();
return;
}
updateCountdownDisplay();
}
function startAutoRefresh(){
if(g_autoRefreshTimer) clearInterval(g_autoRefreshTimer);
resetAutoRefreshCountdown();
g_autoRefreshTimer = setInterval(tickAutoRefresh, 1000);
}
function onAutoRefreshToggle(){
resetAutoRefreshCountdown();
}
function onAutoRefreshSecChange(){
var inp = document.getElementById('autoRefreshSec');
if(!inp) return;
var v = parseInt(inp.value, 10);
if(isNaN(v) || v < 3) v = 3;
if(v > 300) v = 300;
g_autoRefreshSec = v;
inp.value = v;
saveAutoRefreshPref();
resetAutoRefreshCountdown();
}
function onAlarmFilterChange(){
g_currentPage = 1;
g_selectedIds.clear();
loadAlarms();
}
function loadStreams(cb){
Api.get('/open/getAllStreamData').then(function(res){
if(res && res.code===1000){ g_streams = res.data || []; if(cb) cb(); }
});
}
function fillStreamSelect(){
var sel = document.getElementById('fStream');
sel.innerHTML = '<option value="">'+_t('zone_all_cameras','全部摄像头')+'</option>';
g_streams.forEach(function(s){
var o = document.createElement('option');
o.value = (s.id != null && s.id !== '') ? String(s.id) : '';
if(!o.value) o.disabled = true;
o.textContent = s.nickname || s.name || ('#'+s.id);
sel.appendChild(o);
});
}
function parseMeta(s){ try{ return typeof s==='object'?s:JSON.parse(s||'{}'); }catch(e){ return {}; } }
function evName(t){
var m = {
entered_zone: _t('ev_entered_zone','进入区域'),
loiter: _t('ev_loiter','滞留告警'),
line_count: _t('ev_line_count','越线计数告警'),
line_cross: _t('ev_line_cross','越线检测'),
motion: _t('ev_motion','运动')
};
return m[t] || t;
}
function sortByIdDesc(list){
return (list || []).slice().sort(function(a, b){ return (parseInt(b.id, 10) || 0) - (parseInt(a.id, 10) || 0); });
}
function loadAlarms(page){
if(page) g_currentPage = page;
var body = document.getElementById('alarmBody');
showLoading(body, _t('alarm_loading','加载中...'));
var p = { p: g_currentPage, ps: g_pageSize, alarm_only: 1 };
var sid = document.getElementById('fStream').value; if(sid) p.stream_id = sid;
var t = document.getElementById('fType').value; if(t) p.event_type = t;
Api.get('/alarm/openIndex', p).then(function(res){
var list = sortByIdDesc((res && res.code===1000 && res.data) ? res.data : []);
var pageData = (res && res.pageData) ? res.pageData : {};
// 清理失效的选中项
var validIds = {}; list.forEach(function(e){ validIds[e.id]=true; });
g_selectedIds.forEach(function(id){ if(!validIds[id]) g_selectedIds.delete(id); });
renderAlarms(list);
updateSelectAllBtn();
renderPagination(document.getElementById('pagination'), pageData, loadAlarms);
resetAutoRefreshCountdown();
});
}
function renderAlarms(list){
var body = document.getElementById('alarmBody');
if(!list.length){
body.innerHTML = '<div class="alarm-empty"><span class="ic">◧</span>'+_t('alarm_empty_hint','暂无报警,启动分析后将自动捕获报警画面')+'</div>';
return;
}
var html = '<div class="alarm-grid">';
list.forEach(function(e){
var meta = parseMeta(e.metadata);
var isLoiter = e.event_type==='loiter';
var isMotion = e.event_type==='motion';
var tagCls = 'tag', tagTxt = evName(e.event_type);
if(isLoiter) tagCls = 'tag loiter';
else if(isMotion) tagCls = 'tag motion';
var snap = e.snapshot_path || meta.snapshot_path || '';
var thumb = snap
? '<img src="/static/'+escapeHtml(snap)+'" alt="alarm"><span class="zoom">⤢</span>'
: '<div class="noimg">'+_t('zone_alarm_no_img','无快照')+'</div>';
var lbl = meta.label || (isMotion ? _t('zone_alarm_motion_target','运动目标') : _t('zone_alarm_unknown','未知目标'));
var bizAlgo = e.biz_algorithm_name || meta.biz_algorithm_name || '';
var reason = e.alarm_reason || e.description || meta.alarm_reason || '';
var zoneName = e.zone_name || meta.zone_name || '';
var dur = (meta.duration !== undefined && meta.duration>0) ? (_t('zone_alarm_duration','停留')+' '+Math.round(meta.duration)+'s') : '';
var tm = formatAlarmTime(e.timestamp);
var checked = g_selectedIds.has(e.id) ? 'checked' : '';
var clickOpen = snap ? 'onclick="openSnap(\''+escapeHtml(snap)+'\',\''+escapeHtml((e.stream_name||'')+' · '+tagTxt+' · '+(reason||lbl)+' · '+tm)+'\')"' : '';
html += '<div class="alarm-item">'
+ '<div class="thumb" '+clickOpen+'><span class="'+tagCls+'">'+tagTxt+'</span><span class="alarm-id">#'+e.id+'</span>'+thumb+'</div>'
+ '<div class="info">'
+ '<span class="lbl">'+escapeHtml(lbl)+'</span>'
+ (bizAlgo?'<span class="algo">'+_t('alarm_biz_algorithm','所属算法')+''+escapeHtml(bizAlgo)+'</span>':'')
+ (zoneName?'<span class="zone">'+_t('alarm_zone_name','所属布控')+''+escapeHtml(zoneName)+'</span>':'')
+ (reason?'<span class="reason">'+_t('alarm_reason','报警原因')+''+escapeHtml(reason)+'</span>':'')
+ '<span class="meta"><span>ID '+e.id+'</span><span>'+escapeHtml(e.stream_name||('#'+e.stream_id))+'</span><span>'+tm+'</span></span>'
+ (dur?'<span class="meta dur">'+dur+'</span>':'')
+ '</div>'
+ '<div class="row-bot">'
+ '<label class="chk"><input type="checkbox" data-id="'+e.id+'" onchange="onCheckChange(this)" '+checked+'>'+_t('alarm_select','选中')+'</label>'
+ '<button class="del" onclick="delOne('+e.id+')" title="'+_t('btn_del','删除')+'">✕</button>'
+ '</div>'
+ '</div>';
});
html += '</div>';
body.innerHTML = html;
}
function openSnap(path, caption){
if(!path) return;
var ov = document.createElement('div');
ov.className = 'snap-overlay';
ov.innerHTML = '<img src="/static/'+escapeHtml(path)+'">'+(caption?'<div class="cap">'+escapeHtml(caption)+'</div>':'');
ov.onclick = function(){ document.body.removeChild(ov); };
document.body.appendChild(ov);
}
function onCheckChange(cb){
var id = parseInt(cb.getAttribute('data-id'),10);
if(cb.checked){ g_selectedIds.add(id); } else { g_selectedIds.delete(id); }
updateSelectAllBtn();
}
function updateSelectAllBtn(){
var btn = document.getElementById('btnSelectAll');
if(!btn) return;
var checks = document.querySelectorAll('.alarm-item .chk input');
var allChecked = checks.length>0 && Array.prototype.every.call(checks, function(c){ return c.checked; });
btn.textContent = allChecked ? _t('timeline_unselect_all','取消全选') : _t('timeline_select_all','全选');
}
function toggleSelectAll(){
var checks = document.querySelectorAll('.alarm-item .chk input');
if(!checks.length) return;
var allChecked = Array.prototype.every.call(checks, function(c){ return c.checked; });
checks.forEach(function(c){
c.checked = !allChecked;
var id = parseInt(c.getAttribute('data-id'),10);
if(c.checked){ g_selectedIds.add(id); } else { g_selectedIds.delete(id); }
});
updateSelectAllBtn();
}
function delOne(id){
if(!confirm(_t('timeline_del_confirm','确认删除该条事件及其快照?'))) return;
Api.post('/alarm/openDel', {id:id}).then(function(res){
if(res && res.code===1000){
g_selectedIds.delete(id);
showToast(_t('msg_success','操作成功'),'success');
loadAlarms();
} else { showToast((res&&res.msg)||'error','error'); }
});
}
function batchDel(){
if(g_selectedIds.size===0){ showToast(_t('timeline_no_selection','请先勾选要删除的事件'),'warning'); return; }
if(!confirm(_t('timeline_batch_confirm','确认删除选中的 {n} 条事件及其快照?').replace('{n}', g_selectedIds.size))) return;
var ids = Array.from(g_selectedIds);
Api.post('/alarm/openBatchDel', {ids:JSON.stringify(ids)}).then(function(res){
if(res && res.code===1000){
g_selectedIds.clear();
showToast(_t('msg_success','操作成功')+' ('+(res.data&&res.data.deleted||0)+')','success');
loadAlarms();
} else { showToast((res&&res.msg)||'error','error'); }
});
}
function clearAlarms(){
if(!confirm(_t('timeline_clear_confirm','确认清空所有报警事件(进入区域/滞留/运动)及其全部快照图片?此操作不可恢复!'))) return;
Api.post('/alarm/openClearAlarms', {}).then(function(res){
if(res && res.code===1000){
g_selectedIds.clear();
showToast(_t('msg_success','操作成功')+' ('+(res.data&&res.data.deleted||0)+')','success');
loadAlarms();
} else { showToast((res&&res.msg)||'error','error'); }
});
}
loadAutoRefreshPref();
loadStreams(function(){
fillStreamSelect();
loadAlarms();
startAutoRefresh();
});
</script>
{% endblock %}