refactor: 优化导演控制台代码结构和用户体验
高优先级优化: - 合并 REVEAL/COMPLETE 状态的重复代码模板 中优先级优化: - 添加防抖函数 (debounce/debounceLeading) 防止按钮重复点击 - 投票控制按钮添加 500ms 防抖 - 抽奖控制按钮添加 500ms 防抖 - 新增 Toast 通知系统替代 alert - 优化 API 错误反馈:loadPrizeConfig、loadParticipants、 importParticipants、savePrizeConfig 现在显示 Toast 错误提示 低优先级优化: - 新增实时奖项统计面板,显示各奖项投票 TOP3 - 添加奖项统计面板响应式样式 - 添加 Toast 动画和样式 其他: - 添加 getAwardDisplayName 辅助函数 - 优化移动端适配
This commit is contained in:
@@ -5,6 +5,34 @@ import { useAdminStore } from '../stores/admin';
|
||||
import { PRIZE_CONFIG } from '@gala/shared/types';
|
||||
import type { PrizeConfig, LotteryRound } from '@gala/shared/types';
|
||||
|
||||
// 简单的防抖函数
|
||||
function debounce<T extends (...args: any[]) => void>(fn: T, delay: number = 300): (...args: Parameters<T>) => void {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
// 带立即执行的防抖(首次点击立即执行,后续防抖)
|
||||
function debounceLeading<T extends (...args: any[]) => void>(fn: T, delay: number = 300): (...args: Parameters<T>) => void {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastExec = 0;
|
||||
return (...args: Parameters<T>) => {
|
||||
const now = Date.now();
|
||||
if (now - lastExec > delay) {
|
||||
lastExec = now;
|
||||
fn(...args);
|
||||
} else {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
lastExec = Date.now();
|
||||
fn(...args);
|
||||
}, delay - (now - lastExec));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const admin = useAdminStore();
|
||||
|
||||
@@ -13,6 +41,23 @@ const confirmResetCode = ref('');
|
||||
const showResetModal = ref(false);
|
||||
const resetScope = ref<'all' | 'voting' | 'lottery'>('all');
|
||||
|
||||
// Toast notification state
|
||||
const toast = ref<{ show: boolean; message: string; type: 'error' | 'success' | 'info' }>({
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'info',
|
||||
});
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Show toast notification
|
||||
function showToast(message: string, type: 'error' | 'success' | 'info' = 'info', duration: number = 3000) {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toast.value = { show: true, message, type };
|
||||
toastTimer = setTimeout(() => {
|
||||
toast.value.show = false;
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// Prize configuration state
|
||||
const showPrizeConfig = ref(false);
|
||||
const prizeConfigLoading = ref(false);
|
||||
@@ -44,7 +89,9 @@ async function loadPrizeConfig() {
|
||||
editingPrizes.value = data.data.prizes.map((p: any) => ({ ...p }));
|
||||
}
|
||||
} catch (e) {
|
||||
const message = (e as Error).message;
|
||||
console.error('Failed to load prize config:', e);
|
||||
showToast(`加载奖项配置失败: ${message}`, 'error');
|
||||
} finally {
|
||||
prizeConfigLoading.value = false;
|
||||
}
|
||||
@@ -65,11 +112,14 @@ async function savePrizeConfig() {
|
||||
}
|
||||
if (data.success) {
|
||||
showPrizeConfig.value = false;
|
||||
showToast('奖项配置保存成功', 'success');
|
||||
} else {
|
||||
alert('保存失败: ' + (data.error || '未知错误'));
|
||||
const message = data.error || '未知错误';
|
||||
showToast(`保存失败: ${message}`, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + (e as Error).message);
|
||||
const message = (e as Error).message;
|
||||
showToast(`保存失败: ${message}`, 'error');
|
||||
} finally {
|
||||
prizeConfigSaving.value = false;
|
||||
}
|
||||
@@ -128,14 +178,22 @@ async function importParticipants() {
|
||||
tagDistribution: data.data?.tagDistribution ?? data.tagDistribution ?? {},
|
||||
errors: data.data?.errors ?? data.errors ?? [],
|
||||
};
|
||||
|
||||
if (importResult.value.success) {
|
||||
showToast(`成功导入 ${importResult.value.importedCount} 名参与者`, 'success');
|
||||
} else if (importResult.value.errors.length > 0) {
|
||||
showToast(`导入失败: ${importResult.value.errors[0]}`, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
importResult.value = {
|
||||
success: false,
|
||||
totalCount: 0,
|
||||
importedCount: 0,
|
||||
tagDistribution: {},
|
||||
errors: [(error as Error).message],
|
||||
errors: [message],
|
||||
};
|
||||
showToast(`导入失败: ${message}`, 'error');
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
@@ -167,7 +225,9 @@ async function loadParticipants() {
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const message = (error as Error).message;
|
||||
console.error('Failed to load participants:', error);
|
||||
showToast(`加载参与者失败: ${message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,22 +241,22 @@ function setPhase(phase: 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS') {
|
||||
admin.setPhase(phase);
|
||||
}
|
||||
|
||||
// Voting control
|
||||
function openVoting() {
|
||||
// Voting control with debounce
|
||||
const openVoting = debounceLeading(() => {
|
||||
admin.controlVoting('open');
|
||||
}
|
||||
}, 500);
|
||||
|
||||
function closeVoting() {
|
||||
const closeVoting = debounceLeading(() => {
|
||||
admin.controlVoting('close');
|
||||
}
|
||||
}, 500);
|
||||
|
||||
function pauseVoting() {
|
||||
const pauseVoting = debounceLeading(() => {
|
||||
admin.controlVoting('pause');
|
||||
}
|
||||
}, 500);
|
||||
|
||||
function resumeVoting() {
|
||||
const resumeVoting = debounceLeading(() => {
|
||||
admin.controlVoting('resume');
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Program voting control
|
||||
function nextProgram() {
|
||||
@@ -233,27 +293,27 @@ function toggleLateCatch() {
|
||||
}
|
||||
}
|
||||
|
||||
// Lottery control
|
||||
function startGalaxy() {
|
||||
// Lottery control with debounce
|
||||
const startGalaxy = debounceLeading(() => {
|
||||
admin.controlLottery('start_galaxy');
|
||||
}
|
||||
}, 500);
|
||||
|
||||
function startStorm() {
|
||||
const startStorm = debounceLeading(() => {
|
||||
admin.controlLottery('start_storm');
|
||||
}
|
||||
}, 500);
|
||||
|
||||
function stopAndReveal() {
|
||||
const stopAndReveal = debounceLeading(() => {
|
||||
if (!admin.canStopLottery) return;
|
||||
admin.controlLottery('stop_reveal');
|
||||
}
|
||||
}, 500);
|
||||
|
||||
function nextRound() {
|
||||
const nextRound = debounceLeading(() => {
|
||||
admin.controlLottery('next_round');
|
||||
}
|
||||
}, 500);
|
||||
|
||||
function resetLottery() {
|
||||
const resetLottery = debounceLeading(() => {
|
||||
admin.controlLottery('reset');
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Emergency reset
|
||||
function openResetModal(scope: 'all' | 'voting' | 'lottery') {
|
||||
@@ -334,6 +394,12 @@ const lotteryPhaseLabel = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get award display name from awards config
|
||||
function getAwardDisplayName(awardId: string): string {
|
||||
const award = admin.awards.find((a: any) => a.id === awardId);
|
||||
return award?.name || awardId;
|
||||
}
|
||||
|
||||
// Compute award statistics grouped by award type
|
||||
const awardStats = computed(() => {
|
||||
const stats: Record<string, Array<{ programName: string; teamName: string; votes: number; programId: string }>> = {};
|
||||
@@ -413,6 +479,17 @@ onMounted(() => {
|
||||
<span class="close">×</span>
|
||||
</div>
|
||||
|
||||
<!-- Toast Notification -->
|
||||
<Transition name="toast">
|
||||
<div v-if="toast.show" :class="['toast-notification', toast.type]" @click="toast.show = false">
|
||||
<span class="toast-icon">
|
||||
{{ toast.type === 'success' ? '✓' : toast.type === 'error' ? '✕' : 'ℹ' }}
|
||||
</span>
|
||||
<span class="toast-message">{{ toast.message }}</span>
|
||||
<span class="toast-close">×</span>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="content">
|
||||
<!-- Section A: Voting Control -->
|
||||
@@ -552,26 +629,8 @@ onMounted(() => {
|
||||
<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">
|
||||
<!-- REVEAL / COMPLETE State -->
|
||||
<div v-if="['REVEAL', 'COMPLETE'].includes(admin.lotterySubPhase)" class="complete-controls">
|
||||
<button
|
||||
class="ctrl-btn primary"
|
||||
:disabled="isLastRound || admin.pendingAction === 'lottery_next_round'"
|
||||
@@ -674,6 +733,34 @@ onMounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section B.5: Award Statistics -->
|
||||
<section class="control-section stats-section">
|
||||
<div class="section-header">
|
||||
<h2>📊 实时奖项统计</h2>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div v-if="Object.keys(awardStats).length === 0" class="no-stats">
|
||||
暂无投票数据
|
||||
</div>
|
||||
<div v-else class="award-stats-grid">
|
||||
<div v-for="(programs, awardId) in awardStats" :key="awardId" class="award-stat-card">
|
||||
<div class="award-stat-header">
|
||||
<span class="award-stat-name">{{ getAwardDisplayName(awardId) }}</span>
|
||||
<span class="award-stat-total">{{ programs.reduce((sum, p) => sum + p.votes, 0) }} 票</span>
|
||||
</div>
|
||||
<div class="award-stat-programs">
|
||||
<div v-for="(program, index) in programs.slice(0, 3)" :key="program.programId" class="award-stat-item">
|
||||
<span class="stat-rank">{{ index + 1 }}</span>
|
||||
<span class="stat-name">{{ program.programName }}</span>
|
||||
<span class="stat-votes">{{ program.votes }}</span>
|
||||
</div>
|
||||
<div v-if="programs.length > 3" class="stat-more">+{{ programs.length - 3 }} 个节目</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section C: Global Controls -->
|
||||
<section class="control-section global-section">
|
||||
<div class="section-header">
|
||||
@@ -937,6 +1024,70 @@ $admin-danger: #ef4444;
|
||||
to { transform: translateX(-50%) translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
// Toast Notification
|
||||
.toast-notification {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
z-index: 300;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
min-width: 280px;
|
||||
max-width: 90vw;
|
||||
|
||||
&.success {
|
||||
background: rgba(34, 197, 94, 0.95);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: rgba(239, 68, 68, 0.95);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.info {
|
||||
background: rgba(59, 130, 246, 0.95);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toast-close {
|
||||
font-size: 20px;
|
||||
opacity: 0.7;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
transform: translateX(-50%) translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// Control Sections
|
||||
.control-section {
|
||||
background: $admin-surface;
|
||||
@@ -1007,6 +1158,106 @@ $admin-danger: #ef4444;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
// Stats Section
|
||||
.stats-section {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.no-stats {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 40px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.award-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.award-stat-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid $admin-border;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.award-stat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-bottom: 1px solid $admin-border;
|
||||
|
||||
.award-stat-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: $color-gold;
|
||||
}
|
||||
|
||||
.award-stat-total {
|
||||
font-size: 12px;
|
||||
color: $admin-success;
|
||||
background: rgba($admin-success, 0.15);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.award-stat-programs {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.award-stat-item {
|
||||
display: grid;
|
||||
grid-template-columns: 24px 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.stat-rank {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba($color-gold, 0.2);
|
||||
color: $color-gold;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-name {
|
||||
color: $admin-text;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.stat-votes {
|
||||
color: $admin-success;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-more {
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
// Stats
|
||||
.stat-row {
|
||||
display: flex;
|
||||
@@ -1495,11 +1746,25 @@ $admin-danger: #ef4444;
|
||||
// Control Sections
|
||||
.control-section {
|
||||
&.lottery-section,
|
||||
&.global-section {
|
||||
&.global-section,
|
||||
&.stats-section {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Stats Section Mobile
|
||||
.award-stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.award-stat-header {
|
||||
padding: 10px 12px;
|
||||
|
||||
.award-stat-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user