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

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Dependencies
node_modules
.pnpm-store
# Build outputs
dist
*.tsbuildinfo
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
# OS
.DS_Store
Thumbs.db
# Testing
coverage
# Prisma
packages/server/prisma/*.db
packages/server/prisma/*.db-journal
# Misc
*.local
.turbo

10
.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

34
package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "company-celebration2",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@9.15.0",
"type": "module",
"scripts": {
"dev": "pnpm -r --parallel run dev",
"dev:mobile": "pnpm --filter @gala/client-mobile dev",
"dev:screen": "pnpm --filter @gala/client-screen dev",
"dev:server": "pnpm --filter @gala/server dev",
"build": "pnpm --filter @gala/shared build && pnpm -r --filter !@gala/shared run build",
"build:shared": "pnpm --filter @gala/shared build",
"lint": "eslint packages --ext .ts,.tsx,.vue",
"lint:fix": "eslint packages --ext .ts,.tsx,.vue --fix",
"format": "prettier --write \"packages/**/*.{ts,tsx,vue,json,scss}\"",
"typecheck": "pnpm -r run typecheck",
"test": "pnpm -r run test",
"clean": "pnpm -r exec rm -rf node_modules dist .turbo && rm -rf node_modules"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"eslint": "^9.18.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
},
"engines": {
"node": ">=20.0.0",
"pnpm": ">=9.0.0"
}
}

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#c41230" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>年会互动系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,34 @@
{
"name": "@gala/client-mobile",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@gala/shared": "workspace:*",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"pinia": "^2.3.0",
"pinia-plugin-persistedstate": "^4.1.3",
"vant": "^4.9.14",
"socket.io-client": "^4.8.1",
"axios": "^1.7.9",
"idb-keyval": "^6.2.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vant/auto-import-resolver": "^1.3.0",
"unplugin-vue-components": "^0.27.5",
"unplugin-auto-import": "^0.18.6",
"vite": "^6.0.7",
"vite-plugin-pwa": "^0.21.1",
"sass": "^1.83.1",
"typescript": "^5.7.3",
"vue-tsc": "^2.2.0"
}
}

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { useConnectionStore } from './stores/connection';
import ConnectionStatus from './components/ConnectionStatus.vue';
const connectionStore = useConnectionStore();
onMounted(() => {
connectionStore.connect();
});
onUnmounted(() => {
connectionStore.disconnect();
});
</script>
<template>
<div class="app-container">
<ConnectionStatus />
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</template>
<style lang="scss">
.app-container {
min-height: 100vh;
background: linear-gradient(180deg, #fff5f5 0%, #ffffff 100%);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,177 @@
@import './variables.scss';
// Reset
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: $font-size-md;
line-height: 1.5;
color: $color-text-primary;
background-color: $color-bg-primary;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// Safe area for notch devices
.safe-area-top {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
// Utility classes
.text-center {
text-align: center;
}
.text-primary {
color: $color-primary;
}
.text-gold {
color: $color-gold;
}
.text-muted {
color: $color-text-muted;
}
.bg-primary {
background-color: $color-primary;
}
.bg-gold {
background-color: $color-gold;
}
// Guochao decorative elements
.guochao-border {
border: 2px solid $color-gold;
border-radius: $radius-lg;
position: relative;
&::before,
&::after {
content: '';
position: absolute;
width: 12px;
height: 12px;
border: 2px solid $color-gold;
}
&::before {
top: -6px;
left: -6px;
border-right: none;
border-bottom: none;
}
&::after {
bottom: -6px;
right: -6px;
border-left: none;
border-top: none;
}
}
// Gold gradient text
.gold-text {
background: linear-gradient(135deg, $color-gold-light 0%, $color-gold 50%, $color-gold-dark 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
// Red gradient button
.btn-primary {
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
color: $color-text-inverse;
border: none;
border-radius: $radius-lg;
padding: $spacing-md $spacing-lg;
font-size: $font-size-lg;
font-weight: 500;
box-shadow: $shadow-md;
transition: all $transition-normal;
&:active {
transform: scale(0.98);
box-shadow: $shadow-sm;
}
}
// Card style
.card {
background: $color-bg-card;
border-radius: $radius-lg;
box-shadow: $shadow-md;
padding: $spacing-md;
}
// Animation keyframes
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
// Loading skeleton
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: $radius-sm;
}
// Vant overrides
:root {
--van-primary-color: #{$color-primary};
--van-success-color: #{$color-success};
--van-warning-color: #{$color-warning};
--van-danger-color: #{$color-error};
--van-text-color: #{$color-text-primary};
--van-text-color-2: #{$color-text-secondary};
--van-text-color-3: #{$color-text-muted};
--van-border-color: #{$color-border};
--van-background: #{$color-bg-primary};
--van-background-2: #{$color-bg-secondary};
}

View File

@@ -0,0 +1,72 @@
// Guochao Red & Gold Theme Variables
// Primary colors - 国潮红金配色
$color-primary: #c41230; // 中国红
$color-primary-light: #e8384f;
$color-primary-dark: #9a0e26;
$color-gold: #d4a84b; // 金色
$color-gold-light: #f0c96a;
$color-gold-dark: #b8923f;
// Background colors
$color-bg-primary: #fff5f5;
$color-bg-secondary: #fef8f0;
$color-bg-card: #ffffff;
// Text colors
$color-text-primary: #1a1a1a;
$color-text-secondary: #666666;
$color-text-muted: #999999;
$color-text-inverse: #ffffff;
// Status colors
$color-success: #52c41a;
$color-warning: #faad14;
$color-error: #ff4d4f;
$color-info: #1890ff;
// Border
$color-border: #f0f0f0;
$color-border-light: #f5f5f5;
// Shadows
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
$shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
$shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
$shadow-gold: 0 4px 12px rgba(212, 168, 75, 0.3);
// Border radius
$radius-sm: 4px;
$radius-md: 8px;
$radius-lg: 12px;
$radius-xl: 16px;
$radius-full: 9999px;
// Spacing
$spacing-xs: 4px;
$spacing-sm: 8px;
$spacing-md: 16px;
$spacing-lg: 24px;
$spacing-xl: 32px;
// Font sizes
$font-size-xs: 10px;
$font-size-sm: 12px;
$font-size-md: 14px;
$font-size-lg: 16px;
$font-size-xl: 18px;
$font-size-2xl: 20px;
$font-size-3xl: 24px;
// Z-index
$z-index-dropdown: 100;
$z-index-sticky: 200;
$z-index-fixed: 300;
$z-index-modal: 400;
$z-index-toast: 500;
// Transitions
$transition-fast: 0.15s ease;
$transition-normal: 0.3s ease;
$transition-slow: 0.5s ease;

View File

@@ -0,0 +1,88 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const createPinia: typeof import('pinia')['createPinia']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const effectScope: typeof import('vue')['effectScope']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

View File

@@ -0,0 +1,14 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ConnectionStatus: typeof import('./components/ConnectionStatus.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useConnectionStore } from '../stores/connection';
const connectionStore = useConnectionStore();
const statusClass = computed(() => {
switch (connectionStore.connectionStatus) {
case 'connected':
return 'status-connected';
case 'connecting':
return 'status-connecting';
default:
return 'status-disconnected';
}
});
const statusText = computed(() => {
switch (connectionStore.connectionStatus) {
case 'connected':
return '已连接';
case 'connecting':
return '连接中...';
default:
return '未连接';
}
});
</script>
<template>
<div class="connection-status" :class="statusClass">
<span class="status-dot"></span>
<span class="status-text">{{ statusText }}</span>
<span v-if="connectionStore.latency > 0" class="latency">
{{ connectionStore.latency }}ms
</span>
</div>
</template>
<style lang="scss" scoped>
@import '../assets/styles/variables.scss';
.connection-status {
display: flex;
align-items: center;
gap: $spacing-xs;
padding: $spacing-xs $spacing-sm;
border-radius: $radius-full;
font-size: $font-size-xs;
background: rgba(255, 255, 255, 0.9);
box-shadow: $shadow-sm;
position: fixed;
top: env(safe-area-inset-top, 8px);
right: $spacing-sm;
z-index: $z-index-fixed;
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
transition: background-color $transition-normal;
}
.status-text {
color: $color-text-secondary;
}
.latency {
color: $color-text-muted;
font-size: $font-size-xs;
}
&.status-connected {
.status-dot {
background-color: $color-success;
}
}
&.status-connecting {
.status-dot {
background-color: $color-warning;
animation: pulse 1s infinite;
}
}
&.status-disconnected {
.status-dot {
background-color: $color-error;
}
}
}
</style>

View File

@@ -0,0 +1,283 @@
import { ref, computed, watch } from 'vue';
import { get, set } from 'idb-keyval';
import { useConnectionStore } from '../stores/connection';
import { SOCKET_EVENTS } from '@gala/shared/constants';
import { CONFIG } from '@gala/shared/constants';
import type { VoteCategory, VoteSubmitPayload, AckResponse } from '@gala/shared/types';
interface QueuedVote {
localId: string;
candidateId: string;
category: VoteCategory;
clientTimestamp: number;
status: 'pending' | 'sending' | 'confirmed' | 'failed';
retryCount: number;
error?: string;
}
const QUEUE_STORAGE_KEY = 'vote_queue';
/**
* Composable for optimistic UI voting with local queue
*/
export function useVoteQueue() {
const connectionStore = useConnectionStore();
// State
const queue = ref<QueuedVote[]>([]);
const isProcessing = ref(false);
const lastSyncTime = ref<number>(0);
// Computed
const pendingVotes = computed(() => queue.value.filter((v) => v.status === 'pending'));
const failedVotes = computed(() => queue.value.filter((v) => v.status === 'failed'));
const hasPendingVotes = computed(() => pendingVotes.value.length > 0);
/**
* Load queue from IndexedDB
*/
async function loadQueue(): Promise<void> {
try {
const stored = await get<QueuedVote[]>(QUEUE_STORAGE_KEY);
if (stored && Array.isArray(stored)) {
// Reset sending status to pending (in case of page refresh during send)
queue.value = stored.map((v) => ({
...v,
status: v.status === 'sending' ? 'pending' : v.status,
}));
// Process any pending votes
if (connectionStore.isConnected) {
processQueue();
}
}
} catch (error) {
console.error('[VoteQueue] Failed to load queue:', error);
}
}
/**
* Persist queue to IndexedDB
*/
async function persistQueue(): Promise<void> {
try {
await set(QUEUE_STORAGE_KEY, queue.value);
} catch (error) {
console.error('[VoteQueue] Failed to persist queue:', error);
}
}
/**
* Generate a simple UUID
*/
function generateLocalId(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Enqueue a vote (optimistic UI)
* Returns the localId for tracking
*/
function enqueue(candidateId: string, category: VoteCategory): string {
const localId = generateLocalId();
const vote: QueuedVote = {
localId,
candidateId,
category,
clientTimestamp: Date.now(),
status: 'pending',
retryCount: 0,
};
queue.value.push(vote);
persistQueue();
// Immediately try to process
processQueue();
return localId;
}
/**
* Process the queue
*/
async function processQueue(): Promise<void> {
const socket = connectionStore.getSocket();
if (isProcessing.value || !connectionStore.isConnected || !socket) {
return;
}
const pending = queue.value.filter((v) => v.status === 'pending');
if (pending.length === 0) {
return;
}
isProcessing.value = true;
for (const vote of pending) {
await processVote(vote);
}
isProcessing.value = false;
persistQueue();
}
/**
* Process a single vote
*/
async function processVote(vote: QueuedVote): Promise<void> {
const socket = connectionStore.getSocket();
if (!socket?.connected) {
return;
}
vote.status = 'sending';
const payload: VoteSubmitPayload = {
localId: vote.localId,
candidateId: vote.candidateId,
category: vote.category,
clientTimestamp: vote.clientTimestamp,
};
try {
const response = await new Promise<AckResponse<{ newCount: number }>>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('timeout'));
}, 5000);
socket.emit(
SOCKET_EVENTS.VOTE_SUBMIT as any,
payload,
(response: AckResponse<{ newCount: number }>) => {
clearTimeout(timeout);
resolve(response);
}
);
});
if (response.success) {
vote.status = 'confirmed';
connectionStore.addVotedCategory(vote.category);
console.log('[VoteQueue] Vote confirmed:', vote.localId);
} else {
handleVoteError(vote, response.error || 'UNKNOWN_ERROR');
}
} catch (error) {
handleVoteError(vote, 'NETWORK_ERROR');
}
}
/**
* Handle vote error with retry logic
*/
function handleVoteError(vote: QueuedVote, error: string): void {
vote.retryCount++;
vote.error = error;
// Don't retry for certain errors
const nonRetryableErrors = ['ALREADY_VOTED', 'MAX_VOTES_REACHED', 'INVALID_CANDIDATE', 'VOTING_CLOSED'];
if (nonRetryableErrors.includes(error) || vote.retryCount >= CONFIG.VOTE_QUEUE_MAX_RETRIES) {
vote.status = 'failed';
console.warn('[VoteQueue] Vote failed permanently:', vote.localId, error);
} else {
vote.status = 'pending';
console.log('[VoteQueue] Vote will retry:', vote.localId, `(attempt ${vote.retryCount})`);
// Schedule retry with exponential backoff
const delay = CONFIG.VOTE_QUEUE_RETRY_DELAY_MS * Math.pow(2, vote.retryCount - 1);
setTimeout(() => processQueue(), delay);
}
}
/**
* Get vote status by localId
*/
function getVoteStatus(localId: string): QueuedVote | undefined {
return queue.value.find((v) => v.localId === localId);
}
/**
* Check if a category has a pending/confirmed vote
*/
function hasVoteForCategory(category: VoteCategory): boolean {
return queue.value.some(
(v) => v.category === category && (v.status === 'pending' || v.status === 'sending' || v.status === 'confirmed')
);
}
/**
* Clear confirmed votes from queue
*/
function clearConfirmed(): void {
queue.value = queue.value.filter((v) => v.status !== 'confirmed');
persistQueue();
}
/**
* Retry failed votes
*/
function retryFailed(): void {
for (const vote of queue.value) {
if (vote.status === 'failed') {
vote.status = 'pending';
vote.retryCount = 0;
vote.error = undefined;
}
}
persistQueue();
processQueue();
}
/**
* Remove a specific vote from queue
*/
function removeVote(localId: string): void {
const index = queue.value.findIndex((v) => v.localId === localId);
if (index !== -1) {
queue.value.splice(index, 1);
persistQueue();
}
}
// Watch connection status to process queue when reconnected
watch(
() => connectionStore.isConnected,
(connected) => {
if (connected && hasPendingVotes.value) {
console.log('[VoteQueue] Connection restored, processing pending votes');
processQueue();
}
}
);
// Load queue on init
loadQueue();
return {
// State
queue,
isProcessing,
lastSyncTime,
// Computed
pendingVotes,
failedVotes,
hasPendingVotes,
// Actions
enqueue,
processQueue,
getVoteStatus,
hasVoteForCategory,
clearConfirmed,
retryFailed,
removeVote,
};
}

