2026-08-30 22:22:11 +08:00
{% 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;}
2026-09-04 18:16:14 +08:00
/* 实时 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;}
2026-08-30 22:22:11 +08:00
< / style >
{% endblock %}
{% block content %}
< div class = "zone-page" >
< div class = "zone-card" >
< div class = "zone-head" >
< h3 > < span class = "dot" > < / span > {{ T.nav_control|default:'布控管理' }}< / h3 >
< div class = "zone-toolbar" >
< select id = "filterStream" onchange = "onFilterStreamChange()" >
< option value = "" > {{ T.zone_all_cameras|default:'全部摄像头' }}< / option >
< / select >
< span id = "analysisStatusBadge" class = "analysis-badge" style = "display:none" > < / span >
< button class = "btn" onclick = "loadZones()" > {{ T.btn_refresh_symbol|default:'↻ 刷新' }}< / button >
< button class = "btn btn-primary" onclick = "openZoneModal()" > {{ T.btn_add_zone|default:'+ 添加布控' }}< / button >
< / div >
< / div >
<!-- 分析概览条 -->
< div class = "zone-overview" id = "zoneOverview" style = "display:none" >
< div class = "ov-item" > < span class = "lbl" > {{ T.zone_ov_total_cam|default:'摄像头总数' }}< / span > < span class = "val" id = "ovTotalCam" > 0< / span > < / div >
< div class = "ov-item" > < span class = "lbl" > {{ T.zone_ov_running|default:'运行分析' }}< / span > < span class = "val green" id = "ovRunning" > 0 / 0< / span > < span class = "sub" id = "ovFps" > {{ T.zone_ov_fps_avg|default:'平均 FPS 0.0' }}< / span > < / div >
< div class = "ov-item" > < span class = "lbl" > {{ T.zone_ov_engine_instances|default:'布控实例' }}< / span > < span class = "val blue engine-clickable" id = "ovInstances" onclick = "openEngineModal()" title = "{{ T.zone_ctrl_modal_click|default:'点击管理布控实例' }}" > 0< / span > < span class = "sub engine-clickable" id = "ovReuse" onclick = "openEngineModal()" > {{ T.zone_ov_reuse|default:'全部按算法缓存复用' }}< / span > < / div >
< div class = "ov-item" > < span class = "lbl" > {{ T.zone_ov_zones|default:'布控 / 运行中' }}< / span > < span class = "val" id = "ovZones" > 0 / 0< / span > < / div >
< div class = "ov-item" > < span class = "lbl" > {{ T.zone_ov_alarms|default:'近期报警' }}< / span > < span class = "val red" id = "ovAlarms" > 0< / span > < / div >
< / div >
< div class = "zone-body" id = "zoneBodyWrap" >
< table class = "zone-table" id = "zoneTable" >
< thead >
< tr >
< th > {{ T.th_id|default:'ID' }}< / th > < th > {{ T.zone_camera|default:'摄像头' }}< / th > < th > {{ T.zone_name|default:'布控名称' }}< / th >
< th > {{ T.zone_algorithms|default:'业务算法' }}< / th >
< th > {{ T.zone_detect_rate|default:'检测频率' }}< / th >
< th > {{ T.zone_required|default:'必需' }}< / th > < th > {{ T.zone_loiter|default:'滞留(秒)' }}< / th >
< th > {{ T.zone_state|default:'状态' }}< / th >
< th > {{ T.col_action|default:'操作' }}< / th >
< / tr >
< / thead >
< tbody id = "zoneBody" > < / tbody >
< / table >
< div id = "zoneEmpty" class = "zone-empty" style = "display:none" >
< span class = "ic" > ○< / span > < span > {{ T.camera_no_data|default:'暂无数据' }}< / span >
< / div >
< / div >
< / div >
<!-- 报警面板已迁移至 /alarm/index 报警管理页面 -->
< / div >
< div class = "zone-modal-overlay" id = "zoneModal" onclick = "if(event.target===this)closeZoneModal()" >
< div class = "zone-modal" role = "dialog" >
< div class = "zone-modal-head" >
< div >
< h3 class = "zm-title" id = "zoneModalTitle" > {{ T.btn_add_zone|default:'添加布控' }}< / h3 >
< p class = "zm-sub" id = "zoneModalSub" > {{ T.zone_draw_hint|default:'在摄像头画面上点击绘制多边形区域' }}< / p >
< / div >
< button type = "button" class = "zone-modal-close" onclick = "closeZoneModal()" aria-label = "close" > × < / button >
< / div >
< div class = "zone-modal-body" >
< input type = "hidden" id = "f_id" >
<!-- 左侧画布 -->
< div class = "zm-stage" >
< div class = "zm-stage-bar" >
< div class = "zm-stage-row zm-stage-row-video" >
< select id = "f_stream" class = "zm-stream-select" onchange = "onStreamChange()" > < / select >
< button type = "button" class = "zm-tool" onclick = "refreshLivePreview()" title = "{{ T.zone_refresh_live|default:'重新连接视频' }}" > ↻< / button >
< / div >
< div class = "zm-stage-row zm-stage-row-draw" id = "drawToolRow" >
< span class = "zm-row-label" > {{ T.zone_draw_tools|default:'绘制' }}< / span >
< div class = "zm-tool-group zm-draw-modes" id = "drawModeBar" >
< button type = "button" class = "zm-tool active" data-mode = "rect" onclick = "setDrawMode('rect')" title = "{{ T.zone_draw_rect|default:'矩形区域' }}" > ▭< / button >
< button type = "button" class = "zm-tool" data-mode = "polygon" onclick = "setDrawMode('polygon')" title = "{{ T.zone_draw_polygon|default:'多边形' }}" > ⬠< / button >
< span class = "zm-draw-sep" aria-hidden = "true" > < / span >
< button type = "button" class = "zm-tool zm-tool-line" data-mode = "line" id = "btnDrawLine" onclick = "setDrawMode('line')" title = "{{ T.zone_draw_line|default:'方向线 A→B' }}" > →< / button >
< / div >
< span class = "zm-badge" id = "pointStatus" > {{ T.zone_no_points|default:'0 个点' }}< / span >
< span class = "zm-line-status" id = "lineStatus" > < / span >
< div class = "zm-tool-group zm-draw-actions" >
< button type = "button" class = "zm-tool" onclick = "undoPoint()" title = "{{ T.zone_undo|default:'撤销' }}" > ↩< / button >
< button type = "button" class = "zm-tool" onclick = "clearCurrentDraw()" title = "{{ T.zone_clear_current|default:'清除当前图形' }}" > ⌫< / button >
< button type = "button" class = "zm-tool zm-tool-finish" id = "btnFinishDraw" onclick = "finishDraw()" title = "{{ T.zone_finish_draw|default:'完成绘制' }}" > ✓< / button >
< / div >
< / div >
< / div >
< div class = "zm-canvas-box" >
< div class = "draw-wrap no-snap" id = "drawWrap" >
< span class = "zm-ph-icon" > ▣< / span >
< span id = "drawPlaceholder" > {{ T.zone_select_camera_first|default:'正在加载摄像头画面…' }}< / span >
< / div >
< / div >
< div class = "zm-hint-bar" >
< svg width = "14" height = "14" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" > < circle cx = "12" cy = "12" r = "10" / > < line x1 = "12" y1 = "16" x2 = "12" y2 = "12" / > < line x1 = "12" y1 = "8" x2 = "12.01" y2 = "8" / > < / svg >
< span id = "drawHintText" > {{ T.zone_draw_tip_three|default:'三种图形可叠加:▭ 矩形 · ⬠ 多边形 · → 方向线 A→B( 箭头为正向) · 右键撤销当前工具' }}< / span >
< / div >
< / div >
<!-- 右侧属性 -->
< div class = "zm-panel" >
< div class = "zm-panel-scroll" >
< div class = "zm-field" >
< label > {{ T.zone_name|default:'布控名称' }}< / label >
< input id = "f_name" placeholder = "{{ T.zone_name_ph|default:'如:大门口、停车场入口' }}" >
< / div >
< div class = "zm-field" >
< label > {{ T.zone_color|default:'区域颜色' }}< / label >
< div class = "zm-colors" >
< span class = "zm-swatch active" data-color = "#169F85" style = "background:#169F85" onclick = "pickColor(this)" > < / span >
< span class = "zm-swatch" data-color = "#2563eb" style = "background:#2563eb" onclick = "pickColor(this)" > < / span >
< span class = "zm-swatch" data-color = "#dc2626" style = "background:#dc2626" onclick = "pickColor(this)" > < / span >
< span class = "zm-swatch" data-color = "#d97706" style = "background:#d97706" onclick = "pickColor(this)" > < / span >
< span class = "zm-swatch" data-color = "#7c3aed" style = "background:#7c3aed" onclick = "pickColor(this)" > < / span >
< input id = "f_color" type = "color" class = "zm-color-input" value = "#169F85" onchange = "onColorInput()" >
< / div >
< / div >
< div class = "zm-field-row" >
< div class = "zm-field" >
< label > {{ T.zone_loiter|default:'滞留阈值' }}< / label >
< input id = "f_loiter" type = "number" value = "0" min = "0" placeholder = "秒" >
< / div >
< div class = "zm-field" >
< label > {{ T.zone_required|default:'必需区域' }}< / label >
< div class = "zm-toggle-row" id = "reqToggle" >
< button type = "button" class = "zm-toggle" data-val = "0" onclick = "setToggle('reqToggle',0,'f_required');onRequiredChange()" > {{ T.zone_no|default:'否' }}< / button >
< button type = "button" class = "zm-toggle active" data-val = "1" onclick = "setToggle('reqToggle',1,'f_required');onRequiredChange()" > {{ T.zone_yes|default:'是' }}< / button >
< / div >
< input type = "hidden" id = "f_required" value = "1" >
< div class = "biz-hint" id = "zoneRequiredHint" style = "margin-top:6px;font-size:11px;color:#64748b;" > < / div >
< / div >
< / div >
< div class = "zm-field" >
< label > {{ T.zone_detect_rate|default:'算法检测频率' }}< / label >
< div class = "zm-detect-box" >
< div class = "zm-detect-formula" >
< span class = "zm-detect-unit" > {{ T.zone_detect_every|default:'每' }}< / span >
< input type = "number" id = "f_detect_interval" value = "1" min = "0.1" max = "86400" step = "any"
oninput="updateDetectPreview()" title="{{ T.zone_detect_interval_tip|default:'时间间隔(秒),支持小数' }}">
< span class = "zm-detect-unit" > {{ T.zone_detect_sec|default:'秒' }}< / span >
< span class = "zm-detect-unit" > {{ T.zone_detect_analyze|default:'分析' }}< / span >
< input type = "number" id = "f_detect_frames" value = "1" min = "1" max = "999" step = "1"
oninput="updateDetectPreview()" title="{{ T.zone_detect_frames_tip|default:'分析帧数' }}">
< span class = "zm-detect-unit" > {{ T.zone_detect_frame_unit|default:'帧' }}< / span >
< / div >
< div class = "zm-detect-preview" id = "detectPreview" > < / div >
< / div >
< / div >
2026-09-04 18:16:14 +08:00
< 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 >
2026-08-30 22:22:11 +08:00
< 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 >
2026-09-04 18:16:14 +08:00
<!-- 实时 YOLO 检测弹框 -->
< div class = "engine-modal-overlay" id = "yoloPreviewModal" onclick = "if(event.target===this)closeYoloPreview()" >
< div class = "yolo-preview-modal" role = "dialog" aria-modal = "true" aria-label = "实时 YOLO 检测" >
< div class = "yp-head" >
< div class = "yp-title" > < span class = "yp-live-dot" > < / span > < span id = "ypTitle" > 实时 YOLO 人员检测< / span > < / div >
< button type = "button" class = "engine-modal-close" onclick = "closeYoloPreview()" aria-label = "close" > × < / button >
< / div >
< div class = "yp-stage" id = "yoloPreviewStage" >
< div id = "yoloPreviewPlayer" > < / div >
< canvas id = "yoloPreviewCanvas" > < / canvas >
< div class = "yp-loading" id = "ypLoading" > 正在启动 YOLO 分析并连接视频流…< / div >
< / div >
< div class = "yp-foot" >
< span class = "yp-mode preview" id = "ypMode" > 预览模式,不产生报警< / span >
< span class = "yp-stat" id = "ypModel" > 模型:—< / span >
< span class = "yp-stat" id = "ypFps" > 分析 FPS: 0.00< / span >
< span class = "yp-stat" id = "ypAge" > 结果延迟:—< / span >
< span class = "yp-stat" id = "ypCount" > 当前人员: 0< / span >
< / div >
< / div >
< / div >
2026-08-30 22:22:11 +08:00
<!-- 布控实例管理弹框 -->
< div class = "engine-modal-overlay" id = "engineModal" onclick = "if(event.target===this)closeEngineModal()" >
< div class = "engine-modal" role = "dialog" >
< div class = "engine-modal-head" >
< div class = "emh-title" >
< span class = "emh-icon" > ⚙< / span >
< h3 > {{ T.zone_ctrl_modal_title|default:'布控实例管理' }}< / h3 >
< span class = "emh-sub" id = "engineModalSub" > < / span >
< / div >
< button type = "button" class = "engine-modal-close" onclick = "closeEngineModal()" aria-label = "close" > × < / button >
< / div >
< div class = "engine-modal-body" id = "engineModalBody" >
< div class = "engine-loading" > 加载中...< / div >
< / div >
< / div >
< / div >
{% endblock %}
{% block extra_js %}
< script src = "{% static 'lib/easyPlayer/js/easyplayer-pro.js' %}" > < / script >
< script >
function onFilterStreamChange(){
loadZonePageData();
}
function getStreamStatusInfo(streamId){
streamId = parseInt(streamId, 10) || 0;
if(!streamId) return null;
for(var i=0;i< g_runningStreams.length ; i + + ) {
var r = g_runningStreams[i];
if(typeof r === 'object' & & r.stream_id === streamId) return r;
}
return null;
}
function isStreamAnalysisRunning(streamId){
var info = getStreamStatusInfo(streamId);
if(!info) return false;
return info.running !== false & & info.stream_health !== 'stopped';
}
function getStreamHealth(streamId){
var info = getStreamStatusInfo(streamId);
if(!info) return 'stopped';
return info.stream_health || (info.running ? 'ok' : 'stopped');
}
function streamHealthLabel(health){
if(health === 'ok') return _t('zone_health_ok','正常');
if(health === 'reconnecting' || health === 'connecting') return _t('zone_health_reconnecting','重连中');
if(health === 'disconnected') return _t('zone_health_disconnected','视频断开');
if(health === 'stalled') return _t('zone_health_stalled','视频中断');
return _t('zone_analysis_stopped','分析未启动');
}
function streamHealthBadgeClass(health){
if(health === 'ok') return 'running';
if(health === 'reconnecting' || health === 'connecting') return 'reconnecting';
if(health === 'stalled' || health === 'disconnected') return 'stalled';
return 'stopped';
}
var g_healthToastShown = {};
function maybeNotifyStreamHealth(streamId, health){
streamId = parseInt(streamId, 10) || 0;
if(!streamId) return;
var key = streamId + ':' + health;
if(health === 'stalled' || health === 'disconnected'){
if(!g_healthToastShown[key]){
g_healthToastShown[key] = true;
showToast(_t('zone_health_stalled_toast','视频流中断,分析已停止工作,请手动重启分析'), 'warning', 6000);
}
} else if(health === 'ok'){
g_healthToastShown = {};
}
}
function refreshAnalysisUI(){
var sid = parseInt(document.getElementById('filterStream').value, 10) || 0;
var badge = document.getElementById('analysisStatusBadge');
if(!sid){
badge.style.display = 'inline-flex';
2026-09-04 18:16:14 +08:00
var n = g_runningStreams.filter(function(r){
return typeof r === 'object' & & r.running !== false & & r.healthy;
}).length;
2026-08-30 22:22:11 +08:00
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)) : '';
2026-09-04 18:16:14 +08:00
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';
}
2026-08-30 22:22:11 +08:00
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 > ';
2026-09-04 18:16:14 +08:00
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 > ';
2026-08-30 22:22:11 +08:00
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);
2026-09-04 18:16:14 +08:00
if(!info){
2026-08-30 22:22:11 +08:00
return '< span class = "tag tag-warn" > '+_t('zone_status_starting','启动中')+'< / span > ';
}
2026-09-04 18:16:14 +08:00
if(info.analysis_health === 'error' || info.analysis_health === 'stalled'){
return '< span class = "tag tag-err" > '+_t('zone_status_inference_error','推理异常')+'< / span > ';
}
2026-08-30 22:22:11 +08:00
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 > ';
}
2026-09-04 18:16:14 +08:00
if(info.running !== false & & health === 'ok' & &
(info.analysis_health === 'running' || (!info.analysis_health & & fps > 0.05))){
2026-08-30 22:22:11 +08:00
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 > '
2026-09-04 18:16:14 +08:00
+'< button type = "button" class = "act-btn act-preview" onclick = "openYoloPreview('+z.id+')" title = "实时 YOLO 检测" > '+SVG_PREVIEW+'< / button > '
2026-08-30 22:22:11 +08:00
+'< 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;
2026-09-04 18:16:14 +08:00
document.getElementById('f_alarm_repeat').value=z.alarm_repeat_sec == null ? 30 : z.alarm_repeat_sec;
2026-08-30 22:22:11 +08:00
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;
2026-09-04 18:16:14 +08:00
document.getElementById('f_alarm_repeat').value=30;
2026-08-30 22:22:11 +08:00
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();
2026-09-04 18:16:14 +08:00
if(e.key==='Escape' & & document.getElementById('yoloPreviewModal').classList.contains('open')) closeYoloPreview();
2026-08-30 22:22:11 +08:00
});
/* ---------- 摄像头画面绘制 ---------- */
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,
2026-09-04 18:16:14 +08:00
alarm_repeat_sec:Math.max(0, parseFloat(document.getElementById('f_alarm_repeat').value)||0),
2026-08-30 22:22:11 +08:00
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 = '重启推理池'; }
});
}
2026-09-04 18:16:14 +08:00
/* ---------- 实时 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');
}
2026-08-30 22:22:11 +08:00
< / script >
{% endblock %}