feat: rebuild manager frontend with soybean ui

This commit is contained in:
ngfchl
2026-07-07 11:29:02 +08:00
parent 1fa50c63ca
commit 89c509e61d
11 changed files with 2205 additions and 623 deletions
+2
View File
@@ -23,6 +23,8 @@ index/
models/
# 开发/备份文件
front/node_modules/
front/dist/
main.py.bak
.patch_autodelete.py
static/
+12
View File
@@ -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>
+1431
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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": {}
}
+317
View File
@@ -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 BotWeb 管理页不会重启。')) 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>
+6
View File
@@ -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');
+70
View File
@@ -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; } }
+11
View File
@@ -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
View File
@@ -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 BotWeb 管理页不会重启。', '重启 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>