refactor: 优化导演控制台代码结构和用户体验

高优先级优化:
- 合并 REVEAL/COMPLETE 状态的重复代码模板

中优先级优化:
- 添加防抖函数 (debounce/debounceLeading) 防止按钮重复点击
- 投票控制按钮添加 500ms 防抖
- 抽奖控制按钮添加 500ms 防抖
- 新增 Toast 通知系统替代 alert
- 优化 API 错误反馈:loadPrizeConfig、loadParticipants、
  importParticipants、savePrizeConfig 现在显示 Toast 错误提示

低优先级优化:
- 新增实时奖项统计面板,显示各奖项投票 TOP3
- 添加奖项统计面板响应式样式
- 添加 Toast 动画和样式

其他:
- 添加 getAwardDisplayName 辅助函数
- 优化移动端适配
This commit is contained in:
empty
2026-01-29 00:28:19 +08:00
parent 5c5d0ad85c
commit 4a2f1e7c8a

View File

@@ -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;