video_monitor/templates/app/stream/index.html

1040 lines
46 KiB
HTML
Raw Normal View History

2026-08-30 22:22:11 +08:00
{% extends "app/base.html" %}
{% load static %}
{% block title %}{{ T.page_stream|default:'视频管理' }} - {{ settings.name|default:"Monitor" }}{% endblock %}
{% block nav_stream %}active{% endblock %}
{% block page_title %}{{ T.page_stream|default:'视频管理' }}{% endblock %}
{% block extra_head %}
<style>
/* 分组卡片 */
.sg-wrap { display: flex; flex-direction: column; gap: 10px; }
.sg-group {
background: #fff;
border: 1px solid var(--c-border-light, #eef2f5);
border-radius: 8px;
overflow: hidden;
}
.sg-head {
display: flex;
align-items: center;
padding: 4px 16px;
background: #fff;
border-bottom: 1px solid var(--c-border-light, #eef2f5);
gap: 10px;
}
.sg-device {
font-size: 13px;
font-weight: 600;
color: #1f2937;
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.sg-device::before {
content: '';
display: inline-block;
width: 3px;
height: 14px;
background: #169F85;
border-radius: 2px;
}
.sg-count {
background: #e6f7f3;
color: #0e7a65;
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
min-width: 22px;
text-align: center;
}
.sg-toggle {
width: 28px; height: 28px;
border-radius: 6px;
border: 1px solid #d1d5db;
background: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all .2s;
flex-shrink: 0;
padding: 0;
}
.sg-toggle:hover { border-color: #169F85; color: #169F85; background: #f0f9f6; }
.sg-toggle svg { width: 14px; height: 14px; transition: transform .25s; }
.sg-toggle.open svg { transform: rotate(180deg); }
.sg-body {
max-height: 0;
overflow: hidden;
transition: max-height .35s ease;
}
.sg-body.open {
max-height: 2000px;
}
.sg-body .data-table { margin: 0; table-layout: fixed; width: 100%; }
.sg-body .table-wrap { padding: 0; }
.sg-body .data-table td {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ID */
.sg-body .data-table th:nth-child(1),
.sg-body .data-table td:nth-child(1) { width: 60px; }
/* 编号 */
.sg-body .data-table th:nth-child(2),
.sg-body .data-table td:nth-child(2) { width: 16%; }
/* 接入协议 */
.sg-body .data-table th:nth-child(3),
.sg-body .data-table td:nth-child(3) { width: 10%; }
/* 名称 */
.sg-body .data-table th:nth-child(4),
.sg-body .data-table td:nth-child(4) { width: 12%; }
/* IP */
.sg-body .data-table th:nth-child(5),
.sg-body .data-table td:nth-child(5) { width: 11%; }
/* 状态 */
.sg-body .data-table th:nth-child(6),
.sg-body .data-table td:nth-child(6) { width: 5%; }
/* 更新时间 */
.sg-body .data-table th:nth-child(7),
.sg-body .data-table td:nth-child(7) { width: 13%; }
/* 操作 */
.sg-body .data-table th:nth-child(8),
.sg-body .data-table td:nth-child(8) { width: 148px; }
/* 操作列图标按钮 */
.act-bar { display: flex; gap: 2px; align-items: center; justify-content: center; }
.act-btn {
width: 30px; height: 30px;
border-radius: 6px;
border: none;
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all .15s;
color: #6b7280;
padding: 0;
text-decoration: none;
}
.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-on { color: #169F85; }
.act-btn.act-on:hover { background: #e6f7f3; }
.act-btn.act-rec-on { color: #dc2626; position: relative; }
.act-btn.act-rec-on:hover { background: #fef2f2; }
.act-btn.act-rec-on::after {
content: ''; position: absolute; top: 4px; right: 4px;
width: 6px; height: 6px; border-radius: 50%; background: #ef4444;
box-shadow: 0 0 0 2px #fff; animation: recPulse 1.4s infinite;
}
.act-btn.act-rec-idle { color: #d97706; }
.act-btn.act-rec-idle:hover { background: #fffbeb; }
.act-btn svg { width: 16px; height: 16px; pointer-events: none; }
@keyframes recPulse { 0%,100%{opacity:1} 50%{opacity:.35} }
/* 状态指示点 */
.si { display: inline-block; width: 8px; height: 8px; border-radius: 50%; vertical-align: middle; }
.si-on { background: #22c55e; box-shadow: 0 0 4px rgba(34,197,94,.4); }
.si-off { background: #d1d5db; }
.sg-empty {
text-align: center;
padding: 48px 20px;
color: #9ca3af;
font-size: 14px;
background: #fff;
border-radius: 8px;
border: 1px solid var(--c-border-light, #eef2f5);
}
/* 批量操作下拉 */
.batch-dropdown { position: relative; display: inline-block; }
.batch-btn {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
font-size: 13px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
transition: all .15s;
}
.batch-btn:hover { border-color: #169F85; color: #169F85; }
.batch-btn svg { width: 12px; height: 12px; transition: transform .2s; }
.batch-dropdown.open .batch-btn svg { transform: rotate(180deg); }
.batch-menu {
position: absolute;
right: 0;
top: calc(100% + 4px);
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,.1);
min-width: 160px;
z-index: 100;
padding: 4px 0;
display: none;
}
.batch-dropdown.open .batch-menu { display: block; }
.batch-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
font-size: 13px;
color: #374151;
cursor: pointer;
transition: background .1s;
border: none;
background: none;
width: 100%;
text-align: left;
}
.batch-menu-item:hover { background: #f3f4f6; }
.batch-menu-item.danger { color: #b91c1c; }
.batch-menu-item.danger:hover { background: #fef2f2; }
.batch-menu-item svg { width: 14px; height: 14px; flex-shrink: 0; }
.batch-menu-sep { height: 1px; background: #e5e7eb; margin: 4px 0; }
/* ========== 弹框表单紧凑样式 ========== */
.modal .form-group { margin-bottom: 10px; }
.modal .form-label { font-size: 12px; margin-bottom: 4px; }
.modal .form-input, .modal .form-select { padding: 5px 8px; font-size: 12px; }
.modal .form-textarea { min-height: 48px; padding: 5px 8px; font-size: 12px; }
.modal .form-row { gap: 10px; }
/* 隐藏用户名和密码输入框 */
.modal .form-group.hide-field { display: none; }
/* ========== 美化文件选择框 ========== */
.file-input-wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 4px 4px 10px;
border: 1px solid var(--c-border);
border-radius: var(--r-md);
background: var(--c-surface);
transition: border-color var(--tr);
}
.file-input-wrap:hover { border-color: var(--c-border-strong, #d1d5db); }
.file-input-wrap:focus-within { border-color: var(--c-text); }
.file-input-hidden { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.file-input-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: #f3f4f6;
color: var(--c-text);
border: 1px solid var(--c-border);
border-radius: var(--r-sm);
font-size: 12px;
cursor: pointer;
user-select: none;
transition: background var(--tr), border-color var(--tr);
flex-shrink: 0;
}
.file-input-trigger:hover { background: #e5e7eb; border-color: #d1d5db; }
.file-input-trigger:active { background: #d1d5db; }
.file-input-btn-text { line-height: 1; }
.file-input-name {
flex: 1;
font-size: 12px;
color: var(--c-text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
.file-input-name.has-file { color: var(--c-text); }
/* ========== 添加/编辑视频弹框(对齐布控管理弹框尺寸) ========== */
#streamModal.modal-overlay {
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(15, 23, 42, .35);
backdrop-filter: blur(4px);
overflow: hidden;
}
#streamModal .stream-form-modal {
width: min(820px, 94vw);
max-width: min(820px, 94vw);
max-height: 86vh;
margin: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 12px;
border: 1px solid var(--c-border, #e2e8f0);
box-shadow: 0 16px 48px rgba(0, 0, 0, .12);
animation: streamModalIn .22s ease;
}
@keyframes streamModalIn {
from { opacity: 0; transform: scale(.98) translateY(6px); }
to { opacity: 1; transform: none; }
}
#streamModal .modal-header {
padding: 14px 20px;
background: linear-gradient(135deg, #f8fbfa 0%, #f0f9f6 100%);
flex-shrink: 0;
}
#streamModal .modal-title {
font-size: 15px;
display: flex;
align-items: center;
gap: 8px;
}
#streamModal .modal-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;
}
#streamModal .modal-close {
width: 32px;
height: 32px;
border: 1px solid var(--c-border, #e2e8f0);
border-radius: 8px;
background: #fff;
}
#streamModal .modal-close:hover {
border-color: #169F85;
color: #169F85;
background: #f0f9f6;
}
#streamModal .modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 20px;
max-height: none;
}
#streamModal .modal-footer {
padding: 12px 20px;
flex-shrink: 0;
background: linear-gradient(135deg, #f8fbfa 0%, #f0f9f6 100%);
}
#streamModal .collapse-section { margin-bottom: 10px; }
#streamModal .collapse-header { padding: 8px 12px; font-size: 12px; }
#streamModal .collapse-body { padding: 10px 12px 4px; }
</style>
{% endblock %}
{% block content %}
<div class="stream-page">
<!-- 页面头部 -->
<div class="page-head">
<div class="page-head-title">{{ T.page_stream|default:'视频管理' }}</div>
<div class="page-head-right">
<button class="btn" onclick="openOnvifModal()">{{ T.btn_onvif_discover|default:'ONVIF 发现' }}</button>
<button class="btn" onclick="exportFile()">{{ T.btn_export|default:'↓ 导出' }}</button>
<button class="btn" onclick="openImportModal()">{{ T.btn_import|default:'↑ 导入' }}</button>
<button class="btn btn-primary" onclick="openAddModal()">{{ T.btn_add_video|default:'+ 添加视频' }}</button>
</div>
</div>
<!-- 搜索栏 -->
<div class="card mb-16">
<div class="card-body">
<div class="search-bar">
<div class="search-input-wrap">
<span class="search-icon">🔍</span>
<input type="search" class="form-input" id="searchText" name="searchText" autocomplete="off" readonly onfocus="this.removeAttribute('readonly')" placeholder="{{ T.placeholder_search_stream|default:'输入名称或编号搜索' }}" onkeypress="if(event.key==='Enter')loadList(1)">
</div>
<select class="form-select" id="searchStatus" style="width:140px" onchange="loadList(1)">
<option value="-1">{{ T.filter_all_status|default:'全部状态' }}</option>
<option value="1">{{ T.status_forwarding|default:'转发中' }}</option>
<option value="0">{{ T.status_not_forwarding|default:'未转发' }}</option>
</select>
<button class="btn btn-primary" onclick="loadList(1)">{{ T.btn_search|default:'搜索' }}</button>
<button class="btn" onclick="resetSearch()">{{ T.btn_reset|default:'重置' }}</button>
<img id="searchLoading" src="{% static 'images/load.gif' %}" alt="loading" style="display:none;height:20px;margin-left:8px;vertical-align:middle">
<span style="flex:1"></span>
<button class="btn" onclick="loadList()">{{ T.btn_refresh_symbol|default:'↻ 刷新' }}</button>
<div class="batch-dropdown" id="batchDropdown">
<button class="batch-btn" onclick="toggleBatchMenu()">
{{ T.btn_batch_ops|default:'批量操作' }}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="batch-menu">
<button class="batch-menu-item" onclick="handleAllProxy('add')">
<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>
{{ T.batch_start_all_forward|default:'全部开启转发' }}
</button>
<button class="batch-menu-item" onclick="handleAllProxy('del')">
<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>
{{ T.batch_stop_all_forward|default:'全部停止转发' }}
</button>
<div class="batch-menu-sep"></div>
<button class="batch-menu-item danger" onclick="deleteAll()">
<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>
{{ T.batch_delete_all|default:'全部删除' }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 分组列表 -->
<div id="groupContainer" class="sg-wrap"></div>
<div style="margin-top:12px">
<div class="pagination" id="pagination"></div>
</div>
<!-- ========== 添加/编辑弹窗 ========== -->
<div class="modal-overlay" id="streamModal" onclick="if(event.target===this)closeModal('streamModal')">
<div class="modal stream-form-modal">
<div class="modal-header">
<span class="modal-title" id="streamModalTitle">添加视频</span>
<button class="modal-close" onclick="closeModal('streamModal')">&times;</button>
</div>
<div class="modal-body">
<form id="streamForm" onsubmit="return submitStreamForm(event)">
<!-- 基本信息 -->
<div class="collapse-section">
<div class="collapse-header expanded" onclick="toggleCollapse(this)">
<span>{{ T.section_basic_info|default:'基本信息' }}</span><span class="collapse-arrow"></span>
</div>
<div class="collapse-body show">
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ T.label_code|default:'编号' }} <span class="required">*</span></label>
<input type="text" class="form-input" id="f_code" readonly>
</div>
<div class="form-group">
<label class="form-label">{{ T.label_name|default:'名称' }} <span class="required">*</span></label>
<input type="text" class="form-input" id="f_nickname" placeholder="{{ T.placeholder_enter_name|default:'请输入名称' }}" maxlength="50">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ T.label_protocol|default:'接入协议' }} <span class="required">*</span></label>
<select class="form-select" id="f_pull_stream_type" onchange="onPullTypeChange()">
<option value="0">{{ T.option_please_select|default:'请选择' }}</option>
<option value="1">RTSP</option>
<option value="2">RTMP</option>
<option value="3">FLV</option>
<option value="4">HLS</option>
<option value="21">GB28181</option>
<option value="31">cRTSP</option>
<option value="32">cRTMP</option>
</select>
</div>
<div class="form-group">
<label class="form-label">{{ T.label_audio_type|default:'音频类型' }}</label>
<select class="form-select" id="f_is_audio">
<option value="0">{{ T.audio_pull_mute|default:'静音' }}</option>
<option value="1">{{ T.audio_pull_original|default:'原始音频' }}</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">{{ T.label_stream_url|default:'直播流地址' }} <span class="required">*</span></label>
<input type="text" class="form-input" id="f_pull_stream_url" placeholder="rtsp:// / rtmp:// / http:// ...">
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ T.label_stream_ip|default:'直播流IP' }}</label>
<input type="text" class="form-input" id="f_pull_stream_ip" placeholder="{{ T.placeholder_stream_ip|default:'如 192.168.1.100' }}">
</div>
<div class="form-group">
<label class="form-label">{{ T.label_port|default:'端口' }}</label>
<input type="number" class="form-input" id="f_pull_stream_port" placeholder="{{ T.placeholder_port|default:'如 554' }}" value="0">
</div>
<div class="form-group hide-field">
<label class="form-label">{{ T.label_username|default:'用户名' }}</label>
<input type="text" class="form-input" id="f_pull_stream_username" placeholder="{{ T.placeholder_onvif_username|default:'ONVIF用户名' }}">
</div>
<div class="form-group hide-field">
<label class="form-label">{{ T.label_password|default:'密码' }}</label>
<input type="text" class="form-input" id="f_pull_stream_password" placeholder="{{ T.placeholder_onvif_password|default:'ONVIF密码' }}">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">{{ T.label_camera_name|default:'摄像头名称' }}</label>
<input type="text" class="form-input" id="f_camera_name" placeholder="{{ T.placeholder_camera_name|default:'请输入摄像头名称' }}">
</div>
<div class="form-group">
<label class="form-label">{{ T.label_camera_manufacturer|default:'摄像头厂商' }}</label>
<input type="text" class="form-input" id="f_camera_manufacturer" placeholder="{{ T.placeholder_camera_manufacturer|default:'请输入摄像头厂商' }}">
</div>
</div>
<div class="form-group">
<label class="form-label">{{ T.label_camera_device_id|default:'分组编号国标设备ID' }}</label>
<input type="text" class="form-input" id="f_camera_device_id" placeholder="{{ T.placeholder_camera_device_id|default:'默认使用编号' }}">
</div>
<div class="form-group">
<label class="form-label">{{ T.label_remark|default:'备注' }}</label>
<input type="text" class="form-input" id="f_remark" placeholder="{{ T.placeholder_remark|default:'请输入备注信息' }}">
</div>
</div>
</div>
<!-- 录像设置 -->
<div class="collapse-section">
<div class="collapse-header" onclick="toggleCollapse(this)">
<span>{{ T.section_record_params|default:'录像设置' }}</span><span class="collapse-arrow"></span>
</div>
<div class="collapse-body">
<div class="form-group">
<label class="form-label">{{ T.label_enable_record|default:'启用 24/7 录像' }}</label>
<label class="switch">
<input type="checkbox" id="f_record_enable">
<span class="switch-slider"></span>
</label>
<div style="font-size:11px;color:#9ca3af;margin-top:6px">{{ T.label_record_hint|default:'开启后,在转发成功时自动分段录制到本地磁盘' }}</div>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal('streamModal')">{{ T.btn_cancel|default:'取消' }}</button>
<button class="btn btn-primary" onclick="submitStreamForm()">{{ T.btn_submit|default:'提交' }}</button>
</div>
</div>
</div>
<!-- ========== ONVIF 发现弹窗 ========== -->
<div class="modal-overlay" id="onvifModal">
<div class="modal modal-lg">
<div class="modal-header">
<span class="modal-title">{{ T.onvif_discover_title|default:'ONVIF 设备发现' }}</span>
<button class="modal-close" onclick="closeModal('onvifModal')">&times;</button>
</div>
<div class="modal-body">
<div class="search-bar" style="margin-bottom:12px">
<input type="text" class="form-input" id="onvif_user" placeholder="{{ T.label_username|default:'用户名' }}" style="width:140px" value="admin">
<input type="password" class="form-input" id="onvif_pass" placeholder="{{ T.label_password|default:'密码' }}" style="width:140px">
<label style="font-size:12px;display:flex;align-items:center;gap:4px">
<input type="checkbox" id="onvif_resolve" checked> {{ T.onvif_resolve_rtsp|default:'解析 RTSP' }}
</label>
<button class="btn btn-primary" onclick="scanOnvif()" id="btnOnvifScan">{{ T.onvif_scan|default:'开始扫描' }}</button>
</div>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th>{{ T.th_ip|default:'IP' }}</th>
<th>{{ T.label_name|default:'名称' }}</th>
<th>ONVIF</th>
<th>RTSP</th>
<th>{{ T.col_action|default:'操作' }}</th>
</tr>
</thead>
<tbody id="onvifBody">
<tr><td colspan="5" style="text-align:center;color:#9ca3af;padding:24px">{{ T.onvif_scan_hint|default:'点击「开始扫描」发现局域网 ONVIF 摄像头' }}</td></tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal('onvifModal')">{{ T.btn_close|default:'关闭' }}</button>
</div>
</div>
</div>
<!-- ========== 导入弹窗 ========== -->
<div class="modal-overlay" id="importModal">
<div class="modal">
<div class="modal-header">
<span class="modal-title">{{ T.modal_import_camera|default:'批量导入摄像头' }}</span>
<button class="modal-close" onclick="closeModal('importModal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">{{ T.label_import_file|default:'导入文件 (xlsx)' }}</label>
<div class="file-input-wrap">
<input type="file" id="importFile" accept=".xlsx,.xls" class="file-input-hidden">
<label for="importFile" class="file-input-trigger">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--c-text-tertiary)"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
<span class="file-input-btn-text">{{ T.btn_choose_file|default:'选择文件' }}</span>
</label>
<span class="file-input-name" id="importFileName">{{ T.placeholder_no_file|default:'未选择文件' }}</span>
</div>
</div>
<div class="form-group">
<label class="form-label">{{ T.label_import_remark|default:'导入备注' }}</label>
<input type="text" class="form-input" id="importRemark" placeholder="{{ T.placeholder_import_remark|default:'可选备注' }}">
</div>
<div style="font-size:13px;color:var(--c-text-tertiary);margin-top:12px">
<a href="/static/resource/streamTemplate.xlsx" style="color:var(--c-primary)">{{ T.btn_download_template|default:'↓ 下载导入模板' }}</a>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal('importModal')">{{ T.btn_cancel|default:'取消' }}</button>
<button class="btn btn-primary" onclick="submitImport()">{{ T.btn_submit|default:'提交' }}</button>
</div>
</div>
</div>
</div><!-- /.stream-page -->
{% endblock %}
{% block extra_js %}
<script>
var currentPage = 1;
var editMode = 'add'; // add or edit
// 加载列表
function loadList(page) {
if (page) currentPage = page;
var container = document.getElementById('groupContainer');
var searchLoading = document.getElementById('searchLoading');
if (searchLoading) searchLoading.style.display = 'inline-block';
container.innerHTML = '<div class="sg-empty">' + _t('loading', '加载中...') + '</div>';
var params = {
p: currentPage,
ps: 10,
search_text: document.getElementById('searchText').value.trim(),
search_status: document.getElementById('searchStatus').value
};
Api.get('/stream/openIndex', params).then(function(res) {
if (searchLoading) searchLoading.style.display = 'none';
if (res.code === 1000) {
renderTable(res.data || []);
renderPagination(document.getElementById('pagination'), res.pageData, loadList);
} else {
showGroupEmpty(res.msg);
}
}).catch(function() {
if (searchLoading) searchLoading.style.display = 'none';
showGroupEmpty(_t('load_failed', '加载失败'));
});
}
function showGroupEmpty(msg) {
var container = document.getElementById('groupContainer');
container.innerHTML = '<div class="sg-empty">' + (msg || _t('no_data', '暂无数据')) + '</div>';
}
function toggleGroup(btn) {
var head = btn.closest('.sg-head');
var body = head.nextElementSibling;
body.classList.toggle('open');
btn.classList.toggle('open');
}
function renderTable(data) {
var container = document.getElementById('groupContainer');
if (!data || data.length === 0) {
showGroupEmpty();
return;
}
// 构建流数据映射
window.streamMap = {};
data.forEach(function(group) {
var streams = Array.isArray(group) ? group : [group];
streams.forEach(function(s) { if (s.code) window.streamMap[s.code] = s; });
});
var html = '';
data.forEach(function(group) {
var streams = Array.isArray(group) ? group : [group];
var deviceId = streams[0].camera_device_id || streams[0].code || '--';
var count = streams.length;
html += '<div class="sg-group">';
html += '<div class="sg-head">';
html += '<div class="sg-device">' + escapeHtml(deviceId) + '</div>';
html += '<span class="sg-count">' + count + '</span>';
html += '<button class="sg-toggle open" onclick="toggleGroup(this)">';
html += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>';
html += '</button>';
html += '</div>';
html += '<div class="sg-body open">';
html += '<div class="table-wrap"><table class="data-table">';
html += '<thead><tr>';
html += '<th>' + _t('th_id', 'ID') + '</th>';
html += '<th>' + _t('label_code', '编号') + '</th>';
html += '<th>' + _t('label_protocol', '接入协议') + '</th>';
html += '<th>' + _t('label_name', '名称') + '</th>';
html += '<th>' + _t('th_ip', 'IP') + '</th>';
html += '<th>' + _t('th_status', '状态') + '</th>';
html += '<th>' + _t('th_update_time', '更新时间') + '</th>';
html += '<th>' + _t('th_action', '操作') + '</th>';
html += '</tr></thead><tbody>';
streams.forEach(function(d) { html += renderRow(d); });
html += '</tbody></table></div></div></div>';
});
container.innerHTML = html;
}
function renderRow(d) {
var id = d.id || '--';
var code = escapeHtml(d.code || '');
var nickname = escapeHtml(d.nickname || '');
var protocol = getPullStreamTypeName(d.pull_stream_type);
var ip = escapeHtml(d.pull_stream_ip || '--');
var recordTitle;
var recordCls = 'act-btn';
if (d.record_enable == 1 && d.is_recording == 1) {
recordTitle = _t('title_record_recording', '录制中 · 查看录像');
recordCls += ' act-rec-on';
} else if (d.record_enable == 1) {
recordTitle = _t('title_record_enabled_idle', '已启用录像 · 等待转发或查看历史');
recordCls += ' act-rec-idle';
} else {
recordTitle = _t('title_record_disabled_history', '录像已关闭 · 查看历史录像');
}
var recordBtn = '<a href="/record/index?stream_id=' + encodeURIComponent(d.id) + '" class="' + recordCls + '" title="' + recordTitle + '">'
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/>'
+ '<circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg></a>';
var forwardState = d.forward_state == 1
? '<span class="si si-on" title="' + _t('status_forwarding', '转发中') + '"></span>'
: '<span class="si si-off" title="' + _t('status_not_forwarding', '未转发') + '"></span>';
var updateTime = d.last_update_time || '--';
var playBtn = d.forward_state == 1
? '<a href="/stream/player?app=' + encodeURIComponent(d.app) + '&name=' + encodeURIComponent(d.name) + '" class="act-btn act-on" title="' + _t('title_play', '播放') + '"><svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21"/></svg></a>'
: '<button class="act-btn" disabled title="' + _t('title_play', '播放') + '"><svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21"/></svg></button>';
var proxyBtn = d.forward_state == 1
? '<button class="act-btn" onclick="delStreamProxy(\'' + code + '\')" title="' + _t('title_stop_forward', '停止转发') + '"><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></button>'
: '<button class="act-btn act-on" onclick="addStreamProxy(\'' + code + '\')" title="' + _t('title_start_forward', '开启转发') + '"><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></button>';
var editBtn = '<button class="act-btn" onclick="openEditModal(\'' + code + '\')" title="' + _t('btn_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></button>';
var delBtn = '<button class="act-btn act-danger" onclick="deleteStream(\'' + code + '\')" title="' + _t('btn_delete', '删除') + '"><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></button>';
return '<tr>' +
'<td>' + id + '</td>' +
'<td class="mono" style="font-size:12px">' + code + '</td>' +
'<td><span class="badge badge-neutral">' + protocol + '</span></td>' +
'<td>' + nickname + '</td>' +
'<td>' + ip + '</td>' +
'<td>' + forwardState + '</td>' +
'<td style="font-size:12px;color:var(--c-text-tertiary)">' + updateTime + '</td>' +
'<td><div class="act-bar">' + playBtn + recordBtn + proxyBtn + editBtn + delBtn + '</div></td>' +
'</tr>';
}
function resetSearch() {
document.getElementById('searchText').value = '';
document.getElementById('searchStatus').value = '-1';
loadList(1);
}
// 开启转发
function addStreamProxy(code) {
Api.post('/stream/openAddStreamProxy', { code: code }).then(function(res) {
showToast(res.msg, res.code === 1000 ? 'success' : 'error');
if (res.code === 1000) loadList();
});
}
// 停止转发
function delStreamProxy(code) {
Api.post('/stream/openDelStreamProxy', { code: code }).then(function(res) {
showToast(res.msg, res.code === 1000 ? 'success' : 'error');
if (res.code === 1000) loadList();
});
}
// 全部开启/停止转发
function handleAllProxy(handle) {
closeBatchMenu();
var msg = handle === 'add' ? _t('confirm_start_all_forward', '确认全部开启转发吗?') : _t('confirm_stop_all_forward', '确认全部停止转发吗?');
confirmDialog(msg, function() {
Api.post('/stream/openHandleAllStreamProxy', { handle: handle }).then(function(res) {
showToast(res.msg, res.code === 1000 ? 'success' : 'error');
if (res.code === 1000) setTimeout(loadList, 1000);
});
});
}
// 批量操作下拉菜单
function toggleBatchMenu() {
document.getElementById('batchDropdown').classList.toggle('open');
}
function closeBatchMenu() {
document.getElementById('batchDropdown').classList.remove('open');
}
document.addEventListener('click', function(e) {
var dd = document.getElementById('batchDropdown');
if (dd && !dd.contains(e.target)) closeBatchMenu();
});
// 删除单个
function deleteStream(code) {
confirmDialog(_t('confirm_delete_camera', '确认删除该摄像头吗?'), function() {
Api.post('/stream/openDel', { handle: 'one', code: code }).then(function(res) {
showToast(res.msg, res.code === 1000 ? 'success' : 'error');
if (res.code === 1000) loadList();
});
});
}
// 全部删除
function deleteAll() {
confirmDialog(_t('confirm_delete_all', '确认全部删除吗?此操作不可恢复!'), function() {
Api.post('/stream/openDel', { handle: 'all' }).then(function(res) {
showToast(res.msg, res.code === 1000 ? 'success' : 'error');
if (res.code === 1000) loadList();
});
});
}
// 导出
function exportFile() {
window.location.href = '/stream/openExportFile';
}
// ========== 添加/编辑 ==========
function openAddModal() {
editMode = 'add';
document.getElementById('streamModalTitle').textContent = _t('btn_add_video', '添加视频');
document.getElementById('f_code').readOnly = false;
Api.get('/stream/openAddContext').then(function(res) {
if (res.code === 1000) {
var s = res.stream || {};
fillForm(s);
openModal('streamModal');
} else {
showToast(res.msg, 'error');
}
});
}
function openEditModal(code) {
editMode = 'edit';
document.getElementById('streamModalTitle').textContent = _t('modal_edit_video', '编辑视频');
document.getElementById('f_code').readOnly = true;
Api.get('/stream/openEditContext', { code: code }).then(function(res) {
if (res.code === 1000) {
var s = res.stream || {};
fillForm(s);
openModal('streamModal');
} else {
showToast(res.msg, 'error');
}
});
}
function fillForm(s) {
document.getElementById('f_code').value = s.code || '';
document.getElementById('f_nickname').value = s.nickname || '';
document.getElementById('f_pull_stream_type').value = s.pull_stream_type || 0;
document.getElementById('f_is_audio').value = s.is_audio || 0;
document.getElementById('f_pull_stream_url').value = s.pull_stream_url || '';
document.getElementById('f_pull_stream_ip').value = s.pull_stream_ip || '';
document.getElementById('f_pull_stream_port').value = s.pull_stream_port || 0;
document.getElementById('f_pull_stream_username').value = s.pull_stream_username || '';
document.getElementById('f_pull_stream_password').value = s.pull_stream_password || '';
document.getElementById('f_camera_name').value = s.camera_name || '';
document.getElementById('f_camera_manufacturer').value = s.camera_manufacturer || '';
document.getElementById('f_remark').value = s.remark || '';
document.getElementById('f_camera_device_id').value = s.camera_device_id || '';
document.getElementById('f_record_enable').checked = s.record_enable == 1;
onPullTypeChange();
}
function onPullTypeChange() {
var type = parseInt(document.getElementById('f_pull_stream_type').value);
// 添加模式下,被动协议不允许直接添加
if (editMode === 'add' && (type === 21 || type === 31 || type === 32)) {
var names = { 21: 'GB28181', 31: 'cRTSP', 32: 'cRTMP' };
showToast(_t('toast_passive_protocol', names[type] + ' 为被动接入协议,不可直接添加'), 'warning');
document.getElementById('f_pull_stream_type').value = 0;
}
}
function submitStreamForm() {
var code = document.getElementById('f_code').value.trim();
var nickname = document.getElementById('f_nickname').value.trim();
if (!code) { showToast(_t('toast_code_required', '编号不能为空'), 'warning'); return false; }
if (!nickname) { showToast(_t('toast_name_required', '名称不能为空'), 'warning'); return false; }
var pullType = parseInt(document.getElementById('f_pull_stream_type').value);
if (editMode === 'add') {
if (pullType === 0) { showToast(_t('toast_protocol_required', '请选择接入协议'), 'warning'); return false; }
if (pullType === 21 || pullType === 31 || pullType === 32) {
var names = { 21: 'GB28181', 31: 'cRTSP', 32: 'cRTMP' };
showToast(_t('toast_passive_protocol', names[pullType] + ' 为被动接入协议,不可直接添加'), 'warning'); return false;
}
if (pullType === 1 || pullType === 2 || pullType === 3 || pullType === 4) {
var url = document.getElementById('f_pull_stream_url').value.trim();
if (!url) { showToast(_t('toast_stream_url_required', '直播流地址不能为空'), 'warning'); return false; }
}
}
var data = {
code: code,
nickname: nickname,
pull_stream_type: pullType,
pull_stream_url: document.getElementById('f_pull_stream_url').value.trim(),
pull_stream_ip: document.getElementById('f_pull_stream_ip').value.trim(),
pull_stream_port: parseInt(document.getElementById('f_pull_stream_port').value) || 0,
onvif_username: document.getElementById('f_pull_stream_username').value.trim(),
onvif_password: document.getElementById('f_pull_stream_password').value.trim(),
camera_name: document.getElementById('f_camera_name').value.trim(),
camera_manufacturer: document.getElementById('f_camera_manufacturer').value.trim(),
camera_device_id: document.getElementById('f_camera_device_id').value.trim(),
remark: document.getElementById('f_remark').value.trim(),
is_audio: parseInt(document.getElementById('f_is_audio').value),
record_enable: document.getElementById('f_record_enable').checked ? 1 : 0
};
var url = editMode === 'add' ? '/stream/openAdd' : '/stream/openEdit';
Api.post(url, data).then(function(res) {
showToast(res.msg, res.code === 1000 ? 'success' : 'error');
if (res.code === 1000) {
closeModal('streamModal');
loadList();
}
});
return false;
}
// ========== 导入 ==========
function openImportModal() {
document.getElementById('importFile').value = '';
document.getElementById('importFileName').textContent = _t('placeholder_no_file', '未选择文件');
document.getElementById('importFileName').classList.remove('has-file');
document.getElementById('importRemark').value = '';
openModal('importModal');
}
// 文件选择框美化:选中后显示文件名
(function() {
document.addEventListener('change', function(e) {
if (e.target && e.target.id === 'importFile') {
var nameEl = document.getElementById('importFileName');
if (e.target.files && e.target.files.length > 0) {
nameEl.textContent = e.target.files[0].name;
nameEl.classList.add('has-file');
} else {
nameEl.textContent = _t('placeholder_no_file', '未选择文件');
nameEl.classList.remove('has-file');
}
}
});
})();
function submitImport() {
var file = document.getElementById('importFile').files[0];
if (!file) { showToast(_t('toast_select_file', '请选择文件'), 'warning'); return; }
if (file.size > 10 * 1024 * 1024) { showToast(_t('toast_file_size_limit', '文件大小不能超过10M'), 'warning'); return; }
var fd = new FormData();
fd.append('file', file);
fd.append('importRemark', document.getElementById('importRemark').value.trim());
fetch('/stream/openImportFile', {
method: 'POST',
credentials: 'same-origin',
headers: csrfHeaders(),
body: fd
})
.then(function(r) { return r.json(); })
.then(function(res) {
showToast(res.msg, res.code === 1000 ? 'success' : 'error');
if (res.code === 1000) {
closeModal('importModal');
loadList();
}
});
}
// ========== ONVIF 发现 ==========
function openOnvifModal(){
document.getElementById('onvifBody').innerHTML = '<tr><td colspan="5" style="text-align:center;color:#9ca3af;padding:24px">'+_t('onvif_scan_hint','点击「开始扫描」发现局域网 ONVIF 摄像头')+'</td></tr>';
openModal('onvifModal');
}
function scanOnvif(){
var btn = document.getElementById('btnOnvifScan');
var body = document.getElementById('onvifBody');
btn.disabled = true;
body.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#9ca3af;padding:24px">'+_t('onvif_scanning','扫描中...')+'</td></tr>';
var p = {
timeout: 4,
username: document.getElementById('onvif_user').value.trim(),
password: document.getElementById('onvif_pass').value,
resolve_rtsp: document.getElementById('onvif_resolve').checked ? 1 : 0
};
Api.get('/stream/openOnvifDiscover', p).then(function(res){
btn.disabled = false;
body.innerHTML = '';
if(!res || res.code!==1000 || !(res.data||[]).length){
body.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#9ca3af;padding:24px">'+_t('onvif_no_devices','未发现 ONVIF 设备')+'</td></tr>';
return;
}
res.data.forEach(function(d, idx){
var tr = document.createElement('tr');
var rtsp = d.rtsp_url ? escapeHtml(d.rtsp_url) : '<span style="color:#9ca3af">--</span>';
tr.innerHTML = '<td class="mono">'+escapeHtml(d.ip||'')+'</td>'
+'<td>'+escapeHtml(d.name||('Device-'+(idx+1)))+'</td>'
+'<td style="font-size:11px;max-width:180px;overflow:hidden;text-overflow:ellipsis" title="'+escapeHtml(d.xaddr||'')+'">'+escapeHtml((d.xaddr||'').substring(0,40))+'</td>'
+'<td style="font-size:11px;max-width:220px;overflow:hidden;text-overflow:ellipsis" title="'+(d.rtsp_url||'')+'">'+rtsp+'</td>'
+'<td><button class="btn btn-sm" onclick="addFromOnvif('+idx+')">'+_t('onvif_add_stream','添加')+'</button></td>';
tr._onvifData = d;
body.appendChild(tr);
});
window._onvifList = res.data;
}).catch(function(){
btn.disabled = false;
body.innerHTML = '<tr><td colspan="5" style="text-align:center;color:#dc2626;padding:24px">'+_t('load_failed','加载失败')+'</td></tr>';
});
}
function addFromOnvif(idx){
var d = (window._onvifList||[])[idx];
if(!d) return;
closeModal('onvifModal');
editMode = 'add';
document.getElementById('streamModalTitle').textContent = _t('btn_add_video', '添加视频');
document.getElementById('f_code').readOnly = false;
Api.get('/stream/openAddContext').then(function(res){
if(res.code !== 1000){ showToast(res.msg,'error'); return; }
var s = res.stream || {};
fillForm(s);
document.getElementById('f_pull_stream_type').value = '1';
document.getElementById('f_nickname').value = d.name || ('ONVIF-'+d.ip);
document.getElementById('f_camera_name').value = d.name || '';
document.getElementById('f_pull_stream_ip').value = d.ip || '';
if(d.rtsp_url){
document.getElementById('f_pull_stream_url').value = d.rtsp_url;
}
var user = document.getElementById('onvif_user').value.trim();
var pass = document.getElementById('onvif_pass').value;
if(user) document.getElementById('f_pull_stream_username').value = user;
if(pass) document.getElementById('f_pull_stream_password').value = pass;
onPullTypeChange();
openModal('streamModal');
});
}
// 初始化
// 反 Chrome autofillinput 上加了 readonlyChrome 会跳过该字段不填充,
// 用户首次点击/聚焦时移除 readonly 即可正常输入
loadList(1);
</script>
{% endblock %}