feat: improve overview moderation layout and rules page
This commit is contained in:
+73
-12
@@ -25,11 +25,14 @@ const spamTesting = ref(false);
|
||||
const spamTestResult = ref<Record<string, any> | null>(null);
|
||||
const ruleForm = reactive({ type: 'keyword', pattern: '', score: 75 });
|
||||
const ruleSaving = ref(false);
|
||||
const spamRules = reactive({ keywords: [] as Record<string, any>[], regex: [] as Record<string, any>[], counts: { keywords: 0, regex: 0 } });
|
||||
const rulesLoading = ref(false);
|
||||
const ruleKeyword = ref('');
|
||||
const shopForm = reactive({ item_key: '', name: '', cost: 0, stock: -1, desc: '', enabled: true });
|
||||
const shopDialogOpen = ref(false);
|
||||
const lotteryForm = reactive({ id: 0, title: '', prize: '', description: '', condition_type: 'all', condition_value: 0, draw_type: 'people', end_type: 'people', end_value: 3, max_participants: 3, draw_at: '', status: 'active' });
|
||||
const lotteryDialogOpen = ref(false);
|
||||
const activePage = ref<'dashboard' | 'shop' | 'lottery' | 'bans' | 'rules'>('dashboard');
|
||||
const activePage = ref<'dashboard' | 'shop' | 'lottery' | 'rules'>('dashboard');
|
||||
const activeTab = reactive({ shop: 'items', lottery: 'events' });
|
||||
const toast = ref('');
|
||||
const confirmState = reactive({ open: false, title: '', message: '', confirmText: '确认', danger: false, resolver: null as null | ((value: boolean) => void) });
|
||||
@@ -104,16 +107,16 @@ function toDatetimeLocal(date: Date) {
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => ({
|
||||
dashboard: '群组安全总览', shop: '商城管理', lottery: '抽奖管理', bans: '封禁记录', rules: '规则测试'
|
||||
dashboard: '群组安全总览', shop: '商城管理', lottery: '抽奖管理', rules: '规则测试'
|
||||
}[activePage.value]));
|
||||
const pageDesc = computed(() => ({
|
||||
dashboard: '实时审计聊天、封禁、日志和运营活动', shop: '商品 CRUD 与兑换交易记录', lottery: '抽奖项目、开奖条件与参与记录', bans: '封禁审计与手动解封', rules: '检测广告/灰产文本并维护规则库'
|
||||
dashboard: '实时审计聊天、封禁、日志和封禁记录', shop: '商品 CRUD 与兑换交易记录', lottery: '抽奖项目、开奖条件与参与记录', rules: '检测广告/灰产文本并维护规则库'
|
||||
}[activePage.value]));
|
||||
function goPage(page: typeof activePage.value) {
|
||||
activePage.value = page;
|
||||
if (page === 'shop') { loadShopItems(); loadShopTransactions(); }
|
||||
if (page === 'lottery') loadLotteries();
|
||||
if (page === 'bans') loadBans();
|
||||
if (page === 'rules') loadSpamRules();
|
||||
}
|
||||
|
||||
|
||||
@@ -342,6 +345,28 @@ async function testSpamText() {
|
||||
} catch (e: any) { showToast(`检测失败:${e.message}`); }
|
||||
finally { spamTesting.value = false; }
|
||||
}
|
||||
async function loadSpamRules() {
|
||||
if (rulesLoading.value) return;
|
||||
rulesLoading.value = true;
|
||||
try {
|
||||
const result = await fetchJson('/api/spam-rules');
|
||||
spamRules.keywords = result.keywords || [];
|
||||
spamRules.regex = result.regex || [];
|
||||
spamRules.counts = result.counts || { keywords: 0, regex: 0 };
|
||||
} catch (e: any) { showToast(`加载规则库失败:${e.message}`); }
|
||||
finally { rulesLoading.value = false; }
|
||||
}
|
||||
const filteredKeywordRules = computed(() => {
|
||||
const key = ruleKeyword.value.trim().toLowerCase();
|
||||
const rows = [...spamRules.keywords].reverse();
|
||||
return key ? rows.filter(r => String(r.pattern || '').toLowerCase().includes(key)).slice(0, 300) : rows.slice(0, 120);
|
||||
});
|
||||
const filteredRegexRules = computed(() => {
|
||||
const key = ruleKeyword.value.trim().toLowerCase();
|
||||
const rows = [...spamRules.regex].reverse();
|
||||
return key ? rows.filter(r => String(r.pattern || '').toLowerCase().includes(key)).slice(0, 300) : rows.slice(0, 120);
|
||||
});
|
||||
|
||||
async function saveSpamRule() {
|
||||
if (!ruleForm.pattern.trim()) return showToast('请输入规则内容');
|
||||
if (ruleSaving.value) return;
|
||||
@@ -354,6 +379,7 @@ async function saveSpamRule() {
|
||||
});
|
||||
showToast(`规则已写入,关键词 ${result.rule_counts?.keywords || '-'} / 正则 ${result.rule_counts?.regex || '-'}`);
|
||||
ruleForm.pattern = '';
|
||||
await loadSpamRules();
|
||||
await testSpamText();
|
||||
} catch (e: any) { showToast(`写入规则失败:${e.message}`); }
|
||||
finally { ruleSaving.value = false; }
|
||||
@@ -426,8 +452,8 @@ async function sendMessage() {
|
||||
function openChatContextMenu(event: MouseEvent, item: ChatItem) {
|
||||
chatContextMenu.item = item;
|
||||
chatContextMenu.open = true;
|
||||
const menuWidth = 168;
|
||||
const menuHeight = item.deleted ? 92 : 136;
|
||||
const menuWidth = 188;
|
||||
const menuHeight = item.deleted ? 152 : 192;
|
||||
chatContextMenu.x = Math.min(event.clientX, window.innerWidth - menuWidth - 8);
|
||||
chatContextMenu.y = Math.min(event.clientY, window.innerHeight - menuHeight - 8);
|
||||
}
|
||||
@@ -445,6 +471,11 @@ async function contextDeleteMessage(ban = false) {
|
||||
closeChatContextMenu();
|
||||
if (item) await deleteChatMessage(item, ban);
|
||||
}
|
||||
async function contextBanMessage() {
|
||||
const item = chatContextMenu.item;
|
||||
closeChatContextMenu();
|
||||
if (item) await banChatMessage(item);
|
||||
}
|
||||
async function contextCopyText() {
|
||||
const item = chatContextMenu.item;
|
||||
const text = String(item?.text || '').trim();
|
||||
@@ -458,6 +489,18 @@ async function contextCopyText() {
|
||||
}
|
||||
}
|
||||
|
||||
async function banChatMessage(item: ChatItem) {
|
||||
if (!item.user_id) return showToast('当前记录没有用户 ID,无法封禁');
|
||||
if (!await askConfirm('封禁用户', `确认封禁 @${item.username || item.first_name || item.user_id}?`, { confirmText: '封禁', danger: true })) return;
|
||||
item._deleting = true;
|
||||
try {
|
||||
await fetchJson(`/api/messages/${item.id}/ban`, { method: 'POST' });
|
||||
await loadBans();
|
||||
await loadStats();
|
||||
} catch (e: any) { showToast(`封禁失败:${e.message}`); }
|
||||
finally { item._deleting = false; }
|
||||
}
|
||||
|
||||
async function addChatMessageToRules(item: ChatItem) {
|
||||
const text = String(item.text || '').trim();
|
||||
if (!text) return showToast('当前消息没有可写入的文本');
|
||||
@@ -589,7 +632,6 @@ onBeforeUnmount(() => {
|
||||
<button :class="{active: activePage === 'dashboard'}" @click="goPage('dashboard')"><span>总览</span><em>Dashboard</em></button>
|
||||
<button :class="{active: activePage === 'shop'}" @click="goPage('shop')"><span>商城管理</span><em>Shop</em></button>
|
||||
<button :class="{active: activePage === 'lottery'}" @click="goPage('lottery')"><span>抽奖管理</span><em>Lottery</em></button>
|
||||
<button :class="{active: activePage === 'bans'}" @click="goPage('bans')"><span>封禁记录</span><em>Bans</em></button>
|
||||
<button :class="{active: activePage === 'rules'}" @click="goPage('rules')"><span>规则测试</span><em>Rules</em></button>
|
||||
</nav>
|
||||
<div class="side-footer">
|
||||
@@ -668,6 +710,17 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
</SCard>
|
||||
|
||||
<SCard class="panel ban-panel overview-ban-panel" title="🚫 封禁记录" :description="`${bans.length} 条`">
|
||||
<template #extra><SButton size="xs" variant="outline" @click="loadBans">刷新</SButton></template>
|
||||
<div class="ban-list overview-ban-list">
|
||||
<div v-if="!bans.length" class="empty">暂无封禁记录</div>
|
||||
<div v-for="(item, i) in bans" :key="item.id || i" class="ban-item">
|
||||
<div class="ban-main"><b>@{{ item.username || item.first_name || item.user_id }}</b><span>{{ item.reason || '-' }}</span></div>
|
||||
<div class="ban-meta"><span>{{ formatTime(item.time) }}</span><SButton v-if="item.auto_unban" size="xs" variant="outline" color="success" :loading="item._loading" :disabled="item._loading" @click="unban(item)">解封</SButton><STag v-else size="xs" color="secondary" variant="soft">已处理</STag></div>
|
||||
</div>
|
||||
</div>
|
||||
</SCard>
|
||||
</section>
|
||||
|
||||
<div
|
||||
@@ -677,12 +730,24 @@ onBeforeUnmount(() => {
|
||||
@click.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<button @click="contextCopyText">📋 复制内容</button>
|
||||
<button @click="contextAddToRules">📥 写入拦截库</button>
|
||||
<button @click="contextCopyText">📋 复制文本</button>
|
||||
<button v-if="chatContextMenu.item && chatContextMenu.item.user_id" class="warning" @click="contextBanMessage">🚫 封禁用户</button>
|
||||
<button v-if="chatContextMenu.item && !chatContextMenu.item.deleted" class="danger" @click="contextDeleteMessage(false)">🗑️ 删除消息</button>
|
||||
<button v-if="chatContextMenu.item && !chatContextMenu.item.deleted && chatContextMenu.item.user_id" class="warning" @click="contextDeleteMessage(true)">⛔ 删除并封禁</button>
|
||||
</div>
|
||||
|
||||
<section v-show="activePage === 'rules'" class="tab-page rule-test-page">
|
||||
<SCard class="panel page-panel" title="🧪 广告/灰产检测" description="检测文本、查看评分,并维护外部规则库">
|
||||
<div class="rule-test-grid">
|
||||
<div class="rule-editor-block"><label>待检测文本<textarea v-model="spamTestText" class="form-input rule-textarea" placeholder="粘贴可疑聊天内容"></textarea></label><label class="switch-line"><input v-model="spamUseAi" type="checkbox" /> 同时调用 AI 最终检测</label><div class="form-actions"><SButtonLoading color="primary" :loading="spamTesting" :disabled="spamTesting" @click="testSpamText">开始检测</SButtonLoading></div></div>
|
||||
<div class="rule-result-card"><div v-if="!spamTestResult" class="empty">暂无检测结果</div><template v-else><div class="rule-result-head"><STag :color="spamTestResult.final_is_spam ? 'destructive' : spamTestResult.final_is_spam === null ? 'warning' : 'success'" variant="soft">{{ spamTestResult.final_is_spam ? '广告/灰产' : spamTestResult.final_is_spam === null ? '需 AI 判断' : '正常' }}</STag><strong>规则评分:{{ spamTestResult.score }}</strong></div><div class="rule-reason"><b>本地原因</b><span>{{ spamTestResult.reason }}</span></div><div v-if="spamTestResult.ai_reason" class="rule-reason"><b>AI 结果</b><span>{{ spamTestResult.ai_reason }}</span></div><div class="rule-counts">当前规则:关键词 {{ spamTestResult.rule_counts?.keywords }} 条 / 正则 {{ spamTestResult.rule_counts?.regex }} 条</div></template></div>
|
||||
</div>
|
||||
</SCard>
|
||||
<SCard class="panel page-panel rule-write-card" title="➕ 写入规则库" description="关键词适合固定词,正则适合组合话术;写入后自动热加载"><div class="rule-write-grid"><label>类型<select v-model="ruleForm.type" class="form-select"><option value="keyword">关键词</option><option value="regex">正则</option></select></label><label>分数<SInput v-model="ruleForm.score" type="number" placeholder="75" /></label><label class="rule-pattern-field">规则内容<SInput v-model="ruleForm.pattern" :placeholder="ruleForm.type === 'keyword' ? '如 黑U' : '如 (?:黑\s*[uU]).{0,20}(?:联系|项目)'" /></label><SButtonLoading color="primary" :loading="ruleSaving" :disabled="ruleSaving" @click="saveSpamRule">写入规则</SButtonLoading></div><p class="field-help">建议:强灰产 80~100,普通引流 45~70;不要添加过短泛词。</p></SCard>
|
||||
<SCard class="panel page-panel rule-library-card" title="📚 当前规则库" :description="`关键词 ${spamRules.counts.keywords} 条 / 正则 ${spamRules.counts.regex} 条`"><template #extra><SButtonLoading size="xs" variant="outline" :loading="rulesLoading" :disabled="rulesLoading" @click="loadSpamRules">刷新</SButtonLoading></template><div class="rule-library-toolbar"><SInput v-model="ruleKeyword" placeholder="搜索规则内容" /></div><div class="rule-library-grid"><div><h4>关键词规则</h4><div class="rule-list"><div v-for="r in filteredKeywordRules" :key="`kw-${r.line}`" class="rule-row"><span>{{ r.pattern }}</span><em>{{ r.score ?? '-' }}</em></div></div></div><div><h4>正则规则</h4><div class="rule-list"><div v-for="r in filteredRegexRules" :key="`rx-${r.line}`" class="rule-row"><span>{{ r.pattern }}</span><em>{{ r.score ?? '-' }}</em></div></div></div></div></SCard>
|
||||
</section>
|
||||
|
||||
<section v-show="activePage === 'shop'" class="tab-page manager-layout single-panel-page">
|
||||
<SCard class="panel data-panel" title="🛒 商城管理" :description="`${shopItems.length} 个商品`">
|
||||
<template #extra><SButton size="xs" color="primary" @click="newShopItem">新增商品</SButton></template>
|
||||
@@ -719,10 +784,6 @@ onBeforeUnmount(() => {
|
||||
</SCard>
|
||||
</section>
|
||||
|
||||
<section v-show="activePage === 'bans'" class="tab-page">
|
||||
<SCard class="ban-panel page-panel" title="封禁记录" :description="`${bans.length} 条`"><template #extra><SButton size="xs" variant="outline" @click="loadBans">刷新</SButton></template><div class="ban-list"><div v-if="!bans.length" class="empty">暂无封禁记录</div><div v-for="(item, i) in bans" :key="item.id || i" class="ban-item"><div class="ban-main"><b>@{{ item.username || item.first_name || item.user_id }}</b><span>{{ item.reason || '-' }}</span></div><div class="ban-meta"><span>{{ formatTime(item.time) }}</span><SButton v-if="item.auto_unban" size="xs" variant="outline" color="success" :loading="item._loading" :disabled="item._loading" @click="unban(item)">解封</SButton><STag v-else size="xs" color="secondary" variant="soft">已处理</STag></div></div></div></SCard>
|
||||
</section>
|
||||
|
||||
<form v-if="activePage === 'dashboard'" class="send-box floating-chat-send" @submit.prevent="sendMessage">
|
||||
<SInput v-model="newMessage" placeholder="发送消息到 TG 群" />
|
||||
<SButton type="submit" color="primary" :loading="sending" :disabled="sending || !newMessage.trim()">发送</SButton>
|
||||
|
||||
@@ -340,3 +340,20 @@ body { background: #f3f6fb; }
|
||||
.lottery-dialog { max-height: min(82vh, 760px); overflow-y: auto; }
|
||||
.shop-table .table-head, .shop-table .table-row { grid-template-columns: minmax(0, 1.5fr) 90px 80px 80px 150px; }
|
||||
@media (max-width: 780px) { .shop-table .table-head, .shop-table .table-row { grid-template-columns: 1fr; } }
|
||||
|
||||
|
||||
/* Overview 3-column responsive layout */
|
||||
.main-grid { grid-template-columns: minmax(360px, 1.35fr) minmax(300px, .9fr) minmax(280px, .8fr); height: calc(100vh - 210px); max-height: calc(100vh - 210px); min-height: 520px; align-items: stretch; }
|
||||
.main-grid .panel { height: 100%; min-height: 0; }
|
||||
.overview-ban-panel .s-card__content { height: calc(100% - 56px); min-height: 0; overflow: hidden; }
|
||||
.overview-ban-list { height: 100%; overflow-y: auto; padding-right: 4px; }
|
||||
.chat-context-menu { width: 188px; }
|
||||
.rule-library-card .s-card__content { display: grid; gap: 10px; }
|
||||
.rule-library-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.rule-library-grid h4 { margin: 0 0 8px; color: #334155; }
|
||||
.rule-list { max-height: 360px; overflow: auto; border: 1px solid #e2e8f0; border-radius: 14px; background: #f8fafc; }
|
||||
.rule-row { display: grid; grid-template-columns: minmax(0, 1fr) 52px; gap: 8px; padding: 8px 10px; border-bottom: 1px solid #e2e8f0; font-size: 12px; }
|
||||
.rule-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rule-row em { color: #64748b; font-style: normal; text-align: right; }
|
||||
@media (max-width: 1180px) { .main-grid { grid-template-columns: 1fr 1fr; height: auto; max-height: none; } .chat-panel { grid-column: 1 / -1; height: 42vh; } .log-panel, .overview-ban-panel { height: 34vh; } }
|
||||
@media (max-width: 760px) { .main-grid { grid-template-columns: 1fr; } .chat-panel, .log-panel, .overview-ban-panel { height: 36vh; min-height: 280px; } .rule-library-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
+68
@@ -512,6 +512,31 @@ async def api_restart_bot():
|
||||
return JSONResponse({"ok": True, "message": "TG Bot 正在重启"})
|
||||
|
||||
|
||||
@app.get("/api/spam-rules")
|
||||
async def api_list_spam_rules(limit: int = Query(default=500, ge=1, le=2000)):
|
||||
def read_rules(path: Path, rule_type: str):
|
||||
items = []
|
||||
if not path.exists():
|
||||
return items
|
||||
for i, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "|" in line:
|
||||
pattern, score_text = line.rsplit("|", 1)
|
||||
try:
|
||||
score = int(score_text.strip())
|
||||
except ValueError:
|
||||
pattern, score = line, None
|
||||
else:
|
||||
pattern, score = line, None
|
||||
items.append({"type": rule_type, "line": i, "pattern": pattern.strip(), "score": score})
|
||||
return items
|
||||
keywords = read_rules(spam_detector.RULES_DIR / "spam_keywords.txt", "keyword")
|
||||
regex = read_rules(spam_detector.RULES_DIR / "spam_regex.txt", "regex")
|
||||
return JSONResponse({"ok": True, "keywords": keywords[-limit:], "regex": regex[-limit:], "counts": {"keywords": len(keywords), "regex": len(regex)}})
|
||||
|
||||
|
||||
@app.post("/api/spam-test")
|
||||
async def api_spam_test(request: Request):
|
||||
data = await request.json()
|
||||
@@ -621,6 +646,48 @@ async def api_send(request: Request):
|
||||
return JSONResponse({"ok": True, "msg_id": msg.message_id})
|
||||
|
||||
|
||||
@app.post("/api/messages/{message_id}/ban")
|
||||
async def api_ban_message_user(message_id: int):
|
||||
"""根据聊天记录封禁用户,不删除消息。"""
|
||||
import config
|
||||
from datetime import timedelta
|
||||
rec = await Message.get_or_none(id=message_id)
|
||||
if not rec:
|
||||
return JSONResponse({"ok": False, "error": "消息不存在"}, status_code=404)
|
||||
if not rec.chat_id or not rec.user_id:
|
||||
return JSONResponse({"ok": False, "error": "缺少 chat_id 或 user_id,无法封禁"}, status_code=400)
|
||||
try:
|
||||
unban_at = now_tz() + timedelta(minutes=config.BAN_DURATION_MINUTES)
|
||||
await run_tg_request(lambda bot: bot.ban_chat_member(chat_id=rec.chat_id, user_id=rec.user_id, until_date=int(unban_at.timestamp())), connect_timeout=20.0, read_timeout=20.0, label="ban_chat_member")
|
||||
ban = await Ban.create(
|
||||
user_id=rec.user_id,
|
||||
username=rec.username,
|
||||
first_name=rec.first_name,
|
||||
reason="Web 手动封禁",
|
||||
method="web_manual_ban",
|
||||
chat_id=rec.chat_id,
|
||||
duration_minutes=config.BAN_DURATION_MINUTES,
|
||||
unban_at=unban_at,
|
||||
)
|
||||
await Action.create(action="BAN", user_id=rec.user_id, username=rec.username, chat_id=rec.chat_id, details={"method": "web_manual_ban", "message_id": rec.id})
|
||||
await broadcast_sse("ban", json_safe({
|
||||
"id": ban.id,
|
||||
"chat_id": rec.chat_id,
|
||||
"user_id": rec.user_id,
|
||||
"username": rec.username,
|
||||
"first_name": rec.first_name,
|
||||
"reason": ban.reason,
|
||||
"method": ban.method,
|
||||
"duration_minutes": config.BAN_DURATION_MINUTES,
|
||||
"auto_unban": True,
|
||||
"unban_at": unban_at,
|
||||
"time": ban.created_at,
|
||||
}))
|
||||
return JSONResponse(json_safe({"ok": True, "banned": True, "ban_id": ban.id}))
|
||||
except Exception as e:
|
||||
return JSONResponse({"ok": False, "error": f"封禁失败: {type(e).__name__}: {e}"}, status_code=502)
|
||||
|
||||
|
||||
@app.post("/api/chat/{message_id}/delete")
|
||||
@app.post("/api/messages/{message_id}/delete")
|
||||
async def api_delete_message(message_id: int, request: Request):
|
||||
@@ -669,6 +736,7 @@ async def api_delete_message(message_id: int, request: Request):
|
||||
first_name=rec.first_name,
|
||||
reason="Web 删除消息时手动封禁",
|
||||
method="web_delete_ban",
|
||||
chat_id=rec.chat_id,
|
||||
duration_minutes=config.BAN_DURATION_MINUTES,
|
||||
unban_at=unban_at,
|
||||
)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icon.svg" />
|
||||
<title>TG Spam Guard</title>
|
||||
<script type="module" crossorigin src="/static/assets/index-BhOEYMlC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-tp52gmZ4.css">
|
||||
<script type="module" crossorigin src="/static/assets/index-BmqZ_6lm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-3EKqFt0k.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user