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