|
|
|
@ -229,7 +229,18 @@ |
|
|
|
.feed::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(180deg, rgba(7, 20, 36, .04), transparent 50%, rgba(6, 15, 28, .65)); } |
|
|
|
.feed video { |
|
|
|
position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%; |
|
|
|
object-fit: contain; background: #0a1522; |
|
|
|
object-fit: contain; background: #0a1522; touch-action: none; |
|
|
|
} |
|
|
|
.feed.has-video video { cursor: crosshair; } |
|
|
|
.position3d-selection { |
|
|
|
position: absolute; z-index: 3; border: 2px solid #38a3ff; border-radius: 3px; |
|
|
|
background: rgba(56, 163, 255, .14); box-shadow: 0 0 0 1px rgba(255,255,255,.55) inset; |
|
|
|
pointer-events: none; |
|
|
|
} |
|
|
|
.position3d-selection.zoom-out { border-color: #ffb238; background: rgba(255, 178, 56, .14); } |
|
|
|
.position3d-selection::after { |
|
|
|
content: attr(data-action); position: absolute; left: 0; top: -23px; padding: 3px 6px; |
|
|
|
border-radius: 4px; color: #fff; background: rgba(8, 25, 43, .78); font-size: 10px; white-space: nowrap; |
|
|
|
} |
|
|
|
.feed.has-video .feed-scene, |
|
|
|
.feed.has-video .feed-connection { display: none; } |
|
|
|
@ -518,6 +529,7 @@ |
|
|
|
</div> |
|
|
|
<div class="control-row"><label>变倍</label><input id="zoomRange" type="range" min="1" max="7" value="4"><output id="zoomValue">4</output></div> |
|
|
|
<div class="control-row"><label>聚焦</label><input id="focusRange" type="range" min="1" max="7" value="4"><output id="focusValue">4</output></div> |
|
|
|
<button id="wiperBtn" class="action-btn" type="button" style="width:100%;margin-top:9px" title="雨刮器擦拭一次,5秒后自动关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4.5 17a7.5 7.5 0 0 1 15 0"/><path d="m12 17 4.2-6.6"/><circle cx="12" cy="17" r="1.3" fill="currentColor" stroke="none"/></svg><span>雨刮器</span></button> |
|
|
|
</section> |
|
|
|
<section class="side-section"> |
|
|
|
<div class="side-section-head"><strong>预置位</strong><button type="button" title="收起"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="m6 9 6 6 6-6"/></svg></button></div> |
|
|
|
@ -582,6 +594,8 @@ |
|
|
|
const API = '/hik/nvr/playback/hls/ui'; |
|
|
|
// 云台控制直接调用现有 IVS 北向接口,接口契约保持不变。 |
|
|
|
const IVS_PTZ_API = '/device/ptzcontrol'; |
|
|
|
// 视频框选使用独立 3D 定位接口,坐标按有效画面统一归一化到 0~255。 |
|
|
|
const IVS_POSITION_3D_API = '/device/position3d'; |
|
|
|
// 预置位列表直接调用现有 IVS 查询接口,不再经过海康 UI 兼容接口。 |
|
|
|
const IVS_PRESET_LIST_API = '/device/ptzpresetlist'; |
|
|
|
// 平台抓图直接复用现有 IVS 任务创建与图片下载接口。 |
|
|
|
@ -593,6 +607,30 @@ |
|
|
|
IRIS_OPEN: 21, IRIS_CLOSE: 22, ZOOM_IN: 23, ZOOM_OUT: 24, |
|
|
|
FOCUS_NEAR: 25, FOCUS_FAR: 26 |
|
|
|
}; |
|
|
|
// 用户身份由入口 URL 参数(上位系统跳转)传入:username 与 joinbright-token(备用参数名 token), |
|
|
|
// 缺失时回退 sessionStorage。云台请求体携带 username/joinbrightToken,后端据此解析角色并做控制锁抢占。 |
|
|
|
const userInfo = (() => { |
|
|
|
const params = new URLSearchParams(window.location.search); |
|
|
|
const stored = {}; |
|
|
|
try { |
|
|
|
stored.username = sessionStorage.getItem('inspectUsername') || ''; |
|
|
|
stored.joinbrightToken = sessionStorage.getItem('inspectJoinbrightToken') || ''; |
|
|
|
} catch (ignored) { /* 隐私模式下 sessionStorage 可能不可用 */ } |
|
|
|
const username = params.get('username') || stored.username || 'default'; |
|
|
|
const joinbrightToken = params.get('joinbright-token') || params.get('token') |
|
|
|
|| stored.joinbrightToken || ''; |
|
|
|
try { |
|
|
|
sessionStorage.setItem('inspectUsername', username); |
|
|
|
sessionStorage.setItem('inspectJoinbrightToken', joinbrightToken); |
|
|
|
} catch (ignored) { /* 存不进去也不影响本次请求 */ } |
|
|
|
return { username, joinbrightToken }; |
|
|
|
})(); |
|
|
|
function userBodyFields() { |
|
|
|
return { |
|
|
|
username: userInfo.username, |
|
|
|
joinbrightToken: userInfo.joinbrightToken |
|
|
|
}; |
|
|
|
} |
|
|
|
const state = { |
|
|
|
config: null, |
|
|
|
channels: [], |
|
|
|
@ -887,9 +925,152 @@ |
|
|
|
renderCameras(); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 计算 object-fit: contain 后的真实视频内容区,排除上下或左右黑边。 |
|
|
|
*/ |
|
|
|
function liveVideoContentRect(video) { |
|
|
|
const rect = video.getBoundingClientRect(); |
|
|
|
// 浏览器尚未读取流分辨率或元素没有尺寸时不能可靠换算框选坐标。 |
|
|
|
if (!video.videoWidth || !video.videoHeight || !rect.width || !rect.height) return null; |
|
|
|
const scale = Math.min(rect.width / video.videoWidth, rect.height / video.videoHeight); |
|
|
|
const width = video.videoWidth * scale; |
|
|
|
const height = video.videoHeight * scale; |
|
|
|
return { |
|
|
|
left: rect.left + (rect.width - width) / 2, |
|
|
|
top: rect.top + (rect.height - height) / 2, |
|
|
|
width, |
|
|
|
height |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 将鼠标或触摸点限制在有效视频区,并换算为厂商无关的 0~255 坐标。 |
|
|
|
*/ |
|
|
|
function position3dPoint(event, contentRect, clamp = false) { |
|
|
|
let clientX = Number(event.clientX); |
|
|
|
let clientY = Number(event.clientY); |
|
|
|
const right = contentRect.left + contentRect.width; |
|
|
|
const bottom = contentRect.top + contentRect.height; |
|
|
|
// 起点落在黑边时拒绝框选;拖动过程则由调用方选择钳制到画面边界。 |
|
|
|
if (!clamp && (clientX < contentRect.left || clientX > right |
|
|
|
|| clientY < contentRect.top || clientY > bottom)) return null; |
|
|
|
clientX = Math.max(contentRect.left, Math.min(right, clientX)); |
|
|
|
clientY = Math.max(contentRect.top, Math.min(bottom, clientY)); |
|
|
|
return { |
|
|
|
x: Math.round((clientX - contentRect.left) * 255 / contentRect.width), |
|
|
|
y: Math.round((clientY - contentRect.top) * 255 / contentRect.height), |
|
|
|
clientX, |
|
|
|
clientY |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 向当前框选视频所属的 cameraCode 发送 3D 定位请求。 |
|
|
|
*/ |
|
|
|
async function sendPosition3d(slotIndex, start, end) { |
|
|
|
const slot = state.liveSlots[Number(slotIndex)]; |
|
|
|
// 槽位已关闭或缺少 cameraCode 时无法确定真实控制目标。 |
|
|
|
if (!slot || !String(slot.cameraCode || '').trim()) { |
|
|
|
showToast('当前视频缺少 cameraCode', true); |
|
|
|
return; |
|
|
|
} |
|
|
|
try { |
|
|
|
const result = await requestJson(IVS_POSITION_3D_API, { |
|
|
|
method: 'POST', |
|
|
|
body: JSON.stringify({ |
|
|
|
cameraCode: String(slot.cameraCode).trim(), |
|
|
|
startX: start.x, |
|
|
|
startY: start.y, |
|
|
|
endX: end.x, |
|
|
|
endY: end.y, |
|
|
|
...userBodyFields() |
|
|
|
}) |
|
|
|
}); |
|
|
|
// 设备未返回 IVS 成功码时不能提示已经完成定位。 |
|
|
|
if (!result || Number(result.resultCode) !== 0) { |
|
|
|
throw new Error('3D定位控制失败'); |
|
|
|
} |
|
|
|
showToast('已发送3D定位' + (start.x < end.x ? '放大' : '缩小') + '指令'); |
|
|
|
} catch (error) { |
|
|
|
// 将后端参数、认证和设备错误统一显示在当前页面,不隐藏真实失败原因。 |
|
|
|
showToast(error.message || '3D定位控制失败', true); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 在视频上绑定框选手势:左向右放大,右向左缩小。 |
|
|
|
*/ |
|
|
|
function bindPosition3dSelection(video, slotIndex) { |
|
|
|
// 同一播放器节点只绑定一次,避免流重连后重复发送定位请求。 |
|
|
|
if (!video || video.dataset.position3dBound === 'true') return; |
|
|
|
video.dataset.position3dBound = 'true'; |
|
|
|
let drag = null; |
|
|
|
|
|
|
|
video.addEventListener('pointerdown', (event) => { |
|
|
|
// 只接受鼠标主键或单指触摸,避免右键菜单误触真实云台。 |
|
|
|
if (event.button !== 0 || !state.liveSlots[Number(slotIndex)]) return; |
|
|
|
const contentRect = liveVideoContentRect(video); |
|
|
|
// 视频元数据尚未就绪时提示用户等待,不能按整个卡片尺寸猜坐标。 |
|
|
|
if (!contentRect) { |
|
|
|
showToast('视频尺寸尚未就绪,请稍候再框选', true); |
|
|
|
return; |
|
|
|
} |
|
|
|
const start = position3dPoint(event, contentRect); |
|
|
|
// 在 object-fit 黑边上按下时不创建定位框。 |
|
|
|
if (!start) return; |
|
|
|
const feed = video.closest('.feed'); |
|
|
|
const selection = document.createElement('div'); |
|
|
|
selection.className = 'position3d-selection'; |
|
|
|
selection.hidden = true; |
|
|
|
feed.appendChild(selection); |
|
|
|
drag = { pointerId: event.pointerId, contentRect, start, selection, feed }; |
|
|
|
video.setPointerCapture(event.pointerId); |
|
|
|
event.preventDefault(); |
|
|
|
}); |
|
|
|
|
|
|
|
video.addEventListener('pointermove', (event) => { |
|
|
|
// 忽略不属于当前指针的移动,保证多点触摸不会篡改定位框。 |
|
|
|
if (!drag || drag.pointerId !== event.pointerId) return; |
|
|
|
const current = position3dPoint(event, drag.contentRect, true); |
|
|
|
const feedRect = drag.feed.getBoundingClientRect(); |
|
|
|
const left = Math.min(drag.start.clientX, current.clientX) - feedRect.left; |
|
|
|
const top = Math.min(drag.start.clientY, current.clientY) - feedRect.top; |
|
|
|
const width = Math.abs(current.clientX - drag.start.clientX); |
|
|
|
const height = Math.abs(current.clientY - drag.start.clientY); |
|
|
|
drag.selection.hidden = width < 2 && height < 2; |
|
|
|
drag.selection.style.left = left + 'px'; |
|
|
|
drag.selection.style.top = top + 'px'; |
|
|
|
drag.selection.style.width = width + 'px'; |
|
|
|
drag.selection.style.height = height + 'px'; |
|
|
|
const zoomOut = current.x < drag.start.x; |
|
|
|
drag.selection.classList.toggle('zoom-out', zoomOut); |
|
|
|
drag.selection.dataset.action = zoomOut ? '反选缩小' : '框选放大'; |
|
|
|
event.preventDefault(); |
|
|
|
}); |
|
|
|
|
|
|
|
const finish = (event, cancelled = false) => { |
|
|
|
// 当前没有框选或事件来自其他指针时不处理结束动作。 |
|
|
|
if (!drag || drag.pointerId !== event.pointerId) return; |
|
|
|
const active = drag; |
|
|
|
drag = null; |
|
|
|
const end = position3dPoint(event, active.contentRect, true); |
|
|
|
active.selection.remove(); |
|
|
|
// 取消手势或框选过小时只清理覆盖层,不发送任何设备动作。 |
|
|
|
if (cancelled || Math.abs(active.start.x - end.x) <= 2 |
|
|
|
|| Math.abs(active.start.y - end.y) <= 2) return; |
|
|
|
event.preventDefault(); |
|
|
|
event.stopPropagation(); |
|
|
|
void sendPosition3d(slotIndex, active.start, end); |
|
|
|
}; |
|
|
|
video.addEventListener('pointerup', (event) => finish(event)); |
|
|
|
video.addEventListener('pointercancel', (event) => finish(event, true)); |
|
|
|
} |
|
|
|
|
|
|
|
function bindLiveVideo(video, slotIndex) { |
|
|
|
if (!video || video.dataset.liveFocusBound === 'true') return; |
|
|
|
video.dataset.liveFocusBound = 'true'; |
|
|
|
// 同一视频节点复用框选能力,播放器切换或放大预览时无需重复绑定。 |
|
|
|
bindPosition3dSelection(video, slotIndex); |
|
|
|
// 浏览器原生 video 控件会把双击解释为全屏;先拦截默认行为,统一进入应用内放大层。 |
|
|
|
video.addEventListener('dblclick', (event) => { |
|
|
|
event.preventDefault(); |
|
|
|
@ -1265,7 +1446,7 @@ |
|
|
|
+ '" data-nvr-ip="' + escapeHtml(liveSlot.nvrIp) |
|
|
|
+ '" data-channel="' + Number(channel.channel) |
|
|
|
+ '" data-camera-code="' + escapeHtml(liveSlot.cameraCode || '') |
|
|
|
+ '" data-slot-index="' + index + '" title="双击打开实时预览">' + |
|
|
|
+ '" data-slot-index="' + index + '" title="双击放大预览;左向右框选定位放大,右向左框选缩小">' + |
|
|
|
'<div class="feed' + (isLive ? ' has-video' : '') + '">' + |
|
|
|
'<div class="feed-scene ' + scene + '"></div>' + |
|
|
|
'<div class="feed-label"><span class="camera-led"></span>' + cameraName + '</div>' + |
|
|
|
@ -1559,7 +1740,8 @@ |
|
|
|
cameraCode, |
|
|
|
controlCode, |
|
|
|
controlPara1, |
|
|
|
controlPara2 |
|
|
|
controlPara2, |
|
|
|
...userBodyFields() |
|
|
|
}) |
|
|
|
}); |
|
|
|
// IVS接口HTTP成功但业务结果非0时仍按控制失败提示。 |
|
|
|
@ -1570,6 +1752,56 @@ |
|
|
|
} catch (error) { showToast(error.message, true); } |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 雨刮器属于 IVS 辅助设备控制:controlCode=27 打开、28 关闭,controlPara1=1 表示雨刷。 |
|
|
|
*/ |
|
|
|
async function wiperControl(controlCode) { |
|
|
|
const cameraCode = String(state.selectedCameraCode || '').trim(); |
|
|
|
if (!cameraCode) { |
|
|
|
throw new Error('请先选择设备树中的摄像机'); |
|
|
|
} |
|
|
|
const result = await requestJson(IVS_PTZ_API, { |
|
|
|
method: 'POST', |
|
|
|
body: JSON.stringify({ |
|
|
|
cameraCode, |
|
|
|
controlCode, |
|
|
|
// 辅助设备类型固定为 1(雨刷),第二个参数按协议传空。 |
|
|
|
controlPara1: '1', |
|
|
|
controlPara2: '', |
|
|
|
...userBodyFields() |
|
|
|
}) |
|
|
|
}); |
|
|
|
if (!result || Number(result.resultCode) !== 0) { |
|
|
|
throw new Error('IVS雨刮器控制失败'); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 按“擦拭一次”语义使用雨刮器:打开 5 秒后自动关闭,避免长期运转损伤雨刮。 |
|
|
|
*/ |
|
|
|
async function wiperOnce() { |
|
|
|
const button = $('#wiperBtn'); |
|
|
|
if (button.disabled) return; |
|
|
|
button.disabled = true; |
|
|
|
try { |
|
|
|
await wiperControl(27); |
|
|
|
showToast('雨刮器已启动,5秒后自动关闭'); |
|
|
|
setTimeout(async () => { |
|
|
|
try { |
|
|
|
await wiperControl(28); |
|
|
|
showToast('雨刮器已关闭'); |
|
|
|
} catch (error) { |
|
|
|
showToast(error.message || '雨刮器关闭失败', true); |
|
|
|
} finally { |
|
|
|
button.disabled = false; |
|
|
|
} |
|
|
|
}, 5000); |
|
|
|
} catch (error) { |
|
|
|
button.disabled = false; |
|
|
|
showToast(error.message, true); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
function bindPtz() { |
|
|
|
$$('.ptz-btn').forEach((button) => { |
|
|
|
const action = button.dataset.ptz; |
|
|
|
@ -1623,7 +1855,8 @@ |
|
|
|
cameraCode, |
|
|
|
controlCode: 11, |
|
|
|
controlPara1: String(preset), |
|
|
|
controlPara2: '' |
|
|
|
controlPara2: '', |
|
|
|
...userBodyFields() |
|
|
|
}) |
|
|
|
}); |
|
|
|
if (!result || Number(result.resultCode) !== 0) { |
|
|
|
@ -1893,6 +2126,7 @@ |
|
|
|
syncHistorySelection(); |
|
|
|
}); |
|
|
|
$('#snapshotBtn').addEventListener('click', () => capture()); |
|
|
|
$('#wiperBtn').addEventListener('click', () => wiperOnce()); |
|
|
|
$('#callPresetBtn').addEventListener('click', callPreset); |
|
|
|
$('#savePresetBtn').addEventListener('click', savePreset); |
|
|
|
$('#readPresetBtn').addEventListener('click', () => readPresets()); |
|
|
|
|