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

View File

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

View File

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

View File

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