fix: polish lottery dialog and moderation records

This commit is contained in:
ngfchl
2026-07-07 16:27:48 +08:00
parent 0169ac654d
commit 98e2ba60c6
11 changed files with 1087 additions and 8 deletions
+9
View File
@@ -34,6 +34,15 @@ async def ensure_runtime_schema():
ALTER TABLE user_bindings ADD COLUMN IF NOT EXISTS auth_email VARCHAR(255) NULL; ALTER TABLE user_bindings ADD COLUMN IF NOT EXISTS auth_email VARCHAR(255) NULL;
ALTER TABLE user_bindings ADD COLUMN IF NOT EXISTS time_expire VARCHAR(100) NULL; ALTER TABLE user_bindings ADD COLUMN IF NOT EXISTS time_expire VARCHAR(100) NULL;
ALTER TABLE bans ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0; ALTER TABLE bans ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0;
UPDATE bans AS b
SET chat_id = m.chat_id
FROM (
SELECT user_id, MAX(chat_id) AS chat_id
FROM messages
WHERE chat_id <> 0
GROUP BY user_id
) AS m
WHERE b.chat_id = 0 AND b.user_id = m.user_id;
ALTER TABLE actions ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0; ALTER TABLE actions ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0;
ALTER TABLE verify_queue ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0; ALTER TABLE verify_queue ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0;
ALTER TABLE warnings ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0; ALTER TABLE warnings ADD COLUMN IF NOT EXISTS chat_id BIGINT NOT NULL DEFAULT 0;
+23 -4
View File
@@ -716,8 +716,17 @@ onBeforeUnmount(() => {
<div class="ban-list overview-ban-list"> <div class="ban-list overview-ban-list">
<div v-if="!bans.length" class="empty">暂无封禁记录</div> <div v-if="!bans.length" class="empty">暂无封禁记录</div>
<div v-for="(item, i) in bans" :key="item.id || i" class="ban-item"> <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-main">
<div class="ban-meta"><span>{{ formatTime(item.time) }}</span><SButton v-if="item.auto_unban" size="xs" variant="outline" color="success" :loading="item._loading" :disabled="item._loading" @click="unban(item)">解封</SButton><STag v-else size="xs" color="secondary" variant="soft">已处理</STag></div> <b>@{{ item.username || item.first_name || item.user_id }}</b>
<span>{{ item.reason || '-' }}</span>
</div>
<div class="ban-meta">
<span>封禁 {{ formatTime(item.time || item.created_at) }}</span>
<span v-if="item.chat_id" class="muted"> {{ item.chat_id }}</span>
<span v-if="item.unban_at" class="muted">解封 {{ formatTime(item.unban_at) }}</span>
<SButton v-if="item.auto_unban" size="xs" variant="outline" color="success" :loading="item._loading" :disabled="item._loading" @click="unban(item)">解封</SButton>
<STag v-else size="xs" color="secondary" variant="soft">已处理</STag>
</div>
</div> </div>
</div> </div>
</SCard> </SCard>
@@ -772,7 +781,7 @@ onBeforeUnmount(() => {
</div> </div>
<div class="row-actions"> <div class="row-actions">
<SButton size="xs" variant="outline" :disabled="Number(item.participants_count || 0) > 0" @click="editLottery(item)">编辑</SButton> <SButton size="xs" variant="outline" :disabled="Number(item.participants_count || 0) > 0" @click="editLottery(item)">编辑</SButton>
<SButton size="xs" variant="outline" @click="showParticipants(item)">参与</SButton> <SButton size="xs" variant="outline" @click="showParticipants(item)">查看参与</SButton>
<SButton size="xs" variant="outline" color="primary" :loading="item._loading" :disabled="item._loading || item.status !== 'active'" @click="drawLottery(item)">开奖</SButton> <SButton size="xs" variant="outline" color="primary" :loading="item._loading" :disabled="item._loading || item.status !== 'active'" @click="drawLottery(item)">开奖</SButton>
<SButton size="xs" variant="outline" color="warning" :loading="item._loading" :disabled="item._loading || item.status !== 'active'" @click="cancelLottery(item)">关闭</SButton> <SButton size="xs" variant="outline" color="warning" :loading="item._loading" :disabled="item._loading || item.status !== 'active'" @click="cancelLottery(item)">关闭</SButton>
<SButton size="xs" variant="link" color="destructive" :loading="item._loading" :disabled="item._loading" @click="deleteLottery(item)">删除</SButton> <SButton size="xs" variant="link" color="destructive" :loading="item._loading" :disabled="item._loading" @click="deleteLottery(item)">删除</SButton>
@@ -793,7 +802,17 @@ onBeforeUnmount(() => {
<div class="confirm-card editor-dialog"><h3>{{ shopForm.item_key ? '编辑商品' : '新增商品' }}</h3><p>库存填 -1 表示不限库存0 表示售罄</p><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" type="number" placeholder="50" /></label><label>库存<SInput v-model="shopForm.stock" type="number" placeholder="-1 不限库存" /></label><label>说明<SInput v-model="shopForm.desc" placeholder="展示在 /shop 内" /></label><label class="switch-line"><input v-model="shopForm.enabled" type="checkbox" /> 上架销售</label><div class="confirm-actions"><SButton size="xs" variant="outline" :disabled="shopSaving" @click="shopDialogOpen=false">取消</SButton><SButtonLoading size="xs" color="primary" :loading="shopSaving" :disabled="shopSaving" @click="saveShopItem">保存</SButtonLoading></div></div> <div class="confirm-card editor-dialog"><h3>{{ shopForm.item_key ? '编辑商品' : '新增商品' }}</h3><p>库存填 -1 表示不限库存0 表示售罄</p><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" type="number" placeholder="50" /></label><label>库存<SInput v-model="shopForm.stock" type="number" placeholder="-1 不限库存" /></label><label>说明<SInput v-model="shopForm.desc" placeholder="展示在 /shop 内" /></label><label class="switch-line"><input v-model="shopForm.enabled" type="checkbox" /> 上架销售</label><div class="confirm-actions"><SButton size="xs" variant="outline" :disabled="shopSaving" @click="shopDialogOpen=false">取消</SButton><SButtonLoading size="xs" color="primary" :loading="shopSaving" :disabled="shopSaving" @click="saveShopItem">保存</SButtonLoading></div></div>
</div> </div>
<div v-if="lotteryDialogOpen" class="confirm-mask" @click.self="lotteryDialogOpen=false"> <div v-if="lotteryDialogOpen" class="confirm-mask" @click.self="lotteryDialogOpen=false">
<div class="confirm-card editor-dialog lottery-dialog"><h3>{{ lotteryEditorTitle }}</h3><p>{{ lotteryConditionSummary }} · {{ lotteryDrawSummary }}</p><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="segmented"><button v-for="option in conditionOptions" :key="option.value" :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 class="segmented"><button v-for="option in drawOptions" :key="option.value" :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 class="segmented small"><button v-for="option in statusOptions" :key="option.value" :class="{active: lotteryForm.status === option.value}" @click="lotteryForm.status = option.value"><b>{{ option.label }}</b></button></div><div class="confirm-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> <div class="confirm-card editor-dialog lottery-dialog">
<div class="dialog-head">
<div><h3>{{ lotteryEditorTitle }}</h3><p>{{ lotteryConditionSummary }} · {{ lotteryDrawSummary }}</p></div>
<button class="dialog-close" type="button" @click="lotteryDialogOpen=false">×</button>
</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 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>
</div> </div>
<div v-if="toast" class="toast">{{ toast }}</div> <div v-if="toast" class="toast">{{ toast }}</div>
+25
View File
@@ -357,3 +357,28 @@ body { background: #f3f6fb; }
.rule-row em { color: #64748b; font-style: normal; text-align: right; } .rule-row em { color: #64748b; font-style: normal; text-align: right; }
@media (max-width: 1180px) { .main-grid { grid-template-columns: 1fr 1fr; height: auto; max-height: none; } .chat-panel { grid-column: 1 / -1; height: 42vh; } .log-panel, .overview-ban-panel { height: 34vh; } } @media (max-width: 1180px) { .main-grid { grid-template-columns: 1fr 1fr; height: auto; max-height: none; } .chat-panel { grid-column: 1 / -1; height: 42vh; } .log-panel, .overview-ban-panel { height: 34vh; } }
@media (max-width: 760px) { .main-grid { grid-template-columns: 1fr; } .chat-panel, .log-panel, .overview-ban-panel { height: 36vh; min-height: 280px; } .rule-library-grid { grid-template-columns: 1fr; } } @media (max-width: 760px) { .main-grid { grid-template-columns: 1fr; } .chat-panel, .log-panel, .overview-ban-panel { height: 36vh; min-height: 280px; } .rule-library-grid { grid-template-columns: 1fr; } }
/* Polished ban panel and lottery dialog */
.ban-meta .muted { color: #94a3b8; }
.overview-ban-list .ban-item { gap: 8px; }
.overview-ban-list .ban-main span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.dialog-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.dialog-close { width: 32px; height: 32px; border: 0; border-radius: 10px; background: #f1f5f9; color: #64748b; font-size: 22px; line-height: 1; cursor: pointer; }
.dialog-close:hover { background: #e2e8f0; color: #0f172a; }
.lottery-dialog { width: min(720px, calc(100vw - 28px)); padding: 0; max-height: min(88vh, 780px); overflow-y: auto; }
.lottery-dialog > .dialog-head { padding: 18px 18px 10px; border-bottom: 1px solid #e2e8f0; }
.lottery-dialog .form-section { padding: 14px 18px; border-bottom: 1px solid #eef2f7; display: grid; gap: 10px; }
.lottery-dialog .form-section h4 { margin: 0; font-size: 13px; color: #0f172a; }
.form-grid.two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.option-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.segmented button { border: 1px solid #e2e8f0; background: #fff; border-radius: 12px; padding: 10px; text-align: left; color: #334155; cursor: pointer; transition: .15s ease; }
.segmented button b { display: block; font-size: 13px; }
.segmented button small { display: block; margin-top: 3px; color: #94a3b8; font-size: 11px; }
.segmented button.active { border-color: #2563eb; background: #eff6ff; color: #1d4ed8; box-shadow: 0 8px 22px rgba(37,99,235,.12); }
.segmented.small { display: flex; gap: 8px; }
.segmented.small button { min-width: 86px; text-align: center; }
.datetime-block { display: grid; gap: 8px; }
.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; } }
+40 -2
View File
@@ -299,6 +299,44 @@ async def join_lottery_event(event_id: int, user_id: int, username: str = "", fi
return {"ok": True, "event": event, "count": count, "should_draw": event.end_type == "people" and count >= event.end_value} return {"ok": True, "event": event, "count": count, "should_draw": event.end_type == "people" and count >= event.end_value}
def lottery_event_payload(event: LotteryEvent | None) -> dict | None:
if not event:
return None
return {
"id": event.id,
"chat_id": event.chat_id,
"title": event.title,
"prize": event.prize,
"description": event.description,
"condition_type": event.condition_type,
"condition_value": event.condition_value,
"end_type": event.end_type,
"end_value": event.end_value,
"draw_at": event.draw_at,
"status": event.status,
"winner_id": event.winner_id,
"winner_name": event.winner_name,
"ended_at": event.ended_at,
"created_at": event.created_at,
"updated_at": event.updated_at,
}
def lottery_participant_payload(p: LotteryParticipant | None) -> dict | None:
if not p:
return None
return {
"id": p.id,
"event_id": p.event_id,
"chat_id": p.chat_id,
"user_id": p.user_id,
"username": p.username,
"first_name": p.first_name,
"created_at": p.created_at,
"updated_at": p.updated_at,
}
async def draw_lottery_event(event_id: int) -> dict: async def draw_lottery_event(event_id: int) -> dict:
event = await LotteryEvent.filter(id=event_id).first() event = await LotteryEvent.filter(id=event_id).first()
if not event or event.status != "active": if not event or event.status != "active":
@@ -308,11 +346,11 @@ async def draw_lottery_event(event_id: int) -> dict:
event.status = "cancelled" event.status = "cancelled"
event.ended_at = datetime.now() event.ended_at = datetime.now()
await event.save() await event.save()
return {"ok": False, "reason": "无人参与,抽奖已取消", "event": event} return {"ok": False, "reason": "无人参与,抽奖已取消", "event": lottery_event_payload(event)}
winner = random.choice(participants) winner = random.choice(participants)
event.status = "ended" event.status = "ended"
event.winner_id = winner.user_id event.winner_id = winner.user_id
event.winner_name = winner.username or winner.first_name or str(winner.user_id) event.winner_name = winner.username or winner.first_name or str(winner.user_id)
event.ended_at = datetime.now() event.ended_at = datetime.now()
await event.save() await event.save()
return {"ok": True, "event": event, "winner": winner, "count": len(participants)} return {"ok": True, "event": lottery_event_payload(event), "winner": lottery_participant_payload(winner), "count": len(participants)}
+2
View File
@@ -704,6 +704,7 @@ async def api_ban_message_user(message_id: int):
"duration_minutes": config.BAN_DURATION_MINUTES, "duration_minutes": config.BAN_DURATION_MINUTES,
"auto_unban": True, "auto_unban": True,
"unban_at": unban_at, "unban_at": unban_at,
"chat_id": rec.chat_id,
"time": ban.created_at, "time": ban.created_at,
})) }))
return JSONResponse(json_safe({"ok": True, "banned": True, "ban_id": ban.id})) return JSONResponse(json_safe({"ok": True, "banned": True, "ban_id": ban.id}))
@@ -790,6 +791,7 @@ async def api_delete_message(message_id: int, request: Request):
"duration_minutes": config.BAN_DURATION_MINUTES, "duration_minutes": config.BAN_DURATION_MINUTES,
"auto_unban": True, "auto_unban": True,
"unban_at": unban_at, "unban_at": unban_at,
"chat_id": rec.chat_id,
"time": ban.created_at, "time": ban.created_at,
})) }))
return JSONResponse(json_safe({"ok": True, "tg_deleted": tg_deleted, "tg_error": tg_error, "deleted_at": rec.deleted_at, "banned": bool(ban)})) return JSONResponse(json_safe({"ok": True, "tg_deleted": tg_deleted, "tg_error": tg_error, "deleted_at": rec.deleted_at, "banned": bool(ban)}))
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/static/icon.svg" /> <link rel="icon" type="image/svg+xml" href="/static/icon.svg" />
<title>TG Spam Guard</title> <title>TG Spam Guard</title>
<script type="module" crossorigin src="/static/assets/index-BmqZ_6lm.js"></script> <script type="module" crossorigin src="/static/assets/index-zv58GfRa.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-3EKqFt0k.css"> <link rel="stylesheet" crossorigin href="/static/assets/index-BzxaXHZa.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>