475 lines
15 KiB
HTML
475 lines
15 KiB
HTML
<!DOCTYPE html>
|
||
<!-- 文档类型声明:HTML5 -->
|
||
<html lang="zh-CN">
|
||
<!-- 根元素,语言设为简体中文 -->
|
||
<head>
|
||
<!-- 头部:元数据与资源 -->
|
||
<meta charset="UTF-8">
|
||
<!-- 字符编码:UTF-8 -->
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<!-- 视口:适配移动端,初始缩放 1 -->
|
||
<title>远程显示</title>
|
||
<!-- 页面标题 -->
|
||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||
<!-- 网站图标 -->
|
||
<script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
|
||
<!-- Socket.IO 客户端库,用于 WebSocket 通信 -->
|
||
<style>
|
||
/* ========== 全局样式 ========== */
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
/* 通配符:盒模型为 border-box,去除默认边距 */
|
||
body {
|
||
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
|
||
/* 字体:微软雅黑、苹方,无衬线回退 */
|
||
background: linear-gradient(135deg, #e8f4fc 0%, #f0f8ff 50%, #e6f2ff 100%);
|
||
/* 背景:135 度渐变,浅蓝到淡蓝 */
|
||
min-height: 100vh;
|
||
/* 最小高度为视口高度 */
|
||
padding: 16px;
|
||
/* 内边距 */
|
||
}
|
||
/* ========== 主容器 ========== */
|
||
.app {
|
||
max-width: 900px;
|
||
/* 最大宽度限制 */
|
||
margin: 0 auto;
|
||
/* 水平居中 */
|
||
background: #fff;
|
||
/* 白色背景 */
|
||
border-radius: 12px;
|
||
/* 圆角 */
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
|
||
/* 阴影 */
|
||
overflow: hidden;
|
||
/* 子元素超出则裁剪 */
|
||
}
|
||
/* ========== 顶部菜单栏 ========== */
|
||
.menu-bar {
|
||
display: flex;
|
||
/* 弹性布局 */
|
||
align-items: center;
|
||
/* 垂直居中 */
|
||
justify-content: center;
|
||
/* 水平居中 */
|
||
gap: 8px;
|
||
/* 子元素间距 */
|
||
padding: 12px 16px;
|
||
background: #fff;
|
||
border-bottom: 1px solid #e8e8e8;
|
||
/* 底部分割线 */
|
||
}
|
||
.menu-item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
/* 纵向排列图标和文字 */
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 8px 14px;
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
/* 鼠标指针为手型 */
|
||
border-radius: 8px;
|
||
transition: all 0.2s;
|
||
/* 过渡动画 */
|
||
color: #333;
|
||
font-size: 12px;
|
||
}
|
||
.menu-item:hover {
|
||
background: #f5f5f5;
|
||
/* 悬停时浅灰背景 */
|
||
}
|
||
.menu-item .icon {
|
||
width: 28px;
|
||
height: 28px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 18px;
|
||
/* 图标区域 */
|
||
}
|
||
.menu-item.connect .icon { color: #22c55e; }
|
||
/* 连接按钮图标:绿色 */
|
||
.menu-item.settings .icon { color: #3b82f6; }
|
||
/* 设置按钮图标:蓝色 */
|
||
.menu-item.exit .icon { color: #ef4444; }
|
||
/* 断开按钮图标:红色 */
|
||
.menu-item.about .icon { color: #64748b; }
|
||
/* 关于按钮图标:灰色 */
|
||
.menu-item.connected .icon { color: #22c55e; }
|
||
.menu-item.connected { color: #22c55e; }
|
||
/* 已连接状态:绿色 */
|
||
/* ========== 主内容区 ========== */
|
||
.main {
|
||
display: flex;
|
||
padding: 16px;
|
||
gap: 20px;
|
||
/* 左右分栏间距 */
|
||
}
|
||
/* ========== 显示区域 ========== */
|
||
.display-area {
|
||
flex: 1;
|
||
/* 占据剩余空间 */
|
||
min-width: 0;
|
||
/* 允许 flex 子项收缩 */
|
||
aspect-ratio: 1;
|
||
/* 宽高比 1:1 */
|
||
max-height: 420px;
|
||
background: #1e3a5f;
|
||
/* 深蓝背景,模拟 LCD 边框 */
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
box-shadow: inset 0 2px 8px rgba(0,0,0,0.2);
|
||
/* 内阴影 */
|
||
}
|
||
.display-area canvas {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
/* canvas 自适应容器 */
|
||
image-rendering: pixelated;
|
||
image-rendering: crisp-edges;
|
||
/* 像素风格渲染,避免模糊 */
|
||
}
|
||
.display-placeholder {
|
||
color: rgba(255,255,255,0.5);
|
||
/* 半透明白色提示文字 */
|
||
font-size: 14px;
|
||
}
|
||
/* ========== 右侧控制面板 ========== */
|
||
.control-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 8px;
|
||
}
|
||
/* ========== D-pad 方向键 ========== */
|
||
.dpad {
|
||
display: grid;
|
||
grid-template-columns: 50px 50px 50px;
|
||
grid-template-rows: 50px 50px 50px;
|
||
gap: 4px;
|
||
/* 3x3 网格布局 */
|
||
}
|
||
.dpad .btn {
|
||
width: 50px;
|
||
height: 50px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
|
||
/* 渐变背景 */
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 20px;
|
||
color: #22c55e;
|
||
transition: all 0.15s;
|
||
}
|
||
.dpad .btn:hover {
|
||
background: linear-gradient(180deg, #fff 0%, #f1f3f5 100%);
|
||
box-shadow: 0 3px 6px rgba(0,0,0,0.15);
|
||
transform: translateY(-1px);
|
||
/* 悬停上浮效果 */
|
||
}
|
||
.dpad .btn:active {
|
||
transform: translateY(0);
|
||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||
/* 按下时恢复 */
|
||
}
|
||
.dpad .btn-up { grid-column: 2; grid-row: 1; }
|
||
/* 上键:中上 */
|
||
.dpad .btn-down { grid-column: 2; grid-row: 3; }
|
||
/* 下键:中下 */
|
||
.dpad .btn-left { grid-column: 1; grid-row: 2; }
|
||
/* 左键:左中 */
|
||
.dpad .btn-right { grid-column: 3; grid-row: 2; }
|
||
/* 右键:右中 */
|
||
.dpad .btn-ok { grid-column: 2; grid-row: 2; }
|
||
/* 确认键:正中 */
|
||
/* ========== 动作按钮 ========== */
|
||
.action-btns {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
.action-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 10px 18px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
color: #333;
|
||
transition: all 0.15s;
|
||
}
|
||
.action-btn:hover {
|
||
background: linear-gradient(180deg, #fff 0%, #f1f3f5 100%);
|
||
box-shadow: 0 3px 6px rgba(0,0,0,0.15);
|
||
transform: translateY(-1px);
|
||
}
|
||
.action-btn .icon { color: #22c55e; font-size: 16px; }
|
||
/* ========== 连接设置弹窗 ========== */
|
||
.modal {
|
||
display: none;
|
||
/* 默认隐藏 */
|
||
position: fixed;
|
||
inset: 0;
|
||
/* 全屏覆盖 */
|
||
background: rgba(0,0,0,0.4);
|
||
/* 半透明黑色遮罩 */
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 100;
|
||
}
|
||
.modal.show { display: flex; }
|
||
/* 显示时用 flex 居中 */
|
||
.modal-content {
|
||
background: #fff;
|
||
padding: 24px;
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
|
||
min-width: 320px;
|
||
}
|
||
.modal-content h3 { margin-bottom: 16px; font-size: 16px; }
|
||
.modal-content input {
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 6px;
|
||
margin-bottom: 12px;
|
||
font-size: 14px;
|
||
}
|
||
.modal-content .btn-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
justify-content: flex-end;
|
||
margin-top: 16px;
|
||
}
|
||
.modal-content button {
|
||
padding: 8px 16px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
}
|
||
.modal-content .btn-primary {
|
||
background: #22c55e;
|
||
color: #fff;
|
||
/* 主按钮:绿色 */
|
||
}
|
||
.modal-content .btn-secondary {
|
||
background: #e5e7eb;
|
||
color: #333;
|
||
/* 次要按钮:灰色 */
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<!-- 页面主体 -->
|
||
<div class="app">
|
||
<!-- 主应用容器 -->
|
||
<div class="menu-bar">
|
||
<!-- 顶部菜单栏 -->
|
||
<button class="menu-item connect" id="btnConnect" title="连接">
|
||
<span class="icon">📶</span>
|
||
<span>连接</span>
|
||
</button>
|
||
<button class="menu-item settings" title="设置">
|
||
<span class="icon">⚙</span>
|
||
<span>设置</span>
|
||
</button>
|
||
<button class="menu-item exit" title="断开">
|
||
<span class="icon">⏻</span>
|
||
<span>断开</span>
|
||
</button>
|
||
<button class="menu-item about" id="btnAbout" title="关于">
|
||
<span class="icon">ℹ</span>
|
||
<span>关于</span>
|
||
</button>
|
||
</div>
|
||
<div class="main">
|
||
<!-- 主内容区:显示 + 控制 -->
|
||
<div class="display-area">
|
||
<!-- 屏幕显示区域 -->
|
||
<canvas id="screen" style="display:none;"></canvas>
|
||
<!-- canvas 初始隐藏,有画面后显示 -->
|
||
<span class="display-placeholder" id="placeholder">点击「连接」连接装置</span>
|
||
<!-- 未连接时的提示文字 -->
|
||
</div>
|
||
<div class="control-panel">
|
||
<!-- 右侧控制面板 -->
|
||
<div class="dpad">
|
||
<!-- 方向键 + 确认 -->
|
||
<button class="btn btn-up" data-key="U">▲</button>
|
||
<button class="btn btn-left" data-key="L">◀</button>
|
||
<button class="btn btn-ok" data-key="ENT">✓</button>
|
||
<button class="btn btn-right" data-key="R">▶</button>
|
||
<button class="btn btn-down" data-key="D">▼</button>
|
||
</div>
|
||
<div class="action-btns">
|
||
<!-- 复归、返回按钮 -->
|
||
<button class="action-btn" data-key="RESET">
|
||
<span class="icon">↺</span>
|
||
<span>复归</span>
|
||
</button>
|
||
<button class="action-btn" data-key="ESC">
|
||
<span class="icon">↶</span>
|
||
<span>返回</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 连接弹窗 -->
|
||
<div class="modal" id="connectModal">
|
||
<div class="modal-content">
|
||
<h3>连接装置</h3>
|
||
<input type="text" id="hostInput" placeholder="装置 IP 地址" value="192.168.253.3">
|
||
<input type="number" id="portInput" placeholder="端口" value="7003">
|
||
<div class="btn-row">
|
||
<button class="btn-secondary" id="modalCancel">取消</button>
|
||
<button class="btn-primary" id="modalConnect">连接</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
/**
|
||
* 前端逻辑:通过 Socket.IO 与后端 WebSocket 通信。
|
||
* - connect_device: 连接装置,成功后后端持续推送 screen 事件
|
||
* - disconnect_device: 断开装置
|
||
* - key: 发送按键码
|
||
*/
|
||
// ========== 常量与 DOM 引用 ==========
|
||
// 按键名 -> RemoDispBus 协议码:U/D/L/R 方向,ENT 确认,ESC 返回,RESET 复归
|
||
const KEY_MAP = { U: 0x02, D: 0x40, L: 0x10, R: 0x08, ENT: 0x20, ESC: 0x01, RESET: 0x04 };
|
||
const canvas = document.getElementById('screen');
|
||
// canvas 元素,用于绘制远程屏幕
|
||
const placeholder = document.getElementById('placeholder');
|
||
// 占位提示文字
|
||
const connectBtn = document.getElementById('btnConnect');
|
||
// 连接按钮
|
||
const connectModal = document.getElementById('connectModal');
|
||
// 连接弹窗
|
||
const hostInput = document.getElementById('hostInput');
|
||
// IP 输入框
|
||
const portInput = document.getElementById('portInput');
|
||
// 端口输入框
|
||
const modalConnect = document.getElementById('modalConnect');
|
||
// 弹窗内「连接」按钮
|
||
const modalCancel = document.getElementById('modalCancel');
|
||
// 弹窗内「取消」按钮
|
||
|
||
// ========== WebSocket 连接 ==========
|
||
const socket = io();
|
||
// 连接当前页面的 origin,建立 Socket.IO 连接
|
||
|
||
socket.on('connect', () => {});
|
||
// 连接成功时(可留空)
|
||
socket.on('connect_result', (data) => {
|
||
// 收到后端连接结果
|
||
if (data.success) {
|
||
hideConnectModal();
|
||
// 关闭弹窗
|
||
connectBtn.classList.add('connected');
|
||
// 标记为已连接状态
|
||
connectBtn.querySelector('span:last-child').textContent = '已连接';
|
||
// 更新按钮文字
|
||
} else {
|
||
alert('连接失败: ' + (data.error || '未知错误'));
|
||
// 弹出错误提示
|
||
}
|
||
});
|
||
// 服务端推送屏幕:data.png 为 base64 编码的 PNG,解码后绘制到 canvas
|
||
socket.on('screen', (data) => {
|
||
if (!data.png) return;
|
||
// 无 base64 数据则跳过
|
||
const binary = atob(data.png);
|
||
// base64 解码为二进制字符串
|
||
const bytes = new Uint8Array(binary.length);
|
||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||
// 转为 Uint8Array
|
||
const blob = new Blob([bytes], { type: 'image/png' });
|
||
// 创建 PNG Blob
|
||
const url = URL.createObjectURL(blob);
|
||
// 创建临时 URL
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
// 图片加载完成后
|
||
canvas.width = img.width;
|
||
canvas.height = img.height;
|
||
// 设置 canvas 尺寸
|
||
canvas.getContext('2d').drawImage(img, 0, 0);
|
||
// 绘制到 canvas
|
||
canvas.style.display = 'block';
|
||
placeholder.style.display = 'none';
|
||
// 显示 canvas,隐藏占位符
|
||
URL.revokeObjectURL(url);
|
||
// 释放临时 URL
|
||
};
|
||
img.src = url;
|
||
// 触发加载
|
||
});
|
||
socket.on('disconnect_result', () => {});
|
||
// 收到断开结果(可留空)
|
||
|
||
function showConnectModal() { connectModal.classList.add('show'); }
|
||
// 显示连接弹窗
|
||
function hideConnectModal() { connectModal.classList.remove('show'); }
|
||
// 隐藏连接弹窗
|
||
connectBtn.onclick = showConnectModal;
|
||
// 点击连接按钮 -> 显示弹窗
|
||
modalCancel.onclick = hideConnectModal;
|
||
// 点击取消 -> 隐藏弹窗
|
||
|
||
modalConnect.onclick = () => {
|
||
// 点击弹窗内「连接」按钮
|
||
const host = hostInput.value.trim();
|
||
// 获取 IP,去空格
|
||
const port = portInput.value || '7003';
|
||
// 获取端口,缺省 7003
|
||
socket.emit('connect_device', { host, port });
|
||
// 向后端发送连接请求
|
||
};
|
||
|
||
function sendKey(key) {
|
||
// 发送按键到后端
|
||
if (!connectBtn.classList.contains('connected')) return;
|
||
// 未连接则不发送
|
||
socket.emit('key', { code: KEY_MAP[key] ?? 0 });
|
||
// 根据按键名查协议码并发送
|
||
}
|
||
|
||
function doDisconnect() {
|
||
// 执行断开
|
||
socket.emit('disconnect_device');
|
||
// 通知后端断开设备
|
||
connectBtn.classList.remove('connected');
|
||
connectBtn.querySelector('span:last-child').textContent = '连接';
|
||
// 恢复按钮状态和文字
|
||
canvas.style.display = 'none';
|
||
placeholder.style.display = '';
|
||
placeholder.textContent = '点击「连接」连接装置';
|
||
// 隐藏 canvas,显示占位符
|
||
}
|
||
|
||
document.querySelectorAll('.dpad .btn, .action-btn').forEach(btn => {
|
||
// 为所有方向键和动作按钮绑定点击
|
||
btn.onclick = () => sendKey(btn.dataset.key);
|
||
// 点击时发送对应按键码
|
||
});
|
||
document.querySelector('.menu-item.exit').onclick = doDisconnect;
|
||
// 断开按钮 -> 执行断开
|
||
document.getElementById('btnAbout').onclick = () => alert('远程显示工具\n与 RemoDispBus 协议兼容\n端口 7003');
|
||
// 关于按钮 -> 弹出说明
|
||
</script>
|
||
</body>
|
||
</html>
|