2159 lines
79 KiB
HTML
2159 lines
79 KiB
HTML
<!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()">
|
||
🔌 连接插件 & 初始化
|
||
</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>
|
||
|
|
||
安装后重启浏览器,点击左侧「连接插件 & 初始化」
|
||
</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
|
||
/* 设置默认回放时间:最近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>
|