fix: strengthen chinese spam rules and add web logs

This commit is contained in:
ngfchl
2026-07-06 20:06:54 +08:00
parent 04781f144c
commit 4f2341cd6d
5 changed files with 323 additions and 82 deletions
+7
View File
@@ -30,6 +30,7 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
username = user.username or ""
first_name = user.first_name or ""
text = msg.text
logger.info(f"💬 聊天消息 [{username or first_name or user.id}]: {text[:120]}")
# === 防刷屏 ===
now = datetime.now().timestamp()
@@ -96,6 +97,12 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
except Exception as e:
logger.error(f"❌ 删除消息失败: {e}")
# 管理员可用于测试规则:删消息但不封禁,避免把群主/管理员踢掉
if user.id in config.ADMIN_USER_IDS:
await Action.create(action="SPAM_ADMIN_TEST", user_id=user.id, username=username, details={"reason": reason})
logger.warning(f"🧪 管理员垃圾规则测试命中,不封禁: [{username}] {reason}")
return
# 封禁
try:
unban_at = datetime.now() + timedelta(minutes=config.BAN_DURATION_MINUTES)
+10 -7
View File
@@ -3,6 +3,7 @@
import os
import asyncio
import logging
from contextlib import asynccontextmanager
# 代理(必须在其他 import 之前)
PROXY_URL = os.getenv("PROXY_URL", "http://192.168.123.10:11055")
@@ -92,8 +93,8 @@ async def start_tg_bot_safe():
raise
@web_app.on_event("startup")
async def on_startup():
@asynccontextmanager
async def lifespan(app):
# 初始化数据库
await init_db()
# 启动 TG Bot
@@ -108,13 +109,15 @@ async def on_startup():
task.add_done_callback(log_task_result)
logger.info("🚀 服务启动完成")
try:
yield
finally:
await stop_tg_bot()
await close_db()
logger.info("🛑 服务已关闭")
@web_app.on_event("shutdown")
async def on_shutdown():
await stop_tg_bot()
await close_db()
logger.info("🛑 服务已关闭")
web_app.router.lifespan_context = lifespan
def run():
+145 -71
View File
@@ -1,95 +1,162 @@
"""多层垃圾检测引擎 — 缓存 → 硬性正则 → 正常特征 → AI 兜底"""
import re
"""中文垃圾广告检测引擎
策略:
1. 先做本地规则评分(成熟中文广告/黑灰产话术体系)
2. 高置信垃圾直接拦截
3. 明确正常消息直接放行
4. 低置信/模糊样本交给 AI 兜底
核心原则:短消息不能无条件放行;必须先识别广告意图。
"""
import logging
import re
from dataclasses import dataclass
import httpx
import config
from knowledge import OLLAMA_URL, OLLAMA_MODEL
logger = logging.getLogger("spam_guard")
# ============ 硬性垃圾正则 ============
HARD_SPAM_PATTERNS = [
r"菠菜|赌博|博彩|赌场|棋牌|彩票|开奖|中奖|赔率|下注",
r"色情|约炮|外围|楼凤|一夜情|裸聊|成人视频",
r"刷单|传销|杀猪盘|资金盘|庞氏|传销盘",
r"(做USDT|U ?S ?D ?T|搬砖).{0,15}(看主页|看签名|进群|加我|找我)",
r"(看主页|看签名|加我|找我|进群).{0,15}(做USDT|U ?S ?D ?T|搬砖|赚钱|日入|月入)",
r"(一天|日入|月入|赚).{0,10}(万|w|W).{0,8}(项目|路子|渠道|带|做)",
r"(换|开|提).{0,6}(迈巴赫|宝马|奔驰|保时捷|法拉利|兰博基尼)",
r"(小白可带|手把手带|带你赚|带你做)",
r"(t\.me|telegram\.me).{0,30}(join|group|channel)",
@dataclass(frozen=True)
class Rule:
name: str
pattern: str
score: int
# 高危黑产:单项命中即可高度可疑或直接垃圾
HARD_RULES = [
Rule("博彩赌博", r"菠菜|赌博|博彩|赌场|棋牌|彩票|开奖|中奖|赔率|下注|百家乐|时时彩|六合彩", 100),
Rule("色情引流", r"色情|约炮|外围|楼凤|一夜情|裸聊|成人视频|萝莉|上门服务", 100),
Rule("诈骗/传销/资金盘", r"刷单|传销|杀猪盘|资金盘|庞氏|返利盘|跑分|洗钱|代收款", 100),
Rule("非法交易", r"办证|假证|代开发票|买卖账号|出售账号|卖号|接码|猫池", 100),
Rule("Telegram 引流", r"(?:t\.me|telegram\.me|tg://|飞机群).{0,40}(?:join|group|channel|\+|进群|频道)", 90),
]
# 软性信号(需 AI 二次判定)
SOFT_SPAM_SIGNALS = [
r"(免费领|免费送|限时领|点击领取|点击链接|复制链接)",
r"(加微信|wx|加我|私聊|私我|dm|PM)",
r"(赚钱|赚快钱|躺赚|副业|兼职日结)",
r"(开户|入金|出金|充值返|提现)",
r"(代理|加盟|推广返|分润|佣金)",
# 广告意图/黑灰产常见话术:评分组合
SCORE_RULES = [
# 收益承诺
Rule("收益承诺-日赚", r"(?:日赚|日入|每天赚|一天赚|轻松赚|稳赚|躺赚|副业收入).{0,10}(?:\d{2,}|几百|上千|过千|一千|几千|上万|万元?|k|K|w|W)", 75),
Rule("收益承诺-月入", r"(?:月入|月赚|月收益|月收入).{0,10}(?:\d{3,}|过万|上万|几万|万元?|w|W)", 70),
Rule("收益承诺-夸张", r"(?:暴富|翻倍|稳赚不赔|保本收益|无风险收益|零风险|高回报|高收益)", 55),
# 兼职/刷单/项目
Rule("兼职刷单", r"(?:兼职|副业|日结|周结|在家做|手机操作|无需经验|宝妈|学生党).{0,18}(?:赚钱|收入|结算|工资|项目|单)", 55),
Rule("项目引流", r"(?:项目|路子|渠道|资源|教程).{0,12}(?:带你|私聊|加我|看主页|看签名|进群)", 55),
Rule("带人赚钱", r"(?:带你赚|带你做|手把手带|小白可带|包教包会|一对一带)", 65),
# 联系/引流动作
Rule("联系方式-微信", r"(?:加微信|微信号|微[信xX]|vx|v信|薇信|扣扣|QQ|q群|私信|私聊|PM|DM|联系我|找我)", 45),
Rule("引导动作", r"(?:看主页|看签名|主页有|签名有|点我头像|点头像|进群|拉群|频道|群内|开户链接)", 45),
Rule("链接引流", r"(?:https?://|www\.|\.com|\.cn|\.top|\.xyz|\.cc|短链接|复制链接|点击链接)", 40),
# 金融/币圈/贷款
Rule("虚拟币搬砖", r"(?:USDT|U\s?S\s?D\s?T|泰达币|虚拟币|币圈|搬砖|套利).{0,18}(?:赚钱|收益|项目|加我|看主页|进群)", 65),
Rule("贷款套现", r"(?:贷款|借款|套现|信用卡|花呗|白条|提额|下款|黑户|征信).{0,18}(?:秒批|当天|无视|包过|渠道)", 60),
Rule("投资理财", r"(?:投资|理财|股票|期货|外汇|量化|跟单).{0,18}(?:老师|带单|收益|稳赚|回本|翻倍)", 60),
# 营销常见词
Rule("免费福利", r"(?:免费领|免费送|限时领|点击领取|扫码领取|福利|红包|返现|佣金|返佣)", 40),
Rule("代理加盟", r"(?:代理|加盟|招商|推广返|分润|招募|招代理|合作共赢)", 45),
Rule("豪车炫富", r"(?:换|开|提).{0,6}(?:迈巴赫|宝马|奔驰|保时捷|法拉利|兰博基尼)", 55),
]
# 正常消息特征
NORMAL_SIGNALS = [
r"^.{0,8}$",
r"[?]",
r"(怎么|如何|为什么|能不能|请问|求|帮忙|谢谢|好的|收到|OK|ok|嗯|是的|不是|可以|不行|对|错|嗯嗯)",
r"(收割机|harvest|签到|站点|cookie|docker|安装|配置|更新|升级|bug|报错|失败|成功)",
r"^\d+$",
r"^[a-zA-Z0-9\s]{1,20}$",
# 组合加权:成熟广告识别关键在“组合意图”,不是单词命中
COMBO_RULES = [
Rule("收益+金额", r"(?:赚|收入|收益|工资|佣金|返现|日结|副业).{0,12}(?:\d{3,}|几百|上千|过千|几千|上万|万元?|k|K|w|W)", 55),
Rule("收益+引流", r"(?:赚钱|收益|收入|副业|项目|路子).{0,20}(?:加我|私聊|看主页|看签名|进群|联系我|微信|vx|QQ)", 60),
Rule("金额+引流", r"(?:\d{3,}|几百|上千|过千|几千|上万|万元?|k|K|w|W).{0,20}(?:加我|私聊|看主页|看签名|进群|联系我|微信|vx|QQ)", 55),
]
SPAM_PROMPT = """判断以下消息是否为垃圾广告。同时检查消息内容和发送者信息。
NORMAL_RULES = [
Rule("项目技术讨论", r"(?:收割机|harvest|签到|站点|cookie|docker|compose|安装|配置|更新|升级|bug|报错|失败|成功|数据库|postgres|sqlite|redis)", 30),
Rule("疑问求助", r"(?:怎么|如何|为什么|能不能|请问|求|帮忙|麻烦|谢谢|这个|那个).{0,30}[?]?$", 25),
Rule("常见短回复", r"^(?:ok|OK|yes|YES|no|NO|收到|好的|可以|不行|对|错|嗯|嗯嗯|是的|不是|谢谢|感谢)$", 60),
Rule("纯数字", r"^\d{1,8}$", 30),
]
SPAM_PROMPT = """判断以下群聊消息是否为中文垃圾广告/黑灰产推广。
消息内容: {text}
发送者用户名: {username}
发送者昵称: {first_name}
判断标准(满足任一即为垃圾):
1. 包含色情、赌博、诈骗、菠菜等违法推广内容
2. 包含明显广告推销(加群、加微信、免费领、点击链接等)
3. 纯转发的营销内容
垃圾广告包括:赚钱项目、刷单兼职、日赚/月入承诺、加微信/私聊引流、博彩色情、贷款套现、虚拟币搬砖、投资带单、免费福利诱导等。
正常消息包括:技术讨论、问答、简短确认、正常聊天。
请先给出结论,包含"垃圾""正常",再简短说明原因。"""
只输出一行:垃圾 或 正常,并简短说明原因。"""
def normalize(text: str) -> str:
"""零宽字符、规范空白"""
t = re.sub(r'[\u200b\u200c\u200d\u2060\u200e\u200f\ufeff\u00a0]', '', text)
t = re.sub(r'\s+', ' ', t).strip()
"""去零宽字符、统一空白和常见混淆字符。"""
t = re.sub(r"[\u200b\u200c\u200d\u2060\u200e\u200f\ufeff\u00a0]", "", text or "")
t = re.sub(r"\s+", " ", t).strip()
return t
def match_patterns(text: str, patterns: list[str]) -> bool:
for pat in patterns:
if re.search(pat, text, re.IGNORECASE):
return True
return False
def compact(text: str) -> str:
"""去掉空格,处理 日赚 1000 / 日赚1000 这类绕过。"""
return re.sub(r"\s+", "", text)
async def detect(text: str, username: str = "", first_name: str = "") -> tuple[bool, str]:
"""
多层垃圾检测。
返回 (is_spam, reason)
"""
def hit_rules(text: str, rules: list[Rule]) -> list[Rule]:
return [r for r in rules if re.search(r.pattern, text, re.IGNORECASE)]
def local_classify(text: str) -> tuple[bool | None, str, int]:
"""本地规则分类。返回 (是否垃圾/None需AI, 原因, 分数)。"""
norm = normalize(text)
tight = compact(norm)
targets = [norm, tight]
# 第一层:硬性正则
if match_patterns(norm, HARD_SPAM_PATTERNS):
return True, "规则命中(硬性垃圾)"
hard_hits = []
score_hits = []
combo_hits = []
normal_hits = []
# 短消息放行
if len(norm) < 20 and not match_patterns(norm, SOFT_SPAM_SIGNALS):
return False, "短消息跳过"
for target in targets:
hard_hits.extend(hit_rules(target, HARD_RULES))
score_hits.extend(hit_rules(target, SCORE_RULES))
combo_hits.extend(hit_rules(target, COMBO_RULES))
normal_hits.extend(hit_rules(target, NORMAL_RULES))
# 正常特征
if match_patterns(norm, NORMAL_SIGNALS) and not match_patterns(norm, SOFT_SPAM_SIGNALS):
return False, "正常消息特征匹配"
# 去重
dedup = lambda xs: list({x.name: x for x in xs}.values())
hard_hits, score_hits, combo_hits, normal_hits = map(dedup, [hard_hits, score_hits, combo_hits, normal_hits])
# AI 兜底
if hard_hits:
names = ",".join(r.name for r in hard_hits[:3])
return True, f"硬规则命中: {names}", 100
spam_score = sum(r.score for r in score_hits) + sum(r.score for r in combo_hits)
normal_score = sum(r.score for r in normal_hits)
# 广告组合是核心:即使很短也要先判垃圾
if spam_score >= 75:
names = ",".join(r.name for r in (score_hits + combo_hits)[:4])
return True, f"广告规则评分 {spam_score}: {names}", spam_score
# 纯短消息只有在没有广告分时才放行
if len(norm) <= 8 and spam_score == 0:
return False, "短正常消息", -normal_score
# 明显正常技术/求助消息,且无明显广告分
if normal_score >= 30 and spam_score < 45:
names = ",".join(r.name for r in normal_hits[:3])
return False, f"正常规则命中: {names}", spam_score - normal_score
# 低风险且没有广告信号
if spam_score == 0:
return False, "无广告信号", 0
# 模糊样本交给 AI
names = ",".join(r.name for r in (score_hits + combo_hits)[:4])
return None, f"疑似广告评分 {spam_score}: {names}", spam_score
async def ai_classify(text: str, username: str = "", first_name: str = "") -> tuple[bool, str]:
prompt = SPAM_PROMPT.format(text=text, username=username, first_name=first_name)
try:
async with httpx.AsyncClient(timeout=30, proxy=None) as client:
@@ -99,25 +166,32 @@ async def detect(text: str, username: str = "", first_name: str = "") -> tuple[b
"model": OLLAMA_MODEL,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"options": {"num_predict": 50, "temperature": 0.1},
"options": {"num_predict": 80, "temperature": 0.1},
"think": False,
},
)
resp.raise_for_status()
reply = resp.json()["message"]["content"]
logger.info(f"🤖 AI回复: {reply[:100]}")
reply = resp.json()["message"]["content"].strip()
logger.info(f"🤖 垃圾检测 AI 回复: {reply[:100]}")
reply_upper = reply.upper()
spam_words = ["垃圾", "广告", "诈骗", "菠菜", "赌博", "色情", "YES"]
normal_words = ["正常", "非垃圾", "不是垃圾", "NO"]
if any(w in reply for w in normal_words) or "NO" in reply_upper:
return False, f"AI判定: {reply}"
if any(w in reply for w in spam_words) or "YES" in reply_upper:
if re.search(r"垃圾|广告|诈骗|菠菜|赌博|色情", reply, re.IGNORECASE):
return True, f"AI判定: {reply}"
if re.search(r"正常|非垃圾|不是垃圾", reply, re.IGNORECASE):
return False, f"AI判定: {reply}"
return False, f"AI判定不明确: {reply}"
except Exception as e:
logger.error(f"❌ Ollama 调用失败: {e}")
if match_patterns(norm, SOFT_SPAM_SIGNALS):
return True, "关键词兜底(AI不可用)"
return False, f"检测异常: {e}"
return False, f"AI异常: {e}"
async def detect(text: str, username: str = "", first_name: str = "") -> tuple[bool, str]:
"""返回 (is_spam, reason)。"""
decision, reason, score = local_classify(text)
if decision is not None:
return decision, reason
# AI 不可用时,中等以上广告分直接兜底拦截;低分放行
ai_decision, ai_reason = await ai_classify(text, username, first_name)
if ai_reason.startswith("AI异常") and score >= 45:
return True, f"规则兜底(AI不可用): {reason}"
return ai_decision, f"{reason}; {ai_reason}"
+82 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -21,6 +22,42 @@ app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="stat
# SSE
_sse_queues: list[asyncio.Queue] = []
_log_buffer: list[dict] = []
MAX_LOG_BUFFER = 300
class SSELogHandler(logging.Handler):
"""把应用日志推送到 Web 实时日志窗口。"""
def emit(self, record: logging.LogRecord):
try:
item = {
"time": datetime.fromtimestamp(record.created).isoformat(sep=" ", timespec="seconds"),
"level": record.levelname,
"message": self.format(record),
}
_log_buffer.append(item)
del _log_buffer[:-MAX_LOG_BUFFER]
try:
loop = asyncio.get_running_loop()
loop.create_task(broadcast_sse("log", item))
except RuntimeError:
pass
except Exception:
pass
def install_log_handler():
logger = logging.getLogger("spam_guard")
if any(isinstance(h, SSELogHandler) for h in logger.handlers):
return
handler = SSELogHandler()
handler.setLevel(logging.INFO)
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S"))
logger.addHandler(handler)
install_log_handler()
def json_safe(value: Any) -> Any:
@@ -100,6 +137,49 @@ async def api_actions(limit: int = Query(default=100, ge=1, le=500)):
return JSONResponse(json_safe(list(actions)))
@app.get("/api/logs")
async def api_logs(limit: int = Query(default=100, ge=1, le=300)):
return JSONResponse(_log_buffer[-limit:])
@app.post("/api/send")
async def api_send(request: Request):
import config
from telegram import Bot
from telegram.request import HTTPXRequest
data = await request.json()
text = str(data.get("text", "")).strip()
if not text:
return JSONResponse({"ok": False, "error": "消息不能为空"}, status_code=400)
if len(text) > 3500:
return JSONResponse({"ok": False, "error": "消息太长"}, status_code=400)
bot = Bot(token=config.TG_BOT_TOKEN, request=HTTPXRequest(proxy=config.PROXY_URL))
msg = await bot.send_message(chat_id=config.TG_CHAT_ID, text=text)
rec = await Message.create(
msg_id=msg.message_id,
chat_id=config.TG_CHAT_ID,
user_id=0,
username="web-admin",
first_name="Web 管理端",
text=text[:500],
)
logging.getLogger("spam_guard").info(f"🌐 Web 发送消息: {text[:120]}")
await broadcast_sse("chat", {
"id": rec.id,
"msg_id": msg.message_id,
"chat_id": config.TG_CHAT_ID,
"user_id": 0,
"username": "web-admin",
"first_name": "Web 管理端",
"text": text,
"time": datetime.now().isoformat(sep=" ", timespec="seconds"),
"spam": False,
})
return JSONResponse({"ok": True, "msg_id": msg.message_id})
@app.get("/api/scores")
async def api_scores(limit: int = Query(default=50, ge=1, le=200)):
scores = await GameScore.all().order_by("-score").limit(limit).values()
@@ -111,8 +191,9 @@ async def api_unban(user_id: int):
import config
try:
from telegram import Bot
from telegram.request import HTTPXRequest
bot = Bot(token=config.TG_BOT_TOKEN)
bot = Bot(token=config.TG_BOT_TOKEN, request=HTTPXRequest(proxy=config.PROXY_URL))
await bot.unban_chat_member(chat_id=config.TG_CHAT_ID, user_id=user_id)
await Ban.filter(user_id=user_id).update(auto_unban=False, unban_at=datetime.now())
await Action.create(action="UNBAN", user_id=user_id, details={"method": "web"})
+79 -3
View File
@@ -27,7 +27,15 @@
.stat-card.danger .num { color: #f56c6c; }
.stat-card.success .num { color: #67c23a; }
.stat-card.warning .num { color: #e6a23c; }
.chat-panel, .ban-panel { height: 520px; }
.chat-panel, .ban-panel { height: 560px; }
.log-panel { margin-top: 20px; height: 360px; }
.send-box { display: flex; gap: 8px; margin-bottom: 12px; }
.log-window { background: #111827; color: #d1d5db; border-radius: 8px; padding: 12px; height: 280px; overflow-y: auto; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
.log-line { white-space: pre-wrap; line-height: 1.6; }
.log-line.INFO { color: #d1d5db; }
.log-line.WARNING { color: #fbbf24; }
.log-line.ERROR { color: #f87171; }
.chat-item.spam { background: #fff1f0; border-left: 3px solid #f56c6c; padding-left: 8px; }
.chat-item {
padding: 10px 0;
border-bottom: 1px solid #ebeef5;
@@ -116,16 +124,21 @@
<h3>💬 实时聊天</h3>
<el-tag size="small" type="info">{{ chatLogs.length }} 条</el-tag>
</div>
<div style="overflow-y:auto; height:440px;" ref="chatScroll">
<div class="send-box">
<el-input v-model="newMessage" placeholder="输入要发送到 TG 群的消息" size="small" @keyup.enter.native="sendMessage"></el-input>
<el-button type="primary" size="small" :loading="sending" @click="sendMessage">发送</el-button>
</div>
<div style="overflow-y:auto; height:430px;" ref="chatScroll">
<div v-if="chatLogs.length === 0" class="empty-text">
<span class="icon">💬</span>等待消息...
</div>
<div v-for="(item, i) in chatLogs" :key="i" class="chat-item">
<div v-for="(item, i) in chatLogs" :key="i" class="chat-item" :class="{spam: item.spam}">
<div class="meta">
<span class="user">@{{ item.username }} ({{ item.user_id }})</span>
<span class="time">{{ formatTime(item.time) }}</span>
</div>
<div class="text">{{ item.text }}</div>
<el-tag v-if="item.spam" size="mini" type="danger">垃圾:{{ item.reason || item.spam_reason }}</el-tag>
</div>
</div>
</el-card>
@@ -161,6 +174,19 @@
</el-card>
</el-col>
</el-row>
<el-card shadow="hover" class="log-panel">
<div class="panel-header">
<h3>📜 实时日志</h3>
<el-button size="mini" icon="el-icon-refresh" @click="loadLogs">刷新</el-button>
</div>
<div class="log-window" ref="logScroll">
<div v-if="logs.length === 0" class="log-line">等待日志...</div>
<div v-for="(item, i) in logs" :key="i" class="log-line" :class="item.level">
[{{ formatTime(item.time) }}] [{{ item.level }}] {{ item.message }}
</div>
</div>
</el-card>
</div>
<script src="https://unpkg.com/vue@2.7.16/dist/vue.min.js"></script>
@@ -173,6 +199,9 @@ new Vue({
sseConnected: false,
chatLogs: [],
bans: [],
logs: [],
newMessage: '',
sending: false,
stats: { today_bans: 0, total_bans: 0, active_bans: 0 },
}
},
@@ -180,6 +209,7 @@ new Vue({
this.loadChatHistory()
this.loadBans()
this.loadStats()
this.loadLogs()
this.connectSSE()
setInterval(() => this.loadStats(), 30000)
},
@@ -200,6 +230,21 @@ new Vue({
if (this.chatLogs.length > 100) this.chatLogs.pop()
})
es.addEventListener('spam', (e) => {
const data = JSON.parse(e.data)
const item = this.chatLogs.find(x => x.msg_id === data.msg_id)
if (item) Object.assign(item, data)
})
es.addEventListener('log', (e) => {
const data = JSON.parse(e.data)
this.logs.push(data)
if (this.logs.length > 300) this.logs.shift()
this.$nextTick(() => {
if (this.$refs.logScroll) this.$refs.logScroll.scrollTop = this.$refs.logScroll.scrollHeight
})
})
es.addEventListener('ban', (e) => {
const data = JSON.parse(e.data)
data._loading = false
@@ -245,6 +290,37 @@ new Vue({
this.stats = await r.json()
} catch (e) {}
},
async loadLogs() {
try {
const r = await fetch('/api/logs')
this.logs = await r.json()
this.$nextTick(() => {
if (this.$refs.logScroll) this.$refs.logScroll.scrollTop = this.$refs.logScroll.scrollHeight
})
} catch (e) {}
},
async sendMessage() {
const text = this.newMessage.trim()
if (!text) return
this.sending = true
try {
const r = await fetch('/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
})
const data = await r.json()
if (data.ok) {
this.newMessage = ''
this.$message.success('✅ 已发送')
} else {
this.$message.error(`❌ 发送失败: ${data.error}`)
}
} catch (e) {
this.$message.error('❌ 网络错误')
}
this.sending = false
},
async unbanUser(item) {
this.$set(item, '_loading', true)
try {