feat: polish manager crud dialogs

This commit is contained in:
ngfchl
2026-07-07 13:41:04 +08:00
parent 2ee9fc575c
commit 5c11b44dbb
5 changed files with 446 additions and 55 deletions
+81 -53
View File
@@ -22,6 +22,9 @@ 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 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 newMessage = ref('');
const sending = ref(false);
const restarting = ref(false);
@@ -67,6 +70,24 @@ function tagColor(item: ChatItem) {
return 'primary';
}
function showToast(message: string) {
toast.value = message;
window.setTimeout(() => { if (toast.value === message) toast.value = ''; }, 2600);
}
function askConfirm(title: string, message: string, options: { confirmText?: string; danger?: boolean } = {}) {
confirmState.open = true;
confirmState.title = title;
confirmState.message = message;
confirmState.confirmText = options.confirmText || '确认';
confirmState.danger = Boolean(options.danger);
return new Promise<boolean>((resolve) => { confirmState.resolver = resolve; });
}
function closeConfirm(value: boolean) {
confirmState.open = false;
confirmState.resolver?.(value);
confirmState.resolver = null;
}
async function fetchJson(url: string, options?: RequestInit) {
const res = await fetch(url, options);
const data = await res.json().catch(() => ({}));
@@ -115,7 +136,7 @@ async function loadShopTransactions() {
try { shopTransactions.value = await fetchJson(`/api/shop-transactions${selectedQuery.value}`); } catch {}
}
async function saveShopItem() {
if (!shopForm.item_key || !shopForm.name) return alert('请填写商品编号和名称');
if (!shopForm.item_key || !shopForm.name) return showToast('请填写商品编号和名称');
await fetchJson('/api/shop-items', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...shopForm, chat_id: selectedChatId.value || 0 })
@@ -125,7 +146,7 @@ async function saveShopItem() {
}
async function editShopItem(item: ShopItem) { Object.assign(shopForm, item); }
async function deleteShopItem(item: ShopItem) {
if (!window.confirm(`确认删除商品 ${item.name}`)) return;
if (!await askConfirm('删除商品', `确认删除商品${item.name}」?交易记录会保留。`, { confirmText: '删除', danger: true })) return;
await fetchJson(`/api/shop-items/${item.item_key}${selectedQuery.value}`, { method: 'DELETE' });
await loadShopItems();
}
@@ -135,7 +156,7 @@ async function loadLotteries() {
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 editLottery(item: LotteryItem) { Object.assign(lotteryForm, { ...item, id: item.id || 0 }); }
async function saveLottery() {
if (!lotteryForm.title || !lotteryForm.prize) return alert('请填写抽奖标题和奖品');
if (!lotteryForm.title || !lotteryForm.prize) return showToast('请填写抽奖标题和奖品');
lotteryForm.end_type = lotteryForm.draw_type;
if (lotteryForm.draw_type === 'people') lotteryForm.max_participants = Number(lotteryForm.end_value || lotteryForm.max_participants || 1);
const url = lotteryForm.id ? `/api/lotteries/${lotteryForm.id}` : '/api/lotteries';
@@ -144,7 +165,7 @@ async function saveLottery() {
await loadLotteries();
}
async function deleteLottery(item: LotteryItem) {
if (!window.confirm(`确认删除抽奖 ${item.title || item.prize}`)) return;
if (!await askConfirm('删除抽奖', `确认删除抽奖${item.title || item.prize}」?参与记录会一并移除。`, { confirmText: '删除', danger: true })) return;
await fetchJson(`/api/lotteries/${item.id}`, { method: 'DELETE' });
await loadLotteries();
}
@@ -152,12 +173,12 @@ async function loadLotteryParticipants(item: LotteryItem) {
lotteryParticipants.value = await fetchJson(`/api/lotteries/${item.id}/participants`);
}
async function cancelLottery(item: LotteryItem) {
if (!window.confirm('确认取消这个抽奖?')) return;
if (!await askConfirm('取消抽奖', '确认取消这个抽奖?已参与用户不会自动补偿。', { confirmText: '取消抽奖', danger: true })) return;
await fetchJson(`/api/lotteries/${item.id}/cancel`, { method: 'POST' });
await loadLotteries();
}
async function drawLottery(item: LotteryItem) {
if (!window.confirm('确认立即开奖')) return;
if (!await askConfirm('立即开奖', '确认现在开奖?开奖后状态会变更。', { confirmText: '立即开奖' })) return;
await fetchJson(`/api/lotteries/${item.id}/draw`, { method: 'POST' });
await loadLotteries();
}
@@ -218,14 +239,14 @@ async function sendMessage() {
});
newMessage.value = '';
} catch (e: any) {
alert(`发送失败:${e.message}`);
showToast(`发送失败:${e.message}`);
} finally {
sending.value = false;
}
}
async function deleteChatMessage(item: ChatItem, ban = false) {
const action = ban ? '删除并封禁' : '删除';
if (!window.confirm(`确认${action}这条消息?`)) return;
if (!await askConfirm(action, `确认${action}这条消息?`, { confirmText: action, danger: true })) return;
item._deleting = true;
try {
await fetchJson(`/api/chat/${item.id}/delete`, {
@@ -236,37 +257,37 @@ async function deleteChatMessage(item: ChatItem, ban = false) {
Object.assign(item, { deleted: true, manually_deleted: true });
await loadStats();
} catch (e: any) {
alert(`${action}失败:${e.message}`);
showToast(`${action}失败:${e.message}`);
} finally {
item._deleting = false;
}
}
async function unban(item: BanItem) {
if (!window.confirm(`确认解封 ${item.username || item.user_id}`)) return;
if (!await askConfirm('解除封禁', `确认解封 ${item.username || item.user_id}`, { confirmText: '解封' })) return;
item._loading = true;
try {
await fetchJson(`/api/unban/${item.user_id}`, { method: 'POST' });
item.auto_unban = false;
await loadStats();
} catch (e: any) {
alert(`解封失败:${e.message}`);
showToast(`解封失败:${e.message}`);
} finally {
item._loading = false;
}
}
async function restartBot() {
if (!window.confirm('确认只重启 Telegram BotWeb 管理页不会重启。')) return;
if (!await askConfirm('重启 Bot', '确认只重启 Telegram BotWeb 管理页不会重启。', { confirmText: '重启 Bot' })) return;
restartingBot.value = true;
try {
await fetchJson('/api/restart-bot', { method: 'POST' });
window.setTimeout(() => { restartingBot.value = false; loadLogs(); }, 5000);
} catch (e: any) {
restartingBot.value = false;
alert(`重启 Bot 失败:${e.message}`);
showToast(`重启 Bot 失败:${e.message}`);
}
}
async function restartService() {
if (!window.confirm('确认重启 TG Spam Guard 服务?重启期间 Web 和 Bot 会短暂不可用。')) return;
if (!await askConfirm('重启服务', '确认重启 TG Spam Guard 服务?重启期间 Web 和 Bot 会短暂不可用。', { confirmText: '重启服务', danger: true })) return;
restarting.value = true;
try {
await fetchJson('/api/restart', { method: 'POST' });
@@ -381,48 +402,43 @@ onBeforeUnmount(() => {
<SButton size="xs" variant="outline" @click="activeModal=null">关闭</SButton>
</div>
<div v-if="activeModal === 'shop'" class="modal-body">
<div class="shop-form">
<SInput v-model="shopForm.item_key" placeholder="编号,如 1" />
<SInput v-model="shopForm.name" placeholder="商品名称" />
<SInput v-model="shopForm.cost" placeholder="积分价格" />
<SInput v-model="shopForm.desc" placeholder="说明" />
<label class="check"><input v-model="shopForm.enabled" type="checkbox" /> 启用</label>
<SButton size="xs" color="primary" @click="saveShopItem">保存</SButton>
</div>
<h3>商品列表</h3>
<div class="mini-list">
<div v-for="item in shopItems" :key="item.item_key" class="mini-row">
<span><b>{{ item.item_key }}</b> {{ item.name }} {{ item.cost }} <em>{{ item.enabled ? '启用' : '停用' }}</em></span>
<div class="row-actions"><SButton size="xs" variant="outline" @click="editShopItem(item)">编辑</SButton><SButton size="xs" variant="soft" color="danger" @click="deleteShopItem(item)">删除</SButton></div>
<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>
<h3>交易记录</h3>
<div class="mini-list"><div v-for="tx in shopTransactions" :key="tx.id" class="mini-row"><span>@{{ tx.username || tx.user_id }} 兑换 {{ tx.item_name }}消耗 {{ tx.cost }} </span><em>{{ formatTime(tx.created_at) }}</em></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>
<div v-else-if="activeModal === 'lottery'" class="modal-body">
<div class="lottery-form">
<SInput v-model="lotteryForm.title" placeholder="抽奖标题" />
<SInput v-model="lotteryForm.prize" placeholder="奖品" />
<SInput v-model="lotteryForm.description" placeholder="描述/开奖条件说明" />
<select v-model="lotteryForm.condition_type" class="form-select"><option value="all">所有人</option><option value="checkin">今日签到</option><option value="min_points">积分门槛</option></select>
<SInput v-model="lotteryForm.condition_value" placeholder="条件值" />
<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>
<SInput v-model="lotteryForm.end_value" placeholder="人数或分钟数" />
<SInput v-model="lotteryForm.draw_at" placeholder="指定时间,如 2026-07-07 20:00" />
<select v-model="lotteryForm.status" class="form-select"><option value="draft">草稿</option><option value="active">进行中</option><option value="cancelled">取消</option></select>
<SButton size="xs" color="primary" @click="saveLottery">保存抽奖</SButton>
<SButton size="xs" variant="outline" @click="resetLotteryForm">清空</SButton>
</div>
<div class="mini-list">
<div v-for="item in lotteries" :key="item.id" class="mini-row">
<span>#{{ item.id }} {{ item.title || item.prize }}{{ item.draw_type || item.end_type }}{{ item.status }}{{ formatTime(item.created_at) }}</span>
<div class="row-actions"><SButton size="xs" variant="outline" @click="editLottery(item); loadLotteryParticipants(item)">编辑/参与</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="danger" @click="deleteLottery(item)">删除</SButton></div>
</div>
</div>
<h3>参与记录</h3>
<div class="mini-list"><div v-for="p in lotteryParticipants" :key="p.id" class="mini-row"><span>@{{ p.username || p.first_name || p.user_id }}</span><em>{{ formatTime(p.created_at) }}</em></div></div>
<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>
<div v-else class="modal-body">
@@ -441,6 +457,18 @@ onBeforeUnmount(() => {
<SInput v-model="newMessage" placeholder="发送消息到 TG 群" />
<SButton type="submit" color="primary" :disabled="sending || !newMessage.trim()">发送</SButton>
</form>
<div v-if="toast" class="toast">{{ toast }}</div>
<div v-if="confirmState.open" class="confirm-mask" @click.self="closeConfirm(false)">
<div class="confirm-card">
<h3>{{ confirmState.title }}</h3>
<p>{{ confirmState.message }}</p>
<div class="confirm-actions">
<SButton size="xs" variant="outline" @click="closeConfirm(false)">取消</SButton>
<SButton size="xs" :color="confirmState.danger ? 'destructive' : 'primary'" @click="closeConfirm(true)">{{ confirmState.confirmText }}</SButton>
</div>
</div>
</div>
</main>
</SConfigProvider>
</template>
+34
View File
@@ -98,3 +98,37 @@ button { font-family: inherit; }
.modal-card { max-height: calc(100vh - 16px); border-radius: 16px; }
.lottery-form { grid-template-columns: 1fr; }
}
.manager-layout { display: grid; grid-template-columns: 320px 1fr; gap: 14px; background: #f8fafc; }
.editor-panel, .data-panel { border: 1px solid #e2e8f0; border-radius: 16px; background: #fff; padding: 14px; }
.editor-panel { display: flex; flex-direction: column; gap: 10px; box-shadow: 0 10px 30px rgba(15,23,42,.04); }
.editor-panel label { display: grid; gap: 6px; color: #475569; font-size: 12px; font-weight: 700; }
.section-title { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
.section-title span { font-size: 16px; font-weight: 800; color: #0f172a; }
.section-title small { padding: 3px 8px; border-radius: 999px; background: #eef2ff; color: #4f46e5; }
.switch-line { display: flex !important; grid-template-columns: auto 1fr !important; align-items: center; gap: 8px !important; }
.editor-actions { display: flex; gap: 8px; margin-top: 4px; }
.form-two { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.tabbar { display: inline-flex; padding: 4px; margin-bottom: 12px; border-radius: 12px; background: #eef2f7; }
.tabbar button { border: 0; background: transparent; padding: 7px 12px; border-radius: 9px; color: #64748b; cursor: pointer; font-weight: 700; }
.tabbar button.active { background: #fff; color: #1d4ed8; box-shadow: 0 4px 12px rgba(15,23,42,.08); }
.data-table { overflow: auto; border: 1px solid #e2e8f0; border-radius: 14px; }
.table-head, .table-row { display: grid; grid-template-columns: 2.2fr .8fr .8fr 1.6fr; gap: 10px; align-items: center; padding: 10px 12px; min-width: 760px; }
.tx-table .table-head, .tx-table .table-row { grid-template-columns: 1.2fr 1.5fr .7fr .8fr; }
.table-head { position: sticky; top: 0; background: #f8fafc; color: #64748b; font-size: 12px; font-weight: 800; border-bottom: 1px solid #e2e8f0; }
.table-row { border-bottom: 1px solid #eef2f7; color: #334155; }
.table-row:last-child { border-bottom: 0; }
.table-row b, .table-row em, .table-row small { display: block; }
.table-row b { color: #0f172a; font-style: normal; }
.table-row em { color: #475569; font-style: normal; margin-top: 2px; }
.table-row small { color: #94a3b8; margin-top: 2px; }
.toast { position: fixed; right: 20px; bottom: 20px; z-index: 80; padding: 10px 14px; border-radius: 12px; background: #0f172a; color: #fff; box-shadow: 0 18px 50px rgba(15,23,42,.28); font-weight: 700; }
.confirm-mask { position: fixed; inset: 0; z-index: 90; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgba(15,23,42,.46); backdrop-filter: blur(6px); }
.confirm-card { width: min(420px, 100%); border-radius: 18px; background: #fff; padding: 18px; box-shadow: 0 28px 80px rgba(15,23,42,.34); border: 1px solid #e2e8f0; }
.confirm-card h3 { margin: 0 0 8px; color: #0f172a; font-size: 18px; }
.confirm-card p { margin: 0; color: #475569; line-height: 1.6; }
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
@media (max-width: 900px) {
.manager-layout { grid-template-columns: 1fr; }
.form-two { 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-CfwMEsjZ.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-CCHZcUDy.css">
<script type="module" crossorigin src="/static/assets/index-kYxHYLUo.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-zJhQHWIR.css">
</head>
<body>
<div id="app"></div>