16
packages/client-mobile/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_SOCKET_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,22 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import App from './App.vue';
import router from './router';
// Vant styles
import 'vant/lib/index.css';
// Global styles
import './assets/styles/global.scss';
const app = createApp(App);
// Pinia with persistence
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
app.use(pinia);
app.use(router);
app.mount('#app');

View File

@@ -0,0 +1,34 @@
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
component: () => import('../views/HomeView.vue'),
},
{
path: '/vote',
name: 'vote',
component: () => import('../views/VoteView.vue'),
},
{
path: '/vote/:category',
name: 'vote-category',
component: () => import('../views/VoteCategoryView.vue'),
},
{
path: '/result',
name: 'result',
component: () => import('../views/ResultView.vue'),
},
{
path: '/profile',
name: 'profile',
component: () => import('../views/ProfileView.vue'),
},
],
});
export default router;

View File

@@ -0,0 +1,248 @@
import { defineStore } from 'pinia';
import { ref, computed, shallowRef } from 'vue';
import { io, type Socket } from 'socket.io-client';
import type {
ServerToClientEvents,
ClientToServerEvents,
ConnectionAckPayload,
VoteCategory,
SyncStatePayload,
} from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
import { CONFIG } from '@gala/shared/constants';
type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
export const useConnectionStore = defineStore('connection', () => {
// State - use shallowRef for socket to avoid deep reactivity issues
const socket = shallowRef<GalaSocket | null>(null);
const isConnected = ref(false);
const isConnecting = ref(false);
const sessionId = ref<string | null>(null);
const lastPingTime = ref<number>(0);
const latency = ref<number>(0);
const reconnectAttempts = ref(0);
const userId = ref<string | null>(null);
const userName = ref<string | null>(null);
const votedCategories = ref<VoteCategory[]>([]);
// Computed
const connectionStatus = computed(() => {
if (isConnected.value) return 'connected';
if (isConnecting.value) return 'connecting';
return 'disconnected';
});
const remainingVotes = computed(() => {
return CONFIG.MAX_VOTES_PER_USER - votedCategories.value.length;
});
// Heartbeat interval
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let pongTimeout: ReturnType<typeof setTimeout> | null = null;
/**
* Connect to WebSocket server
*/
function connect() {
if (socket.value?.connected || isConnecting.value) {
return;
}
isConnecting.value = true;
const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', {
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: CONFIG.RECONNECTION_DELAY_MS,
reconnectionDelayMax: CONFIG.RECONNECTION_DELAY_MAX_MS,
timeout: CONFIG.HEARTBEAT_TIMEOUT_MS,
transports: ['websocket', 'polling'],
});
// Connection events
socketInstance.on('connect', () => {
console.log('[Socket] Connected');
isConnected.value = true;
isConnecting.value = false;
reconnectAttempts.value = 0;
// Join with user info
joinRoom();
// Start heartbeat
startHeartbeat();
});
socketInstance.on('disconnect', (reason) => {
console.log('[Socket] Disconnected:', reason);
isConnected.value = false;
stopHeartbeat();
});
socketInstance.on('connect_error', (error) => {
console.error('[Socket] Connection error:', error);
isConnecting.value = false;
reconnectAttempts.value++;
});
// Custom pong handler
socketInstance.on('connection:pong' as any, () => {
if (pongTimeout) {
clearTimeout(pongTimeout);
pongTimeout = null;
}
latency.value = Date.now() - lastPingTime.value;
});
// Connection acknowledgment
socketInstance.on('connection:ack' as any, (data: ConnectionAckPayload) => {
sessionId.value = data.sessionId;
if (data.reconnected && data.missedEvents) {
// Handle missed events
console.log('[Socket] Reconnected, processing missed events');
}
});
// Sync state
socketInstance.on('sync:state' as any, (data: SyncStatePayload) => {
if (data.userVotedCategories) {
votedCategories.value = data.userVotedCategories;
}
});
socket.value = socketInstance as GalaSocket;
}
/**
* Join room with user info
*/
function joinRoom() {
if (!socket.value || !userId.value) return;
socket.value.emit(
SOCKET_EVENTS.CONNECTION_JOIN as any,
{
userId: userId.value,
userName: userName.value || 'Guest',
role: 'user',
},
(response: any) => {
if (response.success) {
sessionId.value = response.data.sessionId;
if (response.data.votedCategories) {
votedCategories.value = response.data.votedCategories;
}
}
}
);
}
/**
* Start heartbeat
*/
function startHeartbeat() {
stopHeartbeat();
heartbeatInterval = setInterval(() => {
if (socket.value?.connected) {
lastPingTime.value = Date.now();
socket.value.emit(SOCKET_EVENTS.CONNECTION_PING as any);
// Set pong timeout
pongTimeout = setTimeout(() => {
console.warn('[Socket] Pong timeout, connection may be unstable');
}, CONFIG.HEARTBEAT_TIMEOUT_MS);
}
}, CONFIG.HEARTBEAT_INTERVAL_MS);
}
/**
* Stop heartbeat
*/
function stopHeartbeat() {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
if (pongTimeout) {
clearTimeout(pongTimeout);
pongTimeout = null;
}
}
/**
* Disconnect from server
*/
function disconnect() {
stopHeartbeat();
if (socket.value) {
socket.value.disconnect();
socket.value = null;
}
isConnected.value = false;
isConnecting.value = false;
}
/**
* Set user info
*/
function setUser(id: string, name: string) {
userId.value = id;
userName.value = name;
// Rejoin if already connected
if (socket.value?.connected) {
joinRoom();
}
}
/**
* Add voted category
*/
function addVotedCategory(category: VoteCategory) {
if (!votedCategories.value.includes(category)) {
votedCategories.value.push(category);
}
}
/**
* Request sync from server
*/
function requestSync() {
if (socket.value?.connected) {
socket.value.emit(SOCKET_EVENTS.SYNC_REQUEST as any, {});
}
}
/**
* Get socket instance (for advanced usage)
*/
function getSocket(): GalaSocket | null {
return socket.value;
}
return {
// State (excluding socket to avoid type inference issues)
isConnected,
isConnecting,
sessionId,
latency,
reconnectAttempts,
userId,
userName,
votedCategories,
// Computed
connectionStatus,
remainingVotes,
// Actions
connect,
disconnect,
setUser,
addVotedCategory,
requestSync,
getSocket,
};
});

View File

