feat: add instant lottery and polish moderation UI

This commit is contained in:
ngfchl
2026-07-07 16:39:01 +08:00
parent 98e2ba60c6
commit 41653f48ba
10 changed files with 455 additions and 23 deletions
+5
View File
@@ -77,6 +77,11 @@ async def ensure_runtime_schema():
);
CREATE INDEX IF NOT EXISTS idx_shop_transactions_chat_created ON shop_transactions(chat_id, created_at);
CREATE INDEX IF NOT EXISTS idx_shop_transactions_chat_user ON shop_transactions(chat_id, user_id);
ALTER TABLE lottery_events ADD COLUMN IF NOT EXISTS prize_count INT NOT NULL DEFAULT 1;
ALTER TABLE lottery_events ADD COLUMN IF NOT EXISTS remaining_count INT NOT NULL DEFAULT 1;
UPDATE lottery_events SET prize_count = 1 WHERE prize_count IS NULL OR prize_count <= 0;
UPDATE lottery_events SET remaining_count = CASE WHEN status = 'ended' THEN 0 ELSE GREATEST(1, prize_count) END WHERE remaining_count IS NULL OR remaining_count < 0;
ALTER TABLE lottery_participants ADD COLUMN IF NOT EXISTS is_winner BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE IF NOT EXISTS lottery_events (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+3
View File
@@ -186,6 +186,8 @@ class LotteryEvent(TimestampMixin, Model):
draw_at = fields.DatetimeField(null=True)
max_participants = fields.IntField(default=0)
prize = fields.CharField(max_length=500)
prize_count = fields.IntField(default=1)
remaining_count = fields.IntField(default=1)
condition_type = fields.CharField(max_length=50, default="all") # all/checkin/min_points
condition_value = fields.IntField(default=0)
end_type = fields.CharField(max_length=50, default="people") # people/minutes/manual
@@ -207,6 +209,7 @@ class LotteryParticipant(TimestampMixin, Model):
user_id = fields.BigIntField()
username = fields.CharField(max_length=255, null=True)
first_name = fields.CharField(max_length=255, null=True)
is_winner = fields.BooleanField(default=False)
class Meta:
table = "lottery_participants"
+41 -11
View File
@@ -20,7 +20,7 @@ const shopTransactions = ref<Record<string, any>[]>([]);
const lotteries = ref<LotteryItem[]>([]);
const lotteryParticipants = ref<Record<string, any>[]>([]);
const spamTestText = ref('');
const spamUseAi = ref(false);
const spamUseAi = ref(true);
const spamTesting = ref(false);
const spamTestResult = ref<Record<string, any> | null>(null);
const ruleForm = reactive({ type: 'keyword', pattern: '', score: 75 });
@@ -30,7 +30,7 @@ const rulesLoading = ref(false);
const ruleKeyword = ref('');
const shopForm = reactive({ item_key: '', name: '', cost: 0, stock: -1, desc: '', enabled: true });
const shopDialogOpen = ref(false);
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: 'active' });
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, prize_count: 1, remaining_count: 1, draw_at: '', status: 'active' });
const lotteryDialogOpen = ref(false);
const activePage = ref<'dashboard' | 'shop' | 'lottery' | 'rules'>('dashboard');
const activeTab = reactive({ shop: 'items', lottery: 'events' });
@@ -45,6 +45,7 @@ const shopSaving = ref(false);
const lotterySaving = ref(false);
const logsRefreshing = ref(false);
const copyingLogs = ref(false);
const logWrap = ref(false);
const newMessage = ref('');
const sending = ref(false);
const restarting = ref(false);
@@ -63,6 +64,7 @@ const conditionOptions = [
{ value: 'points', label: '积分门槛', desc: '达到指定积分' }
];
const drawOptions = [
{ value: 'instant', label: '即开即中', desc: '参与即中奖,奖品抽完结束' },
{ value: 'people', label: '满人开奖', desc: '达到人数自动开奖' },
{ value: 'time', label: '定时开奖', desc: '到指定时间自动开奖' }
];
@@ -76,9 +78,11 @@ const lotteryConditionSummary = computed(() => {
if (lotteryForm.condition_type === 'points') return `积分 ≥ ${Number(lotteryForm.condition_value || 0)} 可参与`;
return '所有群成员均可参与';
});
const lotteryDrawSummary = computed(() => lotteryForm.draw_type === 'time'
? `定时开奖:${lotteryForm.draw_at ? formatTime(lotteryForm.draw_at) : '未设置时间'}`
: `${Number(lotteryForm.end_value || lotteryForm.max_participants || 1)} 人自动开奖`);
const lotteryDrawSummary = computed(() => {
if (lotteryForm.draw_type === 'instant') return `即开即中:共 ${Number(lotteryForm.prize_count || 1)} 份奖品`;
if (lotteryForm.draw_type === 'time') return `定时开奖:${lotteryForm.draw_at ? formatTime(lotteryForm.draw_at) : '未设置时间'}`;
return `${Number(lotteryForm.end_value || lotteryForm.max_participants || 1)} 人自动开奖`;
});
function selectLotteryCondition(type: string) {
lotteryForm.condition_type = type;
if (type !== 'points') lotteryForm.condition_value = 0;
@@ -90,6 +94,12 @@ function selectLotteryDraw(type: string) {
lotteryForm.draw_at = '';
lotteryForm.end_value = Number(lotteryForm.end_value || lotteryForm.max_participants || 3);
lotteryForm.max_participants = Number(lotteryForm.end_value || 3);
} else if (type === 'instant') {
lotteryForm.draw_at = '';
lotteryForm.end_value = 0;
lotteryForm.max_participants = 0;
lotteryForm.prize_count = Math.max(1, Number(lotteryForm.prize_count || 1));
lotteryForm.remaining_count = lotteryForm.prize_count;
} else {
lotteryForm.end_value = 0;
lotteryForm.max_participants = 0;
@@ -128,8 +138,9 @@ function formatTime(value?: string) {
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;
const raw = String(msg || '').trim();
const text = logWrap.value ? raw : raw.replace(/\s+/g, ' ');
return !logWrap.value && text.length > 260 ? `${text.slice(0, 260)}...` : text;
}
function chatStatusText(item: ChatItem) {
if (item.deleted) return '已删';
@@ -274,13 +285,14 @@ async function deleteShopItem(item: ShopItem) {
async function loadLotteries() {
try { lotteries.value = (await fetchJson(`/api/lotteries${selectedQuery.value}`)).map((x: LotteryItem) => ({ ...x, _loading: false })); } catch {}
}
function resetLotteryForm() { Object.assign(lotteryForm, { 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: 'active' }); }
function resetLotteryForm() { Object.assign(lotteryForm, { id: 0, title: '', prize: '', description: '', condition_type: 'all', condition_value: 0, draw_type: 'people', end_type: 'people', end_value: 3, max_participants: 3, prize_count: 1, remaining_count: 1, draw_at: '', status: 'active' }); }
function newLottery() { resetLotteryForm(); lotteryDialogOpen.value = true; }
function editLottery(item: LotteryItem) { if (Number(item.participants_count || 0) > 0) return showToast('已有用户参与,不能编辑,仅可开奖或关闭'); Object.assign(lotteryForm, { ...item, id: item.id || 0 }); lotteryDialogOpen.value = true; }
async function saveLottery() {
if (!lotteryForm.title || !lotteryForm.prize) return showToast('请填写抽奖标题和奖品');
if (lotteryForm.condition_type === 'points' && Number(lotteryForm.condition_value || 0) <= 0) return showToast('请填写积分门槛');
if (lotteryForm.draw_type === 'people' && Number(lotteryForm.end_value || 0) <= 0) return showToast('请填写开奖人数');
if (lotteryForm.draw_type === 'instant' && Number(lotteryForm.prize_count || 0) <= 0) return showToast('请填写奖品数量');
if (lotteryForm.draw_type === 'time' && !lotteryForm.draw_at) return showToast('请选择开奖时间');
if (lotterySaving.value) return;
lotterySaving.value = true;
@@ -291,9 +303,19 @@ async function saveLottery() {
payload.end_value = Number(payload.end_value || payload.max_participants || 1);
payload.max_participants = payload.end_value;
payload.draw_at = '';
payload.prize_count = 1;
payload.remaining_count = 1;
} else if (payload.draw_type === 'instant') {
payload.end_value = 0;
payload.max_participants = 0;
payload.draw_at = '';
payload.prize_count = Math.max(1, Number(payload.prize_count || 1));
payload.remaining_count = payload.id ? Number(payload.remaining_count ?? payload.prize_count) : payload.prize_count;
} else {
payload.end_value = 0;
payload.max_participants = 0;
payload.prize_count = 1;
payload.remaining_count = 1;
}
const url = lotteryForm.id ? `/api/lotteries/${lotteryForm.id}` : '/api/lotteries';
await fetchJson(url, { method: lotteryForm.id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
@@ -471,6 +493,13 @@ async function contextDeleteMessage(ban = false) {
closeChatContextMenu();
if (item) await deleteChatMessage(item, ban);
}
async function contextDeleteAndBanMessage() {
const item = chatContextMenu.item;
closeChatContextMenu();
if (!item) return;
if (item.deleted) await banChatMessage(item);
else await deleteChatMessage(item, true);
}
async function contextBanMessage() {
const item = chatContextMenu.item;
closeChatContextMenu();
@@ -698,10 +727,11 @@ onBeforeUnmount(() => {
<template #extra>
<div class="panel-extra">
<SButtonLoading size="xs" variant="outline" :loading="copyingLogs" :disabled="copyingLogs" @click="copyLogs">复制</SButtonLoading>
<SButton size="xs" variant="outline" @click="logWrap = !logWrap">{{ logWrap ? '单行' : '换行' }}</SButton>
<SButtonLoading size="xs" variant="outline" :loading="logsRefreshing" :disabled="logsRefreshing" @click="refreshLogs">刷新</SButtonLoading>
</div>
</template>
<div ref="logScroll" class="log-window">
<div ref="logScroll" class="log-window" :class="{ wrap: logWrap }">
<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>
@@ -743,7 +773,7 @@ onBeforeUnmount(() => {
<button @click="contextAddToRules">📥 写入拦截库</button>
<button v-if="chatContextMenu.item && chatContextMenu.item.user_id" class="warning" @click="contextBanMessage">🚫 封禁用户</button>
<button v-if="chatContextMenu.item && !chatContextMenu.item.deleted" class="danger" @click="contextDeleteMessage(false)">🗑 删除消息</button>
<button v-if="chatContextMenu.item && !chatContextMenu.item.deleted && chatContextMenu.item.user_id" class="warning" @click="contextDeleteMessage(true)"> 删除并封禁</button>
<button v-if="chatContextMenu.item && chatContextMenu.item.user_id" class="warning" @click="contextDeleteAndBanMessage"> 删除并封禁</button>
</div>
<section v-show="activePage === 'rules'" class="tab-page rule-test-page">
@@ -809,7 +839,7 @@ onBeforeUnmount(() => {
</div>
<div class="form-section"><h4>基础信息</h4><div class="form-grid two"><label>抽奖标题<SInput v-model="lotteryForm.title" placeholder="如 月卡福利抽奖" /></label><label>奖品内容<SInput v-model="lotteryForm.prize" placeholder="月卡一张 / 邀请码" /></label></div><label>说明<SInput v-model="lotteryForm.description" placeholder="展示给参与用户,可留空" /></label></div>
<div class="form-section"><h4>参与门槛</h4><div class="segmented option-grid"><button v-for="option in conditionOptions" :key="option.value" type="button" :class="{active: lotteryForm.condition_type === option.value}" @click="selectLotteryCondition(option.value)"><b>{{ option.label }}</b><small>{{ option.desc }}</small></button></div><label v-if="lotteryForm.condition_type === 'points'">积分门槛<SInput v-model="lotteryForm.condition_value" type="number" placeholder="100" /></label></div>
<div class="form-section"><h4>开奖方式</h4><div class="segmented option-grid"><button v-for="option in drawOptions" :key="option.value" type="button" :class="{active: lotteryForm.draw_type === option.value}" @click="selectLotteryDraw(option.value)"><b>{{ option.label }}</b><small>{{ option.desc }}</small></button></div><label v-if="lotteryForm.draw_type === 'people'">开奖人数<SInput v-model="lotteryForm.end_value" type="number" placeholder="3" /></label><div v-else class="datetime-block"><label>开奖时间<input v-model="lotteryForm.draw_at" class="form-input" type="datetime-local" /></label><div class="quick-time"><SButton size="xs" variant="outline" @click="setLotteryDrawAtMinutes(10)">10 分钟</SButton><SButton size="xs" variant="outline" @click="setLotteryDrawAtMinutes(30)">30 分钟</SButton><SButton size="xs" variant="outline" @click="setLotteryDrawAtMinutes(60)">1 小时</SButton></div></div></div>
<div class="form-section"><h4>开奖方式</h4><div class="segmented option-grid"><button v-for="option in drawOptions" :key="option.value" type="button" :class="{active: lotteryForm.draw_type === option.value}" @click="selectLotteryDraw(option.value)"><b>{{ option.label }}</b><small>{{ option.desc }}</small></button></div><label v-if="lotteryForm.draw_type === 'people'">开奖人数<SInput v-model="lotteryForm.end_value" type="number" placeholder="3" /></label><label v-else-if="lotteryForm.draw_type === 'instant'">奖品数量<SInput v-model="lotteryForm.prize_count" type="number" placeholder="1" /></label><div v-else class="datetime-block"><label>开奖时间<input v-model="lotteryForm.draw_at" class="form-input" type="datetime-local" /></label><div class="quick-time"><SButton size="xs" variant="outline" @click="setLotteryDrawAtMinutes(10)">10 分钟</SButton><SButton size="xs" variant="outline" @click="setLotteryDrawAtMinutes(30)">30 分钟</SButton><SButton size="xs" variant="outline" @click="setLotteryDrawAtMinutes(60)">1 小时</SButton></div></div></div>
<div class="form-section"><h4>状态</h4><div class="segmented small"><button v-for="option in statusOptions" :key="option.value" type="button" :class="{active: lotteryForm.status === option.value}" @click="lotteryForm.status = option.value"><b>{{ option.label }}</b></button></div></div>
<div class="confirm-actions sticky-actions"><SButton size="xs" variant="outline" :disabled="lotterySaving" @click="lotteryDialogOpen=false">取消</SButton><SButtonLoading size="xs" color="primary" :loading="lotterySaving" :disabled="lotterySaving" @click="saveLottery">保存</SButtonLoading></div>
</div>
+16
View File
@@ -382,3 +382,19 @@ body { background: #f3f6fb; }
.quick-time { display: flex; gap: 8px; flex-wrap: wrap; }
.sticky-actions { position: sticky; bottom: 0; background: #fff; padding: 12px 18px; border-top: 1px solid #e2e8f0; margin: 0; z-index: 2; }
@media (max-width: 680px) { .form-grid.two, .option-grid { grid-template-columns: 1fr; } .lottery-dialog { max-height: 88vh; } }
/* Requested UI polish: compact bans, wrapping logs, better rules and buttons */
.log-window.wrap .log-line { align-items: start; white-space: normal; }
.log-window.wrap .log-msg { overflow: visible; text-overflow: clip; white-space: pre-wrap; word-break: break-word; }
.overview-ban-list .ban-item { display: grid; grid-template-columns: minmax(0, 1fr); align-items: start; padding: 7px 9px; max-width: 100%; overflow: hidden; }
.overview-ban-list .ban-main { min-width: 0; }
.overview-ban-list .ban-main b, .overview-ban-list .ban-main span { min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.overview-ban-list .ban-meta { min-width: 0; width: 100%; display: flex; flex-wrap: wrap; gap: 5px 8px; white-space: normal; }
.overview-ban-list .ban-meta > span { min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
.rule-test-page .page-panel { border-radius: 18px; }
.rule-result-card { background: linear-gradient(180deg,#f8fafc,#fff); }
.rule-write-grid .s-button, .rule-test-page button { border-radius: 10px; }
.option-grid button, .chat-context-menu button { overflow: hidden; }
.row-actions { flex-wrap: wrap; }
.row-actions .s-button { border-radius: 9px; }
+21 -4
View File
@@ -384,17 +384,34 @@ async def handle_lottery_callback(update: Update, context: ContextTypes.DEFAULT_
await query.answer(result["reason"], show_alert=True)
return
event = result["event"]
if result["should_draw"]:
if result.get("won"):
remain = max(0, int(getattr(event, "remaining_count", 0) or 0))
text = (
"🎉 即开即中\n"
"━━━━━━━━━━━━\n"
f"🏷️ 标题:{event.title or event.prize}\n"
f"🎯 奖品:{event.prize}\n"
f"🏆 中奖用户:@{user.username or user.first_name or user.id}\n"
f"🎁 剩余奖品:{remain}"
)
if result.get("ended"):
text += "\n✅ 奖品已抽完,活动结束"
await query.edit_message_text(text)
else:
await query.edit_message_text(await build_lottery_text(event, result["count"]), reply_markup=lottery_keyboard(event.id))
await query.answer("恭喜中奖!", show_alert=True)
elif result["should_draw"]:
draw = await draw_lottery_event(event.id)
if draw.get("ok"):
winner = draw["winner"]
winner = draw.get("winner") or {}
winner_name = winner.get("username") or winner.get("first_name") or winner.get("user_id")
text = (
"🎉 抽奖已开奖\n"
"━━━━━━━━━━━━\n"
f"🏷️ 标题:{event.title or event.prize}\n"
f"🎯 奖品:{event.prize}\n"
f"🎯 奖品:{event.prize}\n"
f"👥 参与人数:{draw['count']}\n"
f"🏆 中奖用户:@{winner.username or winner.first_name or winner.user_id}"
f"🏆 中奖用户:@{winner_name}"
)
await query.edit_message_text(text)
else:
+25 -2
View File
@@ -294,9 +294,29 @@ async def join_lottery_event(event_id: int, user_id: int, username: str = "", fi
exists = await LotteryParticipant.filter(event_id=event_id, user_id=user_id).exists()
if exists:
return {"ok": False, "reason": "你已经参与过了"}
await LotteryParticipant.create(event_id=event_id, user_id=user_id, username=username, first_name=first_name)
is_instant = event.draw_type == "instant" or event.end_type == "instant"
if is_instant and int(event.remaining_count or 0) <= 0:
event.status = "ended"
event.ended_at = datetime.now()
await event.save()
return {"ok": False, "reason": "奖品已抽完,活动已结束"}
participant = await LotteryParticipant.create(event_id=event_id, user_id=user_id, username=username, first_name=first_name, is_winner=is_instant)
count = await count_lottery_participants(event_id)
return {"ok": True, "event": event, "count": count, "should_draw": event.end_type == "people" and count >= event.end_value}
won = False
ended = False
if is_instant:
won = True
event.remaining_count = max(0, int(event.remaining_count or 0) - 1)
event.winner_id = user_id
event.winner_name = username or first_name or str(user_id)
if event.remaining_count <= 0:
event.status = "ended"
event.ended_at = datetime.now()
ended = True
await event.save()
return {"ok": True, "event": event, "participant": participant, "count": count, "won": won, "ended": ended, "should_draw": (not is_instant) and event.end_type == "people" and count >= event.end_value}
def lottery_event_payload(event: LotteryEvent | None) -> dict | None:
@@ -313,6 +333,8 @@ def lottery_event_payload(event: LotteryEvent | None) -> dict | None:
"end_type": event.end_type,
"end_value": event.end_value,
"draw_at": event.draw_at,
"prize_count": event.prize_count,
"remaining_count": event.remaining_count,
"status": event.status,
"winner_id": event.winner_id,
"winner_name": event.winner_name,
@@ -332,6 +354,7 @@ def lottery_participant_payload(p: LotteryParticipant | None) -> dict | None:
"user_id": p.user_id,
"username": p.username,
"first_name": p.first_name,
"is_winner": getattr(p, "is_winner", False),
"created_at": p.created_at,
"updated_at": p.updated_at,
}
+13 -4
View File
@@ -431,13 +431,16 @@ async def api_create_lottery(request: Request):
end_type=str(data.get("end_type") or data.get("draw_type") or "manual"),
end_value=int(data.get("end_value") or data.get("max_participants") or 0),
max_participants=int(data.get("max_participants") or 0),
prize_count=max(1, int(data.get("prize_count") or 1)),
remaining_count=max(0, int(data.get("remaining_count") if data.get("remaining_count") is not None else data.get("prize_count") or 1)),
status=str(data.get("status") or "draft"),
)
return JSONResponse(json_safe({"ok": True, "event": {
"id": event.id, "chat_id": event.chat_id, "title": event.title, "description": event.description,
"prize": event.prize, "condition_type": event.condition_type, "condition_value": event.condition_value,
"draw_type": event.draw_type, "end_type": event.end_type, "end_value": event.end_value,
"max_participants": event.max_participants, "draw_at": event.draw_at, "status": event.status, "created_at": event.created_at,
"max_participants": event.max_participants, "prize_count": event.prize_count, "remaining_count": event.remaining_count,
"draw_at": event.draw_at, "status": event.status, "created_at": event.created_at,
}}))
@@ -455,15 +458,21 @@ async def api_update_lottery(event_id: int, request: Request):
setattr(event, key, str(data.get(key) or ""))
if "draw_at" in data:
event.draw_at = parse_draw_at(data.get("draw_at"))
for key in ["condition_value", "end_value", "max_participants"]:
for key in ["condition_value", "end_value", "max_participants", "prize_count", "remaining_count"]:
if key in data:
setattr(event, key, int(data.get(key) or 0))
value = int(data.get(key) or 0)
if key == "prize_count":
value = max(1, value)
if key == "remaining_count":
value = max(0, value)
setattr(event, key, value)
await event.save()
return JSONResponse(json_safe({"ok": True, "event": {
"id": event.id, "chat_id": event.chat_id, "title": event.title, "description": event.description,
"prize": event.prize, "condition_type": event.condition_type, "condition_value": event.condition_value,
"draw_type": event.draw_type, "end_type": event.end_type, "end_value": event.end_value,
"max_participants": event.max_participants, "draw_at": event.draw_at, "status": event.status, "created_at": event.created_at,
"max_participants": event.max_participants, "prize_count": event.prize_count, "remaining_count": event.remaining_count,
"draw_at": event.draw_at, "status": event.status, "created_at": event.created_at,
}}))
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-zv58GfRa.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-BzxaXHZa.css">
<script type="module" crossorigin src="/static/assets/index-DYAEflR9.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-D6Ef05OX.css">
</head>
<body>
<div id="app"></div>