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:
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal 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
10
.prettierrc
Normal 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
34
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
16
packages/client-mobile/index.html
Normal file
16
packages/client-mobile/index.html
Normal 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>
|
||||
34
packages/client-mobile/package.json
Normal file
34
packages/client-mobile/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
43
packages/client-mobile/src/App.vue
Normal file
43
packages/client-mobile/src/App.vue
Normal 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>
|
||||
177
packages/client-mobile/src/assets/styles/global.scss
Normal file
177
packages/client-mobile/src/assets/styles/global.scss
Normal 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};
|
||||
}
|
||||
72
packages/client-mobile/src/assets/styles/variables.scss
Normal file
72
packages/client-mobile/src/assets/styles/variables.scss
Normal 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;
|
||||
88
packages/client-mobile/src/auto-imports.d.ts
vendored
Normal file
88
packages/client-mobile/src/auto-imports.d.ts
vendored
Normal 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')
|
||||
}
|
||||
14
packages/client-mobile/src/components.d.ts
vendored
Normal file
14
packages/client-mobile/src/components.d.ts
vendored
Normal 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']
|
||||
}
|
||||
}
|
||||
92
packages/client-mobile/src/components/ConnectionStatus.vue
Normal file
92
packages/client-mobile/src/components/ConnectionStatus.vue
Normal 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>
|
||||
283
packages/client-mobile/src/composables/useVoteQueue.ts
Normal file
283
packages/client-mobile/src/composables/useVoteQueue.ts
Normal 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
16
packages/client-mobile/src/env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
22
packages/client-mobile/src/main.ts
Normal file
22
packages/client-mobile/src/main.ts
Normal 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');
|
||||
34
packages/client-mobile/src/router/index.ts
Normal file
34
packages/client-mobile/src/router/index.ts
Normal 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;
|
||||
248
packages/client-mobile/src/stores/connection.ts
Normal file
248
packages/client-mobile/src/stores/connection.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
275
packages/client-mobile/src/views/HomeView.vue
Normal file
275
packages/client-mobile/src/views/HomeView.vue
Normal 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>
|
||||
29
packages/client-mobile/src/views/ProfileView.vue
Normal file
29
packages/client-mobile/src/views/ProfileView.vue
Normal 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>
|
||||
23
packages/client-mobile/src/views/ResultView.vue
Normal file
23
packages/client-mobile/src/views/ResultView.vue
Normal 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>
|
||||
23
packages/client-mobile/src/views/VoteCategoryView.vue
Normal file
23
packages/client-mobile/src/views/VoteCategoryView.vue
Normal 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>
|
||||
227
packages/client-mobile/src/views/VoteView.vue
Normal file
227
packages/client-mobile/src/views/VoteView.vue
Normal 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>
|
||||
20
packages/client-mobile/tsconfig.json
Normal file
20
packages/client-mobile/tsconfig.json
Normal 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"]
|
||||
}
|
||||
91
packages/client-mobile/vite.config.ts
Normal file
91
packages/client-mobile/vite.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
17
packages/client-screen/index.html
Normal file
17
packages/client-screen/index.html
Normal 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>
|
||||
31
packages/client-screen/package.json
Normal file
31
packages/client-screen/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
111
packages/client-screen/src/App.vue
Normal file
111
packages/client-screen/src/App.vue
Normal 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>
|
||||
86
packages/client-screen/src/assets/styles/global.scss
Normal file
86
packages/client-screen/src/assets/styles/global.scss
Normal 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); }
|
||||
}
|
||||
27
packages/client-screen/src/assets/styles/variables.scss
Normal file
27
packages/client-screen/src/assets/styles/variables.scss
Normal 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
16
packages/client-screen/src/env.d.ts
vendored
Normal 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;
|
||||
}
|
||||
14
packages/client-screen/src/main.ts
Normal file
14
packages/client-screen/src/main.ts
Normal 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');
|
||||
29
packages/client-screen/src/router/index.ts
Normal file
29
packages/client-screen/src/router/index.ts
Normal 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;
|
||||
158
packages/client-screen/src/stores/display.ts
Normal file
158
packages/client-screen/src/stores/display.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
121
packages/client-screen/src/views/AdminControl.vue
Normal file
121
packages/client-screen/src/views/AdminControl.vue
Normal 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>
|
||||
326
packages/client-screen/src/views/LuckyDrawView.vue
Normal file
326
packages/client-screen/src/views/LuckyDrawView.vue
Normal 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>
|
||||
248
packages/client-screen/src/views/MainDisplay.vue
Normal file
248
packages/client-screen/src/views/MainDisplay.vue
Normal 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>
|
||||
201
packages/client-screen/src/views/VoteResultsView.vue
Normal file
201
packages/client-screen/src/views/VoteResultsView.vue
Normal 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>
|
||||
20
packages/client-screen/tsconfig.json
Normal file
20
packages/client-screen/tsconfig.json
Normal 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"]
|
||||
}
|
||||
41
packages/client-screen/vite.config.ts
Normal file
41
packages/client-screen/vite.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
21
packages/server/.env.example
Normal file
21
packages/server/.env.example
Normal 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
|
||||
44
packages/server/package.json
Normal file
44
packages/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
115
packages/server/prisma/schema.prisma
Normal file
115
packages/server/prisma/schema.prisma
Normal 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")
|
||||
}
|
||||
52
packages/server/src/app.ts
Normal file
52
packages/server/src/app.ts
Normal 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 };
|
||||
47
packages/server/src/config/index.ts
Normal file
47
packages/server/src/config/index.ts
Normal 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;
|
||||
63
packages/server/src/config/redis.ts
Normal file
63
packages/server/src/config/redis.ts
Normal 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 };
|
||||
55
packages/server/src/index.ts
Normal file
55
packages/server/src/index.ts
Normal 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();
|
||||
11
packages/server/src/lua/check_user_votes.lua
Normal file
11
packages/server/src/lua/check_user_votes.lua
Normal 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)
|
||||
21
packages/server/src/lua/get_category_results.lua
Normal file
21
packages/server/src/lua/get_category_results.lua
Normal 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)
|
||||
98
packages/server/src/lua/vote_submit.lua
Normal file
98
packages/server/src/lua/vote_submit.lua
Normal 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
|
||||
})
|
||||
32
packages/server/src/middleware/errorHandler.ts
Normal file
32
packages/server/src/middleware/errorHandler.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
40
packages/server/src/middleware/requestLogger.ts
Normal file
40
packages/server/src/middleware/requestLogger.ts
Normal 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();
|
||||
}
|
||||
57
packages/server/src/routes/admin.routes.ts
Normal file
57
packages/server/src/routes/admin.routes.ts
Normal 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;
|
||||
150
packages/server/src/routes/vote.routes.ts
Normal file
150
packages/server/src/routes/vote.routes.ts
Normal 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;
|
||||
222
packages/server/src/services/vote.service.ts
Normal file
222
packages/server/src/services/vote.service.ts
Normal 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();
|
||||
266
packages/server/src/socket/index.ts
Normal file
266
packages/server/src/socket/index.ts
Normal 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;
|
||||
}
|
||||
18
packages/server/src/utils/logger.ts
Normal file
18
packages/server/src/utils/logger.ts
Normal 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;
|
||||
12
packages/server/tsconfig.json
Normal file
12
packages/server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
39
packages/shared/package.json
Normal file
39
packages/shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
56
packages/shared/src/constants/config.ts
Normal file
56
packages/shared/src/constants/config.ts
Normal 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;
|
||||
48
packages/shared/src/constants/errors.ts
Normal file
48
packages/shared/src/constants/errors.ts
Normal 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;
|
||||
39
packages/shared/src/constants/events.ts
Normal file
39
packages/shared/src/constants/events.ts
Normal 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];
|
||||
3
packages/shared/src/constants/index.ts
Normal file
3
packages/shared/src/constants/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './events';
|
||||
export * from './errors';
|
||||
export * from './config';
|
||||
4
packages/shared/src/index.ts
Normal file
4
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Main entry point - re-export everything
|
||||
export * from './types';
|
||||
export * from './constants';
|
||||
export * from './utils';
|
||||
99
packages/shared/src/types/draw.types.ts
Normal file
99
packages/shared/src/types/draw.types.ts
Normal 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;
|
||||
}
|
||||
4
packages/shared/src/types/index.ts
Normal file
4
packages/shared/src/types/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Re-export all types
|
||||
export * from './socket.types';
|
||||
export * from './vote.types';
|
||||
export * from './draw.types';
|
||||
224
packages/shared/src/types/socket.types.ts
Normal file
224
packages/shared/src/types/socket.types.ts
Normal 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'];
|
||||
102
packages/shared/src/types/vote.types.ts
Normal file
102
packages/shared/src/types/vote.types.ts
Normal 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;
|
||||
}
|
||||
72
packages/shared/src/utils/formatters.ts
Normal file
72
packages/shared/src/utils/formatters.ts
Normal 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 '刚刚';
|
||||
}
|
||||
3
packages/shared/src/utils/index.ts
Normal file
3
packages/shared/src/utils/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './validation';
|
||||
export * from './formatters';
|
||||
export * from './retry';
|
||||
116
packages/shared/src/utils/retry.ts
Normal file
116
packages/shared/src/utils/retry.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
64
packages/shared/src/utils/validation.ts
Normal file
64
packages/shared/src/utils/validation.ts
Normal 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>;
|
||||
12
packages/shared/tsconfig.json
Normal file
12
packages/shared/tsconfig.json
Normal 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
8504
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'packages/*'
|
||||
17
tsconfig.base.json
Normal file
17
tsconfig.base.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user