2957 lines
68 KiB
Vue
2957 lines
68 KiB
Vue
<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>
|