@@ -0,0 +1,275 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useConnectionStore } from '../stores/connection';
import { showToast } from 'vant';
const router = useRouter();
const connectionStore = useConnectionStore();
const userName = ref('');
const isLoading = ref(false);
async function handleEnter() {
if (!userName.value.trim()) {
showToast('请输入您的姓名');
return;
}
isLoading.value = true;
// Generate a simple user ID (in production, this would come from auth)
const odrawId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
connectionStore.setUser(odrawId, userName.value.trim());
// Wait for connection
await new Promise((resolve) => setTimeout(resolve, 500));
isLoading.value = false;
router.push('/vote');
}
</script>
<template>
<div class="home-view safe-area-top safe-area-bottom">
<!-- Header decoration -->
<div class="header-decoration">
<div class="lantern left"></div>
<div class="lantern right"></div>
</div>
<!-- Main content -->
<div class="content">
<div class="logo-section">
<div class="year-badge">
<span class="year">2026</span>
<span class="zodiac">马年</span>
</div>
<h1 class="title gold-text">年会互动系统</h1>
<p class="subtitle">投票 · 抽奖 · 互动</p>
</div>
<div class="form-section">
<div class="input-wrapper guochao-border">
<van-field
v-model="userName"
placeholder="请输入您的姓名"
:border="false"
clearable
maxlength="20"
@keyup.enter="handleEnter"
/>
</div>
<van-button
class="enter-btn"
type="primary"
block
round
:loading="isLoading"
loading-text="进入中..."
@click="handleEnter"
>
进入年会
</van-button>
</div>
<!-- Features -->
<div class="features">
<div class="feature-item">
<van-icon name="like-o" size="24" color="#c41230" />
<span>投票评选</span>
</div>
<div class="feature-item">
<van-icon name="gift-o" size="24" color="#d4a84b" />
<span>幸运抽奖</span>
</div>
<div class="feature-item">
<van-icon name="chart-trending-o" size="24" color="#c41230" />
<span>实时结果</span>
</div>
</div>
</div>
<!-- Footer -->
<div class="footer">
<p>© 2026 公司年会</p>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '../assets/styles/variables.scss';
.home-view {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #fff5f5 0%, #fef8f0 50%, #ffffff 100%);
position: relative;
overflow: hidden;
}
.header-decoration {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 120px;
pointer-events: none;
.lantern {
position: absolute;
top: -20px;
width: 40px;
height: 60px;
background: linear-gradient(180deg, $color-primary 0%, $color-primary-dark 100%);
border-radius: 50% 50% 45% 45%;
box-shadow: 0 4px 12px rgba($color-primary, 0.3);
&::before {
content: '';
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
width: 16px;
height: 12px;
background: $color-gold;
border-radius: 2px 2px 0 0;
}
&::after {
content: '';
position: absolute;
bottom: -15px;
left: 50%;
transform: translateX(-50%);
width: 2px;
height: 15px;
background: $color-gold;
}
&.left {
left: 20px;
animation: swing 3s ease-in-out infinite;
}
&.right {
right: 20px;
animation: swing 3s ease-in-out infinite 0.5s;
}
}
}
@keyframes swing {
0%,
100% {
transform: rotate(-5deg);
}
50% {
transform: rotate(5deg);
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: $spacing-xl $spacing-lg;
padding-top: 100px;
}
.logo-section {
text-align: center;
margin-bottom: $spacing-xl;
.year-badge {
display: inline-flex;
flex-direction: column;
align-items: center;
background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
color: $color-text-inverse;
padding: $spacing-sm $spacing-lg;
border-radius: $radius-lg;
margin-bottom: $spacing-md;
box-shadow: $shadow-md;
.year {
font-size: $font-size-2xl;
font-weight: bold;
}
.zodiac {
font-size: $font-size-sm;
opacity: 0.9;
}
}
.title {
font-size: 32px;
font-weight: bold;
margin-bottom: $spacing-sm;
}
.subtitle {
color: $color-text-secondary;
font-size: $font-size-md;
}
}
.form-section {
margin-bottom: $spacing-xl;
.input-wrapper {
background: $color-bg-card;
margin-bottom: $spacing-md;
padding: $spacing-xs;
:deep(.van-field) {
background: transparent;
.van-field__control {
text-align: center;
font-size: $font-size-lg;
}
}
}
.enter-btn {
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
border: none;
height: 48px;
font-size: $font-size-lg;
font-weight: 500;
box-shadow: $shadow-md;
}
}
.features {
display: flex;
justify-content: space-around;
padding: $spacing-md 0;
.feature-item {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-xs;
span {
font-size: $font-size-sm;
color: $color-text-secondary;
}
}
}
.footer {
text-align: center;
padding: $spacing-md;
color: $color-text-muted;
font-size: $font-size-xs;
}
</style>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { useConnectionStore } from '../stores/connection';
const connectionStore = useConnectionStore();
</script>
<template>
<div class="profile-view safe-area-top safe-area-bottom">
<van-nav-bar title="我的" left-arrow @click-left="$router.back()" />
<div class="content">
<van-cell-group inset>
<van-cell title="用户名" :value="connectionStore.userName || '未登录'" />
<van-cell title="已投票数" :value="`${connectionStore.votedCategories.length}/7`" />
<van-cell title="连接状态" :value="connectionStore.connectionStatus" />
</van-cell-group>
</div>
</div>
</template>
<style lang="scss" scoped>
.profile-view {
min-height: 100vh;
background: #f5f5f5;
}
.content {
padding: 16px;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
// Placeholder view
</script>
<template>
<div class="result-view safe-area-top safe-area-bottom">
<van-nav-bar title="投票结果" left-arrow @click-left="$router.back()" />
<div class="content">
<van-empty description="结果统计中..." />
</div>
</div>
</template>
<style lang="scss" scoped>
.result-view {
min-height: 100vh;
background: #f5f5f5;
}
.content {
padding: 16px;
}
</style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
// Placeholder view
</script>
<template>
<div class="vote-category-view safe-area-top safe-area-bottom">
<van-nav-bar title="投票" left-arrow @click-left="$router.back()" />
<div class="content">
<van-empty description="候选人列表加载中..." />
</div>
</div>
</template>
<style lang="scss" scoped>
.vote-category-view {
min-height: 100vh;
background: #f5f5f5;
}
.content {
padding: 16px;
}
</style>

View File

@@ -0,0 +1,227 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useConnectionStore } from '../stores/connection';
import { VOTE_CATEGORIES } from '@gala/shared/types';
import type { VoteCategory } from '@gala/shared/types';
const router = useRouter();
const connectionStore = useConnectionStore();
// Category display info
const categoryInfo: Record<VoteCategory, { name: string; icon: string; color: string }> = {
best_employee: { name: '最佳员工', icon: 'star-o', color: '#c41230' },
best_team: { name: '最佳团队', icon: 'friends-o', color: '#d4a84b' },
best_newcomer: { name: '最佳新人', icon: 'fire-o', color: '#52c41a' },
best_innovation: { name: '最佳创新', icon: 'bulb-o', color: '#1890ff' },
best_service: { name: '最佳服务', icon: 'service-o', color: '#722ed1' },
best_collaboration: { name: '最佳协作', icon: 'cluster-o', color: '#fa8c16' },
best_leadership: { name: '最佳领导力', icon: 'medal-o', color: '#eb2f96' },
};
const categories = computed(() => {
return VOTE_CATEGORIES.map((category) => ({
id: category,
...categoryInfo[category],
voted: connectionStore.votedCategories.includes(category),
}));
});
const votedCount = computed(() => connectionStore.votedCategories.length);
const totalCategories = VOTE_CATEGORIES.length;
function goToCategory(category: VoteCategory) {
router.push(`/vote/${category}`);
}
function goToResults() {
router.push('/result');
}
</script>
<template>
<div class="vote-view safe-area-top safe-area-bottom">
<!-- Header -->
<div class="header">
<h1 class="title">投票评选</h1>
<div class="progress-info">
<span class="progress-text">已投 {{ votedCount }}/{{ totalCategories }}</span>
<van-progress
:percentage="(votedCount / totalCategories) * 100"
:show-pivot="false"
color="#c41230"
track-color="#f5f5f5"
stroke-width="6"
/>
</div>
</div>
<!-- Category list -->
<div class="category-list">
<div
v-for="category in categories"
:key="category.id"
class="category-card"
:class="{ voted: category.voted }"
@click="goToCategory(category.id)"
>
<div class="card-icon" :style="{ backgroundColor: category.color + '15' }">
<van-icon :name="category.icon" :color="category.color" size="28" />
</div>
<div class="card-content">
<h3 class="card-title">{{ category.name }}</h3>
<p class="card-status">
<template v-if="category.voted">
<van-icon name="success" color="#52c41a" />
<span class="voted-text">已投票</span>
</template>
<template v-else>
<span class="pending-text">待投票</span>
</template>
</p>
</div>
<van-icon name="arrow" class="card-arrow" color="#999" />
</div>
</div>
<!-- Bottom action -->
<div class="bottom-action">
<van-button
type="primary"
block
round
:disabled="votedCount === 0"
@click="goToResults"
>
查看投票结果
</van-button>
</div>
</div>
</template>
<style lang="scss" scoped>
@import '../assets/styles/variables.scss';
.vote-view {
min-height: 100vh;
background: $color-bg-primary;
padding-bottom: 80px;
}
.header {
background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
color: $color-text-inverse;
padding: $spacing-lg;
padding-top: calc(env(safe-area-inset-top) + #{$spacing-lg});
.title {
font-size: $font-size-2xl;
font-weight: bold;
margin-bottom: $spacing-md;
}
.progress-info {
.progress-text {
display: block;
font-size: $font-size-sm;
margin-bottom: $spacing-xs;
opacity: 0.9;
}
:deep(.van-progress) {
background: rgba(255, 255, 255, 0.3);
.van-progress__portion {
background: $color-gold;
}
}
}
}
.category-list {
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.category-card {
display: flex;
align-items: center;
background: $color-bg-card;
border-radius: $radius-lg;
padding: $spacing-md;
box-shadow: $shadow-sm;
transition: all $transition-normal;
&:active {
transform: scale(0.98);
box-shadow: $shadow-md;
}
&.voted {
background: linear-gradient(135deg, #f6ffed 0%, #ffffff 100%);
border: 1px solid #b7eb8f;
}
.card-icon {
width: 52px;
height: 52px;
border-radius: $radius-md;
display: flex;
align-items: center;
justify-content: center;
margin-right: $spacing-md;
}
.card-content {
flex: 1;
.card-title {
font-size: $font-size-lg;
font-weight: 500;
color: $color-text-primary;
margin-bottom: $spacing-xs;
}
.card-status {
display: flex;
align-items: center;
gap: $spacing-xs;
font-size: $font-size-sm;
.voted-text {
color: $color-success;
}
.pending-text {
color: $color-text-muted;
}
}
}
.card-arrow {
margin-left: $spacing-sm;
}
}
.bottom-action {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: $spacing-md;
padding-bottom: calc(env(safe-area-inset-bottom) + #{$spacing-md});
background: $color-bg-card;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
:deep(.van-button--primary) {
background: linear-gradient(135deg, $color-primary-light 0%, $color-primary 100%);
border: none;
}
:deep(.van-button--disabled) {
opacity: 0.5;
}
}
</style>

View File

@@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,91 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite';
import AutoImport from 'unplugin-auto-import/vite';
import { VantResolver } from '@vant/auto-import-resolver';
import { VitePWA } from 'vite-plugin-pwa';
import { resolve } from 'path';
export default defineConfig({
plugins: [
vue(),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
dts: 'src/auto-imports.d.ts',
}),
Components({
resolvers: [VantResolver()],
dts: 'src/components.d.ts',
}),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
manifest: {
name: '年会互动系统',
short_name: '年会投票',
description: '公司年会投票与抽奖互动系统',
theme_color: '#c41230',
background_color: '#ffffff',
display: 'standalone',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\./i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60, // 1 hour
},
},
},
],
},
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3000',
ws: true,
},
},
},
build: {
target: 'es2020',
minify: 'terser',
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
vant: ['vant'],
},
},
},
},
});

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>年会大屏 - 抽奖互动</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; }
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
{
"name": "@gala/client-screen",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@gala/shared": "workspace:*",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"pinia": "^2.3.0",
"pixi.js": "^8.6.6",
"@pixi/particle-emitter": "^5.0.8",
"gsap": "^3.12.7",
"socket.io-client": "^4.8.1",
"howler": "^2.2.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7",
"sass": "^1.83.1",
"typescript": "^5.7.3",
"vue-tsc": "^2.2.0",
"@types/howler": "^2.2.12"
}
}

View File

