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

1527 lines
68 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, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>海康 H5 视频监控</title>
<style>
:root {
--bg: #0a0e1a;
--surface: #111827;
--surface2: #0f1623;
--border: #1e3a5f;
--accent: #0ea5e9;
--accent-dim: #0369a1;
--accent-glow: rgba(14,165,233,.12);
--text: #e2e8f0;
--text-muted: #64748b;
--text-dim: #94a3b8;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--radius: 8px;
--btn-h: 38px;
}
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
html, body {
height: 100%; background: var(--bg); color: var(--text);
font-family: 'PingFang SC','Microsoft YaHei',sans-serif;
font-size: 14px; overflow: hidden;
}
#root { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
/* ── 顶栏 ── */
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 0 12px; height: 46px; flex-shrink: 0;
background: var(--surface2); border-bottom: 1px solid var(--border);
}
.topbar-brand { display: flex; align-items: center; gap: 8px; font-size: 15px; font-weight: 700; }
.topbar-brand .logo {
width: 26px; height: 26px; border-radius: 6px;
background: linear-gradient(135deg, var(--accent), #3b82f6);
display: flex; align-items: center; justify-content: center; font-size: 13px;
}
.topbar-right { display: flex; align-items: center; gap: 6px; }
.conn-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--red); box-shadow: 0 0 5px var(--red); }
.conn-dot.on { background: var(--green); box-shadow: 0 0 5px var(--green); }
.conn-label { font-size: 11px; color: var(--text-muted); }
/* ── 播放器 ── */
#player { width: 100%; background: #000; flex-shrink: 0; position: relative; }
.ov-btns { position: absolute; bottom: 6px; right: 6px; display: flex; gap: 4px; }
.ov-btn {
width: 30px; height: 30px; border-radius: 50%; border: none;
background: rgba(0,0,0,.55); color: #fff; cursor: pointer; font-size: 13px;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); transition: background .15s;
}
.ov-btn:hover { background: rgba(14,165,233,.75); }
/* ── 分屏栏 ── */
.split-bar {
display: flex; align-items: center; gap: 5px; flex-shrink: 0;
padding: 5px 12px; background: var(--surface2); border-bottom: 1px solid var(--border);
}
.split-label { font-size: 11px; color: var(--text-muted); }
.sp-pill {
padding: 2px 9px; border-radius: 99px; font-size: 11px; cursor: pointer;
border: 1px solid var(--border); background: var(--surface); color: var(--text-muted);
transition: all .15s;
}
.sp-pill.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.split-bar-r { margin-left: auto; display: flex; gap: 5px; }
.ico-btn {
width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-dim); cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 12px;
transition: all .15s;
}
.ico-btn:hover, .ico-btn.hi { border-color: var(--accent); color: var(--accent); background: var(--accent-glow); }
.ico-btn:active { transform: scale(.9); }
/* ── 标签页 ── */
.tabs {
display: flex; flex-shrink: 0;
background: var(--surface2); border-bottom: 1px solid var(--border);
}
.tab {
flex: 1; padding: 9px 2px; text-align: center; font-size: 11px; font-weight: 600;
color: var(--text-muted); cursor: pointer; letter-spacing: .2px;
border-bottom: 2px solid transparent; transition: all .15s;
}
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
/* ── 可滚动面板 ── */
.panel { flex: 1; overflow-y: auto; background: var(--bg); -webkit-overflow-scrolling: touch; }
.panel::-webkit-scrollbar { width: 2px; }
.panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.pane { display: none; }
.pane.active { display: block; }
/* ── 通用区块 ── */
.sec { padding: 10px 12px; border-bottom: 1px solid var(--border); }
.sec-title {
font-size: 10px; color: var(--text-muted); text-transform: uppercase;
letter-spacing: .8px; font-weight: 600; margin-bottom: 7px;
}
/* ── 搜索框 ── */
.search-wrap { position: relative; padding: 8px 12px 6px; }
.search-inp {
width: 100%; background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text); padding: 7px 10px 7px 28px;
font-size: 12px; outline: none;
}
.search-inp:focus { border-color: var(--accent); }
.search-icon { position: absolute; left: 20px; top: 50%; transform: translateY(-50%); color: var(--text-muted); font-size: 13px; pointer-events: none; }
/* ── 面包屑 ── */
.breadcrumb {
display: flex; align-items: center; flex-wrap: wrap; gap: 3px;
padding: 5px 12px 6px; min-height: 28px; font-size: 11px;
color: var(--text-muted); border-bottom: 1px solid var(--border);
}
.bc-item { color: var(--accent); cursor: pointer; }
.bc-item:hover { text-decoration: underline; }
.bc-sep { color: var(--text-muted); }
.bc-cur { color: var(--text-dim); }
/* ── 返回按钮 ── */
.back-row {
display: flex; align-items: center; gap: 6px;
padding: 7px 12px; font-size: 12px; color: var(--accent);
cursor: pointer; border-bottom: 1px solid var(--border);
}
.back-row:hover { background: var(--accent-glow); }
/* ── 分组标题 ── */
.group-title {
display: flex; align-items: center; justify-content: space-between;
padding: 5px 12px 3px; font-size: 10px; font-weight: 700;
color: var(--text-muted); text-transform: uppercase; letter-spacing: .5px;
}
.group-count {
background: var(--surface); border: 1px solid var(--border);
border-radius: 99px; padding: 1px 6px; font-size: 9px; color: var(--text-muted);
}
/* ── 区域条目 ── */
.region-item {
display: flex; align-items: center; gap: 8px;
padding: 9px 12px; cursor: pointer;
border-bottom: 1px solid rgba(30,58,95,.4);
transition: background .15s;
}
.region-item:hover { background: var(--surface); }
.region-icon { font-size: 15px; flex-shrink: 0; }
.region-name { flex: 1; font-size: 13px; font-weight: 500; }
.region-arrow { font-size: 11px; color: var(--text-muted); }
/* ── 摄像头条目 ── */
.cam-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-bottom: 1px solid rgba(30,58,95,.4);
transition: background .15s;
}
.cam-item:hover { background: var(--surface); }
.cam-item.active { background: var(--accent-glow); border-left: 2px solid var(--accent); }
.cam-icon { font-size: 14px; flex-shrink: 0; }
.cam-info { flex: 1; min-width: 0; }
.cam-name { font-size: 12px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cam-code { font-size: 10px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: monospace; }
.cam-badge { font-size: 9px; padding: 1px 5px; border-radius: 99px; flex-shrink: 0; }
.b-on { background: rgba(34,197,94,.15); color: var(--green); }
.b-off { background: rgba(239,68,68,.15); color: var(--red); }
.b-unk { background: rgba(100,116,139,.15); color: var(--text-muted); }
.cam-actions { display: flex; gap: 4px; flex-shrink: 0; }
.ca-btn {
width: 26px; height: 26px; border-radius: 5px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-dim); cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 12px;
transition: all .15s;
}
.ca-btn.play:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
.ca-btn.cap:hover { background: #7c3aed; border-color: #7c3aed; color: #fff; }
/* ── 输入 / 选择 ── */
.inp {
width: 100%; background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text); padding: 7px 10px;
font-size: 12px; outline: none; transition: border .2s;
}
.inp:focus { border-color: var(--accent); }
.inp[readonly] { color: var(--text-dim); cursor: default; }
.sel {
width: 100%; background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text); padding: 7px 9px;
font-size: 12px; outline: none;
}
.field { margin-bottom: 7px; }
.field-lbl { font-size: 10px; color: var(--text-muted); margin-bottom: 3px; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
/* ── 按钮 ── */
.btn-row { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
.btn {
height: var(--btn-h); padding: 0 12px; border-radius: var(--radius); border: none;
cursor: pointer; font-size: 11px; font-weight: 600;
display: flex; align-items: center; gap: 4px; white-space: nowrap;
transition: opacity .15s, transform .1s;
}
.btn:active { opacity: .75; transform: scale(.95); }
.btn-primary { background: var(--accent); color: #fff; }
.btn-default { background: var(--surface); color: var(--text); border: 1px solid var(--border); }
.btn-danger { background: var(--red); color: #fff; }
.btn-warn { background: var(--yellow); color: #000; }
.btn-ghost { background: transparent; color: var(--accent); border: 1px solid var(--accent); }
.btn-sm { height: 30px; padding: 0 9px; font-size: 11px; }
.btn-full { width: 100%; justify-content: center; }
/* ── 音量 ── */
.vol-row { display: flex; align-items: center; gap: 8px; }
.vol-icon { font-size: 16px; cursor: pointer; flex-shrink: 0; }
.vol-slider {
flex: 1; -webkit-appearance: none; height: 3px;
border-radius: 2px; background: var(--border); outline: none;
}
.vol-slider::-webkit-slider-thumb {
-webkit-appearance: none; width: 14px; height: 14px;
border-radius: 50%; background: var(--accent); cursor: pointer;
}
.vol-val { min-width: 24px; text-align: right; font-size: 11px; color: var(--text-muted); }
/* ── 速率胶囊 ── */
.rate-wrap { display: flex; flex-wrap: wrap; gap: 4px; }
.rate-pill {
padding: 3px 8px; border-radius: 99px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-muted); cursor: pointer; font-size: 11px;
transition: all .15s;
}
.rate-pill.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* ── 抓图图库 ── */
.gallery { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 8px; }
.g-card { border-radius: 7px; overflow: hidden; border: 1px solid var(--border); background: var(--surface); }
.g-card:hover { border-color: var(--accent); }
.g-card img { width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; background: #000; cursor: pointer; }
.g-info { padding: 4px 6px; }
.g-name { font-size: 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.g-time { font-size: 9px; color: var(--text-muted); font-family: monospace; }
.g-actions { display: flex; gap: 3px; padding: 0 6px 5px; }
.g-act {
flex: 1; font-size: 10px; padding: 2px 0; text-align: center;
border: 1px solid var(--border); border-radius: 4px;
background: var(--bg); color: var(--text-muted); cursor: pointer; text-decoration: none;
transition: all .15s;
}
.g-act:hover { border-color: var(--accent); color: var(--accent); }
.g-act.del:hover { border-color: var(--red); color: var(--red); }
/* ── 定时任务卡片 ── */
.task-card {
border: 1px solid var(--border); border-radius: 7px;
background: var(--surface); padding: 9px 10px; margin-bottom: 6px;
}
.task-card.running { border-color: var(--green); }
.task-card.error { border-color: var(--yellow); }
.task-hd { display: flex; align-items: center; justify-content: space-between; margin-bottom: 5px; }
.task-name { font-size: 12px; font-weight: 600; }
.task-st { font-size: 10px; padding: 2px 6px; border-radius: 99px; }
.task-st.RUNNING { background: rgba(34,197,94,.15); color: var(--green); }
.task-st.STOPPED { background: rgba(100,116,139,.15); color: var(--text-muted); }
.task-st.ERROR { background: rgba(245,158,11,.15); color: var(--yellow); }
.task-meta { font-size: 10px; color: var(--text-muted); line-height: 1.8; }
.task-thumb { width: 100%; border-radius: 4px; margin-top: 5px; aspect-ratio: 16/9; object-fit: cover; background: #000; cursor: pointer; border: 1px solid var(--border); }
.task-btns { display: flex; gap: 4px; margin-top: 6px; }
.t-btn {
flex: 1; font-size: 10px; padding: 4px 0; text-align: center;
border: 1px solid var(--border); border-radius: 5px;
background: var(--bg); color: var(--text-dim); cursor: pointer; transition: all .15s;
}
.t-btn.start:hover { border-color: var(--green); color: var(--green); }
.t-btn.stop:hover { border-color: var(--yellow); color: var(--yellow); }
.t-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,.88); align-items: center; justify-content: center;
flex-direction: column; gap: 10px;
}
#lightbox.show { display: flex; }
#lightbox img { max-width: 92vw; max-height: 78vh; border-radius: 7px; }
#lb-info { color: var(--text-dim); font-size: 12px; }
#lb-close { position: fixed; top: 16px; right: 16px; font-size: 26px; color: #fff; cursor: pointer; z-index: 9001; }
/* ── 状态栏 ── */
.statusbar {
background: var(--surface2); border-top: 1px solid var(--border);
padding: 4px 12px; flex-shrink: 0; display: flex; align-items: center; gap: 6px;
}
.sb-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-muted); flex-shrink: 0; }
.sb-dot.playing { background: var(--green); animation: pulse 1.5s infinite; }
.sb-dot.error { background: var(--red); }
.sb-text { font-size: 11px; color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: monospace; }
.sb-rate { font-size: 11px; color: var(--yellow); font-weight: 700; }
/* ── Toast ── */
#toast {
position: fixed; bottom: 56px; left: 50%; transform: translateX(-50%);
background: #1e293b; border: 1px solid var(--border); border-radius: var(--radius);
padding: 7px 16px; font-size: 12px; color: var(--text); pointer-events: none;
display: none; z-index: 9999; white-space: nowrap; box-shadow: 0 4px 20px rgba(0,0,0,.5);
}
#toast.show { display: block; animation: fadeup .2s; }
/* ── 工具类 ── */
.spin {
width: 14px; height: 14px; border: 2px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
animation: spin .6s linear infinite; display: inline-block; vertical-align: middle;
}
.empty-tip { text-align: center; padding: 28px 12px; color: var(--text-muted); font-size: 12px; line-height: 2; }
.empty-tip .ei { font-size: 26px; display: block; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.3} }
@keyframes spin { to{transform:rotate(360deg)} }
@keyframes fadeup { from{opacity:0;transform:translateX(-50%) translateY(6px)} to{opacity:1;transform:translateX(-50%)} }
::-webkit-media-controls { display: none !important; }
</style>
</head>
<body>
<div id="root">
<!-- 顶栏 -->
<div class="topbar">
<div class="topbar-brand">
<div class="logo">📹</div>
海康 H5 监控
</div>
<div class="topbar-right">
<div class="conn-dot" id="conn-dot"></div>
<span class="conn-label" id="conn-label">未连接</span>
</div>
</div>
<!-- 播放器 -->
<div id="player">
<div class="ov-btns">
<button class="ov-btn" title="整体全屏" onclick="wholeFullScreen()"></button>
<button class="ov-btn" title="单窗口全屏" onclick="singleFullScreen()"></button>
</div>
</div>
<!-- 分屏栏 -->
<div class="split-bar">
<span class="split-label">分屏</span>
<span class="sp-pill active" id="sp1" onclick="setSplit(1)">1×1</span>
<span class="sp-pill" id="sp2" onclick="setSplit(2)">2×2</span>
<span class="sp-pill" id="sp3" onclick="setSplit(3)">3×3</span>
<span class="sp-pill" id="sp4" onclick="setSplit(4)">4×4</span>
<div class="split-bar-r">
<button class="ico-btn" id="mode-btn" onclick="cycleMode()" title="普通/高级模式">MSE</button>
<button class="ico-btn" title="全部停止" onclick="stopAllPlay()"></button>
</div>
</div>
<!-- 标签页导航 -->
<div class="tabs">
<div class="tab active" id="tab-region" onclick="switchTab('region')">🗂 区域</div>
<div class="tab" id="tab-preview" onclick="switchTab('preview')">▶ 预览</div>
<div class="tab" id="tab-playback" onclick="switchTab('playback')">⏪ 回放</div>
<div class="tab" id="tab-capture" onclick="switchTab('capture')">📸 抓图</div>
<div class="tab" id="tab-more" onclick="switchTab('more')">⚙ 更多</div>
</div>
<!-- ═══════════════════ 面板内容 ═══════════════════ -->
<div class="panel">
<!-- ══ 区域摄像头 ══ -->
<div class="pane active" id="pane-region">
<!-- 搜索框 -->
<div class="search-wrap">
<span class="search-icon">🔍</span>
<input class="search-inp" type="search" id="cam-search"
placeholder="搜索摄像头名称或编号…" oninput="filterInCurrentView(this.value)">
</div>
<!-- 面包屑 -->
<div class="breadcrumb" id="breadcrumb">
<span class="bc-cur">根节点</span>
</div>
<!-- 区域/摄像头列表 -->
<div id="region-list">
<div class="empty-tip"><div class="spin"></div><br>加载中…</div>
</div>
</div>
<!-- ══ 预览控制 ══ -->
<div class="pane" id="pane-preview">
<div class="sec">
<div class="sec-title">当前摄像头</div>
<div class="field">
<div class="field-lbl">监控点编号(点击区域列表自动填入)</div>
<input class="inp" id="cur-code" placeholder="cameraIndexCode" readonly>
</div>
<div class="row2">
<div class="field">
<div class="field-lbl">码流</div>
<select class="sel" id="stream-type">
<option value="0">主码流</option>
<option value="1">子码流</option>
</select>
</div>
<div class="field">
<div class="field-lbl">协议</div>
<select class="sel" id="stream-proto">
<option value="ws">WS内网推荐</option>
<option value="wss">WSS加密</option>
<option value="rtsp">RTSP</option>
<option value="hls">HLS</option>
<option value="flv">FLV</option>
</select>
</div>
<div class="field">
<div class="field-lbl">传输</div>
<select class="sel" id="trans-mode">
<option value="1">TCP</option>
<option value="0">UDP</option>
</select>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" onclick="startPreview()">▶ 开始预览</button>
<button class="btn btn-danger" onclick="stopPlay()">■ 停止</button>
<button class="btn btn-default btn-sm" onclick="stopAllPlay()">全部停止</button>
</div>
</div>
<div class="sec">
<div class="sec-title">播放地址(调试用)</div>
<div style="display:flex;gap:6px;">
<input class="inp" id="wss-url" readonly placeholder="预览后自动填入">
<button class="btn btn-default btn-sm" onclick="copyText('wss-url')">📋</button>
</div>
<div style="margin-top:6px;font-size:11px;color:var(--yellow);line-height:1.6;">
⚠ 若使用 WSS 报 WebSocket 连接失败,请先在浏览器中访问<br>
<span style="color:var(--accent);font-family:monospace;">https://10.60.49.82</span>
<span style="color:var(--accent);font-family:monospace;">https://10.60.49.82:6014</span><br>
手动接受自签名证书,或改用 <b>WS</b> 协议(内网无需证书)。
</div>
</div>
<div class="sec">
<div class="sec-title">声音</div>
<div class="vol-row">
<span class="vol-icon" id="vol-icon" onclick="toggleMute()">🔇</span>
<input class="vol-slider" type="range" id="vol-slider" min="0" max="100" value="50"
oninput="setVolume(this.value)">
<span class="vol-val" id="vol-val">50</span>
</div>
</div>
<div class="sec">
<div class="sec-title">对讲</div>
<div class="btn-row">
<button class="btn btn-default btn-sm" onclick="talkStart()">🎙 开始</button>
<button class="btn btn-default btn-sm" onclick="talkStop()">✕ 停止</button>
</div>
</div>
<div class="sec">
<div class="sec-title">后端抓图(调用 API</div>
<div class="btn-row">
<button class="btn btn-default btn-sm" onclick="captureImage(selCode, selName, null)">📸 API抓图</button>
</div>
</div>
</div><!-- /pane-preview -->
<!-- ══ 回放控制 ══ -->
<div class="pane" id="pane-playback">
<div class="sec">
<div class="sec-title">回放参数</div>
<div class="field">
<div class="field-lbl">监控点编号</div>
<input class="inp" id="pb-code" placeholder="cameraIndexCode" readonly>
</div>
<div class="field">
<div class="field-lbl">开始时间</div>
<input class="inp" type="datetime-local" id="pb-start" step="1">
</div>
<div class="field">
<div class="field-lbl">结束时间</div>
<input class="inp" type="datetime-local" id="pb-end" step="1">
</div>
<div class="btn-row">
<button class="btn btn-primary" onclick="playbackStart()">▶ 开始回放</button>
<button class="btn btn-default" onclick="reversekStart()">◀ 倒放</button>
<button class="btn btn-danger" onclick="stopPlay()">■ 停止</button>
</div>
</div>
<div class="sec">
<div class="sec-title">回放操作</div>
<div class="btn-row">
<button class="btn btn-default btn-sm" onclick="playbackPause()">⏸ 暂停</button>
<button class="btn btn-default btn-sm" onclick="playbackResume(true)">▶ 正向</button>
<button class="btn btn-default btn-sm" onclick="playbackResume(false)">◀ 倒向</button>
<button class="btn btn-default btn-sm" onclick="changeMode()">⇆ 切换</button>
</div>
<div class="btn-row" style="margin-top:5px;">
<button class="btn btn-default btn-sm" onclick="frameBack()">|◀ 单帧退</button>
<button class="btn btn-default btn-sm" onclick="frameForward()">▶| 单帧进</button>
<button class="btn btn-default btn-sm" onclick="playbackSlow()">🐢 慢放</button>
<button class="btn btn-default btn-sm" onclick="playbackFast()">🐇 快放</button>
</div>
</div>
<div class="sec">
<div class="sec-title">定位Seek</div>
<input class="inp" type="datetime-local" id="pb-seek" step="1" style="margin-bottom:6px;">
<button class="btn btn-primary btn-sm" onclick="seekTo()">📍 跳转</button>
</div>
<div class="sec">
<div class="sec-title">播放速率</div>
<div class="rate-wrap" id="rate-pills">
<span class="rate-pill" data-r="-8" onclick="setRate(-8)">-8×</span>
<span class="rate-pill" data-r="-4" onclick="setRate(-4)">-4×</span>
<span class="rate-pill" data-r="-2" onclick="setRate(-2)">-2×</span>
<span class="rate-pill active" data-r="1" onclick="setRate(1)">1×</span>
<span class="rate-pill" data-r="2" onclick="setRate(2)">2×</span>
<span class="rate-pill" data-r="4" onclick="setRate(4)">4×</span>
<span class="rate-pill" data-r="8" onclick="setRate(8)">8×</span>
<span class="rate-pill" data-r="16" onclick="setRate(16)">16×</span>
<span class="rate-pill" data-r="32" onclick="setRate(32)">32×</span>
<span class="rate-pill" data-r="64" onclick="setRate(64)">64×</span>
</div>
</div>
<div class="sec">
<div class="sec-title">WSS 回放地址(调试用)</div>
<div style="display:flex;gap:6px;">
<input class="inp" id="pb-wss-url" readonly placeholder="回放后自动填入">
<button class="btn btn-default btn-sm" onclick="copyText('pb-wss-url')">📋</button>
</div>
</div>
</div><!-- /pane-playback -->
<!-- ══ 抓图图库 ══ -->
<div class="pane" id="pane-capture">
<div class="sec" style="padding-bottom:6px;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div class="sec-title" style="margin:0;">本地抓图记录</div>
<button class="btn btn-default btn-sm" onclick="loadCaptures()">↺ 刷新</button>
</div>
</div>
<div id="capture-gallery">
<div class="empty-tip"><span class="ei">📸</span>暂无抓图记录<br>点击摄像头条目旁的 📸 按钮开始抓图</div>
</div>
<!-- 定时任务 -->
<div class="sec" style="margin-top:4px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div class="sec-title" style="margin:0;">⏱ 定时抓图任务</div>
<button class="btn btn-default btn-sm" onclick="loadCaptureTasks()">↺ 刷新</button>
</div>
<!-- 新建任务 -->
<div class="field">
<div class="field-lbl">监控点编号</div>
<input class="inp" id="task-code" placeholder="cameraIndexCode">
</div>
<div class="row2">
<div class="field">
<div class="field-lbl">间隔≥10</div>
<input class="inp" type="number" id="task-interval" value="60" min="10">
</div>
<div class="field">
<div class="field-lbl">最大次数0=无限)</div>
<input class="inp" type="number" id="task-max" value="0" min="0">
</div>
</div>
<button class="btn btn-primary btn-sm" onclick="createTask()" style="margin-bottom:10px;">✚ 创建任务</button>
</div>
<div id="task-list" style="padding:0 8px 12px;">
<div class="empty-tip"><span class="ei"></span>暂无定时任务</div>
</div>
</div><!-- /pane-capture -->
<!-- ══ 更多(高级控制) ══ -->
<div class="pane" id="pane-more">
<div class="sec">
<div class="sec-title">旋转 &amp; 缩放</div>
<div class="row2" style="margin-bottom:6px;">
<div>
<div class="field-lbl">旋转角度</div>
<select class="sel" id="sel-rotate" onchange="doRotate()">
<option value="0"></option>
<option value="90">90°</option>
<option value="180">180°</option>
<option value="270">270°</option>
</select>
</div>
<div>
<div class="field-lbl">显示比例</div>
<select class="sel" id="sel-scale" onchange="doScale()">
<option value="fill">平铺</option>
<option value="16:9">16:9</option>
<option value="4:3">4:3</option>
<option value="9:16">9:16</option>
<option value="3:4">3:4</option>
</select>
</div>
</div>
<button class="btn btn-default btn-sm" onclick="scaleCancel()">↩ 还原</button>
</div>
<div class="sec">
<div class="sec-title">智能信息 &amp; 电子放大</div>
<div class="btn-row">
<button class="btn btn-default btn-sm" onclick="intellectTrigger(true)">开启智能</button>
<button class="btn btn-default btn-sm" onclick="intellectTrigger(false)">关闭智能</button>
<button class="btn btn-default btn-sm" onclick="enlarge()">开启放大</button>
<button class="btn btn-default btn-sm" onclick="enlargeClose()">关闭放大</button>
</div>
</div>
<div class="sec">
<div class="sec-title">视频信息</div>
<div class="btn-row">
<button class="btn btn-default btn-sm" onclick="getVideoInfo()">获取视频信息</button>
<button class="btn btn-default btn-sm" onclick="getOSDTime()">获取OSD时间</button>
</div>
</div>
<div class="sec">
<div class="sec-title">抓图水印</div>
<div class="row2">
<div class="field">
<div class="field-lbl">文字</div>
<input class="inp" id="wm-text" value="hikvision">
</div>
<div class="field">
<div class="field-lbl">间隔(px)</div>
<input class="inp" type="number" id="wm-space" value="240">
</div>
<div class="field">
<div class="field-lbl">角度(°)</div>
<input class="inp" type="number" id="wm-angle" value="-15">
</div>
<div class="field">
<div class="field-lbl">颜色</div>
<input class="inp" id="wm-color" value="rgba(255,255,255,0.5)">
</div>
</div>
<div class="field">
<div class="field-lbl">字体</div>
<input class="inp" id="wm-font" value="20px Microsoft YaHei">
</div>
<div class="btn-row">
<button class="btn btn-default btn-sm" onclick="setWatermark()">设置水印</button>
<button class="btn btn-danger btn-sm" onclick="cancelWatermark()">清除水印</button>
</div>
</div>
<div class="sec">
<div class="sec-title">录像 &amp; 对讲录音</div>
<div class="btn-row">
<button class="btn btn-warn btn-sm" onclick="recordStart()">⏺ 录制MP4</button>
<button class="btn btn-danger btn-sm" onclick="recordStop()">⏹ 停止录制</button>
<button class="btn btn-warn btn-sm" onclick="recordStartTalk()">🎤 录制音频</button>
<button class="btn btn-danger btn-sm" onclick="recordStopTalk()">⏹ 停止录音</button>
</div>
</div>
<div style="height:20px;"></div>
</div><!-- /pane-more -->
</div><!-- /panel -->
<!-- 状态栏 -->
<div class="statusbar">
<div class="sb-dot" id="sb-dot"></div>
<span class="sb-text" id="sb-text">就绪</span>
<span class="sb-rate" id="sb-rate"></span>
</div>
</div><!-- /root -->
<!-- 灯箱 -->
<div id="lightbox" onclick="closeLightbox()">
<span id="lb-close" onclick="closeLightbox()"></span>
<img id="lb-img" src="" alt="预览" onclick="event.stopPropagation()">
<div id="lb-info"></div>
</div>
<div id="toast"></div>
<!-- H5player 核心(与 playctrl* 一起在 static/ 根) -->
<script src="/h5player.min.js"></script>
<script>
/* ════════════════════════════════════════════════════════
全局状态
════════════════════════════════════════════════════════ */
var player = null;
var jsMode = 1; // 0=MSE, 1=高级解码
var isMuted = true;
var selCode = ''; // 当前选中摄像头编号
var selName = ''; // 当前选中摄像头名称
/* 区域树导航栈:[{code, name}, ...], code=null 表示根节点 */
var _regionStack = [];
/* 当前层级完整数据(用于搜索过滤) */
var _curRegions = [];
var _curChannels = [];
/* ════════════════════════════════════════════════════════
播放器初始化
════════════════════════════════════════════════════════ */
function initPlayer() {
player = new JSPlugin({
szId: 'player',
szBasePath: '/', // h5player.min.js + playctrl* 在 static/ 根
iMaxSplit: 4,
iCurrentSplit: 1,
openDebug: true,
mseWorkerEnable: false,
bSupporDoubleClickFull: true,
oStyle: { borderSelect: '#0ea5e9' }
});
player.JS_SetWindowControlCallback({
windowEventSelect: function(idx) { setStatus('已选中窗口 ' + idx, ''); },
pluginErrorHandler: function(idx, code, err) {
setStatus('错误[' + idx + ']: ' + (err || code), 'error');
toast('❌ ' + (err || code));
},
windowFullCcreenChange: function(f) { setStatus(f ? '全屏模式' : '退出全屏', ''); },
firstFrameDisplay: function(idx, w, h) {
setStatus('播放中 ' + w + '×' + h, 'playing');
setConnDot(true);
},
StreamEnd: function(idx) { setStatus('流已结束[' + idx + ']', ''); setConnDot(false); },
performanceLack: function() { toast('⚠ 性能不足,建议降低分辨率'); }
});
window.addEventListener('resize', function() {
if (player) player.JS_Resize();
adjustPlayerHeight();
});
adjustPlayerHeight();
/* 默认回放时间最近1小时 */
var now = new Date();
var ago = new Date(now - 3600000);
document.getElementById('pb-end').value = fmtDtLocal(now);
document.getElementById('pb-start').value = fmtDtLocal(ago);
document.getElementById('pb-seek').value = fmtDtLocal(ago);
}
function adjustPlayerHeight() {
var w = document.getElementById('player').offsetWidth;
var h = Math.min(Math.round(w * 9 / 16), Math.round(window.innerHeight * 0.36));
document.getElementById('player').style.height = h + 'px';
if (player) player.JS_Resize();
}
/* ════════════════════════════════════════════════════════
UI 工具
════════════════════════════════════════════════════════ */
function setStatus(text, state) {
document.getElementById('sb-dot').className = 'sb-dot' + (state ? ' ' + state : '');
document.getElementById('sb-text').textContent = text;
}
function setRateBadge(r) {
document.getElementById('sb-rate').textContent = (r && r !== 1) ? r + '×' : '';
}
function setConnDot(on) {
document.getElementById('conn-dot').className = 'conn-dot' + (on ? ' on' : '');
document.getElementById('conn-label').textContent = on ? '已连接' : '未连接';
}
var _tid;
function toast(msg, ms) {
var el = document.getElementById('toast');
el.textContent = msg; el.className = 'show';
clearTimeout(_tid);
_tid = setTimeout(function(){ el.className = ''; }, ms || 2500);
}
/* ════════════════════════════════════════════════════════
标签页切换
════════════════════════════════════════════════════════ */
function switchTab(name) {
['region','preview','playback','capture','more'].forEach(function(n) {
document.getElementById('tab-' + n).classList.toggle('active', n === name);
document.getElementById('pane-' + n).classList.toggle('active', n === name);
});
if (name === 'capture') { loadCaptures(); loadCaptureTasks(); }
}
/* ════════════════════════════════════════════════════════
分屏 / 模式
════════════════════════════════════════════════════════ */
function setSplit(n) {
[1,2,3,4].forEach(function(x){
document.getElementById('sp'+x).classList.toggle('active', x === n);
});
if (!player) return;
player.JS_ArrangeWindow(n)
.then(function(){ setStatus('分屏: ' + n + '×' + n, ''); })
.catch(function(e){ toast('分屏失败: ' + e); });
}
function cycleMode() {
jsMode = jsMode === 0 ? 1 : 0;
var btn = document.getElementById('mode-btn');
btn.textContent = jsMode === 0 ? 'MSE' : 'HDC';
btn.classList.toggle('hi', jsMode === 1);
toast(jsMode === 0 ? '普通模式(MSE)' : '高级解码模式');
}
/* ════════════════════════════════════════════════════════
全屏
════════════════════════════════════════════════════════ */
function wholeFullScreen() {
if (!player) return;
player.JS_FullScreenDisplay(true).catch(function(e){ toast('全屏失败: ' + e); });
}
function singleFullScreen() {
if (!player) return;
player.JS_FullScreenSingle(player.currentWindowIndex).catch(function(e){ toast(e); });
}
/* ════════════════════════════════════════════════════════
★ 区域树导航(与 index.html 完全对齐的 API
GET /api/regions?cascadeFlag=0&pageSize=200[&parentCode={code}]
GET /api/regions/{code}/channels?cascadeFlag=0&pageSize=200
════════════════════════════════════════════════════════ */
function loadRegionTree() {
_regionStack = [];
_loadRegionLevel(null);
}
function _loadRegionLevel(parentCode) {
var listEl = document.getElementById('region-list');
listEl.innerHTML = '<div class="empty-tip"><div class="spin"></div><br>加载中…</div>';
_renderBreadcrumb();
var regionUrl = '/api/regions?cascadeFlag=0&pageSize=200'
+ (parentCode ? '&parentCode=' + encodeURIComponent(parentCode) : '');
var channelUrl = parentCode
? '/api/regions/' + encodeURIComponent(parentCode) + '/channels?cascadeFlag=0&pageSize=200'
: null;
var regionP = fetch(regionUrl).then(function(r){ return r.json(); });
var channelP = channelUrl
? fetch(channelUrl).then(function(r){ return r.json(); })
: Promise.resolve(null);
Promise.all([regionP, channelP]).then(function(results) {
var rRes = results[0];
var cRes = results[1];
_curRegions = (rRes.code === 200 && rRes.data && rRes.data.list) ? rRes.data.list : [];
_curChannels = (cRes && cRes.code === 200 && cRes.data && cRes.data.list) ? cRes.data.list : [];
_renderRegionLevel(_curRegions, _curChannels);
}).catch(function() {
listEl.innerHTML = '<div class="empty-tip"><span class="ei">⚠</span>加载失败,请确认后端服务已启动</div>';
});
}
function _renderRegionLevel(regions, channels) {
var el = document.getElementById('region-list');
var html = '';
/* 返回上级 */
if (_regionStack.length > 0) {
html += '<div class="back-row" onclick="_goBack()">◀ 返回上级</div>';
}
/* 子区域 */
if (regions.length > 0) {
html += '<div class="group-title">区域 <span class="group-count">' + regions.length + '</span></div>';
regions.forEach(function(r) {
var code = escHtml(r.regionIndexCode || '');
var name = escHtml(r.regionName || '未命名');
html += '<div class="region-item" onclick="_drillDown(\'' + code + '\',\'' + name + '\')">'
+ '<span class="region-icon">📁</span>'
+ '<span class="region-name">' + name + '</span>'
+ '<span class="region-arrow">▶</span>'
+ '</div>';
});
}
/* 摄像头通道 */
if (channels.length > 0) {
html += '<div class="group-title" style="margin-top:4px;">摄像头 <span class="group-count">' + channels.length + '</span></div>';
channels.forEach(function(cam) {
html += _buildCamItem(cam);
});
}
if (regions.length === 0 && channels.length === 0) {
html += '<div class="empty-tip"><span class="ei">📭</span>该区域暂无子区域或摄像头</div>';
}
el.innerHTML = html;
}
function _buildCamItem(cam) {
var code = cam.cameraIndexCode || '';
var name = cam.cameraName || '未命名';
var st = cam.onlineStatus;
var icon = (st == null || st === '') ? '📷' : (st === '1' ? '🟢' : '🔴');
var badge = (st == null || st === '') ? 'b-unk' : (st === '1' ? 'b-on' : 'b-off');
var btxt = (st == null || st === '') ? '未知' : (st === '1' ? '在线' : '离线');
var ec = escHtml(code), en = escHtml(name);
return '<div class="cam-item" id="ci-' + ec + '" onclick="selectCam(\'' + ec + '\',\'' + en + '\')">'
+ '<span class="cam-icon">' + icon + '</span>'
+ '<div class="cam-info">'
+ '<div class="cam-name">' + en + '</div>'
+ '<div class="cam-code">' + ec + '</div>'
+ '</div>'
+ '<span class="cam-badge ' + badge + '">' + btxt + '</span>'
+ '<div class="cam-actions">'
+ '<button class="ca-btn play" title="立即预览" onclick="event.stopPropagation();quickPlay(\'' + ec + '\',\'' + en + '\')">▶</button>'
+ '<button class="ca-btn cap" title="API抓图" onclick="event.stopPropagation();captureImage(\'' + ec + '\',\'' + en + '\',this)">📸</button>'
+ '</div>'
+ '</div>';
}
function _drillDown(code, name) {
_regionStack.push({ code: code, name: name });
_loadRegionLevel(code);
}
function _goBack() {
_regionStack.pop();
var parent = _regionStack.length > 0 ? _regionStack[_regionStack.length - 1] : { code: null };
_loadRegionLevel(parent.code);
}
function _jumpTo(idx) {
if (idx < 0) { _regionStack = []; _loadRegionLevel(null); return; }
_regionStack = _regionStack.slice(0, idx + 1);
_loadRegionLevel(_regionStack[idx].code);
}
function _renderBreadcrumb() {
var el = document.getElementById('breadcrumb');
if (_regionStack.length === 0) {
el.innerHTML = '<span class="bc-cur">根节点</span>'; return;
}
var html = '<span class="bc-item" onclick="_jumpTo(-1)">根节点</span>';
_regionStack.forEach(function(item, i) {
html += '<span class="bc-sep"> / </span>';
if (i === _regionStack.length - 1) {
html += '<span class="bc-cur">' + escHtml(item.name) + '</span>';
} else {
html += '<span class="bc-item" onclick="_jumpTo(' + i + ')">' + escHtml(item.name) + '</span>';
}
});
el.innerHTML = html;
}
/* 搜索过滤(仅过滤当前层摄像头,区域保持显示) */
function filterInCurrentView(kw) {
if (!kw) { _renderRegionLevel(_curRegions, _curChannels); return; }
var lk = kw.toLowerCase();
var filtered = _curChannels.filter(function(c) {
return (c.cameraName || '').toLowerCase().indexOf(lk) >= 0
|| (c.cameraIndexCode || '').toLowerCase().indexOf(lk) >= 0;
});
_renderRegionLevel(_curRegions, filtered);
}
/* ════════════════════════════════════════════════════════
选中摄像头
════════════════════════════════════════════════════════ */
function selectCam(code, name) {
selCode = code; selName = name;
/* 同步到预览/回放面板 */
document.getElementById('cur-code').value = code;
document.getElementById('pb-code').value = code;
document.getElementById('task-code').value = code;
/* 高亮 */
document.querySelectorAll('.cam-item').forEach(function(el){ el.classList.remove('active'); });
var el = document.getElementById('ci-' + code);
if (el) el.classList.add('active');
setStatus('已选中: ' + name, '');
toast('已选中: ' + name);
}
/* 一键预览 */
function quickPlay(code, name) {
selectCam(code, name);
switchTab('preview');
startPreview();
}
/* ════════════════════════════════════════════════════════
★ 预览(调后端 v2 接口获取播放地址)
GET /api/cameras/{code}/preview-url/v2?protocol=ws|wss|rtsp|hls|flv&streamType=&transmode=
════════════════════════════════════════════════════════ */
function startPreview() {
if (!player) { toast('播放器未就绪'); return; }
var code = document.getElementById('cur-code').value.trim();
var sType = document.getElementById('stream-type').value;
var tMode = document.getElementById('trans-mode').value;
var proto = document.getElementById('stream-proto').value;
if (!code) { toast('⚠ 请先在区域列表中选择摄像头'); return; }
setStatus('获取预览地址…', '');
toast('⏳ 连接中…', 6000);
fetch('/api/cameras/' + encodeURIComponent(code)
+ '/preview-url/v2?protocol=' + proto + '&streamType=' + sType + '&transmode=' + tMode)
.then(function(r){ return r.json(); })
.then(function(res) {
if (res.code !== 200 || !res.data || !res.data.url) {
setStatus('获取地址失败', 'error');
toast('❌ 获取预览地址失败: ' + (res.msg || JSON.stringify(res)));
return;
}
var wss = res.data.url;
document.getElementById('wss-url').value = wss;
player.JS_Play(wss, { playURL: wss, mode: jsMode, keepDecoder: 0 }, player.currentWindowIndex)
.then(function(){
setStatus('预览: ' + (selName || code), 'playing');
setConnDot(true); setRateBadge(1);
toast('▶ 预览已开始');
})
.catch(function(e){ setStatus('播放失败: ' + e, 'error'); toast('❌ ' + e); });
})
.catch(function(e){ setStatus('网络错误', 'error'); toast('❌ ' + e.message); });
}
/* ════════════════════════════════════════════════════════
★ 回放(调后端接口获取播放地址)
GET /api/cameras/{code}/playback-url?beginTime=&endTime=&protocol=ws|wss|rtsp&transmode=1&recordLocation=0
════════════════════════════════════════════════════════ */
function playbackStart() {
_doPlayback(false);
}
function reversekStart() {
_doPlayback(true);
}
function _doPlayback(reverse) {
if (!player) { toast('播放器未就绪'); return; }
var code = document.getElementById('pb-code').value.trim();
var start = document.getElementById('pb-start').value;
var end = document.getElementById('pb-end').value;
/* 回放协议跟随预览协议选择 */
var proto = document.getElementById('stream-proto') ? document.getElementById('stream-proto').value : 'ws';
if (!code) { toast('⚠ 请先选择摄像头'); return; }
if (!start || !end) { toast('⚠ 请选择时间段'); return; }
var startIso = toIso8601(start);
var endIso = toIso8601(end);
setStatus('获取回放地址…', '');
toast('⏳ 获取中…', 8000);
fetch('/api/cameras/' + encodeURIComponent(code) + '/playback-url'
+ '?beginTime=' + encodeURIComponent(startIso)
+ '&endTime=' + encodeURIComponent(endIso)
+ '&protocol=' + proto + '&transmode=1&recordLocation=0')
.then(function(r){ return r.json(); })
.then(function(res) {
if (res.code !== 200 || !res.data || !res.data.url) {
setStatus('获取回放地址失败', 'error');
toast('❌ ' + (res.msg || JSON.stringify(res)));
return;
}
var wss = res.data.url;
document.getElementById('pb-wss-url').value = wss;
var opt = { playURL: wss, mode: jsMode, keepDecoder: 0 };
if (reverse) opt.PlayBackMode = 3;
player.JS_Play(wss, opt, player.currentWindowIndex, startIso, endIso)
.then(function(){
setStatus((reverse ? '倒放' : '回放') + ': ' + code, 'playing');
setConnDot(true); setRateBadge(reverse ? -1 : 1);
toast((reverse ? '◀ 倒放' : '▶ 回放') + '已开始');
})
.catch(function(e){ setStatus('回放失败: ' + e, 'error'); toast('❌ ' + e); });
})
.catch(function(e){ setStatus('网络错误', 'error'); toast('❌ ' + e.message); });
}
/* 停止 / 暂停 / 恢复 / Seek */
function stopPlay() {
if (!player) return;
player.JS_Stop()
.then(function(){ setStatus('已停止', ''); setConnDot(false); setRateBadge(0); })
.catch(function(e){ toast(e); });
}
function stopAllPlay() {
if (!player) return;
player.JS_StopRealPlayAll()
.then(function(){ setStatus('全部已停止', ''); setConnDot(false); setRateBadge(0); })
.catch(function(e){ toast(e); });
}
function playbackPause() {
if (!player) return;
player.JS_Pause(player.currentWindowIndex)
.then(function(){ setStatus('已暂停', ''); }).catch(function(e){ toast(e); });
}
function playbackResume(fwd) {
if (!player) return;
player.JS_Resume(player.currentWindowIndex, fwd)
.then(function(){ setStatus(fwd ? '正向恢复' : '倒向恢复', 'playing'); }).catch(function(e){ toast(e); });
}
function seekTo() {
if (!player) return;
var seek = toIso8601(document.getElementById('pb-seek').value);
var end = toIso8601(document.getElementById('pb-end').value);
player.JS_Seek(player.currentWindowIndex, seek, end)
.then(function(){ setStatus('已跳转', 'playing'); toast('📍 跳转成功'); })
.catch(function(e){ toast('跳转失败: ' + e); });
}
function playbackSlow() {
if (!player) return;
player.JS_Slow()
.then(function(r){ setRateBadge(r); setStatus('慢放 ' + r + '×', 'playing'); })
.catch(function(e){ toast(e); });
}
function playbackFast() {
if (!player) return;
player.JS_Fast()
.then(function(r){ setRateBadge(r); setStatus('快放 ' + r + '×', 'playing'); })
.catch(function(e){ toast(e); });
}
function frameForward() {
if (!player) return;
player.JS_FrameForward(player.currentWindowIndex)
.then(function(){ setStatus('单帧进', ''); }).catch(function(e){ toast(e); });
}
function frameBack() {
if (!player) return;
player.JS_FrameBack(player.currentWindowIndex)
.then(function(){ setStatus('单帧退', ''); }).catch(function(e){ toast(e); });
}
function changeMode() {
if (!player) return;
player.JS_ChangeMode(player.currentWindowIndex);
toast('正倒放已切换');
}
function setRate(r) {
document.querySelectorAll('.rate-pill').forEach(function(p){
p.classList.toggle('active', parseFloat(p.dataset.r) === r);
});
if (!player) return;
player.JS_Speed(player.currentWindowIndex, r)
.then(function(rate){ setRateBadge(rate); setStatus('速率 ' + rate + '×', 'playing'); })
.catch(function(e){ toast(e); });
}
/* 对讲 */
function talkStart() {
var url = document.getElementById('wss-url').value.trim();
if (!url || !player) { toast('⚠ 请先预览后再对讲'); return; }
player.JS_StartTalk(url)
.then(function(){ toast('🎙 对讲已开始'); setStatus('对讲中…', 'playing'); })
.catch(function(e){ toast('对讲失败: ' + e); });
}
function talkStop() {
if (!player) return;
player.JS_StopTalk()
.then(function(){ setStatus('对讲已停止', ''); }).catch(function(e){ toast(e); });
}
/* 声音 */
function openSound() {
if (!player) return;
player.JS_OpenSound()
.then(function(){ isMuted = false; document.getElementById('vol-icon').textContent = '🔊'; })
.catch(function(e){ toast(e); });
}
function closeSound() {
if (!player) return;
player.JS_CloseSound()
.then(function(){ isMuted = true; document.getElementById('vol-icon').textContent = '🔇'; })
.catch(function(e){ toast(e); });
}
function toggleMute() { isMuted ? openSound() : closeSound(); }
function setVolume(val) {
document.getElementById('vol-val').textContent = val;
if (!player) return;
player.JS_SetVolume(player.currentWindowIndex, parseInt(val)).catch(function(){});
}
/* 录像 / 对讲录音 */
function recordStart() {
if (!player) return;
var fn = _ts() + '.mp4';
player.JS_StartSaveEx(player.currentWindowIndex, fn, 5, { irecordType: 1 })
.then(function(){ toast('⏺ 录制: ' + fn); setStatus('录制中', 'playing'); })
.catch(function(e){ toast('录制失败: ' + e); });
}
function recordStop() {
if (!player) return;
player.JS_StopSave(player.currentWindowIndex)
.then(function(){ toast('⏹ 录制已停止'); setStatus('保存中…', ''); })
.catch(function(e){ toast(e); });
}
function recordStartTalk() {
if (!player) return;
player.JS_StartSaveTalk(_ts() + '.mp3', 2)
.then(function(){ toast('🎤 音频录制中'); }).catch(function(e){ toast(e); });
}
function recordStopTalk() {
if (!player) return;
player.JS_StopSaveTalk()
.then(function(){ toast('⏹ 录音已停止'); }).catch(function(e){ toast(e); });
}
/* ════════════════════════════════════════════════════════
★ 后端抓图(与 index.html 完全对齐)
POST /api/captures/{cameraIndexCode}?cameraName={name}
════════════════════════════════════════════════════════ */
function captureImage(code, name, btn) {
if (!code) { toast('⚠ 请先选择摄像头'); return; }
if (btn) { btn.textContent = '⏳'; btn.disabled = true; }
toast('📸 抓图中…', 5000);
setStatus('抓图: ' + (name || code) + '…', '');
fetch('/api/captures/' + encodeURIComponent(code)
+ '?cameraName=' + encodeURIComponent(name || ''), { method: 'POST' })
.then(function(r){ return r.json(); })
.then(function(res) {
if (btn) { btn.textContent = '📸'; btn.disabled = false; }
if (res.code === 200 && res.data) {
toast('✅ 抓图成功: ' + res.data.filename, 4000);
setStatus('抓图完成: ' + res.data.filename, '');
switchTab('capture');
} else {
toast('❌ 抓图失败: ' + (res.msg || '未知'), 5000);
setStatus('抓图失败', 'error');
}
})
.catch(function(e) {
if (btn) { btn.textContent = '📸'; btn.disabled = false; }
toast('❌ 请求失败: ' + e.message, 5000);
});
}
/* ════════════════════════════════════════════════════════
★ 抓图图库(与 index.html 完全对齐)
GET /api/captures
════════════════════════════════════════════════════════ */
function loadCaptures() {
var el = document.getElementById('capture-gallery');
el.innerHTML = '<div class="empty-tip"><div class="spin"></div><br>加载中…</div>';
fetch('/api/captures')
.then(function(r){ return r.json(); })
.then(function(res) {
if (res.code !== 200 || !res.data || !res.data.length) {
el.innerHTML = '<div class="empty-tip"><span class="ei">📸</span>暂无抓图记录</div>';
return;
}
var html = '<div class="gallery">';
res.data.forEach(function(item) {
var fn = escHtml(item.filename || '');
var url = escHtml(item.localUrl || '');
var t = escHtml(item.captureTime || '');
var code = escHtml(item.cameraIndexCode || '');
var kb = item.fileSizeBytes ? Math.round(item.fileSizeBytes / 1024) + ' KB' : '';
html += '<div class="g-card">'
+ '<img src="' + url + '" alt="' + fn + '" loading="lazy"'
+ ' onclick="openLightbox(\'' + url + '\',\'' + fn + '\',\'' + t + '\')"'
+ ' onerror="this.style.background=\'#1e293b\'">'
+ '<div class="g-info">'
+ '<div class="g-name" title="' + code + '">' + (code || fn) + '</div>'
+ '<div class="g-time">' + t + (kb ? ' · ' + kb : '') + '</div>'
+ '</div>'
+ '<div class="g-actions">'
+ '<a class="g-act" href="' + url + '" download="' + fn + '">⬇ 下载</a>'
+ '<span class="g-act del" onclick="deleteCapture(\'' + fn + '\',this)">🗑 删除</span>'
+ '</div>'
+ '</div>';
});
html += '</div>';
el.innerHTML = html;
})
.catch(function() {
el.innerHTML = '<div class="empty-tip"><span class="ei">⚠</span>加载失败</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('🗑 已删除'); loadCaptures(); }
else { toast('❌ 删除失败: ' + (res.msg || ''), 4000); }
})
.catch(function(){ toast('❌ 请求失败', 3000); });
}
/* ════════════════════════════════════════════════════════
★ 定时任务(与 index.html 完全对齐)
════════════════════════════════════════════════════════ */
function createTask() {
var code = document.getElementById('task-code').value.trim();
var interval = parseInt(document.getElementById('task-interval').value) || 60;
var maxCap = parseInt(document.getElementById('task-max').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: selName || code, intervalSeconds: interval, maxCaptures: maxCap })
})
.then(function(r){ return r.json(); })
.then(function(res) {
if (res.code === 200) {
toast('✅ 任务已创建', 3000);
document.getElementById('task-code').value = '';
loadCaptureTasks();
} else { toast('❌ ' + (res.msg || ''), 4000); }
})
.catch(function(e){ toast('❌ ' + e.message, 4000); });
}
function loadCaptureTasks() {
var el = document.getElementById('task-list');
el.innerHTML = '<div class="empty-tip"><div class="spin"></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-tip"><span class="ei">⏱</span>暂无定时任务</div>';
return;
}
var html = '';
res.data.forEach(function(t) {
var running = t.status === 'RUNNING', err = t.status === 'ERROR';
var cls = running ? 'task-card running' : (err ? 'task-card error' : 'task-card');
var stTxt = running ? '▶ 运行中' : (err ? '⚠ 出错' : '⏹ 已停止');
var maxTxt = t.maxCaptures > 0 ? '/' + t.maxCaptures : '';
html += '<div class="' + cls + '">'
+ '<div class="task-hd">'
+ '<span class="task-name">' + escHtml(t.cameraName || t.cameraIndexCode) + '</span>'
+ '<span class="task-st ' + (t.status || 'STOPPED') + '">' + stTxt + '</span>'
+ '</div>'
+ '<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,50)) + '</span>';
html += '</div>';
if (t.lastCaptureUrl) {
html += '<img class="task-thumb" src="' + escHtml(t.lastCaptureUrl) + '"'
+ ' onclick="openLightbox(\'' + escHtml(t.lastCaptureUrl) + '\',\'最近抓图\',\'\')"'
+ ' onerror="this.style.display=\'none\'">';
}
html += '<div class="task-btns">';
if (running || err) {
html += '<button class="t-btn stop" onclick="taskAction(\'' + t.id + '\',\'stop\')">⏸ 暂停</button>';
} else {
html += '<button class="t-btn start" onclick="taskAction(\'' + t.id + '\',\'start\')">▶ 启动</button>';
}
html += '<button class="t-btn" onclick="captureImage(\'' + escHtml(t.cameraIndexCode) + '\',\'' + escHtml(t.cameraName||'') + '\',null)">📸 立即抓图</button>';
html += '<button class="t-btn del" onclick="deleteTask(\'' + t.id + '\')">🗑</button>';
html += '</div></div>';
});
el.innerHTML = html;
})
.catch(function(){ el.innerHTML = '<div class="empty-tip"><span class="ei">⚠</span>加载失败</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(){ 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(){ toast('❌ 请求失败', 3000); });
}
/* 定时自动刷新任务抓图Tab可见时每10秒刷新 */
setInterval(function() {
var el = document.getElementById('task-list');
var pane = document.getElementById('pane-capture');
if (el && pane && pane.classList.contains('active')) loadCaptureTasks();
}, 10000);
/* ════════════════════════════════════════════════════════
高级控制
════════════════════════════════════════════════════════ */
function doRotate() {
if (!player) return;
player.JS_Rotate(player.currentWindowIndex, parseInt(document.getElementById('sel-rotate').value))
.then(function(){ setStatus('旋转完成', ''); }).catch(function(e){ toast(e); });
}
function doScale() {
if (!player) return;
player.JS_Scale(player.currentWindowIndex, document.getElementById('sel-scale').value)
.then(function(){ setStatus('缩放完成', ''); }).catch(function(e){ toast(e); });
}
function scaleCancel() {
if (!player) return;
player.JS_ScaleCancel(player.currentWindowIndex).then(function(){
document.getElementById('sel-rotate').value = '0';
document.getElementById('sel-scale').value = 'fill';
setStatus('已还原', '');
});
}
function enlarge() {
if (!player) return;
player.JS_EnableZoom(player.currentWindowIndex)
.then(function(){ setStatus('电子放大:请框选区域', ''); }).catch(function(e){ toast(e); });
}
function enlargeClose() {
if (!player) return;
player.JS_DisableZoom(player.currentWindowIndex)
.then(function(){ setStatus('电子放大已关闭', ''); }).catch(function(e){ toast(e); });
}
function intellectTrigger(flag) {
if (!player) return;
player.JS_RenderALLPrivateData(player.currentWindowIndex, flag)
.then(function(){ setStatus('智能信息: ' + (flag ? '开启' : '关闭'), ''); }).catch(function(e){ toast(e); });
}
function getVideoInfo() {
if (!player) return;
player.JS_GetVideoInfo(player.currentWindowIndex)
.then(function(info){ toast(JSON.stringify(info)); setStatus(JSON.stringify(info), ''); }).catch(function(e){ toast(e); });
}
function getOSDTime() {
if (!player) return;
player.JS_GetOSDTime(player.currentWindowIndex)
.then(function(t){ toast('OSD: ' + new Date(t).toLocaleString()); }).catch(function(e){ toast(e); });
}
function setWatermark() {
if (!player) return;
player.JS_SetWatermarkConfig({
text: document.getElementById('wm-text').value,
space: parseInt(document.getElementById('wm-space').value) || 240,
rotateDegree: parseInt(document.getElementById('wm-angle').value) || -15,
font: document.getElementById('wm-font').value,
color: document.getElementById('wm-color').value
}).then(function(){ toast('水印已设置'); }).catch(function(e){ toast(e); });
}
function cancelWatermark() {
if (!player) return;
player.JS_CancelWatermarkConfig(); toast('水印已清除');
}
/* ════════════════════════════════════════════════════════
灯箱
════════════════════════════════════════════════════════ */
function openLightbox(url, name, time) {
document.getElementById('lb-img').src = url;
document.getElementById('lb-info').textContent = name + (time ? ' · ' + time : '');
document.getElementById('lightbox').className = 'show';
}
function closeLightbox() {
document.getElementById('lightbox').className = '';
document.getElementById('lb-img').src = '';
}
document.addEventListener('keydown', function(e){ if (e.key === 'Escape') closeLightbox(); });
/* ════════════════════════════════════════════════════════
工具函数
════════════════════════════════════════════════════════ */
function pad2(n){ return n < 10 ? '0' + n : '' + n; }
function _ts() {
var d = new Date();
return d.getFullYear() + pad2(d.getMonth()+1) + pad2(d.getDate())
+ pad2(d.getHours()) + pad2(d.getMinutes()) + pad2(d.getSeconds());
}
function fmtDtLocal(d) {
return d.getFullYear() + '-' + pad2(d.getMonth()+1) + '-' + pad2(d.getDate())
+ 'T' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds());
}
function toIso8601(dtlocalVal) {
if (!dtlocalVal) return '';
var d = new Date(dtlocalVal);
var offset = -d.getTimezoneOffset();
var sign = offset >= 0 ? '+' : '-';
var oh = pad2(Math.floor(Math.abs(offset) / 60));
var om = pad2(Math.abs(offset) % 60);
return d.getFullYear() + '-' + pad2(d.getMonth()+1) + '-' + pad2(d.getDate())
+ 'T' + pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds())
+ '.' + ('00' + d.getMilliseconds()).slice(-3) + sign + oh + ':' + om;
}
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function copyText(id) {
var v = document.getElementById(id).value;
if (!v) { toast('⚠ 无内容可复制'); return; }
if (navigator.clipboard) { navigator.clipboard.writeText(v).then(function(){ toast('📋 已复制'); }); }
else { toast('⚠ 当前环境不支持复制'); }
}
/* ════════════════════════════════════════════════════════
启动
════════════════════════════════════════════════════════ */
window.addEventListener('DOMContentLoaded', function() {
initPlayer();
loadRegionTree(); // 启动时自动加载区域树
});
</script>
</body>
</html>