Merge branch 'dev' of https://github.com/Mai-with-u/MaiBot into dev
This commit is contained in:
4
bot.py
4
bot.py
@@ -212,6 +212,10 @@ if __name__ == "__main__":
|
|||||||
# 创建事件循环
|
# 创建事件循环
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
# 初始化 WebSocket 日志推送
|
||||||
|
from src.common.logger import initialize_ws_handler
|
||||||
|
initialize_ws_handler(loop)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 执行初始化和任务调度
|
# 执行初始化和任务调度
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ PROJECT_ROOT = logger_file.parent.parent.parent.resolve()
|
|||||||
# 全局handler实例,避免重复创建
|
# 全局handler实例,避免重复创建
|
||||||
_file_handler = None
|
_file_handler = None
|
||||||
_console_handler = None
|
_console_handler = None
|
||||||
|
_ws_handler = None
|
||||||
|
|
||||||
|
|
||||||
def get_file_handler():
|
def get_file_handler():
|
||||||
@@ -59,6 +60,35 @@ def get_console_handler():
|
|||||||
return _console_handler
|
return _console_handler
|
||||||
|
|
||||||
|
|
||||||
|
def get_ws_handler():
|
||||||
|
"""获取 WebSocket handler 单例"""
|
||||||
|
global _ws_handler
|
||||||
|
if _ws_handler is None:
|
||||||
|
_ws_handler = WebSocketLogHandler()
|
||||||
|
# WebSocket handler 推送所有级别的日志
|
||||||
|
_ws_handler.setLevel(logging.DEBUG)
|
||||||
|
return _ws_handler
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_ws_handler(loop):
|
||||||
|
"""初始化 WebSocket handler 的事件循环
|
||||||
|
|
||||||
|
Args:
|
||||||
|
loop: asyncio 事件循环
|
||||||
|
"""
|
||||||
|
handler = get_ws_handler()
|
||||||
|
handler.set_loop(loop)
|
||||||
|
|
||||||
|
# 为 WebSocket handler 设置 JSON 格式化器(与文件格式相同)
|
||||||
|
handler.setFormatter(file_formatter)
|
||||||
|
|
||||||
|
# 添加到根日志记录器
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
if handler not in root_logger.handlers:
|
||||||
|
root_logger.addHandler(handler)
|
||||||
|
print("[日志系统] ✅ WebSocket 日志推送已启用")
|
||||||
|
|
||||||
|
|
||||||
class TimestampedFileHandler(logging.Handler):
|
class TimestampedFileHandler(logging.Handler):
|
||||||
"""基于时间戳的文件处理器,简单的轮转份数限制"""
|
"""基于时间戳的文件处理器,简单的轮转份数限制"""
|
||||||
|
|
||||||
@@ -145,12 +175,78 @@ class TimestampedFileHandler(logging.Handler):
|
|||||||
super().close()
|
super().close()
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketLogHandler(logging.Handler):
|
||||||
|
"""WebSocket 日志处理器 - 将日志实时推送到前端"""
|
||||||
|
|
||||||
|
_log_counter = 0 # 类级别计数器,确保 ID 唯一性
|
||||||
|
|
||||||
|
def __init__(self, loop=None):
|
||||||
|
super().__init__()
|
||||||
|
self.loop = loop
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
def set_loop(self, loop):
|
||||||
|
"""设置事件循环"""
|
||||||
|
self.loop = loop
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
def emit(self, record):
|
||||||
|
"""发送日志到 WebSocket 客户端"""
|
||||||
|
if not self._initialized or self.loop is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 获取格式化后的消息
|
||||||
|
# 对于 structlog,formatted message 包含完整的日志信息
|
||||||
|
formatted_msg = self.format(record) if self.formatter else record.getMessage()
|
||||||
|
|
||||||
|
# 如果是 JSON 格式(文件格式化器),解析它
|
||||||
|
message = formatted_msg
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
log_dict = json.loads(formatted_msg)
|
||||||
|
message = log_dict.get('event', formatted_msg)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
# 不是 JSON,直接使用消息
|
||||||
|
message = formatted_msg
|
||||||
|
|
||||||
|
# 生成唯一 ID: 时间戳毫秒 + 自增计数器
|
||||||
|
WebSocketLogHandler._log_counter += 1
|
||||||
|
log_id = f"{int(record.created * 1000)}_{WebSocketLogHandler._log_counter}"
|
||||||
|
|
||||||
|
# 格式化日志数据
|
||||||
|
log_data = {
|
||||||
|
"id": log_id,
|
||||||
|
"timestamp": datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"level": record.levelname,
|
||||||
|
"module": record.name,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 异步广播日志(不阻塞日志记录)
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
from src.webui.logs_ws import broadcast_log
|
||||||
|
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
broadcast_log(log_data),
|
||||||
|
self.loop
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# WebSocket 推送失败不影响日志记录
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# 不要让 WebSocket 错误影响日志系统
|
||||||
|
self.handleError(record)
|
||||||
|
|
||||||
|
|
||||||
# 旧的轮转文件处理器已移除,现在使用基于时间戳的处理器
|
# 旧的轮转文件处理器已移除,现在使用基于时间戳的处理器
|
||||||
|
|
||||||
|
|
||||||
def close_handlers():
|
def close_handlers():
|
||||||
"""安全关闭所有handler"""
|
"""安全关闭所有handler"""
|
||||||
global _file_handler, _console_handler
|
global _file_handler, _console_handler, _ws_handler
|
||||||
|
|
||||||
if _file_handler:
|
if _file_handler:
|
||||||
_file_handler.close()
|
_file_handler.close()
|
||||||
@@ -159,6 +255,10 @@ def close_handlers():
|
|||||||
if _console_handler:
|
if _console_handler:
|
||||||
_console_handler.close()
|
_console_handler.close()
|
||||||
_console_handler = None
|
_console_handler = None
|
||||||
|
|
||||||
|
if _ws_handler:
|
||||||
|
_ws_handler.close()
|
||||||
|
_ws_handler = None
|
||||||
|
|
||||||
|
|
||||||
def remove_duplicate_handlers(): # sourcery skip: for-append-to-extend, list-comprehension
|
def remove_duplicate_handlers(): # sourcery skip: for-append-to-extend, list-comprehension
|
||||||
|
|||||||
483
src/webui/emoji_routes.py
Normal file
483
src/webui/emoji_routes.py
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
"""表情包管理 API 路由"""
|
||||||
|
from fastapi import APIRouter, HTTPException, Header, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from src.common.logger import get_logger
|
||||||
|
from src.common.database.database_model import Emoji
|
||||||
|
from .token_manager import get_token_manager
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = get_logger("webui.emoji")
|
||||||
|
|
||||||
|
# 创建路由器
|
||||||
|
router = APIRouter(prefix="/emoji", tags=["Emoji"])
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiResponse(BaseModel):
|
||||||
|
"""表情包响应"""
|
||||||
|
id: int
|
||||||
|
full_path: str
|
||||||
|
format: str
|
||||||
|
emoji_hash: str
|
||||||
|
description: str
|
||||||
|
query_count: int
|
||||||
|
is_registered: bool
|
||||||
|
is_banned: bool
|
||||||
|
emotion: Optional[List[str]] # 解析后的 JSON
|
||||||
|
record_time: float
|
||||||
|
register_time: Optional[float]
|
||||||
|
usage_count: int
|
||||||
|
last_used_time: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiListResponse(BaseModel):
|
||||||
|
"""表情包列表响应"""
|
||||||
|
success: bool
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
data: List[EmojiResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiDetailResponse(BaseModel):
|
||||||
|
"""表情包详情响应"""
|
||||||
|
success: bool
|
||||||
|
data: EmojiResponse
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiUpdateRequest(BaseModel):
|
||||||
|
"""表情包更新请求"""
|
||||||
|
description: Optional[str] = None
|
||||||
|
is_registered: Optional[bool] = None
|
||||||
|
is_banned: Optional[bool] = None
|
||||||
|
emotion: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiUpdateResponse(BaseModel):
|
||||||
|
"""表情包更新响应"""
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
data: Optional[EmojiResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiDeleteResponse(BaseModel):
|
||||||
|
"""表情包删除响应"""
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def verify_auth_token(authorization: Optional[str]) -> bool:
|
||||||
|
"""验证认证 Token"""
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="未提供有效的认证信息")
|
||||||
|
|
||||||
|
token = authorization.replace("Bearer ", "")
|
||||||
|
token_manager = get_token_manager()
|
||||||
|
|
||||||
|
if not token_manager.verify_token(token):
|
||||||
|
raise HTTPException(status_code=401, detail="Token 无效或已过期")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_emotion(emotion_str: Optional[str]) -> Optional[List[str]]:
|
||||||
|
"""解析情感标签 JSON 字符串"""
|
||||||
|
if not emotion_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(emotion_str)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def emoji_to_response(emoji: Emoji) -> EmojiResponse:
|
||||||
|
"""将 Emoji 模型转换为响应对象"""
|
||||||
|
return EmojiResponse(
|
||||||
|
id=emoji.id,
|
||||||
|
full_path=emoji.full_path,
|
||||||
|
format=emoji.format,
|
||||||
|
emoji_hash=emoji.emoji_hash,
|
||||||
|
description=emoji.description,
|
||||||
|
query_count=emoji.query_count,
|
||||||
|
is_registered=emoji.is_registered,
|
||||||
|
is_banned=emoji.is_banned,
|
||||||
|
emotion=parse_emotion(emoji.emotion),
|
||||||
|
record_time=emoji.record_time,
|
||||||
|
register_time=emoji.register_time,
|
||||||
|
usage_count=emoji.usage_count,
|
||||||
|
last_used_time=emoji.last_used_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list", response_model=EmojiListResponse)
|
||||||
|
async def get_emoji_list(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
search: Optional[str] = Query(None, description="搜索关键词"),
|
||||||
|
is_registered: Optional[bool] = Query(None, description="是否已注册筛选"),
|
||||||
|
is_banned: Optional[bool] = Query(None, description="是否被禁用筛选"),
|
||||||
|
format: Optional[str] = Query(None, description="格式筛选"),
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取表情包列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: 页码 (从 1 开始)
|
||||||
|
page_size: 每页数量 (1-100)
|
||||||
|
search: 搜索关键词 (匹配 description, emoji_hash)
|
||||||
|
is_registered: 是否已注册筛选
|
||||||
|
is_banned: 是否被禁用筛选
|
||||||
|
format: 格式筛选
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
表情包列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
# 构建查询
|
||||||
|
query = Emoji.select()
|
||||||
|
|
||||||
|
# 搜索过滤
|
||||||
|
if search:
|
||||||
|
query = query.where(
|
||||||
|
(Emoji.description.contains(search)) |
|
||||||
|
(Emoji.emoji_hash.contains(search))
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注册状态过滤
|
||||||
|
if is_registered is not None:
|
||||||
|
query = query.where(Emoji.is_registered == is_registered)
|
||||||
|
|
||||||
|
# 禁用状态过滤
|
||||||
|
if is_banned is not None:
|
||||||
|
query = query.where(Emoji.is_banned == is_banned)
|
||||||
|
|
||||||
|
# 格式过滤
|
||||||
|
if format:
|
||||||
|
query = query.where(Emoji.format == format)
|
||||||
|
|
||||||
|
# 排序:使用次数倒序,然后按记录时间倒序
|
||||||
|
from peewee import Case
|
||||||
|
query = query.order_by(
|
||||||
|
Emoji.usage_count.desc(),
|
||||||
|
Case(None, [(Emoji.record_time.is_null(), 1)], 0),
|
||||||
|
Emoji.record_time.desc()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取总数
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
# 分页
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
emojis = query.offset(offset).limit(page_size)
|
||||||
|
|
||||||
|
# 转换为响应对象
|
||||||
|
data = [emoji_to_response(emoji) for emoji in emojis]
|
||||||
|
|
||||||
|
return EmojiListResponse(
|
||||||
|
success=True,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"获取表情包列表失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取表情包列表失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{emoji_id}", response_model=EmojiDetailResponse)
|
||||||
|
async def get_emoji_detail(
|
||||||
|
emoji_id: int,
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取表情包详细信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emoji_id: 表情包ID
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
表情包详细信息
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
emoji = Emoji.get_or_none(Emoji.id == emoji_id)
|
||||||
|
|
||||||
|
if not emoji:
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包")
|
||||||
|
|
||||||
|
return EmojiDetailResponse(
|
||||||
|
success=True,
|
||||||
|
data=emoji_to_response(emoji)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"获取表情包详情失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取表情包详情失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{emoji_id}", response_model=EmojiUpdateResponse)
|
||||||
|
async def update_emoji(
|
||||||
|
emoji_id: int,
|
||||||
|
request: EmojiUpdateRequest,
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
增量更新表情包(只更新提供的字段)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emoji_id: 表情包ID
|
||||||
|
request: 更新请求(只包含需要更新的字段)
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
emoji = Emoji.get_or_none(Emoji.id == emoji_id)
|
||||||
|
|
||||||
|
if not emoji:
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包")
|
||||||
|
|
||||||
|
# 只更新提供的字段
|
||||||
|
update_data = request.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if not update_data:
|
||||||
|
raise HTTPException(status_code=400, detail="未提供任何需要更新的字段")
|
||||||
|
|
||||||
|
# 处理情感标签(转换为 JSON)
|
||||||
|
if 'emotion' in update_data:
|
||||||
|
if update_data['emotion'] is None:
|
||||||
|
update_data['emotion'] = None
|
||||||
|
else:
|
||||||
|
update_data['emotion'] = json.dumps(update_data['emotion'], ensure_ascii=False)
|
||||||
|
|
||||||
|
# 如果注册状态从 False 变为 True,记录注册时间
|
||||||
|
if 'is_registered' in update_data and update_data['is_registered'] and not emoji.is_registered:
|
||||||
|
update_data['register_time'] = time.time()
|
||||||
|
|
||||||
|
# 执行更新
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(emoji, field, value)
|
||||||
|
|
||||||
|
emoji.save()
|
||||||
|
|
||||||
|
logger.info(f"表情包已更新: ID={emoji_id}, 字段: {list(update_data.keys())}")
|
||||||
|
|
||||||
|
return EmojiUpdateResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"成功更新 {len(update_data)} 个字段",
|
||||||
|
data=emoji_to_response(emoji)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"更新表情包失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"更新表情包失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{emoji_id}", response_model=EmojiDeleteResponse)
|
||||||
|
async def delete_emoji(
|
||||||
|
emoji_id: int,
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除表情包
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emoji_id: 表情包ID
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
删除结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
emoji = Emoji.get_or_none(Emoji.id == emoji_id)
|
||||||
|
|
||||||
|
if not emoji:
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包")
|
||||||
|
|
||||||
|
# 记录删除信息
|
||||||
|
emoji_hash = emoji.emoji_hash
|
||||||
|
|
||||||
|
# 执行删除
|
||||||
|
emoji.delete_instance()
|
||||||
|
|
||||||
|
logger.info(f"表情包已删除: ID={emoji_id}, hash={emoji_hash}")
|
||||||
|
|
||||||
|
return EmojiDeleteResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"成功删除表情包: {emoji_hash}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"删除表情包失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"删除表情包失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/summary")
|
||||||
|
async def get_emoji_stats(
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取表情包统计数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统计数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
total = Emoji.select().count()
|
||||||
|
registered = Emoji.select().where(Emoji.is_registered).count()
|
||||||
|
banned = Emoji.select().where(Emoji.is_banned).count()
|
||||||
|
|
||||||
|
# 按格式统计
|
||||||
|
formats = {}
|
||||||
|
for emoji in Emoji.select(Emoji.format):
|
||||||
|
fmt = emoji.format
|
||||||
|
formats[fmt] = formats.get(fmt, 0) + 1
|
||||||
|
|
||||||
|
# 获取最常用的表情包(前10)
|
||||||
|
top_used = Emoji.select().order_by(Emoji.usage_count.desc()).limit(10)
|
||||||
|
top_used_list = [
|
||||||
|
{
|
||||||
|
"id": emoji.id,
|
||||||
|
"emoji_hash": emoji.emoji_hash,
|
||||||
|
"description": emoji.description,
|
||||||
|
"usage_count": emoji.usage_count
|
||||||
|
}
|
||||||
|
for emoji in top_used
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"total": total,
|
||||||
|
"registered": registered,
|
||||||
|
"banned": banned,
|
||||||
|
"unregistered": total - registered,
|
||||||
|
"formats": formats,
|
||||||
|
"top_used": top_used_list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"获取统计数据失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{emoji_id}/register", response_model=EmojiUpdateResponse)
|
||||||
|
async def register_emoji(
|
||||||
|
emoji_id: int,
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
注册表情包(快捷操作)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emoji_id: 表情包ID
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
emoji = Emoji.get_or_none(Emoji.id == emoji_id)
|
||||||
|
|
||||||
|
if not emoji:
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包")
|
||||||
|
|
||||||
|
if emoji.is_registered:
|
||||||
|
raise HTTPException(status_code=400, detail="该表情包已经注册")
|
||||||
|
|
||||||
|
if emoji.is_banned:
|
||||||
|
raise HTTPException(status_code=400, detail="该表情包已被禁用,无法注册")
|
||||||
|
|
||||||
|
# 注册表情包
|
||||||
|
emoji.is_registered = True
|
||||||
|
emoji.register_time = time.time()
|
||||||
|
emoji.save()
|
||||||
|
|
||||||
|
logger.info(f"表情包已注册: ID={emoji_id}")
|
||||||
|
|
||||||
|
return EmojiUpdateResponse(
|
||||||
|
success=True,
|
||||||
|
message="表情包注册成功",
|
||||||
|
data=emoji_to_response(emoji)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"注册表情包失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"注册表情包失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{emoji_id}/ban", response_model=EmojiUpdateResponse)
|
||||||
|
async def ban_emoji(
|
||||||
|
emoji_id: int,
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
禁用表情包(快捷操作)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
emoji_id: 表情包ID
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
emoji = Emoji.get_or_none(Emoji.id == emoji_id)
|
||||||
|
|
||||||
|
if not emoji:
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {emoji_id} 的表情包")
|
||||||
|
|
||||||
|
# 禁用表情包(同时取消注册)
|
||||||
|
emoji.is_banned = True
|
||||||
|
emoji.is_registered = False
|
||||||
|
emoji.save()
|
||||||
|
|
||||||
|
logger.info(f"表情包已禁用: ID={emoji_id}")
|
||||||
|
|
||||||
|
return EmojiUpdateResponse(
|
||||||
|
success=True,
|
||||||
|
message="表情包禁用成功",
|
||||||
|
data=emoji_to_response(emoji)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"禁用表情包失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"禁用表情包失败: {str(e)}") from e
|
||||||
404
src/webui/expression_routes.py
Normal file
404
src/webui/expression_routes.py
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
"""表达方式管理 API 路由"""
|
||||||
|
from fastapi import APIRouter, HTTPException, Header, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from src.common.logger import get_logger
|
||||||
|
from src.common.database.database_model import Expression
|
||||||
|
from .token_manager import get_token_manager
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = get_logger("webui.expression")
|
||||||
|
|
||||||
|
# 创建路由器
|
||||||
|
router = APIRouter(prefix="/expression", tags=["Expression"])
|
||||||
|
|
||||||
|
|
||||||
|
class ExpressionResponse(BaseModel):
|
||||||
|
"""表达方式响应"""
|
||||||
|
id: int
|
||||||
|
situation: str
|
||||||
|
style: str
|
||||||
|
context: Optional[str]
|
||||||
|
up_content: Optional[str]
|
||||||
|
last_active_time: float
|
||||||
|
chat_id: str
|
||||||
|
create_date: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class ExpressionListResponse(BaseModel):
|
||||||
|
"""表达方式列表响应"""
|
||||||
|
success: bool
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
data: List[ExpressionResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class ExpressionDetailResponse(BaseModel):
|
||||||
|
"""表达方式详情响应"""
|
||||||
|
success: bool
|
||||||
|
data: ExpressionResponse
|
||||||
|
|
||||||
|
|
||||||
|
class ExpressionCreateRequest(BaseModel):
|
||||||
|
"""表达方式创建请求"""
|
||||||
|
situation: str
|
||||||
|
style: str
|
||||||
|
context: Optional[str] = None
|
||||||
|
up_content: Optional[str] = None
|
||||||
|
chat_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExpressionUpdateRequest(BaseModel):
|
||||||
|
"""表达方式更新请求"""
|
||||||
|
situation: Optional[str] = None
|
||||||
|
style: Optional[str] = None
|
||||||
|
context: Optional[str] = None
|
||||||
|
up_content: Optional[str] = None
|
||||||
|
chat_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExpressionUpdateResponse(BaseModel):
|
||||||
|
"""表达方式更新响应"""
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
data: Optional[ExpressionResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExpressionDeleteResponse(BaseModel):
|
||||||
|
"""表达方式删除响应"""
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExpressionCreateResponse(BaseModel):
|
||||||
|
"""表达方式创建响应"""
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
data: ExpressionResponse
|
||||||
|
|
||||||
|
|
||||||
|
def verify_auth_token(authorization: Optional[str]) -> bool:
|
||||||
|
"""验证认证 Token"""
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="未提供有效的认证信息")
|
||||||
|
|
||||||
|
token = authorization.replace("Bearer ", "")
|
||||||
|
token_manager = get_token_manager()
|
||||||
|
|
||||||
|
if not token_manager.verify_token(token):
|
||||||
|
raise HTTPException(status_code=401, detail="Token 无效或已过期")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def expression_to_response(expression: Expression) -> ExpressionResponse:
|
||||||
|
"""将 Expression 模型转换为响应对象"""
|
||||||
|
return ExpressionResponse(
|
||||||
|
id=expression.id,
|
||||||
|
situation=expression.situation,
|
||||||
|
style=expression.style,
|
||||||
|
context=expression.context,
|
||||||
|
up_content=expression.up_content,
|
||||||
|
last_active_time=expression.last_active_time,
|
||||||
|
chat_id=expression.chat_id,
|
||||||
|
create_date=expression.create_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list", response_model=ExpressionListResponse)
|
||||||
|
async def get_expression_list(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
search: Optional[str] = Query(None, description="搜索关键词"),
|
||||||
|
chat_id: Optional[str] = Query(None, description="聊天ID筛选"),
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取表达方式列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: 页码 (从 1 开始)
|
||||||
|
page_size: 每页数量 (1-100)
|
||||||
|
search: 搜索关键词 (匹配 situation, style, context)
|
||||||
|
chat_id: 聊天ID筛选
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
表达方式列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
# 构建查询
|
||||||
|
query = Expression.select()
|
||||||
|
|
||||||
|
# 搜索过滤
|
||||||
|
if search:
|
||||||
|
query = query.where(
|
||||||
|
(Expression.situation.contains(search)) |
|
||||||
|
(Expression.style.contains(search)) |
|
||||||
|
(Expression.context.contains(search))
|
||||||
|
)
|
||||||
|
|
||||||
|
# 聊天ID过滤
|
||||||
|
if chat_id:
|
||||||
|
query = query.where(Expression.chat_id == chat_id)
|
||||||
|
|
||||||
|
# 排序:最后活跃时间倒序(NULL 值放在最后)
|
||||||
|
from peewee import Case
|
||||||
|
query = query.order_by(
|
||||||
|
Case(None, [(Expression.last_active_time.is_null(), 1)], 0),
|
||||||
|
Expression.last_active_time.desc()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取总数
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
# 分页
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
expressions = query.offset(offset).limit(page_size)
|
||||||
|
|
||||||
|
# 转换为响应对象
|
||||||
|
data = [expression_to_response(expr) for expr in expressions]
|
||||||
|
|
||||||
|
return ExpressionListResponse(
|
||||||
|
success=True,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"获取表达方式列表失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取表达方式列表失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{expression_id}", response_model=ExpressionDetailResponse)
|
||||||
|
async def get_expression_detail(
|
||||||
|
expression_id: int,
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取表达方式详细信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expression_id: 表达方式ID
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
表达方式详细信息
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
expression = Expression.get_or_none(Expression.id == expression_id)
|
||||||
|
|
||||||
|
if not expression:
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式")
|
||||||
|
|
||||||
|
return ExpressionDetailResponse(
|
||||||
|
success=True,
|
||||||
|
data=expression_to_response(expression)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"获取表达方式详情失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取表达方式详情失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=ExpressionCreateResponse)
|
||||||
|
async def create_expression(
|
||||||
|
request: ExpressionCreateRequest,
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
创建新的表达方式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: 创建请求
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
创建结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# 创建表达方式
|
||||||
|
expression = Expression.create(
|
||||||
|
situation=request.situation,
|
||||||
|
style=request.style,
|
||||||
|
context=request.context,
|
||||||
|
up_content=request.up_content,
|
||||||
|
chat_id=request.chat_id,
|
||||||
|
last_active_time=current_time,
|
||||||
|
create_date=current_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"表达方式已创建: ID={expression.id}, situation={request.situation}")
|
||||||
|
|
||||||
|
return ExpressionCreateResponse(
|
||||||
|
success=True,
|
||||||
|
message="表达方式创建成功",
|
||||||
|
data=expression_to_response(expression)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"创建表达方式失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"创建表达方式失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{expression_id}", response_model=ExpressionUpdateResponse)
|
||||||
|
async def update_expression(
|
||||||
|
expression_id: int,
|
||||||
|
request: ExpressionUpdateRequest,
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
增量更新表达方式(只更新提供的字段)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expression_id: 表达方式ID
|
||||||
|
request: 更新请求(只包含需要更新的字段)
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
expression = Expression.get_or_none(Expression.id == expression_id)
|
||||||
|
|
||||||
|
if not expression:
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式")
|
||||||
|
|
||||||
|
# 只更新提供的字段
|
||||||
|
update_data = request.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if not update_data:
|
||||||
|
raise HTTPException(status_code=400, detail="未提供任何需要更新的字段")
|
||||||
|
|
||||||
|
# 更新最后活跃时间
|
||||||
|
update_data['last_active_time'] = time.time()
|
||||||
|
|
||||||
|
# 执行更新
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(expression, field, value)
|
||||||
|
|
||||||
|
expression.save()
|
||||||
|
|
||||||
|
logger.info(f"表达方式已更新: ID={expression_id}, 字段: {list(update_data.keys())}")
|
||||||
|
|
||||||
|
return ExpressionUpdateResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"成功更新 {len(update_data)} 个字段",
|
||||||
|
data=expression_to_response(expression)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"更新表达方式失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"更新表达方式失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{expression_id}", response_model=ExpressionDeleteResponse)
|
||||||
|
async def delete_expression(
|
||||||
|
expression_id: int,
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除表达方式
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expression_id: 表达方式ID
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
删除结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
expression = Expression.get_or_none(Expression.id == expression_id)
|
||||||
|
|
||||||
|
if not expression:
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {expression_id} 的表达方式")
|
||||||
|
|
||||||
|
# 记录删除信息
|
||||||
|
situation = expression.situation
|
||||||
|
|
||||||
|
# 执行删除
|
||||||
|
expression.delete_instance()
|
||||||
|
|
||||||
|
logger.info(f"表达方式已删除: ID={expression_id}, situation={situation}")
|
||||||
|
|
||||||
|
return ExpressionDeleteResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"成功删除表达方式: {situation}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"删除表达方式失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"删除表达方式失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/summary")
|
||||||
|
async def get_expression_stats(
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取表达方式统计数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统计数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
total = Expression.select().count()
|
||||||
|
|
||||||
|
# 按 chat_id 统计
|
||||||
|
chat_stats = {}
|
||||||
|
for expr in Expression.select(Expression.chat_id):
|
||||||
|
chat_id = expr.chat_id
|
||||||
|
chat_stats[chat_id] = chat_stats.get(chat_id, 0) + 1
|
||||||
|
|
||||||
|
# 获取最近创建的记录数(7天内)
|
||||||
|
seven_days_ago = time.time() - (7 * 24 * 60 * 60)
|
||||||
|
recent = Expression.select().where(
|
||||||
|
(Expression.create_date.is_null(False)) &
|
||||||
|
(Expression.create_date >= seven_days_ago)
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"total": total,
|
||||||
|
"recent_7days": recent,
|
||||||
|
"chat_count": len(chat_stats),
|
||||||
|
"top_chats": dict(sorted(chat_stats.items(), key=lambda x: x[1], reverse=True)[:10])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"获取统计数据失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e
|
||||||
0
src/webui/log_broadcaster.py
Normal file
0
src/webui/log_broadcaster.py
Normal file
0
src/webui/logs_routes.py
Normal file
0
src/webui/logs_routes.py
Normal file
138
src/webui/logs_ws.py
Normal file
138
src/webui/logs_ws.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""WebSocket 日志推送模块"""
|
||||||
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
from typing import Set
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from src.common.logger import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("webui.logs_ws")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# 全局 WebSocket 连接池
|
||||||
|
active_connections: Set[WebSocket] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def load_recent_logs(limit: int = 100) -> list[dict]:
|
||||||
|
"""从日志文件中加载最近的日志
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: 返回的最大日志条数
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
日志列表
|
||||||
|
"""
|
||||||
|
logs = []
|
||||||
|
log_dir = Path("logs")
|
||||||
|
|
||||||
|
if not log_dir.exists():
|
||||||
|
return logs
|
||||||
|
|
||||||
|
# 获取所有日志文件,按修改时间排序
|
||||||
|
log_files = sorted(log_dir.glob("app_*.log.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
# 用于生成唯一 ID 的计数器
|
||||||
|
log_counter = 0
|
||||||
|
|
||||||
|
# 从最新的文件开始读取
|
||||||
|
for log_file in log_files:
|
||||||
|
if len(logs) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(log_file, "r", encoding="utf-8") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
# 从文件末尾开始读取
|
||||||
|
for line in reversed(lines):
|
||||||
|
if len(logs) >= limit:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
log_entry = json.loads(line.strip())
|
||||||
|
# 转换为前端期望的格式
|
||||||
|
# 使用时间戳 + 计数器生成唯一 ID
|
||||||
|
timestamp_id = log_entry.get("timestamp", "0").replace("-", "").replace(" ", "").replace(":", "")
|
||||||
|
formatted_log = {
|
||||||
|
"id": f"{timestamp_id}_{log_counter}",
|
||||||
|
"timestamp": log_entry.get("timestamp", ""),
|
||||||
|
"level": log_entry.get("level", "INFO").upper(),
|
||||||
|
"module": log_entry.get("logger_name", ""),
|
||||||
|
"message": log_entry.get("event", ""),
|
||||||
|
}
|
||||||
|
logs.append(formatted_log)
|
||||||
|
log_counter += 1
|
||||||
|
except (json.JSONDecodeError, KeyError):
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"读取日志文件失败 {log_file}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 反转列表,使其按时间顺序排列(旧到新)
|
||||||
|
return list(reversed(logs))
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/ws/logs")
|
||||||
|
async def websocket_logs(websocket: WebSocket):
|
||||||
|
"""WebSocket 日志推送端点
|
||||||
|
|
||||||
|
客户端连接后会持续接收服务器端的日志消息
|
||||||
|
"""
|
||||||
|
await websocket.accept()
|
||||||
|
active_connections.add(websocket)
|
||||||
|
logger.info(f"📡 WebSocket 客户端已连接,当前连接数: {len(active_connections)}")
|
||||||
|
|
||||||
|
# 连接建立后,立即发送历史日志
|
||||||
|
try:
|
||||||
|
recent_logs = load_recent_logs(limit=100)
|
||||||
|
logger.info(f"发送 {len(recent_logs)} 条历史日志到客户端")
|
||||||
|
|
||||||
|
for log_entry in recent_logs:
|
||||||
|
await websocket.send_text(json.dumps(log_entry, ensure_ascii=False))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发送历史日志失败: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 保持连接,等待客户端消息或断开
|
||||||
|
while True:
|
||||||
|
# 接收客户端消息(用于心跳或控制指令)
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
|
||||||
|
# 可以处理客户端的控制消息,例如:
|
||||||
|
# - "ping" -> 心跳检测
|
||||||
|
# - {"filter": "ERROR"} -> 设置日志级别过滤
|
||||||
|
if data == "ping":
|
||||||
|
await websocket.send_text("pong")
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
active_connections.discard(websocket)
|
||||||
|
logger.info(f"📡 WebSocket 客户端已断开,当前连接数: {len(active_connections)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ WebSocket 错误: {e}")
|
||||||
|
active_connections.discard(websocket)
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_log(log_data: dict):
|
||||||
|
"""广播日志到所有连接的 WebSocket 客户端
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_data: 日志数据字典
|
||||||
|
"""
|
||||||
|
if not active_connections:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 格式化为 JSON
|
||||||
|
message = json.dumps(log_data, ensure_ascii=False)
|
||||||
|
|
||||||
|
# 记录需要断开的连接
|
||||||
|
disconnected = set()
|
||||||
|
|
||||||
|
# 广播到所有客户端
|
||||||
|
for connection in active_connections:
|
||||||
|
try:
|
||||||
|
await connection.send_text(message)
|
||||||
|
except Exception:
|
||||||
|
# 发送失败,标记为断开
|
||||||
|
disconnected.add(connection)
|
||||||
|
|
||||||
|
# 清理断开的连接
|
||||||
|
if disconnected:
|
||||||
|
active_connections.difference_update(disconnected)
|
||||||
|
logger.debug(f"清理了 {len(disconnected)} 个断开的 WebSocket 连接")
|
||||||
@@ -31,6 +31,14 @@ def setup_webui(mode: str = "production") -> bool:
|
|||||||
|
|
||||||
def setup_dev_mode() -> bool:
|
def setup_dev_mode() -> bool:
|
||||||
"""设置开发模式 - 仅启用 CORS,前端自行启动"""
|
"""设置开发模式 - 仅启用 CORS,前端自行启动"""
|
||||||
|
from src.common.server import get_global_server
|
||||||
|
from .logs_ws import router as logs_router
|
||||||
|
|
||||||
|
# 注册 WebSocket 日志路由(开发模式也需要)
|
||||||
|
server = get_global_server()
|
||||||
|
server.register_router(logs_router)
|
||||||
|
logger.info("✅ WebSocket 日志推送路由已注册")
|
||||||
|
|
||||||
logger.info("📝 WebUI 开发模式已启用")
|
logger.info("📝 WebUI 开发模式已启用")
|
||||||
logger.info("🌐 请手动启动前端开发服务器: cd webui && npm run dev")
|
logger.info("🌐 请手动启动前端开发服务器: cd webui && npm run dev")
|
||||||
logger.info("💡 前端将运行在 http://localhost:7999")
|
logger.info("💡 前端将运行在 http://localhost:7999")
|
||||||
@@ -42,6 +50,7 @@ def setup_production_mode() -> bool:
|
|||||||
try:
|
try:
|
||||||
from src.common.server import get_global_server
|
from src.common.server import get_global_server
|
||||||
from starlette.responses import FileResponse
|
from starlette.responses import FileResponse
|
||||||
|
from .logs_ws import router as logs_router
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
||||||
# 确保正确的 MIME 类型映射
|
# 确保正确的 MIME 类型映射
|
||||||
@@ -52,6 +61,11 @@ def setup_production_mode() -> bool:
|
|||||||
mimetypes.add_type('application/json', '.json')
|
mimetypes.add_type('application/json', '.json')
|
||||||
|
|
||||||
server = get_global_server()
|
server = get_global_server()
|
||||||
|
|
||||||
|
# 注册 WebSocket 日志路由
|
||||||
|
server.register_router(logs_router)
|
||||||
|
logger.info("✅ WebSocket 日志推送路由已注册")
|
||||||
|
|
||||||
base_dir = Path(__file__).parent.parent.parent
|
base_dir = Path(__file__).parent.parent.parent
|
||||||
static_path = base_dir / "webui" / "dist"
|
static_path = base_dir / "webui" / "dist"
|
||||||
|
|
||||||
|
|||||||
365
src/webui/person_routes.py
Normal file
365
src/webui/person_routes.py
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
"""人物信息管理 API 路由"""
|
||||||
|
from fastapi import APIRouter, HTTPException, Header, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
from src.common.logger import get_logger
|
||||||
|
from src.common.database.database_model import PersonInfo
|
||||||
|
from .token_manager import get_token_manager
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = get_logger("webui.person")
|
||||||
|
|
||||||
|
# 创建路由器
|
||||||
|
router = APIRouter(prefix="/person", tags=["Person"])
|
||||||
|
|
||||||
|
|
||||||
|
class PersonInfoResponse(BaseModel):
|
||||||
|
"""人物信息响应"""
|
||||||
|
id: int
|
||||||
|
is_known: bool
|
||||||
|
person_id: str
|
||||||
|
person_name: Optional[str]
|
||||||
|
name_reason: Optional[str]
|
||||||
|
platform: str
|
||||||
|
user_id: str
|
||||||
|
nickname: Optional[str]
|
||||||
|
group_nick_name: Optional[List[Dict[str, str]]] # 解析后的 JSON
|
||||||
|
memory_points: Optional[str]
|
||||||
|
know_times: Optional[float]
|
||||||
|
know_since: Optional[float]
|
||||||
|
last_know: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class PersonListResponse(BaseModel):
|
||||||
|
"""人物列表响应"""
|
||||||
|
success: bool
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
data: List[PersonInfoResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class PersonDetailResponse(BaseModel):
|
||||||
|
"""人物详情响应"""
|
||||||
|
success: bool
|
||||||
|
data: PersonInfoResponse
|
||||||
|
|
||||||
|
|
||||||
|
class PersonUpdateRequest(BaseModel):
|
||||||
|
"""人物信息更新请求"""
|
||||||
|
person_name: Optional[str] = None
|
||||||
|
name_reason: Optional[str] = None
|
||||||
|
nickname: Optional[str] = None
|
||||||
|
memory_points: Optional[str] = None
|
||||||
|
is_known: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonUpdateResponse(BaseModel):
|
||||||
|
"""人物信息更新响应"""
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
data: Optional[PersonInfoResponse] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonDeleteResponse(BaseModel):
|
||||||
|
"""人物删除响应"""
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
def verify_auth_token(authorization: Optional[str]) -> bool:
|
||||||
|
"""验证认证 Token"""
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=401, detail="未提供有效的认证信息")
|
||||||
|
|
||||||
|
token = authorization.replace("Bearer ", "")
|
||||||
|
token_manager = get_token_manager()
|
||||||
|
|
||||||
|
if not token_manager.verify_token(token):
|
||||||
|
raise HTTPException(status_code=401, detail="Token 无效或已过期")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def parse_group_nick_name(group_nick_name_str: Optional[str]) -> Optional[List[Dict[str, str]]]:
|
||||||
|
"""解析群昵称 JSON 字符串"""
|
||||||
|
if not group_nick_name_str:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(group_nick_name_str)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def person_to_response(person: PersonInfo) -> PersonInfoResponse:
|
||||||
|
"""将 PersonInfo 模型转换为响应对象"""
|
||||||
|
return PersonInfoResponse(
|
||||||
|
id=person.id,
|
||||||
|
is_known=person.is_known,
|
||||||
|
person_id=person.person_id,
|
||||||
|
person_name=person.person_name,
|
||||||
|
name_reason=person.name_reason,
|
||||||
|
platform=person.platform,
|
||||||
|
user_id=person.user_id,
|
||||||
|
nickname=person.nickname,
|
||||||
|
group_nick_name=parse_group_nick_name(person.group_nick_name),
|
||||||
|
memory_points=person.memory_points,
|
||||||
|
know_times=person.know_times,
|
||||||
|
know_since=person.know_since,
|
||||||
|
last_know=person.last_know,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/list", response_model=PersonListResponse)
|
||||||
|
async def get_person_list(
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||||
|
search: Optional[str] = Query(None, description="搜索关键词"),
|
||||||
|
is_known: Optional[bool] = Query(None, description="是否已认识筛选"),
|
||||||
|
platform: Optional[str] = Query(None, description="平台筛选"),
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取人物信息列表
|
||||||
|
|
||||||
|
Args:
|
||||||
|
page: 页码 (从 1 开始)
|
||||||
|
page_size: 每页数量 (1-100)
|
||||||
|
search: 搜索关键词 (匹配 person_name, nickname, user_id)
|
||||||
|
is_known: 是否已认识筛选
|
||||||
|
platform: 平台筛选
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
人物信息列表
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
# 构建查询
|
||||||
|
query = PersonInfo.select()
|
||||||
|
|
||||||
|
# 搜索过滤
|
||||||
|
if search:
|
||||||
|
query = query.where(
|
||||||
|
(PersonInfo.person_name.contains(search)) |
|
||||||
|
(PersonInfo.nickname.contains(search)) |
|
||||||
|
(PersonInfo.user_id.contains(search))
|
||||||
|
)
|
||||||
|
|
||||||
|
# 已认识状态过滤
|
||||||
|
if is_known is not None:
|
||||||
|
query = query.where(PersonInfo.is_known == is_known)
|
||||||
|
|
||||||
|
# 平台过滤
|
||||||
|
if platform:
|
||||||
|
query = query.where(PersonInfo.platform == platform)
|
||||||
|
|
||||||
|
# 排序:最后更新时间倒序(NULL 值放在最后)
|
||||||
|
# Peewee 不支持 nulls_last,使用 CASE WHEN 来实现
|
||||||
|
from peewee import Case
|
||||||
|
query = query.order_by(
|
||||||
|
Case(None, [(PersonInfo.last_know.is_null(), 1)], 0),
|
||||||
|
PersonInfo.last_know.desc()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取总数
|
||||||
|
total = query.count()
|
||||||
|
|
||||||
|
# 分页
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
persons = query.offset(offset).limit(page_size)
|
||||||
|
|
||||||
|
# 转换为响应对象
|
||||||
|
data = [person_to_response(person) for person in persons]
|
||||||
|
|
||||||
|
return PersonListResponse(
|
||||||
|
success=True,
|
||||||
|
total=total,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"获取人物列表失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取人物列表失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{person_id}", response_model=PersonDetailResponse)
|
||||||
|
async def get_person_detail(
|
||||||
|
person_id: str,
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取人物详细信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
person_id: 人物唯一 ID
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
人物详细信息
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
person = PersonInfo.get_or_none(PersonInfo.person_id == person_id)
|
||||||
|
|
||||||
|
if not person:
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {person_id} 的人物信息")
|
||||||
|
|
||||||
|
return PersonDetailResponse(
|
||||||
|
success=True,
|
||||||
|
data=person_to_response(person)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"获取人物详情失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取人物详情失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{person_id}", response_model=PersonUpdateResponse)
|
||||||
|
async def update_person(
|
||||||
|
person_id: str,
|
||||||
|
request: PersonUpdateRequest,
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
增量更新人物信息(只更新提供的字段)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
person_id: 人物唯一 ID
|
||||||
|
request: 更新请求(只包含需要更新的字段)
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
更新结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
person = PersonInfo.get_or_none(PersonInfo.person_id == person_id)
|
||||||
|
|
||||||
|
if not person:
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {person_id} 的人物信息")
|
||||||
|
|
||||||
|
# 只更新提供的字段
|
||||||
|
update_data = request.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
if not update_data:
|
||||||
|
raise HTTPException(status_code=400, detail="未提供任何需要更新的字段")
|
||||||
|
|
||||||
|
# 更新最后修改时间
|
||||||
|
update_data['last_know'] = time.time()
|
||||||
|
|
||||||
|
# 执行更新
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(person, field, value)
|
||||||
|
|
||||||
|
person.save()
|
||||||
|
|
||||||
|
logger.info(f"人物信息已更新: {person_id}, 字段: {list(update_data.keys())}")
|
||||||
|
|
||||||
|
return PersonUpdateResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"成功更新 {len(update_data)} 个字段",
|
||||||
|
data=person_to_response(person)
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"更新人物信息失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"更新人物信息失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{person_id}", response_model=PersonDeleteResponse)
|
||||||
|
async def delete_person(
|
||||||
|
person_id: str,
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
删除人物信息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
person_id: 人物唯一 ID
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
删除结果
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
person = PersonInfo.get_or_none(PersonInfo.person_id == person_id)
|
||||||
|
|
||||||
|
if not person:
|
||||||
|
raise HTTPException(status_code=404, detail=f"未找到 ID 为 {person_id} 的人物信息")
|
||||||
|
|
||||||
|
# 记录删除信息
|
||||||
|
person_name = person.person_name or person.nickname or person.user_id
|
||||||
|
|
||||||
|
# 执行删除
|
||||||
|
person.delete_instance()
|
||||||
|
|
||||||
|
logger.info(f"人物信息已删除: {person_id} ({person_name})")
|
||||||
|
|
||||||
|
return PersonDeleteResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"成功删除人物信息: {person_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"删除人物信息失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"删除人物信息失败: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/summary")
|
||||||
|
async def get_person_stats(
|
||||||
|
authorization: Optional[str] = Header(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取人物信息统计数据
|
||||||
|
|
||||||
|
Args:
|
||||||
|
authorization: Authorization header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
统计数据
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verify_auth_token(authorization)
|
||||||
|
|
||||||
|
total = PersonInfo.select().count()
|
||||||
|
known = PersonInfo.select().where(PersonInfo.is_known).count()
|
||||||
|
unknown = total - known
|
||||||
|
|
||||||
|
# 按平台统计
|
||||||
|
platforms = {}
|
||||||
|
for person in PersonInfo.select(PersonInfo.platform):
|
||||||
|
platform = person.platform
|
||||||
|
platforms[platform] = platforms.get(platform, 0) + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"total": total,
|
||||||
|
"known": known,
|
||||||
|
"unknown": unknown,
|
||||||
|
"platforms": platforms
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"获取统计数据失败: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"获取统计数据失败: {str(e)}") from e
|
||||||
@@ -6,6 +6,9 @@ from src.common.logger import get_logger
|
|||||||
from .token_manager import get_token_manager
|
from .token_manager import get_token_manager
|
||||||
from .config_routes import router as config_router
|
from .config_routes import router as config_router
|
||||||
from .statistics_routes import router as statistics_router
|
from .statistics_routes import router as statistics_router
|
||||||
|
from .person_routes import router as person_router
|
||||||
|
from .expression_routes import router as expression_router
|
||||||
|
from .emoji_routes import router as emoji_router
|
||||||
|
|
||||||
logger = get_logger("webui.api")
|
logger = get_logger("webui.api")
|
||||||
|
|
||||||
@@ -16,6 +19,12 @@ router = APIRouter(prefix="/api/webui", tags=["WebUI"])
|
|||||||
router.include_router(config_router)
|
router.include_router(config_router)
|
||||||
# 注册统计数据路由
|
# 注册统计数据路由
|
||||||
router.include_router(statistics_router)
|
router.include_router(statistics_router)
|
||||||
|
# 注册人物信息管理路由
|
||||||
|
router.include_router(person_router)
|
||||||
|
# 注册表达方式管理路由
|
||||||
|
router.include_router(expression_router)
|
||||||
|
# 注册表情包管理路由
|
||||||
|
router.include_router(emoji_router)
|
||||||
|
|
||||||
|
|
||||||
class TokenVerifyRequest(BaseModel):
|
class TokenVerifyRequest(BaseModel):
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
1
webui/dist/assets/index-DQRxVGN2.css
vendored
Normal file
1
webui/dist/assets/index-DQRxVGN2.css
vendored
Normal file
File diff suppressed because one or more lines are too long
145
webui/dist/assets/index-Dhe0sK-p.js
vendored
Normal file
145
webui/dist/assets/index-Dhe0sK-p.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
webui/dist/assets/index-DnJOmtKJ.css
vendored
1
webui/dist/assets/index-DnJOmtKJ.css
vendored
File diff suppressed because one or more lines are too long
145
webui/dist/assets/index-Dq16ignL.js
vendored
145
webui/dist/assets/index-Dq16ignL.js
vendored
File diff suppressed because one or more lines are too long
4
webui/dist/index.html
vendored
4
webui/dist/index.html
vendored
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/x-icon" href="/maimai.ico" />
|
<link rel="icon" type="image/x-icon" href="/maimai.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>MaiBot Dashboard</title>
|
<title>MaiBot Dashboard</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Dq16ignL.js"></script>
|
<script type="module" crossorigin src="/assets/index-Dhe0sK-p.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DnJOmtKJ.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DQRxVGN2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user