@@ -0,0 +1,111 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { useDisplayStore } from './stores/display';
const displayStore = useDisplayStore();
const isFullscreen = ref(false);
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
isFullscreen.value = true;
} else {
document.exitFullscreen();
isFullscreen.value = false;
}
}
onMounted(() => {
displayStore.connect();
// Listen for fullscreen changes
document.addEventListener('fullscreenchange', () => {
isFullscreen.value = !!document.fullscreenElement;
});
});
onUnmounted(() => {
displayStore.disconnect();
});
</script>
<template>
<div class="screen-app" @dblclick="toggleFullscreen">
<router-view />
<!-- Fullscreen hint -->
<div v-if="!isFullscreen" class="fullscreen-hint">
双击进入全屏模式
</div>
<!-- Connection status -->
<div class="connection-indicator" :class="displayStore.connectionStatus">
<span class="dot"></span>
<span class="text">{{ displayStore.connectionStatus === 'connected' ? '已连接' : '连接中...' }}</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.screen-app {
width: 100vw;
height: 100vh;
overflow: hidden;
background: #0a0a0a;
position: relative;
}
.fullscreen-hint {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.6);
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
pointer-events: none;
animation: fadeInOut 3s ease-in-out infinite;
}
@keyframes fadeInOut {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.8; }
}
.connection-indicator {
position: fixed;
top: 20px;
right: 20px;
display: flex;
align-items: center;
gap: 8px;
background: rgba(0, 0, 0, 0.5);
padding: 8px 16px;
border-radius: 20px;
font-size: 12px;
color: white;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ff4d4f;
}
&.connected .dot {
background: #52c41a;
}
&.connecting .dot {
background: #faad14;
animation: pulse 1s infinite;
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>

View File

@@ -0,0 +1,86 @@
@import './variables.scss';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: $color-bg-dark;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
color: $color-text-light;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
// Gold gradient text
.gold-text {
background: linear-gradient(135deg, $color-gold-light 0%, $color-gold 50%, $color-gold-dark 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
// Red glow text
.glow-text {
text-shadow: $glow-red;
}
// Gold glow text
.gold-glow-text {
text-shadow: $glow-gold;
}
// Decorative border
.guochao-frame {
border: 2px solid $color-gold;
position: relative;
&::before,
&::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
border: 2px solid $color-gold;
}
&::before {
top: -10px;
left: -10px;
border-right: none;
border-bottom: none;
}
&::after {
bottom: -10px;
right: -10px;
border-left: none;
border-top: none;
}
}
// Animation keyframes
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes glow-pulse {
0%, 100% { opacity: 1; filter: brightness(1); }
50% { opacity: 0.8; filter: brightness(1.2); }
}
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes scale-pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}

View File

@@ -0,0 +1,27 @@
// Guochao Red & Gold Theme for Big Screen
// Primary colors
$color-primary: #c41230;
$color-primary-light: #e8384f;
$color-primary-dark: #9a0e26;
$color-gold: #d4a84b;
$color-gold-light: #f0c96a;
$color-gold-dark: #b8923f;
// Background
$color-bg-dark: #0a0a0a;
$color-bg-gradient: linear-gradient(180deg, #1a0a0a 0%, #0a0a0a 50%, #0a0510 100%);
// Text
$color-text-light: #ffffff;
$color-text-muted: rgba(255, 255, 255, 0.6);
// Glow effects
$glow-red: 0 0 20px rgba($color-primary, 0.5), 0 0 40px rgba($color-primary, 0.3);
$glow-gold: 0 0 20px rgba($color-gold, 0.5), 0 0 40px rgba($color-gold, 0.3);
// Animations
$transition-fast: 0.2s ease;
$transition-normal: 0.4s ease;
$transition-slow: 0.8s ease;

16
packages/client-screen/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_SOCKET_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,14 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
// Global styles
import './assets/styles/global.scss';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');

View File

@@ -0,0 +1,29 @@
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'main',
component: () => import('../views/MainDisplay.vue'),
},
{
path: '/draw',
name: 'draw',
component: () => import('../views/LuckyDrawView.vue'),
},
{
path: '/results',
name: 'results',
component: () => import('../views/VoteResultsView.vue'),
},
{
path: '/admin',
name: 'admin',
component: () => import('../views/AdminControl.vue'),
},
],
});
export default router;

View File

@@ -0,0 +1,158 @@
import { defineStore } from 'pinia';
import { ref, computed, shallowRef } from 'vue';
import { io, Socket } from 'socket.io-client';
import type {
ServerToClientEvents,
ClientToServerEvents,
DrawStartPayload,
DrawWinnerPayload,
VoteUpdatePayload,
} from '@gala/shared/types';
import { SOCKET_EVENTS } from '@gala/shared/constants';
type GalaSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
export const useDisplayStore = defineStore('display', () => {
// State - use shallowRef for socket to avoid deep reactivity issues
const socket = shallowRef<GalaSocket | null>(null);
const isConnected = ref(false);
const isConnecting = ref(false);
const onlineUsers = ref(0);
const currentMode = ref<'idle' | 'voting' | 'draw' | 'results'>('idle');
// Draw state
const isDrawing = ref(false);
const currentPrize = ref<string | null>(null);
const currentWinner = ref<{
userId: string;
userName: string;
department: string;
avatar?: string;
} | null>(null);
// Computed
const connectionStatus = computed(() => {
if (isConnected.value) return 'connected';
if (isConnecting.value) return 'connecting';
return 'disconnected';
});
/**
* Connect to WebSocket server
*/
function connect() {
if (socket.value?.connected || isConnecting.value) {
return;
}
isConnecting.value = true;
const socketInstance = io(import.meta.env.VITE_SOCKET_URL || '', {
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
timeout: 10000,
transports: ['websocket', 'polling'],
});
// Connection events
socketInstance.on('connect', () => {
console.log('[Screen] Connected');
isConnected.value = true;
isConnecting.value = false;
// Join as screen
socketInstance.emit(SOCKET_EVENTS.CONNECTION_JOIN as any, {
userId: 'screen_main',
userName: 'Main Display',
role: 'screen',
}, () => {});
});
socketInstance.on('disconnect', (reason) => {
console.log('[Screen] Disconnected:', reason);
isConnected.value = false;
});
socketInstance.on('connect_error', (error) => {
console.error('[Screen] Connection error:', error);
isConnecting.value = false;
});
// User count updates
socketInstance.on('connection:users_count' as any, (count: number) => {
onlineUsers.value = count;
});
// Draw events
socketInstance.on('draw:started' as any, (data: DrawStartPayload) => {
isDrawing.value = true;
currentPrize.value = data.prizeName;
currentWinner.value = null;
currentMode.value = 'draw';
});
socketInstance.on('draw:winner' as any, (data: DrawWinnerPayload) => {
currentWinner.value = data.winner;
});
socketInstance.on('draw:ended' as any, () => {
isDrawing.value = false;
});
// Vote updates
socketInstance.on('vote:updated' as any, (data: VoteUpdatePayload) => {
// Emit custom event for components to handle
window.dispatchEvent(new CustomEvent('vote:updated', { detail: data }));
});
socket.value = socketInstance as GalaSocket;
}
/**
* Disconnect from server
*/
function disconnect() {
if (socket.value) {
socket.value.disconnect();
socket.value = null;
}
isConnected.value = false;
isConnecting.value = false;
}
/**
* Set display mode
*/
function setMode(mode: 'idle' | 'voting' | 'draw' | 'results') {
currentMode.value = mode;
}
/**
* Get socket instance (for advanced usage)
*/
function getSocket(): GalaSocket | null {
return socket.value;
}
return {
// State (excluding socket to avoid type inference issues)
isConnected,
isConnecting,
onlineUsers,
currentMode,
isDrawing,
currentPrize,
currentWinner,
// Computed
connectionStatus,
// Actions
connect,
disconnect,
setMode,
getSocket,
};
});

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>

View File

@@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,41 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3000',
ws: true,
},
},
},
build: {
target: 'es2020',
minify: 'terser',
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
pixi: ['pixi.js'],
gsap: ['gsap'],
},
},
},
},
optimizeDeps: {
include: ['pixi.js', 'gsap'],
},
});

View File

@@ -0,0 +1,21 @@
# Server Environment Variables
# Server
PORT=3000
NODE_ENV=development
# CORS (comma-separated origins)
CORS_ORIGINS=http://localhost:5173,http://localhost:5174
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# MySQL (Prisma)
DATABASE_URL="mysql://root:password@localhost:3306/gala"
# JWT
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=24h

View File

@@ -0,0 +1,44 @@
{
"name": "@gala/server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsup src/index.ts --format esm --target node20 --clean",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "tsx src/scripts/seed.ts",
"test": "vitest",
"test:load": "echo 'Load tests not configured yet'"
},
"dependencies": {
"@gala/shared": "workspace:*",
"express": "^4.21.2",
"socket.io": "^4.8.1",
"@socket.io/redis-adapter": "^8.3.0",
"ioredis": "^5.4.2",
"@prisma/client": "^6.2.1",
"zod": "^3.24.1",
"cors": "^2.8.5",
"helmet": "^8.0.0",
"compression": "^1.7.5",
"dotenv": "^16.4.7",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"nanoid": "^5.0.9"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/cors": "^2.8.17",
"@types/compression": "^1.7.5",
"prisma": "^6.2.1",
"tsx": "^4.19.2",
"tsup": "^8.3.5",
"typescript": "^5.7.3",
"vitest": "^2.1.8"
}
}

View File

@@ -0,0 +1,115 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
// Users table
model User {
id String @id @default(cuid())
name String @db.VarChar(100)
department String @db.VarChar(100)
avatar String? @db.VarChar(512)
birthYear Int? @map("birth_year")
zodiac String? @db.VarChar(20)
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
votes Vote[]
drawResults DrawResult[]
@@map("users")
}
// Candidates for voting
model Candidate {
id String @id @default(cuid())
name String @db.VarChar(100)
department String @db.VarChar(100)
avatar String? @db.VarChar(512)
description String? @db.Text
category String @db.VarChar(50)
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
votes Vote[]
voteCounts VoteCount[]
@@index([category])
@@map("candidates")
}
// Individual votes
model Vote {
id String @id @default(cuid())
userId String @map("user_id")
candidateId String @map("candidate_id")
category String @db.VarChar(50)
localId String? @map("local_id") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id])
candidate Candidate @relation(fields: [candidateId], references: [id])
@@unique([userId, category])
@@index([category, candidateId])
@@index([createdAt])
@@map("votes")
}
// Aggregated vote counts (denormalized for performance)
model VoteCount {
id String @id @default(cuid())
candidateId String @map("candidate_id")
category String @db.VarChar(50)
count Int @default(0)
updatedAt DateTime @updatedAt @map("updated_at")
candidate Candidate @relation(fields: [candidateId], references: [id])
@@unique([candidateId, category])
@@index([category, count(sort: Desc)])
@@map("vote_counts")
}
// Lucky draw results
model DrawResult {
id String @id @default(cuid())
drawId String @map("draw_id")
prizeLevel String @map("prize_level") @db.VarChar(20)
prizeName String @map("prize_name") @db.VarChar(100)
winnerId String @map("winner_id")
winnerName String @map("winner_name") @db.VarChar(100)
winnerDepartment String @map("winner_department") @db.VarChar(100)
drawnAt DateTime @default(now()) @map("drawn_at")
drawnBy String @map("drawn_by") @db.VarChar(100)
winner User @relation(fields: [winnerId], references: [id])
@@index([drawId])
@@index([prizeLevel])
@@index([winnerId])
@@map("draw_results")
}
// Draw sessions
model DrawSession {
id String @id @default(cuid())
prizeLevel String @map("prize_level") @db.VarChar(20)
prizeName String @map("prize_name") @db.VarChar(100)
totalPrizes Int @map("total_prizes")
drawnCount Int @default(0) @map("drawn_count")
isActive Boolean @default(false) @map("is_active")
filters Json?
startedAt DateTime? @map("started_at")
endedAt DateTime? @map("ended_at")
createdAt DateTime @default(now()) @map("created_at")
@@index([prizeLevel])
@@index([isActive])
@@map("draw_sessions")
}

