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:
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>;
|
||||
Reference in New Issue
Block a user