Files
webhook/handlers/admin.py
zhilv 58e53c8aec feat(command): 黑白名单开关、自动保存与管理界面重构
- 新增 list_enabled 开关控制是否启用名单过滤
- 表单变更后 800ms 自动保存,去掉手动保存按钮
- Header 显示"未保存"指示器,保存中 toast 提示
- 内容区限制最大宽度 900px,优化宽屏显示
- 侧边栏增加圆角选中态,运行状态带脉冲动画
- 白名单模式灰掉黑名单输入,关闭名单时显示遮罩
- 命令测试结果增加成功/失败颜色反馈
- 回调格式改用等宽字体代码块
2026-05-03 21:56:48 +08:00

1039 lines
32 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: 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>
<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">
开启后,仅名单内的群/用户可触发命令(白名单),或名单内的群/用户不可触发(黑名单)。
</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;
// ── 页面切换 ──────────────────────────────────────────────
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
[allowGroups, denyGroups, allowUsers, denyUsers].forEach(el => el.style.opacity = '1');
[allowContainer, denyContainer, allowUserContainer, denyUserContainer].forEach(el => el.classList.remove('disabled'));
if (!enabled) return;
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() {
isLoading = true;
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_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();
showToast('配置已加载', 'success');
} catch (e) {
showToast('加载异常: ' + e.message, 'error');
} finally {
isLoading = false;
}
}
// ── 保存配置 ──────────────────────────────────────────────
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: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
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' });
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; }
// ── 初始化 ────────────────────────────────────────────────
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")