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

1040 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends "app/base.html" %}
{% load static %}
{% block title %}{{ T.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 %}