hikvision-video-api/target/classes/static/index.html

2159 lines
79 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.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>海康威视视频监控平台</title>
<!-- ① jQuery 3.6.0:已内置于 static/js/CDN 作为兜底 -->
<script src="/js/jquery-3.6.0.min.js"></script>
<script>
window.jQuery || document.write(
'<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.min.js"><\/script>'
);
</script>
<!-- ② JSEncrypt已内置于 static/js/CDN 作为兜底 -->
<script src="/js/jsencrypt.min.js"></script>
<script>
window.JSEncrypt || document.write(
'<script src="https://cdn.bootcdn.net/ajax/libs/jsencrypt/3.3.2/jsencrypt.min.js"><\/script>'
);
</script>
<!--
③ 海康 VideoWebPlugin 核心 JS已内置于 static/js/,版本 1.2.7
客户端需安装 VideoWebPlugin.exe 插件服务
下载地址https://open.hikvision.com/download/5c67f1e2f05948198c909700?type=10
-->
<script src="/js/web-control_1.2.7.min.js"></script>
<style>
:root {
--bg-primary: #0a0e1a;
--bg-card: #111827;
--bg-panel: #0f1623;
--border: #1e3a5f;
--accent: #0ea5e9;
--accent-dim: #0369a1;
--accent-glow: rgba(14,165,233,0.15);
--text-primary: #e2e8f0;
--text-muted: #64748b;
--text-dim: #94a3b8;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--font-mono: 'JetBrains Mono', 'Consolas', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ===== 顶部导航 ===== */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 56px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 100;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 700;
letter-spacing: .5px;
color: var(--text-primary);
}
.topbar-brand .logo-icon {
width: 32px; height: 32px;
background: linear-gradient(135deg, var(--accent), #3b82f6);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 16px;
}
.topbar-status {
display: flex; align-items: center; gap: 8px;
font-size: 13px; color: var(--text-muted);
}
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--red);
box-shadow: 0 0 6px var(--red);
transition: all .3s;
}
.status-dot.connected { background: var(--green); box-shadow: 0 0 6px var(--green); }
/* ===== 主体布局 ===== */
.main-layout {
display: flex;
flex: 1;
overflow: hidden;
}
/* ===== 左侧面板 ===== */
.sidebar {
width: 280px;
background: var(--bg-panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-section {
padding: 16px;
border-bottom: 1px solid var(--border);
}
.sidebar-section-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 12px;
}
/* 配置表单 */
.form-group { margin-bottom: 10px; }
.form-group label {
display: block;
font-size: 12px;
color: var(--text-dim);
margin-bottom: 4px;
}
.form-group input, .form-group select {
width: 100%;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
padding: 7px 10px;
font-size: 13px;
outline: none;
transition: border-color .2s;
}
.form-group input:focus, .form-group select:focus {
border-color: var(--accent);
}
.form-group select option { background: var(--bg-card); }
/* 按钮 */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 14px;
border: none; border-radius: 6px;
font-size: 13px; font-weight: 600;
cursor: pointer;
transition: all .2s;
}
.btn-primary {
background: var(--accent); color: #fff;
width: 100%;
}
.btn-primary:hover { background: #38bdf8; }
.btn-secondary {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-dim);
}
.btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
.btn-danger { background: #7f1d1d; color: #fca5a5; }
.btn-danger:hover { background: var(--red); color: #fff; }
.btn-row { display: flex; gap: 8px; }
/* 摄像头列表 */
.camera-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.camera-list::-webkit-scrollbar { width: 4px; }
.camera-list::-webkit-scrollbar-track { background: transparent; }
.camera-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.camera-item {
display: flex; align-items: center;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: all .15s;
margin-bottom: 4px;
border: 1px solid transparent;
}
.camera-item:hover { background: var(--bg-card); border-color: var(--border); }
.camera-item.active { background: var(--accent-glow); border-color: var(--accent); }
.camera-item-icon {
width: 32px; height: 32px;
border-radius: 6px;
background: var(--bg-primary);
display: flex; align-items: center; justify-content: center;
font-size: 15px;
margin-right: 10px;
flex-shrink: 0;
}
.camera-item-info { flex: 1; min-width: 0; }
.camera-item-name {
font-size: 13px; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.camera-item-code {
font-size: 11px; color: var(--text-muted);
font-family: var(--font-mono);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.camera-badge {
font-size: 10px; font-weight: 600;
padding: 2px 6px; border-radius: 10px;
margin-left: 6px;
}
.badge-online { background: rgba(34,197,94,.15); color: var(--green); }
.badge-offline { background: rgba(239,68,68,.15); color: var(--red); }
.badge-unknown { background: rgba(100,116,139,.15); color: var(--text-muted); }
/* ===== 定时任务卡片 ===== */
.task-card {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
padding: 10px 12px;
margin-bottom: 8px;
}
.task-card.running { border-color: var(--green); }
.task-card.error { border-color: var(--yellow); }
.task-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
.task-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
.task-status {
font-size: 10px; font-weight: 600;
padding: 2px 7px; border-radius: 10px;
}
.task-status.RUNNING { background: rgba(34,197,94,.15); color: var(--green); }
.task-status.STOPPED { background: rgba(100,116,139,.15); color: var(--text-muted); }
.task-status.ERROR { background: rgba(245,158,11,.15); color: var(--yellow); }
.task-meta { font-size: 10px; color: var(--text-muted); line-height: 1.7; }
.task-actions { display: flex; gap: 6px; margin-top: 8px; }
.task-btn {
flex: 1; font-size: 11px; padding: 4px 0;
border: 1px solid var(--border); border-radius: 5px;
background: var(--bg-primary); color: var(--text-dim);
cursor: pointer; text-align: center; transition: all .15s;
}
.task-btn:hover { border-color: var(--accent); color: var(--accent); }
.task-btn.start:hover { border-color: var(--green); color: var(--green); }
.task-btn.stop:hover { border-color: var(--yellow); color: var(--yellow); }
.task-btn.del:hover { border-color: var(--red); color: var(--red); }
.task-last-img {
width: 100%; border-radius: 5px; margin-top: 6px;
aspect-ratio: 16/9; object-fit: cover;
background: #000; cursor: pointer;
border: 1px solid var(--border);
}
/* ===== 抓图按钮(摄像头条目内) ===== */
.capture-btn {
width: 26px; height: 26px;
border: none; border-radius: 5px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
transition: all .15s;
margin-left: 4px;
}
.capture-btn:hover { background: var(--accent-glow); color: var(--accent); }
.capture-btn.loading { animation: spin .6s linear infinite; }
/* ===== 抓图图库 ===== */
.capture-gallery {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 10px;
}
.capture-card {
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border);
background: var(--bg-card);
cursor: pointer;
transition: border-color .15s;
}
.capture-card:hover { border-color: var(--accent); }
.capture-card img {
width: 100%;
aspect-ratio: 16/9;
object-fit: cover;
display: block;
background: #000;
}
.capture-card-info {
padding: 5px 7px;
}
.capture-card-name {
font-size: 11px; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
color: var(--text-primary);
}
.capture-card-time {
font-size: 10px; color: var(--text-muted);
font-family: var(--font-mono);
}
.capture-card-actions {
display: flex; gap: 4px;
padding: 0 7px 6px;
}
.capture-action-btn {
flex: 1; font-size: 10px;
padding: 3px 0;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-primary);
color: var(--text-muted);
cursor: pointer;
text-align: center;
text-decoration: none;
transition: all .15s;
}
.capture-action-btn:hover { border-color: var(--accent); color: var(--accent); }
.capture-action-btn.del:hover { border-color: var(--red); color: var(--red); }
/* ===== 灯箱预览 ===== */
#lightbox {
display: none;
position: fixed; inset: 0; z-index: 9000;
background: rgba(0,0,0,.85);
align-items: center; justify-content: center;
flex-direction: column; gap: 12px;
}
#lightbox.show { display: flex; }
#lightbox img {
max-width: 90vw; max-height: 80vh;
border-radius: 8px;
box-shadow: 0 0 40px rgba(0,0,0,.6);
}
#lightbox-info {
color: var(--text-dim); font-size: 13px; text-align: center;
}
#lightbox-close {
position: fixed; top: 20px; right: 24px;
font-size: 28px; color: #fff; cursor: pointer; z-index: 9001;
}
.empty-cameras {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 13px;
}
.empty-cameras .icon { font-size: 32px; margin-bottom: 10px; }
/* ===== 右侧视频区 ===== */
.video-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 工具栏 */
.video-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
gap: 12px;
flex-wrap: wrap;
}
.toolbar-left { display: flex; align-items: center; gap: 8px; }
.toolbar-right { display: flex; align-items: center; gap: 8px; }
/* 分屏选择 */
.split-btns { display: flex; gap: 4px; }
.split-btn {
width: 34px; height: 28px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 11px; color: var(--text-muted);
transition: all .15s;
}
.split-btn:hover, .split-btn.active {
border-color: var(--accent); color: var(--accent);
background: var(--accent-glow);
}
/* 播放模式 */
.mode-tabs { display: flex; gap: 4px; }
.mode-tab {
padding: 5px 12px;
border-radius: 5px;
font-size: 12px; font-weight: 500;
cursor: pointer;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
transition: all .15s;
}
.mode-tab.active, .mode-tab:hover {
background: var(--accent-glow);
border-color: var(--accent); color: var(--accent);
}
/* 时间选择(回放模式) */
#playback-time-row {
display: none;
align-items: center; gap: 8px;
font-size: 12px; color: var(--text-dim);
}
#playback-time-row.show { display: flex; }
#playback-time-row input[type="datetime-local"] {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-primary);
padding: 4px 8px;
font-size: 12px;
outline: none;
}
/* 插件播放容器 */
.plugin-wrapper {
flex: 1;
position: relative;
background: #000;
overflow: hidden;
}
#playWnd {
width: 100%;
height: 100%;
/* VideoWebPlugin 会把播放画面渲染到这个 div */
}
/* 插件未安装提示 */
.plugin-placeholder {
position: absolute; inset: 0;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: #000;
color: var(--text-muted);
gap: 12px;
text-align: center;
padding: 24px;
}
.plugin-placeholder .icon { font-size: 48px; }
.plugin-placeholder h3 { font-size: 16px; color: var(--text-dim); }
.plugin-placeholder p { font-size: 13px; line-height: 1.6; max-width: 400px; }
.plugin-placeholder a { color: var(--accent); text-decoration: none; }
.plugin-placeholder a:hover { text-decoration: underline; }
/* 状态栏 */
.statusbar {
display: flex; align-items: center;
padding: 6px 16px;
background: var(--bg-panel);
border-top: 1px solid var(--border);
gap: 16px;
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.statusbar span { display: flex; align-items: center; gap: 5px; }
.sb-tag {
color: var(--accent); margin-right: 4px; font-weight: 600;
}
/* ===== toast ===== */
#toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: #1e293b;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 20px;
font-size: 13px;
color: var(--text-primary);
display: none;
z-index: 9999;
box-shadow: 0 8px 24px rgba(0,0,0,.4);
}
#toast.show { display: block; animation: fadeInUp .25s ease; }
@keyframes fadeInUp {
from { opacity: 0; transform: translateX(-50%) translateY(8px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* ===== loading ===== */
.loading {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .6s linear infinite;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ===== 侧栏 Tab ===== */
.sidebar-tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-tab {
flex: 1; text-align: center;
padding: 9px 4px;
font-size: 12px; font-weight: 600;
color: var(--text-muted);
cursor: pointer;
transition: all .15s;
border-bottom: 2px solid transparent;
}
.sidebar-tab:hover { color: var(--text-dim); }
.sidebar-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.sidebar-panel { display: flex; flex-direction: column; }
/* ===== 区域面包屑 ===== */
.region-breadcrumb {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 3px;
padding: 4px 12px 6px;
min-height: 28px;
font-size: 11px;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.region-breadcrumb .bc-item {
cursor: pointer; color: var(--accent);
}
.region-breadcrumb .bc-item:hover { text-decoration: underline; }
.region-breadcrumb .bc-sep { color: var(--text-muted); }
.region-breadcrumb .bc-current { color: var(--text-dim); }
/* ===== 区域节点 ===== */
.region-item {
display: flex; align-items: center;
padding: 8px 12px;
border-radius: 7px;
cursor: pointer;
transition: all .15s;
margin-bottom: 3px;
border: 1px solid transparent;
gap: 8px;
}
.region-item:hover { background: var(--bg-card); border-color: var(--border); }
.region-item.active { background: rgba(14,165,233,.08); border-color: var(--accent); }
.region-icon { font-size: 15px; flex-shrink: 0; }
.region-info { flex: 1; min-width: 0; }
.region-name {
font-size: 13px; font-weight: 500;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.region-meta { font-size: 10px; color: var(--text-muted); }
.region-arrow { font-size: 11px; color: var(--text-muted); }
/* ===== 通道分组标题 ===== */
.channel-group-title {
font-size: 11px; font-weight: 600; letter-spacing: .5px;
color: var(--text-muted); text-transform: uppercase;
padding: 6px 12px 4px;
display: flex; align-items: center; justify-content: space-between;
}
.channel-count {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1px 7px;
font-size: 10px;
color: var(--text-muted);
}
/* ===== 返回按钮 ===== */
.back-btn {
display: flex; align-items: center; gap: 6px;
padding: 7px 12px;
font-size: 12px; color: var(--accent);
cursor: pointer;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.back-btn:hover { background: var(--accent-glow); }
/* 分屏网格 */
.grid-2x2 { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; }
.grid-3x3 { display: grid; grid-template-columns: repeat(3,1fr); grid-template-rows: repeat(3,1fr); }
/* ===== SSE 日志面板 ===== */
.log-panel {
position: fixed;
bottom: 0; left: 0; right: 0;
z-index: 200;
background: var(--bg-panel);
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
transition: height .25s ease;
height: 36px; /* 折叠态:只显示标题栏 */
}
.log-panel.expanded { height: 220px; }
.log-titlebar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
height: 36px;
cursor: pointer;
flex-shrink: 0;
user-select: none;
}
.log-titlebar-left {
display: flex; align-items: center; gap: 10px;
font-size: 12px; font-weight: 600; color: var(--text-dim);
}
.log-sse-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--red);
box-shadow: 0 0 5px var(--red);
transition: all .3s;
flex-shrink: 0;
}
.log-sse-dot.live { background: var(--green); box-shadow: 0 0 5px var(--green); }
.log-badge {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1px 7px;
font-size: 10px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.log-titlebar-right {
display: flex; align-items: center; gap: 8px;
}
.log-clear-btn {
font-size: 11px; color: var(--text-muted);
cursor: pointer; padding: 2px 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-card);
}
.log-clear-btn:hover { color: var(--accent); border-color: var(--accent); }
.log-toggle-icon { font-size: 12px; color: var(--text-muted); }
.log-body {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
display: none;
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.6;
}
.log-panel.expanded .log-body { display: block; }
.log-body::-webkit-scrollbar { width: 4px; }
.log-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* 日志条目 */
.log-entry {
display: flex; align-items: baseline; gap: 8px;
padding: 2px 4px;
border-radius: 3px;
}
.log-entry:hover { background: rgba(255,255,255,.03); }
.log-time { color: var(--text-muted); flex-shrink: 0; }
.log-level {
font-weight: 700; flex-shrink: 0;
min-width: 38px; text-align: center;
}
.log-level.INFO { color: var(--accent); }
.log-level.WARN { color: var(--yellow); }
.log-level.ERROR { color: var(--red); }
.log-type { color: #7c5cbf; flex-shrink: 0; }
.log-src { color: var(--text-muted); flex-shrink: 0; font-size: 10px; }
.log-msg { color: var(--text-primary); flex: 1; }
</style>
</head>
<body>
<!-- ===== 顶部导航 ===== -->
<header class="topbar">
<div class="topbar-brand">
<div class="logo-icon">📹</div>
海康威视视频监控
</div>
<div class="topbar-status">
<div class="status-dot" id="pluginStatusDot"></div>
<span id="pluginStatusText">插件未连接</span>
</div>
</header>
<!-- ===== 主体 ===== -->
<div class="main-layout">
<!-- 左侧面板 -->
<aside class="sidebar">
<!-- 平台配置 -->
<div class="sidebar-section">
<div class="sidebar-section-title">⚙ 平台配置</div>
<div class="form-group">
<label>平台地址</label>
<input type="text" id="platformIp" placeholder="192.168.1.100" value="10.60.49.82" />
</div>
<div class="form-group">
<label>端口</label>
<input type="text" id="platformPort" placeholder="443" value="443" />
</div>
<div class="form-group">
<label>AppKey</label>
<input type="text" id="appKey" placeholder="输入AppKey" value="23046693" />
</div>
<div class="form-group">
<label>AppSecret</label>
<input type="password" id="appSecret" placeholder="输入AppSecret" value="sxsSY3lfL001LB6lLhpO" />
</div>
<button class="btn btn-primary" onclick="initPlugin()">
🔌 连接插件 &amp; 初始化
</button>
</div>
<!-- 侧栏 Tab区域树 / 全部摄像头 / URL直播 / 抓图 -->
<div class="sidebar-tabs">
<div class="sidebar-tab active" id="stab-region" onclick="switchSideTab('region')">🗂 区域</div>
<div class="sidebar-tab" id="stab-all" onclick="switchSideTab('all')">📷 全部</div>
<div class="sidebar-tab" id="stab-url" onclick="switchSideTab('url')">🔗 URL</div>
<div class="sidebar-tab" id="stab-capture" onclick="switchSideTab('capture')">📸 抓图</div>
</div>
<!-- 区域树面板 -->
<div id="panel-region" class="sidebar-panel" style="display:flex;flex-direction:column;flex:1;overflow:hidden;">
<div style="padding:8px 12px 4px;display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:11px;color:var(--text-muted);">点击区域加载通道</span>
<button class="btn btn-secondary" style="padding:2px 9px;font-size:11px;" onclick="loadRegionTree()">↺ 刷新</button>
</div>
<div id="regionBreadcrumb" class="region-breadcrumb"></div>
<div class="camera-list" id="regionTree">
<div class="empty-cameras"><div class="icon">🗂</div><div>请先初始化后加载区域</div></div>
</div>
</div>
<!-- 全部摄像头面板 -->
<div id="panel-all" class="sidebar-panel" style="display:none;flex-direction:column;flex:1;overflow:hidden;">
<div style="padding:8px 12px 4px;display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:11px;color:var(--text-muted);">全部摄像头</span>
<button class="btn btn-secondary" style="padding:2px 9px;font-size:11px;" onclick="loadCameras()">↺ 刷新</button>
</div>
<div class="camera-list" id="cameraList">
<div class="empty-cameras"><div class="icon">📡</div><div>请先初始化插件<br>然后点击刷新加载摄像头</div></div>
</div>
</div>
<!-- URL直播面板 -->
<div id="panel-url" class="sidebar-panel" style="display:none;flex-direction:column;flex:1;overflow:hidden;padding:12px;">
<div style="font-size:11px;color:var(--text-muted);margin-bottom:10px;">
输入监控点编号,通过插件预览并获取 RTSP 直播地址
</div>
<!-- 监控点编号 -->
<div class="form-group">
<label>监控点编号 (cameraIndexCode)</label>
<input type="text" id="urlCameraCode"
placeholder="输入监控点编号"
style="font-size:11px;" />
</div>
<!-- 码流 & 协议 -->
<div style="display:flex;gap:6px;">
<div class="form-group" style="flex:1;margin-bottom:8px;">
<label>码流</label>
<select id="urlStreamMode">
<option value="0" selected>主码流</option>
<option value="1">子码流</option>
</select>
</div>
<div class="form-group" style="flex:1;margin-bottom:8px;">
<label>传输</label>
<select id="urlTransMode">
<option value="1" selected>TCP</option>
<option value="0">UDP</option>
</select>
</div>
</div>
<!-- 播放窗口 -->
<div class="form-group">
<label>播放窗口</label>
<select id="urlWndId">
<option value="-1" selected>自动(空闲)</option>
<option value="0">窗口 1</option>
<option value="1">窗口 2</option>
<option value="2">窗口 3</option>
<option value="3">窗口 4</option>
</select>
</div>
<!-- 操作按钮 -->
<button class="btn btn-primary" onclick="playByCode()" style="width:100%;margin-top:2px;">
▶ 插件播放
</button>
<button class="btn btn-secondary" onclick="fetchRtspUrl()" style="width:100%;margin-top:6px;">
🔗 获取 RTSP 地址
</button>
<!-- RTSP 地址显示 -->
<div style="margin-top:10px;">
<label style="font-size:11px;color:var(--text-muted);display:block;margin-bottom:4px;">RTSP 直播地址</label>
<div style="display:flex;gap:4px;align-items:center;">
<input type="text" id="rtspUrlDisplay"
readonly
placeholder="点击「获取 RTSP 地址」后显示"
value="rtsp://10.60.49.82:17017/openUrl/FhpAxwI"
style="font-size:10px;flex:1;color:var(--text-dim);" />
<button class="btn btn-secondary" onclick="copyRtspUrl()"
style="padding:4px 8px;font-size:11px;white-space:nowrap;">📋</button>
</div>
</div>
<div id="rtspUrlStatus" style="margin-top:8px;font-size:11px;word-break:break-all;"></div>
</div>
<!-- 抓图面板(图库 + 定时任务两个子 Tab -->
<div id="panel-capture" class="sidebar-panel" style="display:none;flex-direction:column;flex:1;overflow:hidden;">
<!-- 子 Tab -->
<div style="display:flex;border-bottom:1px solid var(--border);flex-shrink:0;">
<div id="ctab-gallery" onclick="switchCaptureTab('gallery')"
style="flex:1;text-align:center;padding:7px 4px;font-size:11px;font-weight:600;
cursor:pointer;color:var(--accent);border-bottom:2px solid var(--accent);">
🖼 图库
</div>
<div id="ctab-tasks" onclick="switchCaptureTab('tasks')"
style="flex:1;text-align:center;padding:7px 4px;font-size:11px;font-weight:600;
cursor:pointer;color:var(--text-muted);border-bottom:2px solid transparent;">
⏱ 定时任务
</div>
</div>
<!-- 图库子面板 -->
<div id="cpanel-gallery" style="display:flex;flex-direction:column;flex:1;overflow:hidden;">
<div style="padding:6px 12px 4px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;">
<span style="font-size:11px;color:var(--text-muted);">本地抓图记录</span>
<button class="btn btn-secondary" style="padding:2px 9px;font-size:11px;" onclick="loadCaptures()">↺ 刷新</button>
</div>
<div class="camera-list" id="captureGallery" style="padding:0;overflow-y:auto;flex:1;">
<div class="empty-cameras"><div class="icon">📸</div><div>暂无抓图记录<br>点击摄像头旁的 📸 按钮开始抓图</div></div>
</div>
</div>
<!-- 定时任务子面板 -->
<div id="cpanel-tasks" style="display:none;flex-direction:column;flex:1;overflow:hidden;">
<!-- 新建任务表单 -->
<div style="padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0;">
<div style="font-size:11px;font-weight:600;color:var(--text-muted);margin-bottom:8px;"> 新建定时任务</div>
<div class="form-group" style="margin-bottom:6px;">
<label>监控点编号</label>
<input type="text" id="taskCameraCode" placeholder="cameraIndexCode" style="font-size:11px;" />
</div>
<div class="form-group" style="margin-bottom:6px;">
<label>摄像头名称(选填)</label>
<input type="text" id="taskCameraName" placeholder="备注名称" style="font-size:11px;" />
</div>
<div style="display:flex;gap:6px;margin-bottom:6px;">
<div class="form-group" style="flex:1;margin-bottom:0;">
<label>间隔最小10</label>
<input type="number" id="taskInterval" value="60" min="10" style="font-size:11px;" />
</div>
<div class="form-group" style="flex:1;margin-bottom:0;">
<label>最大次数0=无限)</label>
<input type="number" id="taskMaxCaptures" value="0" min="0" style="font-size:11px;" />
</div>
</div>
<button class="btn btn-primary" style="font-size:12px;" onclick="createCaptureTask()">✚ 创建任务</button>
</div>
<!-- 任务列表 -->
<div style="padding:6px 12px 4px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;">
<span style="font-size:11px;color:var(--text-muted);">任务列表</span>
<button class="btn btn-secondary" style="padding:2px 9px;font-size:11px;" onclick="loadCaptureTasks()">↺ 刷新</button>
</div>
<div id="captureTaskList" style="overflow-y:auto;flex:1;padding:0 8px 8px;">
<div class="empty-cameras"><div class="icon"></div><div>暂无定时任务</div></div>
</div>
</div>
</div>
</aside>
<!-- 灯箱 -->
<div id="lightbox" onclick="closeLightbox()">
<span id="lightbox-close" onclick="closeLightbox()"></span>
<img id="lightbox-img" src="" alt="截图预览" onclick="event.stopPropagation()" />
<div id="lightbox-info"></div>
</div>
<!-- 右侧视频区 -->
<div class="video-area">
<!-- 工具栏 -->
<div class="video-toolbar">
<div class="toolbar-left">
<!-- 播放模式 -->
<div class="mode-tabs">
<div class="mode-tab active" id="tab-preview" onclick="switchMode('preview')">▶ 实时预览</div>
<div class="mode-tab" id="tab-playback" onclick="switchMode('playback')">⏪ 录像回放</div>
</div>
<!-- 回放时间(回放模式显示) -->
<div id="playback-time-row">
<input type="datetime-local" id="beginTime" />
<span></span>
<input type="datetime-local" id="endTime" />
</div>
</div>
<div class="toolbar-right">
<!-- 分屏 -->
<div class="split-btns">
<div class="split-btn active" title="单屏" onclick="setSplit(1)" id="split-1"></div>
<div class="split-btn" title="四分屏" onclick="setSplit(4)" id="split-4"></div>
<div class="split-btn" title="九分屏" onclick="setSplit(9)" id="split-9"></div>
</div>
<!-- 操作按钮 -->
<button class="btn btn-secondary" onclick="doCapture()" title="抓图">📷</button>
<button class="btn btn-secondary" onclick="doRecord()" title="本地录像"></button>
<button class="btn btn-danger" onclick="stopAll()">⏹ 全部停止</button>
</div>
</div>
<!-- 插件播放区 -->
<div class="plugin-wrapper" id="pluginWrapper">
<!-- VideoWebPlugin 渲染到此 div -->
<div id="playWnd"></div>
<!-- 插件未安装时的说明页 -->
<div class="plugin-placeholder" id="pluginPlaceholder">
<div class="icon">🎥</div>
<h3>海康视频 Web 插件</h3>
<p>
需要安装海康官方 <strong>VideoWebPlugin</strong> 客户端插件,<br/>
并在页面初始化后即可在此区域播放实时视频与录像回放。
</p>
<p>
<a href="https://open.hikvision.com/download/5c67f1e2f05948198c909700?type=10" target="_blank">
↓ 下载 VideoWebPlugin 插件
</a>
&nbsp;|&nbsp;
安装后重启浏览器,点击左侧「连接插件 &amp; 初始化」
</p>
<p style="margin-top:8px;font-size:11px;color:#475569;">
插件 JS<code>web-control_1.2.7.min.js</code>)已内置,
仅需安装 <code>VideoWebPlugin.exe</code> 客户端服务
</p>
</div>
</div>
<!-- 状态栏 -->
<div class="statusbar">
<span><span class="sb-tag">模式</span><span id="sb-mode">实时预览</span></span>
<span><span class="sb-tag">分屏</span><span id="sb-split">1</span></span>
<span><span class="sb-tag">当前</span><span id="sb-camera"></span></span>
<span><span class="sb-tag">状态</span><span id="sb-status">就绪</span></span>
</div>
</div>
</div>
<!-- Toast 提示 -->
<div id="toast"></div>
<!-- ===== SSE 日志面板 ===== -->
<div class="log-panel" id="logPanel">
<div class="log-titlebar" onclick="toggleLogPanel()">
<div class="log-titlebar-left">
<div class="log-sse-dot" id="sseDot"></div>
<span>数据抓取日志</span>
<span class="log-badge" id="logBadge">0 条</span>
</div>
<div class="log-titlebar-right">
<span class="log-clear-btn" onclick="clearLog(event)">清空</span>
<span class="log-toggle-icon" id="logToggleIcon"></span>
</div>
</div>
<div class="log-body" id="logBody"></div>
</div>
<script>
/* ============================================================
海康 VideoWebPlugin 集成核心逻辑
官方文档视频WEB插件开发指南 V1.5.4
============================================================ */
var oWebControl = null; // 插件实例
var initCount = 0; // 重试计数
var pubKey = ''; // RSA公钥加密secret用
var currentMode = 'preview'; // preview | playback
var currentSplit = 1;
/* 布局数字 → 文档规定的字符串映射§3.12.2/§3.12.10 */
var LAYOUT_MAP = { 1: '1x1', 4: '2x2', 9: '3x3' };
/* ---------- 工具函数 ---------- */
function toast(msg, duration) {
var el = document.getElementById('toast');
el.textContent = msg;
el.className = 'show';
clearTimeout(el._t);
el._t = setTimeout(function(){ el.className = ''; }, duration || 2500);
}
function setStatus(text) {
document.getElementById('sb-status').textContent = text;
}
function setPluginStatus(connected) {
var dot = document.getElementById('pluginStatusDot');
var text = document.getElementById('pluginStatusText');
if (connected) {
dot.className = 'status-dot connected';
text.textContent = '插件已连接';
} else {
dot.className = 'status-dot';
text.textContent = '插件未连接';
}
}
/* ---------- 初始化插件 ---------- */
function initPlugin() {
var ip = document.getElementById('platformIp').value.trim();
var port = document.getElementById('platformPort').value.trim();
var appKey = document.getElementById('appKey').value.trim();
if (!ip || !appKey) { toast('⚠ 请填写平台地址和AppKey'); return; }
setStatus('正在连接插件…');
toast('正在连接插件…', 5000);
// 如果已有实例先断开HideWnd 隐藏窗口,再 Disconnect 断开服务)
if (oWebControl) {
oWebControl.JS_HideWnd();
oWebControl.JS_Disconnect().then(
function(){ oWebControl = null; _doInit(ip, port, appKey); },
function(){ oWebControl = null; _doInit(ip, port, appKey); }
);
} else {
_doInit(ip, port, appKey);
}
}
function _doInit(ip, port, appKey) {
/* 1. 创建 WebControl 实例 */
oWebControl = new WebControl({
szPluginContainer: 'playWnd', // 绑定 div id
iServicePortStart: 15900,
iServicePortEnd: 15900, // demo 中 Start/End 使用同一端口
/* IE10 ActiveX 兼容§4.2.2 */
szClassId: '23BF3B0A-2C56-4D97-9C03-0CB103AA8F11',
cbConnectSuccess: function() {
initCount = 0;
/* 2. 先注册消息回调demo_window_integration 顺序) */
_bindCallback();
/* 3. 再启动服务 */
oWebControl.JS_StartService('window', {
dllPath: './VideoPluginConnect.dll'
}).then(function() {
/* 4. 创建插件窗口,传入 div 实际尺寸§3.3 */
var wrapper = document.getElementById('pluginWrapper');
var w = wrapper.clientWidth || 800;
var h = wrapper.clientHeight || 500;
oWebControl.JS_CreateWnd('playWnd', w, h).then(function() {
_initParam(ip, port, appKey);
});
});
},
cbConnectError: function() {
/* 尝试唤醒插件进程 */
oWebControl = null;
WebControl.JS_WakeUp('VideoWebPlugin://');
initCount++;
if (initCount < 3) {
setTimeout(function(){ _doInit(ip, port, appKey); }, 3000);
setStatus('等待插件启动…(第' + initCount + '次重试)');
} else {
toast('❌ 插件连接失败,请确认 VideoWebPlugin.exe 已安装并运行');
setStatus('插件连接失败');
initCount = 0;
}
},
cbConnectClose: function(bNormalClose) {
// bNormalClose=true: JS_Disconnect 正常断开false: 异常断开
setPluginStatus(false);
oWebControl = null;
if (!bNormalClose) {
// 异常断开时自动重连(与 demo 一致)
setStatus('插件连接中断,正在重连…');
WebControl.JS_WakeUp('VideoWebPlugin://');
initCount++;
if (initCount < 3) {
setTimeout(function(){ _doInit(ip, port, appKey); }, 3000);
} else {
setStatus('插件重连失败');
initCount = 0;
}
} else {
setStatus('插件连接已断开');
}
}
});
}
/* ---------- 初始化参数(平台配置+RSA加密secret ---------- */
function _initParam(ip, port, appKey) {
var secret = document.getElementById('appSecret').value.trim();
/* 先获取RSA公钥再加密secret§3.12.1 / §4.2.5 */
oWebControl.JS_RequestInterface({
funcName: 'getRSAPubKey',
argument: JSON.stringify({ keyLength: 1024 })
}).then(function(oData) {
/* demo 中公钥即为 responseMsg.data 字符串本身 */
if (oData.responseMsg && oData.responseMsg.data) {
pubKey = oData.responseMsg.data;
}
var encSecret = _rsaEncrypt(secret, pubKey);
/* 初始化平台参数§3.12.2 */
oWebControl.JS_RequestInterface({
funcName: 'init',
argument: JSON.stringify({
appkey: appKey,
secret: encSecret,
ip: ip,
port: parseInt(port) || 443,
playMode: 0, // 0-预览, 1-回放
layout: LAYOUT_MAP[currentSplit] || '1x1', // 必须为字符串§3.12.2
enableHTTPS: 1,
encryptedFields: 'secret',
showToolbar: 1,
showSmart: 0,
reconnectTimes: 5,
reconnectDuration: 5,
toolBarButtonIDs: '2048,2049,2050,2304,2306,2305,2307,2308,2309,4096,4608,4097,4099,4098,4609,4100'
})
}).then(function(oData) {
setPluginStatus(true);
document.getElementById('pluginPlaceholder').style.display = 'none';
setStatus('初始化成功');
toast('✅ 插件初始化成功!');
/* 回调已在 JS_StartService.then() 中注册,此处仅加载数据 */
loadRegionTree();
loadCameras();
}).catch(function(e){
toast('❌ 初始化参数失败: ' + (e.message || e));
});
});
}
/* ---------- RSA加密 ---------- */
function _rsaEncrypt(str, pubKeyStr) {
if (!pubKeyStr || !str) return str;
try {
var encrypt = new JSEncrypt();
encrypt.setPublicKey(pubKeyStr);
return encrypt.encrypt(str) || str;
} catch(e) { return str; }
}
/* ---------- 消息回调§3.13---------- */
function _bindCallback() {
oWebControl.JS_SetWindowControlCallback({
cbIntegrationCallBack: function(oData) {
/*
* responseMsg 结构§3.13
* { type: N, msg: { wndId, result, cameraIndexCode, ... } }
* result 值type=2§3.13.2
* 0x0300(768)-开始播放 0x0301(769)-播放失败
* 0x0302(770)-播放异常 0x0330(816)-播放结束
*/
var rm = oData.responseMsg || {};
var typ = rm.type;
var m = rm.msg || {};
/* type=1: 窗口选中 */
if (typ === 1) {
/* 可在此更新当前选中窗口状态 */
}
/* type=2: 预览/回放播放消息 */
if (typ === 2) {
if (m.result === 0x0300) { // 768 = 开始播放
setStatus('播放中 · wnd' + m.wndId);
toast('▶ 视频播放中');
} else if (m.result === 0x0301) { // 769 = 播放失败
setStatus('播放失败');
toast('⚠ 播放失败,请确认摄像头在线', 4000);
} else if (m.result === 0x0302) { // 770 = 播放异常
setStatus('播放异常');
toast('⚠ 播放异常(取流/解码错误)', 4000);
} else if (m.result === 0x0330) { // 816 = 播放结束
setStatus('播放结束');
}
}
/* type=3: 抓图结果§3.13.3 */
if (typ === 3) {
if (m.result === 0) {
toast('📷 抓图成功: ' + (m.expand || ''));
} else {
toast('⚠ 抓图失败', 3000);
}
}
/* type=5: 全屏切换§3.13.5 */
if (typ === 5) {
setStatus(m.result === 0x0400 ? '全屏模式' : '退出全屏');
}
/* type=6: 布局切换§3.13.6 */
if (typ === 6 && m.layout) {
document.getElementById('sb-split').textContent = m.wndNum;
}
}
});
}
/* ============================================================
侧栏 Tab 切换
============================================================ */
function switchSideTab(tab) {
['region','all','url','capture'].forEach(function(t) {
document.getElementById('stab-' + t).className = 'sidebar-tab' + (t === tab ? ' active' : '');
var panel = document.getElementById('panel-' + t);
panel.style.display = t === tab ? 'flex' : 'none';
});
if (tab === 'capture') loadCaptures();
}
/* ============================================================
区域树导航
============================================================ */
var _regionStack = []; // 导航栈:[{code, name}, ...] code=null 表示根
function loadRegionTree() {
_regionStack = [];
_loadRegionLevel(null, '根节点');
}
/**
* 加载某一层级的区域列表 + 该区域下的通道
* @param regionCode null=根节点
* @param regionName 显示名称
*/
function _loadRegionLevel(regionCode, regionName) {
var treeEl = document.getElementById('regionTree');
treeEl.innerHTML = '<div class="empty-cameras"><div class="loading"></div><br/>加载中…</div>';
_renderBreadcrumb();
var url = '/api/regions?cascadeFlag=0&pageSize=200';
if (regionCode) url += '&parentCode=' + encodeURIComponent(regionCode);
var channelUrl = regionCode
? '/api/regions/' + encodeURIComponent(regionCode) + '/channels?cascadeFlag=0&pageSize=200'
: null;
// 并发请求:区域列表 + 通道列表
var regionPromise = fetch(url).then(function(r){ return r.json(); });
var channelPromise = channelUrl
? fetch(channelUrl).then(function(r){ return r.json(); })
: Promise.resolve(null);
Promise.all([regionPromise, channelPromise]).then(function(results) {
var regionRes = results[0];
var channelRes = results[1];
var regions = (regionRes.code === 200 && regionRes.data && regionRes.data.list) ? regionRes.data.list : [];
var channels = (channelRes && channelRes.code === 200 && channelRes.data && channelRes.data.list) ? channelRes.data.list : [];
_renderRegionLevel(regions, channels, regionCode);
}).catch(function(e) {
treeEl.innerHTML = '<div class="empty-cameras"><div class="icon">⚠</div>加载失败,请确认服务已启动</div>';
});
}
function _renderRegionLevel(regions, channels, parentCode) {
var treeEl = document.getElementById('regionTree');
var html = '';
// 返回按钮(非根节点时显示)
if (_regionStack.length > 0) {
html += '<div class="back-btn" onclick="_goBack()">◀ 返回上级</div>';
}
// 子区域列表
if (regions.length > 0) {
html += '<div class="channel-group-title">区域 <span class="channel-count">' + regions.length + '</span></div>';
regions.forEach(function(region) {
html += '<div class="region-item" onclick="_drillDown(\'' + esc(region.regionIndexCode) + '\',\'' + esc(region.regionName) + '\')">'
+ '<span class="region-icon">📁</span>'
+ '<div class="region-info">'
+ '<div class="region-name">' + escHtml(region.regionName) + '</div>'
+ '</div>'
+ '<span class="region-arrow">▶</span>'
+ '</div>';
});
}
// 该区域下的摄像头通道
if (channels.length > 0) {
html += '<div class="channel-group-title">摄像头 <span class="channel-count">' + channels.length + '</span></div>';
channels.forEach(function(cam) {
// v2 API 不返回 onlineStatus字段缺失时显示未知灰色
var onlineStatus = cam.onlineStatus;
var online = onlineStatus === '1';
var unknown = onlineStatus == null || onlineStatus === '' || onlineStatus === undefined;
var statusIcon = unknown ? '⚪' : (online ? '🟢' : '🔴');
var badgeCls = unknown ? 'badge-unknown' : (online ? 'badge-online' : 'badge-offline');
var badgeText = unknown ? '未知' : (online ? '在线' : '离线');
var camCode = esc(cam.cameraIndexCode || '');
var camName = esc(cam.cameraName || '未命名');
html += '<div class="camera-item" onclick="playCamera.call(this,\'' + camCode + '\',\'' + camName + '\')">'
+ '<div class="camera-item-icon">' + statusIcon + '</div>'
+ '<div class="camera-item-info">'
+ '<div class="camera-item-name">' + escHtml(cam.cameraName || '未命名') + '</div>'
+ '<div class="camera-item-code">' + escHtml(cam.cameraIndexCode || cam.indexCode || '—') + '</div>'
+ '</div>'
+ '<span class="camera-badge ' + badgeCls + '">' + badgeText + '</span>'
+ '<button class="capture-btn" title="抓图" onclick="event.stopPropagation();captureImage(\'' + camCode + '\',\'' + camName + '\',this)">📸</button>'
+ '</div>';
});
}
if (regions.length === 0 && channels.length === 0) {
html += '<div class="empty-cameras"><div class="icon">📭</div>该区域暂无子区域或摄像头</div>';
}
treeEl.innerHTML = html;
}
function _drillDown(code, name) {
_regionStack.push({ code: code, name: name });
_loadRegionLevel(code, name);
}
function _goBack() {
_regionStack.pop();
var parent = _regionStack.length > 0 ? _regionStack[_regionStack.length - 1] : { code: null, name: '根节点' };
// 不 push直接加载
_loadRegionLevel(parent.code, parent.name);
}
function _renderBreadcrumb() {
var el = document.getElementById('regionBreadcrumb');
if (_regionStack.length === 0) {
el.innerHTML = '<span class="bc-current">根节点</span>';
return;
}
var html = '<span class="bc-item" onclick="_jumpTo(-1)">根节点</span>';
_regionStack.forEach(function(item, idx) {
html += '<span class="bc-sep"> / </span>';
if (idx === _regionStack.length - 1) {
html += '<span class="bc-current">' + escHtml(item.name) + '</span>';
} else {
html += '<span class="bc-item" onclick="_jumpTo(' + idx + ')">' + escHtml(item.name) + '</span>';
}
});
el.innerHTML = html;
}
function _jumpTo(idx) {
if (idx === -1) {
_regionStack = [];
_loadRegionLevel(null, '根节点');
} else {
_regionStack = _regionStack.slice(0, idx + 1);
var item = _regionStack[idx];
_loadRegionLevel(item.code, item.name);
}
}
function esc(s) { return String(s || '').replace(/'/g, "\'"); }
/* ============================================================
摄像头列表(调用后端 Spring Boot API
============================================================ */
function loadCameras() {
var listEl = document.getElementById('cameraList');
listEl.innerHTML = '<div class="empty-cameras"><div class="loading"></div><br/>加载中…</div>';
fetch('/api/cameras?pageNo=1&pageSize=100')
.then(function(r){ return r.json(); })
.then(function(res) {
if (res.code !== 200 || !res.data) {
listEl.innerHTML = '<div class="empty-cameras"><div class="icon">⚠</div>加载失败: ' + (res.msg || '未知错误') + '</div>';
return;
}
var cameras = res.data.list || [];
if (!cameras.length) {
listEl.innerHTML = '<div class="empty-cameras"><div class="icon">📷</div>暂无摄像头数据</div>';
return;
}
var html = '';
cameras.forEach(function(cam) {
var onlineStatus = cam.onlineStatus;
var online = onlineStatus === '1';
var unknown = onlineStatus == null || onlineStatus === '' || onlineStatus === undefined;
var statusIcon = unknown ? '⚪' : (online ? '🟢' : '🔴');
var badgeCls = unknown ? 'badge-unknown' : (online ? 'badge-online' : 'badge-offline');
var badgeText = unknown ? '未知' : (online ? '在线' : '离线');
var camCode2 = esc(cam.cameraIndexCode || '');
var camName2 = esc(cam.cameraName || '未命名');
html += '<div class="camera-item" onclick="playCamera.call(this,\'' + camCode2 + '\',\'' + camName2 + '\')">'
+ '<div class="camera-item-icon">' + statusIcon + '</div>'
+ '<div class="camera-item-info">'
+ '<div class="camera-item-name">' + escHtml(cam.cameraName || '未命名') + '</div>'
+ '<div class="camera-item-code">' + escHtml(cam.cameraIndexCode || '—') + '</div>'
+ '</div>'
+ '<span class="camera-badge ' + badgeCls + '">' + badgeText + '</span>'
+ '<button class="capture-btn" title="抓图" onclick="event.stopPropagation();captureImage(\'' + camCode2 + '\',\'' + camName2 + '\',this)">📸</button>'
+ '</div>';
});
listEl.innerHTML = html;
toast('📷 加载了 ' + cameras.length + ' 路摄像头');
})
.catch(function(e){
listEl.innerHTML = '<div class="empty-cameras"><div class="icon">⚠</div>请求失败,请确认后端服务已启动</div>';
});
}
/* ============================================================
播放视频(通过 VideoWebPlugin JS API
============================================================ */
function playCamera(cameraIndexCode, cameraName) {
if (!oWebControl) { toast('⚠ 请先初始化插件'); return; }
/* 更新选中状态onclick 用 .call(this,...) 传入宿主元素,避免依赖隐式全局 event */
document.querySelectorAll('.camera-item').forEach(function(item){ item.classList.remove('active'); });
if (this && this.classList) this.classList.add('active');
document.getElementById('sb-camera').textContent = cameraName || cameraIndexCode;
if (currentMode === 'preview') {
_startPreview(cameraIndexCode);
} else {
_startPlayback(cameraIndexCode);
}
}
/* 实时预览§3.12.4 */
function _startPreview(cameraIndexCode) {
setStatus('正在启动预览…');
oWebControl.JS_RequestInterface({
funcName: 'startPreview',
argument: JSON.stringify({
cameraIndexCode: cameraIndexCode,
streamMode: 0, // 0-主码流
transMode: 1, // 1-TCP
gpuMode: 0,
wndId: -1, // -1=空闲窗口§3.12.4
cascade: 1 // demo 中携带此字段
})
}).then(function(oData){
/* 接口返回 code=0 表示请求受理,播放结果由回调 type=2 通知§3.12.4 */
if (oData.responseMsg && oData.responseMsg.code !== 0) {
setStatus('预览启动失败');
toast('⚠ 预览失败: ' + (oData.responseMsg.msg || ''), 4000);
}
});
}
/* 将 datetime-local 值(如 "2026-05-23T17:30")转为 ISO8601+时区字符串 */
function _toIso8601(dtLocalStr) {
if (!dtLocalStr) return null;
var d = new Date(dtLocalStr); // 浏览器按本地时区解析
var pad = function(n){ return n < 10 ? '0' + n : String(n); };
var pad3 = function(n){ return n < 100 ? (n < 10 ? '00' + n : '0' + n) : String(n); };
var offset = -d.getTimezoneOffset(); // 分钟数,东八区=480
var sign = offset >= 0 ? '+' : '-';
var oh = pad(Math.floor(Math.abs(offset) / 60));
var om = pad(Math.abs(offset) % 60);
return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) +
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) +
'.' + pad3(d.getMilliseconds()) + sign + oh + ':' + om;
}
/* 录像回放§3.12.5
* 流程:先调后端 v2 接口获取 RTSP URL再用插件 startPlayback 播放;
* 同时在状态栏展示 RTSP 地址,方便用 VLC 等播放器验证。 */
function _startPlayback(cameraIndexCode) {
var beginVal = document.getElementById('beginTime').value;
var endVal = document.getElementById('endTime').value;
if (!beginVal || !endVal) { toast('⚠ 请选择回放时间段'); return; }
var beginIso = _toIso8601(beginVal);
var endIso = _toIso8601(endVal);
setStatus('正在获取回放地址…');
toast('⏳ 正在获取回放地址…', 8000);
/* ① 先调后端拿 RTSP URL用于调试/备用),再启动插件回放 */
var apiUrl = '/api/cameras/' + encodeURIComponent(cameraIndexCode) + '/playback-url'
+ '?beginTime=' + encodeURIComponent(beginIso)
+ '&endTime=' + encodeURIComponent(endIso)
+ '&recordLocation=0&protocol=rtsp&transmode=0';
fetch(apiUrl)
.then(function(r){ return r.json(); })
.then(function(res) {
if (res.code === 200 && res.data && res.data.url) {
var rtspUrl = res.data.url;
setStatus('回放地址获取成功,启动插件播放…');
toast('✅ 回放地址已获取', 2000);
/* 显示 RTSP 地址到 URL 面板输入框,方便复制到 VLC */
document.getElementById('rtspUrlDisplay').value = rtspUrl;
/* ② 插件 startPlayback§3.12.5 */
if (!oWebControl) {
toast('⚠ 插件未连接RTSP 地址已显示在 URL 面板,可用 VLC 播放', 6000);
setStatus('回放地址: ' + rtspUrl);
return;
}
oWebControl.JS_RequestInterface({
funcName: 'startPlayback',
argument: JSON.stringify({
cameraIndexCode: cameraIndexCode,
startTimeStamp: Math.floor(new Date(beginVal).getTime() / 1000).toString(),
endTimeStamp: Math.floor(new Date(endVal).getTime() / 1000).toString(),
recordLocation: 0,
transMode: 0,
gpuMode: 0,
wndId: -1
})
}).then(function(oData){
if (oData.responseMsg && oData.responseMsg.code !== 0) {
setStatus('插件回放失败,但 RTSP 地址已获取');
toast('⚠ 插件回放失败: ' + (oData.responseMsg.msg || '') + ' — RTSP 已显示在 URL 面板', 6000);
}
});
} else {
var errMsg = (res.msg || JSON.stringify(res));
setStatus('回放地址获取失败');
toast('❌ 获取回放地址失败: ' + errMsg, 6000);
}
})
.catch(function(e) {
setStatus('回放请求异常');
toast('❌ 回放请求失败: ' + e.message, 5000);
});
}
/* ============================================================
控制功能
============================================================ */
/* 切换模式§3.12.6/3.12.7 停止接口) */
function switchMode(mode) {
currentMode = mode;
document.getElementById('tab-preview').className = 'mode-tab' + (mode==='preview' ? ' active' : '');
document.getElementById('tab-playback').className = 'mode-tab' + (mode==='playback' ? ' active' : '');
var tr = document.getElementById('playback-time-row');
tr.className = mode === 'playback' ? 'show' : '';
document.getElementById('sb-mode').textContent = mode === 'preview' ? '实时预览' : '录像回放';
if (!oWebControl) return;
/* 切换模式时停止当前所有播放 */
var stopFunc = (mode === 'playback') ? 'stopAllPreview' : 'stopAllPlayback';
oWebControl.JS_RequestInterface({ funcName: stopFunc });
}
/* 分屏§3.12.10 layout 必须为字符串) */
function setSplit(n) {
currentSplit = n;
[1,4,9].forEach(function(x){
document.getElementById('split-'+x).className = 'split-btn' + (x===n?' active':'');
});
document.getElementById('sb-split').textContent = n;
if (!oWebControl) return;
oWebControl.JS_RequestInterface({
funcName: 'setLayout',
argument: JSON.stringify({ layout: LAYOUT_MAP[n] || '1x1' })
});
}
/* 抓图§3.12.11 funcName=snapShot参数 name/wndId */
function doCapture() {
if (!oWebControl) { toast('⚠ 插件未连接'); return; }
oWebControl.JS_RequestInterface({
funcName: 'snapShot',
argument: JSON.stringify({ wndId: 0 }) // 0=当前选中窗口§3.12.11
});
/* 抓图结果由回调 type=3 通知,无需在此处理返回值 */
}
/* 紧急录像(插件工具条内置,此处作为 UI 快捷触发提示) */
function doRecord() {
toast('💡 请使用播放窗口下方工具条的「紧急录像」按钮进行本地录像');
}
/* 全部停止§3.12.6/3.12.7 */
function stopAll() {
if (!oWebControl) { toast('⚠ 插件未连接'); return; }
var stopFunc = currentMode === 'preview' ? 'stopAllPreview' : 'stopAllPlayback';
oWebControl.JS_RequestInterface({ funcName: stopFunc })
.then(function(){ setStatus('已停止'); toast('⏹ 已停止所有视频'); });
}
/* ============================================================
URL直播面板 — 通过 cameraIndexCode 播放 + 获取 RTSP 地址
============================================================ */
/* 用监控点编号直接通过插件播放(插件不支持直接传 RTSP URL */
function playByCode() {
if (!oWebControl) { toast('⚠ 请先初始化插件'); return; }
var code = document.getElementById('urlCameraCode').value.trim();
var streamMode = parseInt(document.getElementById('urlStreamMode').value) || 0;
var transMode = parseInt(document.getElementById('urlTransMode').value) || 1;
var wndId = parseInt(document.getElementById('urlWndId').value);
if (!code) { toast('⚠ 请输入监控点编号'); return; }
_setUrlStatus('info', '正在启动播放…');
setStatus('URL面板播放中…');
oWebControl.JS_RequestInterface({
funcName: 'startPreview',
argument: JSON.stringify({
cameraIndexCode: code,
streamMode: streamMode,
transMode: transMode,
gpuMode: 0,
wndId: wndId,
cascade: 1
})
}).then(function(oData) {
var rm = oData.responseMsg || {};
var code = rm.code;
if (code === 0 || code === undefined) {
_setUrlStatus('ok', '▶ 播放请求已发送,等待插件回调');
toast('▶ 开始播放');
} else {
_setUrlStatus('err', '❌ 播放失败 code=' + code + (rm.msg ? ' ' + rm.msg : ''));
toast('⚠ 播放失败: ' + (rm.msg || code), 4000);
}
}).catch(function(e) {
_setUrlStatus('err', '❌ 调用异常: ' + e.message);
});
}
/* 调后端接口获取 RTSP 直播地址 */
function fetchRtspUrl() {
var code = document.getElementById('urlCameraCode').value.trim();
if (!code) { toast('⚠ 请先输入监控点编号'); return; }
_setUrlStatus('info', '正在获取 RTSP 地址…');
/* GET /api/cameras/{cameraIndexCode}/preview-url?protocol=1 */
fetch('/api/cameras/' + encodeURIComponent(code) + '/preview-url?protocol=1')
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 200 && res.data && res.data.url) {
document.getElementById('rtspUrlDisplay').value = res.data.url;
_setUrlStatus('ok', '✅ RTSP 地址获取成功,可复制到 VLC 等播放器使用');
} else {
_setUrlStatus('err', '❌ 获取失败: ' + (res.msg || JSON.stringify(res)));
}
})
.catch(function(e) {
_setUrlStatus('err', '❌ 请求失败: ' + e.message);
});
}
/* 复制 RTSP 地址到剪贴板 */
function copyRtspUrl() {
var url = document.getElementById('rtspUrlDisplay').value.trim();
if (!url) { toast('⚠ 地址为空,请先获取'); return; }
if (navigator.clipboard) {
navigator.clipboard.writeText(url).then(function(){ toast('📋 地址已复制'); });
} else {
var el = document.getElementById('rtspUrlDisplay');
el.select();
document.execCommand('copy');
toast('📋 地址已复制');
}
}
/* URL面板状态提示辅助 */
function _setUrlStatus(type, msg) {
var el = document.getElementById('rtspUrlStatus');
el.style.color = type === 'ok' ? 'var(--green)'
: type === 'err' ? 'var(--red)'
: 'var(--text-muted)';
el.textContent = msg;
}
/* ============================================================
插件窗口跟随浏览器滚动/缩放重绘§4.2.4
============================================================ */
window.addEventListener('scroll', _resizePlugin);
window.addEventListener('resize', _resizePlugin);
function _resizePlugin() {
if (!oWebControl) return;
var wrapper = document.getElementById('pluginWrapper');
var w = wrapper.clientWidth || 800;
var h = wrapper.clientHeight || 500;
oWebControl.JS_Resize(w, h);
_setWndCover(w, h);
}
/**
* 窗口裁剪:当插件 div 因滚动被部分遮挡时,用 JS_CuttingPartWindow 抠掉对应区域
* 防止插件原生窗口"浮出"在其他 DOM 元素之上demo_window_integration 标准写法)
*/
function _setWndCover(w, h) {
if (!oWebControl) return;
var iWidth = window.innerWidth;
var iHeight = window.innerHeight;
var rect = document.getElementById('playWnd').getBoundingClientRect();
var iCoverLeft = rect.left < 0 ? Math.abs(rect.left) : 0;
var iCoverTop = rect.top < 0 ? Math.abs(rect.top) : 0;
var iCoverRight = rect.right > iWidth ? Math.round(rect.right - iWidth) : 0;
var iCoverBottom = rect.bottom > iHeight ? Math.round(rect.bottom - iHeight) : 0;
iCoverLeft = Math.min(iCoverLeft, w);
iCoverTop = Math.min(iCoverTop, h);
iCoverRight = Math.min(iCoverRight, w);
iCoverBottom = Math.min(iCoverBottom, h);
// 先还原整个窗口(多 1px 防边界缺失)
oWebControl.JS_RepairPartWindow(0, 0, w + 1, h);
if (iCoverLeft > 0) oWebControl.JS_CuttingPartWindow(0, 0, iCoverLeft, h);
if (iCoverTop > 0) oWebControl.JS_CuttingPartWindow(0, 0, w + 1, iCoverTop);
if (iCoverRight > 0) oWebControl.JS_CuttingPartWindow(w - iCoverRight, 0, iCoverRight, h);
if (iCoverBottom > 0) oWebControl.JS_CuttingPartWindow(0, h - iCoverBottom, w, iCoverBottom);
}
/* ============================================================
页面关闭/离开时断开插件服务§4.2.8
- 先 JS_HideWnd 隐去窗口,避免插件窗口滞后消失
- 再 JS_Disconnect 断开服务
============================================================ */
window.addEventListener('beforeunload', function() {
if (!oWebControl) return;
oWebControl.JS_HideWnd();
oWebControl.JS_Disconnect();
});
/* ============================================================
SSE 数据抓取日志面板
============================================================ */
var _logCount = 0;
var _logExpanded = false;
var _sseSource = null;
/** 初始化 SSE 连接,页面加载后自动建立 */
function initSse() {
if (_sseSource) { _sseSource.close(); }
_sseSource = new EventSource('/api/events');
_sseSource.addEventListener('fetch', function(e) {
try {
var evt = JSON.parse(e.data);
_appendLog(evt);
} catch(err) { /* 忽略解析异常 */ }
});
_sseSource.onopen = function() {
document.getElementById('sseDot').className = 'log-sse-dot live';
};
_sseSource.onerror = function() {
document.getElementById('sseDot').className = 'log-sse-dot';
/* 浏览器会自动重连,无需手动处理 */
};
}
/** 追加一条日志记录到面板 */
function _appendLog(evt) {
_logCount++;
document.getElementById('logBadge').textContent = _logCount + ' 条';
var body = document.getElementById('logBody');
var entry = document.createElement('div');
entry.className = 'log-entry';
var level = (evt.level || 'INFO');
var type = (evt.type || '');
var source = (evt.source || '');
var message = (evt.message || '');
var time = (evt.time || '');
entry.innerHTML =
'<span class="log-time">' + escHtml(time) + '</span>' +
'<span class="log-level ' + level + '">' + level + '</span>' +
'<span class="log-type">' + escHtml(type) + '</span>' +
'<span class="log-src">' + escHtml(source) + '</span>' +
'<span class="log-msg">' + escHtml(message) + '</span>';
body.appendChild(entry);
/* 自动滚动到最新条目 */
body.scrollTop = body.scrollHeight;
/* 有新日志时若面板折叠则闪烁提示 */
if (!_logExpanded) {
var badge = document.getElementById('logBadge');
badge.style.borderColor = 'var(--accent)';
badge.style.color = 'var(--accent)';
setTimeout(function(){
badge.style.borderColor = '';
badge.style.color = '';
}, 800);
}
}
/** 切换面板展开/折叠 */
function toggleLogPanel() {
_logExpanded = !_logExpanded;
var panel = document.getElementById('logPanel');
var icon = document.getElementById('logToggleIcon');
if (_logExpanded) {
panel.classList.add('expanded');
icon.textContent = '▼';
/* 展开时滚动到底部 */
var body = document.getElementById('logBody');
setTimeout(function(){ body.scrollTop = body.scrollHeight; }, 50);
} else {
panel.classList.remove('expanded');
icon.textContent = '▲';
}
}
/** 清空日志 */
function clearLog(e) {
e.stopPropagation();
document.getElementById('logBody').innerHTML = '';
_logCount = 0;
document.getElementById('logBadge').textContent = '0 条';
}
/* 页面加载后自动建立 SSE 连接 */
window.addEventListener('DOMContentLoaded', initSse);
/* ============================================================
抓图功能
============================================================ */
/**
* 触发抓图
* @param {string} cameraIndexCode 监控点编码
* @param {string} cameraName 监控点名称
* @param {HTMLElement} btn 触发按钮元素(用于显示加载状态)
*/
function captureImage(cameraIndexCode, cameraName, btn) {
if (!cameraIndexCode) { toast('⚠ 监控点编码为空'); return; }
if (btn) { btn.textContent = '⏳'; btn.disabled = true; }
toast('📸 正在抓图…', 5000);
setStatus('正在抓图 ' + (cameraName || cameraIndexCode) + '…');
fetch('/api/captures/' + encodeURIComponent(cameraIndexCode)
+ '?cameraName=' + encodeURIComponent(cameraName || ''), { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(res) {
if (btn) { btn.textContent = '📸'; btn.disabled = false; }
if (res.code === 200 && res.data) {
var d = res.data;
toast('✅ 抓图成功:' + d.filename, 4000);
setStatus('抓图完成 ' + d.filename);
/* 切换到抓图 Tab 并刷新图库 */
switchSideTab('capture');
} else {
toast('❌ 抓图失败:' + (res.msg || '未知错误'), 5000);
setStatus('抓图失败');
}
})
.catch(function(e) {
if (btn) { btn.textContent = '📸'; btn.disabled = false; }
toast('❌ 抓图请求失败:' + e.message, 5000);
setStatus('抓图异常');
});
}
/** 加载本地抓图列表,渲染图库 */
function loadCaptures() {
var el = document.getElementById('captureGallery');
el.innerHTML = '<div class="empty-cameras"><div class="loading"></div><br/>加载中…</div>';
fetch('/api/captures')
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 200 || !res.data) {
el.innerHTML = '<div class="empty-cameras"><div class="icon">⚠</div>加载失败</div>';
return;
}
var list = res.data;
if (!list.length) {
el.innerHTML = '<div class="empty-cameras"><div class="icon">📸</div>暂无抓图记录<br>点击摄像头旁的 📸 按钮开始抓图</div>';
return;
}
var html = '<div class="capture-gallery">';
list.forEach(function(item) {
var fn = escHtml(item.filename || '');
var url = escHtml(item.localUrl || '');
var t = escHtml(item.captureTime || '');
var kb = item.fileSizeBytes ? Math.round(item.fileSizeBytes / 1024) + ' KB' : '';
var code = escHtml(item.cameraIndexCode || '');
html += '<div class="capture-card">'
+ '<img src="' + url + '" alt="' + fn + '" loading="lazy"'
+ ' onclick="openLightbox(\'' + url + '\',\'' + fn + '\',\'' + t + '\')"'
+ ' onerror="this.style.background=\'#1e293b\';this.alt=\'图片加载失败\'" />'
+ '<div class="capture-card-info">'
+ '<div class="capture-card-name" title="' + code + '">' + (code || fn) + '</div>'
+ '<div class="capture-card-time">' + t + (kb ? ' · ' + kb : '') + '</div>'
+ '</div>'
+ '<div class="capture-card-actions">'
+ '<a class="capture-action-btn" href="' + url + '" download="' + fn + '">⬇ 下载</a>'
+ '<span class="capture-action-btn del" onclick="deleteCapture(\'' + escHtml(item.filename) + '\',this)">🗑 删除</span>'
+ '</div>'
+ '</div>';
});
html += '</div>';
el.innerHTML = html;
})
.catch(function(e) {
el.innerHTML = '<div class="empty-cameras"><div class="icon">⚠</div>请求失败</div>';
});
}
/** 删除抓图 */
function deleteCapture(filename, btn) {
if (!confirm('确认删除 ' + filename + ' ')) return;
fetch('/api/captures/' + encodeURIComponent(filename), { method: 'DELETE' })
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 200) {
toast('🗑 已删除 ' + filename);
loadCaptures();
} else {
toast('❌ 删除失败:' + (res.msg || ''), 4000);
}
})
.catch(function(e) { toast('❌ 删除请求失败', 4000); });
}
/** 打开灯箱 */
function openLightbox(url, filename, time) {
document.getElementById('lightbox-img').src = url;
document.getElementById('lightbox-info').textContent = filename + (time ? ' · ' + time : '');
document.getElementById('lightbox').className = 'show';
}
/** 关闭灯箱 */
function closeLightbox() {
document.getElementById('lightbox').className = '';
document.getElementById('lightbox-img').src = '';
}
/* ESC 关闭灯箱 */
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeLightbox();
});
/* ============================================================
抓图子 Tab 切换
============================================================ */
function switchCaptureTab(tab) {
var tabs = ['gallery', 'tasks'];
var colors = { gallery: 'var(--accent)', tasks: 'var(--accent)' };
tabs.forEach(function(t) {
var el = document.getElementById('ctab-' + t);
var panel = document.getElementById('cpanel-' + t);
var active = t === tab;
el.style.color = active ? 'var(--accent)' : 'var(--text-muted)';
el.style.borderBottom = active ? '2px solid var(--accent)' : '2px solid transparent';
panel.style.display = active ? 'flex' : 'none';
});
if (tab === 'tasks') loadCaptureTasks();
if (tab === 'gallery') loadCaptures();
}
/* ============================================================
定时任务管理
============================================================ */
/** 创建定时任务 */
function createCaptureTask() {
var code = document.getElementById('taskCameraCode').value.trim();
var name = document.getElementById('taskCameraName').value.trim();
var interval = parseInt(document.getElementById('taskInterval').value) || 60;
var maxCap = parseInt(document.getElementById('taskMaxCaptures').value) || 0;
if (!code) { toast('⚠ 请输入监控点编号'); return; }
if (interval < 10) { toast('⚠ 间隔最小 10 秒'); return; }
fetch('/api/capture-tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cameraIndexCode: code,
cameraName: name || code,
intervalSeconds: interval,
maxCaptures: maxCap
})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 200) {
toast('✅ 任务已创建,点击「启动」开始执行', 3000);
document.getElementById('taskCameraCode').value = '';
document.getElementById('taskCameraName').value = '';
loadCaptureTasks();
} else {
toast('❌ 创建失败:' + (res.msg || ''), 4000);
}
})
.catch(function(e) { toast('❌ 请求失败:' + e.message, 4000); });
}
/** 加载任务列表 */
function loadCaptureTasks() {
var el = document.getElementById('captureTaskList');
el.innerHTML = '<div class="empty-cameras"><div class="loading"></div><br/>加载中…</div>';
fetch('/api/capture-tasks')
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code !== 200 || !res.data || !res.data.length) {
el.innerHTML = '<div class="empty-cameras"><div class="icon">⏱</div>暂无定时任务</div>';
return;
}
var html = '';
res.data.forEach(function(t) {
var isRunning = t.status === 'RUNNING';
var isError = t.status === 'ERROR';
var cardCls = isRunning ? 'task-card running' : (isError ? 'task-card error' : 'task-card');
var statusCls = t.status || 'STOPPED';
var statusTxt = isRunning ? '▶ 运行中' : (isError ? '⚠ 出错' : '⏹ 已停止');
var maxTxt = t.maxCaptures > 0 ? '/' + t.maxCaptures : '';
html += '<div class="' + cardCls + '">';
html += '<div class="task-header">'
+ '<span class="task-name">' + escHtml(t.cameraName || t.cameraIndexCode) + '</span>'
+ '<span class="task-status ' + statusCls + '">' + statusTxt + '</span>'
+ '</div>';
html += '<div class="task-meta">'
+ '📷 ' + escHtml(t.cameraIndexCode) + '<br>'
+ '⏱ 每 ' + t.intervalSeconds + ' 秒 · '
+ '📸 已抓 ' + (t.captureCount || 0) + maxTxt + ' 次';
if (t.lastCaptureTime) html += '<br>🕐 最近:' + escHtml(t.lastCaptureTime);
if (t.lastError) html += '<br><span style="color:var(--yellow)">⚠ ' + escHtml(t.lastError.substring(0,60)) + '</span>';
html += '</div>';
/* 最近抓图缩略图 */
if (t.lastCaptureUrl) {
html += '<img class="task-last-img" src="' + escHtml(t.lastCaptureUrl) + '"'
+ ' onclick="openLightbox(\'' + escHtml(t.lastCaptureUrl) + '\',\'最近抓图\',\'\')"'
+ ' onerror="this.style.display=\'none\'" />';
}
/* 操作按钮 */
html += '<div class="task-actions">';
if (isRunning || isError) {
html += '<button class="task-btn stop" onclick="taskAction(\'' + t.id + '\',\'stop\')">⏸ 暂停</button>';
} else {
html += '<button class="task-btn start" onclick="taskAction(\'' + t.id + '\',\'start\')">▶ 启动</button>';
}
html += '<button class="task-btn" onclick="captureImage(\'' + esc(t.cameraIndexCode) + '\',\'' + esc(t.cameraName) + '\',null)">📸 立即抓图</button>';
html += '<button class="task-btn del" onclick="deleteTask(\'' + t.id + '\')">🗑</button>';
html += '</div>';
html += '</div>';
});
el.innerHTML = html;
})
.catch(function(e) {
el.innerHTML = '<div class="empty-cameras"><div class="icon">⚠</div>加载失败</div>';
});
}
/** 启动/停止任务 */
function taskAction(id, action) {
fetch('/api/capture-tasks/' + id + '/' + action, { method: 'PUT' })
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 200) {
toast(action === 'start' ? '▶ 任务已启动' : '⏸ 任务已暂停');
loadCaptureTasks();
} else {
toast('❌ 操作失败:' + (res.msg || ''), 4000);
}
})
.catch(function(e) { toast('❌ 请求失败', 3000); });
}
/** 删除任务 */
function deleteTask(id) {
if (!confirm('确认删除该定时任务?')) return;
fetch('/api/capture-tasks/' + id, { method: 'DELETE' })
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.code === 200) { toast('🗑 任务已删除'); loadCaptureTasks(); }
else { toast('❌ 删除失败:' + (res.msg || ''), 4000); }
})
.catch(function(e) { toast('❌ 请求失败', 3000); });
}
/* 定时自动刷新任务列表(当定时任务 Tab 可见时每 10 秒刷新一次) */
setInterval(function() {
var tasksPanel = document.getElementById('cpanel-tasks');
if (tasksPanel && tasksPanel.style.display !== 'none') {
loadCaptureTasks();
}
}, 10000);
/* 小工具 */
function escHtml(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
/* 设置默认回放时间最近1小时本地时间非UTC */
(function(){
var now = new Date();
var oneHrAgo = new Date(now - 3600000);
/* datetime-local 需要 "YYYY-MM-DDTHH:mm" 格式,必须用本地时间各分量拼接 */
function fmtLocal(d) {
var pad = function(n){ return n < 10 ? '0' + n : String(n); };
return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate())
+ 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
document.getElementById('endTime').value = fmtLocal(now);
document.getElementById('beginTime').value = fmtLocal(oneHrAgo);
})();
</script>
</body>
</html>