feat: add lottery results display page
- Add LOTTERY_RESULTS system phase - Add /screen/lottery-results route - Add LotteryResultsView component - Add database connection management (db.ts) - Update DrawResult schema to remove User relation - Add awardIcon field to VoteResultsView Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ const modeRoutes: Record<string, string> = {
|
|||||||
'voting': '/screen/voting',
|
'voting': '/screen/voting',
|
||||||
'draw': '/screen/draw',
|
'draw': '/screen/draw',
|
||||||
'results': '/screen/results',
|
'results': '/screen/results',
|
||||||
|
'lottery_results': '/screen/lottery-results',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Unlock audio playback (required by browser autoplay policy)
|
// Unlock audio playback (required by browser autoplay policy)
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/VoteResultsView.vue'),
|
component: () => import('../views/VoteResultsView.vue'),
|
||||||
meta: { title: '年会大屏 - 投票结果' },
|
meta: { title: '年会大屏 - 投票结果' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/screen/lottery-results',
|
||||||
|
name: 'screen-lottery-results',
|
||||||
|
component: () => import('../views/LotteryResultsView.vue'),
|
||||||
|
meta: { title: '年会大屏 - 抽奖结果' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/screen/horse-race',
|
path: '/screen/horse-race',
|
||||||
name: 'screen-horse-race',
|
name: 'screen-horse-race',
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const useDisplayStore = defineStore('display', () => {
|
|||||||
const isConnected = ref(false);
|
const isConnected = ref(false);
|
||||||
const isConnecting = ref(false);
|
const isConnecting = ref(false);
|
||||||
const onlineUsers = ref(0);
|
const onlineUsers = ref(0);
|
||||||
const currentMode = ref<'idle' | 'voting' | 'draw' | 'results'>('idle');
|
const currentMode = ref<'idle' | 'voting' | 'draw' | 'results' | 'lottery_results'>('idle');
|
||||||
|
|
||||||
// Draw state
|
// Draw state
|
||||||
const isDrawing = ref(false);
|
const isDrawing = ref(false);
|
||||||
@@ -131,11 +131,12 @@ export const useDisplayStore = defineStore('display', () => {
|
|||||||
console.log('[Screen] Admin state sync received:', state.systemPhase);
|
console.log('[Screen] Admin state sync received:', state.systemPhase);
|
||||||
|
|
||||||
// Map SystemPhase to display mode
|
// Map SystemPhase to display mode
|
||||||
const phaseToMode: Record<SystemPhase, 'idle' | 'voting' | 'draw' | 'results'> = {
|
const phaseToMode: Record<SystemPhase, 'idle' | 'voting' | 'draw' | 'results' | 'lottery_results'> = {
|
||||||
'IDLE': 'idle',
|
'IDLE': 'idle',
|
||||||
'VOTING': 'voting',
|
'VOTING': 'voting',
|
||||||
'LOTTERY': 'draw',
|
'LOTTERY': 'draw',
|
||||||
'RESULTS': 'results',
|
'RESULTS': 'results',
|
||||||
|
'LOTTERY_RESULTS': 'lottery_results',
|
||||||
};
|
};
|
||||||
|
|
||||||
const newMode = phaseToMode[state.systemPhase] || 'idle';
|
const newMode = phaseToMode[state.systemPhase] || 'idle';
|
||||||
|
|||||||
483
packages/client-screen/src/views/LotteryResultsView.vue
Normal file
483
packages/client-screen/src/views/LotteryResultsView.vue
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useAdminStore } from '../stores/admin';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const admin = useAdminStore();
|
||||||
|
|
||||||
|
// Lottery results from API
|
||||||
|
const lotteryResults = ref<Array<{
|
||||||
|
id: string;
|
||||||
|
prizeLevel: string;
|
||||||
|
prizeName: string;
|
||||||
|
winners: Array<{ id: string; name: string; department: string }>;
|
||||||
|
drawnAt: string;
|
||||||
|
}>>([]);
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch lottery results from API
|
||||||
|
async function fetchLotteryResults() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/lottery/results');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.data?.draws) {
|
||||||
|
lotteryResults.value = data.data.draws;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch lottery results:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group results by prize level
|
||||||
|
const groupedResults = computed(() => {
|
||||||
|
const groups = new Map<string, typeof lotteryResults.value>();
|
||||||
|
|
||||||
|
for (const result of lotteryResults.value) {
|
||||||
|
const existing = groups.get(result.prizeLevel) || [];
|
||||||
|
existing.push(result);
|
||||||
|
groups.set(result.prizeLevel, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by prize level (reverse order - grand prize first)
|
||||||
|
return Array.from(groups.entries()).reverse();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get round number from prize level
|
||||||
|
function getRoundNumber(prizeLevel: string): number {
|
||||||
|
const match = prizeLevel.match(/(\d)/);
|
||||||
|
return match ? parseInt(match[1], 10) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get prize icon based on round
|
||||||
|
function getPrizeIcon(round: number): string {
|
||||||
|
const icons = ['🥉', '🥈', '🥇', '👑'];
|
||||||
|
return icons[round - 1] || '🎁';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get zodiac label if filtered
|
||||||
|
function getZodiacLabel(prizeName: string): string | null {
|
||||||
|
if (prizeName.includes('马')) return '🐴 属马专属';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to admin store and fetch results on mount
|
||||||
|
onMounted(() => {
|
||||||
|
admin.connect();
|
||||||
|
fetchLotteryResults();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="lottery-results-view">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<button class="back-btn" @click="goBack">← 返回</button>
|
||||||
|
<h1 class="title gold-text">抽奖结果</h1>
|
||||||
|
<div class="status-indicator">
|
||||||
|
<span class="dot" :class="admin.isConnected ? 'online' : 'offline'"></span>
|
||||||
|
{{ admin.isConnected ? '已连接' : '连接中...' }}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Results display -->
|
||||||
|
<main class="results-container">
|
||||||
|
<div v-if="lotteryResults.length === 0" class="no-results">
|
||||||
|
<div class="empty-icon">🎁</div>
|
||||||
|
<div class="empty-text">暂无抽奖结果</div>
|
||||||
|
<div class="empty-subtext">抽奖结束后将在此显示中奖名单</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rounds-grid">
|
||||||
|
<div
|
||||||
|
v-for="[prizeLevel, draws] in groupedResults"
|
||||||
|
:key="prizeLevel"
|
||||||
|
class="round-card"
|
||||||
|
:class="`round-${getRoundNumber(prizeLevel)}`"
|
||||||
|
>
|
||||||
|
<div class="round-header">
|
||||||
|
<div class="prize-icon">{{ getPrizeIcon(getRoundNumber(prizeLevel)) }}</div>
|
||||||
|
<div class="prize-info">
|
||||||
|
<h2 class="prize-level">{{ prizeLevel }}</h2>
|
||||||
|
<div class="prize-name">{{ draws[0]?.prizeName }}</div>
|
||||||
|
<div v-if="getZodiacLabel(draws[0]?.prizeName)" class="zodiac-badge">
|
||||||
|
{{ getZodiacLabel(draws[0]?.prizeName) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="winner-count">
|
||||||
|
<span class="count">{{ draws.reduce((sum, d) => sum + d.winners.length, 0) }}</span>
|
||||||
|
<span class="label">位中奖</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="winners-section">
|
||||||
|
<div
|
||||||
|
v-for="draw in draws"
|
||||||
|
:key="draw.id"
|
||||||
|
class="draw-group"
|
||||||
|
>
|
||||||
|
<div class="draw-time">{{ new Date(draw.drawnAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }}</div>
|
||||||
|
<div class="winners-list">
|
||||||
|
<div
|
||||||
|
v-for="winner in draw.winners"
|
||||||
|
:key="winner.id"
|
||||||
|
class="winner-item"
|
||||||
|
>
|
||||||
|
<div class="winner-avatar">
|
||||||
|
{{ winner.name.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<div class="winner-info">
|
||||||
|
<span class="winner-name">{{ winner.name }}</span>
|
||||||
|
<span class="winner-dept">{{ winner.department }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '../assets/styles/variables.scss' as *;
|
||||||
|
|
||||||
|
.lottery-results-view {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: radial-gradient(circle at center, #b91c1c 0%, #7f1d1d 100%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// Paper texture overlay
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background-image: url("https://www.transparenttextures.com/patterns/pinstriped-suit.png");
|
||||||
|
opacity: 0.1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 30px 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba($color-gold, 0.5);
|
||||||
|
color: $color-gold;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all $transition-fast;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($color-gold, 0.2);
|
||||||
|
transform: translateX(-4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
text-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
&.online {
|
||||||
|
background: #4ade80;
|
||||||
|
box-shadow: 0 0 12px #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.offline {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-container {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px 60px 80px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 120px;
|
||||||
|
opacity: 0.3;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-subtext {
|
||||||
|
font-size: 18px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounds-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-card {
|
||||||
|
background: linear-gradient(135deg, #fffbef 0%, #fff8e1 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow:
|
||||||
|
0 10px 40px rgba(0, 0, 0, 0.3),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// Decorative corner
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
background: linear-gradient(135deg, transparent 50%, rgba($color-gold, 0.1) 50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different accent colors for each round
|
||||||
|
&.round-1 { border-left: 6px solid #cd7f32; }
|
||||||
|
&.round-2 { border-left: 6px solid #c0c0c0; }
|
||||||
|
&.round-3 { border-left: 6px solid #ffd700; }
|
||||||
|
&.round-4 { border-left: 6px solid #e0115f; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 2px solid rgba($color-gold, 0.2);
|
||||||
|
|
||||||
|
.prize-icon {
|
||||||
|
font-size: 56px;
|
||||||
|
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-info {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.prize-level {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-name {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zodiac-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: linear-gradient(135deg, #8b4513, #d2691e);
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner-count {
|
||||||
|
text-align: center;
|
||||||
|
background: linear-gradient(135deg, rgba($color-gold, 0.1), rgba($color-gold, 0.2));
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 2px solid rgba($color-gold, 0.3);
|
||||||
|
|
||||||
|
.count {
|
||||||
|
display: block;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #b91c1c;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.winners-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.draw-group {
|
||||||
|
.draw-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.winners-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: white;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
border: 1px solid rgba($color-gold, 0.2);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
border-color: rgba($color-gold, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #b91c1c, #dc2626);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.winner-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner-dept {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom Scrollbar
|
||||||
|
.lottery-results-view::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
.lottery-results-view::-webkit-scrollbar-track {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.lottery-results-view::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba($color-gold, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.rounds-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
padding: 20px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
|
||||||
|
.title { font-size: 32px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-container {
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-card {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.round-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.prize-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-info .prize-level {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.winners-list {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -16,6 +16,7 @@ const awardResults = computed(() => {
|
|||||||
const results: Array<{
|
const results: Array<{
|
||||||
awardType: string;
|
awardType: string;
|
||||||
awardName: string;
|
awardName: string;
|
||||||
|
awardIcon: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
programs: Array<{ id: string; name: string; teamName: string; votes: number; percentage: number }>;
|
programs: Array<{ id: string; name: string; teamName: string; votes: number; percentage: number }>;
|
||||||
totalVotes: number;
|
totalVotes: number;
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ model User {
|
|||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
votes Vote[]
|
votes Vote[]
|
||||||
drawResults DrawResult[]
|
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -82,14 +81,12 @@ model DrawResult {
|
|||||||
drawId String @map("draw_id")
|
drawId String @map("draw_id")
|
||||||
prizeLevel String @map("prize_level") @db.VarChar(20)
|
prizeLevel String @map("prize_level") @db.VarChar(20)
|
||||||
prizeName String @map("prize_name") @db.VarChar(100)
|
prizeName String @map("prize_name") @db.VarChar(100)
|
||||||
winnerId String @map("winner_id")
|
winnerId String @map("winner_id") @db.VarChar(100)
|
||||||
winnerName String @map("winner_name") @db.VarChar(100)
|
winnerName String @map("winner_name") @db.VarChar(100)
|
||||||
winnerDepartment String @map("winner_department") @db.VarChar(100)
|
winnerDepartment String @map("winner_department") @db.VarChar(100)
|
||||||
drawnAt DateTime @default(now()) @map("drawn_at")
|
drawnAt DateTime @default(now()) @map("drawn_at")
|
||||||
drawnBy String @map("drawn_by") @db.VarChar(100)
|
drawnBy String @map("drawn_by") @db.VarChar(100)
|
||||||
|
|
||||||
winner User @relation(fields: [winnerId], references: [id])
|
|
||||||
|
|
||||||
@@index([drawId])
|
@@index([drawId])
|
||||||
@@index([prizeLevel])
|
@@index([prizeLevel])
|
||||||
@@index([winnerId])
|
@@index([winnerId])
|
||||||
|
|||||||
51
packages/server/src/config/db.ts
Normal file
51
packages/server/src/config/db.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Database configuration using Prisma Client
|
||||||
|
*/
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
// Prisma Client instance with logging in development
|
||||||
|
export const prisma = new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development'
|
||||||
|
? ['query', 'info', 'warn', 'error']
|
||||||
|
: ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to database
|
||||||
|
*/
|
||||||
|
export async function connectDatabase(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await prisma.$connect();
|
||||||
|
logger.info('Database connected successfully');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to connect to database');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from database
|
||||||
|
*/
|
||||||
|
export async function disconnectDatabase(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
logger.info('Database disconnected');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error disconnecting from database');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check for database connection
|
||||||
|
*/
|
||||||
|
export async function checkDatabaseHealth(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Database health check failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { createServer } from 'http';
|
|||||||
import { app, logger } from './app';
|
import { app, logger } from './app';
|
||||||
import { config } from './config';
|
import { config } from './config';
|
||||||
import { connectRedis } from './config/redis';
|
import { connectRedis } from './config/redis';
|
||||||
|
import { connectDatabase, disconnectDatabase } from './config/db';
|
||||||
import { initializeSocket } from './socket';
|
import { initializeSocket } from './socket';
|
||||||
import { loadLuaScripts } from './services/vote.service';
|
import { loadLuaScripts } from './services/vote.service';
|
||||||
import { loadVotingScripts } from './services/voting.engine';
|
import { loadVotingScripts } from './services/voting.engine';
|
||||||
@@ -11,6 +12,10 @@ import { participantService } from './services/participant.service';
|
|||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
// Connect to Database
|
||||||
|
logger.info('Connecting to Database...');
|
||||||
|
await connectDatabase();
|
||||||
|
|
||||||
// Connect to Redis
|
// Connect to Redis
|
||||||
logger.info('Connecting to Redis...');
|
logger.info('Connecting to Redis...');
|
||||||
await connectRedis();
|
await connectRedis();
|
||||||
@@ -50,6 +55,9 @@ async function main(): Promise<void> {
|
|||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
logger.info({ signal }, 'Shutdown signal received');
|
logger.info({ signal }, 'Shutdown signal received');
|
||||||
|
|
||||||
|
// Disconnect from database
|
||||||
|
await disconnectDatabase();
|
||||||
|
|
||||||
httpServer.close(() => {
|
httpServer.close(() => {
|
||||||
logger.info('HTTP server closed');
|
logger.info('HTTP server closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// System Phase State Machine
|
// System Phase State Machine
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export type SystemPhase = 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS';
|
export type SystemPhase = 'IDLE' | 'VOTING' | 'LOTTERY' | 'RESULTS' | 'LOTTERY_RESULTS';
|
||||||
|
|
||||||
export type VotingSubPhase = 'CLOSED' | 'OPEN' | 'PAUSED';
|
export type VotingSubPhase = 'CLOSED' | 'OPEN' | 'PAUSED';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user