fix: polish lottery dialog and moderation records
This commit is contained in:
@@ -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 time_expire VARCHAR(100) NULL;
|
||||
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 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;
|
||||
|
||||
+23
-4
@@ -716,8 +716,17 @@ onBeforeUnmount(() => {
|
||||
<div class="ban-list overview-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" :loading="item._loading" :disabled="item._loading" @click="unban(item)">解封</SButton><STag v-else size="xs" color="secondary" variant="soft">已处理</STag></div>
|
||||
<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 || 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>
|
||||
</SCard>
|
||||
@@ -772,7 +781,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<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" @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="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>
|
||||
@@ -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>
|
||||
<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 v-if="toast" class="toast">{{ toast }}</div>
|
||||
|
||||
@@ -357,3 +357,28 @@ body { background: #f3f6fb; }
|
||||
.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: 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; } }
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
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:
|
||||
event = await LotteryEvent.filter(id=event_id).first()
|
||||
if not event or event.status != "active":
|
||||
@@ -308,11 +346,11 @@ async def draw_lottery_event(event_id: int) -> dict:
|
||||
event.status = "cancelled"
|
||||
event.ended_at = datetime.now()
|
||||
await event.save()
|
||||
return {"ok": False, "reason": "无人参与,抽奖已取消", "event": event}
|
||||
return {"ok": False, "reason": "无人参与,抽奖已取消", "event": lottery_event_payload(event)}
|
||||
winner = random.choice(participants)
|
||||
event.status = "ended"
|
||||
event.winner_id = winner.user_id
|
||||
event.winner_name = winner.username or winner.first_name or str(winner.user_id)
|
||||
event.ended_at = datetime.now()
|
||||
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)}
|
||||
|
||||
@@ -704,6 +704,7 @@ async def api_ban_message_user(message_id: int):
|
||||
"duration_minutes": config.BAN_DURATION_MINUTES,
|
||||
"auto_unban": True,
|
||||
"unban_at": unban_at,
|
||||
"chat_id": rec.chat_id,
|
||||
"time": ban.created_at,
|
||||
}))
|
||||
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,
|
||||
"auto_unban": True,
|
||||
"unban_at": unban_at,
|
||||
"chat_id": rec.chat_id,
|
||||
"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)}))
|
||||
|
||||
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
@@ -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-BmqZ_6lm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-3EKqFt0k.css">
|
||||
<script type="module" crossorigin src="/static/assets/index-zv58GfRa.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-BzxaXHZa.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user