feat: add tabbed admin pages and web auth

This commit is contained in:
ngfchl
2026-07-07 14:10:56 +08:00
parent 23269d261a
commit 1905e81442
7 changed files with 584 additions and 76 deletions
+7
View File
@@ -47,3 +47,10 @@ NEW_MEMBER_RESTRICT_MINUTES = int(os.getenv("NEW_MEMBER_RESTRICT_MINUTES", "10")
# 防刷屏
RATE_LIMIT_MESSAGES = int(os.getenv("RATE_LIMIT_MESSAGES", "5"))
RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "30"))
# Web 管理认证
WEB_AUTH_ENABLED = os.getenv("WEB_AUTH_ENABLED", "true").lower() not in {"0", "false", "no"}
WEB_AUTH_USERNAME = os.getenv("WEB_AUTH_USERNAME", "admin")
WEB_AUTH_PASSWORD = os.getenv("WEB_AUTH_PASSWORD", "admin")
WEB_SESSION_SECRET = os.getenv("WEB_SESSION_SECRET", "tg-spam-guard-change-me")
WEB_TRUST_SAME_SUBNET = os.getenv("WEB_TRUST_SAME_SUBNET", "true").lower() not in {"0", "false", "no"}
+99 -72
View File
@@ -21,10 +21,14 @@ const lotteries = ref<LotteryItem[]>([]);
const lotteryParticipants = ref<Record<string, any>[]>([]);
const shopForm = reactive({ item_key: '', name: '', cost: 0, desc: '', enabled: true });
const lotteryForm = reactive({ id: 0, title: '', prize: '', description: '', condition_type: 'all', condition_value: 0, draw_type: 'people', end_type: 'people', end_value: 3, max_participants: 3, draw_at: '', status: 'draft' });
const activeModal = ref<'shop' | 'lottery' | 'bans' | null>(null);
const activePage = ref<'dashboard' | 'chat' | 'logs' | 'shop' | 'lottery' | 'bans'>('dashboard');
const activeTab = reactive({ shop: 'items', lottery: 'events' });
const toast = ref('');
const confirmState = reactive({ open: false, title: '', message: '', confirmText: '确认', danger: false, resolver: null as null | ((value: boolean) => void) });
const authReady = ref(false);
const authenticated = ref(false);
const loginForm = reactive({ username: '', password: '' });
const loggingIn = ref(false);
const newMessage = ref('');
const sending = ref(false);
const restarting = ref(false);
@@ -37,6 +41,20 @@ let statTimer: number | undefined;
const chatCount = computed(() => chatLogs.value.length);
const selectedQuery = computed(() => selectedChatId.value ? `?chat_id=${selectedChatId.value}` : '');
const pageTitle = computed(() => ({
dashboard: '群组安全总览', chat: '聊天记录', logs: '运行日志', shop: '商城管理', lottery: '抽奖管理', bans: '封禁记录'
}[activePage.value]));
const pageDesc = computed(() => ({
dashboard: '实时审计聊天、封禁、日志和运营活动', chat: '群消息实时审计与手动处置', logs: '服务运行状态与错误排查', shop: '商品 CRUD 与兑换交易记录', lottery: '抽奖项目、开奖条件与参与记录', bans: '封禁审计与手动解封'
}[activePage.value]));
function goPage(page: typeof activePage.value) {
activePage.value = page;
if (page === 'shop') { loadShopItems(); loadShopTransactions(); }
if (page === 'lottery') loadLotteries();
if (page === 'bans') loadBans();
}
function formatTime(value?: string) {
if (!value) return '-';
const text = String(value);
@@ -88,6 +106,32 @@ function closeConfirm(value: boolean) {
confirmState.resolver = null;
}
async function checkAuth() {
try {
const data = await fetchJson('/api/auth/status');
authenticated.value = Boolean(data.authenticated);
} catch {
authenticated.value = false;
} finally {
authReady.value = true;
}
}
async function login() {
if (!loginForm.username || !loginForm.password) return showToast('请输入账号和密码');
loggingIn.value = true;
try {
await fetchJson('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(loginForm) });
authenticated.value = true;
await bootstrapData();
} catch (e: any) {
showToast(e.message || '登录失败');
} finally { loggingIn.value = false; }
}
async function logout() {
await fetchJson('/api/auth/logout', { method: 'POST' }).catch(() => {});
authenticated.value = false;
}
async function fetchJson(url: string, options?: RequestInit) {
const res = await fetch(url, options);
const data = await res.json().catch(() => ({}));
@@ -299,8 +343,8 @@ function copyLogs() {
navigator.clipboard?.writeText(text);
}
onMounted(() => {
loadGroups().then(reloadByGroup);
async function bootstrapData() {
await loadGroups().then(reloadByGroup);
loadChatHistory();
loadBans();
loadStats();
@@ -308,7 +352,11 @@ onMounted(() => {
loadLotteries();
loadLogs();
connectSSE();
statTimer = window.setInterval(loadStats, 30000);
if (!statTimer) statTimer = window.setInterval(loadStats, 30000);
}
onMounted(async () => {
await checkAuth();
if (authenticated.value) await bootstrapData();
});
onBeforeUnmount(() => {
if (es) es.close();
@@ -318,7 +366,9 @@ onBeforeUnmount(() => {
<template>
<SConfigProvider :theme="{ theme: { primary: 'indigo' }, size: 'sm' }">
<main class="admin-shell">
<div v-if="!authReady" class="login-page"><div class="login-card">正在检查登录状态...</div></div>
<div v-else-if="!authenticated" class="login-page"><form class="login-card" @submit.prevent="login"><img class="brand-icon" src="/icon.svg" alt="TG Spam Guard" /><h1>TG Spam Guard</h1><p>请输入管理账号密码</p><SInput v-model="loginForm.username" placeholder="账号" /><SInput v-model="loginForm.password" type="password" placeholder="密码" /><SButton type="submit" color="primary" :disabled="loggingIn">登录</SButton></form></div>
<main v-else class="admin-shell">
<aside class="sidebar">
<div class="side-brand">
<img class="brand-icon" src="/icon.svg" alt="TG Spam Guard" />
@@ -332,12 +382,12 @@ onBeforeUnmount(() => {
<select v-model="selectedChatId" class="form-select side-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>
</div>
<nav class="side-nav">
<a class="active" href="#dashboard"><span>总览</span><em>Dashboard</em></a>
<a href="#chat"><span>聊天审计</span><em>Messages</em></a>
<a href="#logs"><span>运行日志</span><em>Logs</em></a>
<button @click="activeModal='shop'; loadShopItems(); loadShopTransactions()"><span>商城管理</span><em>Shop</em></button>
<button @click="activeModal='lottery'; loadLotteries()"><span>抽奖管理</span><em>Lottery</em></button>
<button @click="activeModal='bans'; loadBans()"><span>封禁记录</span><em>Bans</em></button>
<button :class="{active: activePage === 'dashboard'}" @click="goPage('dashboard')"><span>总览</span><em>Dashboard</em></button>
<button :class="{active: activePage === 'chat'}" @click="goPage('chat')"><span>聊天审计</span><em>Messages</em></button>
<button :class="{active: activePage === 'logs'}" @click="goPage('logs')"><span>运行日志</span><em>Logs</em></button>
<button :class="{active: activePage === 'shop'}" @click="goPage('shop')"><span>商城管理</span><em>Shop</em></button>
<button :class="{active: activePage === 'lottery'}" @click="goPage('lottery')"><span>抽奖管理</span><em>Lottery</em></button>
<button :class="{active: activePage === 'bans'}" @click="goPage('bans')"><span>封禁记录</span><em>Bans</em></button>
</nav>
<div class="side-footer">
<span class="conn" :class="sseConnected ? 'on' : 'off'">{{ sseConnected ? '实时连接' : '连接中' }}</span>
@@ -349,26 +399,27 @@ onBeforeUnmount(() => {
<header class="admin-header" id="dashboard">
<div>
<span class="eyebrow">CONTROL CENTER</span>
<h2>群组安全总览</h2>
<p>实时审计聊天封禁日志和运营活动</p>
<h2>{{ pageTitle }}</h2>
<p>{{ pageDesc }}</p>
</div>
<div class="top-actions">
<SButton size="xs" variant="outline" @click="activeModal='shop'; loadShopItems(); loadShopTransactions()">商城管理</SButton>
<SButton size="xs" variant="outline" @click="activeModal='lottery'; loadLotteries()">抽奖管理</SButton>
<SButton size="xs" variant="outline" @click="activeModal='bans'; loadBans()">封禁记录</SButton>
<SButton size="xs" variant="outline" @click="goPage('shop')">商城管理</SButton>
<SButton size="xs" variant="outline" @click="goPage('lottery')">抽奖管理</SButton>
<SButton size="xs" variant="outline" @click="goPage('bans')">封禁记录</SButton>
<SButton size="xs" variant="outline" @click="logout">退出</SButton>
<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">
<section v-show="activePage === 'dashboard'" 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">
<section v-show="activePage === 'dashboard'" class="main-grid">
<SCard id="chat" class="panel chat-panel" title="聊天记录" :description="`${chatCount} 条`">
<div class="scroll-area chat-scroll">
<div v-if="!chatLogs.length" class="empty">等待消息...</div>
@@ -418,65 +469,41 @@ onBeforeUnmount(() => {
</SCard>
</section>
<div v-if="activeModal" class="modal-mask" @click.self="activeModal=null">
<div class="modal-card">
<div class="modal-head">
<h2>{{ activeModal === 'shop' ? '商城管理' : activeModal === 'lottery' ? '抽奖管理' : '封禁记录' }}</h2>
<SButton size="xs" variant="outline" @click="activeModal=null">关闭</SButton>
<section v-show="activePage === 'chat'" class="tab-page chat-page">
<SCard class="panel chat-panel page-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>
</section>
<div v-if="activeModal === 'shop'" class="modal-body manager-layout">
<aside class="editor-panel">
<div class="section-title"><span>商品编辑</span><small>{{ shopForm.item_key ? '编辑中' : '新建' }}</small></div>
<label>商品编号<SInput v-model="shopForm.item_key" placeholder="如 1 / vip_card" /></label>
<label>商品名称<SInput v-model="shopForm.name" placeholder="补签卡" /></label>
<label>积分价格<SInput v-model="shopForm.cost" placeholder="50" /></label>
<label>说明<SInput v-model="shopForm.desc" placeholder="展示在 /shop 内" /></label>
<label class="switch-line"><input v-model="shopForm.enabled" type="checkbox" /> 上架销售</label>
<div class="editor-actions"><SButton size="xs" color="primary" @click="saveShopItem">保存商品</SButton><SButton size="xs" variant="outline" @click="Object.assign(shopForm, { item_key: '', name: '', cost: 0, desc: '', enabled: true })">新建</SButton></div>
</aside>
<section class="data-panel">
<div class="tabbar"><button :class="{active: activeTab.shop === 'items'}" @click="activeTab.shop='items'">商品列表</button><button :class="{active: activeTab.shop === 'tx'}" @click="activeTab.shop='tx'; loadShopTransactions()">交易记录</button></div>
<div v-if="activeTab.shop === 'items'" class="data-table">
<div class="table-head"><span>编号/商品</span><span>价格</span><span>状态</span><span>操作</span></div>
<div v-for="item in shopItems" :key="item.item_key" class="table-row"><span><b>{{ item.item_key }}</b><em>{{ item.name }}</em><small>{{ item.desc || '无说明' }}</small></span><span>{{ item.cost }} 分</span><span><STag size="xs" :color="item.enabled ? 'success' : 'secondary'" variant="soft">{{ item.enabled ? '上架' : '下架' }}</STag></span><span class="row-actions"><SButton size="xs" variant="outline" @click="editShopItem(item)">编辑</SButton><SButton size="xs" variant="soft" color="destructive" @click="deleteShopItem(item)">删除</SButton></span></div>
</div>
<div v-else class="data-table tx-table"><div class="table-head"><span>用户</span><span>商品</span><span>积分</span><span>时间</span></div><div v-for="tx in shopTransactions" :key="tx.id" class="table-row"><span>@{{ tx.username || tx.user_id }}</span><span>{{ tx.item_name }}</span><span>{{ tx.cost }} </span><span>{{ formatTime(tx.created_at) }}</span></div></div>
</section>
</div>
<section v-show="activePage === 'logs'" class="tab-page log-page">
<SCard class="panel log-panel page-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>
<div v-else-if="activeModal === 'lottery'" class="modal-body manager-layout">
<aside class="editor-panel wide-editor">
<div class="section-title"><span>抽奖项目</span><small>{{ lotteryForm.id ? `#${lotteryForm.id}` : '新建' }}</small></div>
<label>标题<SInput v-model="lotteryForm.title" placeholder="七月群友福利" /></label>
<label>奖品<SInput v-model="lotteryForm.prize" placeholder="月卡 / 邀请码 / 积分" /></label>
<label>描述<SInput v-model="lotteryForm.description" placeholder="规则说明,会展示给群友" /></label>
<div class="form-two"><label>参与条件<select v-model="lotteryForm.condition_type" class="form-select"><option value="all">所有人</option><option value="checkin">今日签到</option><option value="min_points">积分门槛</option></select></label><label>条件值<SInput v-model="lotteryForm.condition_value" placeholder="积分门槛" /></label></div>
<div class="form-two"><label>开奖方式<select v-model="lotteryForm.draw_type" class="form-select"><option value="instant">即开即中</option><option value="people">人满开奖</option><option value="time">指定时间</option><option value="manual">手动开奖</option></select></label><label>人数/分钟<SInput v-model="lotteryForm.end_value" placeholder="3" /></label></div>
<label>指定时间<SInput v-model="lotteryForm.draw_at" placeholder="2026-07-07 20:00" /></label>
<label>状态<select v-model="lotteryForm.status" class="form-select"><option value="draft">草稿</option><option value="active">进行中</option><option value="cancelled">取消</option></select></label>
<div class="editor-actions"><SButton size="xs" color="primary" @click="saveLottery">保存抽奖</SButton><SButton size="xs" variant="outline" @click="resetLotteryForm">新建</SButton></div>
</aside>
<section class="data-panel">
<div class="tabbar"><button :class="{active: activeTab.lottery === 'events'}" @click="activeTab.lottery='events'">抽奖项目</button><button :class="{active: activeTab.lottery === 'participants'}" @click="activeTab.lottery='participants'">参与记录</button></div>
<div v-if="activeTab.lottery === 'events'" class="data-table lottery-table"><div class="table-head"><span>项目</span><span>方式</span><span>状态</span><span>操作</span></div><div v-for="item in lotteries" :key="item.id" class="table-row"><span><b>#{{ item.id }} {{ item.title || item.prize }}</b><em>{{ item.prize }}</em><small>{{ item.description || '无描述' }}</small></span><span>{{ item.draw_type || item.end_type }}</span><span><STag size="xs" variant="soft">{{ item.status }}</STag></span><span class="row-actions"><SButton size="xs" variant="outline" @click="editLottery(item); loadLotteryParticipants(item); activeTab.lottery='participants'">编辑/参与</SButton><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><SButton size="xs" variant="soft" color="destructive" @click="deleteLottery(item)">删除</SButton></span></div></div>
<div v-else class="data-table"><div class="table-head"><span>用户</span><span>用户ID</span><span>参与时间</span><span>抽奖</span></div><div v-for="p in lotteryParticipants" :key="p.id" class="table-row"><span>@{{ p.username || p.first_name || 'unknown' }}</span><span>{{ p.user_id }}</span><span>{{ formatTime(p.created_at) }}</span><span>#{{ p.event_id }}</span></div><div v-if="!lotteryParticipants.length" class="empty">选择某个抽奖后查看参与记录</div></div>
</section>
</div>
<section v-show="activePage === 'shop'" class="tab-page manager-layout">
<aside class="editor-panel"><div class="section-title"><span>商品编辑</span><small>{{ shopForm.item_key ? '编辑中' : '新建' }}</small></div><label>商品编号<SInput v-model="shopForm.item_key" placeholder="如 1 / vip_card" /></label><label>商品名称<SInput v-model="shopForm.name" placeholder="补签卡" /></label><label>积分价格<SInput v-model="shopForm.cost" placeholder="50" /></label><label>说明<SInput v-model="shopForm.desc" placeholder="展示在 /shop 内" /></label><label class="switch-line"><input v-model="shopForm.enabled" type="checkbox" /> 上架销售</label><div class="editor-actions"><SButton size="xs" color="primary" @click="saveShopItem">保存商品</SButton><SButton size="xs" variant="outline" @click="Object.assign(shopForm, { item_key: '', name: '', cost: 0, desc: '', enabled: true })">新建</SButton></div></aside>
<section class="data-panel"><div class="tabbar"><button :class="{active: activeTab.shop === 'items'}" @click="activeTab.shop='items'">商品列表</button><button :class="{active: activeTab.shop === 'tx'}" @click="activeTab.shop='tx'; loadShopTransactions()">交易记录</button></div><div v-if="activeTab.shop === 'items'" class="data-table"><div class="table-head"><span>编号/商品</span><span>价格</span><span>状态</span><span>操作</span></div><div v-for="item in shopItems" :key="item.item_key" class="table-row"><span><b>{{ item.item_key }}</b><em>{{ item.name }}</em><small>{{ item.desc || '无说明' }}</small></span><span>{{ item.cost }} 分</span><span><STag size="xs" :color="item.enabled ? 'success' : 'secondary'" variant="soft">{{ item.enabled ? '上架' : '下架' }}</STag></span><span class="row-actions"><SButton size="xs" variant="outline" @click="editShopItem(item)">编辑</SButton><SButton size="xs" variant="soft" color="destructive" @click="deleteShopItem(item)">删除</SButton></span></div></div><div v-else class="data-table tx-table"><div class="table-head"><span>用户</span><span>商品</span><span>积分</span><span>时间</span></div><div v-for="tx in shopTransactions" :key="tx.id" class="table-row"><span>@{{ tx.username || tx.user_id }}</span><span>{{ tx.item_name }}</span><span>{{ tx.cost }} </span><span>{{ formatTime(tx.created_at) }}</span></div></div></section>
</section>
<div v-else class="modal-body">
<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>
</div>
</div>
</div>
<section v-show="activePage === 'lottery'" class="tab-page manager-layout">
<aside class="editor-panel wide-editor"><div class="section-title"><span>抽奖项目</span><small>{{ lotteryForm.id ? `#${lotteryForm.id}` : '新建' }}</small></div><label>标题<SInput v-model="lotteryForm.title" placeholder="七月群友福利" /></label><label>奖品<SInput v-model="lotteryForm.prize" placeholder="月卡 / 邀请码 / 积分" /></label><label>描述<SInput v-model="lotteryForm.description" placeholder="规则说明,会展示给群友" /></label><div class="form-two"><label>参与条件<select v-model="lotteryForm.condition_type" class="form-select"><option value="all">所有人</option><option value="checkin">今日签到</option><option value="min_points">积分门槛</option></select></label><label>条件值<SInput v-model="lotteryForm.condition_value" placeholder="积分门槛" /></label></div><div class="form-two"><label>开奖方式<select v-model="lotteryForm.draw_type" class="form-select"><option value="instant">即开即中</option><option value="people">人满开奖</option><option value="time">指定时间</option><option value="manual">手动开奖</option></select></label><label>人数/分钟<SInput v-model="lotteryForm.end_value" placeholder="3" /></label></div><label>指定时间<SInput v-model="lotteryForm.draw_at" placeholder="2026-07-07 20:00" /></label><label>状态<select v-model="lotteryForm.status" class="form-select"><option value="draft">草稿</option><option value="active">进行中</option><option value="cancelled">取消</option></select></label><div class="editor-actions"><SButton size="xs" color="primary" @click="saveLottery">保存抽奖</SButton><SButton size="xs" variant="outline" @click="resetLotteryForm">新建</SButton></div></aside>
<section class="data-panel"><div class="tabbar"><button :class="{active: activeTab.lottery === 'events'}" @click="activeTab.lottery='events'">抽奖项目</button><button :class="{active: activeTab.lottery === 'participants'}" @click="activeTab.lottery='participants'">参与记录</button></div><div v-if="activeTab.lottery === 'events'" class="data-table lottery-table"><div class="table-head"><span>项目</span><span>方式</span><span>状态</span><span>操作</span></div><div v-for="item in lotteries" :key="item.id" class="table-row"><span><b>#{{ item.id }} {{ item.title || item.prize }}</b><em>{{ item.prize }}</em><small>{{ item.description || '无描述' }}</small></span><span>{{ item.draw_type || item.end_type }}</span><span><STag size="xs" variant="soft">{{ item.status }}</STag></span><span class="row-actions"><SButton size="xs" variant="outline" @click="editLottery(item); loadLotteryParticipants(item); activeTab.lottery='participants'">编辑/参与</SButton><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><SButton size="xs" variant="soft" color="destructive" @click="deleteLottery(item)">删除</SButton></span></div></div><div v-else class="data-table"><div class="table-head"><span>用户</span><span>用户ID</span><span>参与时间</span><span>抽奖</span></div><div v-for="p in lotteryParticipants" :key="p.id" class="table-row"><span>@{{ p.username || p.first_name || 'unknown' }}</span><span>{{ p.user_id }}</span><span>{{ formatTime(p.created_at) }}</span><span>#{{ p.event_id }}</span></div><div v-if="!lotteryParticipants.length" class="empty">选择某个抽奖后查看参与记录</div></div></section>
</section>
<form class="send-box" @submit.prevent="sendMessage">
<section v-show="activePage === 'bans'" class="tab-page">
<SCard class="ban-panel page-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>
</section>
<form v-if="activePage === 'chat'" class="send-box floating-chat-send" @submit.prevent="sendMessage">
<SInput v-model="newMessage" placeholder="发送消息到 TG 群" />
<SButton type="submit" color="primary" :disabled="sending || !newMessage.trim()">发送</SButton>
</form>
+17
View File
@@ -197,3 +197,20 @@ body { background: #f3f6fb; }
.admin-main { padding-bottom: 20px; }
@media (max-width: 1100px) { .admin-shell .send-box { position: static; left: auto; right: auto; width: 100%; transform: none; } }
@media (max-width: 520px) { .admin-shell .send-box { position: static; left: auto; right: auto; width: 100%; transform: none; } }
/* Tab pages / keep-alive style admin pages */
.login-page { min-height: 100vh; display: grid; place-items: center; padding: 20px; background: radial-gradient(circle at top left, #dbeafe, transparent 32%), linear-gradient(135deg, #0f172a, #1e293b); }
.login-card { width: min(380px, 100%); display: grid; gap: 12px; padding: 28px; border-radius: 24px; background: rgba(255,255,255,.96); box-shadow: 0 30px 90px rgba(0,0,0,.34); border: 1px solid rgba(226,232,240,.9); text-align: center; }
.login-card .brand-icon { margin: 0 auto 4px; }
.login-card h1 { margin: 0; font-size: 24px; }
.login-card p { margin: 0 0 8px; color: #64748b; }
.side-nav button.active { background: rgba(99,102,241,.22); color: #fff; }
.tab-page { min-height: calc(100vh - 144px); }
.page-panel { min-height: calc(100vh - 160px); }
.chat-page { position: relative; padding-bottom: 74px; }
.chat-page .chat-panel { height: calc(100vh - 222px); min-height: 520px; }
.log-page .log-panel { height: calc(100vh - 160px); min-height: 560px; }
.admin-shell .floating-chat-send { position: fixed; left: 284px; right: 20px; bottom: 18px; z-index: 40; width: auto; max-width: none; transform: none; margin: 0; background: rgba(255,255,255,.96); backdrop-filter: blur(14px); }
.admin-shell .send-box:not(.floating-chat-send) { position: static; }
@media (max-width: 1100px) { .admin-shell .floating-chat-send { left: 16px; right: 16px; } .tab-page { min-height: auto; } }
@media (max-width: 520px) { .admin-shell .floating-chat-send { left: 8px; right: 8px; bottom: 8px; } .chat-page .chat-panel { height: calc(100vh - 270px); min-height: 420px; } }
+130 -2
View File
@@ -4,15 +4,21 @@ from __future__ import annotations
import asyncio
import json
import logging
import hmac
import hashlib
import ipaddress
import socket
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi import FastAPI, Query, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from db.models import Action, Ban, GameScore, Message, LotteryEvent, LotteryParticipant, ShopItem, ShopTransaction
from config import WEB_AUTH_ENABLED, WEB_AUTH_PASSWORD, WEB_AUTH_USERNAME, WEB_SESSION_SECRET, WEB_TRUST_SAME_SUBNET
from services.stats_service import get_daily_stats
from services.time_utils import format_dt, from_timestamp, now_tz
from services.game_service import list_shop_items, upsert_shop_item, delete_shop_item, list_shop_transactions, draw_lottery_event
@@ -77,6 +83,128 @@ def install_log_handler():
install_log_handler()
AUTH_COOKIE_NAME = "tg_spam_guard_session"
AUTH_COOKIE_MAX_AGE = 7 * 24 * 3600
LOCAL_NETWORKS: list[ipaddress._BaseNetwork] = []
def _local_ipv4_addresses() -> set[str]:
ips = {"127.0.0.1"}
try:
hostname = socket.gethostname()
for item in socket.getaddrinfo(hostname, None, family=socket.AF_INET):
ips.add(item[4][0])
except Exception:
pass
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect(("8.8.8.8", 80))
ips.add(sock.getsockname()[0])
sock.close()
except Exception:
pass
return ips
def _build_local_networks() -> list[ipaddress._BaseNetwork]:
nets: list[ipaddress._BaseNetwork] = [ipaddress.ip_network("127.0.0.0/8")]
for ip in _local_ipv4_addresses():
try:
addr = ipaddress.ip_address(ip)
if addr.is_loopback:
continue
nets.append(ipaddress.ip_network(f"{ip}/24", strict=False))
except Exception:
pass
return nets
LOCAL_NETWORKS = _build_local_networks()
def _sign_session(username: str, ts: int | None = None) -> str:
ts = ts or int(time.time())
payload = f"{username}:{ts}"
sig = hmac.new(WEB_SESSION_SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
return f"{payload}:{sig}"
def _verify_session(token: str | None) -> bool:
if not token:
return False
try:
username, ts_text, sig = token.split(":", 2)
ts = int(ts_text)
if username != WEB_AUTH_USERNAME or time.time() - ts > AUTH_COOKIE_MAX_AGE:
return False
expected = hmac.new(WEB_SESSION_SECRET.encode(), f"{username}:{ts}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected)
except Exception:
return False
def _client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
return forwarded or (request.client.host if request.client else "")
def _same_subnet_allowed(request: Request) -> bool:
if not WEB_TRUST_SAME_SUBNET:
return False
try:
addr = ipaddress.ip_address(_client_ip(request))
return any(addr in net for net in LOCAL_NETWORKS)
except Exception:
return False
def _is_auth_allowed(request: Request) -> tuple[bool, str]:
if not WEB_AUTH_ENABLED:
return True, "disabled"
if _same_subnet_allowed(request):
return True, "same_subnet"
if _verify_session(request.cookies.get(AUTH_COOKIE_NAME)):
return True, "session"
return False, "login_required"
@app.middleware("http")
async def web_auth_middleware(request: Request, call_next):
path = request.url.path
public = path == "/" or path == "/health" or path.startswith("/static/") or path in {"/api/auth/status", "/api/auth/login"}
if public:
return await call_next(request)
ok, reason = _is_auth_allowed(request)
if not ok:
return JSONResponse({"ok": False, "error": "未登录", "reason": reason}, status_code=401)
return await call_next(request)
@app.get("/api/auth/status")
async def api_auth_status(request: Request):
ok, reason = _is_auth_allowed(request)
return JSONResponse({"ok": True, "authenticated": ok, "reason": reason, "username": WEB_AUTH_USERNAME if ok else None})
@app.post("/api/auth/login")
async def api_auth_login(request: Request):
data = await request.json()
username = str(data.get("username") or "")
password = str(data.get("password") or "")
if not (hmac.compare_digest(username, WEB_AUTH_USERNAME) and hmac.compare_digest(password, WEB_AUTH_PASSWORD)):
return JSONResponse({"ok": False, "error": "账号或密码错误"}, status_code=401)
resp = JSONResponse({"ok": True, "username": username})
resp.set_cookie(AUTH_COOKIE_NAME, _sign_session(username), max_age=AUTH_COOKIE_MAX_AGE, httponly=True, samesite="lax")
return resp
@app.post("/api/auth/logout")
async def api_auth_logout():
resp = JSONResponse({"ok": True})
resp.delete_cookie(AUTH_COOKIE_NAME)
return resp
def json_safe(value: Any) -> Any:
"""将 ORM values() 结果转成 JSONResponse 可序列化对象。"""
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/static/icon.svg" />
<title>TG Spam Guard</title>
<script type="module" crossorigin src="/static/assets/index-BIlnBDgU.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-Dbs1frnk.css">
<script type="module" crossorigin src="/static/assets/index-BkB3pxMb.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-DEuQ3oiJ.css">
</head>
<body>
<div id="app"></div>