feat: initialize Annual Gala Interactive System monorepo

- Set up pnpm workspace with 4 packages: shared, server, client-mobile, client-screen
- Implement Redis atomic voting with Lua scripts (HINCRBY + distributed lock)
- Add optimistic UI with IndexedDB queue for offline resilience
- Configure Socket.io with auto-reconnection (infinite retries)
- Separate mobile (Vant) and big screen (Pixi.js) dependencies

Tech stack:
- Frontend Mobile: Vue 3 + Vant + Socket.io-client
- Frontend Screen: Vue 3 + Pixi.js + GSAP
- Backend: Express + Socket.io + Redis + Prisma/MySQL

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-15 01:19:36 +08:00
commit e7397d22a9
74 changed files with 14088 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
function goBack() {
router.push('/');
}
</script>
<template>
<div class="admin-control">
<header class="header">
<button class="back-btn" @click="goBack"> 返回</button>
<h1 class="title">管理控制台</h1>
</header>
<main class="content">
<div class="control-panel">
<h2>抽奖控制</h2>
<div class="controls">
<button class="control-btn">开始抽奖</button>
<button class="control-btn danger">停止抽奖</button>
</div>
</div>
<div class="control-panel">
<h2>显示模式</h2>
<div class="controls">
<button class="control-btn">待机画面</button>
<button class="control-btn">投票结果</button>
<button class="control-btn">抽奖画面</button>
</div>
</div>
</main>
</div>
</template>
<style lang="scss" scoped>
@import '../assets/styles/variables.scss';
.admin-control {
width: 100%;
height: 100%;
background: #1a1a1a;
color: white;
}
.header {
display: flex;
align-items: center;
gap: 20px;
padding: 20px 30px;
border-bottom: 1px solid #333;
.back-btn {
background: none;
border: 1px solid #666;
color: #999;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
&:hover {
border-color: #999;
color: white;
}
}
.title {
font-size: 24px;
}
}
.content {
padding: 30px;
display: flex;
flex-direction: column;
gap: 30px;
}
.control-panel {
background: #222;
border-radius: 8px;
padding: 20px;
h2 {
font-size: 18px;
margin-bottom: 16px;
color: #999;
}
.controls {
display: flex;
gap: 12px;
}
.control-btn {
padding: 12px 24px;
background: #333;
border: 1px solid #444;
color: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #444;
}
&.danger {
border-color: #ff4d4f;
color: #ff4d4f;
&:hover {
background: rgba(#ff4d4f, 0.1);
}
}
}
}
</style>

View File

