396 lines
16 KiB
Vue
396 lines
16 KiB
Vue
<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 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(() => {
|
||
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>
|