2375 lines
108 KiB
HTML
2375 lines
108 KiB
HTML
|
||
{% extends "app/base.html" %}
|
||
{% load static %}
|
||
|
||
{% block title %}{{ T.nav_control|default:'布控管理' }} - {{ settings.name|default:"Monitor" }}{% endblock %}
|
||
{% block nav_control %}active{% endblock %}
|
||
{% block page_title %}{{ T.nav_control|default:'布控管理' }}{% endblock %}
|
||
|
||
{% block extra_head %}
|
||
<style>
|
||
.zone-page { max-width: 1200px; margin: 0 auto; }
|
||
.zone-card { background:#fff; border-radius:10px; box-shadow:0 2px 10px rgba(0,0,0,.05); overflow:hidden; }
|
||
.zone-head { display:flex; align-items:center; justify-content:space-between; padding:14px 20px;
|
||
background:linear-gradient(135deg,#f8fbfa 0%,#f0f9f6 100%); border-bottom:1px solid var(--c-border-light,#eef2f5); gap:10px; flex-wrap:wrap;}
|
||
.zone-head h3 { margin:0; font-size:15px; font-weight:600; color:#2c3e50; display:flex; align-items:center; gap:8px;}
|
||
.zone-head h3 .dot{width:8px;height:8px;border-radius:50%;background:#169F85;box-shadow:0 0 0 3px rgba(22,159,133,.15);}
|
||
.zone-toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}
|
||
.zone-toolbar select,.zone-toolbar input{padding:6px 10px;font-size:12px;border:1px solid var(--c-border,#dcdfe6);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:#169F85;border-color:#169F85;background:#f0f9f6;}
|
||
.btn-primary{background:linear-gradient(135deg,#14b89a 0%,#169F85 100%);color:#fff;border-color:transparent;}
|
||
.btn-primary:hover{background:#0e7a65;color:#fff;}
|
||
.zone-table{width:100%;border-collapse:collapse;}
|
||
.zone-table th,.zone-table td{padding:10px 14px;font-size:13px;text-align:left;border-bottom:1px solid #f3f4f6;}
|
||
.zone-table th{background:#fafbfc;color:#6b7280;font-weight:600;font-size:12px;}
|
||
.zone-table tr:hover td{background:#f9fafb;}
|
||
.tag{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;}
|
||
.tag-on{background:#d1fae5;color:#065f46;}
|
||
.tag-off{background:#f3f4f6;color:#6b7280;}
|
||
.tag-req{background:#fef3c7;color:#92400e;}
|
||
|
||
.zone-body{position:relative;min-height:360px;}
|
||
.zone-empty{display:flex;align-items:center;justify-content:center;min-height:360px;padding:40px 20px;color:var(--c-text-tertiary,#9ca3af);font-size:14px;}
|
||
|
||
/* 分析概览条 */
|
||
.zone-overview{display:flex;gap:0;padding:0;margin-bottom:14px;border:1px solid var(--c-border-light,#eef2f5);border-radius:9px;overflow:hidden;background:#fff;}
|
||
.ov-item{flex:1;padding:10px 14px;border-right:1px solid #f3f4f6;display:flex;flex-direction:column;gap:2px;}
|
||
.ov-item:last-child{border-right:none;}
|
||
.ov-item .lbl{font-size:11px;color:#6b7280;font-weight:500;}
|
||
.ov-item .val{font-size:18px;font-weight:700;color:#1f2937;line-height:1.1;}
|
||
.ov-item .val.green{color:#0e7a65;}
|
||
.ov-item .val.red{color:#dc2626;}
|
||
.ov-item .val.blue{color:#2563eb;}
|
||
.ov-item .sub{font-size:10px;color:#9ca3af;margin-top:1px;}
|
||
|
||
|
||
|
||
/* 算法小标签(表格内) */
|
||
.algo-chip{display:inline-block;font-size:10px;font-weight:600;padding:2px 7px;border-radius:10px;background:#e0f2fe;color:#075985;margin:1px;}
|
||
.algo-chip.more{background:#f3f4f6;color:#6b7280;}
|
||
.zone-empty .ic{margin-right:8px;font-size:22px;opacity:.45;}
|
||
.poly-preview{width:18px;height:18px;border-radius:3px;display:inline-block;vertical-align:middle;border:1px solid #ddd;}
|
||
|
||
/* ========== 区域编辑器弹框(左右分栏 · 浅色统一风格) ========== */
|
||
.zone-modal-overlay{
|
||
position:fixed;inset:0;z-index:400;
|
||
background:rgba(15,23,42,.35);
|
||
backdrop-filter:blur(4px);
|
||
display:none;align-items:center;justify-content:center;padding:20px;
|
||
}
|
||
.zone-modal-overlay.show{display:flex;}
|
||
.zone-modal{
|
||
width:min(820px,94vw);max-height:86vh;
|
||
background:#fff;border-radius:12px;
|
||
box-shadow:0 16px 48px rgba(0,0,0,.12);
|
||
border:1px solid var(--c-border,#e2e8f0);
|
||
display:flex;flex-direction:column;overflow:hidden;
|
||
animation:zmIn .22s ease;
|
||
}
|
||
@keyframes zmIn{from{opacity:0;transform:scale(.98) translateY(6px)}to{opacity:1;transform:none}}
|
||
.zone-modal-head{
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
padding:14px 20px;border-bottom:1px solid var(--c-border-light,#eef2f5);
|
||
background:linear-gradient(135deg,#f8fbfa 0%,#f0f9f6 100%);
|
||
}
|
||
.zone-modal-head .zm-title{margin:0;font-size:15px;font-weight:600;color:#2c3e50;display:flex;align-items:center;gap:8px;}
|
||
.zone-modal-head .zm-title::before{
|
||
content:'';width:8px;height:8px;border-radius:50%;background:#169F85;
|
||
box-shadow:0 0 0 3px rgba(22,159,133,.15);flex-shrink:0;
|
||
}
|
||
.zone-modal-head .zm-sub{margin:4px 0 0;font-size:12px;color:var(--c-text-secondary,#64748b);}
|
||
.zone-modal-close{
|
||
width:32px;height:32px;border:1px solid var(--c-border,#e2e8f0);border-radius:8px;background:#fff;
|
||
color:#64748b;font-size:18px;line-height:1;cursor:pointer;transition:.15s;
|
||
}
|
||
.zone-modal-close:hover{border-color:#169F85;color:#169F85;background:#f0f9f6;}
|
||
|
||
.zone-modal-body{display:flex;flex:1;min-height:0;overflow:hidden;background:#fff;}
|
||
@media(max-width:860px){.zone-modal-body{flex-direction:column;overflow:auto;}}
|
||
|
||
/* 左侧:画布区(与列表页同色系) */
|
||
.zm-stage{
|
||
flex:1;min-width:0;display:flex;flex-direction:column;
|
||
background:#fff;border-right:1px solid var(--c-border-light,#eef2f5);
|
||
}
|
||
.zm-stage-bar{
|
||
display:flex;flex-direction:column;gap:8px;padding:10px 16px;
|
||
background:linear-gradient(135deg,#f8fbfa 0%,#f0f9f6 100%);
|
||
border-bottom:1px solid var(--c-border-light,#eef2f5);
|
||
}
|
||
.zm-stage-row{
|
||
display:flex;align-items:center;gap:8px;min-width:0;
|
||
}
|
||
.zm-stage-row-video{gap:10px;}
|
||
.zm-stage-row-draw{
|
||
gap:8px;padding-top:8px;border-top:1px solid rgba(226,232,240,.85);
|
||
flex-wrap:wrap;
|
||
}
|
||
.zm-stage-bar select,
|
||
.zm-stream-select{
|
||
flex:1;min-width:0;width:100%;padding:7px 10px;font-size:12px;
|
||
border:1px solid var(--c-border,#dcdfe6);border-radius:6px;
|
||
background:#fff;color:#2c3e50;
|
||
}
|
||
.zm-stage-bar select:focus{outline:none;border-color:#169F85;box-shadow:0 0 0 2px rgba(22,159,133,.12);}
|
||
.zm-badge{
|
||
font-size:11px;padding:3px 10px;border-radius:10px;
|
||
background:#fef3c7;color:#92400e;font-weight:600;white-space:nowrap;
|
||
border:1px solid #fde68a;flex-shrink:0;
|
||
}
|
||
.zm-badge.ready{background:#d1fae5;color:#065f46;border-color:#a7f3d0;}
|
||
.zm-tool-group{display:flex;gap:6px;flex-shrink:0;}
|
||
.zm-draw-actions{margin-left:auto;}
|
||
.zm-row-label{
|
||
font-size:11px;font-weight:600;color:#64748b;white-space:nowrap;flex-shrink:0;
|
||
}
|
||
.zm-tool{
|
||
width:30px;height:30px;border:1px solid var(--c-border,#dcdfe6);border-radius:6px;cursor:pointer;
|
||
background:#fff;color:#64748b;font-size:13px;
|
||
display:flex;align-items:center;justify-content:center;transition:.15s;
|
||
}
|
||
.zm-tool:hover{color:#169F85;border-color:#169F85;background:#f0f9f6;}
|
||
.zm-tool-finish{color:#169F85;font-weight:700;}
|
||
.zm-tool-finish.active{background:#ecfdf5;border-color:#169F85;color:#0e7a65;}
|
||
|
||
.zm-canvas-box{
|
||
flex:1;display:flex;align-items:center;justify-content:center;
|
||
padding:12px 16px;min-height:200px;
|
||
background:var(--c-bg,#f1f5f9);
|
||
}
|
||
.draw-wrap{
|
||
position:relative;width:100%;max-width:100%;max-height:100%;
|
||
background:#fff;border-radius:8px;overflow:hidden;line-height:0;
|
||
border:1px solid var(--c-border,#e2e8f0);
|
||
box-shadow:0 2px 12px rgba(0,0,0,.06);
|
||
}
|
||
.draw-wrap img{display:block;width:100%;height:auto;max-height:34vh;object-fit:contain;margin:0 auto;background:#f8fafc;}
|
||
.zone-video-wrap{
|
||
position:relative;width:100%;min-height:200px;max-height:34vh;background:#0f172a;overflow:hidden;
|
||
}
|
||
.zone-video-wrap #zoneVideoPlayer{width:100%;height:100%;min-height:200px;max-height:34vh;}
|
||
.draw-wrap canvas{position:absolute;inset:0;width:100%;height:100%;cursor:crosshair;z-index:2;pointer-events:auto;}
|
||
.zone-video-wrap #zoneVideoPlayer,
|
||
.zone-video-wrap #zoneVideoPlayer *{pointer-events:none;}
|
||
.zm-player-overlay{
|
||
position:absolute;inset:0;z-index:3;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||
background:rgba(15,23,42,.72);color:#e2e8f0;font-size:13px;gap:8px;pointer-events:none;
|
||
}
|
||
.zm-draw-modes{display:flex;gap:4px;flex-wrap:nowrap;align-items:center;flex-shrink:0;}
|
||
.zm-draw-modes .zm-tool.active{background:#169F85;color:#fff;border-color:#169F85;}
|
||
.zm-draw-modes .zm-tool-line.active{background:#dc2626;border-color:#dc2626;color:#fff;}
|
||
.zm-draw-modes .zm-tool-line:not(.active){color:#dc2626;border-color:#fecaca;}
|
||
.zm-draw-sep{width:1px;height:22px;background:#e2e8f0;margin:0 2px;flex-shrink:0;}
|
||
.zm-line-status{
|
||
font-size:11px;color:#64748b;flex:1;min-width:80px;
|
||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||
}
|
||
@media(max-width:520px){
|
||
.zm-stage-row-draw .zm-line-status{flex-basis:100%;order:3;margin-left:0;}
|
||
.zm-draw-actions{margin-left:0;}
|
||
}
|
||
.draw-wrap.no-snap{
|
||
min-height:200px;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||
background:#fff;border:2px dashed #d1d5db;
|
||
color:var(--c-text-tertiary,#94a3b8);font-size:13px;line-height:1.7;
|
||
padding:28px 20px;text-align:center;box-shadow:none;
|
||
}
|
||
.draw-wrap.no-snap .zm-ph-icon{
|
||
font-size:32px;margin-bottom:8px;color:#169F85;opacity:.25;
|
||
}
|
||
|
||
.zm-hint-bar{
|
||
padding:8px 16px;font-size:11px;color:var(--c-text-secondary,#64748b);
|
||
background:#fff;border-top:1px solid var(--c-border-light,#eef2f5);
|
||
display:flex;align-items:center;gap:6px;
|
||
}
|
||
.zm-hint-bar svg{flex-shrink:0;opacity:.45;color:#169F85;}
|
||
|
||
/* 右侧:属性面板 */
|
||
.zm-panel{
|
||
width:248px;flex-shrink:0;background:#fff;
|
||
border-left:none;display:flex;flex-direction:column;
|
||
}
|
||
@media(max-width:860px){.zm-panel{width:100%;border-top:1px solid var(--c-border-light,#eef2f5);}}
|
||
.zm-panel-scroll{flex:1;overflow:auto;padding:12px 14px;}
|
||
.zm-field{margin-bottom:12px;}
|
||
.zm-field label{
|
||
display:block;font-size:12px;font-weight:600;color:#6b7280;
|
||
margin-bottom:5px;
|
||
}
|
||
.zm-field input,.zm-field select{
|
||
width:100%;padding:8px 10px;font-size:13px;
|
||
border:1px solid var(--c-border,#dcdfe6);border-radius:6px;background:#fff;color:#2c3e50;
|
||
transition:border-color .15s,box-shadow .15s;
|
||
}
|
||
.zm-field input:focus,.zm-field select:focus{
|
||
outline:none;border-color:#169F85;box-shadow:0 0 0 2px rgba(22,159,133,.12);
|
||
}
|
||
.zm-field-row{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
|
||
.zm-colors{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}
|
||
.zm-swatch{
|
||
width:24px;height:24px;border-radius:50%;border:2px solid #fff;
|
||
box-shadow:0 0 0 1px var(--c-border,#dcdfe6);cursor:pointer;transition:transform .12s;
|
||
}
|
||
.zm-swatch:hover{transform:scale(1.08);}
|
||
.zm-swatch.active{box-shadow:0 0 0 2px #169F85;}
|
||
.zm-color-input{width:32px;height:32px;padding:2px;border-radius:6px;border:1px solid var(--c-border,#dcdfe6);cursor:pointer;}
|
||
|
||
.zm-toggle-row{display:flex;gap:6px;}
|
||
.zm-toggle{
|
||
flex:1;padding:7px 4px;font-size:12px;text-align:center;border-radius:6px;
|
||
border:1px solid var(--c-border,#dcdfe6);background:#fff;color:#64748b;cursor:pointer;transition:.15s;
|
||
}
|
||
.zm-toggle:hover{border-color:#169F85;color:#169F85;}
|
||
.zm-toggle.active{border-color:#169F85;background:#ecfdf5;color:#0e7a65;font-weight:600;}
|
||
|
||
/* 算法多选 —— 卡片网格风格 */
|
||
.zm-algo-wrap{display:flex;flex-direction:column;gap:8px;}
|
||
.zm-algo-bar{display:flex;align-items:center;gap:8px;padding:6px 10px;background:linear-gradient(135deg,#f0f9f6 0%,#e6f7f1 100%);border:1px solid #c7ecd9;border-radius:8px;}
|
||
.zm-algo-bar .cnt{font-size:11px;font-weight:600;color:#0e7a65;flex:1;}
|
||
.zm-algo-bar .cnt b{font-size:13px;}
|
||
.zm-algo-bar .act{font-size:11px;color:#169F85;cursor:pointer;padding:2px 6px;border-radius:4px;transition:.12s;}
|
||
.zm-algo-bar .act:hover{background:#fff;color:#0e7a65;}
|
||
.zm-algo-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px;max-height:108px;overflow:auto;padding:2px;}
|
||
.zm-algo-card{position:relative;display:flex;flex-direction:column;gap:4px;padding:10px 10px 10px 12px;border:1.5px solid var(--c-border,#e2e8f0);border-radius:9px;background:#fff;cursor:pointer;transition:.15s;overflow:hidden;}
|
||
.zm-algo-card::before{content:'';position:absolute;left:0;top:0;bottom:0;width:3px;background:transparent;transition:.15s;}
|
||
.zm-algo-card:hover{border-color:#169F85;box-shadow:0 2px 8px rgba(22,159,133,.12);transform:translateY(-1px);}
|
||
.zm-algo-card.sel{border-color:#169F85;background:linear-gradient(135deg,#ecfdf5 0%,#f0f9f6 100%);}
|
||
.zm-algo-card.sel::before{background:#169F85;}
|
||
.zm-algo-card .check{position:absolute;top:8px;right:8px;width:16px;height:16px;border-radius:50%;border:1.5px solid #cbd5e1;background:#fff;display:flex;align-items:center;justify-content:center;transition:.15s;}
|
||
.zm-algo-card.sel .check{background:#169F85;border-color:#169F85;color:#fff;}
|
||
.zm-algo-card .check svg{width:10px;height:10px;opacity:0;transition:.15s;}
|
||
.zm-algo-card.sel .check svg{opacity:1;}
|
||
.zm-algo-card .nm{font-size:12px;font-weight:600;color:#1f2937;line-height:1.3;padding-right:18px;}
|
||
.zm-algo-card .meta{display:flex;gap:4px;flex-wrap:wrap;}
|
||
.zm-algo-card .tag-mini{font-size:9px;font-weight:600;padding:1px 5px;border-radius:3px;background:#e0f2fe;color:#075985;}
|
||
.zm-algo-card .tag-mini.eng{background:#ede9fe;color:#6d28d9;}
|
||
.zm-algo-card .tag-mini.task{background:#fef3c7;color:#92400e;}
|
||
.zm-algo-empty{grid-column:1/-1;font-size:12px;color:#9ca3af;text-align:center;padding:18px;border:1.5px dashed var(--c-border,#e2e8f0);border-radius:8px;background:#fafbfc;}
|
||
.zm-algo-empty .ic{font-size:20px;display:block;margin-bottom:6px;opacity:.5;}
|
||
|
||
/* 检测频率 — 单行紧凑 */
|
||
.zm-detect-box{
|
||
padding:8px 10px;border:1px solid #e2e8f0;border-radius:8px;background:#f8fafc;
|
||
}
|
||
.zm-detect-formula{
|
||
display:grid;
|
||
grid-template-columns:auto 1fr auto auto 1fr auto;
|
||
align-items:center;gap:4px;
|
||
font-size:12px;color:#334155;white-space:nowrap;
|
||
}
|
||
.zm-detect-formula input{
|
||
width:100%;min-width:0;box-sizing:border-box;
|
||
padding:5px 4px;text-align:center;font-size:12px;font-weight:600;
|
||
border:1px solid #cbd5e1;border-radius:6px;background:#fff;color:#0f172a;
|
||
}
|
||
.zm-detect-formula input:focus{
|
||
outline:none;border-color:#169F85;box-shadow:0 0 0 2px rgba(22,159,133,.12);
|
||
}
|
||
.zm-detect-unit{font-size:12px;color:#64748b;flex-shrink:0;}
|
||
.zm-detect-preview{
|
||
margin-top:6px;font-size:11px;color:#64748b;line-height:1.4;
|
||
padding-top:5px;border-top:1px dashed #e2e8f0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
|
||
}
|
||
|
||
.zone-modal-foot{
|
||
padding:12px 20px;border-top:1px solid var(--c-border-light,#eef2f5);
|
||
display:flex;justify-content:flex-end;gap:8px;
|
||
background:linear-gradient(135deg,#f8fbfa 0%,#f0f9f6 100%);
|
||
}
|
||
.btn{padding:8px 16px;font-size:13px;border-radius:8px;border:1px solid #e2e8f0;background:#fff;cursor:pointer;font-weight:500;transition:.15s;}
|
||
.btn:hover{border-color:#169F85;color:#169F85;background:#f0fdf9;}
|
||
.btn-primary{background:linear-gradient(135deg,#14b89a,#169F85);color:#fff;border:none;box-shadow:0 2px 8px rgba(22,159,133,.35);}
|
||
.btn-primary:hover{background:linear-gradient(135deg,#169F85,#0e7a65);color:#fff;transform:translateY(-1px);box-shadow:0 4px 12px rgba(22,159,133,.4);}
|
||
.btn-ghost{background:transparent;border-color:transparent;color:#64748b;}
|
||
.btn-ghost:hover{background:#f1f5f9;color:#0f172a;border-color:transparent;}
|
||
|
||
/* 分析状态徽章 + 启停按钮 */
|
||
.analysis-badge{
|
||
display:inline-flex;align-items:center;gap:6px;
|
||
padding:4px 10px;font-size:11px;font-weight:600;border-radius:10px;
|
||
border:1px solid var(--c-border,#dcdfe6);background:#fff;color:#6b7280;white-space:nowrap;
|
||
}
|
||
.analysis-badge::before{
|
||
content:'';width:8px;height:8px;border-radius:50%;background:#9ca3af;flex-shrink:0;
|
||
}
|
||
.analysis-badge.running{background:#d1fae5;color:#065f46;border-color:#a7f3d0;}
|
||
.analysis-badge.running::before{background:#10b981;box-shadow:0 0 0 3px rgba(16,185,129,.18);animation:pulse 1.6s infinite;}
|
||
.analysis-badge.stopped{background:#f3f4f6;color:#6b7280;border-color:#e5e7eb;}
|
||
.analysis-badge.reconnecting{background:#fef3c7;color:#92400e;border-color:#fde68a;}
|
||
.analysis-badge.reconnecting::before{background:#f59e0b;animation:pulse 1.2s infinite;}
|
||
.analysis-badge.stalled{background:#fee2e2;color:#991b1b;border-color:#fecaca;}
|
||
.analysis-badge.stalled::before{background:#ef4444;box-shadow:0 0 0 3px rgba(239,68,68,.15);}
|
||
.tag-warn{background:#fef3c7;color:#92400e;}
|
||
.tag-err{background:#fee2e2;color:#991b1b;}
|
||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
||
.btn-analyze-start{color:#0e7a65;border-color:#169F85;}
|
||
.btn-analyze-start:hover{background:#ecfdf5;}
|
||
.btn-analyze-stop{color:#b91c1c;border-color:#fca5a5;}
|
||
.btn-analyze-stop:hover{background:#fef2f2;color:#b91c1c;border-color:#f87171;}
|
||
|
||
/* 操作列图标按钮(与视频管理一致) */
|
||
.zone-actions{display:flex;flex-wrap:nowrap;gap:2px;align-items:center;}
|
||
.act-btn{
|
||
width:28px;height:28px;padding:0;border:1px solid transparent;border-radius:6px;
|
||
background:transparent;color:#64748b;cursor:pointer;display:inline-flex;
|
||
align-items:center;justify-content:center;transition:.15s;flex-shrink:0;
|
||
}
|
||
.act-btn:hover{background:#f3f4f6;color:#169F85;}
|
||
.act-btn[disabled]{opacity:.35;cursor:not-allowed;pointer-events:none;}
|
||
.act-btn.act-danger:hover{background:#fef2f2;color:#b91c1c;}
|
||
.act-btn.act-zone-on{color:#0e7a65;font-weight:700;}
|
||
.act-btn.act-zone-on:hover{background:#ecfdf5;}
|
||
.act-btn.act-zone-off{color:#94a3b8;}
|
||
.act-btn.act-zone-off:hover{background:#f1f5f9;color:#64748b;}
|
||
.act-btn.act-analyze-start:hover{background:#ecfdf5;color:#059669;}
|
||
.act-btn.act-analyze-stop{color:#dc2626;}
|
||
.act-btn.act-analyze-stop:hover{background:#fef2f2;color:#b91c1c;}
|
||
.act-btn.act-analyze-disabled{color:#94a3b8;opacity:.5;}
|
||
.act-btn.act-analyze-disabled:hover{background:#f1f5f9;color:#64748b;}
|
||
.act-btn svg{width:15px;height:15px;pointer-events:none;}
|
||
.act-btn.act-loading{color:#169F85;cursor:wait;pointer-events:none;background:#ecfdf5;border-color:#a7f3d0;}
|
||
.act-spin{animation:act-spin .8s linear infinite;}
|
||
@keyframes act-spin{from{transform:rotate(0deg);}to{transform:rotate(360deg);}}
|
||
|
||
/* ========== 引擎实例管理弹框 ========== */
|
||
.engine-clickable{cursor:pointer;transition:color .15s;}
|
||
.engine-clickable:hover{color:#0e7a65 !important;text-decoration:underline;}
|
||
|
||
.engine-modal-overlay{position:fixed;inset:0;background:rgba(15,23,42,.45);backdrop-filter:blur(4px);
|
||
display:none;align-items:center;justify-content:center;z-index:1000;padding:20px;}
|
||
.engine-modal-overlay.open{display:flex;animation:emFadeIn .2s ease;}
|
||
@keyframes emFadeIn{from{opacity:0;}to{opacity:1;}}
|
||
.engine-modal{background:#fff;border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.2);
|
||
width:min(720px,94vw);max-height:88vh;display:flex;flex-direction:column;overflow:hidden;
|
||
animation:emSlideIn .25s cubic-bezier(.16,1,.3,1);}
|
||
@keyframes emSlideIn{from{opacity:0;transform:translateY(20px) scale(.97);}to{opacity:1;transform:none;}}
|
||
.engine-modal-head{padding:16px 22px;background:linear-gradient(135deg,#f8fbfa 0%,#f0f9f6 100%);
|
||
border-bottom:1px solid #eef2f5;display:flex;align-items:center;justify-content:space-between;}
|
||
.emh-title{display:flex;align-items:center;gap:10px;}
|
||
.emh-icon{width:32px;height:32px;border-radius:8px;background:#169F85;color:#fff;
|
||
display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0;}
|
||
.engine-modal-head h3{margin:0;font-size:15px;font-weight:600;color:#1f2937;}
|
||
.emh-sub{font-size:12px;color:#6b7280;font-weight:400;margin-left:4px;}
|
||
.engine-modal-close{width:32px;height:32px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;
|
||
cursor:pointer;font-size:20px;color:#64748b;display:flex;align-items:center;justify-content:center;
|
||
transition:all .15s;line-height:1;}
|
||
.engine-modal-close:hover{border-color:#ef4444;color:#ef4444;background:#fef2f2;}
|
||
.engine-modal-body{flex:1;min-height:0;overflow-y:auto;padding:18px 22px;}
|
||
.engine-loading{text-align:center;padding:40px;color:#9ca3af;font-size:13px;}
|
||
|
||
/* 配置卡 */
|
||
.em-section{margin-bottom:18px;}
|
||
.em-section-title{font-size:12px;font-weight:600;color:#6b7280;text-transform:uppercase;
|
||
letter-spacing:.5px;margin-bottom:10px;display:flex;align-items:center;gap:8px;}
|
||
.em-section-title::before{content:'';width:3px;height:12px;background:#169F85;border-radius:2px;}
|
||
.em-cfg-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
|
||
.em-cfg-item{background:#f8fbfa;border:1px solid #eef2f5;border-radius:8px;padding:12px 14px;
|
||
display:flex;flex-direction:column;gap:6px;}
|
||
.em-cfg-item .lbl{font-size:11px;color:#6b7280;display:flex;align-items:center;justify-content:space-between;}
|
||
.em-cfg-item .lbl .hint{color:#9ca3af;}
|
||
.em-cfg-item .val{font-size:14px;font-weight:600;color:#1f2937;display:flex;align-items:center;gap:8px;}
|
||
.em-cfg-item .val .num{color:#169F85;font-size:18px;}
|
||
.em-cfg-actions{display:flex;gap:6px;margin-top:4px;flex-wrap:wrap;}
|
||
|
||
/* 开关 */
|
||
.em-switch{position:relative;display:inline-block;width:36px;height:20px;cursor:pointer;flex-shrink:0;}
|
||
.em-switch input{opacity:0;width:0;height:0;}
|
||
.em-switch .slider{position:absolute;inset:0;background:#d1d5db;border-radius:20px;transition:.25s;}
|
||
.em-switch .slider::before{content:'';position:absolute;width:16px;height:16px;left:2px;top:2px;
|
||
background:#fff;border-radius:50%;transition:.25s;box-shadow:0 1px 3px rgba(0,0,0,.2);}
|
||
.em-switch input:checked + .slider{background:#169F85;}
|
||
.em-switch input:checked + .slider::before{transform:translateX(16px);}
|
||
|
||
/* worker 数量控制 */
|
||
.em-workers{display:flex;align-items:center;gap:4px;}
|
||
.em-workers button{width:26px;height:26px;border:1px solid #d1d5db;border-radius:6px;background:#fff;
|
||
cursor:pointer;font-size:14px;color:#374151;display:flex;align-items:center;justify-content:center;padding:0;}
|
||
.em-workers button:hover{border-color:#169F85;color:#169F85;background:#f0f9f6;}
|
||
.em-workers input{width:46px;text-align:center;font-size:13px;padding:3px 4px;border:1px solid #d1d5db;border-radius:6px;}
|
||
|
||
/* 按钮 */
|
||
.em-btn{padding:5px 12px;font-size:12px;border-radius:6px;border:1px solid #169F85;
|
||
background:#169F85;color:#fff;cursor:pointer;font-weight:500;transition:all .15s;
|
||
display:inline-flex;align-items:center;gap:4px;}
|
||
.em-btn:hover{background:#0e7a65;}
|
||
.em-btn:disabled{opacity:.5;cursor:not-allowed;}
|
||
.em-btn.ghost{background:#fff;color:#169F85;border-color:#169F85;}
|
||
.em-btn.ghost:hover{background:#f0f9f6;}
|
||
.em-btn.danger{background:#fff;color:#dc2626;border-color:#fecaca;}
|
||
.em-btn.danger:hover{background:#fef2f2;border-color:#dc2626;}
|
||
|
||
/* 实例卡片 */
|
||
.em-inst-list{display:flex;flex-direction:column;gap:10px;}
|
||
.em-inst{background:#fff;border:1px solid #eef2f5;border-radius:10px;padding:14px 16px;
|
||
transition:all .15s;}
|
||
.em-inst:hover{border-color:#169F85;box-shadow:0 2px 8px rgba(22,159,133,.08);}
|
||
.em-inst.disabled{opacity:.6;background:#f9fafb;}
|
||
.em-inst.disabled:hover{border-color:#d1d5db;box-shadow:none;}
|
||
.em-inst-top{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:8px;}
|
||
.em-inst-name{font-size:14px;font-weight:600;color:#1f2937;display:flex;align-items:center;gap:6px;flex:1;min-width:0;}
|
||
.em-inst-name .ic{width:6px;height:6px;border-radius:50%;background:#169F85;flex-shrink:0;}
|
||
.em-inst.disabled .em-inst-name .ic{background:#9ca3af;}
|
||
.em-inst-actions{display:flex;align-items:center;gap:8px;flex-shrink:0;}
|
||
.em-tags{display:flex;flex-wrap:wrap;gap:5px;margin:6px 0;}
|
||
.em-tag{font-size:11px;padding:2px 8px;border-radius:10px;background:#f3f4f6;color:#475569;font-weight:500;}
|
||
.em-tag.engine{background:#dbeafe;color:#1e40af;}
|
||
.em-tag.device{background:#fef3c7;color:#92400e;}
|
||
.em-tag.task{background:#ede9fe;color:#6b21a8;}
|
||
.em-tag.count{background:#d1fae5;color:#065f46;}
|
||
.em-tag.input{background:#fce7f3;color:#9d174d;}
|
||
.em-inst-meta{font-size:12px;color:#6b7280;line-height:1.7;}
|
||
.em-inst-meta .row{display:flex;gap:6px;}
|
||
.em-inst-meta .k{color:#9ca3af;min-width:70px;flex-shrink:0;}
|
||
.em-inst-meta .v{color:#374151;word-break:break-all;}
|
||
.em-inst-meta .v Streams{color:#169F85;font-weight:500;}
|
||
|
||
/* 空状态 */
|
||
.em-empty{text-align:center;padding:40px 20px;color:#9ca3af;font-size:13px;
|
||
background:#f9fafb;border-radius:10px;border:1px dashed #e5e7eb;}
|
||
.em-empty .ic{display:block;font-size:32px;margin-bottom:8px;opacity:.4;}
|
||
|
||
/* 实时 YOLO 预览 */
|
||
.yolo-preview-modal{width:min(1180px,96vw);background:#0f172a;border-radius:12px;overflow:hidden;
|
||
box-shadow:0 24px 70px rgba(0,0,0,.35);color:#e2e8f0;display:flex;flex-direction:column;}
|
||
.yp-head{height:54px;padding:0 18px;background:#fff;color:#1f2937;display:flex;align-items:center;justify-content:space-between;}
|
||
.yp-title{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:600;}
|
||
.yp-live-dot{width:9px;height:9px;border-radius:50%;background:#10b981;box-shadow:0 0 0 4px rgba(16,185,129,.15);}
|
||
.yp-stage{position:relative;width:100%;aspect-ratio:16/9;background:#020617;overflow:hidden;}
|
||
#yoloPreviewPlayer,#yoloPreviewPlayer>*{width:100%;height:100%;}
|
||
#yoloPreviewCanvas{position:absolute;inset:0;width:100%;height:100%;z-index:5;pointer-events:none;}
|
||
.yp-loading{position:absolute;inset:0;z-index:6;display:flex;align-items:center;justify-content:center;background:rgba(2,6,23,.72);color:#cbd5e1;}
|
||
.yp-foot{min-height:52px;padding:10px 16px;background:#111827;display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
|
||
.yp-stat{font-size:12px;color:#cbd5e1;background:#1f2937;border:1px solid #334155;border-radius:14px;padding:4px 10px;}
|
||
.yp-mode{font-size:12px;font-weight:600;border-radius:14px;padding:4px 10px;background:#064e3b;color:#a7f3d0;}
|
||
.yp-mode.preview{background:#78350f;color:#fde68a;}
|
||
.act-btn.act-preview{color:#0f766e}.act-btn.act-preview:hover{background:#ecfdf5;color:#059669;}
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="zone-page">
|
||
<div class="zone-card">
|
||
<div class="zone-head">
|
||
<h3><span class="dot"></span>{{ T.nav_control|default:'布控管理' }}</h3>
|
||
<div class="zone-toolbar">
|
||
<select id="filterStream" onchange="onFilterStreamChange()">
|
||
<option value="">{{ T.zone_all_cameras|default:'全部摄像头' }}</option>
|
||
</select>
|
||
<span id="analysisStatusBadge" class="analysis-badge" style="display:none"></span>
|
||
<button class="btn" onclick="loadZones()">{{ T.btn_refresh_symbol|default:'↻ 刷新' }}</button>
|
||
<button class="btn btn-primary" onclick="openZoneModal()">{{ T.btn_add_zone|default:'+ 添加布控' }}</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分析概览条 -->
|
||
<div class="zone-overview" id="zoneOverview" style="display:none">
|
||
<div class="ov-item"><span class="lbl">{{ T.zone_ov_total_cam|default:'摄像头总数' }}</span><span class="val" id="ovTotalCam">0</span></div>
|
||
<div class="ov-item"><span class="lbl">{{ T.zone_ov_running|default:'运行分析' }}</span><span class="val green" id="ovRunning">0 / 0</span><span class="sub" id="ovFps">{{ T.zone_ov_fps_avg|default:'平均 FPS 0.0' }}</span></div>
|
||
<div class="ov-item"><span class="lbl">{{ T.zone_ov_engine_instances|default:'布控实例' }}</span><span class="val blue engine-clickable" id="ovInstances" onclick="openEngineModal()" title="{{ T.zone_ctrl_modal_click|default:'点击管理布控实例' }}">0</span><span class="sub engine-clickable" id="ovReuse" onclick="openEngineModal()">{{ T.zone_ov_reuse|default:'全部按算法缓存复用' }}</span></div>
|
||
<div class="ov-item"><span class="lbl">{{ T.zone_ov_zones|default:'布控 / 运行中' }}</span><span class="val" id="ovZones">0 / 0</span></div>
|
||
<div class="ov-item"><span class="lbl">{{ T.zone_ov_alarms|default:'近期报警' }}</span><span class="val red" id="ovAlarms">0</span></div>
|
||
</div>
|
||
|
||
<div class="zone-body" id="zoneBodyWrap">
|
||
<table class="zone-table" id="zoneTable">
|
||
<thead>
|
||
<tr>
|
||
<th>{{ T.th_id|default:'ID' }}</th><th>{{ T.zone_camera|default:'摄像头' }}</th><th>{{ T.zone_name|default:'布控名称' }}</th>
|
||
<th>{{ T.zone_algorithms|default:'业务算法' }}</th>
|
||
<th>{{ T.zone_detect_rate|default:'检测频率' }}</th>
|
||
<th>{{ T.zone_required|default:'必需' }}</th><th>{{ T.zone_loiter|default:'滞留(秒)' }}</th>
|
||
<th>{{ T.zone_state|default:'状态' }}</th>
|
||
<th>{{ T.col_action|default:'操作' }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="zoneBody"></tbody>
|
||
</table>
|
||
<div id="zoneEmpty" class="zone-empty" style="display:none">
|
||
<span class="ic">○</span><span>{{ T.camera_no_data|default:'暂无数据' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 报警面板已迁移至 /alarm/index 报警管理页面 -->
|
||
</div>
|
||
|
||
<div class="zone-modal-overlay" id="zoneModal" onclick="if(event.target===this)closeZoneModal()">
|
||
<div class="zone-modal" role="dialog">
|
||
<div class="zone-modal-head">
|
||
<div>
|
||
<h3 class="zm-title" id="zoneModalTitle">{{ T.btn_add_zone|default:'添加布控' }}</h3>
|
||
<p class="zm-sub" id="zoneModalSub">{{ T.zone_draw_hint|default:'在摄像头画面上点击绘制多边形区域' }}</p>
|
||
</div>
|
||
<button type="button" class="zone-modal-close" onclick="closeZoneModal()" aria-label="close">×</button>
|
||
</div>
|
||
|
||
<div class="zone-modal-body">
|
||
<input type="hidden" id="f_id">
|
||
|
||
<!-- 左侧画布 -->
|
||
<div class="zm-stage">
|
||
<div class="zm-stage-bar">
|
||
<div class="zm-stage-row zm-stage-row-video">
|
||
<select id="f_stream" class="zm-stream-select" onchange="onStreamChange()"></select>
|
||
<button type="button" class="zm-tool" onclick="refreshLivePreview()" title="{{ T.zone_refresh_live|default:'重新连接视频' }}">↻</button>
|
||
</div>
|
||
<div class="zm-stage-row zm-stage-row-draw" id="drawToolRow">
|
||
<span class="zm-row-label">{{ T.zone_draw_tools|default:'绘制' }}</span>
|
||
<div class="zm-tool-group zm-draw-modes" id="drawModeBar">
|
||
<button type="button" class="zm-tool active" data-mode="rect" onclick="setDrawMode('rect')" title="{{ T.zone_draw_rect|default:'矩形区域' }}">▭</button>
|
||
<button type="button" class="zm-tool" data-mode="polygon" onclick="setDrawMode('polygon')" title="{{ T.zone_draw_polygon|default:'多边形' }}">⬠</button>
|
||
<span class="zm-draw-sep" aria-hidden="true"></span>
|
||
<button type="button" class="zm-tool zm-tool-line" data-mode="line" id="btnDrawLine" onclick="setDrawMode('line')" title="{{ T.zone_draw_line|default:'方向线 A→B' }}">→</button>
|
||
</div>
|
||
<span class="zm-badge" id="pointStatus">{{ T.zone_no_points|default:'0 个点' }}</span>
|
||
<span class="zm-line-status" id="lineStatus"></span>
|
||
<div class="zm-tool-group zm-draw-actions">
|
||
<button type="button" class="zm-tool" onclick="undoPoint()" title="{{ T.zone_undo|default:'撤销' }}">↩</button>
|
||
<button type="button" class="zm-tool" onclick="clearCurrentDraw()" title="{{ T.zone_clear_current|default:'清除当前图形' }}">⌫</button>
|
||
<button type="button" class="zm-tool zm-tool-finish" id="btnFinishDraw" onclick="finishDraw()" title="{{ T.zone_finish_draw|default:'完成绘制' }}">✓</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="zm-canvas-box">
|
||
<div class="draw-wrap no-snap" id="drawWrap">
|
||
<span class="zm-ph-icon">▣</span>
|
||
<span id="drawPlaceholder">{{ T.zone_select_camera_first|default:'正在加载摄像头画面…' }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="zm-hint-bar">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||
<span id="drawHintText">{{ T.zone_draw_tip_three|default:'三种图形可叠加:▭ 矩形 · ⬠ 多边形 · → 方向线 A→B(箭头为正向)· 右键撤销当前工具' }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧属性 -->
|
||
<div class="zm-panel">
|
||
<div class="zm-panel-scroll">
|
||
<div class="zm-field">
|
||
<label>{{ T.zone_name|default:'布控名称' }}</label>
|
||
<input id="f_name" placeholder="{{ T.zone_name_ph|default:'如:大门口、停车场入口' }}">
|
||
</div>
|
||
<div class="zm-field">
|
||
<label>{{ T.zone_color|default:'区域颜色' }}</label>
|
||
<div class="zm-colors">
|
||
<span class="zm-swatch active" data-color="#169F85" style="background:#169F85" onclick="pickColor(this)"></span>
|
||
<span class="zm-swatch" data-color="#2563eb" style="background:#2563eb" onclick="pickColor(this)"></span>
|
||
<span class="zm-swatch" data-color="#dc2626" style="background:#dc2626" onclick="pickColor(this)"></span>
|
||
<span class="zm-swatch" data-color="#d97706" style="background:#d97706" onclick="pickColor(this)"></span>
|
||
<span class="zm-swatch" data-color="#7c3aed" style="background:#7c3aed" onclick="pickColor(this)"></span>
|
||
<input id="f_color" type="color" class="zm-color-input" value="#169F85" onchange="onColorInput()">
|
||
</div>
|
||
</div>
|
||
<div class="zm-field-row">
|
||
<div class="zm-field">
|
||
<label>{{ T.zone_loiter|default:'滞留阈值' }}</label>
|
||
<input id="f_loiter" type="number" value="0" min="0" placeholder="秒">
|
||
</div>
|
||
<div class="zm-field">
|
||
<label>{{ T.zone_required|default:'必需区域' }}</label>
|
||
<div class="zm-toggle-row" id="reqToggle">
|
||
<button type="button" class="zm-toggle" data-val="0" onclick="setToggle('reqToggle',0,'f_required');onRequiredChange()">{{ T.zone_no|default:'否' }}</button>
|
||
<button type="button" class="zm-toggle active" data-val="1" onclick="setToggle('reqToggle',1,'f_required');onRequiredChange()">{{ T.zone_yes|default:'是' }}</button>
|
||
</div>
|
||
<input type="hidden" id="f_required" value="1">
|
||
<div class="biz-hint" id="zoneRequiredHint" style="margin-top:6px;font-size:11px;color:#64748b;"></div>
|
||
</div>
|
||
</div>
|
||
<div class="zm-field">
|
||
<label>{{ T.zone_detect_rate|default:'算法检测频率' }}</label>
|
||
<div class="zm-detect-box">
|
||
<div class="zm-detect-formula">
|
||
<span class="zm-detect-unit">{{ T.zone_detect_every|default:'每' }}</span>
|
||
<input type="number" id="f_detect_interval" value="1" min="0.1" max="86400" step="any"
|
||
oninput="updateDetectPreview()" title="{{ T.zone_detect_interval_tip|default:'时间间隔(秒),支持小数' }}">
|
||
<span class="zm-detect-unit">{{ T.zone_detect_sec|default:'秒' }}</span>
|
||
<span class="zm-detect-unit">{{ T.zone_detect_analyze|default:'分析' }}</span>
|
||
<input type="number" id="f_detect_frames" value="1" min="1" max="999" step="1"
|
||
oninput="updateDetectPreview()" title="{{ T.zone_detect_frames_tip|default:'分析帧数' }}">
|
||
<span class="zm-detect-unit">{{ T.zone_detect_frame_unit|default:'帧' }}</span>
|
||
</div>
|
||
<div class="zm-detect-preview" id="detectPreview"></div>
|
||
</div>
|
||
</div>
|
||
<div class="zm-field">
|
||
<label>重复报警间隔(秒)</label>
|
||
<input id="f_alarm_repeat" type="number" value="30" min="0" max="86400" step="1"
|
||
title="0 表示同一次停留不重复报警;离开后重新进入仍会立即报警">
|
||
<div class="biz-hint" style="margin-top:5px;font-size:11px;color:#64748b;">首次进入立即报警;0 表示同一次停留只报一次</div>
|
||
</div>
|
||
<div class="zm-field">
|
||
<label>{{ T.zone_algorithms|default:'业务算法' }} <span style="font-weight:400;color:#9ca3af;font-size:11px;">({{ T.zone_algorithms_hint|default:'可多选' }})</span></label>
|
||
<div class="zm-algo-wrap">
|
||
<div class="zm-algo-bar">
|
||
<span class="cnt" id="algoCount">{{ T.zone_algorithms_selected|default:'已选 {n} 个业务算法' }}</span>
|
||
<span class="act" onclick="algoSelectAll(true)">{{ T.alg_select_all|default:'全选' }}</span>
|
||
<span class="act" onclick="algoSelectAll(false)">{{ T.alg_select_none|default:'清空' }}</span>
|
||
</div>
|
||
<div class="zm-algo-grid" id="algoList">
|
||
<span style="color:#9ca3af;font-size:12px;grid-column:1/-1;">{{ T.index_loading|default:'加载中...' }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="zone-modal-foot">
|
||
<button type="button" class="btn btn-ghost" onclick="closeZoneModal()">{{ T.cancel|default:'取消' }}</button>
|
||
<button type="button" class="btn btn-primary" onclick="submitZone()">{{ T.btn_save|default:'保存' }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 实时 YOLO 检测弹框 -->
|
||
<div class="engine-modal-overlay" id="yoloPreviewModal" onclick="if(event.target===this)closeYoloPreview()">
|
||
<div class="yolo-preview-modal" role="dialog" aria-modal="true" aria-label="实时 YOLO 检测">
|
||
<div class="yp-head">
|
||
<div class="yp-title"><span class="yp-live-dot"></span><span id="ypTitle">实时 YOLO 人员检测</span></div>
|
||
<button type="button" class="engine-modal-close" onclick="closeYoloPreview()" aria-label="close">×</button>
|
||
</div>
|
||
<div class="yp-stage" id="yoloPreviewStage">
|
||
<div id="yoloPreviewPlayer"></div>
|
||
<canvas id="yoloPreviewCanvas"></canvas>
|
||
<div class="yp-loading" id="ypLoading">正在启动 YOLO 分析并连接视频流…</div>
|
||
</div>
|
||
<div class="yp-foot">
|
||
<span class="yp-mode preview" id="ypMode">预览模式,不产生报警</span>
|
||
<span class="yp-stat" id="ypModel">模型:—</span>
|
||
<span class="yp-stat" id="ypFps">分析 FPS:0.00</span>
|
||
<span class="yp-stat" id="ypAge">结果延迟:—</span>
|
||
<span class="yp-stat" id="ypCount">当前人员:0</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 布控实例管理弹框 -->
|
||
<div class="engine-modal-overlay" id="engineModal" onclick="if(event.target===this)closeEngineModal()">
|
||
<div class="engine-modal" role="dialog">
|
||
<div class="engine-modal-head">
|
||
<div class="emh-title">
|
||
<span class="emh-icon">⚙</span>
|
||
<h3>{{ T.zone_ctrl_modal_title|default:'布控实例管理' }}</h3>
|
||
<span class="emh-sub" id="engineModalSub"></span>
|
||
</div>
|
||
<button type="button" class="engine-modal-close" onclick="closeEngineModal()" aria-label="close">×</button>
|
||
</div>
|
||
<div class="engine-modal-body" id="engineModalBody">
|
||
<div class="engine-loading">加载中...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script src="{% static 'lib/easyPlayer/js/easyplayer-pro.js' %}"></script>
|
||
<script>
|
||
function onFilterStreamChange(){
|
||
loadZonePageData();
|
||
}
|
||
|
||
function getStreamStatusInfo(streamId){
|
||
streamId = parseInt(streamId, 10) || 0;
|
||
if(!streamId) return null;
|
||
for(var i=0;i<g_runningStreams.length;i++){
|
||
var r = g_runningStreams[i];
|
||
if(typeof r === 'object' && r.stream_id === streamId) return r;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isStreamAnalysisRunning(streamId){
|
||
var info = getStreamStatusInfo(streamId);
|
||
if(!info) return false;
|
||
return info.running !== false && info.stream_health !== 'stopped';
|
||
}
|
||
|
||
function getStreamHealth(streamId){
|
||
var info = getStreamStatusInfo(streamId);
|
||
if(!info) return 'stopped';
|
||
return info.stream_health || (info.running ? 'ok' : 'stopped');
|
||
}
|
||
|
||
function streamHealthLabel(health){
|
||
if(health === 'ok') return _t('zone_health_ok','正常');
|
||
if(health === 'reconnecting' || health === 'connecting') return _t('zone_health_reconnecting','重连中');
|
||
if(health === 'disconnected') return _t('zone_health_disconnected','视频断开');
|
||
if(health === 'stalled') return _t('zone_health_stalled','视频中断');
|
||
return _t('zone_analysis_stopped','分析未启动');
|
||
}
|
||
|
||
function streamHealthBadgeClass(health){
|
||
if(health === 'ok') return 'running';
|
||
if(health === 'reconnecting' || health === 'connecting') return 'reconnecting';
|
||
if(health === 'stalled' || health === 'disconnected') return 'stalled';
|
||
return 'stopped';
|
||
}
|
||
|
||
var g_healthToastShown = {};
|
||
|
||
function maybeNotifyStreamHealth(streamId, health){
|
||
streamId = parseInt(streamId, 10) || 0;
|
||
if(!streamId) return;
|
||
var key = streamId + ':' + health;
|
||
if(health === 'stalled' || health === 'disconnected'){
|
||
if(!g_healthToastShown[key]){
|
||
g_healthToastShown[key] = true;
|
||
showToast(_t('zone_health_stalled_toast','视频流中断,分析已停止工作,请手动重启分析'), 'warning', 6000);
|
||
}
|
||
} else if(health === 'ok'){
|
||
g_healthToastShown = {};
|
||
}
|
||
}
|
||
|
||
function refreshAnalysisUI(){
|
||
var sid = parseInt(document.getElementById('filterStream').value, 10) || 0;
|
||
var badge = document.getElementById('analysisStatusBadge');
|
||
if(!sid){
|
||
badge.style.display = 'inline-flex';
|
||
var n = g_runningStreams.filter(function(r){
|
||
return typeof r === 'object' && r.running !== false && r.healthy;
|
||
}).length;
|
||
badge.className = 'analysis-badge ' + (n>0?'running':'stopped');
|
||
badge.textContent = _t('zone_analysis_running_count','分析中:{n}').replace('{n}', n);
|
||
return;
|
||
}
|
||
var info = null;
|
||
for(var i=0;i<g_runningStreams.length;i++){
|
||
var r = g_runningStreams[i];
|
||
if((typeof r === 'object' && r.stream_id===sid) || r===sid){ info = (typeof r==='object'?r:null); break; }
|
||
}
|
||
var running = !!info && info.running !== false;
|
||
var health = info ? (info.stream_health || 'ok') : 'stopped';
|
||
badge.style.display = 'inline-flex';
|
||
badge.className = 'analysis-badge ' + streamHealthBadgeClass(running ? health : 'stopped');
|
||
var algName = info && info.algorithm_name ? ' · ' + info.algorithm_name : '';
|
||
var fpsStr = '';
|
||
if(info && info.analysis_fps !== undefined){
|
||
fpsStr = ' · ' + (info.analysis_fps||0).toFixed(1) + ' fps';
|
||
}
|
||
var healthStr = running ? (' · ' + streamHealthLabel(health)) : '';
|
||
if(info && (info.analysis_health === 'error' || info.analysis_health === 'stalled')){
|
||
healthStr = ' · ' + _t('zone_status_inference_error','推理异常');
|
||
badge.className = 'analysis-badge stalled';
|
||
} else if(info && info.analysis_health === 'starting'){
|
||
healthStr = ' · ' + _t('zone_status_starting','启动中');
|
||
badge.className = 'analysis-badge reconnecting';
|
||
}
|
||
if(g_lastAnalysisData && g_lastAnalysisData.inference_degraded && running){
|
||
healthStr += ' · ' + _t('zone_inference_degraded','推理异常');
|
||
if(health === 'ok') badge.className = 'analysis-badge stalled';
|
||
}
|
||
if(running && (health === 'stalled' || health === 'disconnected') && info.stalled_sec){
|
||
healthStr += ' (' + Math.round(info.stalled_sec) + 's)';
|
||
}
|
||
badge.textContent = (running ? _t('zone_analysis_running','分析运行中') : _t('zone_analysis_stopped','分析未启动'))
|
||
+ healthStr + algName + fpsStr;
|
||
if(g_lastAnalysisData && g_lastAnalysisData.inference_degraded){
|
||
if(!g_healthToastShown.infer){
|
||
g_healthToastShown.infer = true;
|
||
showToast(_t('zone_inference_degraded_toast','目标检测推理超时,布控无法正常报警,请重启分析或检查小模型'), 'error', 8000);
|
||
}
|
||
}
|
||
if(running) maybeNotifyStreamHealth(sid, health);
|
||
}
|
||
|
||
var SVG_ANALYZE_START = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none"/></svg>';
|
||
var SVG_ANALYZE_STOP = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="1"/></svg>';
|
||
var SVG_PREVIEW = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M8 20v2m8-2v2"/></svg>';
|
||
var SVG_EDIT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>';
|
||
var SVG_DEL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>';
|
||
var SVG_LOADING = '<svg class="act-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round"/></svg>';
|
||
|
||
function getActiveZoneIds(streamId){
|
||
streamId = parseInt(streamId, 10) || 0;
|
||
if(!streamId) return [];
|
||
var info = getStreamStatusInfo(streamId);
|
||
if(!info || !info.active_zone_ids) return [];
|
||
return info.active_zone_ids.map(function(x){ return parseInt(x, 10); });
|
||
}
|
||
|
||
function zoneIsActiveInPipeline(z){
|
||
if(!z || z.id == null) return false;
|
||
var ids = getActiveZoneIds(z.stream_id);
|
||
return ids.indexOf(parseInt(z.id, 10)) >= 0;
|
||
}
|
||
|
||
function zoneIsRunning(z){
|
||
if(!zoneIsActiveInPipeline(z)) return false;
|
||
var info = getStreamStatusInfo(z.stream_id);
|
||
if(!info || info.running === false) return false;
|
||
return true;
|
||
}
|
||
|
||
function buildZoneStateTag(z){
|
||
if(!zoneIsActiveInPipeline(z)){
|
||
return '<span class="tag tag-off">'+_t('zone_status_stopped','已停止')+'</span>';
|
||
}
|
||
var info = getStreamStatusInfo(z.stream_id);
|
||
if(!info){
|
||
return '<span class="tag tag-warn">'+_t('zone_status_starting','启动中')+'</span>';
|
||
}
|
||
if(info.analysis_health === 'error' || info.analysis_health === 'stalled'){
|
||
return '<span class="tag tag-err">'+_t('zone_status_inference_error','推理异常')+'</span>';
|
||
}
|
||
var health = getStreamHealth(z.stream_id);
|
||
var fps = info.analysis_fps != null ? info.analysis_fps : 0;
|
||
if(health === 'stalled' || health === 'disconnected'){
|
||
return '<span class="tag tag-err">'+streamHealthLabel(health)+'</span>';
|
||
}
|
||
if(health === 'reconnecting' || health === 'connecting'){
|
||
return '<span class="tag tag-warn">'+streamHealthLabel(health)+'</span>';
|
||
}
|
||
if(info.running !== false && health === 'ok' &&
|
||
(info.analysis_health === 'running' || (!info.analysis_health && fps > 0.05))){
|
||
return '<span class="tag tag-on">'+_t('zone_status_running','分析中')+'</span>';
|
||
}
|
||
return '<span class="tag tag-warn">'+_t('zone_status_starting','启动中')+'</span>';
|
||
}
|
||
|
||
function buildZoneRunBtn(z){
|
||
var running = zoneIsRunning(z);
|
||
var title = running ? _t('zone_stop','停止此布控') : _t('zone_start','启动此布控');
|
||
var cls = running ? 'act-analyze-stop' : 'act-analyze-start';
|
||
var icon = running ? SVG_ANALYZE_STOP : SVG_ANALYZE_START;
|
||
var en = running ? 0 : 1;
|
||
return '<button type="button" class="act-btn '+cls+'" data-zone="'+z.id+'" data-stream="'+z.stream_id+'" onclick="toggleZone('+z.id+','+en+')" title="'+escapeHtml(title)+'">'+icon+'</button>';
|
||
}
|
||
|
||
function refreshZoneRowButtons(){
|
||
g_zoneCache.forEach(function(z){
|
||
var tr = document.querySelector('#zoneBody tr[data-zone-id="'+z.id+'"]');
|
||
if(!tr) return;
|
||
var slot = tr.querySelector('.zone-run-btn');
|
||
if(slot) slot.innerHTML = buildZoneRunBtn(z);
|
||
var stateCell = tr.querySelector('.zone-state-cell');
|
||
if(stateCell) stateCell.innerHTML = buildZoneStateTag(z);
|
||
});
|
||
}
|
||
|
||
function toggleZone(zoneId, enabled){
|
||
zoneId = parseInt(zoneId, 10) || 0;
|
||
enabled = enabled ? 1 : 0;
|
||
if(!zoneId || g_zoneTogglePending[zoneId]) return;
|
||
var z = g_zoneCache.find(function(x){ return x.id === zoneId; });
|
||
if(!z) return;
|
||
var btn = document.querySelector('.act-btn[data-zone="'+zoneId+'"]');
|
||
var origHTML = btn ? btn.innerHTML : '';
|
||
var origTitle = btn ? btn.getAttribute('title') : '';
|
||
var origClass = btn ? btn.className : '';
|
||
if(btn){
|
||
btn.disabled = true;
|
||
btn.classList.add('act-loading');
|
||
btn.classList.remove('act-analyze-start','act-analyze-stop','act-analyze-disabled');
|
||
btn.innerHTML = SVG_LOADING;
|
||
}
|
||
g_zoneTogglePending[zoneId] = true;
|
||
var actionLabel = enabled ? _t('zone_start','启动此布控') : _t('zone_stop','停止此布控');
|
||
showToast(actionLabel + '...', 'info', 1500);
|
||
Api.post('/control/openToggleZone', {id: zoneId, enabled: enabled}).then(function(res){
|
||
if(!res){
|
||
showToast(_t('msg_unknown_error','失败'),'error');
|
||
return;
|
||
}
|
||
var ok = res.code === 1000;
|
||
showToast(res.msg || (ok ? _t('msg_success','成功') : _t('msg_unknown_error','失败')), ok ? 'success' : 'error');
|
||
if(ok){
|
||
z.state = (res.data && res.data.state != null) ? res.data.state : enabled;
|
||
refreshZoneRowButtons();
|
||
updateZoneOverviewFromList(g_zoneCache);
|
||
refreshZonePageState();
|
||
}
|
||
}).catch(function(){
|
||
showToast(_t('msg_unknown_error','网络错误'),'error');
|
||
}).then(function(){
|
||
if(btn && btn.parentNode){
|
||
btn.disabled = false;
|
||
btn.classList.remove('act-loading');
|
||
btn.className = origClass;
|
||
btn.innerHTML = origHTML;
|
||
btn.setAttribute('title', origTitle);
|
||
}
|
||
delete g_zoneTogglePending[zoneId];
|
||
});
|
||
}
|
||
|
||
function refreshZonePageState(){
|
||
loadZonePageData();
|
||
}
|
||
|
||
function applyOverviewFromAnalysis(d){
|
||
if(!d){ document.getElementById('zoneOverview').style.display='none'; return; }
|
||
document.getElementById('zoneOverview').style.display='flex';
|
||
document.getElementById('ovTotalCam').textContent=d.total_streams||0;
|
||
var rc=d.running_count||0, ts=d.total_streams||0;
|
||
document.getElementById('ovRunning').textContent=rc+' / '+ts;
|
||
var fpsTxt = _t('zone_ov_fps_avg','平均 FPS')+': '+(d.analysis_fps_avg||0);
|
||
if(d.inference_degraded){
|
||
fpsTxt += ' · '+_t('zone_inference_degraded','推理异常');
|
||
}
|
||
document.getElementById('ovFps').textContent=fpsTxt;
|
||
document.getElementById('ovInstances').textContent=d.engine_instance_total||0;
|
||
var usage=d.engine_instances||[];
|
||
var reuseTxt='';
|
||
if(d.inference_shared){
|
||
var w = d.inference_workers_alive||0;
|
||
reuseTxt = _t('zone_ov_shared_infer','共享推理池 · {n} worker').replace('{n}', w);
|
||
if(usage.length>0){
|
||
reuseTxt += ' · ' + usage.map(function(u){return u.algorithm_name||('#'+u.algorithm_id);}).join('、');
|
||
}
|
||
} else if(usage.length===0){
|
||
reuseTxt=_t('zone_ov_no_instance','暂无布控实例');
|
||
} else {
|
||
var shared=usage.filter(function(u){return u.stream_count>1;});
|
||
if(shared.length===0){ reuseTxt=_t('zone_ov_each_stream','每路摄像头各实例化一份'); }
|
||
else{
|
||
reuseTxt=shared.map(function(u){return u.algorithm_name+' × '+u.stream_count+_t('zone_ov_streams','路');}).join(',');
|
||
}
|
||
}
|
||
document.getElementById('ovReuse').textContent=reuseTxt;
|
||
}
|
||
|
||
function fetchAnalysisStatus(){
|
||
Api.get('/analysis/openStatus', {lite: 1}).then(function(res){
|
||
if(res && res.code===1000 && res.data){
|
||
g_runningStreams = res.data.running_streams || [];
|
||
g_lastAnalysisData = res.data;
|
||
applyOverviewFromAnalysis(res.data);
|
||
refreshAnalysisUI();
|
||
refreshZoneRowButtons();
|
||
if(document.getElementById('engineModal').classList.contains('open')){
|
||
renderEngineModal(res.data);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function startAnalysisPolling(){
|
||
if(g_analysisPollTimer) clearInterval(g_analysisPollTimer);
|
||
g_analysisPollTimer = setInterval(fetchAnalysisStatus, 5000);
|
||
}
|
||
var g_streams = [];
|
||
var g_points = []; // 归一化坐标 [[nx,ny],...]
|
||
var g_drawFinished = false; // 多边形是否已完成(停止继续加点)
|
||
var g_imgNatural = {w:0,h:0};
|
||
var g_zonePlayer = null;
|
||
var g_liveSeq = 0;
|
||
var g_liveContext = { app: '', name: '' };
|
||
var g_runningStreams = [];
|
||
var g_zoneTogglePending = {};
|
||
var g_algorithms = []; // 可选业务算法列表
|
||
var g_analysisPollTimer = null;
|
||
var g_zoneCache = [];
|
||
var g_lastAnalysisData = null; // 最近一次分析状态数据(供引擎实例弹框使用)
|
||
var g_drawMode = 'rect'; // rect | polygon | line
|
||
var g_linePoints = []; // [[x,y],[x,y]] 归一化方向线 A→B
|
||
var g_rectPending = null; // 矩形模式第一个角点
|
||
|
||
function setDrawMode(mode){
|
||
mode = mode || 'rect';
|
||
g_drawMode = mode;
|
||
document.querySelectorAll('#drawModeBar .zm-tool[data-mode]').forEach(function(b){
|
||
b.classList.toggle('active', b.getAttribute('data-mode') === g_drawMode);
|
||
});
|
||
if(g_drawMode === 'rect'){
|
||
g_rectPending = null;
|
||
if(g_points.length >= 4) g_drawFinished = true;
|
||
}
|
||
updatePointStatus();
|
||
updateFinishBtn();
|
||
updateDrawGeometryUI();
|
||
redraw();
|
||
}
|
||
|
||
function isRegionDrawingComplete(){
|
||
if(!needsRegionDrawing()) return true;
|
||
if(g_points.length >= 4) return true;
|
||
return g_points.length >= 3 && g_drawFinished;
|
||
}
|
||
|
||
function isZoneRequired(){
|
||
return parseInt(document.getElementById('f_required').value, 10) === 1;
|
||
}
|
||
|
||
function getSelectedAlgos(){
|
||
var ids = getSelectedAlgoIds();
|
||
return (g_algorithms || []).filter(function(a){ return ids.indexOf(a.id) >= 0; });
|
||
}
|
||
|
||
function isLinePost(post){
|
||
return post === 'LINE_CROSS' || post === 'LINE_COUNT';
|
||
}
|
||
|
||
function isRegionPost(post){
|
||
return ['AREA','DWELL','DENSITY','DIRECTION'].indexOf(post) >= 0;
|
||
}
|
||
|
||
function needsLineGeometry(){
|
||
return getSelectedAlgos().some(function(a){ return isLinePost(a.post_process); });
|
||
}
|
||
|
||
function needsRegionGeometry(){
|
||
if(!isZoneRequired()) return false;
|
||
var algos = getSelectedAlgos();
|
||
if(!algos.length) return true;
|
||
if(algos.some(function(a){ return isRegionPost(a.post_process); })) return true;
|
||
if(needsLineGeometry()) return true;
|
||
return false;
|
||
}
|
||
|
||
function syncRequiredForAlgos(showTip){
|
||
var algos = getSelectedAlgos();
|
||
if(!algos.length) return;
|
||
var hasRegionPost = algos.some(function(a){ return isRegionPost(a.post_process); });
|
||
var lineOnly = algos.length > 0 && algos.every(function(a){ return isLinePost(a.post_process); });
|
||
if(hasRegionPost){
|
||
if(!isZoneRequired()){
|
||
setToggleValue('reqToggle', 1, 'f_required');
|
||
onRequiredChange();
|
||
if(showTip) showToast(_t('zone_auto_required_on','已自动开启「必需区域」'), 'info', 2500);
|
||
}
|
||
} else if(lineOnly){
|
||
if(isZoneRequired()){
|
||
setToggleValue('reqToggle', 0, 'f_required');
|
||
onRequiredChange();
|
||
if(showTip) showToast(_t('zone_auto_required_off_line','越线类算法仅需方向线,已自动关闭「必需区域」'), 'info', 3500);
|
||
}
|
||
}
|
||
}
|
||
|
||
function onRequiredChange(){
|
||
updateRequiredHint();
|
||
updateDrawGeometryUI();
|
||
updatePointStatus();
|
||
}
|
||
|
||
function updateRequiredHint(){
|
||
var el = document.getElementById('zoneRequiredHint');
|
||
if(!el) return;
|
||
if(isZoneRequired()){
|
||
el.textContent = _t('zone_required_on_hint','开启:目标须在布控区域内才触发后处理;越线类算法仍需绘制方向线');
|
||
el.style.color = '#64748b';
|
||
} else {
|
||
el.textContent = _t('zone_required_off_hint','关闭:不校验/不依赖布控区域,越线类算法仅需绘制方向线');
|
||
el.style.color = '#1d4ed8';
|
||
}
|
||
}
|
||
|
||
function validateZoneDrawing(){
|
||
var algoIds = getSelectedAlgoIds();
|
||
if(!algoIds.length){
|
||
showToast(_t('zone_algo_required','请至少选择一个业务算法'),'warning');
|
||
return false;
|
||
}
|
||
var algos = getSelectedAlgos();
|
||
var hasLine = needsLineGeometry();
|
||
var hasRegionPost = algos.some(function(a){ return isRegionPost(a.post_process); });
|
||
|
||
if(hasRegionPost && !isZoneRequired()){
|
||
showToast(_t('zone_region_post_need_required','区域类后处理需开启「必需区域」并绘制区域'),'warning');
|
||
return false;
|
||
}
|
||
if(hasLine && g_linePoints.length < 2){
|
||
showToast(_t('zone_line_required','越线类算法需绘制方向线 A→B'),'warning');
|
||
setDrawMode('line');
|
||
return false;
|
||
}
|
||
if(needsRegionDrawing() && g_points.length < 3){
|
||
if(hasLine && !hasRegionPost){
|
||
showToast(_t('zone_line_only_need_region_or_off',
|
||
'越线类算法:请绘制 ▭/⬠ 区域,或关闭「必需区域」后仅保留方向线'), 'warning', 4500);
|
||
} else {
|
||
showToast(_t('zone_draw_min_points','至少绘制 3 个点(矩形或多边形)'),'warning');
|
||
}
|
||
setDrawMode(g_points.length === 4 ? 'rect' : (g_drawMode === 'line' ? 'rect' : g_drawMode));
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function parseLinePoint(raw){
|
||
if(!raw) return null;
|
||
if(Array.isArray(raw) && raw.length >= 2) return [parseFloat(raw[0]), parseFloat(raw[1])];
|
||
try{
|
||
var arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||
if(Array.isArray(arr) && arr.length >= 2) return [parseFloat(arr[0]), parseFloat(arr[1])];
|
||
}catch(e){}
|
||
return null;
|
||
}
|
||
|
||
function loadLinePointsFromZone(z){
|
||
g_linePoints = [];
|
||
var a = parseLinePoint(z && z.line_a);
|
||
var b = parseLinePoint(z && z.line_b);
|
||
if(a) g_linePoints.push(a);
|
||
if(b) g_linePoints.push(b);
|
||
updateLineStatus();
|
||
}
|
||
|
||
function updateLineStatus(){
|
||
var el = document.getElementById('lineStatus');
|
||
if(!el) return;
|
||
if(g_linePoints.length >= 2){
|
||
el.textContent = _t('zone_line_ready','方向线 A→B 已就绪');
|
||
el.style.color = '#065f46';
|
||
} else if(g_drawMode === 'line'){
|
||
if(g_linePoints.length === 1){
|
||
el.textContent = _t('zone_line_need_b','方向线:请点击终点 B');
|
||
el.style.color = '#d97706';
|
||
} else {
|
||
el.textContent = _t('zone_line_need_ab','方向线:点击起点 A,再点击终点 B');
|
||
el.style.color = '#64748b';
|
||
}
|
||
} else if(needsLineGeometry() && g_linePoints.length < 2){
|
||
el.textContent = _t('zone_line_required_hint','越线算法需绘制 → 方向线');
|
||
el.style.color = '#d97706';
|
||
} else if(g_linePoints.length === 1){
|
||
el.textContent = _t('zone_line_need_b','方向线:请点击终点 B');
|
||
el.style.color = '#d97706';
|
||
} else {
|
||
el.textContent = '';
|
||
}
|
||
}
|
||
|
||
function needsRegionDrawing(){
|
||
return needsRegionGeometry();
|
||
}
|
||
|
||
function updateDrawGeometryUI(){
|
||
updateLineStatus();
|
||
updateRequiredHint();
|
||
var bar = document.getElementById('drawToolRow');
|
||
var modalOpen = document.getElementById('zoneModal') && document.getElementById('zoneModal').classList.contains('show');
|
||
if(bar) bar.style.display = modalOpen ? 'flex' : 'none';
|
||
var hintText = document.getElementById('drawHintText');
|
||
if(hintText){
|
||
hintText.textContent = _t('zone_draw_tip_three',
|
||
'三种图形可叠加:▭ 矩形 · ⬠ 多边形 · → 方向线 A→B(箭头为正向)· 右键撤销当前工具');
|
||
}
|
||
}
|
||
|
||
function loadAlgorithmsForZone(cb){
|
||
Api.get('/algorithm/openIndex').then(function(res){
|
||
if(res && res.code===1000){
|
||
g_algorithms = (res.data||[]).filter(function(a){return a.state===1;});
|
||
}
|
||
if(cb) cb();
|
||
});
|
||
}
|
||
|
||
function renderAlgoList(selectedIds){
|
||
var box = document.getElementById('algoList');
|
||
if(!box) return;
|
||
if(!g_algorithms.length){
|
||
box.innerHTML = '<div class="zm-algo-empty"><span class="ic">⊞</span>'+_t('zone_no_algorithm','暂无可用业务算法,请先到业务算法添加')+'</div>';
|
||
updateAlgoCount(selectedIds||[]);
|
||
updateDrawGeometryUI();
|
||
return;
|
||
}
|
||
var sel = selectedIds || [];
|
||
var typeMap = {yolo5:'YOLOv5',yolo8:'YOLOv8',yolo11:'YOLOv11',yolo26:'YOLO26'};
|
||
var engMap = {yolo_pytorch:'Yolo-PyTorch',onnxruntime:'OnnxRuntime',openvino:'OpenVINO'};
|
||
var html = '';
|
||
g_algorithms.forEach(function(a){
|
||
var isSel = sel.indexOf(a.id)>=0;
|
||
var flowDisp = a.flow_type_name || ('流程'+a.flow_type);
|
||
var postDisp = a.post_process_name || a.post_process || '';
|
||
html += '<div class="zm-algo-card'+(isSel?' sel':'')+'" data-id="'+a.id+'" onclick="algoToggle(this)">'
|
||
+'<div class="check"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></div>'
|
||
+'<div class="nm">'+escapeHtml(a.name)+'</div>'
|
||
+'<div class="meta">'
|
||
+'<span class="tag-mini">'+escapeHtml(flowDisp)+'</span>'
|
||
+'<span class="tag-mini task">'+escapeHtml(postDisp)+'</span>'
|
||
+'</div>'
|
||
+'</div>';
|
||
});
|
||
box.innerHTML = html;
|
||
updateAlgoCount(sel);
|
||
updateDrawGeometryUI();
|
||
}
|
||
|
||
function algoToggle(el){
|
||
if(!el) return;
|
||
el.classList.toggle('sel');
|
||
updateAlgoCount(getSelectedAlgoIds());
|
||
var algos = getSelectedAlgos();
|
||
if(algos.some(function(a){ return isRegionPost(a.post_process); }) && !isZoneRequired()){
|
||
showToast(_t('zone_region_post_need_required','区域类后处理需开启「必需区域」并绘制区域'), 'warning', 4000);
|
||
}
|
||
syncRequiredForAlgos(true);
|
||
updateDrawGeometryUI();
|
||
}
|
||
|
||
function algoSelectAll(all){
|
||
var box = document.getElementById('algoList');
|
||
if(!box) return;
|
||
box.querySelectorAll('.zm-algo-card').forEach(function(c){
|
||
if(all) c.classList.add('sel'); else c.classList.remove('sel');
|
||
});
|
||
updateAlgoCount(getSelectedAlgoIds());
|
||
syncRequiredForAlgos(true);
|
||
updateDrawGeometryUI();
|
||
}
|
||
|
||
function updateAlgoCount(ids){
|
||
var el = document.getElementById('algoCount');
|
||
if(!el) return;
|
||
var n = (ids||getSelectedAlgoIds()).length;
|
||
el.innerHTML = _t('zone_algorithms_selected','已选 <b>{n}</b> 个业务算法').replace('{n}','<b>'+n+'</b>').replace('<b><b>','<b>').replace('</b></b>','</b>');
|
||
}
|
||
|
||
function getSelectedAlgoIds(){
|
||
var ids = [];
|
||
var box = document.getElementById('algoList');
|
||
if(!box) return ids;
|
||
box.querySelectorAll('.zm-algo-card.sel').forEach(function(c){
|
||
var v = parseInt(c.getAttribute('data-id'),10);
|
||
if(!isNaN(v)) ids.push(v);
|
||
});
|
||
return ids;
|
||
}
|
||
|
||
function formatIntervalDisplay(v){
|
||
var n = parseFloat(v);
|
||
if(isNaN(n) || n <= 0) return '1';
|
||
if(Math.abs(n - Math.round(n)) < 0.0001) return String(Math.round(n));
|
||
return n.toFixed(2).replace(/\.?0+$/,'');
|
||
}
|
||
|
||
function formatDetectRate(interval, frames){
|
||
interval = Math.max(0.1, parseFloat(interval)||1);
|
||
frames = Math.max(1, parseInt(frames,10)||1);
|
||
var fps = frames / interval;
|
||
var fpsTxt = fps >= 10 ? fps.toFixed(1) : (fps >= 1 ? fps.toFixed(2) : fps.toFixed(3));
|
||
return _t('zone_detect_every','每') + ' ' + formatIntervalDisplay(interval) + ' ' + _t('zone_detect_sec','秒')
|
||
+ ' ' + _t('zone_detect_analyze','分析') + ' ' + frames + ' ' + _t('zone_detect_frame_unit','帧')
|
||
+ '(' + _t('zone_detect_about','约') + ' ' + fpsTxt + ' fps)';
|
||
}
|
||
|
||
function updateDetectPreview(){
|
||
var intervalEl = document.getElementById('f_detect_interval');
|
||
var framesEl = document.getElementById('f_detect_frames');
|
||
var interval = parseFloat(intervalEl.value);
|
||
var frames = parseInt(framesEl.value, 10);
|
||
if(isNaN(interval) || interval < 0.1) interval = 0.1;
|
||
if(interval > 86400) interval = 86400;
|
||
if(isNaN(frames) || frames < 1) frames = 1;
|
||
if(frames > 999) frames = 999;
|
||
intervalEl.value = formatIntervalDisplay(interval);
|
||
framesEl.value = frames;
|
||
document.getElementById('detectPreview').textContent = formatDetectRate(interval, frames);
|
||
}
|
||
|
||
function getDetectRatePayload(){
|
||
var interval = parseFloat(document.getElementById('f_detect_interval').value);
|
||
var frames = parseInt(document.getElementById('f_detect_frames').value, 10);
|
||
if(isNaN(interval) || interval < 0.1) interval = 0.1;
|
||
if(isNaN(frames) || frames < 1) frames = 1;
|
||
return {
|
||
detect_interval_sec: Math.min(86400, interval),
|
||
detect_frames: Math.min(999, frames)
|
||
};
|
||
}
|
||
|
||
function loadStreams(cb){
|
||
if(g_streams.length){
|
||
if(cb) cb();
|
||
return;
|
||
}
|
||
Api.get('/control/openPageData').then(function(res){
|
||
if(res && res.code===1000 && res.data){
|
||
g_streams = res.data.streams || [];
|
||
}
|
||
if(cb) cb();
|
||
});
|
||
}
|
||
|
||
function sortByIdDesc(list){
|
||
return (list || []).slice().sort(function(a, b){ return (parseInt(b.id, 10) || 0) - (parseInt(a.id, 10) || 0); });
|
||
}
|
||
|
||
function renderZoneTable(zones){
|
||
var body=document.getElementById('zoneBody'); body.innerHTML='';
|
||
zones = sortByIdDesc(zones);
|
||
if(!zones || !zones.length){ renderEmpty(true); updateZoneOverviewFromList([]); return; }
|
||
renderEmpty(false);
|
||
zones.forEach(function(z){
|
||
var tr=document.createElement('tr');
|
||
tr.setAttribute('data-zone-id', z.id);
|
||
tr.setAttribute('data-stream-id', z.stream_id);
|
||
var algos=z.algorithms||[];
|
||
var algoHtml='';
|
||
if(algos.length===0){ algoHtml='<span style="color:#9ca3af;font-size:11px;">'+_t('zone_no_biz_algorithm','未绑定业务算法')+'</span>'; }
|
||
else{
|
||
algos.slice(0,3).forEach(function(a){ algoHtml+='<span class="algo-chip">'+escapeHtml(a.name)+'</span>'; });
|
||
if(algos.length>3){ algoHtml+='<span class="algo-chip more">+'+(algos.length-3)+'</span>'; }
|
||
}
|
||
var nm='<div style="display:flex;align-items:center;gap:6px;"><span style="width:9px;height:9px;border-radius:50%;background:'+escapeHtml(z.color||'#169F85')+';flex-shrink:0;"></span>'+escapeHtml(z.name)+'</div>';
|
||
var actions = '<div class="zone-actions">'
|
||
+'<span class="zone-run-btn">'+buildZoneRunBtn(z)+'</span>'
|
||
+'<button type="button" class="act-btn act-preview" onclick="openYoloPreview('+z.id+')" title="实时 YOLO 检测">'+SVG_PREVIEW+'</button>'
|
||
+'<button type="button" class="act-btn" onclick="editZone('+z.id+')" title="'+escapeHtml(_t('btn_edit','编辑'))+'">'+SVG_EDIT+'</button>'
|
||
+'<button type="button" class="act-btn act-danger" onclick="delZone('+z.id+')" title="'+escapeHtml(_t('btn_del','删除'))+'">'+SVG_DEL+'</button>'
|
||
+'</div>';
|
||
tr.innerHTML='<td>'+z.id+'</td><td>'+escapeHtml(z.stream_name||'#'+z.stream_id)+'</td><td>'+nm+'</td>'
|
||
+'<td>'+algoHtml+'</td>'
|
||
+'<td><span style="font-size:11px;color:#475569;">'+escapeHtml(formatDetectRate(z.detect_interval_sec||1, z.detect_frames||1))+'</span></td>'
|
||
+'<td>'+(z.is_required===1?'<span class="tag tag-req">'+_t('zone_yes','是')+'</span>':'<span class="tag tag-off">'+_t('zone_no','否')+'</span>')+'</td>'
|
||
+'<td>'+z.loiter_threshold+'</td>'
|
||
+'<td class="zone-state-cell">'+buildZoneStateTag(z)+'</td>'
|
||
+'<td>'+actions+'</td>';
|
||
body.appendChild(tr);
|
||
});
|
||
updateZoneOverviewFromList(zones);
|
||
}
|
||
|
||
function loadZonePageData(){
|
||
var sid = document.getElementById('filterStream').value;
|
||
var params = sid ? {stream_id: sid} : {};
|
||
Api.get('/control/openPageData', params).then(function(res){
|
||
if(!res||res.code!==1000) return;
|
||
var d = res.data || {};
|
||
g_streams = d.streams || g_streams;
|
||
g_zoneCache = d.zones || [];
|
||
fillStreamSelect(document.getElementById('filterStream'), true);
|
||
if(d.analysis){
|
||
g_runningStreams = d.analysis.running_streams || [];
|
||
applyOverviewFromAnalysis(d.analysis);
|
||
refreshAnalysisUI();
|
||
}
|
||
renderZoneTable(g_zoneCache);
|
||
var el = document.getElementById('ovAlarms');
|
||
if(el && d.recent_alarm_count != null) el.textContent = d.recent_alarm_count;
|
||
});
|
||
}
|
||
|
||
function loadZones(){
|
||
loadZonePageData();
|
||
}
|
||
|
||
function fillStreamSelect(sel, includeAll){
|
||
var prev = sel.value;
|
||
sel.innerHTML = '';
|
||
if(includeAll){ var o=document.createElement('option'); o.value=''; o.textContent=_t('zone_all_cameras','全部摄像头'); sel.appendChild(o); }
|
||
g_streams.forEach(function(s){
|
||
var o=document.createElement('option');
|
||
o.value = (s.id != null && s.id !== '') ? String(s.id) : '';
|
||
o.setAttribute('data-app', s.app||'');
|
||
o.setAttribute('data-name', s.name||'');
|
||
if(!o.value) o.disabled = true;
|
||
o.textContent=s.nickname||s.name||('#'+s.id);
|
||
sel.appendChild(o);
|
||
});
|
||
if(prev && sel.querySelector('option[value="'+prev+'"]')) sel.value = prev;
|
||
}
|
||
|
||
function renderEmpty(show){
|
||
document.getElementById('zoneEmpty').style.display = show?'flex':'none';
|
||
document.getElementById('zoneTable').style.display = show?'none':'';
|
||
}
|
||
|
||
function updateZoneOverviewFromList(zones){
|
||
var total = zones.length;
|
||
var running = zones.filter(function(z){ return zoneIsRunning(z); }).length;
|
||
var el = document.getElementById('ovZones');
|
||
if(el) el.textContent = total + ' / ' + running;
|
||
}
|
||
|
||
function editZone(id){
|
||
var z=g_zoneCache.find(function(x){return x.id===id;});
|
||
if(!z){
|
||
showToast(_t('msg_unknown_error','未找到布控'),'error');
|
||
return;
|
||
}
|
||
document.getElementById('zoneModalTitle').textContent=_t('btn_edit_zone','编辑布控');
|
||
document.getElementById('zoneModalSub').textContent=_t('zone_edit_sub','调整区域范围或属性');
|
||
document.getElementById('f_id').value=z.id;
|
||
fillStreamSelect(document.getElementById('f_stream'),false);
|
||
document.getElementById('f_stream').value=z.stream_id;
|
||
document.getElementById('f_name').value=z.name;
|
||
document.getElementById('f_loiter').value=z.loiter_threshold;
|
||
document.getElementById('f_detect_interval').value=z.detect_interval_sec||1;
|
||
document.getElementById('f_detect_frames').value=z.detect_frames||1;
|
||
document.getElementById('f_alarm_repeat').value=z.alarm_repeat_sec == null ? 30 : z.alarm_repeat_sec;
|
||
updateDetectPreview();
|
||
document.getElementById('f_color').value=z.color;
|
||
syncColorSwatches(z.color);
|
||
setToggleValue('reqToggle', z.is_required, 'f_required');
|
||
renderAlgoList(z.algorithm_ids || []);
|
||
try{ g_points = JSON.parse(z.coordinates||'[]'); }catch(e){ g_points=[]; }
|
||
g_drawFinished = g_points.length >= 3;
|
||
loadLinePointsFromZone(z);
|
||
setDrawMode(g_points.length === 4 ? 'rect' : 'polygon');
|
||
onRequiredChange();
|
||
openDrawWithStream(z.stream_id, true);
|
||
document.getElementById('zoneModal').classList.add('show');
|
||
updateDrawGeometryUI();
|
||
}
|
||
|
||
function openZoneModal(){
|
||
document.getElementById('zoneModalTitle').textContent=_t('btn_add_zone','添加布控');
|
||
document.getElementById('zoneModalSub').textContent=_t('zone_draw_tip_three','三种图形可叠加:▭ 矩形 · ⬠ 多边形 · → 方向线 A→B(箭头为正向)· 右键撤销当前工具');
|
||
document.getElementById('f_id').value='';
|
||
fillStreamSelect(document.getElementById('f_stream'),false);
|
||
document.getElementById('f_name').value='';
|
||
document.getElementById('f_loiter').value=0;
|
||
document.getElementById('f_detect_interval').value=1;
|
||
document.getElementById('f_detect_frames').value=1;
|
||
document.getElementById('f_alarm_repeat').value=30;
|
||
updateDetectPreview();
|
||
document.getElementById('f_color').value='#169F85';
|
||
syncColorSwatches('#169F85');
|
||
setToggleValue('reqToggle', 1, 'f_required');
|
||
renderAlgoList([]);
|
||
g_points=[];
|
||
g_linePoints=[];
|
||
g_rectPending=null;
|
||
g_drawFinished=false;
|
||
setDrawMode('rect');
|
||
resetDraw();
|
||
onRequiredChange();
|
||
var sel=document.getElementById('f_stream');
|
||
if(sel.options.length>0){ sel.selectedIndex=0; onStreamChange(); }
|
||
document.getElementById('zoneModal').classList.add('show');
|
||
updateDrawGeometryUI();
|
||
}
|
||
function closeZoneModal(){
|
||
cancelLivePreview();
|
||
document.getElementById('zoneModal').classList.remove('show');
|
||
}
|
||
|
||
function pickColor(el){
|
||
var c=el.getAttribute('data-color');
|
||
document.getElementById('f_color').value=c;
|
||
syncColorSwatches(c);
|
||
redraw();
|
||
}
|
||
function onColorInput(){
|
||
syncColorSwatches(document.getElementById('f_color').value);
|
||
redraw();
|
||
}
|
||
function syncColorSwatches(color){
|
||
document.querySelectorAll('.zm-swatch').forEach(function(s){
|
||
s.classList.toggle('active', s.getAttribute('data-color')===color);
|
||
});
|
||
}
|
||
function setToggle(groupId, val, hiddenId){
|
||
document.getElementById(hiddenId).value=String(val);
|
||
var g=document.getElementById(groupId);
|
||
if(!g) return;
|
||
g.querySelectorAll('.zm-toggle').forEach(function(b){
|
||
b.classList.toggle('active', parseInt(b.getAttribute('data-val'),10)===val);
|
||
});
|
||
}
|
||
function setToggleValue(groupId, val, hiddenId){
|
||
setToggle(groupId, parseInt(val,10)||0, hiddenId);
|
||
}
|
||
|
||
document.addEventListener('keydown', function(e){
|
||
if(e.key==='Escape' && document.getElementById('zoneModal').classList.contains('show')) closeZoneModal();
|
||
if(e.key==='Escape' && document.getElementById('yoloPreviewModal').classList.contains('open')) closeYoloPreview();
|
||
});
|
||
|
||
/* ---------- 摄像头画面绘制 ---------- */
|
||
function resetDraw(){
|
||
cancelLivePreview();
|
||
var wrap=document.getElementById('drawWrap');
|
||
wrap.className='draw-wrap no-snap';
|
||
wrap.innerHTML='<span class="zm-ph-icon">▣</span><span id="drawPlaceholder">'+_t('zone_select_camera_first','正在加载摄像头画面…')+'</span>';
|
||
g_points=[];
|
||
g_linePoints=[];
|
||
g_rectPending=null;
|
||
g_drawFinished=false;
|
||
g_imgNatural={w:0,h:0};
|
||
updatePointStatus();
|
||
updateFinishBtn();
|
||
updateLineStatus();
|
||
}
|
||
|
||
function destroyZonePlayer(){
|
||
if(g_zonePlayer){
|
||
try { g_zonePlayer.destroy(); } catch(e){}
|
||
g_zonePlayer = null;
|
||
}
|
||
}
|
||
|
||
function cancelLivePreview(){
|
||
g_liveSeq++;
|
||
destroyZonePlayer();
|
||
}
|
||
|
||
function ensureDrawCanvas(parent){
|
||
var canvas=document.getElementById('drawCanvas');
|
||
if(!canvas){
|
||
canvas=document.createElement('canvas');
|
||
canvas.id='drawCanvas';
|
||
parent.appendChild(canvas);
|
||
canvas.addEventListener('click', onCanvasClick);
|
||
canvas.addEventListener('dblclick', onCanvasDblClick);
|
||
canvas.addEventListener('mousemove', onCanvasMove);
|
||
canvas.addEventListener('contextmenu', function(e){ e.preventDefault(); undoPoint(); });
|
||
}
|
||
return canvas;
|
||
}
|
||
|
||
function showLivePreviewError(msg){
|
||
var wrap=document.getElementById('drawWrap');
|
||
if(!wrap) return;
|
||
cancelLivePreview();
|
||
wrap.className='draw-wrap no-snap';
|
||
wrap.innerHTML='<span class="zm-ph-icon">⚠</span><span>'+escapeHtml(msg)+'</span>'
|
||
+'<button type="button" class="btn" style="margin-top:10px;" onclick="retryLivePreview()">'+_t('zone_refresh_live','重新连接视频')+'</button>';
|
||
}
|
||
|
||
function loadLivePreview(app, name){
|
||
if(!app||!name){ resetDraw(); return; }
|
||
var seq = ++g_liveSeq;
|
||
g_liveContext = { app: app, name: name };
|
||
destroyZonePlayer();
|
||
var wrap=document.getElementById('drawWrap');
|
||
wrap.className='draw-wrap';
|
||
wrap.innerHTML=
|
||
'<div id="zoneVideoWrap" class="zone-video-wrap">'
|
||
+ '<div id="zoneVideoPlayer"></div>'
|
||
+ '<div id="zonePlayerOverlay" class="zm-player-overlay"><span class="zm-ph-icon">⟳</span><span>'+_t('zone_live_connecting','正在连接视频流...')+'</span></div>'
|
||
+ '</div>';
|
||
|
||
Api.get('/stream/openPlayer', { app: app, name: name }).then(function(res){
|
||
if(seq !== g_liveSeq) return;
|
||
if(!res || res.code !== 1000 || !res.info || !res.info.stream){
|
||
showLivePreviewError(_t('zone_live_failed','无法连接视频流'));
|
||
return;
|
||
}
|
||
var s = res.info.stream;
|
||
if(parseInt(s.is_online, 10) !== 1){
|
||
showLivePreviewError(_t('zone_live_offline','视频流离线,请确认摄像头在线'));
|
||
return;
|
||
}
|
||
var playUrl = s.wsMp4Url || s.httpMp4Url || s.wsFlvUrl || s.httpFlvUrl;
|
||
if(!playUrl){
|
||
showLivePreviewError(_t('zone_live_no_url','无可用播放地址'));
|
||
return;
|
||
}
|
||
var videoWrap = document.getElementById('zoneVideoWrap');
|
||
if(!videoWrap){ return; }
|
||
ensureDrawCanvas(videoWrap);
|
||
var overlay = document.getElementById('zonePlayerOverlay');
|
||
try{
|
||
g_zonePlayer = new EasyPlayerPro({
|
||
container: document.getElementById('zoneVideoPlayer'),
|
||
videoBuffer: 0.1,
|
||
decoder: '/static/lib/easyPlayer/js/decoder-pro.js',
|
||
isResize: true,
|
||
loadingText: _t('zone_live_connecting','正在连接视频流...'),
|
||
debug: false,
|
||
useMSE: true,
|
||
useWCS: true,
|
||
hasAudio: false,
|
||
isNotMute: true,
|
||
showBandwidth: false,
|
||
operateBtns: { fullscreen: false, screenshot: false, play: false, audio: false, ptz: false },
|
||
timeout: 10,
|
||
forceNoOffscreen: true,
|
||
heartTimeout: 10
|
||
});
|
||
g_zonePlayer.play(playUrl);
|
||
if(overlay) overlay.style.display = 'none';
|
||
setTimeout(function(){
|
||
if(seq !== g_liveSeq) return;
|
||
resizeCanvas();
|
||
redraw();
|
||
updatePointStatus();
|
||
updateFinishBtn();
|
||
}, 400);
|
||
}catch(e){
|
||
showLivePreviewError(_t('zone_live_failed','无法连接视频流'));
|
||
}
|
||
}).catch(function(){
|
||
if(seq !== g_liveSeq) return;
|
||
showLivePreviewError(_t('zone_live_failed','无法连接视频流'));
|
||
});
|
||
}
|
||
|
||
function retryLivePreview(){
|
||
var ctx = g_liveContext || {};
|
||
if(ctx.app && ctx.name) loadLivePreview(ctx.app, ctx.name);
|
||
else onStreamChange();
|
||
}
|
||
|
||
function refreshLivePreview(){
|
||
var sel=document.getElementById('f_stream');
|
||
var opt=sel.options[sel.selectedIndex];
|
||
if(!opt||!opt.value){ showToast(_t('zone_select_camera_first','请先选择摄像头'),'warning'); return; }
|
||
var keep=g_points.slice();
|
||
var keepLine=g_linePoints.slice();
|
||
var app=opt.getAttribute('data-app')||'';
|
||
var name=opt.getAttribute('data-name')||'';
|
||
if(app&&name) loadLivePreview(app,name);
|
||
else { var s=findStream(opt.value); if(s) loadLivePreview(s.app,s.name); }
|
||
g_points=keep;
|
||
g_linePoints=keepLine;
|
||
g_drawFinished = g_points.length >= 4 || g_points.length >= 3;
|
||
setTimeout(function(){ resizeCanvas(); redraw(); }, 500);
|
||
}
|
||
|
||
function onStreamChange(){
|
||
var sel=document.getElementById('f_stream');
|
||
var opt=sel.options[sel.selectedIndex];
|
||
if(!opt||!opt.value){ resetDraw(); return; }
|
||
g_points=[];
|
||
g_linePoints=[];
|
||
g_rectPending=null;
|
||
g_drawFinished=false;
|
||
var app=opt.getAttribute('data-app')||'';
|
||
var name=opt.getAttribute('data-name')||'';
|
||
if(app&&name){ loadLivePreview(app,name); return; }
|
||
var s=findStream(opt.value);
|
||
if(s) loadLivePreview(s.app,s.name); else resetDraw();
|
||
}
|
||
|
||
function findStream(id){
|
||
return g_streams.find(function(s){return String(s.id)===String(id);});
|
||
}
|
||
|
||
function openDrawWithStream(sid, keepPoints){
|
||
var s=findStream(sid);
|
||
if(!s){ resetDraw(); return; }
|
||
if(!keepPoints) g_points=[];
|
||
loadLivePreview(s.app, s.name);
|
||
}
|
||
|
||
function resizeCanvas(){
|
||
var canvas=document.getElementById('drawCanvas');
|
||
var wrap=document.getElementById('zoneVideoWrap');
|
||
if(!canvas||!wrap) return;
|
||
var rect=wrap.getBoundingClientRect();
|
||
if(rect.width < 1 || rect.height < 1) return;
|
||
canvas.width=Math.round(rect.width);
|
||
canvas.height=Math.round(rect.height);
|
||
}
|
||
|
||
window.addEventListener('resize', function(){ if(document.getElementById('drawCanvas')){ resizeCanvas(); redraw(); } });
|
||
|
||
function onCanvasClick(e){
|
||
var canvas=document.getElementById('drawCanvas');
|
||
if(!canvas) return;
|
||
var rect=canvas.getBoundingClientRect();
|
||
var px=(e.clientX-rect.left)/rect.width;
|
||
var py=(e.clientY-rect.top)/rect.height;
|
||
px = Math.max(0, Math.min(1, px));
|
||
py = Math.max(0, Math.min(1, py));
|
||
|
||
if(g_drawMode === 'line'){
|
||
if(g_linePoints.length >= 2) g_linePoints = [];
|
||
g_linePoints.push([px, py]);
|
||
redraw();
|
||
updateLineStatus();
|
||
updatePointStatus();
|
||
updateFinishBtn();
|
||
return;
|
||
}
|
||
|
||
if(g_drawFinished && g_drawMode !== 'rect') return;
|
||
|
||
if(g_drawMode === 'rect'){
|
||
if(!g_rectPending){
|
||
g_rectPending = [px, py];
|
||
redraw(e);
|
||
updatePointStatus();
|
||
return;
|
||
}
|
||
var x1 = g_rectPending[0], y1 = g_rectPending[1];
|
||
var x2 = px, y2 = py;
|
||
g_points = [
|
||
[Math.min(x1,x2), Math.min(y1,y2)],
|
||
[Math.max(x1,x2), Math.min(y1,y2)],
|
||
[Math.max(x1,x2), Math.max(y1,y2)],
|
||
[Math.min(x1,x2), Math.max(y1,y2)]
|
||
];
|
||
g_rectPending = null;
|
||
g_drawFinished = true;
|
||
redraw();
|
||
updatePointStatus();
|
||
updateFinishBtn();
|
||
updateLineStatus();
|
||
return;
|
||
}
|
||
|
||
// polygon
|
||
if(g_points.length>=3){
|
||
var fx=g_points[0][0]*canvas.width, fy=g_points[0][1]*canvas.height;
|
||
var cx=e.clientX-rect.left, cy=e.clientY-rect.top;
|
||
if(Math.hypot(cx-fx, cy-fy)<=14){ finishDraw(); return; }
|
||
}
|
||
g_points.push([px,py]);
|
||
if(g_points.length >= 3) g_drawFinished = false;
|
||
redraw(e);
|
||
updatePointStatus();
|
||
}
|
||
|
||
function onCanvasDblClick(e){
|
||
e.preventDefault();
|
||
if(g_points.length>=3) finishDraw();
|
||
}
|
||
|
||
function onCanvasMove(e){
|
||
var canvas=document.getElementById('drawCanvas');
|
||
if(!canvas) return;
|
||
redraw(e);
|
||
}
|
||
|
||
function undoPoint(){
|
||
if(g_drawMode === 'line'){
|
||
g_linePoints.pop();
|
||
redraw();
|
||
updateLineStatus();
|
||
updatePointStatus();
|
||
updateFinishBtn();
|
||
return;
|
||
}
|
||
if(g_drawFinished && g_drawMode !== 'rect') g_drawFinished=false;
|
||
if(g_drawMode === 'rect' && g_rectPending){
|
||
g_rectPending = null;
|
||
} else {
|
||
g_points.pop();
|
||
}
|
||
redraw();
|
||
updatePointStatus();
|
||
updateFinishBtn();
|
||
}
|
||
function clearPoints(){
|
||
g_points=[];
|
||
g_linePoints=[];
|
||
g_rectPending=null;
|
||
g_drawFinished=false;
|
||
redraw();
|
||
updatePointStatus();
|
||
updateFinishBtn();
|
||
updateLineStatus();
|
||
}
|
||
|
||
function clearCurrentDraw(){
|
||
if(g_drawMode === 'line'){
|
||
g_linePoints = [];
|
||
g_drawFinished = false;
|
||
} else {
|
||
g_points = [];
|
||
g_rectPending = null;
|
||
g_drawFinished = false;
|
||
}
|
||
redraw();
|
||
updatePointStatus();
|
||
updateFinishBtn();
|
||
updateLineStatus();
|
||
}
|
||
|
||
function finishDraw(){
|
||
if(g_points.length<3){
|
||
showToast(_t('zone_draw_min_points','至少绘制 3 个点'),'warning');
|
||
return;
|
||
}
|
||
g_drawFinished=true;
|
||
redraw();
|
||
updatePointStatus();
|
||
updateFinishBtn();
|
||
updateLineStatus();
|
||
if(needsLineGeometry() && g_linePoints.length < 2){
|
||
showToast(_t('zone_line_step2','区域已完成,请选 → 绘制方向线 A→B'), 'info', 3500);
|
||
}
|
||
}
|
||
|
||
function updateFinishBtn(){
|
||
var btn=document.getElementById('btnFinishDraw');
|
||
if(!btn) return;
|
||
var show = g_drawMode !== 'line';
|
||
btn.style.display = show ? '' : 'none';
|
||
if(show) btn.classList.toggle('active', g_drawFinished);
|
||
}
|
||
|
||
function regionDrawSummary(){
|
||
if(g_points.length >= 4 && g_drawMode === 'rect') return '4 ' + _t('zone_points_unit','个点') + ' · ' + _t('zone_draw_rect','矩形');
|
||
if(g_points.length >= 3) return g_points.length + ' ' + _t('zone_points_unit','个点') + ' · ' + _t('zone_draw_polygon','多边形');
|
||
if(g_rectPending) return '1 ' + _t('zone_points_unit','个点') + ' · ' + _t('zone_rect_hint','对角两点绘制矩形');
|
||
return g_points.length + ' ' + _t('zone_points_unit','个点');
|
||
}
|
||
|
||
function updatePointStatus(){
|
||
var el=document.getElementById('pointStatus');
|
||
if(!el) return;
|
||
var parts = [];
|
||
var needRegion = needsRegionDrawing();
|
||
if(needRegion || g_points.length > 0 || g_rectPending){
|
||
parts.push(_t('zone_draw_mode_region','区域') + ': ' + regionDrawSummary());
|
||
}
|
||
if(g_drawMode === 'line' || g_linePoints.length > 0 || needsLineGeometry()){
|
||
parts.push(_t('zone_draw_mode_line','方向线') + ': ' + g_linePoints.length + '/2');
|
||
}
|
||
if(g_linePoints.length >= 2) parts.push(_t('zone_line_ready_short','方向线 OK'));
|
||
else if(g_linePoints.length === 1) parts.push(_t('zone_line_a_only','方向线 A'));
|
||
el.textContent = parts.join(' · ');
|
||
var regionOk = isRegionDrawingComplete();
|
||
var lineOk = !needsLineGeometry() || g_linePoints.length >= 2;
|
||
el.classList.toggle('ready', regionOk && lineOk);
|
||
updateLineStatus();
|
||
}
|
||
|
||
function redraw(mouseEv){
|
||
var canvas=document.getElementById('drawCanvas');
|
||
if(!canvas) return;
|
||
var ctx=canvas.getContext('2d');
|
||
ctx.clearRect(0,0,canvas.width,canvas.height);
|
||
var color=document.getElementById('f_color').value||'#169F85';
|
||
|
||
// 区域多边形/矩形
|
||
if(g_points.length>=1){
|
||
ctx.strokeStyle=color; ctx.fillStyle=hexToRgba(color,0.25); ctx.lineWidth=2;
|
||
ctx.beginPath();
|
||
g_points.forEach(function(p,i){
|
||
var x=p[0]*canvas.width, y=p[1]*canvas.height;
|
||
if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
|
||
});
|
||
if(g_points.length>=3){
|
||
ctx.closePath(); ctx.fill(); ctx.stroke();
|
||
} else if(g_points.length>=2){
|
||
ctx.stroke();
|
||
}
|
||
g_points.forEach(function(p,i){
|
||
var x=p[0]*canvas.width, y=p[1]*canvas.height;
|
||
if(i===0 && g_drawMode==='polygon' && g_points.length>=3 && !g_drawFinished){
|
||
ctx.beginPath(); ctx.arc(x,y,12,0,Math.PI*2);
|
||
ctx.strokeStyle=hexToRgba(color,0.45); ctx.lineWidth=2; ctx.stroke();
|
||
}
|
||
ctx.beginPath(); ctx.arc(x,y,5,0,Math.PI*2);
|
||
ctx.fillStyle='#fff'; ctx.fill();
|
||
ctx.beginPath(); ctx.arc(x,y,4,0,Math.PI*2);
|
||
ctx.fillStyle=color; ctx.fill();
|
||
});
|
||
}
|
||
|
||
// 矩形模式 pending 点
|
||
if(g_drawMode==='rect' && g_rectPending){
|
||
var rx=g_rectPending[0]*canvas.width, ry=g_rectPending[1]*canvas.height;
|
||
ctx.beginPath(); ctx.arc(rx,ry,6,0,Math.PI*2);
|
||
ctx.fillStyle='#2563eb'; ctx.fill();
|
||
if(mouseEv){
|
||
var rect=canvas.getBoundingClientRect();
|
||
var mx=mouseEv.clientX-rect.left, my=mouseEv.clientY-rect.top;
|
||
ctx.setLineDash([6,4]); ctx.strokeStyle='rgba(37,99,235,0.8)'; ctx.lineWidth=2;
|
||
ctx.strokeRect(Math.min(rx,mx), Math.min(ry,my), Math.abs(mx-rx), Math.abs(my-ry));
|
||
ctx.setLineDash([]);
|
||
}
|
||
}
|
||
|
||
// 方向线 A→B
|
||
if(g_linePoints.length>=1){
|
||
var la=g_linePoints[0], lb=g_linePoints.length>=2?g_linePoints[1]:null;
|
||
var ax=la[0]*canvas.width, ay=la[1]*canvas.height;
|
||
ctx.strokeStyle='#dc2626'; ctx.lineWidth=3;
|
||
ctx.beginPath(); ctx.arc(ax,ay,7,0,Math.PI*2); ctx.fillStyle='#dc2626'; ctx.fill();
|
||
ctx.fillStyle='#fff'; ctx.font='bold 11px sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle';
|
||
ctx.fillText('A', ax, ay);
|
||
if(lb){
|
||
var bx=lb[0]*canvas.width, by=lb[1]*canvas.height;
|
||
ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke();
|
||
ctx.beginPath(); ctx.arc(bx,by,7,0,Math.PI*2); ctx.fillStyle='#dc2626'; ctx.fill();
|
||
ctx.fillStyle='#fff'; ctx.fillText('B', bx, by);
|
||
// 箭头
|
||
var ang=Math.atan2(by-ay, bx-ax);
|
||
var al=14;
|
||
ctx.beginPath();
|
||
ctx.moveTo(bx, by);
|
||
ctx.lineTo(bx - al*Math.cos(ang-0.4), by - al*Math.sin(ang-0.4));
|
||
ctx.moveTo(bx, by);
|
||
ctx.lineTo(bx - al*Math.cos(ang+0.4), by - al*Math.sin(ang+0.4));
|
||
ctx.stroke();
|
||
ctx.fillStyle='#dc2626'; ctx.font='11px sans-serif';
|
||
ctx.fillText(_t('zone_forward_dir','正向'), (ax+bx)/2, (ay+by)/2 - 10);
|
||
} else if(mouseEv){
|
||
var rect2=canvas.getBoundingClientRect();
|
||
var mx2=mouseEv.clientX-rect2.left, my2=mouseEv.clientY-rect2.top;
|
||
ctx.setLineDash([5,4]); ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(mx2,my2); ctx.stroke(); ctx.setLineDash([]);
|
||
}
|
||
}
|
||
}
|
||
|
||
function hexToRgba(hex,a){
|
||
var h=hex.replace('#',''); if(h.length===3) h=h.split('').map(function(c){return c+c;}).join('');
|
||
var r=parseInt(h.substr(0,2),16)||0,g=parseInt(h.substr(2,2),16)||0,b=parseInt(h.substr(4,2),16)||0;
|
||
return 'rgba('+r+','+g+','+b+','+a+')';
|
||
}
|
||
|
||
function getSelectedStreamId(){
|
||
var sel=document.getElementById('f_stream');
|
||
var opt=sel.options[sel.selectedIndex];
|
||
if(!opt) return 0;
|
||
var sid=parseInt(opt.value,10);
|
||
if(sid>0) return sid;
|
||
var s=findStreamByAppName(opt.getAttribute('data-app'), opt.getAttribute('data-name'));
|
||
return s ? parseInt(s.id,10)||0 : 0;
|
||
}
|
||
function findStreamByAppName(app, name){
|
||
if(!app||!name) return null;
|
||
return g_streams.find(function(s){ return s.app===app && s.name===name; }) || null;
|
||
}
|
||
|
||
function submitZone(){
|
||
var id=document.getElementById('f_id').value;
|
||
var sid=getSelectedStreamId();
|
||
var nameEl=document.getElementById('f_name');
|
||
var name=nameEl ? nameEl.value.trim() : '';
|
||
if(!sid){
|
||
showToast(_t('zone_stream_required','请选择摄像头'),'warning');
|
||
return;
|
||
}
|
||
if(!name){
|
||
showToast(_t('zone_name_required','请填写布控名称'),'warning');
|
||
if(nameEl) nameEl.focus();
|
||
return;
|
||
}
|
||
if(!validateZoneDrawing()) return;
|
||
if(needsRegionGeometry() && g_points.length >= 3 && !g_drawFinished){
|
||
g_drawFinished = true;
|
||
}
|
||
var coords = needsRegionGeometry() ? g_points : (g_points.length >= 3 ? g_points : []);
|
||
var payload={
|
||
stream_id:sid,
|
||
name:name,
|
||
coordinates:JSON.stringify(coords),
|
||
line_a: g_linePoints.length >= 1 ? JSON.stringify(g_linePoints[0]) : '',
|
||
line_b: g_linePoints.length >= 2 ? JSON.stringify(g_linePoints[1]) : '',
|
||
loiter_threshold:parseInt(document.getElementById('f_loiter').value,10)||0,
|
||
detect_interval_sec:getDetectRatePayload().detect_interval_sec,
|
||
detect_frames:getDetectRatePayload().detect_frames,
|
||
alarm_repeat_sec:Math.max(0, parseFloat(document.getElementById('f_alarm_repeat').value)||0),
|
||
color:document.getElementById('f_color').value.trim(),
|
||
is_required:parseInt(document.getElementById('f_required').value,10),
|
||
algorithm_ids:getSelectedAlgoIds()
|
||
};
|
||
// 兜底:stream_id 解析失败时用 app/name 反查(注意不要覆盖上面的 zone name)
|
||
var sel=document.getElementById('f_stream');
|
||
var opt=sel.options[sel.selectedIndex];
|
||
if(opt){
|
||
var app=opt.getAttribute('data-app')||'';
|
||
var nm=opt.getAttribute('data-name')||'';
|
||
if(app && nm){
|
||
payload.stream_app = app;
|
||
payload.stream_name = nm;
|
||
}
|
||
}
|
||
var url=id?'/control/openEdit':'/control/openAdd';
|
||
if(id) payload.id=parseInt(id,10);
|
||
console.log('[Zone] submit', url, JSON.stringify(payload));
|
||
Api.post(url,payload).then(function(res){
|
||
console.log('[Zone] submit response', JSON.stringify(res));
|
||
if(!res){ showToast(_t('msg_unknown_error','失败'),'error'); return; }
|
||
showToast(res.msg||(res.code===1000?_t('msg_success','成功'):_t('msg_unknown_error','失败')),res.code===1000?'success':'error');
|
||
if(res.code===1000){ closeZoneModal(); loadZones(); }
|
||
}).catch(function(e){
|
||
console.error('[Zone] submit error', e);
|
||
showToast(_t('msg_unknown_error','网络错误'),'error');
|
||
});
|
||
}
|
||
|
||
function delZone(id){
|
||
confirmDialog(_t('zone_del_confirm','确认删除该区域?'),function(){
|
||
Api.post('/control/openDel',{id:id}).then(function(res){
|
||
showToast(res.msg,res.code===1000?'success':'error'); loadZones();
|
||
});
|
||
});
|
||
}
|
||
|
||
loadZonePageData();
|
||
loadAlgorithmsForZone();
|
||
startAnalysisPolling();
|
||
|
||
/* ========== 引擎实例管理弹框 ========== */
|
||
var ENGINE_MAP = {yolo_pytorch:'Yolo-PyTorch', onnxruntime:'OnnxRuntime', openvino:'OpenVINO'};
|
||
var DEVICE_MAP = {cpu:'CPU', cuda:'CUDA', gpu:'GPU'};
|
||
var TASK_MAP = {detect:'Detect', segment:'Segment', classify:'Classify', pose:'Pose', obb:'OBB'};
|
||
var FLOW_MAP = {1:'小模型+后处理', 2:'大模型+后处理', 3:'小模型+大模型+后处理'};
|
||
|
||
function openEngineModal(){
|
||
document.getElementById('engineModal').classList.add('open');
|
||
if(g_lastAnalysisData){ renderEngineModal(g_lastAnalysisData); }
|
||
fetchAnalysisStatus();
|
||
}
|
||
|
||
function closeEngineModal(){
|
||
document.getElementById('engineModal').classList.remove('open');
|
||
}
|
||
|
||
function renderEngineModal(d){
|
||
if(!d){ return; }
|
||
var cfg = d.inference_config || {};
|
||
var instances = d.engine_instances || [];
|
||
var algoStates = d.algo_instance_states || [];
|
||
var sub = document.getElementById('engineModalSub');
|
||
if(sub){
|
||
var subShared = cfg.shared
|
||
? _t('zone_ctrl_modal_sub_shared',' · 共享推理 {alive}/{total} worker').replace('{alive}', cfg.workers_alive||0).replace('{total}', cfg.workers||0)
|
||
: _t('zone_ctrl_modal_sub_local',' · 独立实例');
|
||
sub.textContent = _t('zone_ctrl_modal_sub_count','· 共 {n} 个运行实例').replace('{n}', instances.length) + subShared;
|
||
}
|
||
var html = '';
|
||
|
||
// ===== 配置区 =====
|
||
html += '<div class="em-section">';
|
||
html += '<div class="em-section-title">推理引擎配置</div>';
|
||
html += '<div class="em-cfg-grid">';
|
||
// 共享推理池
|
||
var sharedChecked = cfg.shared ? 'checked' : '';
|
||
html += '<div class="em-cfg-item">';
|
||
html += '<div class="lbl">共享推理池 <label class="em-switch"><input type="checkbox" id="cfgShared" '+sharedChecked+' onchange="onCfgSharedChange()"><span class="slider"></span></label></div>';
|
||
html += '<div class="val">' + (cfg.shared ? '已启用' : '已关闭') + '<span class="hint" style="font-size:11px;font-weight:400;color:#9ca3af;margin-left:4px;">' + (cfg.shared ? '共用 worker' : '每路独立') + '</span></div>';
|
||
html += '</div>';
|
||
// 多进程模式
|
||
html += '<div class="em-cfg-item">';
|
||
html += '<div class="lbl">多进程模式</div>';
|
||
html += '<div class="val">' + (cfg.multi_process ? '启用' : '禁用') + '</div>';
|
||
html += '</div>';
|
||
// worker 数量
|
||
var workers = cfg.workers||0;
|
||
var alive = cfg.workers_alive||0;
|
||
html += '<div class="em-cfg-item">';
|
||
html += '<div class="lbl">Worker 数量 <span class="hint">存活 '+alive+'</span></div>';
|
||
html += '<div class="val"><div class="em-workers">';
|
||
html += '<button onclick="adjustWorkers(-1)">-</button>';
|
||
html += '<input type="number" id="cfgWorkers" value="'+workers+'" min="1" max="32">';
|
||
html += '<button onclick="adjustWorkers(1)">+</button>';
|
||
html += '</div><button class="em-btn" id="btnApplyWorkers" onclick="applyWorkers()">应用</button></div>';
|
||
html += '</div>';
|
||
// 重启推理池
|
||
html += '<div class="em-cfg-item">';
|
||
html += '<div class="lbl">推理池操作</div>';
|
||
html += '<div class="em-cfg-actions"><button class="em-btn danger" onclick="restartInferencePool(this)">重启推理池</button></div>';
|
||
html += '</div>';
|
||
html += '</div>'; // em-cfg-grid
|
||
html += '</div>'; // em-section
|
||
|
||
// ===== 运行中布控实例 =====
|
||
html += '<div class="em-section">';
|
||
html += '<div class="em-section-title">'+_t('zone_ctrl_running_title','运行中布控实例')+' <span style="background:#e6f7f3;color:#0e7a65;font-size:11px;padding:1px 8px;border-radius:10px;font-weight:600;">'+instances.length+'</span></div>';
|
||
if(instances.length===0){
|
||
html += '<div class="em-empty"><span class="ic">○</span>'+_t('zone_ctrl_running_empty','暂无运行中的布控实例')+'<br><span style="font-size:11px;">'+_t('zone_ctrl_running_empty_hint','启动分析后才会创建实例')+'</span></div>';
|
||
} else {
|
||
html += '<div class="em-inst-list">';
|
||
instances.forEach(function(inst){
|
||
var disabled = inst.instance_enabled===false;
|
||
var engName = ENGINE_MAP[inst.engine] || inst.engine || '';
|
||
var devName = DEVICE_MAP[inst.device] || inst.device || '';
|
||
var taskName = TASK_MAP[inst.task_type] || inst.task_type || '';
|
||
var bizList = inst.biz_algorithms || [];
|
||
var bizTxt = bizList.map(function(b){ return b.name + ' (' + (FLOW_MAP[b.flow_type]||('流程'+b.flow_type)) + ')'; }).join('、');
|
||
var streamsTxt = (inst.stream_names||[]).join('、') || (''+inst.stream_count+' 路');
|
||
html += '<div class="em-inst'+(disabled?' disabled':'')+'">';
|
||
html += '<div class="em-inst-top">';
|
||
html += '<div class="em-inst-name"><span class="ic"></span>'+escapeHtml(inst.algorithm_name||('#'+inst.algorithm_id))+'</div>';
|
||
html += '<div class="em-inst-actions">';
|
||
html += '<button class="em-btn ghost" onclick="restartAlgoInstance('+inst.algorithm_id+', this)">重启</button>';
|
||
var chk = disabled ? '' : 'checked';
|
||
html += '<label class="em-switch"><input type="checkbox" ' + chk + ' onchange="onToggleInst('+inst.algorithm_id+', this.checked)"><span class="slider"></span></label>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
html += '<div class="em-tags">';
|
||
if(engName) html += '<span class="em-tag engine">'+engName+'</span>';
|
||
if(devName) html += '<span class="em-tag device">'+devName+'</span>';
|
||
if(taskName) html += '<span class="em-tag task">'+taskName+'</span>';
|
||
if(inst.input_size) html += '<span class="em-tag input">'+inst.input_size[0]+'×'+inst.input_size[1]+'</span>';
|
||
html += '<span class="em-tag count">'+inst.stream_count+' 路摄像头</span>';
|
||
html += '</div>';
|
||
html += '<div class="em-inst-meta">';
|
||
if(bizTxt) html += '<div class="row"><span class="k">业务算法</span><span class="v">'+escapeHtml(bizTxt)+'</span></div>';
|
||
html += '<div class="row"><span class="k">使用摄像头</span><span class="v">'+escapeHtml(streamsTxt)+'</span></div>';
|
||
if(inst.model_file) html += '<div class="row"><span class="k">模型文件</span><span class="v">'+escapeHtml(inst.model_file)+'</span></div>';
|
||
if(inst.conf_threshold!==undefined) html += '<div class="row"><span class="k">参数</span><span class="v">conf='+inst.conf_threshold+' · iou='+inst.iou_threshold+' · '+escapeHtml(inst.algorithm_type||'')+'</span></div>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
|
||
// ===== 业务算法实例化开关 =====
|
||
if(algoStates.length>0){
|
||
html += '<div class="em-section">';
|
||
html += '<div class="em-section-title">业务算法实例化开关 <span style="background:#e6f7f3;color:#0e7a65;font-size:11px;padding:1px 8px;border-radius:10px;font-weight:600;">'+algoStates.length+'</span></div>';
|
||
html += '<div class="em-inst-list">';
|
||
algoStates.forEach(function(a){
|
||
var disabled = !a.instance_enabled;
|
||
var engName = ENGINE_MAP[a.engine] || a.engine || '';
|
||
var smId = a.small_model_id || 0;
|
||
html += '<div class="em-inst'+(disabled?' disabled':'')+'">';
|
||
html += '<div class="em-inst-top">';
|
||
html += '<div class="em-inst-name"><span class="ic"></span>'+escapeHtml(a.name)+'</div>';
|
||
html += '<div class="em-inst-actions">';
|
||
if(smId){ html += '<button class="em-btn ghost" onclick="restartAlgoInstance('+smId+', this)">重启</button>'; }
|
||
var chk = a.instance_enabled ? 'checked' : '';
|
||
html += '<label class="em-switch"><input type="checkbox" ' + chk + ' onchange="onToggleInst('+smId+', this.checked)"><span class="slider"></span></label>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
html += '<div class="em-tags">';
|
||
if(engName) html += '<span class="em-tag engine">'+engName+'</span>';
|
||
html += '<span class="em-tag">'+escapeHtml(a.small_model_name||'未绑定小模型')+'</span>';
|
||
html += '<span class="em-tag task">'+(FLOW_MAP[a.flow_type]||('流程'+a.flow_type))+'</span>';
|
||
html += '</div>';
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
document.getElementById('engineModalBody').innerHTML = html;
|
||
}
|
||
|
||
function onCfgSharedChange(){
|
||
var v = document.getElementById('cfgShared').checked ? 1 : 0;
|
||
if(!confirm(v ? '确定开启共享推理池?\n切换后所有运行中的分析将重启。' : '确定关闭共享推理池?\n切换后所有运行中的分析将重启。')){
|
||
document.getElementById('cfgShared').checked = !v;
|
||
return;
|
||
}
|
||
Api.post('/analysis/openUpdateInferenceConfig', {shared: v}).then(function(res){
|
||
showToast(res.msg||'操作完成', res.code===1000?'success':'error');
|
||
if(res.code===1000){ setTimeout(fetchAnalysisStatus, 800); }
|
||
else { document.getElementById('cfgShared').checked = !v; }
|
||
});
|
||
}
|
||
|
||
function adjustWorkers(delta){
|
||
var el = document.getElementById('cfgWorkers');
|
||
if(!el) return;
|
||
var v = parseInt(el.value, 10) || 1;
|
||
v = Math.max(1, Math.min(32, v + delta));
|
||
el.value = v;
|
||
}
|
||
|
||
function applyWorkers(){
|
||
var el = document.getElementById('cfgWorkers');
|
||
if(!el) return;
|
||
var v = parseInt(el.value, 10) || 1;
|
||
v = Math.max(1, Math.min(32, v));
|
||
var btn = document.getElementById('btnApplyWorkers');
|
||
if(btn){ btn.disabled = true; btn.textContent = '应用中...'; }
|
||
Api.post('/analysis/openUpdateInferenceConfig', {workers: v}).then(function(res){
|
||
showToast(res.msg||'操作完成', res.code===1000?'success':'error');
|
||
if(res.code===1000){ setTimeout(fetchAnalysisStatus, 800); }
|
||
}).catch(function(){
|
||
showToast('网络错误', 'error');
|
||
}).finally(function(){
|
||
if(btn){ btn.disabled = false; btn.textContent = '应用'; }
|
||
});
|
||
}
|
||
|
||
function onToggleInst(algoId, enabled){
|
||
if(!algoId){ return; }
|
||
Api.post('/analysis/openToggleAlgoInstance', {algorithm_id: algoId, enabled: enabled?1:0}).then(function(res){
|
||
showToast(res.msg||'操作完成', res.code===1000?'success':'error');
|
||
if(res.code===1000){ fetchAnalysisStatus(); }
|
||
});
|
||
}
|
||
|
||
function restartAlgoInstance(algoId, btn){
|
||
if(!algoId){ return; }
|
||
if(!confirm('确定重启该算法的所有运行 pipeline?\n相关摄像头分析将短暂中断。')){ return; }
|
||
if(btn){ btn.disabled = true; btn.textContent = '重启中...'; }
|
||
Api.post('/analysis/openRestartAlgoInstance', {algorithm_id: algoId}).then(function(res){
|
||
showToast(res.msg||'操作完成', res.code===1000?'success':'error');
|
||
if(res.code===1000){ setTimeout(fetchAnalysisStatus, 800); }
|
||
}).catch(function(){
|
||
showToast('网络错误', 'error');
|
||
}).finally(function(){
|
||
if(btn){ btn.disabled = false; btn.textContent = '重启'; }
|
||
});
|
||
}
|
||
|
||
function restartInferencePool(btn){
|
||
if(!confirm('确定重启整个推理池?\n所有引擎缓存将被清除,worker 子进程会重新创建。\n期间所有分析会短暂中断。')){ return; }
|
||
if(btn){ btn.disabled = true; btn.textContent = '重启中...'; }
|
||
Api.post('/analysis/openRestartInferencePool', {}).then(function(res){
|
||
showToast(res.msg||'操作完成', res.code===1000?'success':'error');
|
||
if(res.code===1000){ setTimeout(fetchAnalysisStatus, 1000); }
|
||
}).catch(function(){
|
||
showToast('网络错误', 'error');
|
||
}).finally(function(){
|
||
if(btn){ btn.disabled = false; btn.textContent = '重启推理池'; }
|
||
});
|
||
}
|
||
|
||
/* ---------- 实时 YOLO 人员检测 ---------- */
|
||
var g_yp = {sessionId:'', sequence:0, player:null, pollTimer:null, renderTimer:null,
|
||
resizeObserver:null, data:null, zone:null, frameW:0, frameH:0, opening:false,
|
||
animationFrame:null, displayBoxes:{}, lastDrawMs:0};
|
||
|
||
function computeContainRect(containerW, containerH, mediaW, mediaH){
|
||
mediaW = mediaW || 16; mediaH = mediaH || 9;
|
||
var scale = Math.min(containerW / mediaW, containerH / mediaH);
|
||
var width = mediaW * scale, height = mediaH * scale;
|
||
return {x:(containerW-width)/2, y:(containerH-height)/2, width:width, height:height};
|
||
}
|
||
|
||
function resizeYoloCanvas(){
|
||
var canvas=document.getElementById('yoloPreviewCanvas');
|
||
var stage=document.getElementById('yoloPreviewStage');
|
||
if(!canvas||!stage) return;
|
||
var dpr=window.devicePixelRatio||1;
|
||
var w=Math.max(1,stage.clientWidth), h=Math.max(1,stage.clientHeight);
|
||
canvas.width=Math.round(w*dpr); canvas.height=Math.round(h*dpr);
|
||
canvas.style.width=w+'px'; canvas.style.height=h+'px';
|
||
var ctx=canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0);
|
||
drawYoloOverlay();
|
||
}
|
||
|
||
function projectTrackBox(track, resultTimestamp, nowSec){
|
||
var box=(track.box||[]).slice();
|
||
if(box.length!==4 || !track.observed) return box;
|
||
var velocity=track.velocity||[0,0,0,0];
|
||
var lead=Math.max(0,Math.min(1.2,(nowSec||Date.now()/1000)-(resultTimestamp||nowSec)));
|
||
var projected=box.map(function(v,i){return v+(Number(velocity[i])||0)*lead;});
|
||
var bw=Math.max(.001,box[2]-box[0]), bh=Math.max(.001,box[3]-box[1]);
|
||
var dx=((projected[0]+projected[2])-(box[0]+box[2]))*.5;
|
||
var dy=((projected[1]+projected[3])-(box[1]+box[3]))*.5;
|
||
var distance=Math.sqrt(dx*dx+dy*dy), maxMove=Math.max(bw,bh)*.9;
|
||
if(distance>maxMove){
|
||
var ratio=maxMove/distance;
|
||
projected=projected.map(function(v,i){return box[i]+(v-box[i])*ratio;});
|
||
}
|
||
projected[0]=Math.max(0,Math.min(1,projected[0]));
|
||
projected[1]=Math.max(0,Math.min(1,projected[1]));
|
||
projected[2]=Math.max(projected[0],Math.min(1,projected[2]));
|
||
projected[3]=Math.max(projected[1],Math.min(1,projected[3]));
|
||
return projected;
|
||
}
|
||
|
||
function smoothTrackBox(track, target, nowMs){
|
||
var key=String(track.track_id), state=g_yp.displayBoxes[key];
|
||
if(!state){state={box:target.slice(),last:nowMs};g_yp.displayBoxes[key]=state;return state.box;}
|
||
var dt=Math.max(1,Math.min(100,nowMs-state.last)), alpha=1-Math.exp(-dt/90);
|
||
for(var i=0;i<4;i++)state.box[i]+=((target[i]||0)-state.box[i])*alpha;
|
||
state.last=nowMs;return state.box;
|
||
}
|
||
|
||
function drawYoloOverlay(){
|
||
var canvas=document.getElementById('yoloPreviewCanvas');
|
||
var stage=document.getElementById('yoloPreviewStage');
|
||
if(!canvas||!stage) return;
|
||
var ctx=canvas.getContext('2d'), w=stage.clientWidth, h=stage.clientHeight;
|
||
ctx.clearRect(0,0,w,h);
|
||
var data=g_yp.data||{}, fw=data.frame_width||g_yp.frameW||16, fh=data.frame_height||g_yp.frameH||9;
|
||
var rect=computeContainRect(w,h,fw,fh);
|
||
var zone=(data.zones&&data.zones[0])||g_yp.zone;
|
||
if(zone && zone.coords && zone.coords.length>=3){
|
||
ctx.save(); ctx.beginPath();
|
||
zone.coords.forEach(function(p,i){
|
||
var x=rect.x+p[0]*rect.width, y=rect.y+p[1]*rect.height;
|
||
if(i===0)ctx.moveTo(x,y);else ctx.lineTo(x,y);
|
||
});
|
||
ctx.closePath(); ctx.fillStyle=hexToRgba(zone.color||'#169F85',.16);ctx.fill();
|
||
ctx.strokeStyle=zone.color||'#169F85';ctx.lineWidth=2;ctx.stroke();ctx.restore();
|
||
}
|
||
var drawNow=Date.now(), liveIds={};
|
||
(data.tracks||[]).forEach(function(t){
|
||
if(t.label!=='person' || !t.box || t.box.length<4) return;
|
||
liveIds[String(t.track_id)]=true;
|
||
var target=projectTrackBox(t,data.timestamp,drawNow/1000);
|
||
var displayBox=smoothTrackBox(t,target,drawNow);
|
||
var x=rect.x+displayBox[0]*rect.width, y=rect.y+displayBox[1]*rect.height;
|
||
var bw=(displayBox[2]-displayBox[0])*rect.width, bh=(displayBox[3]-displayBox[1])*rect.height;
|
||
var inside=(t.inside_zone_ids||[]).indexOf(zone&&zone.id)>=0;
|
||
var color=inside?'#ef4444':'#a855f7';
|
||
ctx.save();ctx.globalAlpha=t.opacity==null?1:t.opacity;ctx.strokeStyle=color;ctx.lineWidth=2;
|
||
if(!t.observed)ctx.setLineDash([7,5]);ctx.strokeRect(x,y,bw,bh);ctx.setLineDash([]);
|
||
var label='person · ID '+t.track_id+' · '+Math.round((t.score||0)*100)+'%'+(inside?' · 区域内':'');
|
||
ctx.font='12px sans-serif';var tw=ctx.measureText(label).width+10;
|
||
ctx.fillStyle=color;ctx.fillRect(x,Math.max(rect.y,y-22),tw,20);
|
||
ctx.fillStyle='#fff';ctx.fillText(label,x+5,Math.max(rect.y+14,y-7));ctx.restore();
|
||
});
|
||
Object.keys(g_yp.displayBoxes).forEach(function(key){if(!liveIds[key])delete g_yp.displayBoxes[key];});
|
||
g_yp.lastDrawMs=drawNow;
|
||
}
|
||
|
||
function animateYoloOverlay(){
|
||
if(!g_yp.sessionId)return;
|
||
drawYoloOverlay();
|
||
g_yp.animationFrame=requestAnimationFrame(animateYoloOverlay);
|
||
}
|
||
|
||
function updateYoloStats(){
|
||
var d=g_yp.data||{};
|
||
var mode=d.mode||'preview', modeEl=document.getElementById('ypMode');
|
||
if(modeEl){modeEl.className='yp-mode '+(mode==='formal'?'':'preview');modeEl.textContent=mode==='formal'?'正式分析中':'预览模式,不产生报警';}
|
||
var names=d.model_names||[];
|
||
var devices=(d.model_devices||[]).map(function(v){return String(v).toUpperCase();});
|
||
document.getElementById('ypModel').textContent='模型:'+(names.join('、')||'—')+(devices.length?' · '+devices.join('/'):'');
|
||
document.getElementById('ypFps').textContent='分析 FPS:'+Number(d.analysis_fps||0).toFixed(2);
|
||
var age=d.timestamp ? Math.max(0,Date.now()/1000-d.timestamp) : null;
|
||
var ageEl=document.getElementById('ypAge');
|
||
ageEl.textContent='结果延迟:'+(age==null?'—':age.toFixed(1)+' 秒');
|
||
ageEl.style.color=age!=null&&age>10?'#fca5a5':'#cbd5e1';
|
||
var count=(d.tracks||[]).filter(function(t){return t.label==='person'&&t.observed;}).length;
|
||
document.getElementById('ypCount').textContent='当前人员:'+count;
|
||
}
|
||
|
||
function pollYoloPreview(){
|
||
if(!g_yp.sessionId)return;
|
||
Api.get('/analysis/openPreviewData',{session_id:g_yp.sessionId,since:g_yp.sequence}).then(function(res){
|
||
if(!g_yp.sessionId)return;
|
||
if(!res||res.code!==1000){showToast((res&&res.msg)||'预览会话已失效','error');closeYoloPreview();return;}
|
||
var d=res.data||{};
|
||
if(d.changed){g_yp.data=d;g_yp.sequence=d.sequence||g_yp.sequence;g_yp.frameW=d.frame_width||g_yp.frameW;g_yp.frameH=d.frame_height||g_yp.frameH;drawYoloOverlay();}
|
||
else if(g_yp.data){g_yp.data.mode=d.mode||g_yp.data.mode;}
|
||
updateYoloStats();
|
||
}).catch(function(){});
|
||
}
|
||
|
||
function startYoloPlayer(app,name){
|
||
Api.get('/stream/openPlayer',{app:app,name:name}).then(function(res){
|
||
if(!g_yp.sessionId)return;
|
||
var s=res&&res.info&&res.info.stream;
|
||
if(!s||parseInt(s.is_online,10)!==1){document.getElementById('ypLoading').textContent='视频流离线,但 YOLO 分析会继续等待视频';return;}
|
||
g_yp.frameW=s.video_width||g_yp.frameW;g_yp.frameH=s.video_height||g_yp.frameH;
|
||
var url=s.wsMp4Url||s.httpMp4Url||s.wsFlvUrl||s.httpFlvUrl;
|
||
if(!url){document.getElementById('ypLoading').textContent='无可用播放地址';return;}
|
||
try{
|
||
g_yp.player=new EasyPlayerPro({container:document.getElementById('yoloPreviewPlayer'),videoBuffer:.1,
|
||
decoder:'/static/lib/easyPlayer/js/decoder-pro.js',isResize:true,debug:false,useMSE:true,useWCS:true,
|
||
hasAudio:false,isNotMute:true,showBandwidth:false,operateBtns:{fullscreen:true,screenshot:false,play:true,audio:false,ptz:false},
|
||
timeout:10,forceNoOffscreen:true,heartTimeout:10});
|
||
g_yp.player.play(url);document.getElementById('ypLoading').style.display='none';
|
||
}catch(e){document.getElementById('ypLoading').textContent='视频播放器启动失败';}
|
||
});
|
||
}
|
||
|
||
function openYoloPreview(zoneId){
|
||
if(g_yp.opening)return;g_yp.opening=true;
|
||
var zone=g_zoneCache.find(function(z){return parseInt(z.id,10)===parseInt(zoneId,10);});
|
||
document.getElementById('yoloPreviewModal').classList.add('open');
|
||
document.getElementById('ypLoading').style.display='flex';
|
||
document.getElementById('ypLoading').textContent='正在启动 YOLO 分析并连接视频流…';
|
||
document.getElementById('ypTitle').textContent=(zone?zone.stream_name+' · '+zone.name:'')+' 实时 YOLO 人员检测';
|
||
Api.post('/analysis/openPreviewStart',{zone_id:zoneId}).then(function(res){
|
||
g_yp.opening=false;
|
||
if(!res||res.code!==1000){showToast((res&&res.msg)||'启动预览失败','error');closeYoloPreview();return;}
|
||
var d=res.data||{};g_yp.sessionId=d.session_id;g_yp.sequence=0;g_yp.zone=d.zone;g_yp.data={mode:d.mode,zones:[d.zone],tracks:[],model_names:(d.models||[]).map(function(m){return m.name;})};
|
||
updateYoloStats();resizeYoloCanvas();startYoloPlayer(d.app,d.name);
|
||
g_yp.pollTimer=setInterval(pollYoloPreview,250);g_yp.renderTimer=setInterval(updateYoloStats,500);
|
||
g_yp.animationFrame=requestAnimationFrame(animateYoloOverlay);
|
||
if(window.ResizeObserver){g_yp.resizeObserver=new ResizeObserver(resizeYoloCanvas);g_yp.resizeObserver.observe(document.getElementById('yoloPreviewStage'));}
|
||
}).catch(function(){g_yp.opening=false;showToast('启动预览失败','error');closeYoloPreview();});
|
||
}
|
||
|
||
function closeYoloPreview(){
|
||
var sid=g_yp.sessionId;g_yp.sessionId='';g_yp.opening=false;
|
||
if(g_yp.pollTimer)clearInterval(g_yp.pollTimer);if(g_yp.renderTimer)clearInterval(g_yp.renderTimer);
|
||
if(g_yp.animationFrame)cancelAnimationFrame(g_yp.animationFrame);
|
||
g_yp.pollTimer=null;g_yp.renderTimer=null;
|
||
if(g_yp.resizeObserver){try{g_yp.resizeObserver.disconnect();}catch(e){}g_yp.resizeObserver=null;}
|
||
if(g_yp.player){try{g_yp.player.destroy();}catch(e){}g_yp.player=null;}
|
||
if(sid)Api.post('/analysis/openPreviewStop',{session_id:sid}).catch(function(){});
|
||
g_yp.data=null;g_yp.zone=null;g_yp.sequence=0;g_yp.animationFrame=null;g_yp.displayBoxes={};
|
||
document.getElementById('yoloPreviewPlayer').innerHTML='';
|
||
document.getElementById('yoloPreviewModal').classList.remove('open');
|
||
}
|
||
</script>
|
||
{% endblock %}
|