Files
tg-spam-guard/front/src/App.vue
T
2026-07-07 12:59:34 +08:00

396 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 };
type GroupItem = { chat_id: number; name: string };
type ShopItem = Record<string, any>;
type LotteryItem = Record<string, any>;
const sseConnected = ref(false);
const chatLogs = ref<ChatItem[]>([]);
const bans = ref<BanItem[]>([]);
const logs = ref<LogItem[]>([]);
const groups = ref<GroupItem[]>([]);
const selectedChatId = ref<number | null>(null);
const shopItems = ref<ShopItem[]>([]);
const lotteries = ref<LotteryItem[]>([]);
const shopForm = reactive({ item_key: '', name: '', cost: 0, desc: '', enabled: true });
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);
const selectedQuery = computed(() => selectedChatId.value ? `?chat_id=${selectedChatId.value}` : '');
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${selectedQuery.value}`);
chatLogs.value = data.map((m: ChatItem) => ({ ...m, _deleting: false }));
} catch {}
}
async function loadBans() {
try {
const data = await fetchJson(`/api/bans${selectedQuery.value}`);
bans.value = data.map((b: BanItem) => ({ ...b, _loading: false }));
} catch {}
}
async function loadStats() {
try {
Object.assign(stats, await fetchJson(`/api/stats${selectedQuery.value}`));
} 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;
});
}
async function loadGroups() {
try {
groups.value = await fetchJson('/api/groups');
if (!selectedChatId.value && groups.value.length) selectedChatId.value = groups.value[0].chat_id;
} catch {}
}
async function loadShopItems() {
try { shopItems.value = await fetchJson(`/api/shop-items${selectedQuery.value}`); } catch {}
}
async function saveShopItem() {
if (!shopForm.item_key || !shopForm.name) return alert('请填写商品编号和名称');
await fetchJson('/api/shop-items', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...shopForm, chat_id: selectedChatId.value || 0 })
});
Object.assign(shopForm, { item_key: '', name: '', cost: 0, desc: '', enabled: true });
await loadShopItems();
}
async function editShopItem(item: ShopItem) { Object.assign(shopForm, item); }
async function loadLotteries() {
try { lotteries.value = await fetchJson(`/api/lotteries${selectedQuery.value}`); } catch {}
}
async function cancelLottery(item: LotteryItem) {
if (!window.confirm('确认取消这个抽奖?')) return;
await fetchJson(`/api/lotteries/${item.id}/cancel`, { method: 'POST' });
await loadLotteries();
}
async function drawLottery(item: LotteryItem) {
if (!window.confirm('确认立即开奖?')) return;
await fetchJson(`/api/lotteries/${item.id}/draw`, { method: 'POST' });
await loadLotteries();
}
function reloadByGroup() { loadChatHistory(); loadBans(); loadStats(); loadShopItems(); loadLotteries(); }
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, chat_id: selectedChatId.value })
});
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(() => {
loadGroups().then(reloadByGroup);
loadChatHistory();
loadBans();
loadStats();
loadShopItems();
loadLotteries();
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">
<select v-model="selectedChatId" class="form-select" @change="reloadByGroup"><option :value="null">全部群组</option><option v-for="g in groups" :key="g.chat_id" :value="g.chat_id">{{ g.label || g.name }}</option></select>
<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>
<section class="manage-grid">
<SCard class="panel manage-panel" title="🛒 商品管理" description="按当前群组生效">
<div class="shop-form">
<SInput v-model="shopForm.item_key" placeholder="编号,如 1" />
<SInput v-model="shopForm.name" placeholder="商品名称" />
<SInput v-model="shopForm.cost" placeholder="积分价格" />
<SInput v-model="shopForm.desc" placeholder="说明" />
<label class="check"><input v-model="shopForm.enabled" type="checkbox" /> 启用</label>
<SButton size="xs" color="primary" @click="saveShopItem">保存商品</SButton>
</div>
<div class="mini-list">
<div v-for="item in shopItems" :key="item.item_key" class="mini-row">
<span><b>{{ item.item_key }}</b> {{ item.name }} {{ item.cost }} <em>{{ item.enabled ? '启用' : '停用' }}</em></span>
<SButton size="xs" variant="outline" @click="editShopItem(item)">编辑</SButton>
</div>
</div>
</SCard>
<SCard class="panel manage-panel" title="🎁 抽奖管理" description="查看、取消、手动开奖">
<div class="mini-list">
<div v-if="!lotteries.length" class="empty">暂无抽奖</div>
<div v-for="item in lotteries" :key="item.id" class="mini-row">
<span>#{{ item.id }} {{ item.prize }}{{ item.status }}{{ formatTime(item.created_at) }}</span>
<div class="row-actions">
<SButton size="xs" variant="outline" :disabled="item.status !== 'active'" @click="drawLottery(item)">开奖</SButton>
<SButton size="xs" variant="soft" color="warning" :disabled="item.status !== 'active'" @click="cancelLottery(item)">取消</SButton>
</div>
</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>