@@ -0,0 +1,326 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useDisplayStore } from '../stores/display';
const router = useRouter();
const displayStore = useDisplayStore();
// Draw state
const isSpinning = ref(false);
const spinningNames = ref<string[]>([]);
const winner = ref<{ name: string; department: string } | null>(null);
// Mock participants for demo
const mockParticipants = [
{ name: '张三', department: '技术部' },
{ name: '李四', department: '产品部' },
{ name: '王五', department: '设计部' },
{ name: '赵六', department: '市场部' },
{ name: '钱七', department: '运营部' },
{ name: '孙八', department: '人事部' },
{ name: '周九', department: '财务部' },
{ name: '吴十', department: '销售部' },
];
let spinInterval: ReturnType<typeof setInterval> | null = null;
function startDraw() {
if (isSpinning.value) return;
isSpinning.value = true;
winner.value = null;
// Start spinning animation
let speed = 50;
let index = 0;
spinInterval = setInterval(() => {
spinningNames.value = [
mockParticipants[(index) % mockParticipants.length].name,
mockParticipants[(index + 1) % mockParticipants.length].name,
mockParticipants[(index + 2) % mockParticipants.length].name,
];
index++;
}, speed);
// Slow down and stop after 5 seconds
setTimeout(() => {
slowDownAndStop();
}, 5000);
}
function slowDownAndStop() {
if (spinInterval) {
clearInterval(spinInterval);
}
// Pick random winner
const winnerIndex = Math.floor(Math.random() * mockParticipants.length);
winner.value = mockParticipants[winnerIndex];
isSpinning.value = false;
spinningNames.value = [];
}
function goBack() {
router.push('/');
}
onUnmounted(() => {
if (spinInterval) {
clearInterval(spinInterval);
}
});
</script>
<template>
<div class="lucky-draw-view">
<!-- Header -->
<header class="header">
<button class="back-btn" @click="goBack"> 返回</button>
<h1 class="title gold-text">幸运抽奖</h1>
<div class="online">{{ displayStore.onlineUsers }} 人在线</div>
</header>
<!-- Main draw area -->
<main class="draw-area">
<!-- Prize display -->
<div class="prize-section">
<div class="prize-badge">
<span class="level">特等奖</span>
<span class="name">iPhone 16 Pro Max</span>
</div>
</div>
<!-- Spinning names -->
<div class="spin-section">
<div v-if="isSpinning" class="spinning-names">
<div v-for="(name, i) in spinningNames" :key="i" class="name-item">
{{ name }}
</div>
</div>
<!-- Winner display -->
<div v-else-if="winner" class="winner-display">
<div class="winner-card">
<div class="avatar">🎉</div>
<div class="info">
<h2 class="name gold-text">{{ winner.name }}</h2>
<p class="department">{{ winner.department }}</p>
</div>
</div>
<p class="congrats">恭喜中奖</p>
</div>
<!-- Idle state -->
<div v-else class="idle-state">
<p>点击下方按钮开始抽奖</p>
</div>
</div>
<!-- Draw button -->
<div class="action-section">
<button
class="draw-btn"
:class="{ spinning: isSpinning }"
:disabled="isSpinning"
@click="startDraw"
>
{{ isSpinning ? '抽奖中...' : '开始抽奖' }}
</button>
</div>
</main>
</div>
</template>
<style lang="scss" scoped>
@import '../assets/styles/variables.scss';
.lucky-draw-view {
width: 100%;
height: 100%;
background: $color-bg-gradient;
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30px 50px;
.back-btn {
background: none;
border: 1px solid $color-gold;
color: $color-gold;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: all $transition-fast;
&:hover {
background: rgba($color-gold, 0.1);
}
}
.title {
font-size: 48px;
font-weight: bold;
}
.online {
color: $color-text-muted;
font-size: 18px;
}
}
.draw-area {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 50px;
}
.prize-section {
margin-bottom: 60px;
.prize-badge {
display: flex;
flex-direction: column;
align-items: center;
padding: 30px 60px;
border: 2px solid $color-gold;
border-radius: 16px;
background: rgba($color-gold, 0.1);
.level {
font-size: 24px;
color: $color-gold;
margin-bottom: 10px;
}
.name {
font-size: 36px;
color: $color-text-light;
font-weight: bold;
}
}
}
.spin-section {
min-height: 300px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin-bottom: 60px;
.spinning-names {
display: flex;
flex-direction: column;
gap: 20px;
.name-item {
font-size: 72px;
color: $color-text-light;
text-align: center;
animation: flash 0.1s infinite;
&:nth-child(2) {
font-size: 96px;
color: $color-gold;
text-shadow: $glow-gold;
}
}
}
.winner-display {
text-align: center;
.winner-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 40px 80px;
border: 3px solid $color-gold;
border-radius: 20px;
background: rgba($color-gold, 0.1);
animation: scale-pulse 1s ease-in-out infinite;
.avatar {
font-size: 80px;
}
.name {
font-size: 64px;
font-weight: bold;
}
.department {
font-size: 24px;
color: $color-text-muted;
}
}
.congrats {
margin-top: 30px;
font-size: 36px;
color: $color-primary-light;
text-shadow: $glow-red;
}
}
.idle-state {
font-size: 24px;
color: $color-text-muted;
}
}
.action-section {
.draw-btn {
padding: 20px 80px;
font-size: 32px;
font-weight: bold;
color: $color-text-light;
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
border: none;
border-radius: 50px;
cursor: pointer;
transition: all $transition-normal;
box-shadow: $glow-red;
&:hover:not(:disabled) {
transform: scale(1.05);
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
}
&.spinning {
animation: glow-pulse 0.5s infinite;
}
}
}
@keyframes flash {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
@keyframes scale-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.02); }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: $glow-red; }
50% { box-shadow: 0 0 40px rgba($color-primary, 0.8), 0 0 80px rgba($color-primary, 0.5); }
}
</style>

