Files
company-celebration/packages/client-screen/src/views/AdminControl.vue
2026-02-04 00:33:03 +08:00

2957 lines
68 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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';
import { ADMIN_TOKEN_KEY } from '../router';
// 简单的防抖函数
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();
const adminToken = () => localStorage.getItem(ADMIN_TOKEN_KEY) || '';
function getAdminHeaders(extra?: Record<string, string>) {
return {
'Content-Type': 'application/json',
'x-session-token': adminToken(),
...extra,
};
}
// Local UI state
const confirmResetCode = ref('');
const showResetModal = ref(false);
const resetScope = ref<'all' | 'voting' | 'lottery'>('all');
// Advanced cleanup state
const showAdvancedCleanupModal = ref(false);
const confirmCleanupCode = ref('');
const cleanupLoading = ref(false);
const cleanupOptions = ref({
lottery: { redis: true, mysql: true },
voting: { redis: false, mysql: false },
});
// 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);
}
// Program editing state
const showProgramModal = ref(false);
const editingProgram = ref<any>(null);
const programForm = ref({
id: '',
name: '',
teamName: '',
performer: '',
remark: '',
});
const programSaving = ref(false);
// Prize configuration state
const showPrizeConfig = ref(false);
const prizeConfigLoading = ref(false);
const prizeConfigSaving = ref(false);
const editingPrizes = ref<PrizeConfig[]>([]);
async function readJsonSafe(res: Response): Promise<any> {
const text = await res.text();
if (!text) {
return null;
}
try {
return JSON.parse(text);
} catch (error) {
throw new Error('响应不是有效 JSON');
}
}
// Load prize configuration from server
async function loadPrizeConfig() {
prizeConfigLoading.value = true;
try {
const res = await fetch('/api/admin/prizes', {
headers: getAdminHeaders(),
});
const data = await readJsonSafe(res);
if (!res.ok) {
throw new Error(data?.error || data?.message || `加载奖项配置失败(${res.status})`);
}
if (data.success && data.data?.prizes) {
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;
}
}
// Save prize configuration to server
async function savePrizeConfig() {
prizeConfigSaving.value = true;
try {
const res = await fetch('/api/admin/prizes', {
method: 'PUT',
headers: getAdminHeaders(),
body: JSON.stringify({ prizes: editingPrizes.value }),
});
const data = await readJsonSafe(res);
if (!res.ok) {
throw new Error(data?.error || data?.message || `保存奖项配置失败(${res.status})`);
}
if (data.success) {
showPrizeConfig.value = false;
showToast('奖项配置保存成功', 'success');
} else {
const message = data.error || '未知错误';
showToast(`保存失败: ${message}`, 'error');
}
} catch (e) {
const message = (e as Error).message;
showToast(`保存失败: ${message}`, 'error');
} 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',
headers: { 'x-session-token': adminToken() },
body: formData,
});
const data = await readJsonSafe(response);
if (!response.ok) {
throw new Error(data?.error || data?.message || `导入失败(${response.status})`);
}
// 确保保留 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 ?? [],
};
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: [message],
};
showToast(`导入失败: ${message}`, 'error');
} finally {
importing.value = false;
}
}
// Tag display names
const tagLabels: Record<string, string> = {
'6070': '60/70后',
'80': '80后',
'90': '90后',
'horse': '属马',
};
// Load existing participants from server
async function loadParticipants() {
try {
const response = await fetch('/api/admin/participants', {
headers: getAdminHeaders(),
});
const data = await readJsonSafe(response);
if (!response.ok) {
throw new Error(data?.error || data?.message || `加载参与者失败(${response.status})`);
}
if (data.success && data.data?.count > 0) {
importResult.value = {
success: true,
totalCount: data.data.count,
importedCount: data.data.count,
tagDistribution: data.data.tagDistribution || {},
errors: [],
};
}
} catch (error) {
const message = (error as Error).message;
console.error('Failed to load participants:', error);
showToast(`加载参与者失败: ${message}`, 'error');
}
}
// Program management functions
function openProgramEdit(program: any) {
editingProgram.value = program;
programForm.value = {
id: program.id,
name: program.name || '',
teamName: program.teamName || '',
performer: program.performer || '',
remark: program.remark || '',
};
showProgramModal.value = true;
}
function openAddProgram() {
editingProgram.value = null;
programForm.value = {
id: '',
name: '',
teamName: '',
performer: '',
remark: '',
};
showProgramModal.value = true;
}
function closeProgramModal() {
showProgramModal.value = false;
editingProgram.value = null;
}
async function saveProgramsToServer(programs: any[]) {
const res = await fetch('/api/admin/programs', {
method: 'PUT',
headers: getAdminHeaders(),
body: JSON.stringify({ programs }),
});
const data = await readJsonSafe(res);
if (!res.ok) {
throw new Error(data?.error || data?.message || `保存节目配置失败(${res.status})`);
}
return data;
}
async function saveProgram() {
programSaving.value = true;
try {
const programs = [...admin.programs];
if (editingProgram.value) {
// Edit existing program
const index = programs.findIndex(p => p.id === editingProgram.value.id);
if (index !== -1) {
programs[index] = {
...programs[index],
name: programForm.value.name,
teamName: programForm.value.teamName,
performer: programForm.value.performer,
remark: programForm.value.remark,
};
}
} else {
// Add new program
const newId = `program_${Date.now()}`;
programs.push({
id: newId,
name: programForm.value.name,
teamName: programForm.value.teamName,
performer: programForm.value.performer,
remark: programForm.value.remark,
status: 'pending',
order: programs.length + 1,
votes: 0,
stamps: [],
});
}
await saveProgramsToServer(programs);
showToast(editingProgram.value ? '节目已更新' : '节目已添加', 'success');
closeProgramModal();
} catch (e) {
const message = (e as Error).message;
showToast(`保存失败: ${message}`, 'error');
} finally {
programSaving.value = false;
}
}
async function deleteProgram(programId: string) {
if (!confirm('确定要删除这个节目吗?')) return;
try {
const programs = admin.programs.filter(p => p.id !== programId);
await saveProgramsToServer(programs);
showToast('节目已删除', 'success');
} catch (e) {
const message = (e as Error).message;
showToast(`删除失败: ${message}`, 'error');
}
}
async function moveProgram(programId: string, direction: 'up' | 'down') {
const programs = [...admin.programs];
const index = programs.findIndex(p => p.id === programId);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= programs.length) return;
// Swap positions
[programs[index], programs[newIndex]] = [programs[newIndex], programs[index]];
// Update order field
programs.forEach((p, i) => {
p.order = i + 1;
});
try {
await saveProgramsToServer(programs);
showToast('顺序已调整', 'success');
} catch (e) {
const message = (e as Error).message;
showToast(`调整失败: ${message}`, 'error');
}
}
// Logout and go to login page
function handleLogout() {
localStorage.removeItem(ADMIN_TOKEN_KEY);
router.push('/admin/login');
}
// Phase control
function setPhase(phase: 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS' | 'LOTTERY_RESULTS') {
admin.setPhase(phase);
}
// Voting control with debounce
const openVoting = debounceLeading(() => {
admin.controlVoting('open');
}, 500);
const closeVoting = debounceLeading(() => {
admin.controlVoting('close');
}, 500);
const pauseVoting = debounceLeading(() => {
admin.controlVoting('pause');
}, 500);
const resumeVoting = debounceLeading(() => {
admin.controlVoting('resume');
}, 500);
// Program voting control
function nextProgram() {
const socket = admin.getSocket();
if (socket) {
socket.emit('admin:next_program' as any, {}, (response: any) => {
if (!response.success) {
console.error('Failed to move to next program:', response.message);
}
});
}
}
function startProgramVoting(programId: string) {
const socket = admin.getSocket();
if (socket) {
socket.emit('admin:start_program' as any, { programId }, (response: any) => {
if (!response.success) {
console.error('Failed to start program voting:', response.message);
}
});
}
}
function toggleLateCatch() {
const socket = admin.getSocket();
if (socket) {
const newValue = !admin.allowLateCatch;
socket.emit('admin:toggle_late_catch' as any, { enabled: newValue }, (response: any) => {
if (!response.success) {
console.error('Failed to toggle late catch:', response.message);
}
});
}
}
// Lottery control with debounce
const startGalaxy = debounceLeading(() => {
admin.controlLottery('start_galaxy');
}, 500);
const startStorm = debounceLeading(() => {
admin.controlLottery('start_storm');
}, 500);
const stopAndReveal = debounceLeading(() => {
if (!admin.canStopLottery) return;
admin.controlLottery('stop_reveal');
}, 500);
const nextRound = debounceLeading(() => {
admin.controlLottery('next_round');
}, 500);
const resetLottery = debounceLeading(() => {
admin.controlLottery('reset');
}, 500);
// Redraw current round
const showRedrawConfirm = ref(false);
async function redrawCurrentRound() {
try {
const res = await fetch('/api/admin/lottery/redraw', {
method: 'POST',
headers: getAdminHeaders(),
});
const data = await res.json();
if (data.success) {
showToast(data.message || '本轮已重置,可重新抽取', 'success');
} else {
showToast(data.error || '重抽失败', 'error');
}
} catch (e) {
showToast('网络错误', 'error');
} finally {
showRedrawConfirm.value = false;
}
}
// Emergency reset
function openResetModal(scope: 'all' | 'voting' | 'lottery') {
resetScope.value = scope;
confirmResetCode.value = '';
showResetModal.value = true;
}
function confirmReset() {
admin.emergencyReset(resetScope.value, confirmResetCode.value);
showResetModal.value = false;
confirmResetCode.value = '';
}
function cancelReset() {
showResetModal.value = false;
confirmResetCode.value = '';
}
// Advanced cleanup
function openAdvancedCleanupModal() {
confirmCleanupCode.value = '';
cleanupOptions.value = {
lottery: { redis: true, mysql: true },
voting: { redis: false, mysql: false },
};
showAdvancedCleanupModal.value = true;
}
function cancelAdvancedCleanup() {
showAdvancedCleanupModal.value = false;
confirmCleanupCode.value = '';
}
async function confirmAdvancedCleanup() {
if (confirmCleanupCode.value !== 'ADVANCED_CLEANUP') return;
cleanupLoading.value = true;
try {
const res = await fetch('/api/admin/cleanup', {
method: 'POST',
headers: getAdminHeaders(),
body: JSON.stringify({
lottery: cleanupOptions.value.lottery,
voting: cleanupOptions.value.voting,
confirmCode: confirmCleanupCode.value,
}),
});
const data = await res.json();
if (data.success) {
const results = data.data;
let message = '清理完成:';
if (results.lottery?.mysql) message += `抽奖数据 ${results.lottery.dbCount} 条;`;
if (results.voting?.mysql) message += `投票数据 ${results.voting.dbCount} 条;`;
showToast(message, 'success');
} else {
showToast(data.error || '清理失败', 'error');
}
} catch (e) {
showToast('网络错误', 'error');
} finally {
cleanupLoading.value = false;
showAdvancedCleanupModal.value = false;
confirmCleanupCode.value = '';
}
}
// Music control
function toggleMusic() {
if (admin.musicPlaying) {
admin.controlMusic('stop');
} else {
admin.controlMusic('play', 'bgm');
}
}
function playFanfare() {
admin.controlMusic('play', 'fanfare');
}
function playAward() {
admin.controlMusic('play', 'award');
}
// QR Code display control
function showEntryQR() {
const socket = admin.getSocket();
if (socket) {
socket.emit('display:show_entry_qr' as any, {});
}
}
function hideQR() {
const socket = admin.getSocket();
if (socket) {
socket.emit('display:hide_qr' as any, {});
}
}
// 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];
});
const isLastRound = computed(() => admin.lotteryRound >= 4);
const phaseLabel = computed(() => {
switch (admin.systemPhase) {
case 'IDLE': return '待机';
case 'VOTING': return '投票中';
case 'LOTTERY': return '抽奖中';
case 'RESULTS': return '结果展示';
case 'LOTTERY_RESULTS': return '抽奖结果';
default: return '未知';
}
});
const lotteryPhaseLabel = computed(() => {
switch (admin.lotterySubPhase) {
case 'IDLE': return '准备就绪';
case 'GALAXY': return '展示参与者';
case 'STORM': return '滚动中...';
case 'REVEAL': return '揭晓中';
case 'COMPLETE': return '已完成';
default: return '未知';
}
});
// 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 }>> = {};
// Initialize all award types from admin.awards
const awards = admin.awards || [];
awards.forEach((award: any) => {
stats[award.id] = [];
});
// Aggregate stamps by award type
admin.programs.forEach(program => {
if (program.stamps && program.stamps.length > 0) {
// Count stamps by type for this program
const typeCount: Record<string, number> = {};
program.stamps.forEach((stamp: any) => {
const type = stamp.ticketType || 'unknown';
typeCount[type] = (typeCount[type] || 0) + 1;
});
// Add to stats
Object.entries(typeCount).forEach(([type, count]) => {
if (stats[type]) {
stats[type].push({
programId: program.id,
programName: program.name,
teamName: program.teamName,
votes: count,
});
}
});
}
});
// Sort each award type by votes descending
Object.keys(stats).forEach(type => {
stats[type].sort((a, b) => b.votes - a.votes);
});
return stats;
});
// Lifecycle
onMounted(() => {
admin.connect();
// 加载服务器奖项配置
loadPrizeConfig();
// 加载已导入的参与者数据
loadParticipants();
});
</script>
<template>
<div class="admin-control">
<!-- Header -->
<header class="header">
<button class="back-btn" @click="handleLogout"> 退出</button>
<h1 class="title">管理控制台</h1>
<div class="status-bar">
<span class="status-item">
<span class="dot" :class="admin.isConnected ? 'online' : 'offline'"></span>
{{ admin.isConnected ? '已连接' : '未连接' }}
</span>
<span class="status-item">
<span class="icon">👥</span>
{{ admin.onlineUsers }} 人在线
</span>
<span class="status-item phase-badge" :class="admin.systemPhase.toLowerCase()">
{{ phaseLabel }}
</span>
</div>
</header>
<!-- Error Toast -->
<div v-if="admin.lastError" class="error-toast" @click="admin.clearError">
{{ admin.lastError }}
<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 -->
<section class="control-section">
<div class="section-header">
<h2>投票控制</h2>
<span class="section-status" :class="{ active: admin.votingOpen }">
{{ admin.votingOpen ? (admin.votingPaused ? '已暂停' : '进行中') : '已关闭' }}
</span>
</div>
<div class="section-body">
<div class="stat-row">
<div class="stat-item">
<span class="stat-label">总投票数</span>
<span class="stat-value">{{ admin.totalVotes }}</span>
</div>
</div>
<div class="button-group">
<button
class="ctrl-btn primary"
:disabled="admin.votingOpen || admin.votingPaused || admin.pendingAction === 'voting_open'"
@click="openVoting"
>
开启投票
</button>
<button
class="ctrl-btn warning"
:disabled="!admin.votingOpen || admin.pendingAction === 'voting_pause'"
@click="pauseVoting"
>
暂停
</button>
<button
class="ctrl-btn success"
:disabled="!admin.votingPaused || admin.pendingAction === 'voting_resume'"
@click="resumeVoting"
>
恢复
</button>
<button
class="ctrl-btn danger"
:disabled="(!admin.votingOpen && !admin.votingPaused) || admin.pendingAction === 'voting_close'"
@click="closeVoting"
>
关闭投票
</button>
</div>
</div>
</section>
<!-- Section B: Lottery Controller -->
<section class="control-section lottery-section">
<div class="section-header">
<h2>抽奖控制</h2>
<div class="round-indicator">
<span class="round-badge"> {{ admin.lotteryRound }} </span>
<span class="phase-badge">{{ lotteryPhaseLabel }}</span>
</div>
</div>
<div class="section-body">
<!-- Prize Info -->
<div class="prize-info">
<div class="prize-level">{{ currentPrizeConfig.level }}</div>
<div class="prize-name">{{ currentPrizeConfig.name }}</div>
<div class="prize-count">× {{ currentPrizeConfig.winnerCount }} </div>
<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 -->
<div class="lottery-controls">
<!-- IDLE State -->
<button
v-if="admin.lotterySubPhase === 'IDLE'"
class="ctrl-btn primary large"
:disabled="admin.pendingAction === 'lottery_start_galaxy'"
@click="startGalaxy"
>
开始展示
</button>
<!-- GALAXY State -->
<button
v-if="admin.lotterySubPhase === 'GALAXY'"
class="ctrl-btn warning large"
:disabled="admin.pendingAction === 'lottery_start_storm'"
@click="startStorm"
>
开始滚动
</button>
<!-- STORM State -->
<button
v-if="admin.lotterySubPhase === 'STORM'"
class="ctrl-btn danger large"
:disabled="!admin.canStopLottery"
@click="stopAndReveal"
>
<span v-if="!admin.canStopLottery" class="countdown">
{{ admin.stopButtonCountdown }}s
</span>
<span v-else>停止抽奖</span>
</button>
<!-- 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'"
@click="nextRound"
>
下一轮
</button>
<button
class="ctrl-btn outline"
:disabled="admin.pendingAction === 'lottery_reset'"
@click="resetLottery"
>
重置本轮
</button>
<button
class="ctrl-btn warning"
@click="showRedrawConfirm = true"
>
重抽本轮
</button>
</div>
</div>
<!-- Winners Display -->
<div v-if="admin.currentWinners.length > 0" class="winners-display">
<h4>本轮中奖者</h4>
<div class="winners-list">
<div v-for="winner in admin.currentWinners" :key="winner.id" class="winner-item">
<span class="winner-name">{{ winner.name }}</span>
<span class="winner-dept">{{ winner.department }}</span>
</div>
</div>
</div>
<!-- Round Progress -->
<div class="round-progress">
<div
v-for="round in 4"
:key="round"
class="round-dot"
:class="{
active: round === admin.lotteryRound,
completed: round < admin.lotteryRound
}"
>
{{ round }}
</div>
</div>
</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 B.5: Award Statistics (Collapsible) -->
<details class="control-section stats-section collapsible-section">
<summary class="section-header">
<h2>📊 实时奖项统计</h2>
</summary>
<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>
</details>
<!-- Section: Program Configuration -->
<details class="control-section program-section collapsible-section">
<summary class="section-header">
<h2>🎬 节目配置</h2>
<button class="add-program-btn" @click.stop="openAddProgram">+ 添加节目</button>
</summary>
<div class="section-body">
<div v-if="admin.programs.length === 0" class="no-stats">
暂无节目数据
</div>
<div v-else class="program-config-list">
<div v-for="(program, index) in admin.programs" :key="program.id" class="program-config-item">
<span class="program-order">{{ index + 1 }}</span>
<span class="program-name">{{ program.name }}</span>
<span class="program-team">{{ program.teamName }}</span>
<span class="program-status" :class="program.status">
{{ program.status === 'current' ? '进行中' : program.status === 'completed' ? '已完成' : '待演出' }}
</span>
<div class="program-actions">
<button
class="action-btn"
:disabled="index === 0"
@click="moveProgram(program.id, 'up')"
title="上移"
></button>
<button
class="action-btn"
:disabled="index === admin.programs.length - 1"
@click="moveProgram(program.id, 'down')"
title="下移"
></button>
<button
class="action-btn edit"
@click="openProgramEdit(program)"
title="编辑"
></button>
<button
class="action-btn delete"
@click="deleteProgram(program.id)"
title="删除"
>🗑</button>
</div>
</div>
</div>
</div>
</details>
<!-- Section C: Global Controls -->
<section class="control-section global-section">
<div class="section-header">
<h2>全局控制</h2>
</div>
<div class="section-body">
<!-- Display Mode -->
<div class="control-group">
<h4>显示模式</h4>
<div class="button-group">
<button
class="ctrl-btn"
:class="{ active: admin.systemPhase === 'IDLE' }"
@click="setPhase('IDLE')"
>
待机画面
</button>
<button
class="ctrl-btn"
:class="{ active: admin.systemPhase === 'VOTING' }"
@click="setPhase('VOTING')"
>
投票画面
</button>
<button
class="ctrl-btn"
:class="{ active: admin.systemPhase === 'LOTTERY' }"
@click="setPhase('LOTTERY')"
>
抽奖画面
</button>
<button
class="ctrl-btn"
:class="{ active: admin.systemPhase === 'RESULTS' }"
@click="setPhase('RESULTS')"
>
投票结果
</button>
<button
class="ctrl-btn"
:class="{ active: admin.systemPhase === 'LOTTERY_RESULTS' }"
@click="setPhase('LOTTERY_RESULTS')"
>
抽奖结果
</button>
</div>
</div>
<!-- QR Code Display Control -->
<div class="control-group">
<h4>大屏二维码</h4>
<div class="button-group">
<button class="ctrl-btn primary" @click="showEntryQR">
显示入场二维码
</button>
<button class="ctrl-btn outline" @click="hideQR">
隐藏二维码
</button>
</div>
</div>
<!-- Music Control -->
<div class="control-group">
<h4>音乐控制</h4>
<div class="button-group">
<button
class="ctrl-btn"
:class="{ active: admin.musicPlaying }"
@click="toggleMusic"
>
{{ admin.musicPlaying ? '停止音乐' : '播放BGM' }}
</button>
<button class="ctrl-btn" @click="playFanfare">
播放礼花音效
</button>
<button class="ctrl-btn" @click="playAward">
播放颁奖音效
</button>
</div>
</div>
<!-- Developer Options (Collapsible) -->
<details class="control-group dev-options">
<summary><h4>开发者选项</h4></summary>
<div class="button-group">
<button class="ctrl-btn warning-outline" @click="openAdvancedCleanupModal">
高级数据清理
</button>
</div>
</details>
<!-- Emergency Reset -->
<div class="control-group danger-zone">
<h4>数据管理</h4>
<div class="button-group">
<button class="ctrl-btn danger-outline" @click="openResetModal('voting')">
清空投票数据
</button>
<button class="ctrl-btn danger-outline" @click="openResetModal('lottery')">
清空抽奖数据
</button>
<button class="ctrl-btn danger" @click="openResetModal('all')">
全部重置
</button>
</div>
</div>
</div>
</section>
</main>
<!-- Reset Confirmation Modal -->
<div v-if="showResetModal" class="modal-overlay" @click.self="cancelReset">
<div class="modal">
<h3>确认操作</h3>
<p>
您即将清空
<strong>{{ resetScope === 'all' ? '所有数据' : resetScope === 'voting' ? '投票数据' : '抽奖数据' }}</strong>
</p>
<p class="warning-text">此操作将清除 Redis 缓存和数据库记录不可撤销</p>
<div class="modal-input">
<label>请输入 <code>RESET</code> 确认:</label>
<input
v-model="confirmResetCode"
type="text"
placeholder="RESET"
@keyup.enter="confirmReset"
/>
</div>
<div class="modal-actions">
<button class="ctrl-btn outline" @click="cancelReset">取消</button>
<button
class="ctrl-btn danger"
:disabled="confirmResetCode !== 'RESET'"
@click="confirmReset"
>
确认重置
</button>
</div>
</div>
</div>
<!-- Redraw Confirmation Modal -->
<div v-if="showRedrawConfirm" class="modal-overlay" @click.self="showRedrawConfirm = false">
<div class="modal">
<h3>确认重抽本轮</h3>
<p>您即将清除<strong>{{ admin.lotteryRound }}</strong>的中奖者记录</p>
<p class="warning-text">清除后可重新抽取本轮此操作不可撤销</p>
<div class="modal-actions">
<button class="ctrl-btn outline" @click="showRedrawConfirm = false">取消</button>
<button class="ctrl-btn warning" @click="redrawCurrentRound">
确认重抽
</button>
</div>
</div>
</div>
<!-- Advanced Cleanup Modal -->
<div v-if="showAdvancedCleanupModal" class="modal-overlay" @click.self="cancelAdvancedCleanup">
<div class="modal cleanup-modal">
<h3>高级数据清理</h3>
<p>选择要清理的数据类型和存储层</p>
<div class="cleanup-sections">
<!-- Lottery Cleanup Options -->
<div class="cleanup-section">
<h4>🎁 抽奖数据</h4>
<div class="cleanup-options">
<label class="checkbox-label">
<input type="checkbox" v-model="cleanupOptions.lottery.redis" />
<span>Redis 缓存</span>
<small>(中奖者集合抽奖历史)</small>
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="cleanupOptions.lottery.mysql" />
<span>MySQL 数据库</span>
<small>(中奖记录抽奖会话)</small>
</label>
</div>
</div>
<!-- Voting Cleanup Options -->
<div class="cleanup-section">
<h4>🗳 投票数据</h4>
<div class="cleanup-options">
<label class="checkbox-label">
<input type="checkbox" v-model="cleanupOptions.voting.redis" />
<span>Redis 缓存</span>
<small>(实时票数排行榜)</small>
</label>
<label class="checkbox-label">
<input type="checkbox" v-model="cleanupOptions.voting.mysql" />
<span>MySQL 数据库</span>
<small>(投票记录票数统计)</small>
</label>
</div>
</div>
</div>
<p class="warning-text"> 此操作不可撤销系统状态将保持不变</p>
<div class="modal-input">
<label>请输入 <code>ADVANCED_CLEANUP</code> 确认:</label>
<input
v-model="confirmCleanupCode"
type="text"
placeholder="ADVANCED_CLEANUP"
@keyup.enter="confirmAdvancedCleanup"
/>
</div>
<div class="modal-actions">
<button class="ctrl-btn outline" @click="cancelAdvancedCleanup">取消</button>
<button
class="ctrl-btn warning"
:disabled="confirmCleanupCode !== 'ADVANCED_CLEANUP' || cleanupLoading"
@click="confirmAdvancedCleanup"
>
{{ cleanupLoading ? '清理中...' : '确认清理' }}
</button>
</div>
</div>
</div>
<!-- Program Edit Modal -->
<div v-if="showProgramModal" class="modal-overlay" @click.self="closeProgramModal">
<div class="modal program-modal">
<div class="modal-header">
<h3>{{ editingProgram ? '编辑节目' : '添加节目' }}</h3>
<button class="close-btn" @click="closeProgramModal">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>节目名称</label>
<input v-model="programForm.name" type="text" placeholder="请输入节目名称" />
</div>
<div class="form-group">
<label>团队名称</label>
<input v-model="programForm.teamName" type="text" placeholder="请输入团队名称" />
</div>
<div class="form-group">
<label>表演者</label>
<input v-model="programForm.performer" type="text" placeholder="请输入表演者" />
</div>
<div class="form-group">
<label>备注</label>
<input v-model="programForm.remark" type="text" placeholder="请输入备注(可选)" />
</div>
</div>
<div class="modal-actions">
<button class="ctrl-btn outline" @click="closeProgramModal">取消</button>
<button
class="ctrl-btn primary"
:disabled="!programForm.name || programSaving"
@click="saveProgram"
>
{{ programSaving ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '../assets/styles/variables.scss' as *;
// Industrial color palette
$admin-bg: #0d0d0d;
$admin-surface: #1a1a1a;
$admin-border: #333;
$admin-text: #e0e0e0;
$admin-text-muted: #888;
$admin-primary: #3b82f6;
$admin-success: #22c55e;
$admin-warning: #f59e0b;
$admin-danger: #ef4444;
.admin-control {
width: 100%;
height: 100%;
background: $admin-bg;
color: $admin-text;
overflow-y: auto;
}
// Header
.header {
display: flex;
align-items: center;
gap: 20px;
padding: 16px 24px;
background: $admin-surface;
border-bottom: 1px solid $admin-border;
position: sticky;
top: 0;
z-index: 100;
.back-btn {
background: transparent;
border: 1px solid $admin-border;
color: $admin-text-muted;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: $admin-text;
color: $admin-text;
}
}
.title {
font-size: 20px;
font-weight: 600;
flex: 1;
}
.status-bar {
display: flex;
align-items: center;
gap: 20px;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: $admin-text-muted;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
&.online {
background: $admin-success;
box-shadow: 0 0 8px rgba($admin-success, 0.5);
}
&.offline {
background: #666;
}
}
.icon {
font-size: 16px;
}
&.phase-badge {
padding: 4px 12px;
border-radius: 4px;
font-weight: 500;
text-transform: uppercase;
font-size: 12px;
&.idle { background: #333; color: #888; }
&.voting { background: rgba($admin-primary, 0.2); color: $admin-primary; }
&.lottery { background: rgba($admin-warning, 0.2); color: $admin-warning; }
&.results { background: rgba($admin-success, 0.2); color: $admin-success; }
}
}
}
.content {
padding: 24px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
max-width: 1400px;
margin: 0 auto;
}
// Error Toast
.error-toast {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
background: $admin-danger;
color: white;
padding: 12px 24px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
z-index: 200;
animation: slideDown 0.3s ease;
.close {
font-size: 18px;
opacity: 0.7;
}
}
@keyframes slideDown {
from { transform: translateX(-50%) translateY(-20px); opacity: 0; }
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;
border: 1px solid $admin-border;
border-radius: 12px;
overflow: hidden;
&.lottery-section {
grid-column: span 2;
}
&.global-section {
grid-column: span 2;
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid $admin-border;
h2 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
.section-status {
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
background: #333;
color: #888;
&.active {
background: rgba($admin-success, 0.2);
color: $admin-success;
}
}
.round-indicator {
display: flex;
gap: 12px;
}
.round-badge {
padding: 4px 12px;
background: rgba($admin-warning, 0.2);
color: $admin-warning;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.phase-badge {
padding: 4px 12px;
background: #333;
color: #888;
border-radius: 4px;
font-size: 12px;
}
}
.section-body {
padding: 20px;
}
// Stats Section
.stats-section {
grid-column: span 2;
}
// Collapsible Section
.collapsible-section {
&.control-section {
summary.section-header {
cursor: pointer;
list-style: none;
user-select: none;
&::-webkit-details-marker {
display: none;
}
h2::after {
content: ' ▶';
font-size: 0.7em;
opacity: 0.6;
}
}
&[open] summary.section-header h2::after {
content: ' ▼';
}
}
}
.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;
gap: 24px;
margin-bottom: 20px;
}
.stat-item {
display: flex;
flex-direction: column;
gap: 4px;
.stat-label {
font-size: 12px;
color: $admin-text-muted;
text-transform: uppercase;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: $admin-text;
font-variant-numeric: tabular-nums;
}
}
// Button Groups
.button-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.ctrl-btn {
padding: 10px 20px;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
border: 1px solid $admin-border;
background: #252525;
color: $admin-text;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: #333;
border-color: #444;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&.active {
background: rgba($admin-primary, 0.2);
border-color: $admin-primary;
color: $admin-primary;
}
&.primary {
background: $admin-primary;
border-color: $admin-primary;
color: white;
&:hover:not(:disabled) {
background: lighten($admin-primary, 5%);
}
}
&.success {
background: $admin-success;
border-color: $admin-success;
color: white;
&:hover:not(:disabled) {
background: lighten($admin-success, 5%);
}
}
&.warning {
background: $admin-warning;
border-color: $admin-warning;
color: #000;
&:hover:not(:disabled) {
background: lighten($admin-warning, 5%);
}
}
&.danger {
background: $admin-danger;
border-color: $admin-danger;
color: white;
&:hover:not(:disabled) {
background: lighten($admin-danger, 5%);
}
}
&.danger-outline {
background: transparent;
border-color: $admin-danger;
color: $admin-danger;
&:hover:not(:disabled) {
background: rgba($admin-danger, 0.1);
}
}
&.warning-outline {
background: transparent;
border-color: $admin-warning;
color: $admin-warning;
&:hover:not(:disabled) {
background: rgba($admin-warning, 0.1);
}
}
&.outline {
background: transparent;
border-color: $admin-border;
color: $admin-text-muted;
&:hover:not(:disabled) {
border-color: $admin-text;
color: $admin-text;
}
}
&.large {
padding: 16px 40px;
font-size: 18px;
}
}
// Prize Info
.prize-info {
display: flex;
align-items: center;
gap: 20px;
padding: 16px 20px;
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
margin-bottom: 20px;
.prize-level {
font-size: 20px;
font-weight: 700;
color: $color-gold;
}
.prize-name {
font-size: 16px;
color: $admin-text;
}
.prize-count {
font-size: 14px;
color: $admin-text-muted;
}
.prize-filter {
padding: 4px 12px;
background: rgba($admin-warning, 0.2);
color: $admin-warning;
border-radius: 4px;
font-size: 12px;
}
}
// Lottery Controls
.lottery-controls {
display: flex;
justify-content: center;
padding: 20px 0;
.countdown {
font-variant-numeric: tabular-nums;
}
}
.complete-controls {
display: flex;
gap: 16px;
}
// Winners Display
.winners-display {
margin-top: 20px;
padding: 16px;
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
h4 {
font-size: 14px;
color: $admin-text-muted;
margin: 0 0 12px 0;
}
}
.winners-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.winner-item {
display: flex;
flex-direction: column;
padding: 8px 16px;
background: rgba($color-gold, 0.1);
border: 1px solid rgba($color-gold, 0.3);
border-radius: 8px;
.winner-name {
font-size: 16px;
font-weight: 600;
color: $color-gold;
}
.winner-dept {
font-size: 12px;
color: $admin-text-muted;
}
}
// Round Progress
.round-progress {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid $admin-border;
}
.round-dot {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 14px;
font-weight: 600;
background: #252525;
border: 2px solid $admin-border;
color: $admin-text-muted;
&.active {
background: rgba($admin-warning, 0.2);
border-color: $admin-warning;
color: $admin-warning;
}
&.completed {
background: rgba($admin-success, 0.2);
border-color: $admin-success;
color: $admin-success;
}
}
// Control Groups
.control-group {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
h4 {
font-size: 12px;
color: $admin-text-muted;
text-transform: uppercase;
margin: 0 0 12px 0;
}
&.danger-zone {
padding-top: 20px;
border-top: 1px solid rgba($admin-danger, 0.3);
h4 {
color: $admin-danger;
}
}
&.warning-zone {
padding-top: 20px;
border-top: 1px solid rgba($admin-warning, 0.3);
h4 {
color: $admin-warning;
}
}
&.dev-options {
padding-top: 20px;
border-top: 1px solid rgba($admin-warning, 0.3);
summary {
cursor: pointer;
list-style: none;
&::-webkit-details-marker {
display: none;
}
h4 {
display: inline;
color: $admin-warning;
&::before {
content: '▶ ';
font-size: 0.8em;
}
}
}
&[open] summary h4::before {
content: '▼ ';
}
.button-group {
margin-top: 12px;
}
}
}
// Modal
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: $admin-surface;
border: 1px solid $admin-border;
border-radius: 16px;
padding: 24px;
width: 400px;
max-width: 90vw;
h3 {
font-size: 18px;
margin: 0 0 16px 0;
}
p {
color: $admin-text-muted;
margin: 0 0 12px 0;
strong {
color: $admin-text;
}
}
.warning-text {
color: $admin-danger;
font-weight: 500;
}
}
.modal-input {
margin: 20px 0;
label {
display: block;
font-size: 14px;
color: $admin-text-muted;
margin-bottom: 8px;
code {
background: #333;
padding: 2px 6px;
border-radius: 4px;
color: $admin-danger;
}
}
input {
width: 100%;
padding: 12px;
background: #252525;
border: 1px solid $admin-border;
border-radius: 8px;
color: $admin-text;
font-size: 16px;
&:focus {
outline: none;
border-color: $admin-primary;
}
}
}
// Advanced Cleanup Modal Styles
.cleanup-modal {
width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.cleanup-sections {
display: flex;
flex-direction: column;
gap: 20px;
margin: 20px 0;
}
.cleanup-section {
background: rgba(255, 255, 255, 0.02);
border: 1px solid $admin-border;
border-radius: 12px;
padding: 16px;
h4 {
font-size: 14px;
color: $admin-text;
margin: 0 0 12px 0;
display: flex;
align-items: center;
gap: 8px;
}
}
.cleanup-options {
display: flex;
flex-direction: column;
gap: 10px;
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.05);
}
input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: $admin-warning;
}
span {
font-size: 14px;
color: $admin-text;
font-weight: 500;
}
small {
margin-left: auto;
font-size: 12px;
color: $admin-text-muted;
}
}
}
// Award Statistics Styles
.award-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.award-group {
background: rgba(255, 255, 255, 0.02);
border: 1px solid $admin-border;
border-radius: 8px;
overflow: hidden;
}
.award-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.03);
border-bottom: 1px solid $admin-border;
.award-name {
font-size: 13px;
font-weight: 600;
color: $color-gold;
}
.award-total {
font-size: 12px;
color: $admin-text-muted;
background: rgba($admin-primary, 0.15);
padding: 2px 8px;
border-radius: 4px;
}
}
.award-programs {
padding: 8px;
max-height: 150px;
overflow-y: auto;
}
.program-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 8px;
align-items: center;
padding: 6px 8px;
border-radius: 4px;
font-size: 12px;
&:hover {
background: rgba(255, 255, 255, 0.03);
}
.program-name {
color: $admin-text;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.program-team {
color: $admin-text-muted;
font-size: 11px;
white-space: nowrap;
}
.program-votes {
color: $admin-success;
font-weight: 600;
min-width: 24px;
text-align: right;
}
}
.no-votes {
padding: 12px;
text-align: center;
color: $admin-text-muted;
font-size: 12px;
font-style: italic;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
// ==========================================
// Mobile Responsive Styles
// ==========================================
@media (max-width: 768px) {
// Header
.header {
flex-wrap: wrap;
gap: 12px;
padding: 12px 16px;
.title {
font-size: 18px;
order: -1;
width: 100%;
text-align: center;
}
.back-btn {
padding: 6px 12px;
font-size: 13px;
}
.status-bar {
flex-wrap: wrap;
justify-content: center;
gap: 12px;
width: 100%;
}
.status-item {
font-size: 13px;
}
}
// Content Grid
.content {
grid-template-columns: 1fr;
padding: 16px;
gap: 16px;
}
// Control Sections
.control-section {
&.lottery-section,
&.global-section,
&.stats-section,
&.program-section {
grid-column: span 1;
}
}
// Program Section Mobile
.program-config-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 12px;
.program-team {
display: none;
}
.program-status {
font-size: 11px;
padding: 3px 8px;
margin-left: auto;
}
}
.program-order {
width: 28px;
height: 28px;
font-size: 12px;
flex-shrink: 0;
}
.program-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.program-actions {
width: 100%;
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
// 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;
padding: 12px 16px;
h2 {
font-size: 15px;
}
.round-indicator {
gap: 8px;
}
}
.section-body {
padding: 16px;
}
// Stats
.stat-row {
gap: 16px;
margin-bottom: 16px;
}
.stat-item {
.stat-value {
font-size: 24px;
}
}
// Buttons
.button-group {
gap: 8px;
}
.ctrl-btn {
padding: 10px 16px;
font-size: 13px;
flex: 1;
min-width: 100px;
text-align: center;
&.large {
padding: 14px 24px;
font-size: 16px;
width: 100%;
}
}
// Prize Info
.prize-info {
flex-wrap: wrap;
justify-content: center;
gap: 12px;
padding: 12px 16px;
text-align: center;
.prize-level {
font-size: 18px;
width: 100%;
}
}
// Lottery Controls
.lottery-controls {
padding: 16px 0;
}
.complete-controls {
flex-direction: column;
gap: 12px;
width: 100%;
.ctrl-btn {
width: 100%;
}
}
// Winners
.winners-list {
gap: 8px;
}
.winner-item {
padding: 6px 12px;
.winner-name {
font-size: 14px;
}
}
// Round Progress
.round-progress {
gap: 12px;
margin-top: 16px;
padding-top: 16px;
}
.round-dot {
width: 28px;
height: 28px;
font-size: 12px;
}
// Program List
.program-list {
max-height: 300px;
}
.program-item {
padding: 8px 12px;
font-size: 13px;
}
// Control Groups
.control-group {
margin-bottom: 20px;
h4 {
font-size: 11px;
margin-bottom: 10px;
}
}
// Modal
.modal {
width: 95vw;
padding: 20px;
h3 {
font-size: 16px;
}
}
.cleanup-modal {
width: 95vw;
}
.cleanup-section {
padding: 12px;
h4 {
font-size: 13px;
}
}
.cleanup-options .checkbox-label {
padding: 8px 10px;
span {
font-size: 13px;
}
small {
font-size: 11px;
}
}
.modal-input input {
padding: 10px;
font-size: 14px;
}
.modal-actions {
flex-direction: column;
gap: 10px;
.ctrl-btn {
width: 100%;
}
}
}
// 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;
}
}
// Import Section Mobile Styles
@media (max-width: 768px) {
.import-section {
grid-column: span 1;
}
.import-controls {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.file-input-wrapper {
width: 100%;
.file-input-label {
width: 100%;
text-align: center;
box-sizing: border-box;
}
}
.result-stats {
gap: 12px;
.stat-item {
min-width: calc(33% - 8px);
.stat-value {
font-size: 18px;
}
}
}
.import-result {
padding: 12px;
}
}
// 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;
}
// Program Configuration Styles
.program-section {
grid-column: span 2;
}
.add-program-btn {
padding: 6px 12px;
background: $admin-primary;
border: none;
border-radius: 6px;
color: white;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: lighten($admin-primary, 5%);
}
}
.program-config-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.program-config-item {
display: grid;
grid-template-columns: 40px 1fr 120px 80px auto;
gap: 12px;
align-items: center;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid $admin-border;
border-radius: 8px;
transition: background 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.04);
}
}
.program-order {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: rgba($color-gold, 0.2);
color: $color-gold;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
}
.program-name {
font-size: 14px;
font-weight: 500;
color: $admin-text;
}
.program-team {
font-size: 13px;
color: $admin-text-muted;
}
.program-status {
padding: 4px 10px;
border-radius: 4px;
font-size: 12px;
text-align: center;
&.pending {
background: rgba(#888, 0.2);
color: #888;
}
&.current {
background: rgba($admin-warning, 0.2);
color: $admin-warning;
}
&.completed {
background: rgba($admin-success, 0.2);
color: $admin-success;
}
}
.program-actions {
display: flex;
gap: 6px;
}
.action-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: #252525;
border: 1px solid $admin-border;
border-radius: 6px;
color: $admin-text-muted;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: #333;
color: $admin-text;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
&.edit:hover:not(:disabled) {
border-color: $admin-primary;
color: $admin-primary;
}
&.delete:hover:not(:disabled) {
border-color: $admin-danger;
color: $admin-danger;
}
}
// Program Modal Styles
.program-modal {
width: 450px;
max-width: 90vw;
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 18px;
}
.close-btn {
background: none;
border: none;
color: $admin-text-muted;
font-size: 24px;
cursor: pointer;
padding: 0;
line-height: 1;
&:hover {
color: $admin-text;
}
}
}
.modal-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
label {
font-size: 13px;
color: $admin-text-muted;
}
input {
padding: 10px 12px;
background: #252525;
border: 1px solid $admin-border;
border-radius: 6px;
color: $admin-text;
font-size: 14px;
&:focus {
outline: none;
border-color: $admin-primary;
}
&::placeholder {
color: #555;
}
}
}
}
</style>