chore: sync current WechatHookBot workspace
This commit is contained in:
1177
utils/bot_utils.py
1177
utils/bot_utils.py
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
"""
|
||||
统一配置管理器
|
||||
|
||||
单例模式,提供:
|
||||
- 配置缓存,避免重复读取文件
|
||||
- 配置热更新检测
|
||||
- 类型安全的配置访问
|
||||
"""
|
||||
|
||||
"""
|
||||
统一配置管理器
|
||||
|
||||
单例模式,提供:
|
||||
- 配置缓存,避免重复读取文件
|
||||
- 配置热更新检测
|
||||
- 类型安全的配置访问
|
||||
"""
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
@@ -15,176 +15,218 @@ from typing import Any, Dict, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
MAIN_CONFIG_PATH = PROJECT_ROOT / "main_config.toml"
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""
|
||||
配置管理器 (线程安全单例)
|
||||
|
||||
使用示例:
|
||||
from utils.config_manager import get_config
|
||||
|
||||
# 获取单个配置项
|
||||
admins = get_config().get("Bot", "admins", [])
|
||||
|
||||
# 获取整个配置节
|
||||
bot_config = get_config().get_section("Bot")
|
||||
|
||||
# 检查并重新加载
|
||||
if get_config().reload_if_changed():
|
||||
logger.info("配置已更新")
|
||||
"""
|
||||
|
||||
_instance: Optional["ConfigManager"] = None
|
||||
_lock = Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
instance = super().__new__(cls)
|
||||
instance._initialized = False
|
||||
cls._instance = instance
|
||||
return cls._instance
|
||||
|
||||
"""
|
||||
配置管理器 (线程安全单例)
|
||||
|
||||
使用示例:
|
||||
from utils.config_manager import get_config
|
||||
|
||||
# 获取单个配置项
|
||||
admins = get_config().get("Bot", "admins", [])
|
||||
|
||||
# 获取整个配置节
|
||||
bot_config = get_config().get_section("Bot")
|
||||
|
||||
# 检查并重新加载
|
||||
if get_config().reload_if_changed():
|
||||
logger.info("配置已更新")
|
||||
"""
|
||||
|
||||
_instance: Optional["ConfigManager"] = None
|
||||
_lock = Lock()
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
instance = super().__new__(cls)
|
||||
instance._initialized = False
|
||||
cls._instance = instance
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._config: Dict[str, Any] = {}
|
||||
self._config_path = Path("main_config.toml")
|
||||
self._config_path = MAIN_CONFIG_PATH
|
||||
self._file_mtime: float = 0
|
||||
self._config_lock = Lock()
|
||||
self._reload()
|
||||
self._initialized = True
|
||||
logger.debug("ConfigManager 初始化完成")
|
||||
|
||||
def _reload(self) -> bool:
|
||||
"""重新加载配置文件"""
|
||||
try:
|
||||
if not self._config_path.exists():
|
||||
logger.warning(f"配置文件不存在: {self._config_path}")
|
||||
return False
|
||||
|
||||
current_mtime = self._config_path.stat().st_mtime
|
||||
if current_mtime == self._file_mtime and self._config:
|
||||
return False # 文件未变化
|
||||
|
||||
with self._config_lock:
|
||||
with open(self._config_path, "rb") as f:
|
||||
self._config = tomllib.load(f)
|
||||
self._file_mtime = current_mtime
|
||||
|
||||
logger.debug("配置文件已重新加载")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"加载配置文件失败: {e}")
|
||||
return False
|
||||
|
||||
def get(self, section: str, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
获取配置项
|
||||
|
||||
Args:
|
||||
section: 配置节名称,如 "Bot"
|
||||
key: 配置项名称,如 "admins"
|
||||
default: 默认值
|
||||
|
||||
Returns:
|
||||
配置值或默认值
|
||||
"""
|
||||
return self._config.get(section, {}).get(key, default)
|
||||
|
||||
def get_section(self, section: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取整个配置节
|
||||
|
||||
Args:
|
||||
section: 配置节名称
|
||||
|
||||
Returns:
|
||||
配置节字典的副本
|
||||
"""
|
||||
return self._config.get(section, {}).copy()
|
||||
|
||||
def get_all(self) -> Dict[str, Any]:
|
||||
"""获取完整配置(只读副本)"""
|
||||
return self._config.copy()
|
||||
|
||||
def reload_if_changed(self) -> bool:
|
||||
"""
|
||||
如果文件有变化则重新加载
|
||||
|
||||
Returns:
|
||||
是否重新加载了配置
|
||||
"""
|
||||
try:
|
||||
if not self._config_path.exists():
|
||||
return False
|
||||
current_mtime = self._config_path.stat().st_mtime
|
||||
if current_mtime != self._file_mtime:
|
||||
return self._reload()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _reload(self) -> bool:
|
||||
"""重新加载配置文件"""
|
||||
try:
|
||||
if not self._config_path.exists():
|
||||
logger.warning(f"配置文件不存在: {self._config_path}")
|
||||
return False
|
||||
|
||||
current_mtime = self._config_path.stat().st_mtime
|
||||
if current_mtime == self._file_mtime and self._config:
|
||||
return False # 文件未变化
|
||||
|
||||
with self._config_lock:
|
||||
with open(self._config_path, "rb") as f:
|
||||
self._config = tomllib.load(f)
|
||||
self._file_mtime = current_mtime
|
||||
|
||||
logger.debug("配置文件已重新加载")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"加载配置文件失败: {e}")
|
||||
return False
|
||||
|
||||
def get(self, section: str, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
获取配置项
|
||||
|
||||
Args:
|
||||
section: 配置节名称,如 "Bot"
|
||||
key: 配置项名称,如 "admins"
|
||||
default: 默认值
|
||||
|
||||
Returns:
|
||||
配置值或默认值
|
||||
"""
|
||||
return self._config.get(section, {}).get(key, default)
|
||||
|
||||
def get_section(self, section: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取整个配置节
|
||||
|
||||
Args:
|
||||
section: 配置节名称
|
||||
|
||||
Returns:
|
||||
配置节字典的副本
|
||||
"""
|
||||
return self._config.get(section, {}).copy()
|
||||
|
||||
def get_all(self) -> Dict[str, Any]:
|
||||
"""获取完整配置(只读副本)"""
|
||||
return self._config.copy()
|
||||
|
||||
def reload_if_changed(self) -> bool:
|
||||
"""
|
||||
如果文件有变化则重新加载
|
||||
|
||||
Returns:
|
||||
是否重新加载了配置
|
||||
"""
|
||||
try:
|
||||
if not self._config_path.exists():
|
||||
return False
|
||||
current_mtime = self._config_path.stat().st_mtime
|
||||
if current_mtime != self._file_mtime:
|
||||
return self._reload()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def force_reload(self) -> bool:
|
||||
"""强制重新加载配置"""
|
||||
self._file_mtime = 0
|
||||
return self._reload()
|
||||
|
||||
def apply_config(self, new_config: Dict[str, Any], mtime: Optional[float] = None) -> bool:
|
||||
"""
|
||||
直接应用外部加载的配置(用于热更新)
|
||||
|
||||
# ==================== 便捷函数 ====================
|
||||
Args:
|
||||
new_config: 新配置字典
|
||||
mtime: 配置文件的修改时间(可选)
|
||||
|
||||
Returns:
|
||||
是否成功应用
|
||||
"""
|
||||
if new_config is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
with self._config_lock:
|
||||
self._config = new_config
|
||||
if mtime is None and self._config_path.exists():
|
||||
mtime = self._config_path.stat().st_mtime
|
||||
if mtime:
|
||||
self._file_mtime = mtime
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"应用配置失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ==================== 便捷函数 ====================
|
||||
|
||||
def get_config() -> ConfigManager:
|
||||
"""获取配置管理器实例"""
|
||||
return ConfigManager()
|
||||
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""获取项目根目录。"""
|
||||
return PROJECT_ROOT
|
||||
|
||||
|
||||
def get_main_config_path() -> Path:
|
||||
"""获取主配置文件路径。"""
|
||||
return MAIN_CONFIG_PATH
|
||||
|
||||
|
||||
def get_bot_config() -> Dict[str, Any]:
|
||||
"""快捷获取 [Bot] 配置节"""
|
||||
return get_config().get_section("Bot")
|
||||
|
||||
|
||||
def get_performance_config() -> Dict[str, Any]:
|
||||
"""快捷获取 [Performance] 配置节"""
|
||||
return get_config().get_section("Performance")
|
||||
|
||||
|
||||
def get_database_config() -> Dict[str, Any]:
|
||||
"""快捷获取 [Database] 配置节"""
|
||||
return get_config().get_section("Database")
|
||||
|
||||
|
||||
def get_scheduler_config() -> Dict[str, Any]:
|
||||
"""快捷获取 [Scheduler] 配置节"""
|
||||
return get_config().get_section("Scheduler")
|
||||
|
||||
|
||||
def get_queue_config() -> Dict[str, Any]:
|
||||
"""快捷获取 [Queue] 配置节"""
|
||||
return get_config().get_section("Queue")
|
||||
|
||||
|
||||
def get_concurrency_config() -> Dict[str, Any]:
|
||||
"""快捷获取 [Concurrency] 配置节"""
|
||||
return get_config().get_section("Concurrency")
|
||||
|
||||
|
||||
def get_webui_config() -> Dict[str, Any]:
|
||||
"""快捷获取 [WebUI] 配置节"""
|
||||
return get_config().get_section("WebUI")
|
||||
|
||||
|
||||
# ==================== 导出列表 ====================
|
||||
|
||||
__all__ = [
|
||||
|
||||
|
||||
def get_performance_config() -> Dict[str, Any]:
|
||||
"""快捷获取 [Performance] 配置节"""
|
||||
return get_config().get_section("Performance")
|
||||
|
||||
|
||||
def get_database_config() -> Dict[str, Any]:
|
||||
"""快捷获取 [Database] 配置节"""
|
||||
return get_config().get_section("Database")
|
||||
|
||||
|
||||
def get_scheduler_config() -> Dict[str, Any]:
|
||||
"""快捷获取 [Scheduler] 配置节"""
|
||||
return get_config().get_section("Scheduler")
|
||||
|
||||
|
||||
def get_queue_config() -> Dict[str, Any]:
|
||||
"""快捷获取 [Queue] 配置节"""
|
||||
return get_config().get_section("Queue")
|
||||
|
||||
|
||||
def get_concurrency_config() -> Dict[str, Any]:
|
||||
"""快捷获取 [Concurrency] 配置节"""
|
||||
return get_config().get_section("Concurrency")
|
||||
|
||||
|
||||
def get_webui_config() -> Dict[str, Any]:
|
||||
"""快捷获取 [WebUI] 配置节"""
|
||||
return get_config().get_section("WebUI")
|
||||
|
||||
|
||||
# ==================== 导出列表 ====================
|
||||
|
||||
__all__ = [
|
||||
'ConfigManager',
|
||||
'get_config',
|
||||
'get_project_root',
|
||||
'get_main_config_path',
|
||||
'get_bot_config',
|
||||
'get_performance_config',
|
||||
'get_database_config',
|
||||
'get_scheduler_config',
|
||||
'get_queue_config',
|
||||
'get_concurrency_config',
|
||||
'get_webui_config',
|
||||
]
|
||||
'get_scheduler_config',
|
||||
'get_queue_config',
|
||||
'get_concurrency_config',
|
||||
'get_webui_config',
|
||||
]
|
||||
|
||||
@@ -190,11 +190,14 @@ class ContextStore:
|
||||
if self._use_redis_for_group_history():
|
||||
redis_cache = get_cache()
|
||||
try:
|
||||
key = f"group_history:{_safe_chat_id(chat_id)}"
|
||||
# 使用与写入时相同的 key 格式: "group_history:{chat_id}"
|
||||
# 注意:redis_cache._make_key 会生成 "group_history:{chat_id}" 格式
|
||||
# 这里直接使用原始 chat_id,与 add_group_message 保持一致
|
||||
key = f"group_history:{chat_id}"
|
||||
redis_cache.delete(key)
|
||||
logger.debug(f"[ContextStore] 已清除 Redis 群聊历史: {chat_id}")
|
||||
logger.info(f"[ContextStore] 已清除 Redis 群聊历史: {key}")
|
||||
except Exception as e:
|
||||
logger.debug(f"[ContextStore] 清除 Redis 群聊历史失败: {e}")
|
||||
logger.warning(f"[ContextStore] 清除 Redis 群聊历史失败: {e}")
|
||||
|
||||
# 清除本地文件中的群聊历史
|
||||
history_file = self._get_history_file(chat_id)
|
||||
|
||||
@@ -131,12 +131,19 @@ MESSAGE_DECORATOR_TYPES: Dict[str, str] = {
|
||||
'voice_message': '语音消息',
|
||||
'video_message': '视频消息',
|
||||
'emoji_message': '表情消息',
|
||||
'link_message': '链接消息',
|
||||
'card_message': '名片消息',
|
||||
'miniapp_message': '小程序消息',
|
||||
'file_message': '文件消息',
|
||||
'quote_message': '引用消息',
|
||||
'pat_message': '拍一拍',
|
||||
'at_message': '@消息',
|
||||
'system_message': '系统消息',
|
||||
'other_message': '其他消息',
|
||||
'chatroom_member_add': '群成员新增',
|
||||
'chatroom_member_remove': '群成员删除',
|
||||
'chatroom_info_change': '群信息变化',
|
||||
'chatroom_member_nickname_change': '群成员昵称修改',
|
||||
}
|
||||
|
||||
|
||||
@@ -148,12 +155,19 @@ on_image_message = _create_message_decorator('image_message', '图片消息')
|
||||
on_voice_message = _create_message_decorator('voice_message', '语音消息')
|
||||
on_video_message = _create_message_decorator('video_message', '视频消息')
|
||||
on_emoji_message = _create_message_decorator('emoji_message', '表情消息')
|
||||
on_link_message = _create_message_decorator('link_message', '链接消息')
|
||||
on_card_message = _create_message_decorator('card_message', '名片消息')
|
||||
on_miniapp_message = _create_message_decorator('miniapp_message', '小程序消息')
|
||||
on_file_message = _create_message_decorator('file_message', '文件消息')
|
||||
on_quote_message = _create_message_decorator('quote_message', '引用消息')
|
||||
on_pat_message = _create_message_decorator('pat_message', '拍一拍')
|
||||
on_at_message = _create_message_decorator('at_message', '@消息')
|
||||
on_system_message = _create_message_decorator('system_message', '系统消息')
|
||||
on_other_message = _create_message_decorator('other_message', '其他消息')
|
||||
on_chatroom_member_add = _create_message_decorator('chatroom_member_add', '群成员新增')
|
||||
on_chatroom_member_remove = _create_message_decorator('chatroom_member_remove', '群成员删除')
|
||||
on_chatroom_info_change = _create_message_decorator('chatroom_info_change', '群信息变化')
|
||||
on_chatroom_member_nickname_change = _create_message_decorator('chatroom_member_nickname_change', '群成员昵称修改')
|
||||
|
||||
|
||||
# ==================== 导出列表 ====================
|
||||
@@ -170,12 +184,19 @@ __all__ = [
|
||||
'on_voice_message',
|
||||
'on_video_message',
|
||||
'on_emoji_message',
|
||||
'on_link_message',
|
||||
'on_card_message',
|
||||
'on_miniapp_message',
|
||||
'on_file_message',
|
||||
'on_quote_message',
|
||||
'on_pat_message',
|
||||
'on_at_message',
|
||||
'on_system_message',
|
||||
'on_other_message',
|
||||
'on_chatroom_member_add',
|
||||
'on_chatroom_member_remove',
|
||||
'on_chatroom_info_change',
|
||||
'on_chatroom_member_nickname_change',
|
||||
# 工具
|
||||
'MESSAGE_DECORATOR_TYPES',
|
||||
'_create_message_decorator',
|
||||
|
||||
@@ -183,17 +183,25 @@ class EventManager:
|
||||
start_time = time.time()
|
||||
all_completed = True
|
||||
|
||||
logger.debug(
|
||||
f"[EventManager] 触发: {event_type}, "
|
||||
f"处理器数量: {len(handlers)}"
|
||||
)
|
||||
# logger.debug(
|
||||
# f"[EventManager] 触发: {event_type}, "
|
||||
# f"处理器数量: {len(handlers)}"
|
||||
# )
|
||||
|
||||
performance_monitor = None
|
||||
try:
|
||||
from utils.bot_utils import get_performance_monitor
|
||||
performance_monitor = get_performance_monitor()
|
||||
except Exception:
|
||||
performance_monitor = None
|
||||
|
||||
for handler_info in handlers:
|
||||
stats.handler_calls += 1
|
||||
success = True
|
||||
handler_start = time.perf_counter()
|
||||
|
||||
try:
|
||||
logger.debug(f"[EventManager] 调用: {handler_info.handler_name}")
|
||||
|
||||
# logger.debug(f"[EventManager] 调用: {handler_info.handler_name}")
|
||||
result = await handler_info.handler(*args, **kwargs)
|
||||
|
||||
# 检查是否中断
|
||||
@@ -207,12 +215,21 @@ class EventManager:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
success = False
|
||||
stats.error_count += 1
|
||||
logger.error(
|
||||
f"[EventManager] {handler_info.handler_name} 执行失败: {e}"
|
||||
)
|
||||
logger.debug(f"详细错误:\n{traceback.format_exc()}")
|
||||
# 继续执行其他处理器
|
||||
finally:
|
||||
if performance_monitor:
|
||||
handler_elapsed = time.perf_counter() - handler_start
|
||||
performance_monitor.record_plugin_execution(
|
||||
handler_info.instance.__class__.__name__,
|
||||
handler_elapsed,
|
||||
success
|
||||
)
|
||||
|
||||
elapsed_ms = (time.time() - start_time) * 1000
|
||||
stats.total_time_ms += elapsed_ms
|
||||
@@ -247,13 +264,31 @@ class EventManager:
|
||||
|
||||
semaphore = asyncio.Semaphore(max_concurrency)
|
||||
|
||||
performance_monitor = None
|
||||
try:
|
||||
from utils.bot_utils import get_performance_monitor
|
||||
performance_monitor = get_performance_monitor()
|
||||
except Exception:
|
||||
performance_monitor = None
|
||||
|
||||
async def run_handler(handler_info: HandlerInfo):
|
||||
async with semaphore:
|
||||
success = True
|
||||
handler_start = time.perf_counter()
|
||||
try:
|
||||
return await handler_info.handler(*args, **kwargs)
|
||||
except Exception as e:
|
||||
success = False
|
||||
logger.error(f"[EventManager] {handler_info.handler_name} 失败: {e}")
|
||||
return None
|
||||
finally:
|
||||
if performance_monitor:
|
||||
handler_elapsed = time.perf_counter() - handler_start
|
||||
performance_monitor.record_plugin_execution(
|
||||
handler_info.instance.__class__.__name__,
|
||||
handler_elapsed,
|
||||
success
|
||||
)
|
||||
|
||||
tasks = [run_handler(h) for h in handlers]
|
||||
return await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
@@ -139,13 +139,8 @@ class HookBot:
|
||||
)
|
||||
return
|
||||
|
||||
# 5. 格式转换
|
||||
try:
|
||||
message = normalize_message(msg_type, data)
|
||||
except Exception as e:
|
||||
logger.error(f"格式转换失败: {e}")
|
||||
self._stats.record_error()
|
||||
return
|
||||
# 5. 消息已在 bot.py 中标准化,直接使用
|
||||
message = data
|
||||
|
||||
# 6. 过滤检查
|
||||
if not self._filter.should_process(message):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
413
utils/member_info_service.py
Normal file
413
utils/member_info_service.py
Normal file
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
群成员信息服务
|
||||
|
||||
提供统一的用户信息查询接口,优先从 MemberSync 数据库读取,
|
||||
避免频繁调用 API 和 Redis 缓存
|
||||
"""
|
||||
|
||||
import aiosqlite
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class MemberInfoService:
|
||||
"""
|
||||
群成员信息服务(单例模式)
|
||||
|
||||
优先级:
|
||||
1. MemberSync SQLite 数据库(最快、最可靠)
|
||||
2. 其他缓存/历史(由调用方处理,本服务不触发 API)
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
# MemberSync 数据库路径
|
||||
self.db_path = Path(__file__).parent.parent / "plugins" / "MemberSync" / "data" / "member_sync.db"
|
||||
self._columns_cache = None
|
||||
self._initialized = True
|
||||
|
||||
logger.info(f"[MemberInfoService] 初始化,数据库路径: {self.db_path}")
|
||||
|
||||
async def _get_table_columns(self, db: aiosqlite.Connection) -> set:
|
||||
"""获取 group_members 表的列信息(带缓存)"""
|
||||
if self._columns_cache is not None:
|
||||
return self._columns_cache
|
||||
try:
|
||||
cursor = await db.execute("PRAGMA table_info(group_members)")
|
||||
rows = await cursor.fetchall()
|
||||
self._columns_cache = {row[1] for row in rows if row and len(row) > 1}
|
||||
except Exception:
|
||||
self._columns_cache = set()
|
||||
return self._columns_cache
|
||||
|
||||
async def get_member_info(self, wxid: str) -> Optional[Dict]:
|
||||
"""
|
||||
获取成员信息(昵称 + 头像 + 个性签名)
|
||||
|
||||
Args:
|
||||
wxid: 用户 wxid
|
||||
|
||||
Returns:
|
||||
成员信息字典 {"wxid": str, "nickname": str, "avatar_url": str}
|
||||
如果不存在返回 None
|
||||
"""
|
||||
if not self.db_path.exists():
|
||||
logger.debug(f"[MemberInfoService] 数据库不存在: {self.db_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
columns = await self._get_table_columns(db)
|
||||
fields = ["wxid", "nickname", "avatar_url"]
|
||||
if "signature" in columns:
|
||||
fields.append("signature")
|
||||
if "group_nickname" in columns:
|
||||
fields.append("group_nickname")
|
||||
cursor = await db.execute(
|
||||
f"SELECT {', '.join(fields)} FROM group_members WHERE wxid = ?",
|
||||
(wxid,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row:
|
||||
result = {
|
||||
"wxid": row[0],
|
||||
"nickname": row[1],
|
||||
"avatar_url": row[2] or ""
|
||||
}
|
||||
if "signature" in columns:
|
||||
result["signature"] = row[fields.index("signature")] or ""
|
||||
if "group_nickname" in columns:
|
||||
result["group_nickname"] = row[fields.index("group_nickname")] or ""
|
||||
logger.debug(f"[MemberInfoService] 数据库命中: {wxid} -> {result['nickname']}")
|
||||
return result
|
||||
else:
|
||||
logger.debug(f"[MemberInfoService] 数据库未找到: {wxid}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MemberInfoService] 查询失败: {e}")
|
||||
return None
|
||||
|
||||
async def get_member_nickname(self, wxid: str) -> Optional[str]:
|
||||
"""
|
||||
快速获取成员昵称
|
||||
|
||||
Args:
|
||||
wxid: 用户 wxid
|
||||
|
||||
Returns:
|
||||
昵称,如果不存在返回 None
|
||||
"""
|
||||
info = await self.get_member_info(wxid)
|
||||
return info["nickname"] if info else None
|
||||
|
||||
async def get_member_avatar(self, wxid: str) -> Optional[str]:
|
||||
"""
|
||||
快速获取成员头像 URL
|
||||
|
||||
Args:
|
||||
wxid: 用户 wxid
|
||||
|
||||
Returns:
|
||||
头像 URL,如果不存在返回 None
|
||||
"""
|
||||
info = await self.get_member_info(wxid)
|
||||
return info["avatar_url"] if info else None
|
||||
|
||||
async def get_member_signature(self, wxid: str) -> Optional[str]:
|
||||
"""快速获取成员个性签名"""
|
||||
info = await self.get_member_info(wxid)
|
||||
return info.get("signature") if info else None
|
||||
|
||||
async def get_chatroom_members(self, chatroom_wxid: str) -> List[Dict]:
|
||||
"""
|
||||
获取指定群聊的所有成员
|
||||
|
||||
Args:
|
||||
chatroom_wxid: 群聊 ID
|
||||
|
||||
Returns:
|
||||
成员信息列表
|
||||
"""
|
||||
if not self.db_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
columns = await self._get_table_columns(db)
|
||||
fields = ["wxid", "nickname", "avatar_url"]
|
||||
if "group_nickname" in columns:
|
||||
fields.append("group_nickname")
|
||||
if "signature" in columns:
|
||||
fields.append("signature")
|
||||
cursor = await db.execute(
|
||||
f"SELECT {', '.join(fields)} FROM group_members WHERE chatroom_wxid = ?",
|
||||
(chatroom_wxid,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
result = [
|
||||
{
|
||||
"wxid": row[0],
|
||||
"nickname": row[1],
|
||||
"avatar_url": row[2] or "",
|
||||
"group_nickname": row[fields.index("group_nickname")] or "" if "group_nickname" in columns else "",
|
||||
"signature": row[fields.index("signature")] or "" if "signature" in columns else "",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
logger.debug(f"[MemberInfoService] 获取群 {chatroom_wxid} 成员: {len(result)} 人")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MemberInfoService] 获取群成员失败: {e}")
|
||||
return []
|
||||
|
||||
async def get_chatroom_member_info(self, chatroom_wxid: str, wxid: str) -> Optional[Dict]:
|
||||
"""
|
||||
获取指定群聊中的成员信息
|
||||
|
||||
Args:
|
||||
chatroom_wxid: 群聊 ID
|
||||
wxid: 用户 wxid
|
||||
|
||||
Returns:
|
||||
成员信息字典 {"wxid": str, "nickname": str, "avatar_url": str}
|
||||
如果不存在返回 None
|
||||
"""
|
||||
if not self.db_path.exists():
|
||||
return None
|
||||
if not chatroom_wxid or not wxid:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
columns = await self._get_table_columns(db)
|
||||
fields = ["wxid", "nickname", "avatar_url"]
|
||||
if "group_nickname" in columns:
|
||||
fields.append("group_nickname")
|
||||
if "signature" in columns:
|
||||
fields.append("signature")
|
||||
cursor = await db.execute(
|
||||
f"""
|
||||
SELECT {', '.join(fields)}
|
||||
FROM group_members
|
||||
WHERE chatroom_wxid = ? AND wxid = ?
|
||||
""",
|
||||
(chatroom_wxid, wxid)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row:
|
||||
result = {
|
||||
"wxid": row[0],
|
||||
"nickname": row[1],
|
||||
"avatar_url": row[2] or "",
|
||||
}
|
||||
if "group_nickname" in columns:
|
||||
result["group_nickname"] = row[fields.index("group_nickname")] or ""
|
||||
if "signature" in columns:
|
||||
result["signature"] = row[fields.index("signature")] or ""
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"[MemberInfoService] 查询群成员失败: {e}")
|
||||
return None
|
||||
|
||||
async def get_chatroom_member_nickname(self, chatroom_wxid: str, wxid: str) -> Optional[str]:
|
||||
"""获取指定群聊中的成员昵称"""
|
||||
info = await self.get_chatroom_member_info(chatroom_wxid, wxid)
|
||||
return info["nickname"] if info else None
|
||||
|
||||
async def get_chatroom_member_avatar(self, chatroom_wxid: str, wxid: str) -> Optional[str]:
|
||||
"""获取指定群聊中的成员头像 URL"""
|
||||
info = await self.get_chatroom_member_info(chatroom_wxid, wxid)
|
||||
return info["avatar_url"] if info else None
|
||||
|
||||
async def get_chatroom_member_signature(self, chatroom_wxid: str, wxid: str) -> Optional[str]:
|
||||
"""获取指定群聊中的成员个性签名"""
|
||||
info = await self.get_chatroom_member_info(chatroom_wxid, wxid)
|
||||
return info.get("signature") if info else None
|
||||
|
||||
async def get_chatroom_member_wxids(self, chatroom_wxid: str) -> List[str]:
|
||||
"""
|
||||
获取指定群聊的所有成员 wxid 列表
|
||||
|
||||
Args:
|
||||
chatroom_wxid: 群聊 ID
|
||||
|
||||
Returns:
|
||||
wxid 列表
|
||||
"""
|
||||
if not self.db_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT wxid FROM group_members WHERE chatroom_wxid = ?",
|
||||
(chatroom_wxid,)
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [row[0] for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MemberInfoService] 获取群成员wxid列表失败: {e}")
|
||||
return []
|
||||
|
||||
async def get_all_members(self) -> List[Dict]:
|
||||
"""
|
||||
获取所有成员信息
|
||||
|
||||
Returns:
|
||||
成员信息列表
|
||||
"""
|
||||
if not self.db_path.exists():
|
||||
logger.debug(f"[MemberInfoService] 数据库不存在: {self.db_path}")
|
||||
return []
|
||||
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
columns = await self._get_table_columns(db)
|
||||
fields = ["wxid", "nickname", "avatar_url"]
|
||||
if "group_nickname" in columns:
|
||||
fields.append("group_nickname")
|
||||
if "signature" in columns:
|
||||
fields.append("signature")
|
||||
cursor = await db.execute(
|
||||
f"SELECT {', '.join(fields)} FROM group_members ORDER BY updated_at DESC"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
result = [
|
||||
{
|
||||
"wxid": row[0],
|
||||
"nickname": row[1],
|
||||
"avatar_url": row[2] or "",
|
||||
"group_nickname": row[fields.index("group_nickname")] or "" if "group_nickname" in columns else "",
|
||||
"signature": row[fields.index("signature")] or "" if "signature" in columns else "",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
logger.debug(f"[MemberInfoService] 获取所有成员: {len(result)} 人")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MemberInfoService] 查询所有成员失败: {e}")
|
||||
return []
|
||||
|
||||
async def get_members_by_wxids(self, wxids: List[str]) -> Dict[str, Dict]:
|
||||
"""
|
||||
批量获取成员信息
|
||||
|
||||
Args:
|
||||
wxids: wxid 列表
|
||||
|
||||
Returns:
|
||||
{wxid: {"nickname": str, "avatar_url": str}} 字典
|
||||
"""
|
||||
if not self.db_path.exists() or not wxids:
|
||||
return {}
|
||||
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
columns = await self._get_table_columns(db)
|
||||
fields = ["wxid", "nickname", "avatar_url"]
|
||||
if "group_nickname" in columns:
|
||||
fields.append("group_nickname")
|
||||
if "signature" in columns:
|
||||
fields.append("signature")
|
||||
# 构建 IN 查询
|
||||
placeholders = ",".join("?" * len(wxids))
|
||||
cursor = await db.execute(
|
||||
f"SELECT {', '.join(fields)} FROM group_members WHERE wxid IN ({placeholders})",
|
||||
wxids
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
result = {
|
||||
row[0]: {
|
||||
"nickname": row[1],
|
||||
"avatar_url": row[2] or "",
|
||||
"group_nickname": row[fields.index("group_nickname")] or "" if "group_nickname" in columns else "",
|
||||
"signature": row[fields.index("signature")] or "" if "signature" in columns else "",
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
|
||||
logger.debug(f"[MemberInfoService] 批量查询: 请求 {len(wxids)} 人,命中 {len(result)} 人")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MemberInfoService] 批量查询失败: {e}")
|
||||
return {}
|
||||
|
||||
async def check_member_exists(self, wxid: str) -> bool:
|
||||
"""
|
||||
检查成员是否存在于数据库
|
||||
|
||||
Args:
|
||||
wxid: 用户 wxid
|
||||
|
||||
Returns:
|
||||
是否存在
|
||||
"""
|
||||
if not self.db_path.exists():
|
||||
return False
|
||||
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
cursor = await db.execute(
|
||||
"SELECT 1 FROM group_members WHERE wxid = ? LIMIT 1",
|
||||
(wxid,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row is not None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[MemberInfoService] 检查成员存在失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# 全局单例
|
||||
_service_instance: Optional[MemberInfoService] = None
|
||||
|
||||
|
||||
def get_member_service() -> MemberInfoService:
|
||||
"""获取全局单例"""
|
||||
global _service_instance
|
||||
if _service_instance is None:
|
||||
_service_instance = MemberInfoService()
|
||||
return _service_instance
|
||||
|
||||
|
||||
# 便捷函数
|
||||
async def get_member_info(wxid: str) -> Optional[Dict]:
|
||||
"""便捷函数:获取成员信息"""
|
||||
return await get_member_service().get_member_info(wxid)
|
||||
|
||||
|
||||
async def get_member_nickname(wxid: str) -> Optional[str]:
|
||||
"""便捷函数:获取成员昵称"""
|
||||
return await get_member_service().get_member_nickname(wxid)
|
||||
|
||||
|
||||
async def get_member_avatar(wxid: str) -> Optional[str]:
|
||||
"""便捷函数:获取成员头像"""
|
||||
return await get_member_service().get_member_avatar(wxid)
|
||||
|
||||
|
||||
async def get_member_signature(wxid: str) -> Optional[str]:
|
||||
"""便捷函数:获取成员个性签名"""
|
||||
return await get_member_service().get_member_signature(wxid)
|
||||
@@ -1,145 +1,153 @@
|
||||
"""
|
||||
消息去重器模块
|
||||
|
||||
防止同一条消息被重复处理(某些环境下回调会重复触发)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class MessageDeduplicator:
|
||||
"""
|
||||
消息去重器
|
||||
|
||||
使用基于时间的滑动窗口实现去重:
|
||||
- 记录最近处理的消息 ID
|
||||
- 在 TTL 时间内重复的消息会被过滤
|
||||
- 自动清理过期记录,限制内存占用
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ttl_seconds: float = 30.0,
|
||||
max_size: int = 5000,
|
||||
):
|
||||
"""
|
||||
初始化去重器
|
||||
|
||||
Args:
|
||||
ttl_seconds: 消息 ID 的有效期(秒),0 表示禁用去重
|
||||
max_size: 最大缓存条目数,防止内存泄漏
|
||||
"""
|
||||
self.ttl_seconds = max(float(ttl_seconds), 0.0)
|
||||
self.max_size = max(int(max_size), 0)
|
||||
self._cache: Dict[str, float] = {} # key -> timestamp
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@staticmethod
|
||||
"""
|
||||
消息去重器模块
|
||||
|
||||
防止同一条消息被重复处理(某些环境下回调会重复触发)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class MessageDeduplicator:
|
||||
"""
|
||||
消息去重器
|
||||
|
||||
使用基于时间的滑动窗口实现去重:
|
||||
- 记录最近处理的消息 ID
|
||||
- 在 TTL 时间内重复的消息会被过滤
|
||||
- 自动清理过期记录,限制内存占用
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ttl_seconds: float = 30.0,
|
||||
max_size: int = 5000,
|
||||
):
|
||||
"""
|
||||
初始化去重器
|
||||
|
||||
Args:
|
||||
ttl_seconds: 消息 ID 的有效期(秒),0 表示禁用去重
|
||||
max_size: 最大缓存条目数,防止内存泄漏
|
||||
"""
|
||||
self.ttl_seconds = max(float(ttl_seconds), 0.0)
|
||||
self.max_size = max(int(max_size), 0)
|
||||
self._cache: Dict[str, float] = {} # key -> timestamp
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@staticmethod
|
||||
def extract_msg_id(data: Dict[str, Any]) -> str:
|
||||
"""
|
||||
从原始消息数据中提取消息 ID
|
||||
|
||||
Args:
|
||||
data: 原始消息数据
|
||||
|
||||
Returns:
|
||||
消息 ID 字符串,提取失败返回空字符串
|
||||
"""
|
||||
for key in ("msgid", "msg_id", "MsgId", "id"):
|
||||
"""
|
||||
从原始消息数据中提取消息 ID
|
||||
|
||||
Args:
|
||||
data: 原始消息数据
|
||||
|
||||
Returns:
|
||||
消息 ID 字符串,提取失败返回空字符串
|
||||
"""
|
||||
for key in (
|
||||
"newMsgId",
|
||||
"new_msg_id",
|
||||
"msgId",
|
||||
"msgid",
|
||||
"msg_id",
|
||||
"MsgId",
|
||||
"id",
|
||||
):
|
||||
value = data.get(key)
|
||||
if value:
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
async def is_duplicate(self, data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
检查消息是否重复
|
||||
|
||||
Args:
|
||||
data: 原始消息数据
|
||||
|
||||
Returns:
|
||||
True 表示是重复消息,False 表示是新消息
|
||||
"""
|
||||
if self.ttl_seconds <= 0:
|
||||
return False
|
||||
|
||||
msg_id = self.extract_msg_id(data)
|
||||
if not msg_id:
|
||||
# 没有消息 ID 时不做去重,避免误判
|
||||
return False
|
||||
|
||||
key = f"msgid:{msg_id}"
|
||||
now = time.time()
|
||||
|
||||
async with self._lock:
|
||||
# 检查是否存在且未过期
|
||||
last_seen = self._cache.get(key)
|
||||
if last_seen is not None and (now - last_seen) < self.ttl_seconds:
|
||||
return True
|
||||
|
||||
# 记录新消息
|
||||
self._cache.pop(key, None) # 确保插入到末尾(保持顺序)
|
||||
self._cache[key] = now
|
||||
|
||||
# 清理过期条目
|
||||
self._cleanup_expired(now)
|
||||
|
||||
# 限制大小
|
||||
self._limit_size()
|
||||
|
||||
return False
|
||||
|
||||
def _cleanup_expired(self, now: float):
|
||||
"""清理过期条目(需在锁内调用)"""
|
||||
cutoff = now - self.ttl_seconds
|
||||
while self._cache:
|
||||
first_key = next(iter(self._cache))
|
||||
if self._cache[first_key] >= cutoff:
|
||||
break
|
||||
self._cache.pop(first_key, None)
|
||||
|
||||
def _limit_size(self):
|
||||
"""限制缓存大小(需在锁内调用)"""
|
||||
if self.max_size <= 0:
|
||||
return
|
||||
while len(self._cache) > self.max_size:
|
||||
first_key = next(iter(self._cache))
|
||||
self._cache.pop(first_key, None)
|
||||
|
||||
def clear(self):
|
||||
"""清空缓存"""
|
||||
self._cache.clear()
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""获取统计信息"""
|
||||
return {
|
||||
"cached_count": len(self._cache),
|
||||
"ttl_seconds": self.ttl_seconds,
|
||||
"max_size": self.max_size,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, perf_config: Dict[str, Any]) -> "MessageDeduplicator":
|
||||
"""
|
||||
从配置创建去重器
|
||||
|
||||
Args:
|
||||
perf_config: Performance 配置节
|
||||
|
||||
Returns:
|
||||
MessageDeduplicator 实例
|
||||
"""
|
||||
return cls(
|
||||
ttl_seconds=perf_config.get("dedup_ttl_seconds", 30),
|
||||
max_size=perf_config.get("dedup_max_size", 5000),
|
||||
)
|
||||
|
||||
|
||||
# ==================== 导出 ====================
|
||||
|
||||
__all__ = ['MessageDeduplicator']
|
||||
|
||||
async def is_duplicate(self, data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
检查消息是否重复
|
||||
|
||||
Args:
|
||||
data: 原始消息数据
|
||||
|
||||
Returns:
|
||||
True 表示是重复消息,False 表示是新消息
|
||||
"""
|
||||
if self.ttl_seconds <= 0:
|
||||
return False
|
||||
|
||||
msg_id = self.extract_msg_id(data)
|
||||
if not msg_id:
|
||||
# 没有消息 ID 时不做去重,避免误判
|
||||
return False
|
||||
|
||||
key = f"msgid:{msg_id}"
|
||||
now = time.time()
|
||||
|
||||
async with self._lock:
|
||||
# 检查是否存在且未过期
|
||||
last_seen = self._cache.get(key)
|
||||
if last_seen is not None and (now - last_seen) < self.ttl_seconds:
|
||||
return True
|
||||
|
||||
# 记录新消息
|
||||
self._cache.pop(key, None) # 确保插入到末尾(保持顺序)
|
||||
self._cache[key] = now
|
||||
|
||||
# 清理过期条目
|
||||
self._cleanup_expired(now)
|
||||
|
||||
# 限制大小
|
||||
self._limit_size()
|
||||
|
||||
return False
|
||||
|
||||
def _cleanup_expired(self, now: float):
|
||||
"""清理过期条目(需在锁内调用)"""
|
||||
cutoff = now - self.ttl_seconds
|
||||
while self._cache:
|
||||
first_key = next(iter(self._cache))
|
||||
if self._cache[first_key] >= cutoff:
|
||||
break
|
||||
self._cache.pop(first_key, None)
|
||||
|
||||
def _limit_size(self):
|
||||
"""限制缓存大小(需在锁内调用)"""
|
||||
if self.max_size <= 0:
|
||||
return
|
||||
while len(self._cache) > self.max_size:
|
||||
first_key = next(iter(self._cache))
|
||||
self._cache.pop(first_key, None)
|
||||
|
||||
def clear(self):
|
||||
"""清空缓存"""
|
||||
self._cache.clear()
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""获取统计信息"""
|
||||
return {
|
||||
"cached_count": len(self._cache),
|
||||
"ttl_seconds": self.ttl_seconds,
|
||||
"max_size": self.max_size,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, perf_config: Dict[str, Any]) -> "MessageDeduplicator":
|
||||
"""
|
||||
从配置创建去重器
|
||||
|
||||
Args:
|
||||
perf_config: Performance 配置节
|
||||
|
||||
Returns:
|
||||
MessageDeduplicator 实例
|
||||
"""
|
||||
return cls(
|
||||
ttl_seconds=perf_config.get("dedup_ttl_seconds", 30),
|
||||
max_size=perf_config.get("dedup_max_size", 5000),
|
||||
)
|
||||
|
||||
|
||||
# ==================== 导出 ====================
|
||||
|
||||
__all__ = ['MessageDeduplicator']
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
"""
|
||||
消息发送钩子工具
|
||||
|
||||
用于自动记录机器人发送的消息到 MessageLogger
|
||||
"""
|
||||
|
||||
"""
|
||||
消息发送钩子工具
|
||||
|
||||
用于自动记录机器人发送的消息到 MessageLogger
|
||||
"""
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from utils.config_manager import get_config
|
||||
|
||||
|
||||
async def log_bot_message(to_wxid: str, content: str, msg_type: str = "text", media_url: str = ""):
|
||||
"""
|
||||
记录机器人发送的消息到 MessageLogger
|
||||
|
||||
Args:
|
||||
to_wxid: 接收者微信ID
|
||||
content: 消息内容
|
||||
msg_type: 消息类型 (text/image/video/file等)
|
||||
media_url: 媒体文件URL (可选)
|
||||
"""
|
||||
"""
|
||||
记录机器人发送的消息到 MessageLogger
|
||||
|
||||
Args:
|
||||
to_wxid: 接收者微信ID
|
||||
content: 消息内容
|
||||
msg_type: 消息类型 (text/image/video/file等)
|
||||
media_url: 媒体文件URL (可选)
|
||||
"""
|
||||
try:
|
||||
logger.info(f"message_hook: 开始记录机器人消息")
|
||||
|
||||
# 动态导入避免循环依赖
|
||||
from plugins.MessageLogger.main import MessageLogger
|
||||
logger.info(f"message_hook: MessageLogger 导入成功")
|
||||
|
||||
# 获取 MessageLogger 实例
|
||||
message_logger = MessageLogger.get_instance()
|
||||
logger.info(f"message_hook: MessageLogger 实例: {message_logger}")
|
||||
|
||||
|
||||
# 获取 MessageLogger 实例
|
||||
message_logger = MessageLogger.get_instance()
|
||||
logger.info(f"message_hook: MessageLogger 实例: {message_logger}")
|
||||
|
||||
if message_logger:
|
||||
logger.info(f"message_hook: 调用 save_bot_message")
|
||||
await message_logger.save_bot_message(to_wxid, content, msg_type, media_url)
|
||||
@@ -48,11 +50,9 @@ async def log_bot_message(to_wxid: str, content: str, msg_type: str = "text", me
|
||||
bot_nickname = "机器人"
|
||||
bot_wxid = ""
|
||||
try:
|
||||
import tomllib
|
||||
with open("main_config.toml", "rb") as f:
|
||||
main_config = tomllib.load(f)
|
||||
bot_nickname = main_config.get("Bot", {}).get("nickname") or bot_nickname
|
||||
bot_wxid = main_config.get("Bot", {}).get("wxid") or ""
|
||||
bot_config = get_config().get_section("Bot")
|
||||
bot_nickname = bot_config.get("nickname") or bot_nickname
|
||||
bot_wxid = bot_config.get("wxid") or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -78,50 +78,50 @@ async def log_bot_message(to_wxid: str, content: str, msg_type: str = "text", me
|
||||
logger.error(f"记录机器人消息失败: {e}")
|
||||
import traceback
|
||||
logger.error(f"详细错误: {traceback.format_exc()}")
|
||||
|
||||
|
||||
def create_message_hook(original_method):
|
||||
"""
|
||||
创建消息发送钩子装饰器
|
||||
|
||||
Args:
|
||||
original_method: 原始的发送消息方法
|
||||
|
||||
Returns:
|
||||
包装后的方法
|
||||
"""
|
||||
async def wrapper(self, to_wxid: str, content: str, *args, **kwargs):
|
||||
# 调用原始方法
|
||||
result = await original_method(self, to_wxid, content, *args, **kwargs)
|
||||
|
||||
# 记录消息
|
||||
await log_bot_message(to_wxid, content, "text")
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def create_file_message_hook(original_method, msg_type: str):
|
||||
"""
|
||||
创建文件消息发送钩子装饰器
|
||||
|
||||
Args:
|
||||
original_method: 原始的发送文件方法
|
||||
msg_type: 消息类型
|
||||
|
||||
Returns:
|
||||
包装后的方法
|
||||
"""
|
||||
async def wrapper(self, to_wxid: str, file_path: str, *args, **kwargs):
|
||||
# 调用原始方法
|
||||
result = await original_method(self, to_wxid, file_path, *args, **kwargs)
|
||||
|
||||
# 记录消息
|
||||
import os
|
||||
filename = os.path.basename(file_path)
|
||||
await log_bot_message(to_wxid, f"[{msg_type}] {filename}", msg_type, file_path)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
def create_message_hook(original_method):
|
||||
"""
|
||||
创建消息发送钩子装饰器
|
||||
|
||||
Args:
|
||||
original_method: 原始的发送消息方法
|
||||
|
||||
Returns:
|
||||
包装后的方法
|
||||
"""
|
||||
async def wrapper(self, to_wxid: str, content: str, *args, **kwargs):
|
||||
# 调用原始方法
|
||||
result = await original_method(self, to_wxid, content, *args, **kwargs)
|
||||
|
||||
# 记录消息
|
||||
await log_bot_message(to_wxid, content, "text")
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def create_file_message_hook(original_method, msg_type: str):
|
||||
"""
|
||||
创建文件消息发送钩子装饰器
|
||||
|
||||
Args:
|
||||
original_method: 原始的发送文件方法
|
||||
msg_type: 消息类型
|
||||
|
||||
Returns:
|
||||
包装后的方法
|
||||
"""
|
||||
async def wrapper(self, to_wxid: str, file_path: str, *args, **kwargs):
|
||||
# 调用原始方法
|
||||
result = await original_method(self, to_wxid, file_path, *args, **kwargs)
|
||||
|
||||
# 记录消息
|
||||
import os
|
||||
filename = os.path.basename(file_path)
|
||||
await log_bot_message(to_wxid, f"[{msg_type}] {filename}", msg_type, file_path)
|
||||
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -1,151 +1,190 @@
|
||||
"""
|
||||
消息队列模块
|
||||
|
||||
提供高性能的优先级消息队列,支持多种溢出策略:
|
||||
- drop_oldest: 丢弃最旧的消息
|
||||
- drop_lowest: 丢弃优先级最低的消息
|
||||
- sampling: 按采样率丢弃消息
|
||||
- reject: 拒绝新消息
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import heapq
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
# ==================== 消息优先级常量 ====================
|
||||
|
||||
class MessagePriority:
|
||||
"""消息优先级常量"""
|
||||
CRITICAL = 100 # 系统消息、登录信息
|
||||
HIGH = 80 # 管理员命令、群成员变动
|
||||
NORMAL = 50 # @bot 消息(默认)
|
||||
LOW = 20 # 普通群消息
|
||||
|
||||
|
||||
# ==================== 溢出策略 ====================
|
||||
|
||||
"""
|
||||
消息队列模块
|
||||
|
||||
提供高性能的优先级消息队列,支持多种溢出策略:
|
||||
- drop_oldest: 丢弃最旧的消息
|
||||
- drop_lowest: 丢弃优先级最低的消息
|
||||
- sampling: 按采样率丢弃消息
|
||||
- reject: 拒绝新消息
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import heapq
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
||||
# ==================== 消息优先级常量 ====================
|
||||
|
||||
class MessagePriority:
|
||||
"""消息优先级常量"""
|
||||
CRITICAL = 100 # 系统消息、登录信息
|
||||
HIGH = 80 # 管理员命令、群成员变动
|
||||
NORMAL = 50 # @bot 消息(默认)
|
||||
LOW = 20 # 普通群消息
|
||||
|
||||
|
||||
# ==================== 溢出策略 ====================
|
||||
|
||||
class OverflowStrategy(Enum):
|
||||
"""队列溢出策略"""
|
||||
DROP_OLDEST = "drop_oldest" # 丢弃最旧的消息
|
||||
DROP_LOWEST = "drop_lowest" # 丢弃优先级最低的消息
|
||||
SAMPLING = "sampling" # 按采样率丢弃
|
||||
REJECT = "reject" # 拒绝新消息
|
||||
DEGRADE = "degrade" # 降级:丢弃低优先级新消息
|
||||
|
||||
|
||||
# ==================== 优先级消息 ====================
|
||||
|
||||
@dataclass(order=True)
|
||||
class PriorityMessage:
|
||||
"""优先级消息"""
|
||||
priority: int = field(compare=True)
|
||||
timestamp: float = field(compare=True)
|
||||
msg_type: int = field(compare=False)
|
||||
data: Dict[str, Any] = field(compare=False)
|
||||
|
||||
def __init__(self, msg_type: int, data: Dict[str, Any], priority: int = None):
|
||||
# 优先级越高,数值越大,但 heapq 是最小堆,所以取负数
|
||||
self.priority = -(priority if priority is not None else MessagePriority.NORMAL)
|
||||
self.timestamp = time.time()
|
||||
self.msg_type = msg_type
|
||||
self.data = data
|
||||
|
||||
|
||||
# ==================== 优先级消息队列 ====================
|
||||
|
||||
class PriorityMessageQueue:
|
||||
"""
|
||||
优先级消息队列
|
||||
|
||||
特性:
|
||||
- 基于堆的优先级队列
|
||||
- 支持多种溢出策略
|
||||
- 线程安全(使用 asyncio.Lock)
|
||||
- 支持任务计数和 join
|
||||
"""
|
||||
|
||||
|
||||
# ==================== 优先级消息 ====================
|
||||
|
||||
@dataclass(order=True)
|
||||
class PriorityMessage:
|
||||
"""优先级消息"""
|
||||
priority: int = field(compare=True)
|
||||
timestamp: float = field(compare=True)
|
||||
msg_type: int = field(compare=False)
|
||||
data: Dict[str, Any] = field(compare=False)
|
||||
|
||||
def __init__(self, msg_type: int, data: Dict[str, Any], priority: int = None):
|
||||
# 优先级越高,数值越大,但 heapq 是最小堆,所以取负数
|
||||
self.priority = -(priority if priority is not None else MessagePriority.NORMAL)
|
||||
self.timestamp = time.time()
|
||||
self.msg_type = msg_type
|
||||
self.data = data
|
||||
|
||||
|
||||
# ==================== 优先级消息队列 ====================
|
||||
|
||||
class PriorityMessageQueue:
|
||||
"""
|
||||
优先级消息队列
|
||||
|
||||
特性:
|
||||
- 基于堆的优先级队列
|
||||
- 支持多种溢出策略
|
||||
- 线程安全(使用 asyncio.Lock)
|
||||
- 支持任务计数和 join
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
maxsize: int = 1000,
|
||||
overflow_strategy: str = "drop_oldest",
|
||||
sampling_rate: float = 0.5,
|
||||
):
|
||||
"""
|
||||
初始化队列
|
||||
|
||||
Args:
|
||||
maxsize: 最大队列大小
|
||||
overflow_strategy: 溢出策略 (drop_oldest, drop_lowest, sampling, reject)
|
||||
sampling_rate: 采样策略的保留率 (0.0-1.0)
|
||||
"""
|
||||
self.maxsize = maxsize
|
||||
self.overflow_strategy = OverflowStrategy(overflow_strategy)
|
||||
):
|
||||
"""
|
||||
初始化队列
|
||||
|
||||
Args:
|
||||
maxsize: 最大队列大小
|
||||
overflow_strategy: 溢出策略 (drop_oldest, drop_lowest, sampling, reject)
|
||||
sampling_rate: 采样策略的保留率 (0.0-1.0)
|
||||
"""
|
||||
self.maxsize = maxsize
|
||||
if isinstance(overflow_strategy, OverflowStrategy):
|
||||
self.overflow_strategy = overflow_strategy
|
||||
else:
|
||||
self.overflow_strategy = OverflowStrategy(overflow_strategy)
|
||||
self.sampling_rate = max(0.0, min(1.0, sampling_rate))
|
||||
|
||||
self._heap: List[PriorityMessage] = []
|
||||
self._lock = asyncio.Lock()
|
||||
self._not_empty = asyncio.Event()
|
||||
self._unfinished_tasks = 0
|
||||
self._finished = asyncio.Event()
|
||||
self._finished.set()
|
||||
|
||||
# 统计
|
||||
self._total_put = 0
|
||||
self._total_dropped = 0
|
||||
self._total_rejected = 0
|
||||
|
||||
def qsize(self) -> int:
|
||||
"""返回队列大小"""
|
||||
return len(self._heap)
|
||||
|
||||
def empty(self) -> bool:
|
||||
"""队列是否为空"""
|
||||
return len(self._heap) == 0
|
||||
|
||||
def full(self) -> bool:
|
||||
"""队列是否已满"""
|
||||
return len(self._heap) >= self.maxsize
|
||||
|
||||
|
||||
self._heap: List[PriorityMessage] = []
|
||||
self._lock = asyncio.Lock()
|
||||
self._not_empty = asyncio.Event()
|
||||
self._unfinished_tasks = 0
|
||||
self._finished = asyncio.Event()
|
||||
self._finished.set()
|
||||
|
||||
# 统计
|
||||
self._total_put = 0
|
||||
self._total_dropped = 0
|
||||
self._total_rejected = 0
|
||||
|
||||
def qsize(self) -> int:
|
||||
"""返回队列大小"""
|
||||
return len(self._heap)
|
||||
|
||||
def empty(self) -> bool:
|
||||
"""队列是否为空"""
|
||||
return len(self._heap) == 0
|
||||
|
||||
def full(self) -> bool:
|
||||
"""队列是否已满"""
|
||||
return len(self._heap) >= self.maxsize
|
||||
|
||||
async def put(
|
||||
self,
|
||||
msg_type: int,
|
||||
data: Dict[str, Any],
|
||||
priority: int = None,
|
||||
) -> bool:
|
||||
"""
|
||||
添加消息到队列
|
||||
|
||||
Args:
|
||||
msg_type: 消息类型
|
||||
data: 消息数据
|
||||
priority: 优先级(可选)
|
||||
|
||||
Returns:
|
||||
是否成功添加
|
||||
"""
|
||||
) -> bool:
|
||||
"""
|
||||
添加消息到队列
|
||||
|
||||
Args:
|
||||
msg_type: 消息类型
|
||||
data: 消息数据
|
||||
priority: 优先级(可选)
|
||||
|
||||
Returns:
|
||||
是否成功添加
|
||||
"""
|
||||
async with self._lock:
|
||||
self._total_put += 1
|
||||
|
||||
# 处理队列满的情况
|
||||
if self.full():
|
||||
if not self._handle_overflow():
|
||||
effective_priority = priority if priority is not None else MessagePriority.NORMAL
|
||||
if not self._handle_overflow(effective_priority):
|
||||
self._total_rejected += 1
|
||||
return False
|
||||
|
||||
msg = PriorityMessage(msg_type, data, priority)
|
||||
msg = PriorityMessage(msg_type, data, priority if priority is not None else MessagePriority.NORMAL)
|
||||
heapq.heappush(self._heap, msg)
|
||||
self._unfinished_tasks += 1
|
||||
self._finished.clear()
|
||||
self._not_empty.set()
|
||||
return True
|
||||
|
||||
def _handle_overflow(self) -> bool:
|
||||
def _drop_oldest(self) -> bool:
|
||||
if not self._heap:
|
||||
return False
|
||||
oldest_idx = 0
|
||||
for i, msg in enumerate(self._heap):
|
||||
if msg.timestamp < self._heap[oldest_idx].timestamp:
|
||||
oldest_idx = i
|
||||
self._heap.pop(oldest_idx)
|
||||
heapq.heapify(self._heap)
|
||||
self._total_dropped += 1
|
||||
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
|
||||
if not self._heap:
|
||||
self._not_empty.clear()
|
||||
if self._unfinished_tasks == 0:
|
||||
self._finished.set()
|
||||
return True
|
||||
|
||||
def _drop_lowest(self) -> bool:
|
||||
if not self._heap:
|
||||
return False
|
||||
lowest_idx = 0
|
||||
for i, msg in enumerate(self._heap):
|
||||
if msg.priority > self._heap[lowest_idx].priority:
|
||||
lowest_idx = i
|
||||
self._heap.pop(lowest_idx)
|
||||
heapq.heapify(self._heap)
|
||||
self._total_dropped += 1
|
||||
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
|
||||
if not self._heap:
|
||||
self._not_empty.clear()
|
||||
if self._unfinished_tasks == 0:
|
||||
self._finished.set()
|
||||
return True
|
||||
|
||||
def _handle_overflow(self, incoming_priority: int) -> bool:
|
||||
"""
|
||||
处理队列溢出
|
||||
|
||||
@@ -158,105 +197,84 @@ class PriorityMessageQueue:
|
||||
|
||||
if self.overflow_strategy == OverflowStrategy.DROP_OLDEST:
|
||||
# 找到最旧的消息(timestamp 最小)
|
||||
if self._heap:
|
||||
oldest_idx = 0
|
||||
for i, msg in enumerate(self._heap):
|
||||
if msg.timestamp < self._heap[oldest_idx].timestamp:
|
||||
oldest_idx = i
|
||||
self._heap.pop(oldest_idx)
|
||||
heapq.heapify(self._heap)
|
||||
self._total_dropped += 1
|
||||
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
|
||||
return True
|
||||
return self._drop_oldest()
|
||||
|
||||
elif self.overflow_strategy == OverflowStrategy.DROP_LOWEST:
|
||||
# 找到优先级最低的消息(priority 值最大,因为是负数)
|
||||
if self._heap:
|
||||
lowest_idx = 0
|
||||
for i, msg in enumerate(self._heap):
|
||||
if msg.priority > self._heap[lowest_idx].priority:
|
||||
lowest_idx = i
|
||||
self._heap.pop(lowest_idx)
|
||||
heapq.heapify(self._heap)
|
||||
self._total_dropped += 1
|
||||
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
|
||||
return True
|
||||
return self._drop_lowest()
|
||||
|
||||
elif self.overflow_strategy == OverflowStrategy.SAMPLING:
|
||||
# 按采样率决定是否接受
|
||||
if random.random() < self.sampling_rate:
|
||||
# 接受新消息,丢弃最旧的
|
||||
if self._heap:
|
||||
oldest_idx = 0
|
||||
for i, msg in enumerate(self._heap):
|
||||
if msg.timestamp < self._heap[oldest_idx].timestamp:
|
||||
oldest_idx = i
|
||||
self._heap.pop(oldest_idx)
|
||||
heapq.heapify(self._heap)
|
||||
self._total_dropped += 1
|
||||
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
|
||||
return True
|
||||
return self._drop_oldest()
|
||||
else:
|
||||
self._total_dropped += 1
|
||||
return False
|
||||
|
||||
elif self.overflow_strategy == OverflowStrategy.DEGRADE:
|
||||
if incoming_priority < MessagePriority.HIGH:
|
||||
self._total_dropped += 1
|
||||
return False
|
||||
return self._drop_lowest()
|
||||
|
||||
return False
|
||||
|
||||
async def get(self, timeout: float = None) -> Tuple[int, Dict[str, Any]]:
|
||||
"""
|
||||
获取优先级最高的消息
|
||||
|
||||
Args:
|
||||
timeout: 超时时间(秒),None 表示无限等待
|
||||
|
||||
Returns:
|
||||
(msg_type, data) 元组
|
||||
|
||||
Raises:
|
||||
asyncio.TimeoutError: 超时
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
async with self._lock:
|
||||
if self._heap:
|
||||
msg = heapq.heappop(self._heap)
|
||||
if not self._heap:
|
||||
self._not_empty.clear()
|
||||
return (msg.msg_type, msg.data)
|
||||
|
||||
# 计算剩余超时时间
|
||||
if timeout is not None:
|
||||
elapsed = time.time() - start_time
|
||||
remaining = timeout - elapsed
|
||||
if remaining <= 0:
|
||||
raise asyncio.TimeoutError("Queue get timeout")
|
||||
try:
|
||||
await asyncio.wait_for(self._not_empty.wait(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
raise asyncio.TimeoutError("Queue get timeout")
|
||||
else:
|
||||
await self._not_empty.wait()
|
||||
|
||||
def get_nowait(self) -> Tuple[int, Dict[str, Any]]:
|
||||
"""非阻塞获取消息"""
|
||||
if not self._heap:
|
||||
raise asyncio.QueueEmpty()
|
||||
msg = heapq.heappop(self._heap)
|
||||
if not self._heap:
|
||||
self._not_empty.clear()
|
||||
return (msg.msg_type, msg.data)
|
||||
|
||||
def task_done(self):
|
||||
"""标记任务完成"""
|
||||
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
|
||||
if self._unfinished_tasks == 0:
|
||||
self._finished.set()
|
||||
|
||||
async def join(self):
|
||||
"""等待所有任务完成"""
|
||||
await self._finished.wait()
|
||||
|
||||
|
||||
async def get(self, timeout: float = None) -> Tuple[int, Dict[str, Any]]:
|
||||
"""
|
||||
获取优先级最高的消息
|
||||
|
||||
Args:
|
||||
timeout: 超时时间(秒),None 表示无限等待
|
||||
|
||||
Returns:
|
||||
(msg_type, data) 元组
|
||||
|
||||
Raises:
|
||||
asyncio.TimeoutError: 超时
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
async with self._lock:
|
||||
if self._heap:
|
||||
msg = heapq.heappop(self._heap)
|
||||
if not self._heap:
|
||||
self._not_empty.clear()
|
||||
return (msg.msg_type, msg.data)
|
||||
|
||||
# 计算剩余超时时间
|
||||
if timeout is not None:
|
||||
elapsed = time.time() - start_time
|
||||
remaining = timeout - elapsed
|
||||
if remaining <= 0:
|
||||
raise asyncio.TimeoutError("Queue get timeout")
|
||||
try:
|
||||
await asyncio.wait_for(self._not_empty.wait(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
raise asyncio.TimeoutError("Queue get timeout")
|
||||
else:
|
||||
await self._not_empty.wait()
|
||||
|
||||
def get_nowait(self) -> Tuple[int, Dict[str, Any]]:
|
||||
"""非阻塞获取消息"""
|
||||
if not self._heap:
|
||||
raise asyncio.QueueEmpty()
|
||||
msg = heapq.heappop(self._heap)
|
||||
if not self._heap:
|
||||
self._not_empty.clear()
|
||||
return (msg.msg_type, msg.data)
|
||||
|
||||
def task_done(self):
|
||||
"""标记任务完成"""
|
||||
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
|
||||
if self._unfinished_tasks == 0:
|
||||
self._finished.set()
|
||||
|
||||
async def join(self):
|
||||
"""等待所有任务完成"""
|
||||
await self._finished.wait()
|
||||
|
||||
def clear(self):
|
||||
"""清空队列"""
|
||||
self._heap.clear()
|
||||
@@ -264,42 +282,84 @@ class PriorityMessageQueue:
|
||||
self._unfinished_tasks = 0
|
||||
self._finished.set()
|
||||
|
||||
async def update_config(
|
||||
self,
|
||||
maxsize: Optional[int] = None,
|
||||
overflow_strategy: Optional[str] = None,
|
||||
sampling_rate: Optional[float] = None,
|
||||
):
|
||||
"""运行中更新队列配置。"""
|
||||
async with self._lock:
|
||||
if maxsize is not None:
|
||||
self.maxsize = max(int(maxsize), 1)
|
||||
|
||||
if overflow_strategy is not None:
|
||||
if isinstance(overflow_strategy, OverflowStrategy):
|
||||
self.overflow_strategy = overflow_strategy
|
||||
else:
|
||||
try:
|
||||
self.overflow_strategy = OverflowStrategy(overflow_strategy)
|
||||
except ValueError:
|
||||
logger.warning(f"忽略无效的队列溢出策略: {overflow_strategy}")
|
||||
|
||||
if sampling_rate is not None:
|
||||
self.sampling_rate = max(0.0, min(1.0, float(sampling_rate)))
|
||||
|
||||
while len(self._heap) > self.maxsize:
|
||||
self._drop_oldest()
|
||||
|
||||
if not self._heap:
|
||||
self._not_empty.clear()
|
||||
else:
|
||||
self._not_empty.set()
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""获取队列统计信息"""
|
||||
return {
|
||||
"current_size": len(self._heap),
|
||||
"max_size": self.maxsize,
|
||||
"total_put": self._total_put,
|
||||
"total_dropped": self._total_dropped,
|
||||
"total_rejected": self._total_rejected,
|
||||
"unfinished_tasks": self._unfinished_tasks,
|
||||
"overflow_strategy": self.overflow_strategy.value,
|
||||
"max_size": self.maxsize,
|
||||
"total_put": self._total_put,
|
||||
"total_dropped": self._total_dropped,
|
||||
"total_rejected": self._total_rejected,
|
||||
"unfinished_tasks": self._unfinished_tasks,
|
||||
"overflow_strategy": self.overflow_strategy.value,
|
||||
"utilization": len(self._heap) / max(self.maxsize, 1),
|
||||
}
|
||||
|
||||
def drop_lowest_priority(self) -> bool:
|
||||
"""兼容接口:丢弃优先级最低的消息"""
|
||||
return self._drop_lowest()
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, queue_config: Dict[str, Any]) -> "PriorityMessageQueue":
|
||||
"""
|
||||
从配置创建队列
|
||||
|
||||
Args:
|
||||
queue_config: Queue 配置节
|
||||
|
||||
|
||||
Args:
|
||||
queue_config: Queue 配置节
|
||||
|
||||
Returns:
|
||||
PriorityMessageQueue 实例
|
||||
"""
|
||||
overflow_strategy = queue_config.get("overflow_strategy", "drop_oldest")
|
||||
try:
|
||||
overflow_strategy = OverflowStrategy(overflow_strategy)
|
||||
except ValueError:
|
||||
logger.warning(f"无效的队列溢出策略: {overflow_strategy},将使用 drop_oldest")
|
||||
overflow_strategy = OverflowStrategy.DROP_OLDEST
|
||||
|
||||
return cls(
|
||||
maxsize=queue_config.get("max_size", 1000),
|
||||
overflow_strategy=queue_config.get("overflow_strategy", "drop_oldest"),
|
||||
overflow_strategy=overflow_strategy,
|
||||
sampling_rate=queue_config.get("sampling_rate", 0.5),
|
||||
)
|
||||
|
||||
|
||||
# ==================== 导出 ====================
|
||||
|
||||
__all__ = [
|
||||
'MessagePriority',
|
||||
'OverflowStrategy',
|
||||
'PriorityMessage',
|
||||
'PriorityMessageQueue',
|
||||
]
|
||||
|
||||
|
||||
# ==================== 导出 ====================
|
||||
|
||||
__all__ = [
|
||||
'MessagePriority',
|
||||
'OverflowStrategy',
|
||||
'PriorityMessage',
|
||||
'PriorityMessageQueue',
|
||||
]
|
||||
|
||||
48
utils/operation_lock.py
Normal file
48
utils/operation_lock.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
全局操作锁
|
||||
|
||||
用于在关键操作期间暂停消息处理(例如全量成员同步)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class OperationLock:
|
||||
"""全局暂停/恢复控制"""
|
||||
|
||||
_paused: bool = False
|
||||
_reason: str = ""
|
||||
_pause_event: asyncio.Event = asyncio.Event()
|
||||
_pause_event.set()
|
||||
|
||||
@classmethod
|
||||
def pause(cls, reason: str = "") -> None:
|
||||
if cls._paused:
|
||||
return
|
||||
cls._paused = True
|
||||
cls._reason = reason or ""
|
||||
cls._pause_event.clear()
|
||||
logger.warning(f"[OperationLock] 消息处理已暂停: {cls._reason}")
|
||||
|
||||
@classmethod
|
||||
def resume(cls) -> None:
|
||||
if not cls._paused:
|
||||
return
|
||||
cls._paused = False
|
||||
cls._reason = ""
|
||||
cls._pause_event.set()
|
||||
logger.warning("[OperationLock] 消息处理已恢复")
|
||||
|
||||
@classmethod
|
||||
def is_paused(cls) -> bool:
|
||||
return cls._paused
|
||||
|
||||
@classmethod
|
||||
def reason(cls) -> str:
|
||||
return cls._reason
|
||||
|
||||
@classmethod
|
||||
async def wait_if_paused(cls) -> None:
|
||||
if cls._paused:
|
||||
await cls._pause_event.wait()
|
||||
@@ -3,13 +3,14 @@ import inspect
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Dict, Type, List, Union
|
||||
|
||||
from loguru import logger
|
||||
|
||||
# from WechatAPI import WechatAPIClient # 注释掉,WechatHookBot 不需要这个导入
|
||||
from utils.singleton import Singleton
|
||||
from utils.config_manager import get_bot_config
|
||||
from utils.config_manager import get_bot_config, get_project_root
|
||||
from utils.llm_tooling import register_plugin_tools, unregister_plugin_tools
|
||||
from .event_manager import EventManager
|
||||
from .plugin_base import PluginBase
|
||||
@@ -22,6 +23,7 @@ class PluginManager(metaclass=Singleton):
|
||||
self.plugin_info: Dict[str, dict] = {} # 新增:存储所有插件信息
|
||||
|
||||
self.bot = None
|
||||
self._plugins_dir = get_project_root() / "plugins"
|
||||
|
||||
# 使用统一配置管理器
|
||||
bot_config = get_bot_config()
|
||||
@@ -118,9 +120,14 @@ class PluginManager(metaclass=Singleton):
|
||||
bool: 是否成功加载插件
|
||||
"""
|
||||
found = False
|
||||
for dirname in os.listdir("plugins"):
|
||||
if not self._plugins_dir.exists():
|
||||
logger.warning(f"插件目录不存在: {self._plugins_dir}")
|
||||
return False
|
||||
|
||||
for plugin_dir in self._plugins_dir.iterdir():
|
||||
dirname = plugin_dir.name
|
||||
try:
|
||||
if os.path.isdir(f"plugins/{dirname}") and os.path.exists(f"plugins/{dirname}/main.py"):
|
||||
if plugin_dir.is_dir() and (plugin_dir / "main.py").exists():
|
||||
module = importlib.import_module(f"plugins.{dirname}.main")
|
||||
importlib.reload(module)
|
||||
|
||||
@@ -137,6 +144,7 @@ class PluginManager(metaclass=Singleton):
|
||||
|
||||
if not found:
|
||||
logger.warning(f"未找到插件类 {plugin_name}")
|
||||
return False
|
||||
|
||||
def _resolve_load_order(self, plugin_classes: List[Type[PluginBase]]) -> List[Type[PluginBase]]:
|
||||
"""
|
||||
@@ -202,8 +210,13 @@ class PluginManager(metaclass=Singleton):
|
||||
all_plugin_classes = []
|
||||
plugin_disabled_map = {}
|
||||
|
||||
for dirname in os.listdir("plugins"):
|
||||
if os.path.isdir(f"plugins/{dirname}") and os.path.exists(f"plugins/{dirname}/main.py"):
|
||||
if not self._plugins_dir.exists():
|
||||
logger.warning(f"插件目录不存在: {self._plugins_dir}")
|
||||
return loaded_plugins
|
||||
|
||||
for plugin_dir in self._plugins_dir.iterdir():
|
||||
dirname = plugin_dir.name
|
||||
if plugin_dir.is_dir() and (plugin_dir / "main.py").exists():
|
||||
try:
|
||||
module = importlib.import_module(f"plugins.{dirname}.main")
|
||||
for name, obj in inspect.getmembers(module):
|
||||
@@ -369,10 +382,15 @@ class PluginManager(metaclass=Singleton):
|
||||
return []
|
||||
|
||||
async def refresh_plugins(self):
|
||||
for dirname in os.listdir("plugins"):
|
||||
if not self._plugins_dir.exists():
|
||||
logger.warning(f"插件目录不存在: {self._plugins_dir}")
|
||||
return
|
||||
|
||||
for plugin_dir in self._plugins_dir.iterdir():
|
||||
dirname = plugin_dir.name
|
||||
try:
|
||||
dirpath = f"plugins/{dirname}"
|
||||
if os.path.isdir(dirpath) and os.path.exists(f"{dirpath}/main.py"):
|
||||
dirpath = Path(plugin_dir)
|
||||
if dirpath.is_dir() and (dirpath / "main.py").exists():
|
||||
# 验证目录名合法性
|
||||
if not dirname.isidentifier():
|
||||
logger.warning(f"跳过非法插件目录名: {dirname}")
|
||||
@@ -415,8 +433,7 @@ class PluginManager(metaclass=Singleton):
|
||||
try:
|
||||
# 尝试从 AIChat 插件配置读取
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
aichat_config_path = Path("plugins/AIChat/config.toml")
|
||||
aichat_config_path = self._plugins_dir / "AIChat" / "config.toml"
|
||||
if aichat_config_path.exists():
|
||||
with open(aichat_config_path, "rb") as f:
|
||||
aichat_config = tomllib.load(f)
|
||||
@@ -429,8 +446,7 @@ class PluginManager(metaclass=Singleton):
|
||||
"""获取工具超时配置"""
|
||||
try:
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
aichat_config_path = Path("plugins/AIChat/config.toml")
|
||||
aichat_config_path = self._plugins_dir / "AIChat" / "config.toml"
|
||||
if aichat_config_path.exists():
|
||||
with open(aichat_config_path, "rb") as f:
|
||||
aichat_config = tomllib.load(f)
|
||||
|
||||
563
utils/webui.py
Normal file
563
utils/webui.py
Normal file
@@ -0,0 +1,563 @@
|
||||
"""
|
||||
WebUI 多功能面板
|
||||
|
||||
功能:实时日志查看 + 配置编辑器 + 插件管理
|
||||
前端静态文件位于 utils/webui_static/ 目录。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import collections
|
||||
import copy
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import time
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from aiohttp import web, WSMsgType
|
||||
from loguru import logger
|
||||
|
||||
try:
|
||||
import tomli_w
|
||||
except ImportError:
|
||||
tomli_w = None
|
||||
|
||||
|
||||
class LogBuffer:
|
||||
"""环形日志缓冲区"""
|
||||
|
||||
def __init__(self, maxlen: int = 500):
|
||||
self._buffer = collections.deque(maxlen=maxlen)
|
||||
self._clients: set[web.WebSocketResponse] = set()
|
||||
|
||||
def append(self, line: str):
|
||||
self._buffer.append(line)
|
||||
dead = set()
|
||||
for ws in self._clients:
|
||||
if ws.closed:
|
||||
dead.add(ws)
|
||||
continue
|
||||
task = asyncio.get_event_loop().create_task(ws.send_str(line))
|
||||
task.add_done_callback(lambda t, w=ws: self._clients.discard(w) if t.exception() else None)
|
||||
self._clients -= dead
|
||||
|
||||
def get_history(self) -> list[str]:
|
||||
return list(self._buffer)
|
||||
|
||||
def add_client(self, ws: web.WebSocketResponse):
|
||||
self._clients.add(ws)
|
||||
|
||||
def remove_client(self, ws: web.WebSocketResponse):
|
||||
self._clients.discard(ws)
|
||||
|
||||
|
||||
_log_buffer = LogBuffer()
|
||||
|
||||
|
||||
def get_log_buffer() -> LogBuffer:
|
||||
return _log_buffer
|
||||
|
||||
|
||||
def loguru_sink(message):
|
||||
"""loguru sink"""
|
||||
text = str(message).rstrip("\n")
|
||||
_log_buffer.append(text)
|
||||
|
||||
|
||||
# 主配置 section 中文名映射
|
||||
SECTION_LABELS = {
|
||||
"HttpHook": "Hook 连接",
|
||||
"Bot": "机器人",
|
||||
"Database": "数据库",
|
||||
"Performance": "性能",
|
||||
"Queue": "消息队列",
|
||||
"Concurrency": "并发控制",
|
||||
"Scheduler": "定时任务",
|
||||
"WebUI": "WebUI",
|
||||
}
|
||||
|
||||
# 插件配置 section 中文名映射
|
||||
PLUGIN_CONFIG_LABELS = {
|
||||
"AIChat": {
|
||||
"plugin": "插件基本信息",
|
||||
"api": "AI API 配置",
|
||||
"proxy": "代理配置",
|
||||
"prompt": "人设配置",
|
||||
"output": "输出后处理",
|
||||
"behavior": "触发行为",
|
||||
"memory": "对话记忆",
|
||||
"history": "群组历史记录",
|
||||
"image_description": "图片描述",
|
||||
"video_recognition": "视频识别",
|
||||
"rate_limit": "限流配置",
|
||||
"redis": "Redis 存储",
|
||||
"tools": "LLM 工具",
|
||||
"tools.timeout": "工具超时",
|
||||
"tools.concurrency": "工具并发",
|
||||
"vector_memory": "向量长期记忆",
|
||||
},
|
||||
}
|
||||
|
||||
# 静态文件目录
|
||||
_STATIC_DIR = Path(__file__).parent / "webui_static"
|
||||
|
||||
SESSION_COOKIE_NAME = "whb_webui_session"
|
||||
PBKDF2_ROUNDS = 240000
|
||||
DEFAULT_WEBUI_USERNAME = "admin"
|
||||
DEFAULT_WEBUI_PASSWORD = "admin123456"
|
||||
DEFAULT_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60
|
||||
AUTH_MANAGED_KEYS = {"auth_username", "auth_password_hash"}
|
||||
|
||||
|
||||
def hash_password(password: str, *, salt: str | None = None) -> str:
|
||||
"""生成 PBKDF2 密码哈希。"""
|
||||
if not isinstance(password, str) or password == "":
|
||||
raise ValueError("密码不能为空")
|
||||
if salt is None:
|
||||
salt = secrets.token_hex(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt.encode("utf-8"),
|
||||
PBKDF2_ROUNDS,
|
||||
)
|
||||
encoded = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
return f"pbkdf2_sha256${PBKDF2_ROUNDS}${salt}${encoded}"
|
||||
|
||||
|
||||
def verify_password(password: str, stored_hash: str) -> bool:
|
||||
"""校验明文密码是否匹配哈希。"""
|
||||
if not isinstance(password, str) or not isinstance(stored_hash, str):
|
||||
return False
|
||||
try:
|
||||
algo, rounds, salt, digest = stored_hash.split("$", 3)
|
||||
rounds_int = int(rounds)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if algo != "pbkdf2_sha256" or rounds_int <= 0:
|
||||
return False
|
||||
calculated = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
password.encode("utf-8"),
|
||||
salt.encode("utf-8"),
|
||||
rounds_int,
|
||||
)
|
||||
calculated_encoded = base64.urlsafe_b64encode(calculated).decode("ascii").rstrip("=")
|
||||
return hmac.compare_digest(calculated_encoded, digest)
|
||||
|
||||
|
||||
class WebUIServer:
|
||||
"""WebUI HTTP 服务器"""
|
||||
|
||||
def __init__(self, host: str = "0.0.0.0", port: int = 5001, config_path: str = "main_config.toml"):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.config_path = Path(config_path)
|
||||
self.app = web.Application(middlewares=[self._auth_middleware])
|
||||
self.runner = None
|
||||
self._sessions: dict[str, dict[str, float | str]] = {}
|
||||
self._auth_update_lock = asyncio.Lock()
|
||||
self._setup_routes()
|
||||
self._ensure_auth_initialized()
|
||||
|
||||
def _setup_routes(self):
|
||||
self.app.router.add_get("/", self._handle_index)
|
||||
self.app.router.add_get("/api/auth/status", self._handle_auth_status)
|
||||
self.app.router.add_post("/api/auth/login", self._handle_auth_login)
|
||||
self.app.router.add_post("/api/auth/logout", self._handle_auth_logout)
|
||||
self.app.router.add_post("/api/auth/change-credentials", self._handle_change_credentials)
|
||||
self.app.router.add_get("/ws", self._handle_ws)
|
||||
self.app.router.add_get("/api/config", self._handle_get_config)
|
||||
self.app.router.add_post("/api/config", self._handle_save_config)
|
||||
self.app.router.add_get("/api/plugins", self._handle_get_plugins)
|
||||
self.app.router.add_post("/api/plugins/toggle", self._handle_toggle_plugin)
|
||||
self.app.router.add_get("/api/plugins/{name}/config", self._handle_get_plugin_config)
|
||||
self.app.router.add_post("/api/plugins/{name}/config", self._handle_save_plugin_config)
|
||||
self.app.router.add_static("/static/", path=_STATIC_DIR, name="static")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_session_timeout(value) -> int:
|
||||
try:
|
||||
timeout = int(value)
|
||||
except Exception:
|
||||
return DEFAULT_SESSION_TIMEOUT_SECONDS
|
||||
return max(300, timeout)
|
||||
|
||||
def _load_main_config(self) -> dict:
|
||||
with open(self.config_path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
def _save_main_config(self, data: dict):
|
||||
if tomli_w is None:
|
||||
raise RuntimeError("tomli_w 未安装,无法写入 main_config.toml")
|
||||
with open(self.config_path, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
|
||||
def _ensure_auth_initialized(self):
|
||||
try:
|
||||
data = self._load_main_config()
|
||||
except Exception as e:
|
||||
logger.error(f"读取配置失败,无法初始化 WebUI 认证: {e}")
|
||||
return
|
||||
|
||||
webui_cfg = data.setdefault("WebUI", {})
|
||||
changed = False
|
||||
|
||||
username = str(webui_cfg.get("auth_username", "")).strip()
|
||||
if not username:
|
||||
webui_cfg["auth_username"] = DEFAULT_WEBUI_USERNAME
|
||||
changed = True
|
||||
|
||||
pwd_hash = str(webui_cfg.get("auth_password_hash", "")).strip()
|
||||
if not pwd_hash:
|
||||
webui_cfg["auth_password_hash"] = hash_password(DEFAULT_WEBUI_PASSWORD)
|
||||
changed = True
|
||||
logger.warning(
|
||||
"WebUI 未配置管理员账号密码,已初始化默认账号: "
|
||||
f"{DEFAULT_WEBUI_USERNAME} / {DEFAULT_WEBUI_PASSWORD},请尽快在 WebUI 的安全页面修改。"
|
||||
)
|
||||
|
||||
normalized_timeout = self._normalize_session_timeout(
|
||||
webui_cfg.get("session_timeout_seconds", DEFAULT_SESSION_TIMEOUT_SECONDS)
|
||||
)
|
||||
if webui_cfg.get("session_timeout_seconds") != normalized_timeout:
|
||||
webui_cfg["session_timeout_seconds"] = normalized_timeout
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
try:
|
||||
self._save_main_config(data)
|
||||
except Exception as e:
|
||||
logger.error(f"写入 WebUI 认证初始化配置失败: {e}")
|
||||
|
||||
def _get_auth_settings(self) -> tuple[str, str, int]:
|
||||
try:
|
||||
data = self._load_main_config()
|
||||
webui_cfg = data.get("WebUI", {})
|
||||
username = str(webui_cfg.get("auth_username", DEFAULT_WEBUI_USERNAME)).strip() or DEFAULT_WEBUI_USERNAME
|
||||
pwd_hash = str(webui_cfg.get("auth_password_hash", "")).strip()
|
||||
timeout = self._normalize_session_timeout(
|
||||
webui_cfg.get("session_timeout_seconds", DEFAULT_SESSION_TIMEOUT_SECONDS)
|
||||
)
|
||||
if not pwd_hash:
|
||||
# 配置异常时兜底,避免直接失去登录能力
|
||||
pwd_hash = hash_password(DEFAULT_WEBUI_PASSWORD)
|
||||
return username, pwd_hash, timeout
|
||||
except Exception as e:
|
||||
logger.error(f"读取 WebUI 认证配置失败,使用默认兜底: {e}")
|
||||
return DEFAULT_WEBUI_USERNAME, hash_password(DEFAULT_WEBUI_PASSWORD), DEFAULT_SESSION_TIMEOUT_SECONDS
|
||||
|
||||
def _cleanup_sessions(self):
|
||||
now = time.time()
|
||||
expired_tokens = [
|
||||
token
|
||||
for token, session in self._sessions.items()
|
||||
if float(session.get("expires_at", 0)) <= now
|
||||
]
|
||||
for token in expired_tokens:
|
||||
self._sessions.pop(token, None)
|
||||
|
||||
def _create_session(self, username: str, timeout_seconds: int) -> str:
|
||||
self._cleanup_sessions()
|
||||
token = secrets.token_urlsafe(32)
|
||||
self._sessions[token] = {
|
||||
"username": username,
|
||||
"expires_at": time.time() + timeout_seconds,
|
||||
}
|
||||
return token
|
||||
|
||||
def _invalidate_session(self, token: str):
|
||||
if token:
|
||||
self._sessions.pop(token, None)
|
||||
|
||||
def _set_session_cookie(self, response: web.Response, request: web.Request, token: str, timeout_seconds: int):
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
token,
|
||||
max_age=timeout_seconds,
|
||||
path="/",
|
||||
httponly=True,
|
||||
secure=request.secure,
|
||||
samesite="Lax",
|
||||
)
|
||||
|
||||
def _get_session_username(self, request: web.Request, *, refresh: bool = True) -> str | None:
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME, "")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
self._cleanup_sessions()
|
||||
session = self._sessions.get(token)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
now = time.time()
|
||||
expires_at = float(session.get("expires_at", 0))
|
||||
if expires_at <= now:
|
||||
self._sessions.pop(token, None)
|
||||
return None
|
||||
|
||||
username = str(session.get("username", "")).strip()
|
||||
if not username:
|
||||
self._sessions.pop(token, None)
|
||||
return None
|
||||
|
||||
if refresh:
|
||||
_, _, timeout = self._get_auth_settings()
|
||||
session["expires_at"] = now + timeout
|
||||
return username
|
||||
|
||||
@staticmethod
|
||||
def _is_public_path(path: str) -> bool:
|
||||
return (
|
||||
path == "/"
|
||||
or path.startswith("/static/")
|
||||
or path == "/favicon.ico"
|
||||
or path == "/api/auth/status"
|
||||
or path == "/api/auth/login"
|
||||
)
|
||||
|
||||
@web.middleware
|
||||
async def _auth_middleware(self, request: web.Request, handler):
|
||||
path = request.path
|
||||
if self._is_public_path(path):
|
||||
return await handler(request)
|
||||
|
||||
username = self._get_session_username(request)
|
||||
if not username:
|
||||
if path.startswith("/api/") or path == "/ws":
|
||||
return web.json_response({"ok": False, "error": "未登录或会话已过期"}, status=401)
|
||||
raise web.HTTPFound("/")
|
||||
|
||||
request["webui_user"] = username
|
||||
return await handler(request)
|
||||
|
||||
async def _handle_auth_status(self, request: web.Request) -> web.Response:
|
||||
configured_username, _, _ = self._get_auth_settings()
|
||||
current_username = self._get_session_username(request, refresh=False)
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"authenticated": bool(current_username),
|
||||
"username": current_username or configured_username,
|
||||
})
|
||||
|
||||
async def _handle_auth_login(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
username = str(body.get("username", "")).strip()
|
||||
password = str(body.get("password", ""))
|
||||
configured_username, configured_hash, timeout = self._get_auth_settings()
|
||||
|
||||
if username != configured_username or not verify_password(password, configured_hash):
|
||||
return web.json_response({"ok": False, "error": "用户名或密码错误"}, status=401)
|
||||
|
||||
token = self._create_session(configured_username, timeout)
|
||||
response = web.json_response({"ok": True, "username": configured_username})
|
||||
self._set_session_cookie(response, request, token, timeout)
|
||||
return response
|
||||
|
||||
async def _handle_auth_logout(self, request: web.Request) -> web.Response:
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME, "")
|
||||
if token:
|
||||
self._invalidate_session(token)
|
||||
response = web.json_response({"ok": True})
|
||||
response.del_cookie(SESSION_COOKIE_NAME, path="/")
|
||||
return response
|
||||
|
||||
async def _handle_change_credentials(self, request: web.Request) -> web.Response:
|
||||
if tomli_w is None:
|
||||
return web.json_response({"ok": False, "error": "tomli_w 未安装,无法保存认证配置"})
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
current_password = str(body.get("current_password", ""))
|
||||
new_username = str(body.get("new_username", "")).strip()
|
||||
new_password = str(body.get("new_password", ""))
|
||||
except Exception:
|
||||
return web.json_response({"ok": False, "error": "请求参数格式错误"})
|
||||
|
||||
if not current_password:
|
||||
return web.json_response({"ok": False, "error": "请输入当前密码"})
|
||||
if not new_username:
|
||||
return web.json_response({"ok": False, "error": "账号不能为空"})
|
||||
if len(new_password) < 8:
|
||||
return web.json_response({"ok": False, "error": "新密码长度至少 8 位"})
|
||||
|
||||
async with self._auth_update_lock:
|
||||
try:
|
||||
data = self._load_main_config()
|
||||
webui_cfg = data.setdefault("WebUI", {})
|
||||
stored_hash = str(webui_cfg.get("auth_password_hash", "")).strip()
|
||||
if not stored_hash:
|
||||
stored_hash = hash_password(DEFAULT_WEBUI_PASSWORD)
|
||||
|
||||
if not verify_password(current_password, stored_hash):
|
||||
return web.json_response({"ok": False, "error": "当前密码错误"})
|
||||
|
||||
webui_cfg["auth_username"] = new_username
|
||||
webui_cfg["auth_password_hash"] = hash_password(new_password)
|
||||
webui_cfg["session_timeout_seconds"] = self._normalize_session_timeout(
|
||||
webui_cfg.get("session_timeout_seconds", DEFAULT_SESSION_TIMEOUT_SECONDS)
|
||||
)
|
||||
self._save_main_config(data)
|
||||
|
||||
timeout = self._normalize_session_timeout(webui_cfg["session_timeout_seconds"])
|
||||
self._sessions.clear()
|
||||
new_token = self._create_session(new_username, timeout)
|
||||
|
||||
response = web.json_response({"ok": True, "username": new_username})
|
||||
self._set_session_cookie(response, request, new_token, timeout)
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def _handle_index(self, request: web.Request) -> web.Response:
|
||||
index_file = _STATIC_DIR / "index.html"
|
||||
return web.FileResponse(index_file)
|
||||
|
||||
async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
_log_buffer.add_client(ws)
|
||||
for line in _log_buffer.get_history():
|
||||
await ws.send_str(line)
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type == WSMsgType.ERROR:
|
||||
break
|
||||
finally:
|
||||
_log_buffer.remove_client(ws)
|
||||
return ws
|
||||
|
||||
async def _handle_get_config(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = self._load_main_config()
|
||||
safe_data = copy.deepcopy(data)
|
||||
webui_cfg = safe_data.get("WebUI")
|
||||
if isinstance(webui_cfg, dict):
|
||||
for key in AUTH_MANAGED_KEYS:
|
||||
webui_cfg.pop(key, None)
|
||||
return web.json_response({"ok": True, "data": safe_data, "labels": SECTION_LABELS})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def _handle_save_config(self, request: web.Request) -> web.Response:
|
||||
if tomli_w is None:
|
||||
return web.json_response({"ok": False, "error": "tomli_w 未安装"})
|
||||
try:
|
||||
body = await request.json()
|
||||
data = body.get("data", {})
|
||||
|
||||
# 避免配置编辑器覆盖认证字段(认证字段专用接口维护)
|
||||
current = self._load_main_config()
|
||||
current_webui = current.get("WebUI", {})
|
||||
new_webui = data.setdefault("WebUI", {})
|
||||
for key in AUTH_MANAGED_KEYS:
|
||||
if key in current_webui and key not in new_webui:
|
||||
new_webui[key] = current_webui[key]
|
||||
|
||||
self._save_main_config(data)
|
||||
self._ensure_auth_initialized()
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def _handle_get_plugins(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
from utils.plugin_manager import PluginManager
|
||||
pm = PluginManager()
|
||||
plugins = []
|
||||
for name, info in pm.plugin_info.items():
|
||||
directory = info.get("directory", name)
|
||||
cfg_path = Path("plugins") / directory / "config.toml"
|
||||
plugins.append({
|
||||
"name": name,
|
||||
"description": info.get("description", ""),
|
||||
"author": info.get("author", ""),
|
||||
"version": info.get("version", ""),
|
||||
"directory": directory,
|
||||
"enabled": info.get("enabled", False),
|
||||
"has_config": cfg_path.exists(),
|
||||
})
|
||||
plugins.sort(key=lambda p: p["name"])
|
||||
return web.json_response({"ok": True, "plugins": plugins})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def _handle_toggle_plugin(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
from utils.plugin_manager import PluginManager
|
||||
body = await request.json()
|
||||
name = body.get("name", "")
|
||||
enable = body.get("enable", False)
|
||||
if name == "ManagePlugin":
|
||||
return web.json_response({"ok": False, "error": "ManagePlugin 不可禁用"})
|
||||
pm = PluginManager()
|
||||
if enable:
|
||||
ok = await pm.load_plugin(name)
|
||||
if ok:
|
||||
return web.json_response({"ok": True})
|
||||
return web.json_response({"ok": False, "error": f"启用 {name} 失败"})
|
||||
else:
|
||||
ok = await pm.unload_plugin(name)
|
||||
if ok:
|
||||
return web.json_response({"ok": True})
|
||||
return web.json_response({"ok": False, "error": f"禁用 {name} 失败"})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def _handle_get_plugin_config(self, request: web.Request) -> web.Response:
|
||||
name = request.match_info["name"]
|
||||
try:
|
||||
from utils.plugin_manager import PluginManager
|
||||
pm = PluginManager()
|
||||
info = pm.plugin_info.get(name)
|
||||
if not info:
|
||||
return web.json_response({"ok": False, "error": f"插件 {name} 不存在"})
|
||||
directory = info.get("directory", name)
|
||||
cfg_path = Path("plugins") / directory / "config.toml"
|
||||
if not cfg_path.exists():
|
||||
return web.json_response({"ok": False, "error": "该插件无配置文件"})
|
||||
with open(cfg_path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
labels = PLUGIN_CONFIG_LABELS.get(name, {})
|
||||
return web.json_response({"ok": True, "data": data, "labels": labels})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def _handle_save_plugin_config(self, request: web.Request) -> web.Response:
|
||||
if tomli_w is None:
|
||||
return web.json_response({"ok": False, "error": "tomli_w 未安装"})
|
||||
name = request.match_info["name"]
|
||||
try:
|
||||
from utils.plugin_manager import PluginManager
|
||||
pm = PluginManager()
|
||||
info = pm.plugin_info.get(name)
|
||||
if not info:
|
||||
return web.json_response({"ok": False, "error": f"插件 {name} 不存在"})
|
||||
directory = info.get("directory", name)
|
||||
cfg_path = Path("plugins") / directory / "config.toml"
|
||||
body = await request.json()
|
||||
data = body.get("data", {})
|
||||
with open(cfg_path, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)})
|
||||
|
||||
async def start(self):
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
site = web.TCPSite(self.runner, self.host, self.port)
|
||||
await site.start()
|
||||
logger.success(f"WebUI 已启动: http://{self.host}:{self.port}")
|
||||
|
||||
async def stop(self):
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
logger.info("WebUI 已停止")
|
||||
165
utils/webui_static/app.js
Normal file
165
utils/webui_static/app.js
Normal file
@@ -0,0 +1,165 @@
|
||||
const { createApp, ref, provide, onMounted, onUnmounted } = Vue;
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
const api = useApi();
|
||||
const currentPage = ref('log');
|
||||
const authReady = ref(false);
|
||||
const authenticated = ref(false);
|
||||
const authUser = ref('');
|
||||
const loginLoading = ref(false);
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
provide('currentPage', currentPage);
|
||||
|
||||
async function refreshAuthState() {
|
||||
const json = await api.getAuthStatus();
|
||||
if (json) {
|
||||
authenticated.value = !!json.authenticated;
|
||||
authUser.value = json.username || '';
|
||||
if (json.username && !loginForm.value.username) {
|
||||
loginForm.value.username = json.username;
|
||||
}
|
||||
} else {
|
||||
authenticated.value = false;
|
||||
authUser.value = '';
|
||||
}
|
||||
authReady.value = true;
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const username = (loginForm.value.username || '').trim();
|
||||
const password = loginForm.value.password || '';
|
||||
if (!username) {
|
||||
ElementPlus.ElMessage.warning('请输入账号');
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
ElementPlus.ElMessage.warning('请输入密码');
|
||||
return;
|
||||
}
|
||||
|
||||
loginLoading.value = true;
|
||||
const json = await api.login(username, password);
|
||||
loginLoading.value = false;
|
||||
if (!json) return;
|
||||
|
||||
authenticated.value = true;
|
||||
authUser.value = json.username || username;
|
||||
loginForm.value.password = '';
|
||||
currentPage.value = 'log';
|
||||
ElementPlus.ElMessage.success('登录成功');
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await api.logout();
|
||||
authenticated.value = false;
|
||||
loginForm.value.password = '';
|
||||
currentPage.value = 'log';
|
||||
}
|
||||
|
||||
function handleAuthExpired() {
|
||||
const wasAuthenticated = authenticated.value;
|
||||
authenticated.value = false;
|
||||
loginForm.value.password = '';
|
||||
currentPage.value = 'log';
|
||||
if (wasAuthenticated) {
|
||||
ElementPlus.ElMessage.warning('登录状态已失效,请重新登录');
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthUpdated(event) {
|
||||
const username = event?.detail?.username;
|
||||
if (username) {
|
||||
authUser.value = username;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLayoutAuthUpdated(username) {
|
||||
if (username) {
|
||||
authUser.value = username;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('webui-auth-required', handleAuthExpired);
|
||||
window.addEventListener('webui-auth-updated', handleAuthUpdated);
|
||||
refreshAuthState();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('webui-auth-required', handleAuthExpired);
|
||||
window.removeEventListener('webui-auth-updated', handleAuthUpdated);
|
||||
});
|
||||
|
||||
return {
|
||||
authReady,
|
||||
authenticated,
|
||||
authUser,
|
||||
loginLoading,
|
||||
loginForm,
|
||||
login,
|
||||
logout,
|
||||
handleLayoutAuthUpdated,
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div v-if="authReady" class="app-root">
|
||||
<div v-if="!authenticated"
|
||||
class="login-page">
|
||||
<el-card class="login-card">
|
||||
<template #header>
|
||||
<div class="login-title">WechatHookBot 控制台</div>
|
||||
<div class="login-subtitle">Bright Tech UI · 安全登录</div>
|
||||
</template>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="账号">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
autocomplete="username"
|
||||
placeholder="请输入账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
@keyup.enter="login" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="loginLoading" style="width:100%" @click="login">
|
||||
登录
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<AppLayout
|
||||
v-else
|
||||
:auth-user="authUser"
|
||||
@logout="logout"
|
||||
@auth-updated="handleLayoutAuthUpdated" />
|
||||
</div>
|
||||
`
|
||||
});
|
||||
|
||||
app.use(ElementPlus, {
|
||||
locale: ElementPlusLocaleZhCn,
|
||||
});
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component);
|
||||
}
|
||||
|
||||
app.component('AppLayout', window.AppLayout);
|
||||
app.component('LogViewer', window.LogViewer);
|
||||
app.component('ConfigEditor', window.ConfigEditor);
|
||||
app.component('ConfigSection', window.ConfigSection);
|
||||
app.component('PluginList', window.PluginList);
|
||||
app.component('PluginConfigDialog', window.PluginConfigDialog);
|
||||
app.component('SecuritySettings', window.SecuritySettings);
|
||||
|
||||
app.mount('#app');
|
||||
60
utils/webui_static/components/AppLayout.js
Normal file
60
utils/webui_static/components/AppLayout.js
Normal file
@@ -0,0 +1,60 @@
|
||||
window.AppLayout = {
|
||||
props: {
|
||||
authUser: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
emits: ['logout', 'auth-updated'],
|
||||
setup() {
|
||||
const { inject } = Vue;
|
||||
const currentPage = inject('currentPage');
|
||||
|
||||
const menuItems = [
|
||||
{ index: 'log', icon: 'Document', label: '日志' },
|
||||
{ index: 'config', icon: 'Setting', label: '配置' },
|
||||
{ index: 'plugin', icon: 'Box', label: '插件' },
|
||||
{ index: 'security', icon: 'Lock', label: '安全' },
|
||||
];
|
||||
|
||||
return { currentPage, menuItems };
|
||||
},
|
||||
template: `
|
||||
<el-container class="app-shell">
|
||||
<el-aside width="220px" class="app-aside">
|
||||
<div class="brand-panel">
|
||||
<div class="brand-title">WechatHookBot</div>
|
||||
<div class="brand-sub">Control Surface</div>
|
||||
</div>
|
||||
<el-menu :default-active="currentPage"
|
||||
class="app-menu"
|
||||
@select="(idx) => currentPage = idx"
|
||||
background-color="transparent">
|
||||
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.label }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main class="app-main">
|
||||
<div class="app-topbar">
|
||||
<div class="topbar-title">Real-time Operations Panel</div>
|
||||
<div class="topbar-right">
|
||||
<span class="auth-pill">
|
||||
当前账号: {{ authUser || '-' }}
|
||||
</span>
|
||||
<el-button size="small" @click="$emit('logout')">退出登录</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-stage">
|
||||
<LogViewer v-show="currentPage === 'log'" />
|
||||
<ConfigEditor v-show="currentPage === 'config'" />
|
||||
<PluginList v-show="currentPage === 'plugin'" />
|
||||
<SecuritySettings
|
||||
v-show="currentPage === 'security'"
|
||||
@updated="$emit('auth-updated', $event)" />
|
||||
</div>
|
||||
</el-main>
|
||||
</el-container>
|
||||
`
|
||||
};
|
||||
51
utils/webui_static/components/ConfigEditor.js
Normal file
51
utils/webui_static/components/ConfigEditor.js
Normal file
@@ -0,0 +1,51 @@
|
||||
window.ConfigEditor = {
|
||||
setup() {
|
||||
const { ref, onMounted } = Vue;
|
||||
const api = useApi();
|
||||
|
||||
const configData = ref({});
|
||||
const configLabels = ref({});
|
||||
const loaded = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
async function load() {
|
||||
const json = await api.getConfig();
|
||||
if (json) {
|
||||
configData.value = json.data;
|
||||
configLabels.value = json.labels || {};
|
||||
loaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
const json = await api.saveConfig(configData.value);
|
||||
if (json) ElementPlus.ElMessage.success('配置已保存');
|
||||
saving.value = false;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
return { configData, configLabels, loaded, saving, save };
|
||||
},
|
||||
template: `
|
||||
<div class="panel-page">
|
||||
<div class="panel-scroll">
|
||||
<template v-if="loaded">
|
||||
<ConfigSection
|
||||
v-for="(fields, section) in configData" :key="section"
|
||||
:section="section"
|
||||
:label="configLabels[section] || section"
|
||||
:fields="fields"
|
||||
v-model="configData[section]" />
|
||||
</template>
|
||||
<div v-else class="panel-loading">
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<el-button type="primary" @click="save" :loading="saving">保存配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
84
utils/webui_static/components/ConfigSection.js
Normal file
84
utils/webui_static/components/ConfigSection.js
Normal file
@@ -0,0 +1,84 @@
|
||||
window.ConfigSection = {
|
||||
props: {
|
||||
section: String,
|
||||
label: String,
|
||||
fields: Object,
|
||||
modelValue: Object,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit }) {
|
||||
const { computed } = Vue;
|
||||
|
||||
const flatFields = computed(() => {
|
||||
const result = [];
|
||||
for (const [key, val] of Object.entries(props.fields)) {
|
||||
if (typeof val === 'object' && !Array.isArray(val)) continue;
|
||||
result.push({ key, val });
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
function updateField(key, newVal) {
|
||||
props.modelValue[key] = newVal;
|
||||
}
|
||||
|
||||
function fieldType(val) {
|
||||
if (typeof val === 'boolean') return 'boolean';
|
||||
if (typeof val === 'number') return 'number';
|
||||
if (Array.isArray(val)) return 'array';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function removeTag(key, index) {
|
||||
props.modelValue[key].splice(index, 1);
|
||||
}
|
||||
|
||||
function addTag(key, val) {
|
||||
if (!val || !val.trim()) return;
|
||||
if (!Array.isArray(props.modelValue[key])) props.modelValue[key] = [];
|
||||
props.modelValue[key].push(val.trim());
|
||||
}
|
||||
|
||||
return { flatFields, updateField, fieldType, removeTag, addTag };
|
||||
},
|
||||
template: `
|
||||
<el-card shadow="never" class="config-card">
|
||||
<template #header>
|
||||
<div class="config-card-header">
|
||||
<span class="config-card-title">{{ label }}</span>
|
||||
<span class="config-card-section">[{{ section }}]</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-for="item in flatFields" :key="item.key"
|
||||
class="config-row">
|
||||
<div class="config-key">
|
||||
{{ item.key }}
|
||||
</div>
|
||||
<div class="config-val">
|
||||
<el-switch v-if="fieldType(item.val) === 'boolean'"
|
||||
:model-value="modelValue[item.key]"
|
||||
@update:model-value="updateField(item.key, $event)"
|
||||
active-text="开" inactive-text="关" />
|
||||
<el-input-number v-else-if="fieldType(item.val) === 'number'"
|
||||
:model-value="modelValue[item.key]"
|
||||
@update:model-value="updateField(item.key, $event)"
|
||||
:step="Number.isInteger(item.val) ? 1 : 0.1"
|
||||
controls-position="right" style="width: 200px" />
|
||||
<div v-else-if="fieldType(item.val) === 'array'" class="config-tags">
|
||||
<el-tag v-for="(tag, ti) in modelValue[item.key]" :key="ti"
|
||||
closable @close="removeTag(item.key, ti)"
|
||||
style="margin-bottom:2px">
|
||||
{{ tag }}
|
||||
</el-tag>
|
||||
<el-input size="small" style="width:140px"
|
||||
placeholder="回车添加"
|
||||
@keyup.enter="addTag(item.key, $event.target.value); $event.target.value=''" />
|
||||
</div>
|
||||
<el-input v-else
|
||||
:model-value="String(modelValue[item.key] ?? '')"
|
||||
@update:model-value="updateField(item.key, $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
`
|
||||
};
|
||||
113
utils/webui_static/components/LogViewer.js
Normal file
113
utils/webui_static/components/LogViewer.js
Normal file
@@ -0,0 +1,113 @@
|
||||
window.LogViewer = {
|
||||
setup() {
|
||||
const { ref, computed, onMounted, onUnmounted, nextTick, watch } = Vue;
|
||||
const { connected, logs, paused, connect, clear, togglePause, destroy } = useWebSocket();
|
||||
|
||||
const filterText = ref('');
|
||||
const containerRef = ref(null);
|
||||
|
||||
function onWheel(event) {
|
||||
const el = containerRef.value;
|
||||
if (!el) return;
|
||||
|
||||
const atTop = el.scrollTop <= 0;
|
||||
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
||||
const scrollingUp = event.deltaY < 0;
|
||||
const scrollingDown = event.deltaY > 0;
|
||||
|
||||
// 防止日志容器触底/触顶时把滚轮事件传递给外层页面
|
||||
if ((scrollingUp && atTop) || (scrollingDown && atBottom)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
const kw = filterText.value.toLowerCase();
|
||||
if (!kw) return logs.value;
|
||||
return logs.value.filter(line => line.toLowerCase().includes(kw));
|
||||
});
|
||||
|
||||
watch(filteredLogs, () => {
|
||||
if (!paused.value) {
|
||||
nextTick(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.scrollTop = containerRef.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const levelColors = {
|
||||
DEBUG: 'var(--el-text-color-placeholder)',
|
||||
INFO: 'var(--el-color-primary)',
|
||||
SUCCESS: 'var(--el-color-success)',
|
||||
WARNING: 'var(--el-color-warning)',
|
||||
ERROR: 'var(--el-color-danger)',
|
||||
CRITICAL: '#b42318',
|
||||
};
|
||||
|
||||
function colorize(raw) {
|
||||
let s = raw
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(
|
||||
/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/,
|
||||
'<span style="color:var(--el-text-color-placeholder)">$1</span>'
|
||||
);
|
||||
const m = raw.match(/\|\s*(DEBUG|INFO|SUCCESS|WARNING|ERROR|CRITICAL)\s*\|/);
|
||||
if (m) {
|
||||
s = s.replace(
|
||||
/\|\s*(DEBUG|INFO|SUCCESS|WARNING|ERROR|CRITICAL)\s*\|/,
|
||||
'| <span style="color:' + levelColors[m[1]] + '">' + m[1] + '</span> |'
|
||||
);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connect();
|
||||
if (containerRef.value) {
|
||||
containerRef.value.addEventListener('wheel', onWheel, { passive: false });
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (containerRef.value) {
|
||||
containerRef.value.removeEventListener('wheel', onWheel);
|
||||
}
|
||||
destroy();
|
||||
});
|
||||
|
||||
return {
|
||||
filterText, paused, connected, filteredLogs,
|
||||
containerRef, togglePause, clear, colorize
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div class="panel-page">
|
||||
<div class="log-toolbar">
|
||||
<el-input v-model="filterText" placeholder="搜索过滤..." clearable
|
||||
class="log-search" size="small">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button size="small" :type="paused ? 'primary' : ''" @click="togglePause">
|
||||
{{ paused ? '恢复' : '暂停' }}
|
||||
</el-button>
|
||||
<el-button size="small" @click="clear">清空</el-button>
|
||||
<span class="log-status" :class="connected ? 'is-online' : 'is-offline'">
|
||||
<span class="status-dot"></span>
|
||||
{{ connected ? '已连接' : '已断开' }}
|
||||
</span>
|
||||
</div>
|
||||
<div ref="containerRef"
|
||||
class="log-stream">
|
||||
<div v-for="(line, i) in filteredLogs" :key="i"
|
||||
class="log-line"
|
||||
v-html="colorize(line)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
82
utils/webui_static/components/PluginConfigDialog.js
Normal file
82
utils/webui_static/components/PluginConfigDialog.js
Normal file
@@ -0,0 +1,82 @@
|
||||
window.PluginConfigDialog = {
|
||||
props: {
|
||||
visible: Boolean,
|
||||
pluginName: String,
|
||||
},
|
||||
emits: ['update:visible'],
|
||||
setup(props, { emit }) {
|
||||
const { ref, watch, computed } = Vue;
|
||||
const api = useApi();
|
||||
|
||||
const configData = ref({});
|
||||
const configLabels = ref({});
|
||||
const saving = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
watch(() => props.visible, async (val) => {
|
||||
if (val && props.pluginName) {
|
||||
loading.value = true;
|
||||
const json = await api.getPluginConfig(props.pluginName);
|
||||
loading.value = false;
|
||||
if (json) {
|
||||
configData.value = json.data;
|
||||
configLabels.value = json.labels || {};
|
||||
} else {
|
||||
emit('update:visible', false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function collectSections(obj, prefix) {
|
||||
const result = [];
|
||||
for (const [key, val] of Object.entries(obj)) {
|
||||
if (typeof val !== 'object' || Array.isArray(val)) continue;
|
||||
const fullKey = prefix ? prefix + '.' + key : key;
|
||||
result.push({
|
||||
key: fullKey,
|
||||
fields: val,
|
||||
label: configLabels.value[fullKey] || fullKey,
|
||||
});
|
||||
result.push(...collectSections(val, fullKey));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const sections = computed(() => collectSections(configData.value, ''));
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
const json = await api.savePluginConfig(props.pluginName, configData.value);
|
||||
if (json) {
|
||||
ElementPlus.ElMessage.success('插件配置已保存');
|
||||
emit('update:visible', false);
|
||||
}
|
||||
saving.value = false;
|
||||
}
|
||||
|
||||
function close() { emit('update:visible', false); }
|
||||
|
||||
return { configData, sections, saving, loading, save, close };
|
||||
},
|
||||
template: `
|
||||
<el-dialog :model-value="visible"
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
:title="pluginName + ' 配置'"
|
||||
width="720px" top="8vh" destroy-on-close>
|
||||
<div v-if="loading" class="panel-loading">
|
||||
加载中...
|
||||
</div>
|
||||
<div v-else class="dialog-body-scroll">
|
||||
<ConfigSection v-for="sec in sections" :key="sec.key"
|
||||
:section="sec.key"
|
||||
:label="sec.label"
|
||||
:fields="sec.fields"
|
||||
v-model="sec.fields" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="close">取消</el-button>
|
||||
<el-button type="primary" @click="save" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
`
|
||||
};
|
||||
73
utils/webui_static/components/PluginList.js
Normal file
73
utils/webui_static/components/PluginList.js
Normal file
@@ -0,0 +1,73 @@
|
||||
window.PluginList = {
|
||||
setup() {
|
||||
const { ref, onMounted } = Vue;
|
||||
const api = useApi();
|
||||
|
||||
const plugins = ref([]);
|
||||
const loaded = ref(false);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogPluginName = ref('');
|
||||
|
||||
async function load() {
|
||||
const json = await api.getPlugins();
|
||||
if (json) { plugins.value = json.plugins; loaded.value = true; }
|
||||
}
|
||||
|
||||
async function toggle(plugin, enable) {
|
||||
const json = await api.togglePlugin(plugin.name, enable);
|
||||
if (json) {
|
||||
ElementPlus.ElMessage.success((enable ? '已启用: ' : '已禁用: ') + plugin.name);
|
||||
await load();
|
||||
}
|
||||
}
|
||||
|
||||
function openConfig(name) {
|
||||
dialogPluginName.value = name;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
return { plugins, loaded, toggle, openConfig, dialogVisible, dialogPluginName };
|
||||
},
|
||||
template: `
|
||||
<div class="panel-scroll">
|
||||
<template v-if="loaded">
|
||||
<el-card v-for="p in plugins" :key="p.name" shadow="hover" class="plugin-card">
|
||||
<div class="plugin-main">
|
||||
<div class="plugin-info">
|
||||
<div class="plugin-title">
|
||||
<span class="plugin-name">{{ p.name }}</span>
|
||||
<span class="plugin-version">
|
||||
v{{ p.version }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="plugin-desc">
|
||||
{{ p.description }}
|
||||
</div>
|
||||
<div class="plugin-meta">
|
||||
作者: {{ p.author }} · 目录: {{ p.directory }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-actions">
|
||||
<el-button v-if="p.has_config" size="small" @click="openConfig(p.name)">
|
||||
配置
|
||||
</el-button>
|
||||
<el-tag :type="p.enabled ? 'success' : 'danger'" size="small">
|
||||
{{ p.enabled ? '已启用' : '已禁用' }}
|
||||
</el-tag>
|
||||
<el-switch :model-value="p.enabled"
|
||||
@update:model-value="toggle(p, $event)"
|
||||
:disabled="p.name === 'ManagePlugin'"
|
||||
active-text="开" inactive-text="关" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
<div v-else class="panel-loading">
|
||||
加载中...
|
||||
</div>
|
||||
<PluginConfigDialog v-model:visible="dialogVisible" :plugin-name="dialogPluginName" />
|
||||
</div>
|
||||
`
|
||||
};
|
||||
138
utils/webui_static/components/SecuritySettings.js
Normal file
138
utils/webui_static/components/SecuritySettings.js
Normal file
@@ -0,0 +1,138 @@
|
||||
window.SecuritySettings = {
|
||||
emits: ['updated'],
|
||||
setup(props, { emit }) {
|
||||
const { ref, onMounted } = Vue;
|
||||
const api = useApi();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const currentUsername = ref('');
|
||||
const form = ref({
|
||||
current_password: '',
|
||||
new_username: '',
|
||||
new_password: '',
|
||||
confirm_password: '',
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
const json = await api.getAuthStatus();
|
||||
if (json) {
|
||||
currentUsername.value = json.username || '';
|
||||
form.value.new_username = json.username || '';
|
||||
}
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const payload = {
|
||||
current_password: form.value.current_password || '',
|
||||
new_username: (form.value.new_username || '').trim(),
|
||||
new_password: form.value.new_password || '',
|
||||
};
|
||||
|
||||
if (!payload.current_password) {
|
||||
ElementPlus.ElMessage.warning('请输入当前密码');
|
||||
return;
|
||||
}
|
||||
if (!payload.new_username) {
|
||||
ElementPlus.ElMessage.warning('请输入新账号');
|
||||
return;
|
||||
}
|
||||
if (payload.new_password.length < 8) {
|
||||
ElementPlus.ElMessage.warning('新密码至少 8 位');
|
||||
return;
|
||||
}
|
||||
if (payload.new_password !== form.value.confirm_password) {
|
||||
ElementPlus.ElMessage.warning('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
const json = await api.changeCredentials(payload);
|
||||
saving.value = false;
|
||||
if (!json) return;
|
||||
|
||||
currentUsername.value = json.username || payload.new_username;
|
||||
form.value.current_password = '';
|
||||
form.value.new_password = '';
|
||||
form.value.confirm_password = '';
|
||||
form.value.new_username = currentUsername.value;
|
||||
|
||||
emit('updated', currentUsername.value);
|
||||
window.dispatchEvent(new CustomEvent('webui-auth-updated', {
|
||||
detail: { username: currentUsername.value },
|
||||
}));
|
||||
ElementPlus.ElMessage.success('账号密码已更新');
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
return {
|
||||
loading,
|
||||
saving,
|
||||
currentUsername,
|
||||
form,
|
||||
save,
|
||||
};
|
||||
},
|
||||
template: `
|
||||
<div class="panel-scroll">
|
||||
<el-card shadow="never" class="security-wrap">
|
||||
<template #header>
|
||||
<div class="plugin-main">
|
||||
<span class="plugin-name">管理员账号安全</span>
|
||||
<el-tag size="small" type="info">当前账号: {{ currentUsername || '-' }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="6" animated />
|
||||
<div v-else>
|
||||
<el-alert
|
||||
title="密码仅保存为哈希值,修改后会立即写入 main_config.toml"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-bottom:16px;" />
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="当前密码">
|
||||
<el-input
|
||||
v-model="form.current_password"
|
||||
show-password
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入当前密码" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新账号">
|
||||
<el-input
|
||||
v-model="form.new_username"
|
||||
autocomplete="username"
|
||||
placeholder="请输入新账号" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新密码(至少 8 位)">
|
||||
<el-input
|
||||
v-model="form.new_password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
placeholder="请输入新密码" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="确认新密码">
|
||||
<el-input
|
||||
v-model="form.confirm_password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
placeholder="请再次输入新密码"
|
||||
@keyup.enter="save" />
|
||||
</el-form-item>
|
||||
|
||||
<el-button type="primary" :loading="saving" @click="save">
|
||||
保存账号密码
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
`,
|
||||
};
|
||||
67
utils/webui_static/composables/useApi.js
Normal file
67
utils/webui_static/composables/useApi.js
Normal file
@@ -0,0 +1,67 @@
|
||||
window.useApi = function useApi() {
|
||||
async function request(url, options = {}, extra = {}) {
|
||||
const { silent = false, skipAuthRedirect = false } = extra;
|
||||
try {
|
||||
const mergedHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers || {}),
|
||||
};
|
||||
const res = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
...options,
|
||||
headers: mergedHeaders,
|
||||
});
|
||||
|
||||
let json = null;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch (e) {
|
||||
if (!silent) ElementPlus.ElMessage.error('服务端返回格式错误');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
if (!skipAuthRedirect) {
|
||||
window.dispatchEvent(new CustomEvent('webui-auth-required'));
|
||||
}
|
||||
if (!silent) ElementPlus.ElMessage.error(json.error || '未登录或会话已过期');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!json.ok) {
|
||||
if (!silent) ElementPlus.ElMessage.error(json.error || '请求失败');
|
||||
return null;
|
||||
}
|
||||
return json;
|
||||
} catch (e) {
|
||||
if (!silent) ElementPlus.ElMessage.error('网络请求失败');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getAuthStatus: () => request('/api/auth/status', {}, { silent: true, skipAuthRedirect: true }),
|
||||
login: (username, password) => request('/api/auth/login', {
|
||||
method: 'POST', body: JSON.stringify({ username, password })
|
||||
}, { skipAuthRedirect: true }),
|
||||
logout: () => request('/api/auth/logout', { method: 'POST' }, { silent: true }),
|
||||
changeCredentials: (payload) => request('/api/auth/change-credentials', {
|
||||
method: 'POST', body: JSON.stringify(payload)
|
||||
}),
|
||||
|
||||
getConfig: () => request('/api/config'),
|
||||
saveConfig: (data) => request('/api/config', {
|
||||
method: 'POST', body: JSON.stringify({ data })
|
||||
}),
|
||||
getPlugins: () => request('/api/plugins'),
|
||||
togglePlugin: (name, enable) => request('/api/plugins/toggle', {
|
||||
method: 'POST', body: JSON.stringify({ name, enable })
|
||||
}),
|
||||
getPluginConfig: (name) =>
|
||||
request(`/api/plugins/${encodeURIComponent(name)}/config`),
|
||||
savePluginConfig: (name, data) =>
|
||||
request(`/api/plugins/${encodeURIComponent(name)}/config`, {
|
||||
method: 'POST', body: JSON.stringify({ data })
|
||||
}),
|
||||
};
|
||||
};
|
||||
35
utils/webui_static/composables/useWebSocket.js
Normal file
35
utils/webui_static/composables/useWebSocket.js
Normal file
@@ -0,0 +1,35 @@
|
||||
window.useWebSocket = function useWebSocket() {
|
||||
const { ref, onUnmounted } = Vue;
|
||||
|
||||
const connected = ref(false);
|
||||
const logs = ref([]);
|
||||
const paused = ref(false);
|
||||
let ws = null;
|
||||
let reconnectTimer = null;
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||
|
||||
ws.onopen = () => { connected.value = true; };
|
||||
ws.onclose = () => {
|
||||
connected.value = false;
|
||||
reconnectTimer = setTimeout(connect, 3000);
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
// 使用新数组引用,确保依赖 logs 的 watch/computed 能稳定触发(用于自动滚动到底部)
|
||||
const nextLogs = [...logs.value, e.data];
|
||||
logs.value = nextLogs.length > 2000 ? nextLogs.slice(-1500) : nextLogs;
|
||||
};
|
||||
}
|
||||
|
||||
function clear() { logs.value = []; }
|
||||
function togglePause() { paused.value = !paused.value; }
|
||||
|
||||
function destroy() {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (ws) ws.close();
|
||||
}
|
||||
|
||||
return { connected, logs, paused, connect, clear, togglePause, destroy };
|
||||
};
|
||||
32
utils/webui_static/index.html
Normal file
32
utils/webui_static/index.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WechatHookBot</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
|
||||
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260302_3">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://unpkg.com/element-plus"></script>
|
||||
<script src="https://unpkg.com/element-plus/dist/locale/zh-cn.js"></script>
|
||||
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
|
||||
|
||||
<script src="/static/composables/useWebSocket.js?v=20260302_3"></script>
|
||||
<script src="/static/composables/useApi.js?v=20260302_3"></script>
|
||||
<script src="/static/components/ConfigSection.js?v=20260302_3"></script>
|
||||
<script src="/static/components/LogViewer.js?v=20260302_3"></script>
|
||||
<script src="/static/components/ConfigEditor.js?v=20260302_3"></script>
|
||||
<script src="/static/components/PluginConfigDialog.js?v=20260302_3"></script>
|
||||
<script src="/static/components/PluginList.js?v=20260302_3"></script>
|
||||
<script src="/static/components/SecuritySettings.js?v=20260302_3"></script>
|
||||
<script src="/static/components/AppLayout.js?v=20260302_3"></script>
|
||||
<script src="/static/app.js?v=20260302_3"></script>
|
||||
</body>
|
||||
</html>
|
||||
591
utils/webui_static/style.css
Normal file
591
utils/webui_static/style.css
Normal file
@@ -0,0 +1,591 @@
|
||||
:root {
|
||||
--wb-primary: #0077ff;
|
||||
--wb-primary-alt: #00b8ff;
|
||||
--wb-ink: #0f2a4b;
|
||||
--wb-subtle: #5f7da3;
|
||||
--wb-page: #eef5ff;
|
||||
--wb-panel: rgba(255, 255, 255, 0.76);
|
||||
--wb-card: rgba(255, 255, 255, 0.92);
|
||||
--wb-border: #d7e4f6;
|
||||
--wb-border-strong: #c7d8ef;
|
||||
--wb-grid: rgba(0, 102, 255, 0.08);
|
||||
--wb-shadow-lg: 0 24px 56px rgba(24, 74, 150, 0.16);
|
||||
--wb-shadow-md: 0 12px 30px rgba(20, 76, 154, 0.12);
|
||||
|
||||
--el-color-primary: var(--wb-primary);
|
||||
--el-color-success: #17b26a;
|
||||
--el-color-warning: #f79009;
|
||||
--el-color-danger: #f04438;
|
||||
--el-bg-color: #f8fbff;
|
||||
--el-bg-color-page: var(--wb-page);
|
||||
--el-bg-color-overlay: #ffffff;
|
||||
--el-fill-color-blank: #ffffff;
|
||||
--el-border-color: var(--wb-border);
|
||||
--el-border-color-light: #e4edf9;
|
||||
--el-border-color-lighter: #edf3fb;
|
||||
--el-text-color-primary: var(--wb-ink);
|
||||
--el-text-color-regular: #26486f;
|
||||
--el-text-color-secondary: var(--wb-subtle);
|
||||
--el-text-color-placeholder: #8ca4c1;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-family: "Space Grotesk", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at 15% 12%, rgba(0, 167, 255, 0.2), transparent 42%),
|
||||
radial-gradient(circle at 85% 8%, rgba(0, 98, 255, 0.17), transparent 44%),
|
||||
linear-gradient(180deg, #f9fcff 0%, #edf4ff 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body::before,
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
body::before {
|
||||
background-image:
|
||||
linear-gradient(transparent 31px, var(--wb-grid) 32px),
|
||||
linear-gradient(90deg, transparent 31px, var(--wb-grid) 32px);
|
||||
background-size: 32px 32px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
body::after {
|
||||
background:
|
||||
radial-gradient(circle at 18% 78%, rgba(0, 168, 255, 0.16), transparent 34%),
|
||||
radial-gradient(circle at 80% 74%, rgba(0, 110, 255, 0.14), transparent 38%);
|
||||
}
|
||||
|
||||
.app-root {
|
||||
height: 100%;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
padding: 14px;
|
||||
gap: 12px;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.app-aside {
|
||||
border: 1px solid var(--wb-border);
|
||||
border-radius: 22px;
|
||||
overflow: hidden;
|
||||
background: var(--wb-panel);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: var(--wb-shadow-lg);
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
padding: 20px 16px 16px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #083a72;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.4px;
|
||||
text-transform: uppercase;
|
||||
color: #6284af;
|
||||
}
|
||||
|
||||
.app-menu {
|
||||
border-right: none !important;
|
||||
background: transparent !important;
|
||||
padding: 10px 8px 14px;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item {
|
||||
height: 42px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 12px;
|
||||
color: #2f557f;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item .el-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item:hover {
|
||||
background: rgba(0, 122, 255, 0.12);
|
||||
color: #005ac0;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item.is-active {
|
||||
color: #ffffff !important;
|
||||
background: linear-gradient(135deg, #0084ff 0%, #00b0ff 100%);
|
||||
box-shadow: 0 10px 22px rgba(0, 129, 255, 0.3);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
border: 1px solid var(--wb-border);
|
||||
border-radius: 22px;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--wb-panel);
|
||||
backdrop-filter: blur(14px);
|
||||
box-shadow: var(--wb-shadow-lg);
|
||||
}
|
||||
|
||||
.app-topbar {
|
||||
height: 58px;
|
||||
padding: 0 18px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 250, 255, 0.92));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
color: #6584ac;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.auth-pill {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: #0b4d94;
|
||||
border: 1px solid #b8d8ff;
|
||||
background: linear-gradient(120deg, #eaf6ff 0%, #f4fbff 100%);
|
||||
}
|
||||
|
||||
.content-stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.panel-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.panel-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 18px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.panel-loading {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--el-border-color-light);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
background: rgba(251, 253, 255, 0.8);
|
||||
}
|
||||
|
||||
.log-toolbar {
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(246, 250, 255, 0.8));
|
||||
}
|
||||
|
||||
.log-search {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.log-status {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 12px;
|
||||
color: #5f7ca1;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #9ab4d8;
|
||||
box-shadow: 0 0 0 4px rgba(154, 180, 216, 0.2);
|
||||
}
|
||||
|
||||
.log-status.is-online .status-dot {
|
||||
background: #0fb772;
|
||||
box-shadow: 0 0 0 4px rgba(15, 183, 114, 0.16);
|
||||
}
|
||||
|
||||
.log-status.is-offline .status-dot {
|
||||
background: #f04438;
|
||||
box-shadow: 0 0 0 4px rgba(240, 68, 56, 0.14);
|
||||
}
|
||||
|
||||
.log-stream {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px 16px;
|
||||
font-family: "JetBrains Mono", "Cascadia Code", "Consolas", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
padding: 2px 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.config-card {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.config-card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-card-title {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.config-card-section {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
gap: 12px;
|
||||
border-bottom: 1px dashed var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.config-key {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
color: #4f7097;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.config-val {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.config-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.plugin-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.plugin-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.plugin-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.plugin-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.plugin-version {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.plugin-desc {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.plugin-meta {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.plugin-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-body-scroll {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.security-wrap {
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(92vw, 430px);
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
color: #0d335f;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #6b87ab;
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--wb-border);
|
||||
background: var(--wb-card);
|
||||
box-shadow: var(--wb-shadow-md);
|
||||
}
|
||||
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
margin-right: 0;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(249, 252, 255, 0.86));
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.el-dialog__footer {
|
||||
padding: 10px 18px 16px;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-input-number__decrease,
|
||||
.el-input-number__increase,
|
||||
.el-input-number .el-input__wrapper {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-input-number .el-input__wrapper {
|
||||
box-shadow: 0 0 0 1px var(--wb-border-strong) inset !important;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus,
|
||||
.el-textarea__inner:focus,
|
||||
.el-input-number .el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 2px rgba(0, 119, 255, 0.22) inset !important;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
background: linear-gradient(135deg, var(--wb-primary) 0%, var(--wb-primary-alt) 100%);
|
||||
box-shadow: 0 10px 22px rgba(0, 123, 255, 0.26);
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
color: #ffffff;
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.el-alert {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-aside {
|
||||
width: 86px !important;
|
||||
min-width: 86px !important;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
padding: 14px 8px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-menu {
|
||||
padding: 8px 6px 10px;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item {
|
||||
justify-content: center;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item .el-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.app-menu .el-menu-item span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-topbar {
|
||||
height: 52px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.topbar-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.log-search {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.config-key {
|
||||
width: 132px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.log-toolbar {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.log-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-status {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.plugin-main {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plugin-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.config-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-key {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user