From 4f2341cd6d65c95bf8f9de9d33a23fd5052a7f37 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Mon, 6 Jul 2026 20:06:54 +0800 Subject: [PATCH] fix: strengthen chinese spam rules and add web logs --- handlers/spam.py | 7 ++ main.py | 17 +-- services/spam_detector.py | 216 +++++++++++++++++++++++++------------- web/api.py | 83 ++++++++++++++- web/static/index.html | 82 ++++++++++++++- 5 files changed, 323 insertions(+), 82 deletions(-) diff --git a/handlers/spam.py b/handlers/spam.py index 26bc7cc..3d28ed5 100644 --- a/handlers/spam.py +++ b/handlers/spam.py @@ -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) diff --git a/main.py b/main.py index 83bf7f0..8ecd05f 100644 --- a/main.py +++ b/main.py @@ -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(): diff --git a/services/spam_detector.py b/services/spam_detector.py index 1d54c8a..cfa29c6 100644 --- a/services/spam_detector.py +++ b/services/spam_detector.py @@ -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}" diff --git a/web/api.py b/web/api.py index ceb34cf..247875e 100644 --- a/web/api.py +++ b/web/api.py @@ -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"}) diff --git a/web/static/index.html b/web/static/index.html index 94a9224..aebc7fb 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -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 @@

💬 实时聊天

{{ chatLogs.length }} 条 -
+
+ + 发送 +
+
💬等待消息...
-
+
@{{ item.username }} ({{ item.user_id }}) {{ formatTime(item.time) }}
{{ item.text }}
+ 垃圾:{{ item.reason || item.spam_reason }}
@@ -161,6 +174,19 @@ + + +
+

📜 实时日志

+ 刷新 +
+
+
等待日志...
+
+ [{{ formatTime(item.time) }}] [{{ item.level }}] {{ item.message }} +
+
+
@@ -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 {