feat: rebuild manager frontend with soybean ui
This commit is contained in:
@@ -23,6 +23,8 @@ index/
|
||||
models/
|
||||
|
||||
# 开发/备份文件
|
||||
front/node_modules/
|
||||
front/dist/
|
||||
main.py.bak
|
||||
.patch_autodelete.py
|
||||
static/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TG Spam Guard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1431
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "tg-spam-guard-front",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5174",
|
||||
"build": "vite build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@soybeanjs/ui": "latest",
|
||||
"@vitejs/plugin-vue": "latest",
|
||||
"vite": "latest",
|
||||
"vue": "latest"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
|
||||
import { SButton, SCard, SConfigProvider, SInput, STag } from '@soybeanjs/ui';
|
||||
|
||||
type ChatItem = Record<string, any> & { _deleting?: boolean };
|
||||
type BanItem = Record<string, any> & { _loading?: boolean };
|
||||
type LogItem = { time?: string; level?: string; message?: string };
|
||||
|
||||
const sseConnected = ref(false);
|
||||
const chatLogs = ref<ChatItem[]>([]);
|
||||
const bans = ref<BanItem[]>([]);
|
||||
const logs = ref<LogItem[]>([]);
|
||||
const newMessage = ref('');
|
||||
const sending = ref(false);
|
||||
const restarting = ref(false);
|
||||
const restartingBot = ref(false);
|
||||
const stats = reactive({ today_bans: 0, total_bans: 0, active_bans: 0 });
|
||||
const logScroll = ref<HTMLElement | null>(null);
|
||||
let es: EventSource | null = null;
|
||||
let statTimer: number | undefined;
|
||||
|
||||
const chatCount = computed(() => chatLogs.value.length);
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) return '-';
|
||||
const text = String(value);
|
||||
const m = text.match(/(\d{2}:\d{2}:\d{2})/);
|
||||
if (m) return m[1];
|
||||
return text.replace('T', ' ').slice(11, 19) || text;
|
||||
}
|
||||
function compactLog(msg?: string) {
|
||||
const text = String(msg || '').replace(/\s+/g, ' ').trim();
|
||||
return text.length > 260 ? `${text.slice(0, 260)}...` : text;
|
||||
}
|
||||
function chatStatusText(item: ChatItem) {
|
||||
if (item.deleted) return '已删';
|
||||
if (item.spam) return '垃圾';
|
||||
if (Number(item.user_id || 0) === 0) return 'Web';
|
||||
return '正常';
|
||||
}
|
||||
function chatStatusClass(item: ChatItem) {
|
||||
if (item.deleted) return 'deleted';
|
||||
if (item.spam) return 'spam';
|
||||
if (Number(item.user_id || 0) === 0) return 'web';
|
||||
return 'normal';
|
||||
}
|
||||
function chatItemClass(item: ChatItem) {
|
||||
return ['chat-item', chatStatusClass(item)];
|
||||
}
|
||||
function tagColor(item: ChatItem) {
|
||||
if (item.deleted) return 'secondary';
|
||||
if (item.spam) return 'destructive';
|
||||
if (Number(item.user_id || 0) === 0) return 'accent';
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, options?: RequestInit) {
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) throw new Error(data.error || data.message || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
async function loadChatHistory() {
|
||||
try {
|
||||
const data = await fetchJson('/api/chat');
|
||||
chatLogs.value = data.map((m: ChatItem) => ({ ...m, _deleting: false }));
|
||||
} catch {}
|
||||
}
|
||||
async function loadBans() {
|
||||
try {
|
||||
const data = await fetchJson('/api/bans');
|
||||
bans.value = data.map((b: BanItem) => ({ ...b, _loading: false }));
|
||||
} catch {}
|
||||
}
|
||||
async function loadStats() {
|
||||
try {
|
||||
Object.assign(stats, await fetchJson('/api/stats'));
|
||||
} catch {}
|
||||
}
|
||||
async function loadLogs() {
|
||||
try {
|
||||
logs.value = await fetchJson('/api/logs');
|
||||
scrollLogBottom();
|
||||
} catch {}
|
||||
}
|
||||
function scrollLogBottom() {
|
||||
nextTick(() => {
|
||||
if (logScroll.value) logScroll.value.scrollTop = logScroll.value.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function connectSSE() {
|
||||
if (es) es.close();
|
||||
es = new EventSource('/api/stream');
|
||||
es.addEventListener('connected', () => { sseConnected.value = true; });
|
||||
es.addEventListener('chat', (e) => {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
chatLogs.value.unshift(data);
|
||||
if (chatLogs.value.length > 120) chatLogs.value.pop();
|
||||
});
|
||||
es.addEventListener('spam', (e) => {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
const item = chatLogs.value.find(x => x.msg_id === data.msg_id);
|
||||
if (item) Object.assign(item, data);
|
||||
});
|
||||
es.addEventListener('chat_delete', (e) => {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
const item = chatLogs.value.find(x => x.id === data.id);
|
||||
if (item) Object.assign(item, { deleted: true, manually_deleted: true, deleted_at: data.deleted_at });
|
||||
loadStats();
|
||||
});
|
||||
es.addEventListener('ban', (e) => {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
bans.value.unshift({ ...data, _loading: false });
|
||||
if (bans.value.length > 80) bans.value.pop();
|
||||
loadStats();
|
||||
});
|
||||
es.addEventListener('unban', (e) => {
|
||||
const data = JSON.parse((e as MessageEvent).data);
|
||||
const ban = bans.value.find(b => b.user_id === data.user_id);
|
||||
if (ban) ban.auto_unban = false;
|
||||
loadStats();
|
||||
});
|
||||
es.addEventListener('log', (e) => {
|
||||
logs.value.push(JSON.parse((e as MessageEvent).data));
|
||||
if (logs.value.length > 300) logs.value.shift();
|
||||
scrollLogBottom();
|
||||
});
|
||||
es.onerror = () => {
|
||||
sseConnected.value = false;
|
||||
window.setTimeout(connectSSE, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = newMessage.value.trim();
|
||||
if (!text || sending.value) return;
|
||||
sending.value = true;
|
||||
try {
|
||||
await fetchJson('/api/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text })
|
||||
});
|
||||
newMessage.value = '';
|
||||
} catch (e: any) {
|
||||
alert(`发送失败:${e.message}`);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
async function deleteChatMessage(item: ChatItem, ban = false) {
|
||||
const action = ban ? '删除并封禁' : '删除';
|
||||
if (!window.confirm(`确认${action}这条消息?`)) return;
|
||||
item._deleting = true;
|
||||
try {
|
||||
await fetchJson(`/api/chat/${item.id}/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ban })
|
||||
});
|
||||
Object.assign(item, { deleted: true, manually_deleted: true });
|
||||
await loadStats();
|
||||
} catch (e: any) {
|
||||
alert(`${action}失败:${e.message}`);
|
||||
} finally {
|
||||
item._deleting = false;
|
||||
}
|
||||
}
|
||||
async function unban(item: BanItem) {
|
||||
if (!window.confirm(`确认解封 ${item.username || item.user_id}?`)) return;
|
||||
item._loading = true;
|
||||
try {
|
||||
await fetchJson(`/api/unban/${item.user_id}`, { method: 'POST' });
|
||||
item.auto_unban = false;
|
||||
await loadStats();
|
||||
} catch (e: any) {
|
||||
alert(`解封失败:${e.message}`);
|
||||
} finally {
|
||||
item._loading = false;
|
||||
}
|
||||
}
|
||||
async function restartBot() {
|
||||
if (!window.confirm('确认只重启 Telegram Bot?Web 管理页不会重启。')) return;
|
||||
restartingBot.value = true;
|
||||
try {
|
||||
await fetchJson('/api/restart-bot', { method: 'POST' });
|
||||
window.setTimeout(() => { restartingBot.value = false; loadLogs(); }, 5000);
|
||||
} catch (e: any) {
|
||||
restartingBot.value = false;
|
||||
alert(`重启 Bot 失败:${e.message}`);
|
||||
}
|
||||
}
|
||||
async function restartService() {
|
||||
if (!window.confirm('确认重启 TG Spam Guard 服务?重启期间 Web 和 Bot 会短暂不可用。')) return;
|
||||
restarting.value = true;
|
||||
try {
|
||||
await fetchJson('/api/restart', { method: 'POST' });
|
||||
} catch {}
|
||||
window.setTimeout(() => location.reload(), 8000);
|
||||
}
|
||||
function copyLogs() {
|
||||
const text = logs.value.map(l => `${formatTime(l.time)} ${l.level || ''} ${compactLog(l.message)}`).join('\n');
|
||||
navigator.clipboard?.writeText(text);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadChatHistory();
|
||||
loadBans();
|
||||
loadStats();
|
||||
loadLogs();
|
||||
connectSSE();
|
||||
statTimer = window.setInterval(loadStats, 30000);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (es) es.close();
|
||||
if (statTimer) window.clearInterval(statTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SConfigProvider :theme="{ theme: { primary: 'indigo' }, size: 'sm' }">
|
||||
<main class="page">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>🛡️ TG Spam Guard</h1>
|
||||
<p>Telegram 群组安全管理面板</p>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span class="conn" :class="sseConnected ? 'on' : 'off'">{{ sseConnected ? '实时连接' : '连接中' }}</span>
|
||||
<SButton size="xs" variant="outline" color="primary" :disabled="restartingBot" @click="restartBot">重启Bot</SButton>
|
||||
<SButton size="xs" variant="soft" color="warning" :disabled="restarting" @click="restartService">重启服务</SButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="stats-grid">
|
||||
<SCard class="stat-card blue"><span>今日封禁</span><strong>{{ stats.today_bans || 0 }}</strong></SCard>
|
||||
<SCard class="stat-card red"><span>累计封禁</span><strong>{{ stats.total_bans || 0 }}</strong></SCard>
|
||||
<SCard class="stat-card orange"><span>当前封禁</span><strong>{{ stats.active_bans || 0 }}</strong></SCard>
|
||||
<SCard class="stat-card green"><span>聊天记录</span><strong>{{ chatCount }}</strong></SCard>
|
||||
</section>
|
||||
|
||||
<section class="main-grid">
|
||||
<SCard class="panel chat-panel" title="💬 聊天记录" :description="`${chatCount} 条`">
|
||||
<div class="scroll-area chat-scroll">
|
||||
<div v-if="!chatLogs.length" class="empty">等待消息...</div>
|
||||
<article v-for="(item, i) in chatLogs" :key="item.id || i" :class="chatItemClass(item)">
|
||||
<div class="chat-head">
|
||||
<div class="chat-user-line">
|
||||
<STag size="xs" :color="tagColor(item)" variant="soft">{{ chatStatusText(item) }}</STag>
|
||||
<span class="chat-user">@{{ item.username || item.first_name || 'unknown' }}</span>
|
||||
<span class="chat-uid">{{ item.user_id }}</span>
|
||||
</div>
|
||||
<div class="chat-meta-line">
|
||||
<span class="chat-id">#{{ item.msg_id || '-' }}</span>
|
||||
<span class="chat-time">{{ formatTime(item.time) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-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 />
|
||||
<div class="chat-actions">
|
||||
<STag v-if="item.deleted" size="xs" color="secondary" variant="soft">已删 · {{ formatTime(item.deleted_at) }}</STag>
|
||||
<template v-else>
|
||||
<SButton size="xs" variant="link" color="destructive" :disabled="item._deleting" @click="deleteChatMessage(item, false)">删除</SButton>
|
||||
<SButton v-if="item.user_id" size="xs" variant="link" color="warning" :disabled="item._deleting" @click="deleteChatMessage(item, true)">删封</SButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</SCard>
|
||||
|
||||
<SCard class="panel log-panel" title="📜 实时日志" :description="`${logs.length} 条`">
|
||||
<template #extra>
|
||||
<div class="panel-extra">
|
||||
<SButton size="xs" variant="outline" @click="copyLogs">复制</SButton>
|
||||
<SButton size="xs" variant="outline" @click="loadLogs">刷新</SButton>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="logScroll" class="log-window">
|
||||
<div v-if="!logs.length" class="empty dark">等待日志...</div>
|
||||
<div v-for="(item, i) in logs" :key="i" class="log-line" :class="item.level" :title="compactLog(item.message)">
|
||||
<span class="log-time">{{ formatTime(item.time) }}</span>
|
||||
<span class="log-level">{{ item.level }}</span>
|
||||
<span class="log-msg">{{ compactLog(item.message) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SCard>
|
||||
</section>
|
||||
|
||||
<SCard class="ban-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" :disabled="item._loading" @click="unban(item)">解封</SButton>
|
||||
<STag v-else size="xs" color="secondary" variant="soft">已处理</STag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SCard>
|
||||
|
||||
<form class="send-box" @submit.prevent="sendMessage">
|
||||
<SInput v-model="newMessage" placeholder="发送消息到 TG 群" />
|
||||
<SButton type="submit" color="primary" :disabled="sending || !newMessage.trim()">发送</SButton>
|
||||
</form>
|
||||
</main>
|
||||
</SConfigProvider>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from 'vue';
|
||||
import '@soybeanjs/ui/styles.css';
|
||||
import './style.css';
|
||||
import App from './App.vue';
|
||||
|
||||
createApp(App).mount('#app');
|
||||
@@ -0,0 +1,70 @@
|
||||
:root {
|
||||
color: #0f172a;
|
||||
background: #eef2ff;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-height: 100vh; background: linear-gradient(135deg, #eef2ff 0%, #f8fafc 45%, #ecfeff 100%); }
|
||||
button { font-family: inherit; }
|
||||
.page { min-height: 100vh; padding: 14px; padding-bottom: 82px; }
|
||||
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; margin-bottom: 12px; border: 1px solid rgba(148,163,184,.28); border-radius: 18px; background: rgba(255,255,255,.86); box-shadow: 0 18px 40px rgba(15,23,42,.08); backdrop-filter: blur(12px); }
|
||||
.topbar h1 { margin: 0; font-size: 22px; line-height: 1.1; }
|
||||
.topbar p { margin: 4px 0 0; color: #64748b; font-size: 12px; }
|
||||
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
.conn { position: relative; padding-left: 14px; font-size: 12px; color: #64748b; white-space: nowrap; }
|
||||
.conn::before { content: ''; position: absolute; left: 0; top: 50%; width: 8px; height: 8px; border-radius: 999px; transform: translateY(-50%); background: #ef4444; }
|
||||
.conn.on::before { background: #22c55e; box-shadow: 0 0 0 4px rgba(34,197,94,.14); }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; }
|
||||
.stat-card .s-card__content, .stat-card { min-height: 58px; }
|
||||
.stat-card { display: flex; flex-direction: column; justify-content: center; gap: 4px; border-left: 4px solid #3b82f6; }
|
||||
.stat-card span { color: #64748b; font-size: 12px; }
|
||||
.stat-card strong { font-size: 24px; line-height: 1; }
|
||||
.stat-card.red { border-left-color: #ef4444; }
|
||||
.stat-card.orange { border-left-color: #f59e0b; }
|
||||
.stat-card.green { border-left-color: #10b981; }
|
||||
.main-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px; height: calc(100vh - 286px); min-height: 460px; }
|
||||
.panel { min-height: 0; overflow: hidden; }
|
||||
.panel .s-card__content { height: 100%; min-height: 0; }
|
||||
.scroll-area { height: 100%; overflow-y: auto; padding-right: 4px; }
|
||||
.empty { padding: 32px 0; text-align: center; color: #94a3b8; }
|
||||
.empty.dark { color: #64748b; }
|
||||
.chat-item { position: relative; margin-bottom: 7px; padding: 6px 8px 6px 12px; border: 1px solid #eef2f7; border-radius: 12px; background: rgba(255,255,255,.96); overflow: hidden; transition: background .15s, box-shadow .15s, border-color .15s; }
|
||||
.chat-item::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: #3b82f6; }
|
||||
.chat-item:hover { box-shadow: 0 6px 16px rgba(15,23,42,.07); }
|
||||
.chat-item.spam { border-color: #fecaca; background: #fff7f7; }
|
||||
.chat-item.spam::before { background: #ef4444; }
|
||||
.chat-item.deleted { opacity: .78; background: #f8fafc; }
|
||||
.chat-item.deleted::before { background: #94a3b8; }
|
||||
.chat-item.web::before { background: #8b5cf6; }
|
||||
.chat-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
|
||||
.chat-user-line { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.chat-user { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #2563eb; font-weight: 800; font-size: 13px; }
|
||||
.chat-uid { color: #94a3b8; font-size: 11px; }
|
||||
.chat-meta-line { display: flex; align-items: center; justify-content: flex-end; gap: 5px; white-space: nowrap; }
|
||||
.chat-id { color: #64748b; font-size: 11px; background: #f1f5f9; border-radius: 999px; padding: 1px 6px; }
|
||||
.chat-time { color: #94a3b8; font-size: 11px; }
|
||||
.chat-text { margin-top: 4px; color: #334155; font-size: 13px; line-height: 1.38; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; }
|
||||
.deleted .chat-text { color: #94a3b8; text-decoration: line-through; }
|
||||
.chat-foot { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-top: 4px; min-height: 20px; }
|
||||
.chat-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-left: auto; }
|
||||
.reason-tag { max-width: min(70%, 520px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.panel-extra { display: flex; gap: 6px; align-items: center; }
|
||||
.log-window { height: 100%; overflow: auto; padding: 6px; border-radius: 12px; border: 1px solid #1e293b; background: #0b1020; color: #cbd5e1; font-family: Consolas, Monaco, 'Courier New', monospace; font-size: 11px; line-height: 1.28; }
|
||||
.log-line { display: grid; grid-template-columns: 68px 48px minmax(0, 1fr); gap: 6px; padding: 2px 3px; border-bottom: 1px solid rgba(148,163,184,.08); white-space: nowrap; }
|
||||
.log-msg { overflow: hidden; text-overflow: ellipsis; }
|
||||
.log-time { color: #64748b; }
|
||||
.log-level { text-align: center; border-radius: 4px; font-weight: 800; }
|
||||
.log-line.INFO .log-level { color: #93c5fd; background: rgba(59,130,246,.12); }
|
||||
.log-line.WARNING .log-level { color: #fbbf24; background: rgba(245,158,11,.12); }
|
||||
.log-line.ERROR .log-level { color: #f87171; background: rgba(239,68,68,.14); }
|
||||
.ban-panel { margin-top: 10px; height: 206px; overflow: hidden; }
|
||||
.ban-panel .s-card__content { height: 100%; min-height: 0; }
|
||||
.ban-list { height: 100%; overflow: auto; display: grid; gap: 6px; }
|
||||
.ban-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 7px 9px; border-radius: 10px; background: #fff7ed; border-left: 4px solid #f97316; }
|
||||
.ban-main { min-width: 0; display: grid; gap: 2px; }
|
||||
.ban-main b { color: #9a3412; font-size: 13px; }
|
||||
.ban-main span { color: #64748b; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ban-meta { display: flex; align-items: center; gap: 8px; color: #94a3b8; font-size: 11px; white-space: nowrap; }
|
||||
.send-box { position: fixed; left: 50%; bottom: 12px; width: min(960px, calc(100vw - 24px)); transform: translateX(-50%); display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; padding: 10px; border: 1px solid rgba(148,163,184,.32); border-radius: 16px; background: rgba(255,255,255,.94); box-shadow: 0 18px 40px rgba(15,23,42,.14); backdrop-filter: blur(12px); z-index: 50; }
|
||||
@media (max-width: 900px) { .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .main-grid { grid-template-columns: 1fr; height: auto; min-height: 0; } .chat-panel, .log-panel { height: 42vh; min-height: 330px; } .ban-panel { height: 240px; } }
|
||||
@media (max-width: 520px) { .page { padding: 8px; padding-bottom: 78px; } .topbar { align-items: flex-start; flex-direction: column; border-radius: 14px; padding: 11px; } .topbar h1 { font-size: 19px; } .top-actions { width: 100%; justify-content: space-between; } .stats-grid { gap: 7px; } .stat-card strong { font-size: 20px; } .chat-panel, .log-panel { height: 39vh; min-height: 300px; } .chat-item { margin-bottom: 8px; padding: 6px 7px 6px 12px; } .chat-head { gap: 6px; } .chat-user { max-width: 42vw; } .chat-uid { display: none; } .chat-text { padding: 5px 7px; border-radius: 8px; background: #f8fafc; line-height: 1.35; } .reason-tag { max-width: 58%; } .log-window { font-size: 10px; } .log-line { grid-template-columns: 55px 42px minmax(0, 1fr); gap: 4px; } .ban-panel { height: 220px; } .send-box { bottom: 8px; width: calc(100vw - 12px); padding: 8px; border-radius: 13px; } }
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/static/',
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true
|
||||
}
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
-619
@@ -1,625 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🛡️ TG Spam Guard - 收割机</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #eef2f7; color: #303133; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
#app { padding: 12px 12px 72px; height: 100vh; overflow: hidden; max-width: 1680px; margin: 0 auto; }
|
||||
|
||||
.topbar {
|
||||
height: 54px;
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
border-radius: 12px;
|
||||
padding: 0 18px;
|
||||
margin-bottom: 10px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
.topbar h1 { margin: 0; font-size: 18px; font-weight: 700; }
|
||||
.status { display: flex; align-items: center; gap: 8px; font-size: 13px; opacity: 0.95; }
|
||||
.sse-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.sse-dot.on { background: #22c55e; box-shadow: 0 0 0 4px rgba(34,197,94,.2); }
|
||||
.sse-dot.off { background: #ef4444; box-shadow: 0 0 0 4px rgba(239,68,68,.2); }
|
||||
|
||||
.stats-row { margin-bottom: 10px; }
|
||||
.mini-stat .el-card__body { padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.mini-stat .label { font-size: 12px; color: #6b7280; }
|
||||
.mini-stat .num { font-size: 20px; font-weight: 800; line-height: 1; }
|
||||
.mini-stat.blue .num { color: #2563eb; }
|
||||
.mini-stat.red .num { color: #ef4444; }
|
||||
.mini-stat.orange .num { color: #f59e0b; }
|
||||
.mini-stat.green .num { color: #16a34a; }
|
||||
|
||||
.main-grid { height: calc(100vh - 54px - 10px - 54px - 10px - 212px - 84px); min-height: 360px; margin-bottom: 10px; }
|
||||
.panel { height: 100%; }
|
||||
.panel .el-card__body { height: 100%; padding: 12px; display: flex; flex-direction: column; }
|
||||
.panel-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
|
||||
.panel-header h3 { margin: 0; font-size: 15px; color: #111827; white-space: nowrap; }
|
||||
.panel-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.send-box { display: flex; gap: 8px; margin-bottom: 10px; }
|
||||
.fixed-send-box {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 12px;
|
||||
transform: translateX(-50%);
|
||||
width: min(760px, calc(100vw - 24px));
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: rgba(255,255,255,.96);
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 30px rgba(15,23,42,.16);
|
||||
z-index: 50;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.scroll-area { flex: 1; overflow-y: auto; padding-right: 4px; }
|
||||
.empty-text { text-align: center; padding: 50px 0; color: #9ca3af; }
|
||||
.empty-text .icon { font-size: 36px; display: block; margin-bottom: 8px; }
|
||||
|
||||
.chat-item {
|
||||
position: relative;
|
||||
margin: 0 0 7px 0;
|
||||
padding: 6px 8px 6px 12px;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
transition: background .15s, box-shadow .15s, border-color .15s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-item::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: #3b82f6; }
|
||||
.chat-item:hover { background: #f8fafc; box-shadow: 0 3px 10px rgba(15,23,42,.05); }
|
||||
.chat-item.normal::before { background: #3b82f6; }
|
||||
.chat-item.web::before { background: #8b5cf6; }
|
||||
.chat-item.spam { background: #fff7f7; border-color: #fecaca; }
|
||||
.chat-item.spam::before { background: #ef4444; }
|
||||
.chat-item.deleted { background: #f8fafc; border-color: #e2e8f0; opacity: .78; }
|
||||
.chat-item.deleted::before { background: #94a3b8; }
|
||||
.chat-item.deleted .chat-text { color: #94a3b8; text-decoration: line-through; }
|
||||
.chat-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
|
||||
.chat-user-line { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.chat-user { font-weight: 700; color: #2563eb; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.chat-uid { color: #94a3b8; font-size: 11px; font-weight: 500; }
|
||||
.chat-meta-line { display: flex; align-items: center; justify-content: flex-end; gap: 5px; white-space: nowrap; }
|
||||
.chat-time { flex-shrink: 0; font-size: 11px; color: #9ca3af; white-space: nowrap; }
|
||||
.chat-id-pill { color: #64748b; font-size: 11px; background: #f1f5f9; border-radius: 999px; padding: 1px 6px; }
|
||||
.chat-status { font-size: 11px; border-radius: 999px; padding: 1px 6px; line-height: 16px; background: #eff6ff; color: #2563eb; white-space: nowrap; }
|
||||
.chat-status.spam { background: #fee2e2; color: #dc2626; }
|
||||
.chat-status.deleted { background: #e2e8f0; color: #64748b; }
|
||||
.chat-status.web { background: #ede9fe; color: #7c3aed; }
|
||||
.chat-text {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
line-height: 1.38;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.chat-foot { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-top: 4px; min-height: 20px; }
|
||||
.chat-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.chat-actions .el-button--text { padding: 0; font-size: 12px; }
|
||||
.reason-tag { display: inline-block; max-width: min(70%, 520px); }
|
||||
.reason-tag .el-tag { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }
|
||||
.deleted-tag { flex-shrink: 0; }
|
||||
|
||||
.log-window {
|
||||
flex: 1;
|
||||
background: #0b1020;
|
||||
color: #cbd5e1;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 8px;
|
||||
padding: 4px 6px;
|
||||
overflow: auto;
|
||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.log-line {
|
||||
display: grid;
|
||||
grid-template-columns: 68px 46px minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
min-height: 16px;
|
||||
padding: 1px 2px;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid rgba(148,163,184,.08);
|
||||
}
|
||||
.log-line:hover { background: rgba(148,163,184,.08); }
|
||||
.log-time { color: #64748b; }
|
||||
.log-level { font-weight: 700; text-align: center; border-radius: 3px; padding: 0 3px; }
|
||||
.log-msg { overflow: hidden; text-overflow: ellipsis; }
|
||||
.log-line.INFO .log-level { color: #93c5fd; background: rgba(59,130,246,.12); }
|
||||
.log-line.WARNING .log-level { color: #fbbf24; background: rgba(245,158,11,.12); }
|
||||
.log-line.ERROR .log-level { color: #f87171; background: rgba(239,68,68,.14); }
|
||||
.log-line.INFO .log-msg { color: #cbd5e1; }
|
||||
.log-line.WARNING .log-msg { color: #fde68a; }
|
||||
.log-line.ERROR .log-msg { color: #fecaca; }
|
||||
|
||||
.ban-section { height: 212px; }
|
||||
.ban-section .el-card__body { height: 100%; padding: 12px; display: flex; flex-direction: column; }
|
||||
.ban-list { flex: 1; overflow-y: auto; }
|
||||
.ban-item { padding: 8px 10px; border-bottom: 1px solid #edf2f7; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.ban-info { flex: 1; min-width: 0; }
|
||||
.ban-info .user { font-weight: 700; color: #ef4444; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ban-info .reason { font-size: 12px; color: #6b7280; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ban-info .time { font-size: 11px; color: #9ca3af; margin-top: 2px; }
|
||||
|
||||
.el-card { border: none; border-radius: 12px; }
|
||||
.el-button--mini, .el-button--small { border-radius: 8px; }
|
||||
.confirm-dialog { border-radius: 14px; max-width: calc(100vw - 32px); }
|
||||
.confirm-dialog .el-message-box__content { word-break: break-word; }
|
||||
.chat-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.chat-actions .el-button--text { padding: 0; }
|
||||
.chat-id-pill { color: #94a3b8; font-size: 11px; }
|
||||
.spam-tooltip { max-width: min(720px, calc(100vw - 48px)); word-break: break-word; line-height: 1.45; }
|
||||
@media (max-width: 900px) { .spam-tooltip { max-width: calc(100vw - 32px); } }
|
||||
|
||||
@media (max-width: 1200px) and (min-width: 901px) {
|
||||
.main-grid { height: calc(100vh - 54px - 10px - 54px - 10px - 220px - 84px); }
|
||||
.chat-item .meta { flex-direction: column; gap: 4px; }
|
||||
.chat-item .meta-right { justify-content: flex-start; }
|
||||
.log-line { grid-template-columns: 62px 42px minmax(0, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
#app { height: auto; min-height: 100vh; overflow: auto; padding: 8px 8px 104px; }
|
||||
.topbar { height: auto; min-height: 48px; padding: 10px 12px; margin-bottom: 8px; align-items: flex-start; gap: 8px; }
|
||||
.topbar h1 { font-size: 16px; line-height: 1.25; }
|
||||
.status { font-size: 12px; white-space: nowrap; margin-top: 2px; }
|
||||
.stats-row { margin-bottom: 4px; }
|
||||
.stats-row .el-col { margin-bottom: 8px; }
|
||||
.mini-stat .el-card__body { padding: 9px 10px; }
|
||||
.mini-stat .label { font-size: 11px; }
|
||||
.mini-stat .num { font-size: 18px; }
|
||||
.main-grid { height: auto; min-height: 0; margin-bottom: 8px; }
|
||||
.main-grid > .el-col { height: auto !important; }
|
||||
.panel { height: auto; min-height: 0; margin-bottom: 10px; }
|
||||
.panel .el-card__body { padding: 10px; }
|
||||
.panel-header { align-items: flex-start; margin-bottom: 8px; }
|
||||
.panel-header h3 { font-size: 14px; }
|
||||
.panel-actions { gap: 6px; }
|
||||
.scroll-area { height: 40vh; min-height: 280px; flex: none; }
|
||||
.chat-item { padding: 8px; }
|
||||
.chat-item { margin-bottom: 8px; padding: 6px 7px 6px 12px; }
|
||||
.chat-head { grid-template-columns: minmax(0, 1fr) auto; gap: 6px; }
|
||||
.chat-user-line { min-width: 0; }
|
||||
.chat-user { max-width: 42vw; }
|
||||
.chat-uid { display: none; }
|
||||
.chat-meta-line { justify-content: flex-end; }
|
||||
.chat-foot { align-items: flex-start; }
|
||||
.chat-actions { justify-content: flex-end; margin-left: auto; }
|
||||
.chat-text { margin-top: 4px; padding: 5px 7px; background: #f8fafc; border-radius: 8px; line-height: 1.35; }
|
||||
.reason-tag { max-width: 62%; }
|
||||
.chat-item .user { max-width: 100%; white-space: normal; word-break: break-all; }
|
||||
.chat-item .time { white-space: normal; }
|
||||
.log-window { height: 34vh; min-height: 250px; flex: none; font-size: 10px; }
|
||||
.log-line { grid-template-columns: 54px 38px minmax(0, 1fr); gap: 4px; min-height: 18px; }
|
||||
.ban-section { height: 34vh; min-height: 280px; }
|
||||
.ban-section .el-card__body { padding: 10px; }
|
||||
.ban-item { align-items: flex-start; gap: 8px; }
|
||||
.ban-info .reason { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.fixed-send-box { bottom: 8px; width: calc(100vw - 16px); padding: 8px; border-radius: 12px; }
|
||||
.fixed-send-box .el-button { padding-left: 12px; padding-right: 12px; }
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
#app { padding-left: 6px; padding-right: 6px; }
|
||||
.topbar { border-radius: 10px; }
|
||||
.panel .el-card__body, .ban-section .el-card__body { padding: 8px; }
|
||||
.scroll-area { height: 38vh; min-height: 260px; }
|
||||
.log-window { height: 32vh; min-height: 240px; }
|
||||
.log-msg { white-space: normal; }
|
||||
.log-line { align-items: start; }
|
||||
.fixed-send-box { width: calc(100vw - 12px); }
|
||||
.confirm-dialog { width: calc(100vw - 28px) !important; }
|
||||
.confirm-dialog .el-message-box__btns { display: flex; gap: 8px; }
|
||||
.confirm-dialog .el-message-box__btns button { flex: 1; margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TG Spam Guard</title>
|
||||
<script type="module" crossorigin src="/static/assets/index-ONfVjDIq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-DhYmQjqq.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="topbar">
|
||||
<h1>🛡️ TG Spam Guard</h1>
|
||||
<div class="status">
|
||||
<span class="sse-dot" :class="sseConnected ? 'on' : 'off'"></span>
|
||||
{{ sseConnected ? '实时连接' : '连接中...' }}
|
||||
<el-button size="mini" type="primary" plain :loading="restartingBot" @click="restartBot">重启Bot</el-button>
|
||||
<el-button size="mini" type="warning" plain :loading="restarting" @click="restartService">重启服务</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row class="stats-row" :gutter="10">
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="mini-stat blue"><span class="label">今日封禁</span><span class="num">{{ stats.today_bans || 0 }}</span></el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="mini-stat red"><span class="label">累计封禁</span><span class="num">{{ stats.total_bans || 0 }}</span></el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="mini-stat orange"><span class="label">当前封禁</span><span class="num">{{ stats.active_bans || 0 }}</span></el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="6">
|
||||
<el-card shadow="never" class="mini-stat green"><span class="label">聊天记录</span><span class="num">{{ chatLogs.length || 0 }}</span></el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row class="main-grid" :gutter="10">
|
||||
<el-col :xs="24" :md="12" style="height:100%;">
|
||||
<el-card shadow="hover" class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>💬 聊天记录</h3>
|
||||
<el-tag size="mini" type="info">{{ chatLogs.length }} 条</el-tag>
|
||||
</div>
|
||||
<div class="scroll-area" 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" :class="chatItemClass(item)">
|
||||
<div class="chat-head">
|
||||
<div class="chat-user-line">
|
||||
<span class="chat-status" :class="chatStatusClass(item)">{{ chatStatusText(item) }}</span>
|
||||
<span class="chat-user">@{{ item.username || item.first_name || 'unknown' }}</span>
|
||||
<span class="chat-uid">{{ item.user_id }}</span>
|
||||
</div>
|
||||
<div class="chat-meta-line">
|
||||
<span class="chat-id-pill">#{{ item.msg_id || '-' }}</span>
|
||||
<span class="chat-time">{{ formatTime(item.time) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-text">{{ item.text || '(空消息)' }}</div>
|
||||
<div class="chat-foot">
|
||||
<el-tooltip v-if="item.spam" effect="dark" placement="top-start" popper-class="spam-tooltip" :content="item.reason || item.spam_reason || ''">
|
||||
<span class="reason-tag"><el-tag size="mini" type="danger">{{ item.reason || item.spam_reason }}</el-tag></span>
|
||||
</el-tooltip>
|
||||
<span v-else></span>
|
||||
<span class="chat-actions">
|
||||
<el-tag v-if="item.deleted" class="deleted-tag" size="mini" type="info">已删 · {{ formatTime(item.deleted_at) }}</el-tag>
|
||||
<el-button v-else type="text" size="mini" icon="el-icon-delete" :loading="item._deleting" @click="deleteChatMessage(item, false)">删除</el-button>
|
||||
<el-button v-if="!item.deleted && item.user_id" type="text" size="mini" icon="el-icon-remove-outline" :loading="item._deleting" @click="deleteChatMessage(item, true)">删封</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="12" style="height:100%;">
|
||||
<el-card shadow="hover" class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>📜 实时日志</h3>
|
||||
<div class="panel-actions">
|
||||
<el-tag size="mini" type="info">{{ logs.length }} 条</el-tag>
|
||||
<el-button size="mini" icon="el-icon-document-copy" @click="copyLogs">复制</el-button>
|
||||
<el-button size="mini" icon="el-icon-refresh" @click="loadLogs">刷新</el-button>
|
||||
</div>
|
||||
</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" :title="compactLog(item.message)">
|
||||
<span class="log-time">{{ formatTime(item.time) }}</span>
|
||||
<span class="log-level">{{ item.level }}</span>
|
||||
<span class="log-msg">{{ compactLog(item.message) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="hover" class="ban-section">
|
||||
<div class="panel-header">
|
||||
<h3>🚫 封禁记录</h3>
|
||||
<div class="panel-actions">
|
||||
<el-tag size="mini" type="danger">{{ bans.length }} 条</el-tag>
|
||||
<el-button size="mini" icon="el-icon-refresh" @click="loadBans">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ban-list">
|
||||
<div v-if="bans.length === 0" class="empty-text" style="padding:35px 0;"><span class="icon">✅</span>暂无封禁记录</div>
|
||||
<div v-for="(item, i) in bans" :key="i" class="ban-item">
|
||||
<div class="ban-info">
|
||||
<div class="user">@{{ item.username || item.first_name || 'unknown' }} ({{ item.user_id }})</div>
|
||||
<div class="reason">📝 {{ item.reason }}</div>
|
||||
<div class="time">⏰ {{ formatTime(item.time) }}</div>
|
||||
</div>
|
||||
<el-button v-if="item.auto_unban !== false" size="mini" type="danger" :loading="item._loading" @click="unbanUser(item)">解封</el-button>
|
||||
<el-tag v-else size="mini" type="success">已解封</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div class="fixed-send-box">
|
||||
<el-input v-model="newMessage" placeholder="发送消息到 TG 群" size="small" clearable @keyup.enter.native="sendMessage"></el-input>
|
||||
<el-button type="primary" size="small" :loading="sending" @click="sendMessage">发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/vue@2.7.16/dist/vue.min.js"></script>
|
||||
<script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data() {
|
||||
return {
|
||||
sseConnected: false,
|
||||
chatLogs: [],
|
||||
bans: [],
|
||||
logs: [],
|
||||
newMessage: '',
|
||||
sending: false,
|
||||
restarting: false,
|
||||
restartingBot: false,
|
||||
stats: { today_bans: 0, total_bans: 0, active_bans: 0 },
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadChatHistory()
|
||||
this.loadBans()
|
||||
this.loadStats()
|
||||
this.loadLogs()
|
||||
this.connectSSE()
|
||||
setInterval(() => this.loadStats(), 30000)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this._es) this._es.close()
|
||||
},
|
||||
methods: {
|
||||
connectSSE() {
|
||||
if (this._es) this._es.close()
|
||||
const es = new EventSource('/api/stream')
|
||||
this._es = es
|
||||
es.addEventListener('connected', () => { this.sseConnected = true })
|
||||
es.addEventListener('chat', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
this.chatLogs.unshift(data)
|
||||
if (this.chatLogs.length > 120) 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.scrollLogBottom()
|
||||
})
|
||||
es.addEventListener('chat_delete', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
const item = this.chatLogs.find(m => m.id === data.id)
|
||||
if (item) {
|
||||
this.$set(item, 'deleted', true)
|
||||
this.$set(item, 'manually_deleted', true)
|
||||
this.$set(item, 'deleted_at', data.deleted_at)
|
||||
}
|
||||
this.loadStats()
|
||||
})
|
||||
es.addEventListener('ban', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
data._loading = false
|
||||
this.bans.unshift(data)
|
||||
if (this.bans.length > 80) this.bans.pop()
|
||||
this.loadStats()
|
||||
this.$message({ type: 'error', message: `🚫 封禁 @${data.username} - ${data.reason}`, duration: 5000 })
|
||||
})
|
||||
es.addEventListener('unban', (e) => {
|
||||
const data = JSON.parse(e.data)
|
||||
const ban = this.bans.find(b => b.user_id === data.user_id)
|
||||
if (ban) ban.auto_unban = false
|
||||
this.loadStats()
|
||||
this.$message({ type: 'success', message: `✅ 已解封 ${data.username || data.user_id}`, duration: 3000 })
|
||||
})
|
||||
es.onerror = () => {
|
||||
this.sseConnected = false
|
||||
setTimeout(() => this.connectSSE(), 3000)
|
||||
}
|
||||
},
|
||||
async loadChatHistory() {
|
||||
try {
|
||||
const data = await (await fetch('/api/chat')).json()
|
||||
this.chatLogs = data.map(m => ({ ...m, _deleting: false }))
|
||||
} catch (e) {}
|
||||
},
|
||||
async loadBans() {
|
||||
try {
|
||||
const data = await (await fetch('/api/bans')).json()
|
||||
this.bans = data.map(b => ({ ...b, _loading: false }))
|
||||
} catch (e) {}
|
||||
},
|
||||
async loadStats() {
|
||||
try { this.stats = await (await fetch('/api/stats')).json() } catch (e) {}
|
||||
},
|
||||
async restartBot() {
|
||||
this.$confirm('确认只重启 Telegram Bot?Web 管理页不会重启。', '重启 TG Bot', {
|
||||
confirmButtonText: '确认重启',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
customClass: 'confirm-dialog',
|
||||
dangerouslyUseHTMLString: false,
|
||||
distinguishCancelAndClose: true
|
||||
}).then(async () => {
|
||||
this.restartingBot = true
|
||||
try {
|
||||
const data = await (await fetch('/api/restart-bot', { method: 'POST' })).json()
|
||||
if (data.ok) {
|
||||
this.$message.success('✅ TG Bot 正在重启')
|
||||
setTimeout(() => { this.restartingBot = false; this.loadLogs() }, 5000)
|
||||
} else {
|
||||
this.$message.error(`❌ 重启失败: ${data.error || '未知错误'}`)
|
||||
this.restartingBot = false
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error('❌ 网络错误')
|
||||
this.restartingBot = false
|
||||
}
|
||||
}).catch(() => {})
|
||||
},
|
||||
async restartService() {
|
||||
this.$confirm('确认重启 TG Spam Guard 服务?重启期间 Web 和 Bot 会短暂不可用。', '重启服务', {
|
||||
confirmButtonText: '确认重启',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
customClass: 'confirm-dialog',
|
||||
dangerouslyUseHTMLString: false,
|
||||
distinguishCancelAndClose: true
|
||||
}).then(async () => {
|
||||
this.restarting = true
|
||||
try {
|
||||
const data = await (await fetch('/api/restart', { method: 'POST' })).json()
|
||||
if (data.ok) {
|
||||
this.$message.success('✅ 服务正在重启,请稍等刷新')
|
||||
setTimeout(() => location.reload(), 8000)
|
||||
} else {
|
||||
this.$message.error(`❌ 重启失败: ${data.error || '未知错误'}`)
|
||||
this.restarting = false
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.warning('服务可能已开始重启,请稍等刷新')
|
||||
setTimeout(() => location.reload(), 8000)
|
||||
}
|
||||
}).catch(() => {})
|
||||
},
|
||||
async loadLogs() {
|
||||
try {
|
||||
this.logs = await (await fetch('/api/logs')).json()
|
||||
this.scrollLogBottom()
|
||||
} 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 deleteChatMessage(item, banUser = false) {
|
||||
this.$confirm(banUser ? '先删除 Telegram 群内消息,再封禁该用户,并标记数据库。继续吗?' : '先删除 Telegram 群内消息,再把数据库记录标记为手动删除。继续吗?', banUser ? '删除并封禁' : '删除聊天记录', {
|
||||
confirmButtonText: banUser ? '删封' : '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
customClass: 'confirm-dialog',
|
||||
dangerouslyUseHTMLString: false,
|
||||
distinguishCancelAndClose: true
|
||||
}).then(async () => {
|
||||
this.$set(item, '_deleting', true)
|
||||
try {
|
||||
const data = await (await fetch(`/api/messages/${item.id}/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ban_user: banUser })
|
||||
})).json()
|
||||
if (data.ok) {
|
||||
this.$set(item, 'deleted', true)
|
||||
this.$set(item, 'manually_deleted', true)
|
||||
this.$set(item, 'deleted_at', data.deleted_at || new Date().toISOString())
|
||||
this.$message.success(data.banned ? '✅ 已删除并封禁' : '✅ 已标记为手动删除')
|
||||
this.loadStats()
|
||||
} else {
|
||||
this.$message.error(`❌ 删除失败: ${data.error}`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error('❌ 网络错误')
|
||||
}
|
||||
this.$set(item, '_deleting', false)
|
||||
}).catch(() => {})
|
||||
},
|
||||
async copyLogs() {
|
||||
const text = this.logs.map(l => `${this.formatTime(l.time)} ${l.level || ''} ${this.compactLog(l.message)}`).join('\n')
|
||||
if (!text) return this.$message.warning('暂无日志可复制')
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
this.$message.success(`✅ 已复制 ${this.logs.length} 条日志`)
|
||||
} catch (e) {
|
||||
this.$message.error('❌ 复制失败')
|
||||
}
|
||||
},
|
||||
async unbanUser(item) {
|
||||
this.$set(item, '_loading', true)
|
||||
try {
|
||||
const data = await (await fetch(`/api/unban/${item.user_id}`, { method: 'POST' })).json()
|
||||
if (data.ok) {
|
||||
item.auto_unban = false
|
||||
this.$message.success(`✅ 已解封 @${item.username}`)
|
||||
this.loadStats()
|
||||
} else {
|
||||
this.$message.error(`❌ 解封失败: ${data.error}`)
|
||||
}
|
||||
} catch (e) {
|
||||
this.$message.error('❌ 网络错误')
|
||||
}
|
||||
this.$set(item, '_loading', false)
|
||||
},
|
||||
scrollLogBottom() {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.logScroll) this.$refs.logScroll.scrollTop = this.$refs.logScroll.scrollHeight
|
||||
})
|
||||
},
|
||||
formatTime(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
return isNaN(d.getTime()) ? iso : d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
},
|
||||
chatItemClass(item) {
|
||||
return {
|
||||
spam: !!item.spam,
|
||||
deleted: !!item.deleted,
|
||||
web: !item.spam && !item.deleted && Number(item.user_id || 0) === 0,
|
||||
normal: !item.spam && !item.deleted && Number(item.user_id || 0) !== 0,
|
||||
}
|
||||
},
|
||||
chatStatusText(item) {
|
||||
if (item.deleted) return '已删'
|
||||
if (item.spam) return '垃圾'
|
||||
if (Number(item.user_id || 0) === 0) return 'Web'
|
||||
return '正常'
|
||||
},
|
||||
chatStatusClass(item) {
|
||||
if (item.deleted) return 'deleted'
|
||||
if (item.spam) return 'spam'
|
||||
if (Number(item.user_id || 0) === 0) return 'web'
|
||||
return ''
|
||||
},
|
||||
compactLog(msg) {
|
||||
if (!msg) return ''
|
||||
return String(msg).replace(/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+\[[A-Z]+\]\s+/, '')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user