From d090c80e502e88fc907eb3d62acc48b7c8afa159 Mon Sep 17 00:00:00 2001 From: empty Date: Wed, 28 Jan 2026 21:37:05 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8A=95=E7=A5=A8=E8=AE=A1?= =?UTF-8?q?=E6=95=B0=E4=B8=8E=E7=8A=B6=E6=80=81=E5=90=8C=E6=AD=A5=EF=BC=8C?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=A5=A8=E6=8D=AE=E4=B8=8E=E6=88=B3=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复投票系统:禁止重复投票、恢复状态、同步大屏 完善投票流程与展示:计数准确、状态可恢复、样式统一 --- .serena/.gitignore | 1 + .serena/memories/project-notes.md | 1 + .serena/project.yml | 89 +++ .../client-mobile/src/components/Postmark.vue | 6 +- .../src/components/ProgramCard.vue | 621 +++++++----------- .../src/components/VotingDock.vue | 367 ++++++----- .../client-mobile/src/stores/connection.ts | 18 + packages/client-mobile/src/stores/voting.ts | 234 ++++--- packages/client-mobile/src/views/VoteView.vue | 21 +- .../src/components/PostcardGrid.vue | 51 +- .../src/components/PostcardItem.vue | 184 ++++-- .../client-screen/src/components/Postmark.vue | 10 +- .../src/composables/useSocketClient.ts | 1 + packages/client-screen/src/stores/admin.ts | 18 + .../client-screen/src/views/AdminControl.vue | 34 +- .../client-screen/src/views/HorseRaceView.vue | 10 +- .../src/views/LiveVotingView.vue | 199 +++--- .../src/views/VoteResultsView.vue | 302 ++++++--- packages/server/config/programs.json | 81 ++- packages/server/src/lua/cast_vote.lua | 41 +- packages/server/src/lua/vote_submit.lua | 5 + packages/server/src/services/admin.service.ts | 131 +++- .../src/services/program-config.service.ts | 77 ++- packages/server/src/services/vote.service.ts | 3 +- packages/server/src/socket/index.ts | 29 +- packages/shared/src/types/admin.types.ts | 28 +- packages/shared/src/types/socket.types.ts | 2 + 27 files changed, 1541 insertions(+), 1023 deletions(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/memories/project-notes.md create mode 100644 .serena/project.yml diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/memories/project-notes.md b/.serena/memories/project-notes.md new file mode 100644 index 0000000..d24e712 --- /dev/null +++ b/.serena/memories/project-notes.md @@ -0,0 +1 @@ +补充:移动端 `connection.ts` 在 `joinRoom` 成功回调内调用 `requestSync()`,确保刷新后立即同步票据,避免投票状态丢失。注意 lua 脚本改动需重启 server 才生效。 \ No newline at end of file diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..5b56341 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,89 @@ +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# powershell python python_jedi r rego +# ruby ruby_solargraph rust scala swift +# terraform toml typescript typescript_vts vue +# yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# - csharp: Requires the presence of a .sln file in the project folder. +# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus. +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- vue + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in all projects +# same syntax as gitignore, so you can use * and ** +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +project_name: "company-celebration2" +included_optional_tools: [] diff --git a/packages/client-mobile/src/components/Postmark.vue b/packages/client-mobile/src/components/Postmark.vue index ad88055..86f4129 100644 --- a/packages/client-mobile/src/components/Postmark.vue +++ b/packages/client-mobile/src/components/Postmark.vue @@ -117,9 +117,9 @@ const currentDate = computed(() => { font-family: 'Kaiti', 'STKaiti', serif; font-weight: bold; - &.top { font-size: 10px; letter-spacing: 1px; } - &.date { font-size: 8px; letter-spacing: 0.5px; opacity: 0.8; } - &.bottom { font-size: 9px; } + &.top { font-size: var(--postmark-top-size, 10px); letter-spacing: 1px; } + &.date { font-size: var(--postmark-date-size, 8px); letter-spacing: 0.5px; opacity: 0.8; } + &.bottom { font-size: var(--postmark-bottom-size, 9px); } } .grunge-overlay { diff --git a/packages/client-mobile/src/components/ProgramCard.vue b/packages/client-mobile/src/components/ProgramCard.vue index ad746c8..6b90d66 100644 --- a/packages/client-mobile/src/components/ProgramCard.vue +++ b/packages/client-mobile/src/components/ProgramCard.vue @@ -1,19 +1,18 @@ @@ -77,143 +96,159 @@ function handleMedalClick(type: TicketType) { padding-bottom: env(safe-area-inset-bottom); } -.dock-tray { +// 选中提示 +.selection-hint { display: flex; + align-items: center; justify-content: center; - align-items: flex-end; - padding: $spacing-md $spacing-md $spacing-lg; - // Red Glassmorphism - background: $color-surface-glass; - backdrop-filter: $backdrop-blur; - -webkit-backdrop-filter: $backdrop-blur; - border-top: 1px solid rgba($color-gold, 0.3); - box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.3); - border-radius: $radius-xl $radius-xl 0 0; + gap: 8px; + background: linear-gradient(135deg, $color-gold 0%, #b8860b 100%); + color: #1a1a1a; + padding: 8px 16px; + margin: 0 16px 8px; + border-radius: 20px; + font-weight: 600; + animation: pulse-hint 1.5s ease-in-out infinite; } -.medal-slot { - position: relative; - transform: rotate(var(--angle)) translateY(0); - transform-origin: bottom center; - transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +.hint-icon { + font-size: 18px; +} + +.hint-text { + font-size: 13px; +} + +@keyframes pulse-hint { + 0%, 100% { + box-shadow: 0 4px 12px rgba($color-gold, 0.4); + } + 50% { + box-shadow: 0 4px 20px rgba($color-gold, 0.6); + } +} + +.film-strip { + background: linear-gradient(180deg, #1a1a1a 0%, #2a2a2a 50%, #1a1a1a 100%); + border-top: 2px solid rgba($color-gold, 0.3); + box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.5); + padding: 0; +} + +// 齿孔行 +.sprocket-row { + display: flex; + justify-content: space-around; + padding: 0 12px; + height: 10px; + background: #111; + + &.top { + border-bottom: 1px solid #333; + } + + &.bottom { + border-top: 1px solid #333; + } +} + +.sprocket-hole { + width: 6px; + height: 5px; + background: #0a0a0a; + border-radius: 1px; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.8); + margin: 2.5px 0; +} + +// 帧容器 +.frames-container { + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.frames-scroll { + display: flex; + padding: 8px 12px; + gap: 8px; + min-width: max-content; +} + +// 胶片帧(奖项) +.film-frame { + width: 64px; + height: 72px; + background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%); + border: 1px solid #444; + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; cursor: pointer; - margin: 0 -2px; - + transition: all 0.2s ease; + position: relative; + flex-shrink: 0; + &:active { - transform: rotate(var(--angle)) translateY(-4px) scale(0.95); + transform: scale(0.95); } - + + // 已使用 &.is-used { - opacity: 0.2; - filter: grayscale(1) brightness(0.5); - pointer-events: none; + background: linear-gradient(135deg, #1a3a1a 0%, #0a2a0a 100%); + border-color: #2a5a2a; + opacity: 0.8; + + .frame-icon { + opacity: 0.6; + } + + .frame-name { + color: #4a8a4a; + } } - + + // 选中状态 &.is-selected { - transform: rotate(0deg) translateY(-25px) scale(1.25); - z-index: 10; - - .medal-face { - background: linear-gradient(135deg, $color-gold 0%, #fff 50%, $color-gold 100%); - color: $color-primary; - box-shadow: - 0 8px 24px rgba($color-gold, 0.6), - 0 0 0 2px rgba($color-gold, 0.4); - animation: selected-glow 1.5s ease-in-out infinite; + background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%); + border-color: $color-gold; + box-shadow: + 0 0 12px rgba($color-gold, 0.5), + inset 0 1px 2px rgba(255, 255, 255, 0.2); + transform: translateY(-4px); + + .frame-name { + color: $color-gold; } } } -.medal-icon { - display: flex; - flex-direction: column; - align-items: center; - gap: $spacing-sm; +.frame-icon { + font-size: 22px; + line-height: 1; } -.medal-face { - width: 52px; - height: 52px; - background: linear-gradient(135deg, $color-primary-dark 0%, $color-primary 50%, $color-primary-dark 100%); - color: $color-gold; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - box-shadow: - 0 4px 12px rgba(0, 0, 0, 0.4), - inset 0 1px 2px rgba(255, 255, 255, 0.2); - border: 1.5px solid rgba($color-gold, 0.4); - transition: all 0.3s ease; -} - -.medal-svg-wrapper { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - - :deep(svg) { - width: 100%; - height: 100%; - } -} - -@keyframes selected-glow { - 0%, 100% { filter: drop-shadow(0 0 5px rgba($color-gold, 0.5)); } - 50% { filter: drop-shadow(0 0 15px rgba($color-gold, 0.8)); } -} - -.medal-emoji { - font-size: 24px; - filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3)); -} - -.medal-label { - font-size: $font-size-xs; - color: $color-gold; - font-weight: 600; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); +.frame-name { + font-size: 10px; + color: #888; + margin-top: 4px; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 58px; + text-align: center; } -.dock-hint { - position: absolute; - top: -40px; - left: 50%; - transform: translateX(-50%); - display: flex; - align-items: center; - gap: $spacing-xs; - background: rgba($color-gold, 0.9); - color: $color-bg-primary; - padding: $spacing-xs $spacing-md; - border-radius: $radius-full; - font-size: $font-size-sm; - font-weight: 600; - animation: hint-bounce 1s ease-in-out infinite; -} - -.hint-arrow { - animation: arrow-bounce 0.6s ease-in-out infinite; -} - -@keyframes hint-bounce { - 0%, 100% { - transform: translateX(-50%) translateY(0); - } - 50% { - transform: translateX(-50%) translateY(-4px); - } -} - -@keyframes arrow-bounce { - 0%, 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-3px); - } +.voted-program { + font-size: 9px; + color: #4a8a4a; + margin-top: 2px; } diff --git a/packages/client-mobile/src/stores/connection.ts b/packages/client-mobile/src/stores/connection.ts index 713680d..eaba719 100644 --- a/packages/client-mobile/src/stores/connection.ts +++ b/packages/client-mobile/src/stores/connection.ts @@ -107,6 +107,9 @@ export const useConnectionStore = defineStore('connection', () => { // Start heartbeat startHeartbeat(); + + // Request initial admin state to ensure UI is in sync + socketInstance.emit(SOCKET_EVENTS.ADMIN_STATE_SYNC as any); }); socketInstance.on('disconnect', (reason) => { @@ -144,6 +147,12 @@ export const useConnectionStore = defineStore('connection', () => { if (data.userVotedCategories) { votedCategories.value = data.userVotedCategories; } + if (data.userTickets) { + import('./voting').then(({ useVotingStore }) => { + const votingStore = useVotingStore(); + votingStore.syncFromServer(data.userTickets); + }); + } }); // Admin state sync - update voting state socketInstance.on(SOCKET_EVENTS.ADMIN_STATE_SYNC as any, (state: AdminState) => { @@ -157,6 +166,7 @@ export const useConnectionStore = defineStore('connection', () => { programs: state.voting.programs, currentProgramId: state.voting.currentProgramId, allowLateCatch: state.voting.allowLateCatch, + awards: state.voting.awards, }); }); }); @@ -192,6 +202,14 @@ export const useConnectionStore = defineStore('connection', () => { if (response.data.votedCategories) { votedCategories.value = response.data.votedCategories; } + if (response.data.userTickets) { + import('./voting').then(({ useVotingStore }) => { + const votingStore = useVotingStore(); + votingStore.syncFromServer(response.data.userTickets); + }); + } + // 连接成功后主动同步票据,避免刷新后丢失投票状态 + requestSync(); } } ); diff --git a/packages/client-mobile/src/stores/voting.ts b/packages/client-mobile/src/stores/voting.ts index 3e455a0..4657079 100644 --- a/packages/client-mobile/src/stores/voting.ts +++ b/packages/client-mobile/src/stores/voting.ts @@ -1,49 +1,46 @@ import { defineStore } from 'pinia'; import { ref, computed } from 'vue'; import { useConnectionStore } from './connection'; -import { TICKET_TYPES, type TicketType } from '@gala/shared/constants'; import { showToast } from 'vant'; -import type { VoteStamp } from '@gala/shared/types'; -// Ticket display info -export const TICKET_INFO: Record = { - creative: { name: '最佳创意', icon: 'creative' }, - visual: { name: '最佳视觉', icon: 'visual' }, - atmosphere: { name: '最佳氛围', icon: 'atmosphere' }, - performance: { name: '最佳表演', icon: 'performance' }, - teamwork: { name: '最佳团队', icon: 'teamwork' }, - popularity: { name: '最受欢迎', icon: 'popularity' }, - potential: { name: '最具潜力', icon: 'potential' }, -}; - -interface PendingVote { - ticketType: TicketType; - programId: string; - timestamp: number; +// 奖项配置接口(从服务器同步) +export interface AwardConfig { + id: string; + name: string; + icon: string; + order: number; } -interface VotingProgram { +// 节目接口 +export interface VotingProgram { id: string; name: string; teamName: string; + performer?: string; order: number; + remark?: string; status: 'pending' | 'voting' | 'completed'; votes: number; } +interface PendingVote { + awardId: string; + programId: string; + timestamp: number; +} + export const useVotingStore = defineStore('voting', () => { const connectionStore = useConnectionStore(); - // State: ticketType -> programId (null if unused) - const tickets = ref>({ - creative: null, - visual: null, - atmosphere: null, - performance: null, - teamwork: null, - popularity: null, - potential: null, - }); + // ============================================================================ + // 奖项票系统:每个奖项一张票,票数 = 奖项数 = 节目数 + // ============================================================================ + + // 奖项列表(从服务器同步) + const awards = ref([]); + + // 已投票记录:awardId -> programId + const tickets = ref>({}); // Server-synced voting state const votingOpen = ref(false); @@ -52,8 +49,8 @@ export const useVotingStore = defineStore('voting', () => { const currentProgramId = ref(null); const allowLateCatch = ref(true); - // Currently selected stamp in dock - const selectedStamp = ref(null); + // 当前选中的奖项(准备投票) + const selectedAwardId = ref(null); // Pending votes (optimistic updates waiting for server confirmation) const pendingVotes = ref>(new Map()); @@ -61,39 +58,83 @@ export const useVotingStore = defineStore('voting', () => { // Animation state for cards const stampingCard = ref(null); + // ============================================================================ // Computed - const usedTickets = computed(() => { - return TICKET_TYPES.filter((t) => tickets.value[t] !== null); + // ============================================================================ + + // 已使用的票数 + const usedTicketCount = computed(() => { + return Object.values(tickets.value).filter(v => v !== null).length; }); - const availableTickets = computed(() => { - return TICKET_TYPES.filter((t) => tickets.value[t] === null); + // 总票数 = 奖项数 + const totalTicketCount = computed(() => awards.value.length); + + // 剩余票数 + const remainingTicketCount = computed(() => + Math.max(0, totalTicketCount.value - usedTicketCount.value) + ); + + // 是否有选中奖项 + const isAwardSelected = computed(() => selectedAwardId.value !== null); + + // 获取选中的奖项信息 + const selectedAward = computed(() => { + if (!selectedAwardId.value) return null; + return awards.value.find(a => a.id === selectedAwardId.value) || null; }); - const isStampSelected = computed(() => selectedStamp.value !== null); + // 已使用的奖项列表 + const usedAwards = computed(() => { + return awards.value.filter(a => tickets.value[a.id] !== null && tickets.value[a.id] !== undefined); + }); - // Check if a program has received any stamp from this user - function getProgramStamp(programId: string): TicketType | null { - for (const ticketType of TICKET_TYPES) { - if (tickets.value[ticketType] === programId) { - return ticketType; + // 可用的奖项列表 + const availableAwards = computed(() => { + return awards.value.filter(a => tickets.value[a.id] === null || tickets.value[a.id] === undefined); + }); + + // ============================================================================ + // 奖项票操作 + // ============================================================================ + + // 初始化票据状态(当奖项列表更新时) + function initTickets() { + const newTickets: Record = {}; + for (const award of awards.value) { + newTickets[award.id] = tickets.value[award.id] ?? null; + } + tickets.value = newTickets; + } + + // 检查某个奖项是否已使用 + function isAwardUsed(awardId: string): boolean { + return tickets.value[awardId] !== null && tickets.value[awardId] !== undefined; + } + + // 检查某个节目是否已获得任何奖项 + function getProgramAward(programId: string): AwardConfig | null { + for (const award of awards.value) { + if (tickets.value[award.id] === programId) { + return award; } } return null; } - // Select a stamp from the dock - function selectStamp(ticketType: TicketType) { - if (tickets.value[ticketType] !== null) { - showToast({ message: '该印章已使用', position: 'bottom' }); + // 选中一个奖项 + function selectAward(awardId: string) { + if (isAwardUsed(awardId)) { + const award = awards.value.find(a => a.id === awardId); + showToast({ message: `${award?.name || '该奖项'}已投出`, position: 'bottom' }); return; } - selectedStamp.value = ticketType; + selectedAwardId.value = awardId; } - // Deselect stamp - function deselectStamp() { - selectedStamp.value = null; + // 取消选中 + function deselectAward() { + selectedAwardId.value = null; } // Trigger haptic feedback @@ -103,39 +144,39 @@ export const useVotingStore = defineStore('voting', () => { } } - // Cast vote with optimistic update + // 投票:将选中的奖项投给某个节目 async function castVote(programId: string): Promise { - const ticketType = selectedStamp.value; - if (!ticketType) { - showToast({ message: '请先选择一个印章', position: 'bottom' }); + const awardId = selectedAwardId.value; + if (!awardId) { + showToast({ message: '请先选择一个奖项', position: 'bottom' }); return false; } - // Check if already voted for this program - const existingStamp = getProgramStamp(programId); - if (existingStamp) { - showToast({ message: '已为该节目投过票', position: 'bottom' }); + // 检查是否已为该节目投过票(任何奖项) + const existingAward = getProgramAward(programId); + if (existingAward) { + showToast({ message: `已将${existingAward.name}投给该节目`, position: 'bottom' }); return false; } // Optimistic update - const previousValue = tickets.value[ticketType]; - tickets.value[ticketType] = programId; + const previousValue = tickets.value[awardId]; + tickets.value[awardId] = programId; stampingCard.value = programId; // Trigger haptic triggerHaptic(); // Create pending vote record - const voteKey = `${ticketType}:${programId}`; + const voteKey = `${awardId}:${programId}`; pendingVotes.value.set(voteKey, { - ticketType, + awardId, programId, timestamp: Date.now(), }); // Clear selection - selectedStamp.value = null; + selectedAwardId.value = null; // Call backend try { @@ -153,7 +194,7 @@ export const useVotingStore = defineStore('voting', () => { socket.emit('vote:submit' as any, { candidateId: programId, - category: ticketType, + category: awardId, // 奖项ID作为category clientTimestamp: Date.now(), localId, }, (response: any) => { @@ -163,7 +204,6 @@ export const useVotingStore = defineStore('voting', () => { }); if (!result.success) { - // Use server message if available const errorMessage = result.message || '投票失败,请重试'; throw new Error(errorMessage); } @@ -174,7 +214,7 @@ export const useVotingStore = defineStore('voting', () => { } catch (error) { // Revert optimistic update - tickets.value[ticketType] = previousValue; + tickets.value[awardId] = previousValue; pendingVotes.value.delete(voteKey); const errorMessage = error instanceof Error ? error.message : '投票失败,请重试'; @@ -193,20 +233,20 @@ export const useVotingStore = defineStore('voting', () => { } } - // Revoke a vote - async function revokeVote(ticketType: TicketType): Promise { - const programId = tickets.value[ticketType]; + // 撤销投票 + async function revokeVote(awardId: string): Promise { + const programId = tickets.value[awardId]; if (!programId) return false; // Optimistic update - tickets.value[ticketType] = null; + tickets.value[awardId] = null; try { const socket = connectionStore.getSocket(); if (!socket) throw new Error('Not connected'); const result = await new Promise<{ success: boolean }>((resolve) => { - socket.emit('vote:revoke' as any, { ticketType }, (response: any) => { + socket.emit('vote:revoke' as any, { awardId }, (response: any) => { resolve(response); }); setTimeout(() => resolve({ success: false }), 10000); @@ -217,17 +257,23 @@ export const useVotingStore = defineStore('voting', () => { } catch { // Revert - tickets.value[ticketType] = programId; + tickets.value[awardId] = programId; showToast({ message: '撤销失败', position: 'bottom', type: 'fail' }); return false; } } // Sync state from server - function syncFromServer(serverTickets: Record) { + function syncFromServer(serverTickets: Record) { tickets.value = { ...serverTickets }; } + // Sync awards from server + function syncAwards(serverAwards: AwardConfig[]) { + awards.value = serverAwards; + initTickets(); + } + // Sync voting state from AdminState function syncVotingState(state: { votingOpen?: boolean; @@ -235,26 +281,29 @@ export const useVotingStore = defineStore('voting', () => { programs?: VotingProgram[]; currentProgramId?: string | null; allowLateCatch?: boolean; + awards?: AwardConfig[]; }) { if (state.votingOpen !== undefined) votingOpen.value = state.votingOpen; if (state.votingPaused !== undefined) votingPaused.value = state.votingPaused; if (state.programs !== undefined) programs.value = state.programs; if (state.currentProgramId !== undefined) currentProgramId.value = state.currentProgramId; if (state.allowLateCatch !== undefined) allowLateCatch.value = state.allowLateCatch; + if (state.awards !== undefined) syncAwards(state.awards); } // Check if voting is allowed for a specific program - // In unified voting mode, all programs can be voted when voting is open function canVoteForProgram(programId: string): { allowed: boolean; reason?: string } { - // Check if voting is open if (!votingOpen.value) { return { allowed: false, reason: '投票尚未开始' }; } if (votingPaused.value) { return { allowed: false, reason: '投票已暂停' }; } - - // In unified voting mode, all programs are votable when voting is open + // 检查是否已为该节目投过票 + const existingAward = getProgramAward(programId); + if (existingAward) { + return { allowed: false, reason: `已投${existingAward.name}` }; + } return { allowed: true }; } @@ -264,10 +313,6 @@ export const useVotingStore = defineStore('voting', () => { if (program) { program.votes = data.newCount; } - // Update global total votes if provided - if (data.totalVotes !== undefined) { - // We don't have a totalVotes ref in the store yet, but we could add it or just ignore - } } // Get current voting program @@ -286,20 +331,34 @@ export const useVotingStore = defineStore('voting', () => { }); return { + // 奖项票状态 + awards, tickets, - selectedStamp, + selectedAwardId, + selectedAward, pendingVotes, stampingCard, - usedTickets, - availableTickets, - isStampSelected, - getProgramStamp, - selectStamp, - deselectStamp, + + // Computed + usedTicketCount, + totalTicketCount, + remainingTicketCount, + isAwardSelected, + usedAwards, + availableAwards, + + // 操作 + initTickets, + isAwardUsed, + getProgramAward, + selectAward, + deselectAward, castVote, revokeVote, syncFromServer, - // New exports + syncAwards, + + // 投票状态 votingOpen, votingPaused, programs, @@ -312,4 +371,3 @@ export const useVotingStore = defineStore('voting', () => { handleVoteUpdate, }; }); - diff --git a/packages/client-mobile/src/views/VoteView.vue b/packages/client-mobile/src/views/VoteView.vue index 883d849..eee743e 100644 --- a/packages/client-mobile/src/views/VoteView.vue +++ b/packages/client-mobile/src/views/VoteView.vue @@ -16,14 +16,13 @@ const programs = computed(() => { // If no programs from server, show default list if (votingStore.programs.length === 0) { return [ - { id: 'p1', name: '龙腾四海', teamName: '市场部', order: 1, status: 'pending' as const, votes: 0 }, - { id: 'p2', name: '金马奔腾', teamName: '技术部', order: 2, status: 'pending' as const, votes: 0 }, - { id: 'p3', name: '春风得意', teamName: '人力资源部', order: 3, status: 'pending' as const, votes: 0 }, - { id: 'p4', name: '鸿运当头', teamName: '财务部', order: 4, status: 'pending' as const, votes: 0 }, - { id: 'p5', name: '马到成功', teamName: '运营部', order: 5, status: 'pending' as const, votes: 0 }, - { id: 'p6', name: '一马当先', teamName: '产品部', order: 6, status: 'pending' as const, votes: 0 }, - { id: 'p7', name: '万马奔腾', teamName: '设计部', order: 7, status: 'pending' as const, votes: 0 }, - { id: 'p8', name: '龙马精神', teamName: '销售部', order: 8, status: 'pending' as const, votes: 0 }, + { id: 'p1', name: '龙腾四海', teamName: '市场部', performer: '张三、李四', order: 1, remark: '赞美节目如琥珀般凝固了某个经典、美好、闪光的瞬间,适合怀旧、温情、经典的表演。', status: 'pending' as const, votes: 0 }, + { id: 'p2', name: '金马奔腾', teamName: '技术部', performer: '王五、赵六', order: 2, remark: '强调节目留下了值得回味的\'声音\',可以是歌声、朗诵声,甚至是幽默的回响。适配唱、诵、幽默类节目。', status: 'pending' as const, votes: 0 }, + { id: 'p3', name: '春风得意', teamName: '人力资源部', performer: '刘七、陈八', order: 3, remark: '赞美节目引发了跨越时代的共鸣,无论是家国情怀、青春记忆还是职场幽默。适配有感染力、引发集体回忆的节目。', status: 'pending' as const, votes: 0 }, + { id: 'p4', name: '鸿运当头', teamName: '财务部', performer: '周九、吴十', order: 4, remark: '形容节目用声音和表演编织了一个时代的梦境,画面感强。适配意境优美、故事性强或对唱情歌类节目。', status: 'pending' as const, votes: 0 }, + { id: 'p5', name: '马到成功', teamName: '运营部', performer: '郑十一、冯十二', order: 5, remark: '既指复刻了过去的潮流,也指创造了今晚的潮流。适配活力四射、改编新颖、引领现场气氛的节目。', status: 'pending' as const, votes: 0 }, + { id: 'p6', name: '一马当先', teamName: '产品部', performer: '孙十三、杨十四', order: 6, remark: '强调节目的独特韵味与精心打磨,可以是深情的独唱,也可以是巧妙的改编,突出\'独特\'和\'匠心\'。', status: 'pending' as const, votes: 0 }, + { id: 'p7', name: '万马奔腾', teamName: '设计部', performer: '何十五、林十六', order: 7, remark: '赞美节目与\'复古70-80\'主题高度契合,与时代精神同频共振。适配主题鲜明、情怀真挚的集体性节目。', status: 'pending' as const, votes: 0 }, ]; } return votingStore.programs; @@ -80,10 +79,10 @@ onMounted(() => {
- - {{ votingStore.usedTickets.length }}/7 + {{ votingStore.usedTicketCount }}/{{ votingStore.totalTicketCount }}
@@ -96,6 +95,8 @@ onMounted(() => { :program-id="program.id" :program-name="program.name" :team-name="program.teamName" + :performer="(program as any).performer" + :remark="(program as any).remark" :index="index" :status="program.status" :is-current="program.id === votingStore.currentProgramId" diff --git a/packages/client-screen/src/components/PostcardGrid.vue b/packages/client-screen/src/components/PostcardGrid.vue index ecd1ac1..2b0206b 100644 --- a/packages/client-screen/src/components/PostcardGrid.vue +++ b/packages/client-screen/src/components/PostcardGrid.vue @@ -60,6 +60,8 @@ defineExpose({ :id="program.id" :name="program.name" :team-name="program.teamName" + :performer="(program as any).performer" + :remark="(program as any).remark" :order="program.order" :votes="program.votes" :stamps="program.stamps" @@ -85,29 +87,26 @@ defineExpose({ display: grid; grid-template-columns: repeat(var(--columns, 4), 1fr); grid-template-rows: repeat(var(--rows, 2), 1fr); - gap: 24px; - padding: 32px; + gap: 40px; + padding: 40px 80px 80px; width: 100%; height: 100%; box-sizing: border-box; - background: #FDFBF7; - - // Subtle paper texture for the entire grid background - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E"); - background-blend-mode: overlay; + background: transparent; + overflow: visible; // Allow shadows and rotation to bleed out if needed // Responsive breakpoints @media (max-width: 1200px) { grid-template-columns: repeat(3, 1fr); - gap: 20px; - padding: 24px; + gap: 30px; + padding: 30px; } @media (max-width: 900px) { grid-template-columns: repeat(2, 1fr); grid-template-rows: auto; - gap: 16px; - padding: 16px; + gap: 20px; + padding: 20px; overflow-y: auto; height: auto; min-height: 100%; @@ -115,8 +114,8 @@ defineExpose({ @media (max-width: 500px) { grid-template-columns: 1fr; - gap: 12px; - padding: 12px; + gap: 16px; + padding: 16px; } } @@ -126,29 +125,37 @@ defineExpose({ } .empty-slot { - background: rgba(0, 0, 0, 0.02); - border: 2px dashed #ddd; - border-radius: 4px; + background: rgba(255, 255, 255, 0.03); + border: 2px dashed rgba(255, 255, 255, 0.1); + border-radius: 8px; display: flex; align-items: center; justify-content: center; + transition: all 0.3s ease; + + &:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.2); + } } .empty-placeholder { display: flex; flex-direction: column; align-items: center; - gap: 8px; - color: #bbb; + gap: 12px; + color: rgba(255, 255, 255, 0.25); .empty-icon { - font-size: 32px; - opacity: 0.5; + font-size: 48px; + opacity: 0.6; + filter: grayscale(1) brightness(0.8); } .empty-text { - font-size: 14px; - font-family: 'SimSun', 'Songti SC', serif; + font-size: 16px; + font-family: 'Kaiti', 'STKaiti', serif; + letter-spacing: 2px; } } diff --git a/packages/client-screen/src/components/PostcardItem.vue b/packages/client-screen/src/components/PostcardItem.vue index 36f57e8..3c2e868 100644 --- a/packages/client-screen/src/components/PostcardItem.vue +++ b/packages/client-screen/src/components/PostcardItem.vue @@ -19,17 +19,26 @@ export interface Props { id: string; name: string; teamName: string; + performer?: string; // 表演者 + remark?: string; // 节目备注 order: number; votes: number; stamps?: VoteStamp[]; - slogan?: string; } const props = withDefaults(defineProps(), { - slogan: 'With all our passion', + remark: 'With all our passion', stamps: () => [], }); +// 寄处显示:部门·表演者 +const senderDisplay = computed(() => { + if (props.performer) { + return `${props.teamName}·${props.performer}`; + } + return props.teamName; +}); + // Generate zip code display: 2|0|2|6|0|[order] const zipCodes = computed(() => { const orderStr = String(props.order).padStart(1, '0'); @@ -114,13 +123,13 @@ defineExpose({ - +

{{ name }}

- {{ slogan }} + {{ remark }}
@@ -129,7 +138,7 @@ defineExpose({
寄: - {{ teamName }} + {{ senderDisplay }}
收: @@ -152,21 +161,35 @@ defineExpose({ position: relative; width: 100%; aspect-ratio: 1.5 / 1; - background: #FDFBF7; - border: 1px solid #2c2c2c; - box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.1); - padding: 16px 20px; + background: #fdfcf0; // Parchment color + border: 1px solid rgba(0, 0, 0, 0.1); + box-shadow: + 0 10px 25px rgba(0, 0, 0, 0.25), + 0 2px 5px rgba(0, 0, 0, 0.1); + padding: 20px 24px; display: flex; flex-direction: column; - overflow: hidden; + overflow: visible; // Allows full shadows and stamps to bleed correctly + transition: transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); + + // Organic scattered look + &:nth-child(odd) { transform: rotate(-1.2deg); } + &:nth-child(even) { transform: rotate(0.8deg); } + &:nth-child(3n) { transform: rotate(-0.5deg); } + + &:hover { + transform: rotate(0deg) translateY(-5px) scale(1.02); + z-index: 10; + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.4); + } // Paper texture &::before { content: ''; position: absolute; inset: 0; - background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%' height='100%' filter='url(%23noise)'/%3E%3C/svg%3E"); - opacity: 0.03; + background-image: url("https://www.transparenttextures.com/patterns/pinstriped-suit.png"); + opacity: 0.05; pointer-events: none; } } @@ -181,8 +204,9 @@ defineExpose({ .postmark-wrapper { position: absolute; - width: 80px; - height: 80px; + width: 110px; + height: 110px; + filter: drop-shadow(0 2px 4px rgba(185, 28, 28, 0.2)); &.is-new { animation: stamp-fly-in 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; @@ -196,11 +220,12 @@ defineExpose({ } 70% { transform: translate(-50%, -50%) scale(0.9); + filter: drop-shadow(0 10px 20px rgba(185, 28, 28, 0.5)); opacity: 1; } 100% { transform: translate(-50%, -50%) scale(1); - opacity: 0.9; + opacity: 0.95; } } @@ -211,36 +236,37 @@ defineExpose({ align-items: flex-start; position: relative; z-index: 2; - margin-bottom: 12px; + margin-bottom: 16px; } .zip-codes { display: flex; - gap: 3px; + gap: 4px; } .zip-box { - width: 22px; - height: 26px; - border: 1.5px solid #c41e3a; + width: 26px; + height: 32px; + border: 1.5px solid #b91c1c; display: flex; align-items: center; justify-content: center; font-family: 'Courier New', monospace; - font-size: 13px; - font-weight: bold; - color: #2c2c2c; + font-size: 16px; + font-weight: 800; + color: #1a1a1a; background: #fff; + box-shadow: inset 0 1px 3px rgba(0,0,0,0.1); } .stamp-box { - width: 90px; - height: 90px; - display: flex; - align-items: center; - justify-content: center; - background: transparent; - border: 1px solid #ddd; + width: 100px; + height: 100px; + padding: 4px; + background: white; + border: 1px solid #e5e7eb; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); + transform: rotate(2deg); } .stamp-image { @@ -253,67 +279,75 @@ defineExpose({ .content-row { flex: 1; display: flex; + flex-direction: row; justify-content: space-between; + gap: 20px; position: relative; z-index: 2; + min-height: 0; // Prevent overflow } // Left Side: Title + Slogan .content-left { - flex: 1; + flex: 1.2; display: flex; flex-direction: column; - justify-content: flex-start; - padding-right: 20px; + gap: 12px; + min-width: 0; // Allow shrinking for text wrap } .program-name { font-family: 'SimSun', 'Songti SC', 'STSong', serif; - font-size: 28px; - font-weight: bold; - color: #c41e3a; - margin: 0 0 12px 0; + font-size: 32px; + font-weight: 900; + color: #b91c1c; + margin: 0; letter-spacing: 6px; + text-shadow: 0.5px 0.5px 1px rgba(0,0,0,0.1); + word-break: break-all; } .slogan-box { display: inline-block; - border: 1px solid #ccc; - padding: 6px 14px; + padding: 8px 14px; align-self: flex-start; - background: #fff; + background: rgba(0, 0, 0, 0.02); + border-left: 3px solid #b91c1c; + border-radius: 2px; + max-width: 100%; } .slogan-text { - font-family: 'Georgia', 'Times New Roman', serif; - font-size: 12px; - font-style: italic; - color: #666; + font-family: 'Kaiti', 'STKaiti', serif; + font-size: 15px; + color: #4b5563; + line-height: 1.5; + word-break: break-all; + display: block; } // Right Side: Address .content-right { + flex: 1; display: flex; flex-direction: column; - justify-content: flex-end; - align-items: flex-start; - min-width: 120px; + justify-content: flex-start; // Start from top area + min-width: 200px; + padding-top: 15%; // Approximately the golden ratio point of the content height + padding-bottom: 30px; // Ensure space for vote count } .address-zone { - text-align: left; - border-bottom: 1px solid #999; - padding-bottom: 8px; - margin-left: -25px; - margin-top: -40px; width: 100%; } .address-line { display: flex; align-items: baseline; - gap: 6px; - margin-bottom: 6px; + gap: 8px; + margin-bottom: 18px; // Slightly tighter for more refined look + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + padding-bottom: 3px; &:last-child { margin-bottom: 0; @@ -321,36 +355,42 @@ defineExpose({ .label { font-family: 'SimSun', 'Songti SC', serif; - font-size: 11px; - color: #666; - min-width: 24px; + font-size: 14px; + color: #6b7280; + min-width: 30px; } .value { font-family: 'Kaiti', 'STKaiti', serif; - font-size: 13px; - color: #2c2c2c; + font-size: 18px; + color: #1f2937; + flex: 1; + line-height: 1.2; + transform: rotate(-0.5deg); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } } // Footer Zone .footer-zone { - display: flex; - justify-content: flex-end; - align-items: center; - margin-top: auto; - padding-top: 8px; - position: relative; - z-index: 2; + position: absolute; + right: 15px; + bottom: 15px; + z-index: 5; .vote-count { font-family: 'SimSun', 'Songti SC', serif; - font-size: 14px; - font-weight: bold; - color: #c41e3a; - padding: 4px 12px; - background: rgba(196, 30, 58, 0.08); - border: 1px solid rgba(196, 30, 58, 0.2); + font-size: 16px; + font-weight: 800; + color: #b91c1c; + padding: 6px 14px; + background: white; + border: 1.5px solid #b91c1c; + border-radius: 2px; + box-shadow: 3px 3px 0 rgba(185, 28, 28, 0.1); + white-space: nowrap; } } diff --git a/packages/client-screen/src/components/Postmark.vue b/packages/client-screen/src/components/Postmark.vue index 73b5f98..3d7ce22 100644 --- a/packages/client-screen/src/components/Postmark.vue +++ b/packages/client-screen/src/components/Postmark.vue @@ -90,8 +90,8 @@ const currentDate = computed(() => { .postmark { position: relative; - width: 70px; - height: 70px; + width: 100%; + height: 100%; // Multiply blend mode for realism mix-blend-mode: multiply; animation: stamp-reveal 0.3s ease-out forwards; @@ -108,9 +108,9 @@ const currentDate = computed(() => { font-family: 'Kaiti', 'STKaiti', serif; font-weight: bold; - &.top { font-size: 11px; } - &.date { font-size: 10px; letter-spacing: 0.5px; } - &.bottom { font-size: 10px; } + &.top { font-size: 14px; } + &.date { font-size: 12px; letter-spacing: 0.5px; } + &.bottom { font-size: 12px; } } .grunge-overlay { diff --git a/packages/client-screen/src/composables/useSocketClient.ts b/packages/client-screen/src/composables/useSocketClient.ts index 5a19c0e..b117c0f 100644 --- a/packages/client-screen/src/composables/useSocketClient.ts +++ b/packages/client-screen/src/composables/useSocketClient.ts @@ -37,6 +37,7 @@ export interface VoteEvent { candidateId: string; category: string; totalVotes: number; + programVotes?: number; delta: number; timestamp: number; } diff --git a/packages/client-screen/src/stores/admin.ts b/packages/client-screen/src/stores/admin.ts index 0f45b5f..23d8f5a 100644 --- a/packages/client-screen/src/stores/admin.ts +++ b/packages/client-screen/src/stores/admin.ts @@ -36,6 +36,7 @@ export const useAdminStore = defineStore('admin', () => { const programs = ref>([]); const allowLateCatch = ref(true); const currentProgramId = ref(null); + const awards = ref([]); // Lottery State const lotteryRound = ref(1); @@ -206,6 +207,21 @@ export const useAdminStore = defineStore('admin', () => { syncFromServer(state); }); + // Real-time vote updates (for totalVotes and program votes) + socketInstance.on(SOCKET_EVENTS.VOTE_UPDATED as any, (data: { candidateId: string; totalVotes?: number; programVotes?: number; delta?: number }) => { + const program = programs.value.find(p => p.id === data.candidateId); + if (program) { + if (typeof data.programVotes === 'number') { + program.votes = data.programVotes; + } else if (typeof data.delta === 'number') { + program.votes += data.delta; + } + } + if (typeof data.totalVotes === 'number') { + totalVotes.value = data.totalVotes; + } + }); + socket.value = socketInstance as GalaSocket; } @@ -215,6 +231,7 @@ export const useAdminStore = defineStore('admin', () => { votingPaused.value = state.voting.subPhase === 'PAUSED'; totalVotes.value = state.voting.totalVotes; programs.value = state.voting.programs || []; + awards.value = state.voting.awards || []; allowLateCatch.value = state.voting.allowLateCatch ?? true; currentProgramId.value = state.voting.currentProgramId || null; lotteryRound.value = state.lottery.round; @@ -404,6 +421,7 @@ export const useAdminStore = defineStore('admin', () => { programs, allowLateCatch, currentProgramId, + awards, lotteryRound, lotterySubPhase, stormStartedAt, diff --git a/packages/client-screen/src/views/AdminControl.vue b/packages/client-screen/src/views/AdminControl.vue index e8f7611..c86b783 100644 --- a/packages/client-screen/src/views/AdminControl.vue +++ b/packages/client-screen/src/views/AdminControl.vue @@ -19,12 +19,27 @@ const prizeConfigLoading = ref(false); const prizeConfigSaving = ref(false); const editingPrizes = ref([]); +async function readJsonSafe(res: Response): Promise { + const text = await res.text(); + if (!text) { + return null; + } + try { + return JSON.parse(text); + } catch (error) { + throw new Error('响应不是有效 JSON'); + } +} + // Load prize configuration from server async function loadPrizeConfig() { prizeConfigLoading.value = true; try { const res = await fetch('/api/admin/prizes'); - const data = await res.json(); + const data = await readJsonSafe(res); + if (!res.ok) { + throw new Error(data?.error || data?.message || `加载奖项配置失败(${res.status})`); + } if (data.success && data.data?.prizes) { editingPrizes.value = data.data.prizes.map((p: any) => ({ ...p })); } @@ -44,7 +59,10 @@ async function savePrizeConfig() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prizes: editingPrizes.value }), }); - const data = await res.json(); + const data = await readJsonSafe(res); + if (!res.ok) { + throw new Error(data?.error || data?.message || `保存奖项配置失败(${res.status})`); + } if (data.success) { showPrizeConfig.value = false; } else { @@ -98,7 +116,10 @@ async function importParticipants() { body: formData, }); - const data = await response.json(); + const data = await readJsonSafe(response); + if (!response.ok) { + throw new Error(data?.error || data?.message || `导入失败(${response.status})`); + } // 确保保留 success 字段,后端返回结构为 { success: true, data: {...} } importResult.value = { success: data.success ?? data.data?.success ?? false, @@ -132,7 +153,10 @@ const tagLabels: Record = { async function loadParticipants() { try { const response = await fetch('/api/admin/participants'); - const data = await response.json(); + const data = await readJsonSafe(response); + if (!response.ok) { + throw new Error(data?.error || data?.message || `加载参与者失败(${response.status})`); + } if (data.success && data.data?.count > 0) { importResult.value = { success: true, @@ -273,7 +297,7 @@ function showEntryQR() { function hideQR() { const socket = admin.getSocket(); if (socket) { - socket.emit('display:hide_qr' as any, ); + socket.emit('display:hide_qr' as any, {}); } } diff --git a/packages/client-screen/src/views/HorseRaceView.vue b/packages/client-screen/src/views/HorseRaceView.vue index c0d21b4..af8651e 100644 --- a/packages/client-screen/src/views/HorseRaceView.vue +++ b/packages/client-screen/src/views/HorseRaceView.vue @@ -39,12 +39,16 @@ function handleStateSync(state: AdminState) { } // Handle real-time vote updates -function handleVoteUpdate(data: { candidateId: string; totalVotes: number }) { +function handleVoteUpdate(data: { candidateId: string; totalVotes?: number; programVotes?: number; delta?: number }) { if (renderer) { - renderer.updateVotes(data.candidateId, data.totalVotes); + if (typeof data.programVotes === 'number') { + renderer.updateVotes(data.candidateId, data.programVotes); + } } // Update total votes - totalVotes.value = data.totalVotes; + if (typeof data.totalVotes === 'number') { + totalVotes.value = data.totalVotes; + } } onMounted(async () => { diff --git a/packages/client-screen/src/views/LiveVotingView.vue b/packages/client-screen/src/views/LiveVotingView.vue index 6f35463..16fa5a7 100644 --- a/packages/client-screen/src/views/LiveVotingView.vue +++ b/packages/client-screen/src/views/LiveVotingView.vue @@ -34,16 +34,24 @@ function handleStateSync(state: AdminState) { } // 监听投票更新事件 -function handleVoteUpdate(data: { candidateId: string; totalVotes: number; stamp?: any }) { +function handleVoteUpdate(data: { candidateId: string; totalVotes?: number; programVotes?: number; delta?: number; stamp?: any }) { const program = programs.value.find(p => p.id === data.candidateId); if (program) { - program.votes = data.totalVotes; + if (typeof data.programVotes === 'number') { + program.votes = data.programVotes; + } else if (typeof data.delta === 'number') { + program.votes += data.delta; + } // 如果有印章信息,添加到列表触发动画 if (data.stamp) { if (!program.stamps) program.stamps = []; program.stamps.push(data.stamp); } - // 更新总票数 + } + // 更新总票数 + if (typeof data.totalVotes === 'number') { + totalVotes.value = data.totalVotes; + } else { totalVotes.value = programs.value.reduce((sum, p) => sum + p.votes, 0); } } @@ -99,15 +107,8 @@ onUnmounted(() => {
- - diff --git a/packages/client-screen/src/views/VoteResultsView.vue b/packages/client-screen/src/views/VoteResultsView.vue index c29ac18..867f0d0 100644 --- a/packages/client-screen/src/views/VoteResultsView.vue +++ b/packages/client-screen/src/views/VoteResultsView.vue @@ -11,28 +11,19 @@ function goBack() { router.push('/'); } -// Award type labels -const awardLabels: Record = { - creative: '最佳创意奖', - visual: '最佳视觉奖', - atmosphere: '最佳气氛奖', - performance: '最佳表演奖', - teamwork: '最佳团队奖', - popularity: '最受欢迎奖', - potential: '最具潜力奖', -}; - -// Compute award statistics grouped by award type +// Compute award statistics grouped by award config from server const awardResults = computed(() => { const results: Array<{ awardType: string; awardName: string; + remark: string; programs: Array<{ id: string; name: string; teamName: string; votes: number; percentage: number }>; totalVotes: number; }> = []; - // Process each award type - TICKET_TYPES.forEach(awardType => { + // Process each award from admin store + admin.awards.forEach(award => { + const awardType = award.id; const programStats: Map = new Map(); // Aggregate stamps by program for this award type @@ -67,7 +58,9 @@ const awardResults = computed(() => { results.push({ awardType, - awardName: awardLabels[awardType] || awardType, + awardName: award.name, + awardIcon: award.icon, + remark: award.remark || '', programs, totalVotes, }); @@ -101,6 +94,8 @@ onMounted(() => {

{{ award.awardName }}

{{ award.totalVotes }} 票
+
{{ award.awardIcon }}
+

{{ award.remark }}

@@ -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;