feat: enhance lottery system with participant import and prize config
- Fix ES module import issue in admin.service.ts (require -> import) - Fix lottery reveal ghosting by hiding name particles on complete - Add participant import from Excel with tag calculation - Add prize configuration service with JSON persistence - Constrain winners overlay to scroll area dimensions - Fix macOS lsof syntax in stop script - Add HorseRace view and renderer (WIP) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAdminStore } from '../stores/admin';
|
||||
import { PRIZE_CONFIG } from '@gala/shared/types';
|
||||
import type { PrizeConfig, LotteryRound } from '@gala/shared/types';
|
||||
|
||||
const router = useRouter();
|
||||
const admin = useAdminStore();
|
||||
@@ -12,9 +13,120 @@ const confirmResetCode = ref('');
|
||||
const showResetModal = ref(false);
|
||||
const resetScope = ref<'all' | 'voting' | 'lottery'>('all');
|
||||
|
||||
// Countdown timer for stop button
|
||||
const countdownDisplay = ref(3);
|
||||
let countdownInterval: ReturnType<typeof setInterval> | null = null;
|
||||
// Prize configuration state
|
||||
const showPrizeConfig = ref(false);
|
||||
const prizeConfigLoading = ref(false);
|
||||
const prizeConfigSaving = ref(false);
|
||||
const editingPrizes = ref<PrizeConfig[]>([]);
|
||||
|
||||
// Load prize configuration from server
|
||||
async function loadPrizeConfig() {
|
||||
prizeConfigLoading.value = true;
|
||||
try {
|
||||
const res = await fetch('/api/admin/prizes');
|
||||
const data = await res.json();
|
||||
if (data.success && data.data?.prizes) {
|
||||
editingPrizes.value = data.data.prizes.map((p: any) => ({ ...p }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load prize config:', e);
|
||||
} finally {
|
||||
prizeConfigLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Save prize configuration to server
|
||||
async function savePrizeConfig() {
|
||||
prizeConfigSaving.value = true;
|
||||
try {
|
||||
const res = await fetch('/api/admin/prizes', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prizes: editingPrizes.value }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showPrizeConfig.value = false;
|
||||
} else {
|
||||
alert('保存失败: ' + (data.error || '未知错误'));
|
||||
}
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + (e as Error).message);
|
||||
} finally {
|
||||
prizeConfigSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Open prize configuration modal
|
||||
function openPrizeConfig() {
|
||||
showPrizeConfig.value = true;
|
||||
loadPrizeConfig();
|
||||
}
|
||||
|
||||
// Participant import state
|
||||
const importFile = ref<File | null>(null);
|
||||
const importing = ref(false);
|
||||
const importResult = ref<{
|
||||
success: boolean;
|
||||
totalCount: number;
|
||||
importedCount: number;
|
||||
tagDistribution: Record<string, number>;
|
||||
errors: string[];
|
||||
} | null>(null);
|
||||
|
||||
// Handle file selection
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files && input.files.length > 0) {
|
||||
importFile.value = input.files[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Import participants from Excel
|
||||
async function importParticipants() {
|
||||
if (!importFile.value) return;
|
||||
|
||||
importing.value = true;
|
||||
importResult.value = null;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', importFile.value);
|
||||
|
||||
const response = await fetch('/api/admin/participants/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
// 确保保留 success 字段,后端返回结构为 { success: true, data: {...} }
|
||||
importResult.value = {
|
||||
success: data.success ?? data.data?.success ?? false,
|
||||
totalCount: data.data?.totalCount ?? data.totalCount ?? 0,
|
||||
importedCount: data.data?.importedCount ?? data.importedCount ?? 0,
|
||||
tagDistribution: data.data?.tagDistribution ?? data.tagDistribution ?? {},
|
||||
errors: data.data?.errors ?? data.errors ?? [],
|
||||
};
|
||||
} catch (error) {
|
||||
importResult.value = {
|
||||
success: false,
|
||||
totalCount: 0,
|
||||
importedCount: 0,
|
||||
tagDistribution: {},
|
||||
errors: [(error as Error).message],
|
||||
};
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Tag display names
|
||||
const tagLabels: Record<string, string> = {
|
||||
'6070': '60/70后',
|
||||
'80': '80后',
|
||||
'90': '90后',
|
||||
'horse': '属马',
|
||||
};
|
||||
|
||||
// Navigation
|
||||
function goBack() {
|
||||
@@ -85,15 +197,6 @@ function startGalaxy() {
|
||||
|
||||
function startStorm() {
|
||||
admin.controlLottery('start_storm');
|
||||
// Start countdown
|
||||
countdownDisplay.value = 3;
|
||||
countdownInterval = setInterval(() => {
|
||||
countdownDisplay.value = Math.max(0, countdownDisplay.value - 1);
|
||||
if (countdownDisplay.value === 0 && countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopAndReveal() {
|
||||
@@ -142,6 +245,11 @@ function playFanfare() {
|
||||
|
||||
// Computed helpers
|
||||
const currentPrizeConfig = computed(() => {
|
||||
// 优先使用从服务器加载的配置
|
||||
if (editingPrizes.value.length > 0) {
|
||||
return editingPrizes.value.find(p => p.round === admin.lotteryRound) || editingPrizes.value[0];
|
||||
}
|
||||
// 降级到硬编码配置
|
||||
return PRIZE_CONFIG.find(p => p.round === admin.lotteryRound) || PRIZE_CONFIG[0];
|
||||
});
|
||||
|
||||
@@ -223,12 +331,8 @@ const awardStats = computed(() => {
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
admin.connect();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
}
|
||||
// 加载服务器奖项配置
|
||||
loadPrizeConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -330,6 +434,37 @@ onUnmounted(() => {
|
||||
<div v-if="currentPrizeConfig.zodiacFilter" class="prize-filter">
|
||||
限定: 属马
|
||||
</div>
|
||||
<button class="prize-config-btn" @click="openPrizeConfig" title="配置奖项">
|
||||
⚙️ 配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Prize Configuration Modal -->
|
||||
<div v-if="showPrizeConfig" class="modal-overlay" @click.self="showPrizeConfig = false">
|
||||
<div class="modal-content prize-config-modal">
|
||||
<div class="modal-header">
|
||||
<h3>奖项配置</h3>
|
||||
<button class="close-btn" @click="showPrizeConfig = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div v-if="prizeConfigLoading" class="loading">加载中...</div>
|
||||
<div v-else class="prize-list">
|
||||
<div v-for="prize in editingPrizes" :key="prize.round" class="prize-edit-item">
|
||||
<div class="prize-round">第 {{ prize.round }} 轮</div>
|
||||
<input v-model="prize.level" placeholder="等级名称" class="prize-input" />
|
||||
<input v-model="prize.name" placeholder="奖品名称" class="prize-input prize-input-wide" />
|
||||
<input v-model.number="prize.winnerCount" type="number" min="1" class="prize-input prize-input-small" />
|
||||
<span class="prize-unit">名</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="ctrl-btn outline" @click="showPrizeConfig = false">取消</button>
|
||||
<button class="ctrl-btn primary" :disabled="prizeConfigSaving" @click="savePrizeConfig">
|
||||
{{ prizeConfigSaving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lottery State Machine Controls -->
|
||||
@@ -362,11 +497,29 @@ onUnmounted(() => {
|
||||
@click="stopAndReveal"
|
||||
>
|
||||
<span v-if="!admin.canStopLottery" class="countdown">
|
||||
{{ countdownDisplay }}s
|
||||
{{ admin.stopButtonCountdown }}s
|
||||
</span>
|
||||
<span v-else>停止抽奖</span>
|
||||
</button>
|
||||
|
||||
<!-- REVEAL State -->
|
||||
<div v-if="admin.lotterySubPhase === 'REVEAL'" class="complete-controls">
|
||||
<button
|
||||
class="ctrl-btn primary"
|
||||
:disabled="isLastRound || admin.pendingAction === 'lottery_next_round'"
|
||||
@click="nextRound"
|
||||
>
|
||||
下一轮
|
||||
</button>
|
||||
<button
|
||||
class="ctrl-btn outline"
|
||||
:disabled="admin.pendingAction === 'lottery_reset'"
|
||||
@click="resetLottery"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- COMPLETE State -->
|
||||
<div v-if="admin.lotterySubPhase === 'COMPLETE'" class="complete-controls">
|
||||
<button
|
||||
@@ -414,6 +567,63 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section: Participant Import -->
|
||||
<section class="control-section import-section">
|
||||
<div class="section-header">
|
||||
<h2>🎯 抽奖名单导入</h2>
|
||||
<span v-if="importResult" class="section-status" :class="{ active: importResult.success }">
|
||||
{{ importResult.success ? `已导入 ${importResult.importedCount} 人` : '导入失败' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="section-body">
|
||||
<div class="import-controls">
|
||||
<label class="file-input-wrapper">
|
||||
<input
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
@change="handleFileSelect"
|
||||
class="file-input"
|
||||
/>
|
||||
<span class="file-input-label">
|
||||
{{ importFile ? importFile.name : '选择 Excel 文件' }}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
class="ctrl-btn primary"
|
||||
:disabled="!importFile || importing"
|
||||
@click="importParticipants"
|
||||
>
|
||||
{{ importing ? '导入中...' : '开始导入' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="import-hint">支持格式:岗位 | 姓名 | 年份</p>
|
||||
|
||||
<!-- Import Result -->
|
||||
<div v-if="importResult" class="import-result" :class="{ error: !importResult.success }">
|
||||
<div v-if="importResult.success" class="result-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">总行数</span>
|
||||
<span class="stat-value">{{ importResult.totalCount }}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">成功导入</span>
|
||||
<span class="stat-value">{{ importResult.importedCount }}</span>
|
||||
</div>
|
||||
<div v-for="(count, tag) in importResult.tagDistribution" :key="tag" class="stat-item">
|
||||
<span class="stat-label">{{ tagLabels[tag] || tag }}</span>
|
||||
<span class="stat-value">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="importResult.errors.length > 0" class="result-errors">
|
||||
<p v-for="(err, i) in importResult.errors.slice(0, 5)" :key="i" class="error-line">{{ err }}</p>
|
||||
<p v-if="importResult.errors.length > 5" class="error-more">...还有 {{ importResult.errors.length - 5 }} 条错误</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section C: Global Controls -->
|
||||
<section class="control-section global-section">
|
||||
<div class="section-header">
|
||||
@@ -1375,4 +1585,169 @@ $admin-danger: #ef4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import Section Styles
|
||||
.import-section {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.import-controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.file-input-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
.file-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-input-label {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background: #252525;
|
||||
border: 1px dashed #444;
|
||||
border-radius: 8px;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #666;
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.import-hint {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.import-result {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border: 1px solid rgba(34, 197, 94, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
|
||||
&.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.result-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
|
||||
.stat-item {
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.result-errors {
|
||||
margin-top: 12px;
|
||||
|
||||
.error-line {
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.error-more {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
// Prize Config Button & Modal
|
||||
.prize-config-btn {
|
||||
margin-top: 12px;
|
||||
padding: 6px 12px;
|
||||
background: #333;
|
||||
border: 1px solid #555;
|
||||
border-radius: 6px;
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #444;
|
||||
border-color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.prize-config-modal {
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.prize-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.prize-edit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #252525;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.prize-round {
|
||||
min-width: 60px;
|
||||
font-weight: bold;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.prize-input {
|
||||
padding: 8px 12px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
}
|
||||
|
||||
.prize-input-wide {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.prize-input-small {
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.prize-unit {
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user