2025-12-28 08:07:55 +08:00
|
|
|
<!DOCTYPE html>
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
<title>设备控制台</title>
|
|
|
|
|
<link href="https://cdn.staticfile.org/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
|
|
|
|
|
<link href="https://cdn.staticfile.org/bootstrap-icons/1.10.0/font/bootstrap-icons.min.css" rel="stylesheet">
|
|
|
|
|
<style>
|
|
|
|
|
[v-cloak] { display: none; }
|
|
|
|
|
|
2025-12-28 13:14:40 +08:00
|
|
|
html, body {
|
|
|
|
|
height: 100%; margin: 0; padding: 0;
|
|
|
|
|
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
|
|
|
|
overflow: hidden; background: #fff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#app {
|
|
|
|
|
height: 100%; display: flex; flex-direction: column;
|
|
|
|
|
}
|
2025-12-28 08:07:55 +08:00
|
|
|
|
2025-12-28 13:14:40 +08:00
|
|
|
/* 头部 */
|
|
|
|
|
.page-header {
|
|
|
|
|
padding: 15px 30px; border-bottom: 1px solid #e9ecef; background: #fff;
|
|
|
|
|
display: flex; justify-content: space-between; align-items: center; flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
.page-title { font-size: 1.2rem; font-weight: 700; color: #343a40; display: flex; align-items: center; }
|
2025-12-28 08:07:55 +08:00
|
|
|
|
2025-12-28 13:14:40 +08:00
|
|
|
/* 内容区:左右分栏 */
|
|
|
|
|
.content-body {
|
|
|
|
|
flex: 1; overflow: hidden;
|
|
|
|
|
display: flex;
|
|
|
|
|
max-width: 1400px; margin: 0 auto; width: 100%;
|
|
|
|
|
}
|
2025-12-28 08:07:55 +08:00
|
|
|
|
2025-12-28 13:14:40 +08:00
|
|
|
/* 左侧:云台区 */
|
|
|
|
|
.ptz-section {
|
|
|
|
|
flex: 0 0 400px;
|
|
|
|
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
|
|
|
border-right: 1px solid #f0f0f0; background: #fafafa;
|
|
|
|
|
padding: 20px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 右侧:系统功能区 */
|
|
|
|
|
.sys-section {
|
|
|
|
|
flex: 1; padding: 30px 40px; overflow-y: auto;
|
|
|
|
|
background: #fff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 超大方向盘 */
|
|
|
|
|
.d-pad-container { position: relative; width: 260px; height: 260px; margin-bottom: 30px; }
|
|
|
|
|
.d-pad-bg { width: 100%; height: 100%; background: #e9ecef; border-radius: 50%; box-shadow: inset 0 5px 15px rgba(0,0,0,0.05); position: absolute; z-index: 0; }
|
|
|
|
|
|
|
|
|
|
.ptz-btn {
|
|
|
|
|
position: absolute; width: 65px; height: 65px; border: none; background: #fff;
|
|
|
|
|
border-radius: 12px; box-shadow: 0 4px 10px rgba(0,0,0,0.08); color: #495057;
|
|
|
|
|
display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 1;
|
|
|
|
|
transition: all 0.1s;
|
|
|
|
|
}
|
|
|
|
|
.ptz-btn:active { background: #0d6efd; color: #fff; transform: scale(0.92); box-shadow: 0 2px 5px rgba(13,110,253,0.3); }
|
|
|
|
|
.ptz-btn i { font-size: 1.8rem; }
|
|
|
|
|
|
|
|
|
|
.btn-up { top: 15px; left: 97.5px; }
|
|
|
|
|
.btn-down { bottom: 15px; left: 97.5px; }
|
|
|
|
|
.btn-left { top: 97.5px; left: 15px; }
|
|
|
|
|
.btn-right { top: 97.5px; right: 15px; }
|
|
|
|
|
.btn-center {
|
|
|
|
|
top: 97.5px; left: 97.5px; border-radius: 50%;
|
|
|
|
|
background: #fff; color: #dc3545;
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
|
|
|
}
|
|
|
|
|
.btn-center:active { background: #dc3545; color: #fff; }
|
|
|
|
|
|
|
|
|
|
/* 速度滑块 */
|
|
|
|
|
.speed-ctrl { width: 80%; padding: 15px; background: #fff; border: 1px solid #dee2e6; border-radius: 8px; }
|
|
|
|
|
|
|
|
|
|
/* 右侧分组 */
|
|
|
|
|
.ctrl-group { border: 1px solid #e9ecef; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
|
|
|
|
|
.group-title { font-size: 0.9rem; font-weight: 700; color: #0d6efd; margin-bottom: 15px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 2px solid #f0f0f0; padding-bottom: 8px; }
|
|
|
|
|
|
|
|
|
|
.lens-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
|
|
|
|
.lens-label { font-weight: 600; color: #555; width: 80px; }
|
|
|
|
|
|
|
|
|
|
.time-display {
|
|
|
|
|
font-family: 'Consolas', monospace; font-size: 1.2rem; color: #0d6efd;
|
|
|
|
|
background: #f8f9fa; padding: 10px; text-align: center; border-radius: 6px; margin-bottom: 15px; border: 1px dashed #dee2e6;
|
|
|
|
|
}
|
2025-12-28 08:07:55 +08:00
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
|
|
|
|
|
<div id="app" v-cloak>
|
2025-12-28 13:14:40 +08:00
|
|
|
<div class="page-header">
|
|
|
|
|
<div class="page-title">
|
|
|
|
|
<i class="bi bi-joystick me-2 text-primary"></i>
|
|
|
|
|
云台与设备控制 <span class="text-muted ms-2 fw-normal fs-6">(ID: {{ deviceId }})</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="btn btn-outline-secondary" @click="closeMode">
|
|
|
|
|
<i class="bi bi-arrow-return-left me-1"></i> 返回
|
|
|
|
|
</button>
|
2025-12-28 08:07:55 +08:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="content-body">
|
2025-12-28 13:14:40 +08:00
|
|
|
|
2025-12-28 08:07:55 +08:00
|
|
|
<div class="ptz-section">
|
2025-12-28 13:14:40 +08:00
|
|
|
<h6 class="text-muted text-uppercase fw-bold mb-4" style="letter-spacing: 1px;">PTZ Direction</h6>
|
|
|
|
|
|
|
|
|
|
<div class="d-pad-container">
|
|
|
|
|
<div class="d-pad-bg"></div>
|
|
|
|
|
<button class="ptz-btn btn-up" @mousedown="ptzStart('Up')" @mouseup="ptzStop('Up')" @mouseleave="ptzStop('Up')"><i class="bi bi-caret-up-fill"></i></button>
|
|
|
|
|
<button class="ptz-btn btn-left" @mousedown="ptzStart('Left')" @mouseup="ptzStop('Left')" @mouseleave="ptzStop('Left')"><i class="bi bi-caret-left-fill"></i></button>
|
|
|
|
|
<button class="ptz-btn btn-right" @mousedown="ptzStart('Right')" @mouseup="ptzStop('Right')" @mouseleave="ptzStop('Right')"><i class="bi bi-caret-right-fill"></i></button>
|
|
|
|
|
<button class="ptz-btn btn-down" @mousedown="ptzStart('Down')" @mouseup="ptzStop('Down')" @mouseleave="ptzStop('Down')"><i class="bi bi-caret-down-fill"></i></button>
|
|
|
|
|
<button class="ptz-btn btn-center" title="停止"><i class="bi bi-stop-fill"></i></button>
|
2025-12-28 08:07:55 +08:00
|
|
|
</div>
|
2025-12-28 13:14:40 +08:00
|
|
|
|
|
|
|
|
<div class="speed-ctrl">
|
|
|
|
|
<label class="form-label d-flex justify-content-between mb-2">
|
|
|
|
|
<span class="fw-bold text-muted"><i class="bi bi-speedometer2 me-2"></i>转动速度</span>
|
|
|
|
|
<span class="badge bg-primary">{{ speed }}</span>
|
|
|
|
|
</label>
|
|
|
|
|
<input type="range" class="form-range" min="1" max="7" step="1" v-model.number="speed">
|
|
|
|
|
<div class="d-flex justify-content-between small text-muted font-monospace">
|
|
|
|
|
<span>1 (Slow)</span><span>7 (Fast)</span>
|
|
|
|
|
</div>
|
2025-12-28 08:07:55 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="sys-section">
|
2025-12-28 13:14:40 +08:00
|
|
|
|
|
|
|
|
<div class="row g-4">
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<div class="ctrl-group h-100">
|
|
|
|
|
<div class="group-title"><i class="bi bi-camera-fill me-2"></i>镜头参数 (Lens)</div>
|
|
|
|
|
|
|
|
|
|
<div class="lens-row">
|
|
|
|
|
<span class="lens-label">变倍 Zoom</span>
|
|
|
|
|
<div class="btn-group flex-grow-1">
|
|
|
|
|
<button class="btn btn-outline-secondary" @mousedown="ptzStart('ZoomOut')" @mouseup="ptzStop('ZoomOut')" @mouseleave="ptzStop('ZoomOut')"><i class="bi bi-dash-lg"></i> 缩小</button>
|
|
|
|
|
<button class="btn btn-outline-secondary" @mousedown="ptzStart('ZoomIn')" @mouseup="ptzStop('ZoomIn')" @mouseleave="ptzStop('ZoomIn')"><i class="bi bi-plus-lg"></i> 放大</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="lens-row">
|
|
|
|
|
<span class="lens-label">聚焦 Focus</span>
|
|
|
|
|
<div class="btn-group flex-grow-1">
|
|
|
|
|
<button class="btn btn-outline-secondary" @mousedown="ptzStart('FocusNear')" @mouseup="ptzStop('FocusNear')" @mouseleave="ptzStop('FocusNear')">近焦</button>
|
|
|
|
|
<button class="btn btn-outline-secondary" @mousedown="ptzStart('FocusFar')" @mouseup="ptzStop('FocusFar')" @mouseleave="ptzStop('FocusFar')">远焦</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="lens-row">
|
|
|
|
|
<span class="lens-label">光圈 Iris</span>
|
|
|
|
|
<div class="btn-group flex-grow-1">
|
|
|
|
|
<button class="btn btn-outline-secondary" @mousedown="ptzStart('IrisClose')" @mouseup="ptzStop('IrisClose')" @mouseleave="ptzStop('IrisClose')">变小</button>
|
|
|
|
|
<button class="btn btn-outline-secondary" @mousedown="ptzStart('IrisOpen')" @mouseup="ptzStop('IrisOpen')" @mouseleave="ptzStop('IrisOpen')">变大</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="col-md-6">
|
|
|
|
|
<div class="ctrl-group h-100">
|
|
|
|
|
<div class="group-title"><i class="bi bi-stars me-2"></i>辅助 (Aux)</div>
|
|
|
|
|
<p class="text-muted small mb-3">雨刷功能支持开关模式,点击一次开启,再次点击关闭。</p>
|
|
|
|
|
<button class="btn w-100 py-3 fw-bold transition-all"
|
|
|
|
|
:class="wiperOn ? 'btn-primary' : 'btn-outline-secondary'"
|
|
|
|
|
@click="toggleWiper">
|
|
|
|
|
<i class="bi bi-wind me-2 fs-5"></i>雨刷 (Wiper) {{ wiperOn ? 'ON' : 'OFF' }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2025-12-28 08:07:55 +08:00
|
|
|
</div>
|
|
|
|
|
|
2025-12-28 13:14:40 +08:00
|
|
|
<div class="col-12">
|
|
|
|
|
<div class="ctrl-group">
|
|
|
|
|
<div class="group-title"><i class="bi bi-hdd-rack me-2"></i>系统维护 (System Maintenance)</div>
|
|
|
|
|
|
|
|
|
|
<div class="row align-items-center">
|
|
|
|
|
<div class="col-md-6 border-end">
|
|
|
|
|
<div class="d-flex justify-content-between small text-muted mb-2">
|
|
|
|
|
<span>设备时间 (Device Time)</span>
|
|
|
|
|
<a href="#" @click.prevent="getDeviceTime" class="text-decoration-none">刷新</a>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="time-display">{{ deviceTime || '等待获取...' }}</div>
|
|
|
|
|
<button class="btn btn-success w-100" @click="syncTime" :disabled="loading">
|
|
|
|
|
<i class="bi bi-clock-history me-1"></i> 将本机时间同步至设备
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="col-md-6 ps-4">
|
|
|
|
|
<div class="alert alert-light border mb-3 small text-muted">
|
|
|
|
|
<i class="bi bi-info-circle me-1"></i>
|
|
|
|
|
重启设备将导致视频流中断约 60 秒,期间无法录像。
|
|
|
|
|
</div>
|
|
|
|
|
<button class="btn btn-danger w-100 py-2" @click="reboot" :disabled="loading">
|
|
|
|
|
<i class="bi bi-power me-1"></i> 执行远程重启 (Reboot)
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-12-28 08:07:55 +08:00
|
|
|
</div>
|
2025-12-28 13:14:40 +08:00
|
|
|
|
2025-12-28 08:07:55 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
|
|
|
|
|
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
|
|
|
|
|
<script>
|
|
|
|
|
const { createApp, ref, onMounted } = Vue;
|
|
|
|
|
let API_BASE = "";
|
|
|
|
|
|
|
|
|
|
createApp({
|
|
|
|
|
setup() {
|
|
|
|
|
const deviceId = ref(0);
|
2025-12-28 13:14:40 +08:00
|
|
|
const speed = ref(4);
|
2025-12-28 08:07:55 +08:00
|
|
|
const deviceTime = ref("");
|
2025-12-28 13:14:40 +08:00
|
|
|
const loading = ref(false);
|
|
|
|
|
const wiperOn = ref(false);
|
2025-12-28 08:07:55 +08:00
|
|
|
|
2025-12-28 13:14:40 +08:00
|
|
|
// 通用日志函数
|
|
|
|
|
const logToParent = (method, url, status, msg) => {
|
|
|
|
|
if(window.parent) {
|
|
|
|
|
window.parent.postMessage({
|
|
|
|
|
type: 'API_LOG',
|
|
|
|
|
log: { method, url, status, msg }
|
|
|
|
|
}, '*');
|
|
|
|
|
}
|
2025-12-28 08:07:55 +08:00
|
|
|
};
|
2025-12-28 13:14:40 +08:00
|
|
|
|
|
|
|
|
// 【核心修改】PTZ 控制函数
|
|
|
|
|
const sendPtz = async (action, stop, duration = 0) => {
|
|
|
|
|
const url = `${API_BASE}/api/cameras/${deviceId.value}/ptz`;
|
|
|
|
|
try {
|
|
|
|
|
// 1. 严格类型转换,防止发送字符串 "4" 导致后端 400
|
|
|
|
|
const payload = {
|
|
|
|
|
action: action,
|
|
|
|
|
speed: parseInt(speed.value), // 强制转 Int
|
|
|
|
|
duration: parseInt(duration), // 强制转 Int
|
|
|
|
|
stop: Boolean(stop) // 强制转 Bool
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 2. 打印即将发送的包,方便调试 (在诊断台看)
|
|
|
|
|
const statusText = stop ? "STOP" : "START";
|
|
|
|
|
const shortLog = `${action} [${statusText}] Spd:${payload.speed}`;
|
|
|
|
|
|
|
|
|
|
await axios.post(url, payload);
|
|
|
|
|
|
|
|
|
|
// 3. 成功日志
|
|
|
|
|
logToParent('PTZ', url, 200, shortLog);
|
|
|
|
|
} catch(e) {
|
|
|
|
|
// 4. 【关键】捕获 400 错误并解析详细原因
|
|
|
|
|
let msg = e.response?.data?.message || e.message;
|
|
|
|
|
|
|
|
|
|
// 尝试解析 ASP.NET Core 的详细验证错误 (ValidationProblems)
|
|
|
|
|
if (e.response?.data?.errors) {
|
|
|
|
|
const errors = e.response.data.errors;
|
|
|
|
|
// 将错误对象转为字符串
|
|
|
|
|
msg += " | " + JSON.stringify(errors);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 上报红色的错误日志
|
|
|
|
|
logToParent('PTZ', url, e.response?.status || 'ERROR', msg);
|
|
|
|
|
|
|
|
|
|
// 弹窗提示,方便您第一时间看到原因
|
|
|
|
|
// alert("控制失败: " + msg);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const ptzStart = (action) => sendPtz(action, false);
|
|
|
|
|
const ptzStop = (action) => sendPtz(action, true);
|
|
|
|
|
|
|
|
|
|
const toggleWiper = async () => {
|
|
|
|
|
wiperOn.value = !wiperOn.value;
|
|
|
|
|
await sendPtz('Wiper', !wiperOn.value);
|
2025-12-28 08:07:55 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getDeviceTime = async () => {
|
2025-12-28 13:14:40 +08:00
|
|
|
const url = `${API_BASE}/api/cameras/${deviceId.value}/time`;
|
|
|
|
|
try {
|
|
|
|
|
logToParent('GET', url, 'PENDING', 'Getting Time...');
|
|
|
|
|
const res = await axios.get(url);
|
|
|
|
|
if(res.data && res.data.currentTime) {
|
|
|
|
|
const t = new Date(res.data.currentTime);
|
|
|
|
|
deviceTime.value = t.toLocaleString();
|
|
|
|
|
logToParent('GET', url, 200, 'OK');
|
|
|
|
|
}
|
|
|
|
|
} catch(e) {
|
|
|
|
|
deviceTime.value = "获取失败";
|
|
|
|
|
logToParent('GET', url, 'ERROR', e.message);
|
|
|
|
|
}
|
2025-12-28 08:07:55 +08:00
|
|
|
};
|
2025-12-28 13:14:40 +08:00
|
|
|
|
2025-12-28 08:07:55 +08:00
|
|
|
const syncTime = async () => {
|
2025-12-28 13:14:40 +08:00
|
|
|
loading.value = true;
|
|
|
|
|
const url = `${API_BASE}/api/cameras/${deviceId.value}/time`;
|
2025-12-28 08:07:55 +08:00
|
|
|
try {
|
2025-12-28 13:14:40 +08:00
|
|
|
const nowStr = new Date().toISOString();
|
|
|
|
|
await axios.post(url, JSON.stringify(nowStr), { headers: { 'Content-Type': 'application/json' } });
|
|
|
|
|
|
|
|
|
|
deviceTime.value = new Date().toLocaleString() + " (已同步)";
|
2025-12-28 08:07:55 +08:00
|
|
|
alert("指令已下发");
|
2025-12-28 13:14:40 +08:00
|
|
|
logToParent('POST', url, 200, 'Time Synced');
|
|
|
|
|
} catch(e) {
|
|
|
|
|
alert("失败: " + e.message);
|
|
|
|
|
logToParent('POST', url, 'ERROR', e.message);
|
|
|
|
|
}
|
|
|
|
|
finally { loading.value = false; }
|
2025-12-28 08:07:55 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const reboot = async () => {
|
2025-12-28 13:14:40 +08:00
|
|
|
if(!confirm("确定要重启吗?")) return;
|
|
|
|
|
const url = `${API_BASE}/api/cameras/${deviceId.value}/reboot`;
|
|
|
|
|
try {
|
|
|
|
|
await axios.post(url);
|
|
|
|
|
alert("重启指令已发送");
|
|
|
|
|
logToParent('POST', url, 200, 'Reboot Sent');
|
|
|
|
|
} catch(e) {
|
|
|
|
|
alert("失败: " + e.message);
|
|
|
|
|
logToParent('POST', url, 'ERROR', e.message);
|
2025-12-28 08:07:55 +08:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-12-28 13:14:40 +08:00
|
|
|
const closeMode = () => {
|
|
|
|
|
window.parent.postMessage({ type: 'CLOSE_CONTROL_MODE' }, '*');
|
|
|
|
|
};
|
2025-12-28 08:07:55 +08:00
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
window.addEventListener('message', (e) => {
|
|
|
|
|
if (e.data.type === 'LOAD_CTRL_DATA') {
|
|
|
|
|
if(e.data.apiBase) API_BASE = e.data.apiBase;
|
|
|
|
|
deviceId.value = e.data.deviceId;
|
|
|
|
|
getDeviceTime();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-12-28 13:14:40 +08:00
|
|
|
return {
|
|
|
|
|
deviceId, speed, deviceTime, loading, wiperOn,
|
|
|
|
|
ptzStart, ptzStop, toggleWiper,
|
|
|
|
|
getDeviceTime, syncTime, reboot, closeMode
|
|
|
|
|
};
|
2025-12-28 08:07:55 +08:00
|
|
|
}
|
|
|
|
|
}).mount('#app');
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|