feat: add tabbed admin pages and web auth
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user