feat: improve chat actions and rule management

This commit is contained in:
ngfchl
2026-07-07 17:55:23 +08:00
parent e3a49910dc
commit 87b17aa31e
8 changed files with 432 additions and 349 deletions
+68 -16
View File
@@ -51,6 +51,7 @@ const logsRefreshing = ref(false);
const copyingLogs = ref(false);
const logWrap = ref(false);
const newMessage = ref('');
const replyTarget = ref<ChatItem | null>(null);
const sending = ref(false);
const restarting = ref(false);
const restartingBot = ref(false);
@@ -121,7 +122,7 @@ function toDatetimeLocal(date: Date) {
}
const pageTitle = computed(() => ({
dashboard: '群组安全总览', shop: '商城管理', lottery: '抽奖管理', rules: '规则测试'
dashboard: '群组安全总览', shop: '商城管理', lottery: '抽奖管理', rules: '规则管理'
}[activePage.value]));
const pageDesc = computed(() => ({
dashboard: '实时审计聊天、封禁、日志和封禁记录', shop: '商品 CRUD 与兑换交易记录', lottery: '抽奖项目、开奖条件与参与记录', rules: '检测广告/灰产文本并维护规则库'
@@ -134,6 +135,14 @@ function goPage(page: typeof activePage.value) {
}
function isTransientBotRecord(item: ChatItem) {
const text = String(item?.text || '').trim();
return Number(item?.user_id || 0) === 0 && ['🔍 正在搜索知识库', '正在搜索知识库', '🔎 正在搜索知识库', '正在整理详细答案'].some(prefix => text.startsWith(prefix));
}
function visibleChatRows(rows: ChatItem[]) {
return rows.filter(item => !isTransientBotRecord(item));
}
function formatTime(value?: string) {
if (!value) return '-';
const text = String(value);
@@ -221,7 +230,7 @@ async function fetchJson(url: string, options?: RequestInit) {
async function loadChatHistory() {
try {
const data = await fetchJson(`/api/chat${selectedQuery.value}`);
chatLogs.value = data.map((m: ChatItem) => ({ ...m, _deleting: false }));
chatLogs.value = visibleChatRows(data.map((m: ChatItem) => ({ ...m, _deleting: false })));
} catch {}
}
async function loadBans() {
@@ -443,6 +452,7 @@ function connectSSE() {
es.addEventListener('connected', () => { sseConnected.value = true; });
es.addEventListener('chat', (e) => {
const data = JSON.parse((e as MessageEvent).data);
if (isTransientBotRecord(data)) return;
chatLogs.value.unshift(data);
if (chatLogs.value.length > 120) chatLogs.value.pop();
});
@@ -488,9 +498,10 @@ async function sendMessage() {
await fetchJson('/api/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, chat_id: selectedChatId.value })
body: JSON.stringify({ text, chat_id: selectedChatId.value, reply_to_message_id: replyTarget.value?.msg_id || null })
});
newMessage.value = '';
replyTarget.value = null;
} catch (e: any) {
showToast(`发送失败:${e.message}`);
} finally {
@@ -545,6 +556,14 @@ async function contextCopyText() {
showToast('复制失败,浏览器未允许剪贴板权限');
}
}
function contextReplyMessage() {
const item = chatContextMenu.item;
closeChatContextMenu();
if (!item?.msg_id) return showToast('当前消息无法回复');
replyTarget.value = item;
nextTick(() => document.querySelector<HTMLInputElement>('.floating-chat-send input')?.focus());
}
function cancelReply() { replyTarget.value = null; }
async function banChatMessage(item: ChatItem) {
if (!item.user_id) return showToast('当前记录没有用户 ID,无法封禁');
@@ -689,7 +708,7 @@ 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 === 'rules'}" @click="goPage('rules')"><span>规则测试</span><em>Rules</em></button>
<button :class="{active: activePage === 'rules'}" @click="goPage('rules')"><span>规则管理</span><em>Rules</em></button>
</nav>
<div class="side-footer">
<span class="conn" :class="sseConnected ? 'on' : 'off'">{{ sseConnected ? '实时连接' : '连接中' }}</span>
@@ -734,7 +753,7 @@ onBeforeUnmount(() => {
<span class="chat-time">{{ formatTime(item.time) }}</span>
</div>
</div>
<div class="chat-text">{{ item.text || '(空消息)' }}</div>
<div class="chat-text" :title="item.text || '(空消息)'">{{ item.text || '(空消息)' }}</div>
<div class="chat-foot">
<STag v-if="item.spam" class="reason-tag" size="xs" color="destructive" variant="soft" :title="item.reason || item.spam_reason">{{ item.reason || item.spam_reason }}</STag>
<span v-else />
@@ -797,22 +816,51 @@ onBeforeUnmount(() => {
@click.stop
@contextmenu.prevent
>
<button @click="contextCopyText">📋 复制内容</button>
<button class="danger" :disabled="!!chatContextMenu.item?.deleted" @click="contextDeleteMessage(false)">🗑 删除</button>
<button class="warning" :disabled="!chatContextMenu.item?.user_id" @click="contextDeleteAndBanMessage"> 删除并封禁</button>
<button class="warning" :disabled="!chatContextMenu.item?.user_id" @click="contextBanMessage">🚫 封印用户</button>
<button @click="contextAddToRules">📥 写入拦截库</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.user_id" class="warning" @click="contextDeleteAndBanMessage"> 删除并封禁</button>
<button @click="contextCopyText">📋 复制内容</button>
<button @click="contextReplyMessage"> 回复</button>
</div>
<section v-show="activePage === 'rules'" class="tab-page rule-test-page">
<SCard class="panel page-panel" title="🧪 广告/灰产检测" description="检测文本、查看评分,并维护外部规则库">
<section v-show="activePage === 'rules'" class="tab-page rule-manage-page">
<SCard class="panel page-panel rule-detect-card" 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 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>
<SButtonLoading variant="outline" color="warning" :loading="ruleSaving" :disabled="ruleSaving || !spamTestText.trim()" @click="ruleForm.pattern = spamTestText; ruleForm.type = 'keyword'; ruleForm.score = 90; saveSpamRule()">将内容写入规则库</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>
<div class="rule-write-grid inline-rule-writer">
<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>
</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}-${r.pattern}`" class="rule-row"><span :title="r.pattern">{{ r.pattern }}</span><em>{{ r.score || '-' }}</em></div><div v-if="!filteredKeywordRules.length" class="empty">暂无关键词规则</div></div></div>
<div><h4>正则规则</h4><div class="rule-list"><div v-for="r in filteredRegexRules" :key="`re-${r.line}-${r.pattern}`" class="rule-row"><span :title="r.pattern">{{ r.pattern }}</span><em>{{ r.score || '-' }}</em></div><div v-if="!filteredRegexRules.length" class="empty">暂无正则规则</div></div></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">
@@ -851,7 +899,11 @@ onBeforeUnmount(() => {
</section>
<form v-if="activePage === 'dashboard'" class="send-box floating-chat-send" @submit.prevent="sendMessage">
<SInput v-model="newMessage" placeholder="发送消息到 TG 群" />
<div v-if="replyTarget" class="reply-chip">
<span>回复 @{{ replyTarget.username || replyTarget.first_name || replyTarget.user_id }}{{ replyTarget.text || '(空消息)' }}</span>
<button type="button" @click="cancelReply">×</button>
</div>
<SInput v-model="newMessage" :placeholder="replyTarget ? '输入回复内容' : '发送消息到 TG 群'" />
<SButton type="submit" color="primary" :loading="sending" :disabled="sending || !newMessage.trim()">发送</SButton>
</form>
+10
View File
@@ -458,3 +458,13 @@ body { background: #f3f6fb; }
.participant-row span { color: #64748b; font-size: 12px; }
.participant-meta { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; color: #64748b; font-size: 12px; }
.participants-dialog .confirm-actions { padding: 10px 14px 14px; border-top: 1px solid #e2e8f0; }
/* Chat text clamp and reply composer */
.chat-text { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; max-height: calc(1.38em * 3); overflow: hidden; }
.send-box.floating-chat-send { grid-template-columns: minmax(0, 1fr) auto; }
.reply-chip { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 8px; min-width: 0; padding: 6px 9px; border-radius: 10px; background: #eef2ff; color: #3730a3; font-size: 12px; }
.reply-chip span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reply-chip button { border: 0; background: transparent; color: #64748b; cursor: pointer; font-size: 16px; line-height: 1; }
.rule-manage-page { display: grid; gap: 12px; }
.inline-rule-writer { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e2e8f0; }
.chat-context-menu button:disabled { opacity: .45; cursor: not-allowed; }
+16
View File
@@ -8,6 +8,20 @@ import config
logger = logging.getLogger("spam_guard")
TRANSIENT_BOT_RECORD_PREFIXES = (
"🔍 正在搜索知识库",
"正在搜索知识库",
"🔎 正在搜索知识库",
"正在整理详细答案",
)
def should_record_bot_message(text: str) -> bool:
body = (text or "").strip()
if not body:
return False
return not any(body.startswith(prefix) for prefix in TRANSIENT_BOT_RECORD_PREFIXES)
async def auto_delete(message, seconds: int | None = None) -> None:
"""延迟删除消息。删除失败不影响主流程。"""
@@ -55,6 +69,8 @@ async def record_bot_message(message, text: str | None = None, username: str = "
from web.api import broadcast_sse, json_safe
body = text if text is not None else (getattr(message, "text", None) or getattr(message, "caption", None) or "")
if not should_record_bot_message(str(body)):
return None
rec = await Message.get_or_none(chat_id=message.chat_id, msg_id=message.message_id)
if rec:
return rec
+7 -2
View File
@@ -233,6 +233,7 @@ class LoginPayload(BaseModel):
class SendPayload(BaseModel):
text: str = Field(default='', max_length=4000)
chat_id: int | None = None
reply_to_message_id: int | None = None
class ShopItemPayload(BaseModel):
@@ -729,8 +730,12 @@ async def api_send(data: SendPayload):
if len(text) > 3500:
return JSONResponse({"ok": False, "error": "消息太长"}, status_code=400)
chat_id = int(data.get("chat_id") or config.TG_CHAT_ID)
msg = await run_tg_request(lambda bot: bot.send_message(chat_id=chat_id, text=text), label="send_message")
chat_id = int(data.chat_id or config.TG_CHAT_ID)
reply_to_message_id = data.reply_to_message_id
msg = await run_tg_request(
lambda bot: bot.send_message(chat_id=chat_id, text=text, reply_to_message_id=reply_to_message_id),
label="send_message",
)
rec = await Message.create(
msg_id=msg.message_id,
chat_id=chat_id,
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
+2 -2
View File
@@ -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-BtxpMffg.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-es9pSdo5.css">
<script type="module" crossorigin src="/static/assets/index-CLi70OmX.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-NLxQkQW1.css">
</head>
<body>
<div id="app"></div>