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

742 lines
39 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

{% extends "app/base.html" %}
{% load static %}
{% block title %}{{ T.nav_biz_algorithms|default:T.nav_algorithms|default:'业务算法' }} - {{ settings.name|default:"Monitor" }}{% endblock %}
{% block nav_algorithms %}active{% endblock %}
{% block page_title %}{{ T.nav_biz_algorithms|default:T.nav_algorithms|default:'业务算法' }}{% endblock %}
{% block extra_head %}
<style>
.biz-page{max-width:1200px;margin:0 auto;}
.biz-card{background:#fff;border-radius:10px;box-shadow:0 2px 10px rgba(0,0,0,.05);overflow:hidden;}
.biz-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 #eef2f5;gap:10px;flex-wrap:wrap;}
.biz-head h3{margin:0;font-size:15px;font-weight:600;color:#2c3e50;display:flex;align-items:center;gap:8px;}
.biz-head h3 .dot{width:8px;height:8px;border-radius:50%;background:#169F85;box-shadow:0 0 0 3px rgba(22,159,133,.15);}
.biz-table{width:100%;border-collapse:collapse;}
.biz-table th,.biz-table td{padding:10px 14px;font-size:13px;text-align:left;border-bottom:1px solid #f3f4f6;}
.biz-table th{background:#fafbfc;color:#6b7280;font-weight:600;font-size:12px;}
.biz-table tr:hover td{background:#f9fafb;}
.biz-id-cell{color:#6b7280;font-size:12px;font-variant-numeric:tabular-nums;white-space:nowrap;}
.biz-actions{display:flex;gap:6px;flex-wrap:wrap;}
.biz-actions .btn{padding:4px 10px;font-size:11px;}
.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-flow{background:#e0f2fe;color:#075985;}
.tag-post{background:#fef3c7;color:#92400e;}
.btn{padding:7px 14px;font-size:12px;border-radius:6px;border:1px solid #dcdfe6;background:#fff;cursor:pointer;transition:.15s;}
.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;}
.biz-empty{padding:48px 20px;text-align:center;color:#9ca3af;font-size:14px;}
/* 编辑弹框 — 对齐大模型管理风格 */
.biz-modal-overlay{position:fixed;inset:0;z-index:400;background:rgba(15,23,42,.38);backdrop-filter:blur(5px);
display:none;align-items:center;justify-content:center;padding:20px;}
.biz-modal-overlay.show{display:flex;}
.biz-modal{width:min(560px,96vw);max-height:92vh;background:#fff;border-radius:14px;
box-shadow:0 20px 60px rgba(15,23,42,.18);border:1px solid #e2e8f0;display:flex;flex-direction:column;overflow:hidden;
animation:bizModalIn .22s ease;}
@keyframes bizModalIn{from{opacity:0;transform:scale(.97) translateY(8px)}to{opacity:1;transform:none}}
.biz-modal-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:18px 22px 16px;
border-bottom:1px solid #eef2f5;background:linear-gradient(135deg,#f8fbfa 0%,#f0f9f6 100%);}
.biz-modal-title{margin:0;font-size:16px;font-weight:600;color:#1e293b;display:flex;align-items:center;gap:8px;}
.biz-modal-title::before{content:'';width:8px;height:8px;border-radius:50%;background:#169F85;box-shadow:0 0 0 3px rgba(22,159,133,.15);}
.biz-modal-sub{margin:6px 0 0;font-size:12px;color:#64748b;line-height:1.5;}
.biz-modal-close{width:34px;height:34px;border:1px solid #e2e8f0;border-radius:9px;background:#fff;color:#64748b;
font-size:20px;line-height:1;cursor:pointer;flex-shrink:0;}
.biz-modal-close:hover{border-color:#169F85;color:#169F85;background:#f0f9f6;}
.biz-modal-body{padding:18px 22px;overflow:auto;flex:1;}
.biz-modal-foot{padding:14px 22px;border-top:1px solid #eef2f5;display:flex;justify-content:flex-end;gap:10px;background:#fafbfc;}
.biz-section{margin-bottom:18px;}
.biz-section:last-child{margin-bottom:0;}
.biz-section-title{font-size:11px;font-weight:700;color:#169F85;text-transform:uppercase;letter-spacing:.06em;
margin-bottom:12px;padding-bottom:6px;border-bottom:1px dashed #e2e8f0;}
.biz-field{margin-bottom:14px;}
.biz-field:last-child{margin-bottom:0;}
.biz-field label{display:block;font-size:12px;font-weight:600;color:#475569;margin-bottom:6px;}
.biz-input{width:100%;padding:10px 12px;font-size:13px;border:1px solid #e2e8f0;border-radius:8px;
background:#fff;color:#1e293b;box-sizing:border-box;transition:border-color .15s,box-shadow .15s;}
.biz-input:focus{outline:none;border-color:#169F85;box-shadow:0 0 0 3px rgba(22,159,133,.12);}
.biz-input[disabled]{background:#f8fafc;color:#94a3b8;}
textarea.biz-input{resize:vertical;min-height:80px;font-family:inherit;line-height:1.5;}
select.biz-input{cursor:pointer;}
select.biz-multi-select{width:100%;min-height:140px;padding:6px 8px;font-size:13px;line-height:1.5;
border:1px solid #e2e8f0;border-radius:8px;background:#fff;color:#1e293b;box-sizing:border-box;}
select.biz-multi-select:focus{outline:none;border-color:#169F85;box-shadow:0 0 0 3px rgba(22,159,133,.12);}
select.biz-multi-select option{padding:6px 8px;border-radius:4px;margin:1px 0;}
select.biz-multi-select option:checked{background:#169F85;color:#fff;}
.biz-hint{font-size:11px;color:#94a3b8;margin-top:5px;line-height:1.4;}
.flow-panel{display:none;}
.flow-panel.show{display:block;}
/* 模型文件状态徽标 */
.model-badge{display:inline-block;padding:1px 6px;border-radius:8px;font-size:10px;font-weight:600;margin-left:4px;vertical-align:middle;}
.model-badge.ok{background:#d1fae5;color:#065f46;}
.model-badge.miss{background:#fee2e2;color:#991b1b;}
.model-name-wrap{display:inline-flex;align-items:center;gap:2px;}
.model-sub{font-size:10px;color:#9ca3af;display:block;margin-top:2px;}
/* 分配布控弹框 */
.assign-modal{width:min(640px,96vw);}
.assign-toolbar{display:flex;gap:8px;align-items:center;padding:10px 0 12px;border-bottom:1px solid #f3f4f6;margin-bottom:8px;}
.assign-list{max-height:360px;overflow:auto;border:1px solid #e2e8f0;border-radius:8px;}
.assign-item{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid #f3f4f6;cursor:pointer;}
.assign-item:last-child{border-bottom:none;}
.assign-item:hover{background:#f9fafb;}
.assign-item input{margin:0;accent-color:#169F85;}
.assign-item .nm{font-size:13px;color:#1f2937;font-weight:500;flex:1;}
.assign-item .sub{font-size:11px;color:#9ca3af;}
.assign-empty{padding:32px;text-align:center;color:#9ca3af;font-size:13px;}
</style>
{% endblock %}
{% block content %}
<div class="biz-page">
<div class="biz-card">
<div class="biz-head">
<h3><span class="dot"></span>{{ T.biz_algo_title|default:'业务算法' }}</h3>
<button type="button" class="btn btn-primary" onclick="openAdd()">+ {{ T.biz_algo_add|default:'添加业务算法' }}</button>
</div>
<table class="biz-table">
<thead>
<tr>
<th>{{ T.th_id|default:'ID' }}</th>
<th>{{ T.col_name|default:'名称' }}</th>
<th>{{ T.biz_flow_type|default:'流程' }}</th>
<th>{{ T.biz_small_model|default:'小模型' }}</th>
<th>{{ T.biz_targets|default:'目标' }}</th>
<th>{{ T.biz_llm|default:'大模型' }}</th>
<th>{{ T.biz_post_process|default:'后处理' }}</th>
<th>{{ T.biz_zone_count|default:'布控' }}</th>
<th>{{ T.col_state|default:'状态' }}</th>
<th>{{ T.col_actions|default:'操作' }}</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<div class="biz-empty" id="empty" style="display:none;">{{ T.biz_algo_empty|default:'暂无业务算法,点击右上角添加' }}</div>
</div>
</div>
<!-- 添加/编辑 -->
<div class="biz-modal-overlay" id="modal" onclick="if(event.target===this)closeModal()">
<div class="biz-modal" role="dialog">
<div class="biz-modal-head">
<div>
<h3 class="biz-modal-title" id="modalTitle">{{ T.biz_algo_add|default:'添加业务算法' }}</h3>
<p class="biz-modal-sub" id="modalSub">{{ T.biz_modal_sub|default:'配置推理流程与后处理业务逻辑,保存后在布控管理中绑定区域' }}</p>
</div>
<button type="button" class="biz-modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="biz-modal-body">
<input type="hidden" id="f_id">
<div class="biz-section">
<div class="biz-section-title">{{ T.biz_section_basic|default:'基本信息' }}</div>
<div class="biz-field">
<label>{{ T.biz_algo_name|default:'业务算法名称' }}</label>
<input type="text" class="biz-input" id="f_name" placeholder="{{ T.biz_algo_name_ph|default:'如:周界入侵-人员' }}">
</div>
<div class="biz-field">
<label>{{ T.biz_flow_type|default:'流程类型' }}</label>
<select class="biz-input" id="f_flow_type" onchange="onFlowChange()">
<option value="1">{{ T.biz_flow_1|default:'小模型 + 后处理' }}</option>
<option value="2">{{ T.biz_flow_2|default:'大模型 + 后处理' }}</option>
<option value="3">{{ T.biz_flow_3|default:'小模型 + 大模型 + 后处理' }}</option>
<option value="4">{{ T.biz_flow_4|default:'检测小模型 + ReID小模型 + 后处理' }}</option>
</select>
</div>
</div>
<div class="biz-section flow-panel show" id="panelSmall">
<div class="biz-section-title" id="panelSmallTitle">{{ T.biz_section_small|default:'小模型推理' }}</div>
<div class="biz-field" id="fieldDetector" style="display:none;">
<label>{{ T.biz_detector_model|default:'检测小模型 (YOLO)' }} *</label>
<select class="biz-input" id="f_detector_model" onchange="onDetectorModelChange()"></select>
<div class="biz-hint" id="detectorModelHint" style="display:none;"></div>
</div>
<div class="biz-field">
<label id="labelSmallModel">{{ T.biz_small_model|default:'小模型' }}</label>
<select class="biz-input" id="f_small_model" onchange="onSmallModelChange()"></select>
<div class="biz-hint" id="smallModelHint" style="display:none;"></div>
</div>
<div class="biz-field" id="fieldReidHint" style="display:none;">
<div class="biz-hint" style="color:#1e40af;background:#eff6ff;padding:8px 10px;border-radius:6px;">
{{ T.biz_reid_flow_hint|default:'ReID 不能单独检测全图:先用 YOLO 定位目标,再用 OSNet 提取特征并关联 track_id最后执行后处理如越线计数。' }}
</div>
</div>
<div class="biz-field">
<label>{{ T.biz_targets|default:'检测目标' }}</label>
<select class="biz-multi-select" id="f_target_labels" multiple size="6"></select>
<div class="biz-hint">{{ T.biz_targets_multi_hint|default:'单击选择;按住 CtrlMac 为 ⌘)可多选' }}</div>
</div>
</div>
<div class="biz-section flow-panel" id="panelLlm">
<div class="biz-section-title">{{ T.biz_section_llm|default:'大模型推理' }}</div>
<div class="biz-field">
<label>{{ T.biz_llm|default:'大模型' }}</label>
<select class="biz-input" id="f_llm"></select>
</div>
<div class="biz-field">
<label>{{ T.biz_llm_prompt|default:'提示词' }}</label>
<textarea class="biz-input" id="f_llm_prompt" placeholder="{{ T.biz_llm_prompt_ph|default:'描述需要大模型判断的场景…' }}"></textarea>
</div>
<div class="biz-field">
<label>{{ T.biz_llm_validate|default:'提示词校验值' }}</label>
<input type="text" class="biz-input" id="f_llm_validate" placeholder="{{ T.biz_llm_validate_ph|default:'多个关键词用英文逗号分隔' }}">
<div class="biz-hint">{{ T.biz_llm_validate_hint|default:'大模型回复中包含任一关键词则通过校验' }}</div>
</div>
</div>
<div class="biz-section">
<div class="biz-section-title">{{ T.biz_section_post|default:'后处理' }}</div>
<div class="biz-field">
<label>{{ T.biz_post_process|default:'后处理逻辑' }}</label>
<div style="display:flex;gap:8px;align-items:center;">
<select class="biz-input" id="f_post_process" style="flex:1;">
<option value="AREA">{{ T.biz_post_area|default:'区域入侵' }}</option>
<option value="LINE_CROSS">{{ T.biz_post_line_cross|default:'越线检测' }}</option>
<option value="LINE_COUNT">{{ T.biz_post_line_count|default:'越线计数' }}</option>
<option value="DIRECTION">{{ T.biz_post_direction|default:'方向入侵' }}</option>
<option value="DENSITY">{{ T.biz_post_density|default:'密度报警' }}</option>
<option value="DWELL">{{ T.biz_post_dwell|default:'滞留报警' }}</option>
</select>
<button type="button" class="btn" style="white-space:nowrap;" onclick="openPostHelp()">{{ T.biz_post_help|default:'说明' }}</button>
</div>
<div class="biz-hint" id="postHint">{{ T.biz_post_area_hint|default:'目标中心进入布控多边形即触发报警' }}</div>
</div>
<div class="biz-field" id="panelLineCount" style="display:none;">
<label>{{ T.biz_line_count_threshold|default:'计数报警阈值' }}</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<span style="font-size:11px;color:#64748b;">{{ T.biz_forward_count|default:'正向 (A→B)' }}</span>
<input type="number" class="biz-input" id="f_forward_count_threshold" min="0" value="0" placeholder="0=不报警">
</div>
<div>
<span style="font-size:11px;color:#64748b;">{{ T.biz_reverse_count|default:'逆向 (B→A)' }}</span>
<input type="number" class="biz-input" id="f_reverse_count_threshold" min="0" value="0" placeholder="0=不报警">
</div>
</div>
<div class="biz-hint">{{ T.biz_line_count_threshold_hint|default:'累计过线人数达到阈值时触发报警0 表示该方向不计数报警。布控需绘制矩形区域 + 方向线 A→B 定义正向。' }}</div>
</div>
<div class="biz-field">
<label>{{ T.col_state|default:'状态' }}</label>
<select class="biz-input" id="f_state">
<option value="1">{{ T.state_enabled|default:'启用' }}</option>
<option value="0">{{ T.state_disabled|default:'禁用' }}</option>
</select>
</div>
</div>
</div>
<div class="biz-modal-foot">
<button type="button" class="btn" onclick="closeModal()">{{ T.btn_cancel|default:'取消' }}</button>
<button type="button" class="btn btn-primary" onclick="saveForm()">{{ T.btn_save|default:'保存' }}</button>
</div>
</div>
</div>
<!-- 分配布控 -->
<div class="biz-modal-overlay" id="assignModal" onclick="if(event.target===this)closeAssignModal()">
<div class="biz-modal assign-modal" role="dialog">
<div class="biz-modal-head">
<div>
<h3 class="biz-modal-title" id="assignTitle">{{ T.biz_assign_zones|default:'分配布控' }}</h3>
<p class="biz-modal-sub" id="assignSub">{{ T.biz_assign_zones_sub|default:'勾选需要绑定该业务算法的布控区域' }}</p>
</div>
<button type="button" class="biz-modal-close" onclick="closeAssignModal()">&times;</button>
</div>
<div class="biz-modal-body" style="padding-top:8px;">
<div class="assign-toolbar">
<button type="button" class="btn" style="padding:4px 10px;font-size:11px;" onclick="toggleAllZones(true)">{{ T.alg_select_all|default:'全选' }}</button>
<button type="button" class="btn" style="padding:4px 10px;font-size:11px;" onclick="toggleAllZones(false)">{{ T.alg_select_none|default:'全不选' }}</button>
<span id="assignCount" style="font-size:11px;color:#6b7280;margin-left:auto;"></span>
</div>
<div class="assign-list" id="assignList"></div>
</div>
<div class="biz-modal-foot">
<button type="button" class="btn" onclick="closeAssignModal()">{{ T.btn_cancel|default:'取消' }}</button>
<button type="button" class="btn btn-primary" onclick="submitAssign()">{{ T.biz_save_assign|default:'保存分配' }}</button>
</div>
</div>
</div>
<!-- 后处理说明 -->
<div class="biz-modal-overlay" id="postHelpModal" onclick="if(event.target===this)closePostHelp()">
<div class="biz-modal" role="dialog" style="width:min(680px,96vw);">
<div class="biz-modal-head">
<div>
<h3 class="biz-modal-title">{{ T.biz_post_help_title|default:'后处理方案说明' }}</h3>
<p class="biz-modal-sub">{{ T.biz_post_help_sub|default:'共 6 种后处理,按场景选择' }}</p>
</div>
<button type="button" class="biz-modal-close" onclick="closePostHelp()">&times;</button>
</div>
<div class="biz-modal-body">
<div class="post-help-item" style="padding:14px 0;border-bottom:1px solid #f3f4f6;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span class="tag tag-post" style="background:#d1fae5;color:#065f46;">AREA</span>
<b style="font-size:14px;color:#1e293b;">{{ T.biz_post_area|default:'区域入侵' }}</b>
</div>
<div style="font-size:13px;color:#475569;line-height:1.6;">
{{ T.biz_post_area_desc|default:'目标中心点进入布控多边形内即触发报警。适用于周界防范、禁入区域、危险区域等场景。' }}
</div>
</div>
<div class="post-help-item" style="padding:14px 0;border-bottom:1px solid #f3f4f6;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span class="tag tag-post" style="background:#dbeafe;color:#1e40af;">LINE_CROSS</span>
<b style="font-size:14px;color:#1e293b;">{{ T.biz_post_line_cross|default:'越线检测' }}</b>
</div>
<div style="font-size:13px;color:#475569;line-height:1.6;">
{{ T.biz_post_line_cross_desc|default:'有人沿方向线正向A→B跨过警戒线时立即触发一次报警。适用于围栏越界、禁止闯入等「一过线就报」的场景。' }}
</div>
</div>
<div class="post-help-item" style="padding:14px 0;border-bottom:1px solid #f3f4f6;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span class="tag tag-post" style="background:#cffafe;color:#0e7490;">LINE_COUNT</span>
<b style="font-size:14px;color:#1e293b;">{{ T.biz_post_line_count|default:'越线计数' }}</b>
</div>
<div style="font-size:13px;color:#475569;line-height:1.6;">
{{ T.biz_post_line_count_desc|default:'按方向线 A→B 分别累计正向、逆向过线人数;累计达到阈值才报警。适用于人流统计、限流等场景。' }}
</div>
<div style="font-size:12px;color:#64748b;margin-top:8px;padding:8px 10px;background:#f8fafc;border-radius:6px;">
{{ T.biz_post_line_diff|default:'与越线检测的区别:越线检测是「过线立刻报」;越线计数是「累计到 N 人才报」,并可分别统计正向/逆向。' }}
</div>
</div>
<div class="post-help-item" style="padding:14px 0;border-bottom:1px solid #f3f4f6;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span class="tag tag-post" style="background:#fef3c7;color:#92400e;">DIRECTION</span>
<b style="font-size:14px;color:#1e293b;">{{ T.biz_post_direction|default:'方向入侵' }}</b>
</div>
<div style="font-size:13px;color:#475569;line-height:1.6;">
{{ T.biz_post_direction_desc|default:'目标在区域内移动时,若运动方向与设定方向一致则报警。适用于单向通行、逆行检测等场景。' }}
</div>
</div>
<div class="post-help-item" style="padding:14px 0;border-bottom:1px solid #f3f4f6;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span class="tag tag-post" style="background:#f3e8ff;color:#6b21a8;">DENSITY</span>
<b style="font-size:14px;color:#1e293b;">{{ T.biz_post_density|default:'密度报警' }}</b>
</div>
<div style="font-size:13px;color:#475569;line-height:1.6;">
{{ T.biz_post_density_desc|default:'布控区域内同时存在的目标数达到或超过阈值时报警。适用于人群聚集、排队过长、车辆拥堵等场景。' }}
</div>
</div>
<div class="post-help-item" style="padding:14px 0;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
<span class="tag tag-post" style="background:#fee2e2;color:#991b1b;">DWELL</span>
<b style="font-size:14px;color:#1e293b;">{{ T.biz_post_dwell|default:'滞留报警' }}</b>
</div>
<div style="font-size:13px;color:#475569;line-height:1.6;">
{{ T.biz_post_dwell_desc|default:'目标在布控区域内停留时间超过滞留阈值(秒)时报警。与区域入侵不同:区域入侵是进入即报,滞留是待够时间才报。' }}
</div>
</div>
</div>
<div class="biz-modal-foot">
<button type="button" class="btn btn-primary" onclick="closePostHelp()">{{ T.btn_ok|default:'我知道了' }}</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
var g_options = {small_models:[], llms:[]};
var g_assignBizId = 0;
function _t(k,d){ return (window.T && window.T[k]) || d; }
function esc(s){ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;'); }
function loadOptions(cb){
Api.get('/algorithm/openOptions').then(function(res){
if(res.code===1000){ g_options = res.data || g_options; fillSelects(); if(cb) cb(); }
});
}
function fillSelects(){
refreshModelSelects();
var ll = document.getElementById('f_llm');
ll.innerHTML = '<option value="">'+_t('please_select','请选择')+'</option>';
(g_options.llms||[]).forEach(function(m){
var label = m.name || m.model_name || (m.id ? ('#'+m.id) : '');
ll.innerHTML += '<option value="'+m.id+'">'+esc(label)+'</option>';
});
}
function isDetectModel(m){
return m && (m.task_type || 'detect').toLowerCase() !== 'reid';
}
function isReidModel(m){
return m && (m.task_type || '').toLowerCase() === 'reid';
}
function modelOptionLabel(m){
var flag = m.model_file_exists ? ' [✓就绪]' : (m.model_file ? ' [✗文件缺失]' : ' [✗未配置]');
return esc(m.name) + flag;
}
function refreshModelSelects(){
var ft = getFlowType();
var isReidFlow = ft === 4;
var detSel = document.getElementById('f_detector_model');
var smSel = document.getElementById('f_small_model');
var prevDet = detSel ? detSel.value : '';
var prevSm = smSel ? smSel.value : '';
if(detSel){
detSel.innerHTML = '<option value="">'+_t('please_select','请选择')+'</option>';
(g_options.small_models||[]).filter(isDetectModel).forEach(function(m){
detSel.innerHTML += '<option value="'+m.id+'">'+modelOptionLabel(m)+'</option>';
});
if(prevDet) detSel.value = prevDet;
}
if(smSel){
smSel.innerHTML = '<option value="">'+_t('please_select','请选择')+'</option>';
var list = isReidFlow
? (g_options.small_models||[]).filter(isReidModel)
: (g_options.small_models||[]).filter(isDetectModel);
list.forEach(function(m){
smSel.innerHTML += '<option value="'+m.id+'">'+modelOptionLabel(m)+'</option>';
});
if(prevSm) smSel.value = prevSm;
}
document.getElementById('fieldDetector').style.display = isReidFlow ? '' : 'none';
document.getElementById('fieldReidHint').style.display = isReidFlow ? '' : 'none';
document.getElementById('labelSmallModel').textContent = isReidFlow
? _t('biz_reid_model','ReID 小模型 (OSNet)')
: _t('biz_small_model','小模型');
document.getElementById('panelSmallTitle').textContent = isReidFlow
? _t('biz_section_detect_reid','检测 + ReID 推理')
: _t('biz_section_small','小模型推理');
}
function onFlowChange(){
var ft = getFlowType();
document.getElementById('panelSmall').classList.toggle('show', ft===1 || ft===3 || ft===4);
document.getElementById('panelLlm').classList.toggle('show', ft===2 || ft===3);
refreshModelSelects();
onDetectorModelChange();
onSmallModelChange();
}
function showModelHint(el, m){
if(!el) return;
if(!m){ el.style.display='none'; return; }
el.style.display = 'block';
if(m.model_file_exists){
el.style.color = '#065f46';
el.innerHTML = '✓ 模型文件已就绪:<b>'+esc(m.model_file)+'</b>'+(m.engine?(' · 引擎 '+esc(m.engine)):'');
} else if(m.model_file){
el.style.color = '#991b1b';
el.innerHTML = '✗ 模型文件不存在:<b>'+esc(m.model_file)+'</b>,请到「小模型管理」上传或修改路径';
} else {
el.style.color = '#991b1b';
el.innerHTML = '✗ 未配置模型文件,请到「小模型管理」设置模型文件路径';
}
}
function onDetectorModelChange(){
if(getFlowType() !== 4) return;
var id = parseInt(document.getElementById('f_detector_model').value, 10);
var m = (g_options.small_models||[]).find(function(x){ return x.id===id; });
fillTargetSelect(m ? m.labels : [], getSelectedTargets());
showModelHint(document.getElementById('detectorModelHint'), m);
}
function getFlowType(){
return parseInt(document.getElementById('f_flow_type').value, 10) || 1;
}
function onSmallModelChange(){
var ft = getFlowType();
var id = parseInt(document.getElementById('f_small_model').value, 10);
var m = (g_options.small_models||[]).find(function(x){ return x.id===id; });
if(ft === 4){
showModelHint(document.getElementById('smallModelHint'), m);
return;
}
fillTargetSelect(m ? m.labels : [], []);
showModelHint(document.getElementById('smallModelHint'), m);
}
function fillTargetSelect(labels, selected){
var sel = document.getElementById('f_target_labels');
if(!sel) return;
sel.innerHTML = '';
if(!labels || !labels.length){
var o = document.createElement('option');
o.disabled = true;
o.textContent = _t('biz_no_labels','请先选择小模型');
sel.appendChild(o);
return;
}
var selSet = {};
(selected||[]).forEach(function(lb){ selSet[lb] = true; });
labels.forEach(function(lb){
var o = document.createElement('option');
o.value = lb;
o.textContent = lb;
if(selSet[lb]) o.selected = true;
sel.appendChild(o);
});
}
function getSelectedTargets(){
var sel = document.getElementById('f_target_labels');
if(!sel) return [];
return Array.from(sel.selectedOptions).map(function(o){ return o.value; });
}
function sortByIdDesc(list){
return (list || []).slice().sort(function(a, b){ return (parseInt(b.id, 10) || 0) - (parseInt(a.id, 10) || 0); });
}
function loadList(){
Api.get('/algorithm/openIndex').then(function(res){
var rows = sortByIdDesc(res.data || []);
document.getElementById('empty').style.display = rows.length ? 'none' : 'block';
document.getElementById('tbody').innerHTML = rows.map(function(r){
var targets = (r.target_labels||[]).join(', ') || '—';
var st = r.state===1
? '<span class="tag tag-on">'+_t('state_enabled','启用')+'</span>'
: '<span class="tag tag-off">'+_t('state_disabled','禁用')+'</span>';
// 小模型列:显示名称 + 文件状态徽标
var smCell = '—';
if(r.flow_type === 4){
var parts = [];
if(r.detector_model_name){
parts.push('<div><span style="font-size:10px;color:#64748b;">YOLO</span> <b>'+esc(r.detector_model_name)+'</b></div>');
}
if(r.small_model_name){
parts.push('<div><span style="font-size:10px;color:#64748b;">ReID</span> <b>'+esc(r.small_model_name)+'</b></div>');
}
smCell = parts.length ? parts.join('') : '—';
} else if(r.small_model_name){
var hasFile = !!r.small_model_file_exists;
var badge = hasFile
? '<span class="model-badge ok">✓就绪</span>'
: '<span class="model-badge miss">✗'+(r.small_model_file?'文件缺失':'未配置')+'</span>';
var sub = hasFile
? '<span class="model-sub">'+esc(r.small_model_engine||'')+(r.small_model_file?' · '+esc(r.small_model_file):'')+'</span>'
: '<span class="model-sub">'+(r.small_model_file?('文件: '+esc(r.small_model_file)):'未配置模型文件')+'</span>';
smCell = '<div class="model-name-wrap"><b>'+esc(r.small_model_name)+'</b>'+badge+'</div>'+sub;
}
return '<tr>'
+'<td class="biz-id-cell">'+r.id+'</td>'
+'<td><b>'+esc(r.name)+'</b></td>'
+'<td><span class="tag tag-flow">'+esc(r.flow_type_name)+'</span></td>'
+'<td>'+smCell+'</td>'
+'<td style="max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="'+esc(targets)+'">'+esc(targets)+'</td>'
+'<td>'+esc(r.llm_name||'—')+'</td>'
+'<td><span class="tag tag-post">'+esc(r.post_process_name)+'</span></td>'
+'<td>'+(r.zone_count||0)+'</td>'
+'<td>'+st+'</td>'
+'<td><div class="biz-actions">'
+'<button class="btn" onclick="openEdit('+r.id+')">'+_t('btn_edit','编辑')+'</button>'
+'<button class="btn" onclick="openAssignModal('+r.id+')">'+_t('biz_assign','分配')+'</button>'
+'<button class="btn" onclick="delRow('+r.id+')">'+_t('btn_delete','删除')+'</button>'
+'</div></td></tr>';
}).join('');
});
}
function openAdd(){
document.getElementById('modalTitle').textContent = _t('biz_algo_add','添加业务算法');
document.getElementById('f_id').value = '';
document.getElementById('f_name').value = '';
document.getElementById('f_flow_type').value = '1';
document.getElementById('f_detector_model').value = '';
document.getElementById('f_small_model').value = '';
document.getElementById('f_llm').value = '';
document.getElementById('f_llm_prompt').value = '';
document.getElementById('f_llm_validate').value = '';
document.getElementById('f_post_process').value = 'AREA';
document.getElementById('f_forward_count_threshold').value = '0';
document.getElementById('f_reverse_count_threshold').value = '0';
document.getElementById('f_state').value = '1';
onFlowChange();
onPostChange();
fillTargetSelect([], []);
onSmallModelChange();
document.getElementById('modal').classList.add('show');
}
function openEdit(id){
Api.get('/algorithm/openIndex').then(function(res){
var r = (res.data||[]).find(function(x){ return x.id===id; });
if(!r) return;
document.getElementById('modalTitle').textContent = _t('biz_algo_edit','编辑业务算法');
document.getElementById('f_id').value = r.id;
document.getElementById('f_name').value = r.name;
document.getElementById('f_flow_type').value = String(r.flow_type);
onFlowChange();
document.getElementById('f_detector_model').value = r.detector_model_id || '';
document.getElementById('f_small_model').value = r.small_model_id || '';
if(r.flow_type === 4){
var det = (g_options.small_models||[]).find(function(x){ return x.id===r.detector_model_id; });
fillTargetSelect(det ? det.labels : [], r.target_labels || []);
onDetectorModelChange();
} else {
var m = (g_options.small_models||[]).find(function(x){ return x.id===r.small_model_id; });
fillTargetSelect(m ? m.labels : [], r.target_labels || []);
}
onSmallModelChange();
(function(){
var llSel = document.getElementById('f_llm');
// 清理上次编辑时插入的“已禁用回显项”
Array.from(llSel.querySelectorAll('option[data-echo="1"]')).forEach(function(o){ o.remove(); });
// 若引用的 LLM 被禁用(不在启用列表中),补一个回显项,避免看起来像配置丢失
if (r.llm_id && !llSel.querySelector('option[value="'+r.llm_id+'"]')) {
var opt = document.createElement('option');
opt.value = r.llm_id;
opt.textContent = (r.llm_name || ('#'+r.llm_id)) + ' ['+_t('state_disabled','禁用')+']';
opt.setAttribute('data-echo','1');
llSel.appendChild(opt);
}
llSel.value = r.llm_id || '';
})();
document.getElementById('f_llm_prompt').value = r.llm_prompt || '';
document.getElementById('f_llm_validate').value = r.llm_validate || '';
document.getElementById('f_post_process').value = r.post_process || 'AREA';
document.getElementById('f_forward_count_threshold').value = r.forward_count_threshold || 0;
document.getElementById('f_reverse_count_threshold').value = r.reverse_count_threshold || 0;
document.getElementById('f_state').value = String(r.state);
onPostChange();
document.getElementById('modal').classList.add('show');
});
}
function closeModal(){ document.getElementById('modal').classList.remove('show'); }
function saveForm(){
var payload = {
id: document.getElementById('f_id').value,
name: document.getElementById('f_name').value.trim(),
flow_type: getFlowType(),
small_model_id: document.getElementById('f_small_model').value,
detector_model_id: document.getElementById('f_detector_model').value,
target_labels: getSelectedTargets(),
llm_id: document.getElementById('f_llm').value,
llm_prompt: document.getElementById('f_llm_prompt').value.trim(),
llm_validate: document.getElementById('f_llm_validate').value.trim(),
post_process: document.getElementById('f_post_process').value,
forward_count_threshold: parseInt(document.getElementById('f_forward_count_threshold').value, 10) || 0,
reverse_count_threshold: parseInt(document.getElementById('f_reverse_count_threshold').value, 10) || 0,
state: document.getElementById('f_state').value
};
var url = payload.id ? '/algorithm/openEdit' : '/algorithm/openAdd';
Api.post(url, payload).then(function(res){
if(res.code===1000){ closeModal(); loadList(); showToast(res.msg||_t('msg_success','成功'), 'success'); }
else showToast(res.msg||_t('msg_failed','失败'), 'error');
});
}
function delRow(id){
// 删除前先查询该算法是否被布控引用,有引用则直接弹提示,不进入删除流程
Api.get('/algorithm/openAssignContext', {biz_algorithm_id: id}).then(function(res){
if(res.code!==1000 || !res.data){
// 查询失败,回退到直接删除(后端会再次校验)
_doDelBiz(id);
return;
}
var zones = (res.data.zones || []).filter(function(z){ return z.selected; });
if(zones.length){
var names = zones.map(function(z){
return (z.stream_name||('#'+z.stream_id)) + '/' + (z.zone_name||('#'+z.id));
}).join('、');
showToast('该算法已被 '+zones.length+' 个布控引用,请先在「分配布控」中解除绑定后再删除:'+names, 'error', 6000);
return;
}
_doDelBiz(id);
}).catch(function(){ _doDelBiz(id); });
}
function _doDelBiz(id){
if(!confirm(_t('confirm_delete','确定删除?'))) return;
Api.post('/algorithm/openDel', {id:id}).then(function(res){
if(res.code===1000) loadList();
showToast(res.msg||(res.code===1000?_t('msg_success','成功'):_t('msg_failed','失败')), res.code===1000?'success':'error');
});
}
function openAssignModal(bizId){
g_assignBizId = bizId;
document.getElementById('assignModal').classList.add('show');
Api.get('/algorithm/openAssignContext', {biz_algorithm_id: bizId}).then(function(res){
if(res.code!==1000){ showToast(res.msg||_t('msg_failed','失败'), 'error'); return; }
var biz = res.data.biz_algorithm || {};
document.getElementById('assignTitle').textContent = _t('biz_assign_for','分配布控 · ')+ (biz.name||('#'+bizId));
var zones = res.data.zones || [];
var list = document.getElementById('assignList');
if(!zones.length){
list.innerHTML = '<div class="assign-empty">'+_t('biz_no_zones','暂无布控区域,请先到布控管理添加')+'</div>';
updateAssignCount();
return;
}
list.innerHTML = zones.map(function(z){
var stateTag = z.state === 1
? ''
: ' · <span style="color:#9ca3af;">'+_t('zone_disabled','禁用')+'</span>';
return '<label class="assign-item">'
+'<input type="checkbox" value="'+z.id+'"'+(z.selected?' checked':'')+'>'
+'<span class="nm">'+esc(z.zone_name)+stateTag+'</span>'
+'<span class="sub">'+esc(z.stream_name||('stream#'+z.stream_id))+'</span>'
+'</label>';
}).join('');
list.querySelectorAll('input').forEach(function(cb){ cb.addEventListener('change', updateAssignCount); });
updateAssignCount();
});
}
function closeAssignModal(){ document.getElementById('assignModal').classList.remove('show'); }
function toggleAllZones(check){
document.querySelectorAll('#assignList input[type=checkbox]').forEach(function(cb){ cb.checked = check; });
updateAssignCount();
}
function updateAssignCount(){
var n = document.querySelectorAll('#assignList input[type=checkbox]:checked').length;
document.getElementById('assignCount').textContent = _t('biz_zones_selected','已选 {n} 个布控').replace('{n}', n);
}
function submitAssign(){
var ids = Array.from(document.querySelectorAll('#assignList input[type=checkbox]:checked')).map(function(cb){
return parseInt(cb.value, 10);
});
Api.post('/algorithm/openAssignZones', {biz_algorithm_id: g_assignBizId, zone_ids: ids}).then(function(res){
showToast(res.msg||(res.code===1000?_t('msg_success','成功'):_t('msg_failed','失败')), res.code===1000?'success':'error');
if(res.code===1000){ closeAssignModal(); loadList(); }
});
}
document.addEventListener('keydown', function(e){
if(e.key!=='Escape') return;
if(document.getElementById('modal').classList.contains('show')) closeModal();
if(document.getElementById('assignModal').classList.contains('show')) closeAssignModal();
if(document.getElementById('postHelpModal').classList.contains('show')) closePostHelp();
});
// 后处理说明弹框
function openPostHelp(){ document.getElementById('postHelpModal').classList.add('show'); }
function closePostHelp(){ document.getElementById('postHelpModal').classList.remove('show'); }
// 下拉联动:切换提示文案
var POST_HINTS = {
'AREA': '{{ T.biz_post_area_hint|default:"目标中心进入布控多边形即触发报警" }}',
'LINE_CROSS': '{{ T.biz_post_line_cross_hint|default:"目标轨迹跨过警戒线 line_a→line_b 触发,需在布控区域配置警戒线" }}',
'LINE_COUNT': '{{ T.biz_post_line_count_hint|default:"按方向线 A→B 统计正向/逆向过线,达到阈值报警;布控需绘制矩形+方向线" }}',
'DIRECTION': '{{ T.biz_post_direction_hint|default:"目标移动方向匹配 ref_angle 触发,参考角度 0=东 90=南 180=西 270=北" }}',
'DENSITY': '{{ T.biz_post_density_hint|default:"区域内目标数 ≥ density_threshold 触发,需在布控区域配置密度阈值" }}',
'DWELL': '{{ T.biz_post_dwell_hint|default:"目标在区域内停留 ≥ loiter_threshold 秒触发" }}'
};
function onPostChange(){
var v = document.getElementById('f_post_process').value;
document.getElementById('postHint').textContent = POST_HINTS[v] || '';
var panel = document.getElementById('panelLineCount');
if(panel) panel.style.display = (v === 'LINE_COUNT') ? 'block' : 'none';
}
document.getElementById('f_post_process').addEventListener('change', onPostChange);
loadOptions(function(){ loadList(); });
</script>
{% endblock %}