- 用 CommandConfig dataclass 单例替代模块级变量,解决 from import 造成的本地绑定不随 global 更新的 bug - 删除 db.py,改用 settings.yaml 存储动态配置,首次启动自动创建并合并 .env 默认值 - 新增文件轮询 watcher(2 秒),检测 YAML 变更自动热重载 - 管理界面 API 改为直接读写 YAML,即时生效 - 依赖 aiosqlite 替换为 pyyaml
961 lines
29 KiB
Python
961 lines
29 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: 220px;
|
||
--header-height: 56px;
|
||
--radius: 6px;
|
||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||
}
|
||
|
||
* { 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;
|
||
transition: width 0.2s;
|
||
}
|
||
|
||
.sidebar-logo {
|
||
height: var(--header-height);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 20px;
|
||
color: #fff;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebar-logo .logo-icon {
|
||
width: 28px; height: 28px;
|
||
background: var(--primary-color);
|
||
border-radius: 6px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
margin-right: 10px;
|
||
font-size: 14px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-nav { flex: 1; padding: 8px 0; overflow-y: auto; }
|
||
|
||
.nav-group-title {
|
||
padding: 16px 20px 6px;
|
||
font-size: 11px;
|
||
color: rgba(255,255,255,0.35);
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
.nav-item {
|
||
display: flex; align-items: center;
|
||
padding: 10px 20px;
|
||
color: rgba(255,255,255,0.65);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
font-size: 14px;
|
||
border-left: 3px solid transparent;
|
||
}
|
||
|
||
.nav-item:hover {
|
||
color: #fff;
|
||
background: rgba(255,255,255,0.06);
|
||
}
|
||
|
||
.nav-item.active {
|
||
color: #fff;
|
||
background: rgba(22,119,255,0.15);
|
||
border-left-color: var(--primary-color);
|
||
}
|
||
|
||
.nav-item .nav-icon {
|
||
width: 18px; margin-right: 10px;
|
||
text-align: center; font-size: 15px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* Main area */
|
||
.main {
|
||
margin-left: var(--sidebar-width);
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* Header */
|
||
.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: 16px;
|
||
font-weight: 500;
|
||
color: var(--text-main);
|
||
}
|
||
|
||
.header-right {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.header-badge {
|
||
font-size: 12px;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
background: rgba(22,119,255,0.1);
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
/* Content */
|
||
.content {
|
||
flex: 1;
|
||
padding: 24px;
|
||
}
|
||
|
||
/* Card */
|
||
.card {
|
||
background: var(--card-bg);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.card-header {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border-color);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.card-header h3 {
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.card-body { padding: 20px; }
|
||
|
||
/* Form */
|
||
.form-group {
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.form-group:last-child { margin-bottom: 0; }
|
||
|
||
.form-label {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 6px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-main);
|
||
}
|
||
|
||
.form-label .label-hint {
|
||
margin-left: 6px;
|
||
font-weight: 400;
|
||
color: var(--text-light);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.form-input, .form-select, .form-textarea {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius);
|
||
font-size: 14px;
|
||
color: var(--text-main);
|
||
background: #fff;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
outline: none;
|
||
}
|
||
|
||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||
border-color: var(--primary-color);
|
||
box-shadow: 0 0 0 2px rgba(22,119,255,0.15);
|
||
}
|
||
|
||
.form-textarea {
|
||
min-height: 80px;
|
||
resize: vertical;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.form-row {
|
||
display: flex;
|
||
gap: 16px;
|
||
}
|
||
|
||
.form-row .form-group { flex: 1; }
|
||
|
||
/* Tag input */
|
||
.tag-container {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
padding: 6px 10px;
|
||
border: 1px solid var(--border-color);
|
||
border-radius: var(--radius);
|
||
min-height: 38px;
|
||
cursor: text;
|
||
transition: border-color 0.15s, box-shadow 0.15s;
|
||
background: #fff;
|
||
}
|
||
|
||
.tag-container:focus-within {
|
||
border-color: var(--primary-color);
|
||
box-shadow: 0 0 0 2px rgba(22,119,255,0.15);
|
||
}
|
||
|
||
.tag-item {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 2px 8px;
|
||
background: rgba(22,119,255,0.08);
|
||
color: var(--primary-color);
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
gap: 4px;
|
||
}
|
||
|
||
.tag-item .tag-close {
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
opacity: 0.6;
|
||
line-height: 1;
|
||
}
|
||
|
||
.tag-item .tag-close:hover { opacity: 1; }
|
||
|
||
.tag-input {
|
||
border: none;
|
||
outline: none;
|
||
flex: 1;
|
||
min-width: 80px;
|
||
font-size: 14px;
|
||
padding: 2px 0;
|
||
color: var(--text-main);
|
||
}
|
||
|
||
/* Buttons */
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 7px 16px;
|
||
border-radius: var(--radius);
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
border: 1px solid transparent;
|
||
transition: all 0.15s;
|
||
gap: 6px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: var(--primary-color);
|
||
color: #fff;
|
||
}
|
||
.btn-primary:hover { background: var(--primary-hover); }
|
||
|
||
.btn-default {
|
||
background: #fff;
|
||
border-color: var(--border-color);
|
||
color: var(--text-main);
|
||
}
|
||
.btn-default:hover { border-color: var(--primary-color); color: var(--primary-color); }
|
||
|
||
.btn-danger {
|
||
background: #fff;
|
||
border-color: var(--danger);
|
||
color: var(--danger);
|
||
}
|
||
.btn-danger:hover { background: var(--danger); color: #fff; }
|
||
|
||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||
|
||
/* Switch */
|
||
.switch-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.switch {
|
||
position: relative;
|
||
width: 40px; height: 22px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.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(18px);
|
||
}
|
||
|
||
.switch-text {
|
||
font-size: 13px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* Status dot */
|
||
.status-dot {
|
||
width: 8px; height: 8px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
margin-right: 6px;
|
||
}
|
||
.status-dot.on { background: var(--success); }
|
||
.status-dot.off { background: var(--text-light); }
|
||
|
||
/* Toast */
|
||
.toast-container {
|
||
position: fixed;
|
||
top: 16px;
|
||
right: 16px;
|
||
z-index: 9999;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.toast {
|
||
padding: 10px 16px;
|
||
border-radius: var(--radius);
|
||
font-size: 14px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
animation: slideIn 0.2s ease;
|
||
min-width: 240px;
|
||
}
|
||
|
||
.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; }
|
||
|
||
@keyframes slideIn {
|
||
from { transform: translateX(100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
|
||
/* Responsive */
|
||
@media (max-width: 768px) {
|
||
.sidebar { width: 0; overflow: hidden; }
|
||
.sidebar.open { width: var(--sidebar-width); }
|
||
.main { margin-left: 0; }
|
||
.form-row { flex-direction: column; gap: 0; }
|
||
}
|
||
|
||
/* Tabs */
|
||
.tabs {
|
||
display: flex;
|
||
border-bottom: 1px solid var(--border-color);
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.tab-item {
|
||
padding: 12px 20px;
|
||
font-size: 14px;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.tab-item:hover { color: var(--primary-color); }
|
||
|
||
.tab-item.active {
|
||
color: var(--primary-color);
|
||
border-bottom-color: var(--primary-color);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.tab-panel { display: none; }
|
||
.tab-panel.active { display: block; }
|
||
|
||
/* Info block */
|
||
.info-block {
|
||
padding: 12px 16px;
|
||
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;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Sidebar -->
|
||
<aside class="sidebar" id="sidebar">
|
||
<div class="sidebar-logo">
|
||
<div class="logo-icon">N</div>
|
||
<span>NcatBot 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>
|
||
|
||
<!-- Main -->
|
||
<div class="main">
|
||
<!-- Header -->
|
||
<header class="header">
|
||
<div class="header-title" id="headerTitle">命令监听配置</div>
|
||
<div class="header-right">
|
||
<span class="header-badge" id="statusBadge">● 运行中</span>
|
||
<button class="btn btn-sm btn-primary" onclick="saveAll()">保存配置</button>
|
||
<button class="btn btn-sm btn-default" onclick="reloadFromYaml()">重新加载</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Content -->
|
||
<div class="content">
|
||
|
||
<!-- 命令监听页 -->
|
||
<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">命令前缀 <span class="label-hint">触发命令的起始字符</span></label>
|
||
<input class="form-input" id="command_prefix" placeholder="#" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">监听范围 <span class="label-hint">消息来源过滤</span></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="label-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="label-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">
|
||
<label class="form-label">测试消息</label>
|
||
<input class="form-input" id="testMessage" placeholder="输入消息测试是否匹配命令,如 #帮助" oninput="testPattern()" />
|
||
</div>
|
||
<div id="testResult" style="font-size:13px; color:var(--text-secondary);">输入消息后自动检测</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">
|
||
白名单模式:仅名单内的群/用户可触发命令。<br>
|
||
黑名单模式:名单内的群/用户不可触发命令。<br>
|
||
名单为空时表示不限制。
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">名单模式</label>
|
||
<select class="form-select" id="command_list_mode">
|
||
<option value="allow">白名单(仅允许名单内)</option>
|
||
<option value="deny">黑名单(拒绝名单内)</option>
|
||
</select>
|
||
</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">
|
||
<label class="form-label">白名单群 <span class="label-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">
|
||
<label class="form-label">黑名单群 <span class="label-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">
|
||
<label class="form-label">白名单用户 <span class="label-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">
|
||
<label class="form-label">黑名单用户 <span class="label-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 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 <span class="label-hint">留空则不触发回调</span></label>
|
||
<input class="form-input" id="command_callback_url" placeholder="https://example.com/callback" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">回调超时(秒) <span class="label-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">
|
||
<div class="info-block" style="margin-bottom:0;">
|
||
<strong>请求数据:</strong><br>
|
||
<code style="font-size:12px;background:#f5f5f5;padding:2px 6px;border-radius:3px;">
|
||
{"command":"帮助","content":"参数","raw_message":"#帮助 参数","user_id":"123","message_id":"456","group_id":"789"}
|
||
</code>
|
||
<br><br>
|
||
<strong>响应格式:</strong><br>
|
||
<code style="font-size:12px;background:#f5f5f5;padding:2px 6px;border-radius:3px;">
|
||
{"reply":"回复文本","messages":[{"type":"text","msg":"..."},{"type":"image","url":"..."}],"at_sender":true}
|
||
</code>
|
||
</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">
|
||
<div style="text-align:center; padding: 20px 0;">
|
||
<div style="font-size:40px; margin-bottom:12px;">N</div>
|
||
<h2 style="font-size:20px; margin-bottom:8px;">NcatBot Webhook Plugin</h2>
|
||
<p style="color:var(--text-secondary); font-size:14px;">对外暴露 HTTP 接口,接收外部消息转发至 QQ</p>
|
||
</div>
|
||
<div style="margin-top:16px;">
|
||
<div style="display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--border-color); font-size:13px;">
|
||
<span style="color:var(--text-secondary);">版本</span>
|
||
<span>v0.1.2</span>
|
||
</div>
|
||
<div style="display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--border-color); font-size:13px;">
|
||
<span style="color:var(--text-secondary);">框架</span>
|
||
<span>NcatBot + aiohttp</span>
|
||
</div>
|
||
<div style="display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--border-color); font-size:13px;">
|
||
<span style="color:var(--text-secondary);">存储</span>
|
||
<span>YAML</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toast container -->
|
||
<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';
|
||
|
||
// ── 页面切换 ──────────────────────────────────────────────
|
||
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) {
|
||
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);
|
||
}
|
||
input.value = '';
|
||
}
|
||
}
|
||
|
||
function renderTags(field) {
|
||
const cfg = TAG_FIELDS[field];
|
||
const container = document.getElementById(cfg.container);
|
||
const input = document.getElementById(cfg.input);
|
||
// Remove old tags
|
||
container.querySelectorAll('.tag-item').forEach(t => t.remove());
|
||
// Add tags before input
|
||
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);
|
||
}
|
||
|
||
// ── 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: 'ℹ' };
|
||
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);
|
||
}, 3000);
|
||
}
|
||
|
||
// ── 加载配置 ──────────────────────────────────────────────
|
||
async function loadSettings() {
|
||
try {
|
||
const resp = await fetch('/api/settings');
|
||
const json = await resp.json();
|
||
if (json.code !== 0) { showToast('加载配置失败: ' + json.msg, 'error'); return; }
|
||
const data = json.data;
|
||
|
||
// 普通字段
|
||
const fields = [
|
||
'command_prefix', 'command_length_min', 'command_length_max',
|
||
'command_scope', 'command_list_mode', '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] || '';
|
||
}
|
||
});
|
||
|
||
// Tag 字段
|
||
['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);
|
||
});
|
||
|
||
showToast('配置已加载', 'success');
|
||
} catch (e) {
|
||
showToast('加载配置异常: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── 保存配置 ──────────────────────────────────────────────
|
||
async function saveAll() {
|
||
const data = {};
|
||
|
||
// 普通字段
|
||
const fields = [
|
||
'command_prefix', 'command_length_min', 'command_length_max',
|
||
'command_scope', 'command_list_mode', 'command_callback_url',
|
||
'command_callback_timeout',
|
||
];
|
||
fields.forEach(key => {
|
||
const el = document.getElementById(key);
|
||
if (el) data[key] = el.value;
|
||
});
|
||
|
||
// checkbox
|
||
const cb = document.getElementById('command_at_sender');
|
||
if (cb) data.command_at_sender = cb.checked ? 'true' : 'false';
|
||
|
||
// Tag 字段
|
||
Object.keys(tagValues).forEach(key => {
|
||
data[key] = tagValues[key].join(',');
|
||
});
|
||
|
||
try {
|
||
const resp = await fetch('/api/settings', {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data),
|
||
});
|
||
const json = await resp.json();
|
||
if (json.code === 0) {
|
||
showToast('配置已保存并生效', 'success');
|
||
} else {
|
||
showToast('保存失败: ' + json.msg, 'error');
|
||
}
|
||
} catch (e) {
|
||
showToast('保存异常: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ── 从 YAML 重新加载 ──────────────────────────────────────
|
||
async function reloadFromYaml() {
|
||
try {
|
||
const resp = await fetch('/api/settings/reload', { method: 'POST' });
|
||
const json = await resp.json();
|
||
if (json.code === 0) {
|
||
showToast('已从 YAML 重新加载', '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;
|
||
}
|
||
|
||
// ── 初始化 ────────────────────────────────────────────────
|
||
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")
|