feat: optimize lottery manager form

This commit is contained in:
ngfchl
2026-07-07 14:41:57 +08:00
parent 813ea433bc
commit 2946305286
5 changed files with 516 additions and 16 deletions
+165 -14
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { SButton, SCard, SConfigProvider, SInput, STag } from '@soybeanjs/ui';
import { SButton, SButtonLoading, SCard, SConfigProvider, SInput, STag } from '@soybeanjs/ui';
type ChatItem = Record<string, any> & { _deleting?: boolean };
type BanItem = Record<string, any> & { _loading?: boolean };
@@ -20,7 +20,7 @@ const shopTransactions = ref<Record<string, any>[]>([]);
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 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 activePage = ref<'dashboard' | 'shop' | 'lottery' | 'bans'>('dashboard');
const activeTab = reactive({ shop: 'items', lottery: 'events' });
const toast = ref('');
@@ -45,6 +45,55 @@ let statTimer: number | undefined;
const chatCount = computed(() => chatLogs.value.length);
const selectedQuery = computed(() => selectedChatId.value ? `?chat_id=${selectedChatId.value}` : '');
const conditionOptions = [
{ value: 'all', label: '所有人', desc: '无需门槛' },
{ value: 'checkin', label: '已签到', desc: '今日签到后可参与' },
{ value: 'points', label: '积分门槛', desc: '达到指定积分' }
];
const drawOptions = [
{ value: 'people', label: '满人开奖', desc: '达到人数自动开奖' },
{ value: 'time', label: '定时开奖', desc: '到指定时间自动开奖' }
];
const statusOptions = [
{ value: 'draft', label: '草稿' },
{ value: 'active', label: '启用' }
];
const lotteryEditorTitle = computed(() => lotteryForm.id ? '编辑抽奖' : '新建抽奖');
const lotteryConditionSummary = computed(() => {
if (lotteryForm.condition_type === 'checkin') return '今日已签到用户可参与';
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)} 人自动开奖`);
function selectLotteryCondition(type: string) {
lotteryForm.condition_type = type;
if (type !== 'points') lotteryForm.condition_value = 0;
}
function selectLotteryDraw(type: string) {
lotteryForm.draw_type = type;
lotteryForm.end_type = type;
if (type === 'people') {
lotteryForm.draw_at = '';
lotteryForm.end_value = Number(lotteryForm.end_value || lotteryForm.max_participants || 3);
lotteryForm.max_participants = Number(lotteryForm.end_value || 3);
} else {
lotteryForm.end_value = 0;
lotteryForm.max_participants = 0;
if (!lotteryForm.draw_at) setLotteryDrawAtMinutes(10);
}
}
function setLotteryDrawAtMinutes(minutes: number) {
const d = new Date(Date.now() + minutes * 60000);
d.setSeconds(0, 0);
lotteryForm.draw_at = toDatetimeLocal(d);
}
function toDatetimeLocal(date: Date) {
const pad = (n: number) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
const pageTitle = computed(() => ({
dashboard: '群组安全总览', shop: '商城管理', lottery: '抽奖管理', bans: '封禁记录'
}[activePage.value]));
@@ -211,17 +260,29 @@ 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: 'draft' }); }
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 editLottery(item: LotteryItem) { Object.assign(lotteryForm, { ...item, id: item.id || 0 }); }
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 === 'time' && !lotteryForm.draw_at) return showToast('请选择开奖时间');
if (lotterySaving.value) return;
lotterySaving.value = true;
try {
lotteryForm.end_type = lotteryForm.draw_type;
if (lotteryForm.draw_type === 'people') lotteryForm.max_participants = Number(lotteryForm.end_value || lotteryForm.max_participants || 1);
const payload = { ...lotteryForm, chat_id: selectedChatId.value || 0 };
payload.end_type = payload.draw_type;
if (payload.draw_type === 'people') {
payload.end_value = Number(payload.end_value || payload.max_participants || 1);
payload.max_participants = payload.end_value;
payload.draw_at = '';
} else {
payload.end_value = 0;
payload.max_participants = 0;
}
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({ ...lotteryForm, chat_id: selectedChatId.value || 0 }) });
await fetchJson(url, { method: lotteryForm.id ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
showToast(lotteryForm.id ? '抽奖已更新' : '抽奖已创建');
resetLotteryForm();
await loadLotteries();
} catch (e: any) { showToast(`保存抽奖失败:${e.message}`); }
@@ -366,9 +427,19 @@ async function restartService() {
} catch {}
window.setTimeout(() => location.reload(), 8000);
}
function copyLogs() {
const text = logs.value.map(l => `${formatTime(l.time)} ${l.level || ''} ${compactLog(l.message)}`).join('\n');
navigator.clipboard?.writeText(text);
async function refreshLogs() {
if (logsRefreshing.value) return;
logsRefreshing.value = true;
try { await loadLogs(); } finally { logsRefreshing.value = false; }
}
async function copyLogs() {
if (copyingLogs.value) return;
copyingLogs.value = true;
try {
const text = logs.value.map(l => `${formatTime(l.time)} ${l.level || ''} ${compactLog(l.message)}`).join('\n');
await navigator.clipboard?.writeText(text);
showToast('日志已复制');
} finally { copyingLogs.value = false; }
}
async function bootstrapData() {
@@ -480,8 +551,8 @@ onBeforeUnmount(() => {
<SCard class="panel log-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>
<SButtonLoading size="xs" variant="outline" :loading="copyingLogs" :disabled="copyingLogs" @click="copyLogs">复制</SButtonLoading>
<SButtonLoading size="xs" variant="outline" :loading="logsRefreshing" :disabled="logsRefreshing" @click="refreshLogs">刷新</SButtonLoading>
</div>
</template>
<div ref="logScroll" class="log-window">
@@ -500,9 +571,89 @@ onBeforeUnmount(() => {
<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" :loading="item._loading" :disabled="item._loading" @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>
<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" :loading="lotterySaving" :disabled="lotterySaving" @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" :loading="item._loading" :disabled="item._loading || item.status !== 'active'" @click="drawLottery(item)">开奖</SButton><SButton size="xs" variant="soft" color="warning" :loading="item._loading" :disabled="item._loading || item.status !== 'active'" @click="cancelLottery(item)">取消</SButton><SButton size="xs" variant="soft" color="destructive" :loading="item._loading" :disabled="item._loading" @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 v-show="activePage === 'lottery'" class="tab-page manager-layout lottery-page">
<aside class="editor-panel lottery-editor">
<div class="section-title"><span>{{ lotteryEditorTitle }}</span><small>{{ lotteryConditionSummary }} · {{ lotteryDrawSummary }}</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-block">
<span class="field-label">参与条件</span>
<div class="seg-buttons">
<SButton v-for="option in conditionOptions" :key="option.value" size="sm" :variant="lotteryForm.condition_type === option.value ? 'solid' : 'outline'" :color="lotteryForm.condition_type === option.value ? 'primary' : 'secondary'" @click="selectLotteryCondition(option.value)">
{{ option.label }}
</SButton>
</div>
<p class="field-help">{{ conditionOptions.find(o => o.value === lotteryForm.condition_type)?.desc }}</p>
<label v-if="lotteryForm.condition_type === 'points'" class="inline-field">最低积分<SInput v-model="lotteryForm.condition_value" type="number" placeholder="100" /></label>
</div>
<div class="form-block">
<span class="field-label">开奖方式</span>
<div class="seg-buttons">
<SButton v-for="option in drawOptions" :key="option.value" size="sm" :variant="lotteryForm.draw_type === option.value ? 'solid' : 'outline'" :color="lotteryForm.draw_type === option.value ? 'primary' : 'secondary'" @click="selectLotteryDraw(option.value)">
{{ option.label }}
</SButton>
</div>
<p class="field-help">{{ drawOptions.find(o => o.value === lotteryForm.draw_type)?.desc }}</p>
<label v-if="lotteryForm.draw_type === 'people'" class="inline-field">开奖人数<SInput v-model="lotteryForm.end_value" type="number" placeholder="3" /></label>
<div v-else class="time-picker-block">
<label class="inline-field">开奖时间<input v-model="lotteryForm.draw_at" class="form-input native-time" type="datetime-local" /></label>
<div class="quick-times">
<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-block">
<span class="field-label">发布状态</span>
<div class="seg-buttons">
<SButton v-for="option in statusOptions" :key="option.value" size="sm" :variant="lotteryForm.status === option.value ? 'solid' : 'outline'" :color="lotteryForm.status === option.value ? 'success' : 'secondary'" @click="lotteryForm.status = option.value">
{{ option.label }}
</SButton>
</div>
<p class="field-help">新建建议直接启用草稿仅保存后台配置</p>
</div>
<div class="form-preview">
<strong>{{ lotteryForm.title || '抽奖标题' }}</strong>
<span>{{ lotteryForm.prize || '奖品内容' }}</span>
<small>{{ lotteryConditionSummary }} / {{ lotteryDrawSummary }}</small>
</div>
<div class="form-actions">
<SButtonLoading color="primary" :loading="lotterySaving" :disabled="lotterySaving" @click="saveLottery">{{ lotteryForm.id ? '保存修改' : '创建抽奖' }}</SButtonLoading>
<SButton variant="outline" :disabled="lotterySaving" @click="resetLotteryForm">清空</SButton>
</div>
</aside>
<SCard class="panel data-panel" title="🎁 抽奖项目" :description="`${lotteries.length} 个活动`">
<div class="table-list lottery-list">
<div v-for="item in lotteries" :key="item.id" class="data-row lottery-row">
<div class="data-main">
<div class="row-title">
<strong>{{ item.title || item.prize }}</strong>
<STag size="xs" :color="item.status === 'active' ? 'success' : item.status === 'drawn' ? 'primary' : item.status === 'cancelled' ? 'destructive' : 'secondary'" variant="soft">{{ item.status }}</STag>
</div>
<div class="row-desc">奖品{{ item.prize }} · 条件{{ item.condition_type }}{{ item.condition_value ? ':' + item.condition_value : '' }}</div>
<div class="row-desc">开奖{{ item.draw_type === 'time' ? formatTime(item.draw_at) : `${item.max_participants || item.end_value || '-'}` }}</div>
</div>
<div class="row-actions">
<SButton size="xs" variant="outline" @click="editLottery(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>
</div>
</div>
<div v-if="!lotteries.length" class="empty">暂无抽奖项目</div>
</div>
<div v-if="lotteryParticipants.length" class="sub-list"><h4>参与记录</h4><div v-for="p in lotteryParticipants" :key="p.id" class="mini-row">用户 {{ p.user_id }} · {{ formatTime(p.created_at) }}</div></div>
</SCard>
</section>
<section v-show="activePage === 'bans'" class="tab-page">
+20
View File
@@ -219,3 +219,23 @@ body { background: #f3f6fb; }
.admin-shell .floating-chat-send { left: 284px; right: 20px; bottom: 18px; }
.main-grid { padding-bottom: 72px; }
@media (max-width: 1100px) { .admin-shell .floating-chat-send { left: 16px; right: 16px; } }
.lottery-editor { gap: 14px; }
.form-block { display: flex; flex-direction: column; gap: 8px; padding: 12px; border: 1px solid #e2e8f0; border-radius: 14px; background: #f8fafc; }
.field-label { font-size: 13px; font-weight: 800; color: #334155; }
.field-help { margin: 0; font-size: 12px; color: #64748b; }
.seg-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
.inline-field { display: grid; gap: 6px; font-size: 12px; color: #475569; }
.time-picker-block { display: grid; gap: 8px; }
.native-time { width: 100%; height: 38px; color: #0f172a; }
.quick-times { display: flex; flex-wrap: wrap; gap: 6px; }
.form-preview { display: grid; gap: 5px; padding: 12px; border: 1px dashed #c7d2fe; border-radius: 14px; background: linear-gradient(135deg, #eef2ff, #f8fafc); }
.form-preview strong { font-size: 15px; color: #1e293b; }
.form-preview span { color: #334155; }
.form-preview small { color: #64748b; }
.lottery-row { align-items: flex-start; }
.lottery-list .row-desc { line-height: 1.6; }
@media (max-width: 860px) {
.seg-buttons, .quick-times { display: grid; grid-template-columns: 1fr; }
}
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-DmSoVPta.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-Djy9tMxw.css">
<script type="module" crossorigin src="/static/assets/index-DPwRdHjl.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-CGm8xHHJ.css">
</head>
<body>
<div id="app"></div>