Files
company-celebration/packages/client-screen/src/views/AdminControl.vue
empty 7dc77da939 feat: add participant data persistence display and mobile responsive fixes
- Add getStats() method to participantService for tag distribution
- Update participants API to return tagDistribution statistics
- Load existing participants on AdminControl mount
- Add mobile responsive styles for import section

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 22:02:12 +08:00

1842 lines
41 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';
const router = useRouter();
const admin = useAdminStore();
// Local UI state
const confirmResetCode = ref('');
const showResetModal = ref(false);
const resetScope = ref<'all' | 'voting' | 'lottery'>('all');
// Prize configuration state
const showPrizeConfig = ref(false);
const prizeConfigLoading = ref(false);
const prizeConfigSaving = ref(false);
const editingPrizes = ref<PrizeConfig[]>([]);
// Load prize configuration from server
async function loadPrizeConfig() {
prizeConfigLoading.value = true;
try {
const res = await fetch('/api/admin/prizes');
const data = await res.json();
if (data.success && data.data?.prizes) {
editingPrizes.value = data.data.prizes.map((p: any) => ({ ...p }));
}
} catch (e) {
console.error('Failed to load prize config:', e);
} finally {
prizeConfigLoading.value = false;
}
}
// Save prize configuration to server
async function savePrizeConfig() {
prizeConfigSaving.value = true;
try {
const res = await fetch('/api/admin/prizes', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prizes: editingPrizes.value }),
});
const data = await res.json();
if (data.success) {
showPrizeConfig.value = false;
} else {
alert('保存失败: ' + (data.error || '未知错误'));
}
} catch (e) {
alert('保存失败: ' + (e as Error).message);
} finally {
prizeConfigSaving.value = false;
}
}
// Open prize configuration modal
function openPrizeConfig() {
showPrizeConfig.value = true;
loadPrizeConfig();
}
// Participant import state
const importFile = ref<File | null>(null);
const importing = ref(false);
const importResult = ref<{
success: boolean;
totalCount: number;
importedCount: number;
tagDistribution: Record<string, number>;
errors: string[];
} | null>(null);
// Handle file selection
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
importFile.value = input.files[0];
}
}
// Import participants from Excel
async function importParticipants() {
if (!importFile.value) return;
importing.value = true;
importResult.value = null;
try {
const formData = new FormData();
formData.append('file', importFile.value);
const response = await fetch('/api/admin/participants/import', {
method: 'POST',
body: formData,
});
const data = await response.json();
// 确保保留 success 字段,后端返回结构为 { success: true, data: {...} }
importResult.value = {
success: data.success ?? data.data?.success ?? false,
totalCount: data.data?.totalCount ?? data.totalCount ?? 0,
importedCount: data.data?.importedCount ?? data.importedCount ?? 0,
tagDistribution: data.data?.tagDistribution ?? data.tagDistribution ?? {},
errors: data.data?.errors ?? data.errors ?? [],
};
} catch (error) {
importResult.value = {
success: false,
totalCount: 0,
importedCount: 0,
tagDistribution: {},
errors: [(error as Error).message],
};
} finally {
importing.value = false;
}
}
// Tag display names
const tagLabels: Record<string, string> = {
'6070': '60/70后',
'80': '80后',
'90': '90后',
'horse': '属马',
};
// Load existing participants from server
async function loadParticipants() {
try {
const response = await fetch('/api/admin/participants');
const data = await response.json();
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) {
console.error('Failed to load participants:', error);
}
}
// Navigation
function goBack() {
router.push('/');
}
// Phase control
function setPhase(phase: 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS') {
admin.setPhase(phase);
}
// Voting control
function openVoting() {
admin.controlVoting('open');
}
function closeVoting() {
admin.controlVoting('close');
}
function pauseVoting() {
admin.controlVoting('pause');
}
function resumeVoting() {
admin.controlVoting('resume');
}
// 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
function startGalaxy() {
admin.controlLottery('start_galaxy');
}
function startStorm() {
admin.controlLottery('start_storm');
}
function stopAndReveal() {
if (!admin.canStopLottery) return;
admin.controlLottery('stop_reveal');
}
function nextRound() {
admin.controlLottery('next_round');
}
function resetLottery() {
admin.controlLottery('reset');
}
// 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 = '';
}
// Music control
function toggleMusic() {
if (admin.musicPlaying) {
admin.controlMusic('stop');
} else {
admin.controlMusic('play', 'bgm');
}
}
function playFanfare() {
admin.controlMusic('play', 'fanfare');
}
// 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 '结果展示';
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 '未知';
}
});
// Award type labels
const awardLabels: Record<string, string> = {
creative: '最佳创意奖',
visual: '最佳视觉奖',
atmosphere: '最佳气氛奖',
performance: '最佳表演奖',
teamwork: '最佳团队奖',
popularity: '最受欢迎奖',
potential: '最具潜力奖',
};
// 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
Object.keys(awardLabels).forEach(type => {
stats[type] = [];
});
// 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="goBack"> 返回</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>
<!-- 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 State -->
<div v-if="admin.lotterySubPhase === 'REVEAL'" class="complete-controls">
<button
class="ctrl-btn primary"
:disabled="isLastRound || admin.pendingAction === 'lottery_next_round'"
@click="nextRound"
>
下一轮
</button>
<button
class="ctrl-btn outline"
:disabled="admin.pendingAction === 'lottery_reset'"
@click="resetLottery"
>
重置
</button>
</div>
<!-- COMPLETE State -->
<div v-if="admin.lotterySubPhase === 'COMPLETE'" class="complete-controls">
<button
class="ctrl-btn primary"
:disabled="isLastRound || admin.pendingAction === 'lottery_next_round'"
@click="nextRound"
>
下一轮
</button>
<button
class="ctrl-btn outline"
:disabled="admin.pendingAction === 'lottery_reset'"
@click="resetLottery"
>
重置
</button>
</div>
</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 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>
</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>
</div>
</div>
<!-- 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">此操作不可撤销</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>
</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; }
}
// 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
.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);
}
}
&.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;
}
}
}
// 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;
}
}
}
// 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 {
grid-column: span 1;
}
}
.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;
}
}
.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;
}
</style>