mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-05 18:44:03 +08:00
353 lines
14 KiB
HTML
353 lines
14 KiB
HTML
<%#
|
||
luci-app-openclaw — 终端配置页面 (简洁版)
|
||
-%>
|
||
<%+header%>
|
||
|
||
<%
|
||
local uci = require "luci.model.uci".cursor()
|
||
local pty_port = uci:get("openclaw", "main", "pty_port") or "18793"
|
||
local pty_token = uci:get("openclaw", "main", "pty_token") or ""
|
||
%>
|
||
|
||
<style type="text/css">
|
||
.oc-page-header { margin: 0 0 16px 0; }
|
||
.oc-page-header h2 { font-size: 18px; font-weight: 600; color: inherit; margin: 0 0 6px 0; }
|
||
.oc-page-header p { font-size: 13px; color: inherit; opacity: 0.76; margin: 0; line-height: 1.6; }
|
||
|
||
.oc-console-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 16px;
|
||
margin-bottom: 16px;
|
||
background: #f6f8fa;
|
||
border: 1px solid #e1e4e8;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
color: #555;
|
||
flex-wrap: wrap;
|
||
}
|
||
.oc-console-info .label { color: #888; }
|
||
.oc-console-info .value { font-family: monospace; color: #333; font-weight: 500; }
|
||
.oc-console-info .sep { color: #ddd; }
|
||
.oc-console-info .btn-open {
|
||
margin-left: auto;
|
||
padding: 5px 14px;
|
||
background: #4a90d9;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-weight: 500;
|
||
}
|
||
.oc-console-info .btn-open:hover { background: #357abd; }
|
||
|
||
.oc-terminal-wrap {
|
||
border: 2px solid #2d333b;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
background: #1a1b26;
|
||
}
|
||
|
||
#oc-terminal-iframe { width: 100%; height: 650px; border: none; display: block; }
|
||
|
||
.oc-terminal-loading {
|
||
display: flex; align-items: center; justify-content: center;
|
||
min-height: 250px; color: #7aa2f7; font-size: 14px; background: #1a1b26;
|
||
}
|
||
</style>
|
||
|
||
<div class="oc-page-header">
|
||
<h2>⚙️ 终端配置 (配置管理)</h2>
|
||
<p>通过交互式终端 (oc-config) 进行 OpenClaw 的完整配置管理。支持 AI 模型配置、消息渠道设置(QQ、Telegram、Discord 等)、健康检查等。</p>
|
||
</div>
|
||
|
||
<div class="oc-console-info">
|
||
<span class="label">终端服务:</span>
|
||
<span class="value" id="oc-term-addr">-</span>
|
||
<span class="sep">|</span>
|
||
<span class="label">状态:</span>
|
||
<span id="oc-term-status-text">检测中...</span>
|
||
<a id="oc-term-open-btn" class="btn-open" href="#" target="_blank" rel="noopener">
|
||
↗ 新窗口打开终端
|
||
</a>
|
||
</div>
|
||
|
||
<div id="oc-adv-pairing-banner" style="display:none; padding:12px 16px; margin-bottom:14px; background:#fffbe6; border:1px solid #ffe58f; border-left:4px solid #faad14; border-radius:6px;">
|
||
<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; flex-wrap:wrap;">
|
||
<div style="flex:1; min-width:280px;">
|
||
<div style="font-weight:600; color:#d48806; font-size:14px; margin-bottom:4px; display:flex; align-items:center; gap:8px;">
|
||
<span>🔔 检测到待配对设备请求</span>
|
||
<span id="oc-adv-pairing-badge" style="background:#faad14; color:#fff; padding:1px 7px; border-radius:10px; font-size:11px;">0</span>
|
||
</div>
|
||
<div style="font-size:13px; color:#555; line-height:1.5; margin-bottom:6px;">
|
||
Control UI 浏览器连接时触发了安全配对策略,您可直接在此一键批准,无需在下方终端中手动查找与输入 Request ID。
|
||
</div>
|
||
<div style="font-size:12px; color:#cf1322; background:#fff1f0; border:1px solid #ffa39e; padding:6px 10px; border-radius:4px; line-height:1.4; margin-bottom:6px;">
|
||
⚠️ <strong>风险提示</strong>:批准设备配对后,该浏览器将获得 OpenClaw 网关的完全控制权限。请确保在受信任的局域网环境,并仅在您本人正在连接时批准。
|
||
</div>
|
||
<div id="oc-adv-pairing-items" style="font-size:12px; font-family:monospace; color:#333; line-height:1.6;"></div>
|
||
</div>
|
||
<div style="display:flex; gap:8px; align-items:center; margin-top:4px;">
|
||
<button type="button" id="oc-adv-pairing-approve-all-btn" style="padding:6px 14px; background:#52c41a; color:#fff; border:none; border-radius:4px; font-weight:600; font-size:12px; cursor:pointer;">
|
||
⚡ 一键批准配对
|
||
</button>
|
||
<button type="button" id="oc-adv-pairing-refresh-btn" style="padding:6px 10px; background:#f0f2f5; color:#333; border:1px solid #d0d7de; border-radius:4px; font-size:12px; cursor:pointer;">
|
||
🔄 刷新
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="oc-adv-pairing-msg" style="display:none; margin-top:8px; font-size:13px; font-weight:500;"></div>
|
||
</div>
|
||
|
||
<div class="oc-terminal-wrap">
|
||
<div id="oc-terminal-container">
|
||
<div class="oc-terminal-loading" id="oc-terminal-loading">
|
||
⏳ 正在连接配置终端...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script type="text/javascript">
|
||
//<![CDATA[
|
||
(function() {
|
||
var ptyPort = '<%=pty_port%>';
|
||
var ptyToken = '<%=pty_token%>';
|
||
var statusUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "status_api")%>';
|
||
var tokenUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "get_token")%>';
|
||
// get_token 返回凭据,已改为 post(),需带 CSRF token
|
||
var ocCsrfToken = '<%=token%>';
|
||
var container = document.getElementById('oc-terminal-container');
|
||
var loading = document.getElementById('oc-terminal-loading');
|
||
var addrEl = document.getElementById('oc-term-addr');
|
||
var statusTextEl = document.getElementById('oc-term-status-text');
|
||
var openBtn = document.getElementById('oc-term-open-btn');
|
||
|
||
var host = window.location.hostname;
|
||
if (addrEl) addrEl.textContent = host + ':' + ptyPort;
|
||
var targetUrl = 'http://' + host + ':' + ptyPort + '/';
|
||
if (ptyToken) {
|
||
targetUrl += '?pty_token=' + encodeURIComponent(ptyToken);
|
||
}
|
||
if (openBtn) openBtn.href = targetUrl;
|
||
|
||
var devicesListUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "devices_list")%>';
|
||
var devicesApproveUrl = '<%=luci.dispatcher.build_url("admin", "services", "openclaw", "devices_approve")%>';
|
||
var advPairingBanner = document.getElementById('oc-adv-pairing-banner');
|
||
var advPairingBadge = document.getElementById('oc-adv-pairing-badge');
|
||
var advPairingItems = document.getElementById('oc-adv-pairing-items');
|
||
var advPairingMsg = document.getElementById('oc-adv-pairing-msg');
|
||
var advPairingApproveAllBtn = document.getElementById('oc-adv-pairing-approve-all-btn');
|
||
var advPairingRefreshBtn = document.getElementById('oc-adv-pairing-refresh-btn');
|
||
|
||
function escapeHtml(str) {
|
||
return String(str || '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function checkAdvDevices() {
|
||
(new XHR()).get(devicesListUrl, null, function(x) {
|
||
try {
|
||
var data = JSON.parse(x.responseText);
|
||
if (!data || data.status !== 'ok') {
|
||
if (advPairingBadge) advPairingBadge.textContent = '!';
|
||
if (advPairingBanner) advPairingBanner.style.display = 'none';
|
||
return;
|
||
}
|
||
if (!Array.isArray(data.pending) || !Array.isArray(data.paired)) throw new Error('设备列表结构无效');
|
||
var pending = data.pending;
|
||
if (advPairingBadge) advPairingBadge.textContent = pending.length;
|
||
if (pending.length > 0) {
|
||
var html = '';
|
||
for (var i = 0; i < pending.length; i++) {
|
||
var item = pending[i];
|
||
var ip = escapeHtml(item.remoteIp || item.ip || '未知IP');
|
||
var client = escapeHtml(item.clientId || item.clientMode || 'webchat');
|
||
var plat = escapeHtml(item.platform || '');
|
||
var rid = escapeHtml(item.requestId || '');
|
||
html += '<div style="background:#fff; border:1px solid #e1e4e8; border-radius:4px; padding:6px 10px; margin-bottom:6px; display:flex; justify-content:space-between; align-items:center; gap:8px; flex-wrap:wrap;">' +
|
||
'<div><strong>IP:</strong> ' + ip + ' | <strong>客户端:</strong> ' + client + (plat ? ' (' + plat + ')' : '') + ' <br/><span style="color:#888; font-size:11px;">ID: ' + rid + '</span></div>' +
|
||
'<button type="button" class="btn-adv-approve-single" data-rid="' + rid + '" style="font-size:12px; padding:3px 10px; background:#f6ffed; border:1px solid #b7eb8f; color:#389e0d; border-radius:4px; cursor:pointer;">批准配对</button>' +
|
||
'</div>';
|
||
}
|
||
advPairingItems.innerHTML = html;
|
||
advPairingBanner.style.display = 'block';
|
||
|
||
var singleBtns = advPairingItems.querySelectorAll('.btn-adv-approve-single');
|
||
for (var j = 0; j < singleBtns.length; j++) {
|
||
singleBtns[j].addEventListener('click', function(ev) {
|
||
var rid = this.getAttribute('data-rid');
|
||
approveAdvDevice(rid);
|
||
});
|
||
}
|
||
} else {
|
||
advPairingBanner.style.display = 'none';
|
||
}
|
||
} catch(e) {}
|
||
});
|
||
}
|
||
|
||
function approveAdvDevice(rid) {
|
||
advPairingMsg.style.display = 'block';
|
||
advPairingMsg.style.color = '#1890ff';
|
||
advPairingMsg.textContent = '⏳ 正在批准设备配对...';
|
||
var postData = { token: ocCsrfToken };
|
||
if (rid) {
|
||
postData.request_id = rid;
|
||
} else {
|
||
postData.all = '1';
|
||
}
|
||
(new XHR()).post(devicesApproveUrl, postData, function(x) {
|
||
try {
|
||
var res = JSON.parse(x.responseText);
|
||
if (res.status === 'ok') {
|
||
if ((res.success_count || 0) > 0) {
|
||
advPairingMsg.style.color = '#52c41a';
|
||
advPairingMsg.textContent = '✅ ' + (res.message || '批准成功!');
|
||
setTimeout(function() {
|
||
checkAdvDevices();
|
||
}, 1500);
|
||
} else {
|
||
advPairingMsg.style.color = '#faad14';
|
||
advPairingMsg.textContent = 'ℹ️ ' + (res.message || '当前没有待配对的设备请求');
|
||
setTimeout(function() {
|
||
checkAdvDevices();
|
||
}, 1500);
|
||
}
|
||
} else if (res.status === 'partial' || res.status === 'unconfirmed') {
|
||
advPairingMsg.style.color = '#d48806';
|
||
advPairingMsg.textContent = '⚠️ ' + (res.message || '部分设备状态尚未确认,请刷新后重试');
|
||
setTimeout(function() { checkAdvDevices(); }, 1500);
|
||
} else {
|
||
advPairingMsg.style.color = '#cf222e';
|
||
advPairingMsg.textContent = '❌ ' + (res.message || '批准失败');
|
||
}
|
||
} catch(e) {
|
||
advPairingMsg.style.color = '#cf222e';
|
||
advPairingMsg.textContent = '❌ 批准失败:请求异常';
|
||
}
|
||
});
|
||
}
|
||
|
||
if (advPairingApproveAllBtn) {
|
||
advPairingApproveAllBtn.addEventListener('click', function() {
|
||
approveAdvDevice(null);
|
||
});
|
||
}
|
||
if (advPairingRefreshBtn) {
|
||
advPairingRefreshBtn.addEventListener('click', function() {
|
||
checkAdvDevices();
|
||
});
|
||
}
|
||
|
||
function checkAndLoadTerminal() {
|
||
checkAdvDevices();
|
||
setTimeout(checkAdvDevices, 3000);
|
||
(new XHR()).get(statusUrl, null, function(x) {
|
||
try {
|
||
var d = JSON.parse(x.responseText);
|
||
if (d.pty_running) {
|
||
if (statusTextEl) statusTextEl.innerHTML = '<span style="color:#1a7f37;">● 运行中</span>';
|
||
showIframe();
|
||
} else {
|
||
if (statusTextEl) statusTextEl.innerHTML = '<span style="color:#cf222e;">● 未运行</span>';
|
||
loading.innerHTML = '<div style="text-align:center;padding:24px;color:#cf222e;">❌ 配置终端未运行<br/>' +
|
||
'<span style="font-size:12px;color:#999;">请先在「基本设置」中启用并启动服务。</span></div>';
|
||
}
|
||
} catch(e) {
|
||
if (statusTextEl) statusTextEl.textContent = '检测完成';
|
||
showIframe();
|
||
}
|
||
});
|
||
}
|
||
|
||
function showIframe() {
|
||
var isHttps = (window.location.protocol === 'https:');
|
||
if (isHttps) {
|
||
loading.innerHTML = '<div style="text-align:center;padding:40px 20px;color:#abb2bf;">' +
|
||
'<div style="font-size:36px;margin-bottom:12px;">↗</div>' +
|
||
'<div style="font-size:15px;margin-bottom:8px;color:#e5c07b;font-weight:600;">由于浏览器安全策略限制,无法在 HTTPS 页面中直接内嵌 HTTP 终端</div>' +
|
||
'<div style="font-size:13px;color:#828997;max-width:560px;margin:0 auto 20px;line-height:1.6;">' +
|
||
'终端服务已在端口 ' + ptyPort + ' 正常就绪。请点击下方按钮直接在新窗口打开交互式终端,已自动绑定认证 Token。' +
|
||
'</div>' +
|
||
'<a href="' + targetUrl + '" target="_blank" rel="noopener" style="display:inline-block;padding:9px 24px;background:#4a90d9;color:#fff;border-radius:6px;text-decoration:none;font-weight:500;font-size:13px;box-shadow:0 2px 6px rgba(0,0,0,0.3);">' +
|
||
'立即在新窗口打开终端' +
|
||
'</a>' +
|
||
'<div style="margin-top:16px;font-size:12px;color:#5c6370;">' +
|
||
'小贴士:您也可以通过 HTTP (http://' + host + ') 登录路由器后台直接在此内嵌操作。' +
|
||
'</div>' +
|
||
'</div>';
|
||
return;
|
||
}
|
||
|
||
loading.style.display = 'none';
|
||
|
||
var iframe = document.createElement('iframe');
|
||
iframe.id = 'oc-terminal-iframe';
|
||
iframe.src = targetUrl;
|
||
iframe.setAttribute('allowfullscreen', 'true');
|
||
iframe.setAttribute('allow', 'clipboard-read; clipboard-write');
|
||
// 防止 LuCI 父页面键盘事件干扰 iframe 内的终端输入
|
||
iframe.addEventListener('load', function() {
|
||
try { iframe.contentWindow.focus(); } catch(e) {}
|
||
});
|
||
container.appendChild(iframe);
|
||
// 点击 iframe 区域时确保 focus 在 iframe 内
|
||
container.addEventListener('click', function() {
|
||
try { iframe.contentWindow.focus(); } catch(e) {}
|
||
});
|
||
|
||
// 当 iframe 获得焦点时,阻止 LuCI 父页面的键盘事件处理
|
||
// 防止按键(如 Enter/数字键)触发 LuCI 的导航或表单提交
|
||
var termWrap = document.querySelector('.oc-terminal-wrap');
|
||
if (termWrap) {
|
||
termWrap.addEventListener('keydown', function(e) { e.stopPropagation(); }, true);
|
||
termWrap.addEventListener('keypress', function(e) { e.stopPropagation(); }, true);
|
||
termWrap.addEventListener('keyup', function(e) { e.stopPropagation(); }, true);
|
||
}
|
||
|
||
// 停止 LuCI 的 XHR.poll (如果存在),防止 session 超时检测导致页面跳转
|
||
// LuCI 的 footer.htm 可能启动了 uci.changes 轮询
|
||
if (window.XHR && XHR.halt) {
|
||
try { XHR.halt(); } catch(e) {}
|
||
}
|
||
}
|
||
|
||
// 监听 iframe 终端组件的 token 请求,支持重载后自动补充认证凭据
|
||
window.addEventListener('message', function(ev) {
|
||
if (ev.data && ev.data.type === 'openclaw_request_token') {
|
||
var srcWin = ev.source;
|
||
if (ptyToken) {
|
||
if (srcWin) {
|
||
try { srcWin.postMessage({ type: 'openclaw_token', pty_token: ptyToken }, '*'); } catch(e) {}
|
||
}
|
||
} else {
|
||
(new XHR()).post(tokenUrl, { token: ocCsrfToken }, function(tx) {
|
||
try {
|
||
var td = JSON.parse(tx.responseText);
|
||
ptyToken = td.pty_token || '';
|
||
if (ptyToken && srcWin) {
|
||
srcWin.postMessage({ type: 'openclaw_token', pty_token: ptyToken }, '*');
|
||
}
|
||
} catch(e) {}
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
checkAndLoadTerminal();
|
||
})();
|
||
//]]>
|
||
</script>
|
||
|
||
<%+footer%>
|