Files
webhook/handlers/admin.py
zhilv f82363f45f ♻️ refactor(command): 配置系统从 SQLite 迁移至 YAML 并修复白名单失效
- 用 CommandConfig dataclass 单例替代模块级变量,解决 from import 造成的本地绑定不随 global 更新的 bug
- 删除 db.py,改用 settings.yaml 存储动态配置,首次启动自动创建并合并 .env 默认值
- 新增文件轮询 watcher(2 秒),检测 YAML 变更自动热重载
- 管理界面 API 改为直接读写 YAML,即时生效
- 依赖 aiosqlite 替换为 pyyaml
2026-05-03 18:23:29 +08:00

961 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""后台管理:提供 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")