View File

@@ -0,0 +1,52 @@
import express, { Application } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import { config } from './config';
import { logger } from './utils/logger';
import { errorHandler } from './middleware/errorHandler';
import { requestLogger } from './middleware/requestLogger';
import voteRoutes from './routes/vote.routes';
import adminRoutes from './routes/admin.routes';
export const app: Application = express();
// Security middleware
app.use(helmet());
// CORS
app.use(
cors({
origin: config.corsOrigins,
credentials: true,
})
);
// Compression
app.use(compression());
// Body parsing
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
// Request logging
app.use(requestLogger);
// Health check
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API routes
app.use('/api/vote', voteRoutes);
app.use('/api/admin', adminRoutes);
// 404 handler
app.use((_req, res) => {
res.status(404).json({ error: 'Not Found' });
});
// Error handler
app.use(errorHandler);
export { logger };

View File

@@ -0,0 +1,47 @@
import 'dotenv/config';
export const config = {
// Server
port: parseInt(process.env.PORT || '3000', 10),
nodeEnv: process.env.NODE_ENV || 'development',
isDev: process.env.NODE_ENV !== 'production',
// CORS
corsOrigins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:5173', 'http://localhost:5174'],
// Redis
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB || '0', 10),
},
// MySQL (via Prisma)
databaseUrl: process.env.DATABASE_URL || 'mysql://root:password@localhost:3306/gala',
// JWT (for session tokens)
jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
// Socket.io
socket: {
pingTimeout: 10000,
pingInterval: 5000,
maxHttpBufferSize: 1e6, // 1MB
},
// Voting
voting: {
maxVotesPerUser: 7,
lockTtlMs: 5000,
},
// Sync
sync: {
batchSize: 100,
intervalMs: 1000,
},
} as const;
export type Config = typeof config;

View File

@@ -0,0 +1,63 @@
import Redis from 'ioredis';
import { config } from './index';
import { logger } from '../utils/logger';
export const redis = new Redis({
host: config.redis.host,
port: config.redis.port,
password: config.redis.password,
db: config.redis.db,
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 100, 3000);
logger.warn({ times, delay }, 'Redis connection retry');
return delay;
},
lazyConnect: true,
});
// Connection event handlers
redis.on('connect', () => {
logger.info('Redis connected');
});
redis.on('ready', () => {
logger.info('Redis ready');
});
redis.on('error', (err) => {
logger.error({ err }, 'Redis error');
});
redis.on('close', () => {
logger.warn('Redis connection closed');
});
redis.on('reconnecting', () => {
logger.info('Redis reconnecting...');
});
/**
* Connect to Redis
*/
export async function connectRedis(): Promise<void> {
try {
await redis.connect();
// Test connection
await redis.ping();
logger.info('Redis connection established');
} catch (error) {
logger.error({ error }, 'Failed to connect to Redis');
throw error;
}
}
/**
* Disconnect from Redis
*/
export async function disconnectRedis(): Promise<void> {
await redis.quit();
logger.info('Redis disconnected');
}
export { Redis };

View File

@@ -0,0 +1,55 @@
import { createServer } from 'http';
import { app, logger } from './app';
import { config } from './config';
import { connectRedis } from './config/redis';
import { initializeSocket } from './socket';
import { loadLuaScripts } from './services/vote.service';
async function main(): Promise<void> {
try {
// Connect to Redis
logger.info('Connecting to Redis...');
await connectRedis();
// Load Lua scripts
logger.info('Loading Lua scripts...');
await loadLuaScripts();
// Create HTTP server
const httpServer = createServer(app);
// Initialize Socket.io
logger.info('Initializing Socket.io...');
await initializeSocket(httpServer);
// Start server
httpServer.listen(config.port, () => {
logger.info({ port: config.port, env: config.nodeEnv }, 'Server started');
logger.info(`Health check: http://localhost:${config.port}/health`);
});
// Graceful shutdown
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
httpServer.close(() => {
logger.info('HTTP server closed');
process.exit(0);
});
// Force exit after 10 seconds
setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
} catch (error) {
logger.error({ error }, 'Failed to start server');
process.exit(1);
}
}
main();

View File

@@ -0,0 +1,11 @@
-- check_user_votes.lua
-- Check which categories a user has voted in
--
-- KEYS[1] = vote:user:{user_id}:categories
-- Returns: Set of category IDs the user has voted in
local user_categories_key = KEYS[1]
local categories = redis.call('SMEMBERS', user_categories_key)
return cjson.encode(categories)

View File

@@ -0,0 +1,21 @@
-- get_category_results.lua
-- Get top candidates for a category from leaderboard
--
-- KEYS[1] = leaderboard:{category}
-- ARGV[1] = limit (top N results)
local leaderboard_key = KEYS[1]
local limit = tonumber(ARGV[1]) or 10
-- Get top candidates with scores (descending order)
local results = redis.call('ZREVRANGE', leaderboard_key, 0, limit - 1, 'WITHSCORES')
local formatted = {}
for i = 1, #results, 2 do
table.insert(formatted, {
candidate_id = results[i],
vote_count = tonumber(results[i + 1])
})
end
return cjson.encode(formatted)

View File

@@ -0,0 +1,98 @@
-- vote_submit.lua
-- Atomic vote submission with distributed locking
--
-- KEYS[1] = vote:count:{category}
-- KEYS[2] = vote:user:{user_id}:categories
-- KEYS[3] = vote:category:{category}:voters
-- KEYS[4] = leaderboard:{category}
-- KEYS[5] = sync:queue:votes
-- KEYS[6] = lock:vote:{user_id}:{category}
--
-- ARGV[1] = candidate_id
-- ARGV[2] = user_id
-- ARGV[3] = category
-- ARGV[4] = timestamp
-- ARGV[5] = local_id (client UUID)
-- ARGV[6] = lock_ttl_ms
-- ARGV[7] = max_categories
local vote_count_key = KEYS[1]
local user_categories_key = KEYS[2]
local category_voters_key = KEYS[3]
local leaderboard_key = KEYS[4]
local sync_queue_key = KEYS[5]
local lock_key = KEYS[6]
local candidate_id = ARGV[1]
local user_id = ARGV[2]
local category = ARGV[3]
local timestamp = ARGV[4]
local local_id = ARGV[5]
local lock_ttl = tonumber(ARGV[6])
local max_categories = tonumber(ARGV[7])
-- Step 1: Acquire distributed lock (prevent concurrent double-vote attempts)
local lock_acquired = redis.call('SET', lock_key, timestamp, 'NX', 'PX', lock_ttl)
if not lock_acquired then
return cjson.encode({
success = false,
error = 'LOCK_FAILED',
message = 'Another vote operation in progress'
})
end
-- Step 2: Check if user already voted in this category
local already_voted = redis.call('SISMEMBER', category_voters_key, user_id)
if already_voted == 1 then
redis.call('DEL', lock_key)
return cjson.encode({
success = false,
error = 'ALREADY_VOTED',
message = 'User already voted in this category'
})
end
-- Step 3: Check if user has exceeded max votes (7 categories)
local user_vote_count = redis.call('SCARD', user_categories_key)
if user_vote_count >= max_categories then
redis.call('DEL', lock_key)
return cjson.encode({
success = false,
error = 'MAX_VOTES_REACHED',
message = 'User has voted in all categories'
})
end
-- Step 4: Perform atomic vote operations
-- 4a: Increment vote count for candidate
local new_count = redis.call('HINCRBY', vote_count_key, candidate_id, 1)
-- 4b: Add category to user's voted categories
redis.call('SADD', user_categories_key, category)
-- 4c: Add user to category's voters
redis.call('SADD', category_voters_key, user_id)
-- 4d: Update leaderboard (sorted set)
redis.call('ZINCRBY', leaderboard_key, 1, candidate_id)
-- Step 5: Queue for MySQL sync
local vote_record = cjson.encode({
user_id = user_id,
category = category,
candidate_id = candidate_id,
timestamp = timestamp,
local_id = local_id
})
redis.call('RPUSH', sync_queue_key, vote_record)
-- Step 6: Release lock
redis.call('DEL', lock_key)
-- Return success with new count
return cjson.encode({
success = true,
candidate_id = candidate_id,
new_count = new_count,
user_total_votes = user_vote_count + 1
})

View File

@@ -0,0 +1,32 @@
import type { Request, Response, NextFunction } from 'express';
import { logger } from '../utils/logger';
export interface AppError extends Error {
statusCode?: number;
code?: string;
}
export function errorHandler(
err: AppError,
_req: Request,
res: Response,
_next: NextFunction
): void {
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
logger.error(
{
err,
statusCode,
code: err.code,
},
'Request error'
);
res.status(statusCode).json({
success: false,
error: err.code || 'INTERNAL_ERROR',
message: statusCode === 500 ? 'Internal Server Error' : message,
});
}

View File

@@ -0,0 +1,40 @@
import type { Request, Response, NextFunction } from 'express';
import { logger } from '../utils/logger';
import { nanoid } from 'nanoid';
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
const requestId = nanoid(10);
const startTime = Date.now();
// Attach request ID
req.headers['x-request-id'] = requestId;
// Log request
logger.info(
{
requestId,
method: req.method,
url: req.url,
ip: req.ip,
userAgent: req.get('user-agent'),
},
'Incoming request'
);
// Log response on finish
res.on('finish', () => {
const duration = Date.now() - startTime;
logger.info(
{
requestId,
method: req.method,
url: req.url,
statusCode: res.statusCode,
duration,
},
'Request completed'
);
});
next();
}

View File

