- 移除 /api/ 路由的鉴权豁免,所有数据接口必须携带 API Key - 仅 /healthz 和 /admin/(HTML 页面壳)免鉴权 - 前端新增登录遮罩层,401 时弹出 API Key 输入框 - Key 存储在 sessionStorage,所有 API 请求自动附加 X-API-Key header - 支持 ?apiKey=xxx URL 参数自动登录(登录后从 URL 移除避免泄露)
1107 lines
35 KiB
Python
1107 lines
35 KiB
Python
"""后台管理:提供 Web 管理界面和 REST API,动态修改命令监听配置。"""
|
||
|
||
from aiohttp import web
|
||
|
||
from ..config import command, get_settings_flat, reload_settings, update_settings_from_api
|
||
from ..response import error, ok
|
||
|
||
|
||
# ── API ──────────────────────────────────────────────────────
|
||
|
||
async def api_get_settings(request: web.Request) -> web.Response:
|
||
"""GET /api/settings — 返回全部动态配置。"""
|
||
return ok(data=get_settings_flat())
|
||
|
||
|
||
async def api_update_settings(request: web.Request) -> web.Response:
|
||
"""PUT /api/settings — 批量更新配置并立即生效。"""
|
||
try:
|
||
data = await request.json()
|
||
except Exception:
|
||
return error("invalid json")
|
||
|
||
if not isinstance(data, dict):
|
||
return error("request body must be a json object")
|
||
|
||
filtered = update_settings_from_api(data)
|
||
if not filtered:
|
||
return error("no valid settings to update")
|
||
|
||
return ok(data=filtered, msg="配置已更新并生效")
|
||
|
||
|
||
async def api_reload_settings(request: web.Request) -> web.Response:
|
||
"""POST /api/settings/reload — 从 settings.yaml 重新加载配置。"""
|
||
reload_settings()
|
||
return ok(msg="配置已从 settings.yaml 重新加载")
|
||
|
||
|
||
# ── 管理页面 ─────────────────────────────────────────────────
|
||
|
||
ADMIN_HTML = r"""<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>NcatBot Webhook 管理</title>
|
||
<style>
|
||
:root {
|
||
--sidebar-bg: #001529;
|
||
--sidebar-active: #1677FF;
|
||
--main-bg: #f0f2f5;
|
||
--primary-color: #1677FF;
|
||
--primary-hover: #4096FF;
|
||
--card-bg: #ffffff;
|
||
--text-main: #1F2329;
|
||
--text-secondary: #646A73;
|
||
--text-light: #8F959E;
|
||
--border-color: #DEE0E3;
|
||
--success: #52C41A;
|
||
--warning: #FAAD14;
|
||
--danger: #FF4D4F;
|
||
--sidebar-width: 200px;
|
||
--header-height: 52px;
|
||
--radius: 6px;
|
||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||
--content-max-width: 900px;
|
||
}
|
||
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
background: var(--main-bg);
|
||
color: var(--text-main);
|
||
display: flex;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* Sidebar */
|
||
.sidebar {
|
||
width: var(--sidebar-width);
|
||
background: var(--sidebar-bg);
|
||
height: 100vh;
|
||
position: fixed;
|
||
left: 0; top: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
z-index: 100;
|
||
}
|
||
|
||
.sidebar-logo {
|
||
height: var(--header-height);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 16px;
|
||
color: #fff;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||
}
|
||
|
||
.sidebar-logo .logo-icon {
|
||
width: 26px; height: 26px;
|
||
background: var(--primary-color);
|
||
border-radius: 6px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
margin-right: 10px;
|
||
font-size: 13px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-nav { flex: 1; padding: 12px 0; overflow-y: auto; }
|
||
|
||
.nav-group-title {
|
||
padding: 12px 16px 4px;
|
||
font-size: 11px;
|
||
color: rgba(255,255,255,0.35);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.nav-item {
|
||
display: flex; align-items: center;
|
||
padding: 12px 16px;
|
||
margin: 2px 8px;
|
||
border-radius: 6px;
|
||
color: rgba(255,255,255,0.65);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.nav-item:hover {
|
||
color: #fff;
|
||
background: rgba(255,255,255,0.08);
|
||
}
|
||
|
||
.nav-item.active {
|
||
color: #fff;
|
||
background: var(--primary-color);
|
||
}
|
||
|
||
.nav-item .nav-icon {
|
||
width: 18px; margin-right: 10px;
|
||
text-align: center; font-size: 14px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Main area */
|
||
.main {
|
||
margin-left: var(--sidebar-width);
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* Header - sticky with unsaved indicator */
|
||
.header {
|
||
height: var(--header-height);
|
||
background: var(--card-bg);
|
||
border-bottom: 1px solid var(--border-color);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 24px;
|
||
position: sticky; top: 0;
|
||
z-index: 50;
|
||
}
|
||
|
||
.header-title {
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
color: var(--text-main);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.header-right {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
/* Status badge with pulse animation */
|
||
.status-badge {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 12px;
|
||
padding: 4px 10px;
|
||
border-radius: 12px;
|
||
background: rgba(82,196,26,0.1);
|
||
color: var(--success);
|
||
}
|
||
|
||
.status-dot {
|
||
width: 6px; height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--success);
|
||
animation: pulse 2s infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; transform: scale(1); }
|
||
50% { opacity: 0.5; transform: scale(1.2); }
|
||
}
|
||
|
||
/* Unsaved indicator */
|
||
.unsaved-indicator {
|
||
font-size: 12px;
|
||
color: var(--warning);
|
||
display: none;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
.unsaved-indicator.show { display: flex; }
|
||
|
||
/* Content - with max width */
|
||
.content {
|
||
flex: 1;
|
||
padding: 24px;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.content-inner {
|
||
width: 100%;
|
||
max-width: var(--content-max-width);
|
||
}
|
||
|
||
/* Card */
|
||
.card {
|
||
background: var(--card-bg);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.card-header {
|
||
padding: 14px 18px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.card-header h3 {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.card-body { padding: 18px; }
|
||
|
||
/* Form */
|
||
.form-group {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.form-group:last-child { margin-bottom: 0; }
|
||
|
||
.form-label {
|
||
display: block;
|
||
margin-bottom: 6px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-main);
|
||
}
|
||
|
||
.form-hint {
|
||
font-weight: 400;
|
||
color: var(--text-light);
|
||
font-size: 12px;
|
||
margin-left: 4px;
|
||
}
|
||
|
||
.form-input, .form-select {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius);
|
||
font-size: 13px;
|
||
color: var(--text-main);
|
||
background: #fff;
|
||
transition: border-color 0.15s, box-shadow 0.15s, opacity 0.2s;
|
||
outline: none;
|
||
}
|
||
|
||
.form-input:focus, .form-select:focus {
|
||
border-color: var(--primary-color);
|
||
box-shadow: 0 0 0 2px rgba(22,119,255,0.12);
|
||
}
|
||
|
||
.form-input::placeholder { color: var(--text-light); }
|
||
|
||
.form-input:disabled, .form-select:disabled {
|
||
background: #f5f5f5;
|
||
color: var(--text-light);
|
||
cursor: not-allowed;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.form-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 16px;
|
||
}
|
||
|
||
@media (max-width: 600px) {
|
||
.form-row { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
/* Tag input */
|
||
.tag-container {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
padding: 8px 10px;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius);
|
||
min-height: 42px;
|
||
cursor: text;
|
||
transition: border-color 0.15s, box-shadow 0.15s, opacity 0.2s, background 0.2s;
|
||
background: #fff;
|
||
}
|
||
|
||
.tag-container:focus-within {
|
||
border-color: var(--primary-color);
|
||
box-shadow: 0 0 0 2px rgba(22,119,255,0.12);
|
||
}
|
||
|
||
.tag-container.disabled {
|
||
background: #f5f5f5;
|
||
opacity: 0.6;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.tag-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 3px 8px;
|
||
background: rgba(22,119,255,0.1);
|
||
color: var(--primary-color);
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
gap: 4px;
|
||
}
|
||
|
||
.tag-item .tag-close {
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
opacity: 0.5;
|
||
line-height: 1;
|
||
}
|
||
|
||
.tag-item .tag-close:hover { opacity: 1; }
|
||
|
||
.tag-input {
|
||
border: none;
|
||
outline: none;
|
||
flex: 1;
|
||
min-width: 100px;
|
||
font-size: 13px;
|
||
padding: 2px 0;
|
||
color: var(--text-main);
|
||
background: transparent;
|
||
}
|
||
|
||
.tag-input::placeholder { color: var(--text-light); }
|
||
|
||
/* Switch */
|
||
.switch-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.switch {
|
||
position: relative;
|
||
width: 38px; height: 22px;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.switch input { display: none; }
|
||
|
||
.switch-slider {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: #ccc;
|
||
border-radius: 11px;
|
||
transition: 0.2s;
|
||
}
|
||
|
||
.switch-slider::before {
|
||
content: '';
|
||
position: absolute;
|
||
width: 18px; height: 18px;
|
||
left: 2px; top: 2px;
|
||
background: #fff;
|
||
border-radius: 50%;
|
||
transition: 0.2s;
|
||
}
|
||
|
||
.switch input:checked + .switch-slider { background: var(--primary-color); }
|
||
.switch input:checked + .switch-slider::before { transform: translateX(16px); }
|
||
|
||
.switch-text {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* Code block */
|
||
.code-block {
|
||
font-family: "SF Mono", Monaco, "Cascadia Code", Consolas, monospace;
|
||
font-size: 12px;
|
||
background: #f6f8fa;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: 4px;
|
||
padding: 10px 12px;
|
||
overflow-x: auto;
|
||
line-height: 1.5;
|
||
color: #24292f;
|
||
}
|
||
|
||
/* Info block */
|
||
.info-block {
|
||
padding: 12px 14px;
|
||
background: #f0f5ff;
|
||
border-radius: var(--radius);
|
||
border-left: 3px solid var(--primary-color);
|
||
margin-bottom: 16px;
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* Section disabled overlay */
|
||
.section-disabled {
|
||
position: relative;
|
||
}
|
||
|
||
.section-disabled::after {
|
||
content: '名单功能已关闭';
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(255,255,255,0.7);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 13px;
|
||
color: var(--text-light);
|
||
border-radius: var(--radius);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Toast */
|
||
.toast-container {
|
||
position: fixed;
|
||
top: 12px;
|
||
right: 12px;
|
||
z-index: 9999;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.toast {
|
||
padding: 10px 14px;
|
||
border-radius: var(--radius);
|
||
font-size: 13px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
animation: slideIn 0.2s ease;
|
||
}
|
||
|
||
.toast-success { background: #f6ffed; color: #389e0d; border: 1px solid #b7eb8f; }
|
||
.toast-error { background: #fff2f0; color: #cf1322; border: 1px solid #ffccc7; }
|
||
.toast-info { background: #e6f7ff; color: #0958d9; border: 1px solid #91caff; }
|
||
.toast-saving { background: #fffbe6; color: #d48806; border: 1px solid #ffe58f; }
|
||
|
||
@keyframes slideIn {
|
||
from { transform: translateX(100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
|
||
/* Responsive */
|
||
@media (max-width: 768px) {
|
||
.sidebar { width: 0; overflow: hidden; }
|
||
.main { margin-left: 0; }
|
||
.content { padding: 16px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- 登录遮罩 -->
|
||
<div id="loginOverlay" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.45);align-items:center;justify-content:center;">
|
||
<div style="background:#fff;border-radius:8px;padding:32px 28px;width:340px;box-shadow:0 8px 24px rgba(0,0,0,0.18);">
|
||
<div style="text-align:center;margin-bottom:20px;">
|
||
<div style="font-size:28px;margin-bottom:6px;">N</div>
|
||
<h3 style="font-size:16px;margin-bottom:4px;">管理面板登录</h3>
|
||
<p style="font-size:12px;color:var(--text-light);">请输入 API Key 以访问管理面板</p>
|
||
</div>
|
||
<input id="loginApiKey" class="form-input" type="password" placeholder="API Key" style="margin-bottom:6px;"
|
||
onkeydown="if(event.key==='Enter')doLogin()" />
|
||
<div id="loginError" style="font-size:12px;color:var(--danger);min-height:18px;margin-bottom:8px;"></div>
|
||
<button onclick="doLogin()" style="width:100%;padding:9px 0;background:var(--primary-color);color:#fff;border:none;border-radius:var(--radius);font-size:14px;cursor:pointer;">登录</button>
|
||
</div>
|
||
</div>
|
||
|
||
<aside class="sidebar">
|
||
<div class="sidebar-logo">
|
||
<div class="logo-icon">N</div>
|
||
<span>Webhook</span>
|
||
</div>
|
||
<nav class="sidebar-nav">
|
||
<div class="nav-group-title">配置</div>
|
||
<div class="nav-item active" data-page="command">
|
||
<span class="nav-icon">⌘</span>
|
||
<span>命令监听</span>
|
||
</div>
|
||
<div class="nav-item" data-page="list">
|
||
<span class="nav-icon">☰</span>
|
||
<span>黑白名单</span>
|
||
</div>
|
||
<div class="nav-item" data-page="callback">
|
||
<span class="nav-icon">↗</span>
|
||
<span>回调设置</span>
|
||
</div>
|
||
<div class="nav-group-title">系统</div>
|
||
<div class="nav-item" data-page="about">
|
||
<span class="nav-icon">ℹ</span>
|
||
<span>关于</span>
|
||
</div>
|
||
</nav>
|
||
</aside>
|
||
|
||
<div class="main">
|
||
<header class="header">
|
||
<div class="header-title">
|
||
<span id="headerTitle">命令监听配置</span>
|
||
<span class="unsaved-indicator" id="unsavedIndicator">● 未保存</span>
|
||
</div>
|
||
<div class="header-right">
|
||
<div class="status-badge">
|
||
<span class="status-dot"></span>
|
||
<span>运行中</span>
|
||
</div>
|
||
<button class="btn btn-sm btn-default" onclick="reloadFromYaml()" style="padding:5px 12px;font-size:12px;border:1px solid var(--border-color);border-radius:5px;cursor:pointer;background:#fff;">
|
||
重新加载
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="content">
|
||
<div class="content-inner">
|
||
|
||
<!-- 命令监听页 -->
|
||
<div class="page" id="page-command">
|
||
<div class="card">
|
||
<div class="card-header"><h3>基本设置</h3></div>
|
||
<div class="card-body">
|
||
<div class="info-block">
|
||
修改后自动保存,无需手动操作。
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">命令前缀</label>
|
||
<input class="form-input" id="command_prefix" placeholder="例如: #" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">监听范围</label>
|
||
<select class="form-select" id="command_scope">
|
||
<option value="all">全部消息</option>
|
||
<option value="group">仅群聊</option>
|
||
<option value="private">仅私聊</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">命令长度 <span class="form-hint">最少字符</span></label>
|
||
<input class="form-input" id="command_length_min" type="number" min="1" max="20" placeholder="2" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">命令长度 <span class="form-hint">最多字符</span></label>
|
||
<input class="form-input" id="command_length_max" type="number" min="1" max="20" placeholder="4" />
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<div class="switch-wrapper">
|
||
<label class="switch">
|
||
<input type="checkbox" id="command_at_sender" />
|
||
<span class="switch-slider"></span>
|
||
</label>
|
||
<span class="switch-text">回复时 @发送者(仅群聊)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h3>命令匹配测试</h3></div>
|
||
<div class="card-body">
|
||
<div class="form-group">
|
||
<input class="form-input" id="testMessage" placeholder="输入消息测试,如 #帮助" oninput="testPattern()" />
|
||
</div>
|
||
<div id="testResult" style="font-size:13px;color:var(--text-light);">输入消息后自动检测</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 黑白名单页 -->
|
||
<div class="page" id="page-list" style="display:none;">
|
||
<div class="card">
|
||
<div class="card-header"><h3>名单开关</h3></div>
|
||
<div class="card-body">
|
||
<div class="info-block">
|
||
<strong>白名单模式:</strong>用户在名单 <em>或</em> 群在名单 → 可触发。<br>
|
||
例如:设置白名单群后,该群全员可用;设置白名单用户后,该用户全域可用。<br>
|
||
<strong>黑名单模式:</strong>用户在名单 <em>或</em> 群在名单 → 拒绝。<br>
|
||
名单为空时表示不限制。
|
||
</div>
|
||
<div class="form-group">
|
||
<div class="switch-wrapper">
|
||
<label class="switch">
|
||
<input type="checkbox" id="command_list_enabled" onchange="toggleListEnabled()" />
|
||
<span class="switch-slider"></span>
|
||
</label>
|
||
<span class="switch-text">启用黑白名单过滤</span>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">名单模式</label>
|
||
<select class="form-select" id="command_list_mode" onchange="updateListModeUI()">
|
||
<option value="allow">白名单(仅允许名单内)</option>
|
||
<option value="deny">黑名单(拒绝名单内)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="listSection">
|
||
<div class="card">
|
||
<div class="card-header"><h3>群名单</h3></div>
|
||
<div class="card-body">
|
||
<div class="form-row">
|
||
<div class="form-group" id="allowedGroupsGroup">
|
||
<label class="form-label">白名单群 <span class="form-hint">群号</span></label>
|
||
<div class="tag-container" id="allowedGroupsContainer" onclick="focusTagInput(this)">
|
||
<input class="tag-input" id="allowedGroupsInput" placeholder="输入群号回车" onkeydown="handleTagKey(event, 'command_allowed_groups')" />
|
||
</div>
|
||
</div>
|
||
<div class="form-group" id="deniedGroupsGroup">
|
||
<label class="form-label">黑名单群 <span class="form-hint">群号</span></label>
|
||
<div class="tag-container" id="deniedGroupsContainer" onclick="focusTagInput(this)">
|
||
<input class="tag-input" id="deniedGroupsInput" placeholder="输入群号回车" onkeydown="handleTagKey(event, 'command_denied_groups')" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h3>用户名单</h3></div>
|
||
<div class="card-body">
|
||
<div class="form-row">
|
||
<div class="form-group" id="allowedUsersGroup">
|
||
<label class="form-label">白名单用户 <span class="form-hint">QQ号</span></label>
|
||
<div class="tag-container" id="allowedUsersContainer" onclick="focusTagInput(this)">
|
||
<input class="tag-input" id="allowedUsersInput" placeholder="输入QQ号回车" onkeydown="handleTagKey(event, 'command_allowed_users')" />
|
||
</div>
|
||
</div>
|
||
<div class="form-group" id="deniedUsersGroup">
|
||
<label class="form-label">黑名单用户 <span class="form-hint">QQ号</span></label>
|
||
<div class="tag-container" id="deniedUsersContainer" onclick="focusTagInput(this)">
|
||
<input class="tag-input" id="deniedUsersInput" placeholder="输入QQ号回车" onkeydown="handleTagKey(event, 'command_denied_users')" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 回调设置页 -->
|
||
<div class="page" id="page-callback" style="display:none;">
|
||
<div class="card">
|
||
<div class="card-header"><h3>回调配置</h3></div>
|
||
<div class="card-body">
|
||
<div class="info-block">
|
||
命令匹配后,插件会 POST 数据到回调 URL,返回的 JSON 控制回复内容。
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">回调 URL</label>
|
||
<input class="form-input" id="command_callback_url" placeholder="留空则不触发回调" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">超时时间 <span class="form-hint">秒</span></label>
|
||
<input class="form-input" id="command_callback_timeout" type="number" min="1" max="600" placeholder="180" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><h3>数据格式</h3></div>
|
||
<div class="card-body" style="font-size:13px;">
|
||
<p style="margin-bottom:8px;color:var(--text-secondary);">请求数据:</p>
|
||
<div class="code-block">{"command":"帮助","content":"参数","raw_message":"#帮助 参数","user_id":"123","group_id":"456"}</div>
|
||
<p style="margin:12px 0 8px;color:var(--text-secondary);">响应格式:</p>
|
||
<div class="code-block">{"reply":"回复文本","messages":[{"type":"text","msg":"..."}],"at_sender":true}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 关于页 -->
|
||
<div class="page" id="page-about" style="display:none;">
|
||
<div class="card">
|
||
<div class="card-header"><h3>关于</h3></div>
|
||
<div class="card-body" style="text-align:center;padding:30px 20px;">
|
||
<div style="font-size:36px;margin-bottom:10px;">N</div>
|
||
<h2 style="font-size:18px;margin-bottom:6px;">NcatBot Webhook Plugin</h2>
|
||
<p style="color:var(--text-secondary);font-size:13px;margin-bottom:20px;">HTTP 接口代理,消息转发至 QQ</p>
|
||
<div style="border-top:1px solid var(--border-color);padding-top:16px;">
|
||
<div style="display:flex;justify-content:space-between;padding:6px 0;font-size:13px;">
|
||
<span style="color:var(--text-secondary)">版本</span><span>v0.2.0</span>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;padding:6px 0;font-size:13px;">
|
||
<span style="color:var(--text-secondary)">框架</span><span>NcatBot + aiohttp</span>
|
||
</div>
|
||
<div style="display:flex;justify-content:space-between;padding:6px 0;font-size:13px;">
|
||
<span style="color:var(--text-secondary)">存储</span><span>YAML</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast-container" id="toastContainer"></div>
|
||
|
||
<script>
|
||
// ── 全局状态 ──────────────────────────────────────────────
|
||
const TAG_FIELDS = {
|
||
command_allowed_groups: { container: 'allowedGroupsContainer', input: 'allowedGroupsInput' },
|
||
command_denied_groups: { container: 'deniedGroupsContainer', input: 'deniedGroupsInput' },
|
||
command_allowed_users: { container: 'allowedUsersContainer', input: 'allowedUsersInput' },
|
||
command_denied_users: { container: 'deniedUsersContainer', input: 'deniedUsersInput' },
|
||
};
|
||
|
||
const tagValues = {
|
||
command_allowed_groups: [],
|
||
command_denied_groups: [],
|
||
command_allowed_users: [],
|
||
command_denied_users: [],
|
||
};
|
||
|
||
const PAGE_TITLES = {
|
||
command: '命令监听配置',
|
||
list: '黑白名单',
|
||
callback: '回调设置',
|
||
about: '关于',
|
||
};
|
||
|
||
let currentPage = 'command';
|
||
let saveTimeout = null;
|
||
let isLoading = false;
|
||
|
||
// ── 鉴权 ────────────────────────────────────────────────
|
||
function getApiKey() {
|
||
return sessionStorage.getItem('webhook_api_key') || new URLSearchParams(location.search).get('apiKey') || '';
|
||
}
|
||
|
||
function authHeaders(headers = {}) {
|
||
const key = getApiKey();
|
||
if (key) headers['X-API-Key'] = key;
|
||
return headers;
|
||
}
|
||
|
||
function showLogin() {
|
||
document.getElementById('loginOverlay').style.display = 'flex';
|
||
}
|
||
|
||
function hideLogin() {
|
||
document.getElementById('loginOverlay').style.display = 'none';
|
||
}
|
||
|
||
async function doLogin() {
|
||
const key = document.getElementById('loginApiKey').value.trim();
|
||
if (!key) { document.getElementById('loginError').textContent = '请输入 API Key'; return; }
|
||
sessionStorage.setItem('webhook_api_key', key);
|
||
const ok = await loadSettings(true);
|
||
if (!ok) {
|
||
sessionStorage.removeItem('webhook_api_key');
|
||
document.getElementById('loginError').textContent = 'API Key 无效';
|
||
} else {
|
||
hideLogin();
|
||
}
|
||
}
|
||
|
||
// ── 页面切换 ──────────────────────────────────────────────
|
||
document.querySelectorAll('.nav-item[data-page]').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
const page = item.dataset.page;
|
||
if (page === currentPage) return;
|
||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||
item.classList.add('active');
|
||
document.querySelectorAll('.page').forEach(p => p.style.display = 'none');
|
||
document.getElementById('page-' + page).style.display = 'block';
|
||
document.getElementById('headerTitle').textContent = PAGE_TITLES[page] || '';
|
||
currentPage = page;
|
||
});
|
||
});
|
||
|
||
// ── Tag 输入 ──────────────────────────────────────────────
|
||
function focusTagInput(container) {
|
||
if (container.classList.contains('disabled')) return;
|
||
container.querySelector('.tag-input').focus();
|
||
}
|
||
|
||
function handleTagKey(event, field) {
|
||
if (event.key === 'Enter') {
|
||
event.preventDefault();
|
||
const input = event.target;
|
||
const value = input.value.trim();
|
||
if (value && !tagValues[field].includes(value)) {
|
||
tagValues[field].push(value);
|
||
renderTags(field);
|
||
triggerAutoSave();
|
||
}
|
||
input.value = '';
|
||
}
|
||
}
|
||
|
||
function renderTags(field) {
|
||
const cfg = TAG_FIELDS[field];
|
||
const container = document.getElementById(cfg.container);
|
||
const input = document.getElementById(cfg.input);
|
||
container.querySelectorAll('.tag-item').forEach(t => t.remove());
|
||
tagValues[field].forEach((val, idx) => {
|
||
const tag = document.createElement('span');
|
||
tag.className = 'tag-item';
|
||
tag.innerHTML = val + ' <span class="tag-close" onclick="removeTag(\'' + field + '\',' + idx + ')">×</span>';
|
||
container.insertBefore(tag, input);
|
||
});
|
||
}
|
||
|
||
function removeTag(field, idx) {
|
||
tagValues[field].splice(idx, 1);
|
||
renderTags(field);
|
||
triggerAutoSave();
|
||
}
|
||
|
||
// ── 黑白名单 UI 状态 ──────────────────────────────────────
|
||
function toggleListEnabled() {
|
||
const enabled = document.getElementById('command_list_enabled').checked;
|
||
const section = document.getElementById('listSection');
|
||
if (enabled) {
|
||
section.classList.remove('section-disabled');
|
||
} else {
|
||
section.classList.add('section-disabled');
|
||
}
|
||
updateListModeUI();
|
||
}
|
||
|
||
function updateListModeUI() {
|
||
const mode = document.getElementById('command_list_mode').value;
|
||
const enabled = document.getElementById('command_list_enabled').checked;
|
||
|
||
const allowGroups = document.getElementById('allowedGroupsGroup');
|
||
const denyGroups = document.getElementById('deniedGroupsGroup');
|
||
const allowUsers = document.getElementById('allowedUsersGroup');
|
||
const denyUsers = document.getElementById('deniedUsersGroup');
|
||
|
||
const allowContainer = document.getElementById('allowedGroupsContainer');
|
||
const denyContainer = document.getElementById('deniedGroupsContainer');
|
||
const allowUserContainer = document.getElementById('allowedUsersContainer');
|
||
const denyUserContainer = document.getElementById('deniedUsersContainer');
|
||
|
||
// Reset - all enabled
|
||
[allowGroups, denyGroups, allowUsers, denyUsers].forEach(el => el.style.opacity = '1');
|
||
[allowContainer, denyContainer, allowUserContainer, denyUserContainer].forEach(el => el.classList.remove('disabled'));
|
||
|
||
if (!enabled) return;
|
||
|
||
// OR 逻辑下,两种名单可以同时配置,但只用当前模式的名单
|
||
if (mode === 'allow') {
|
||
// 白名单模式:灰掉黑名单(不生效)
|
||
denyGroups.style.opacity = '0.4';
|
||
denyUsers.style.opacity = '0.4';
|
||
denyContainer.classList.add('disabled');
|
||
denyUserContainer.classList.add('disabled');
|
||
} else {
|
||
// 黑名单模式:灰掉白名单(不生效)
|
||
allowGroups.style.opacity = '0.4';
|
||
allowUsers.style.opacity = '0.4';
|
||
allowContainer.classList.add('disabled');
|
||
allowUserContainer.classList.add('disabled');
|
||
}
|
||
}
|
||
|
||
// ── Toast ──────────────────────────────────────────────────
|
||
function showToast(msg, type = 'info') {
|
||
const container = document.getElementById('toastContainer');
|
||
const toast = document.createElement('div');
|
||
toast.className = 'toast toast-' + type;
|
||
const icons = { success: '✓', error: '✗', info: 'ℹ', saving: '…' };
|
||
toast.innerHTML = '<span>' + (icons[type] || '') + '</span><span>' + msg + '</span>';
|
||
container.appendChild(toast);
|
||
setTimeout(() => {
|
||
toast.style.opacity = '0';
|
||
toast.style.transition = 'opacity 0.3s';
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 2500);
|
||
}
|
||
|
||
// ── 未保存指示器 ───────────────────────────────────────────
|
||
function setUnsaved(show) {
|
||
const indicator = document.getElementById('unsavedIndicator');
|
||
if (show) {
|
||
indicator.classList.add('show');
|
||
} else {
|
||
indicator.classList.remove('show');
|
||
}
|
||
}
|
||
|
||
// ── 自动保存 ───────────────────────────────────────────────
|
||
function triggerAutoSave() {
|
||
if (isLoading) return;
|
||
setUnsaved(true);
|
||
clearTimeout(saveTimeout);
|
||
saveTimeout = setTimeout(saveAll, 800);
|
||
}
|
||
|
||
// 监听所有输入变化
|
||
document.querySelectorAll('.form-input, .form-select').forEach(el => {
|
||
el.addEventListener('input', triggerAutoSave);
|
||
el.addEventListener('change', triggerAutoSave);
|
||
});
|
||
document.querySelectorAll('.switch input').forEach(el => {
|
||
el.addEventListener('change', triggerAutoSave);
|
||
});
|
||
|
||
// ── 加载配置 ──────────────────────────────────────────────
|
||
async function loadSettings(silent = false) {
|
||
isLoading = true;
|
||
try {
|
||
const resp = await fetch('/api/settings', { headers: authHeaders() });
|
||
if (resp.status === 401) {
|
||
if (!silent) showLogin();
|
||
isLoading = false;
|
||
return false;
|
||
}
|
||
const json = await resp.json();
|
||
if (json.code !== 0) { if (!silent) showToast('加载失败: ' + json.msg, 'error'); isLoading = false; return false; }
|
||
const data = json.data;
|
||
|
||
const fields = [
|
||
'command_prefix', 'command_length_min', 'command_length_max',
|
||
'command_scope', 'command_list_mode', 'command_list_enabled',
|
||
'command_callback_url', 'command_callback_timeout', 'command_at_sender',
|
||
];
|
||
fields.forEach(key => {
|
||
const el = document.getElementById(key);
|
||
if (!el) return;
|
||
if (el.type === 'checkbox') {
|
||
el.checked = data[key] === 'true' || data[key] === '1' || data[key] === 'yes';
|
||
} else {
|
||
el.value = data[key] || '';
|
||
}
|
||
});
|
||
|
||
['command_allowed_groups', 'command_denied_groups', 'command_allowed_users', 'command_denied_users'].forEach(key => {
|
||
const raw = data[key] || '';
|
||
tagValues[key] = raw.split(',').map(s => s.trim()).filter(Boolean);
|
||
renderTags(key);
|
||
});
|
||
|
||
// 更新名单 UI
|
||
toggleListEnabled();
|
||
|
||
if (!silent) showToast('配置已加载', 'success');
|
||
} catch (e) {
|
||
if (!silent) showToast('加载异常: ' + e.message, 'error');
|
||
} finally {
|
||
isLoading = false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ── 保存配置 ──────────────────────────────────────────────
|
||
async function saveAll() {
|
||
const data = {};
|
||
|
||
const fields = [
|
||
'command_prefix', 'command_length_min', 'command_length_max',
|
||
'command_scope', 'command_list_mode', 'command_list_enabled',
|
||
'command_callback_url', 'command_callback_timeout',
|
||
];
|
||
fields.forEach(key => {
|
||
const el = document.getElementById(key);
|
||
if (el) data[key] = el.value;
|
||
});
|
||
|
||
const atSender = document.getElementById('command_at_sender');
|
||
if (atSender) data.command_at_sender = atSender.checked ? 'true' : 'false';
|
||
|
||
const listEnabled = document.getElementById('command_list_enabled');
|
||
if (listEnabled) data.command_list_enabled = listEnabled.checked ? 'true' : 'false';
|
||
|
||
Object.keys(tagValues).forEach(key => {
|
||
data[key] = tagValues[key].join(',');
|
||
});
|
||
|
||
try {
|
||
showToast('保存中…', 'saving');
|
||
const resp = await fetch('/api/settings', {
|
||
method: 'PUT',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify(data),
|
||
});
|
||
if (resp.status === 401) { showLogin(); return; }
|
||
const json = await resp.json();
|
||
if (json.code === 0) {
|
||
showToast('已保存', 'success');
|
||
setUnsaved(false);
|
||
} else {
|
||
showToast('保存失败: ' + json.msg, 'error');
|
||
}
|
||
} catch (e) {
|
||
showToast('保存异常: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── 重新加载 ───────────────────────────────────────────────
|
||
async function reloadFromYaml() {
|
||
try {
|
||
const resp = await fetch('/api/settings/reload', { method: 'POST', headers: authHeaders() });
|
||
if (resp.status === 401) { showLogin(); return; }
|
||
const json = await resp.json();
|
||
if (json.code === 0) {
|
||
showToast('已重新加载', 'success');
|
||
await loadSettings();
|
||
} else {
|
||
showToast('加载失败: ' + json.msg, 'error');
|
||
}
|
||
} catch (e) {
|
||
showToast('加载异常: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── 命令匹配测试 ───────────────────────────────────────────
|
||
function testPattern() {
|
||
const prefix = document.getElementById('command_prefix').value || '#';
|
||
const minLen = parseInt(document.getElementById('command_length_min').value) || 2;
|
||
const maxLen = parseInt(document.getElementById('command_length_max').value) || 4;
|
||
const msg = document.getElementById('testMessage').value.trim();
|
||
const result = document.getElementById('testResult');
|
||
|
||
if (!msg) {
|
||
result.innerHTML = '<span style="color:var(--text-light)">输入消息后自动检测</span>';
|
||
return;
|
||
}
|
||
|
||
const pattern = new RegExp('^' + escapeRegex(prefix) + '(\\S{' + minLen + ',' + maxLen + '})(?:\\s+(.+))?$');
|
||
const match = msg.match(pattern);
|
||
|
||
if (match) {
|
||
result.innerHTML = '<span style="color:var(--success)">✓ 匹配成功</span> — 命令: <strong>' + escapeHtml(match[1]) + '</strong>' +
|
||
(match[2] ? ',内容: <strong>' + escapeHtml(match[2].trim()) + '</strong>' : '');
|
||
} else {
|
||
result.innerHTML = '<span style="color:var(--danger)">✗ 不匹配</span> — 需要: ' +
|
||
escapeHtml(prefix) + ' + ' + minLen + '~' + maxLen + '个字符';
|
||
}
|
||
}
|
||
|
||
function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
||
function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||
|
||
// ── 初始化 ────────────────────────────────────────────────
|
||
// 支持 URL 参数 ?apiKey=xxx 自动登录
|
||
const urlKey = new URLSearchParams(location.search).get('apiKey');
|
||
if (urlKey) {
|
||
sessionStorage.setItem('webhook_api_key', urlKey);
|
||
// 清除 URL 中的 apiKey 参数,避免泄露
|
||
const url = new URL(location.href);
|
||
url.searchParams.delete('apiKey');
|
||
history.replaceState(null, '', url.href);
|
||
}
|
||
loadSettings();
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
async def admin_page_handler(request: web.Request) -> web.Response:
|
||
"""GET /admin/ — 返回管理页面 HTML。"""
|
||
return web.Response(text=ADMIN_HTML, content_type="text/html")
|