@@ -135,58 +130,77 @@ onMounted(() => {
.vote-results-view {
width: 100%;
height: 100%;
- background: $color-bg-gradient;
+ background: radial-gradient(circle at center, #b91c1c 0%, #7f1d1d 100%);
display: flex;
flex-direction: column;
overflow: auto;
+ position: relative;
+
+ // Add a subtle paper texture overlay
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background-image: url("https://www.transparenttextures.com/patterns/pinstriped-suit.png");
+ opacity: 0.1;
+ pointer-events: none;
+ }
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
- padding: 24px 40px;
+ padding: 30px 60px;
flex-shrink: 0;
+ z-index: 10;
.back-btn {
- background: none;
- border: 1px solid $color-gold;
+ background: rgba(255, 255, 255, 0.1);
+ border: 1px solid rgba($color-gold, 0.5);
color: $color-gold;
- padding: 8px 16px;
- border-radius: 8px;
+ padding: 10px 20px;
+ border-radius: 30px;
cursor: pointer;
font-size: 14px;
transition: all $transition-fast;
+ backdrop-filter: blur(5px);
&:hover {
- background: rgba($color-gold, 0.1);
+ background: rgba($color-gold, 0.2);
+ transform: translateX(-4px);
}
}
.title {
- font-size: 36px;
- font-weight: bold;
+ font-size: 48px;
+ font-weight: 800;
+ letter-spacing: 4px;
+ text-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.status-indicator {
display: flex;
align-items: center;
- gap: 8px;
+ gap: 10px;
font-size: 14px;
- color: rgba(255, 255, 255, 0.6);
+ color: rgba(255, 255, 255, 0.8);
+ background: rgba(0, 0, 0, 0.2);
+ padding: 8px 16px;
+ border-radius: 20px;
.dot {
- width: 8px;
- height: 8px;
+ width: 10px;
+ height: 10px;
border-radius: 50%;
&.online {
- background: #22c55e;
- box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
+ background: #4ade80;
+ box-shadow: 0 0 12px #4ade80;
}
&.offline {
- background: #666;
+ background: #94a3b8;
}
}
}
@@ -195,112 +209,184 @@ onMounted(() => {
.results-grid {
flex: 1;
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
- gap: 20px;
- padding: 20px 40px 40px;
+ grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
+ gap: 40px;
+ padding: 20px 60px 80px;
align-content: start;
}
.category-card {
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba($color-gold, 0.3);
- border-radius: 12px;
- padding: 20px;
+ position: relative;
+ background: #fdfcf0; // Parchment color
+ background-image: radial-gradient(#e5e7eb 0.5px, transparent 0.5px);
+ background-size: 20px 20px;
+ border-radius: 4px;
+ padding: 30px;
+ box-shadow:
+ 0 10px 30px rgba(0, 0, 0, 0.3),
+ 0 1px 2px rgba(0, 0, 0, 0.1);
+ color: #2c2c2c;
+ min-height: 380px;
+ display: flex;
+ flex-direction: column;
+ transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
+
+ // Random staggered rotation for organic "tossed on table" look
+ &:nth-child(odd) { transform: rotate(-1deg); }
+ &:nth-child(even) { transform: rotate(1.5deg); }
+ &:nth-child(3n) { transform: rotate(-0.5deg); }
+
+ &:hover {
+ transform: rotate(0) scale(1.02);
+ z-index: 5;
+ }
+
+ // Airmail-style decorative border
+ &::after {
+ content: '';
+ position: absolute;
+ top: 5px; left: 5px; right: 5px; bottom: 5px;
+ border: 1px solid rgba(185, 28, 28, 0.1);
+ pointer-events: none;
+ }
.category-header {
display: flex;
justify-content: space-between;
- align-items: center;
- margin-bottom: 16px;
+ align-items: flex-start;
+ margin-bottom: 20px;
padding-bottom: 12px;
- border-bottom: 1px solid rgba($color-gold, 0.2);
+ border-bottom: 2px solid #b91c1c;
+
+ .category-name {
+ font-size: 28px;
+ color: #b91c1c;
+ font-weight: 800;
+ margin: 0;
+ font-family: 'SimSun', serif;
+ }
+
+ .total-votes {
+ font-size: 14px;
+ color: #7f1d1d;
+ background: rgba(185, 28, 28, 0.08);
+ padding: 4px 12px;
+ border: 1px solid rgba(185, 28, 28, 0.2);
+ border-radius: 4px;
+ font-weight: 600;
+ }
}
- .category-name {
- font-size: 20px;
- color: $color-gold;
- font-weight: 600;
- margin: 0;
+ .award-stamp {
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ font-size: 64px;
+ opacity: 0.1;
+ transform: rotate(20deg);
+ filter: grayscale(1);
+ pointer-events: none;
+ z-index: 0;
+ user-select: none;
}
- .total-votes {
- font-size: 14px;
- color: rgba(255, 255, 255, 0.5);
- background: rgba($color-gold, 0.1);
- padding: 4px 10px;
- border-radius: 12px;
+ .category-remark {
+ font-size: 16px;
+ color: #4b5563;
+ font-family: 'Kaiti', 'STKaiti', serif;
+ line-height: 1.6;
+ margin: 0 0 24px 0;
+ padding: 12px;
+ background: rgba(0, 0, 0, 0.02);
+ border-radius: 8px;
+ border-left: 4px solid rgba($color-gold, 0.4);
+ font-style: normal;
}
.results-list {
+ flex: 1;
display: flex;
flex-direction: column;
- gap: 10px;
+ gap: 12px;
}
.no-results {
- padding: 20px;
- text-align: center;
- color: rgba(255, 255, 255, 0.4);
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #94a3b8;
font-style: italic;
+ font-size: 18px;
+ font-family: 'Kaiti', serif;
+ letter-spacing: 2px;
+ border: 2px dashed #e2e8f0;
+ border-radius: 8px;
}
.result-item {
display: flex;
align-items: center;
- gap: 12px;
- padding: 10px 12px;
- background: rgba(255, 255, 255, 0.03);
- border-radius: 8px;
+ gap: 15px;
+ padding: 12px 16px;
+ background: white;
+ border: 1px solid #e5e7eb;
+ border-radius: 6px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
&.winner {
- background: rgba($color-gold, 0.1);
- border: 1px solid $color-gold;
+ background: #fffbef;
+ border: 1.5px solid #b91c1c;
+ box-shadow: 0 4px 6px rgba(185, 28, 28, 0.1);
.rank {
- background: $color-gold;
- color: #000;
+ background: #b91c1c;
+ color: white;
}
.name {
- color: $color-gold;
+ color: #b91c1c;
+ font-weight: bold;
}
.bar {
- background: linear-gradient(90deg, $color-gold-dark, $color-gold);
+ background: linear-gradient(90deg, #991b1b, #ef4444);
}
}
.rank {
- width: 26px;
- height: 26px;
+ width: 28px;
+ height: 28px;
display: flex;
align-items: center;
justify-content: center;
- background: rgba(255, 255, 255, 0.1);
+ background: #f1f5f9;
+ color: #64748b;
border-radius: 50%;
- font-size: 13px;
- font-weight: bold;
+ font-size: 14px;
+ font-weight: 800;
flex-shrink: 0;
}
.info {
display: flex;
flex-direction: column;
- min-width: 80px;
- max-width: 100px;
+ min-width: 100px;
+ max-width: 140px;
}
.name {
- font-size: 14px;
- color: $color-text-light;
+ font-size: 16px;
+ color: #1e293b;
+ font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.team {
- font-size: 11px;
- color: rgba(255, 255, 255, 0.4);
+ font-size: 12px;
+ color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -308,51 +394,71 @@ onMounted(() => {
.bar-container {
flex: 1;
- height: 18px;
- background: rgba(255, 255, 255, 0.1);
- border-radius: 9px;
+ height: 14px;
+ background: #f8fafc;
+ border: 1px solid #f1f5f9;
+ border-radius: 10px;
overflow: hidden;
.bar {
height: 100%;
- background: linear-gradient(90deg, $color-primary-dark, $color-primary);
- border-radius: 9px;
- transition: width 1s ease;
+ background: linear-gradient(90deg, #475569, #94a3b8);
+ border-radius: 10px;
+ transition: width 1s cubic-bezier(0.16, 1, 0.3, 1);
}
}
.votes {
- width: 50px;
+ width: 55px;
text-align: right;
- font-size: 14px;
- color: $color-text-muted;
+ font-size: 16px;
+ color: #1e293b;
+ font-weight: 800;
+ font-family: 'monospace';
flex-shrink: 0;
}
}
}
+// Custom Scrollbar
+.vote-results-view::-webkit-scrollbar {
+ width: 12px;
+}
+.vote-results-view::-webkit-scrollbar-track {
+ background: rgba(0, 0, 0, 0.1);
+}
+.vote-results-view::-webkit-scrollbar-thumb {
+ background: rgba($color-gold, 0.3);
+ border-radius: 6px;
+ border: 3px solid transparent;
+ background-clip: content-box;
+}
+
// Responsive
+@media (max-width: 1200px) {
+ .results-grid {
+ grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
+ padding: 20px 30px;
+ }
+}
+
@media (max-width: 768px) {
.header {
- padding: 16px 20px;
+ padding: 20px;
+ flex-direction: column;
+ gap: 15px;
- .title {
- font-size: 24px;
- }
+ .title { font-size: 32px; }
}
.results-grid {
- grid-template-columns: 1fr;
- padding: 16px 20px;
- gap: 16px;
+ padding: 10px 20px;
+ gap: 20px;
}
.category-card {
- padding: 16px;
-
- .category-name {
- font-size: 18px;
- }
+ min-height: auto;
+ padding: 20px;
}
}
diff --git a/packages/server/config/programs.json b/packages/server/config/programs.json
index 6739b34..e3581bd 100644
--- a/packages/server/config/programs.json
+++ b/packages/server/config/programs.json
@@ -4,49 +4,108 @@
"id": "p1",
"name": "龙腾四海",
"teamName": "市场部",
- "order": 1
+ "performer": "张三、李四",
+ "order": 1,
+ "remark": "大型民族舞表演,融合了古典与现代元素,展现龙的精神。"
},
{
"id": "p2",
"name": "金马奔腾",
"teamName": "技术部",
- "order": 2
+ "performer": "王五、赵六",
+ "order": 2,
+ "remark": "动感的现代舞,充满力量与节奏感。"
},
{
"id": "p3",
"name": "春风得意",
"teamName": "人力资源部",
- "order": 3
+ "performer": "刘七、陈八",
+ "order": 3,
+ "remark": "温馨的情景剧,讲述了职场中的温暖瞬间。"
},
{
"id": "p4",
"name": "鸿运当头",
"teamName": "财务部",
- "order": 4
+ "performer": "周九、吴十",
+ "order": 4,
+ "remark": "精彩的杂技表演,寓意新年鸿运连连。"
},
{
"id": "p5",
"name": "马到成功",
"teamName": "运营部",
- "order": 5
+ "performer": "郑十一、冯十二",
+ "order": 5,
+ "remark": "热血沸腾的多人合唱,充满了前进的动力。"
},
{
"id": "p6",
"name": "一马当先",
"teamName": "产品部",
- "order": 6
+ "performer": "孙十三、杨十四",
+ "order": 6,
+ "remark": "极具创意的光影秀,探索未来科技的可能。"
},
{
"id": "p7",
"name": "万马奔腾",
"teamName": "设计部",
- "order": 7
+ "performer": "何十五、林十六",
+ "order": 7,
+ "remark": "大合唱,展现团队的凝聚力和向心力。"
+ }
+ ],
+ "awards": [
+ {
+ "id": "creative",
+ "name": "时光琥珀奖",
+ "icon": "🏆",
+ "order": 1,
+ "remark": "赞美节目如琥珀般凝固了某个经典、美好、闪光的瞬间,适合怀旧、温情、经典的表演。"
},
{
- "id": "p8",
- "name": "龙马精神",
- "teamName": "销售部",
- "order": 8
+ "id": "visual",
+ "name": "岁月留声奖",
+ "icon": "🎵",
+ "order": 2,
+ "remark": "强调节目留下了值得回味的'声音',可以是歌声、朗诵声,甚至是幽默的回响。适配唱、诵、幽默类节目。"
+ },
+ {
+ "id": "atmosphere",
+ "name": "风华共鸣奖",
+ "icon": "🎭",
+ "order": 3,
+ "remark": "赞美节目引发了跨越时代的共鸣,无论是家国情怀、青春记忆还是职场幽默。适配有感染力、引发集体回忆的节目。"
+ },
+ {
+ "id": "performance",
+ "name": "光影织梦奖",
+ "icon": "✨",
+ "order": 4,
+ "remark": "形容节目用声音和表演编织了一个时代的梦境,画面感强。适配意境优美、故事性强或对唱情歌类节目。"
+ },
+ {
+ "id": "teamwork",
+ "name": "潮流印记奖",
+ "icon": "🌊",
+ "order": 5,
+ "remark": "既指复刻了过去的潮流,也指创造了今晚的潮流。适配活力四射、改编新颖、引领现场气氛的节目。"
+ },
+ {
+ "id": "popularity",
+ "name": "匠心独韵奖",
+ "icon": "💎",
+ "order": 6,
+ "remark": "强调节目的独特韵味与精心打磨,可以是深情的独唱,也可以是巧妙的改编,突出'独特'和'匠心'。"
+ },
+ {
+ "id": "potential",
+ "name": "同频时代奖",
+ "icon": "📻",
+ "order": 7,
+ "remark": "赞美节目与'复古70-80'主题高度契合,与时代精神同频共振。适配主题鲜明、情怀真挚的集体性节目。"
}
],
"settings": {
diff --git a/packages/server/src/lua/cast_vote.lua b/packages/server/src/lua/cast_vote.lua
index 6350922..3af8797 100644
--- a/packages/server/src/lua/cast_vote.lua
+++ b/packages/server/src/lua/cast_vote.lua
@@ -5,7 +5,7 @@
-- 1. Each user has 7 distinct tickets (creative, visual, etc.)
-- 2. Each ticket can only be assigned to ONE program
-- 3. A user can only give ONE ticket to each program (no multi-ticket to same program)
--- 4. Supports revoke: if user already used this ticket, revoke old vote first
+-- 4. 禁止同一用户在同一奖项重复投票(不允许改投)
--
-- KEYS[1] = vote:user:{userId}:tickets (Hash)
-- KEYS[2] = vote:user:{userId}:programs (Set)
@@ -49,15 +49,13 @@ end
local already_voted_program = redis.call('SISMEMBER', user_programs_key, program_id)
local current_ticket_program = redis.call('HGET', user_tickets_key, ticket_type)
--- Case: User trying to vote same program with same ticket (no-op)
-if current_ticket_program == program_id then
+-- Case: User already used this ticket (no re-vote allowed)
+if current_ticket_program and current_ticket_program ~= false then
redis.call('DEL', lock_key)
return cjson.encode({
- success = true,
- message = 'Already voted for this program with this ticket',
- program_id = program_id,
- ticket_type = ticket_type,
- is_duplicate = true
+ success = false,
+ error = 'ALREADY_VOTED',
+ message = 'You already used this ticket'
})
end
@@ -71,25 +69,7 @@ if already_voted_program == 1 and current_ticket_program ~= program_id then
})
end
--- Step 3: If this ticket was used before, revoke the old vote
-local old_program_id = current_ticket_program
-local revoked = false
-
-if old_program_id and old_program_id ~= false then
- -- Decrement old program's count
- local old_count_key = 'vote:count:' .. old_program_id .. ':' .. ticket_type
- local old_leaderboard_key = 'vote:leaderboard:' .. ticket_type
- local old_voters_key = 'vote:program:' .. old_program_id .. ':voters'
-
- redis.call('DECR', old_count_key)
- redis.call('ZINCRBY', old_leaderboard_key, -1, old_program_id)
- redis.call('SREM', old_voters_key, user_id)
- redis.call('SREM', user_programs_key, old_program_id)
-
- revoked = true
-end
-
--- Step 4: Cast the new vote
+-- Step 3: Cast the new vote
-- 4a: Set the ticket assignment
redis.call('HSET', user_tickets_key, ticket_type, program_id)
@@ -111,8 +91,7 @@ local vote_record = cjson.encode({
user_id = user_id,
program_id = program_id,
ticket_type = ticket_type,
- timestamp = timestamp,
- revoked_program = old_program_id or nil
+ timestamp = timestamp
})
redis.call('RPUSH', sync_queue_key, vote_record)
@@ -123,7 +102,5 @@ return cjson.encode({
success = true,
program_id = program_id,
ticket_type = ticket_type,
- new_count = new_count,
- revoked = revoked,
- revoked_program = old_program_id or nil
+ new_count = new_count
})
diff --git a/packages/server/src/lua/vote_submit.lua b/packages/server/src/lua/vote_submit.lua
index 515cfa2..6117a24 100644
--- a/packages/server/src/lua/vote_submit.lua
+++ b/packages/server/src/lua/vote_submit.lua
@@ -7,6 +7,7 @@
-- KEYS[4] = leaderboard:{category}
-- KEYS[5] = sync:queue:votes
-- KEYS[6] = lock:vote:{user_id}:{category}
+-- KEYS[7] = vote:user:{userId}:tickets (Hash)
--
-- ARGV[1] = candidate_id
-- ARGV[2] = user_id
@@ -22,6 +23,7 @@ local category_voters_key = KEYS[3]
local leaderboard_key = KEYS[4]
local sync_queue_key = KEYS[5]
local lock_key = KEYS[6]
+local user_tickets_key = KEYS[7]
local candidate_id = ARGV[1]
local user_id = ARGV[2]
@@ -70,6 +72,9 @@ 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)
+-- 4b-2: Persist user's choice for this category
+redis.call('HSET', user_tickets_key, category, candidate_id)
+
-- 4c: Add user to category's voters
redis.call('SADD', category_voters_key, user_id)
diff --git a/packages/server/src/services/admin.service.ts b/packages/server/src/services/admin.service.ts
index a6bdc5a..a7fa97d 100644
--- a/packages/server/src/services/admin.service.ts
+++ b/packages/server/src/services/admin.service.ts
@@ -20,6 +20,7 @@ import type {
VoteStamp,
} from '@gala/shared/types';
import { INITIAL_ADMIN_STATE, PRIZE_CONFIG } from '@gala/shared/types';
+import { VOTE_KEYS, TICKET_TYPES } from '@gala/shared/constants';
const ADMIN_STATE_KEY = 'gala:admin:state';
@@ -39,6 +40,17 @@ class AdminService extends EventEmitter {
const savedState = await redis.get(ADMIN_STATE_KEY);
if (savedState) {
const parsed = JSON.parse(savedState);
+ const configPrograms = programConfigService.getVotingPrograms();
+ const savedPrograms = (parsed?.voting?.programs || []) as VotingProgram[];
+ const mergedPrograms = configPrograms.map((program) => {
+ const saved = savedPrograms.find(p => p.id === program.id);
+ return {
+ ...program,
+ status: saved?.status ?? program.status,
+ votes: saved?.votes ?? 0,
+ stamps: saved?.stamps ?? [],
+ };
+ });
// Deep merge to ensure new fields have defaults
this.state = {
...INITIAL_ADMIN_STATE,
@@ -46,10 +58,9 @@ class AdminService extends EventEmitter {
voting: {
...INITIAL_ADMIN_STATE.voting,
...parsed.voting,
- // Ensure programs always has default values from config service
- programs: parsed.voting?.programs?.length > 0
- ? parsed.voting.programs
- : programConfigService.getVotingPrograms(),
+ // 始终从配置服务加载最新的 programs 和 awards
+ programs: mergedPrograms,
+ awards: programConfigService.getAwards(),
},
lottery: {
...INITIAL_ADMIN_STATE.lottery,
@@ -62,10 +73,24 @@ class AdminService extends EventEmitter {
};
logger.info('Admin state restored from Redis (merged with defaults)');
} else {
+ const programSettings = programConfigService.getSettings();
+ this.state = {
+ ...INITIAL_ADMIN_STATE,
+ voting: {
+ ...INITIAL_ADMIN_STATE.voting,
+ programs: programConfigService.getVotingPrograms(),
+ awards: programConfigService.getAwards(),
+ allowLateCatch: programSettings.allowLateCatch,
+ },
+ };
await this.saveState();
logger.info('Admin state initialized with defaults');
}
+ // Sync actual vote counts from Redis (VotingEngine is source of truth)
+ await this.syncVotesFromRedis();
+
+
// 从配置文件刷新当前轮次的奖项信息
await this.refreshPrizeFromConfig();
} catch (error) {
@@ -297,12 +322,30 @@ class AdminService extends EventEmitter {
/**
* Add a vote stamp to a program (for display on big screen)
*/
- async addVoteStamp(programId: string, userName: string, department: string, ticketType: string): Promise<{ success: boolean; stamp?: VoteStamp }> {
+ async addVoteStamp(
+ programId: string,
+ userName: string,
+ department: string,
+ ticketType: string,
+ options?: { revokedProgramId?: string }
+ ): Promise<{ success: boolean; stamp?: VoteStamp; programVotes?: number; totalVotes?: number; revokedProgramId?: string; revokedProgramVotes?: number }> {
const program = this.state.voting.programs.find(p => p.id === programId);
if (!program) {
return { success: false };
}
+ let wasReplacement = false;
+ let revokedProgramVotes: number | undefined;
+ const revokedProgramId = options?.revokedProgramId;
+ if (revokedProgramId && revokedProgramId !== programId) {
+ const revokedProgram = this.state.voting.programs.find(p => p.id === revokedProgramId);
+ if (revokedProgram && revokedProgram.votes > 0) {
+ revokedProgram.votes -= 1;
+ revokedProgramVotes = revokedProgram.votes;
+ wasReplacement = true;
+ }
+ }
+
const now = new Date();
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
@@ -321,10 +364,19 @@ class AdminService extends EventEmitter {
if (!program.stamps) program.stamps = [];
program.stamps.push(stamp);
program.votes++;
- this.state.voting.totalVotes++;
+ if (!wasReplacement) {
+ this.state.voting.totalVotes++;
+ }
await this.saveState();
- return { success: true, stamp };
+ return {
+ success: true,
+ stamp,
+ programVotes: program.votes,
+ totalVotes: this.state.voting.totalVotes,
+ revokedProgramId,
+ revokedProgramVotes,
+ };
}
/**
@@ -501,12 +553,15 @@ class AdminService extends EventEmitter {
try {
if (scope === 'all' || scope === 'voting') {
+ const programSettings = programConfigService.getSettings();
this.state.voting = {
...INITIAL_ADMIN_STATE.voting,
programs: programConfigService.getVotingPrograms(),
+ awards: programConfigService.getAwards(),
+ allowLateCatch: programSettings.allowLateCatch,
};
// Clear voting data in Redis
- await redis.del('gala:votes:*');
+ await this.clearVotingRedisData();
}
if (scope === 'all' || scope === 'lottery') {
@@ -577,6 +632,66 @@ class AdminService extends EventEmitter {
this.state.voting.totalVotes = count;
await this.saveState();
}
+ /**
+ * Sync vote counts from Redis (source of truth) to local state
+ */
+ async syncVotesFromRedis(): Promise {
+ try {
+ let total = 0;
+ for (const program of this.state.voting.programs) {
+ let count = 0;
+ for (const ticketType of TICKET_TYPES) {
+ const key = VOTE_KEYS.count(program.id, ticketType);
+ const value = await redis.get(key);
+ if (value) {
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isNaN(parsed)) {
+ count += parsed;
+ }
+ }
+ }
+ program.votes = count;
+ total += count;
+ }
+ this.state.voting.totalVotes = total;
+
+ // We don't save state here immediately to avoid overwriting other potential changes
+ // during init, but since we called this in initialize(), we should save.
+ await this.saveState();
+ logger.info({ totalVotes: total }, 'Synced vote counts from Redis');
+ } catch (error) {
+ logger.error({ error }, 'Failed to sync votes from Redis');
+ }
+ }
+
+ /**
+ * Clear voting-related Redis keys (both new ticket system and legacy)
+ */
+ private async clearVotingRedisData(): Promise {
+ const patterns = [
+ 'vote:count:*',
+ 'vote:user:*',
+ 'vote:program:*',
+ 'vote:leaderboard:*',
+ 'vote:category:*',
+ 'vote:sync:queue',
+ 'sync:queue:votes',
+ 'vote:lock:*',
+ 'lock:vote:*',
+ 'leaderboard:*',
+ ];
+
+ for (const pattern of patterns) {
+ let cursor = '0';
+ do {
+ const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 1000);
+ cursor = nextCursor;
+ if (keys.length > 0) {
+ await redis.del(...keys);
+ }
+ } while (cursor !== '0');
+ }
+ }
}
export const adminService = new AdminService();
diff --git a/packages/server/src/services/program-config.service.ts b/packages/server/src/services/program-config.service.ts
index 9da6c3b..30b3268 100644
--- a/packages/server/src/services/program-config.service.ts
+++ b/packages/server/src/services/program-config.service.ts
@@ -18,6 +18,15 @@ export interface ProgramConfig {
id: string;
name: string;
teamName: string;
+ performer?: string; // 表演者
+ order: number;
+ remark?: string; // 节目备注
+}
+
+export interface AwardConfig {
+ id: string;
+ name: string;
+ icon: string;
order: number;
}
@@ -28,6 +37,7 @@ export interface ProgramSettings {
export interface ProgramConfigFile {
programs: ProgramConfig[];
+ awards: AwardConfig[];
settings: ProgramSettings;
}
@@ -49,8 +59,19 @@ class ProgramConfigService {
this.config = JSON.parse(content);
logger.info({
programCount: this.config?.programs.length,
+ awardCount: this.config?.awards?.length || 0,
configPath: this.configPath
}, 'Program config loaded');
+
+ // Validate: programs.length === awards.length
+ if (this.config?.programs && this.config?.awards) {
+ if (this.config.programs.length !== this.config.awards.length) {
+ logger.warn({
+ programCount: this.config.programs.length,
+ awardCount: this.config.awards.length
+ }, 'Warning: program count does not match award count');
+ }
+ }
} else {
logger.warn({ configPath: this.configPath }, 'Program config file not found, using defaults');
this.config = this.getDefaults();
@@ -67,14 +88,22 @@ class ProgramConfigService {
private getDefaults(): ProgramConfigFile {
return {
programs: [
- { id: 'p1', name: '龙腾四海', teamName: '市场部', order: 1 },
- { id: 'p2', name: '金马奔腾', teamName: '技术部', order: 2 },
- { id: 'p3', name: '春风得意', teamName: '人力资源部', order: 3 },
- { id: 'p4', name: '鸿运当头', teamName: '财务部', order: 4 },
- { id: 'p5', name: '马到成功', teamName: '运营部', order: 5 },
- { id: 'p6', name: '一马当先', teamName: '产品部', order: 6 },
- { id: 'p7', name: '万马奔腾', teamName: '设计部', order: 7 },
- { id: 'p8', name: '龙马精神', teamName: '销售部', order: 8 },
+ { id: 'p1', name: '龙腾四海', teamName: '市场部', performer: '待定', order: 1, remark: '赞美节目如琥珀般凝固了某个经典、美好、闪光的瞬间。' },
+ { id: 'p2', name: '金马奔腾', teamName: '技术部', performer: '待定', order: 2, remark: '强调节目留下了值得回味的"声音"。' },
+ { id: 'p3', name: '春风得意', teamName: '人力资源部', performer: '待定', order: 3, remark: '赞美节目引发了跨越时代的共鸣。' },
+ { id: 'p4', name: '鸿运当头', teamName: '财务部', performer: '待定', order: 4, remark: '形容节目用声音和表演编织了一个时代的梦境。' },
+ { id: 'p5', name: '马到成功', teamName: '运营部', performer: '待定', order: 5, remark: '既指复刻了过去的潮流,也指创造了今晚的潮流。' },
+ { id: 'p6', name: '一马当先', teamName: '产品部', performer: '待定', order: 6, remark: '强调节目的独特韵味与精心打磨。' },
+ { id: 'p7', name: '万马奔腾', teamName: '设计部', performer: '待定', order: 7, remark: '赞美节目与"复古70-80"主题高度契合。' },
+ ],
+ awards: [
+ { id: 'time_amber', name: '时光琥珀奖', icon: '🏆', order: 1 },
+ { id: 'echo_years', name: '岁月留声奖', icon: '🎵', order: 2 },
+ { id: 'resonance', name: '风华共鸣奖', icon: '🎭', order: 3 },
+ { id: 'dream_weaver', name: '光影织梦奖', icon: '✨', order: 4 },
+ { id: 'trend_mark', name: '潮流印记奖', icon: '🌊', order: 5 },
+ { id: 'craftsmanship', name: '匠心独韵奖', icon: '💎', order: 6 },
+ { id: 'in_sync', name: '同频时代奖', icon: '📻', order: 7 },
],
settings: {
allowLateCatch: true,
@@ -90,6 +119,20 @@ class ProgramConfigService {
return this.config?.programs || this.getDefaults().programs;
}
+ /**
+ * Get all awards
+ */
+ getAwards(): AwardConfig[] {
+ return this.config?.awards || this.getDefaults().awards;
+ }
+
+ /**
+ * Get award by id
+ */
+ getAwardById(id: string): AwardConfig | undefined {
+ return this.getAwards().find(a => a.id === id);
+ }
+
/**
* Convert config programs to VotingProgram format (with runtime fields)
*/
@@ -135,6 +178,24 @@ class ProgramConfigService {
}
}
+ /**
+ * Update awards and save to file
+ */
+ async updateAwards(awards: AwardConfig[]): Promise<{ success: boolean; error?: string }> {
+ try {
+ if (!this.config) {
+ this.config = this.getDefaults();
+ }
+ this.config.awards = awards;
+ await this.saveToFile();
+ logger.info({ awardCount: awards.length }, 'Awards updated');
+ return { success: true };
+ } catch (error) {
+ logger.error({ error }, 'Failed to update awards');
+ return { success: false, error: (error as Error).message };
+ }
+ }
+
/**
* Update settings and save to file
*/
diff --git a/packages/server/src/services/vote.service.ts b/packages/server/src/services/vote.service.ts
index 2fd35bf..ff0c4c1 100644
--- a/packages/server/src/services/vote.service.ts
+++ b/packages/server/src/services/vote.service.ts
@@ -1,7 +1,7 @@
import { redis } from '../config/redis';
import { config } from '../config';
import { logger } from '../utils/logger';
-import { REDIS_KEYS } from '@gala/shared/constants';
+import { REDIS_KEYS, VOTE_KEYS } from '@gala/shared/constants';
import type { VoteCategory } from '@gala/shared/types';
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
@@ -85,6 +85,7 @@ export class VoteService {
`${REDIS_KEYS.LEADERBOARD}:${category}`,
REDIS_KEYS.SYNC_QUEUE,
`${REDIS_KEYS.VOTE_LOCK}:${userId}:${category}`,
+ VOTE_KEYS.userTickets(userId),
];
const args = [
diff --git a/packages/server/src/socket/index.ts b/packages/server/src/socket/index.ts
index d5c8e7d..6cf6d3c 100644
--- a/packages/server/src/socket/index.ts
+++ b/packages/server/src/socket/index.ts
@@ -7,7 +7,7 @@ import { logger } from '../utils/logger';
import { voteService } from '../services/vote.service';
import { votingEngine } from '../services/voting.engine';
import { adminService } from '../services/admin.service';
-import { SOCKET_EVENTS, SOCKET_ROOMS, TICKET_TYPES, type TicketType } from '@gala/shared/constants';
+import { SOCKET_EVENTS, SOCKET_ROOMS, TICKET_TYPES, VOTE_KEYS, type TicketType } from '@gala/shared/constants';
import type {
ServerToClientEvents,
ClientToServerEvents,
@@ -206,8 +206,9 @@ async function handleJoin(
await socket.join(SOCKET_ROOMS.ADMIN);
}
- // Get user's voted categories
+ // Get user's voted categories and tickets
const votedCategories = await voteService.getUserVotedCategories(userId);
+ const userTickets = await redis.hgetall(VOTE_KEYS.userTickets(userId));
logger.info({ socketId: socket.id, userId, userName, role }, 'User joined');
@@ -221,6 +222,8 @@ async function handleJoin(
sessionId: socket.id,
serverTime: Date.now(),
reconnected: false,
+ votedCategories,
+ userTickets,
},
});
} catch (error) {
@@ -298,18 +301,30 @@ async function handleVoteSubmit(
data.candidateId,
socket.data.userName || '匿名用户',
socket.data.department || '未知部门',
- category
+ category,
+ { revokedProgramId: result.revoked_program }
);
// Broadcast vote update to all clients with stamp info
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
candidateId: data.candidateId,
category: category,
- totalVotes: result.new_count!,
+ totalVotes: stampResult.totalVotes ?? 0,
+ programVotes: stampResult.programVotes ?? 0,
delta: 1,
stamp: stampResult.stamp, // Pass the stamp info for animation
});
+ if (stampResult.revokedProgramId && stampResult.revokedProgramVotes !== undefined) {
+ io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
+ candidateId: stampResult.revokedProgramId,
+ category: category,
+ totalVotes: stampResult.totalVotes ?? 0,
+ programVotes: stampResult.revokedProgramVotes,
+ delta: -1,
+ });
+ }
+
safeCallback({
success: true,
data: {
@@ -346,7 +361,8 @@ async function handleVoteSubmit(
io.to(SOCKET_ROOMS.ALL).emit(SOCKET_EVENTS.VOTE_UPDATED as any, {
candidateId: data.candidateId,
category: data.category,
- totalVotes: result.new_count!,
+ totalVotes: stampResult.totalVotes ?? 0,
+ programVotes: stampResult.programVotes ?? 0,
delta: 1,
stamp: stampResult.stamp, // Include stamp for big screen
});
@@ -380,10 +396,12 @@ async function handleSyncRequest(socket: GalaSocket): Promise {
try {
const votedCategories = await voteService.getUserVotedCategories(userId);
+ const userTickets = await redis.hgetall(VOTE_KEYS.userTickets(userId));
socket.emit(SOCKET_EVENTS.SYNC_STATE as any, {
votes: {}, // TODO: Include current vote counts
userVotedCategories: votedCategories,
+ userTickets,
});
} catch (error) {
logger.error({ socketId: socket.id, userId, error }, 'Sync request error');
@@ -649,4 +667,3 @@ function handleScanUnsubscribe(socket: GalaSocket, data: ScanSubscribePayload):
socket.leave(roomName);
logger.info({ socketId: socket.id, scanToken }, 'Socket unsubscribed from scan updates');
}
-
diff --git a/packages/shared/src/types/admin.types.ts b/packages/shared/src/types/admin.types.ts
index 9592822..4bc4faa 100644
--- a/packages/shared/src/types/admin.types.ts
+++ b/packages/shared/src/types/admin.types.ts
@@ -38,16 +38,28 @@ export interface VotingState {
currentProgramId: string | null; // 当前投票节目 ID
currentProgramIndex: number; // 当前节目序号 (0-based index)
programs: VotingProgram[]; // 节目列表(已排序)
+ awards: AwardConfig[]; // 奖项列表
allowLateCatch: boolean; // 补投票开关(默认 true)
votingStartedAt?: number; // 当前节目投票开始时间(用于计时)
}
+// 奖项配置
+export interface AwardConfig {
+ id: string;
+ name: string;
+ icon: string;
+ remark?: string; // 奖项备注
+ order: number;
+}
+
export type ProgramVotingStatus = 'pending' | 'voting' | 'completed';
export interface VotingProgram {
id: string;
name: string;
teamName: string;
+ performer?: string; // 表演者
+ remark?: string; // 节目备注
order: number; // 初始顺序
status: ProgramVotingStatus; // 投票状态
votes: number; // 票数
@@ -148,14 +160,13 @@ export const PRIZE_CONFIG: PrizeConfig[] = [
// Default programs for voting
export const DEFAULT_PROGRAMS: VotingProgram[] = [
- { id: 'p1', name: '龙腾四海', teamName: '市场部', order: 1, status: 'pending', votes: 0, stamps: [] },
- { id: 'p2', name: '金马奔腾', teamName: '技术部', order: 2, status: 'pending', votes: 0, stamps: [] },
- { id: 'p3', name: '春风得意', teamName: '人力资源部', order: 3, status: 'pending', votes: 0, stamps: [] },
- { id: 'p4', name: '鸿运当头', teamName: '财务部', order: 4, status: 'pending', votes: 0, stamps: [] },
- { id: 'p5', name: '马到成功', teamName: '运营部', order: 5, status: 'pending', votes: 0, stamps: [] },
- { id: 'p6', name: '一马当先', teamName: '产品部', order: 6, status: 'pending', votes: 0, stamps: [] },
- { id: 'p7', name: '万马奔腾', teamName: '设计部', order: 7, status: 'pending', votes: 0, stamps: [] },
- { id: 'p8', name: '龙马精神', teamName: '销售部', order: 8, status: 'pending', votes: 0, stamps: [] },
+ { id: 'p1', name: '龙腾四海', teamName: '市场部', performer: '待定', order: 1, remark: '赞美节目如琥珀般凝固了某个经典、美好、闪光的瞬间。', status: 'pending', votes: 0, stamps: [] },
+ { id: 'p2', name: '金马奔腾', teamName: '技术部', performer: '待定', order: 2, remark: '强调节目留下了值得回味的\'声音\',可以是歌声、朗诵声。', status: 'pending', votes: 0, stamps: [] },
+ { id: 'p3', name: '春风得意', teamName: '人力', performer: '待定', order: 3, remark: '赞美节目引发了跨越时代的共鸣。', status: 'pending', votes: 0, stamps: [] },
+ { id: 'p4', name: '鸿运当头', teamName: '财务部', performer: '待定', order: 4, remark: '形容节目用声音和表演编织了一个时代的梦境。', status: 'pending', votes: 0, stamps: [] },
+ { id: 'p5', name: '马到成功', teamName: '运营部', performer: '待定', order: 5, remark: '既指复刻了过去的潮流,也指创造了今晚的潮流。', status: 'pending', votes: 0, stamps: [] },
+ { id: 'p6', name: '一马当先', teamName: '产品部', performer: '待定', order: 6, remark: '强调节目的独特韵味与精心打磨。', status: 'pending', votes: 0, stamps: [] },
+ { id: 'p7', name: '万马奔腾', teamName: '设计部', performer: '待定', order: 7, remark: '赞美节目与时代精神同频共振。', status: 'pending', votes: 0, stamps: [] },
];
// ============================================================================
@@ -170,6 +181,7 @@ export const INITIAL_ADMIN_STATE: AdminState = {
currentProgramId: null,
currentProgramIndex: -1,
programs: DEFAULT_PROGRAMS,
+ awards: [],
allowLateCatch: true,
},
lottery: {
diff --git a/packages/shared/src/types/socket.types.ts b/packages/shared/src/types/socket.types.ts
index 93b5baf..e03c3d4 100644
--- a/packages/shared/src/types/socket.types.ts
+++ b/packages/shared/src/types/socket.types.ts
@@ -19,6 +19,7 @@ export interface VoteUpdatePayload {
candidateId: string;
category: VoteCategory;
totalVotes: number;
+ programVotes?: number;
delta: number;
}
@@ -97,6 +98,7 @@ export interface ConnectionAckPayload {
export interface SyncStatePayload {
votes: Record>; // category -> candidateId -> count
userVotedCategories: VoteCategory[];
+ userTickets?: Record;
currentDraw?: {
isActive: boolean;
prizeLevel: PrizeLevel;