View File

@@ -0,0 +1,248 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useDisplayStore } from '../stores/display';
const router = useRouter();
const displayStore = useDisplayStore();
const currentTime = ref(new Date().toLocaleTimeString('zh-CN'));
// Update time every second
onMounted(() => {
setInterval(() => {
currentTime.value = new Date().toLocaleTimeString('zh-CN');
}, 1000);
});
function goToDraw() {
router.push('/draw');
}
function goToResults() {
router.push('/results');
}
</script>
<template>
<div class="main-display">
<!-- Background particles will be added via Pixi.js -->
<div class="background-overlay"></div>
<!-- Header -->
<header class="header">
<div class="logo">
<span class="year gold-text">2026</span>
<span class="title">年会盛典</span>
</div>
<div class="status">
<span class="online-count">
<span class="dot"></span>
{{ displayStore.onlineUsers }} 人在线
</span>
<span class="time">{{ currentTime }}</span>
</div>
</header>
<!-- Main content -->
<main class="content">
<div class="welcome-section">
<h1 class="main-title">
<span class="gold-text">马到成功</span>
</h1>
<p class="subtitle">2026 年度盛典</p>
</div>
<!-- Action buttons -->
<div class="actions">
<button class="action-btn draw-btn" @click="goToDraw">
<span class="icon">🎁</span>
<span class="text">幸运抽奖</span>
</button>
<button class="action-btn results-btn" @click="goToResults">
<span class="icon">📊</span>
<span class="text">投票结果</span>
</button>
</div>
</main>
<!-- Decorative elements -->
<div class="decoration left-lantern"></div>
<div class="decoration right-lantern"></div>
</div>
</template>
<style lang="scss" scoped>
@import '../assets/styles/variables.scss';
.main-display {
width: 100%;
height: 100%;
background: $color-bg-gradient;
position: relative;
display: flex;
flex-direction: column;
}
.background-overlay {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center, transparent 0%, rgba(0, 0, 0, 0.5) 100%);
pointer-events: none;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30px 50px;
position: relative;
z-index: 10;
.logo {
display: flex;
align-items: baseline;
gap: 16px;
.year {
font-size: 48px;
font-weight: bold;
}
.title {
font-size: 24px;
color: $color-text-light;
letter-spacing: 4px;
}
}
.status {
display: flex;
align-items: center;
gap: 30px;
font-size: 18px;
color: $color-text-muted;
.online-count {
display: flex;
align-items: center;
gap: 8px;
.dot {
width: 10px;
height: 10px;
background: #52c41a;
border-radius: 50%;
animation: pulse 2s infinite;
}
}
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
z-index: 10;
}
.welcome-section {
text-align: center;
margin-bottom: 80px;
.main-title {
font-size: 120px;
font-weight: bold;
margin-bottom: 20px;
text-shadow: $glow-gold;
}
.subtitle {
font-size: 36px;
color: $color-text-muted;
letter-spacing: 8px;
}
}
.actions {
display: flex;
gap: 60px;
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 40px 60px;
border: 2px solid $color-gold;
border-radius: 20px;
background: rgba($color-gold, 0.1);
cursor: pointer;
transition: all $transition-normal;
&:hover {
background: rgba($color-gold, 0.2);
transform: translateY(-5px);
box-shadow: $glow-gold;
}
.icon {
font-size: 64px;
}
.text {
font-size: 24px;
color: $color-gold;
letter-spacing: 4px;
}
}
.draw-btn {
border-color: $color-primary;
background: rgba($color-primary, 0.1);
&:hover {
background: rgba($color-primary, 0.2);
box-shadow: $glow-red;
}
.text {
color: $color-primary-light;
}
}
}
.decoration {
position: absolute;
width: 80px;
height: 120px;
background: linear-gradient(180deg, $color-primary 0%, $color-primary-dark 100%);
border-radius: 50% 50% 45% 45%;
opacity: 0.6;
&.left-lantern {
top: 100px;
left: 50px;
animation: float 4s ease-in-out infinite;
}
&.right-lantern {
top: 100px;
right: 50px;
animation: float 4s ease-in-out infinite 1s;
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes float {
0%, 100% { transform: translateY(0) rotate(-3deg); }
50% { transform: translateY(-15px) rotate(3deg); }
}
</style>

View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
function goBack() {
router.push('/');
}
// Mock vote results
const categories = [
{
name: '最佳员工',
results: [
{ name: '张三', votes: 45, percentage: 30 },
{ name: '李四', votes: 38, percentage: 25 },
{ name: '王五', votes: 32, percentage: 21 },
],
},
{
name: '最佳团队',
results: [
{ name: '技术一组', votes: 52, percentage: 35 },
{ name: '产品组', votes: 41, percentage: 27 },
{ name: '设计组', votes: 35, percentage: 23 },
],
},
];
</script>
<template>
<div class="vote-results-view">
<!-- Header -->
<header class="header">
<button class="back-btn" @click="goBack"> 返回</button>
<h1 class="title gold-text">投票结果</h1>
<div class="placeholder"></div>
</header>
<!-- Results grid -->
<main class="results-grid">
<div v-for="category in categories" :key="category.name" class="category-card">
<h2 class="category-name">{{ category.name }}</h2>
<div class="results-list">
<div
v-for="(result, index) in category.results"
:key="result.name"
class="result-item"
:class="{ winner: index === 0 }"
>
<span class="rank">{{ index + 1 }}</span>
<span class="name">{{ result.name }}</span>
<div class="bar-container">
<div class="bar" :style="{ width: result.percentage + '%' }"></div>
</div>
<span class="votes">{{ result.votes }}</span>
</div>
</div>
</div>
</main>
</div>
</template>
<style lang="scss" scoped>
@import '../assets/styles/variables.scss';
.vote-results-view {
width: 100%;
height: 100%;
background: $color-bg-gradient;
display: flex;
flex-direction: column;
overflow: auto;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30px 50px;
.back-btn {
background: none;
border: 1px solid $color-gold;
color: $color-gold;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: all $transition-fast;
&:hover {
background: rgba($color-gold, 0.1);
}
}
.title {
font-size: 48px;
font-weight: bold;
}
.placeholder {
width: 100px;
}
}
.results-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 40px;
padding: 40px 50px;
}
.category-card {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba($color-gold, 0.3);
border-radius: 16px;
padding: 30px;
.category-name {
font-size: 28px;
color: $color-gold;
margin-bottom: 24px;
text-align: center;
}
.results-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.result-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
&.winner {
background: rgba($color-gold, 0.1);
border: 1px solid $color-gold;
.rank {
background: $color-gold;
color: #000;
}
.name {
color: $color-gold;
}
.bar {
background: linear-gradient(90deg, $color-gold-dark, $color-gold);
}
}
.rank {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
font-size: 16px;
font-weight: bold;
}
.name {
width: 120px;
font-size: 20px;
color: $color-text-light;
}
.bar-container {
flex: 1;
height: 24px;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
overflow: hidden;
.bar {
height: 100%;
background: linear-gradient(90deg, $color-primary-dark, $color-primary);
border-radius: 12px;
transition: width 1s ease;
}
}
.votes {
width: 60px;
text-align: right;
font-size: 18px;
color: $color-text-muted;
}
}
}
</style>