feat: initialize Annual Gala Interactive System monorepo

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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