video_monitor/templates/app/control/index.html
2026-09-04 18:16:14 +08:00

2375 lines
108 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends "app/base.html" %}
{% load static %}
{% block title %}{{ T.nav_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">&times;</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">&times;</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">分析 FPS0.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">&times;</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 %}