@@ -0,0 +1,57 @@
import { Router, IRouter } from 'express';
const router: IRouter = Router();
/**
* GET /api/admin/stats
* Get system statistics
*/
router.get('/stats', async (_req, res, next) => {
try {
// TODO: Implement admin stats
return res.json({
success: true,
data: {
totalUsers: 0,
totalVotes: 0,
activeConnections: 0,
},
});
} catch (error) {
next(error);
}
});
/**
* POST /api/admin/draw/start
* Start a lucky draw
*/
router.post('/draw/start', async (_req, res, next) => {
try {
// TODO: Implement draw start
return res.json({
success: true,
message: 'Draw started',
});
} catch (error) {
next(error);
}
});
/**
* POST /api/admin/draw/stop
* Stop the current draw
*/
router.post('/draw/stop', async (_req, res, next) => {
try {
// TODO: Implement draw stop
return res.json({
success: true,
message: 'Draw stopped',
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,150 @@
import { Router, IRouter } from 'express';
import { voteService } from '../services/vote.service';
import { voteSubmitSchema } from '@gala/shared/utils';
import { VOTE_CATEGORIES } from '@gala/shared/types';
import type { VoteCategory } from '@gala/shared/types';
const router: IRouter = Router();
/**
* POST /api/vote/submit
* Submit a vote (HTTP fallback for WebSocket)
*/
router.post('/submit', async (req, res, next) => {
try {
// TODO: Get userId from auth middleware
const userId = req.headers['x-user-id'] as string;
if (!userId) {
return res.status(401).json({
success: false,
error: 'UNAUTHORIZED',
message: 'User ID required',
});
}
// Validate input
const parseResult = voteSubmitSchema.safeParse(req.body);
if (!parseResult.success) {
return res.status(400).json({
success: false,
error: 'INVALID_INPUT',
message: parseResult.error.message,
});
}
const { candidateId, category, localId } = parseResult.data;
const result = await voteService.submitVote(
userId,
category as VoteCategory,
candidateId,
localId
);
if (!result.success) {
const statusCodes: Record<string, number> = {
ALREADY_VOTED: 409,
MAX_VOTES_REACHED: 403,
LOCK_FAILED: 503,
INTERNAL_ERROR: 500,
};
return res.status(statusCodes[result.error!] || 500).json({
success: false,
error: result.error,
message: result.message,
});
}
return res.json({
success: true,
data: {
candidateId: result.candidate_id,
newCount: result.new_count,
userTotalVotes: result.user_total_votes,
},
});
} catch (error) {
next(error);
}
});
/**
* GET /api/vote/results/:category
* Get results for a specific category
*/
router.get('/results/:category', async (req, res, next) => {
try {
const { category } = req.params;
const limit = parseInt(req.query.limit as string, 10) || 10;
if (!VOTE_CATEGORIES.includes(category as VoteCategory)) {
return res.status(400).json({
success: false,
error: 'INVALID_CATEGORY',
message: 'Invalid vote category',
});
}
const results = await voteService.getCategoryResults(category as VoteCategory, limit);
return res.json({
success: true,
data: {
category,
results,
},
});
} catch (error) {
next(error);
}
});
/**
* GET /api/vote/results
* Get results for all categories
*/
router.get('/results', async (_req, res, next) => {
try {
const results = await voteService.getAllResults(VOTE_CATEGORIES as unknown as VoteCategory[]);
return res.json({
success: true,
data: results,
});
} catch (error) {
next(error);
}
});
/**
* GET /api/vote/status
* Get user's vote status
*/
router.get('/status', async (req, res, next) => {
try {
const userId = req.headers['x-user-id'] as string;
if (!userId) {
return res.status(401).json({
success: false,
error: 'UNAUTHORIZED',
message: 'User ID required',
});
}
const votedCategories = await voteService.getUserVotedCategories(userId);
return res.json({
success: true,
data: {
userId,
votedCategories,
remainingVotes: 7 - votedCategories.length,
},
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,222 @@
import { redis } from '../config/redis';
import { config } from '../config';
import { logger } from '../utils/logger';
import { REDIS_KEYS } from '@gala/shared/constants';
import type { VoteCategory } from '@gala/shared/types';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Load Lua scripts
const luaScripts = {
voteSubmit: readFileSync(join(__dirname, '../lua/vote_submit.lua'), 'utf-8'),
getCategoryResults: readFileSync(join(__dirname, '../lua/get_category_results.lua'), 'utf-8'),
checkUserVotes: readFileSync(join(__dirname, '../lua/check_user_votes.lua'), 'utf-8'),
};
// Script SHA cache
let scriptShas: Record<string, string> = {};
/**
* Load Lua scripts into Redis
*/
export async function loadLuaScripts(): Promise<void> {
try {
const [voteSubmitSha, getCategoryResultsSha, checkUserVotesSha] = await Promise.all([
redis.script('LOAD', luaScripts.voteSubmit),
redis.script('LOAD', luaScripts.getCategoryResults),
redis.script('LOAD', luaScripts.checkUserVotes),
]);
scriptShas = {
voteSubmit: voteSubmitSha as string,
getCategoryResults: getCategoryResultsSha as string,
checkUserVotes: checkUserVotesSha as string,
};
logger.info('Lua scripts loaded successfully');
} catch (error) {
logger.error({ error }, 'Failed to load Lua scripts');
throw error;
}
}
// ============================================================================
// Vote Result Types
// ============================================================================
interface VoteSubmitResult {
success: boolean;
error?: string;
message?: string;
candidate_id?: string;
new_count?: number;
user_total_votes?: number;
}
interface CategoryResult {
candidate_id: string;
vote_count: number;
}
// ============================================================================
// Vote Service
// ============================================================================
export class VoteService {
/**
* Submit a vote atomically
*/
async submitVote(
userId: string,
category: VoteCategory,
candidateId: string,
localId: string
): Promise<VoteSubmitResult> {
const timestamp = Date.now().toString();
// Build Redis keys
const keys = [
`${REDIS_KEYS.VOTE_COUNT}:${category}`,
`${REDIS_KEYS.USER_CATEGORIES}:${userId}:categories`,
`${REDIS_KEYS.CATEGORY_VOTERS}:${category}:voters`,
`${REDIS_KEYS.LEADERBOARD}:${category}`,
REDIS_KEYS.SYNC_QUEUE,
`${REDIS_KEYS.VOTE_LOCK}:${userId}:${category}`,
];
const args = [
candidateId,
userId,
category,
timestamp,
localId,
config.voting.lockTtlMs.toString(),
config.voting.maxVotesPerUser.toString(),
];
try {
const resultJson = await redis.evalsha(
scriptShas.voteSubmit,
keys.length,
...keys,
...args
);
const result = JSON.parse(resultJson as string) as VoteSubmitResult;
if (result.success) {
logger.info(
{ userId, category, candidateId, newCount: result.new_count },
'Vote submitted successfully'
);
} else {
logger.warn(
{ userId, category, candidateId, error: result.error },
'Vote submission rejected'
);
}
return result;
} catch (error) {
logger.error({ error, userId, category, candidateId }, 'Vote submission error');
return {
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to submit vote',
};
}
}
/**
* Get real-time results for a category
*/
async getCategoryResults(category: VoteCategory, limit = 10): Promise<CategoryResult[]> {
const key = `${REDIS_KEYS.LEADERBOARD}:${category}`;
try {
const resultJson = await redis.evalsha(
scriptShas.getCategoryResults,
1,
key,
limit.toString()
);
return JSON.parse(resultJson as string) as CategoryResult[];
} catch (error) {
logger.error({ error, category }, 'Failed to get category results');
return [];
}
}
/**
* Get all results for all categories
*/
async getAllResults(categories: VoteCategory[]): Promise<Record<VoteCategory, CategoryResult[]>> {
const pipeline = redis.pipeline();
for (const category of categories) {
const key = `${REDIS_KEYS.LEADERBOARD}:${category}`;
pipeline.zrevrange(key, 0, -1, 'WITHSCORES');
}
const results = await pipeline.exec();
const formatted: Record<string, CategoryResult[]> = {};
categories.forEach((category, index) => {
const [err, data] = results![index];
if (!err && data) {
const pairs = data as string[];
const categoryResults: CategoryResult[] = [];
for (let i = 0; i < pairs.length; i += 2) {
categoryResults.push({
candidate_id: pairs[i],
vote_count: parseInt(pairs[i + 1], 10),
});
}
formatted[category] = categoryResults;
} else {
formatted[category] = [];
}
});
return formatted as Record<VoteCategory, CategoryResult[]>;
}
/**
* Check which categories a user has voted in
*/
async getUserVotedCategories(userId: string): Promise<VoteCategory[]> {
const key = `${REDIS_KEYS.USER_CATEGORIES}:${userId}:categories`;
try {
const resultJson = await redis.evalsha(scriptShas.checkUserVotes, 1, key);
return JSON.parse(resultJson as string) as VoteCategory[];
} catch (error) {
logger.error({ error, userId }, 'Failed to get user voted categories');
return [];
}
}
/**
* Get vote count for a specific candidate
*/
async getCandidateVoteCount(category: VoteCategory, candidateId: string): Promise<number> {
const key = `${REDIS_KEYS.VOTE_COUNT}:${category}`;
const count = await redis.hget(key, candidateId);
return count ? parseInt(count, 10) : 0;
}
/**
* Check if user has voted in a specific category
*/
async hasUserVotedInCategory(userId: string, category: VoteCategory): Promise<boolean> {
const key = `${REDIS_KEYS.CATEGORY_VOTERS}:${category}:voters`;
const result = await redis.sismember(key, userId);
return result === 1;
}
}
export const voteService = new VoteService();

View File

@@ -0,0 +1,266 @@
import { Server as HttpServer } from 'http';
import { Server, Socket } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
import { redis } from '../config/redis';
import { config } from '../config';
import { logger } from '../utils/logger';
import { voteService } from '../services/vote.service';
import { SOCKET_EVENTS, SOCKET_ROOMS } from '@gala/shared/constants';
import type {
ServerToClientEvents,
ClientToServerEvents,
InterServerEvents,
SocketData,
VoteSubmitPayload,
JoinPayload,
AckCallback,
VoteCategory,
ConnectionAckPayload,
} from '@gala/shared/types';
export type GalaSocket = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
export type GalaServer = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
let io: GalaServer;
/**
* Initialize Socket.io server
*/
export async function initializeSocket(httpServer: HttpServer): Promise<GalaServer> {
io = new Server(httpServer, {
cors: {
origin: config.corsOrigins,
credentials: true,
},
pingTimeout: config.socket.pingTimeout,
pingInterval: config.socket.pingInterval,
maxHttpBufferSize: config.socket.maxHttpBufferSize,
});
// Set up Redis adapter for horizontal scaling
const pubClient = redis.duplicate();
const subClient = redis.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
// Connection handler
io.on('connection', handleConnection);
logger.info('Socket.io server initialized');
return io;
}
/**
* Handle new socket connection
*/
function handleConnection(socket: GalaSocket): void {
logger.info({ socketId: socket.id }, 'New socket connection');
// Join event
socket.on(SOCKET_EVENTS.CONNECTION_JOIN, (data: JoinPayload, callback: AckCallback<ConnectionAckPayload>) => {
handleJoin(socket, data, callback);
});
// Vote submit event
socket.on(SOCKET_EVENTS.VOTE_SUBMIT, (data: VoteSubmitPayload, callback: AckCallback<{ newCount: number }>) => {
handleVoteSubmit(socket, data, callback);
});
// Ping event (custom heartbeat)
socket.on(SOCKET_EVENTS.CONNECTION_PING, () => {
socket.emit(SOCKET_EVENTS.CONNECTION_PONG as any);
});
// Sync request
socket.on(SOCKET_EVENTS.SYNC_REQUEST, () => {
handleSyncRequest(socket);
});
// Disconnect handler
socket.on('disconnect', (reason) => {
handleDisconnect(socket, reason);
});
// Error handler
socket.on('error', (error) => {
logger.error({ socketId: socket.id, error }, 'Socket error');
});
}
/**
* Handle user join
*/
async function handleJoin(
socket: GalaSocket,
data: JoinPayload,
callback: AckCallback<ConnectionAckPayload>
): Promise<void> {
try {
const { userId, userName, role } = data;
// Store user data in socket
socket.data.userId = userId;
socket.data.userName = userName;
socket.data.role = role;
socket.data.connectedAt = new Date();
socket.data.sessionId = socket.id;
// Join appropriate rooms
await socket.join(SOCKET_ROOMS.ALL);
if (role === 'user') {
await socket.join(SOCKET_ROOMS.MOBILE_USERS);
} else if (role === 'screen') {
await socket.join(SOCKET_ROOMS.SCREEN_DISPLAY);
} else if (role === 'admin') {
await socket.join(SOCKET_ROOMS.ADMIN);
}
// Get user's voted categories
const votedCategories = await voteService.getUserVotedCategories(userId);
logger.info({ socketId: socket.id, userId, userName, role }, 'User joined');
// Broadcast user count update
const userCount = await getUserCount();
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.CONNECTION_USERS_COUNT as any, userCount);
callback({
success: true,
data: {
sessionId: socket.id,
serverTime: Date.now(),
reconnected: false,
},
});
} catch (error) {
logger.error({ socketId: socket.id, error }, 'Join error');
callback({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to join',
});
}
}
/**
* Handle vote submission via WebSocket
*/
async function handleVoteSubmit(
socket: GalaSocket,
data: VoteSubmitPayload,
callback: AckCallback<{ newCount: number }>
): Promise<void> {
const userId = socket.data.userId;
if (!userId) {
callback({
success: false,
error: 'UNAUTHORIZED',
message: 'Not authenticated',
});
return;
}
try {
const result = await voteService.submitVote(
userId,
data.category as VoteCategory,
data.candidateId,
data.localId
);
if (!result.success) {
callback({
success: false,
error: result.error as any,
message: result.message,
});
return;
}
// Broadcast vote update to all clients
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
candidateId: data.candidateId,
category: data.category,
totalVotes: result.new_count!,
delta: 1,
});
callback({
success: true,
data: {
newCount: result.new_count!,
},
});
} catch (error) {
logger.error({ socketId: socket.id, userId, error }, 'Vote submit error');
callback({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to submit vote',
});
}
}
/**
* Handle sync request
*/
async function handleSyncRequest(socket: GalaSocket): Promise<void> {
const userId = socket.data.userId;
if (!userId) {
return;
}
try {
const votedCategories = await voteService.getUserVotedCategories(userId);
socket.emit(SOCKET_EVENTS.SYNC_STATE as any, {
votes: {}, // TODO: Include current vote counts
userVotedCategories: votedCategories,
});
} catch (error) {
logger.error({ socketId: socket.id, userId, error }, 'Sync request error');
}
}
/**
* Handle disconnect
*/
function handleDisconnect(socket: GalaSocket, reason: string): void {
logger.info(
{
socketId: socket.id,
userId: socket.data.userId,
reason,
},
'Socket disconnected'
);
// Broadcast updated user count
getUserCount().then((count) => {
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.CONNECTION_USERS_COUNT as any, count);
});
}
/**
* Get current connected user count
*/
async function getUserCount(): Promise<number> {
const sockets = await io.in(SOCKET_ROOMS.MOBILE_USERS).fetchSockets();
return sockets.length;
}
/**
* Get Socket.io server instance
*/
export function getIO(): GalaServer {
if (!io) {
throw new Error('Socket.io not initialized');
}
return io;
}

View File

@@ -0,0 +1,18 @@
import pino from 'pino';
import { config } from '../config';
export const logger = pino({
level: config.isDev ? 'debug' : 'info',
transport: config.isDev
? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:standard',
ignore: 'pid,hostname',
},
}
: undefined,
});
export type Logger = typeof logger;

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,39 @@
{
"name": "@gala/shared",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./types": {
"types": "./dist/types/index.d.ts",
"import": "./dist/types/index.js"
},
"./constants": {
"types": "./dist/constants/index.d.ts",
"import": "./dist/constants/index.js"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"import": "./dist/utils/index.js"
}
},
"scripts": {
"build": "tsup src/index.ts src/types/index.ts src/constants/index.ts src/utils/index.ts --format esm --dts --clean",
"dev": "tsup src/index.ts src/types/index.ts src/constants/index.ts src/utils/index.ts --format esm --dts --watch",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.24.1"
},
"devDependencies": {
"tsup": "^8.3.5",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,56 @@
// Shared configuration values
export const CONFIG = {
// Voting
MAX_VOTES_PER_USER: 7,
VOTE_CATEGORIES_COUNT: 7,
// WebSocket
HEARTBEAT_INTERVAL_MS: 5000,
HEARTBEAT_TIMEOUT_MS: 10000,
RECONNECTION_DELAY_MS: 1000,
RECONNECTION_DELAY_MAX_MS: 5000,
RECONNECTION_ATTEMPTS: Infinity,
// Rate limiting
RATE_LIMIT_WINDOW_MS: 1000,
RATE_LIMIT_MAX_REQUESTS: 10,
// Session
SESSION_TTL_SECONDS: 3600, // 1 hour
// Queue
VOTE_QUEUE_MAX_RETRIES: 3,
VOTE_QUEUE_RETRY_DELAY_MS: 1000,
// Sync
SYNC_BATCH_SIZE: 100,
SYNC_INTERVAL_MS: 1000,
} as const;
export const REDIS_KEYS = {
// Vote counting
VOTE_COUNT: 'vote:count', // vote:count:{category}
USER_CATEGORIES: 'vote:user', // vote:user:{userId}:categories
CATEGORY_VOTERS: 'vote:category', // vote:category:{category}:voters
LEADERBOARD: 'leaderboard', // leaderboard:{category}
// Locking
VOTE_LOCK: 'lock:vote', // lock:vote:{userId}:{category}
// Sync
SYNC_QUEUE: 'sync:queue:votes',
SYNC_PROCESSING: 'sync:processing',
// Session
SESSION: 'session', // session:{sessionId}
USER_SESSION: 'session:user', // session:user:{userId}
// Rate limiting
RATE_LIMIT: 'ratelimit:vote', // ratelimit:vote:{userId}
// Draw
DRAW_ACTIVE: 'draw:active',
DRAW_PARTICIPANTS: 'draw:participants',
DRAW_WINNERS: 'draw:winners',
} as const;

View File

@@ -0,0 +1,48 @@
// Error codes and messages
export const ERROR_CODES = {
// Vote errors
ALREADY_VOTED: 'ALREADY_VOTED',
MAX_VOTES_REACHED: 'MAX_VOTES_REACHED',
INVALID_CANDIDATE: 'INVALID_CANDIDATE',
VOTING_CLOSED: 'VOTING_CLOSED',
// Auth errors
UNAUTHORIZED: 'UNAUTHORIZED',
INVALID_SESSION: 'INVALID_SESSION',
SESSION_EXPIRED: 'SESSION_EXPIRED',
// Rate limiting
RATE_LIMITED: 'RATE_LIMITED',
TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS',
// Server errors
INTERNAL_ERROR: 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
LOCK_FAILED: 'LOCK_FAILED',
// Draw errors
DRAW_IN_PROGRESS: 'DRAW_IN_PROGRESS',
NO_PARTICIPANTS: 'NO_PARTICIPANTS',
DRAW_NOT_FOUND: 'DRAW_NOT_FOUND',
} as const;
export const ERROR_MESSAGES: Record<keyof typeof ERROR_CODES, string> = {
ALREADY_VOTED: '您已在此类别投过票',
MAX_VOTES_REACHED: '您已用完所有投票机会',
INVALID_CANDIDATE: '无效的候选人',
VOTING_CLOSED: '投票已结束',
UNAUTHORIZED: '请先登录',
INVALID_SESSION: '会话无效,请重新登录',
SESSION_EXPIRED: '会话已过期,请重新登录',
RATE_LIMITED: '操作过于频繁,请稍后再试',
TOO_MANY_REQUESTS: '请求过多,请稍后再试',
INTERNAL_ERROR: '服务器内部错误',
SERVICE_UNAVAILABLE: '服务暂时不可用',
LOCK_FAILED: '服务器繁忙,请重试',
DRAW_IN_PROGRESS: '抽奖正在进行中',
NO_PARTICIPANTS: '没有符合条件的参与者',
DRAW_NOT_FOUND: '抽奖不存在',
};
export type ErrorCode = keyof typeof ERROR_CODES;

View File

@@ -0,0 +1,39 @@
// Socket event name constants for consistency across client and server
export const SOCKET_EVENTS = {
// Vote events
VOTE_SUBMIT: 'vote:submit',
VOTE_BATCH: 'vote:batch',
VOTE_UPDATED: 'vote:updated',
VOTE_RESULT: 'vote:result',
VOTE_ERROR: 'vote:error',
// Draw events
DRAW_START: 'draw:start',
DRAW_STARTED: 'draw:started',
DRAW_SPINNING: 'draw:spinning',
DRAW_WINNER: 'draw:winner',
DRAW_STOP: 'draw:stop',
DRAW_ENDED: 'draw:ended',
// Connection events
CONNECTION_ACK: 'connection:ack',
CONNECTION_PING: 'connection:ping',
CONNECTION_PONG: 'connection:pong',
CONNECTION_JOIN: 'connection:join',
CONNECTION_USERS_COUNT: 'connection:users_count',
// Sync events
SYNC_REQUEST: 'sync:request',
SYNC_STATE: 'sync:state',
} as const;
export const SOCKET_ROOMS = {
MOBILE_USERS: 'room:mobile',
SCREEN_DISPLAY: 'room:screen',
ADMIN: 'room:admin',
ALL: 'room:all',
} as const;
export type SocketEventName = (typeof SOCKET_EVENTS)[keyof typeof SOCKET_EVENTS];
export type SocketRoomName = (typeof SOCKET_ROOMS)[keyof typeof SOCKET_ROOMS];

View File

@@ -0,0 +1,3 @@
export * from './events';
export * from './errors';
export * from './config';

View File

@@ -0,0 +1,4 @@
// Main entry point - re-export everything
export * from './types';
export * from './constants';
export * from './utils';

View File

@@ -0,0 +1,99 @@
import type { PrizeLevel } from './socket.types';
// ============================================================================
// User Types
// ============================================================================
export interface User {
id: string;
name: string;
department: string;
avatar?: string;
birthYear?: number;
zodiac?: string;
joinDate?: Date;
isActive: boolean;
createdAt: Date;
}
export interface UserSession {
sessionId: string;
visitorId: string;
userId: string;
userName: string;
role: 'user' | 'admin' | 'screen';
createdAt: Date;
lastActivity: Date;
ipAddress?: string;
userAgent?: string;
}
// ============================================================================
// Draw Types
// ============================================================================
export interface DrawParticipant {
userId: string;
userName: string;
department: string;
avatar?: string;
zodiac?: string;
birthYear?: number;
}
export interface DrawResult {
id: string;
drawId: string;
prizeLevel: PrizeLevel;
prizeName: string;
winnerId: string;
winnerName: string;
winnerDepartment: string;
drawnAt: Date;
drawnBy: string; // Admin who triggered the draw
}
export interface DrawSession {
id: string;
prizeLevel: PrizeLevel;
prizeName: string;
totalPrizes: number;
drawnCount: number;
isActive: boolean;
startedAt?: Date;
endedAt?: Date;
filters?: {
excludeWinners: boolean;
zodiacFilter?: string;
ageRange?: { min?: number; max?: number };
departments?: string[];
};
}
export interface DrawHistory {
sessions: DrawSession[];
results: DrawResult[];
totalParticipants: number;
totalWinners: number;
}
// ============================================================================
// API Types
// ============================================================================
export interface StartDrawRequest {
prizeLevel: PrizeLevel;
prizeName: string;
filters?: DrawSession['filters'];
}
export interface StartDrawResponse {
success: boolean;
drawId?: string;
participantCount?: number;
error?: string;
}
export interface GetDrawHistoryResponse {
history: DrawHistory;
}

View File

@@ -0,0 +1,4 @@
// Re-export all types
export * from './socket.types';
export * from './vote.types';
export * from './draw.types';

View File

@@ -0,0 +1,224 @@
// Socket.io event type definitions for type-safe client-server communication
// ============================================================================
// Vote Types
// ============================================================================
export interface VoteSubmitPayload {
candidateId: string;
category: VoteCategory;
clientTimestamp: number;
localId: string; // For optimistic UI reconciliation
}
export interface VoteBatchPayload {
votes: VoteSubmitPayload[];
}
export interface VoteUpdatePayload {
candidateId: string;
category: VoteCategory;
totalVotes: number;
delta: number;
}
export interface VoteResultPayload {
category: VoteCategory;
results: Array<{
candidateId: string;
candidateName: string;
votes: number;
rank: number;
}>;
}
// ============================================================================
// Draw Types
// ============================================================================
export interface DrawStartPayload {
drawId: string;
prizeLevel: PrizeLevel;
prizeName: string;
participantCount: number;
}
export interface DrawSpinPayload {
drawId: string;
currentNames: string[]; // Names being shuffled on screen
phase: 'accelerating' | 'spinning' | 'decelerating';
}
export interface DrawWinnerPayload {
drawId: string;
winner: {
odrawId: string;
visitorId: string;
userId: string;
userName: string;
department: string;
avatar?: string;
};
prizeLevel: PrizeLevel;
prizeName: string;
}
export interface DrawStartRequest {
prizeLevel: PrizeLevel;
filters?: DrawFilters;
}
export interface DrawFilters {
excludeWinners?: boolean; // Exclude previous winners
zodiacFilter?: string; // e.g., 'horse' for Year of the Horse
ageRange?: { min?: number; max?: number };
departments?: string[];
}
// ============================================================================
// Connection Types
// ============================================================================
export interface JoinPayload {
userId: string;
userName: string;
role: UserRole;
sessionToken?: string;
}
export interface ConnectionAckPayload {
sessionId: string;
serverTime: number;
reconnected: boolean;
missedEvents?: unknown[];
}
export interface SyncStatePayload {
votes: Record<VoteCategory, Record<string, number>>; // category -> candidateId -> count
userVotedCategories: VoteCategory[];
currentDraw?: {
isActive: boolean;
prizeLevel: PrizeLevel;
drawId: string;
};
}
export interface SyncRequestPayload {
lastEventId?: string;
lastTimestamp?: number;
}
// ============================================================================
// Error Types
// ============================================================================
export interface ErrorPayload {
code: SocketErrorCode;
message: string;
details?: Record<string, unknown>;
}
export type SocketErrorCode =
| 'ALREADY_VOTED'
| 'MAX_VOTES_REACHED'
| 'INVALID_CANDIDATE'
| 'VOTING_CLOSED'
| 'UNAUTHORIZED'
| 'RATE_LIMITED'
| 'INTERNAL_ERROR';
// ============================================================================
// Callback Types
// ============================================================================
export type AckCallback<T = unknown> = (response: AckResponse<T>) => void;
export interface AckResponse<T = unknown> {
success: boolean;
error?: SocketErrorCode;
message?: string;
data?: T;
}
// ============================================================================
// Socket.io Event Maps
// ============================================================================
export interface ServerToClientEvents {
// Vote events
'vote:updated': (data: VoteUpdatePayload) => void;
'vote:result': (data: VoteResultPayload) => void;
'vote:error': (data: ErrorPayload) => void;
// Draw events
'draw:started': (data: DrawStartPayload) => void;
'draw:spinning': (data: DrawSpinPayload) => void;
'draw:winner': (data: DrawWinnerPayload) => void;
'draw:ended': () => void;
// Connection events
'connection:ack': (data: ConnectionAckPayload) => void;
'connection:users_count': (count: number) => void;
'connection:pong': () => void;
// Sync events
'sync:state': (data: SyncStatePayload) => void;
}
export interface ClientToServerEvents {
// Vote events
'vote:submit': (data: VoteSubmitPayload, callback: AckCallback<{ newCount: number }>) => void;
'vote:batch': (data: VoteBatchPayload, callback: AckCallback<{ processed: number }>) => void;
// Draw events (admin only)
'draw:start': (data: DrawStartRequest, callback: AckCallback<{ drawId: string }>) => void;
'draw:stop': (callback: AckCallback) => void;
// Connection events
'connection:ping': () => void;
'connection:join': (data: JoinPayload, callback: AckCallback<ConnectionAckPayload>) => void;
// Sync events
'sync:request': (data: SyncRequestPayload) => void;
}
export interface InterServerEvents {
ping: () => void;
}
export interface SocketData {
userId: string;
userName: string;
role: UserRole;
connectedAt: Date;
sessionId: string;
}
// ============================================================================
// Enums & Constants
// ============================================================================
export type UserRole = 'user' | 'admin' | 'screen';
export type VoteCategory =
| 'best_employee'
| 'best_team'
| 'best_newcomer'
| 'best_innovation'
| 'best_service'
| 'best_collaboration'
| 'best_leadership';
export type PrizeLevel = 'special' | 'first' | 'second' | 'third' | 'participation';
export const VOTE_CATEGORIES: VoteCategory[] = [
'best_employee',
'best_team',
'best_newcomer',
'best_innovation',
'best_service',
'best_collaboration',
'best_leadership',
];
export const PRIZE_LEVELS: PrizeLevel[] = ['special', 'first', 'second', 'third', 'participation'];

View File

@@ -0,0 +1,102 @@
import type { VoteCategory } from './socket.types';
// ============================================================================
// Candidate Types
// ============================================================================
export interface Candidate {
id: string;
name: string;
department: string;
avatar?: string;
description?: string;
category: VoteCategory;
createdAt: Date;
}
export interface CandidateWithVotes extends Candidate {
voteCount: number;
rank?: number;
percentage?: number;
}
// ============================================================================
// Vote Types
// ============================================================================
export interface Vote {
id: string;
odrawId: string;
userId: string;
candidateId: string;
category: VoteCategory;
createdAt: Date;
}
export interface VoteStats {
candidateId: string;
category: VoteCategory;
totalVotes: number;
percentage: number;
rank: number;
}
export interface CategoryVoteStats {
category: VoteCategory;
totalVotes: number;
candidates: VoteStats[];
updatedAt: Date;
}
// ============================================================================
// Voting Session Types
// ============================================================================
export interface VotingSession {
id: string;
name: string;
startTime: Date;
endTime: Date;
isActive: boolean;
maxVotesPerUser: number;
categories: VoteCategory[];
}
export interface UserVoteStatus {
userId: string;
votedCategories: VoteCategory[];
remainingVotes: number;
votes: Array<{
category: VoteCategory;
candidateId: string;
votedAt: Date;
}>;
}
// ============================================================================
// API Request/Response Types
// ============================================================================
export interface SubmitVoteRequest {
candidateId: string;
category: VoteCategory;
}
export interface SubmitVoteResponse {
success: boolean;
voteId?: string;
newCount?: number;
error?: string;
}
export interface GetCategoryResultsRequest {
category: VoteCategory;
limit?: number;
}
export interface GetCategoryResultsResponse {
category: VoteCategory;
results: CandidateWithVotes[];
totalVotes: number;
updatedAt: Date;
}

View File

@@ -0,0 +1,72 @@
// Date and number formatting utilities
/**
* Format a date to Chinese locale string
*/
export function formatDate(date: Date | string | number): string {
const d = new Date(date);
return d.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
/**
* Format a date with time
*/
export function formatDateTime(date: Date | string | number): string {
const d = new Date(date);
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
/**
* Format a number with thousand separators
*/
export function formatNumber(num: number): string {
return num.toLocaleString('zh-CN');
}
/**
* Format a percentage
*/
export function formatPercentage(value: number, decimals = 1): string {
return `${(value * 100).toFixed(decimals)}%`;
}
/**
* Format vote count with unit
*/
export function formatVoteCount(count: number): string {
if (count >= 10000) {
return `${(count / 10000).toFixed(1)}万票`;
}
return `${count}`;
}
/**
* Get relative time string (e.g., "3分钟前")
*/
export function getRelativeTime(date: Date | string | number): string {
const now = Date.now();
const then = new Date(date).getTime();
const diff = now - then;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}天前`;
if (hours > 0) return `${hours}小时前`;
if (minutes > 0) return `${minutes}分钟前`;
if (seconds > 10) return `${seconds}秒前`;
return '刚刚';
}

View File

@@ -0,0 +1,3 @@
export * from './validation';
export * from './formatters';
export * from './retry';

View File

@@ -0,0 +1,116 @@
// Retry utility with exponential backoff
export interface RetryOptions {
maxRetries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
backoffMultiplier?: number;
shouldRetry?: (error: unknown, attempt: number) => boolean;
onRetry?: (error: unknown, attempt: number, delayMs: number) => void;
}
const DEFAULT_OPTIONS: Required<Omit<RetryOptions, 'shouldRetry' | 'onRetry'>> = {
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 30000,
backoffMultiplier: 2,
};
/**
* Execute a function with retry logic and exponential backoff
*/
export async function retry<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const opts = { ...DEFAULT_OPTIONS, ...options };
let lastError: unknown;
let delay = opts.initialDelayMs;
for (let attempt = 1; attempt <= opts.maxRetries + 1; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (attempt > opts.maxRetries) {
break;
}
// Check if we should retry
if (opts.shouldRetry && !opts.shouldRetry(error, attempt)) {
break;
}
// Notify about retry
if (opts.onRetry) {
opts.onRetry(error, attempt, delay);
}
// Wait before retrying
await sleep(delay);
// Calculate next delay with exponential backoff
delay = Math.min(delay * opts.backoffMultiplier, opts.maxDelayMs);
}
}
throw lastError;
}
/**
* Sleep for a specified duration
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Create a timeout promise that rejects after specified duration
*/
export function timeout<T>(promise: Promise<T>, ms: number, message?: string): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(message || `Timeout after ${ms}ms`)), ms)
),
]);
}
/**
* Debounce a function
*/
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delayMs: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
timeoutId = setTimeout(() => {
fn(...args);
timeoutId = null;
}, delayMs);
};
}
/**
* Throttle a function
*/
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
limitMs: number
): (...args: Parameters<T>) => void {
let lastRun = 0;
return (...args: Parameters<T>) => {
const now = Date.now();
if (now - lastRun >= limitMs) {
lastRun = now;
fn(...args);
}
};
}

View File

@@ -0,0 +1,64 @@
import { z } from 'zod';
import { VOTE_CATEGORIES, PRIZE_LEVELS } from '../types/socket.types';
// ============================================================================
// Vote Validation Schemas
// ============================================================================
export const voteSubmitSchema = z.object({
candidateId: z.string().min(1).max(64),
category: z.enum(VOTE_CATEGORIES as unknown as [string, ...string[]]),
clientTimestamp: z.number().positive(),
localId: z.string().uuid(),
});
export const voteBatchSchema = z.object({
votes: z.array(voteSubmitSchema).min(1).max(7),
});
// ============================================================================
// Draw Validation Schemas
// ============================================================================
export const drawFiltersSchema = z.object({
excludeWinners: z.boolean().optional(),
zodiacFilter: z.string().optional(),
ageRange: z
.object({
min: z.number().min(18).max(100).optional(),
max: z.number().min(18).max(100).optional(),
})
.optional(),
departments: z.array(z.string()).optional(),
});
export const drawStartSchema = z.object({
prizeLevel: z.enum(PRIZE_LEVELS as unknown as [string, ...string[]]),
filters: drawFiltersSchema.optional(),
});
// ============================================================================
// Connection Validation Schemas
// ============================================================================
export const joinPayloadSchema = z.object({
userId: z.string().min(1).max(64),
userName: z.string().min(1).max(100),
role: z.enum(['user', 'admin', 'screen']),
sessionToken: z.string().optional(),
});
export const syncRequestSchema = z.object({
lastEventId: z.string().optional(),
lastTimestamp: z.number().optional(),
});
// ============================================================================
// Type Exports
// ============================================================================
export type VoteSubmitInput = z.infer<typeof voteSubmitSchema>;
export type VoteBatchInput = z.infer<typeof voteBatchSchema>;
export type DrawStartInput = z.infer<typeof drawStartSchema>;
export type JoinPayloadInput = z.infer<typeof joinPayloadSchema>;
export type SyncRequestInput = z.infer<typeof syncRequestSchema>;

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

8504
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- 'packages/*'

17
tsconfig.base.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"lib": ["ES2022"]
}
}